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

@@ -29,13 +29,14 @@ export function issueRoutes(db: Db) {
router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
const isIdentifier = /^[A-Z]+-\d+$/i.test(id);
const issue = isIdentifier ? await svc.getByIdentifier(id) : await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const ancestors = await svc.getAncestors(id);
const ancestors = await svc.getAncestors(issue.id);
res.json({ ...issue, ancestors });
});
@@ -55,6 +56,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.created",
entityType: "issue",
entityId: issue.id,
@@ -100,6 +102,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
@@ -118,6 +121,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
@@ -142,16 +146,20 @@ export function issueRoutes(db: Db) {
const assigneeChanged =
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
if (assigneeChanged && issue.assigneeAgentId) {
const reopened =
(existing.status === "done" || existing.status === "cancelled") &&
issue.status !== "done" && issue.status !== "cancelled";
if ((assigneeChanged || reopened) && issue.assigneeAgentId) {
void heartbeat
.wakeup(issue.assigneeAgentId, {
source: "assignment",
source: reopened ? "automation" : "assignment",
triggerDetail: "system",
reason: "issue_assigned",
reason: reopened ? "issue_reopened" : "issue_assigned",
payload: { issueId: issue.id, mutation: "update" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.update" },
contextSnapshot: { issueId: issue.id, source: reopened ? "issue.reopen" : "issue.update" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
}
@@ -180,6 +188,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.deleted",
entityType: "issue",
entityId: issue.id,
@@ -210,6 +219,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.checked_out",
entityType: "issue",
entityId: issue.id,
@@ -252,6 +262,7 @@ export function issueRoutes(db: Db) {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.released",
entityType: "issue",
entityId: released.id,
@@ -282,19 +293,54 @@ export function issueRoutes(db: Db) {
assertCompanyAccess(req, issue.companyId);
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
const isClosed = issue.status === "done" || issue.status === "cancelled";
let reopened = false;
let reopenFromStatus: string | null = null;
let currentIssue = issue;
if (reopenRequested && isClosed) {
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
reopened = true;
reopenFromStatus = issue.status;
currentIssue = reopenedIssue;
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: currentIssue.id,
details: {
status: "todo",
reopened: true,
reopenedFrom: reopenFromStatus,
source: "comment",
},
});
}
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: issue.companyId,
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
entityId: currentIssue.id,
details: { commentId: comment.id },
});
@@ -313,6 +359,32 @@ export function issueRoutes(db: Db) {
}
}).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions"));
if (reopened && currentIssue.assigneeAgentId) {
void heartbeat
.wakeup(currentIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_reopened_via_comment",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
reopenedFrom: reopenFromStatus,
mutation: "comment",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
},
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue reopen comment"));
}
res.status(201).json(comment);
});