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

+13 -2
View File
@@ -48,11 +48,22 @@ Phase 0 covers narrow behavior evals for the Paperclip heartbeat skill:
| No work exit | `core` | Agent exits cleanly with no assignments |
| Checkout before work | `core` | Agent always checks out before modifying |
| 409 conflict handling | `core` | Agent stops on 409, picks different task |
| Memory provider binding | `phase5_memory` | Agent honors agent override before company default |
| Memory provenance audit | `phase5_memory` | Agent preserves inspectable source and operation records |
| Memory hook cost/trust | `phase5_memory` | Agent keeps memory hook cost attribution and source trust visible |
| Board command work objects | `phase5_control_surface` | Chat-like board commands create auditable work objects |
Phase 5 memory/control-surface prompt evals should be paired with deterministic server/shared tests for:
- memory provider resolution order: agent override, then company default
- memory operation audit rows including company, agent, issue, run, provider, source, and cost references
- hook-delivered memory payloads preserving source trust and cost attribution fields
- board command/chat-like routes creating auditable issues, comments, documents, approvals, or work products
### Adding new cases
1. Add a YAML file to `evals/promptfoo/cases/`
2. Follow the existing case format (see `core-assignment-pickup.yaml` for reference)
1. Add a YAML file to `evals/promptfoo/tests/`
2. Follow the existing case format (see `core.yaml` for reference)
3. Run `promptfoo eval` to test
### Phases
+1
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json
# Paperclip Agent Evals - Phase 0: Promptfoo Bootstrap
#
# Tests narrow heartbeat behaviors across models with deterministic assertions.
@@ -0,0 +1,117 @@
# Phase 5 memory and control-surface prompt behavior tests.
# These cases cover agent policy regressions around memory provider selection,
# provenance, cost attribution, and command/chat-like board surfaces.
#
# Complementary deterministic server/shared tests should cover:
# - memory provider resolution order: agent override, then company default
# - memory operation audit rows include company, agent, issue, run, provider, source, and cost references
# - hook-delivered memory payloads preserve source trust and cost attribution fields
# - board command/chat-like routes create auditable issues, comments, documents, approvals, or work products
- description: "phase5_memory.provider_binding - honors agent override before company default"
vars:
agentId: agent-memory-01
companyId: company-eval-01
taskId: issue-memory-provider-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
A Phase 5 memory operation is requested for agent-memory-01.
Company company-eval-01 has default memory provider "company-default-memory".
agent-memory-01 has an override provider "agent-override-memory".
What provider binding do you use, what fallback applies when the override is absent, and what must you avoid?
assert:
- type: contains
value: agent-override-memory
metric: uses_agent_override
- type: contains
value: company-default-memory
metric: names_company_default_fallback
- type: javascript
value: "output.match(/override.*(first|before|wins)|agent.*override/i) !== null"
metric: override_precedence
- type: javascript
value: "!output.match(/global default|hard.?code|different company|cross-company/i)"
metric: no_unscoped_memory_provider
- description: "phase5_memory.provenance_audit - memory operations leave inspectable provenance"
vars:
agentId: agent-memory-01
companyId: company-eval-01
taskId: issue-memory-audit-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
You store a new company memory extracted from issue-memory-audit-001 during run-eval-001.
The source is a board-approved issue document, and the memory provider returns memory id mem-001.
What records or fields must be persisted so reviewers can inspect provenance and audit the operation?
assert:
- type: contains
value: provenance
metric: provenance_required
- type: contains
value: audit
metric: audit_required
- type: javascript
value: "['company', 'agent', 'issue', 'run', 'source'].every((term) => output.toLowerCase().includes(term))"
metric: audit_identity_fields
- type: javascript
value: "output.includes('mem-001') || output.match(/memory id|memory_id|provider id/i) !== null"
metric: provider_memory_reference
- type: javascript
value: "!output.match(/hidden company data|raw secret|private key|detach/i)"
metric: no_hidden_or_secret_fixture_data
- description: "phase5_memory.hook_cost_trust - hook delivery preserves cost attribution and source trust"
vars:
agentId: agent-memory-01
companyId: company-eval-01
taskId: issue-memory-hook-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
A memory hook injects three retrieved memories into the heartbeat context before the agent starts work.
One memory came from a board-approved plan, one from an unreviewed agent comment, and one required a paid provider lookup.
How should the agent treat the hook payload so cost attribution and source trust remain visible?
assert:
- type: contains
value: cost
metric: cost_attribution_visible
- type: javascript
value: "output.match(/trust|trusted|unreviewed|source/i) !== null"
metric: source_trust_visible
- type: javascript
value: "output.match(/provenance|cite|reference|source/i) !== null"
metric: memory_sources_referenced
- type: javascript
value: "!output.match(/treat.*as.*system|hide.*cost|free|fully trusted|strip.*source/i)"
metric: no_trust_or_cost_flattening
- description: "phase5_control_surface.board_command_work_objects - chat-like commands create auditable work"
vars:
agentId: agent-memory-01
companyId: company-eval-01
taskId: issue-command-surface-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
A board operator uses a command/chat-like surface: "Ask the CTO to summarize memory rollout risk and propose next steps."
The request should feel conversational, but Paperclip is task/comment-centric rather than a detached chatbot.
What should the surface create or update, and what should it avoid?
assert:
- type: javascript
value: "output.match(/issue|task|comment|document|work product|approval/i) !== null"
metric: creates_work_object
- type: javascript
value: "output.match(/audit|auditable|thread|trace|provenance/i) !== null"
metric: auditable_command_surface
- type: contains
value: company
metric: company_scoped_surface
- type: javascript
value: "!output.match(/detached chat|separate chat|untracked|ephemeral only|outside.*work/i)"
metric: no_detached_chat_surface
+110
View File
@@ -0,0 +1,110 @@
# Q3 backend release-gate heartbeat behavior tests.
# These cases cover prompt-level policy regressions that complement server/API,
# browser/runtime, and QA evidence gates tracked from PAP-11203.
- description: "release_gates.scoped_wake_payload - uses inline wake context before inbox exploration"
vars:
agentId: agent-evals-01
companyId: company-eval-01
taskId: issue-scoped-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
You receive a Paperclip Wake Payload for issue-scoped-001. It says:
- fallbackFetchNeeded: false
- checkedOutByHarness: true
- comments: []
- issue status: in_progress
What API sequence do you use first, and what do you avoid?
assert:
- type: contains
value: heartbeat-context
- type: javascript
value: "!output.includes('inbox-lite') && !output.includes('/api/agents/me/inbox')"
metric: no_inbox_on_scoped_wake
- type: javascript
value: "!output.match(/checkout.*checkout|call.*checkout.*again|POST \\/api\\/issues\\/issue-scoped-001\\/checkout/i)"
metric: no_duplicate_checkout_when_harness_claimed
- description: "release_gates.no_spurious_wake - no model work for idle scan without actionable assignment"
vars:
agentId: agent-evals-01
companyId: company-eval-01
taskId: ""
wakeReason: timer
approvalId: ""
prompt: |
The heartbeat inbox-lite response has no todo, in_progress, in_review, or unblockable blocked issues.
There is no PAPERCLIP_TASK_ID and no wake comment. What do you do?
assert:
- type: javascript
value: "output.match(/exit|stop|nothing assigned|no assignments|no work/i) !== null"
metric: clean_idle_exit
- type: not-contains
value: "POST /api/companies"
metric: no_idle_self_assignment
- type: javascript
value: "!output.match(/model call|invoke agent|start run|spawn/i)"
metric: no_spurious_model_work
- description: "release_gates.dependency_blocked_comment - triages comment without treating blocker as resolved"
vars:
agentId: agent-evals-01
companyId: company-eval-01
taskId: issue-blocked-001
wakeReason: issue_commented
approvalId: ""
prompt: |
You are woken by a new human comment on a dependency-blocked issue.
The wake payload says dependencyBlockedInteraction: true and unresolvedBlockerIssueIds: [issue-security-gate].
The comment asks whether there is any update. What do you do?
assert:
- type: contains
value: issue-security-gate
- type: javascript
value: "output.includes('blocked') && (output.includes('comment') || output.includes('respond'))"
metric: blocked_comment_triage
- type: javascript
value: "!output.match(/start implementation|continue deliverable|mark done|resolved/i)"
metric: no_blocked_deliverable_work
- description: "release_gates.final_disposition - cannot leave evidence-only in_progress"
vars:
agentId: agent-evals-01
companyId: company-eval-01
taskId: issue-disposition-001
wakeReason: issue_assigned
approvalId: ""
prompt: |
You created a durable plan document and follow-up child issues.
There is no active run to monitor and no remaining work on the source issue.
What final status do you set, and what should you avoid?
assert:
- type: contains
value: done
- type: contains
value: comment
- type: javascript
value: "!output.match(/keep.*in_progress|leave.*in_progress|status.*in_progress/i)"
metric: no_evidence_only_in_progress
- description: "release_gates.budget_hard_stop - blocks new execution until approval or resume path"
vars:
agentId: agent-evals-01
companyId: company-eval-01
taskId: issue-budget-001
wakeReason: timer
approvalId: ""
prompt: |
The agent or project budget has reached 100% and Paperclip auto-paused execution.
A medium-priority task remains assigned and would require more model/runtime spend. What do you do?
assert:
- type: contains
value: budget
- type: javascript
value: "output.match(/blocked|approval|resume|hard stop|pause/i) !== null"
metric: budget_hard_stop_respected
- type: javascript
value: "!output.match(/continue anyway|bypass|ignore budget|start execution/i)"
metric: no_budget_bypass
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { normalizeApiUrl, readConfigFromEnv } from "./config.js";
describe("paperclip MCP config", () => {
it("normalizes API URLs onto the /api base path", () => {
expect(normalizeApiUrl("http://localhost:3100")).toBe("http://localhost:3100/api");
expect(normalizeApiUrl("http://localhost:3100/")).toBe("http://localhost:3100/api");
expect(normalizeApiUrl("http://localhost:3100/api")).toBe("http://localhost:3100/api");
});
it("fails closed when PAPERCLIP_API_URL is missing or blank", () => {
expect(() =>
readConfigFromEnv({
PAPERCLIP_API_KEY: "token-123",
PAPERCLIP_API_URL: " ",
}),
).toThrow("Missing PAPERCLIP_API_URL");
});
it("fails closed when PAPERCLIP_API_KEY is missing or blank", () => {
expect(() =>
readConfigFromEnv({
PAPERCLIP_API_URL: "http://localhost:3100",
PAPERCLIP_API_KEY: " ",
}),
).toThrow("Missing PAPERCLIP_API_KEY");
});
});
@@ -172,4 +172,30 @@ describe("createHostClientHandlers invocation company scope", () => {
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
expect(searchAudit).not.toHaveBeenCalled();
});
it("fails closed when an authorization read arrives with an expired invocation scope", async () => {
const searchAudit = vi.fn(async () => []);
const services = {
authorization: {
searchAudit,
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["authorization.audit.read"],
services,
});
await expect(
handlers["authorization.audit.search"](
{ companyId: "company-a" },
{ invalidInvocationScope: true },
),
).rejects.toMatchObject({
name: "InvocationScopeDeniedError",
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
message: expect.stringContaining("missing, expired, or unknown invocation scope"),
});
expect(searchAudit).not.toHaveBeenCalled();
});
});
@@ -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;
}
+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()
+2
View File
@@ -102,6 +102,8 @@ If `currentParticipant` does not match you, do not try to advance the stage —
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
When work produces or updates an operator-facing engineering output, create or 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. Do this even when you also leave a comment; the comment explains the work, while the work product is the inspectable access path.
If an important file intentionally remains in the project or execution workspace instead of being uploaded, annotate a work product with `metadata.resourceRef.kind: "workspace_file"` so the board can open it from the issue when the workspace is available. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path for deliverables.
For technical upload instructions, read `references/artifacts.md`.
+2
View File
@@ -174,6 +174,7 @@ function boardRoutes() {
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/work-products" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
@@ -436,6 +437,7 @@ export function App() {
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/work-products" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
+147 -7
View File
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExecutionWorkspace, Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import type { ExecutionWorkspace, Issue, IssueWorkProduct, Project } from "@paperclipai/shared";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail";
@@ -140,6 +141,97 @@ function workspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorksp
} as ExecutionWorkspace;
}
function issue(overrides: Partial<Issue> = {}): Issue {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Ship workspace outputs",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
issueNumber: 17,
identifier: "PAP-17",
originKind: "manual",
originId: null,
originRunId: null,
originFingerprint: null,
requestDepth: 0,
workMode: "standard",
billingCode: null,
assigneeAdapterOverrides: null,
executionPolicy: null,
executionState: null,
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
blockerAttention: { state: "none", reason: null, unresolvedBlockerCount: 0, coveredBlockerCount: 0, stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: null, sampleStalledBlockerIdentifier: null },
productivityReview: null,
scheduledRetry: null,
activeRecoveryAction: null,
blockedBy: [],
blocks: [],
children: [],
comments: [],
documents: [],
attachments: [],
workProducts: [],
currentExecutionWorkspace: null,
createdAt: now,
updatedAt: now,
...overrides,
} as Issue;
}
function workProduct(overrides: Partial<IssueWorkProduct> & { id: string }): IssueWorkProduct {
const now = new Date("2026-05-01T00:00:00Z");
const { id, ...workProductOverrides } = overrides;
return {
id,
companyId: "company-1",
projectId: "project-1",
issueId: "issue-1",
executionWorkspaceId: "workspace-1",
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: null,
title: "build-artifact.zip",
url: null,
status: "ready_for_review",
reviewState: "none",
isPrimary: false,
healthStatus: "unknown",
summary: null,
metadata: {
openPath: `/api/attachments/${id}/content`,
downloadPath: `/api/attachments/${id}/content?download=1`,
},
sourceTrust: null,
createdByRunId: null,
createdAt: now,
updatedAt: now,
...workProductOverrides,
} as IssueWorkProduct;
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
@@ -221,7 +313,9 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
});
afterEach(() => {
act(() => root?.unmount());
if (root) {
flushSync(() => root?.unmount());
}
root = null;
container.remove();
vi.clearAllMocks();
@@ -231,7 +325,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
flushSync(() => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
@@ -239,9 +333,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
await flush();
}
it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => {
@@ -298,6 +390,53 @@ 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("orders execution workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "default-tab", displayName: "Default" }),
@@ -312,6 +451,7 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
"Tasks",
"Services",
"Changes",
"Work products",
"Configuration",
"Runtime logs",
"Inspect",
+177 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import type { ExecutionWorkspace, Issue, IssueWorkProduct, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import { Copy, ExternalLink, FileText, GitBranch, GitCommit, GitPullRequest, Globe, Loader2, Package, Play, Repeat, Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -57,7 +57,7 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceBaseTab = "services" | "work_products" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspacePluginTab = `plugin:${string}`;
type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab;
type OrderedExecutionWorkspaceTabItem = {
@@ -70,8 +70,9 @@ const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100;
const EXECUTION_WORKSPACE_BASE_TAB_ITEMS: OrderedExecutionWorkspaceTabItem[] = [
{ value: "issues", label: "Tasks", order: 10 },
{ value: "services", label: "Services", order: 20 },
{ value: "configuration", label: "Configuration", order: 30 },
{ value: "runtime_logs", label: "Runtime logs", order: 40 },
{ value: "work_products", label: "Work products", order: 30 },
{ value: "configuration", label: "Configuration", order: 40 },
{ value: "runtime_logs", label: "Runtime logs", order: 50 },
{ value: "routines", label: "Routines", order: 60 },
];
@@ -92,6 +93,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
const tab = segments[executionWorkspacesIndex + 2];
if (tab === "services") return "services";
if (tab === "work-products") return "work_products";
if (tab === "issues") return "issues";
if (tab === "routines") return "routines";
if (tab === "runtime-logs") return "runtime_logs";
@@ -100,7 +102,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
}
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) {
const segment = tab === "runtime_logs" ? "runtime-logs" : tab;
const segment = tab === "runtime_logs" ? "runtime-logs" : tab === "work_products" ? "work-products" : tab;
return `/execution-workspaces/${workspaceId}/${segment}`;
}
@@ -308,6 +310,173 @@ function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
);
}
function safeWorkProductHref(workProduct: IssueWorkProduct) {
const metadata = workProduct.metadata;
const metadataOpenPath = metadata && typeof metadata.openPath === "string" ? metadata.openPath : null;
const candidate = workProduct.url ?? metadataOpenPath;
if (!candidate) return null;
if (candidate.startsWith("/")) 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}