Submatter/apps/orchestrator/src/db/convoy.ts

163 lines
4.3 KiB
TypeScript
Raw Normal View History

import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
import type { db as dbInstance } from "@tasks/database";
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
import {
briefObjectContentSchema,
convoyObjectContentSchema,
validateConvoyTransition,
type ConvoyStatus,
} from "@tasks/shared/convoy-lifecycle";
import type { BriefSummary } from "@tasks/shared/orchestrator";
type Db = typeof dbInstance;
export type LoadedConvoy = {
id: string;
workspaceId: string;
title: string;
status: ConvoyStatus;
skipFlags: string[];
briefs: BriefSummary[];
completedRoles: Set<string>;
completedBriefIds: Set<string>;
};
function parseStatus(raw: string | null): ConvoyStatus {
const status = raw ?? "draft";
return status as ConvoyStatus;
}
export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
const rows = await db
.select()
.from(objects)
.where(
and(
eq(objects.type, "project"),
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
inArray(objects.status, ["draft", "ready", "in_progress"]),
),
);
const loaded: LoadedConvoy[] = [];
for (const row of rows) {
const content = convoyObjectContentSchema.safeParse(row.content);
if (!content.success) continue;
const briefRows = await db
.select()
.from(objects)
.where(and(eq(objects.parentId, row.id), eq(objects.workspaceId, row.workspaceId)));
const events = await db
.select({ role: convoyEvents.role, eventType: convoyEvents.eventType, briefId: convoyEvents.briefId })
.from(convoyEvents)
.where(eq(convoyEvents.convoyId, row.id))
.orderBy(desc(convoyEvents.occurredAt))
.limit(200);
const completedRoles = new Set<string>();
const completedBriefIds = new Set<string>();
for (const event of events) {
if (event.eventType === "role_complete") {
completedRoles.add(event.role);
if (event.briefId) completedBriefIds.add(event.briefId);
}
}
const briefs: BriefSummary[] = briefRows.map((brief) => {
const parsed = briefObjectContentSchema.safeParse(brief.content);
return {
id: brief.id,
briefNumber: parsed.success ? parsed.data.briefNumber : brief.sortOrder,
status: brief.status,
sliceDependencies: parsed.success ? parsed.data.sliceDependencies : [],
};
});
loaded.push({
id: row.id,
workspaceId: row.workspaceId,
title: row.title,
status: parseStatus(row.status),
skipFlags: content.data.skipFlags,
briefs,
completedRoles,
completedBriefIds,
});
}
return loaded;
}
export async function transitionConvoyInDb(
db: Db,
input: {
workspaceId: string;
convoyId: string;
toStatus: ConvoyStatus;
actor: string;
reason?: string;
},
) {
const [convoy] = await db
.select()
.from(objects)
.where(
and(
eq(objects.id, input.convoyId),
eq(objects.workspaceId, input.workspaceId),
eq(objects.type, "project"),
),
)
.limit(1);
if (!convoy) {
throw new Error(`Convoy ${input.convoyId} not found`);
}
const fromStatus = parseStatus(convoy.status);
validateConvoyTransition({
from: fromStatus,
to: input.toStatus,
actor: input.actor,
reason: input.reason,
});
await db
.update(objects)
.set({ status: input.toStatus, updatedAt: new Date() })
.where(eq(objects.id, convoy.id));
await db.insert(convoyEvents).values({
workspaceId: input.workspaceId,
convoyId: convoy.id,
role: input.actor,
actor: input.actor,
eventType: "status_change",
metadata: {
from: fromStatus,
to: input.toStatus,
reason: input.reason ?? null,
source: "orchestrator",
},
});
}
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
const openRuns = await db
.select({
backlogItemId: agentRuns.backlogItemId,
startedAt: agentRuns.startedAt,
})
.from(agentRuns)
.where(and(isNull(agentRuns.finishedAt), lt(agentRuns.startedAt, cutoff)))
.limit(50);
if (openRuns.length === 0) return [];
return openRuns.map((run) => run.backlogItemId);
}