Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/api/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import { emitTerminalThreadStatus } from "./routes/decopilot/thread-status-event
import { SqlThreadStorage } from "../storage/threads";
import { OrganizationBillingStorage } from "../storage/organization-billing";
import { TaskBoardStorage } from "../storage/task-board";
import { boardLanesForDb } from "../tools/task-board/board-handler";
import { advanceTasksToReviewOnThreadFinish } from "../tools/task-board/run-reactions";
import { SqlAsyncResearchJobStorage } from "../storage/async-research-jobs";
import { AsyncResearchJobSweeper } from "../storage/async-research-jobs-sweeper";
Expand Down Expand Up @@ -1131,12 +1132,13 @@ export async function createApp(options: CreateAppOptions = {}) {
// reaches the projector, so this reactor is its only terminal writer — and
// owes the board the pass the projector's own terminals already run. Same
// storages, built here because this wiring precedes theirs.
onThreadFinished: (threadId, orgId) =>
onThreadFinished: async (threadId, orgId) =>
advanceTasksToReviewOnThreadFinish(
new TaskBoardStorage(database.db),
threadId,
orgId,
new OrganizationBillingStorage(database.db),
await boardLanesForDb(database.db, orgId),
),
};

Expand Down Expand Up @@ -1626,6 +1628,7 @@ export async function createApp(options: CreateAppOptions = {}) {
projectorTaskBoard,
automationContextFactory,
projectorBilling,
database.db,
);
if (getSettings().taskBoardReviewSweeperEnabled) {
taskBoardReviewSweeper.start();
Expand Down Expand Up @@ -1684,6 +1687,7 @@ export async function createApp(options: CreateAppOptions = {}) {
runId,
orgId,
projectorBilling,
await boardLanesForDb(database.db, orgId),
);
// The headless reviewer trigger used to be called here and could never
// work: this callback runs inside a DBOS step, and the dispatch bottoms
Expand Down Expand Up @@ -1718,6 +1722,7 @@ export async function createApp(options: CreateAppOptions = {}) {
runId,
orgId,
projectorBilling,
await boardLanesForDb(database.db, orgId),
);
// No reviewer trigger here either — see completeRunIfNotCompleted above
// for why it cannot live in a step. `TaskBoardReviewSweeper` owns it.
Expand Down
18 changes: 14 additions & 4 deletions apps/api/src/jira/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
*/

import { orgFlagEnabled } from "@decocms/shared/organization/schema";
import { boardFor } from "@/tools/task-board/board-handler";
import {
boardAutomationFor,
boardFor,
boardCan,
boardLanes,
} from "@/tools/task-board/board-handler";
import { SUPER_AGENT_ASSIGNEE_ID } from "@decocms/shared/task-board";
import type { StudioContext } from "@/core/studio-context";
import type {
Expand Down Expand Up @@ -337,18 +342,23 @@ async function maybeAutoDelegate(
// The board decides: a column with no rule on it is uneventful. This is also
// what replaced `integration.autoDelegate`, which could only ever mean the
// Super Agent, on To Do, for an org that had Jira.
const automation = await (await boardFor(ctx, orgId)).automationFor(
item.status,
);
const automation = await boardAutomationFor(ctx, orgId, item.status);
if (!automation) return item;
// Conditional claim, not a plain update: the cron, a webhook wake-up (its
// debounce is per-pod) and a manual JIRA_SYNC_RUN can all be mid-sync on the
// same issue, and a read-then-write would dispatch two paid agent runs on it.
const queue = (await boardLanes(ctx, orgId)).queue;
if (
!boardCan(orgId, "todo", queue, "auto-delegating Jira issues to the agent")
) {
return item;
}
const delegated = await ctx.storage.taskBoard.claimUnassignedForSuperAgent(
item.id,
orgId,
integration.createdBy,
JIRA_SYNC_ACTOR,
queue,
);
if (!delegated) return item;
await ctx.storage.taskBoard.recordActivity({
Expand Down
114 changes: 92 additions & 22 deletions apps/api/src/storage/task-board-advance-review.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ import { TaskBoardStorage } from "./task-board";
import { SqlThreadStorage } from "./threads";
import { SUPER_AGENT_ASSIGNEE_ID } from "@decocms/shared/task-board";

/** Studio's own board, which is what these fixtures run on. */
const CANON_LANES = {
intake: "triage",
queue: "todo",
progress: "in_progress",
review: "in_review",
archive: "archived",
};

const ORG = "org_advance_review";
const USER = "user_advance_review";

Expand Down Expand Up @@ -152,7 +161,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {

await Promise.all(
Array.from({ length: 10 }, () =>
taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG),
taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG,
CANON_LANES,
),
),
);

Expand Down Expand Up @@ -211,7 +224,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
.execute();

// No PR → stays In Progress with nothing to review.
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG,
CANON_LANES,
);
const before = await taskBoard.getById(task.id, ORG);
expect(before?.status).toBe("in_progress");
expect(before?.reviewCycleStartedAt).toBeNull();
Expand All @@ -225,7 +242,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
repoOwner: "acme",
repoName: "site",
});
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG,
CANON_LANES,
);
const after = await taskBoard.getById(task.id, ORG);
expect(after?.status).toBe("in_progress");
expect(after?.reviewCycleStartedAt).not.toBeNull();
Expand All @@ -244,7 +265,7 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {

const winners = await Promise.all(
Array.from({ length: 8 }, () =>
taskBoard.openReviewCycleIfInProgress(task.id, ORG),
taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES),
),
);

