Remove api trigger kind and mark webhook as coming soon

Drop "api" from the trigger kind dropdown and disable the "webhook"
option with a "COMING SOON" label until it's ready.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 06:54:03 -05:00
49 changed files with 1793 additions and 418 deletions

View File

@@ -12,6 +12,8 @@ export interface RunProcessResult {
timedOut: boolean; timedOut: boolean;
stdout: string; stdout: string;
stderr: string; stderr: string;
pid: number | null;
startedAt: string | null;
} }
interface RunningProcess { interface RunningProcess {
@@ -724,6 +726,7 @@ export async function runChildProcess(
graceSec: number; graceSec: number;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onLogError?: (err: unknown, runId: string, message: string) => void; onLogError?: (err: unknown, runId: string, message: string) => void;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
stdin?: string; stdin?: string;
}, },
): Promise<RunProcessResult> { ): Promise<RunProcessResult> {
@@ -756,12 +759,19 @@ export async function runChildProcess(
shell: false, shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents; }) as ChildProcessWithEvents;
const startedAt = new Date().toISOString();
if (opts.stdin != null && child.stdin) { if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin); child.stdin.write(opts.stdin);
child.stdin.end(); child.stdin.end();
} }
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
onLogError(err, runId, "failed to record child process metadata");
});
}
runningProcesses.set(runId, { child, graceSec: opts.graceSec }); runningProcesses.set(runId, { child, graceSec: opts.graceSec });
let timedOut = false; let timedOut = false;
@@ -820,6 +830,8 @@ export async function runChildProcess(
timedOut, timedOut,
stdout, stdout,
stderr, stderr,
pid: child.pid ?? null,
startedAt,
}); });
}); });
}); });

View File

@@ -120,6 +120,7 @@ export interface AdapterExecutionContext {
context: Record<string, unknown>; context: Record<string, unknown>;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>; onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
authToken?: string; authToken?: string;
} }
@@ -297,7 +298,7 @@ export type TranscriptEntry =
| { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean }
| { kind: "user"; ts: string; text: string } | { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean }
| { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "init"; ts: string; model: string; sessionId: string }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
| { kind: "stderr"; ts: string; text: string } | { kind: "stderr"; ts: string; text: string }

View File

@@ -296,7 +296,7 @@ export async function runClaudeLogin(input: {
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -362,7 +362,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null; const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -448,6 +448,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stdin: prompt, stdin: prompt,
timeoutSec, timeoutSec,
graceSec, graceSec,
onSpawn,
onLog, onLog,
}); });
@@ -565,7 +566,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isClaudeUnknownSessionError(initial.parsed) isClaudeUnknownSessionError(initial.parsed)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const retry = await runAttempt(null); const retry = await runAttempt(null);

View File

