Merge public-gh/master into feature/plugin-runtime-instance-cleanup
This commit is contained in:
@@ -97,7 +97,11 @@ function requestBaseUrl(req: Request) {
|
||||
|
||||
function readSkillMarkdown(skillName: string): string | null {
|
||||
const normalized = skillName.trim().toLowerCase();
|
||||
if (normalized !== "paperclip" && normalized !== "paperclip-create-agent")
|
||||
if (
|
||||
normalized !== "paperclip" &&
|
||||
normalized !== "paperclip-create-agent" &&
|
||||
normalized !== "para-memory-files"
|
||||
)
|
||||
return null;
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
@@ -1610,6 +1614,10 @@ export function accessRoutes(
|
||||
res.json({
|
||||
skills: [
|
||||
{ name: "paperclip", path: "/api/skills/paperclip" },
|
||||
{
|
||||
name: "para-memory-files",
|
||||
path: "/api/skills/para-memory-files"
|
||||
},
|
||||
{
|
||||
name: "paperclip-create-agent",
|
||||
path: "/api/skills/paperclip-create-agent"
|
||||
|
||||
@@ -575,6 +575,34 @@ export function agentRoutes(db: Db) {
|
||||
res.json({ ...agent, chainOfCommand });
|
||||
});
|
||||
|
||||
router.get("/agents/me/inbox-lite", async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||
res.status(401).json({ error: "Agent authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
assigneeAgentId: req.actor.agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
});
|
||||
|
||||
res.json(
|
||||
rows.map((issue) => ({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
updatedAt: issue.updatedAt,
|
||||
activeRun: issue.activeRun,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
@@ -1275,6 +1303,7 @@ export function agentRoutes(db: Db) {
|
||||
contextSnapshot: {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
forceFreshSession: req.body.forceFreshSession === true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
documentService,
|
||||
logActivity,
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
@@ -28,6 +31,8 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = issueService(db);
|
||||
@@ -37,6 +42,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
@@ -291,7 +297,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
||||
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId
|
||||
@@ -300,6 +306,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.findMentionedProjectIds(issue.id),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
@@ -308,12 +315,226 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
...documentPayload,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/heartbeat-context", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
const wakeCommentId =
|
||||
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
|
||||
? req.query.wakeCommentId.trim()
|
||||
: null;
|
||||
|
||||
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.getCommentCursor(issue.id),
|
||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
]);
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
updatedAt: issue.updatedAt,
|
||||
},
|
||||
ancestors: ancestors.map((ancestor) => ({
|
||||
id: ancestor.id,
|
||||
identifier: ancestor.identifier,
|
||||
title: ancestor.title,
|
||||
status: ancestor.status,
|
||||
priority: ancestor.priority,
|
||||
})),
|
||||
project: project
|
||||
? {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
targetDate: project.targetDate,
|
||||
}
|
||||
: null,
|
||||
goal: goal
|
||||
? {
|
||||
id: goal.id,
|
||||
title: goal.title,
|
||||
status: goal.status,
|
||||
level: goal.level,
|
||||
parentId: goal.parentId,
|
||||
}
|
||||
: null,
|
||||
commentCursor,
|
||||
wakeComment:
|
||||
wakeComment && wakeComment.issueId === issue.id
|
||||
? wakeComment
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const docs = await documentsSvc.listIssueDocuments(issue.id);
|
||||
res.json(docs);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents/:key", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
|
||||
if (!doc) {
|
||||
res.status(404).json({ error: "Document not found" });
|
||||
return;
|
||||
}
|
||||
res.json(doc);
|
||||
});
|
||||
|
||||
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const result = await documentsSvc.upsertIssueDocument({
|
||||
issueId: issue.id,
|
||||
key: keyParsed.data,
|
||||
title: req.body.title ?? null,
|
||||
format: req.body.format,
|
||||
body: req.body.body,
|
||||
changeSummary: req.body.changeSummary ?? null,
|
||||
baseRevisionId: req.body.baseRevisionId ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
const doc = result.document;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: result.created ? "issue.document_created" : "issue.document_updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: doc.key,
|
||||
documentId: doc.id,
|
||||
title: doc.title,
|
||||
format: doc.format,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(result.created ? 201 : 200).json(doc);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
|
||||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.delete("/issues/:id/documents/:key", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Document not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.document_deleted",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: removed.key,
|
||||
documentId: removed.id,
|
||||
title: removed.title,
|
||||
},
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
@@ -791,7 +1012,29 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const comments = await svc.listComments(id);
|
||||
const afterCommentId =
|
||||
typeof req.query.after === "string" && req.query.after.trim().length > 0
|
||||
? req.query.after.trim()
|
||||
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
|
||||
? req.query.afterCommentId.trim()
|
||||
: null;
|
||||
const order =
|
||||
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
|
||||
? "asc"
|
||||
: "desc";
|
||||
const limitRaw =
|
||||
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
|
||||
? Number(req.query.limit)
|
||||
: null;
|
||||
const limit =
|
||||
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
|
||||
: null;
|
||||
const comments = await svc.listComments(id, {
|
||||
afterCommentId,
|
||||
order,
|
||||
limit,
|
||||
});
|
||||
res.json(comments);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user