Expand All @@ -253,20 +274,28 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {

it("re-opens a cycle only after it is closed", async () => {
const { task } = await cardWithFinishedRun("second round");
const first = await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
const first = await taskBoard.openReviewCycleIfInProgress(
task.id,
ORG,
CANON_LANES,
);
expect(first).not.toBeNull();
const firstAt = first?.reviewCycleStartedAt;

// Still open — the boundary must not move under a standing verdict.
expect(
await taskBoard.openReviewCycleIfInProgress(task.id, ORG),
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES),
).toBeNull();
expect((await taskBoard.getById(task.id, ORG))?.reviewCycleStartedAt).toBe(
firstAt as string,
);

await taskBoard.closeReviewCycle(task.id, ORG);
const second = await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
const second = await taskBoard.openReviewCycleIfInProgress(
task.id,
ORG,
CANON_LANES,
);
expect(second).not.toBeNull();
expect(second?.reviewCycleStartedAt).not.toBe(firstAt as string);
});
Expand Down Expand Up @@ -300,7 +329,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
repoName: "site",
});

await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG,
CANON_LANES,
);

const after = await taskBoard.getById(task.id, ORG);
expect(after?.status).toBe("in_progress");
Expand All @@ -311,9 +344,13 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
// the PR read says — that is the move this whole change exists to prevent.
it("leaves a card with an open cycle where it is", async () => {
const { task, thread } = await cardWithFinishedRun("already reviewing");
await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES);

await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG,
CANON_LANES,
);

expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_progress");
});
Expand All @@ -329,7 +366,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
await taskBoard.advanceToReviewIfInProgress(task.id, ORG, USER);
expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_review");

const opened = await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
const opened = await taskBoard.openReviewCycleIfInProgress(
task.id,
ORG,
CANON_LANES,
);

expect(opened?.status).toBe("in_progress");
expect(opened?.reviewCycleStartedAt).not.toBeNull();
Expand All @@ -340,11 +381,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
// would undo that and re-stamp a boundary verdicts already stand on.
it("leaves an In Review card with a cycle already open alone", async () => {
const { task } = await cardWithFinishedRun("mid review");
await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES);
await taskBoard.update(task.id, ORG, { status: "in_review" }, USER);

expect(
await taskBoard.openReviewCycleIfInProgress(task.id, ORG),
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES),
).toBeNull();
expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_review");
});
Expand All @@ -363,7 +404,7 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
await taskBoard.unassignSuperAgent(task.id, ORG, USER);

expect(
await taskBoard.openReviewCycleIfInProgress(task.id, ORG),
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES),
).toBeNull();
expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_review");
});
Expand All @@ -373,15 +414,19 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
await taskBoard.update(task.id, ORG, { status: "done" }, USER);

expect(
await taskBoard.openReviewCycleIfInProgress(task.id, ORG),
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES),
).toBeNull();
});

