Files
paperclip/server/src/services/issues.ts
T
Forgotten c2709687b8 feat: server-side issue search, dashboard charts, and inbox badges
Add ILIKE-based issue search across title, identifier, description,
and comments with relevance ranking. Add assigneeUserId filter and
allow agents to return issues to creator. Show assigned issue count
in sidebar badges. Add minCount param to live-runs endpoint. Add
activity charts (run activity, priority, status, success rate) to
dashboard. Improve active agents panel with recent run cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:33:39 -06:00

1053 lines
36 KiB
TypeScript

import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import {
agents,
assets,
companies,
companyMemberships,
goals,
heartbeatRuns,
issueAttachments,
issueLabels,
issueComments,
issues,
labels,
projectWorkspaces,
projects,
} from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
function assertTransition(from: string, to: string) {
if (from === to) return;
if (!ALL_ISSUE_STATUSES.includes(to)) {
throw conflict(`Unknown issue status: ${to}`);
}
}
function applyStatusSideEffects(
status: string | undefined,
patch: Partial<typeof issues.$inferInsert>,
): Partial<typeof issues.$inferInsert> {
if (!status) return patch;
if (status === "in_progress" && !patch.startedAt) {
patch.startedAt = new Date();
}
if (status === "done") {
patch.completedAt = new Date();
}
if (status === "cancelled") {
patch.cancelledAt = new Date();
}
return patch;
}
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
projectId?: string;
labelId?: string;
q?: string;
}
type IssueRow = typeof issues.$inferSelect;
type IssueLabelRow = typeof labels.$inferSelect;
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
}
const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
if (issueIds.length === 0) return map;
const rows = await dbOrTx
.select({
issueId: issueLabels.issueId,
label: labels,
})
.from(issueLabels)
.innerJoin(labels, eq(issueLabels.labelId, labels.id))
.where(inArray(issueLabels.issueId, issueIds))
.orderBy(asc(labels.name), asc(labels.id));
for (const row of rows) {
const existing = map.get(row.issueId);
if (existing) existing.push(row.label);
else map.set(row.issueId, [row.label]);
}
return map;
}
async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWithLabels[]> {
if (rows.length === 0) return [];
const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id));
return rows.map((row) => {
const issueLabels = labelsByIssueId.get(row.id) ?? [];
return {
...row,
labels: issueLabels,
labelIds: issueLabels.map((label) => label.id),
};
});
}
export function issueService(db: Db) {
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
id: agents.id,
companyId: agents.companyId,
status: agents.status,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!assignee) throw notFound("Assignee agent not found");
if (assignee.companyId !== companyId) {
throw unprocessable("Assignee must belong to same company");
}
if (assignee.status === "pending_approval") {
throw conflict("Cannot assign work to pending approval agents");
}
if (assignee.status === "terminated") {
throw conflict("Cannot assign work to terminated agents");
}
}
async function assertAssignableUser(companyId: string, userId: string) {
const membership = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows[0] ?? null);
if (!membership) {
throw notFound("Assignee user not found");
}
}
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
if (labelIds.length === 0) return;
const existing = await dbOrTx
.select({ id: labels.id })
.from(labels)
.where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)));
if (existing.length !== new Set(labelIds).size) {
throw unprocessable("One or more labels are invalid for this company");
}
}
async function syncIssueLabels(
issueId: string,
companyId: string,
labelIds: string[],
dbOrTx: any = db,
) {
const deduped = [...new Set(labelIds)];
await assertValidLabelIds(companyId, deduped, dbOrTx);
await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
if (deduped.length === 0) return;
await dbOrTx.insert(issueLabels).values(
deduped.map((labelId) => ({
issueId,
labelId,
companyId,
})),
);
}
async function isTerminalOrMissingHeartbeatRun(runId: string) {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) return true;
return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status);
}
async function adoptStaleCheckoutRun(input: {
issueId: string;
actorAgentId: string;
actorRunId: string;
expectedCheckoutRunId: string;
}) {
const stale = await isTerminalOrMissingHeartbeatRun(input.expectedCheckoutRunId);
if (!stale) return null;
const now = new Date();
const adopted = await db
.update(issues)
.set({
checkoutRunId: input.actorRunId,
executionRunId: input.actorRunId,
executionLockedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, input.issueId),
eq(issues.status, "in_progress"),
eq(issues.assigneeAgentId, input.actorAgentId),
eq(issues.checkoutRunId, input.expectedCheckoutRunId),
),
)
.returning({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.then((rows) => rows[0] ?? null);
return adopted;
}
return {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
const startsWithPattern = `${escapedSearch}%`;
const containsPattern = `%${escapedSearch}%`;
const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
const commentContainsMatch = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
if (filters?.status) {
const statuses = filters.status.split(",").map((s) => s.trim());
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
}
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
.from(issueLabels)
.where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId)));
if (labeledIssueIds.length === 0) return [];
conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
}
if (hasSearch) {
conditions.push(
or(
titleContainsMatch,
identifierContainsMatch,
descriptionContainsMatch,
commentContainsMatch,
)!,
);
}
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const searchOrder = sql<number>`
CASE
WHEN ${titleStartsWithMatch} THEN 0
WHEN ${titleContainsMatch} THEN 1
WHEN ${identifierStartsWithMatch} THEN 2
WHEN ${identifierContainsMatch} THEN 3
WHEN ${descriptionContainsMatch} THEN 4
WHEN ${commentContainsMatch} THEN 5
ELSE 6
END
`;
const rows = await db
.select()
.from(issues)
.where(and(...conditions))
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
return withIssueLabels(db, rows);
},
getById: async (id: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
},
getByIdentifier: async (identifier: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
},
create: async (
companyId: string,
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
) => {
const { labelIds: inputLabelIds, ...issueData } = data;
if (data.assigneeAgentId && data.assigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
if (data.assigneeAgentId) {
await assertAssignableAgent(companyId, data.assigneeAgentId);
}
if (data.assigneeUserId) {
await assertAssignableUser(companyId, data.assigneeUserId);
}
if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
.where(eq(companies.id, companyId))
.returning({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix });
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}
if (values.status === "done") {
values.completedAt = new Date();
}
if (values.status === "cancelled") {
values.cancelledAt = new Date();
}
const [issue] = await tx.insert(issues).values(values).returning();
if (inputLabelIds) {
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
}
const [enriched] = await withIssueLabels(tx, [issue]);
return enriched;
});
},
update: async (id: string, data: Partial<typeof issues.$inferInsert> & { labelIds?: string[] }) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const { labelIds: nextLabelIds, ...issueData } = data;
if (issueData.status) {
assertTransition(existing.status, issueData.status);
}
const patch: Partial<typeof issues.$inferInsert> = {
...issueData,
updatedAt: new Date(),
};
const nextAssigneeAgentId =
issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
const nextAssigneeUserId =
issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId;
if (nextAssigneeAgentId && nextAssigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
if (issueData.assigneeAgentId) {
await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
}
if (issueData.assigneeUserId) {
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
}
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {
patch.completedAt = null;
}
if (issueData.status && issueData.status !== "cancelled") {
patch.cancelledAt = null;
}
if (issueData.status && issueData.status !== "in_progress") {
patch.checkoutRunId = null;
}
if (
(issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) ||
(issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId)
) {
patch.checkoutRunId = null;
}
return db.transaction(async (tx) => {
const updated = await tx
.update(issues)
.set(patch)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
if (nextLabelIds !== undefined) {
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
}
const [enriched] = await withIssueLabels(tx, [updated]);
return enriched;
});
},
remove: (id: string) =>
db.transaction(async (tx) => {
const attachmentAssetIds = await tx
.select({ assetId: issueAttachments.assetId })
.from(issueAttachments)
.where(eq(issueAttachments.issueId, id));
const removedIssue = await tx
.delete(issues)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (removedIssue && attachmentAssetIds.length > 0) {
await tx
.delete(assets)
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
}
if (!removedIssue) return null;
const [enriched] = await withIssueLabels(tx, [removedIssue]);
return enriched;
}),
checkout: async (id: string, agentId: string, expectedStatuses: string[], checkoutRunId: string | null) => {
const issueCompany = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!issueCompany) throw notFound("Issue not found");
await assertAssignableAgent(issueCompany.companyId, agentId);
const now = new Date();
const sameRunAssigneeCondition = checkoutRunId
? and(
eq(issues.assigneeAgentId, agentId),
or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, checkoutRunId)),
)
: and(eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId));
const executionLockCondition = checkoutRunId
? or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId))
: isNull(issues.executionRunId);
const updated = await db
.update(issues)
.set({
assigneeAgentId: agentId,
assigneeUserId: null,
checkoutRunId,
executionRunId: checkoutRunId,
status: "in_progress",
startedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, id),
inArray(issues.status, expectedStatuses),
or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition),
executionLockCondition,
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
const [enriched] = await withIssueLabels(db, [updated]);
return enriched;
}
const current = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Issue not found");
if (
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
current.checkoutRunId == null &&
(current.executionRunId == null || current.executionRunId === checkoutRunId) &&
checkoutRunId
) {
const adopted = await db
.update(issues)
.set({
checkoutRunId,
executionRunId: checkoutRunId,
updatedAt: new Date(),
})
.where(
and(
eq(issues.id, id),
eq(issues.status, "in_progress"),
eq(issues.assigneeAgentId, agentId),
isNull(issues.checkoutRunId),
or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId)),
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (adopted) return adopted;
}
if (
checkoutRunId &&
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
current.checkoutRunId &&
current.checkoutRunId !== checkoutRunId
) {
const adopted = await adoptStaleCheckoutRun({
issueId: id,
actorAgentId: agentId,
actorRunId: checkoutRunId,
expectedCheckoutRunId: current.checkoutRunId,
});
if (adopted) {
const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
}
// If this run already owns it and it's in_progress, return it (no self-409)
if (
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
sameRunLock(current.checkoutRunId, checkoutRunId)
) {
const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
throw conflict("Issue checkout conflict", {
issueId: current.id,
status: current.status,
assigneeAgentId: current.assigneeAgentId,
checkoutRunId: current.checkoutRunId,
executionRunId: current.executionRunId,
});
},
assertCheckoutOwner: async (id: string, actorAgentId: string, actorRunId: string | null) => {
const current = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Issue not found");
if (
current.status === "in_progress" &&
current.assigneeAgentId === actorAgentId &&
sameRunLock(current.checkoutRunId, actorRunId)
) {
return { ...current, adoptedFromRunId: null as string | null };
}
if (
actorRunId &&
current.status === "in_progress" &&
current.assigneeAgentId === actorAgentId &&
current.checkoutRunId &&
current.checkoutRunId !== actorRunId
) {
const adopted = await adoptStaleCheckoutRun({
issueId: id,
actorAgentId,
actorRunId,
expectedCheckoutRunId: current.checkoutRunId,
});
if (adopted) {
return {
...adopted,
adoptedFromRunId: current.checkoutRunId,
};
}
}
throw conflict("Issue run ownership conflict", {
issueId: current.id,
status: current.status,
assigneeAgentId: current.assigneeAgentId,
checkoutRunId: current.checkoutRunId,
actorAgentId,
actorRunId,
});
},
release: async (id: string, actorAgentId?: string, actorRunId?: string | null) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
throw conflict("Only assignee can release issue");
}
if (
actorAgentId &&
existing.status === "in_progress" &&
existing.assigneeAgentId === actorAgentId &&
existing.checkoutRunId &&
!sameRunLock(existing.checkoutRunId, actorRunId ?? null)
) {
throw conflict("Only checkout run can release issue", {
issueId: existing.id,
assigneeAgentId: existing.assigneeAgentId,
checkoutRunId: existing.checkoutRunId,
actorRunId: actorRunId ?? null,
});
}
const updated = await db
.update(issues)
.set({
status: "todo",
assigneeAgentId: null,
checkoutRunId: null,
updatedAt: new Date(),
})
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
const [enriched] = await withIssueLabels(db, [updated]);
return enriched;
},
listLabels: (companyId: string) =>
db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)),
getLabelById: (id: string) =>
db
.select()
.from(labels)
.where(eq(labels.id, id))
.then((rows) => rows[0] ?? null),
createLabel: async (companyId: string, data: Pick<typeof labels.$inferInsert, "name" | "color">) => {
const [created] = await db
.insert(labels)
.values({
companyId,
name: data.name.trim(),
color: data.color,
})
.returning();
return created;
},
deleteLabel: async (id: string) =>
db
.delete(labels)
.where(eq(labels.id, id))
.returning()
.then((rows) => rows[0] ?? null),
listComments: (issueId: string) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt)),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
return db
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
})
.returning()
.then((rows) => rows[0]);
},
createAttachment: async (input: {
issueId: string;
issueCommentId?: string | null;
provider: string;
objectKey: string;
contentType: string;
byteSize: number;
sha256: string;
originalFilename?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
if (input.issueCommentId) {
const comment = await db
.select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId })
.from(issueComments)
.where(eq(issueComments.id, input.issueCommentId))
.then((rows) => rows[0] ?? null);
if (!comment) throw notFound("Issue comment not found");
if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) {
throw unprocessable("Attachment comment must belong to same issue and company");
}
}
return db.transaction(async (tx) => {
const [asset] = await tx
.insert(assets)
.values({
companyId: issue.companyId,
provider: input.provider,
objectKey: input.objectKey,
contentType: input.contentType,
byteSize: input.byteSize,
sha256: input.sha256,
originalFilename: input.originalFilename ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
})
.returning();
const [attachment] = await tx
.insert(issueAttachments)
.values({
companyId: issue.companyId,
issueId: issue.id,
assetId: asset.id,
issueCommentId: input.issueCommentId ?? null,
})
.returning();
return {
id: attachment.id,
companyId: attachment.companyId,
issueId: attachment.issueId,
issueCommentId: attachment.issueCommentId,
assetId: attachment.assetId,
provider: asset.provider,
objectKey: asset.objectKey,
contentType: asset.contentType,
byteSize: asset.byteSize,
sha256: asset.sha256,
originalFilename: asset.originalFilename,
createdByAgentId: asset.createdByAgentId,
createdByUserId: asset.createdByUserId,
createdAt: attachment.createdAt,
updatedAt: attachment.updatedAt,
};
});
},
listAttachments: async (issueId: string) =>
db
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.issueId, issueId))
.orderBy(desc(issueAttachments.createdAt)),
getAttachmentById: async (id: string) =>
db
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.id, id))
.then((rows) => rows[0] ?? null),
removeAttachment: async (id: string) =>
db.transaction(async (tx) => {
const existing = await tx
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
await tx.delete(assets).where(eq(assets.id, existing.assetId));
return existing;
}),
findMentionedAgents: async (companyId: string, body: string) => {
const re = /\B@([^\s@,!?.]+)/g;
const tokens = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase());
if (tokens.size === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
.from(agents).where(eq(agents.companyId, companyId));
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
},
getAncestors: async (issueId: string) => {
const raw: Array<{
id: string; identifier: string | null; title: string; description: string | null;
status: string; priority: string;
assigneeAgentId: string | null; projectId: string | null; goalId: string | null;
}> = [];
const visited = new Set<string>([issueId]);
const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null);
let currentId = start?.parentId ?? null;
while (currentId && !visited.has(currentId) && raw.length < 50) {
visited.add(currentId);
const parent = await db.select({
id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description,
status: issues.status, priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId,
goalId: issues.goalId, parentId: issues.parentId,
}).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null);
if (!parent) break;
raw.push({
id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null,
status: parent.status, priority: parent.priority,
assigneeAgentId: parent.assigneeAgentId ?? null,
projectId: parent.projectId ?? null, goalId: parent.goalId ?? null,
});
currentId = parent.parentId ?? null;
}
// Batch-fetch referenced projects and goals
const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))];
const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))];
const projectMap = new Map<string, {
id: string;
name: string;
description: string | null;
status: string;
goalId: string | null;
workspaces: Array<{
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
}>;
primaryWorkspace: {
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
} | null;
}>();
const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>();
if (projectIds.length > 0) {
const workspaceRows = await db
.select()
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const workspaceMap = new Map<string, Array<(typeof workspaceRows)[number]>>();
for (const workspace of workspaceRows) {
const existing = workspaceMap.get(workspace.projectId);
if (existing) existing.push(workspace);
else workspaceMap.set(workspace.projectId, [workspace]);
}
const rows = await db.select({
id: projects.id, name: projects.name, description: projects.description,
status: projects.status, goalId: projects.goalId,
}).from(projects).where(inArray(projects.id, projectIds));
for (const r of rows) {
const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
const workspaces = projectWorkspaceRows.map((workspace) => ({
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
name: workspace.name,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl ?? null,
repoRef: workspace.repoRef ?? null,
metadata: (workspace.metadata as Record<string, unknown> | null) ?? null,
isPrimary: workspace.isPrimary,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
}));
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
projectMap.set(r.id, {
...r,
workspaces,
primaryWorkspace,
});
// Also collect goalIds from projects
if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
}
}
if (goalIds.length > 0) {
const rows = await db.select({
id: goals.id, title: goals.title, description: goals.description,
level: goals.level, status: goals.status,
}).from(goals).where(inArray(goals.id, goalIds));
for (const r of rows) goalMap.set(r.id, r);
}
return raw.map(a => ({
...a,
project: a.projectId ? projectMap.get(a.projectId) ?? null : null,
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
}));
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
isNull(issues.hiddenAt),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
};
}