Merge pull request #1346 from paperclipai/feature/inbox-heartbeat-company-skills
Improve inbox workflows, runtime recovery, and company controls
This commit is contained in:
@@ -8,6 +8,8 @@ export interface RunProcessResult {
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
pid: number | null;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
@@ -423,6 +425,7 @@ export async function runChildProcess(
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
@@ -455,12 +458,19 @@ export async function runChildProcess(
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
|
||||
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||
onLogError(err, runId, "failed to record child process metadata");
|
||||
});
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
let timedOut = false;
|
||||
@@ -519,6 +529,8 @@ export async function runChildProcess(
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: child.pid ?? null,
|
||||
startedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface AdapterExecutionContext {
|
||||
context: Record<string, unknown>;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function runClaudeLogin(input: {
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -369,7 +369,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -455,6 +455,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
|
||||
@@ -572,7 +573,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
|
||||
@@ -167,7 +167,7 @@ export async function ensureCodexSkillsInjected(
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -347,7 +347,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
@@ -454,6 +454,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stderr") {
|
||||
await onLog(stream, chunk);
|
||||
@@ -540,7 +541,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function ensureCursorSkillsInjected(
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -281,7 +281,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -299,13 +299,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
@@ -419,6 +419,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stdout") {
|
||||
await onLog(stream, chunk);
|
||||
@@ -511,7 +512,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
|
||||
@@ -129,7 +129,7 @@ async function ensureGeminiSkillsInjected(
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -248,13 +248,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
@@ -349,6 +349,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
@@ -447,7 +448,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
|
||||
@@ -88,7 +88,7 @@ async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLo
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -196,7 +196,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -215,13 +215,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
@@ -301,6 +301,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
@@ -387,7 +388,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
|
||||
@@ -102,7 +102,7 @@ function buildSessionPath(agentId: string, timestamp: string): string {
|
||||
}
|
||||
|
||||
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(
|
||||
config.promptTemplate,
|
||||
@@ -226,7 +226,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
@@ -261,14 +261,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
// Fall back to base prompt template
|
||||
@@ -398,6 +398,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog: bufferedOnLog,
|
||||
stdin: buildRpcStdin(),
|
||||
});
|
||||
@@ -478,7 +479,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const newSessionPath = buildSessionPath(agent.id, new Date().toISOString());
|
||||
|
||||
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;
|
||||
10301
packages/db/src/migrations/meta/0038_snapshot.json
Normal file
10301
packages/db/src/migrations/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,13 @@
|
||||
"when": 1773756922363,
|
||||
"tag": "0037_friendly_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "7",
|
||||
"when": 1773931592563,
|
||||
"tag": "0038_careless_iron_monger",
|
||||
"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 { agents } from "./agents.js";
|
||||
import { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||
@@ -31,6 +31,12 @@ export const heartbeatRuns = pgTable(
|
||||
stderrExcerpt: text("stderr_excerpt"),
|
||||
errorCode: text("error_code"),
|
||||
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>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -123,6 +123,9 @@ export type {
|
||||
InstanceExperimentalSettings,
|
||||
InstanceSettings,
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
AgentChainOfCommandEntry,
|
||||
AgentDetail,
|
||||
AgentPermissions,
|
||||
AgentKeyCreated,
|
||||
AgentConfigRevision,
|
||||
@@ -253,8 +256,10 @@ export {
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
type UpdateCompanyBranding,
|
||||
createAgentSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
|
||||
@@ -4,11 +4,29 @@ import type {
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
} from "../constants.js";
|
||||
import type {
|
||||
CompanyMembership,
|
||||
PrincipalPermissionGrant,
|
||||
} from "./access.js";
|
||||
|
||||
export interface AgentPermissions {
|
||||
canCreateAgents: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -34,6 +52,11 @@ export interface Agent {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AgentDetail extends Agent {
|
||||
chainOfCommand: AgentChainOfCommandEntry[];
|
||||
access: AgentAccessState;
|
||||
}
|
||||
|
||||
export interface AgentKeyCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface HeartbeatRun {
|
||||
stderrExcerpt: string | null;
|
||||
errorCode: string | null;
|
||||
externalRunId: string | null;
|
||||
processPid: number | null;
|
||||
processStartedAt: Date | null;
|
||||
retryOfRunId: string | null;
|
||||
processLossRetryCount: number;
|
||||
contextSnapshot: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -2,6 +2,9 @@ export type { Company } from "./company.js";
|
||||
export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js";
|
||||
export type {
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
AgentChainOfCommandEntry,
|
||||
AgentDetail,
|
||||
AgentPermissions,
|
||||
AgentKeyCreated,
|
||||
AgentConfigRevision,
|
||||
|
||||
@@ -100,6 +100,7 @@ export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema
|
||||
|
||||
export const updateAgentPermissionsSchema = z.object({
|
||||
canCreateAgents: z.boolean(),
|
||||
canAssignTasks: z.boolean(),
|
||||
});
|
||||
|
||||
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
|
||||
|
||||
@@ -22,3 +22,13 @@ export const updateCompanySchema = createCompanySchema
|
||||
});
|
||||
|
||||
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
||||
|
||||
/** Branding-only subset that CEO agents may update. */
|
||||
export const updateCompanyBrandingSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
});
|
||||
|
||||
export type UpdateCompanyBranding = z.infer<typeof updateCompanyBrandingSchema>;
|
||||
|
||||
@@ -15,8 +15,10 @@ export {
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
type UpdateCompanyBranding,
|
||||
} from "./company.js";
|
||||
export {
|
||||
portabilityIncludeSchema,
|
||||
|
||||
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",
|
||||
"active"
|
||||
);
|
||||
await access.setPrincipalPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
created.id,
|
||||
"tasks:assign",
|
||||
true,
|
||||
req.actor.userId ?? null
|
||||
);
|
||||
const grants = grantsFromDefaults(
|
||||
invite.defaultsPayload as Record<string, unknown> | null,
|
||||
"agent"
|
||||
|
||||
@@ -71,6 +71,80 @@ export function agentRoutes(db: Db) {
|
||||
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) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") {
|
||||
@@ -575,8 +649,7 @@ export function agentRoutes(db: Db) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
||||
res.json({ ...agent, chainOfCommand });
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
||||
router.get("/agents/me/inbox-lite", async (req, res) => {
|
||||
@@ -618,13 +691,11 @@ export function agentRoutes(db: Db) {
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
if (!canRead) {
|
||||
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
||||
res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand });
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chainOfCommand = await svc.getChainOfCommand(agent.id);
|
||||
res.json({ ...agent, chainOfCommand });
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
||||
router.get("/agents/:id/configuration", async (req, res) => {
|
||||
@@ -884,6 +955,12 @@ export function agentRoutes(db: Db) {
|
||||
},
|
||||
});
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
agent.id,
|
||||
actor.actorType === "user" ? actor.actorId : null,
|
||||
);
|
||||
|
||||
if (approval) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -945,6 +1022,12 @@ export function agentRoutes(db: Db) {
|
||||
details: { name: agent.name, role: agent.role },
|
||||
});
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
agent.id,
|
||||
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
|
||||
);
|
||||
|
||||
if (agent.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
@@ -988,6 +1071,18 @@ export function agentRoutes(db: Db) {
|
||||
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);
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -998,10 +1093,13 @@ export function agentRoutes(db: Db) {
|
||||
action: "agent.permissions_updated",
|
||||
entityType: "agent",
|
||||
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) => {
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
companyPortabilityPreviewSchema,
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
budgetService,
|
||||
companyPortabilityService,
|
||||
companyService,
|
||||
@@ -58,9 +60,12 @@ export function companyRoutes(db: Db) {
|
||||
});
|
||||
|
||||
router.get("/:companyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
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);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -144,23 +149,44 @@ export function companyRoutes(db: Db) {
|
||||
res.status(201).json(company);
|
||||
});
|
||||
|
||||
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
router.patch("/:companyId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
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) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.updated",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: req.body,
|
||||
details: body,
|
||||
});
|
||||
res.json(company);
|
||||
});
|
||||
|
||||
@@ -820,6 +820,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||
if (hiddenAtRaw !== undefined) {
|
||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||
@@ -856,6 +857,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
const previous: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updateFields)) {
|
||||
@@ -864,7 +870,6 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -1278,6 +1283,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
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, {
|
||||
companyId: currentIssue.companyId,
|
||||
actorType: actor.actorType,
|
||||
|
||||
@@ -251,6 +251,86 @@ export function accessService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
isInstanceAdmin,
|
||||
canUser,
|
||||
@@ -264,5 +344,7 @@ export function accessService(db: Db) {
|
||||
listUserCompanyAccess,
|
||||
setUserCompanyAccess,
|
||||
setPrincipalGrants,
|
||||
listPrincipalGrants,
|
||||
setPrincipalPermission,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -955,6 +955,15 @@ export function companyPortabilityService(db: Db) {
|
||||
}
|
||||
|
||||
const 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,
|
||||
);
|
||||
importedSlugToAgentId.set(planAgent.slug, created.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
||||
resultAgents.push({
|
||||
|
||||
@@ -60,6 +60,7 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -163,6 +164,10 @@ const heartbeatRunListColumns = {
|
||||
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
externalRunId: heartbeatRuns.externalRunId,
|
||||
processPid: heartbeatRuns.processPid,
|
||||
processStartedAt: heartbeatRuns.processStartedAt,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
@@ -598,6 +603,26 @@ function isSameTaskScope(left: string | null, right: string | 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) {
|
||||
if (!value) return null;
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
@@ -1326,6 +1351,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) {
|
||||
const runtimeConfig = parseObject(agent.runtimeConfig);
|
||||
const heartbeat = parseObject(runtimeConfig.heartbeat);
|
||||
@@ -1453,13 +1628,17 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
|
||||
const activeRuns = await db
|
||||
.select()
|
||||
.select({
|
||||
run: heartbeatRuns,
|
||||
adapterType: agents.adapterType,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
.where(eq(heartbeatRuns.status, "running"));
|
||||
|
||||
const reaped: string[] = [];
|
||||
|
||||
for (const run of activeRuns) {
|
||||
for (const { run, adapterType } of activeRuns) {
|
||||
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
|
||||
|
||||
// Apply staleness threshold to avoid false positives
|
||||
@@ -1468,25 +1647,69 @@ export function heartbeatService(db: Db) {
|
||||
if (now.getTime() - refTime < staleThresholdMs) continue;
|
||||
}
|
||||
|
||||
await setRunStatus(run.id, "failed", {
|
||||
error: "Process lost -- server may have restarted",
|
||||
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
|
||||
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",
|
||||
finishedAt: now,
|
||||
});
|
||||
await setWakeupStatus(run.wakeupRequestId, "failed", {
|
||||
finishedAt: now,
|
||||
error: "Process lost -- server may have restarted",
|
||||
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
||||
});
|
||||
const updatedRun = await getRun(run.id);
|
||||
if (updatedRun) {
|
||||
await appendRunEvent(updatedRun, 1, {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "error",
|
||||
message: "Process lost -- server may have restarted",
|
||||
});
|
||||
await releaseIssueExecutionAndPromote(updatedRun);
|
||||
if (!finalizedRun) finalizedRun = await getRun(run.id);
|
||||
if (!finalizedRun) continue;
|
||||
|
||||
let retriedRun: typeof heartbeatRuns.$inferSelect | null = null;
|
||||
if (shouldRetry) {
|
||||
const agent = await getAgent(run.agentId);
|
||||
if (agent) {
|
||||
retriedRun = await enqueueProcessLossRetry(finalizedRun, agent, now);
|
||||
}
|
||||
} 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 startNextQueuedRunForAgent(run.agentId);
|
||||
runningProcesses.delete(run.id);
|
||||
@@ -2152,6 +2375,9 @@ export function heartbeatService(db: Db) {
|
||||
context,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, meta);
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
const adapterManagedRuntimeServices = adapterResult.runtimeServices
|
||||
@@ -3403,6 +3629,8 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
wakeup: enqueueWakeup,
|
||||
|
||||
reportRunActivity: clearDetachedRunWarning,
|
||||
|
||||
reapOrphanedRuns,
|
||||
|
||||
resumeQueuedRuns,
|
||||
|
||||
@@ -126,6 +126,17 @@ Access control:
|
||||
|
||||
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
|
||||
|
||||
## Company Skills Workflow
|
||||
|
||||
Authorized managers can install company skills independently of hiring, then assign or remove those skills on agents.
|
||||
|
||||
- Install and inspect company skills with the company skills API.
|
||||
- Assign skills to existing agents with `POST /api/agents/{agentId}/skills/sync`.
|
||||
- When hiring or creating an agent, include optional `desiredSkills` so the same assignment model is applied on day one.
|
||||
|
||||
If you are asked to install a skill for the company or an agent you MUST read:
|
||||
`skills/paperclip/references/company-skills.md`
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
||||
@@ -240,32 +251,67 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||
|
||||
## Key Endpoints (Quick Reference)
|
||||
|
||||
| Action | Endpoint |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| My identity | `GET /api/agents/me` |
|
||||
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
||||
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||
| List issue documents | `GET /api/issues/:issueId/documents` |
|
||||
| Get issue document | `GET /api/issues/:issueId/documents/:key` |
|
||||
| Create/update issue document | `PUT /api/issues/:issueId/documents/:key` |
|
||||
| Get issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` |
|
||||
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
||||
| Get comments | `GET /api/issues/:issueId/comments` |
|
||||
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
||||
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||
| Create project | `POST /api/companies/:companyId/projects` |
|
||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||
| Release task | `POST /api/issues/:issueId/release` |
|
||||
| List agents | `GET /api/companies/:companyId/agents` |
|
||||
| Dashboard | `GET /api/companies/:companyId/dashboard` |
|
||||
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
|
||||
| Action | Endpoint |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| My identity | `GET /api/agents/me` |
|
||||
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
||||
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||
| List issue documents | `GET /api/issues/:issueId/documents` |
|
||||
| Get issue document | `GET /api/issues/:issueId/documents/:key` |
|
||||
| Create/update issue document | `PUT /api/issues/:issueId/documents/:key` |
|
||||
| Get issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` |
|
||||
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
||||
| Get comments | `GET /api/issues/:issueId/comments` |
|
||||
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
||||
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||
| Create project | `POST /api/companies/:companyId/projects` |
|
||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||
| Release task | `POST /api/issues/:issueId/release` |
|
||||
| List agents | `GET /api/companies/:companyId/agents` |
|
||||
| List company skills | `GET /api/companies/:companyId/skills` |
|
||||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||
| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` |
|
||||
| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` |
|
||||
| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` |
|
||||
| Preview company export | `POST /api/companies/:companyId/exports/preview` |
|
||||
| Build company export | `POST /api/companies/:companyId/exports` |
|
||||
| Dashboard | `GET /api/companies/:companyId/dashboard` |
|
||||
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
|
||||
| Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` |
|
||||
| List issue attachments | `GET /api/issues/:issueId/attachments` |
|
||||
| Get attachment content | `GET /api/attachments/:attachmentId/content` |
|
||||
| Delete attachment | `DELETE /api/attachments/:attachmentId` |
|
||||
|
||||
## Company Import / Export
|
||||
|
||||
Use the company-scoped routes when a CEO agent needs to inspect or move package content.
|
||||
|
||||
- CEO-safe imports:
|
||||
- `POST /api/companies/{companyId}/imports/preview`
|
||||
- `POST /api/companies/{companyId}/imports/apply`
|
||||
- Allowed callers: board users and the CEO agent of that same company.
|
||||
- Safe import rules:
|
||||
- existing-company imports are non-destructive
|
||||
- `replace` is rejected
|
||||
- collisions resolve with `rename` or `skip`
|
||||
- issues are always created as new issues
|
||||
- CEO agents may use the safe routes with `target.mode = "new_company"` to create a new company directly. Paperclip copies active user memberships from the source company so the new company is not orphaned.
|
||||
|
||||
For export, preview first and keep tasks explicit:
|
||||
|
||||
- `POST /api/companies/{companyId}/exports/preview`
|
||||
- `POST /api/companies/{companyId}/exports`
|
||||
- Export preview defaults to `issues: false`
|
||||
- Add `issues` or `projectIssues` only when you intentionally need task files
|
||||
- Use `selectedFiles` to narrow the final package to specific agents, skills, projects, or tasks after you inspect the preview inventory
|
||||
|
||||
## Searching Issues
|
||||
|
||||
|
||||
@@ -280,6 +280,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.
|
||||
|
||||
## 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)
|
||||
|
||||
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:
|
||||
|
||||
187
skills/paperclip/references/company-skills.md
Normal file
187
skills/paperclip/references/company-skills.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Company Skills Workflow
|
||||
|
||||
Use this reference when a board user, CEO, or manager asks you to find a skill, install it into the company library, or assign it to an agent.
|
||||
|
||||
## What Exists
|
||||
|
||||
- Company skill library: install, inspect, update, and read imported skills for the whole company.
|
||||
- Agent skill assignment: add or remove company skills on an existing agent.
|
||||
- Hire/create composition: pass `desiredSkills` when creating or hiring an agent so the same assignment model applies immediately.
|
||||
|
||||
The canonical model is:
|
||||
|
||||
1. install the skill into the company
|
||||
2. assign the company skill to the agent
|
||||
3. optionally do step 2 during hire/create with `desiredSkills`
|
||||
|
||||
## Permission Model
|
||||
|
||||
- Company skill reads: any same-company actor
|
||||
- Company skill mutations: board, CEO, or an agent with the effective `agents:create` capability
|
||||
- Agent skill assignment: same permission model as updating that agent
|
||||
|
||||
## Core Endpoints
|
||||
|
||||
- `GET /api/companies/:companyId/skills`
|
||||
- `GET /api/companies/:companyId/skills/:skillId`
|
||||
- `POST /api/companies/:companyId/skills/import`
|
||||
- `POST /api/companies/:companyId/skills/scan-projects`
|
||||
- `POST /api/companies/:companyId/skills/:skillId/install-update`
|
||||
- `GET /api/agents/:agentId/skills`
|
||||
- `POST /api/agents/:agentId/skills/sync`
|
||||
- `POST /api/companies/:companyId/agent-hires`
|
||||
- `POST /api/companies/:companyId/agents`
|
||||
|
||||
## Install A Skill Into The Company
|
||||
|
||||
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
|
||||
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://github.com/vercel-labs/agent-browser"
|
||||
}'
|
||||
```
|
||||
|
||||
If the task is to discover skills from the company project workspaces first:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/scan-projects" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
## Inspect What Was Installed
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
Read the skill entry and its `SKILL.md`:
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>/files?path=SKILL.md" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
## Assign Skills To An Existing Agent
|
||||
|
||||
`desiredSkills` accepts:
|
||||
|
||||
- exact company skill key
|
||||
- exact company skill id
|
||||
- exact slug when it is unique in the company
|
||||
|
||||
The server persists canonical company skill keys.
|
||||
|
||||
```sh
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills/sync" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"desiredSkills": [
|
||||
"vercel-labs/agent-browser/agent-browser"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
If you need the current state first:
|
||||
|
||||
```sh
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
|
||||
```
|
||||
|
||||
## Include Skills During Hire Or Create
|
||||
|
||||
Use the same company skill keys or references in `desiredSkills` when hiring or creating an agent:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "QA Browser Agent",
|
||||
"role": "qa",
|
||||
"adapterType": "codex_local",
|
||||
"adapterConfig": {
|
||||
"cwd": "/abs/path/to/repo"
|
||||
},
|
||||
"desiredSkills": [
|
||||
"agent-browser"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
For direct create without approval:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "QA Browser Agent",
|
||||
"role": "qa",
|
||||
"adapterType": "codex_local",
|
||||
"adapterConfig": {
|
||||
"cwd": "/abs/path/to/repo"
|
||||
},
|
||||
"desiredSkills": [
|
||||
"agent-browser"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Built-in Paperclip runtime skills are still added automatically when required by the adapter.
|
||||
- If a reference is missing or ambiguous, the API returns `422`.
|
||||
- Prefer linking back to the relevant issue, approval, and agent when you comment about skill changes.
|
||||
- Use company portability routes when you need whole-package import/export, not just a skill:
|
||||
- `POST /api/companies/:companyId/imports/preview`
|
||||
- `POST /api/companies/:companyId/imports/apply`
|
||||
- `POST /api/companies/:companyId/exports/preview`
|
||||
- `POST /api/companies/:companyId/exports`
|
||||
- Use skill-only import when the task is specifically to add a skill to the company library without importing the surrounding company/team/package structure.
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
@@ -45,6 +46,11 @@ export interface AgentHireResponse {
|
||||
approval: Approval | null;
|
||||
}
|
||||
|
||||
export interface AgentPermissionUpdate {
|
||||
canCreateAgents: boolean;
|
||||
canAssignTasks: boolean;
|
||||
}
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
if (!companyId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
@@ -62,7 +68,7 @@ export const agentsApi = {
|
||||
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
||||
get: async (id: string, companyId?: string) => {
|
||||
try {
|
||||
return await api.get<Agent>(agentPath(id, companyId));
|
||||
return await api.get<AgentDetail>(agentPath(id, companyId));
|
||||
} catch (error) {
|
||||
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
|
||||
// resolve using company agent list while ignoring terminated agents.
|
||||
@@ -83,7 +89,7 @@ export const agentsApi = {
|
||||
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
||||
);
|
||||
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) =>
|
||||
@@ -100,8 +106,8 @@ export const agentsApi = {
|
||||
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId), data),
|
||||
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
|
||||
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
||||
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
||||
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
||||
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import type { Approval, Agent } from "@paperclipai/shared";
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ApprovalCard({
|
||||
isPending: boolean;
|
||||
}) {
|
||||
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 =
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
|
||||
@@ -7,6 +7,15 @@ export const typeLabel: Record<string, string> = {
|
||||
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> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
|
||||
@@ -46,6 +46,7 @@ interface CommentThreadProps {
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
@@ -269,13 +270,15 @@ export function CommentThread({
|
||||
enableReassign = false,
|
||||
reassignOptions = [],
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
mentions: providedMentions,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = 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 editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -337,8 +340,8 @@ export function CommentThread({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(currentAssigneeValue);
|
||||
}, [currentAssigneeValue]);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
@@ -370,7 +373,7 @@ export function CommentThread({
|
||||
setBody("");
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(false);
|
||||
setReassignTarget(currentAssigneeValue);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ import {
|
||||
Calendar,
|
||||
Plus,
|
||||
X,
|
||||
FolderOpen,
|
||||
Github,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
@@ -41,8 +44,6 @@ const projectStatuses = [
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
type WorkspaceSetup = "none" | "local" | "repo" | "both";
|
||||
|
||||
export function NewProjectDialog() {
|
||||
const { newProjectOpen, closeNewProject } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
@@ -53,7 +54,6 @@ export function NewProjectDialog() {
|
||||
const [goalIds, setGoalIds] = useState<string[]>([]);
|
||||
const [targetDate, setTargetDate] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [workspaceSetup, setWorkspaceSetup] = useState<WorkspaceSetup>("none");
|
||||
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
|
||||
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
@@ -87,7 +87,6 @@ export function NewProjectDialog() {
|
||||
setGoalIds([]);
|
||||
setTargetDate("");
|
||||
setExpanded(false);
|
||||
setWorkspaceSetup("none");
|
||||
setWorkspaceLocalPath("");
|
||||
setWorkspaceRepoUrl("");
|
||||
setWorkspaceError(null);
|
||||
@@ -124,23 +123,16 @@ export function NewProjectDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWorkspaceSetup = (next: WorkspaceSetup) => {
|
||||
setWorkspaceSetup((prev) => (prev === next ? "none" : next));
|
||||
setWorkspaceError(null);
|
||||
};
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
const localRequired = workspaceSetup === "local" || workspaceSetup === "both";
|
||||
const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both";
|
||||
const localPath = workspaceLocalPath.trim();
|
||||
const repoUrl = workspaceRepoUrl.trim();
|
||||
|
||||
if (localRequired && !isAbsolutePath(localPath)) {
|
||||
if (localPath && !isAbsolutePath(localPath)) {
|
||||
setWorkspaceError("Local folder must be a full absolute path.");
|
||||
return;
|
||||
}
|
||||
if (repoRequired && !isGitHubRepoUrl(repoUrl)) {
|
||||
if (repoUrl && !isGitHubRepoUrl(repoUrl)) {
|
||||
setWorkspaceError("Repo must use a valid GitHub repo URL.");
|
||||
return;
|
||||
}
|
||||
@@ -157,28 +149,15 @@ export function NewProjectDialog() {
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
|
||||
const workspacePayloads: Array<Record<string, unknown>> = [];
|
||||
if (localRequired && repoRequired) {
|
||||
workspacePayloads.push({
|
||||
name: deriveWorkspaceNameFromPath(localPath),
|
||||
cwd: localPath,
|
||||
repoUrl,
|
||||
});
|
||||
} else if (localRequired) {
|
||||
workspacePayloads.push({
|
||||
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,
|
||||
});
|
||||
if (localPath || repoUrl) {
|
||||
const workspacePayload: Record<string, unknown> = {
|
||||
name: localPath
|
||||
? deriveWorkspaceNameFromPath(localPath)
|
||||
: deriveWorkspaceNameFromRepo(repoUrl),
|
||||
...(localPath ? { cwd: localPath } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
};
|
||||
await projectsApi.createWorkspace(created.id, workspacePayload);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
|
||||
@@ -279,81 +258,52 @@ export function NewProjectDialog() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-3 space-y-3 border-t border-border">
|
||||
<div className="pt-3">
|
||||
<p className="text-sm font-medium">Where will work be done on this project?</p>
|
||||
<p className="text-xs text-muted-foreground">Add a repo and/or local folder for this project.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-3 text-left transition-colors",
|
||||
workspaceSetup === "local" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
|
||||
)}
|
||||
onClick={() => toggleWorkspaceSetup("local")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
A local folder
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Use a full path on this machine.</p>
|
||||
</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 className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="block text-xs text-muted-foreground">Repo URL</label>
|
||||
<span className="text-xs text-muted-foreground/50">optional</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
Link a GitHub repository so agents can clone, read, and push code for this project.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
value={workspaceRepoUrl}
|
||||
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(workspaceSetup === "local" || workspaceSetup === "both") && (
|
||||
<div className="rounded-md border border-border p-2">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Local folder (full path)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||
value={workspaceLocalPath}
|
||||
onChange={(e) => setWorkspaceLocalPath(e.target.value)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="block text-xs text-muted-foreground">Local folder</label>
|
||||
<span className="text-xs text-muted-foreground/50">optional</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<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.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{(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>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
value={workspaceRepoUrl}
|
||||
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||
value={workspaceLocalPath}
|
||||
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workspaceError && (
|
||||
<p className="text-xs text-destructive">{workspaceError}</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
currentUserAssigneeOption,
|
||||
formatAssigneeUserLabel,
|
||||
parseAssigneeValue,
|
||||
suggestedCommentAssigneeValue,
|
||||
} from "./assignees";
|
||||
|
||||
describe("assignee selection helpers", () => {
|
||||
@@ -50,4 +51,42 @@ describe("assignee selection helpers", () => {
|
||||
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
|
||||
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;
|
||||
}
|
||||
|
||||
interface CommentAssigneeSuggestionInput {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
}
|
||||
|
||||
interface CommentAssigneeSuggestionComment {
|
||||
authorAgentId?: string | null;
|
||||
authorUserId?: string | null;
|
||||
}
|
||||
|
||||
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
|
||||
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
|
||||
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
|
||||
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 {
|
||||
if (!value) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
|
||||
@@ -111,6 +111,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
||||
logCompressed: false,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
@@ -289,7 +293,11 @@ describe("inbox helpers", () => {
|
||||
getInboxWorkItems({
|
||||
issues: [olderIssue, newerIssue],
|
||||
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([
|
||||
"issue:1",
|
||||
"approval:approval-between",
|
||||
|
||||
@@ -23,6 +23,11 @@ export type InboxWorkItem =
|
||||
kind: "approval";
|
||||
timestamp: number;
|
||||
approval: Approval;
|
||||
}
|
||||
| {
|
||||
kind: "failed_run";
|
||||
timestamp: number;
|
||||
run: HeartbeatRun;
|
||||
};
|
||||
|
||||
export interface InboxBadgeData {
|
||||
@@ -146,9 +151,11 @@ export function approvalActivityTimestamp(approval: Approval): number {
|
||||
export function getInboxWorkItems({
|
||||
issues,
|
||||
approvals,
|
||||
failedRuns = [],
|
||||
}: {
|
||||
issues: Issue[];
|
||||
approvals: Approval[];
|
||||
failedRuns?: HeartbeatRun[];
|
||||
}): InboxWorkItem[] {
|
||||
return [
|
||||
...issues.map((issue) => ({
|
||||
@@ -161,6 +168,11 @@ export function getInboxWorkItems({
|
||||
timestamp: approvalActivityTimestamp(approval),
|
||||
approval,
|
||||
})),
|
||||
...failedRuns.map((run) => ({
|
||||
kind: "failed_run" as const,
|
||||
timestamp: normalizeTimestamp(run.createdAt),
|
||||
run,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const timestampDiff = b.timestamp - a.timestamp;
|
||||
if (timestampDiff !== 0) return timestampDiff;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents";
|
||||
import {
|
||||
agentsApi,
|
||||
type AgentKey,
|
||||
type ClaudeLoginResult,
|
||||
type AvailableSkill,
|
||||
type AgentPermissionUpdate,
|
||||
} from "../api/agents";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -64,6 +70,7 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type AgentDetail as AgentDetailRecord,
|
||||
type BudgetPolicySummary,
|
||||
type HeartbeatRun,
|
||||
type HeartbeatRunEvent,
|
||||
@@ -486,7 +493,7 @@ export function AgentDetail() {
|
||||
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.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],
|
||||
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
|
||||
enabled: canFetchAgent,
|
||||
@@ -672,8 +679,8 @@ export function AgentDetail() {
|
||||
});
|
||||
|
||||
const updatePermissions = useMutation({
|
||||
mutationFn: (canCreateAgents: boolean) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
|
||||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
@@ -1076,7 +1083,7 @@ function AgentOverview({
|
||||
agentId,
|
||||
agentRouteId,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
runs: HeartbeatRun[];
|
||||
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
|
||||
runtimeState?: AgentRuntimeState;
|
||||
@@ -1233,14 +1240,14 @@ function AgentConfigurePage({
|
||||
onSavingChange,
|
||||
updatePermissions,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
agentId: string;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [revisionsOpen, setRevisionsOpen] = useState(false);
|
||||
@@ -1340,13 +1347,13 @@ function ConfigurationTab({
|
||||
onSavingChange,
|
||||
updatePermissions,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||
@@ -1389,6 +1396,19 @@ function ConfigurationTab({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<AgentConfigForm
|
||||
@@ -1406,21 +1426,62 @@ function ConfigurationTab({
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Permissions</h3>
|
||||
<div className="border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Can create new agents</span>
|
||||
<div className="border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<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
|
||||
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
|
||||
variant={canCreateAgents ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() =>
|
||||
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
|
||||
updatePermissions.mutate({
|
||||
canCreateAgents: !canCreateAgents,
|
||||
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
||||
})
|
||||
}
|
||||
disabled={updatePermissions.isPending}
|
||||
>
|
||||
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
|
||||
{canCreateAgents ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -203,7 +203,7 @@ export function ApprovalDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { IssueRow } from "../components/IssueRow";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -33,12 +33,10 @@ import {
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -64,16 +62,8 @@ type InboxCategoryFilter =
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "join_requests"
|
||||
| "failed_runs"
|
||||
| "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 {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
@@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FailedRunCard({
|
||||
function FailedRunInboxRow({
|
||||
run,
|
||||
issueById,
|
||||
agentName: linkedAgentName,
|
||||
issueLinkState,
|
||||
onDismiss,
|
||||
onRetry,
|
||||
isRetrying,
|
||||
}: {
|
||||
run: HeartbeatRun;
|
||||
issueById: Map<string, Issue>;
|
||||
agentName: string | null;
|
||||
issueLinkState: unknown;
|
||||
onDismiss: () => void;
|
||||
onRetry: () => void;
|
||||
isRetrying: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
const issue = issueId ? issueById.get(issueId) ?? null : null;
|
||||
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
|
||||
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 (
|
||||
<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="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<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"}
|
||||
<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="flex items-start gap-2 sm:items-center">
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<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" />
|
||||
<span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md bg-red-500/20 p-1.5">
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</span>
|
||||
{linkedAgentName ? (
|
||||
<Identity name={linkedAgentName} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
|
||||
{issue ? (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground mr-1.5">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{issue.title}
|
||||
</>
|
||||
) : (
|
||||
<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} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{sourceLabel} run failed {timeAgo(run.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={() => retryRun.mutate()}
|
||||
disabled={retryRun.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{retryRun.isPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
asChild
|
||||
>
|
||||
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
|
||||
Open run
|
||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
|
||||
<span className="truncate max-w-[300px]">{displayError}</span>
|
||||
<span>{timeAgo(run.createdAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={onRetry}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isRetrying ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
|
||||
{displayError}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
{retryRun.isError && (
|
||||
<div className="text-xs text-destructive">
|
||||
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 sm:hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={onRetry}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isRetrying ? "Retrying…" : "Retry"}
|
||||
</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>
|
||||
);
|
||||
@@ -253,7 +206,7 @@ function ApprovalInboxRow({
|
||||
isPending: boolean;
|
||||
}) {
|
||||
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 =
|
||||
approval.type !== "budget_override_required" &&
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||
@@ -473,13 +426,19 @@ export function Inbox() {
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
const failedRunsForTab = useMemo(() => {
|
||||
if (tab === "all" && !showFailedRunsCategory) return [];
|
||||
return failedRuns;
|
||||
}, [failedRuns, tab, showFailedRunsCategory]);
|
||||
|
||||
const workItemsToRender = useMemo(
|
||||
() =>
|
||||
getInboxWorkItems({
|
||||
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||
failedRuns: failedRunsForTab,
|
||||
}),
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
|
||||
);
|
||||
|
||||
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 invalidateInboxIssueQueries = () => {
|
||||
@@ -607,13 +606,6 @@ export function Inbox() {
|
||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||
const showJoinRequestsSection =
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||
const showFailedRunsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasRunFailures,
|
||||
showOnRecent: hasRunFailures,
|
||||
showOnUnread: hasRunFailures,
|
||||
showOnAll: showFailedRunsCategory && hasRunFailures,
|
||||
});
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
@@ -623,7 +615,6 @@ export function Inbox() {
|
||||
});
|
||||
|
||||
const visibleSections = [
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showJoinRequestsSection ? "join_requests" : 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 isUnread = issue.isUnreadForMe && !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 && (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
@@ -206,7 +207,6 @@ export function IssueDetail() {
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
@@ -375,11 +375,15 @@ export function IssueDetail() {
|
||||
return options;
|
||||
}, [agents, currentUserId]);
|
||||
|
||||
const currentAssigneeValue = useMemo(() => {
|
||||
if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`;
|
||||
if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`;
|
||||
return "";
|
||||
}, [issue?.assigneeAgentId, issue?.assigneeUserId]);
|
||||
const actualAssigneeValue = useMemo(
|
||||
() => assigneeValueFromSelection(issue ?? {}),
|
||||
[issue],
|
||||
);
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
||||
[issue, comments, currentUserId],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
@@ -1002,7 +1006,8 @@ export function IssueDetail() {
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
@@ -1055,6 +1060,30 @@ export function IssueDetail() {
|
||||
</TabsContent>
|
||||
|
||||
<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 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
@@ -1123,43 +1152,6 @@ export function IssueDetail() {
|
||||
</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 */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
|
||||
Reference in New Issue
Block a user