Merge public-gh/master into pap-1239-ui-ux

This commit is contained in:
dotta
2026-04-09 09:04:22 -05:00
56 changed files with 16126 additions and 397 deletions
@@ -0,0 +1,38 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { runChildProcess } from "./server-utils.js";
describe("runChildProcess", () => {
it("waits for onSpawn before sending stdin to the child", async () => {
const spawnDelayMs = 150;
const startedAt = Date.now();
let onSpawnCompletedAt = 0;
const result = await runChildProcess(
randomUUID(),
process.execPath,
[
"-e",
"let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>data+=chunk);process.stdin.on('end',()=>process.stdout.write(data));",
],
{
cwd: process.cwd(),
env: {},
stdin: "hello from stdin",
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
onSpawn: async () => {
await new Promise((resolve) => setTimeout(resolve, spawnDelayMs));
onSpawnCompletedAt = Date.now();
},
},
);
const finishedAt = Date.now();
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("hello from stdin");
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
});
});
+119 -12
View File
@@ -201,6 +201,22 @@ type PaperclipWakeIssue = {
priority: string | null;
};
type PaperclipWakeExecutionPrincipal = {
type: "agent" | "user" | null;
agentId: string | null;
userId: string | null;
};
type PaperclipWakeExecutionStage = {
wakeRole: "reviewer" | "approver" | "executor" | null;
stageId: string | null;
stageType: string | null;
currentParticipant: PaperclipWakeExecutionPrincipal | null;
returnAssignee: PaperclipWakeExecutionPrincipal | null;
lastDecisionOutcome: string | null;
allowedActions: string[];
};
type PaperclipWakeComment = {
id: string | null;
issueId: string | null;
@@ -214,6 +230,7 @@ type PaperclipWakeComment = {
type PaperclipWakePayload = {
reason: string | null;
issue: PaperclipWakeIssue | null;
executionStage: PaperclipWakeExecutionStage | null;
commentIds: string[];
latestCommentId: string | null;
comments: PaperclipWakeComment[];
@@ -257,6 +274,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
};
}
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
const principal = parseObject(value);
const typeRaw = asString(principal.type, "").trim().toLowerCase();
if (typeRaw !== "agent" && typeRaw !== "user") return null;
return {
type: typeRaw,
agentId: asString(principal.agentId, "").trim() || null,
userId: asString(principal.userId, "").trim() || null,
};
}
function normalizePaperclipWakeExecutionStage(value: unknown): PaperclipWakeExecutionStage | null {
const stage = parseObject(value);
const wakeRoleRaw = asString(stage.wakeRole, "").trim().toLowerCase();
const wakeRole =
wakeRoleRaw === "reviewer" || wakeRoleRaw === "approver" || wakeRoleRaw === "executor"
? wakeRoleRaw
: null;
const allowedActions = Array.isArray(stage.allowedActions)
? stage.allowedActions
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => entry.trim())
: [];
const currentParticipant = normalizePaperclipWakeExecutionPrincipal(stage.currentParticipant);
const returnAssignee = normalizePaperclipWakeExecutionPrincipal(stage.returnAssignee);
const stageId = asString(stage.stageId, "").trim() || null;
const stageType = asString(stage.stageType, "").trim() || null;
const lastDecisionOutcome = asString(stage.lastDecisionOutcome, "").trim() || null;
if (!wakeRole && !stageId && !stageType && !currentParticipant && !returnAssignee && !lastDecisionOutcome && allowedActions.length === 0) {
return null;
}
return {
wakeRole,
stageId,
stageType,
currentParticipant,
returnAssignee,
lastDecisionOutcome,
allowedActions,
};
}
export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
const payload = parseObject(value);
const comments = Array.isArray(payload.comments)
@@ -270,12 +331,16 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => entry.trim())
: [];
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
if (comments.length === 0 && commentIds.length === 0) return null;
if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
return {
reason: asString(payload.reason, "").trim() || null,
issue: normalizePaperclipWakeIssue(payload.issue),
executionStage,
commentIds,
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
comments,
@@ -300,6 +365,12 @@ export function renderPaperclipWakePrompt(
const normalized = normalizePaperclipWakePayload(value);
if (!normalized) return "";
const resumedSession = options.resumedSession === true;
const executionStage = normalized.executionStage;
const principalLabel = (principal: PaperclipWakeExecutionPrincipal | null) => {
if (!principal || !principal.type) return "unknown";
if (principal.type === "agent") return principal.agentId ? `agent ${principal.agentId}` : "agent";
return principal.userId ? `user ${principal.userId}` : "user";
};
const lines = resumedSession
? [
@@ -342,7 +413,38 @@ export function renderPaperclipWakePrompt(
lines.push(`- omitted comments: ${normalized.missingCount}`);
}
lines.push("", "New comments in order:");
if (executionStage) {
lines.push(
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,
`- execution stage: ${executionStage.stageType ?? "unknown"}`,
`- execution participant: ${principalLabel(executionStage.currentParticipant)}`,
`- execution return assignee: ${principalLabel(executionStage.returnAssignee)}`,
`- last decision outcome: ${executionStage.lastDecisionOutcome ?? "none"}`,
);
if (executionStage.allowedActions.length > 0) {
lines.push(`- allowed actions: ${executionStage.allowedActions.join(", ")}`);
}
lines.push("");
if (executionStage.wakeRole === "reviewer" || executionStage.wakeRole === "approver") {
lines.push(
`You are waking as the active ${executionStage.wakeRole} for this issue.`,
"Do not execute the task itself or continue executor work.",
"Review the issue and choose one of the allowed actions above.",
"If you request changes, the workflow routes back to the stored return assignee.",
"",
);
} else if (executionStage.wakeRole === "executor") {
lines.push(
"You are waking because changes were requested in the execution workflow.",
"Address the requested changes on this issue and resubmit when the work is ready.",
"",
);
}
}
if (normalized.comments.length > 0) {
lines.push("New comments in order:");
}
for (const [index, comment] of normalized.comments.entries()) {
const authorLabel = comment.authorId
@@ -967,16 +1069,12 @@ export async function runChildProcess(
}) as ChildProcessWithEvents;
const startedAt = new Date().toISOString();
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
}
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
onLogError(err, runId, "failed to record child process metadata");
});
}
const spawnPersistPromise =
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
onLogError(err, runId, "failed to record child process metadata");
})
: Promise.resolve();
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
@@ -1014,6 +1112,15 @@ export async function runChildProcess(
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
const stdin = child.stdin;
if (opts.stdin != null && stdin) {
void spawnPersistPromise.finally(() => {
if (child.killed || stdin.destroyed) return;
stdin.write(opts.stdin as string);
stdin.end();
});
}
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { parseCodexStdoutLine } from "./parse-stdout.js";
describe("parseCodexStdoutLine", () => {
it("marks completed tool_use items as resolved tool results", () => {
const started = parseCodexStdoutLine(JSON.stringify({
type: "item.started",
item: {
id: "tool-1",
type: "tool_use",
name: "search",
input: { query: "paperclip" },
},
}), "2026-04-08T12:00:00.000Z");
const completed = parseCodexStdoutLine(JSON.stringify({
type: "item.completed",
item: {
id: "tool-1",
type: "tool_use",
name: "search",
status: "completed",
},
}), "2026-04-08T12:00:01.000Z");
expect(started).toEqual([{
kind: "tool_call",
ts: "2026-04-08T12:00:00.000Z",
name: "search",
toolUseId: "tool-1",
input: { query: "paperclip" },
}]);
expect(completed).toEqual([{
kind: "tool_result",
ts: "2026-04-08T12:00:01.000Z",
toolUseId: "tool-1",
content: "search completed",
isError: false,
}]);
});
it("keeps explicit tool_result payloads authoritative after tool_use completion", () => {
const completed = parseCodexStdoutLine(JSON.stringify({
type: "item.completed",
item: {
id: "tool-2",
type: "tool_result",
tool_use_id: "tool-1",
content: "final payload",
status: "completed",
},
}), "2026-04-08T12:00:02.000Z");
expect(completed).toEqual([{
kind: "tool_result",
ts: "2026-04-08T12:00:02.000Z",
toolUseId: "tool-1",
content: "final payload",
isError: false,
}]);
});
it("marks failed completed tool_use items as error results", () => {
const completed = parseCodexStdoutLine(JSON.stringify({
type: "item.completed",
item: {
id: "tool-3",
type: "tool_use",
name: "write_file",
status: "error",
error: { message: "permission denied" },
},
}), "2026-04-08T12:00:03.000Z");
expect(completed).toEqual([{
kind: "tool_result",
ts: "2026-04-08T12:00:03.000Z",
toolUseId: "tool-3",
content: "permission denied",
isError: true,
}]);
});
});
@@ -118,6 +118,52 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
}
function parseToolUseItem(
item: Record<string, unknown>,
ts: string,
phase: "started" | "completed",
): TranscriptEntry[] {
const name = asString(item.name, "unknown");
const toolUseId = asString(item.id, name || "tool_use");
if (phase === "started") {
return [{
kind: "tool_call",
ts,
name,
toolUseId,
input: item.input ?? {},
}];
}
const status = asString(item.status);
const isError =
item.is_error === true ||
status === "failed" ||
status === "errored" ||
status === "error" ||
status === "cancelled";
const rawContent =
item.content ??
item.output ??
item.result ??
item.error ??
item.message;
const content =
asString(rawContent) ||
errorText(rawContent) ||
stringifyUnknown(rawContent) ||
`${name} ${isError ? "failed" : "completed"}`;
return [{
kind: "tool_result",
ts,
toolUseId,
content,
isError,
}];
}
function parseCodexItem(
item: Record<string, unknown>,
ts: string,
@@ -146,13 +192,7 @@ function parseCodexItem(
}
if (itemType === "tool_use") {
return [{
kind: "tool_call",
ts,
name: asString(item.name, "unknown"),
toolUseId: asString(item.id),
input: item.input ?? {},
}];
return parseToolUseItem(item, ts, phase);
}
if (itemType === "tool_result" && phase === "completed") {
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS "inbox_dismissals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"item_key" text NOT NULL,
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_user_idx" ON "inbox_dismissals" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_item_idx" ON "inbox_dismissals" USING btree ("company_id","item_key");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_dismissals_company_user_item_idx" ON "inbox_dismissals" USING btree ("company_id","user_id","item_key");
File diff suppressed because it is too large Load Diff
@@ -372,6 +372,13 @@
"when": 1775571715162,
"tag": "0052_mushy_trauma",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1775604018515,
"tag": "0053_sharp_wild_child",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,24 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const inboxDismissals = pgTable(
"inbox_dismissals",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
userId: text("user_id").notNull(),
itemKey: text("item_key").notNull(),
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("inbox_dismissals_company_user_idx").on(table.companyId, table.userId),
companyItemIdx: index("inbox_dismissals_company_item_idx").on(table.companyId, table.itemKey),
companyUserItemUnique: uniqueIndex("inbox_dismissals_company_user_item_idx").on(
table.companyId,
table.userId,
table.itemKey,
),
}),
);
+1
View File
@@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { inboxDismissals } from "./inbox_dismissals.js";
export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js";
export { issueReadStates } from "./issue_read_states.js";
+1
View File
@@ -288,6 +288,7 @@ export type {
DashboardSummary,
ActivityEvent,
SidebarBadges,
InboxDismissal,
CompanyMembership,
PrincipalPermissionGrant,
Invite,
+10 -1
View File
@@ -12,9 +12,18 @@ describe("routine variable helpers", () => {
).toEqual(["repo", "priority"]);
});
it("deduplicates placeholder names across the routine title and description", () => {
expect(
extractRoutineVariableNames([
"Triage {{repo}}",
"Review {{repo}} for {{priority}} bugs",
]),
).toEqual(["repo", "priority"]);
});
it("preserves existing metadata when syncing variables from a template", () => {
expect(
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [
syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
]),
).toEqual([
+14 -7
View File
@@ -1,18 +1,25 @@
import type { RoutineVariable } from "./types/routine.js";
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
}
export function extractRoutineVariableNames(template: string | null | undefined): string[] {
if (!template) return [];
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
const templates = Array.isArray(input) ? input : [input];
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
}
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
const found = new Set<string>();
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) {
const name = match[1];
if (name && !found.has(name)) {
found.add(name);
for (const source of normalizeRoutineTemplateInput(template)) {
for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
const name = match[1];
if (name && !found.has(name)) {
found.add(name);
}
}
}
return [...found];
@@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
}
export function syncRoutineVariablesWithTemplate(
template: string | null | undefined,
template: RoutineTemplateInput,
existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] {
const names = extractRoutineVariableNames(template);
@@ -0,0 +1,9 @@
export interface InboxDismissal {
id: string;
companyId: string;
userId: string;
itemKey: string;
dismissedAt: Date;
createdAt: Date;
updatedAt: Date;
}
+1
View File
@@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js";
export type { SidebarBadges } from "./sidebar-badges.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type {
CompanyMembership,
PrincipalPermissionGrant,
@@ -213,6 +213,80 @@ describe("agent permission routes", () => {
);
});
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/agents`)
.send({
name: "Builder",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
});
expect(res.status).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
runtimeConfig: {
heartbeat: {
enabled: false,
intervalSec: 3600,
},
},
}),
);
});
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/agent-hires`)
.send({
name: "Builder",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
});
expect(res.status).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
runtimeConfig: {
heartbeat: {
enabled: false,
intervalSec: 3600,
},
},
}),
);
});
it("exposes explicit task assignment access on agent detail", async () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([
{
@@ -369,6 +369,252 @@ describe("codex execute", () => {
}
});
it("renders execution-stage wake instructions for reviewer and executor roles", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-stage-wake-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-stage-wake",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "execution_review_requested",
paperclipWake: {
reason: "execution_review_requested",
issue: {
id: "issue-1",
identifier: "PAP-1207",
title: "implement the plan of PAP-1200",
status: "in_review",
priority: "medium",
},
executionStage: {
wakeRole: "reviewer",
stageId: "stage-1",
stageType: "review",
currentParticipant: { type: "agent", agentId: "qa-agent" },
returnAssignee: { type: "agent", agentId: "coder-agent" },
lastDecisionOutcome: null,
allowedActions: ["approve", "request_changes"],
},
commentIds: [],
latestCommentId: null,
comments: [],
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.prompt).toContain("execution wake role: reviewer");
expect(capture.prompt).toContain("You are waking as the active reviewer for this issue.");
expect(capture.prompt).toContain("Do not execute the task itself or continue executor work.");
expect(capture.prompt).toContain("allowed actions: approve, request_changes");
const executorCapturePath = path.join(root, "capture-executor.json");
const executorResult = await execute({
runId: "run-stage-wake-executor",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: executorCapturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "execution_changes_requested",
paperclipWake: {
reason: "execution_changes_requested",
issue: {
id: "issue-1",
identifier: "PAP-1207",
title: "implement the plan of PAP-1200",
status: "in_progress",
priority: "medium",
},
executionStage: {
wakeRole: "executor",
stageId: "stage-1",
stageType: "review",
currentParticipant: { type: "agent", agentId: "qa-agent" },
returnAssignee: { type: "agent", agentId: "coder-agent" },
lastDecisionOutcome: "changes_requested",
allowedActions: ["address_changes", "resubmit"],
},
commentIds: [],
latestCommentId: null,
comments: [],
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(executorResult.exitCode).toBe(0);
const executorCapture = JSON.parse(await fs.readFile(executorCapturePath, "utf8")) as CapturePayload;
expect(executorCapture.prompt).toContain("execution wake role: executor");
expect(executorCapture.prompt).toContain("You are waking because changes were requested in the execution workflow.");
expect(executorCapture.prompt).toContain("allowed actions: address_changes, resubmit");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("renders an issue-scoped wake prompt even when the wake has no comments yet", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-issue-wake-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "codex");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCodexCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-issue-wake",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_assigned",
paperclipWake: {
reason: "issue_assigned",
issue: {
id: "issue-1",
identifier: "PAP-1201",
title: "Fix gallery opening for inline images",
status: "todo",
priority: "medium",
},
commentIds: [],
latestCommentId: null,
comments: [],
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
expect(capture.paperclipWakePayloadJson).not.toBeNull();
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
reason: "issue_assigned",
issue: {
identifier: "PAP-1201",
title: "Fix gallery opening for inline images",
status: "todo",
priority: "medium",
},
commentIds: [],
});
expect(capture.prompt).toContain("## Paperclip Wake Payload");
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
expect(capture.prompt).toContain("- pending comments: 0/0");
expect(capture.prompt).toContain("- issue status: todo");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
const workspace = path.join(root, "workspace");
@@ -488,6 +488,23 @@ describe("heartbeat comment wake batching", () => {
expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
const firstPayload = gateway.getAgentPayloads()[0] ?? {};
expect(firstPayload.paperclip).toMatchObject({
wake: {
reason: "issue_assigned",
issue: {
id: issueId,
identifier: `${issuePrefix}-1`,
title: "Require a comment",
status: "todo",
priority: "medium",
},
commentIds: [],
},
});
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
gateway.releaseFirstWait();
await waitFor(async () => {
const runs = await db
@@ -272,6 +272,18 @@ describe("shouldResetTaskSessionForWake", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
});
it("resets session context on execution review wakes", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(true);
});
it("resets session context on execution approval wakes", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
});
it("resets session context on execution changes-requested wakes", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(true);
});
it("preserves session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
});
@@ -0,0 +1,212 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
approvals,
companies,
createDb,
heartbeatRuns,
inboxDismissals,
invites,
joinRequests,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("inbox dismissals", () => {
let db!: ReturnType<typeof createDb>;
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
db = createDb(tempDb.connectionString);
dismissalsSvc = inboxDismissalService(db);
badgesSvc = sidebarBadgeService(db);
}, 20_000);
afterEach(async () => {
await db.delete(inboxDismissals);
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(heartbeatRuns);
await db.delete(approvals);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("upserts a single dismissal record per user and inbox item key", async () => {
const companyId = randomUUID();
const userId = "board-user";
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
const dismissals = await dismissalsSvc.list(companyId, userId);
expect(dismissals).toHaveLength(1);
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
});
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
const companyId = randomUUID();
const userId = "board-user";
const primaryAgentId = randomUUID();
const secondaryAgentId = randomUUID();
const hiddenApprovalId = randomUUID();
const resurfacedApprovalId = randomUUID();
const inviteId = randomUUID();
const hiddenJoinRequestId = randomUUID();
const hiddenRunId = randomUUID();
const visibleRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: primaryAgentId,
companyId,
name: "Primary",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: secondaryAgentId,
companyId,
name: "Secondary",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(approvals).values([
{
id: hiddenApprovalId,
companyId,
type: "hire_agent",
status: "pending",
payload: {},
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
{
id: resurfacedApprovalId,
companyId,
type: "hire_agent",
status: "revision_requested",
payload: {},
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
},
]);
await db.insert(invites).values({
id: inviteId,
companyId,
inviteType: "company_join",
tokenHash: "hash-1",
allowedJoinTypes: "both",
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
});
await db.insert(joinRequests).values({
id: hiddenJoinRequestId,
inviteId,
companyId,
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
});
await db.insert(heartbeatRuns).values([
{
id: hiddenRunId,
companyId,
agentId: primaryAgentId,
invocationSource: "assignment",
status: "failed",
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
{
id: visibleRunId,
companyId,
agentId: secondaryAgentId,
invocationSource: "assignment",
status: "timed_out",
createdAt: new Date("2026-03-11T04:00:00.000Z"),
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
},
]);
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
const dismissedAtByKey = new Map(
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
dismissal.itemKey,
new Date(dismissal.dismissedAt).getTime(),
]),
);
const badges = await badgesSvc.get(companyId, {
dismissals: dismissedAtByKey,
joinRequests: [{
id: hiddenJoinRequestId,
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
}],
unreadTouchedIssues: 1,
});
expect(badges).toEqual({
inbox: 3,
approvals: 1,
failedRuns: 1,
joinRequests: 0,
});
});
});
@@ -0,0 +1,244 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
getRelationSummaries: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
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),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue() {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
status: "todo",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-580",
title: "Activity event issue",
executionPolicy: null,
executionState: null,
};
}
describe("issue activity event routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
});
it("logs blocker activity with added and removed issue summaries", async () => {
const issue = makeIssue();
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getRelationSummaries
.mockResolvedValueOnce({
blockedBy: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
})
.mockResolvedValueOnce({
blockedBy: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
});
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.blockers_updated",
details: expect.objectContaining({
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
addedBlockedByIssues: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
},
],
removedBlockedByIssues: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
},
],
}),
}),
);
});
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }],
},
{
id: "22222222-2222-4222-8222-222222222222",
type: "approval",
participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }],
},
],
})!;
const nextPolicy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }],
},
{
id: "22222222-2222-4222-8222-222222222222",
type: "approval",
participants: [{ type: "user", userId: "local-board" }],
},
],
})!;
const issue = {
...makeIssue(),
executionPolicy: existingPolicy,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
executionPolicy: patch.executionPolicy,
updatedAt: new Date(),
}));
const res = await request(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ executionPolicy: nextPolicy });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.reviewers_updated",
details: expect.objectContaining({
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.approvers_updated",
details: expect.objectContaining({
participants: [{ type: "user", agentId: null, userId: "local-board" }],
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
}),
}),
);
});
});
@@ -7,6 +7,7 @@ import { normalizeIssueExecutionPolicy } from "../services/issue-execution-polic
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
@@ -75,8 +76,12 @@ vi.mock("../services/index.js", () => ({
function createApp() {
const app = express();
app.use(express.json());
return app;
}
function installActor(app: express.Express, actor?: Record<string, unknown>) {
app.use((req, _res, next) => {
(req as any).actor = {
(req as any).actor = actor ?? {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
@@ -119,6 +124,10 @@ describe("issue comment reopen routes", () => {
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
});
it("treats reopen=true as a no-op when the issue is already open", async () => {
@@ -128,7 +137,7 @@ describe("issue comment reopen routes", () => {
...patch,
}));
const res = await request(createApp())
const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@@ -157,7 +166,7 @@ describe("issue comment reopen routes", () => {
...patch,
}));
const res = await request(createApp())
const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@@ -207,7 +216,7 @@ describe("issue comment reopen routes", () => {
status: "cancelled",
});
const res = await request(createApp())
const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@@ -265,7 +274,7 @@ describe("issue comment reopen routes", () => {
_tx: tx,
}));
const res = await request(createApp())
const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done", comment: "Approved for ship" });
@@ -294,4 +303,146 @@ describe("issue comment reopen routes", () => {
}),
);
});
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
executionPolicy: policy,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(
installActor(createApp(), {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
runId: "run-1",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
executionState: expect.objectContaining({
status: "pending",
currentStageType: "review",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
}),
returnAssignee: expect.objectContaining({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
}),
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
expect.objectContaining({
reason: "execution_review_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "reviewer",
stageType: "review",
allowedActions: ["approve", "request_changes"],
}),
}),
}),
);
});
it("wakes the return assignee with execution_changes_requested", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(
installActor(createApp(), {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-2",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_progress",
comment: "Needs another pass",
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "execution_changes_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "executor",
stageType: "review",
lastDecisionOutcome: "changes_requested",
allowedActions: ["address_changes", "resubmit"],
}),
}),
}),
);
});
});
@@ -0,0 +1,201 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
getRelationSummaries: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
}));
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),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp(
actor: Record<string, unknown> = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
},
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
describe("issue execution policy routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
});
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
createdByUserId: "local-board",
identifier: "PAP-999",
title: "Execution policy edit",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ executionPolicy: policy });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
executionPolicy: policy,
actorAgentId: null,
actorUserId: "local-board",
}),
);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(updatePatch.status).toBeUndefined();
expect(updatePatch.assigneeAgentId).toBeUndefined();
expect(updatePatch.assigneeUserId).toBeUndefined();
expect(updatePatch.executionState).toBeUndefined();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("rejects agent stage advances from non-participants", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const approverAgentId = "44444444-4444-4444-8444-444444444444";
const executorAgentId = "22222222-2222-4222-8222-222222222222";
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: reviewerAgentId }],
},
{
id: "55555555-5555-4555-8555-555555555555",
type: "approval",
participants: [{ type: "agent", agentId: approverAgentId }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_review",
assigneeAgentId: reviewerAgentId,
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1000",
title: "Execution policy guard",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: "11111111-1111-4111-8111-111111111111",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: executorAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(
createApp({
type: "agent",
agentId: approverAgentId,
companyId: "company-1",
source: "api_key",
runId: "run-1",
}),
)
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "done", comment: "Skipping review." });
expect(res.status).toBe(403);
expect(res.body.error).toContain("active review participant");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -413,33 +413,45 @@ describe("issue execution policy transitions", () => {
const policy = twoStagePolicy();
const reviewStageId = policy.stages[0].id;
it("non-participant cannot advance stage via status change", () => {
expect(() =>
applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
it("non-participant stage updates are coerced back to the active stage", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Trying to bypass review",
}),
).toThrow("Only the active reviewer or approver can advance");
},
policy,
requestedStatus: "done",
requestedAssigneePatch: { assigneeUserId: boardUserId },
actor: { agentId: coderAgentId },
commentBody: "Trying to bypass review",
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
},
});
expect(result.decision).toBeUndefined();
});
it("non-participant can still post non-advancing updates", () => {
@@ -663,6 +675,7 @@ describe("issue execution policy transitions", () => {
describe("no-op transitions", () => {
const policy = twoStagePolicy();
const reviewStageId = policy.stages[0].id;
it("non-done status change without review context is a no-op", () => {
const result = applyIssueExecutionPolicyTransition({
@@ -682,6 +695,72 @@ describe("issue execution policy transitions", () => {
expect(result.patch).toEqual({});
});
it("coerces a malformed executor in_review patch into the first policy stage", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "in_review",
requestedAssigneePatch: { assigneeUserId: boardUserId },
actor: { agentId: coderAgentId },
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionState: {
status: "pending",
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
},
});
});
it("reasserts the active stage when issue status drifted out of in_review", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy,
requestedStatus: "in_progress",
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
actor: { agentId: coderAgentId },
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
},
});
});
it("no policy and no state is a no-op", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
@@ -699,6 +778,25 @@ describe("issue execution policy transitions", () => {
expect(result.patch).toEqual({});
});
it("does not auto-start workflow when policy is added to an already in_review issue", () => {
const reviewOnly = reviewOnlyPolicy();
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: null,
assigneeUserId: boardUserId,
executionPolicy: null,
executionState: null,
},
policy: reviewOnly,
requestedStatus: undefined,
requestedAssigneePatch: {},
actor: { userId: boardUserId },
});
expect(result.patch).toEqual({});
});
});
describe("multi-participant stages", () => {
@@ -895,4 +993,100 @@ describe("issue execution policy transitions", () => {
expect(result.patch.assigneeUserId).toBe(boardUserId);
});
});
describe("policy edits while a stage is active", () => {
it("clears the active execution state when its stage is removed from the policy", () => {
const reviewAndApproval = twoStagePolicy();
const approvalOnly = approvalOnlyPolicy();
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: reviewAndApproval,
executionState: {
status: "pending",
currentStageId: reviewAndApproval.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy: approvalOnly,
requestedStatus: undefined,
requestedAssigneePatch: {},
actor: { userId: boardUserId },
});
expect(result.patch).toMatchObject({
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionState: null,
});
});
it("reassigns the active stage when the current participant is removed", () => {
const policy = makePolicy([
{
type: "review",
participants: [
{ type: "agent", agentId: qaAgentId },
{ type: "agent", agentId: ctoAgentId },
],
},
]);
const updatedPolicy = makePolicy([
{
type: "review",
participants: [{ type: "agent", agentId: ctoAgentId }],
},
]);
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy: {
...updatedPolicy,
stages: [{ ...updatedPolicy.stages[0], id: policy.stages[0].id }],
},
requestedStatus: undefined,
requestedAssigneePatch: {},
actor: { userId: boardUserId },
});
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: ctoAgentId,
assigneeUserId: null,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: ctoAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
},
});
});
});
});
@@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
projectId,
goalId: null,
parentIssueId: null,
title: "repo triage",
title: "repo triage for {{repo}}",
description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId,
priority: "medium",
@@ -346,6 +346,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
},
{},
);
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
const run = await svc.runRoutine(variableRoutine.id, {
source: "manual",
@@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
});
const storedIssue = await db
.select({ description: issues.description })
.select({ title: issues.title, description: issues.description })
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
@@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
.where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null);
expect(storedIssue?.title).toBe("repo triage for paperclip");
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
expect(storedRun?.triggerPayload).toEqual({
variables: {
+2
View File
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
@@ -166,6 +167,7 @@ export async function createApp(
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();
+17 -1
View File
@@ -449,11 +449,25 @@ export function agentRoutes(db: Db) {
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
return {
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
enabled: parseBooleanLike(heartbeat.enabled) ?? false,
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
};
}
function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record<string, unknown> {
const parsedRuntimeConfig = asRecord(runtimeConfig);
const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {};
const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat);
const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {};
if (parseBooleanLike(heartbeat.enabled) == null) {
heartbeat.enabled = false;
}
normalizedRuntimeConfig.heartbeat = heartbeat;
return normalizedRuntimeConfig;
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
@@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) {
const normalizedHireInput = {
...hireInput,
adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
};
const company = await db
@@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) {
const createdAgent = await svc.create(companyId, {
...createInput,
adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
+69
View File
@@ -0,0 +1,69 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { inboxDismissalService, logActivity } from "../services/index.js";
const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
});
export function inboxDismissalRoutes(db: Db) {
const router = Router();
const svc = inboxDismissalService(db);
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, 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 dismissals = await svc.list(companyId, req.actor.userId);
res.json(dismissals);
});
router.post(
"/companies/:companyId/inbox-dismissals",
validate(inboxDismissalSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, 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 dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.dismissed",
entityType: "company",
entityId: companyId,
details: {
userId: req.actor.userId,
itemKey: dismissal.itemKey,
dismissedAt: dismissal.dismissedAt,
},
});
res.status(201).json(dismissal);
},
);
return router;
}
+1
View File
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
+326 -27
View File
@@ -56,13 +56,219 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
} from "../services/issue-execution-policy.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type ActivityIssueRelationSummary = {
id: string;
identifier: string | null;
title: string;
};
type ActivityExecutionParticipant = Pick<
NormalizedExecutionPolicy["stages"][number]["participants"][number],
"type" | "agentId" | "userId"
>;
type ExecutionStageWakeContext = {
wakeRole: "reviewer" | "approver" | "executor";
stageId: string | null;
stageType: ParsedExecutionState["currentStageType"];
currentParticipant: ParsedExecutionState["currentParticipant"];
returnAssignee: ParsedExecutionState["returnAssignee"];
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
) {
if (!left || !right || left.type !== right.type) return false;
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
}
function executionParticipantMatchesAgent(
participant: ParsedExecutionState["currentParticipant"] | null,
agentId: string | null | undefined,
) {
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
}
function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"];
allowedActions: string[];
}): ExecutionStageWakeContext {
return {
wakeRole: input.wakeRole,
stageId: input.state.currentStageId,
stageType: input.state.currentStageType,
currentParticipant: input.state.currentParticipant,
returnAssignee: input.state.returnAssignee,
lastDecisionOutcome: input.state.lastDecisionOutcome,
allowedActions: input.allowedActions,
};
}
function summarizeIssueRelationForActivity(relation: {
id: string;
identifier: string | null;
title: string;
}): ActivityIssueRelationSummary {
return {
id: relation.id,
identifier: relation.identifier,
title: relation.title,
};
}
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
}
function summarizeExecutionParticipants(
policy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
): ActivityExecutionParticipant[] {
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
return (
stage?.participants.map((participant) => ({
type: participant.type,
agentId: participant.agentId ?? null,
userId: participant.userId ?? null,
})) ?? []
);
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
) {
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
const previousByKey = new Map(previousParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
const nextByKey = new Map(nextParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
return {
participants: nextParticipants,
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
};
}
function buildExecutionStageWakeup(input: {
issueId: string;
previousState: ParsedExecutionState | null;
nextState: ParsedExecutionState | null;
interruptedRunId: string | null;
requestedByActorType: "user" | "agent";
requestedByActorId: string;
}) {
const { issueId, previousState, nextState, interruptedRunId } = input;
if (!nextState) return null;
if (nextState.status === "pending") {
const agentId =
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
const stageChanged =
previousState?.status !== "pending" ||
previousState?.currentStageId !== nextState.currentStageId ||
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
if (!agentId || !stageChanged) return null;
const reason =
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
allowedActions: ["approve", "request_changes"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason,
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: reason,
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
if (nextState.status === "changes_requested") {
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
const becameChangesRequested =
previousState?.status !== "changes_requested" ||
previousState?.lastDecisionId !== nextState.lastDecisionId ||
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
if (!agentId || !becameChangesRequested) return null;
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: "executor",
allowedActions: ["address_changes", "resubmit"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason: "execution_changes_requested",
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "execution_changes_requested",
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
return null;
}
export function issueRoutes(
db: Db,
storage: StorageService,
@@ -1066,9 +1272,10 @@ export function issueRoutes(
}
const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
const issue = await svc.create(companyId, {
...req.body,
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
executionPolicy,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
@@ -1110,24 +1317,6 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
const assigneeWillChange =
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
req.body.assigneeAgentId === null &&
typeof req.body.assigneeUserId === "string" &&
!!existing.createdByUserId &&
req.body.assigneeUserId === existing.createdByUserId;
if (assigneeWillChange) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
@@ -1191,14 +1380,20 @@ export function issueRoutes(
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
}
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
const nextExecutionPolicy =
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy;
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
const requestedAssigneePatchProvided =
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy:
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
policy: nextExecutionPolicy,
requestedStatus,
requestedAssigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
@@ -1224,6 +1419,48 @@ export function issueRoutes(
}
Object.assign(updateFields, transition.patch);
const effectiveExecutionState = parseIssueExecutionState(
transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState,
);
const isUnauthorizedAgentStageMutation =
req.actor.type === "agent" &&
req.actor.agentId &&
existing.status === "in_review" &&
transition.workflowControlledAssignment &&
!transition.decision &&
effectiveExecutionState?.status === "pending" &&
(
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
requestedAssigneePatchProvided
) &&
!executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId);
if (isUnauthorizedAgentStageMutation) {
const stageLabel = effectiveExecutionState.currentStageType ?? "execution";
res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` });
return;
}
const nextAssigneeAgentId =
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
const nextAssigneeUserId =
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
const assigneeWillChange =
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
nextAssigneeAgentId === null &&
typeof nextAssigneeUserId === "string" &&
!!existing.createdByUserId &&
nextAssigneeUserId === existing.createdByUserId;
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
let issue;
try {
if (transition.decision && decisionId) {
@@ -1291,8 +1528,9 @@ export function issueRoutes(
return;
}
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
const updatedRelations = await svc.getRelationSummaries(issue.id);
updatedRelations = await svc.getRelationSummaries(issue.id);
issueResponse = {
...issue,
blockedBy: updatedRelations.blockedBy,
@@ -1349,6 +1587,8 @@ export function issueRoutes(
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
const nextBlockedByRelations = updatedRelations?.blockedBy ?? [];
const previousBlockedByRelations = existingRelations?.blockedBy ?? [];
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
@@ -1364,11 +1604,58 @@ export function issueRoutes(
blockedByIssueIds: req.body.blockedByIssueIds,
addedBlockedByIssueIds,
removedBlockedByIssueIds,
blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity),
addedBlockedByIssues: nextBlockedByRelations
.filter((relation) => addedBlockedByIssueIds.includes(relation.id))
.map(summarizeIssueRelationForActivity),
removedBlockedByIssues: previousBlockedByRelations
.filter((relation) => removedBlockedByIssueIds.includes(relation.id))
.map(summarizeIssueRelationForActivity),
},
});
}
}
const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review");
if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.reviewers_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
participants: reviewerChanges.participants,
addedParticipants: reviewerChanges.addedParticipants,
removedParticipants: reviewerChanges.removedParticipants,
},
});
}
const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval");
if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approvers_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
participants: approverChanges.participants,
addedParticipants: approverChanges.addedParticipants,
removedParticipants: approverChanges.removedParticipants,
},
});
}
if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient();
if (tc && actor.agentId) {
@@ -1414,6 +1701,16 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
issueId: issue.id,
previousState: previousExecutionState,
nextState: nextExecutionState,
interruptedRunId,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
@@ -1427,7 +1724,9 @@ export function issueRoutes(
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
};
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
if (executionStageWakeup) {
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
addWakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
+29 -8
View File
@@ -1,12 +1,20 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
function buildDismissedAtByKey(
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
);
}
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
@@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
}
const joinRequestCount = canApproveJoins
const visibleJoinRequests = canApproveJoins
? await db
.select({ count: sql<number>`count(*)` })
.select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
.then((rows) => Number(rows[0]?.count ?? 0))
: 0;
: [];
const dismissedAtByKey =
req.actor.type === "board" && req.actor.userId
? await db
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
.then(buildDismissedAtByKey)
: new Map<string, number>();
const badges = await svc.get(companyId, {
joinRequests: joinRequestCount,
dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
});
const summary = await dashboard.summary(companyId);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
res.json(badges);
});
+34 -20
View File
@@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return true;
if (
wakeReason === "issue_assigned" ||
wakeReason === "execution_review_requested" ||
wakeReason === "execution_approval_requested" ||
wakeReason === "execution_changes_requested"
) {
return true;
}
return false;
}
@@ -714,6 +721,9 @@ function describeSessionResetReason(
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
return null;
}
@@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
}
| null;
}) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot);
if (commentIds.length === 0) return null;
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const issueSummary =
input.issueSummary ??
@@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null)
: null);
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
const commentRows = await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentRows =
commentIds.length === 0
? []
: await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
const comments: Array<Record<string, unknown>> = [];
@@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
priority: issueSummary.priority,
}
: null,
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,
@@ -2159,7 +2173,7 @@ export function heartbeatService(db: Db) {
const heartbeat = parseObject(runtimeConfig.heartbeat);
return {
enabled: asBoolean(heartbeat.enabled, true),
enabled: asBoolean(heartbeat.enabled, false),
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
+41
View File
@@ -0,0 +1,41 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { inboxDismissals } from "@paperclipai/db";
export function inboxDismissalService(db: Db) {
return {
list: async (companyId: string, userId: string) =>
db
.select()
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
.orderBy(desc(inboxDismissals.updatedAt)),
dismiss: async (
companyId: string,
userId: string,
itemKey: string,
dismissedAt: Date = new Date(),
) => {
const now = new Date();
const [row] = await db
.insert(inboxDismissals)
.values({
companyId,
userId,
itemKey,
dismissedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
set: {
dismissedAt,
updatedAt: now,
},
})
.returning();
return row;
},
};
}
+1
View File
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
+177 -65
View File
@@ -36,6 +36,7 @@ type TransitionInput = {
type TransitionResult = {
patch: Record<string, unknown>;
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
workflowControlledAssignment?: boolean;
};
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
@@ -144,6 +145,11 @@ function selectStageParticipant(
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
}
function stageHasParticipant(stage: IssueExecutionStage, participant: IssueExecutionStagePrincipal | null): boolean {
if (!participant) return false;
return stage.participants.some((candidate) => principalsEqual(candidate, participant));
}
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
if (!principal) {
return { assigneeAgentId: null, assigneeUserId: null };
@@ -198,14 +204,49 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
};
}
function buildPendingStagePatch(input: {
patch: Record<string, unknown>;
previous: IssueExecutionState | null;
policy: IssueExecutionPolicy;
stage: IssueExecutionStage;
participant: IssueExecutionStagePrincipal;
returnAssignee: IssueExecutionStagePrincipal | null;
}) {
input.patch.status = "in_review";
Object.assign(input.patch, patchForPrincipal(input.participant));
input.patch.executionState = buildPendingState({
previous: input.previous,
stage: input.stage,
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
participant: input.participant,
returnAssignee: input.returnAssignee,
});
}
function clearExecutionStatePatch(input: {
patch: Record<string, unknown>;
issueStatus: string;
requestedStatus?: string;
returnAssignee: IssueExecutionStagePrincipal | null;
}) {
input.patch.executionState = null;
if (input.requestedStatus === undefined && input.issueStatus === "in_review" && input.returnAssignee) {
input.patch.status = "in_progress";
Object.assign(input.patch, patchForPrincipal(input.returnAssignee));
}
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue);
const actor = actorPrincipal(input.actor);
const requestedAssigneePatchProvided =
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
const requestedStatus = input.requestedStatus;
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
if (!input.policy) {
if (existingState) {
@@ -228,90 +269,159 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch };
}
if (currentStage && input.issue.status === "in_review") {
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
if (requestedStatus && requestedStatus !== "in_review") {
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}
return { patch };
if (existingState?.currentStageId && !currentStage) {
clearExecutionStatePatch({
patch,
issueStatus: input.issue.status,
requestedStatus,
returnAssignee: existingState.returnAssignee,
});
return { patch };
}
if (activeStage) {
const currentParticipant =
existingState?.currentParticipant ??
selectStageParticipant(activeStage, {
exclude: existingState?.returnAssignee ?? null,
});
if (!currentParticipant) {
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
}
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, currentStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
if (!stageHasParticipant(activeStage, currentParticipant)) {
const participant = selectStageParticipant(activeStage, {
preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
clearExecutionStatePatch({
patch,
issueStatus: input.issue.status,
requestedStatus,
returnAssignee: existingState?.returnAssignee ?? null,
});
return { patch };
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
previous: approvedState,
stage: nextStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
if (principalsEqual(currentParticipant, actor)) {
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, activeStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
}
buildPendingStagePatch({
patch,
previous: approvedState,
policy: input.policy,
stage: nextStage,
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, activeStage);
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, currentStage);
}
if (
input.issue.status !== "in_review" ||
!principalsEqual(currentAssignee, currentParticipant) ||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
) {
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant: currentParticipant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
return { patch };
}
if (requestedStatus !== "done") {
const shouldStartWorkflow =
requestedStatus === "done" ||
requestedStatus === "in_review";
if (!shouldStartWorkflow) {
return { patch };
}
@@ -333,14 +443,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: pendingStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
participant,
returnAssignee,
});
return { patch };
return {
patch,
workflowControlledAssignment: true,
};
}
+6 -4
View File
@@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
executionWorkspaceSettings?: Record<string, unknown> | null;
}) {
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => {
@@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: input.routine.projectId,
goalId: input.routine.goalId,
parentId: input.routine.parentIssueId,
title: input.routine.title,
title,
description,
status: "todo",
priority: input.routine.priority,
@@ -996,7 +997,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate(
input.description,
[input.title, input.description],
sanitizeRoutineVariableInputs(input.variables),
);
assertRoutineVariableDefinitions(variables);
@@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextTitle = patch.title ?? existing.title;
const nextDescription = patch.description === undefined ? existing.description : patch.description;
const nextVariables = syncRoutineVariablesWithTemplate(
nextDescription,
[nextTitle, nextDescription],
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
);
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
@@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: nextProjectId,
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
title: patch.title ?? existing.title,
title: nextTitle,
description: nextDescription,
assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority,
+37 -6
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { and, desc, eq, inArray, not } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
import type { SidebarBadges } from "@paperclipai/shared";
@@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
function normalizeTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function isDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: Date | string | null | undefined,
) {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function sidebarBadgeService(db: Db) {
return {
get: async (
companyId: string,
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
extra?: {
dismissals?: ReadonlyMap<string, number>;
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
unreadTouchedIssues?: number;
},
): Promise<SidebarBadges> => {
const actionableApprovals = await db
.select({ count: sql<number>`count(*)` })
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
.from(approvals)
.where(
and(
@@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
.then((rows) =>
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
);
const latestRunByAgent = await db
.selectDistinctOn([heartbeatRuns.agentId], {
id: heartbeatRuns.id,
runStatus: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
@@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
const failedRuns = latestRunByAgent.filter((row) =>
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
).length;
const joinRequests = extra?.joinRequests ?? 0;
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
!isDismissed(
extra?.dismissals ?? new Map(),
`join:${row.id}`,
row.updatedAt ?? row.createdAt,
)
).length;
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
return {
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
+8
View File
@@ -0,0 +1,8 @@
import type { InboxDismissal } from "@paperclipai/shared";
import { api } from "./client";
export const inboxDismissalsApi = {
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
dismiss: (companyId: string, itemKey: string) =>
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
};
+1
View File
@@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
+2 -65
View File
@@ -2,72 +2,9 @@ import { Link } from "@/lib/router";
import { Identity } from "./Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils";
import { formatActivityVerb } from "../lib/activity-format";
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
const ACTION_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.document_created": "created document for",
"issue.document_updated": "updated document on",
"issue.document_deleted": "deleted document from",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
"agent.resumed": "resumed",
"agent.terminated": "terminated",
"agent.key_created": "created API key for",
"agent.budget_updated": "updated budget for",
"agent.runtime_session_reset": "reset session for",
"heartbeat.invoked": "invoked heartbeat for",
"heartbeat.cancelled": "cancelled heartbeat for",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
"project.created": "created",
"project.updated": "updated",
"project.deleted": "deleted",
"goal.created": "created",
"goal.updated": "updated",
"goal.deleted": "deleted",
"cost.reported": "reported cost for",
"cost.recorded": "recorded cost for",
"company.created": "created company",
"company.updated": "updated company",
"company.archived": "archived",
"company.budget_updated": "updated budget for",
};
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
}
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>;
if (details.status !== undefined) {
const from = previous.status;
return from
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
: `changed status to ${humanizeValue(details.status)} on`;
}
if (details.priority !== undefined) {
const from = previous.priority;
return from
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
: `changed priority to ${humanizeValue(details.priority)} on`;
}
}
return ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
}
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
switch (entityType) {
case "issue": return `/issues/${name ?? entityId}`;
@@ -88,7 +25,7 @@ interface ActivityRowProps {
}
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
const verb = formatVerb(event.action, event.details);
const verb = formatActivityVerb(event.action, event.details, { agentMap });
const isHeartbeatEvent = event.entityType === "heartbeat_run";
const heartbeatAgentId = isHeartbeatEvent
+2 -2
View File
@@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
/>
</div>
<CollapsibleSection
+2 -9
View File
@@ -33,6 +33,7 @@ import {
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId
} from "../lib/onboarding-launch";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
@@ -460,15 +461,7 @@ export function OnboardingWizard() {
role: "ceo",
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1
}
}
runtimeConfig: buildNewAgentRuntimeConfig()
});
setCreatedAgentId(agent.id);
queryClient.invalidateQueries({
+5 -3
View File
@@ -36,18 +36,20 @@ function updateVariableList(
}
export function RoutineVariablesEditor({
title,
description,
value,
onChange,
}: {
title: string;
description: string;
value: RoutineVariable[];
onChange: (value: RoutineVariable[]) => void;
}) {
const [open, setOpen] = useState(true);
const syncedVariables = useMemo(
() => syncRoutineVariablesWithTemplate(description, value),
[description, value],
() => syncRoutineVariablesWithTemplate([title, description], value),
[description, title, value],
);
const syncedSignature = serializeVariables(syncedVariables);
const currentSignature = serializeVariables(value);
@@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
<div>
<p className="text-sm font-medium">Variables</p>
<p className="text-xs text-muted-foreground">
Detected from `{"{{name}}"}` placeholders in the routine instructions.
Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
</p>
</div>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
+71 -10
View File
@@ -1,17 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { inboxDismissalsApi } from "../api/inboxDismissals";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
loadDismissedInboxAlerts,
saveDismissedInboxAlerts,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
@@ -19,13 +21,13 @@ import {
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
export function useDismissedInboxAlerts() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems());
setDismissed(loadDismissedInboxAlerts());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
@@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissedInboxItems(next);
saveDismissedInboxAlerts(next);
return next;
});
};
@@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss };
}
export function useInboxDismissals(companyId: string | null | undefined) {
const queryClient = useQueryClient();
const queryKey = companyId
? queryKeys.inboxDismissals(companyId)
: ["inbox-dismissals", "__disabled__"] as const;
const { data: dismissals = [] } = useQuery({
queryKey,
queryFn: () => inboxDismissalsApi.list(companyId!),
enabled: !!companyId,
});
const dismissMutation = useMutation({
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
onMutate: async ({ itemKey }) => {
if (!companyId) return { previous: [] as typeof dismissals };
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
const now = new Date();
queryClient.setQueryData(queryKey, [
{
id: `optimistic:${itemKey}`,
companyId,
userId: "me",
itemKey,
dismissedAt: now,
createdAt: now,
updatedAt: now,
},
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
]);
return { previous };
},
onError: (_error, _variables, context) => {
if (!context) return;
queryClient.setQueryData(queryKey, context.previous);
},
onSettled: () => {
if (!companyId) return;
queryClient.invalidateQueries({ queryKey });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
},
});
const dismissedAtByKey = useMemo(
() => buildInboxDismissedAtByKey(dismissals),
[dismissals],
);
return {
dismissals,
dismissedAtByKey,
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
isPending: dismissMutation.isPending,
};
}
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
@@ -77,7 +136,8 @@ export function useReadInboxItems() {
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
const { dismissedAtByKey } = useInboxDismissals(companyId);
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
@@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
dashboard,
heartbeatRuns,
mineIssues,
dismissed,
dismissedAlerts,
dismissedAtByKey,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Agent } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { formatActivityVerb, formatIssueActivityAction } from "./activity-format";
describe("activity formatting", () => {
const agentMap = new Map<string, Agent>([
["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent],
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
]);
it("formats blocker activity using linked issue identifiers", () => {
const details = {
addedBlockedByIssues: [
{ id: "issue-2", identifier: "PAP-22", title: "Blocked task" },
],
removedBlockedByIssues: [],
};
expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to");
expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22");
});
it("formats reviewer activity using agent names", () => {
const details = {
addedParticipants: [
{ type: "agent", agentId: "agent-reviewer", userId: null },
],
removedParticipants: [],
};
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to");
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot");
});
it("formats approver removals using user-aware labels", () => {
const details = {
addedParticipants: [],
removedParticipants: [
{ type: "user", agentId: null, userId: "local-board" },
],
};
expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from");
expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board");
});
it("falls back to updated wording when reviewers are both added and removed", () => {
const details = {
addedParticipants: [
{ type: "agent", agentId: "agent-reviewer", userId: null },
],
removedParticipants: [
{ type: "agent", agentId: "agent-approver", userId: null },
],
};
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
});
});
+289
View File
@@ -0,0 +1,289 @@
import type { Agent } from "@paperclipai/shared";
type ActivityDetails = Record<string, unknown> | null | undefined;
type ActivityParticipant = {
type: "agent" | "user";
agentId?: string | null;
userId?: string | null;
};
type ActivityIssueReference = {
id?: string | null;
identifier?: string | null;
title?: string | null;
};
interface ActivityFormatOptions {
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}
const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.document_created": "created document for",
"issue.document_updated": "updated document on",
"issue.document_deleted": "deleted document from",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
"agent.resumed": "resumed",
"agent.terminated": "terminated",
"agent.key_created": "created API key for",
"agent.budget_updated": "updated budget for",
"agent.runtime_session_reset": "reset session for",
"heartbeat.invoked": "invoked heartbeat for",
"heartbeat.cancelled": "cancelled heartbeat for",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
"project.created": "created",
"project.updated": "updated",
"project.deleted": "deleted",
"goal.created": "created",
"goal.updated": "updated",
"goal.deleted": "deleted",
"cost.reported": "reported cost for",
"cost.recorded": "recorded cost for",
"company.created": "created company",
"company.updated": "updated company",
"company.archived": "archived",
"company.budget_updated": "updated budget for",
};
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.feedback_vote_saved": "saved feedback on an AI output",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
"agent.resumed": "resumed the agent",
"agent.terminated": "terminated the agent",
"heartbeat.invoked": "invoked a heartbeat",
"heartbeat.cancelled": "cancelled a heartbeat",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
}
function isActivityParticipant(value: unknown): value is ActivityParticipant {
const record = asRecord(value);
if (!record) return false;
return record.type === "agent" || record.type === "user";
}
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
return asRecord(value) !== null;
}
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
const value = details?.[key];
if (!Array.isArray(value)) return [];
return value.filter(isActivityParticipant);
}
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
const value = details?.[key];
if (!Array.isArray(value)) return [];
return value.filter(isActivityIssueReference);
}
function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string {
if (!userId || userId === "local-board") return "Board";
if (currentUserId && userId === currentUserId) return "You";
return `user ${userId.slice(0, 5)}`;
}
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
if (participant.type === "agent") {
const agentId = participant.agentId ?? "";
return options.agentMap?.get(agentId)?.name ?? "agent";
}
return formatUserLabel(participant.userId, options.currentUserId);
}
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
if (reference.identifier) return reference.identifier;
if (reference.title) return reference.title;
if (reference.id) return reference.id.slice(0, 8);
return "issue";
}
function formatChangedEntityLabel(
singular: string,
plural: string,
labels: string[],
): string {
if (labels.length <= 0) return plural;
if (labels.length === 1) return `${singular} ${labels[0]}`;
return `${labels.length} ${plural}`;
}
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
if (!details) return null;
const previous = asRecord(details._previous) ?? {};
if (details.status !== undefined) {
const from = previous.status;
return from
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
: `changed status to ${humanizeValue(details.status)} on`;
}
if (details.priority !== undefined) {
const from = previous.priority;
return from
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
: `changed priority to ${humanizeValue(details.priority)} on`;
}
return null;
}
function formatIssueUpdatedAction(details: ActivityDetails): string | null {
if (!details) return null;
const previous = asRecord(details._previous) ?? {};
const parts: string[] = [];
if (details.status !== undefined) {
const from = previous.status;
parts.push(
from
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
: `changed the status to ${humanizeValue(details.status)}`,
);
}
if (details.priority !== undefined) {
const from = previous.priority;
parts.push(
from
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
: `changed the priority to ${humanizeValue(details.priority)}`,
);
}
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue");
}
if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description");
return parts.length > 0 ? parts.join(", ") : null;
}
function formatStructuredIssueChange(input: {
action: string;
details: ActivityDetails;
options: ActivityFormatOptions;
forIssueDetail: boolean;
}): string | null {
const details = input.details;
if (!details) return null;
if (input.action === "issue.blockers_updated") {
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
if (added.length > 0 && removed.length === 0) {
const changed = formatChangedEntityLabel("blocker", "blockers", added);
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
}
if (removed.length > 0 && added.length === 0) {
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
}
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
}
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
if (added.length > 0 && removed.length === 0) {
const changed = formatChangedEntityLabel(singular, plural, added);
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
}
if (removed.length > 0 && added.length === 0) {
const changed = formatChangedEntityLabel(singular, plural, removed);
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
}
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
}
return null;
}
export function formatActivityVerb(
action: string,
details?: Record<string, unknown> | null,
options: ActivityFormatOptions = {},
): string {
if (action === "issue.updated") {
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
if (issueUpdatedVerb) return issueUpdatedVerb;
}
const structuredChange = formatStructuredIssueChange({
action,
details,
options,
forIssueDetail: false,
});
if (structuredChange) return structuredChange;
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
}
export function formatIssueActivityAction(
action: string,
details?: Record<string, unknown> | null,
options: ActivityFormatOptions = {},
): string {
if (action === "issue.updated") {
const issueUpdatedAction = formatIssueUpdatedAction(details);
if (issueUpdatedAction) return issueUpdatedAction;
}
const structuredChange = formatStructuredIssueChange({
action,
details,
options,
forIssueDetail: true,
});
if (structuredChange) return structuredChange;
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
) {
const key = typeof details.key === "string" ? details.key : "document";
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
}
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
}
+37 -3
View File
@@ -12,6 +12,7 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getAvailableInboxIssueColumns,
getApprovalsForTab,
@@ -19,6 +20,7 @@ import {
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadLastInboxTab,
@@ -287,7 +289,8 @@ describe("inbox helpers", () => {
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map<string, number>(),
});
expect(result).toEqual({
@@ -307,7 +310,8 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
mineIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
});
expect(result).toEqual({
@@ -327,7 +331,8 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [],
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
dismissed: new Set<string>(),
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map(),
});
expect(result.mineIssues).toBe(1);
@@ -335,6 +340,35 @@ describe("inbox helpers", () => {
expect(result.inbox).toBe(3);
});
it("resurfaces non-issue items when they change after dismissal", () => {
const dismissedAtByKey = buildInboxDismissedAtByKey([
{
id: "dismissal-1",
companyId: "company-1",
userId: "user-1",
itemKey: "approval:approval-1",
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
]);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T00:30:00.000Z"),
),
).toBe(true);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T01:30:00.000Z"),
),
).toBe(false);
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];
+39 -11
View File
@@ -1,4 +1,11 @@
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import type {
Approval,
DashboardSummary,
HeartbeatRun,
InboxDismissal,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@@ -44,16 +51,19 @@ export interface InboxBadgeData {
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
export function saveDismissedInboxAlerts(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
@@ -61,6 +71,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
);
}
export function isInboxEntityDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: string | Date | null | undefined,
): boolean {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
@@ -426,25 +452,27 @@ export function computeInboxBadgeData({
dashboard,
heartbeatRuns,
mineIssues,
dismissed,
dismissedAlerts,
dismissedAtByKey,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
mineIssues: Issue[];
dismissed: Set<string>;
dismissedAlerts: Set<string>;
dismissedAtByKey: ReadonlyMap<string, number>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
).length;
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
const agentErrorCount = dashboard?.agents.error ?? 0;
@@ -453,11 +481,11 @@ export function computeInboxBadgeData({
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
!dismissedAlerts.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
+37
View File
@@ -130,6 +130,43 @@ describe("buildAssistantPartsFromTranscript", () => {
]);
});
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
name: "search",
toolUseId: "tool-1",
input: { query: "paperclip" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:02.000Z",
toolUseId: "tool-1",
content: "search completed",
isError: false,
},
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
]);
expect(result.parts).toMatchObject([
{ type: "reasoning", text: "Checking the task." },
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "search",
result: "search completed",
isError: false,
},
{ type: "text", text: "Found the relevant code." },
]);
expect(result.segments).toEqual([{
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
}]);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([
{
@@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildNewAgentRuntimeConfig } from "./new-agent-runtime-config";
describe("buildNewAgentRuntimeConfig", () => {
it("defaults new agents to no timer heartbeat", () => {
expect(buildNewAgentRuntimeConfig()).toEqual({
heartbeat: {
enabled: false,
intervalSec: 300,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
});
});
it("preserves explicit heartbeat settings", () => {
expect(
buildNewAgentRuntimeConfig({
heartbeatEnabled: true,
intervalSec: 3600,
}),
).toEqual({
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { defaultCreateValues } from "../components/agent-config-defaults";
export function buildNewAgentRuntimeConfig(input?: {
heartbeatEnabled?: boolean;
intervalSec?: number;
}) {
return {
heartbeat: {
enabled: input?.heartbeatEnabled ?? defaultCreateValues.heartbeatEnabled,
intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
};
}
+1
View File
@@ -107,6 +107,7 @@ export const queryKeys = {
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
activity: (companyId: string) => ["activity", companyId] as const,
costs: (companyId: string, from?: string, to?: string) =>
["costs", companyId, from, to] as const,
+34 -15
View File
@@ -84,6 +84,7 @@ import {
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadInboxNesting,
@@ -100,7 +101,7 @@ import {
type InboxTab,
type InboxWorkItem,
} from "../lib/inbox";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
@@ -596,7 +597,8 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed, dismiss } = useDismissedInboxItems();
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine";
@@ -803,8 +805,11 @@ export function Inbox() {
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns, dismissed],
() =>
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter(
(r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt),
),
[heartbeatRuns, dismissedAtByKey],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
@@ -819,10 +824,12 @@ export function Inbox() {
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
if (tab === "mine") {
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
filtered = filtered.filter(
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
);
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]);
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@@ -839,9 +846,13 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
if (tab === "mine") {
return joinRequests.filter(
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
);
}
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
}, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]);
const workItemsToRender = useMemo(
() =>
@@ -1200,14 +1211,18 @@ export function Inbox() {
const handleArchiveNonIssue = useCallback((key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => {
dismiss(key);
if (key.startsWith("alert:")) {
dismissAlert(key);
} else {
dismissInboxItem(key);
}
setArchivingNonIssueIds((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 200);
}, [dismiss]);
}, [dismissAlert, dismissInboxItem]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (!canArchiveFromTab) return null;
@@ -1409,12 +1424,16 @@ export function Inbox() {
}
const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
const showAggregateAgentError =
!!dashboard &&
dashboard.agents.error > 0 &&
!hasRunFailures &&
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
!!dashboard &&
dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
!dismissedAlerts.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = nestedWorkItems.length > 0;
const showAlertsSection = shouldShowInboxSection({
@@ -1711,7 +1730,7 @@ export function Inbox() {
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(runKey)}
onDismiss={() => dismissInboxItem(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
@@ -1945,7 +1964,7 @@ export function Inbox() {
</Link>
<button
type="button"
onClick={() => dismiss("alert:agent-errors")}
onClick={() => dismissAlert("alert:agent-errors")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
@@ -1968,7 +1987,7 @@ export function Inbox() {
</Link>
<button
type="button"
onClick={() => dismiss("alert:budget")}
onClick={() => dismissAlert("alert:budget")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
+3 -78
View File
@@ -69,6 +69,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatIssueActivityAction } from "@/lib/activity-format";
import {
Activity as ActivityIcon,
Check,
@@ -105,48 +106,17 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
queueTargetRunId?: string | null;
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.feedback_vote_saved": "saved feedback on an AI output",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
"agent.resumed": "resumed the agent",
"agent.terminated": "terminated the agent",
"heartbeat.invoked": "invoked a heartbeat",
"heartbeat.cancelled": "cancelled a heartbeat",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ISSUE_COMMENT_PAGE_SIZE = 50;
function keepPreviousData<T>(previousData: T | undefined) {
return previousData;
}
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -196,50 +166,6 @@ function titleizeFilename(input: string) {
.join(" ");
}
function formatAction(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>;
const parts: string[] = [];
if (details.status !== undefined) {
const from = previous.status;
parts.push(
from
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
: `changed the status to ${humanizeValue(details.status)}`
);
}
if (details.priority !== undefined) {
const from = previous.priority;
parts.push(
from
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
: `changed the priority to ${humanizeValue(details.priority)}`
);
}
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(
details.assigneeAgentId || details.assigneeUserId
? "assigned the issue"
: "unassigned the issue",
);
}
if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description");
if (parts.length > 0) return parts.join(", ");
}
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
) {
const key = typeof details.key === "string" ? details.key : "document";
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
}
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
function mergeOptimisticFeedbackVote(
previousVotes: FeedbackVote[] | undefined,
nextVote: {
@@ -2229,7 +2155,7 @@ export function IssueDetail() {
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatAction(evt.action, evt.details)}</span>
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}
@@ -2255,7 +2181,6 @@ export function IssueDetail() {
)}
</Tabs>
{/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
+5 -9
View File
@@ -23,6 +23,7 @@ import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { isValidAdapterType } from "../adapters/metadata";
import { ReportsToPicker } from "../components/ReportsToPicker";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
@@ -175,15 +176,10 @@ export function NewAgent() {
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
adapterType: configValues.adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: configValues.heartbeatEnabled,
intervalSec: configValues.intervalSec,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
runtimeConfig: buildNewAgentRuntimeConfig({
heartbeatEnabled: configValues.heartbeatEnabled,
intervalSec: configValues.intervalSec,
}),
budgetMonthlyCents: 0,
});
}
+1
View File
@@ -860,6 +860,7 @@ export function RoutineDetail() {
/>
<RoutineVariablesHint />
<RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
+1
View File
@@ -806,6 +806,7 @@ export function Routines() {
<div className="mt-3 space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
title={draft.title}
description={draft.description}
value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}