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

This commit is contained in:
dotta 2026-04-09 09:04:22 -05:00
commit b578bf1f51
56 changed files with 16126 additions and 397 deletions

View file

@ -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);
});
});

View file

@ -201,6 +201,22 @@ type PaperclipWakeIssue = {
priority: string | null; 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 = { type PaperclipWakeComment = {
id: string | null; id: string | null;
issueId: string | null; issueId: string | null;
@ -214,6 +230,7 @@ type PaperclipWakeComment = {
type PaperclipWakePayload = { type PaperclipWakePayload = {
reason: string | null; reason: string | null;
issue: PaperclipWakeIssue | null; issue: PaperclipWakeIssue | null;
executionStage: PaperclipWakeExecutionStage | null;
commentIds: string[]; commentIds: string[];
latestCommentId: string | null; latestCommentId: string | null;
comments: PaperclipWakeComment[]; 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 { export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
const payload = parseObject(value); const payload = parseObject(value);
const comments = Array.isArray(payload.comments) 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) .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => entry.trim()) .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 { return {
reason: asString(payload.reason, "").trim() || null, reason: asString(payload.reason, "").trim() || null,
issue: normalizePaperclipWakeIssue(payload.issue), issue: normalizePaperclipWakeIssue(payload.issue),
executionStage,
commentIds, commentIds,
latestCommentId: asString(payload.latestCommentId, "").trim() || null, latestCommentId: asString(payload.latestCommentId, "").trim() || null,
comments, comments,
@ -300,6 +365,12 @@ export function renderPaperclipWakePrompt(
const normalized = normalizePaperclipWakePayload(value); const normalized = normalizePaperclipWakePayload(value);
if (!normalized) return ""; if (!normalized) return "";
const resumedSession = options.resumedSession === true; 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 const lines = resumedSession
? [ ? [
@ -342,7 +413,38 @@ export function renderPaperclipWakePrompt(
lines.push(`- omitted comments: ${normalized.missingCount}`); 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()) { for (const [index, comment] of normalized.comments.entries()) {
const authorLabel = comment.authorId const authorLabel = comment.authorId
@ -967,16 +1069,12 @@ export async function runChildProcess(
}) as ChildProcessWithEvents; }) as ChildProcessWithEvents;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
if (opts.stdin != null && child.stdin) { const spawnPersistPromise =
child.stdin.write(opts.stdin); typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
child.stdin.end(); ? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
} onLogError(err, runId, "failed to record child process metadata");
})
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) { : Promise.resolve();
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
onLogError(err, runId, "failed to record child process metadata");
});
}
runningProcesses.set(runId, { child, graceSec: opts.graceSec }); 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")); .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) => { child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId); runningProcesses.delete(runId);

View file

@ -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,
}]);
});
});

View file

@ -118,6 +118,52 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }]; 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( function parseCodexItem(
item: Record<string, unknown>, item: Record<string, unknown>,
ts: string, ts: string,
@ -146,13 +192,7 @@ function parseCodexItem(
} }
if (itemType === "tool_use") { if (itemType === "tool_use") {
return [{ return parseToolUseItem(item, ts, phase);
kind: "tool_call",
ts,
name: asString(item.name, "unknown"),
toolUseId: asString(item.id),
input: item.input ?? {},
}];
} }
if (itemType === "tool_result" && phase === "completed") { if (itemType === "tool_result" && phase === "completed") {

View file

@ -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

View file

@ -372,6 +372,13 @@
"when": 1775571715162, "when": 1775571715162,
"tag": "0052_mushy_trauma", "tag": "0052_mushy_trauma",
"breakpoints": true "breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1775604018515,
"tag": "0053_sharp_wild_child",
"breakpoints": true
} }
] ]
} }

View file

@ -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,
),
}),
);

View file

@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js"; export { issueComments } from "./issue_comments.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js"; export { issueInboxArchives } from "./issue_inbox_archives.js";
export { inboxDismissals } from "./inbox_dismissals.js";
export { feedbackVotes } from "./feedback_votes.js"; export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js"; export { feedbackExports } from "./feedback_exports.js";
export { issueReadStates } from "./issue_read_states.js"; export { issueReadStates } from "./issue_read_states.js";

View file

