Server: migration prompts, structured logging, heartbeat reaping, and issue-run tracking

Replace auto-migrate-if-empty with interactive migration flow that inspects
pending migrations and prompts before applying. Add pino-pretty for structured
console + file logging. Add reapOrphanedRuns to clean up stuck heartbeat runs
on startup and periodically. Track runId through auth middleware, activity logs,
and all mutation routes. Add issue-run cross-reference queries, live-run and
active-run endpoints for issues, issue identifier lookup, reopen-via-comment
flow, and done/cancelled -> todo status transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 09:09:40 -06:00
parent 21b7bc8da0
commit a90063415e
14 changed files with 605 additions and 67 deletions

View File

@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db";
import { activityLog, heartbeatRuns, issues } from "@paperclip/db";
export interface ActivityFilters {
companyId: string;
@@ -10,6 +10,7 @@ export interface ActivityFilters {
}
export function activityService(db: Db) {
const issueIdAsText = sql<string>`${issues.id}::text`;
return {
list: (filters: ActivityFilters) => {
const conditions = [eq(activityLog.companyId, filters.companyId)];
@@ -27,6 +28,58 @@ export function activityService(db: Db) {
return db.select().from(activityLog).where(and(...conditions)).orderBy(desc(activityLog.createdAt));
},
forIssue: (issueId: string) =>
db
.select()
.from(activityLog)
.where(
and(
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, issueId),
),
)
.orderBy(desc(activityLog.createdAt)),
runsForIssue: (issueId: string) =>
db
.selectDistinctOn([activityLog.runId], {
runId: activityLog.runId,
status: heartbeatRuns.status,
agentId: heartbeatRuns.agentId,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource,
})
.from(activityLog)
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
.where(
and(
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, issueId),
isNotNull(activityLog.runId),
),
)
.orderBy(activityLog.runId, desc(heartbeatRuns.createdAt)),
issuesForRun: (runId: string) =>
db
.selectDistinctOn([issueIdAsText], {
issueId: issues.id,
title: issues.title,
status: issues.status,
priority: issues.priority,
})
.from(activityLog)
.innerJoin(issues, eq(activityLog.entityId, issueIdAsText))
.where(
and(
eq(activityLog.runId, runId),
eq(activityLog.entityType, "issue"),
),
)
.orderBy(issueIdAsText),
create: (data: typeof activityLog.$inferInsert) =>
db
.insert(activityLog)