Adopt and surface workspace work products

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-06 13:50:56 +00:00
1 parent 518fc71cec
commit fe73622cc0
12 files changed
+1282 -15

No files matched your search

@@ -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");
@@ -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
}
]
}
@@ -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,
@@ -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<string, unknown> | null;
runtimeServices?: WorkspaceRuntimeService[];
workProducts?: IssueWorkProduct[];
createdAt: Date;
updatedAt: Date;
}
@@ -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");
});
});
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
});
});
});
+21 -2
View File
@@ -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<string, unknown> | null) ?? null),
metadata: (row.metadata as Record<string, unknown> | 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) => {
+51
View File
@@ -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<string, unknown> | 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,
@@ -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<string, unknown> | null;
projectId?: string | null;
executionWorkspaceId?: string | null;
runtimeServiceId?: string | null;
};
export type WorkProductAdoptionResult = {
created: number;
updated: number;
skipped: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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<string>, 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<string>();
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<string, unknown>, 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<string, unknown> | 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<string, unknown> => 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<string, unknown> | null;
sourceTrust?: SourceTrustMetadata | null;
}): Promise<WorkProductAdoptionResult> {
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 };
}
+14
View File
@@ -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()
+169 -7
View File
@@ -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<ExecutionWorkspace> = {}): ExecutionWorksp
} as ExecutionWorkspace;
}
function issue(overrides: Partial<Issue> = {}): 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<IssueWorkProduct> & { 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> = {}): 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(
<QueryClientProvider client={queryClient}>
@@ -239,9 +333,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
</QueryClientProvider>,
);
});
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",
+177 -6
View File
@@ -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 (
<div className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{label}</span>
</div>
);
}
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 (
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace work products</CardTitle>
<CardDescription>
Pull requests, previews, files, branches, commits, and documents linked to this execution workspace.
</CardDescription>
</CardHeader>
<CardContent>
{workProducts.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Package className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
No work products have been linked to this execution workspace yet.
</p>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border pb-3">
<WorkProductSummaryItem icon={Package} label={pluralize(workProducts.length, "work product")} />
{pullRequestCount > 0 ? (
<WorkProductSummaryItem icon={GitPullRequest} label={pluralize(pullRequestCount, "pull request")} />
) : null}
{previewCount > 0 ? (
<WorkProductSummaryItem icon={Globe} label={pluralize(previewCount, "preview")} />
) : null}
{fileCount > 0 ? (
<WorkProductSummaryItem icon={FileText} label={pluralize(fileCount, "file")} />
) : null}
</div>
<div className="divide-y divide-border">
{workProducts.map((product) => {
const Icon = workProductTypeIcon(product.type);
const href = safeWorkProductHref(product);
const sourceIssue = issueById.get(product.issueId) ?? null;
return (
<div key={product.id} className="flex flex-col gap-3 py-3 sm:flex-row sm:items-start">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 text-sm font-medium hover:underline"
>
<span className="truncate">{product.title}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
</a>
) : (
<span className="min-w-0 truncate text-sm font-medium">{product.title}</span>
)}
{product.isPrimary ? <StatusPill>Primary</StatusPill> : null}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{workProductTypeLabel(product.type)}</span>
<span aria-hidden="true">·</span>
<span>{formatWorkProductStatus(product.status)}</span>
<span aria-hidden="true">·</span>
<span>Updated {formatDateTime(product.updatedAt)}</span>
{sourceIssue ? (
<>
<span aria-hidden="true">·</span>
<Link to={issueUrl(sourceIssue)} className="hover:underline">
{sourceIssue.identifier ?? sourceIssue.id}
</Link>
</>
) : null}
</div>
{product.summary ? (
<p className="max-w-3xl text-sm text-muted-foreground">{product.summary}</p>
) : null}
</div>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
}
function WorkspaceLink({
project,
workspace,
@@ -1222,6 +1391,8 @@ export function ExecutionWorkspaceDetail() {
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
) : activeTab === "work_products" ? (
<ExecutionWorkspaceWorkProductsList workspace={workspace} issues={linkedIssues} />
) : activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}