@ -288,6 +288,7 @@ export type {
DashboardSummary, DashboardSummary,
ActivityEvent, ActivityEvent,
SidebarBadges, SidebarBadges,
InboxDismissal,
CompanyMembership, CompanyMembership,
PrincipalPermissionGrant, PrincipalPermissionGrant,
Invite, Invite,

View file

@ -12,9 +12,18 @@ describe("routine variable helpers", () => {
).toEqual(["repo", "priority"]); ).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", () => { it("preserves existing metadata when syncing variables from a template", () => {
expect( expect(
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [ syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] }, { name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
]), ]),
).toEqual([ ).toEqual([

View file

@ -1,18 +1,25 @@
import type { RoutineVariable } from "./types/routine.js"; import type { RoutineVariable } from "./types/routine.js";
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; 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 { export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name); return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
} }
export function extractRoutineVariableNames(template: string | null | undefined): string[] { function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
if (!template) return []; 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>(); const found = new Set<string>();
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) { for (const source of normalizeRoutineTemplateInput(template)) {
const name = match[1]; for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
if (name && !found.has(name)) { const name = match[1];
found.add(name); if (name && !found.has(name)) {
found.add(name);
}
} }
} }
return [...found]; return [...found];
@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
} }
export function syncRoutineVariablesWithTemplate( export function syncRoutineVariablesWithTemplate(
template: string | null | undefined, template: RoutineTemplateInput,
existing: RoutineVariable[] | null | undefined, existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] { ): RoutineVariable[] {
const names = extractRoutineVariableNames(template); const names = extractRoutineVariableNames(template);

View file

@ -0,0 +1,9 @@
export interface InboxDismissal {
id: string;
companyId: string;
userId: string;
itemKey: string;
dismissedAt: Date;
createdAt: Date;
updatedAt: Date;
}

View file

@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js"; export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js"; export type { ActivityEvent } from "./activity.js";
export type { SidebarBadges } from "./sidebar-badges.js"; export type { SidebarBadges } from "./sidebar-badges.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type { export type {
CompanyMembership, CompanyMembership,
PrincipalPermissionGrant, PrincipalPermissionGrant,

View file

@ -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 () => { it("exposes explicit task assignment access on agent detail", async () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([ mockAccessService.listPrincipalGrants.mockResolvedValue([
{ {

View file

@ -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 () => { 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");

View file

@ -488,6 +488,23 @@ describe("heartbeat comment wake batching", () => {
expect(firstRun).not.toBeNull(); expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1); 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(); gateway.releaseFirstWait();
await waitFor(async () => { await waitFor(async () => {
const runs = await db const runs = await db

View file

@ -272,6 +272,18 @@ describe("shouldResetTaskSessionForWake", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true); 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", () => { it("preserves session context on timer heartbeats", () => {
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false); expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
}); });

View file

@ -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,
});
});
});

View file

@ -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 }],
}),
}),
);
});
});

View file

@ -7,6 +7,7 @@ import { normalizeIssueExecutionPolicy } from "../services/issue-execution-polic
const mockIssueService = vi.hoisted(() => ({ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(), getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(), update: vi.fn(),
addComment: vi.fn(), addComment: vi.fn(),
findMentionedAgents: vi.fn(), findMentionedAgents: vi.fn(),
@ -75,8 +76,12 @@ vi.mock("../services/index.js", () => ({
function createApp() { function createApp() {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
return app;
}
function installActor(app: express.Express, actor?: Record<string, unknown>) {
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).actor = { (req as any).actor = actor ?? {
type: "board", type: "board",
userId: "local-board", userId: "local-board",
companyIds: ["company-1"], companyIds: ["company-1"],
@ -119,6 +124,10 @@ describe("issue comment reopen routes", () => {
mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); 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 () => { 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, ...patch,
})); }));
const res = await request(createApp()) const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111") .patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@ -157,7 +166,7 @@ describe("issue comment reopen routes", () => {
...patch, ...patch,
})); }));
const res = await request(createApp()) const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111") .patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@ -207,7 +216,7 @@ describe("issue comment reopen routes", () => {
status: "cancelled", status: "cancelled",
}); });
const res = await request(createApp()) const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111") .patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
@ -265,7 +274,7 @@ describe("issue comment reopen routes", () => {
_tx: tx, _tx: tx,
})); }));
const res = await request(createApp()) const res = await request(installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111") .patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done", comment: "Approved for ship" }); .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"],
}),
}),
}),
);
});
}); });

View file

@ -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();
});
});

View file

