mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(issues): guard prepared task promotion by status version (#16893)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Integrations can prepare a backlog task before its assigned agent starts. > - A person can change its status or assignment during preparation. > - An unconditional promotion can replace that newer decision. > - Safe promotion needs a saved revision that includes every assignment path. > - This pull request checks that revision under the task row lock and derives policy cleanup from the locked row. > - The benefit is safe initialization recovery with the existing company, task, and run authority. ## Linked Issues or Issue Description Prerequisite #16896 has merged. This PR now targets master and contains only the conditional promotion API. The prerequisite fixed the confirmed baseline first-upload browser regression. **What happened?** A client that saves a task's initial documents and then sends an unconditional status update can override a concurrent status or assignment decision. The update validator previously dropped an expected version, so clients could not make this promotion conditional. Some direct assignment and release paths also did not advance the revision. **Expected behavior** A prepared task moves from backlog to todo only if its original status and assignment revision still matches. A stale request returns 409 before a task mutation, audit publication, or wake. Policy cleanup preserves unrelated saved settings. **Steps to reproduce** 1. Create a backlog task and save its statusVersion before preparing it. 2. Release its assignment, or change an assignee and then restore that assignee. 3. Send `{ "status": "todo", "expectedStatusVersion": <original revision> }` to its PATCH endpoint. 4. The stale promotion must return 409. A matching revision on an unchanged backlog task must succeed. Duplicate and related PR searches found no existing prepared-task promotion fix. ## What Changed - Accept an optional nonnegative expectedStatusVersion for exactly a status-only backlog-to-todo promotion. Reject extra public fields before parsing can discard them. - Check the current revision and backlog state under the issue row lock. Reject stale requests before checkout adoption or execution-control effects. - Add SQL migration 0358 to advance the revision for both assignee fields at the database boundary. Release, administrative release, direct writes, and assignment round trips now invalidate an old version. - Derive guarded policy cleanup from the locked row. Preserve review limits and protected authorization when removing a monitor. Reject derived ownership changes or review decisions. - Add schema, real HTTP/PostgreSQL, concurrency, migration replay, and policy regression coverage. Update the response type, OpenAPI, developer docs, and access map. ## Verification - Red before the initial fix: stale requests returned 200 instead of 409, validation removed the expected version, and a stale active task could adopt a checkout. - Review found two further defects. Four new release/direct-assignment tests showed unchanged revisions. The real HTTP/service test returned 422 for a valid two-field promotion after monitor cleanup. Helper tests also proved that cleanup lost valid review limits and protected authorization. All now pass after focused repairs. - Green: 124 route and policy tests, including 7 real Express/PostgreSQL cases, 37 existing route tests, and 80 policy/helper tests. The real row-lock race preserves a policy edit committed while promotion waits. - Green: all 23 database client tests, plus 9 focused assignment and replay checks. Historical migration 0227 receipt recovery cannot remove the new assignment trigger. Reapplying migration 0358 leaves existing rows unchanged and installs one trigger. Combined status/assignment writes increment once; unchanged assignees do not increment. - Earlier verification passed all 218 schema/route/service cases and the generated OpenAPI capability test. Rebased focused regressions passed all 27 selected cases. Final server TypeScript passed after all repairs; shared TypeScript passed. Migration safety/numbering, five-snapshot retention, map validation, and diff checks pass. - The baseline browser attachment case failed twice on the earlier head. Separate #16896 proved the lost-composer cause, passed 335 component tests and the real first-upload journey, and has green CI with a 5/5 review. No assertion or timeout was weakened. - Some early local checks were blocked by missing or stale sibling dependency artifacts. This worktree now has its own frozen offline install and standard SDK prerequisites. The failures and successful reruns remain in the task evidence. - Before rebasing, head `3304bb00de6ad6a248b5e046c008072ad20ac8f5` passed all CI (55 successful checks and one neutral), Greptile 5/5, and a secondary source review at 5/5 with no unresolved findings. The secondary reviewer did not execute project tests, inspect CI, or exercise a live agent journey. Earlier incomplete and must-fix assessments remain visible; both established findings are repaired above. - After #16896 merged, all five CAS commits were rebased onto master `87d9386ae637c652d1fed09d2046be1bcaeb413b`. Migration 0358 was regenerated because master added another 0357. Range comparison shows no other change to the CAS patches; inherited UI commits are absent. The original head is preserved locally. - Rebased head `1c0264e51a280b1dbf597725a7e42e72d4be1b1b` passes all 124 route/policy tests, 9 selected assignment/promotion tests, and all 24 database client/snapshot-drift tests. Database build, migration checks, generated snapshot linkage, five-snapshot retention (0354–0358), maps, and diff checks pass. Fresh-head CI and review are pending. No deployment or live agent journey was performed for this API. ## Risks - The guard supports prepared backlog promotion only. Other statuses or mixed public mutation fields return 422. A stale revision or non-backlog row returns 409. - Preserve the original version from a server that already advertises this guard before initialization. Older versions did not track every assignment path. Reconcile older or uncertain initialization instead of fetching a new revision and blindly retrying. - Migration 0358 is SQL-only and idempotent. It adds no table, column, or task backfill. Its separate trigger survives historical status-migration replay. - Existing authorization and unguarded updates remain in place. Guarded promotion does not provide a general lifecycle or review-stage control API. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context window are not exposed by this session. Used tool calls, code execution, and test-first regression verification. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
This commit is contained in:
1 parent
c145475cae
commit
7c58ea07b2
19 files changed
+5243
-4635
No files matched your search
+7
-4
@@ -494,10 +494,13 @@ is unique, so retries return the original accepted action. Provider source
|
||||
ordering fences duplicate and stale updates, and a cleared projection retains
|
||||
its revision/cursor tombstone so an older provider event cannot resurrect it.
|
||||
|
||||
Issue `status_version` advances only when `status` changes. The JavaScript backup
|
||||
path includes user-defined functions and triggers so a restored database keeps
|
||||
that invariant. Removing or disabling a future native rollout flag must not
|
||||
delete these records; persisted experimental runs remain available for recovery
|
||||
Issue `status_version` advances when `status`, `assignee_agent_id`, or
|
||||
`assignee_user_id` changes. A combined status and assignment write advances it
|
||||
once. Writing the same assignee alone does not advance it. A new blocking
|
||||
decision can also advance the revision. The assignment trigger has its own name so replay of the historical status migration cannot remove it. The
|
||||
JavaScript backup path includes user-defined functions and triggers so a restored
|
||||
database keeps that invariant. Removing or disabling a future native rollout
|
||||
flag must not delete these records; persisted experimental runs remain available for recovery
|
||||
and inspection.
|
||||
|
||||
`native_run_finalizations` also stores restart ownership and recovery state.
|
||||
|
||||
@@ -2291,6 +2291,28 @@ Team install/preview APIs enforce source policy. External skill sources require
|
||||
explicit approval flags, and local-path skill sources are development-only
|
||||
unless `allowLocalPathSources` is set by the caller.
|
||||
|
||||
## Starting a prepared task
|
||||
|
||||
An integration can create a task in `backlog`, save its initial documents, and
|
||||
then promote it with `PATCH /api/issues/{id}`. Send exactly
|
||||
`{ "status": "todo", "expectedStatusVersion": N }`, using the `statusVersion`
|
||||
from the task whose initialization the integration owns. Capture that version
|
||||
on a server that already advertises this guard, before preparing the task.
|
||||
Older servers did not track every assignment path; reconcile older initialization
|
||||
intents instead of promoting them automatically after an upgrade. The server checks the
|
||||
version and backlog state under the task row lock. A status or assignment change
|
||||
returns `409` with `details.code: "issue_status_version_changed"`; mixed mutation
|
||||
fields return `422`. The guard is optional and applies only to this promotion.
|
||||
It does not provide a general conditional lifecycle or execution-control API.
|
||||
Assignment changes advance the revision at the database boundary, including
|
||||
release, administrative release, and direct service writes. A no-op assignment
|
||||
keeps the revision. Migration 0358 adds this trigger without rewriting tasks.
|
||||
|
||||
Persist the original version with the integration's initialization intent so a
|
||||
later retry cannot override a person's intervening decision. A caller still needs
|
||||
the ordinary company, task, assignment and run permissions. Do not retry an
|
||||
uncertain write until the saved task and initialization state are reconciled.
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
|
||||
@@ -86,6 +86,16 @@ necessarily shared server preferences.
|
||||
|
||||
Review: `baseline`.
|
||||
|
||||
Agent REST callers can promote a prepared backlog task with
|
||||
`PATCH /api/issues/{id}` and exactly `{ "status": "todo", "expectedStatusVersion": N }`.
|
||||
The optional nonnegative integer version comes from the current task response.
|
||||
The server checks both the version and backlog state under the task row lock;
|
||||
status or ownership changes, including changes back to their earlier values,
|
||||
return `409` without promoting or waking the task. Mixed mutation fields return
|
||||
`422`. Existing company, task, assignment and run authority still apply.
|
||||
Route and real-PostgreSQL service tests cover the contract; no live agent journey
|
||||
or dedicated native tool is claimed.
|
||||
|
||||
API: `present` — representative operation IDs:
|
||||
|
||||
- `PATCH /api/issues/{id}`.
|
||||
|
||||
@@ -2132,6 +2132,24 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
||||
triggerCount: 1,
|
||||
finalizationCount: 1,
|
||||
}]);
|
||||
await replaySql`UPDATE issues SET assignee_agent_id = ${agentId} WHERE id = ${issueId}`;
|
||||
expect(await replaySql`SELECT status_version::int FROM issues WHERE id = ${issueId}`)
|
||||
.toEqual([{ status_version: 2 }]);
|
||||
await replaySql`UPDATE issues SET status = 'backlog', assignee_agent_id = null,
|
||||
assignee_user_id = 'handoff-owner' WHERE id = ${issueId}`;
|
||||
expect(await replaySql`SELECT status_version::int FROM issues WHERE id = ${issueId}`)
|
||||
.toEqual([{ status_version: 3 }]);
|
||||
const assignmentVersionHash = await migrationHash("0358_issue_assignment_status_version.sql");
|
||||
await replaySql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${assignmentVersionHash}`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
expect(await replaySql`SELECT status_version::int FROM issues WHERE id = ${issueId}`)
|
||||
.toEqual([{ status_version: 3 }]);
|
||||
expect(await replaySql`SELECT count(*)::int AS count FROM pg_trigger
|
||||
WHERE tgname = 'paperclip_issue_assignment_version_trigger' AND NOT tgisinternal`)
|
||||
.toEqual([{ count: 1 }]);
|
||||
await replaySql`UPDATE issues SET assignee_user_id = null WHERE id = ${issueId}`;
|
||||
expect(await replaySql`SELECT status_version::int FROM issues WHERE id = ${issueId}`)
|
||||
.toEqual([{ status_version: 4 }]);
|
||||
} finally {
|
||||
await replaySql.end();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE OR REPLACE FUNCTION paperclip_bump_issue_assignment_version()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."assignee_agent_id" IS DISTINCT FROM OLD."assignee_agent_id"
|
||||
OR NEW."assignee_user_id" IS DISTINCT FROM OLD."assignee_user_id" THEN
|
||||
NEW."status_version" := OLD."status_version" + 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS paperclip_issue_assignment_version_trigger ON "issues";--> statement-breakpoint
|
||||
CREATE TRIGGER paperclip_issue_assignment_version_trigger
|
||||
BEFORE UPDATE OF "assignee_agent_id", "assignee_user_id" ON "issues"
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_bump_issue_assignment_version();
|
||||
+4726
-4622
File diff suppressed because it is too large.
Load diff
+7
@@ -2493,6 +2493,13 @@
|
||||
"when": 1792439538702,
|
||||
"tag": "0357_naive_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 358,
|
||||
"version": "7",
|
||||
"when": 1792444400633,
|
||||
"tag": "0358_issue_assignment_status_version",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -839,6 +839,7 @@ export type IssueSummary = Pick<
|
||||
};
|
||||
|
||||
export interface Issue {
|
||||
statusVersion?: number;
|
||||
/** True only while the title is the provisional slice of the initial prompt. */
|
||||
titleNeedsGeneration?: boolean;
|
||||
conversationAgentId?: string | null;
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
import { createAgentSchema } from "./agent.js";
|
||||
|
||||
describe("issue validators", () => {
|
||||
it("retains an optional nonnegative integer version for prepared-task promotion", () => {
|
||||
expect(updateIssueSchema.parse({ status: "todo", expectedStatusVersion: 0 }).expectedStatusVersion).toBe(0);
|
||||
expect(updateIssueSchema.parse({}).expectedStatusVersion).toBeUndefined();
|
||||
for (const version of [-1, 0.5, null, "0"]) {
|
||||
expect(updateIssueSchema.safeParse({ status: "todo", expectedStatusVersion: version }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
it("preserves the exact expected execution policy snapshot without defaults", () => {
|
||||
const snapshot = { mode: "normal", stages: [], monitor: { nextCheckAt: "2026-10-10T12:00:00Z" } };
|
||||
expect(updateIssueSchema.parse({ expectedExecutionPolicy: snapshot }).expectedExecutionPolicy).toEqual(snapshot);
|
||||
|
||||
@@ -923,6 +923,7 @@ export const updateIssueSchema = objectWithoutDefaults(
|
||||
)
|
||||
.partial()
|
||||
.extend({
|
||||
expectedStatusVersion: z.number().int().nonnegative().optional(),
|
||||
/** Reject a policy write if the policy changed after the caller read it. */
|
||||
expectedExecutionPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
requestDepth: issueRequestDepthInputSchema.optional(),
|
||||
|
||||
@@ -301,6 +301,91 @@ describe("issue execution policy routes", () => {
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a stale status version before changing or waking a task", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1",
|
||||
status: "backlog", statusVersion: 8, title: "Prepared task", identifier: "TEST-1",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockResolvedValue({ ...issue, status: "todo", statusVersion: 9 });
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details).toMatchObject({ code: "issue_status_version_changed" });
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not adopt a checkout when a prepared task became active before promotion", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1",
|
||||
status: "in_progress", statusVersion: 8, title: "Active task", identifier: "TEST-1",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValueOnce({ adoptedFromRunId: "previous-run" });
|
||||
const res = await request(await createApp({
|
||||
type: "agent", agentId: issue.assigneeAgentId, companyId: "company-1",
|
||||
runId: "55555555-5555-4555-8555-555555555555",
|
||||
})).patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a matching status version to the locked update", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1",
|
||||
status: "backlog", statusVersion: 7, title: "Prepared task", identifier: "TEST-1",
|
||||
assigneeAgentId: null, assigneeUserId: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockResolvedValue({ ...issue, status: "todo", statusVersion: 8 });
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({
|
||||
status: "todo", expectedStatusVersion: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not audit or wake a promotion rejected by the locked version check", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1",
|
||||
status: "backlog", statusVersion: 7, title: "Prepared task", identifier: "TEST-1",
|
||||
assigneeAgentId: null, assigneeUserId: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
const { conflict } = await import("../errors.js");
|
||||
mockIssueService.update.mockRejectedValueOnce(conflict("The task changed", { code: "issue_status_version_changed" }));
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: 7 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details).toMatchObject({ code: "issue_status_version_changed" });
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "done" }, { status: "cancelled" }, { title: "Changed" },
|
||||
{ assigneeAgentId: "33333333-3333-4333-8333-333333333333" },
|
||||
{ comment: "Start work" }, { interrupt: true }, { executionPolicy: null },
|
||||
])("rejects mixed version-guarded promotions before execution effects: %j", async (fields) => {
|
||||
const res = await request(await createApp()).patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "todo", expectedStatusVersion: 0, ...fields });
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reauthorizes a terminal verdict against the review policy held under the update lock", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
|
||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, stripMonitorFromExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
||||
import { buildExecutionPolicy } from "../../../ui/src/lib/issue-execution-policy.ts";
|
||||
|
||||
@@ -9,6 +9,28 @@ const ctoAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const ctoUserId = "cto-user";
|
||||
const boardUserId = "board-user";
|
||||
|
||||
describe("stripMonitorFromExecutionPolicy", () => {
|
||||
it.each([
|
||||
{ stages: [] },
|
||||
{ stages: [{ type: "review", participants: [{ type: "user", userId: boardUserId }] }] },
|
||||
])("retains review limits and protected authorization after monitor cleanup with $stages.length stages", ({ stages }) => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
maxReviewRounds: 4,
|
||||
authorizationPolicy: { protectedAgent: { blockAssignment: true, blockReason: "Protected executor" } },
|
||||
stages,
|
||||
monitor: { nextCheckAt: "2099-01-01T00:00:00.000Z" },
|
||||
})!;
|
||||
const { monitor: _monitor, ...retainedPolicy } = policy;
|
||||
expect(stripMonitorFromExecutionPolicy(policy)).toEqual(retainedPolicy);
|
||||
});
|
||||
|
||||
it("removes an otherwise empty monitor-only policy", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({ monitor: { nextCheckAt: "2099-01-01T00:00:00.000Z" } });
|
||||
expect(stripMonitorFromExecutionPolicy(policy)).toBeNull();
|
||||
expect(stripMonitorFromExecutionPolicy(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function makePolicy(
|
||||
stages: Array<{ type: "review" | "approval"; participants: Array<{ type: "agent" | "user"; agentId?: string; userId?: string }> }>,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, issues } from "@paperclipai/db";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("guarded promotion route/service contract", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-status-version-route-");
|
||||
db = createDb(temporary.connectionString, { maxConnections: 4 });
|
||||
});
|
||||
afterAll(async () => { await temporary?.cleanup(); });
|
||||
|
||||
async function fixture() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Guarded promotion", issuePrefix: `V${companyId.slice(0, 6)}` });
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Paused executor", role: "engineer", adapterType: "process", status: "paused" }).returning();
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
maxReviewRounds: 4,
|
||||
stages: [{ type: "review", participants: [{ type: "user", userId: "reviewer" }] }],
|
||||
monitor: { nextCheckAt: "2099-01-01T00:00:00.000Z" },
|
||||
});
|
||||
const [issue] = await db.insert(issues).values({
|
||||
companyId, title: "Promote backlog with retained monitor", status: "backlog", assigneeAgentId: agent!.id,
|
||||
executionPolicy: policy,
|
||||
...buildInitialIssueMonitorFields({ policy, status: "in_progress", assigneeAgentId: agent!.id }),
|
||||
}).returning();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "board-user", companyIds: [companyId], isInstanceAdmin: true, source: "local_implicit" };
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return { app, issue: issue! };
|
||||
}
|
||||
|
||||
it("promotes through the real service and clears a retained monitor without accepting public execution fields", async () => {
|
||||
const { app, issue } = await fixture();
|
||||
const response = await request(app).patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: issue.statusVersion });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
const saved = await issueService(db).getById(issue.id);
|
||||
const { monitor: _monitor, ...retainedPolicy } = issue.executionPolicy!;
|
||||
expect(saved).toMatchObject({
|
||||
status: "todo", statusVersion: issue.statusVersion + 1,
|
||||
executionPolicy: retainedPolicy,
|
||||
executionState: { monitor: { status: "cleared", clearReason: "invalid_status" } },
|
||||
monitorNextCheckAt: null, monitorWakeRequestedAt: null,
|
||||
});
|
||||
expect(saved?.executionPolicy).not.toHaveProperty("monitor");
|
||||
});
|
||||
|
||||
it("derives cleanup from the locked policy after a concurrent policy-only edit", async () => {
|
||||
const { app, issue } = await fixture();
|
||||
const replacementPolicy = normalizeIssueExecutionPolicy({
|
||||
maxReviewRounds: 9,
|
||||
stages: [{ type: "approval", participants: [{ type: "user", userId: "new-reviewer" }] }],
|
||||
monitor: { nextCheckAt: "2099-02-01T00:00:00.000Z" },
|
||||
});
|
||||
let release!: () => void;
|
||||
let ready!: () => void;
|
||||
const lockReady = new Promise<void>((resolve) => { ready = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const editDb = createDb(temporary.connectionString, { maxConnections: 1 });
|
||||
const probeDb = createDb(temporary.connectionString, { maxConnections: 1 });
|
||||
let holderPid = 0;
|
||||
const edit = editDb.transaction(async (tx) => {
|
||||
await tx.update(issues).set({ executionPolicy: replacementPolicy }).where(eq(issues.id, issue.id));
|
||||
holderPid = (await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0]!.pid;
|
||||
ready();
|
||||
await gate;
|
||||
});
|
||||
await lockReady;
|
||||
const promotion = request(app).patch(`/api/issues/${issue.id}`)
|
||||
.send({ status: "todo", expectedStatusVersion: issue.statusVersion }).then((response) => response);
|
||||
let waiting = false;
|
||||
try {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!waiting && Date.now() < deadline) {
|
||||
const [result] = await probeDb.execute<{ waiting: boolean }>(sql`
|
||||
select exists (select 1 from pg_stat_activity
|
||||
where datname = current_database() and wait_event_type = 'Lock'
|
||||
and ${holderPid} = any(pg_blocking_pids(pid))) as waiting
|
||||
`);
|
||||
waiting = result!.waiting;
|
||||
if (!waiting) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
await edit;
|
||||
}
|
||||
const response = await promotion;
|
||||
expect(waiting, "promotion must wait at the service row lock").toBe(true);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
const saved = await issueService(db).getById(issue.id);
|
||||
const { monitor: _monitor, ...retainedPolicy } = replacementPolicy!;
|
||||
expect(saved?.executionPolicy).toEqual(retainedPolicy);
|
||||
expect(saved?.status).toBe("todo");
|
||||
expect(saved?.executionState).toMatchObject({ monitor: { status: "cleared" } });
|
||||
});
|
||||
|
||||
it.each([{ executionState: null }, { executionPolicy: null }, { title: "Mixed mutation" }])("rejects a public mixed mutation %j", async (extra) => {
|
||||
const { app, issue } = await fixture();
|
||||
const response = await request(app).patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: issue.statusVersion, ...extra });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
const [saved] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||
expect(saved).toEqual(issue);
|
||||
});
|
||||
|
||||
it("rejects derived review reassignment instead of broadening a guarded promotion", async () => {
|
||||
const { app, issue } = await fixture();
|
||||
const policy = normalizeIssueExecutionPolicy(issue.executionPolicy)!;
|
||||
const stage = policy.stages[0]!;
|
||||
const [before] = await db.update(issues).set({
|
||||
executionState: {
|
||||
status: "pending", currentStageId: stage.id, currentStageIndex: 0, currentStageType: stage.type,
|
||||
currentParticipant: { type: "user", userId: "removed-reviewer", agentId: null },
|
||||
returnAssignee: { type: "agent", agentId: issue.assigneeAgentId, userId: null },
|
||||
completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null,
|
||||
},
|
||||
}).where(eq(issues.id, issue.id)).returning();
|
||||
const response = await request(app).patch(`/api/issues/${issue.id}`)
|
||||
.send({ status: "todo", expectedStatusVersion: before!.statusVersion });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
const [saved] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||
expect(saved).toEqual(before);
|
||||
});
|
||||
|
||||
it("rejects stale promotion before clearing its monitor", async () => {
|
||||
const { app, issue } = await fixture();
|
||||
const response = await request(app).patch(`/api/issues/${issue.id}`).send({ status: "todo", expectedStatusVersion: issue.statusVersion + 1 });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(409);
|
||||
expect(response.body.details).toMatchObject({ code: "issue_status_version_changed" });
|
||||
const [saved] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
||||
expect(saved).toEqual(issue);
|
||||
});
|
||||
});
|
||||
@@ -604,6 +604,112 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
|
||||
});
|
||||
|
||||
it("promotes prepared backlog tasks only at the observed status version", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedIssueCompany(db, companyId);
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Prepared task", status: "backlog" });
|
||||
const original = (await svc.getById(issueId))!;
|
||||
const results = await Promise.allSettled([
|
||||
svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }),
|
||||
svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }),
|
||||
]);
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
const rejected = results.find(result => result.status === "rejected");
|
||||
expect(rejected?.status === "rejected" && rejected.reason)
|
||||
.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
||||
expect(await svc.getById(issueId)).toMatchObject({ status: "todo", statusVersion: original.statusVersion + 1 });
|
||||
});
|
||||
|
||||
it("rejects guarded writes outside backlog promotion without changing the task", async () => {
|
||||
const companyId = randomUUID();
|
||||
await seedIssueCompany(db, companyId);
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Active task", status: "in_progress" });
|
||||
const original = (await svc.getById(issueId))!;
|
||||
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion, title: "Replaced" }))
|
||||
.rejects.toMatchObject({ status: 422 });
|
||||
expect(await svc.getById(issueId)).toMatchObject({ title: "Active task", status: "in_progress", statusVersion: original.statusVersion });
|
||||
});
|
||||
|
||||
it.each(["status", "assignment"])("rejects a prepared promotion after a %s decision returns to its old value", async (change) => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
await seedIssueCompany(db, companyId);
|
||||
await seedCodexCoder(db, companyId, agentId);
|
||||
await seedCodexCoder(db, companyId, otherAgentId);
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Prepared task", status: "backlog", assigneeAgentId: agentId });
|
||||
const original = (await svc.getById(issueId))!;
|
||||
if (change === "status") {
|
||||
await svc.update(issueId, { status: "cancelled" });
|
||||
await svc.update(issueId, { status: "backlog" });
|
||||
} else {
|
||||
await svc.update(issueId, { assigneeAgentId: otherAgentId });
|
||||
await svc.update(issueId, { assigneeAgentId: agentId });
|
||||
}
|
||||
const current = (await svc.getById(issueId))!;
|
||||
expect(current.statusVersion).toBeGreaterThan(original.statusVersion);
|
||||
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
||||
expect((await svc.getById(issueId))!).toMatchObject({ status: "backlog", assigneeAgentId: agentId, statusVersion: current.statusVersion });
|
||||
});
|
||||
|
||||
it.each(["release", "force release", "direct agent round trip", "direct human round trip"])(
|
||||
"invalidates a prepared promotion after %s", async (change) => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
await seedIssueCompany(db, companyId);
|
||||
await seedCodexCoder(db, companyId, agentId);
|
||||
await seedCodexCoder(db, companyId, otherAgentId);
|
||||
const issueId = randomUUID();
|
||||
const humanAssignment = change === "direct human round trip";
|
||||
await db.insert(issues).values({
|
||||
id: issueId, companyId, title: "Prepared ownership", status: "backlog",
|
||||
assigneeAgentId: humanAssignment ? null : agentId,
|
||||
assigneeUserId: humanAssignment ? "original-owner" : null,
|
||||
});
|
||||
const original = (await svc.getById(issueId))!;
|
||||
if (change === "release") {
|
||||
await svc.release(issueId, agentId);
|
||||
} else if (change === "force release") {
|
||||
await svc.adminForceRelease(issueId, { clearAssignee: true });
|
||||
} else if (humanAssignment) {
|
||||
await db.update(issues).set({ assigneeUserId: "another-owner" }).where(eq(issues.id, issueId));
|
||||
await db.update(issues).set({ assigneeUserId: "original-owner" }).where(eq(issues.id, issueId));
|
||||
} else {
|
||||
await db.update(issues).set({ assigneeAgentId: otherAgentId }).where(eq(issues.id, issueId));
|
||||
await db.update(issues).set({ assigneeAgentId: agentId }).where(eq(issues.id, issueId));
|
||||
}
|
||||
const current = (await svc.getById(issueId))!;
|
||||
expect(current.statusVersion).toBe(original.statusVersion + (change.startsWith("direct") ? 2 : 1));
|
||||
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "issue_status_version_changed" } });
|
||||
expect(await svc.getById(issueId)).toMatchObject({
|
||||
status: "backlog", assigneeAgentId: current.assigneeAgentId,
|
||||
assigneeUserId: current.assigneeUserId, statusVersion: current.statusVersion,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a prepared version when the stored assignee is unchanged", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
await seedIssueCompany(db, companyId);
|
||||
await seedCodexCoder(db, companyId, agentId);
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Same owner", status: "backlog", assigneeAgentId: agentId });
|
||||
const original = (await svc.getById(issueId))!;
|
||||
await db.update(issues).set({ assigneeAgentId: agentId, assigneeUserId: null }).where(eq(issues.id, issueId));
|
||||
expect((await svc.getById(issueId))?.statusVersion).toBe(original.statusVersion);
|
||||
await expect(svc.update(issueId, { status: "todo", expectedStatusVersion: original.statusVersion }))
|
||||
.resolves.toMatchObject({ status: "todo", statusVersion: original.statusVersion + 1 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueThreadInteractions);
|
||||
|
||||
@@ -455,6 +455,14 @@ describe("openapi routes", () => {
|
||||
expect(wake.responses["409"]).toBeDefined();
|
||||
expect(wake.description).toContain("durable queued/deferred receipt");
|
||||
});
|
||||
it("advertises the guarded prepared-task promotion contract to API clients", async () => {
|
||||
const res = await request(createApp()).get("/api/openapi.json");
|
||||
const patch = res.body.paths["/api/issues/{id}"].patch;
|
||||
expect(patch.requestBody.content["application/json"].schema.properties.expectedStatusVersion)
|
||||
.toMatchObject({ type: "integer", minimum: 0 });
|
||||
expect(patch.responses["409"]).toBeDefined();
|
||||
expect(patch.description).toContain("send exactly status=todo and expectedStatusVersion");
|
||||
});
|
||||
it("serves the generated OpenAPI document", async () => {
|
||||
const res = await request(createApp()).get("/api/openapi.json");
|
||||
|
||||
|
||||
@@ -13549,6 +13549,15 @@ export function issueRoutes(
|
||||
|
||||
router.patch(
|
||||
"/issues/:id",
|
||||
(req, _res, next) => {
|
||||
if (req.body?.expectedStatusVersion !== undefined && (
|
||||
req.body.status !== "todo" ||
|
||||
Object.keys(req.body).some(key => key !== "status" && key !== "expectedStatusVersion")
|
||||
)) {
|
||||
throw unprocessable("expectedStatusVersion requires a status-only backlog-to-todo promotion");
|
||||
}
|
||||
next();
|
||||
},
|
||||
validateIssueMutationBody(updateIssueRouteSchema),
|
||||
async (req, res) => {
|
||||
// Snapshot-guarded writes are policy-only. Do not admit task-control
|
||||
@@ -13568,6 +13577,13 @@ export function issueRoutes(
|
||||
);
|
||||
if (!existing) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, existing))) return;
|
||||
if (req.body.expectedStatusVersion !== undefined && (
|
||||
existing.statusVersion !== req.body.expectedStatusVersion || existing.status !== "backlog"
|
||||
)) {
|
||||
throw conflict("The task status or assignment changed. Read the task again before starting work.", {
|
||||
code: "issue_status_version_changed",
|
||||
});
|
||||
}
|
||||
if (req.body.parentId) {
|
||||
const parent = await svc.getById(req.body.parentId);
|
||||
if (!parent || parent.companyId !== existing.companyId || !(await assertIssueReadAllowed(req, res, parent))) {
|
||||
@@ -13646,6 +13662,7 @@ export function issueRoutes(
|
||||
deferWakeForGoal,
|
||||
hiddenAt: hiddenAtRaw,
|
||||
expectedExecutionPolicy,
|
||||
expectedStatusVersion,
|
||||
onBehalfOfUserId: _requestedOnBehalfOfUserId,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
@@ -14011,7 +14028,9 @@ export function issueRoutes(
|
||||
req.body.executionPolicy !== undefined && monitorChanged,
|
||||
);
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
const transition: ReturnType<typeof applyIssueExecutionPolicyTransition> = expectedStatusVersion !== undefined
|
||||
? { patch: {} }
|
||||
: applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy: nextExecutionPolicy,
|
||||
previousPolicy: previousExecutionPolicy,
|
||||
@@ -14391,6 +14410,7 @@ export function issueRoutes(
|
||||
...updateFields,
|
||||
workspaceSelectionActor: req.actor,
|
||||
expectedExecutionPolicy,
|
||||
expectedStatusVersion,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorRunId: actor.agentId ? actor.runId : null,
|
||||
actorRunStopId: actor.agentId && interruptedRunId === actor.runId ? issueMutationStopId : null,
|
||||
|
||||
@@ -4293,7 +4293,7 @@ registry.registerPath({
|
||||
tags: ["issues"],
|
||||
summary: "Update an issue",
|
||||
description:
|
||||
"When posting a comment, attachmentIds selects up to 20 unique uploaded attachments from this exact task and company. The comment, attachment binding, and issue update commit atomically. attachmentIds without a comment is rejected; Markdown links alone do not bind uploads.",
|
||||
"When posting a comment, attachmentIds selects up to 20 unique uploaded attachments from this exact task and company. The comment, attachment binding, and issue update commit atomically. attachmentIds without a comment is rejected; Markdown links alone do not bind uploads. For a prepared backlog task, send exactly status=todo and expectedStatusVersion from the task response. The locked promotion returns 409 if the status or assignment changed. Other mutation fields with this guard return 422.",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(updateIssueSchema.partial()),
|
||||
@@ -4303,6 +4303,7 @@ registry.registerPath({
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -325,12 +325,8 @@ function nextAssigneeIds(input: {
|
||||
export function stripMonitorFromExecutionPolicy(policy: IssueExecutionPolicy | null): IssueExecutionPolicy | null {
|
||||
if (!policy) return null;
|
||||
if (!policy.monitor) return policy;
|
||||
if (policy.stages.length === 0) return null;
|
||||
return {
|
||||
mode: policy.mode,
|
||||
commentRequired: policy.commentRequired,
|
||||
stages: policy.stages,
|
||||
};
|
||||
const { monitor: _monitor, ...remainingPolicy } = policy;
|
||||
return normalizeIssueExecutionPolicy(remainingPolicy);
|
||||
}
|
||||
|
||||
export function setIssueExecutionPolicyMonitorScheduledBy(
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
retryNativeChatReviewPresentation,
|
||||
} from "./native-runtime/native-chat-review-presentation.js";
|
||||
import {
|
||||
applyIssueExecutionPolicyTransition,
|
||||
buildInitialIssueMonitorFields,
|
||||
normalizeIssueExecutionPolicy,
|
||||
} from "./issue-execution-policy.js";
|
||||
@@ -10793,6 +10794,7 @@ export function issueService(db: Db) {
|
||||
companyGuard?: string;
|
||||
workspaceSelectionActor?: AuthorizationActor;
|
||||
expectedExecutionPolicy?: typeof issues.$inferInsert.executionPolicy;
|
||||
expectedStatusVersion?: number;
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
postCommitActivityPublications?: ActivityPublication[],
|
||||
@@ -10842,8 +10844,15 @@ export function issueService(db: Db) {
|
||||
companyGuard,
|
||||
workspaceSelectionActor,
|
||||
expectedExecutionPolicy,
|
||||
expectedStatusVersion,
|
||||
...issueData
|
||||
} = data;
|
||||
if (expectedStatusVersion !== undefined && (
|
||||
issueData.status !== "todo" || Object.keys(issueData).some(key => key !== "status") ||
|
||||
nextLabelIds !== undefined || blockedByIssueIds !== undefined || expectedExecutionPolicy !== undefined
|
||||
)) {
|
||||
throw unprocessable("expectedStatusVersion requires a status-only backlog-to-todo promotion");
|
||||
}
|
||||
// An explicit edit claims the title, even if it keeps the same text.
|
||||
if (issueData.title !== undefined) issueData.titleNeedsGeneration = false;
|
||||
if (
|
||||
@@ -11096,6 +11105,32 @@ export function issueService(db: Db) {
|
||||
.for("update")
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!receiptExisting) return null;
|
||||
if (expectedStatusVersion !== undefined && (
|
||||
receiptExisting.statusVersion !== expectedStatusVersion || receiptExisting.status !== "backlog"
|
||||
)) {
|
||||
throw conflict("The task status or assignment changed. Read the task again before starting work.", {
|
||||
code: "issue_status_version_changed",
|
||||
});
|
||||
}
|
||||
if (expectedStatusVersion !== undefined) {
|
||||
const policy = normalizeIssueExecutionPolicy(receiptExisting.executionPolicy ?? null);
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: receiptExisting,
|
||||
policy,
|
||||
previousPolicy: policy,
|
||||
requestedStatus: "todo",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: actorAgentId ?? null, userId: actorUserId ?? null },
|
||||
allowBoardOverride: workspaceSelectionActor?.type === "board" && workspaceSelectionActor.source !== "mcp_oauth",
|
||||
});
|
||||
if (transition.decision ||
|
||||
(transition.patch.status !== undefined && transition.patch.status !== "todo") ||
|
||||
(transition.patch.assigneeAgentId !== undefined && transition.patch.assigneeAgentId !== receiptExisting.assigneeAgentId) ||
|
||||
(transition.patch.assigneeUserId !== undefined && transition.patch.assigneeUserId !== receiptExisting.assigneeUserId)) {
|
||||
throw unprocessable("expectedStatusVersion requires a status-only backlog-to-todo promotion");
|
||||
}
|
||||
Object.assign(patch, transition.patch);
|
||||
}
|
||||
if (issueData.executionWorkspaceId !== undefined || issueData.projectWorkspaceId !== undefined ||
|
||||
issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined) {
|
||||
Object.assign(patch, await executionWorkspaceService(db).prepareTaskWorkspaceUpdate({
|
||||
|
||||
Reference in new issue
Block a user