it("is org-scoped — another org cannot open the cycle", async () => {
const { task } = await cardWithFinishedRun("cross-org cycle");

expect(
await taskBoard.openReviewCycleIfInProgress(task.id, "org_other"),
await taskBoard.openReviewCycleIfInProgress(
task.id,
"org_other",
CANON_LANES,
),
).toBeNull();
expect(
(await taskBoard.getById(task.id, ORG))?.reviewCycleStartedAt,
Expand All @@ -392,7 +437,7 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {
// reviewer is working reads In Progress and still has to be swept.
it("lists an In Progress card with an open cycle as pending review", async () => {
const { task } = await cardWithFinishedRun("pending while in progress");
await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES);

const pending = await taskBoard.listItemsPendingReview(100);

Expand All @@ -401,7 +446,7 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {

it("drops a card out of the work list once it ships", async () => {
const { task } = await cardWithFinishedRun("shipped, stop sweeping");
await taskBoard.openReviewCycleIfInProgress(task.id, ORG);
await taskBoard.openReviewCycleIfInProgress(task.id, ORG, CANON_LANES);
await taskBoard.update(task.id, ORG, { status: "done" }, USER);

expect(
Expand Down Expand Up @@ -497,7 +542,11 @@ describe("failed runs never reach In Review (real Postgres)", () => {
it("leaves a card whose only run failed In Progress", async () => {
const { task, thread } = await cardWithRun("failed run", "failed");

await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG2);
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(
thread.id,
ORG2,
CANON_LANES,
);

expect((await taskBoard.getById(task.id, ORG2))?.status).toBe(
"in_progress",
Expand Down Expand Up @@ -586,7 +635,15 @@ describe("failed runs never reach In Review (real Postgres)", () => {
const { task } = await cardWithRun("retry me", "failed");
const due = new Date(Date.now() - 1000);

expect(await taskBoard.scheduleRunRetry(task.id, ORG2, 1, due)).toBe(true);
expect(
await taskBoard.scheduleRunRetry(
task.id,
ORG2,
1,
due,
CANON_LANES.progress,
),
).toBe(true);
expect((await taskBoard.getById(task.id, ORG2))?.retryAttempts).toBe(1);
expect(
(await taskBoard.listItemsDueForRetry(10, new Date())).map((r) => r.id),
Expand Down Expand Up @@ -643,6 +700,7 @@ describe("failed runs never reach In Review (real Postgres)", () => {
ORG2,
1,
new Date(Date.now() + 60_000),
CANON_LANES.progress,
);

const stuck = await taskBoard.listItemsStuckAfterFailure(10, new Date());
Expand Down Expand Up @@ -754,19 +812,31 @@ describe("failed runs never reach In Review (real Postgres)", () => {

it("sends an exhausted card back to To Do and clears its retry state", async () => {
const { task } = await cardWithRun("out of retries", "failed");
await taskBoard.scheduleRunRetry(task.id, ORG2, 3, new Date());
await taskBoard.scheduleRunRetry(
task.id,
ORG2,
3,
new Date(),
CANON_LANES.progress,
);

const returned = await taskBoard.returnToTodoAfterFailure(
task.id,
ORG2,
USER2,
CANON_LANES,
);

expect(returned?.status).toBe("todo");
expect(returned?.retryAttempts).toBe(0);
// A card that already left In Progress is not dragged backwards.
expect(
await taskBoard.returnToTodoAfterFailure(task.id, ORG2, USER2),
await taskBoard.returnToTodoAfterFailure(
task.id,
ORG2,
USER2,
CANON_LANES,
),
).toBeNull();
});
});
14 changes: 12 additions & 2 deletions apps/api/src/storage/task-board-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export class BoardColumnStorage {
title: column.title,
position,
role: roleOf.get(column.key) ?? null,
tracker_statuses: column.trackerStatuses,
// Stringified, not handed over as an array: `pg` serialises a JS array
// as a Postgres ARRAY literal (`{a,b}`), which jsonb rejects outright
// — and an EMPTY one as `{}`, which it accepts as an empty OBJECT. So
// the silent case is the dangerous one.
tracker_statuses: JSON.stringify(column.trackerStatuses),
}));
await tx
.insertInto("task_board_columns")
Expand All @@ -126,7 +130,13 @@ export class BoardColumnStorage {
})),
)
.execute();
return rows.map(toEntity);
return all.map((column, position) => ({
key: column.key,
title: column.title,
position,
role: roleOf.get(column.key) ?? null,
trackerStatuses: column.trackerStatuses,
}));
});
}

Expand Down
Loading
Loading