mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Skills Manager gives operators a reusable skill layer, but iteration still required manual edits, ad hoc prompts, and indirect run inspection. > - Skill authors need a focused workflow for editing skill files, saving representative test inputs, and running those inputs through an agent without exposing harness tasks as normal company work. > - The backend therefore needs durable test inputs, reusable run templates, hidden harness issues, scoped run execution, retention metadata, and read-containment rules around hidden work. > - The frontend needs a three-pane Studio that keeps skill files, saved inputs/templates, and run output/history visible together while preserving the existing design system and token rules. > - This pull request ships that Skill Studio surface end to end: database migrations, shared contracts, server APIs/services, hidden harness execution behavior, UI routes/components, and focused tests. > - The benefit is faster and safer skill iteration, with inspectable outputs and fewer ways for internal harness work to leak into normal task lists, costs, or adjacent read APIs. ## Linked Issues or Issue Description No public GitHub issue exists for this feature. Feature request summary: - Problem: Skill authors need to edit and test company skills in one place instead of switching between the skill detail page, task creation, run output, and manual prompt history. - Proposed solution: Add a Skill Studio workbench with saved inputs, reusable templates, hidden sandboxed test runs, live run status, output inspection, run history, rerun/delete controls, and frontmatter-aware editing. - Expected users: Paperclip operators and agent-company maintainers who create, fork, import, and tune skills. - Related public PRs: Supersedes #9205, which was replaced so the public PR branch name follows contributor policy. - Duplicate search: searched public GitHub issues and PRs for "Skill Studio"; no other active public issue or PR directly covers this feature. ## What Changed - Added database migrations for Skill Studio test inputs, test runs, test run retention, and reusable run templates. - Added shared Skill Studio types, validators, route helpers, frontmatter utilities, and status handling. - Added server services and routes for saved inputs, test runs, templates, reruns, terminal-run deletion, hidden harness issue execution, and run-detail hydration. - Strengthened hidden-issue read containment across issue-adjacent routes and cost rollups used by skill test harness work. - Added the Skill Studio UI with skill file editing, frontmatter editing, saved inputs, templates, run creation/cancel/rerun/delete flows, output rendering, history, route support, and responsive pane behavior. - Added focused backend, shared, and UI tests for the new APIs, routing logic, editor/run behavior, hidden-issue containment, and migration safety. - Rebased onto current `master`, removed the generated lockfile diff from the PR, and verified no workflow files are changed. ## Verification - [x] `pnpm --filter @paperclipai/db check:migrations` - [x] `pnpm check:token-gates` - [x] `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts server/src/__tests__/company-skill-test-runs-service.test.ts ui/src/lib/skill-studio.test.ts ui/src/pages/SkillStudio.test.tsx` — 5 files, 132 tests passed - [x] Greptile review on the latest PR head - [x] GitHub PR checks on the latest PR head ## Risks - Medium risk because this is a broad feature touching database schema, server orchestration, issue visibility, and a large UI surface. - Hidden harness issue containment is security-sensitive; this PR includes regression coverage for adjacent read paths and cost rollups. - The new migrations are additive and use idempotent guards where applicable, but deployed databases that previously tested draft migration numbers should still be checked carefully. - The UI depends on a new resizable panels package in `ui/package.json`; the lockfile is intentionally left to repository automation. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent with shell, git, and GitHub CLI tool use. Earlier feature commits include assistance from other Paperclip coding agents; this PR preparation, rebase, cleanup commit, and PR body were completed by OpenAI Codex in a Paperclip worktree. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
|
|
|
|
/**
|
|
* E2E: Signoff execution policy flow.
|
|
*
|
|
* Validates the full signoff lifecycle through the API and UI:
|
|
* 1. Create a company with executor + reviewer + approver agents
|
|
* 2. Create an issue with a two-stage execution policy (review → approval)
|
|
* 3. Executor marks done → issue routes to reviewer (in_review)
|
|
* 4. Reviewer approves → issue routes to approver
|
|
* 5. Approver approves → execution completes, issue marked done
|
|
* 6. Verify "changes requested" flow returns to executor
|
|
*
|
|
* Requires local_trusted deployment mode (set in playwright.config.ts webServer env).
|
|
*
|
|
* Agent auth flow:
|
|
* - Board request (local_trusted auto-auth) handles setup/teardown.
|
|
* - Agent-specific actions use API keys + heartbeat run IDs.
|
|
* - Reviewers/approvers invoke heartbeat runs (gets run IDs) then PATCH
|
|
* directly without checkout (checkout would force in_progress, breaking
|
|
* the in_review state the signoff policy requires).
|
|
*/
|
|
|
|
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
|
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
const COMPANY_NAME = `E2E-Signoff-${Date.now()}`;
|
|
|
|
interface AgentAuth {
|
|
agentId: string;
|
|
token: string;
|
|
keyId: string;
|
|
request: APIRequestContext;
|
|
}
|
|
|
|
interface TestContext {
|
|
companyId: string;
|
|
companyPrefix: string;
|
|
executor: AgentAuth;
|
|
reviewer: AgentAuth;
|
|
approver: AgentAuth;
|
|
boardRequest: APIRequestContext;
|
|
issueIds: string[];
|
|
}
|
|
|
|
interface IssueRunLockState {
|
|
assigneeAgentId: string | null;
|
|
checkoutRunId: string | null;
|
|
executionRunId: string | null;
|
|
}
|
|
|
|
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
|
|
async function createAgentRequest(token: string): Promise<APIRequestContext> {
|
|
return pwRequest.newContext({
|
|
baseURL: BASE_URL,
|
|
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
|
});
|
|
}
|
|
|
|
/** Invoke a heartbeat run for an agent, returning the run ID. */
|
|
async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promise<string> {
|
|
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`);
|
|
expect(res.ok()).toBe(true);
|
|
const run = await res.json();
|
|
return run.id;
|
|
}
|
|
|
|
async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise<IssueRunLockState> {
|
|
const res = await board.get(`${BASE_URL}/api/issues/${issueId}`);
|
|
expect(res.ok()).toBe(true);
|
|
const issue = await res.json();
|
|
return {
|
|
assigneeAgentId: issue.assigneeAgentId ?? null,
|
|
checkoutRunId: issue.checkoutRunId ?? null,
|
|
executionRunId: issue.executionRunId ?? null,
|
|
};
|
|
}
|
|
|
|
async function retryAgentPatchWithCurrentLockOnConflict(
|
|
board: APIRequestContext,
|
|
agent: AgentAuth,
|
|
issueId: string,
|
|
failedRes: Awaited<ReturnType<APIRequestContext["patch"]>>,
|
|
patchData: Record<string, unknown>,
|
|
) {
|
|
if (failedRes.status() !== 409) return failedRes;
|
|
const issueRunLock = await getIssueRunLockState(board, issueId);
|
|
if (issueRunLock.assigneeAgentId !== agent.agentId) return failedRes;
|
|
|
|
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId;
|
|
if (!lockedRunId) return failedRes;
|
|
|
|
const retryRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
headers: { "X-Paperclip-Run-Id": lockedRunId },
|
|
data: patchData,
|
|
});
|
|
return retryRes.ok() ? retryRes : failedRes;
|
|
}
|
|
|
|
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
|
|
async function agentPatch(
|
|
board: APIRequestContext,
|
|
agent: AgentAuth,
|
|
issueId: string,
|
|
data: Record<string, unknown>,
|
|
) {
|
|
const runId = await invokeHeartbeat(board, agent.agentId);
|
|
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
headers: { "X-Paperclip-Run-Id": runId },
|
|
data,
|
|
});
|
|
return res;
|
|
}
|
|
|
|
/** Checkout an issue as an agent, then PATCH it. Used for executor mark-done. */
|
|
async function agentCheckoutAndPatch(
|
|
board: APIRequestContext,
|
|
agent: AgentAuth,
|
|
issueId: string,
|
|
expectedStatuses: string[],
|
|
patchData: Record<string, unknown>,
|
|
) {
|
|
const runId = await invokeHeartbeat(board, agent.agentId);
|
|
const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
headers: { "X-Paperclip-Run-Id": runId },
|
|
data: patchData,
|
|
});
|
|
if (directPatchRes.ok()) return directPatchRes;
|
|
|
|
// Checkout (sets executionRunId so PATCH is allowed)
|
|
const checkoutRes = await agent.request.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
|
headers: { "X-Paperclip-Run-Id": runId },
|
|
data: { agentId: agent.agentId, expectedStatuses },
|
|
});
|
|
if (!checkoutRes.ok()) {
|
|
if (checkoutRes.status() === 409) {
|
|
const res = await retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, checkoutRes, patchData);
|
|
if (res.ok()) {
|
|
return res;
|
|
}
|
|
}
|
|
// If agent checkout fails (e.g. run expired), fall back to board checkout
|
|
// then PATCH with the agent's identity
|
|
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
|
data: { agentId: agent.agentId, expectedStatuses },
|
|
});
|
|
if (!boardCheckout.ok()) {
|
|
throw new Error(`Board checkout failed: ${await boardCheckout.text()}`);
|
|
}
|
|
// Board PATCH (executor mark-done triggers signoff regardless of actor)
|
|
const res = await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
data: patchData,
|
|
});
|
|
return res;
|
|
}
|
|
// PATCH with agent identity
|
|
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
headers: { "X-Paperclip-Run-Id": runId },
|
|
data: patchData,
|
|
});
|
|
return retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, res, patchData);
|
|
}
|
|
|
|
async function setupCompany(boardRequest: APIRequestContext): Promise<TestContext> {
|
|
// Verify server is in local_trusted mode
|
|
const healthRes = await boardRequest.get(`${BASE_URL}/api/health`);
|
|
expect(healthRes.ok()).toBe(true);
|
|
const health = await healthRes.json();
|
|
if (health.deploymentMode !== "local_trusted") {
|
|
throw new Error(
|
|
`Signoff e2e tests require local_trusted deployment mode, ` +
|
|
`but server is in "${health.deploymentMode}" mode. ` +
|
|
`Set PAPERCLIP_DEPLOYMENT_MODE=local_trusted or use the webServer config.`,
|
|
);
|
|
}
|
|
|
|
// Create company
|
|
const companyRes = await boardRequest.post(`${BASE_URL}/api/companies`, {
|
|
data: { name: COMPANY_NAME },
|
|
});
|
|
if (!companyRes.ok()) {
|
|
const errBody = await companyRes.text();
|
|
throw new Error(`POST /api/companies → ${companyRes.status()}: ${errBody}`);
|
|
}
|
|
const company = await companyRes.json();
|
|
const companyId = company.id;
|
|
const companyPrefix = company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E";
|
|
|
|
// Helper: hire/approve agent + API key + request context
|
|
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
|
|
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agent-hires`, {
|
|
data: {
|
|
name,
|
|
role,
|
|
title,
|
|
adapterType: "process",
|
|
adapterConfig: {
|
|
command: process.execPath,
|
|
args: ["-e", "process.stdout.write('done\\n')"],
|
|
},
|
|
},
|
|
});
|
|
expect(agentRes.ok()).toBe(true);
|
|
const hire = await agentRes.json();
|
|
const agent = hire.agent;
|
|
if (hire.approval) {
|
|
const approvalRes = await boardRequest.post(`${BASE_URL}/api/approvals/${hire.approval.id}/approve`, {
|
|
data: { decisionNote: "Approved for signoff e2e setup." },
|
|
});
|
|
expect(approvalRes.ok()).toBe(true);
|
|
}
|
|
|
|
const keyRes = await boardRequest.post(`${BASE_URL}/api/agents/${agent.id}/keys`, {
|
|
data: { name: `e2e-${name.toLowerCase()}` },
|
|
});
|
|
expect(keyRes.ok()).toBe(true);
|
|
const keyData = await keyRes.json();
|
|
|
|
return {
|
|
agentId: agent.id,
|
|
token: keyData.token,
|
|
keyId: keyData.id,
|
|
request: await createAgentRequest(keyData.token),
|
|
};
|
|
}
|
|
|
|
const executor = await createAgent("Executor", "engineer", "Software Engineer");
|
|
const reviewer = await createAgent("Reviewer", "qa", "QA Engineer");
|
|
const approver = await createAgent("Approver", "cto", "CTO");
|
|
|
|
return {
|
|
companyId,
|
|
companyPrefix,
|
|
executor,
|
|
reviewer,
|
|
approver,
|
|
boardRequest,
|
|
issueIds: [],
|
|
};
|
|
}
|
|
|
|
async function createIssueWithPolicy(ctx: TestContext, title: string, stages?: unknown[]) {
|
|
const defaultStages = [
|
|
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
|
|
{ type: "approval", participants: [{ type: "agent", agentId: ctx.approver.agentId }] },
|
|
];
|
|
const res = await ctx.boardRequest.post(`${BASE_URL}/api/companies/${ctx.companyId}/issues`, {
|
|
data: {
|
|
title,
|
|
status: "in_progress",
|
|
assigneeAgentId: ctx.executor.agentId,
|
|
executionPolicy: { stages: stages ?? defaultStages },
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const issue = await res.json();
|
|
ctx.issueIds.push(issue.id);
|
|
return issue;
|
|
}
|
|
|
|
test.describe("Signoff execution policy", () => {
|
|
let ctx: TestContext;
|
|
|
|
test.beforeAll(async () => {
|
|
const boardRequest = await pwRequest.newContext({ baseURL: BASE_URL });
|
|
ctx = await setupCompany(boardRequest);
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (!ctx) return;
|
|
const board = ctx.boardRequest;
|
|
|
|
// Dispose agent request contexts
|
|
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
|
|
await agent.request.dispose();
|
|
}
|
|
|
|
// Clean up issues, keys, agents, company (best-effort)
|
|
for (const issueId of ctx.issueIds) {
|
|
await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
|
data: { status: "cancelled", comment: "E2E test cleanup." },
|
|
}).catch(() => {});
|
|
}
|
|
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
|
|
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}/keys/${agent.keyId}`).catch(() => {});
|
|
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}`).catch(() => {});
|
|
}
|
|
await board.delete(`${BASE_URL}/api/companies/${ctx.companyId}`).catch(() => {});
|
|
await board.dispose();
|
|
});
|
|
|
|
test("happy path: executor → review → approval → done", async ({ page }) => {
|
|
const issue = await createIssueWithPolicy(ctx, "Signoff happy path");
|
|
const issueId = issue.id;
|
|
|
|
// Verify policy was saved
|
|
expect(issue.executionPolicy).toBeTruthy();
|
|
expect(issue.executionPolicy.stages).toHaveLength(2);
|
|
expect(issue.executionPolicy.stages[0].type).toBe("review");
|
|
expect(issue.executionPolicy.stages[1].type).toBe("approval");
|
|
|
|
// Step 1: Executor marks done → should route to reviewer
|
|
const step1Res = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
|
{ status: "done", comment: "Implemented the feature, ready for review." },
|
|
);
|
|
expect(step1Res.ok()).toBe(true);
|
|
const step1Issue = await step1Res.json();
|
|
|
|
expect(step1Issue.status).toBe("in_review");
|
|
expect(step1Issue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
|
expect(step1Issue.executionState).toBeTruthy();
|
|
expect(step1Issue.executionState.status).toBe("pending");
|
|
expect(step1Issue.executionState.currentStageType).toBe("review");
|
|
expect(step1Issue.executionState.returnAssignee).toMatchObject({
|
|
type: "agent",
|
|
agentId: ctx.executor.agentId,
|
|
});
|
|
|
|
// Step 2: Navigate to issue in UI and verify execution label
|
|
await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`);
|
|
await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Step 3: Reviewer approves → should route to approver
|
|
const step3Res = await agentPatch(
|
|
ctx.boardRequest, ctx.reviewer, issueId,
|
|
{ status: "done", comment: "QA signoff complete. Looks good." },
|
|
);
|
|
expect(step3Res.ok()).toBe(true);
|
|
const step3Issue = await step3Res.json();
|
|
|
|
expect(step3Issue.status).toBe("in_review");
|
|
expect(step3Issue.assigneeAgentId).toBe(ctx.approver.agentId);
|
|
expect(step3Issue.executionState.status).toBe("pending");
|
|
expect(step3Issue.executionState.currentStageType).toBe("approval");
|
|
expect(step3Issue.executionState.completedStageIds).toHaveLength(1);
|
|
|
|
// Step 4: Verify UI shows approval pending
|
|
await page.reload();
|
|
await expect(page.locator("text=Approval pending")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Step 5: Approver approves → should complete
|
|
const step5Res = await agentPatch(
|
|
ctx.boardRequest, ctx.approver, issueId,
|
|
{ status: "done", comment: "Approved. Ship it." },
|
|
);
|
|
expect(step5Res.ok()).toBe(true);
|
|
const step5Issue = await step5Res.json();
|
|
|
|
expect(step5Issue.status).toBe("done");
|
|
expect(step5Issue.executionState.status).toBe("completed");
|
|
expect(step5Issue.executionState.completedStageIds).toHaveLength(2);
|
|
expect(step5Issue.executionState.lastDecisionOutcome).toBe("approved");
|
|
});
|
|
|
|
test("changes requested: reviewer bounces back to executor", async () => {
|
|
const issue = await createIssueWithPolicy(ctx, "Signoff changes requested");
|
|
const issueId = issue.id;
|
|
|
|
// Executor marks done → routes to reviewer
|
|
const doneRes = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
|
{ status: "done", comment: "Ready for review." },
|
|
);
|
|
expect(doneRes.ok()).toBe(true);
|
|
expect((await doneRes.json()).status).toBe("in_review");
|
|
|
|
// Reviewer requests changes → returns to executor
|
|
const changesRes = await agentPatch(
|
|
ctx.boardRequest, ctx.reviewer, issueId,
|
|
{ status: "in_progress", comment: "Needs another pass on edge cases." },
|
|
);
|
|
expect(changesRes.ok()).toBe(true);
|
|
const changesIssue = await changesRes.json();
|
|
|
|
expect(changesIssue.status).toBe("in_progress");
|
|
expect(changesIssue.assigneeAgentId).toBe(ctx.executor.agentId);
|
|
expect(changesIssue.executionState.status).toBe("changes_requested");
|
|
expect(changesIssue.executionState.lastDecisionOutcome).toBe("changes_requested");
|
|
|
|
// Executor re-submits → goes back to reviewer (same stage)
|
|
const resubmitRes = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
|
{ status: "done", comment: "Fixed the edge cases." },
|
|
);
|
|
expect(resubmitRes.ok()).toBe(true);
|
|
const resubmitIssue = await resubmitRes.json();
|
|
|
|
expect(resubmitIssue.status).toBe("in_review");
|
|
expect(resubmitIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
|
expect(resubmitIssue.executionState.status).toBe("pending");
|
|
expect(resubmitIssue.executionState.currentStageType).toBe("review");
|
|
});
|
|
|
|
test("comment required: approval without comment fails", async () => {
|
|
const issue = await createIssueWithPolicy(ctx, "Signoff comment required");
|
|
const issueId = issue.id;
|
|
|
|
// Executor marks done → routes to reviewer
|
|
const doneRes = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
|
{ status: "done", comment: "Done." },
|
|
);
|
|
expect(doneRes.ok()).toBe(true);
|
|
const doneIssue = await doneRes.json();
|
|
expect(doneIssue.status).toBe("in_review");
|
|
expect(doneIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
|
|
|
// Reviewer tries to approve without comment → should fail
|
|
const noCommentRes = await agentPatch(
|
|
ctx.boardRequest, ctx.reviewer, issueId,
|
|
{ status: "done" },
|
|
);
|
|
expect(noCommentRes.ok()).toBe(false);
|
|
const errorBody = await noCommentRes.json();
|
|
expect(JSON.stringify(errorBody)).toContain("comment");
|
|
});
|
|
|
|
test("non-participant cannot advance stage", async () => {
|
|
const issue = await createIssueWithPolicy(ctx, "Signoff access control");
|
|
const issueId = issue.id;
|
|
|
|
// Executor marks done → routes to reviewer
|
|
const doneRes = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
|
{ status: "done", comment: "Done." },
|
|
);
|
|
expect(doneRes.ok()).toBe(true);
|
|
|
|
// Verify issue is in_review with reviewer
|
|
const issueRes = await ctx.boardRequest.get(`${BASE_URL}/api/issues/${issueId}`);
|
|
const inReviewIssue = await issueRes.json();
|
|
expect(inReviewIssue.status).toBe("in_review");
|
|
expect(inReviewIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
|
expect(inReviewIssue.executionState.currentStageType).toBe("review");
|
|
|
|
// Non-participant (approver at this stage) tries to advance → should be rejected
|
|
const advanceRes = await agentPatch(
|
|
ctx.boardRequest, ctx.approver, issueId,
|
|
{ status: "done", comment: "I'm the approver, not the reviewer." },
|
|
);
|
|
expect(advanceRes.ok()).toBe(false);
|
|
expect(advanceRes.status()).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test("review-only policy: reviewer approval completes execution", async () => {
|
|
const issue = await createIssueWithPolicy(ctx, "Signoff review-only", [
|
|
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
|
|
]);
|
|
|
|
// Executor marks done → routes to reviewer
|
|
const doneRes = await agentCheckoutAndPatch(
|
|
ctx.boardRequest, ctx.executor, issue.id, ["in_progress"],
|
|
{ status: "done", comment: "Ready for review." },
|
|
);
|
|
expect(doneRes.ok()).toBe(true);
|
|
expect((await doneRes.json()).status).toBe("in_review");
|
|
|
|
// Reviewer approves → should complete immediately (no approval stage)
|
|
const approveRes = await agentPatch(
|
|
ctx.boardRequest, ctx.reviewer, issue.id,
|
|
{ status: "done", comment: "LGTM." },
|
|
);
|
|
expect(approveRes.ok()).toBe(true);
|
|
const doneIssue = await approveRes.json();
|
|
expect(doneIssue.status).toBe("done");
|
|
expect(doneIssue.executionState.status).toBe("completed");
|
|
expect(doneIssue.executionState.completedStageIds).toHaveLength(1);
|
|
});
|
|
});
|