Improve agent detail, issue creation, and approvals pages

Expand AgentDetail with heartbeat history and manual trigger controls.
Enhance NewIssueDialog with richer field options. Add agent connection
string retrieval API. Improve issue routes with parent chain resolution.
Clean up Approvals page layout. Update query keys and validators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 20:46:12 -06:00
parent 6dbbf1bbec
commit b95c05a242
10 changed files with 396 additions and 45 deletions

View File

@@ -247,6 +247,13 @@ export function agentRoutes(db: Db) {
res.json(agent);
});
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const keys = await svc.listKeys(id);
res.json(keys);
});
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
@@ -268,6 +275,17 @@ export function agentRoutes(db: Db) {
res.status(201).json(key);
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req);
const keyId = req.params.keyId as string;
const revoked = await svc.revokeKey(keyId);
if (!revoked) {
res.status(404).json({ error: "Key not found" });
return;
}
res.json({ ok: true });
});
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);

View File

@@ -87,7 +87,8 @@ export function issueRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
const issue = await svc.update(id, req.body);
const { comment: commentBody, ...updateFields } = req.body;
const issue = await svc.update(id, updateFields);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
@@ -102,9 +103,43 @@ export function issueRoutes(db: Db) {
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: req.body,
details: updateFields,
});
let comment = null;
if (commentBody) {
comment = await svc.addComment(id, commentBody, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
details: { commentId: comment.id },
});
// @-mention wakeups
svc.findMentionedAgents(issue.companyId, commentBody).then((ids) => {
for (const mentionedId of ids) {
heartbeat.wakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: `Mentioned in comment on issue ${id}`,
payload: { issueId: id, commentId: comment!.id },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: id, commentId: comment!.id, source: "comment.mention" },
}).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent"));
}
}).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions"));
}
const assigneeChanged =
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
if (assigneeChanged && issue.assigneeAgentId) {
@@ -121,7 +156,7 @@ export function issueRoutes(db: Db) {
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
}
res.json(issue);
res.json({ ...issue, comment });
});
router.delete("/issues/:id", async (req, res) => {

View File

@@ -153,6 +153,26 @@ export function agentService(db: Db) {
};
},
listKeys: (id: string) =>
db
.select({
id: agentApiKeys.id,
name: agentApiKeys.name,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.agentId, id)),
revokeKey: async (keyId: string) => {
const rows = await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.id, keyId))
.returning();
return rows[0] ?? null;
},
orgForCompany: async (companyId: string) => {
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
const byManager = new Map<string | null, typeof rows>();

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, issues, issueComments } from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
@@ -55,7 +55,8 @@ export function issueService(db: Db) {
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt));
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`;
return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt));
},
getById: (id: string) =>
@@ -156,6 +157,11 @@ export function issueService(db: Db) {
if (!current) throw notFound("Issue not found");
// If this agent already owns it and it's in_progress, return it (no self-409)
if (current.assigneeAgentId === agentId && current.status === "in_progress") {
return db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
}
throw conflict("Issue checkout conflict", {
issueId: current.id,
status: current.status,