@ -413,33 +413,45 @@ describe("issue execution policy transitions", () => {
const policy = twoStagePolicy(); const policy = twoStagePolicy();
const reviewStageId = policy.stages[0].id; const reviewStageId = policy.stages[0].id;
it("non-participant cannot advance stage via status change", () => { it("non-participant stage updates are coerced back to the active stage", () => {
expect(() => const result = applyIssueExecutionPolicyTransition({
applyIssueExecutionPolicyTransition({ issue: {
issue: { status: "in_review",
status: "in_review", assigneeAgentId: qaAgentId,
assigneeAgentId: qaAgentId, assigneeUserId: null,
assigneeUserId: null, executionPolicy: policy,
executionPolicy: policy, executionState: {
executionState: { status: "pending",
status: "pending", currentStageId: reviewStageId,
currentStageId: reviewStageId, currentStageIndex: 0,
currentStageIndex: 0, currentStageType: "review",
currentStageType: "review", currentParticipant: { type: "agent", agentId: qaAgentId },
currentParticipant: { type: "agent", agentId: qaAgentId }, returnAssignee: { type: "agent", agentId: coderAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId }, completedStageIds: [],
completedStageIds: [], lastDecisionId: null,
lastDecisionId: null, lastDecisionOutcome: null,
lastDecisionOutcome: null,
},
}, },
policy, },
requestedStatus: "done", policy,
requestedAssigneePatch: {}, requestedStatus: "done",
actor: { agentId: coderAgentId }, requestedAssigneePatch: { assigneeUserId: boardUserId },
commentBody: "Trying to bypass review", actor: { agentId: coderAgentId },
}), commentBody: "Trying to bypass review",
).toThrow("Only the active reviewer or approver can advance"); });
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", () => { it("non-participant can still post non-advancing updates", () => {
@ -663,6 +675,7 @@ describe("issue execution policy transitions", () => {
describe("no-op transitions", () => { describe("no-op transitions", () => {
const policy = twoStagePolicy(); const policy = twoStagePolicy();
const reviewStageId = policy.stages[0].id;
it("non-done status change without review context is a no-op", () => { it("non-done status change without review context is a no-op", () => {
const result = applyIssueExecutionPolicyTransition({ const result = applyIssueExecutionPolicyTransition({
@ -682,6 +695,72 @@ describe("issue execution policy transitions", () => {
expect(result.patch).toEqual({}); 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", () => { it("no policy and no state is a no-op", () => {
const result = applyIssueExecutionPolicyTransition({ const result = applyIssueExecutionPolicyTransition({
issue: { issue: {
@ -699,6 +778,25 @@ describe("issue execution policy transitions", () => {
expect(result.patch).toEqual({}); 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", () => { describe("multi-participant stages", () => {
@ -895,4 +993,100 @@ describe("issue execution policy transitions", () => {
expect(result.patch.assigneeUserId).toBe(boardUserId); 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 },
},
});
});
});
}); });

View file

@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
projectId, projectId,
goalId: null, goalId: null,
parentIssueId: null, parentIssueId: null,
title: "repo triage", title: "repo triage for {{repo}}",
description: "Review {{repo}} for {{priority}} bugs", description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId, assigneeAgentId: agentId,
priority: "medium", 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, { const run = await svc.runRoutine(variableRoutine.id, {
source: "manual", source: "manual",
@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}); });
const storedIssue = await db const storedIssue = await db
.select({ description: issues.description }) .select({ title: issues.title, description: issues.description })
.from(issues) .from(issues)
.where(eq(issues.id, run.linkedIssueId!)) .where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
.where(eq(routineRuns.id, run.id)) .where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
expect(storedIssue?.title).toBe("repo triage for paperclip");
expect(storedIssue?.description).toBe("Review paperclip for high bugs"); expect(storedIssue?.description).toBe("Review paperclip for high bugs");
expect(storedRun?.triggerPayload).toEqual({ expect(storedRun?.triggerPayload).toEqual({
variables: { variables: {

View file

@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js"; import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js"; import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js"; import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js"; import { assetRoutes } from "./routes/assets.js";
@ -166,6 +167,7 @@ export async function createApp(
api.use(activityRoutes(db)); api.use(activityRoutes(db));
api.use(dashboardRoutes(db)); api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db)); api.use(sidebarBadgeRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db)); api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>(); const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager(); const workerManager = createPluginWorkerManager();

View file

@ -449,11 +449,25 @@ export function agentRoutes(db: Db) {
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) { function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {}; const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
return { return {
enabled: parseBooleanLike(heartbeat.enabled) ?? true, enabled: parseBooleanLike(heartbeat.enabled) ?? false,
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), 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 { function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519"); const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) {
const normalizedHireInput = { const normalizedHireInput = {
...hireInput, ...hireInput,
adapterConfig: normalizedAdapterConfig, adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
}; };
const company = await db const company = await db
@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) {
const createdAgent = await svc.create(companyId, { const createdAgent = await svc.create(companyId, {
...createInput, ...createInput,
adapterConfig: normalizedAdapterConfig, adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
status: "idle", status: "idle",
spentMonthlyCents: 0, spentMonthlyCents: 0,
lastHeartbeatAt: null, lastHeartbeatAt: null,

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;
}

View file

@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js"; export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js"; export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js"; export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js"; export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js"; export { instanceSettingsRoutes } from "./instance-settings.js";

View file

@ -56,13 +56,219 @@ import {
SVG_CONTENT_TYPE, SVG_CONTENT_TYPE,
} from "../attachment-types.js"; } from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.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 MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({ const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(), 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( export function issueRoutes(
db: Db, db: Db,
storage: StorageService, storage: StorageService,
@ -1066,9 +1272,10 @@ export function issueRoutes(
} }
const actor = getActorInfo(req); const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
const issue = await svc.create(companyId, { const issue = await svc.create(companyId, {
...req.body, ...req.body,
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy), executionPolicy,
createdByAgentId: actor.agentId, createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByUserId: actor.actorType === "user" ? actor.actorId : null,
}); });
@ -1110,24 +1317,6 @@ export function issueRoutes(
return; return;
} }
assertCompanyAccess(req, existing.companyId); 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; if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req); const actor = getActorInfo(req);
@ -1191,14 +1380,20 @@ export function issueRoutes(
if (req.body.executionPolicy !== undefined) { if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); 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({ const transition = applyIssueExecutionPolicyTransition({
issue: existing, issue: existing,
policy: policy: nextExecutionPolicy,
updateFields.executionPolicy !== undefined requestedStatus,
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: { requestedAssigneePatch: {
assigneeAgentId: assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
@ -1224,6 +1419,48 @@ export function issueRoutes(
} }
Object.assign(updateFields, transition.patch); 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; let issue;
try { try {
if (transition.decision && decisionId) { if (transition.decision && decisionId) {
@ -1291,8 +1528,9 @@ export function issueRoutes(
return; return;
} }
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue; 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)) { if (issue && Array.isArray(req.body.blockedByIssueIds)) {
const updatedRelations = await svc.getRelationSummaries(issue.id); updatedRelations = await svc.getRelationSummaries(issue.id);
issueResponse = { issueResponse = {
...issue, ...issue,
blockedBy: updatedRelations.blockedBy, blockedBy: updatedRelations.blockedBy,
@ -1349,6 +1587,8 @@ export function issueRoutes(
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]); const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate)); const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.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) { if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
await logActivity(db, { await logActivity(db, {
companyId: issue.companyId, companyId: issue.companyId,
@ -1364,11 +1604,58 @@ export function issueRoutes(
blockedByIssueIds: req.body.blockedByIssueIds, blockedByIssueIds: req.body.blockedByIssueIds,
addedBlockedByIssueIds, addedBlockedByIssueIds,
removedBlockedByIssueIds, 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") { if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient(); const tc = getTelemetryClient();
if (tc && actor.agentId) { if (tc && actor.agentId) {
@ -1414,6 +1701,16 @@ export function issueRoutes(
existing.status === "backlog" && existing.status === "backlog" &&
issue.status !== "backlog" && issue.status !== "backlog" &&
req.body.status !== undefined; 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. // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => { void (async () => {
@ -1427,7 +1724,9 @@ export function issueRoutes(
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup }); 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, { addWakeup(issue.assigneeAgentId, {
source: "assignment", source: "assignment",
triggerDetail: "system", triggerDetail: "system",

View file

@ -1,12 +1,20 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db"; import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js"; import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js"; import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.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) { export function sidebarBadgeRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = sidebarBadgeService(db); const svc = sidebarBadgeService(db);
@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve"); canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
} }
const joinRequestCount = canApproveJoins const visibleJoinRequests = canApproveJoins
? await db ? await db
.select({ count: sql<number>`count(*)` }) .select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests) .from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) .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, { const badges = await svc.get(companyId, {
joinRequests: joinRequestCount, dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
}); });
const summary = await dashboard.summary(companyId); const summary = await dashboard.summary(companyId);
const hasFailedRuns = badges.failedRuns > 0; const hasFailedRuns = badges.failedRuns > 0;
const alertsCount = const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) + (summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 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); res.json(badges);
}); });

View file

@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
if (contextSnapshot?.forceFreshSession === true) return true; if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); 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; return false;
} }
@ -714,6 +721,9 @@ function describeSessionResetReason(
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned"; 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; return null;
} }
@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
} }
| null; | null;
}) { }) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot); const commentIds = extractWakeCommentIds(input.contextSnapshot);
if (commentIds.length === 0) return null;
const issueId = readNonEmptyString(input.contextSnapshot.issueId); const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const issueSummary = const issueSummary =
input.issueSummary ?? input.issueSummary ??
@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
: null); : null);
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
const commentRows = await input.db const commentRows =
.select({ commentIds.length === 0
id: issueComments.id, ? []
issueId: issueComments.issueId, : await input.db
body: issueComments.body, .select({
authorAgentId: issueComments.authorAgentId, id: issueComments.id,
authorUserId: issueComments.authorUserId, issueId: issueComments.issueId,
createdAt: issueComments.createdAt, body: issueComments.body,
}) authorAgentId: issueComments.authorAgentId,
.from(issueComments) authorUserId: issueComments.authorUserId,
.where( createdAt: issueComments.createdAt,
and( })
eq(issueComments.companyId, input.companyId), .from(issueComments)
inArray(issueComments.id, commentIds), .where(
), and(
); eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
const comments: Array<Record<string, unknown>> = []; const comments: Array<Record<string, unknown>> = [];
@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
priority: issueSummary.priority, priority: issueSummary.priority,
} }
: null, : null,
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
commentIds, commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null, latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments, comments,
@ -2159,7 +2173,7 @@ export function heartbeatService(db: Db) {
const heartbeat = parseObject(runtimeConfig.heartbeat); const heartbeat = parseObject(runtimeConfig.heartbeat);
return { return {
enabled: asBoolean(heartbeat.enabled, true), enabled: asBoolean(heartbeat.enabled, false),
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)), intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true), wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns), maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),

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;
},
};
}

View file

@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js"; export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js"; export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js"; export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js"; export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js"; export { instanceSettingsService } from "./instance-settings.js";

View file

@ -36,6 +36,7 @@ type TransitionInput = {
type TransitionResult = { type TransitionResult = {
patch: Record<string, unknown>; patch: Record<string, unknown>;
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">; decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
workflowControlledAssignment?: boolean;
}; };
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed"; 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; 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) { function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
if (!principal) { if (!principal) {
return { assigneeAgentId: null, assigneeUserId: null }; 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 { export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState); const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue); const currentAssignee = assigneePrincipal(input.issue);
const actor = actorPrincipal(input.actor); const actor = actorPrincipal(input.actor);
const requestedAssigneePatchProvided =
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch); const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null; const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
const requestedStatus = input.requestedStatus; const requestedStatus = input.requestedStatus;
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
if (!input.policy) { if (!input.policy) {
if (existingState) { if (existingState) {
@ -228,90 +269,159 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch }; return { patch };
} }
if (currentStage && input.issue.status === "in_review") { if (existingState?.currentStageId && !currentStage) {
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) { clearExecutionStatePatch({
if (requestedStatus && requestedStatus !== "in_review") { patch,
throw unprocessable("Only the active reviewer or approver can advance the current execution stage"); issueStatus: input.issue.status,
} requestedStatus,
return { patch }; 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 (!stageHasParticipant(activeStage, currentParticipant)) {
if (!input.commentBody?.trim()) { const participant = selectStageParticipant(activeStage, {
throw unprocessable("Approving a review or approval stage requires a comment"); preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
}
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,
exclude: existingState?.returnAssignee ?? null, exclude: existingState?.returnAssignee ?? null,
}); });
if (!participant) { 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"; buildPendingStagePatch({
Object.assign(patch, patchForPrincipal(participant)); patch,
patch.executionState = buildPendingState({ previous: existingState,
previous: approvedState, policy: input.policy,
stage: nextStage, stage: activeStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
participant, participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee, returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
}); });
return { return {
patch, patch,
decision: { workflowControlledAssignment: true,
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
}; };
} }
if (requestedStatus && requestedStatus !== "in_review") { if (principalsEqual(currentParticipant, actor)) {
if (!input.commentBody?.trim()) { if (requestedStatus === "done") {
throw unprocessable("Requesting changes requires a comment"); 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 { return {
patch, patch,
decision: { workflowControlledAssignment: true,
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
}; };
} }
return { patch }; return { patch };
} }
if (requestedStatus !== "done") { const shouldStartWorkflow =
requestedStatus === "done" ||
requestedStatus === "in_review";
if (!shouldStartWorkflow) {
return { patch }; return { patch };
} }
@ -333,14 +443,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`); throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
} }
patch.status = "in_review"; buildPendingStagePatch({
Object.assign(patch, patchForPrincipal(participant)); patch,
patch.executionState = buildPendingState({
previous: existingState, previous: existingState,
policy: input.policy,
stage: pendingStage, stage: pendingStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
participant, participant,
returnAssignee, returnAssignee,
}); });
return { patch }; return {
patch,
workflowControlledAssignment: true,
};
} }

View file

@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
executionWorkspaceSettings?: Record<string, unknown> | null; executionWorkspaceSettings?: Record<string, unknown> | null;
}) { }) {
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input); 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 description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables); const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => { const run = await db.transaction(async (tx) => {
@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: input.routine.projectId, projectId: input.routine.projectId,
goalId: input.routine.goalId, goalId: input.routine.goalId,
parentId: input.routine.parentIssueId, parentId: input.routine.parentIssueId,
title: input.routine.title, title,
description, description,
status: "todo", status: "todo",
priority: input.routine.priority, 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.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate( const variables = syncRoutineVariablesWithTemplate(
input.description, [input.title, input.description],
sanitizeRoutineVariableInputs(input.variables), sanitizeRoutineVariableInputs(input.variables),
); );
assertRoutineVariableDefinitions(variables); assertRoutineVariableDefinitions(variables);
@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!existing) return null; if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId; const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId; const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextTitle = patch.title ?? existing.title;
const nextDescription = patch.description === undefined ? existing.description : patch.description; const nextDescription = patch.description === undefined ? existing.description : patch.description;
const nextVariables = syncRoutineVariablesWithTemplate( const nextVariables = syncRoutineVariablesWithTemplate(
nextDescription, [nextTitle, nextDescription],
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
); );
if (patch.projectId) await assertProject(existing.companyId, nextProjectId); if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: nextProjectId, projectId: nextProjectId,
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId, goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId, parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
title: patch.title ?? existing.title, title: nextTitle,
description: nextDescription, description: nextDescription,
assigneeAgentId: nextAssigneeAgentId, assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority, priority: patch.priority ?? existing.priority,

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 type { Db } from "@paperclipai/db";
import { agents, approvals, heartbeatRuns } from "@paperclipai/db"; import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
import type { SidebarBadges } from "@paperclipai/shared"; import type { SidebarBadges } from "@paperclipai/shared";
@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"]; const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"]; 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) { export function sidebarBadgeService(db: Db) {
return { return {
get: async ( get: async (
companyId: string, 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> => { ): Promise<SidebarBadges> => {
const actionableApprovals = await db const actionableApprovals = await db
.select({ count: sql<number>`count(*)` }) .select({ id: approvals.id, updatedAt: approvals.updatedAt })
.from(approvals) .from(approvals)
.where( .where(
and( and(
@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES), 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 const latestRunByAgent = await db
.selectDistinctOn([heartbeatRuns.agentId], { .selectDistinctOn([heartbeatRuns.agentId], {
id: heartbeatRuns.id,
runStatus: heartbeatRuns.status, runStatus: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt,
}) })
.from(heartbeatRuns) .from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt)); .orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
const failedRuns = latestRunByAgent.filter((row) => 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; ).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; const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
return { return {
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues, inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,

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 }),
};

View file

@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats"; export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings"; export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges"; export { sidebarBadgesApi } from "./sidebarBadges";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills"; export { companySkillsApi } from "./companySkills";

View file

@ -2,72 +2,9 @@ import { Link } from "@/lib/router";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { formatActivityVerb } from "../lib/activity-format";
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; 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 { function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
switch (entityType) { switch (entityType) {
case "issue": return `/issues/${name ?? entityId}`; case "issue": return `/issues/${name ?? entityId}`;
@ -88,7 +25,7 @@ interface ActivityRowProps {
} }
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: 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 isHeartbeatEvent = event.entityType === "heartbeat_run";
const heartbeatAgentId = isHeartbeatEvent const heartbeatAgentId = isHeartbeatEvent

View file

@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<ToggleWithNumber <ToggleWithNumber
label="Heartbeat on interval" label="Heartbeat on interval"
hint={help.heartbeatInterval} hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)} checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)} onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec" numberLabel="sec"
numberPrefix="Run heartbeat every" numberPrefix="Run heartbeat every"
numberHint={help.intervalSec} numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
/> />
</div> </div>
<CollapsibleSection <CollapsibleSection

View file

@ -33,6 +33,7 @@ import {
buildOnboardingProjectPayload, buildOnboardingProjectPayload,
selectDefaultCompanyGoalId selectDefaultCompanyGoalId
} from "../lib/onboarding-launch"; } from "../lib/onboarding-launch";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import { import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL DEFAULT_CODEX_LOCAL_MODEL
@ -460,15 +461,7 @@ export function OnboardingWizard() {
role: "ceo", role: "ceo",
adapterType, adapterType,
adapterConfig: buildAdapterConfig(), adapterConfig: buildAdapterConfig(),
runtimeConfig: { runtimeConfig: buildNewAgentRuntimeConfig()
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1
}
}
}); });
setCreatedAgentId(agent.id); setCreatedAgentId(agent.id);
queryClient.invalidateQueries({ queryClient.invalidateQueries({

View file

@ -36,18 +36,20 @@ function updateVariableList(
} }
export function RoutineVariablesEditor({ export function RoutineVariablesEditor({
title,
description, description,
value, value,
onChange, onChange,
}: { }: {
title: string;
description: string; description: string;
value: RoutineVariable[]; value: RoutineVariable[];
onChange: (value: RoutineVariable[]) => void; onChange: (value: RoutineVariable[]) => void;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const syncedVariables = useMemo( const syncedVariables = useMemo(
() => syncRoutineVariablesWithTemplate(description, value), () => syncRoutineVariablesWithTemplate([title, description], value),
[description, value], [description, title, value],
); );
const syncedSignature = serializeVariables(syncedVariables); const syncedSignature = serializeVariables(syncedVariables);
const currentSignature = serializeVariables(value); const currentSignature = serializeVariables(value);
@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
<div> <div>
<p className="text-sm font-medium">Variables</p> <p className="text-sm font-medium">Variables</p>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />} {open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}

View file

@ -1,17 +1,19 @@
import { useEffect, useMemo, useState } from "react"; 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 { accessApi } from "../api/access";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { inboxDismissalsApi } from "../api/inboxDismissals";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard"; import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { import {
buildInboxDismissedAtByKey,
computeInboxBadgeData, computeInboxBadgeData,
getRecentTouchedIssues, getRecentTouchedIssues,
loadDismissedInboxItems, loadDismissedInboxAlerts,
saveDismissedInboxItems, saveDismissedInboxAlerts,
loadReadInboxItems, loadReadInboxItems,
saveReadInboxItems, saveReadInboxItems,
READ_ITEMS_KEY, READ_ITEMS_KEY,
@ -19,13 +21,13 @@ import {
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() { export function useDismissedInboxAlerts() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems); const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
useEffect(() => { useEffect(() => {
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return; if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems()); setDismissed(loadDismissedInboxAlerts());
}; };
window.addEventListener("storage", handleStorage); window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage); return () => window.removeEventListener("storage", handleStorage);
@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
setDismissed((prev) => { setDismissed((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(id); next.add(id);
saveDismissedInboxItems(next); saveDismissedInboxAlerts(next);
return next; return next;
}); });
}; };
@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss }; 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() { export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems); const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
@ -77,7 +136,8 @@ export function useReadInboxItems() {
} }
export function useInboxBadge(companyId: string | null | undefined) { export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems(); const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
const { dismissedAtByKey } = useInboxDismissals(companyId);
const { data: approvals = [] } = useQuery({ const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!), queryKey: queryKeys.approvals.list(companyId!),
@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
dashboard, dashboard,
heartbeatRuns, heartbeatRuns,
mineIssues, mineIssues,
dismissed, dismissedAlerts,
dismissedAtByKey,
}), }),
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed], [approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
); );
} }

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");
});
});

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, " ");
}

View file

@ -12,6 +12,7 @@ import type {
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
DEFAULT_INBOX_ISSUE_COLUMNS, DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey,
computeInboxBadgeData, computeInboxBadgeData,
getAvailableInboxIssueColumns, getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
@ -19,6 +20,7 @@ import {
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns, loadInboxIssueColumns,
loadLastInboxTab, loadLastInboxTab,
@ -287,7 +289,8 @@ describe("inbox helpers", () => {
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
], ],
mineIssues: [makeIssue("1", true)], mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(), dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map<string, number>(),
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -307,7 +310,8 @@ describe("inbox helpers", () => {
dashboard, dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
mineIssues: [], 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({ expect(result).toEqual({
@ -327,7 +331,8 @@ describe("inbox helpers", () => {
dashboard, dashboard,
heartbeatRuns: [], heartbeatRuns: [],
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)], 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); expect(result.mineIssues).toBe(1);
@ -335,6 +340,35 @@ describe("inbox helpers", () => {
expect(result.inbox).toBe(3); 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", () => { it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)]; const issues = [makeIssue("1", true), makeIssue("2", false)];

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 RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@ -44,16 +51,19 @@ export interface InboxBadgeData {
alerts: number; alerts: number;
} }
export function loadDismissedInboxItems(): Set<string> { export function loadDismissedInboxAlerts(): Set<string> {
try { try {
const raw = localStorage.getItem(DISMISSED_KEY); 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 { } catch {
return new Set(); return new Set();
} }
} }
export function saveDismissedInboxItems(ids: Set<string>) { export function saveDismissedInboxAlerts(ids: Set<string>) {
try { try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch { } 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> { export function loadReadInboxItems(): Set<string> {
try { try {
const raw = localStorage.getItem(READ_ITEMS_KEY); const raw = localStorage.getItem(READ_ITEMS_KEY);
@ -426,25 +452,27 @@ export function computeInboxBadgeData({
dashboard, dashboard,
heartbeatRuns, heartbeatRuns,
mineIssues, mineIssues,
dismissed, dismissedAlerts,
dismissedAtByKey,
}: { }: {
approvals: Approval[]; approvals: Approval[];
joinRequests: JoinRequest[]; joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined; dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[]; heartbeatRuns: HeartbeatRun[];
mineIssues: Issue[]; mineIssues: Issue[];
dismissed: Set<string>; dismissedAlerts: Set<string>;
dismissedAtByKey: ReadonlyMap<string, number>;
}): InboxBadgeData { }): InboxBadgeData {
const actionableApprovals = approvals.filter( const actionableApprovals = approvals.filter(
(approval) => (approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`), !isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
).length; ).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`), (run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
).length; ).length;
const visibleJoinRequests = joinRequests.filter( const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`), (jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
).length; ).length;
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length; const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
const agentErrorCount = dashboard?.agents.error ?? 0; const agentErrorCount = dashboard?.agents.error ?? 0;
@ -453,11 +481,11 @@ export function computeInboxBadgeData({
const showAggregateAgentError = const showAggregateAgentError =
agentErrorCount > 0 && agentErrorCount > 0 &&
failedRuns === 0 && failedRuns === 0 &&
!dismissed.has("alert:agent-errors"); !dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert = const showBudgetAlert =
monthBudgetCents > 0 && monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 && monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget"); !dismissedAlerts.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert); const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return { return {

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", () => { it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([ const result = buildAssistantPartsFromTranscript([
{ {

View file

@ -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,
},
});
});
});

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,
},
};
}

View file

@ -107,6 +107,7 @@ export const queryKeys = {
}, },
dashboard: (companyId: string) => ["dashboard", companyId] as const, dashboard: (companyId: string) => ["dashboard", companyId] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", 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, activity: (companyId: string) => ["activity", companyId] as const,
costs: (companyId: string, from?: string, to?: string) => costs: (companyId: string, from?: string, to?: string) =>
["costs", companyId, from, to] as const, ["costs", companyId, from, to] as const,

View file

@ -84,6 +84,7 @@ import {
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns, loadInboxIssueColumns,
loadInboxNesting, loadInboxNesting,
@ -100,7 +101,7 @@ import {
type InboxTab, type InboxTab,
type InboxWorkItem, type InboxWorkItem,
} from "../lib/inbox"; } from "../lib/inbox";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns"; export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
@ -596,7 +597,8 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns); 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 { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine"; const pathSegment = location.pathname.split("/").pop() ?? "mine";
@ -803,8 +805,11 @@ export function Inbox() {
const currentUserId = session?.user.id ?? session?.session.userId ?? null; const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const failedRuns = useMemo( 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 liveIssueIds = useMemo(() => {
const ids = new Set<string>(); const ids = new Set<string>();
@ -819,10 +824,12 @@ export function Inbox() {
const approvalsToRender = useMemo(() => { const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter); let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
if (tab === "mine") { 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; return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]); }, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
const showJoinRequestsCategory = const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory = const showTouchedCategory =
@ -839,9 +846,13 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => { const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return []; 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; return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]); }, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]);
const workItemsToRender = useMemo( const workItemsToRender = useMemo(
() => () =>
@ -1200,14 +1211,18 @@ export function Inbox() {
const handleArchiveNonIssue = useCallback((key: string) => { const handleArchiveNonIssue = useCallback((key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key)); setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => { setTimeout(() => {
dismiss(key); if (key.startsWith("alert:")) {
dismissAlert(key);
} else {
dismissInboxItem(key);
}
setArchivingNonIssueIds((prev) => { setArchivingNonIssueIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(key); next.delete(key);
return next; return next;
}); });
}, 200); }, 200);
}, [dismiss]); }, [dismissAlert, dismissInboxItem]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => { const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (!canArchiveFromTab) return null; if (!canArchiveFromTab) return null;
@ -1409,12 +1424,16 @@ export function Inbox() {
} }
const hasRunFailures = failedRuns.length > 0; 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 = const showBudgetAlert =
!!dashboard && !!dashboard &&
dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80 && dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget"); !dismissedAlerts.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = nestedWorkItems.length > 0; const showWorkItemsSection = nestedWorkItems.length > 0;
const showAlertsSection = shouldShowInboxSection({ const showAlertsSection = shouldShowInboxSection({
@ -1711,7 +1730,7 @@ export function Inbox() {
issueById={issueById} issueById={issueById}
agentName={agentName(item.run.agentId)} agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
onDismiss={() => dismiss(runKey)} onDismiss={() => dismissInboxItem(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)} onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)} isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)} unreadState={nonIssueUnreadState(runKey)}
@ -1945,7 +1964,7 @@ export function Inbox() {
</Link> </Link>
<button <button
type="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" 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" aria-label="Dismiss"
> >
@ -1968,7 +1987,7 @@ export function Inbox() {
</Link> </Link>
<button <button
type="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" 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" aria-label="Dismiss"
> >

View file

@ -69,6 +69,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatIssueActivityAction } from "@/lib/activity-format";
import { import {
Activity as ActivityIcon, Activity as ActivityIcon,
Check, Check,
@ -105,48 +106,17 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
queueTargetRunId?: string | null; 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 ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000; const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000; const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000; 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; const ISSUE_COMMENT_PAGE_SIZE = 50;
function keepPreviousData<T>(previousData: T | undefined) { function keepPreviousData<T>(previousData: T | undefined) {
return previousData; 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 { function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null; if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>; return value as Record<string, unknown>;
@ -196,50 +166,6 @@ function titleizeFilename(input: string) {
.join(" "); .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( function mergeOptimisticFeedbackVote(
previousVotes: FeedbackVote[] | undefined, previousVotes: FeedbackVote[] | undefined,
nextVote: { nextVote: {
@ -2229,7 +2155,7 @@ export function IssueDetail() {
{activity.slice(0, 20).map((evt) => ( {activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} /> <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> <span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div> </div>
))} ))}
@ -2255,7 +2181,6 @@ export function IssueDetail() {
)} )}
</Tabs> </Tabs>
{/* Mobile properties drawer */} {/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}> <Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]"> <SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">

View file

@ -23,6 +23,7 @@ import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { isValidAdapterType } from "../adapters/metadata"; import { isValidAdapterType } from "../adapters/metadata";
import { ReportsToPicker } from "../components/ReportsToPicker"; import { ReportsToPicker } from "../components/ReportsToPicker";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import { import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL, DEFAULT_CODEX_LOCAL_MODEL,
@ -175,15 +176,10 @@ export function NewAgent() {
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}), ...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
adapterType: configValues.adapterType, adapterType: configValues.adapterType,
adapterConfig: buildAdapterConfig(), adapterConfig: buildAdapterConfig(),
runtimeConfig: { runtimeConfig: buildNewAgentRuntimeConfig({
heartbeat: { heartbeatEnabled: configValues.heartbeatEnabled,
enabled: configValues.heartbeatEnabled, intervalSec: configValues.intervalSec,
intervalSec: configValues.intervalSec, }),
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
budgetMonthlyCents: 0, budgetMonthlyCents: 0,
}); });
} }

View file

@ -860,6 +860,7 @@ export function RoutineDetail() {
/> />
<RoutineVariablesHint /> <RoutineVariablesHint />
<RoutineVariablesEditor <RoutineVariablesEditor
title={editDraft.title}
description={editDraft.description} description={editDraft.description}
value={editDraft.variables} value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))} onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}

View file

@ -806,6 +806,7 @@ export function Routines() {
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<RoutineVariablesHint /> <RoutineVariablesHint />
<RoutineVariablesEditor <RoutineVariablesEditor
title={draft.title}
description={draft.description} description={draft.description}
value={draft.variables} value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))} onChange={(variables) => setDraft((current) => ({ ...current, variables }))}