mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge public-gh/master into pap-979-runtime-workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
4d61dbfd34
46 changed files with 3635 additions and 297 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
|
|
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
|
|||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
||||
});
|
||||
|
||||
it("exposes a dedicated agent route for the inbox mine view", async () => {
|
||||
mockIssueService.list.mockResolvedValue([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "run-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/agents/me/inbox/mine")
|
||||
.query({ userId: "board-user" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
|
||||
touchedByUserId: "board-user",
|
||||
inboxArchivedByUserId: "board-user",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
});
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-910",
|
||||
title: "Inbox follow-up",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,28 @@ describe("boardMutationGuard", () => {
|
|||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations when x-forwarded-host matches origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://10.90.10.20:3443")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations when x-forwarded-host does not match origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
.post("/mutate")
|
||||
.set("Host", "127.0.0.1")
|
||||
.set("X-Forwarded-Host", "10.90.10.20:3443")
|
||||
.set("Origin", "https://evil.example.com")
|
||||
.send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not block authenticated agent mutations", async () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({
|
|||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
|
|
@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("interrupts an active run before a combined comment update", async () => {
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
executionRunId: "run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
}));
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "running",
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "heartbeat.cancelled",
|
||||
details: expect.objectContaining({
|
||||
source: "issue_comment_interrupt",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ function parseOrigin(value: string | undefined) {
|
|||
|
||||
function trustedOriginsForRequest(req: Request) {
|
||||
const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase()));
|
||||
const host = req.header("host")?.trim();
|
||||
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = forwardedHost || req.header("host")?.trim();
|
||||
if (host) {
|
||||
origins.add(`http://${host}`.toLowerCase());
|
||||
origins.add(`https://${host}`.toLowerCase());
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
|
|||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
agentMineInboxQuerySchema,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
|
|
@ -1006,6 +1007,23 @@ export function agentRoutes(db: Db) {
|
|||
);
|
||||
});
|
||||
|
||||
router.get("/agents/me/inbox/mine", async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||
res.status(401).json({ error: "Agent authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const query = agentMineInboxQuerySchema.parse(req.query);
|
||||
const issuesSvc = issueService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
touchedByUserId: query.userId,
|
||||
inboxArchivedByUserId: query.userId,
|
||||
status: query.status,
|
||||
});
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
|
|
@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.
|
|||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
interrupt: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
|
|
@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
return true;
|
||||
}
|
||||
|
||||
async function resolveActiveIssueRun(issue: {
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
executionRunId?: string | null;
|
||||
}) {
|
||||
let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
|
||||
|
||||
if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) {
|
||||
const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
|
||||
const activeIssueId =
|
||||
activeRun &&
|
||||
activeRun.contextSnapshot &&
|
||||
typeof activeRun.contextSnapshot === "object" &&
|
||||
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) {
|
||||
runToInterrupt = activeRun;
|
||||
}
|
||||
}
|
||||
|
||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await svc.getByIdentifier(rawId);
|
||||
|
|
@ -714,6 +742,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.json(readState);
|
||||
});
|
||||
|
||||
router.delete("/issues/:id/read", 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;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.read_unmarked",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { userId: req.actor.userId },
|
||||
});
|
||||
res.json({ id: issue.id, removed });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/inbox-archive", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -888,7 +948,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => {
|
||||
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
|
|
@ -918,7 +978,45 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
|
||||
const actor = getActorInfo(req);
|
||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
||||
const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||
const {
|
||||
comment: commentBody,
|
||||
reopen: reopenRequested,
|
||||
interrupt: interruptRequested,
|
||||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
let interruptedRunId: string | null = null;
|
||||
|
||||
if (interruptRequested) {
|
||||
if (!commentBody) {
|
||||
res.status(400).json({ error: "Interrupt is only supported when posting a comment" });
|
||||
return;
|
||||
}
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
|
||||
return;
|
||||
}
|
||||
|
||||
const runToInterrupt = await resolveActiveIssueRun(existing);
|
||||
if (runToInterrupt) {
|
||||
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
|
||||
if (cancelled) {
|
||||
interruptedRunId = cancelled.id;
|
||||
await logActivity(db, {
|
||||
companyId: cancelled.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "heartbeat.cancelled",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: cancelled.id,
|
||||
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenAtRaw !== undefined) {
|
||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||
}
|
||||
|
|
@ -993,6 +1091,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
identifier: issue.identifier,
|
||||
...(commentBody ? { source: "comment" } : {}),
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
_previous: hasFieldChanges ? previous : undefined,
|
||||
},
|
||||
});
|
||||
|
|
@ -1019,6 +1118,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
...(hasFieldChanges ? { updated: true } : {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -1040,10 +1140,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
mutation: "update",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.update" },
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
source: "issue.update",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1052,10 +1160,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_status_changed",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
mutation: "update",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
source: "issue.status_change",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1348,28 +1464,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
return;
|
||||
}
|
||||
|
||||
let runToInterrupt = currentIssue.executionRunId
|
||||
? await heartbeat.getRun(currentIssue.executionRunId)
|
||||
: null;
|
||||
|
||||
if (
|
||||
(!runToInterrupt || runToInterrupt.status !== "running") &&
|
||||
currentIssue.assigneeAgentId
|
||||
) {
|
||||
const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
|
||||
const activeIssueId =
|
||||
activeRun &&
|
||||
activeRun.contextSnapshot &&
|
||||
typeof activeRun.contextSnapshot === "object" &&
|
||||
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
|
||||
runToInterrupt = activeRun;
|
||||
}
|
||||
}
|
||||
|
||||
if (runToInterrupt && runToInterrupt.status === "running") {
|
||||
const runToInterrupt = await resolveActiveIssueRun(currentIssue);
|
||||
if (runToInterrupt) {
|
||||
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
|
||||
if (cancelled) {
|
||||
interruptedRunId = cancelled.id;
|
||||
|
|
|
|||
|
|
@ -795,6 +795,20 @@ export function issueService(db: Db) {
|
|||
return row;
|
||||
},
|
||||
|
||||
markUnread: async (companyId: string, issueId: string, userId: string) => {
|
||||
const deleted = await db
|
||||
.delete(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.issueId, issueId),
|
||||
eq(issueReadStates.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return deleted.length > 0;
|
||||
},
|
||||
|
||||
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue