forked from farhoodlabs/paperclip
d9f91576a0
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so planning approvals and child-issue fan-out are part of the core control-plane loop. > - Accepted plans are supposed to be a safe bridge from planning into execution, especially when agents wake from review decisions and reuse isolated workspaces. > - The duplicate-subtask incident showed that an accepted plan revision could be interpreted more than once across overlapping runs, which broke the single-source-of-truth model for issue decomposition. > - Fixing that required tightening the backend contract first: accepted-plan decomposition needs an exact-once fingerprint, durable claim state, and retry-safe child creation. > - Once that backend behavior existed, the board still needed visibility into what happened, so the issue detail view needed a dedicated decomposition section instead of forcing operators to reconstruct child creation from raw activity. > - This pull request adds the exact-once decomposition primitive, hardens wake routing and regressions around the incident, and surfaces decomposition state in the UI so future incidents are both prevented and easier to inspect. ## What Changed - Added accepted-plan decomposition semantics to `doc/execution-semantics.md`, including the exact-once fingerprint, durable claim/result expectations, and retry/resume behavior. - Added persistent accepted-plan decomposition claims in the backend, including schema, shared types/validators, service logic, and issue routes for creating and listing decomposition state. - Hardened heartbeat routing so an accepted-plan continuation stays scoped to the relevant planning issue instead of opportunistically re-decomposing another accepted issue on the same assignee. - Added regression coverage for the original failure modes: concurrent same-parent retries, cross-issue accepted-plan isolation, and partial child recreation under the same fingerprint. - Added the `Plan decomposition` issue-detail section plus supporting API/query-key/activity formatting updates so operators can see revision status, owner, child counts, and the linked child issues directly in the UI. - Included the small follow-up UI fix so the decomposition section still renders when the issue work mode is no longer `planning`. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "lists persisted decompositions with child issue summaries"` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "accepted plan decomposition" server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - Manual UI path: create a planning issue without an isolated execution workspace, add a `plan` document, accept the `request_confirmation`, let Paperclip create child issues, then reopen the parent issue detail page and confirm the `Plan decomposition` section shows the accepted revision, status, idempotent-claim badge, and child links. - Separate follow-up bug noted during manual UI validation: accepting a plan on an issue whose run never records `workspace_finalize` is tracked in `PAPA-445` and is not part of this PR’s fix scope. ## Risks - This adds a new migration and a large Drizzle snapshot update; reviewers should confirm the schema shape and generated metadata match the intended decomposition table. - The exact-once claim changes sit on the accepted-plan fan-out path, so regressions there could block legitimate child creation or mis-handle retries if the claim state machine is wrong. - The new UI only appears when decomposition records exist; reviewers should use the manual verification path above rather than expecting existing issues on a stale local instance to show the section automatically. - `PAPA-445` remains an open follow-up for the `workspace_finalize` accept gate when a planning handoff never records finalize; that bug can interfere with reproducing the UI flow on isolated workspaces but does not change the correctness of the exact-once decomposition feature itself. > Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening change for accepted-plan decomposition, not a new uncoordinated roadmap feature. ## Model Used - OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent; exact backend model ID/context window not exposed in the run context), with repository tool use, shell execution, and code-editing capabilities. <img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05 48 PM" src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28" /> ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
3943 lines
117 KiB
TypeScript
3943 lines
117 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { asc, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { sql } from "drizzle-orm";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
environments,
|
|
executionWorkspaces,
|
|
goals,
|
|
heartbeatRuns,
|
|
instanceSettings,
|
|
issueComments,
|
|
issueInboxArchives,
|
|
issueDocuments,
|
|
issuePlanDecompositions,
|
|
issueRelations,
|
|
issueThreadInteractions,
|
|
issues,
|
|
projectWorkspaces,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.ts";
|
|
import {
|
|
clampIssueListLimit,
|
|
deriveIssueCommentRunLogAttribution,
|
|
ISSUE_LIST_MAX_LIMIT,
|
|
issueService,
|
|
} from "../services/issues.ts";
|
|
import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describe("issue list limit helpers", () => {
|
|
it("clamps untrusted issue-list limits to the server maximum", () => {
|
|
expect(clampIssueListLimit(0)).toBe(1);
|
|
expect(clampIssueListLimit(25.9)).toBe(25);
|
|
expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT);
|
|
});
|
|
});
|
|
|
|
describe("deriveIssueCommentRunLogAttribution", () => {
|
|
it("recovers agent attribution from run logs that printed the posted comment id", () => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: `comment id: ${commentId}\n`,
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
});
|
|
});
|
|
|
|
it("does not rewrite comments without exact run-log proof", () => {
|
|
const commentId = randomUUID();
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-05-11T18:55:40.090Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId: randomUUID(),
|
|
agentId: randomUUID(),
|
|
createdAt: new Date("2026-05-11T18:51:56.246Z"),
|
|
startedAt: new Date("2026-05-11T18:51:56.257Z"),
|
|
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
|
|
logContent: "posted results without echoing the comment id",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
|
|
await db.execute(sql.raw(`
|
|
CREATE TABLE IF NOT EXISTS "issue_relations" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"company_id" uuid NOT NULL,
|
|
"issue_id" uuid NOT NULL,
|
|
"related_issue_id" uuid NOT NULL,
|
|
"type" text NOT NULL,
|
|
"created_by_agent_id" uuid,
|
|
"created_by_user_id" text,
|
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
`));
|
|
}
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("returns issues an agent participated in across the supported signals", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: otherAgentId,
|
|
companyId,
|
|
name: "OtherAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const assignedIssueId = randomUUID();
|
|
const createdIssueId = randomUUID();
|
|
const commentedIssueId = randomUUID();
|
|
const activityIssueId = randomUUID();
|
|
const excludedIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: createdIssueId,
|
|
companyId,
|
|
title: "Created issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
{
|
|
id: commentedIssueId,
|
|
companyId,
|
|
title: "Commented issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: activityIssueId,
|
|
companyId,
|
|
title: "Activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
},
|
|
{
|
|
id: excludedIssueId,
|
|
companyId,
|
|
title: "Excluded issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: otherAgentId,
|
|
assigneeAgentId: otherAgentId,
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentedIssueId,
|
|
authorAgentId: agentId,
|
|
body: "Investigating this issue.",
|
|
});
|
|
|
|
await db.insert(activityLog).values({
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: activityIssueId,
|
|
agentId,
|
|
details: { changed: true },
|
|
});
|
|
|
|
const result = await svc.list(companyId, { participantAgentId: agentId });
|
|
const resultIds = new Set(result.map((issue) => issue.id));
|
|
|
|
expect(resultIds).toEqual(new Set([
|
|
assignedIssueId,
|
|
createdIssueId,
|
|
commentedIssueId,
|
|
activityIssueId,
|
|
]));
|
|
expect(resultIds.has(excludedIssueId)).toBe(false);
|
|
});
|
|
|
|
it("combines participation filtering with search", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const matchedIssueId = randomUUID();
|
|
const otherIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: matchedIssueId,
|
|
companyId,
|
|
title: "Invoice reconciliation",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
title: "Weekly planning",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByAgentId: agentId,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
participantAgentId: agentId,
|
|
q: "invoice",
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
|
});
|
|
|
|
it("applies result limits to issue search", async () => {
|
|
const companyId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const exactIdentifierId = randomUUID();
|
|
const titleMatchId = randomUUID();
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: exactIdentifierId,
|
|
companyId,
|
|
issueNumber: 42,
|
|
identifier: "PAP-42",
|
|
title: "Completely unrelated",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: titleMatchId,
|
|
companyId,
|
|
title: "Search ranking issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: descriptionMatchId,
|
|
companyId,
|
|
title: "Another item",
|
|
description: "Contains the search keyword",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
q: "search",
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
|
});
|
|
|
|
it("can page issues by most recently updated before priority", async () => {
|
|
const companyId = randomUUID();
|
|
const oldCriticalIssueId = randomUUID();
|
|
const recentMediumIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: oldCriticalIssueId,
|
|
companyId,
|
|
title: "Old critical issue",
|
|
status: "todo",
|
|
priority: "critical",
|
|
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: recentMediumIssueId,
|
|
companyId,
|
|
title: "Recent medium issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {
|
|
limit: 1,
|
|
sortField: "updated",
|
|
sortDir: "desc",
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
|
|
});
|
|
|
|
it("ranks comment matches ahead of description-only matches", async () => {
|
|
const companyId = randomUUID();
|
|
const commentMatchId = randomUUID();
|
|
const descriptionMatchId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: commentMatchId,
|
|
companyId,
|
|
title: "Comment match",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: descriptionMatchId,
|
|
companyId,
|
|
title: "Description match",
|
|
description: "Contains pull/3303 in the description",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentMatchId,
|
|
body: "Reference: https://github.com/paperclipai/paperclip/pull/3303",
|
|
});
|
|
|
|
const result = await svc.list(companyId, {
|
|
q: "pull/3303",
|
|
limit: 2,
|
|
includeRoutineExecutions: true,
|
|
});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]);
|
|
});
|
|
|
|
it("filters issue lists to the full descendant tree for a root issue", async () => {
|
|
const companyId = randomUUID();
|
|
const rootId = randomUUID();
|
|
const childId = randomUUID();
|
|
const grandchildId = randomUUID();
|
|
const siblingId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: childId,
|
|
companyId,
|
|
parentId: rootId,
|
|
title: "Child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: grandchildId,
|
|
companyId,
|
|
parentId: childId,
|
|
title: "Grandchild",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: siblingId,
|
|
companyId,
|
|
title: "Sibling",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId });
|
|
|
|
expect(new Set(result.map((issue) => issue.id))).toEqual(new Set([childId, grandchildId]));
|
|
});
|
|
|
|
it("combines descendant filtering with search", async () => {
|
|
const companyId = randomUUID();
|
|
const rootId = randomUUID();
|
|
const childId = randomUUID();
|
|
const grandchildId = randomUUID();
|
|
const outsideMatchId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: childId,
|
|
companyId,
|
|
parentId: rootId,
|
|
title: "Relevant parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: grandchildId,
|
|
companyId,
|
|
parentId: childId,
|
|
title: "Needle grandchild",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: outsideMatchId,
|
|
companyId,
|
|
title: "Needle outside",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { descendantOf: rootId, q: "needle" });
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
|
|
});
|
|
|
|
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "PC1A2",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
issueNumber: 1064,
|
|
identifier: "PC1A2-1064",
|
|
title: "Feedback votes error",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: "user-1",
|
|
});
|
|
|
|
const issue = await svc.getById("pc1a2-1064");
|
|
|
|
expect(issue).toEqual(
|
|
expect.objectContaining({
|
|
id: issueId,
|
|
identifier: "PC1A2-1064",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
|
|
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
|
|
});
|
|
it("filters issues by execution workspace id", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const targetWorkspaceId = randomUUID();
|
|
const otherWorkspaceId = randomUUID();
|
|
const linkedIssueId = randomUUID();
|
|
const otherLinkedIssueId = randomUUID();
|
|
const unlinkedIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: targetWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Target workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
{
|
|
id: otherWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Other workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: linkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId: targetWorkspaceId,
|
|
},
|
|
{
|
|
id: otherLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Other linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId: otherWorkspaceId,
|
|
},
|
|
{
|
|
id: unlinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Unlinked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
|
|
});
|
|
|
|
it("filters issues by generic workspace id across execution and project workspace links", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const executionLinkedIssueId = randomUUID();
|
|
const projectLinkedIssueId = randomUUID();
|
|
const otherIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Feature workspace",
|
|
sourceType: "local_path",
|
|
visibility: "default",
|
|
isPrimary: false,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Execution workspace",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: executionLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Execution linked issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
{
|
|
id: projectLinkedIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Project linked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Other issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const executionResult = await svc.list(companyId, { workspaceId: executionWorkspaceId });
|
|
const projectResult = await svc.list(companyId, { workspaceId: projectWorkspaceId });
|
|
|
|
expect(executionResult.map((issue) => issue.id)).toEqual([executionLinkedIssueId]);
|
|
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
|
});
|
|
|
|
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const normalIssueId = randomUUID();
|
|
const pluginVisibleIssueId = randomUUID();
|
|
const operationIssueId = randomUUID();
|
|
const typedOperationIssueId = randomUUID();
|
|
const legacyContentMachineOperationIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Plugin Runner",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Plugin operations",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: normalIssueId,
|
|
companyId,
|
|
title: "Normal issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
},
|
|
{
|
|
id: pluginVisibleIssueId,
|
|
companyId,
|
|
title: "Plugin-visible issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:feature",
|
|
},
|
|
{
|
|
id: operationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Plugin operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
originId: "mission-alpha:operation-1",
|
|
},
|
|
{
|
|
id: typedOperationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Typed plugin operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclip.missions:operation:evaluation",
|
|
originId: "mission-alpha:operation-2",
|
|
},
|
|
{
|
|
id: legacyContentMachineOperationIssueId,
|
|
companyId,
|
|
projectId,
|
|
title: "Legacy Content Machine operation issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
originKind: "plugin:paperclipai.content-machine:evaluation",
|
|
originId: "content-machine-operation-1",
|
|
},
|
|
]);
|
|
|
|
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
|
expect(defaultIssueIds).toContain(normalIssueId);
|
|
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
|
expect(defaultIssueIds).not.toContain(operationIssueId);
|
|
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
|
|
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
|
|
|
const inboxIssueIds = (await svc.list(companyId, {
|
|
assigneeAgentId: agentId,
|
|
status: "todo,in_progress,blocked",
|
|
includeRoutineExecutions: true,
|
|
})).map((issue) => issue.id);
|
|
expect(inboxIssueIds).toContain(normalIssueId);
|
|
expect(inboxIssueIds).not.toContain(operationIssueId);
|
|
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
|
|
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
|
|
|
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
|
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
|
await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
|
|
.resolves.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: operationIssueId }),
|
|
expect.objectContaining({ id: typedOperationIssueId }),
|
|
]));
|
|
|
|
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
|
expect(projectIssueIds).toContain(operationIssueId);
|
|
expect(projectIssueIds).toContain(typedOperationIssueId);
|
|
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
|
|
|
|
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
|
expect(advancedIssueIds).toContain(operationIssueId);
|
|
expect(advancedIssueIds).toContain(typedOperationIssueId);
|
|
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
|
|
});
|
|
|
|
it("excludes plugin operation issues from unread inbox counts", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "board-user";
|
|
const otherUserId = "other-user";
|
|
const normalIssueId = randomUUID();
|
|
const operationIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: normalIssueId,
|
|
companyId,
|
|
title: "Normal touched issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
},
|
|
{
|
|
id: operationIssueId,
|
|
companyId,
|
|
title: "Plugin operation touched issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
originKind: "plugin:paperclip.missions:operation",
|
|
},
|
|
]);
|
|
await db.insert(issueComments).values([
|
|
{
|
|
companyId,
|
|
issueId: normalIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "Unread normal update.",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: operationIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "Unread operation update.",
|
|
},
|
|
]);
|
|
|
|
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
|
});
|
|
|
|
it("hides archived inbox issues until new external activity arrives", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const otherUserId = "user-2";
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const visibleIssueId = randomUUID();
|
|
const archivedIssueId = randomUUID();
|
|
const resurfacedIssueId = randomUUID();
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: visibleIssueId,
|
|
companyId,
|
|
title: "Visible issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: archivedIssueId,
|
|
companyId,
|
|
title: "Archived issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
},
|
|
{
|
|
id: resurfacedIssueId,
|
|
companyId,
|
|
title: "Resurfaced issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
|
|
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: resurfacedIssueId,
|
|
authorUserId: otherUserId,
|
|
body: "This should bring the issue back into Mine.",
|
|
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
});
|
|
|
|
const archivedFiltered = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
|
|
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
|
resurfacedIssueId,
|
|
visibleIssueId,
|
|
]);
|
|
|
|
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
|
|
|
const afterUnarchive = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
|
|
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
|
visibleIssueId,
|
|
archivedIssueId,
|
|
resurfacedIssueId,
|
|
]));
|
|
});
|
|
|
|
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const otherUserId = "user-2";
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Issue with old comment then status change",
|
|
status: "todo",
|
|
priority: "medium",
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
});
|
|
|
|
// Old external comment before archiving
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
authorUserId: otherUserId,
|
|
body: "Old comment before archive",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
});
|
|
|
|
// Archive after seeing the comment
|
|
await svc.archiveInbox(
|
|
companyId,
|
|
issueId,
|
|
userId,
|
|
new Date("2026-03-26T12:00:00.000Z"),
|
|
);
|
|
|
|
// Verify it's archived
|
|
const afterArchive = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
|
|
|
|
// Status/work update changes updatedAt (no new comment)
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
status: "in_progress",
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
// Should resurface because updatedAt > archivedAt
|
|
const afterUpdate = await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
});
|
|
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
|
|
});
|
|
|
|
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
|
|
const companyId = randomUUID();
|
|
const olderIssueId = randomUUID();
|
|
const commentIssueId = randomUUID();
|
|
const activityIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: olderIssueId,
|
|
companyId,
|
|
title: "Older issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: commentIssueId,
|
|
companyId,
|
|
title: "Comment activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: activityIssueId,
|
|
companyId,
|
|
title: "Logged activity issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId: commentIssueId,
|
|
body: "New comment without touching issue.updatedAt",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
});
|
|
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId,
|
|
actorType: "system",
|
|
actorId: "system",
|
|
action: "issue.document_updated",
|
|
entityType: "issue",
|
|
entityId: activityIssueId,
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
action: "issue.read_marked",
|
|
entityType: "issue",
|
|
entityId: olderIssueId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, {});
|
|
|
|
expect(result.map((issue) => issue.id)).toEqual([
|
|
activityIssueId,
|
|
commentIssueId,
|
|
olderIssueId,
|
|
]);
|
|
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T12:00:00.000Z",
|
|
);
|
|
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T11:00:00.000Z",
|
|
);
|
|
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
|
|
"2026-03-26T10:00:00.000Z",
|
|
);
|
|
});
|
|
|
|
it("paginates earlier comments in descending order from an anchor comment", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const firstCommentId = randomUUID();
|
|
const anchorCommentId = randomUUID();
|
|
const latestCommentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Paged comments issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
id: firstCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "First comment",
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
},
|
|
{
|
|
id: anchorCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Anchor comment",
|
|
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
},
|
|
{
|
|
id: latestCommentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Latest comment",
|
|
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
afterCommentId: anchorCommentId,
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]);
|
|
});
|
|
|
|
it("lists user comments when derived run attribution scans a timestamp window", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Comments issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
contextSnapshot: { issueId },
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Comment should be visible",
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
expect(comments[0]?.body).toBe("Comment should be visible");
|
|
});
|
|
|
|
it("lists user comments when a candidate attribution run log is missing", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const commentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Comments issue with missing run log",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
contextSnapshot: { issueId },
|
|
createdAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
startedAt: new Date("2026-05-12T22:58:00.000Z"),
|
|
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
|
|
logStore: "local_file",
|
|
logRef: "missing/run-log.ndjson",
|
|
logBytes: 128,
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
authorUserId: "user-1",
|
|
body: "Comment should still be visible",
|
|
createdAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
order: "desc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
|
|
expect(comments[0]?.body).toBe("Comment should still be visible");
|
|
expect(comments[0]?.metadata).toBeNull();
|
|
});
|
|
|
|
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
|
const companyId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
const unblockedId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker issue",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: unblockedId,
|
|
companyId,
|
|
title: "Unblocked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueRelations).values({
|
|
companyId,
|
|
issueId: blockerId,
|
|
relatedIssueId: blockedId,
|
|
type: "blocks",
|
|
});
|
|
|
|
const defaultResult = await svc.list(companyId);
|
|
expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined();
|
|
|
|
const result = await svc.list(companyId, { includeBlockedBy: true });
|
|
const byId = new Map(result.map((issue) => [issue.id, issue]));
|
|
|
|
expect(byId.get(blockedId)?.blockedBy).toEqual([
|
|
expect.objectContaining({
|
|
id: blockerId,
|
|
identifier: null,
|
|
title: "Blocker issue",
|
|
status: "todo",
|
|
priority: "high",
|
|
}),
|
|
]);
|
|
expect(byId.get(blockerId)?.blockedBy).toEqual([]);
|
|
expect(byId.get(unblockedId)?.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("trims list payload fields that can grow large on issue index routes", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const longDescription = "x".repeat(5_000);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Large issue",
|
|
description: longDescription,
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) },
|
|
executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) },
|
|
executionWorkspaceSettings: { notes: "w".repeat(2_000) },
|
|
});
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
expect(result).toBeTruthy();
|
|
expect(result?.description).toHaveLength(1200);
|
|
expect(result?.executionPolicy).toBeNull();
|
|
expect(result?.executionState).toBeNull();
|
|
expect(result?.executionWorkspaceSettings).toBeNull();
|
|
});
|
|
|
|
it("does not let description preview truncation split multibyte characters", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const description = `${"x".repeat(1199)}— still valid after truncation`;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Multibyte boundary issue",
|
|
description,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
expect(result?.description).toHaveLength(1200);
|
|
expect(result?.description?.endsWith("—")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
sharedWorkspaceKey: "workspace-key",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
});
|
|
});
|
|
|
|
it("captures the assignee default environment when neither issue nor project specifies one", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const assigneeEnvironmentId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: assigneeEnvironmentId,
|
|
companyId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId,
|
|
title: "Environment matrix: e2b / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
environmentId: assigneeEnvironmentId,
|
|
});
|
|
});
|
|
|
|
it("does not promote the assignee default environment when the project policy already specifies one", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const projectEnvironmentId = randomUUID();
|
|
const assigneeEnvironmentId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: projectEnvironmentId,
|
|
companyId,
|
|
name: "QA SSH",
|
|
driver: "ssh",
|
|
status: "active",
|
|
config: {},
|
|
},
|
|
{
|
|
id: assigneeEnvironmentId,
|
|
companyId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: assigneeEnvironmentId,
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
environmentId: projectEnvironmentId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId,
|
|
title: "Environment matrix: e2b / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
// Project policy's environmentId must win over the assignee's default;
|
|
// executionWorkspaceSettings should not bake in an environmentId in this case
|
|
// so resolveExecutionWorkspaceEnvironmentId can fall through to the project
|
|
// policy's value at run time.
|
|
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
|
});
|
|
|
|
it("captures the new assignee's default environment on reassignment", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const firstEnvironmentId = randomUUID();
|
|
const secondEnvironmentId = randomUUID();
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{
|
|
id: firstEnvironmentId,
|
|
companyId,
|
|
name: "QA SSH",
|
|
driver: "ssh",
|
|
status: "active",
|
|
config: {},
|
|
},
|
|
{
|
|
id: secondEnvironmentId,
|
|
companyId,
|
|
name: "QA E2B",
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "e2b" },
|
|
},
|
|
]);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: firstAgentId,
|
|
companyId,
|
|
name: "QA SSH Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: firstEnvironmentId,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: secondAgentId,
|
|
companyId,
|
|
name: "QA E2B Codex",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
defaultEnvironmentId: secondEnvironmentId,
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId: firstAgentId,
|
|
title: "Environment matrix: ssh / codex_local",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(created.executionWorkspaceSettings).toMatchObject({
|
|
environmentId: firstEnvironmentId,
|
|
});
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
|
|
expect(reassigned).not.toBeNull();
|
|
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
|
environmentId: secondEnvironmentId,
|
|
});
|
|
});
|
|
|
|
it("preserves an operator-set environmentId across reassignment", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const firstEnvironmentId = randomUUID();
|
|
const secondEnvironmentId = randomUUID();
|
|
const operatorEnvironmentId = randomUUID();
|
|
const firstAgentId = randomUUID();
|
|
const secondAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(environments).values([
|
|
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
|
|
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
|
|
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
|
|
]);
|
|
|
|
await db.insert(agents).values([
|
|
{
|
|
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
defaultEnvironmentId: firstEnvironmentId, permissions: {},
|
|
},
|
|
{
|
|
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
|
|
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
|
defaultEnvironmentId: secondEnvironmentId, permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId, companyId, name: "Workspace project", status: "in_progress",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
allowIssueOverride: true,
|
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
|
},
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
projectId,
|
|
assigneeAgentId: firstAgentId,
|
|
title: "Operator overrides env then reassigns",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
// Operator explicitly overrides the environmentId in a separate update.
|
|
const overridden = await svc.update(created.id, {
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
environmentId: operatorEnvironmentId,
|
|
},
|
|
});
|
|
expect(overridden!.executionWorkspaceSettings).toMatchObject({
|
|
environmentId: operatorEnvironmentId,
|
|
});
|
|
|
|
// A subsequent reassignment-only update must NOT overwrite the operator's
|
|
// explicit choice with the new assignee's default.
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
|
environmentId: operatorEnvironmentId,
|
|
});
|
|
});
|
|
|
|
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const parentExecutionWorkspaceId = randomUUID();
|
|
const explicitProjectWorkspaceId = randomUUID();
|
|
const explicitExecutionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Parent workspace",
|
|
},
|
|
{
|
|
id: explicitProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Explicit workspace",
|
|
},
|
|
]);
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: parentExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Parent worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
},
|
|
{
|
|
id: explicitExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Explicit shared workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId: parentExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
executionWorkspaceId: explicitExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
},
|
|
});
|
|
|
|
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const sourceIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "operator_branch",
|
|
strategyType: "git_worktree",
|
|
name: "Operator branch",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "operator_branch",
|
|
},
|
|
});
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
projectId,
|
|
title: "Follow-up issue",
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
});
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
mode: "operator_branch",
|
|
});
|
|
});
|
|
|
|
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Ship child helpers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
goalId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: 1,
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, {
|
|
title: "Child helper",
|
|
status: "todo",
|
|
description: "Implement the helper.",
|
|
acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"],
|
|
blockParentUntilDone: true,
|
|
});
|
|
|
|
expect(parentBlockerAdded).toBe(true);
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.goalId).toBe(goalId);
|
|
expect(child.requestDepth).toBe(2);
|
|
expect(child.description).toContain("## Acceptance Criteria");
|
|
expect(child.description).toContain("- Uses the parent issue as parentId");
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
|
|
const parentRelations = await svc.getRelationSummaries(parentIssueId);
|
|
expect(parentRelations.blockedBy).toEqual([
|
|
expect.objectContaining({
|
|
id: child.id,
|
|
title: "Child helper",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("clamps helper-created child requestDepth to the safe maximum", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Ship child helpers",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
goalId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
goalId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Child helper",
|
|
status: "todo",
|
|
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
|
|
});
|
|
|
|
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await svc.update(blockedId, {
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
const blockerRelations = await svc.getRelationSummaries(blockerId);
|
|
const blockedRelations = await svc.getRelationSummaries(blockedId);
|
|
|
|
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
|
|
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
});
|
|
|
|
it("adds terminal blockers to immediate blocked-by summaries", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const issueA = randomUUID();
|
|
const issueB = randomUUID();
|
|
const issueC = randomUUID();
|
|
const issueD = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" },
|
|
{ id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" },
|
|
{ id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" },
|
|
{ id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" },
|
|
]);
|
|
|
|
await svc.update(issueC, { blockedByIssueIds: [issueD] });
|
|
await svc.update(issueB, { blockedByIssueIds: [issueC] });
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
const relations = await svc.getRelationSummaries(issueA);
|
|
|
|
expect(relations.blockedBy).toHaveLength(1);
|
|
expect(relations.blockedBy[0]).toMatchObject({
|
|
id: issueB,
|
|
identifier: "PAP-2",
|
|
title: "Issue B",
|
|
terminalBlockers: [
|
|
expect.objectContaining({
|
|
id: issueD,
|
|
identifier: "PAP-4",
|
|
title: "Issue D",
|
|
status: "todo",
|
|
priority: "high",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("rejects blocking cycles", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const issueA = randomUUID();
|
|
const issueB = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
|
|
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
|
|
]);
|
|
|
|
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
|
|
|
await expect(
|
|
svc.update(issueB, { blockedByIssueIds: [issueA] }),
|
|
).rejects.toMatchObject({ status: 422 });
|
|
});
|
|
|
|
it("only returns dependents once every blocker is done", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const blockerA = randomUUID();
|
|
const blockerB = randomUUID();
|
|
const blockedIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" },
|
|
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" },
|
|
{
|
|
id: blockedIssueId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
]);
|
|
|
|
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
|
|
|
|
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
|
|
|
|
await svc.update(blockerB, { status: "done" });
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerA)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: blockedIssueId,
|
|
assigneeAgentId,
|
|
blockerIssueIds: expect.arrayContaining([blockerA, blockerB]),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("reports dependency readiness for blocked issue chains", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
|
|
{ id: blockedId, companyId, title: "Blocked", status: "todo", priority: "medium" },
|
|
]);
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
issueId: blockedId,
|
|
blockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerCount: 1,
|
|
allBlockersDone: false,
|
|
isDependencyReady: false,
|
|
});
|
|
|
|
await svc.update(blockerId, { status: "done" });
|
|
|
|
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
|
|
issueId: blockedId,
|
|
blockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [],
|
|
unresolvedBlockerCount: 0,
|
|
allBlockersDone: true,
|
|
isDependencyReady: true,
|
|
});
|
|
});
|
|
|
|
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const sourceIssueId = randomUUID();
|
|
const recoveryIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: sourceIssueId,
|
|
companyId,
|
|
title: "Source issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: recoveryIssueId,
|
|
companyId,
|
|
title: "Liveness escalation issue",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
originKind: "harness_liveness_escalation",
|
|
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
|
|
},
|
|
]);
|
|
|
|
await svc.update(sourceIssueId, {
|
|
blockedByIssueIds: [recoveryIssueId],
|
|
});
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
|
|
});
|
|
|
|
await svc.update(recoveryIssueId, {
|
|
status: "done",
|
|
});
|
|
|
|
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
|
|
blockedBy: [],
|
|
});
|
|
});
|
|
|
|
it("rejects execution when unresolved blockers remain", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const blockerId = randomUUID();
|
|
const blockedId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
|
|
{
|
|
id: blockedId,
|
|
companyId,
|
|
title: "Blocked",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
]);
|
|
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
|
|
|
|
await expect(
|
|
svc.update(blockedId, { status: "in_progress" }),
|
|
).rejects.toMatchObject({ status: 422 });
|
|
|
|
await expect(
|
|
svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null),
|
|
).rejects.toMatchObject({ status: 422 });
|
|
});
|
|
|
|
it("wakes parents only when all direct children are terminal", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const parentId = randomUUID();
|
|
const childA = randomUUID();
|
|
const childB = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: childA,
|
|
companyId,
|
|
parentId,
|
|
title: "Child A",
|
|
status: "done",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: childB,
|
|
companyId,
|
|
parentId,
|
|
title: "Child B",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
|
|
|
|
await svc.update(childB, { status: "cancelled" });
|
|
|
|
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
|
|
id: parentId,
|
|
assigneeAgentId,
|
|
childIssueIds: [childA, childB],
|
|
childIssueSummaries: [
|
|
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
|
|
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
|
|
],
|
|
childIssueSummaryTruncated: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
isPrimary: true,
|
|
sharedWorkspaceKey: "workspace-key",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
providerRef: `/tmp/${executionWorkspaceId}`,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "isolated_workspace",
|
|
workspaceRuntime: { profile: "agent" },
|
|
});
|
|
});
|
|
|
|
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const parentExecutionWorkspaceId = randomUUID();
|
|
const explicitProjectWorkspaceId = randomUUID();
|
|
const explicitExecutionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Parent workspace",
|
|
},
|
|
{
|
|
id: explicitProjectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Explicit workspace",
|
|
},
|
|
]);
|
|
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: parentExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Parent worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
},
|
|
{
|
|
id: explicitExecutionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Explicit shared workspace",
|
|
status: "active",
|
|
providerType: "local_fs",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId: parentExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId,
|
|
title: "Child issue",
|
|
projectWorkspaceId: explicitProjectWorkspaceId,
|
|
executionWorkspaceId: explicitExecutionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
},
|
|
});
|
|
|
|
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
|
|
expect(child.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const sourceIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "operator_branch",
|
|
strategyType: "git_worktree",
|
|
name: "Operator branch",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "operator_branch",
|
|
},
|
|
});
|
|
|
|
const followUp = await svc.create(companyId, {
|
|
projectId,
|
|
title: "Follow-up issue",
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
});
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
mode: "operator_branch",
|
|
});
|
|
});
|
|
|
|
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspace project",
|
|
status: "in_progress",
|
|
});
|
|
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary workspace",
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
metadata: {
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
workspaceRuntime: { profile: "old" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Recovery issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId: "env-old",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-old.sh",
|
|
teardownCommand: "bash ./scripts/teardown-old.sh",
|
|
},
|
|
workspaceRuntime: { profile: "old" },
|
|
},
|
|
});
|
|
|
|
await svc.update(issueId, {
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId: "env-new",
|
|
workspaceStrategy: {
|
|
type: "cloud_sandbox",
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
},
|
|
workspaceRuntime: { profile: "new" },
|
|
},
|
|
});
|
|
|
|
const workspace = await db
|
|
.select({ metadata: executionWorkspaces.metadata })
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(workspace?.metadata).toEqual({
|
|
config: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
teardownCommand: "bash ./scripts/teardown-new.sh",
|
|
cleanupCommand: null,
|
|
workspaceRuntime: { profile: "new" },
|
|
desiredState: null,
|
|
serviceStates: null,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("can skip comment-body scans for bounded issue detail reads", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const titleProjectId = randomUUID();
|
|
const commentProjectId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(projects).values([
|
|
{
|
|
id: titleProjectId,
|
|
companyId,
|
|
name: "Title project",
|
|
status: "in_progress",
|
|
},
|
|
{
|
|
id: commentProjectId,
|
|
companyId,
|
|
name: "Comment project",
|
|
status: "in_progress",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`,
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]);
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
|
titleProjectId,
|
|
commentProjectId,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-execution-lock-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssueWithRun(status: string | null) {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = status ? randomUUID() : null;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
if (runId) {
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status,
|
|
invocationSource: "manual",
|
|
});
|
|
}
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Execution lock",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
executionAgentNameKey: runId ? "codexcoder" : null,
|
|
executionLockedAt: runId ? new Date() : null,
|
|
});
|
|
|
|
return { issueId, runId };
|
|
}
|
|
|
|
it("clears execution locks owned by terminal runs", async () => {
|
|
const { issueId } = await seedIssueWithRun("failed");
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(true);
|
|
|
|
const row = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
});
|
|
});
|
|
|
|
it("does not clear execution locks owned by live runs", async () => {
|
|
const { issueId, runId } = await seedIssueWithRun("running");
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row?.executionRunId).toBe(runId);
|
|
expect(row?.executionAgentNameKey).toBe("codexcoder");
|
|
expect(row?.executionLockedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("does not update issues without an execution lock", async () => {
|
|
const { issueId } = await seedIssueWithRun(null);
|
|
|
|
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({ executionRunId: issues.executionRunId, executionLockedAt: issues.executionLockedAt })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("accepted plan decomposition", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-decomposition-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issuePlanDecompositions);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projectWorkspaces);
|
|
await db.delete(projects);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(instanceSettings);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedAcceptedPlanContext() {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Accepted plan decomposition",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
|
|
return { companyId, goalId, assigneeAgentId };
|
|
}
|
|
|
|
async function seedAcceptedPlanIssue(args?: {
|
|
companyId?: string;
|
|
goalId?: string;
|
|
assigneeAgentId?: string;
|
|
sourceIssueId?: string;
|
|
issueTitle?: string;
|
|
workMode?: "planning" | "standard";
|
|
}) {
|
|
const companyId = args?.companyId ?? randomUUID();
|
|
const goalId = args?.goalId ?? randomUUID();
|
|
const assigneeAgentId = args?.assigneeAgentId ?? randomUUID();
|
|
const sourceIssueId = args?.sourceIssueId ?? randomUUID();
|
|
const planDocumentId = randomUUID();
|
|
const acceptedPlanRevisionId = randomUUID();
|
|
const acceptedInteractionId = randomUUID();
|
|
|
|
if (!args?.companyId || !args?.goalId || !args?.assigneeAgentId) {
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
|
await db.insert(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title: "Accepted plan decomposition",
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
}
|
|
|
|
await db.insert(issues).values({
|
|
id: sourceIssueId,
|
|
companyId,
|
|
goalId,
|
|
title: args?.issueTitle ?? "Planning issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
workMode: args?.workMode ?? "planning",
|
|
assigneeAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(documents).values({
|
|
id: planDocumentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "Plan body",
|
|
latestRevisionId: acceptedPlanRevisionId,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: assigneeAgentId,
|
|
updatedByAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: acceptedPlanRevisionId,
|
|
companyId,
|
|
documentId: planDocumentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "Plan body",
|
|
createdByAgentId: assigneeAgentId,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId: sourceIssueId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: acceptedInteractionId,
|
|
companyId,
|
|
issueId: sourceIssueId,
|
|
kind: "request_confirmation",
|
|
status: "accepted",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: sourceIssueId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
revisionId: acceptedPlanRevisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
},
|
|
resolvedAt: new Date(),
|
|
createdByUserId: "local-board",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
|
|
return { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId };
|
|
}
|
|
|
|
async function getAcceptedPlanClaim(sourceIssueId: string) {
|
|
return db
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
it("reuses the same child issue set on repeat decomposition attempts for an accepted plan revision", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
const children = [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
title: "Add decomposition route tests",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const first = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(first.decomposition).not.toHaveProperty("requestedChildren");
|
|
expect(first.childIssueIds).toHaveLength(2);
|
|
expect(first.newlyCreatedIssues).toHaveLength(2);
|
|
expect(first.decomposition.status).toBe("completed");
|
|
|
|
const second = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(second.childIssueIds).toEqual(first.childIssueIds);
|
|
expect(second.newlyCreatedIssues).toHaveLength(0);
|
|
expect(second.decomposition.status).toBe("completed");
|
|
|
|
const persistedClaims = await db
|
|
.select()
|
|
.from(issuePlanDecompositions)
|
|
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId));
|
|
expect(persistedClaims).toHaveLength(1);
|
|
expect(persistedClaims[0]?.requestedChildCount).toBe(2);
|
|
expect(persistedClaims[0]?.childIssueIds).toEqual(first.childIssueIds);
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
|
|
|
const companyIssues = await svc.list(companyId, { parentId: sourceIssueId });
|
|
expect(companyIssues).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects a different child set for the same accepted plan fingerprint", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
await expect(svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the claim table",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
title: "This duplicate should be rejected",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
});
|
|
});
|
|
|
|
it("allows accepted-plan decomposition on a standard-work issue with an accepted plan document", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue({
|
|
workMode: "standard",
|
|
issueTitle: "Implement after planning",
|
|
});
|
|
|
|
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Implement the approved first slice",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(result.childIssueIds).toHaveLength(1);
|
|
expect(result.newlyCreatedIssues).toHaveLength(1);
|
|
expect(result.decomposition.status).toBe("completed");
|
|
});
|
|
|
|
it("serializes concurrent accepted-plan retries for the same parent issue without duplicate children", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const children = [
|
|
{
|
|
title: "Persist exact-once decomposition claim",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Guard concurrent retry callers",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(claim).not.toBeNull();
|
|
|
|
for (const childIssueId of initial.childIssueIds) {
|
|
await db.delete(issues).where(eq(issues.id, childIssueId));
|
|
}
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [],
|
|
completedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const svcA = issueService(db);
|
|
const svcB = issueService(db);
|
|
const [first, second] = await Promise.all([
|
|
svcA.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
}),
|
|
svcB.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
}),
|
|
]);
|
|
|
|
expect(first.childIssueIds).toEqual(second.childIssueIds);
|
|
expect(first.childIssueIds).toHaveLength(2);
|
|
expect(first.newlyCreatedIssues.length + second.newlyCreatedIssues.length).toBe(2);
|
|
|
|
const persistedClaim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(persistedClaim?.status).toBe("completed");
|
|
expect(persistedClaim?.childIssueIds).toEqual(first.childIssueIds);
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
|
});
|
|
|
|
it("rejects another planning parent's accepted revision even when both issues share the assignee", async () => {
|
|
const { companyId, goalId, assigneeAgentId } = await seedAcceptedPlanContext();
|
|
const firstIssue = await seedAcceptedPlanIssue({
|
|
companyId,
|
|
goalId,
|
|
assigneeAgentId,
|
|
issueTitle: "Earlier accepted plan",
|
|
});
|
|
const secondIssue = await seedAcceptedPlanIssue({
|
|
companyId,
|
|
goalId,
|
|
assigneeAgentId,
|
|
issueTitle: "Later accepted plan",
|
|
});
|
|
|
|
await svc.decomposeAcceptedPlan(firstIssue.sourceIssueId, {
|
|
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Decompose the first issue only",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
await expect(svc.decomposeAcceptedPlan(secondIssue.sourceIssueId, {
|
|
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "This must not land on the second parent",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
});
|
|
|
|
const secondIssueChildren = await db
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, secondIssue.sourceIssueId));
|
|
expect(secondIssueChildren).toHaveLength(0);
|
|
});
|
|
|
|
it("resumes partial child creation under the claimed fingerprint without duplicating completed children", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const children = [
|
|
{
|
|
title: "Create the first child once",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Recreate only the missing tail child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
expect(claim).not.toBeNull();
|
|
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
expect(firstChildId).toBeTruthy();
|
|
expect(secondChildId).toBeTruthy();
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.childIssueIds[0]).toBe(firstChildId);
|
|
expect(retried.newlyCreatedIssues).toHaveLength(1);
|
|
expect(retried.newlyCreatedIssues[0]?.title).toBe("Recreate only the missing tail child");
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.some((row) => row.id === firstChildId)).toBe(true);
|
|
expect(childrenRows.map((row) => row.title).sort()).toEqual(children.map((child) => child.title).sort());
|
|
});
|
|
|
|
it("resumes a partial decomposition after reassignment when only actor metadata changes", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const reassignedAgentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: reassignedAgentId,
|
|
companyId,
|
|
name: "SecondCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const children = [
|
|
{
|
|
title: "Keep the original child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
createdByAgentId: assigneeAgentId,
|
|
actorAgentId: assigneeAgentId,
|
|
},
|
|
{
|
|
title: "Create only the missing child after reassignment",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
createdByAgentId: assigneeAgentId,
|
|
actorAgentId: assigneeAgentId,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
|
|
expect(claim).not.toBeNull();
|
|
expect(firstChildId).toBeTruthy();
|
|
expect(secondChildId).toBeTruthy();
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issues)
|
|
.set({ assigneeAgentId: reassignedAgentId, updatedAt: new Date() })
|
|
.where(eq(issues.id, sourceIssueId));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
ownerAgentId: assigneeAgentId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: children.map((child) => ({
|
|
...child,
|
|
createdByAgentId: reassignedAgentId,
|
|
actorAgentId: reassignedAgentId,
|
|
})),
|
|
actorAgentId: reassignedAgentId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.decomposition.ownerAgentId).toBe(reassignedAgentId);
|
|
expect(retried.childIssueIds[0]).toBe(firstChildId);
|
|
expect(retried.newlyCreatedIssues).toHaveLength(1);
|
|
expect(retried.newlyCreatedIssues[0]?.title).toBe("Create only the missing child after reassignment");
|
|
|
|
const childrenRows = await db
|
|
.select({ id: issues.id, title: issues.title, createdByAgentId: issues.createdByAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.parentId, sourceIssueId))
|
|
.orderBy(asc(issues.createdAt), asc(issues.id));
|
|
expect(childrenRows).toHaveLength(2);
|
|
expect(childrenRows.map((row) => row.id).sort()).toEqual([...retried.childIssueIds].sort());
|
|
expect(childrenRows.find((row) => row.id !== firstChildId)?.createdByAgentId).toBe(reassignedAgentId);
|
|
});
|
|
|
|
it("preserves the existing live claim owner when another actor resumes the same fingerprint", async () => {
|
|
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
const competingAgentId = randomUUID();
|
|
const liveOwnerRunId = randomUUID();
|
|
const competingRunId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: competingAgentId,
|
|
companyId,
|
|
name: "SecondCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: liveOwnerRunId,
|
|
companyId,
|
|
agentId: assigneeAgentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
},
|
|
{
|
|
id: competingRunId,
|
|
companyId,
|
|
agentId: competingAgentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
},
|
|
]);
|
|
|
|
const children = [
|
|
{
|
|
title: "Keep the first created child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
{
|
|
title: "Create the missing second child",
|
|
status: "todo" as const,
|
|
workMode: "standard" as const,
|
|
priority: "medium" as const,
|
|
},
|
|
];
|
|
|
|
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: assigneeAgentId,
|
|
actorRunId: liveOwnerRunId,
|
|
});
|
|
const [firstChildId, secondChildId] = initial.childIssueIds;
|
|
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
|
|
|
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
|
await db
|
|
.update(issuePlanDecompositions)
|
|
.set({
|
|
status: "in_flight",
|
|
childIssueIds: [firstChildId!],
|
|
completedAt: null,
|
|
ownerAgentId: assigneeAgentId,
|
|
ownerRunId: liveOwnerRunId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issuePlanDecompositions.id, claim!.id));
|
|
|
|
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children,
|
|
actorAgentId: competingAgentId,
|
|
actorRunId: competingRunId,
|
|
});
|
|
|
|
expect(retried.decomposition.status).toBe("completed");
|
|
expect(retried.decomposition.ownerAgentId).toBe(assigneeAgentId);
|
|
expect(retried.decomposition.ownerRunId).toBe(liveOwnerRunId);
|
|
});
|
|
|
|
it("lists persisted decompositions with child issue summaries", async () => {
|
|
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
|
|
|
const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
|
expect(initial).toEqual([]);
|
|
|
|
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
|
acceptedPlanRevisionId,
|
|
children: [
|
|
{
|
|
title: "Surface decomposition status in operator UI",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
title: "Add regression coverage",
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
},
|
|
],
|
|
actorAgentId: assigneeAgentId,
|
|
});
|
|
|
|
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
|
expect(decompositions).toHaveLength(1);
|
|
const [record] = decompositions;
|
|
expect(record?.status).toBe("completed");
|
|
expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId);
|
|
expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number");
|
|
expect(record?.childIssues.map((child) => child.id).sort()).toEqual(
|
|
[...result.childIssueIds].sort(),
|
|
);
|
|
expect(record).not.toHaveProperty("requestedChildren");
|
|
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
|
|
});
|
|
});
|