Preserve local mainline work before sync

This commit is contained in:
Dotta committed 2026-07-02 10:03:42 +00:00
1 parent 38550dcf77
commit b48db0f7b6
24 files changed
+2199 -26

No files matched your search

+329 -1
View File
@@ -1,5 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
agents,
approvals,
budgetIncidents,
budgetPolicies,
companies,
costEvents,
createDb,
projects,
} from "@paperclipai/db";
import { budgetService } from "../services/budgets.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -309,3 +324,316 @@ describe("budgetService", () => {
);
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("budgetService release gate enforcement", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-budgets-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(budgetIncidents);
await db.delete(approvals);
await db.delete(budgetPolicies);
await db.delete(costEvents);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
mockLogActivity.mockClear();
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createBudgetFixture() {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Budget Project",
status: "in_progress",
});
return { companyId, agentId, projectId };
}
async function insertCostEvent(input: {
companyId: string;
agentId: string;
projectId?: string | null;
costCents: number;
occurredAt?: Date;
}) {
const [event] = await db
.insert(costEvents)
.values({
companyId: input.companyId,
agentId: input.agentId,
projectId: input.projectId ?? null,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-5-release-gate",
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 20,
costCents: input.costCents,
occurredAt: input.occurredAt ?? new Date(),
})
.returning();
return event!;
}
it("raises one soft incident per window before hard-stopping and safely logging agent telemetry", async () => {
const { companyId, agentId } = await createBudgetFixture();
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
const service = budgetService(db, { cancelWorkForScope });
const [policy] = await db
.insert(budgetPolicies)
.values({
companyId,
scopeType: "agent",
scopeId: agentId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
})
.returning();
const softEvent = await insertCostEvent({ companyId, agentId, costCents: 80 });
await service.evaluateCostEvent(softEvent);
await service.evaluateCostEvent(softEvent);
let incidentRows = await db
.select()
.from(budgetIncidents);
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
expect(incidentRows[0]).toMatchObject({
companyId,
policyId: policy!.id,
scopeType: "agent",
scopeId: agentId,
thresholdType: "soft",
amountLimit: 100,
amountObserved: 80,
approvalId: null,
status: "open",
});
const [agentBeforeHardStop] = await db
.select({ status: agents.status, pauseReason: agents.pauseReason })
.from(agents);
expect(agentBeforeHardStop).toEqual({ status: "active", pauseReason: null });
const hardEvent = await insertCostEvent({ companyId, agentId, costCents: 25 });
await service.evaluateCostEvent(hardEvent);
await service.evaluateCostEvent(hardEvent);
incidentRows = await db
.select()
.from(budgetIncidents);
expect(incidentRows.filter((incident) => incident.thresholdType === "soft")).toHaveLength(1);
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
expect(incidentRows.find((incident) => incident.thresholdType === "soft")).toMatchObject({
status: "resolved",
});
expect(incidentRows.find((incident) => incident.thresholdType === "hard")).toMatchObject({
amountLimit: 100,
amountObserved: 105,
status: "open",
});
const [approval] = await db.select().from(approvals);
expect(approval).toMatchObject({
companyId,
type: "budget_override_required",
status: "pending",
});
const [agentAfterHardStop] = await db
.select({ status: agents.status, pauseReason: agents.pauseReason, pausedAt: agents.pausedAt })
.from(agents);
expect(agentAfterHardStop).toMatchObject({ status: "paused", pauseReason: "budget" });
expect(agentAfterHardStop?.pausedAt).toBeInstanceOf(Date);
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "agent", scopeId: agentId });
const block = await service.getInvocationBlock(companyId, agentId);
expect(block).toEqual({
scopeType: "agent",
scopeId: agentId,
scopeName: "Budget Agent SECRET_TOKEN_SHOULD_NOT_LEAK",
reason: "Agent is paused because its budget hard-stop was reached.",
});
const telemetryCalls = mockLogActivity.mock.calls.map(([, input]) => input);
expect(telemetryCalls.filter((call) => call.action === "budget.soft_threshold_crossed")).toHaveLength(1);
expect(telemetryCalls.filter((call) => call.action === "budget.hard_threshold_crossed")).toHaveLength(1);
expect(telemetryCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "budget.soft_threshold_crossed",
entityType: "budget_incident",
details: expect.objectContaining({
scopeType: "agent",
scopeId: agentId,
amountObserved: 80,
amountLimit: 100,
}),
}),
expect.objectContaining({
action: "budget.hard_threshold_crossed",
entityType: "budget_incident",
details: expect.objectContaining({
scopeType: "agent",
scopeId: agentId,
amountObserved: 105,
amountLimit: 100,
approvalId: approval!.id,
}),
}),
]),
);
for (const call of telemetryCalls) {
expect(JSON.stringify(call.details)).not.toContain("SECRET_TOKEN_SHOULD_NOT_LEAK");
expect(call.details).not.toHaveProperty("prompt");
expect(call.details).not.toHaveProperty("message");
}
});
it("hard-stops project work until a valid budget raise resumes it and overview reconciles ledger spend", async () => {
const { companyId, agentId, projectId } = await createBudgetFixture();
const cancelWorkForScope = vi.fn().mockResolvedValue(undefined);
const service = budgetService(db, { cancelWorkForScope });
await db.insert(budgetPolicies).values({
companyId,
scopeType: "project",
scopeId: projectId,
metric: "billed_cents",
windowKind: "lifetime",
amount: 100,
warnPercent: 75,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
});
const event = await insertCostEvent({ companyId, agentId, projectId, costCents: 125 });
await service.evaluateCostEvent(event);
await service.evaluateCostEvent(event);
const incidentRows = await db
.select()
.from(budgetIncidents);
expect(incidentRows.filter((incident) => incident.thresholdType === "hard")).toHaveLength(1);
const hardIncident = incidentRows.find((incident) => incident.thresholdType === "hard")!;
expect(hardIncident).toMatchObject({
companyId,
scopeType: "project",
scopeId: projectId,
amountLimit: 100,
amountObserved: 125,
status: "open",
});
const [projectAfterHardStop] = await db
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
.from(projects);
expect(projectAfterHardStop?.pauseReason).toBe("budget");
expect(projectAfterHardStop?.pausedAt).toBeInstanceOf(Date);
expect(cancelWorkForScope).toHaveBeenCalledWith({ companyId, scopeType: "project", scopeId: projectId });
const overviewWhileBlocked = await service.overview(companyId);
expect(overviewWhileBlocked.pausedProjectCount).toBe(1);
expect(overviewWhileBlocked.pendingApprovalCount).toBe(1);
expect(overviewWhileBlocked.policies[0]).toMatchObject({
scopeType: "project",
scopeId: projectId,
amount: 100,
observedAmount: 125,
remainingAmount: 0,
utilizationPercent: 125,
status: "hard_stop",
paused: true,
pauseReason: "budget",
});
expect(overviewWhileBlocked.activeIncidents).toHaveLength(1);
await expect(
service.resolveIncident(
companyId,
hardIncident.id,
{ action: "raise_budget_and_resume", amount: 125 },
"board-user",
),
).rejects.toThrow("New budget must exceed current observed spend");
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toEqual({
scopeType: "project",
scopeId: projectId,
scopeName: "Budget Project",
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
});
const resolved = await service.resolveIncident(
companyId,
hardIncident.id,
{ action: "raise_budget_and_resume", amount: 175, decisionNote: "Approved release-gate budget raise." },
"board-user",
);
expect(resolved).toMatchObject({ status: "resolved", approvalStatus: "approved" });
const [projectAfterResume] = await db
.select({ pauseReason: projects.pauseReason, pausedAt: projects.pausedAt })
.from(projects);
expect(projectAfterResume).toEqual({ pauseReason: null, pausedAt: null });
expect(await service.getInvocationBlock(companyId, agentId, { projectId })).toBeNull();
const overviewAfterResume = await service.overview(companyId);
expect(overviewAfterResume.pausedProjectCount).toBe(0);
expect(overviewAfterResume.pendingApprovalCount).toBe(0);
expect(overviewAfterResume.policies[0]).toMatchObject({
scopeType: "project",
scopeId: projectId,
amount: 175,
observedAmount: 125,
remainingAmount: 50,
utilizationPercent: 71.43,
status: "ok",
paused: false,
pauseReason: null,
});
expect(overviewAfterResume.activeIncidents).toHaveLength(0);
});
});
@@ -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");
});
});
@@ -300,6 +300,30 @@ function createRunContextDb(
};
}
function createWorkProductRuntimeLinkDb(input: {
runtimeServices?: Record<string, unknown>[];
executionWorkspaces?: Record<string, unknown>[];
projects?: Record<string, unknown>[];
} = {}) {
const rowsForSelection = (selection: Record<string, unknown>) => {
const keys = Object.keys(selection);
if (keys.includes("runtimeServiceId")) return input.runtimeServices ?? [];
if (keys.includes("executionWorkspaceId")) return input.executionWorkspaces ?? [];
if (keys.includes("projectId")) return input.projects ?? [];
return [];
};
return {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
then: async (resolve: (rows: unknown[]) => unknown) => resolve(rowsForSelection(selection)),
})),
})),
})),
};
}
async function createApp(actor: Record<string, unknown>, db?: unknown) {
const routeDb = db ?? createRunContextDb(
{},
@@ -980,6 +1004,83 @@ describe("agent issue mutation checkout ownership", () => {
);
});
it("normalizes runtime service work product links to the execution workspace and project", async () => {
const projectId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const executionWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const runtimeServiceId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
mockIssueService.getById.mockResolvedValue(makeIssue({ projectId }));
const app = await createApp(boardActor(), createWorkProductRuntimeLinkDb({
runtimeServices: [{
runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
scopeId: executionWorkspaceId,
}],
executionWorkspaces: [{
executionWorkspaceId,
companyId,
projectId,
}],
}));
const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "runtime_service",
provider: "paperclip",
title: "Preview service",
runtimeServiceId,
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith(
issueId,
companyId,
expect.objectContaining({
projectId,
executionWorkspaceId,
runtimeServiceId,
}),
);
});
it("rejects runtime service work product links that point at another execution workspace", async () => {
const projectId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const executionWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const otherExecutionWorkspaceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const runtimeServiceId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
mockIssueService.getById.mockResolvedValue(makeIssue({ projectId }));
const app = await createApp(boardActor(), createWorkProductRuntimeLinkDb({
runtimeServices: [{
runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
scopeId: executionWorkspaceId,
}],
executionWorkspaces: [{
executionWorkspaceId: otherExecutionWorkspaceId,
companyId,
projectId,
}],
}));
const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "runtime_service",
provider: "paperclip",
title: "Preview service",
runtimeServiceId,
executionWorkspaceId: otherExecutionWorkspaceId,
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.error).toBe("Runtime service must belong to the referenced execution workspace");
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
});
it("rejects agent-created work products with a forged run id", async () => {
const app = await createApp(ownerActor());
@@ -0,0 +1,27 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const pluginLoaderPath = path.resolve(import.meta.dirname, "../adapters/plugin-loader.ts");
describe("external adapter plugin loader", () => {
it("keeps adapter loading dynamic instead of hardcoding external adapter imports", () => {
const source = fs.readFileSync(pluginLoaderPath, "utf8");
const importSpecifiers = Array.from(
source.matchAll(/^\s*import(?:[\s\S]*?\sfrom\s+)?["']([^"']+)["'];?$/gm),
(match) => match[1],
);
expect(importSpecifiers).toEqual([
"node:fs",
"node:path",
"./types.js",
"../middleware/logger.js",
"../services/adapter-plugin-store.js",
"../services/adapter-plugin-store.js",
]);
expect(source).toContain("await import(modulePath)");
expect(importSpecifiers.filter((specifier) => specifier.startsWith("../adapters/"))).toEqual([]);
expect(importSpecifiers.filter((specifier) => specifier.startsWith("@paperclipai/"))).toEqual([]);
});
});
@@ -1,10 +1,46 @@
import { describe, expect, it } from "vitest";
import {
createPluginSecretsHandler,
extractSecretRefPathsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
describe("createPluginSecretsHandler", () => {
it("ignores UUID-looking metadata outside schema-declared secret-ref paths", () => {
const secretRef = "77777777-7777-4777-8777-777777777777";
const refs = extractSecretRefPathsFromConfig(
{
credentials: {
apiKey: secretRef,
},
metadata: {
leakedLookingUuid: "88888888-8888-4888-8888-888888888888",
},
},
{
type: "object",
properties: {
credentials: {
type: "object",
properties: {
apiKey: { type: "string", format: "secret-ref" },
},
},
metadata: {
type: "object",
properties: {
leakedLookingUuid: { type: "string" },
},
},
},
},
);
expect(Array.from(refs.entries())).toEqual([
[secretRef, new Set(["credentials.apiKey"])],
]);
});
it("fails closed for plugin secret resolution until company scoping lands", async () => {
const handler = createPluginSecretsHandler({
db: {} as never,
@@ -0,0 +1,254 @@
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",
});
});
});
@@ -6,6 +6,7 @@ You are an agent at Paperclip company.
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
- Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
- When your work produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path.
- When your work produces or updates an operator-facing engineering output, create/update the matching work product: `pull_request` for opened PRs, `preview_url` for published previews, `runtime_service` for managed preview/dev services, `commit` for notable pushed commits, and `branch` when the branch itself is the handoff. A comment is not a substitute for the work product access path.
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
+201 -1
View File
@@ -20,7 +20,9 @@ import {
pipelineCases,
pipelineStages,
pipelines,
projects,
projectWorkspaces,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
addIssueCommentSchema,
@@ -1750,6 +1752,194 @@ export function issueRoutes(
});
}
type WorkProductLinkedIssue = {
id: string;
companyId: string;
projectId: string | null;
};
type WorkProductLinkFields = {
projectId?: string | null;
executionWorkspaceId?: string | null;
runtimeServiceId?: string | null;
};
type CurrentWorkProductLinkFields = {
projectId: string | null;
executionWorkspaceId: string | null;
runtimeServiceId: string | null;
};
function hasOwn(input: object, key: string) {
return Object.prototype.hasOwnProperty.call(input, key);
}
async function normalizeWorkProductRuntimeLinks(input: {
issue: WorkProductLinkedIssue;
next: WorkProductLinkFields;
current?: CurrentWorkProductLinkFields | null;
mode: "create" | "update";
}): Promise<WorkProductLinkFields> {
const projectIdChanged = hasOwn(input.next, "projectId");
const executionWorkspaceIdChanged = hasOwn(input.next, "executionWorkspaceId");
const runtimeServiceIdChanged = hasOwn(input.next, "runtimeServiceId");
const hasLinkChange = projectIdChanged || executionWorkspaceIdChanged || runtimeServiceIdChanged;
if (input.mode === "update" && !hasLinkChange) return {};
let projectId = projectIdChanged
? input.next.projectId ?? null
: input.current?.projectId ?? input.issue.projectId ?? null;
let executionWorkspaceId = executionWorkspaceIdChanged
? input.next.executionWorkspaceId ?? null
: input.current?.executionWorkspaceId ?? null;
const runtimeServiceId = runtimeServiceIdChanged
? input.next.runtimeServiceId ?? null
: input.current?.runtimeServiceId ?? null;
let runtimeService: {
runtimeServiceId: string;
companyId: string;
projectId: string | null;
executionWorkspaceId: string | null;
issueId: string | null;
scopeType: string;
scopeId: string | null;
} | null = null;
if (runtimeServiceId) {
runtimeService = await db
.select({
runtimeServiceId: workspaceRuntimeServices.id,
companyId: workspaceRuntimeServices.companyId,
projectId: workspaceRuntimeServices.projectId,
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
issueId: workspaceRuntimeServices.issueId,
scopeType: workspaceRuntimeServices.scopeType,
scopeId: workspaceRuntimeServices.scopeId,
})
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
.then((rows) => rows[0] ?? null);
if (!runtimeService || runtimeService.companyId !== input.issue.companyId) {
throw unprocessable("Runtime service must belong to the same company as the issue", {
code: "invalid_work_product_runtime_link",
runtimeServiceId,
});
}
if (
runtimeService.scopeType === "issue" &&
(runtimeService.issueId ?? runtimeService.scopeId) &&
(runtimeService.issueId ?? runtimeService.scopeId) !== input.issue.id
) {
throw unprocessable("Issue-scoped runtime service must be linked to the same issue", {
code: "invalid_work_product_runtime_link",
runtimeServiceId,
issueId: input.issue.id,
});
}
if (!executionWorkspaceId && runtimeService.executionWorkspaceId) {
executionWorkspaceId = runtimeService.executionWorkspaceId;
}
if (!projectId && runtimeService.projectId) {
projectId = runtimeService.projectId;
}
}
let workspace: {
executionWorkspaceId: string;
companyId: string;
projectId: string;
} | null = null;
if (executionWorkspaceId) {
workspace = await db
.select({
executionWorkspaceId: executionWorkspaces.id,
companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace || workspace.companyId !== input.issue.companyId) {
throw unprocessable("Execution workspace must belong to the same company as the issue", {
code: "invalid_work_product_runtime_link",
executionWorkspaceId,
});
}
if (!projectId) {
projectId = workspace.projectId;
}
}
if (
runtimeService?.executionWorkspaceId &&
executionWorkspaceId &&
runtimeService.executionWorkspaceId !== executionWorkspaceId
) {
throw unprocessable("Runtime service must belong to the referenced execution workspace", {
code: "invalid_work_product_runtime_link",
runtimeServiceId,
executionWorkspaceId,
});
}
if (runtimeService?.projectId && projectId && runtimeService.projectId !== projectId) {
throw unprocessable("Runtime service project must match the work product project", {
code: "invalid_work_product_runtime_link",
runtimeServiceId,
projectId,
});
}
if (workspace?.projectId && projectId && workspace.projectId !== projectId) {
throw unprocessable("Execution workspace project must match the work product project", {
code: "invalid_work_product_runtime_link",
executionWorkspaceId,
projectId,
});
}
if (input.issue.projectId && projectId && input.issue.projectId !== projectId) {
throw unprocessable("Work product project must match the issue project", {
code: "invalid_work_product_runtime_link",
issueProjectId: input.issue.projectId,
projectId,
});
}
const projectProvenByIssue = Boolean(projectId && projectId === input.issue.projectId);
const projectProvenByWorkspace = Boolean(projectId && workspace?.projectId === projectId);
const projectProvenByRuntimeService = Boolean(projectId && runtimeService?.projectId === projectId);
if (projectId && !projectProvenByIssue && !projectProvenByWorkspace && !projectProvenByRuntimeService) {
const project = await db
.select({
projectId: projects.id,
companyId: projects.companyId,
})
.from(projects)
.where(eq(projects.id, projectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== input.issue.companyId) {
throw unprocessable("Work product project must belong to the same company as the issue", {
code: "invalid_work_product_runtime_link",
projectId,
});
}
}
const normalized: WorkProductLinkFields = {};
if (input.mode === "create" || projectIdChanged || (hasLinkChange && projectId !== input.current?.projectId)) {
normalized.projectId = projectId;
}
if (
input.mode === "create" ||
executionWorkspaceIdChanged ||
(hasLinkChange && executionWorkspaceId !== input.current?.executionWorkspaceId)
) {
normalized.executionWorkspaceId = executionWorkspaceId;
}
if (input.mode === "create" || runtimeServiceIdChanged) {
normalized.runtimeServiceId = runtimeServiceId;
}
return normalized;
}
async function assertIssueEnvironmentSelection(
companyId: string,
environmentId: string | null | undefined,
@@ -4625,9 +4815,13 @@ export function issueRoutes(
const actor = getActorInfo(req);
const createInput = {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
sourceTrust: await sourceTrustForActorWrite(issue, actor),
};
Object.assign(createInput, await normalizeWorkProductRuntimeLinks({
issue,
next: createInput,
mode: "create",
}));
const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, issue.companyId, req.body, "create");
if (createdByRunId === undefined) return;
createInput.createdByRunId = createdByRunId;
@@ -4827,6 +5021,12 @@ export function issueRoutes(
const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, existing.companyId, req.body, "update");
if (createdByRunId === undefined && Object.prototype.hasOwnProperty.call(req.body, "createdByRunId")) return;
if (createdByRunId !== undefined) patch.createdByRunId = createdByRunId;
Object.assign(patch, await normalizeWorkProductRuntimeLinks({
issue,
next: patch,
current: existing,
mode: "update",
}));
if (requiresPaperclipAttachmentMetadata(patch, existing)) {
if (patch.metadata !== undefined) {
patch.metadata = await canonicalizePaperclipArtifactMetadata({
+8 -7
View File
@@ -365,7 +365,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
),
)
.then((rows) => rows[0] ?? null);
if (existing) return existing;
if (existing) return { incident: existing, created: false };
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const payload = buildApprovalPayload({
@@ -392,7 +392,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
.then((rows) => rows[0] ?? null)
: null;
return db
const incident = await db
.insert(budgetIncidents)
.values({
companyId: policy.companyId,
@@ -411,6 +411,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
})
.returning()
.then((rows) => rows[0] ?? null);
return incident ? { incident, created: true } : null;
}
async function resolveOpenSoftIncidents(policyId: string) {
@@ -671,14 +672,14 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
if (policy.notifyEnabled && observedAmount >= softThreshold) {
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
if (softIncident) {
if (softIncident?.created) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.soft_threshold_crossed",
entityType: "budget_incident",
entityId: softIncident.id,
entityId: softIncident.incident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
@@ -693,20 +694,20 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
await resolveOpenSoftIncidents(policy.id);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
await pauseAndCancelScopeForBudget(policy);
if (hardIncident) {
if (hardIncident?.created) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.hard_threshold_crossed",
entityType: "budget_incident",
entityId: hardIncident.id,
entityId: hardIncident.incident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amountObserved: observedAmount,
amountLimit: policy.amount,
approvalId: hardIncident.approvalId ?? null,
approvalId: hardIncident.incident.approvalId ?? null,
},
});
}
+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
@@ -103,6 +103,7 @@ import {
releaseRuntimeServicesForRun,
type ExecutionWorkspaceInput,
type RealizedExecutionWorkspace,
type RuntimeServiceRef,
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
@@ -172,6 +173,7 @@ import {
type AgentOrgRow,
} from "./agent-invokability.js";
import {
buildLowTrustSourceTrust,
redactQuarantinedBodyForHigherTrust,
sanitizeQuarantinedCommentForHigherTrust,
} from "./source-trust.js";
@@ -217,6 +219,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;
@@ -9453,6 +9456,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;
@@ -10543,6 +10553,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",
@@ -10612,6 +10659,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 {
@@ -10771,6 +10819,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(
@@ -10901,6 +10950,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,498 @@
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),
));
}
if (existing) {
await tx
.update(issueWorkProducts)
.set(values)
.where(eq(issueWorkProducts.id, existing.id));
return "updated" as const;
}
await tx
.insert(issueWorkProducts)
.values({
...values,
createdByRunId: input.runId,
createdAt: now,
});
return "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()