mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip helps people manage AI agents and their tasks. > - A task monitor schedules a later agent check. > - The monitor card shows when that check will run. > - The card has no direct control to cancel the monitor. > - This change adds an X button and a confirmation dialog. > - The operator can stop the scheduled check from the card. ## Linked Issues or Issue Description **What existing behavior does this improve?** The monitor card on the task page. **Current behavior** The operator must open task properties to clear a monitor. **Proposed behavior** Select the X in the top right of the card. Select Keep monitor to leave the schedule in place. Select Cancel monitor to remove the scheduled check. **Reason and benefit** The operator can cancel the monitor where its schedule is shown. Confirmation helps prevent an accidental cancellation. **Breaking changes** None. The change uses the existing task update API. ## What Changed - Add an accessible X button at the top right of the monitor card. - Confirm cancellation before the task update. - Keep the dialog open with an error if the update fails. - Preserve the other execution policy settings and refresh the task after success. - Reject stale policy writes under the task row lock with an optional expectedExecutionPolicy snapshot. - Document the snapshot field and add a database test for stale cancellation. - Restrict snapshot requests to policy-only edits before execution-control actions. - Add seven route tests for rejected mixed requests and absent policy updates. - Keep automatic retry waits separate from monitor cancellation. - Add component and task page tests. ## Verification - `pnpm check:token-gates` passed. - Monitor banner and task page tests passed: 199 tests. A targeted database test and seven route tests also passed. - UI typecheck and production build passed. - Open a task with a scheduled monitor. Select X, then Keep monitor. The schedule must remain. - Select X, then Cancel monitor. The card must disappear after the update. - Local full build reached the Rust runner build but could not complete because Cargo is absent. CI must verify the full build. - Full repository typecheck, build, test suites, and E2E checks passed in CI for the final commit. Local full checks were limited by missing Cargo and workspace memory. ## Risks - Low risk. Cancellation uses the existing company access checks and activity log. - A cancelled monitor no longer wakes the agent. The dialog explains this effect. ## Model Used OpenAI Codex, GPT-6. The runtime does not expose a more specific model ID or context window size. Used code editing, shell commands, and tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
7522 lines
229 KiB
TypeScript
7522 lines
229 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { asc, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { sql } from "drizzle-orm";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
environments,
|
|
executionWorkspaces,
|
|
goals,
|
|
heartbeatRuns,
|
|
instanceSettings,
|
|
issueComments,
|
|
issueInboxArchives,
|
|
issueDocuments,
|
|
issueAccessGrants,
|
|
issuePlanDecompositions,
|
|
issueReadStates,
|
|
issueRelations,
|
|
issueThreadInteractions,
|
|
issueWorkProducts,
|
|
issues,
|
|
projectWorkspaces,
|
|
projectAccessMembers,
|
|
projects,
|
|
workspaceOperations,
|
|
toolApplications,
|
|
toolConnections,
|
|
toolOauthStates,
|
|
} 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,
|
|
readIssueCommentRunLogText,
|
|
} from "../services/issues.ts";
|
|
import { getRunLogStore } from "../services/run-log-store.js";
|
|
import {
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
} from "../services/execution-workspace-policy.ts";
|
|
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService run attachment artifacts", () => {
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("registers a run-produced attachment as an attachment-backed artifact work product", async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-attachment-artifact-");
|
|
const db = createDb(tempDb.connectionString);
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "ART",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "ArtifactAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Artifact registration",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
|
|
|
|
const attachment = await issueService(db).createAttachment({
|
|
issueId,
|
|
issueCommentId: null,
|
|
provider: "local_disk",
|
|
objectKey: "issues/artifact/screenshot.png",
|
|
contentType: "image/png",
|
|
byteSize: 128,
|
|
sha256: "a".repeat(64),
|
|
originalFilename: "screenshot.png",
|
|
createdByAgentId: agentId,
|
|
createdByRunId: runId,
|
|
});
|
|
|
|
const artifact = await db
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.externalId, attachment.id))
|
|
.then((rows) => rows[0]);
|
|
expect(artifact).toMatchObject({
|
|
companyId,
|
|
issueId,
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
title: "screenshot.png",
|
|
createdByRunId: runId,
|
|
metadata: {
|
|
attachmentId: attachment.id,
|
|
contentType: "image/png",
|
|
byteSize: 128,
|
|
contentPath: `/api/attachments/${attachment.id}/content`,
|
|
openPath: `/api/attachments/${attachment.id}/content`,
|
|
downloadPath: `/api/attachments/${attachment.id}/content?download=1`,
|
|
originalFilename: "screenshot.png",
|
|
},
|
|
});
|
|
}, 20_000);
|
|
});
|
|
|
|
describe("readIssueCommentRunLogText", () => {
|
|
it("cancels timed-out storage reads so later listings can recover", async () => {
|
|
let active = 0;
|
|
const cleanups: Array<() => void> = [];
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation((_handle, options) =>
|
|
new Promise((_resolve, reject) => {
|
|
active += 1;
|
|
let settled = false;
|
|
const abort = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
active -= 1;
|
|
reject(new DOMException("Read aborted", "AbortError"));
|
|
};
|
|
cleanups.push(abort);
|
|
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
}),
|
|
);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
|
|
const firstBatch = Array.from({ length: 8 }, () => readIssueCommentRunLogText(run));
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(await Promise.all(firstBatch)).toEqual(Array(8).fill(""));
|
|
expect(active).toBe(0);
|
|
read.mockResolvedValueOnce({ content: "storage recovered" });
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("storage recovered");
|
|
expect(read).toHaveBeenCalledTimes(9);
|
|
} finally {
|
|
for (const cleanup of cleanups) cleanup();
|
|
await Promise.allSettled(firstBatch);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
vi.useRealTimers();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("keeps readable attribution evidence for concurrent listings", async () => {
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation(async () => {
|
|
await gate;
|
|
return { content: "comment id: legacy-comment" };
|
|
});
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
|
|
const listings = Array.from({ length: 2 }, () =>
|
|
Promise.all(Array.from({ length: 8 }, () => readIssueCommentRunLogText(run))),
|
|
);
|
|
try {
|
|
release();
|
|
for (const listing of listings) {
|
|
expect(await listing).toEqual(Array(8).fill("comment id: legacy-comment"));
|
|
}
|
|
expect(read).toHaveBeenCalledTimes(16);
|
|
} finally {
|
|
release();
|
|
await Promise.allSettled(listings);
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([null, 128])("keeps partial attribution evidence when storage fails with logBytes=%s", async (logBytes) => {
|
|
const read = vi.spyOn(getRunLogStore(), "read").mockRejectedValue(new Error("Storage gateway unavailable"));
|
|
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes };
|
|
try {
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("");
|
|
read.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 });
|
|
await expect(readIssueCommentRunLogText(run)).resolves.toBe("earlier evidence");
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("bounds reads that ignore cancellation and stops late pagination after the deadline", async () => {
|
|
let release!: (value: { content: string; nextOffset: number }) => void;
|
|
const stalled = new Promise<{ content: string; nextOffset: number }>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const read = vi.spyOn(getRunLogStore(), "read")
|
|
.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 })
|
|
.mockReturnValueOnce(stalled);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
let result: string | undefined;
|
|
const pending = readIssueCommentRunLogText({
|
|
runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null,
|
|
}).then((value) => { result = value; });
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(result).toBe("earlier evidence");
|
|
expect(read.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
|
|
release({ content: "too late", nextOffset: 24 });
|
|
await pending;
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(result).toBe("earlier evidence");
|
|
} finally {
|
|
release({ content: "", nextOffset: 0 });
|
|
await pending.catch(() => {});
|
|
vi.useRealTimers();
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([null, 128, 0])("reads existing attribution markers with logBytes=%s", async (logBytes) => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const read = vi.spyOn(getRunLogStore(), "read")
|
|
.mockResolvedValueOnce({ content: "comment id: ", nextOffset: 12 })
|
|
.mockResolvedValueOnce({ content: commentId + "\n" });
|
|
try {
|
|
const logContent = await readIssueCommentRunLogText({
|
|
runId, logStore: "local_file", logRef: "test/run.ndjson", logBytes,
|
|
});
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2020-01-01T00:00:01Z"),
|
|
}],
|
|
[{
|
|
runId,
|
|
agentId,
|
|
createdAt: new Date("2020-01-01T00:00:00Z"),
|
|
startedAt: new Date("2020-01-01T00:00:00Z"),
|
|
finishedAt: new Date("2020-01-01T00:00:02Z"),
|
|
logContent,
|
|
}],
|
|
);
|
|
if (logBytes === 0) {
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(derived.size).toBe(0);
|
|
} else {
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(read.mock.calls[1]?.[1]?.offset).toBe(12);
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
});
|
|
}
|
|
} finally {
|
|
read.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
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("resolves directly from the comment's own run id without reading logs", () => {
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: runId,
|
|
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: "",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.get(commentId)).toEqual({
|
|
derivedAuthorAgentId: agentId,
|
|
derivedCreatedByRunId: runId,
|
|
derivedAuthorSource: "run_id",
|
|
});
|
|
});
|
|
|
|
it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => {
|
|
// A human board comment can land inside an agent's run window; since both are
|
|
// stored as `local-board`, a timing-only guess would mis-attribute it. So a
|
|
// single overlapping run with no run-id and no log marker stays unresolved.
|
|
const commentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
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: "posted results without echoing the comment id",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("does not guess when multiple agent runs overlap and no log proves the author", () => {
|
|
const commentId = randomUUID();
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
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: "no comment id here",
|
|
},
|
|
{
|
|
runId: randomUUID(),
|
|
agentId: randomUUID(),
|
|
createdAt: new Date("2026-05-11T18:54:00.000Z"),
|
|
startedAt: new Date("2026-05-11T18:54:00.000Z"),
|
|
finishedAt: new Date("2026-05-11T18:56:00.000Z"),
|
|
logContent: "also nothing",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("does NOT attribute on same-agent run-window overlap alone (option A)", () => {
|
|
// Even when every overlapping run is the same agent, timing alone cannot
|
|
// prove the comment was the agent's vs a human board comment during the run.
|
|
const commentId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: null,
|
|
authorUserId: "local-board",
|
|
createdByRunId: null,
|
|
createdAt: new Date("2026-06-29T17:41:59.916Z"),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
runId: randomUUID(),
|
|
agentId,
|
|
createdAt: new Date("2026-06-29T17:41:26.116Z"),
|
|
startedAt: new Date("2026-06-29T17:41:26.116Z"),
|
|
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
|
|
logContent: "no comment id here",
|
|
},
|
|
{
|
|
runId: randomUUID(),
|
|
agentId,
|
|
createdAt: new Date("2026-06-29T17:40:09.531Z"),
|
|
startedAt: new Date("2026-06-29T17:40:09.531Z"),
|
|
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
|
|
logContent: "also nothing",
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(derived.has(commentId)).toBe(false);
|
|
});
|
|
|
|
it("never reattributes a comment that already has a stored agent author", () => {
|
|
const commentId = randomUUID();
|
|
const derived = deriveIssueCommentRunLogAttribution(
|
|
[
|
|
{
|
|
id: commentId,
|
|
authorAgentId: randomUUID(),
|
|
authorUserId: null,
|
|
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: "",
|
|
},
|
|
],
|
|
);
|
|
|
|
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);
|
|
|
|
it("rejects stale monitor cancellation under the task lock and preserves saved policy settings", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Monitor cancellation", issuePrefix: "MCL", requireBoardApprovalForNewAgents: false });
|
|
const oldPolicy = {
|
|
mode: "normal" as const, commentRequired: true, stages: [],
|
|
monitor: { nextCheckAt: "2026-10-10T12:00:00.000Z", notes: "Check status", scheduledBy: "board" as const },
|
|
};
|
|
const savedPolicy = {
|
|
...oldPolicy,
|
|
stages: [{ type: "review" as const, approvalsNeeded: 1 as const, participants: [{ type: "user" as const, userId: "reviewer" }] }],
|
|
maxReviewRounds: 4,
|
|
authorizationPolicy: { assignmentPolicy: { mode: "protected" as const } },
|
|
};
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Monitored task", executionPolicy: oldPolicy });
|
|
await svc.update(issueId, { executionPolicy: savedPolicy });
|
|
const { monitor: _oldMonitor, ...staleCancellation } = oldPolicy;
|
|
await expect(svc.update(issueId, { executionPolicy: staleCancellation, expectedExecutionPolicy: oldPolicy }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(savedPolicy);
|
|
const { monitor: _savedMonitor, ...cancellation } = savedPolicy;
|
|
await svc.update(issueId, { executionPolicy: cancellation, expectedExecutionPolicy: savedPolicy });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
|
|
await expect(svc.update(issueId, { executionPolicy: null, expectedExecutionPolicy: null }))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueComments);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(issueInboxArchives);
|
|
await db.delete(issueReadStates);
|
|
await db.delete(activityLog);
|
|
await db.delete(issues);
|
|
await db.delete(documents);
|
|
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(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedAssignableAgentCompany() {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
return companyId;
|
|
}
|
|
|
|
it("does not treat passive issue activity as touching it, but includes real user mutations", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issueId = randomUUID();
|
|
const userId = "board-user";
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Issue viewed without participation",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
await svc.markRead(companyId, issueId, userId);
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.read_marked",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.file_resource_content_read",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.file_resource_download_denied",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.tree_control_previewed",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
},
|
|
]);
|
|
|
|
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([]);
|
|
|
|
await db.insert(activityLog).values({
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "issue.comment_cancelled",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
});
|
|
|
|
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([
|
|
expect.objectContaining({ id: issueId }),
|
|
]);
|
|
});
|
|
|
|
function agentRow(companyId: string, input: {
|
|
id: string;
|
|
name: string;
|
|
status?: string;
|
|
reportsTo?: string | null;
|
|
}) {
|
|
return {
|
|
id: input.id,
|
|
companyId,
|
|
name: input.name,
|
|
role: "engineer",
|
|
status: input.status ?? "active",
|
|
reportsTo: input.reportsTo ?? null,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
};
|
|
}
|
|
|
|
it("rejects direct terminated assignees with structured conflict details", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const terminatedAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: terminatedAgentId,
|
|
name: "TerminatedCoder",
|
|
status: "terminated",
|
|
}));
|
|
|
|
await expect(svc.create(companyId, {
|
|
title: "Do not assign this",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: terminatedAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId: terminatedAgentId,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const activeAgentId = randomUUID();
|
|
const terminatedManagerId = randomUUID();
|
|
const blockedAgentId = randomUUID();
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }),
|
|
agentRow(companyId, {
|
|
id: terminatedManagerId,
|
|
name: "TerminatedManager",
|
|
status: "terminated",
|
|
}),
|
|
agentRow(companyId, {
|
|
id: blockedAgentId,
|
|
name: "BlockedCoder",
|
|
reportsTo: terminatedManagerId,
|
|
}),
|
|
]);
|
|
const issue = await svc.create(companyId, {
|
|
title: "Keep current assignment",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: activeAgentId,
|
|
});
|
|
|
|
await expect(svc.update(issue.id, {
|
|
assigneeAgentId: blockedAgentId,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "ancestor_terminated",
|
|
assigneeAgentId: blockedAgentId,
|
|
invalidAncestorAgentId: terminatedManagerId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted?.assigneeAgentId).toBe(activeAgentId);
|
|
});
|
|
|
|
it("rejects checkout by a terminated agent before assigning the issue", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const terminatedAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: terminatedAgentId,
|
|
name: "TerminatedCheckoutCoder",
|
|
status: "terminated",
|
|
}));
|
|
const issue = await svc.create(companyId, {
|
|
title: "Checkout must stay unassigned",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
});
|
|
|
|
await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID()))
|
|
.rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId: terminatedAgentId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted).toMatchObject({
|
|
assigneeAgentId: null,
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("expires a pending connection and its OAuth state when reassigned to a user without a status change", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const agentId = randomUUID();
|
|
const userId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, { id: agentId, name: "ConnectionRequester" }));
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member",
|
|
});
|
|
const issue = await svc.create(companyId, {
|
|
title: "Waiting for a connection", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId, name: "Connection app", type: "mcp",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId, applicationId: application!.id, name: "Notion", uid: randomUUID(),
|
|
transport: "mcp_remote", authKind: "oauth",
|
|
}).returning();
|
|
const [interaction] = await db.insert(issueThreadInteractions).values({
|
|
companyId, issueId: issue.id, kind: "connection_intent", status: "pending",
|
|
createdByAgentId: agentId, addresseeUserId: "local-board",
|
|
payload: {
|
|
version: 1, serviceSlug: "notion", serviceName: "Notion",
|
|
requestingAgentId: agentId, requestingAgentName: "ConnectionRequester", phase: "authorizing",
|
|
},
|
|
}).returning();
|
|
const oauthState = randomUUID();
|
|
await db.insert(toolOauthStates).values({
|
|
state: oauthState, companyId, connectionId: connection!.id, issueId: issue.id,
|
|
interactionId: interaction!.id, codeVerifier: "test-verifier",
|
|
createdByActorType: "user", createdByActorId: "local-board",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const updated = await svc.update(issue.id, {
|
|
assigneeAgentId: null, assigneeUserId: userId, actorUserId: "local-board",
|
|
});
|
|
expect(updated).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: userId });
|
|
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id)))
|
|
.resolves.toEqual([expect.objectContaining({
|
|
status: "expired",
|
|
result: { version: 1, outcome: "expired", reason: "The task assignment changed" },
|
|
resolvedAt: expect.any(Date),
|
|
})]);
|
|
await expect(db.select().from(toolOauthStates).where(eq(toolOauthStates.state, oauthState))).resolves.toEqual([]);
|
|
});
|
|
|
|
it("expires pending thread interactions on any service-level terminal transition", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Close me with a pending card",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
// Direct service callers (tree control, recovery, pipelines, status cards)
|
|
// never pass through the HTTP routes, so the expiry must fire here.
|
|
const updated = await svc.update(issue.id, { status: "cancelled", actorUserId: "local-board" });
|
|
expect(updated?.status).toBe("cancelled");
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "expired",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
expect(interaction?.result).toMatchObject({ version: 1, outcome: "issue_closed" });
|
|
expect(interaction?.resolvedAt).not.toBeNull();
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(1);
|
|
// details.source is dot-separated and trips the JWT-shaped redaction
|
|
// heuristic in sanitizeRecord, so assert the identifying fields instead.
|
|
expect(logged[0]?.details).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "ask_user_questions",
|
|
interactionStatus: "expired",
|
|
});
|
|
});
|
|
|
|
it("expires superseded interactions when human comments are added through the service", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Answer with a comment",
|
|
description: null,
|
|
status: "in_review",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
const comment = await svc.addComment(issue.id, "Use option A", { userId: "local-board" });
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "expired",
|
|
resolvedByUserId: "local-board",
|
|
});
|
|
expect(interaction?.result).toMatchObject({
|
|
expirationReason: "superseded_by_comment",
|
|
commentId: comment.id,
|
|
});
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(1);
|
|
expect(logged[0]?.details).toMatchObject({
|
|
interactionId,
|
|
interactionKind: "ask_user_questions",
|
|
interactionStatus: "expired",
|
|
});
|
|
});
|
|
|
|
it("keeps interactions pending when the board concierge adds a comment", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const issue = await svc.create(companyId, {
|
|
title: "Concierge reply",
|
|
description: null,
|
|
status: "in_review",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId: issue.id,
|
|
kind: "ask_user_questions",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
supersedeOnUserComment: true,
|
|
questions: [{
|
|
id: "scope",
|
|
prompt: "Pick one",
|
|
selectionMode: "single",
|
|
options: [{ id: "a", label: "A" }],
|
|
}],
|
|
} as never,
|
|
});
|
|
|
|
await svc.addComment(issue.id, "Automated concierge reply", {
|
|
userId: "board-concierge",
|
|
});
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(interaction).toMatchObject({
|
|
status: "pending",
|
|
resolvedByUserId: null,
|
|
result: null,
|
|
});
|
|
|
|
const logged = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
|
|
expect(logged).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects moving an existing terminated assignment into progress without clearing it", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const assigneeAgentId = randomUUID();
|
|
await db.insert(agents).values(agentRow(companyId, {
|
|
id: assigneeAgentId,
|
|
name: "SoonTerminatedCoder",
|
|
}));
|
|
const issue = await svc.create(companyId, {
|
|
title: "Do not restart after termination",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
});
|
|
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId));
|
|
|
|
await expect(svc.update(issue.id, {
|
|
status: "in_progress",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "agent_not_assignable",
|
|
reason: "assignee_terminated",
|
|
assigneeAgentId,
|
|
},
|
|
});
|
|
|
|
const persisted = await db
|
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
|
.from(issues)
|
|
.where(eq(issues.id, issue.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(persisted).toMatchObject({
|
|
assigneeAgentId,
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("resolves only structured same-company agent mentions", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const otherCompanyId = await seedAssignableAgentCompany();
|
|
const localAgentId = randomUUID();
|
|
const foreignAgentId = randomUUID();
|
|
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
|
|
agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }),
|
|
]);
|
|
|
|
const mentions = await svc.findMentionedAgents(
|
|
companyId,
|
|
[
|
|
`hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`,
|
|
`and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`,
|
|
].join(" "),
|
|
);
|
|
|
|
expect(mentions).toEqual([localAgentId]);
|
|
});
|
|
|
|
it("does not wake agents from raw @name text without a structured mention", async () => {
|
|
const companyId = await seedAssignableAgentCompany();
|
|
const localAgentId = randomUUID();
|
|
|
|
await db.insert(agents).values([
|
|
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
|
|
]);
|
|
|
|
await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this"))
|
|
.resolves.toEqual([]);
|
|
});
|
|
|
|
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("treats assigneeAgentId='null' as an explicit unassigned filter", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const assignedIssueId = randomUUID();
|
|
const unassignedIssueId = 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: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: unassignedIssueId,
|
|
companyId,
|
|
title: "Unassigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { assigneeAgentId: "null" });
|
|
expect(result.map((issue) => issue.id)).toEqual([unassignedIssueId]);
|
|
});
|
|
|
|
it("keeps UUID assignee filtering behavior unchanged", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
const assignedIssueId = 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(agents).values([
|
|
{
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: otherAgentId,
|
|
companyId,
|
|
name: "Other",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
title: "Other issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: otherAgentId,
|
|
},
|
|
]);
|
|
|
|
const result = await svc.list(companyId, { assigneeAgentId });
|
|
expect(result.map((issue) => issue.id)).toEqual([assignedIssueId]);
|
|
});
|
|
|
|
it("rejects malformed assigneeAgentId filter values", async () => {
|
|
const companyId = 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: randomUUID(),
|
|
companyId,
|
|
title: "Any issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await expect(
|
|
svc.list(companyId, { assigneeAgentId: "not-a-uuid" }),
|
|
).rejects.toThrow(/assigneeAgentId/i);
|
|
});
|
|
|
|
it("counts only unassigned issues for assigneeAgentId='null'", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const assignedIssueId = randomUUID();
|
|
const unassignedIssueId = 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: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: unassignedIssueId,
|
|
companyId,
|
|
title: "Unassigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
},
|
|
]);
|
|
|
|
await expect(svc.count(companyId, { assigneeAgentId: "null" })).resolves.toBe(1);
|
|
});
|
|
|
|
it("counts UUID-assigned issues with assigneeAgentId", async () => {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const assignedIssueId = 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(agents).values({
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(issues).values([
|
|
{
|
|
id: assignedIssueId,
|
|
companyId,
|
|
title: "Assigned issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: otherIssueId,
|
|
companyId,
|
|
title: "Other issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await expect(svc.count(companyId, { assigneeAgentId })).resolves.toBe(1);
|
|
});
|
|
|
|
it("rejects malformed assigneeAgentId filter values in count", async () => {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await expect(
|
|
svc.count(companyId, { assigneeAgentId: "not-a-uuid" }),
|
|
).rejects.toThrow(/assigneeAgentId/i);
|
|
});
|
|
|
|
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("filters issues by whether they have a plan document", async () => {
|
|
const companyId = randomUUID();
|
|
const withPlanId = randomUUID();
|
|
const withoutPlanId = randomUUID();
|
|
const otherDocumentId = randomUUID();
|
|
const planDocumentId = 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: withPlanId,
|
|
companyId,
|
|
title: "Issue with plan",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: withoutPlanId,
|
|
companyId,
|
|
title: "Issue without plan",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
await db.insert(documents).values([
|
|
{
|
|
id: planDocumentId,
|
|
companyId,
|
|
title: null,
|
|
format: "markdown",
|
|
latestBody: "# Plan",
|
|
},
|
|
{
|
|
id: otherDocumentId,
|
|
companyId,
|
|
title: "Notes",
|
|
format: "markdown",
|
|
latestBody: "# Notes",
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueDocuments).values([
|
|
{
|
|
companyId,
|
|
issueId: withPlanId,
|
|
documentId: planDocumentId,
|
|
key: "plan",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: withoutPlanId,
|
|
documentId: otherDocumentId,
|
|
key: "notes",
|
|
},
|
|
]);
|
|
|
|
const withPlan = await svc.list(companyId, { hasPlanDocument: true });
|
|
const withoutPlan = await svc.list(companyId, { hasPlanDocument: false });
|
|
|
|
expect(withPlan.map((issue) => issue.id)).toEqual([withPlanId]);
|
|
expect(withoutPlan.map((issue) => issue.id)).toEqual([withoutPlanId]);
|
|
});
|
|
|
|
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 direct description matches ahead of comment-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([descriptionMatchId, commentMatchId]);
|
|
});
|
|
|
|
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);
|
|
await expect(svc.countUnreadTouchedByUser(companyId, userId, ["todo", "in_progress"])).resolves.toBe(1);
|
|
});
|
|
|
|
it("accepts array-form status filters in list and count", async () => {
|
|
const companyId = randomUUID();
|
|
const todoIssueId = randomUUID();
|
|
const inProgressIssueId = randomUUID();
|
|
const doneIssueId = 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: todoIssueId,
|
|
companyId,
|
|
title: "Todo issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: inProgressIssueId,
|
|
companyId,
|
|
title: "In-progress issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: doneIssueId,
|
|
companyId,
|
|
title: "Done issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
},
|
|
]);
|
|
|
|
const resultIds = (await svc.list(companyId, { status: ["todo", "in_progress"] }))
|
|
.map((issue) => issue.id);
|
|
|
|
expect(resultIds).toEqual(expect.arrayContaining([todoIssueId, inProgressIssueId]));
|
|
expect(resultIds).not.toContain(doneIssueId);
|
|
await expect(svc.count(companyId, { status: ["todo", "in_progress"] })).resolves.toBe(2);
|
|
});
|
|
|
|
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 issues only for user-attention events", async () => {
|
|
const companyId = randomUUID();
|
|
const userId = "user-1";
|
|
const otherUserId = "user-2";
|
|
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: "Worker",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
});
|
|
|
|
const issueIds = {
|
|
updatedAt: randomUUID(),
|
|
agentComment: randomUUID(),
|
|
derivedAgentComment: randomUUID(),
|
|
systemComment: randomUUID(),
|
|
suggestTasks: randomUUID(),
|
|
askQuestions: randomUUID(),
|
|
requestConfirmation: randomUUID(),
|
|
inReview: randomUUID(),
|
|
blocked: randomUUID(),
|
|
done: randomUUID(),
|
|
humanComment: randomUUID(),
|
|
mention: randomUUID(),
|
|
unarchived: randomUUID(),
|
|
};
|
|
|
|
await db.insert(issues).values(Object.entries(issueIds).map(([title, id]) => ({
|
|
id,
|
|
companyId,
|
|
title,
|
|
status: "todo",
|
|
priority: "medium" as const,
|
|
createdByUserId: userId,
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
})));
|
|
|
|
const archivedAt = new Date("2026-03-26T12:00:00.000Z");
|
|
for (const issueId of Object.values(issueIds)) {
|
|
await svc.archiveInbox(companyId, issueId, userId, archivedAt);
|
|
}
|
|
|
|
const listVisibleIds = async () => new Set((await svc.list(companyId, {
|
|
touchedByUserId: userId,
|
|
inboxArchivedByUserId: userId,
|
|
})).map((issue) => issue.id));
|
|
|
|
await expect(listVisibleIds()).resolves.toEqual(new Set());
|
|
|
|
await db
|
|
.update(issues)
|
|
.set({ status: "in_progress", updatedAt: new Date("2026-03-26T13:00:00.000Z") })
|
|
.where(eq(issues.id, issueIds.updatedAt));
|
|
|
|
await db.insert(issueComments).values([
|
|
{
|
|
companyId,
|
|
issueId: issueIds.agentComment,
|
|
authorAgentId: agentId,
|
|
body: "Agent progress update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.derivedAgentComment,
|
|
authorUserId: "local-board",
|
|
derivedAuthorAgentId: agentId,
|
|
derivedAuthorSource: "run_log_comment_post",
|
|
body: "Legacy agent-attributed progress update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.systemComment,
|
|
body: "System lifecycle update",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.humanComment,
|
|
authorUserId: otherUserId,
|
|
body: "A human needs your attention",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: issueIds.mention,
|
|
authorAgentId: agentId,
|
|
body: "Please review this, [Viewer](user://user-1)",
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.suggestTasks,
|
|
kind: "suggest_tasks",
|
|
payload: { version: 1, tasks: [{ clientKey: "follow-up", title: "Follow up" }] },
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.askQuestions,
|
|
kind: "ask_user_questions",
|
|
payload: {
|
|
version: 1,
|
|
questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [] }],
|
|
},
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId,
|
|
issueId: issueIds.requestConfirmation,
|
|
kind: "request_confirmation",
|
|
payload: { version: 1, prompt: "Proceed?" },
|
|
createdByAgentId: agentId,
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
});
|
|
|
|
await db.insert(activityLog).values([
|
|
[issueIds.inReview, "in_review"],
|
|
[issueIds.blocked, "blocked"],
|
|
[issueIds.done, "done"],
|
|
].map(([issueId, status]) => ({
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: agentId,
|
|
agentId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
details: { status, _previous: { status: "in_progress" } },
|
|
createdAt: new Date("2026-03-26T13:00:00.000Z"),
|
|
})));
|
|
|
|
await svc.unarchiveInbox(companyId, issueIds.unarchived, userId);
|
|
|
|
const expectedVisible = new Set([
|
|
issueIds.suggestTasks,
|
|
issueIds.askQuestions,
|
|
issueIds.requestConfirmation,
|
|
issueIds.inReview,
|
|
issueIds.blocked,
|
|
issueIds.done,
|
|
issueIds.humanComment,
|
|
issueIds.mention,
|
|
issueIds.unarchived,
|
|
]);
|
|
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
|
|
|
|
await svc.archiveInbox(
|
|
companyId,
|
|
issueIds.humanComment,
|
|
userId,
|
|
new Date("2026-03-26T14:00:00.000Z"),
|
|
);
|
|
expectedVisible.delete(issueIds.humanComment);
|
|
|
|
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
|
|
});
|
|
|
|
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("paginates later comments in ascending 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: "asc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments.map((comment) => comment.id)).toEqual([latestCommentId]);
|
|
});
|
|
|
|
it("returns no comments for an anchor cursor that is not a UUID", async () => {
|
|
const companyId = 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(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Malformed cursor issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
body: "Only comment",
|
|
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
});
|
|
|
|
const comments = await svc.listComments(issueId, {
|
|
afterCommentId: commentId.slice(0, 8),
|
|
order: "asc",
|
|
limit: 50,
|
|
});
|
|
|
|
expect(comments).toEqual([]);
|
|
});
|
|
|
|
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?.descriptionTruncated).toBe(true);
|
|
expect(result?.executionPolicy).toBeNull();
|
|
expect(result?.executionState).toBeNull();
|
|
expect(result?.executionWorkspaceSettings).toBeNull();
|
|
});
|
|
|
|
it("marks list descriptions as not truncated when they fit the preview limit", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const description = "x".repeat(1200);
|
|
|
|
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: "Exact preview issue",
|
|
description,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
expect(result?.description).toHaveLength(1200);
|
|
expect(result?.descriptionTruncated).toBe(false);
|
|
});
|
|
|
|
it("marks null list descriptions as not truncated", async () => {
|
|
const companyId = 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 db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Null description issue",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const [result] = await svc.list(companyId);
|
|
|
|
expect(result?.description).toBeNull();
|
|
expect(result?.descriptionTruncated).toBe(false);
|
|
});
|
|
|
|
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);
|
|
expect(result?.descriptionTruncated).toBe(true);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.findOpenAncestorCreatedByAgent", () => {
|
|
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-ancestor-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
await ensureIssueRelationsTable(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedChain() {
|
|
const companyId = randomUUID();
|
|
const delegatorId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Chain Co",
|
|
issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: delegatorId,
|
|
companyId,
|
|
name: "Delegator",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
const rootId = randomUUID();
|
|
const midId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootId,
|
|
companyId,
|
|
title: "Root task",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
issueNumber: 1,
|
|
identifier: "CHAIN-1",
|
|
},
|
|
{
|
|
id: midId,
|
|
companyId,
|
|
title: "Delegated review",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId: rootId,
|
|
createdByAgentId: delegatorId,
|
|
issueNumber: 2,
|
|
identifier: "CHAIN-2",
|
|
},
|
|
]);
|
|
return { companyId, delegatorId, rootId, midId };
|
|
}
|
|
|
|
it("finds an open ancestor created by the agent, at any depth", async () => {
|
|
const { delegatorId, midId } = await seedChain();
|
|
|
|
const direct = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
|
|
expect(direct?.id).toBe(midId);
|
|
|
|
const other = await svc.findOpenAncestorCreatedByAgent(midId, randomUUID());
|
|
expect(other).toBeNull();
|
|
});
|
|
|
|
it("ignores closed ancestors created by the agent", async () => {
|
|
const { delegatorId, midId } = await seedChain();
|
|
await db.update(issues).set({ status: "done" }).where(eq(issues.id, midId));
|
|
|
|
expect(await svc.findOpenAncestorCreatedByAgent(midId, delegatorId)).toBeNull();
|
|
});
|
|
|
|
it("finds the ancestor through an arbitrarily deep chain", async () => {
|
|
const { companyId, delegatorId, midId } = await seedChain();
|
|
// Extend the chain 60 levels below the delegator-created ancestor so the
|
|
// match sits far above the new child's parent.
|
|
let parentId = midId;
|
|
for (let index = 0; index < 60; index += 1) {
|
|
const childId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: childId,
|
|
companyId,
|
|
title: `Deep child ${index}`,
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
parentId,
|
|
issueNumber: index + 10,
|
|
identifier: `CHAIN-${index + 10}`,
|
|
});
|
|
parentId = childId;
|
|
}
|
|
|
|
const found = await svc.findOpenAncestorCreatedByAgent(parentId, delegatorId);
|
|
expect(found?.id).toBe(midId);
|
|
});
|
|
|
|
it("terminates on a corrupted parent-graph cycle", async () => {
|
|
const { delegatorId, rootId, midId } = await seedChain();
|
|
// Corrupt the graph: root's parent points back at mid.
|
|
await db.update(issues).set({ parentId: midId }).where(eq(issues.id, rootId));
|
|
|
|
const found = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
|
|
expect(found?.id).toBe(midId);
|
|
expect(await svc.findOpenAncestorCreatedByAgent(midId, randomUUID())).toBeNull();
|
|
});
|
|
});
|
|
|
|
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(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(environments);
|
|
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("inherits responsible user for agent-created child issues", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const responsibleUserId = 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: "Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const parent = await svc.create(companyId, {
|
|
title: "Parent issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
createdByUserId: responsibleUserId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
responsibleUserId,
|
|
contextSnapshot: { issueId: parent.id },
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parent.id,
|
|
title: "Agent-created child",
|
|
createdByAgentId: agentId,
|
|
actorRunId: runId,
|
|
});
|
|
|
|
expect(parent.responsibleUserId).toBe(responsibleUserId);
|
|
expect(child.responsibleUserId).toBe(responsibleUserId);
|
|
});
|
|
|
|
it("creates children under a private issue in the same private subtree", async () => {
|
|
const companyId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Private subtree company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
title: "Private root",
|
|
visibility: "private",
|
|
privacyRootIssueId: parentIssueId,
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
title: "Inherited private child",
|
|
visibility: "open",
|
|
});
|
|
|
|
expect(child.visibility).toBe("private");
|
|
expect(child.privacyRootIssueId).toBe(parentIssueId);
|
|
});
|
|
|
|
it("creates one personal private project and parks private tasks there", async () => {
|
|
const companyId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Personal private tasks company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
svc.create(companyId, {
|
|
title: "First personal private task",
|
|
visibility: "private",
|
|
createdByUserId: responsibleUserId,
|
|
}),
|
|
svc.create(companyId, {
|
|
title: "Second personal private task",
|
|
visibility: "private",
|
|
createdByUserId: responsibleUserId,
|
|
}),
|
|
]);
|
|
|
|
const personalProjects = await db.select().from(projects)
|
|
.where(eq(projects.personalOwnerUserId, responsibleUserId));
|
|
expect(personalProjects).toHaveLength(1);
|
|
expect(personalProjects[0]).toMatchObject({
|
|
companyId,
|
|
name: "My private tasks",
|
|
visibility: "private",
|
|
});
|
|
expect(first.projectId).toBe(personalProjects[0]!.id);
|
|
expect(second.projectId).toBe(personalProjects[0]!.id);
|
|
expect(first.privacyRootIssueId).toBe(first.id);
|
|
expect(second.privacyRootIssueId).toBe(second.id);
|
|
await expect(db.select().from(projectAccessMembers)
|
|
.where(eq(projectAccessMembers.projectId, personalProjects[0]!.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
|
]);
|
|
});
|
|
|
|
it("keeps a responsible human and grants an agent access to its explicitly private task", async () => {
|
|
const companyId = randomUUID();
|
|
const responsibleUserId = randomUUID();
|
|
const creatorAgentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Agent private task company",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: creatorAgentId,
|
|
companyId,
|
|
name: "Private task creator",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Agent email scratch task",
|
|
visibility: "private",
|
|
createdByAgentId: creatorAgentId,
|
|
actorResponsibleUserId: responsibleUserId,
|
|
});
|
|
|
|
expect(issue.responsibleUserId).toBe(responsibleUserId);
|
|
const personalProject = await db.select().from(projects)
|
|
.where(eq(projects.id, issue.projectId!))
|
|
.then((rows) => rows[0]!);
|
|
expect(personalProject.personalOwnerUserId).toBe(responsibleUserId);
|
|
await expect(db.select().from(projectAccessMembers)
|
|
.where(eq(projectAccessMembers.projectId, personalProject.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
|
]);
|
|
await expect(db.select().from(issueAccessGrants)
|
|
.where(eq(issueAccessGrants.issueId, issue.id)))
|
|
.resolves.toEqual([
|
|
expect.objectContaining({
|
|
subjectType: "agent",
|
|
subjectId: creatorAgentId,
|
|
source: "explicit",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
|
|
const companyId = randomUUID();
|
|
const creatorUserId = randomUUID();
|
|
const requestedResponsibleUserId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const untrusted = await svc.create(companyId, {
|
|
title: "Untrusted explicit responsible user",
|
|
createdByUserId: creatorUserId,
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
});
|
|
const trusted = await svc.create(companyId, {
|
|
title: "Trusted explicit responsible user",
|
|
createdByUserId: creatorUserId,
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
trustExplicitResponsibleUserId: true,
|
|
});
|
|
|
|
expect(untrusted.responsibleUserId).toBe(creatorUserId);
|
|
expect(trusted.responsibleUserId).toBe(requestedResponsibleUserId);
|
|
});
|
|
|
|
it("derives responsible user from authenticated actor context without trusting issue body", async () => {
|
|
const companyId = randomUUID();
|
|
const actorResponsibleUserId = randomUUID();
|
|
const requestedResponsibleUserId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Actor-context responsible user",
|
|
responsibleUserId: requestedResponsibleUserId,
|
|
actorResponsibleUserId,
|
|
});
|
|
|
|
expect(issue.responsibleUserId).toBe(actorResponsibleUserId);
|
|
});
|
|
|
|
it("does not stamp the assignee default environment onto new issues", 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",
|
|
});
|
|
});
|
|
|
|
it("ignores legacy project environment selection when creating new issues", 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",
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
|
});
|
|
|
|
it("does not rewrite execution workspace settings 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).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
|
|
expect(reassigned).not.toBeNull();
|
|
expect(reassigned!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
it("strips legacy environmentId values from execution workspace settings updates", 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",
|
|
});
|
|
|
|
const overridden = await svc.update(created.id, {
|
|
executionWorkspaceSettings: {
|
|
mode: "shared_workspace",
|
|
environmentId: operatorEnvironmentId,
|
|
},
|
|
});
|
|
expect(overridden!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
|
|
const reassigned = await svc.update(created.id, {
|
|
assigneeAgentId: secondAgentId,
|
|
});
|
|
expect(reassigned!.executionWorkspaceSettings).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
});
|
|
|
|
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("createChild preserves strategy-only workspace intent without realizing the parent workspace", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const environmentId = 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,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Accepted plan parent",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
environmentId,
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/master",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
},
|
|
},
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Accepted plan child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceInheritanceMode: "strategy_only",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBeNull();
|
|
expect(child.executionWorkspacePreference).toBeNull();
|
|
expect(child.executionWorkspaceSettings).toEqual({
|
|
mode: "isolated_workspace",
|
|
environmentId,
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/master",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("createChild targeting another project does not forward the parent project workspace", async () => {
|
|
const companyId = randomUUID();
|
|
const parentProjectId = randomUUID();
|
|
const targetProjectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const targetProjectWorkspaceId = 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: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
|
|
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
name: "paperclip",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: targetProjectWorkspaceId,
|
|
companyId,
|
|
projectId: targetProjectId,
|
|
name: "paperclip-id",
|
|
isPrimary: true,
|
|
},
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Google Workspace MCP",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree", baseRef: "origin/master" },
|
|
},
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentIssueId, {
|
|
title: "Implement the Paperclip ID connect broker",
|
|
status: "todo",
|
|
priority: "medium",
|
|
projectId: targetProjectId,
|
|
executionWorkspaceInheritanceMode: "strategy_only",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(targetProjectId);
|
|
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
|
|
});
|
|
|
|
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(workspaceOperations);
|
|
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();
|
|
});
|
|
|
|
async function seedSharedWorkspaceDependency() {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
const dependentId = randomUUID();
|
|
const foreignIssueId = 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: "QA",
|
|
role: "qa",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Shared workspace project",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Shared workspace",
|
|
sourceType: "local_path",
|
|
visibility: "default",
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Shared exec workspace",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15043",
|
|
title: "Predecessor",
|
|
status: "done",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
{
|
|
id: dependentId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15046",
|
|
title: "Dependent",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
{
|
|
id: foreignIssueId,
|
|
companyId,
|
|
projectId,
|
|
identifier: "PAP-15125",
|
|
title: "Foreign in-flight issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
},
|
|
]);
|
|
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
|
|
|
return {
|
|
companyId,
|
|
assigneeAgentId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
};
|
|
}
|
|
|
|
it("returns authoritative update receipts for row fields and blocker relations", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const blockerId = 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: "Receipt issue",
|
|
description: "old description",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
]);
|
|
|
|
const fieldUpdate = await svc.update(issueId, {
|
|
title: "Receipt issue",
|
|
priority: "high",
|
|
description: "new description",
|
|
});
|
|
expect(fieldUpdate?.changes).toEqual({
|
|
priority: { from: "medium", to: "high" },
|
|
description: { from: "old description", to: "new description", updated: true },
|
|
});
|
|
|
|
const blockersSet = await svc.update(issueId, { blockedByIssueIds: [blockerId, blockerId] });
|
|
expect(blockersSet?.blockedByIssueIds).toEqual([blockerId]);
|
|
expect(blockersSet?.changes.blockedByIssueIds).toEqual({ from: [], to: [blockerId] });
|
|
|
|
const blockersCleared = await svc.update(issueId, { blockedByIssueIds: [] });
|
|
expect(blockersCleared?.blockedByIssueIds).toEqual([]);
|
|
expect(blockersCleared?.changes.blockedByIssueIds).toEqual({ from: [blockerId], to: [] });
|
|
|
|
await db.update(issues).set({
|
|
title: "Concurrent receipt issue",
|
|
priority: "medium",
|
|
}).where(eq(issues.id, issueId));
|
|
const [titleUpdate, priorityUpdate] = await Promise.all([
|
|
svc.update(issueId, { title: "Concurrent title" }),
|
|
svc.update(issueId, { priority: "high" }),
|
|
]);
|
|
expect(titleUpdate?.changes).toEqual({
|
|
title: { from: "Concurrent receipt issue", to: "Concurrent title" },
|
|
});
|
|
expect(priorityUpdate?.changes).toEqual({
|
|
priority: { from: "medium", to: "high" },
|
|
});
|
|
});
|
|
|
|
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("returns blocked-by summaries on newly created issues", 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();
|
|
await db.insert(issues).values({
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
});
|
|
|
|
const created = await svc.create(companyId, {
|
|
title: "Blocked issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
expect(created.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
expect(created.blockedBy[0]).toEqual(expect.objectContaining({
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
}));
|
|
expect(created.blocks).toEqual([]);
|
|
});
|
|
|
|
it("returns blocked-by summaries on newly created child issues", 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 parentId = randomUUID();
|
|
const blockerId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: blockerId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "high",
|
|
},
|
|
]);
|
|
|
|
const { issue: child } = await svc.createChild(parentId, {
|
|
title: "Blocked child issue",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
blockedByIssueIds: [blockerId],
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentId);
|
|
expect(child.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
|
expect(child.blocks).toEqual([]);
|
|
});
|
|
|
|
it("returns blocks summaries when child creation blocks the parent", 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 parentId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: parentId,
|
|
companyId,
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const { issue: child } = await svc.createChild(parentId, {
|
|
title: "Parent-blocking child",
|
|
status: "todo",
|
|
priority: "medium",
|
|
blockParentUntilDone: true,
|
|
});
|
|
|
|
expect(child.blocks.map((relation) => relation.id)).toEqual([parentId]);
|
|
expect(child.blocks[0]).toEqual(expect.objectContaining({
|
|
title: "Parent",
|
|
status: "todo",
|
|
priority: "medium",
|
|
}));
|
|
expect(child.blockedBy).toEqual([]);
|
|
});
|
|
|
|
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("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: foreignIssueId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
unresolvedBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it("keeps dependents blocked on unattributed workspace operations for the blocker workspace", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: false,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
});
|
|
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
unresolvedBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
|
|
const {
|
|
companyId,
|
|
executionWorkspaceId,
|
|
blockerId,
|
|
dependentId,
|
|
foreignIssueId,
|
|
assigneeAgentId,
|
|
} = await seedSharedWorkspaceDependency();
|
|
|
|
// The blocker touched its workspace but has not yet recorded
|
|
// workspace_finalize — the dependent must NOT wake.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: blockerId,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
|
});
|
|
|
|
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: false,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
});
|
|
|
|
// A failed finalize must keep the gate closed.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: blockerId,
|
|
phase: "workspace_finalize",
|
|
status: "failed",
|
|
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
|
});
|
|
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
|
const pendingDependent = (await svc.list(companyId, { status: "blocked" }))
|
|
.find((issue) => issue.id === dependentId);
|
|
expect(pendingDependent?.blockerAttention).toMatchObject({
|
|
state: "needs_attention",
|
|
unresolvedBlockerCount: 1,
|
|
attentionBlockerCount: 1,
|
|
pendingFinalizeBlockerIssueIds: [blockerId],
|
|
sampleBlockerIdentifier: "PAP-15043",
|
|
});
|
|
await expect(
|
|
svc.checkout(dependentId, assigneeAgentId, ["blocked"], null),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: {
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockers: [{
|
|
issueId: blockerId,
|
|
identifier: "PAP-15043",
|
|
title: "Predecessor",
|
|
reason: "pending_finalize",
|
|
}],
|
|
},
|
|
});
|
|
|
|
// A later successful finalize on the same workspace, even when attributed
|
|
// to another issue, proves the shared branch is coherent past the failure.
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: foreignIssueId,
|
|
phase: "workspace_finalize",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
|
});
|
|
await db.insert(workspaceOperations).values({
|
|
companyId,
|
|
executionWorkspaceId,
|
|
issueId: null,
|
|
phase: "worktree_prepare",
|
|
status: "succeeded",
|
|
startedAt: new Date("2026-05-23T22:15:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
assigneeAgentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
|
isDependencyReady: true,
|
|
pendingFinalizeBlockerIssueIds: [],
|
|
});
|
|
});
|
|
|
|
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", 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: "QA",
|
|
role: "qa",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const blockerId = randomUUID();
|
|
const dependentId = randomUUID();
|
|
await db.insert(issues).values([
|
|
// Done blocker with no execution workspace ever attached (e.g. closed manually).
|
|
{ id: blockerId, companyId, title: "Manual done blocker", status: "done", priority: "medium" },
|
|
{
|
|
id: dependentId,
|
|
companyId,
|
|
title: "Dependent",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId,
|
|
},
|
|
]);
|
|
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
|
|
|
// No executionWorkspaceId → no barrier → dependent should be wakeable.
|
|
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: dependentId,
|
|
assigneeAgentId,
|
|
blockerIssueIds: [blockerId],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
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, identifier: "PAP-1", 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,
|
|
details: {
|
|
unresolvedBlockerIssueIds: [blockerId],
|
|
unresolvedBlockers: [{
|
|
issueId: blockerId,
|
|
identifier: "PAP-1",
|
|
title: "Blocker",
|
|
reason: "not_done",
|
|
}],
|
|
},
|
|
});
|
|
});
|
|
|
|
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",
|
|
// Give the children distinct, ordered issue numbers. The service
|
|
// sorts direct children by issueNumber, then createdAt. A batched
|
|
// insert gives every row in the statement the same defaultNow()
|
|
// createdAt, so without a distinct issueNumber the two children
|
|
// tie on both sort keys and the database is free to return them
|
|
// in either order.
|
|
issueNumber: 1,
|
|
},
|
|
{
|
|
id: childB,
|
|
companyId,
|
|
parentId,
|
|
title: "Child B",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
issueNumber: 2,
|
|
},
|
|
]);
|
|
|
|
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("preserves the parent project when a generic child create inherits workspace linkage", 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,
|
|
});
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
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",
|
|
},
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
title: "Generic child issue",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(projectId);
|
|
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
});
|
|
|
|
it("uses the target project's own workspaces for a cross-project child instead of inheriting the parent's", async () => {
|
|
const companyId = randomUUID();
|
|
const parentProjectId = randomUUID();
|
|
const targetProjectId = randomUUID();
|
|
const parentIssueId = randomUUID();
|
|
const parentProjectWorkspaceId = randomUUID();
|
|
const targetProjectWorkspaceId = 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: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
|
|
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(projectWorkspaces).values([
|
|
{
|
|
id: parentProjectWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
name: "paperclip",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: targetProjectWorkspaceId,
|
|
companyId,
|
|
projectId: targetProjectId,
|
|
name: "paperclip-id",
|
|
isPrimary: true,
|
|
},
|
|
]);
|
|
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Issue worktree",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: parentIssueId,
|
|
companyId,
|
|
projectId: parentProjectId,
|
|
projectWorkspaceId: parentProjectWorkspaceId,
|
|
title: "Google Workspace MCP",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
});
|
|
|
|
const child = await svc.create(companyId, {
|
|
parentId: parentIssueId,
|
|
projectId: targetProjectId,
|
|
title: "Implement the Paperclip ID connect broker",
|
|
});
|
|
|
|
expect(child.parentId).toBe(parentIssueId);
|
|
expect(child.projectId).toBe(targetProjectId);
|
|
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
|
|
expect(child.executionWorkspaceId).not.toBe(executionWorkspaceId);
|
|
});
|
|
|
|
it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => {
|
|
const companyId = 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 expect(svc.create(companyId, {
|
|
title: "Projectless isolated worktree",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
details: {
|
|
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => {
|
|
const companyId = 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 });
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Ambiguous inherited worktree",
|
|
status: "todo",
|
|
priority: "medium",
|
|
executionWorkspaceSettings: {
|
|
mode: "inherit",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
});
|
|
|
|
expect(issue.executionWorkspaceSettings).toEqual({
|
|
mode: "inherit",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
});
|
|
});
|
|
|
|
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, {
|
|
title: "Follow-up issue",
|
|
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
|
});
|
|
|
|
expect(followUp.parentId).toBeNull();
|
|
expect(followUp.projectId).toBe(projectId);
|
|
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
|
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
|
expect(followUp.executionWorkspaceSettings).toEqual({
|
|
mode: "operator_branch",
|
|
});
|
|
});
|
|
|
|
it("derives project identity when an update adds workspace linkage to a projectless issue", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = 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",
|
|
isPrimary: true,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Projectless issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
const updated = await svc.update(issueId, {
|
|
projectWorkspaceId,
|
|
});
|
|
|
|
expect(updated?.projectId).toBe(projectId);
|
|
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
|
|
});
|
|
|
|
it("rejects updates that pin a projectless issue to an isolated git worktree", 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Workspace Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
|
|
const issue = await svc.create(companyId, {
|
|
title: "Assign then isolate",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await expect(svc.update(issue.id, {
|
|
assigneeAgentId: agentId,
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
|
details: {
|
|
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
|
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
|
|
},
|
|
});
|
|
});
|
|
|
|
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).toMatchObject({
|
|
config: {
|
|
environmentId: null,
|
|
provisionCommand: "bash ./scripts/provision-new.sh",
|
|
runtimeProvisionCommand: null,
|
|
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,
|
|
]);
|
|
});
|
|
|
|
it("returns multiple same-company mentions in order, deduped", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const firstProjectId = randomUUID();
|
|
const secondProjectId = randomUUID();
|
|
const thirdProjectId = 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: firstProjectId, companyId, name: "First project", status: "in_progress" },
|
|
{ id: secondProjectId, companyId, name: "Second project", status: "in_progress" },
|
|
{ id: thirdProjectId, companyId, name: "Third project", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title:
|
|
`See [First](${buildProjectMentionHref(firstProjectId)}) and ` +
|
|
`[Second](${buildProjectMentionHref(secondProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
// Repeats the first mention (deduped) and introduces a third.
|
|
body:
|
|
`Also [First again](${buildProjectMentionHref(firstProjectId)}) and ` +
|
|
`[Third](${buildProjectMentionHref(thirdProjectId)})`,
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
|
|
firstProjectId,
|
|
secondProjectId,
|
|
thirdProjectId,
|
|
]);
|
|
});
|
|
|
|
it("filters out a mention from another company", async () => {
|
|
const companyId = randomUUID();
|
|
const foreignCompanyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const sameCompanyProjectId = randomUUID();
|
|
const foreignProjectId = randomUUID();
|
|
|
|
await db.insert(companies).values([
|
|
{
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
},
|
|
{
|
|
id: foreignCompanyId,
|
|
name: "Other company",
|
|
issuePrefix: `F${foreignCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
},
|
|
]);
|
|
|
|
await db.insert(projects).values([
|
|
{ id: sameCompanyProjectId, companyId, name: "Same-company project", status: "in_progress" },
|
|
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project", status: "in_progress" },
|
|
]);
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title:
|
|
`Ours [Same](${buildProjectMentionHref(sameCompanyProjectId)}) and ` +
|
|
`theirs [Foreign](${buildProjectMentionHref(foreignProjectId)})`,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
|
|
expect(await svc.findMentionedProjectIds(issueId)).toEqual([sameCompanyProjectId]);
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
it("does not clear checkout locks when a different execution run is live", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const runningRunId = 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(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: runningRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Mixed execution lock",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: runningRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.clearCheckoutRunIfTerminal(issueId)).resolves.toBe(false);
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row?.checkoutRunId).toBe(failedRunId);
|
|
expect(row?.executionRunId).toBe(runningRunId);
|
|
expect(row?.executionAgentNameKey).toBe("codexcoder");
|
|
expect(row?.executionLockedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("does not let stale release clobber a successor checkout lock", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const releasingRunId = randomUUID();
|
|
const successorRunId = 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(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: releasingRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:06:00.000Z"),
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Race stale release",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: failedRunId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
});
|
|
|
|
const [releaseResult, checkoutResult] = await Promise.allSettled([
|
|
svc.release(issueId, agentId, releasingRunId),
|
|
svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId),
|
|
]);
|
|
|
|
expect(checkoutResult.status).toBe("fulfilled");
|
|
if (releaseResult.status === "rejected") {
|
|
expect(releaseResult.reason).toMatchObject({ status: 409 });
|
|
}
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: successorRunId,
|
|
executionRunId: successorRunId,
|
|
});
|
|
});
|
|
|
|
it("checkout refuses to promote a 'done' issue when 'done' is not in expectedStatuses, even with a lingering executionRunId pointer", async () => {
|
|
// Regression for PR #2482 checkout-adoption review finding: the original
|
|
// patch's stale-executionRunId adoption SQL set `status: 'in_progress'`
|
|
// unconditionally, bypassing the caller's expectedStatuses guard. With the
|
|
// guard restored, attempting to take over a 'done' issue with
|
|
// expectedStatuses=['todo'] must fail and leave the row untouched.
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
const successorRunId = 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(heartbeatRuns).values([
|
|
{
|
|
id: failedRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale lock on done issue",
|
|
status: "done",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: failedRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
completedAt: new Date("2026-06-10T10:01:00.000Z"),
|
|
});
|
|
|
|
await expect(svc.checkout(issueId, agentId, ["todo"], successorRunId))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "done",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: null,
|
|
});
|
|
});
|
|
|
|
it("checkout adoption of a stale checkoutRunId preserves the issue's assigneeUserId", async () => {
|
|
// Regression for PR #2482 checkout-adoption review finding: any adoption
|
|
// helper that re-locks an existing in_progress issue (e.g. when the prior
|
|
// checkout/execution run is terminal) must not strip the row's
|
|
// assigneeUserId. We exercise this via the adoptStaleCheckoutRun path,
|
|
// which fires when checkoutRunId points at a terminal run while
|
|
// executionRunId still points at a different, non-terminal run.
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const userId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedCheckoutRunId = randomUUID();
|
|
const queuedExecutionRunId = randomUUID();
|
|
const successorRunId = 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(heartbeatRuns).values([
|
|
{
|
|
id: failedCheckoutRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
invocationSource: "manual",
|
|
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
|
|
},
|
|
{
|
|
id: queuedExecutionRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "queued",
|
|
invocationSource: "manual",
|
|
},
|
|
{
|
|
id: successorRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "manual",
|
|
startedAt: new Date("2026-06-10T10:07:00.000Z"),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale checkout lock with user co-assignee",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: userId,
|
|
checkoutRunId: failedCheckoutRunId,
|
|
executionRunId: queuedExecutionRunId,
|
|
executionAgentNameKey: "codexcoder",
|
|
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
|
|
});
|
|
|
|
const result = await svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId);
|
|
expect(result).toBeTruthy();
|
|
|
|
const row = await db
|
|
.select({
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toMatchObject({
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
assigneeUserId: userId,
|
|
checkoutRunId: successorRunId,
|
|
executionRunId: successorRunId,
|
|
});
|
|
});
|
|
});
|
|
|
|
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?: IssueWorkMode;
|
|
}) {
|
|
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);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adoption", () => {
|
|
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-checkout-owner-");
|
|
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 seedOwnershipIssue(params: {
|
|
checkoutStatus: "running" | "failed" | "timed_out";
|
|
actorRunStatus?: "running" | "failed" | "timed_out" | "succeeded";
|
|
assigneeMatchesActor?: boolean;
|
|
}) {
|
|
const companyId = randomUUID();
|
|
const assigneeAgentId = randomUUID();
|
|
const actorAgentId = params.assigneeMatchesActor === false ? randomUUID() : assigneeAgentId;
|
|
const staleRunId = randomUUID();
|
|
const actorRunId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const actorRunStatus = params.actorRunStatus ?? "running";
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
const agentRows = [
|
|
{
|
|
id: assigneeAgentId,
|
|
companyId,
|
|
name: "Assignee",
|
|
role: "engineer" as const,
|
|
status: "active" as const,
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
];
|
|
if (actorAgentId !== assigneeAgentId) {
|
|
agentRows.push({
|
|
id: actorAgentId,
|
|
companyId,
|
|
name: "Actor",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
}
|
|
await db.insert(agents).values(agentRows);
|
|
await db.insert(heartbeatRuns).values([
|
|
{
|
|
id: staleRunId,
|
|
companyId,
|
|
agentId: assigneeAgentId,
|
|
status: params.checkoutStatus,
|
|
invocationSource: "manual",
|
|
finishedAt: params.checkoutStatus === "running" ? null : new Date(),
|
|
},
|
|
{
|
|
id: actorRunId,
|
|
companyId,
|
|
agentId: actorAgentId,
|
|
status: actorRunStatus,
|
|
invocationSource: "manual",
|
|
startedAt: actorRunStatus === "running" ? new Date() : null,
|
|
finishedAt: actorRunStatus === "running" ? null : new Date(),
|
|
},
|
|
]);
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Checkout owner recovery",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
assigneeAgentId,
|
|
checkoutRunId: staleRunId,
|
|
executionRunId: staleRunId,
|
|
executionLockedAt: new Date(),
|
|
executionAgentNameKey: "assignee",
|
|
});
|
|
|
|
return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId };
|
|
}
|
|
|
|
it("lets the current assignee adopt a stale terminal checkout owner", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
|
|
|
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.executionRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
});
|
|
|
|
it("treats timed_out checkout owners as stale and recoverable", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "timed_out" });
|
|
|
|
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
});
|
|
|
|
it("does not allow non-assignees to adopt stale checkout ownership", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", assigneeMatchesActor: false });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("keeps live checkout owners protected with a 409 conflict", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "running" });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("does not let terminal actor runs adopt stale checkout ownership", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", actorRunStatus: "succeeded" });
|
|
|
|
await expect(
|
|
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
|
|
).rejects.toMatchObject({ status: 409 });
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, seeded.issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
});
|
|
});
|
|
|
|
it("adopts unowned checkout after a concurrent stale-checkout clear wins the lock race", async () => {
|
|
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
executionRunId: seeded.actorRunId,
|
|
executionLockedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, seeded.issueId));
|
|
|
|
const rowLocked = deferred<void>();
|
|
const clearCanCommit = deferred<void>();
|
|
|
|
const concurrentClear = db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select ${issues.id} from ${issues} where ${issues.id} = ${seeded.issueId} for update`,
|
|
);
|
|
rowLocked.resolve();
|
|
await clearCanCommit.promise;
|
|
await tx
|
|
.update(issues)
|
|
.set({
|
|
checkoutRunId: null,
|
|
executionRunId: seeded.actorRunId,
|
|
executionLockedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issues.id, seeded.issueId));
|
|
});
|
|
|
|
await rowLocked.promise;
|
|
|
|
const ownershipPromise = svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
clearCanCommit.resolve();
|
|
await concurrentClear;
|
|
|
|
const ownership = await ownershipPromise;
|
|
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.executionRunId).toBe(seeded.actorRunId);
|
|
expect(ownership.adoptedFromRunId).toBeNull();
|
|
|
|
const row = await db
|
|
.select({
|
|
checkoutRunId: issues.checkoutRunId,
|
|
executionRunId: issues.executionRunId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, seeded.issueId))
|
|
.then((rows) => rows[0]);
|
|
expect(row).toEqual({
|
|
checkoutRunId: seeded.actorRunId,
|
|
executionRunId: seeded.actorRunId,
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
describeEmbeddedPostgres("issueService.addComment createdByRunId", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof issueService>;
|
|
let singleConnectionDb!: ReturnType<typeof createDb>;
|
|
let singleConnectionSvc!: ReturnType<typeof issueService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let companyId!: string;
|
|
let agentId!: string;
|
|
let issueId!: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-comment-runid-");
|
|
db = createDb(tempDb.connectionString);
|
|
svc = issueService(db);
|
|
singleConnectionDb = createDb(tempDb.connectionString, { maxConnections: 1 });
|
|
singleConnectionSvc = issueService(singleConnectionDb);
|
|
|
|
companyId = randomUUID();
|
|
agentId = randomUUID();
|
|
issueId = 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: "TestAgent",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Test issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
}, 20_000);
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createdByRunIdFor(commentId: string) {
|
|
return db
|
|
.select({ createdByRunId: issueComments.createdByRunId })
|
|
.from(issueComments)
|
|
.where(eq(issueComments.id, commentId))
|
|
.then((rows) => rows[0]?.createdByRunId ?? null);
|
|
}
|
|
|
|
it("keeps addComment transaction reads on a single pooled connection", async () => {
|
|
const comment = await singleConnectionDb.transaction(async (tx) =>
|
|
singleConnectionSvc.addComment(
|
|
issueId,
|
|
"transaction-safe comment",
|
|
{},
|
|
{ authorType: "system" },
|
|
tx,
|
|
),
|
|
);
|
|
|
|
expect(comment.body).toBe("transaction-safe comment");
|
|
});
|
|
|
|
it("nulls out a non-UUID x-paperclip-run-id instead of 500-ing", async () => {
|
|
const comment = await svc.addComment(issueId, "hello from a synthetic run id", {
|
|
runId: "client-request-abc123",
|
|
});
|
|
|
|
expect(comment.id).toBeTruthy();
|
|
expect(await createdByRunIdFor(comment.id)).toBeNull();
|
|
});
|
|
|
|
it("nulls out a UUID runId absent from heartbeat_runs instead of 500-ing", async () => {
|
|
const comment = await svc.addComment(issueId, "hello from a stale run", {
|
|
runId: randomUUID(),
|
|
});
|
|
|
|
expect(comment.id).toBeTruthy();
|
|
expect(await createdByRunIdFor(comment.id)).toBeNull();
|
|
});
|
|
|
|
it("preserves a valid runId that exists in heartbeat_runs for the company", async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
|
|
const comment = await svc.addComment(issueId, "hello from a live run", { runId });
|
|
|
|
expect(await createdByRunIdFor(comment.id)).toBe(runId);
|
|
});
|
|
|
|
it("deduplicates concurrent identical comments from the same run", async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
svc.addComment(issueId, "one durable result", { agentId, runId }),
|
|
svc.addComment(issueId, "one durable result", { agentId, runId }),
|
|
]);
|
|
|
|
expect(second.id).toBe(first.id);
|
|
const duplicates = await db
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(eq(issueComments.createdByRunId, runId));
|
|
expect(duplicates).toHaveLength(1);
|
|
});
|
|
});
|