@@ -212,7 +212,7 @@ export async function ensureCodexSkillsInjected(
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -398,7 +398,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null; const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -421,7 +421,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
); );
} }
@@ -505,6 +505,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stdin: prompt, stdin: prompt,
timeoutSec, timeoutSec,
graceSec, graceSec,
onSpawn,
onLog: async (stream, chunk) => { onLog: async (stream, chunk) => {
if (stream !== "stderr") { if (stream !== "stderr") {
await onLog(stream, chunk); await onLog(stream, chunk);
@@ -591,7 +592,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr) isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const retry = await runAttempt(null); const retry = await runAttempt(null);

View File

@@ -157,7 +157,7 @@ export async function ensureCursorSkillsInjected(
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -290,7 +290,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null; const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -308,13 +308,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Resolve any relative file references from ${instructionsDir}.\n\n`; `Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length; instructionsChars = instructionsPrefix.length;
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
); );
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
); );
} }
@@ -428,6 +428,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timeoutSec, timeoutSec,
graceSec, graceSec,
stdin: prompt, stdin: prompt,
onSpawn,
onLog: async (stream, chunk) => { onLog: async (stream, chunk) => {
if (stream !== "stdout") { if (stream !== "stdout") {
await onLog(stream, chunk); await onLog(stream, chunk);
@@ -520,7 +521,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr) isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const retry = await runAttempt(null); const retry = await runAttempt(null);

View File

@@ -133,7 +133,7 @@ async function ensureGeminiSkillsInjected(
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -238,7 +238,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null; const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -254,13 +254,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${instructionsFilePath}. ` + `The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`; `Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
); );
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
); );
} }
@@ -355,6 +355,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
env, env,
timeoutSec, timeoutSec,
graceSec, graceSec,
onSpawn,
onLog, onLog,
}); });
return { return {
@@ -453,7 +454,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr) isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const retry = await runAttempt(null); const retry = await runAttempt(null);

View File

@@ -605,6 +605,7 @@ class GatewayWsClient {
this.resolveChallenge = resolve; this.resolveChallenge = resolve;
this.rejectChallenge = reject; this.rejectChallenge = reject;
}); });
this.challengePromise.catch(() => {});
} }
async connect( async connect(

View File

@@ -89,7 +89,7 @@ async function ensureOpenCodeSkillsInjected(
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -203,7 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null; const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -222,13 +222,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`; `Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`, `[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
); );
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
); );
} }
@@ -308,6 +308,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stdin: prompt, stdin: prompt,
timeoutSec, timeoutSec,
graceSec, graceSec,
onSpawn,
onLog, onLog,
}); });
return { return {
@@ -394,7 +395,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const retry = await runAttempt(null); const retry = await runAttempt(null);

View File

@@ -27,6 +27,7 @@ import { ensurePiModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips"); const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
const PI_AGENT_SKILLS_DIR = path.join(os.homedir(), ".pi", "agent", "skills");
function firstNonEmptyLine(text: string): string { function firstNonEmptyLine(text: string): string {
return ( return (
@@ -59,33 +60,32 @@ async function ensurePiSkillsInjected(
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
if (selectedEntries.length === 0) return; if (selectedEntries.length === 0) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); await fs.mkdir(PI_AGENT_SKILLS_DIR, { recursive: true });
await fs.mkdir(piSkillsHome, { recursive: true });
const removedSkills = await removeMaintainerOnlySkillSymlinks( const removedSkills = await removeMaintainerOnlySkillSymlinks(
piSkillsHome, PI_AGENT_SKILLS_DIR,
selectedEntries.map((entry) => entry.runtimeName), selectedEntries.map((entry) => entry.runtimeName),
); );
for (const skillName of removedSkills) { for (const skillName of removedSkills) {
await onLog( await onLog(
"stderr", "stderr",
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`, `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${PI_AGENT_SKILLS_DIR}\n`,
); );
} }
for (const entry of selectedEntries) { for (const entry of selectedEntries) {
const target = path.join(piSkillsHome, entry.runtimeName); const target = path.join(PI_AGENT_SKILLS_DIR, entry.runtimeName);
try { try {
const result = await ensurePaperclipSkillSymlink(entry.source, target); const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue; if (result === "skipped") continue;
await onLog( await onLog(
"stderr", "stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.key}" into ${piSkillsHome}\n`, `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.key}" into ${PI_AGENT_SKILLS_DIR}\n`,
); );
} catch (err) { } catch (err) {
await onLog( await onLog(
"stderr", "stderr",
`[paperclip] Failed to inject Pi skill "${entry.key}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, `[paperclip] Failed to inject Pi skill "${entry.key}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`,
); );
} }
} }
@@ -106,7 +106,7 @@ function buildSessionPath(agentId: string, timestamp: string): string {
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString( const promptTemplate = asString(
config.promptTemplate, config.promptTemplate,
@@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (runtimeSessionId && !canResumeSession) { if (runtimeSessionId && !canResumeSession) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
); );
} }
@@ -267,14 +267,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Resolve any relative file references from ${instructionsFileDir}.\n\n` + `Resolve any relative file references from ${instructionsFileDir}.\n\n` +
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`; `You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`, `[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
); );
} catch (err) { } catch (err) {
instructionsReadFailed = true; instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
); );
// Fall back to base prompt template // Fall back to base prompt template
@@ -339,12 +339,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (provider) args.push("--provider", provider); if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId); if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking); if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read,bash,edit,write,grep,find,ls"); args.push("--tools", "read,bash,edit,write,grep,find,ls");
args.push("--session", sessionFile); args.push("--session", sessionFile);
// Add Paperclip skills directory so Pi can load the paperclip skill
args.push("--skill", PI_AGENT_SKILLS_DIR);
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
return args; return args;
}; };
@@ -401,6 +404,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
env: runtimeEnv, env: runtimeEnv,
timeoutSec, timeoutSec,
graceSec, graceSec,
onSpawn,
onLog: bufferedOnLog, onLog: bufferedOnLog,
stdin: buildRpcStdin(), stdin: buildRpcStdin(),
}); });
@@ -481,7 +485,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) { ) {
await onLog( await onLog(
"stderr", "stdout",
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
); );
const newSessionPath = buildSessionPath(agent.id, new Date().toISOString()); const newSessionPath = buildSessionPath(agent.id, new Date().toISOString());

View File

@@ -72,11 +72,22 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
for (const tr of toolResults) { for (const tr of toolResults) {
const content = tr.content; const content = tr.content;
const isError = tr.isError === true; const isError = tr.isError === true;
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
// Extract text from Pi's content array format
let contentStr: string;
if (typeof content === "string") {
contentStr = content;
} else if (Array.isArray(content)) {
contentStr = extractTextContent(content as Array<{ type: string; text?: string }>);
} else {
contentStr = JSON.stringify(content);
}
entries.push({ entries.push({
kind: "tool_result", kind: "tool_result",
ts, ts,
toolUseId: asString(tr.toolCallId, "unknown"), toolUseId: asString(tr.toolCallId, "unknown"),
toolName: asString(tr.toolName),
content: contentStr, content: contentStr,
isError, isError,
}); });
@@ -130,14 +141,35 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
if (type === "tool_execution_end") { if (type === "tool_execution_end") {
const toolCallId = asString(parsed.toolCallId); const toolCallId = asString(parsed.toolCallId);
const toolName = asString(parsed.toolName);
const result = parsed.result; const result = parsed.result;
const isError = parsed.isError === true; const isError = parsed.isError === true;
const contentStr = typeof result === "string" ? result : JSON.stringify(result);
// Extract text from Pi's content array format
// Can be: {"content": [{"type": "text", "text": "..."}]} or [{"type": "text", "text": "..."}]
let contentStr: string;
if (typeof result === "string") {
contentStr = result;
} else if (Array.isArray(result)) {
// Direct array format: result is [{"type": "text", "text": "..."}]
contentStr = extractTextContent(result as Array<{ type: string; text?: string }>);
} else if (result && typeof result === "object") {
const resultObj = result as Record<string, unknown>;
if (Array.isArray(resultObj.content)) {
// Wrapped format: result is {"content": [{"type": "text", "text": "..."}]}
contentStr = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
} else {
contentStr = JSON.stringify(result);
}
} else {
contentStr = JSON.stringify(result);
}
return [{ return [{
kind: "tool_result", kind: "tool_result",
ts, ts,
toolUseId: toolCallId || "unknown", toolUseId: toolCallId || "unknown",
toolName,
content: contentStr, content: contentStr,
isError, isError,
}]; }];

View File

@@ -0,0 +1,5 @@
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_pid" integer;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_started_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN "retry_of_run_id" uuid;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_loss_retry_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("retry_of_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -5341,6 +5341,31 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"process_pid": {
"name": "process_pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"process_started_at": {
"name": "process_started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"retry_of_run_id": {
"name": "retry_of_run_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"process_loss_retry_count": {
"name": "process_loss_retry_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"context_snapshot": { "context_snapshot": {
"name": "context_snapshot", "name": "context_snapshot",
"type": "jsonb", "type": "jsonb",
@@ -5430,6 +5455,19 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": {
"name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk",
"tableFrom": "heartbeat_runs",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"retry_of_run_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -11309,4 +11347,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View File

@@ -5341,6 +5341,31 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"process_pid": {
"name": "process_pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"process_started_at": {
"name": "process_started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"retry_of_run_id": {
"name": "retry_of_run_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"process_loss_retry_count": {
"name": "process_loss_retry_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"context_snapshot": { "context_snapshot": {
"name": "context_snapshot", "name": "context_snapshot",
"type": "jsonb", "type": "jsonb",
@@ -5430,6 +5455,19 @@
], ],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
},
"heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": {
"name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk",
"tableFrom": "heartbeat_runs",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"retry_of_run_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
@@ -11352,4 +11390,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View File

@@ -271,8 +271,8 @@
{ {
"idx": 38, "idx": 38,
"version": "7", "version": "7",
"when": 1773926116580, "when": 1773931592563,
"tag": "0038_fat_magneto", "tag": "0038_careless_iron_monger",
"breakpoints": true "breakpoints": true
}, },
{ {
@@ -281,6 +281,13 @@
"when": 1773927102783, "when": 1773927102783,
"tag": "0039_eager_shotgun", "tag": "0039_eager_shotgun",
"breakpoints": true "breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1773926116580,
"tag": "0038_fat_magneto",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core"; import { type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core";
import { companies } from "./companies.js"; import { companies } from "./companies.js";
import { agents } from "./agents.js"; import { agents } from "./agents.js";
import { agentWakeupRequests } from "./agent_wakeup_requests.js"; import { agentWakeupRequests } from "./agent_wakeup_requests.js";
@@ -31,6 +31,12 @@ export const heartbeatRuns = pgTable(
stderrExcerpt: text("stderr_excerpt"), stderrExcerpt: text("stderr_excerpt"),
errorCode: text("error_code"), errorCode: text("error_code"),
externalRunId: text("external_run_id"), externalRunId: text("external_run_id"),
processPid: integer("process_pid"),
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, {
onDelete: "set null",
}),
processLossRetryCount: integer("process_loss_retry_count").notNull().default(0),
contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(), contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -164,6 +164,9 @@ export type {
InstanceExperimentalSettings, InstanceExperimentalSettings,
InstanceSettings, InstanceSettings,
Agent, Agent,
AgentAccessState,
AgentChainOfCommandEntry,
AgentDetail,
AgentPermissions, AgentPermissions,
AgentInstructionsBundleMode, AgentInstructionsBundleMode,
AgentInstructionsFileSummary, AgentInstructionsFileSummary,

View File

@@ -4,6 +4,10 @@ import type {
AgentRole, AgentRole,
AgentStatus, AgentStatus,
} from "../constants.js"; } from "../constants.js";
import type {
CompanyMembership,
PrincipalPermissionGrant,
} from "./access.js";
export interface AgentPermissions { export interface AgentPermissions {
canCreateAgents: boolean; canCreateAgents: boolean;
@@ -41,6 +45,20 @@ export interface AgentInstructionsBundle {
files: AgentInstructionsFileSummary[]; files: AgentInstructionsFileSummary[];
} }
export interface AgentAccessState {
canAssignTasks: boolean;
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[];
}
export interface AgentChainOfCommandEntry {
id: string;
name: string;
role: AgentRole;
title: string | null;
}
export interface Agent { export interface Agent {
id: string; id: string;
companyId: string; companyId: string;
@@ -66,6 +84,11 @@ export interface Agent {
updatedAt: Date; updatedAt: Date;
} }
export interface AgentDetail extends Agent {
chainOfCommand: AgentChainOfCommandEntry[];
access: AgentAccessState;
}
export interface AgentKeyCreated { export interface AgentKeyCreated {
id: string; id: string;
name: string; name: string;

View File

@@ -33,6 +33,10 @@ export interface HeartbeatRun {
stderrExcerpt: string | null; stderrExcerpt: string | null;
errorCode: string | null; errorCode: string | null;
externalRunId: string | null; externalRunId: string | null;
processPid: number | null;
processStartedAt: Date | null;
retryOfRunId: string | null;
processLossRetryCount: number;
contextSnapshot: Record<string, unknown> | null; contextSnapshot: Record<string, unknown> | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -31,6 +31,9 @@ export type {
} from "./adapter-skills.js"; } from "./adapter-skills.js";
export type { export type {
Agent, Agent,
AgentAccessState,
AgentChainOfCommandEntry,
AgentDetail,
AgentPermissions, AgentPermissions,
AgentInstructionsBundleMode, AgentInstructionsBundleMode,
AgentInstructionsFileSummary, AgentInstructionsFileSummary,

View File

@@ -120,6 +120,7 @@ export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema
export const updateAgentPermissionsSchema = z.object({ export const updateAgentPermissionsSchema = z.object({
canCreateAgents: z.boolean(), canCreateAgents: z.boolean(),
canAssignTasks: z.boolean(),
}); });
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>; export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;

View File

@@ -26,6 +26,8 @@ export type UpdateCompany = z.infer<typeof updateCompanySchema>;
export const updateCompanyBrandingSchema = z export const updateCompanyBrandingSchema = z
.object({ .object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
brandColor: brandColorSchema, brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema, logoAssetId: logoAssetIdSchema,
}) })

View File

@@ -0,0 +1,246 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const baseAgent = {
id: agentId,
companyId,
name: "Builder",
urlKey: "builder",
role: "engineer",
title: "Builder",
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-03-19T00:00:00.000Z"),
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
};
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
create: vi.fn(),
updatePermissions: vi.fn(),
getChainOfCommand: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
listTaskSessions: vi.fn(),
resetRuntimeSession: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(),
resolveAdapterConfigForRuntime: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
function createDbStub() {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
then: vi.fn().mockResolvedValue([{
id: companyId,
name: "Paperclip",
requireBoardApprovalForNewAgents: false,
}]),
}),
}),
}),
};
}
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", agentRoutes(createDbStub() as any));
app.use(errorHandler);
return app;
}
describe("agent permission routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.getById.mockResolvedValue(baseAgent);
mockAgentService.getChainOfCommand.mockResolvedValue([]);
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
mockAgentService.create.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.getMembership.mockResolvedValue({
id: "membership-1",
companyId,
principalType: "agent",
principalId: agentId,
status: "active",
membershipRole: "member",
createdAt: new Date("2026-03-19T00:00:00.000Z"),
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
});
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
mockLogActivity.mockResolvedValue(undefined);
});
it("grants tasks:assign by default when board creates a new agent", 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: {},
});
expect(res.status).toBe(201);
expect(mockAccessService.ensureMembership).toHaveBeenCalledWith(
companyId,
"agent",
agentId,
"member",
"active",
);
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
companyId,
"agent",
agentId,
"tasks:assign",
true,
"board-user",
);
});
it("exposes explicit task assignment access on agent detail", async () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([
{
id: "grant-1",
companyId,
principalType: "agent",
principalId: agentId,
permissionKey: "tasks:assign",
scope: null,
grantedByUserId: "board-user",
createdAt: new Date("2026-03-19T00:00:00.000Z"),
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
},
]);
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app).get(`/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
});
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({
...baseAgent,
permissions: { canCreateAgents: true },
});
const app = createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/agents/${agentId}/permissions`)
.send({ canCreateAgents: true, canAssignTasks: false });
expect(res.status).toBe(200);
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
companyId,
"agent",
agentId,
"tasks:assign",
true,
"board-user",
);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("agent_creator");
});
});

View File

@@ -0,0 +1,321 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
applyPendingMigrations,
createDb,
ensurePostgresDatabase,
agents,
agentWakeupRequests,
companies,
heartbeatRunEvents,
heartbeatRuns,
issues,
} from "@paperclipai/db";
import { runningProcesses } from "../adapters/index.ts";
import { heartbeatService } from "../services/heartbeat.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, instance, dataDir };
}
function spawnAliveProcess() {
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
}
describe("heartbeat orphaned process recovery", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
const childProcesses = new Set<ChildProcess>();
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
runningProcesses.clear();
for (const child of childProcesses) {
child.kill("SIGKILL");
}
childProcesses.clear();
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
for (const child of childProcesses) {
child.kill("SIGKILL");
}
childProcesses.clear();
runningProcesses.clear();
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
async function seedRunFixture(input?: {
adapterType?: string;
runStatus?: "running" | "queued" | "failed";
processPid?: number | null;
processLossRetryCount?: number;
includeIssue?: boolean;
runErrorCode?: string | null;
runError?: string | null;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const wakeupRequestId = randomUUID();
const issueId = randomUUID();
const now = new Date("2026-03-19T00:00:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "paused",
adapterType: input?.adapterType ?? "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId,
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: input?.includeIssue === false ? {} : { issueId },
status: "claimed",
runId,
claimedAt: now,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: input?.runStatus ?? "running",
wakeupRequestId,
contextSnapshot: input?.includeIssue === false ? {} : { issueId },
processPid: input?.processPid ?? null,
processLossRetryCount: input?.processLossRetryCount ?? 0,
errorCode: input?.runErrorCode ?? null,
error: input?.runError ?? null,
startedAt: now,
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
});
if (input?.includeIssue !== false) {
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover local adapter after lost process",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
}
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
it("keeps a local run active when the recorded pid is still alive", async () => {
const child = spawnAliveProcess();
childProcesses.add(child);
expect(child.pid).toBeTypeOf("number");
const { runId, wakeupRequestId } = await seedRunFixture({
processPid: child.pid ?? null,
includeIssue: false,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(0);
const run = await heartbeat.getRun(runId);
expect(run?.status).toBe("running");
expect(run?.errorCode).toBe("process_detached");
expect(run?.error).toContain(String(child.pid));
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.status).toBe("claimed");
});
it("queues exactly one retry when the recorded local pid is dead", async () => {
const { agentId, runId, issueId } = await seedRunFixture({
processPid: 999_999_999,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(1);
expect(result.runIds).toEqual([runId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
const failedRun = runs.find((row) => row.id === runId);
const retryRun = runs.find((row) => row.id !== runId);
expect(failedRun?.status).toBe("failed");
expect(failedRun?.errorCode).toBe("process_lost");
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
const issue = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
expect(issue?.checkoutRunId).toBe(runId);
});
it("does not queue a second retry after the first process-loss retry was already used", async () => {
const { agentId, runId, issueId } = await seedRunFixture({
processPid: 999_999_999,
processLossRetryCount: 1,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(1);
expect(result.runIds).toEqual([runId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("failed");
const issue = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.executionRunId).toBeNull();
expect(issue?.checkoutRunId).toBe(runId);
});
it("clears the detached warning when the run reports activity again", async () => {
const { runId } = await seedRunFixture({
includeIssue: false,
runErrorCode: "process_detached",
runError: "Lost in-memory process handle, but child pid 123 is still alive",
});
const heartbeat = heartbeatService(db);
const updated = await heartbeat.reportRunActivity(runId);
expect(updated?.errorCode).toBeNull();
expect(updated?.error).toBeNull();
const run = await heartbeat.getRun(runId);
expect(run?.errorCode).toBeNull();
expect(run?.error).toBeNull();
});
});

View File

@@ -2450,6 +2450,14 @@ export function accessRoutes(
"member", "member",
"active" "active"
); );
await access.setPrincipalPermission(
companyId,
"agent",
created.id,
"tasks:assign",
true,
req.actor.userId ?? null
);
const grants = grantsFromDefaults( const grants = grantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null, invite.defaultsPayload as Record<string, unknown> | null,
"agent" "agent"

View File

@@ -84,6 +84,80 @@ export function agentRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents); return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
} }
async function buildAgentAccessState(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
const membership = await access.getMembership(agent.companyId, "agent", agent.id);
const grants = membership
? await access.listPrincipalGrants(agent.companyId, "agent", agent.id)
: [];
const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign");
if (agent.role === "ceo") {
return {
canAssignTasks: true,
taskAssignSource: "ceo_role" as const,
membership,
grants,
};
}
if (canCreateAgents(agent)) {
return {
canAssignTasks: true,
taskAssignSource: "agent_creator" as const,
membership,
grants,
};
}
if (hasExplicitTaskAssignGrant) {
return {
canAssignTasks: true,
taskAssignSource: "explicit_grant" as const,
membership,
grants,
};
}
return {
canAssignTasks: false,
taskAssignSource: "none" as const,
membership,
grants,
};
}
async function buildAgentDetail(
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
options?: { restricted?: boolean },
) {
const [chainOfCommand, accessState] = await Promise.all([
svc.getChainOfCommand(agent.id),
buildAgentAccessState(agent),
]);
return {
...(options?.restricted ? redactForRestrictedAgentView(agent) : agent),
chainOfCommand,
access: accessState,
};
}
async function applyDefaultAgentTaskAssignGrant(
companyId: string,
agentId: string,
grantedByUserId: string | null,
) {
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
await access.setPrincipalPermission(
companyId,
"agent",
agentId,
"tasks:assign",
true,
grantedByUserId,
);
}
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (req.actor.type === "board") { if (req.actor.type === "board") {
@@ -799,8 +873,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" }); res.status(404).json({ error: "Agent not found" });
return; return;
} }
const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json(await buildAgentDetail(agent));
res.json({ ...agent, chainOfCommand });
}); });
router.get("/agents/me/inbox-lite", async (req, res) => { router.get("/agents/me/inbox-lite", async (req, res) => {
@@ -842,13 +915,11 @@ export function agentRoutes(db: Db) {
if (req.actor.type === "agent" && req.actor.agentId !== id) { if (req.actor.type === "agent" && req.actor.agentId !== id) {
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canRead) { if (!canRead) {
const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json(await buildAgentDetail(agent, { restricted: true }));
res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand });
return; return;
} }
} }
const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json(await buildAgentDetail(agent));
res.json({ ...agent, chainOfCommand });
}); });
router.get("/agents/:id/configuration", async (req, res) => { router.get("/agents/:id/configuration", async (req, res) => {
@@ -1122,6 +1193,12 @@ export function agentRoutes(db: Db) {
}, },
}); });
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
actor.actorType === "user" ? actor.actorId : null,
);
if (approval) { if (approval) {
await logActivity(db, { await logActivity(db, {
companyId, companyId,
@@ -1197,6 +1274,12 @@ export function agentRoutes(db: Db) {
}, },
}); });
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
if (agent.budgetMonthlyCents > 0) { if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy( await budgets.upsertPolicy(
companyId, companyId,
@@ -1240,6 +1323,18 @@ export function agentRoutes(db: Db) {
return; return;
} }
const effectiveCanAssignTasks =
agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks;
await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active");
await access.setPrincipalPermission(
agent.companyId,
"agent",
agent.id,
"tasks:assign",
effectiveCanAssignTasks,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
const actor = getActorInfo(req); const actor = getActorInfo(req);
await logActivity(db, { await logActivity(db, {
companyId: agent.companyId, companyId: agent.companyId,
@@ -1250,10 +1345,13 @@ export function agentRoutes(db: Db) {
action: "agent.permissions_updated", action: "agent.permissions_updated",
entityType: "agent", entityType: "agent",
entityId: agent.id, entityId: agent.id,
details: req.body, details: {
canCreateAgents: agent.permissions?.canCreateAgents ?? false,
canAssignTasks: effectiveCanAssignTasks,
},
}); });
res.json(agent); res.json(await buildAgentDetail(agent));
}); });
router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => {

View File

@@ -7,6 +7,7 @@ import {
createCompanySchema, createCompanySchema,
updateCompanyBrandingSchema, updateCompanyBrandingSchema,
updateCompanySchema, updateCompanySchema,
updateCompanyBrandingSchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { forbidden } from "../errors.js"; import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
@@ -90,9 +91,12 @@ export function companyRoutes(db: Db, storage?: StorageService) {
}); });
router.get("/:companyId", async (req, res) => { router.get("/:companyId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
// Allow agents (CEO) to read their own company; board always allowed
if (req.actor.type !== "agent") {
assertBoard(req);
}
const company = await svc.getById(companyId); const company = await svc.getById(companyId);
if (!company) { if (!company) {
res.status(404).json({ error: "Company not found" }); res.status(404).json({ error: "Company not found" });
@@ -238,23 +242,44 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.status(201).json(company); res.status(201).json(company);
}); });
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => { router.patch("/:companyId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
const company = await svc.update(companyId, req.body);
const actor = getActorInfo(req);
let body: Record<string, unknown>;
if (req.actor.type === "agent") {
// Only CEO agents may update company branding fields
const agentSvc = agentService(db);
const actorAgent = req.actor.agentId ? await agentSvc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents or board users may update company settings");
}
if (actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
body = updateCompanyBrandingSchema.parse(req.body);
} else {
assertBoard(req);
body = updateCompanySchema.parse(req.body);
}
const company = await svc.update(companyId, body);
if (!company) { if (!company) {
res.status(404).json({ error: "Company not found" }); res.status(404).json({ error: "Company not found" });
return; return;
} }
await logActivity(db, { await logActivity(db, {
companyId, companyId,
actorType: "user", actorType: actor.actorType,
actorId: req.actor.userId ?? "board", actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.updated", action: "company.updated",
entityType: "company", entityType: "company",
entityId: companyId, entityId: companyId,
details: req.body, details: body,
}); });
res.json(company); res.json(company);
}); });

View File

@@ -826,6 +826,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
} }
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
if (hiddenAtRaw !== undefined) { if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
@@ -863,6 +864,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
} }
await routinesSvc.syncRunStatusForIssue(issue.id); await routinesSvc.syncRunStatusForIssue(issue.id);
if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity"));
}
// Build activity details with previous values for changed fields // Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {}; const previous: Record<string, unknown> = {};
for (const key of Object.keys(updateFields)) { for (const key of Object.keys(updateFields)) {
@@ -871,7 +877,6 @@ export function issueRoutes(db: Db, storage: StorageService) {
} }
} }
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0; const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, { await logActivity(db, {
companyId: issue.companyId, companyId: issue.companyId,
@@ -1285,6 +1290,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
userId: actor.actorType === "user" ? actor.actorId : undefined, userId: actor.actorType === "user" ? actor.actorId : undefined,
}); });
if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment"));
}
await logActivity(db, { await logActivity(db, {
companyId: currentIssue.companyId, companyId: currentIssue.companyId,
actorType: actor.actorType, actorType: actor.actorType,

View File

@@ -279,6 +279,86 @@ export function accessService(db: Db) {
return sourceMemberships; return sourceMemberships;
} }
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
principalId: string,
) {
return db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
),
)
.orderBy(principalPermissionGrants.permissionKey);
}
async function setPrincipalPermission(
companyId: string,
principalType: PrincipalType,
principalId: string,
permissionKey: PermissionKey,
enabled: boolean,
grantedByUserId: string | null,
scope: Record<string, unknown> | null = null,
) {
if (!enabled) {
await db
.delete(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
);
return;
}
await ensureMembership(companyId, principalType, principalId, "member", "active");
const existing = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
)
.then((rows) => rows[0] ?? null);
if (existing) {
await db
.update(principalPermissionGrants)
.set({
scope,
grantedByUserId,
updatedAt: new Date(),
})
.where(eq(principalPermissionGrants.id, existing.id));
return;
}
await db.insert(principalPermissionGrants).values({
companyId,
principalType,
principalId,
permissionKey,
scope,
grantedByUserId,
createdAt: new Date(),
updatedAt: new Date(),
});
}
return { return {
isInstanceAdmin, isInstanceAdmin,
canUser, canUser,
@@ -294,5 +374,7 @@ export function accessService(db: Db) {
listUserCompanyAccess, listUserCompanyAccess,
setUserCompanyAccess, setUserCompanyAccess,
setPrincipalGrants, setPrincipalGrants,
listPrincipalGrants,
setPrincipalPermission,
}; };
} }

View File

@@ -3076,6 +3076,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} }
let created = await agents.create(targetCompany.id, patch); let created = await agents.create(targetCompany.id, patch);
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
targetCompany.id,
"agent",
created.id,
"tasks:assign",
true,
actorUserId ?? null,
);
try { try {
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, { const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
clearLegacyPromptTemplate: true, clearLegacyPromptTemplate: true,

View File

@@ -61,6 +61,7 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
const startLocksByAgent = new Map<string, Promise<void>>(); const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
@@ -164,6 +165,10 @@ const heartbeatRunListColumns = {
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"), stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
errorCode: heartbeatRuns.errorCode, errorCode: heartbeatRuns.errorCode,
externalRunId: heartbeatRuns.externalRunId, externalRunId: heartbeatRuns.externalRunId,
processPid: heartbeatRuns.processPid,
processStartedAt: heartbeatRuns.processStartedAt,
retryOfRunId: heartbeatRuns.retryOfRunId,
processLossRetryCount: heartbeatRuns.processLossRetryCount,
contextSnapshot: heartbeatRuns.contextSnapshot, contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt, createdAt: heartbeatRuns.createdAt,
updatedAt: heartbeatRuns.updatedAt, updatedAt: heartbeatRuns.updatedAt,
@@ -599,6 +604,26 @@ function isSameTaskScope(left: string | null, right: string | null) {
return (left ?? null) === (right ?? null); return (left ?? null) === (right ?? null);
} }
function isTrackedLocalChildProcessAdapter(adapterType: string) {
return SESSIONED_LOCAL_ADAPTERS.has(adapterType);
}
// A positive liveness check means some process currently owns the PID.
// On Linux, PIDs can be recycled, so this is a best-effort signal rather
// than proof that the original child is still alive.
function isProcessAlive(pid: number | null | undefined) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === "EPERM") return true;
if (code === "ESRCH") return false;
return false;
}
}
function truncateDisplayId(value: string | null | undefined, max = 128) { function truncateDisplayId(value: string | null | undefined, max = 128) {
if (!value) return null; if (!value) return null;
return value.length > max ? value.slice(0, max) : value; return value.length > max ? value.slice(0, max) : value;
@@ -1328,6 +1353,156 @@ export function heartbeatService(db: Db) {
}); });
} }
async function nextRunEventSeq(runId: string) {
const [row] = await db
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
return Number(row?.maxSeq ?? 0) + 1;
}
async function persistRunProcessMetadata(
runId: string,
meta: { pid: number; startedAt: string },
) {
const startedAt = new Date(meta.startedAt);
return db
.update(heartbeatRuns)
.set({
processPid: meta.pid,
processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, runId))
.returning()
.then((rows) => rows[0] ?? null);
}
async function clearDetachedRunWarning(runId: string) {
const updated = await db
.update(heartbeatRuns)
.set({
error: null,
errorCode: null,
updatedAt: new Date(),
})
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running"), eq(heartbeatRuns.errorCode, DETACHED_PROCESS_ERROR_CODE)))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
await appendRunEvent(updated, await nextRunEventSeq(updated.id), {
eventType: "lifecycle",
stream: "system",
level: "info",
message: "Detached child process reported activity; cleared detached warning",
});
return updated;
}
async function enqueueProcessLossRetry(
run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect,
now: Date,
) {
const contextSnapshot = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(contextSnapshot.issueId);
const taskKey = deriveTaskKey(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "process_lost_retry",
retryReason: "process_lost",
};
const queued = await db.transaction(async (tx) => {
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
companyId: run.companyId,
agentId: run.agentId,
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: {
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
},
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
const retryRun = await tx
.insert(heartbeatRuns)
.values({
companyId: run.companyId,
agentId: run.agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: retryContextSnapshot,
sessionIdBefore: sessionBefore,
retryOfRunId: run.id,
processLossRetryCount: (run.processLossRetryCount ?? 0) + 1,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
await tx
.update(agentWakeupRequests)
.set({
runId: retryRun.id,
updatedAt: now,
})
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
if (issueId) {
await tx
.update(issues)
.set({
executionRunId: retryRun.id,
executionAgentNameKey: normalizeAgentNameKey(agent.name),
executionLockedAt: now,
updatedAt: now,
})
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)));
}
return retryRun;
});
publishLiveEvent({
companyId: queued.companyId,
type: "heartbeat.run.queued",
payload: {
runId: queued.id,
agentId: queued.agentId,
invocationSource: queued.invocationSource,
triggerDetail: queued.triggerDetail,
wakeupRequestId: queued.wakeupRequestId,
},
});
await appendRunEvent(queued, 1, {
eventType: "lifecycle",
stream: "system",
level: "warn",
message: "Queued automatic retry after orphaned child process was confirmed dead",
payload: {
retryOfRunId: run.id,
},
});
return queued;
}
function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) {
const runtimeConfig = parseObject(agent.runtimeConfig); const runtimeConfig = parseObject(agent.runtimeConfig);
const heartbeat = parseObject(runtimeConfig.heartbeat); const heartbeat = parseObject(runtimeConfig.heartbeat);
@@ -1455,13 +1630,17 @@ export function heartbeatService(db: Db) {
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them) // Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
const activeRuns = await db const activeRuns = await db
.select() .select({
run: heartbeatRuns,
adapterType: agents.adapterType,
})
.from(heartbeatRuns) .from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
.where(eq(heartbeatRuns.status, "running")); .where(eq(heartbeatRuns.status, "running"));
const reaped: string[] = []; const reaped: string[] = [];
for (const run of activeRuns) { for (const { run, adapterType } of activeRuns) {
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue; if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
// Apply staleness threshold to avoid false positives // Apply staleness threshold to avoid false positives
@@ -1470,25 +1649,69 @@ export function heartbeatService(db: Db) {
if (now.getTime() - refTime < staleThresholdMs) continue; if (now.getTime() - refTime < staleThresholdMs) continue;
} }
await setRunStatus(run.id, "failed", { const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
error: "Process lost -- server may have restarted", if (tracksLocalChild && run.processPid && isProcessAlive(run.processPid)) {
if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) {
const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`;
const detachedRun = await setRunStatus(run.id, "running", {
error: detachedMessage,
errorCode: DETACHED_PROCESS_ERROR_CODE,
});
if (detachedRun) {
await appendRunEvent(detachedRun, await nextRunEventSeq(detachedRun.id), {
eventType: "lifecycle",
stream: "system",
level: "warn",
message: detachedMessage,
payload: {
processPid: run.processPid,
},
});
}
}
continue;
}
const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1;
const baseMessage = run.processPid
? `Process lost -- child pid ${run.processPid} is no longer running`
: "Process lost -- server may have restarted";
let finalizedRun = await setRunStatus(run.id, "failed", {
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
errorCode: "process_lost", errorCode: "process_lost",
finishedAt: now, finishedAt: now,
}); });
await setWakeupStatus(run.wakeupRequestId, "failed", { await setWakeupStatus(run.wakeupRequestId, "failed", {
finishedAt: now, finishedAt: now,
error: "Process lost -- server may have restarted", error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
}); });
const updatedRun = await getRun(run.id); if (!finalizedRun) finalizedRun = await getRun(run.id);
if (updatedRun) { if (!finalizedRun) continue;
await appendRunEvent(updatedRun, 1, {
eventType: "lifecycle", let retriedRun: typeof heartbeatRuns.$inferSelect | null = null;
stream: "system", if (shouldRetry) {
level: "error", const agent = await getAgent(run.agentId);
message: "Process lost -- server may have restarted", if (agent) {
}); retriedRun = await enqueueProcessLossRetry(finalizedRun, agent, now);
await releaseIssueExecutionAndPromote(updatedRun); }
} else {
await releaseIssueExecutionAndPromote(finalizedRun);
} }
await appendRunEvent(finalizedRun, await nextRunEventSeq(finalizedRun.id), {
eventType: "lifecycle",
stream: "system",
level: "error",
message: shouldRetry
? `${baseMessage}; queued retry ${retriedRun?.id ?? ""}`.trim()
: baseMessage,
payload: {
...(run.processPid ? { processPid: run.processPid } : {}),
...(retriedRun ? { retryRunId: retriedRun.id } : {}),
},
});
await finalizeAgentStatus(run.agentId, "failed"); await finalizeAgentStatus(run.agentId, "failed");
await startNextQueuedRunForAgent(run.agentId); await startNextQueuedRunForAgent(run.agentId);
runningProcesses.delete(run.id); runningProcesses.delete(run.id);
@@ -2159,6 +2382,9 @@ export function heartbeatService(db: Db) {
context, context,
onLog, onLog,
onMeta: onAdapterMeta, onMeta: onAdapterMeta,
onSpawn: async (meta) => {
await persistRunProcessMetadata(run.id, meta);
},
authToken: authToken ?? undefined, authToken: authToken ?? undefined,
}); });
const adapterManagedRuntimeServices = adapterResult.runtimeServices const adapterManagedRuntimeServices = adapterResult.runtimeServices
@@ -3410,6 +3636,8 @@ export function heartbeatService(db: Db) {
wakeup: enqueueWakeup, wakeup: enqueueWakeup,
reportRunActivity: clearDetachedRunWarning,
reapOrphanedRuns, reapOrphanedRuns,
resumeQueuedRuns, resumeQueuedRuns,

View File

@@ -71,6 +71,8 @@ Read enough ancestor/comment context to understand _why_ the task exists and wha
**Step 8 — Update status and communicate.** Always include the run ID header. **Step 8 — Update status and communicate.** Always include the run ID header.
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act. If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below.
```json ```json
PATCH /api/issues/{issueId} PATCH /api/issues/{issueId}
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
@@ -155,12 +157,19 @@ If you are asked to install a skill for the company or an agent you MUST read:
## Comment Style (Required) ## Comment Style (Required)
When posting issue comments, use concise markdown with: When posting issue comments or writing issue descriptions, use concise markdown with:
- a short status line - a short status line
- bullets for what changed / what is blocked - bullets for what changed / what is blocked
- links to related entities when available - links to related entities when available
**Ticket references are links (required):** If you mention another issue identifier such as `PAP-224`, `ZED-24`, or any `{PREFIX}-{NUMBER}` ticket id inside a comment body or issue description, wrap it in a Markdown link:
- `[PAP-224](/PAP/issues/PAP-224)`
- `[ZED-24](/ZED/issues/ZED-24)`
Never leave bare ticket ids in issue descriptions or comments when a clickable internal link can be provided.
**Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links: **Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links:
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`) - Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
@@ -182,7 +191,8 @@ Submitted CTO hire request and linked it for board review.
- Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b) - Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b)
- Pending agent: [CTO draft](/PAP/agents/cto) - Pending agent: [CTO draft](/PAP/agents/cto)
- Source issue: [PC-142](/PAP/issues/PC-142) - Source issue: [PAP-142](/PAP/issues/PAP-142)
- Depends on: [PAP-224](/PAP/issues/PAP-224)
``` ```
## Planning (Required when planning requested) ## Planning (Required when planning requested)

View File

@@ -346,6 +346,26 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts,
Use the dashboard for situational awareness, especially if you're a manager or CEO. Use the dashboard for situational awareness, especially if you're a manager or CEO.
## Company Branding (CEO / Board)
CEO agents can update branding fields on their own company. Board users can update all fields.
```
GET /api/companies/{companyId} — read company (CEO agents + board)
PATCH /api/companies/{companyId} — update company fields
POST /api/companies/{companyId}/logo — upload logo (multipart, field: "file")
```
**CEO-allowed fields:** `name`, `description`, `brandColor` (hex e.g. `#FF5733` or null), `logoAssetId` (UUID or null).
**Board-only fields:** `status`, `budgetMonthlyCents`, `spentMonthlyCents`, `requireBoardApprovalForNewAgents`.
**Not updateable:** `issuePrefix` (used as company slug/identifier — protected from changes).
**Logo workflow:**
1. `POST /api/companies/{companyId}/logo` with file upload → returns `{ assetId }`.
2. `PATCH /api/companies/{companyId}` with `{ "logoAssetId": "<assetId>" }`.
## OpenClaw Invite Prompt (CEO) ## OpenClaw Invite Prompt (CEO)
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:

View File

@@ -34,7 +34,42 @@ The canonical model is:
## Install A Skill Into The Company ## Install A Skill Into The Company
Import from GitHub, a local path, or a `skills.sh`-style source string: Import using a **skills.sh URL**, a key-style source string, a GitHub URL, or a local path.
### Source types (in order of preference)
| Source format | Example | When to use |
|---|---|---|
| **skills.sh URL** | `https://skills.sh/google-labs-code/stitch-skills/design-md` | When a user gives you a `skills.sh` link. This is the managed skill registry — **always prefer it when available**. |
| **Key-style string** | `google-labs-code/stitch-skills/design-md` | Shorthand for the same skill — `org/repo/skill-name` format. Equivalent to the skills.sh URL. |
| **GitHub URL** | `https://github.com/vercel-labs/agent-browser` | When the skill is in a GitHub repo but not on skills.sh. |
| **Local path** | `/abs/path/to/skill-dir` | When the skill is on disk (dev/testing only). |
**Critical:** If a user gives you a `https://skills.sh/...` URL, use that URL or its key-style equivalent (`org/repo/skill-name`) as the `source`. Do **not** convert it to a GitHub URL — skills.sh is the managed registry and the source of truth for versioning, discovery, and updates.
### Example: skills.sh import (preferred)
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "https://skills.sh/google-labs-code/stitch-skills/design-md"
}'
```
Or equivalently using the key-style string:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "google-labs-code/stitch-skills/design-md"
}'
```
### Example: GitHub import
```sh ```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
@@ -45,10 +80,11 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/
}' }'
``` ```
You can also use a source string such as: You can also use source strings such as:
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser` - `google-labs-code/stitch-skills/design-md`
- `vercel-labs/agent-browser/agent-browser` - `vercel-labs/agent-browser/agent-browser`
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser`
If the task is to discover skills from the company project workspaces first: If the task is to discover skills from the company project workspaces first:

