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:
@@ -4,6 +4,7 @@ import type { Db } from "@paperclip/db";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { issueService } from "../services/index.js";
|
||||
|
||||
const createActivitySchema = z.object({
|
||||
actorType: z.enum(["agent", "user", "system"]).optional().default("system"),
|
||||
@@ -18,6 +19,7 @@ const createActivitySchema = z.object({
|
||||
export function activityRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = activityService(db);
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
router.get("/companies/:companyId/activity", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -43,5 +45,35 @@ export function activityRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/activity", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.forIssue(id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/runs", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.runsForIssue(id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const result = await svc.issuesForRun(runId);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents as agentsTable, heartbeatRuns } from "@paperclip/db";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
createAgentKeySchema,
|
||||
createAgentSchema,
|
||||
@@ -7,7 +9,7 @@ import {
|
||||
updateAgentSchema,
|
||||
} from "@paperclip/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { agentService, heartbeatService, logActivity } from "../services/index.js";
|
||||
import { agentService, heartbeatService, issueService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
|
||||
@@ -160,6 +162,7 @@ export function agentRoutes(db: Db) {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "agent.created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
@@ -195,6 +198,7 @@ export function agentRoutes(db: Db) {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "agent.updated",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
@@ -349,6 +353,7 @@ export function agentRoutes(db: Db) {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "heartbeat.invoked",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: run.id,
|
||||
@@ -397,6 +402,7 @@ export function agentRoutes(db: Db) {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "heartbeat.invoked",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: run.id,
|
||||
@@ -472,5 +478,77 @@ export function agentRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/live-runs", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issueSvc = issueService(db);
|
||||
const issue = await issueSvc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
const liveRuns = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${id}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/active-run", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issueSvc = issueService(db);
|
||||
const issue = await issueSvc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
if (!issue.assigneeAgentId || issue.status !== "in_progress") {
|
||||
res.json(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await svc.getById(issue.assigneeAgentId);
|
||||
if (!agent) {
|
||||
res.json(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const run = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
|
||||
if (!run) {
|
||||
res.json(null);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
...run,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function getActorInfo(req: Request) {
|
||||
actorType: "agent" as const,
|
||||
actorId: req.actor.agentId ?? "unknown-agent",
|
||||
agentId: req.actor.agentId ?? null,
|
||||
runId: req.actor.runId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,5 +27,6 @@ export function getActorInfo(req: Request) {
|
||||
actorType: "user" as const,
|
||||
actorId: req.actor.userId ?? "board",
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user