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:
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
}];
|
}];
|
||||||
|
|||||||
5
packages/db/src/migrations/0038_careless_iron_monger.sql
Normal file
5
packages/db/src/migrations/0038_careless_iron_monger.sql
Normal 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;
|
||||||
@@ -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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ export type {
|
|||||||
InstanceExperimentalSettings,
|
InstanceExperimentalSettings,
|
||||||
InstanceSettings,
|
InstanceSettings,
|
||||||
Agent,
|
Agent,
|
||||||
|
AgentAccessState,
|
||||||
|
AgentChainOfCommandEntry,
|
||||||
|
AgentDetail,
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
AgentInstructionsBundleMode,
|
AgentInstructionsBundleMode,
|
||||||
AgentInstructionsFileSummary,
|
AgentInstructionsFileSummary,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
246
server/src/__tests__/agent-permissions-routes.test.ts
Normal file
246
server/src/__tests__/agent-permissions-routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
321
server/src/__tests__/heartbeat-process-recovery.test.ts
Normal file
321
server/src/__tests__/heartbeat-process-recovery.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user