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:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user