mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
Add blocker relations and dependency wakeups
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2f73346a64
commit
dde4cc070e
18 changed files with 13924 additions and 69 deletions
|
|
@ -120,9 +120,14 @@ describe("issue comment reopen routes", () => {
|
|||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
|
@ -144,10 +149,15 @@ describe("issue comment reopen routes", () => {
|
|||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
});
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
195
server/src/__tests__/issue-dependency-wakeups-routes.test.ts
Normal file
195
server/src/__tests__/issue-dependency-wakeups-routes.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(async () => null),
|
||||
update: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
findMentionedAgents: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
documentService: () => ({
|
||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||
}),
|
||||
executionWorkspaceService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
goalService: () => ({
|
||||
getById: vi.fn(),
|
||||
getDefaultCompanyGoal: vi.fn(),
|
||||
}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: mockWakeup,
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({
|
||||
getById: vi.fn(),
|
||||
listByIds: vi.fn(async () => []),
|
||||
}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue dependency wakeups in issue routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("wakes dependents when the final blocker transitions to done", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-100",
|
||||
title: "Finish blocker",
|
||||
description: null,
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
parentId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
});
|
||||
mockIssueService.update.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-100",
|
||||
title: "Finish blocker",
|
||||
description: null,
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
parentId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
});
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: "issue-2",
|
||||
assigneeAgentId: "agent-2",
|
||||
blockerIssueIds: ["issue-1", "issue-3"],
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-2",
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "issue-2",
|
||||
resolvedBlockerIssueId: "issue-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the parent when all direct children become terminal", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "child-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-101",
|
||||
title: "Last child",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
parentId: "parent-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
});
|
||||
mockIssueService.update.mockResolvedValue({
|
||||
id: "child-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-101",
|
||||
title: "Last child",
|
||||
description: null,
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
parentId: "parent-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
});
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue({
|
||||
id: "parent-1",
|
||||
assigneeAgentId: "agent-9",
|
||||
childIssueIds: ["child-0", "child-1"],
|
||||
});
|
||||
|
||||
const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-9",
|
||||
expect.objectContaining({
|
||||
reason: "issue_children_completed",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "parent-1",
|
||||
completedChildIssueId: "child-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { errorHandler } from "../middleware/index.js";
|
|||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getAncestors: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
findMentionedProjectIds: vi.fn(),
|
||||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
|
|
@ -123,6 +124,7 @@ describe("issue goal context routes", () => {
|
|||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
totalComments: 0,
|
||||
|
|
@ -201,4 +203,33 @@ describe("issue goal context routes", () => {
|
|||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
expect(res.body.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => {
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
identifier: "PAP-580",
|
||||
title: "Finish wakeup plumbing",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.blockedBy).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
identifier: "PAP-580",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
instanceSettings,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issueRelations,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
|
|
@ -24,6 +26,22 @@ import { issueService } from "../services/issues.ts";
|
|||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
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"}`,
|
||||
|
|
@ -39,10 +57,12 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
await ensureIssueRelationsTable(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
|
|
@ -594,10 +614,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
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);
|
||||
|
|
@ -859,3 +881,210 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
await ensureIssueRelationsTable(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const blockedId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
title: "Blocker",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: blockedId,
|
||||
companyId,
|
||||
title: "Blocked issue",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
await svc.update(blockedId, {
|
||||
blockedByIssueIds: [blockerId],
|
||||
});
|
||||
|
||||
const blockerRelations = await svc.getRelationSummaries(blockerId);
|
||||
const blockedRelations = await svc.getRelationSummaries(blockedId);
|
||||
|
||||
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
|
||||
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
||||
});
|
||||
|
||||
it("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" });
|
||||
|
||||
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([
|
||||
{
|
||||
id: blockedIssueId,
|
||||
assigneeAgentId,
|
||||
blockerIssueIds: [blockerA, blockerB],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("wakes parents only when all direct children are terminal", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const parentId = randomUUID();
|
||||
const childA = randomUUID();
|
||||
const childB = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: parentId,
|
||||
companyId,
|
||||
title: "Parent issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
id: childA,
|
||||
companyId,
|
||||
parentId,
|
||||
title: "Child A",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: childB,
|
||||
companyId,
|
||||
parentId,
|
||||
title: "Child B",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
|
||||
|
||||
await svc.update(childB, { status: "cancelled" });
|
||||
|
||||
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({
|
||||
id: parentId,
|
||||
assigneeAgentId,
|
||||
childIssueIds: [childA, childB],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue