mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Adopt and surface workspace work products
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user