diff --git a/packages/db/src/migrations/0131_issue_work_product_identity_unique.sql b/packages/db/src/migrations/0131_issue_work_product_identity_unique.sql new file mode 100644 index 00000000000..85b563a1211 --- /dev/null +++ b/packages/db/src/migrations/0131_issue_work_product_identity_unique.sql @@ -0,0 +1,16 @@ +DELETE FROM "issue_work_products" loser +USING "issue_work_products" keeper +WHERE loser."company_id" = keeper."company_id" + AND loser."issue_id" = keeper."issue_id" + AND loser."type" = keeper."type" + AND loser."provider" = keeper."provider" + AND loser."external_id" = keeper."external_id" + AND ( + keeper."updated_at" > loser."updated_at" + OR ( + keeper."updated_at" = loser."updated_at" + AND keeper."id"::text > loser."id"::text + ) + );--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_work_products_company_issue_identity_uq" + ON "issue_work_products" USING btree ("company_id", "issue_id", "type", "provider", "external_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e3e0473eb20..19569f5c472 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -918,6 +918,13 @@ "when": 1783025224120, "tag": "0130_run_responsible_user_invariant", "breakpoints": true + }, + { + "idx": 131, + "version": "7", + "when": 1783343000000, + "tag": "0131_issue_work_product_identity_unique", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issue_work_products.ts b/packages/db/src/schema/issue_work_products.ts index 8b568f9d1e5..5ac76862982 100644 --- a/packages/db/src/schema/issue_work_products.ts +++ b/packages/db/src/schema/issue_work_products.ts @@ -5,6 +5,7 @@ import { pgTable, text, timestamp, + uniqueIndex, uuid, } from "drizzle-orm/pg-core"; import type { SourceTrustMetadata } from "@paperclipai/shared"; @@ -58,6 +59,13 @@ export const issueWorkProducts = pgTable( table.provider, table.externalId, ), + companyIssueProviderExternalUq: uniqueIndex("issue_work_products_company_issue_identity_uq").on( + table.companyId, + table.issueId, + table.type, + table.provider, + table.externalId, + ), companyUpdatedIdx: index("issue_work_products_company_updated_idx").on( table.companyId, table.updatedAt, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 4e7fdcc2d2c..c1f958c6d4b 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -1,4 +1,5 @@ import type { TrustAuthorizationPolicy } from "../trust-policy.js"; +import type { IssueWorkProduct } from "./work-product.js"; export type ExecutionWorkspaceStrategyType = | "project_primary" @@ -257,6 +258,7 @@ export interface ExecutionWorkspace { config: ExecutionWorkspaceConfig | null; metadata: Record | null; runtimeServices?: WorkspaceRuntimeService[]; + workProducts?: IssueWorkProduct[]; createdAt: Date; updatedAt: Date; } diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index 1353357acde..b3575700e9b 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -127,4 +127,36 @@ describe.sequential("execution workspace routes", () => { expect(res.status).toBe(422); expect(mockExecutionWorkspaceService.listOverview).not.toHaveBeenCalled(); }); + + it("returns work products on execution workspace detail responses", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue({ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Alpha", + workProducts: [ + { + id: "work-product-1", + issueId: "issue-1", + executionWorkspaceId: "workspace-1", + type: "pull_request", + title: "PR 1", + url: "https://example.com/pr/1", + }, + ], + }); + + const res = await request(createApp()).get("/api/execution-workspaces/workspace-1"); + + expect(res.status).toBe(200); + expect(res.body.workProducts).toEqual([ + expect.objectContaining({ + id: "work-product-1", + executionWorkspaceId: "workspace-1", + type: "pull_request", + title: "PR 1", + }), + ]); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith("workspace-1"); + }); }); diff --git a/server/src/__tests__/work-product-adoption.test.ts b/server/src/__tests__/work-product-adoption.test.ts new file mode 100644 index 00000000000..f49a09cc837 --- /dev/null +++ b/server/src/__tests__/work-product-adoption.test.ts @@ -0,0 +1,284 @@ +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + issueWorkProducts, + issues, + projects, + workspaceRuntimeServices, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { adoptWorkProductsForRun } from "../services/work-product-adoption.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres work product adoption tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("work product adoption", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + const companyId = "11111111-1111-4111-8111-111111111111"; + const agentId = "22222222-2222-4222-8222-222222222222"; + const projectId = "33333333-3333-4333-8333-333333333333"; + const issueId = "44444444-4444-4444-8444-444444444444"; + const runId = "55555555-5555-4555-8555-555555555555"; + const executionWorkspaceId = "66666666-6666-4666-8666-666666666666"; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-work-product-adoption-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueWorkProducts); + await db.delete(workspaceRuntimeServices); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedBase() { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + role: "engineer", + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-11218", + cwd: "/workspace/paperclip", + repoUrl: "https://github.com/paperclipai/paperclip", + branchName: "pap-11218-work-products", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + identifier: "PAP-11218", + title: "Drive work-product adoption", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId }, + }); + } + + it("adopts managed runtime services as durable work products idempotently", async () => { + await seedBase(); + const runtimeServiceId = "77777777-7777-4777-8777-777777777777"; + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + executionWorkspaceId, + issueId, + scopeType: "execution_workspace", + scopeId: executionWorkspaceId, + serviceName: "preview", + status: "running", + lifecycle: "ephemeral", + url: "https://preview.example.com/", + provider: "local_process", + ownerAgentId: agentId, + startedByRunId: runId, + healthStatus: "healthy", + }); + + const runtimeService = { + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId: null, + executionWorkspaceId, + issueId, + serviceName: "preview", + status: "running", + lifecycle: "ephemeral", + scopeType: "execution_workspace", + scopeId: executionWorkspaceId, + reuseKey: null, + command: "pnpm dev", + cwd: "/workspace/paperclip", + port: 3100, + url: "https://preview.example.com/", + provider: "local_process", + providerRef: null, + ownerAgentId: agentId, + startedByRunId: runId, + lastUsedAt: "2026-07-01T20:00:00.000Z", + startedAt: "2026-07-01T20:00:00.000Z", + stoppedAt: null, + stopPolicy: null, + healthStatus: "healthy", + reused: false, + } as const; + + await expect(adoptWorkProductsForRun({ + db, + companyId, + issueId, + runId, + projectId, + executionWorkspaceId, + runtimeServices: [runtimeService], + })).resolves.toEqual({ created: 1, updated: 0, skipped: 0 }); + await expect(adoptWorkProductsForRun({ + db, + companyId, + issueId, + runId, + projectId, + executionWorkspaceId, + runtimeServices: [runtimeService], + })).resolves.toEqual({ created: 0, updated: 1, skipped: 0 }); + + const rows = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "runtime_service", + provider: "paperclip", + externalId: runtimeServiceId, + url: "https://preview.example.com/", + runtimeServiceId, + executionWorkspaceId, + projectId, + createdByRunId: runId, + healthStatus: "healthy", + }); + }); + + it("adopts PR, preview, artifact, branch, and commit metadata from final result JSON", async () => { + await seedBase(); + + await expect(adoptWorkProductsForRun({ + db, + companyId, + issueId, + runId, + projectId, + executionWorkspaceId, + workspace: { + cwd: "/workspace/paperclip", + repoUrl: "https://github.com/paperclipai/paperclip", + branchName: "pap-11218-work-products", + }, + resultJson: { + pullRequestUrl: "https://github.com/paperclipai/paperclip/pull/11218", + previewUrls: ["https://pap-11218.example.com"], + commit: { + sha: "abcdef1234567890abcdef1234567890abcdef12", + url: "https://github.com/paperclipai/paperclip/commit/abcdef1234567890abcdef1234567890abcdef12", + message: "Drive work-product adoption", + }, + artifacts: [{ + title: "Smoke report", + url: "https://artifacts.example.com/smoke.html", + }], + }, + })).resolves.toEqual({ created: 5, updated: 0, skipped: 0 }); + + const rows = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId)); + expect(rows.map((row) => row.type).sort()).toEqual(["artifact", "branch", "commit", "preview_url", "pull_request"]); + expect(rows.find((row) => row.type === "pull_request")).toMatchObject({ + provider: "github", + externalId: "paperclipai/paperclip#11218", + status: "ready_for_review", + reviewState: "needs_board_review", + isPrimary: true, + }); + expect(rows.find((row) => row.type === "preview_url")).toMatchObject({ + url: "https://pap-11218.example.com/", + status: "active", + isPrimary: true, + }); + expect(rows.find((row) => row.type === "branch")).toMatchObject({ + provider: "git", + externalId: "https://github.com/paperclipai/paperclip#pap-11218-work-products", + }); + expect(rows.find((row) => row.type === "commit")).toMatchObject({ + provider: "github", + externalId: "abcdef1234567890abcdef1234567890abcdef12", + summary: "Drive work-product adoption", + }); + expect(rows.find((row) => row.type === "artifact")).toMatchObject({ + provider: "custom", + title: "Smoke report", + url: "https://artifacts.example.com/smoke.html", + status: "ready_for_review", + reviewState: "needs_board_review", + }); + }); + + it("deduplicates concurrent adoption for the same external work product", async () => { + await seedBase(); + + const input = { + db, + companyId, + issueId, + runId, + projectId, + executionWorkspaceId, + resultJson: { + pullRequestUrl: "https://github.com/paperclipai/paperclip/pull/11218", + }, + }; + + await Promise.all([ + adoptWorkProductsForRun(input), + adoptWorkProductsForRun(input), + ]); + + const rows = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: "pull_request", + provider: "github", + externalId: "paperclipai/paperclip#11218", + isPrimary: true, + }); + }); +}); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index d099ca67268..4a54d49ea0b 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -19,10 +19,12 @@ import type { WorkspaceRuntimeService, WorkspaceOverviewPrimaryService, WorkspaceOverviewQuery, + IssueWorkProduct, } from "@paperclipai/shared"; import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; +import { workProductService } from "./work-products.js"; import { listCurrentRuntimeServicesForExecutionWorkspaces, listCurrentRuntimeServicesForProjectWorkspaces, @@ -320,6 +322,7 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ function toExecutionWorkspace( row: ExecutionWorkspaceRow, runtimeServices: WorkspaceRuntimeService[] = [], + workProducts?: IssueWorkProduct[], ): ExecutionWorkspace { return { id: row.id, @@ -346,6 +349,7 @@ function toExecutionWorkspace( config: readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null), metadata: (row.metadata as Record | null) ?? null, runtimeServices, + ...(workProducts ? { workProducts } : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -444,6 +448,8 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & { }; export function executionWorkspaceService(db: Db) { + const workProducts = workProductService(db); + function buildListConditions( companyId: string, filters?: { @@ -749,10 +755,14 @@ export function executionWorkspaceService(db: Db) { .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; - const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); + const [runtimeServicesByWorkspaceId, workspaceWorkProducts] = await Promise.all([ + loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]), + workProducts.listForExecutionWorkspace(row.companyId, row.id), + ]); return toExecutionWorkspace( row, (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + workspaceWorkProducts, ); }, @@ -1023,7 +1033,16 @@ export function executionWorkspaceService(db: Db) { .where(eq(executionWorkspaces.id, id)) .returning() .then((rows) => rows[0] ?? null); - return row ? toExecutionWorkspace(row) : null; + if (!row) return null; + const [runtimeServicesByWorkspaceId, workspaceWorkProducts] = await Promise.all([ + loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]), + workProducts.listForExecutionWorkspace(row.companyId, row.id), + ]); + return toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + workspaceWorkProducts, + ); }, clearEnvironmentSelection: async (companyId: string, environmentId: string) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6b5282801c2..4c9774f82b8 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -108,6 +108,7 @@ import { releaseRuntimeServicesForRun, type ExecutionWorkspaceInput, type RealizedExecutionWorkspace, + type RuntimeServiceRef, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; @@ -182,6 +183,7 @@ import { type AgentOrgRow, } from "./agent-invokability.js"; import { + buildLowTrustSourceTrust, redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust, } from "./source-trust.js"; @@ -231,6 +233,7 @@ import { type EffectiveRunConfigSecretManifestEntry, } from "./effective-run-config-fingerprints.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { adoptWorkProductsForRun } from "./work-product-adoption.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; @@ -10153,6 +10156,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionWorkspacePreference: issueContext.executionWorkspacePreference, } : null; + const runWorkProductSourceTrust = issueRef && trustPreset.kind === "low_trust_review" + ? buildLowTrustSourceTrust({ + issueId: issueRef.id, + runId: run.id, + agentId: agent.id, + }) + : null; const continuationSummary = issueRef ? await getIssueContinuationSummaryDocument(db, issueRef.id) : null; @@ -11292,6 +11302,43 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }); }; + const adoptHeartbeatWorkProducts = async (input: { + runtimeServices?: RuntimeServiceRef[]; + resultJson?: Record | null; + }) => { + if (!issueRef) return; + try { + const result = await adoptWorkProductsForRun({ + db, + companyId: agent.companyId, + issueId: issueRef.id, + runId: run.id, + projectId: executionWorkspace.projectId ?? issueRef.projectId ?? null, + executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef.executionWorkspaceId ?? null, + workspace: { + cwd: executionWorkspace.cwd, + repoUrl: executionWorkspace.repoUrl, + repoRef: executionWorkspace.repoRef, + branchName: executionWorkspace.branchName, + }, + runtimeServices: input.runtimeServices, + resultJson: input.resultJson, + sourceTrust: runWorkProductSourceTrust, + }); + if (result.created > 0) { + await onLog("stdout", `[paperclip] Adopted ${result.created} work product${result.created === 1 ? "" : "s"} for this run.\n`); + } + } catch (err) { + logger.warn( + { err, runId: run.id, issueId: issueRef.id }, + "failed to adopt heartbeat work products", + ); + await onLog( + "stderr", + `[paperclip] Failed to adopt work products: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + }; if (runScopedMentionedSkillKeys.length > 0) { await onLog( "stdout", @@ -11361,6 +11408,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) updatedAt: new Date(), }) .where(eq(heartbeatRuns.id, run.id)); + await adoptHeartbeatWorkProducts({ runtimeServices }); } if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) { try { @@ -11662,6 +11710,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) updatedAt: new Date(), }) .where(eq(heartbeatRuns.id, run.id)); + await adoptHeartbeatWorkProducts({ runtimeServices: adapterManagedRuntimeServices }); if (issueId) { try { await issuesSvc.addComment( @@ -11794,6 +11843,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adapterResult.summary ?? null, ); + await adoptHeartbeatWorkProducts({ resultJson: persistedResultJson }); + const persistedRunWrite = await setRunStatusIfRunning(run.id, status, { finishedAt: new Date(), error: runErrorMessage, diff --git a/server/src/services/work-product-adoption.ts b/server/src/services/work-product-adoption.ts new file mode 100644 index 00000000000..97332c307bd --- /dev/null +++ b/server/src/services/work-product-adoption.ts @@ -0,0 +1,501 @@ +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { issueWorkProducts } from "@paperclipai/db"; +import type { SourceTrustMetadata } from "@paperclipai/shared"; +import type { RuntimeServiceRef } from "./workspace-runtime.js"; + +const MAX_ADOPTED_WORK_PRODUCTS_PER_RUN = 50; + +const WORK_PRODUCT_TYPES = new Set([ + "preview_url", + "runtime_service", + "pull_request", + "branch", + "commit", + "artifact", + "document", +]); + +const WORK_PRODUCT_STATUSES = new Set([ + "active", + "ready_for_review", + "approved", + "changes_requested", + "merged", + "closed", + "failed", + "archived", + "draft", +]); + +const REVIEW_STATES = new Set([ + "none", + "needs_board_review", + "approved", + "changes_requested", +]); + +type AdoptedWorkProductCandidate = { + type: string; + provider: string; + externalId: string; + title: string; + url?: string | null; + status: string; + reviewState: string; + isPrimary: boolean; + healthStatus: "unknown" | "healthy" | "unhealthy"; + summary?: string | null; + metadata?: Record | null; + projectId?: string | null; + executionWorkspaceId?: string | null; + runtimeServiceId?: string | null; +}; + +export type WorkProductAdoptionResult = { + created: number; + updated: number; + skipped: number; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function normalizeUrl(value: unknown): string | null { + const raw = readNonEmptyString(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString(); + } catch { + return null; + } +} + +function normalizeEnum(value: unknown, allowed: Set, fallback: string): string { + const normalized = readNonEmptyString(value)?.toLowerCase(); + return normalized && allowed.has(normalized) ? normalized : fallback; +} + +function providerForUrl(url: string): string { + try { + const host = new URL(url).hostname.toLowerCase(); + if (host === "github.com" || host.endsWith(".github.com")) return "github"; + if (host === "vercel.app" || host.endsWith(".vercel.app")) return "vercel"; + } catch { + // normalizeUrl already validated this; keep the fallback defensive. + } + return "custom"; +} + +function githubPullRequestIdentity(url: string): { externalId: string; title: string } | null { + try { + const parsed = new URL(url); + if (parsed.hostname.toLowerCase() !== "github.com") return null; + const [owner, repo, kind, number] = parsed.pathname.split("/").filter(Boolean); + if (!owner || !repo || kind !== "pull" || !number) return null; + return { + externalId: `${owner}/${repo}#${number}`, + title: `${owner}/${repo} PR #${number}`, + }; + } catch { + return null; + } + return null; +} + +function shortSha(value: string) { + return value.length > 12 ? value.slice(0, 12) : value; +} + +function candidateKey(candidate: AdoptedWorkProductCandidate) { + return `${candidate.type}\0${candidate.provider}\0${candidate.externalId}`; +} + +function uniqueCandidates(candidates: AdoptedWorkProductCandidate[]) { + const seen = new Set(); + const unique: AdoptedWorkProductCandidate[] = []; + for (const candidate of candidates) { + const key = candidateKey(candidate); + if (seen.has(key)) continue; + seen.add(key); + unique.push(candidate); + if (unique.length >= MAX_ADOPTED_WORK_PRODUCTS_PER_RUN) break; + } + return unique; +} + +function isCandidate(value: AdoptedWorkProductCandidate | null): value is AdoptedWorkProductCandidate { + return value !== null; +} + +function valuesFromKeys(record: Record, keys: string[]): unknown[] { + const values: unknown[] = []; + for (const key of keys) { + const value = record[key]; + if (value === undefined || value === null) continue; + if (Array.isArray(value)) values.push(...value); + else values.push(value); + } + return values; +} + +function urlFromValue(value: unknown): string | null { + const direct = normalizeUrl(value); + if (direct) return direct; + const record = asRecord(value); + if (!record) return null; + return normalizeUrl(record.url) ?? + normalizeUrl(record.href) ?? + normalizeUrl(record.htmlUrl) ?? + normalizeUrl(record.html_url) ?? + normalizeUrl(record.openPath) ?? + normalizeUrl(record.open_path); +} + +function titleFromValue(value: unknown, fallback: string): string { + const record = asRecord(value); + return readNonEmptyString(record?.title) ?? + readNonEmptyString(record?.name) ?? + readNonEmptyString(record?.label) ?? + fallback; +} + +function previewCandidateFromValue(value: unknown, index: number): AdoptedWorkProductCandidate | null { + const url = urlFromValue(value); + if (!url) return null; + const record = asRecord(value); + return { + type: "preview_url", + provider: readNonEmptyString(record?.provider) ?? providerForUrl(url), + externalId: readNonEmptyString(record?.externalId) ?? readNonEmptyString(record?.external_id) ?? url, + title: titleFromValue(value, index === 0 ? "Preview" : `Preview ${index + 1}`), + url, + status: normalizeEnum(record?.status, WORK_PRODUCT_STATUSES, "active"), + reviewState: normalizeEnum(record?.reviewState ?? record?.review_state, REVIEW_STATES, "none"), + isPrimary: readBoolean(record?.isPrimary ?? record?.is_primary) ?? index === 0, + healthStatus: normalizeEnum(record?.healthStatus ?? record?.health_status, new Set(["unknown", "healthy", "unhealthy"]), "unknown") as "unknown" | "healthy" | "unhealthy", + summary: readNonEmptyString(record?.summary), + metadata: { adoptedFrom: "result_json" }, + }; +} + +function pullRequestCandidateFromValue(value: unknown): AdoptedWorkProductCandidate | null { + const url = urlFromValue(value); + if (!url) return null; + const record = asRecord(value); + const github = githubPullRequestIdentity(url); + const draft = readBoolean(record?.draft) ?? false; + const merged = readBoolean(record?.merged) ?? false; + const closed = readBoolean(record?.closed) ?? false; + return { + type: "pull_request", + provider: readNonEmptyString(record?.provider) ?? providerForUrl(url), + externalId: readNonEmptyString(record?.externalId) ?? readNonEmptyString(record?.external_id) ?? github?.externalId ?? url, + title: titleFromValue(value, github?.title ?? "Pull request"), + url, + status: normalizeEnum(record?.status, WORK_PRODUCT_STATUSES, merged ? "merged" : closed ? "closed" : draft ? "draft" : "ready_for_review"), + reviewState: normalizeEnum(record?.reviewState ?? record?.review_state, REVIEW_STATES, "needs_board_review"), + isPrimary: readBoolean(record?.isPrimary ?? record?.is_primary) ?? true, + healthStatus: "unknown", + summary: readNonEmptyString(record?.summary), + metadata: { adoptedFrom: "result_json" }, + }; +} + +function branchCandidateFromValue(value: unknown, fallbackExternalScope: string | null): AdoptedWorkProductCandidate | null { + const record = asRecord(value); + const branchName = readNonEmptyString(record?.branchName) ?? + readNonEmptyString(record?.branch_name) ?? + readNonEmptyString(record?.name) ?? + readNonEmptyString(value); + if (!branchName) return null; + const scope = readNonEmptyString(record?.repoUrl) ?? + readNonEmptyString(record?.repo_url) ?? + readNonEmptyString(record?.repository) ?? + fallbackExternalScope ?? + "workspace"; + return { + type: "branch", + provider: readNonEmptyString(record?.provider) ?? "git", + externalId: `${scope}#${branchName}`, + title: titleFromValue(value, `Branch ${branchName}`), + status: normalizeEnum(record?.status, WORK_PRODUCT_STATUSES, "active"), + reviewState: normalizeEnum(record?.reviewState ?? record?.review_state, REVIEW_STATES, "none"), + isPrimary: readBoolean(record?.isPrimary ?? record?.is_primary) ?? false, + healthStatus: "unknown", + summary: readNonEmptyString(record?.summary), + metadata: { adoptedFrom: "result_json", branchName }, + }; +} + +function commitCandidateFromValue(value: unknown): AdoptedWorkProductCandidate | null { + const record = asRecord(value); + const url = urlFromValue(value); + const sha = readNonEmptyString(record?.sha) ?? + readNonEmptyString(record?.commitSha) ?? + readNonEmptyString(record?.commit_sha) ?? + (/^[0-9a-f]{7,40}$/i.test(readNonEmptyString(value) ?? "") ? readNonEmptyString(value) : null); + if (!sha && !url) return null; + const externalId = readNonEmptyString(record?.externalId) ?? + readNonEmptyString(record?.external_id) ?? + sha ?? + url!; + return { + type: "commit", + provider: readNonEmptyString(record?.provider) ?? (url ? providerForUrl(url) : "git"), + externalId, + title: titleFromValue(value, sha ? `Commit ${shortSha(sha)}` : "Commit"), + url, + status: normalizeEnum(record?.status, WORK_PRODUCT_STATUSES, "active"), + reviewState: normalizeEnum(record?.reviewState ?? record?.review_state, REVIEW_STATES, "none"), + isPrimary: readBoolean(record?.isPrimary ?? record?.is_primary) ?? false, + healthStatus: "unknown", + summary: readNonEmptyString(record?.summary) ?? readNonEmptyString(record?.message), + metadata: { adoptedFrom: "result_json", ...(sha ? { sha } : {}) }, + }; +} + +function artifactCandidateFromValue(value: unknown): AdoptedWorkProductCandidate | null { + const explicit = explicitCandidateFromValue(value); + if (explicit) return explicit; + const url = urlFromValue(value); + if (!url) return null; + const record = asRecord(value); + return { + type: "artifact", + provider: readNonEmptyString(record?.provider) ?? providerForUrl(url), + externalId: readNonEmptyString(record?.externalId) ?? readNonEmptyString(record?.external_id) ?? url, + title: titleFromValue(value, "Artifact"), + url, + status: normalizeEnum(record?.status, WORK_PRODUCT_STATUSES, "ready_for_review"), + reviewState: normalizeEnum(record?.reviewState ?? record?.review_state, REVIEW_STATES, "needs_board_review"), + isPrimary: readBoolean(record?.isPrimary ?? record?.is_primary) ?? false, + healthStatus: normalizeEnum(record?.healthStatus ?? record?.health_status, new Set(["unknown", "healthy", "unhealthy"]), "unknown") as "unknown" | "healthy" | "unhealthy", + summary: readNonEmptyString(record?.summary), + metadata: { adoptedFrom: "result_json" }, + }; +} + +function explicitCandidateFromValue(value: unknown): AdoptedWorkProductCandidate | null { + const record = asRecord(value); + if (!record) return null; + const type = normalizeEnum(record.type, WORK_PRODUCT_TYPES, ""); + if (!type) return null; + const url = normalizeUrl(record.url); + const externalId = readNonEmptyString(record.externalId) ?? readNonEmptyString(record.external_id) ?? url; + if (!externalId) return null; + return { + type, + provider: readNonEmptyString(record.provider) ?? (url ? providerForUrl(url) : "custom"), + externalId, + title: titleFromValue(value, type.replace(/_/g, " ")), + url, + status: normalizeEnum(record.status, WORK_PRODUCT_STATUSES, type === "pull_request" ? "ready_for_review" : "active"), + reviewState: normalizeEnum(record.reviewState ?? record.review_state, REVIEW_STATES, type === "pull_request" ? "needs_board_review" : "none"), + isPrimary: readBoolean(record.isPrimary ?? record.is_primary) ?? false, + healthStatus: normalizeEnum(record.healthStatus ?? record.health_status, new Set(["unknown", "healthy", "unhealthy"]), "unknown") as "unknown" | "healthy" | "unhealthy", + summary: readNonEmptyString(record.summary), + metadata: asRecord(record.metadata) ?? { adoptedFrom: "result_json" }, + }; +} + +function runtimeServiceStatus(service: RuntimeServiceRef) { + if (service.status === "failed") return "failed"; + if (service.status === "stopped") return "closed"; + return "active"; +} + +function runtimeServiceCandidate(service: RuntimeServiceRef): AdoptedWorkProductCandidate { + const title = service.url && service.serviceName.toLowerCase().includes("preview") + ? `Preview: ${service.serviceName}` + : `Runtime service: ${service.serviceName}`; + return { + type: "runtime_service", + provider: "paperclip", + externalId: service.id, + title, + url: service.url, + status: runtimeServiceStatus(service), + reviewState: "none", + isPrimary: Boolean(service.url), + healthStatus: service.healthStatus, + summary: service.url + ? `${service.serviceName} is available at ${service.url}.` + : `${service.serviceName} runtime service is ${service.status}.`, + metadata: { + adoptedFrom: "runtime_service", + serviceName: service.serviceName, + serviceProvider: service.provider, + scopeType: service.scopeType, + scopeId: service.scopeId, + reused: service.reused, + port: service.port, + }, + projectId: service.projectId, + executionWorkspaceId: service.executionWorkspaceId, + runtimeServiceId: service.id, + }; +} + +function collectResultJsonCandidates(input: { + resultJson: Record | null | undefined; + workspaceExternalScope: string | null; +}): AdoptedWorkProductCandidate[] { + const roots = [input.resultJson, asRecord(input.resultJson?.meta), asRecord(asRecord(input.resultJson?.result)?.meta)] + .filter((value): value is Record => Boolean(value)); + const candidates: AdoptedWorkProductCandidate[] = []; + + for (const root of roots) { + candidates.push( + ...valuesFromKeys(root, ["workProducts", "work_products"]).map(explicitCandidateFromValue).filter(isCandidate), + ...valuesFromKeys(root, ["previewUrl", "preview_url", "previewUrls", "preview_urls", "previews"]) + .map((value, index) => previewCandidateFromValue(value, index)) + .filter(isCandidate), + ...valuesFromKeys(root, ["pullRequest", "pull_request", "pullRequests", "pull_requests", "pullRequestUrl", "pull_request_url", "prUrl", "pr_url"]) + .map(pullRequestCandidateFromValue) + .filter(isCandidate), + ...valuesFromKeys(root, ["branch", "branchName", "branch_name", "branches"]) + .map((value) => branchCandidateFromValue(value, input.workspaceExternalScope)) + .filter(isCandidate), + ...valuesFromKeys(root, ["commit", "commits", "commitSha", "commit_sha", "commitUrl", "commit_url", "commitUrls", "commit_urls"]) + .map(commitCandidateFromValue) + .filter(isCandidate), + ...valuesFromKeys(root, ["artifact", "artifacts"]) + .map(artifactCandidateFromValue) + .filter(isCandidate), + ); + } + + return candidates; +} + +export async function adoptWorkProductsForRun(input: { + db: Db; + companyId: string; + issueId: string; + runId: string; + projectId?: string | null; + executionWorkspaceId?: string | null; + workspace?: { + cwd?: string | null; + repoUrl?: string | null; + repoRef?: string | null; + branchName?: string | null; + } | null; + runtimeServices?: RuntimeServiceRef[]; + resultJson?: Record | null; + sourceTrust?: SourceTrustMetadata | null; +}): Promise { + const workspaceExternalScope = + readNonEmptyString(input.workspace?.repoUrl) ?? + readNonEmptyString(input.workspace?.cwd) ?? + null; + const candidates = uniqueCandidates([ + ...(input.runtimeServices ?? []).map(runtimeServiceCandidate), + ...(input.workspace?.branchName ? [branchCandidateFromValue({ + branchName: input.workspace.branchName, + repoUrl: input.workspace.repoUrl, + status: "active", + }, workspaceExternalScope)].filter(isCandidate) : []), + ...collectResultJsonCandidates({ + resultJson: input.resultJson, + workspaceExternalScope, + }), + ]); + + let created = 0; + let updated = 0; + let skipped = 0; + + for (const candidate of candidates) { + if (!WORK_PRODUCT_TYPES.has(candidate.type) || !candidate.provider || !candidate.externalId) { + skipped += 1; + continue; + } + + const now = new Date(); + const values = { + companyId: input.companyId, + issueId: input.issueId, + projectId: candidate.projectId ?? input.projectId ?? null, + executionWorkspaceId: candidate.executionWorkspaceId ?? input.executionWorkspaceId ?? null, + runtimeServiceId: candidate.runtimeServiceId ?? null, + type: candidate.type, + provider: candidate.provider, + externalId: candidate.externalId, + title: candidate.title, + url: candidate.url ?? null, + status: candidate.status, + reviewState: candidate.reviewState, + isPrimary: candidate.isPrimary, + healthStatus: candidate.healthStatus, + summary: candidate.summary ?? null, + metadata: candidate.metadata ?? null, + sourceTrust: input.sourceTrust ?? null, + updatedAt: now, + }; + + const outcome = await input.db.transaction(async (tx) => { + const existing = await tx + .select() + .from(issueWorkProducts) + .where(and( + eq(issueWorkProducts.companyId, input.companyId), + eq(issueWorkProducts.issueId, input.issueId), + eq(issueWorkProducts.type, candidate.type), + eq(issueWorkProducts.provider, candidate.provider), + eq(issueWorkProducts.externalId, candidate.externalId), + )) + .orderBy(desc(issueWorkProducts.updatedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (candidate.isPrimary) { + await tx + .update(issueWorkProducts) + .set({ isPrimary: false, updatedAt: now }) + .where(and( + eq(issueWorkProducts.companyId, input.companyId), + eq(issueWorkProducts.issueId, input.issueId), + eq(issueWorkProducts.type, candidate.type), + )); + } + + await tx + .insert(issueWorkProducts) + .values({ + ...values, + createdByRunId: input.runId, + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + issueWorkProducts.companyId, + issueWorkProducts.issueId, + issueWorkProducts.type, + issueWorkProducts.provider, + issueWorkProducts.externalId, + ], + set: values, + }); + + return existing ? "updated" as const : "created" as const; + }); + + if (outcome === "created") created += 1; + else updated += 1; + } + + return { created, updated, skipped }; +} diff --git a/server/src/services/work-products.ts b/server/src/services/work-products.ts index 61c88c771ad..d916d1a5d1c 100644 --- a/server/src/services/work-products.ts +++ b/server/src/services/work-products.ts @@ -42,6 +42,20 @@ export function workProductService(db: Db) { return rows.map(toIssueWorkProduct); }, + listForExecutionWorkspace: async (companyId: string, executionWorkspaceId: string) => { + const rows = await db + .select() + .from(issueWorkProducts) + .where( + and( + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.executionWorkspaceId, executionWorkspaceId), + ), + ) + .orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt)); + return rows.map(toIssueWorkProduct); + }, + getById: async (id: string) => { const row = await db .select() diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index c96aad1120c..251dbbfe054 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ExecutionWorkspace, Project } from "@paperclipai/shared"; -import { act, type ReactNode } from "react"; +import type { ExecutionWorkspace, Issue, IssueWorkProduct, Project } from "@paperclipai/shared"; +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail"; @@ -140,6 +141,97 @@ function workspace(overrides: Partial = {}): ExecutionWorksp } as ExecutionWorkspace; } +function issue(overrides: Partial = {}): Issue { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "issue-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Ship workspace outputs", + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + issueNumber: 17, + identifier: "PAP-17", + originKind: "manual", + originId: null, + originRunId: null, + originFingerprint: null, + requestDepth: 0, + workMode: "standard", + billingCode: null, + assigneeAdapterOverrides: null, + executionPolicy: null, + executionState: null, + executionWorkspaceId: "workspace-1", + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + blockerAttention: { state: "none", reason: null, unresolvedBlockerCount: 0, coveredBlockerCount: 0, stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: null, sampleStalledBlockerIdentifier: null }, + productivityReview: null, + scheduledRetry: null, + activeRecoveryAction: null, + blockedBy: [], + blocks: [], + children: [], + comments: [], + documents: [], + attachments: [], + workProducts: [], + currentExecutionWorkspace: null, + createdAt: now, + updatedAt: now, + ...overrides, + } as Issue; +} + +function workProduct(overrides: Partial & { id: string }): IssueWorkProduct { + const now = new Date("2026-05-01T00:00:00Z"); + const { id, ...workProductOverrides } = overrides; + return { + id, + companyId: "company-1", + projectId: "project-1", + issueId: "issue-1", + executionWorkspaceId: "workspace-1", + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "build-artifact.zip", + url: null, + status: "ready_for_review", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: { + openPath: `/api/attachments/${id}/content`, + downloadPath: `/api/attachments/${id}/content?download=1`, + }, + sourceTrust: null, + createdByRunId: null, + createdAt: now, + updatedAt: now, + ...workProductOverrides, + } as IssueWorkProduct; +} + function project(overrides: Partial = {}): Project { const now = new Date("2026-05-01T00:00:00Z"); return { @@ -221,7 +313,9 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { }); afterEach(() => { - act(() => root?.unmount()); + if (root) { + flushSync(() => root?.unmount()); + } root = null; container.remove(); vi.clearAllMocks(); @@ -231,7 +325,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { async function render() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await act(async () => { + flushSync(() => { root = createRoot(container); root.render( @@ -239,9 +333,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { , ); }); - await act(async () => { - await flush(); - }); + await flush(); } it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => { @@ -298,6 +390,75 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); }); + it("renders workspace work products without requiring a plugin tab", async () => { + mockRouteLocation.pathname = "/execution-workspaces/workspace-1/work-products"; + mockIssuesApi.list.mockResolvedValue([issue()]); + mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ + workProducts: [ + workProduct({ + id: "wp-pr", + type: "pull_request", + provider: "github", + title: "PR 42", + url: "https://github.com/paperclipai/paperclip/pull/42", + status: "ready_for_review", + summary: "Workspace branch is ready for review.", + }), + workProduct({ + id: "wp-preview", + type: "preview_url", + provider: "vercel", + title: "Vercel preview", + url: "https://preview.example.com", + status: "active", + }), + workProduct({ + id: "wp-file", + type: "artifact", + provider: "paperclip", + title: "qa-screenshot.png", + status: "approved", + }), + ], + })); + + await render(); + + expect(container.textContent).toContain("Workspace work products"); + expect(container.textContent).toContain("3 work products"); + expect(container.textContent).toContain("1 pull request"); + expect(container.textContent).toContain("1 preview"); + expect(container.textContent).toContain("1 file"); + expect(container.textContent).toContain("PR 42"); + expect(container.textContent).toContain("Vercel preview"); + expect(container.textContent).toContain("qa-screenshot.png"); + expect(container.querySelector('a[href="https://github.com/paperclipai/paperclip/pull/42"]')).not.toBeNull(); + expect(container.querySelector('a[href="/issues/PAP-17"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + }); + + it("does not render protocol-relative metadata work-product links", async () => { + mockRouteLocation.pathname = "/execution-workspaces/workspace-1/work-products"; + mockIssuesApi.list.mockResolvedValue([issue()]); + mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ + workProducts: [ + workProduct({ + id: "wp-file", + title: "agent-report.html", + metadata: { + openPath: "//evil.example/report.html", + }, + }), + ], + })); + + await render(); + + expect(container.textContent).toContain("agent-report.html"); + expect(container.querySelector('a[href="//evil.example/report.html"]')).toBeNull(); + expect(container.querySelector('a[href="https://evil.example/report.html"]')).toBeNull(); + }); + it("orders execution workspace plugin tabs against built-in tabs by slot order", async () => { mockPluginSlotState.slots = [ pluginSlot({ id: "default-tab", displayName: "Default" }), @@ -312,6 +473,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { "Tasks", "Services", "Changes", + "Work products", "Configuration", "Runtime logs", "Inspect", diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 75141c30940..afebc017bd4 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared"; -import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react"; +import type { ExecutionWorkspace, Issue, IssueWorkProduct, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared"; +import { Copy, ExternalLink, FileText, GitBranch, GitCommit, GitPullRequest, Globe, Loader2, Package, Play, Repeat, Server } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -57,7 +57,7 @@ type WorkspaceFormState = { workspaceRuntime: string; }; -type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines"; +type ExecutionWorkspaceBaseTab = "services" | "work_products" | "configuration" | "runtime_logs" | "issues" | "routines"; type ExecutionWorkspacePluginTab = `plugin:${string}`; type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab; type OrderedExecutionWorkspaceTabItem = { @@ -70,8 +70,9 @@ const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; const EXECUTION_WORKSPACE_BASE_TAB_ITEMS: OrderedExecutionWorkspaceTabItem[] = [ { value: "issues", label: "Tasks", order: 10 }, { value: "services", label: "Services", order: 20 }, - { value: "configuration", label: "Configuration", order: 30 }, - { value: "runtime_logs", label: "Runtime logs", order: 40 }, + { value: "work_products", label: "Work products", order: 30 }, + { value: "configuration", label: "Configuration", order: 40 }, + { value: "runtime_logs", label: "Runtime logs", order: 50 }, { value: "routines", label: "Routines", order: 60 }, ]; @@ -92,6 +93,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null; const tab = segments[executionWorkspacesIndex + 2]; if (tab === "services") return "services"; + if (tab === "work-products") return "work_products"; if (tab === "issues") return "issues"; if (tab === "routines") return "routines"; if (tab === "runtime-logs") return "runtime_logs"; @@ -100,7 +102,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex } function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) { - const segment = tab === "runtime_logs" ? "runtime-logs" : tab; + const segment = tab === "runtime_logs" ? "runtime-logs" : tab === "work_products" ? "work-products" : tab; return `/execution-workspaces/${workspaceId}/${segment}`; } @@ -308,6 +310,173 @@ function MonoValue({ value, copy }: { value: string; copy?: boolean }) { ); } +function safeWorkProductHref(workProduct: IssueWorkProduct) { + const metadata = workProduct.metadata; + const metadataOpenPath = metadata && typeof metadata.openPath === "string" ? metadata.openPath : null; + const candidate = workProduct.url ?? metadataOpenPath; + if (!candidate) return null; + if (candidate.startsWith("/") && !candidate.startsWith("//")) return candidate; + return isSafeExternalUrl(candidate) ? candidate : null; +} + +function workProductTypeIcon(type: IssueWorkProduct["type"]) { + switch (type) { + case "pull_request": + return GitPullRequest; + case "preview_url": + return Globe; + case "runtime_service": + return Server; + case "branch": + return GitBranch; + case "commit": + return GitCommit; + case "artifact": + return Package; + case "document": + return FileText; + default: + return FileText; + } +} + +function workProductTypeLabel(type: IssueWorkProduct["type"]) { + switch (type) { + case "preview_url": + return "Preview"; + case "runtime_service": + return "Runtime service"; + case "pull_request": + return "Pull request"; + case "artifact": + return "File"; + default: + return type.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); + } +} + +function formatWorkProductStatus(status: string) { + return status.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function WorkProductSummaryItem({ + icon: Icon, + label, +}: { + icon: typeof Package; + label: string; +}) { + return ( +
+
+ ); +} + +function ExecutionWorkspaceWorkProductsList({ + workspace, + issues, +}: { + workspace: ExecutionWorkspace; + issues: Issue[]; +}) { + const workProducts = workspace.workProducts ?? []; + const issueById = useMemo(() => new Map(issues.map((issue) => [issue.id, issue])), [issues]); + const pullRequestCount = workProducts.filter((product) => product.type === "pull_request").length; + const previewCount = workProducts.filter((product) => product.type === "preview_url" || product.type === "runtime_service").length; + const fileCount = workProducts.filter((product) => product.type === "artifact").length; + + return ( + + + Workspace work products + + Pull requests, previews, files, branches, commits, and documents linked to this execution workspace. + + + + {workProducts.length === 0 ? ( +
+
+ ) : ( +
+
+ + {pullRequestCount > 0 ? ( + + ) : null} + {previewCount > 0 ? ( + + ) : null} + {fileCount > 0 ? ( + + ) : null} +
+ +
+ {workProducts.map((product) => { + const Icon = workProductTypeIcon(product.type); + const href = safeWorkProductHref(product); + const sourceIssue = issueById.get(product.issueId) ?? null; + return ( +
+
+ ); + })} +
+
+ )} +
+
+ ); +} + function WorkspaceLink({ project, workspace, @@ -1222,6 +1391,8 @@ export function ExecutionWorkspaceDetail() { error={linkedIssuesQuery.error as Error | null} project={project} /> + ) : activeTab === "work_products" ? ( + ) : activePluginTab ? (