View File

@@ -1,5 +1,6 @@
import type { import type {
Agent, Agent,
AgentDetail,
AgentInstructionsBundle, AgentInstructionsBundle,
AgentInstructionsFileDetail, AgentInstructionsFileDetail,
AgentSkillSnapshot, AgentSkillSnapshot,
@@ -48,6 +49,11 @@ export interface AgentHireResponse {
approval: Approval | null; approval: Approval | null;
} }
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canAssignTasks: boolean;
}
function withCompanyScope(path: string, companyId?: string) { function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path; if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?"; const separator = path.includes("?") ? "&" : "?";
@@ -65,7 +71,7 @@ export const agentsApi = {
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`), api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => { get: async (id: string, companyId?: string) => {
try { try {
return await api.get<Agent>(agentPath(id, companyId)); return await api.get<AgentDetail>(agentPath(id, companyId));
} catch (error) { } catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity, // Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents. // resolve using company agent list while ignoring terminated agents.
@@ -86,7 +92,7 @@ export const agentsApi = {
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey, (agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
); );
if (matches.length !== 1) throw error; if (matches.length !== 1) throw error;
return api.get<Agent>(agentPath(matches[0]!.id, companyId)); return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
} }
}, },
getConfiguration: (id: string, companyId?: string) => getConfiguration: (id: string, companyId?: string) =>
@@ -103,8 +109,8 @@ export const agentsApi = {
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data), api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) => update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId), data), api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) => updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data), api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) => instructionsBundle: (id: string, companyId?: string) =>
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")), api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
updateInstructionsBundle: ( updateInstructionsBundle: (

View File

@@ -957,7 +957,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */ /* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */ /** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [

View File

@@ -2,7 +2,7 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import type { Approval, Agent } from "@paperclipai/shared"; import type { Approval, Agent } from "@paperclipai/shared";
@@ -32,7 +32,7 @@ export function ApprovalCard({
isPending: boolean; isPending: boolean;
}) { }) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type; const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons = const showResolutionButtons =
approval.type !== "budget_override_required" && approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested"); (approval.status === "pending" || approval.status === "revision_requested");

View File

@@ -7,6 +7,15 @@ export const typeLabel: Record<string, string> = {
budget_override_required: "Budget Override", budget_override_required: "Budget Override",
}; };
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
const base = typeLabel[type] ?? type;
if (type === "hire_agent" && payload?.name) {
return `${base}: ${String(payload.name)}`;
}
return base;
}
export const typeIcon: Record<string, typeof UserPlus> = { export const typeIcon: Record<string, typeof UserPlus> = {
hire_agent: UserPlus, hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb, approve_ceo_strategy: Lightbulb,

View File

@@ -46,6 +46,7 @@ interface CommentThreadProps {
enableReassign?: boolean; enableReassign?: boolean;
reassignOptions?: InlineEntityOption[]; reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string; currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[]; mentions?: MentionOption[];
} }
@@ -269,13 +270,15 @@ export function CommentThread({
enableReassign = false, enableReassign = false,
reassignOptions = [], reassignOptions = [],
currentAssigneeValue = "", currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions, mentions: providedMentions,
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true); const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false); const [attaching, setAttaching] = useState(false);
const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null); const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null); const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null); const attachInputRef = useRef<HTMLInputElement | null>(null);
@@ -337,8 +340,8 @@ export function CommentThread({
}, []); }, []);
useEffect(() => { useEffect(() => {
setReassignTarget(currentAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
}, [currentAssigneeValue]); }, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id} // Scroll to comment when URL hash matches #comment-{id}
useEffect(() => { useEffect(() => {
@@ -370,7 +373,7 @@ export function CommentThread({
setBody(""); setBody("");
if (draftKey) clearDraft(draftKey); if (draftKey) clearDraft(draftKey);
setReopen(false); setReopen(false);
setReassignTarget(currentAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@@ -23,10 +23,13 @@ import {
Calendar, Calendar,
Plus, Plus,
X, X,
FolderOpen, HelpCircle,
Github,
GitBranch,
} from "lucide-react"; } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PROJECT_COLORS } from "@paperclipai/shared"; import { PROJECT_COLORS } from "@paperclipai/shared";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
@@ -41,8 +44,6 @@ const projectStatuses = [
{ value: "cancelled", label: "Cancelled" }, { value: "cancelled", label: "Cancelled" },
]; ];
type WorkspaceSetup = "none" | "local" | "repo" | "both";
export function NewProjectDialog() { export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog(); const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
@@ -53,7 +54,6 @@ export function NewProjectDialog() {
const [goalIds, setGoalIds] = useState<string[]>([]); const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState(""); const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [workspaceSetup, setWorkspaceSetup] = useState<WorkspaceSetup>("none");
const [workspaceLocalPath, setWorkspaceLocalPath] = useState(""); const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null); const [workspaceError, setWorkspaceError] = useState<string | null>(null);
@@ -87,7 +87,6 @@ export function NewProjectDialog() {
setGoalIds([]); setGoalIds([]);
setTargetDate(""); setTargetDate("");
setExpanded(false); setExpanded(false);
setWorkspaceSetup("none");
setWorkspaceLocalPath(""); setWorkspaceLocalPath("");
setWorkspaceRepoUrl(""); setWorkspaceRepoUrl("");
setWorkspaceError(null); setWorkspaceError(null);
@@ -124,23 +123,16 @@ export function NewProjectDialog() {
} }
}; };
const toggleWorkspaceSetup = (next: WorkspaceSetup) => {
setWorkspaceSetup((prev) => (prev === next ? "none" : next));
setWorkspaceError(null);
};
async function handleSubmit() { async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return; if (!selectedCompanyId || !name.trim()) return;
const localRequired = workspaceSetup === "local" || workspaceSetup === "both";
const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both";
const localPath = workspaceLocalPath.trim(); const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim(); const repoUrl = workspaceRepoUrl.trim();
if (localRequired && !isAbsolutePath(localPath)) { if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path."); setWorkspaceError("Local folder must be a full absolute path.");
return; return;
} }
if (repoRequired && !isGitHubRepoUrl(repoUrl)) { if (repoUrl && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub repo URL."); setWorkspaceError("Repo must use a valid GitHub repo URL.");
return; return;
} }
@@ -157,28 +149,15 @@ export function NewProjectDialog() {
...(targetDate ? { targetDate } : {}), ...(targetDate ? { targetDate } : {}),
}); });
const workspacePayloads: Array<Record<string, unknown>> = []; if (localPath || repoUrl) {
if (localRequired && repoRequired) { const workspacePayload: Record<string, unknown> = {
workspacePayloads.push({ name: localPath
name: deriveWorkspaceNameFromPath(localPath), ? deriveWorkspaceNameFromPath(localPath)
cwd: localPath, : deriveWorkspaceNameFromRepo(repoUrl),
repoUrl, ...(localPath ? { cwd: localPath } : {}),
}); ...(repoUrl ? { repoUrl } : {}),
} else if (localRequired) { };
workspacePayloads.push({ await projectsApi.createWorkspace(created.id, workspacePayload);
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
});
} else if (repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromRepo(repoUrl),
repoUrl,
});
}
for (const workspacePayload of workspacePayloads) {
await projectsApi.createWorkspace(created.id, {
...workspacePayload,
});
} }
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
@@ -279,81 +258,52 @@ export function NewProjectDialog() {
/> />
</div> </div>
<div className="px-4 pb-3 space-y-3 border-t border-border"> <div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div className="pt-3"> <div>
<p className="text-sm font-medium">Where will work be done on this project?</p> <div className="mb-1 flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">Add a repo and/or local folder for this project.</p> <label className="block text-xs text-muted-foreground">Repo URL</label>
</div> <span className="text-xs text-muted-foreground/50">optional</span>
<div className="grid gap-2 sm:grid-cols-3"> <Tooltip delayDuration={300}>
<button <TooltipTrigger asChild>
type="button" <HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
className={cn( </TooltipTrigger>
"rounded-lg border px-3 py-3 text-left transition-colors", <TooltipContent side="top" className="max-w-[240px] text-xs">
workspaceSetup === "local" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30", Link a GitHub repository so agents can clone, read, and push code for this project.
)} </TooltipContent>
onClick={() => toggleWorkspaceSetup("local")} </Tooltip>
> </div>
<div className="flex items-center gap-2 text-sm font-medium"> <input
<FolderOpen className="h-4 w-4" /> className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
A local folder value={workspaceRepoUrl}
</div> onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
<p className="mt-1 text-xs text-muted-foreground">Use a full path on this machine.</p> placeholder="https://github.com/org/repo"
</button> />
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "repo" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("repo")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Github className="h-4 w-4" />
A repo
</div>
<p className="mt-1 text-xs text-muted-foreground">Paste a GitHub URL.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "both" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("both")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<GitBranch className="h-4 w-4" />
Both
</div>
<p className="mt-1 text-xs text-muted-foreground">Configure both repo and local folder.</p>
</button>
</div> </div>
{(workspaceSetup === "local" || workspaceSetup === "both") && ( <div>
<div className="rounded-md border border-border p-2"> <div className="mb-1 flex items-center gap-1.5">
<label className="mb-1 block text-xs text-muted-foreground">Local folder (full path)</label> <label className="block text-xs text-muted-foreground">Local folder</label>
<div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground/50">optional</span>
<input <Tooltip delayDuration={300}>
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" <TooltipTrigger asChild>
value={workspaceLocalPath} <HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
onChange={(e) => setWorkspaceLocalPath(e.target.value)} </TooltipTrigger>
placeholder="/absolute/path/to/workspace" <TooltipContent side="top" className="max-w-[240px] text-xs">
/> Set an absolute path on this machine where local agents will read and write files for this project.
<ChoosePathButton /> </TooltipContent>
</div> </Tooltip>
</div> </div>
)} <div className="flex items-center gap-2">
{(workspaceSetup === "repo" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">Repo URL</label>
<input <input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none" className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceRepoUrl} value={workspaceLocalPath}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)} onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo" placeholder="/absolute/path/to/workspace"
/> />
<ChoosePathButton />
</div> </div>
)} </div>
{workspaceError && ( {workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p> <p className="text-xs text-destructive">{workspaceError}</p>
)} )}

View File

@@ -400,7 +400,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
type: "tool", type: "tool",
ts: entry.ts, ts: entry.ts,
endTs: entry.ts, endTs: entry.ts,
name: "tool", name: entry.toolName ?? "tool",
toolUseId: entry.toolUseId, toolUseId: entry.toolUseId,
input: null, input: null,
result: entry.content, result: entry.content,

View File

@@ -4,6 +4,7 @@ import {
currentUserAssigneeOption, currentUserAssigneeOption,
formatAssigneeUserLabel, formatAssigneeUserLabel,
parseAssigneeValue, parseAssigneeValue,
suggestedCommentAssigneeValue,
} from "./assignees"; } from "./assignees";
describe("assignee selection helpers", () => { describe("assignee selection helpers", () => {
@@ -50,4 +51,42 @@ describe("assignee selection helpers", () => {
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
}); });
it("suggests the last non-me commenter without changing the actual assignee encoding", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[
{ authorUserId: "board-user" },
{ authorAgentId: "agent-123" },
],
"board-user",
),
).toBe("agent:agent-123");
});
it("falls back to the actual assignee when there is no better commenter hint", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[{ authorUserId: "board-user" }],
"board-user",
),
).toBe("user:board-user");
});
it("skips the current agent when choosing a suggested commenter assignee", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[
{ authorUserId: "board-user" },
{ authorAgentId: "agent-self" },
{ authorAgentId: "agent-123" },
],
null,
"agent-self",
),
).toBe("agent:agent-123");
});
}); });

View File

@@ -9,12 +9,43 @@ export interface AssigneeOption {
searchText?: string; searchText?: string;
} }
interface CommentAssigneeSuggestionInput {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
}
interface CommentAssigneeSuggestionComment {
authorAgentId?: string | null;
authorUserId?: string | null;
}
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string { export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
return ""; return "";
} }
export function suggestedCommentAssigneeValue(
issue: CommentAssigneeSuggestionInput,
comments: CommentAssigneeSuggestionComment[] | null | undefined,
currentUserId: string | null | undefined,
currentAgentId?: string | null | undefined,
): string {
if (comments && comments.length > 0 && (currentUserId || currentAgentId)) {
for (let i = comments.length - 1; i >= 0; i--) {
const comment = comments[i];
if (comment.authorAgentId && comment.authorAgentId !== currentAgentId) {
return assigneeValueFromSelection({ assigneeAgentId: comment.authorAgentId });
}
if (comment.authorUserId && comment.authorUserId !== currentUserId) {
return assigneeValueFromSelection({ assigneeUserId: comment.authorUserId });
}
}
}
return assigneeValueFromSelection(issue);
}
export function parseAssigneeValue(value: string): AssigneeSelection { export function parseAssigneeValue(value: string): AssigneeSelection {
if (!value) { if (!value) {
return { assigneeAgentId: null, assigneeUserId: null }; return { assigneeAgentId: null, assigneeUserId: null };

View File

@@ -111,6 +111,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
logCompressed: false, logCompressed: false,
errorCode: null, errorCode: null,
externalRunId: null, externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
stdoutExcerpt: null, stdoutExcerpt: null,
stderrExcerpt: null, stderrExcerpt: null,
contextSnapshot: null, contextSnapshot: null,
@@ -289,7 +293,11 @@ describe("inbox helpers", () => {
getInboxWorkItems({ getInboxWorkItems({
issues: [olderIssue, newerIssue], issues: [olderIssue, newerIssue],
approvals: [approval], approvals: [approval],
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`), }).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
return `run:${item.run.id}`;
}),
).toEqual([ ).toEqual([
"issue:1", "issue:1",
"approval:approval-between", "approval:approval-between",

View File

@@ -23,6 +23,11 @@ export type InboxWorkItem =
kind: "approval"; kind: "approval";
timestamp: number; timestamp: number;
approval: Approval; approval: Approval;
}
| {
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}; };
export interface InboxBadgeData { export interface InboxBadgeData {
@@ -146,9 +151,11 @@ export function approvalActivityTimestamp(approval: Approval): number {
export function getInboxWorkItems({ export function getInboxWorkItems({
issues, issues,
approvals, approvals,
failedRuns = [],
}: { }: {
issues: Issue[]; issues: Issue[];
approvals: Approval[]; approvals: Approval[];
failedRuns?: HeartbeatRun[];
}): InboxWorkItem[] { }): InboxWorkItem[] {
return [ return [
...issues.map((issue) => ({ ...issues.map((issue) => ({
@@ -161,6 +168,11 @@ export function getInboxWorkItems({
timestamp: approvalActivityTimestamp(approval), timestamp: approvalActivityTimestamp(approval),
approval, approval,
})), })),
...failedRuns.map((run) => ({
kind: "failed_run" as const,
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
].sort((a, b) => { ].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp; const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff; if (timestampDiff !== 0) return timestampDiff;

View File

@@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import {
agentsApi,
type AgentKey,
type ClaudeLoginResult,
type AvailableSkill,
type AgentPermissionUpdate,
} from "../api/agents";
import { companySkillsApi } from "../api/companySkills"; import { companySkillsApi } from "../api/companySkills";
import { budgetsApi } from "../api/budgets"; import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -73,6 +79,7 @@ import {
type Agent, type Agent,
type AgentSkillEntry, type AgentSkillEntry,
type AgentSkillSnapshot, type AgentSkillSnapshot,
type AgentDetail as AgentDetailRecord,
type BudgetPolicySummary, type BudgetPolicySummary,
type HeartbeatRun, type HeartbeatRun,
type HeartbeatRunEvent, type HeartbeatRunEvent,
@@ -516,7 +523,7 @@ export function AgentDetail() {
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const { data: agent, isLoading, error } = useQuery({ const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
enabled: canFetchAgent, enabled: canFetchAgent,
@@ -704,8 +711,8 @@ export function AgentDetail() {
}); });
const updatePermissions = useMutation({ const updatePermissions = useMutation({
mutationFn: (canCreateAgents: boolean) => mutationFn: (permissions: AgentPermissionUpdate) =>
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined), agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
onSuccess: () => { onSuccess: () => {
setActionError(null); setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
@@ -1109,7 +1116,7 @@ function AgentOverview({
agentId, agentId,
agentRouteId, agentRouteId,
}: { }: {
agent: Agent; agent: AgentDetailRecord;
runs: HeartbeatRun[]; runs: HeartbeatRun[];
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
runtimeState?: AgentRuntimeState; runtimeState?: AgentRuntimeState;
@@ -1266,14 +1273,14 @@ function AgentConfigurePage({
onSavingChange, onSavingChange,
updatePermissions, updatePermissions,
}: { }: {
agent: Agent; agent: AgentDetailRecord;
agentId: string; agentId: string;
companyId?: string; companyId?: string;
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void; onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void; onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [revisionsOpen, setRevisionsOpen] = useState(false); const [revisionsOpen, setRevisionsOpen] = useState(false);
@@ -1377,13 +1384,13 @@ function ConfigurationTab({
hidePromptTemplate, hidePromptTemplate,
hideInstructionsFile, hideInstructionsFile,
}: { }: {
agent: Agent; agent: AgentDetailRecord;
companyId?: string; companyId?: string;
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void; onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void; onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
hidePromptTemplate?: boolean; hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean; hideInstructionsFile?: boolean;
}) { }) {
@@ -1427,6 +1434,19 @@ function ConfigurationTab({
onSavingChange(isConfigSaving); onSavingChange(isConfigSaving);
}, [onSavingChange, isConfigSaving]); }, [onSavingChange, isConfigSaving]);
const canCreateAgents = Boolean(agent.permissions?.canCreateAgents);
const canAssignTasks = Boolean(agent.access?.canAssignTasks);
const taskAssignSource = agent.access?.taskAssignSource ?? "none";
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
const taskAssignHint =
taskAssignSource === "ceo_role"
? "Enabled automatically for CEO agents."
: taskAssignSource === "agent_creator"
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<AgentConfigForm <AgentConfigForm
@@ -1446,21 +1466,62 @@ function ConfigurationTab({
<div> <div>
<h3 className="text-sm font-medium mb-3">Permissions</h3> <h3 className="text-sm font-medium mb-3">Permissions</h3>
<div className="border border-border rounded-lg p-4"> <div className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between gap-4 text-sm">
<span>Can create new agents</span> <div className="space-y-1">
<div>Can create new agents</div>
<p className="text-xs text-muted-foreground">
Lets this agent create or hire agents and implicitly assign tasks.
</p>
</div>
<Button <Button
variant={agent.permissions?.canCreateAgents ? "default" : "outline"} variant={canCreateAgents ? "default" : "outline"}
size="sm" size="sm"
className="h-7 px-2.5 text-xs" className="h-7 px-2.5 text-xs"
onClick={() => onClick={() =>
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents)) updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
})
} }
disabled={updatePermissions.isPending} disabled={updatePermissions.isPending}
> >
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"} {canCreateAgents ? "Enabled" : "Disabled"}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can assign tasks</div>
<p className="text-xs text-muted-foreground">
{taskAssignHint}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={canAssignTasks}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks
? "bg-green-500 focus-visible:ring-green-500/70"
: "bg-input/50 focus-visible:ring-ring",
)}
onClick={() =>
updatePermissions.mutate({
canCreateAgents,
canAssignTasks: !canAssignTasks,
})
}
disabled={updatePermissions.isPending || taskAssignLocked}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-background transition-transform",
canAssignTasks ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -203,7 +203,7 @@ export function ApprovalDetail() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" /> <TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" />
<div> <div>
<h2 className="text-lg font-semibold">{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}</h2> <h2 className="text-lg font-semibold">{approvalLabel(approval.type, approval.payload as Record<string, unknown> | null)}</h2>
<p className="text-xs text-muted-foreground font-mono">{approval.id}</p> <p className="text-xs text-muted-foreground font-mono">{approval.id}</p>
</div> </div>
</div> </div>

View File

@@ -18,7 +18,7 @@ import { IssueRow } from "../components/IssueRow";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -33,12 +33,10 @@ import {
import { import {
Inbox as InboxIcon, Inbox as InboxIcon,
AlertTriangle, AlertTriangle,
ArrowUpRight,
XCircle, XCircle,
X, X,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { import {
@@ -64,16 +62,8 @@ type InboxCategoryFilter =
type SectionKey = type SectionKey =
| "work_items" | "work_items"
| "join_requests" | "join_requests"
| "failed_runs"
| "alerts"; | "alerts";
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
assignment: "Assignment",
on_demand: "Manual",
automation: "Automation",
};
function firstNonEmptyLine(value: string | null | undefined): string | null { function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null; return null;
} }
function FailedRunCard({ function FailedRunInboxRow({
run, run,
issueById, issueById,
agentName: linkedAgentName, agentName: linkedAgentName,
issueLinkState, issueLinkState,
onDismiss, onDismiss,
onRetry,
isRetrying,
}: { }: {
run: HeartbeatRun; run: HeartbeatRun;
issueById: Map<string, Issue>; issueById: Map<string, Issue>;
agentName: string | null; agentName: string | null;
issueLinkState: unknown; issueLinkState: unknown;
onDismiss: () => void; onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
}) { }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const issueId = readIssueIdFromRun(run); const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null; const issue = issueId ? issueById.get(issueId) ?? null : null;
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
const displayError = runFailureMessage(run); const displayError = runFailureMessage(run);
const retryRun = useMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {};
const context = run.contextSnapshot as Record<string, unknown> | null;
if (context) {
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
}
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload,
});
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return result;
},
onSuccess: (newRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
navigate(`/agents/${run.agentId}/runs/${newRun.id}`);
},
});
return ( return (
<div className="group relative overflow-hidden rounded-xl border border-red-500/30 bg-gradient-to-br from-red-500/10 via-card to-card p-4"> <div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" /> <div className="flex items-start gap-2 sm:items-center">
<button <Link
type="button" to={`/agents/${run.agentId}/runs/${run.id}`}
onClick={onDismiss} className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
className="absolute right-2 top-2 z-10 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100" >
aria-label="Dismiss" <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
> <span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<X className="h-4 w-4" /> <span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
</button> <XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<div className="relative space-y-3">
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
>
<span className="font-mono text-muted-foreground mr-1.5">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.title}
</Link>
) : (
<span className="block text-sm text-muted-foreground">
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"}
</span> </span>
)} <span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> {issue ? (
<div className="min-w-0 flex-1"> <>
<div className="flex flex-wrap items-center gap-2"> <span className="font-mono text-muted-foreground mr-1.5">
<span className="rounded-md bg-red-500/20 p-1.5"> {issue.identifier ?? issue.id.slice(0, 8)}
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" /> </span>
</span> {issue.title}
{linkedAgentName ? ( </>
<Identity name={linkedAgentName} size="sm" />
) : ( ) : (
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span> <>Failed run{linkedAgentName ? `${linkedAgentName}` : ""}</>
)} )}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<StatusBadge status={run.status} /> <StatusBadge status={run.status} />
</div> {linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
<p className="mt-2 text-xs text-muted-foreground"> <span className="truncate max-w-[300px]">{displayError}</span>
{sourceLabel} run failed {timeAgo(run.createdAt)} <span>{timeAgo(run.createdAt)}</span>
</p> </span>
</div> </span>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end"> </Link>
<Button <div className="hidden shrink-0 items-center gap-2 sm:flex">
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
className="h-8 shrink-0 px-2.5" size="sm"
onClick={() => retryRun.mutate()} className="h-8 shrink-0 px-2.5"
disabled={retryRun.isPending} onClick={onRetry}
> disabled={isRetrying}
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> >
{retryRun.isPending ? "Retrying…" : "Retry"} <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
</Button> {isRetrying ? "Retrying…" : "Retry"}
<Button </Button>
type="button" <button
variant="outline" type="button"
size="sm" onClick={onDismiss}
className="h-8 shrink-0 px-2.5" className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
asChild aria-label="Dismiss"
> >
<Link to={`/agents/${run.agentId}/runs/${run.id}`}> <X className="h-4 w-4" />
Open run </button>
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</div>
</div> </div>
</div>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm"> <div className="mt-3 flex gap-2 sm:hidden">
{displayError} <Button
</div> type="button"
variant="outline"
<div className="text-xs"> size="sm"
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span> className="h-8 shrink-0 px-2.5"
</div> onClick={onRetry}
disabled={isRetrying}
{retryRun.isError && ( >
<div className="text-xs text-destructive"> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"} {isRetrying ? "Retrying…" : "Retry"}
</div> </Button>
)} <button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
); );
@@ -253,7 +206,7 @@ function ApprovalInboxRow({
isPending: boolean; isPending: boolean;
}) { }) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type; const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons = const showResolutionButtons =
approval.type !== "budget_override_required" && approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status); ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
@@ -473,13 +426,19 @@ export function Inbox() {
const showFailedRunsCategory = const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const failedRunsForTab = useMemo(() => {
if (tab === "all" && !showFailedRunsCategory) return [];
return failedRuns;
}, [failedRuns, tab, showFailedRunsCategory]);
const workItemsToRender = useMemo( const workItemsToRender = useMemo(
() => () =>
getInboxWorkItems({ getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
failedRuns: failedRunsForTab,
}), }),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab], [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
); );
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
@@ -538,6 +497,46 @@ export function Inbox() {
}, },
}); });
const [retryingRunIds, setRetryingRunIds] = useState<Set<string>>(new Set());
const retryRunMutation = useMutation({
mutationFn: async (run: HeartbeatRun) => {
const payload: Record<string, unknown> = {};
const context = run.contextSnapshot as Record<string, unknown> | null;
if (context) {
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
}
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload,
});
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return { newRun: result, originalRun: run };
},
onMutate: (run) => {
setRetryingRunIds((prev) => new Set(prev).add(run.id));
},
onSuccess: ({ newRun, originalRun }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) });
navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`);
},
onSettled: (_data, _error, run) => {
if (!run) return;
setRetryingRunIds((prev) => {
const next = new Set(prev);
next.delete(run.id);
return next;
});
},
});
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set()); const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => { const invalidateInboxIssueQueries = () => {
@@ -607,13 +606,6 @@ export function Inbox() {
const showWorkItemsSection = workItemsToRender.length > 0; const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsSection = const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showFailedRunsSection = shouldShowInboxSection({
tab,
hasItems: hasRunFailures,
showOnRecent: hasRunFailures,
showOnUnread: hasRunFailures,
showOnAll: showFailedRunsCategory && hasRunFailures,
});
const showAlertsSection = shouldShowInboxSection({ const showAlertsSection = shouldShowInboxSection({
tab, tab,
hasItems: hasAlerts, hasItems: hasAlerts,
@@ -623,7 +615,6 @@ export function Inbox() {
}); });
const visibleSections = [ const visibleSections = [
showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null, showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null, showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : null, showWorkItemsSection ? "work_items" : null,
@@ -751,6 +742,21 @@ export function Inbox() {
); );
} }
if (item.kind === "failed_run") {
return (
<FailedRunInboxRow
key={`run:${item.run.id}`}
run={item.run}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
/>
);
}
const issue = item.issue; const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id);
@@ -857,28 +863,6 @@ export function Inbox() {
</> </>
)} )}
{showFailedRunsSection && (
<>
{showSeparatorBefore("failed_runs") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Failed Runs
</h3>
<div className="grid gap-3">
{failedRuns.map((run) => (
<FailedRunCard
key={run.id}
run={run}
issueById={issueById}
agentName={agentName(run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${run.id}`)}
/>
))}
</div>
</div>
</>
)}
{showAlertsSection && ( {showAlertsSection && (
<> <>

View File

@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
@@ -206,7 +207,6 @@ export function IssueDetail() {
const [detailTab, setDetailTab] = useState("comments"); const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({ const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false, approvals: false,
cost: false,
}); });
const [attachmentError, setAttachmentError] = useState<string | null>(null); const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [attachmentDragActive, setAttachmentDragActive] = useState(false);
@@ -375,11 +375,15 @@ export function IssueDetail() {
return options; return options;
}, [agents, currentUserId]); }, [agents, currentUserId]);
const currentAssigneeValue = useMemo(() => { const actualAssigneeValue = useMemo(
if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`; () => assigneeValueFromSelection(issue ?? {}),
if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`; [issue],
return ""; );
}, [issue?.assigneeAgentId, issue?.assigneeUserId]);
const suggestedAssigneeValue = useMemo(
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
[issue, comments, currentUserId],
);
const commentsWithRunMeta = useMemo(() => { const commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>(); const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
@@ -1002,7 +1006,8 @@ export function IssueDetail() {
draftKey={`paperclip:issue-comment-draft:${issue.id}`} draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign enableReassign
reassignOptions={commentReassignOptions} reassignOptions={commentReassignOptions}
currentAssigneeValue={currentAssigneeValue} currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions} mentions={mentionOptions}
onAdd={async (body, reopen, reassignment) => { onAdd={async (body, reopen, reassignment) => {
if (reassignment) { if (reassignment) {
@@ -1055,6 +1060,30 @@ export function IssueDetail() {
</TabsContent> </TabsContent>
<TabsContent value="activity"> <TabsContent value="activity">
{linkedRuns && linkedRuns.length > 0 && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
)}
</div>
)}
</div>
)}
{!activity || activity.length === 0 ? ( {!activity || activity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p> <p className="text-xs text-muted-foreground">No activity yet.</p>
) : ( ) : (
@@ -1123,43 +1152,6 @@ export function IssueDetail() {
</Collapsible> </Collapsible>
)} )}
{linkedRuns && linkedRuns.length > 0 && (
<Collapsible
open={secondaryOpen.cost}
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, cost: open }))}
className="rounded-lg border border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
<span className="text-sm font-medium text-muted-foreground">Cost Summary</span>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.cost && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border px-3 py-2">
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Mobile properties drawer */} {/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}> <Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>

View File

@@ -51,7 +51,7 @@ import type { RoutineTrigger } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook", "api"]; const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256"]; const signingModes = ["bearer", "hmac_sha256"];
const routineTabs = ["triggers", "runs", "activity"] as const; const routineTabs = ["triggers", "runs", "activity"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = { const concurrencyPolicyDescriptions: Record<string, string> = {
@@ -907,7 +907,9 @@ export function RoutineDetail() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{triggerKinds.map((kind) => ( {triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind}>{kind}</SelectItem> <SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
{kind}{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>