mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Merge public-gh/master into pap-1239-ui-ux
This commit is contained in:
commit
b578bf1f51
56 changed files with 16126 additions and 397 deletions
38
packages/adapter-utils/src/server-utils.test.ts
Normal file
38
packages/adapter-utils/src/server-utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
83
packages/adapters/codex-local/src/ui/parse-stdout.test.ts
Normal file
83
packages/adapters/codex-local/src/ui/parse-stdout.test.ts
Normal 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,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal 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");
|
||||||
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
24
packages/db/src/schema/inbox_dismissals.ts
Normal 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,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
|
InboxDismissal,
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
PrincipalPermissionGrant,
|
PrincipalPermissionGrant,
|
||||||
Invite,
|
Invite,
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface InboxDismissal {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
userId: string;
|
||||||
|
itemKey: string;
|
||||||
|
dismissedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
212
server/src/__tests__/inbox-dismissals.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal file
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal 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 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal file
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
69
server/src/routes/inbox-dismissals.ts
Normal file
69
server/src/routes/inbox-dismissals.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
41
server/src/services/inbox-dismissals.ts
Normal file
41
server/src/services/inbox-dismissals.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
8
ui/src/api/inboxDismissals.ts
Normal file
8
ui/src/api/inboxDismissals.ts
Normal 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 }),
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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" />}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
ui/src/lib/activity-format.test.ts
Normal file
60
ui/src/lib/activity-format.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatActivityVerb, formatIssueActivityAction } from "./activity-format";
|
||||||
|
|
||||||
|
describe("activity formatting", () => {
|
||||||
|
const agentMap = new Map<string, Agent>([
|
||||||
|
["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent],
|
||||||
|
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("formats blocker activity using linked issue identifiers", () => {
|
||||||
|
const details = {
|
||||||
|
addedBlockedByIssues: [
|
||||||
|
{ id: "issue-2", identifier: "PAP-22", title: "Blocked task" },
|
||||||
|
],
|
||||||
|
removedBlockedByIssues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to");
|
||||||
|
expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats reviewer activity using agent names", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||||
|
],
|
||||||
|
removedParticipants: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to");
|
||||||
|
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats approver removals using user-aware labels", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [],
|
||||||
|
removedParticipants: [
|
||||||
|
{ type: "user", agentId: null, userId: "local-board" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from");
|
||||||
|
expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to updated wording when reviewers are both added and removed", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||||
|
],
|
||||||
|
removedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-approver", userId: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
|
||||||
|
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
|
||||||
|
});
|
||||||
|
});
|
||||||
289
ui/src/lib/activity-format.ts
Normal file
289
ui/src/lib/activity-format.ts
Normal 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, " ");
|
||||||
|
}
|
||||||
|
|
@ -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)];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal file
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
ui/src/lib/new-agent-runtime-config.ts
Normal file
16
ui/src/lib/new-agent-runtime-config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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)]">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }))}
|
||||||
|
|
|
||||||
|
|
@ -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 }))}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue