mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so agent work needs visible ownership, recovery, and operator controls. > - This local branch had accumulated several related control-plane reliability and operator-experience fixes across recovery actions, watchdog folding, model-profile defaults, mentions, markdown editing, plugin launchers, and small UI polish. > - The branch needed to be converted into a PR against the current `origin/master` without losing dirty work or including lockfile/workflow churn. > - The safest standalone shape is a single rollup PR because the recovery/server/UI files overlap heavily across the local commits and splitting would create avoidable conflicts. > - This pull request replays the local branch onto latest `origin/master`, preserves the uncommitted work as logical commits, and adds a Zod 4 validator compatibility fix found during verification. > - The benefit is that the May 17 local branch can be reviewed and merged as one coherent, conflict-free branch under the 100-file Greptile limit. ## What Changed - Rebased the local May 17 branch work onto current `origin/master` in a dedicated worktree. - Preserved and committed previously dirty changes for recovery retry handling, plugin/sidebar launcher polish, and `.herenow` ignores. - Added recovery-action behavior for returning source issues to `todo` when retrying source-scoped recovery. - Included the existing local recovery/liveness/watchdog fold, Codex cheap-profile, markdown/mention, duplicate-agent, and UI polish commits from the branch. - Normalized shared validator `z.record(...)` schemas to explicit string-key records for Zod 4 compatibility. - Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*` changes and stays below the 100-file Greptile limit. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `npm run install` in `node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the local native sqlite3 binding after installing with scripts disabled - `pnpm exec vitest run packages/shared/src/validators/issue.test.ts packages/shared/src/project-mentions.test.ts packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/heartbeat-model-profile.test.ts server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts server/src/__tests__/plugin-local-folders.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/SidebarAccountMenu.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/duplicate-agent-payload.test.ts ui/src/pages/Routines.test.tsx` - First pass: 13 files passed with 201 passing tests; 3 server files failed before sqlite3 native binding was built. - After rebuilding sqlite3: `server/src/__tests__/heartbeat-model-profile.test.ts`, `server/src/__tests__/issue-recovery-actions.test.ts`, and `server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts` passed/loaded; embedded Postgres tests were skipped by the local host guard. - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Medium risk: this is a broad rollup PR across recovery semantics, server tests, shared validators, and UI surfaces. - Some embedded Postgres tests skipped locally due the host guard, so CI should provide the stronger database-backed signal. - UI changes were covered by component tests, but no browser screenshot was captured in this PR creation pass. - This branch may overlap with existing recovery/liveness PR work; merge this PR independently or restack/close overlapping branches rather than merging duplicate implementations together. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository and GitHub workflow, medium reasoning effort. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import type { Agent } from "@paperclipai/shared";
|
|
import type { CompanyUserProfile } from "./company-members";
|
|
|
|
type ActivityDetails = Record<string, unknown> | null | undefined;
|
|
|
|
type ActivityParticipant = {
|
|
type: "agent" | "user";
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
};
|
|
|
|
type ActivityIssueReference = {
|
|
id?: string | null;
|
|
identifier?: string | null;
|
|
title?: string | null;
|
|
};
|
|
|
|
interface ActivityFormatOptions {
|
|
agentMap?: Map<string, Agent>;
|
|
userProfileMap?: Map<string, CompanyUserProfile>;
|
|
currentUserId?: string | null;
|
|
}
|
|
|
|
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|
"issue.created": "created",
|
|
"issue.updated": "updated",
|
|
"issue.checked_out": "checked out",
|
|
"issue.released": "released",
|
|
"issue.comment_added": "commented on",
|
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
|
"issue.attachment_added": "attached file to",
|
|
"issue.attachment_removed": "removed attachment from",
|
|
"issue.document_created": "created document for",
|
|
"issue.document_updated": "updated document on",
|
|
"issue.document_locked": "locked document on",
|
|
"issue.document_unlocked": "unlocked document on",
|
|
"issue.document_deleted": "deleted document from",
|
|
"issue.monitor_scheduled": "scheduled monitor on",
|
|
"issue.monitor_triggered": "triggered monitor for",
|
|
"issue.monitor_cleared": "cleared monitor on",
|
|
"issue.monitor_skipped": "skipped monitor for",
|
|
"issue.monitor_exhausted": "exhausted monitor on",
|
|
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
|
|
"issue.monitor_recovery_issue_created": "created monitor recovery for",
|
|
"issue.monitor_escalated_to_board": "escalated monitor for",
|
|
"issue.commented": "commented on",
|
|
"issue.deleted": "deleted",
|
|
"issue.successful_run_handoff_required": "flagged missing next step on",
|
|
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
|
|
"issue.successful_run_handoff_escalated": "escalated missing next step on",
|
|
"issue.recovery_action_opened": "opened a recovery action on",
|
|
"issue.recovery_action_resolved": "resolved the recovery action on",
|
|
"issue.recovery_action_escalated": "escalated the recovery action on",
|
|
"agent.created": "created",
|
|
"agent.updated": "updated",
|
|
"agent.paused": "paused",
|
|
"agent.resumed": "resumed",
|
|
"agent.terminated": "terminated",
|
|
"agent.key_created": "created API key for",
|
|
"agent.budget_updated": "updated budget for",
|
|
"agent.runtime_session_reset": "reset session for",
|
|
"heartbeat.invoked": "invoked heartbeat for",
|
|
"heartbeat.cancelled": "cancelled heartbeat for",
|
|
"heartbeat.output_stale_source_resolved": "system-folded stale run on",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "refused recovery-on-recovery for",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
"project.created": "created",
|
|
"project.updated": "updated",
|
|
"project.deleted": "deleted",
|
|
"goal.created": "created",
|
|
"goal.updated": "updated",
|
|
"goal.deleted": "deleted",
|
|
"cost.reported": "reported cost for",
|
|
"cost.recorded": "recorded cost for",
|
|
"company.created": "created company",
|
|
"company.updated": "updated company",
|
|
"company.archived": "archived",
|
|
"company.budget_updated": "updated budget for",
|
|
};
|
|
|
|
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|
"issue.created": "created the issue",
|
|
"issue.updated": "updated the issue",
|
|
"issue.checked_out": "checked out the issue",
|
|
"issue.released": "released the issue",
|
|
"issue.comment_added": "added a comment",
|
|
"issue.comment_cancelled": "cancelled a queued comment",
|
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
|
"issue.attachment_added": "added an attachment",
|
|
"issue.attachment_removed": "removed an attachment",
|
|
"issue.document_created": "created a document",
|
|
"issue.document_updated": "updated a document",
|
|
"issue.document_locked": "locked a document",
|
|
"issue.document_unlocked": "unlocked a document",
|
|
"issue.document_deleted": "deleted a document",
|
|
"issue.monitor_scheduled": "scheduled a monitor",
|
|
"issue.monitor_triggered": "triggered a monitor",
|
|
"issue.monitor_cleared": "cleared a monitor",
|
|
"issue.monitor_skipped": "skipped a monitor",
|
|
"issue.monitor_exhausted": "exhausted a monitor",
|
|
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
|
|
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
|
|
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
|
|
"issue.deleted": "deleted the issue",
|
|
"issue.successful_run_handoff_required": "Run finished without a clear next step",
|
|
"issue.successful_run_handoff_resolved": "Next step chosen",
|
|
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
|
|
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
|
"issue.recovery_action_resolved": "Resolved the recovery action",
|
|
"issue.recovery_action_escalated": "Escalated the recovery action",
|
|
"agent.created": "created an agent",
|
|
"agent.updated": "updated the agent",
|
|
"agent.paused": "paused the agent",
|
|
"agent.resumed": "resumed the agent",
|
|
"agent.terminated": "terminated the agent",
|
|
"heartbeat.invoked": "invoked a heartbeat",
|
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
|
"heartbeat.output_stale_source_resolved": "System folded a stale run",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "Refused recovery-on-recovery escalation",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function humanizeValue(value: unknown): string {
|
|
if (typeof value !== "string") return String(value ?? "none");
|
|
return value.replace(/_/g, " ");
|
|
}
|
|
|
|
function isActivityParticipant(value: unknown): value is ActivityParticipant {
|
|
const record = asRecord(value);
|
|
if (!record) return false;
|
|
return record.type === "agent" || record.type === "user";
|
|
}
|
|
|
|
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
|
|
return asRecord(value) !== null;
|
|
}
|
|
|
|
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityParticipant);
|
|
}
|
|
|
|
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityIssueReference);
|
|
}
|
|
|
|
function formatUserLabel(userId: string | null | undefined, options: ActivityFormatOptions = {}): string {
|
|
if (!userId || userId === "local-board") return "Board";
|
|
if (options.currentUserId && userId === options.currentUserId) return "You";
|
|
const profile = options.userProfileMap?.get(userId);
|
|
if (profile) return profile.label;
|
|
return `user ${userId.slice(0, 5)}`;
|
|
}
|
|
|
|
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
|
|
if (participant.type === "agent") {
|
|
const agentId = participant.agentId ?? "";
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
return formatUserLabel(participant.userId, options);
|
|
}
|
|
|
|
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
|
if (reference.identifier) return reference.identifier;
|
|
if (reference.title) return reference.title;
|
|
if (reference.id) return reference.id.slice(0, 8);
|
|
return "issue";
|
|
}
|
|
|
|
function formatChangedEntityLabel(
|
|
singular: string,
|
|
plural: string,
|
|
labels: string[],
|
|
): string {
|
|
if (labels.length <= 0) return plural;
|
|
if (labels.length === 1) return `${singular} ${labels[0]}`;
|
|
return `${labels.length} ${plural}`;
|
|
}
|
|
|
|
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
return from
|
|
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
|
: `changed status to ${humanizeValue(details.status)} on`;
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
return from
|
|
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
|
: `changed priority to ${humanizeValue(details.priority)} on`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null {
|
|
if (!details) return null;
|
|
const agentId = details.assigneeAgentId;
|
|
const userId = details.assigneeUserId;
|
|
if (typeof agentId === "string" && agentId) {
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
if (typeof userId === "string" && userId) {
|
|
return formatUserLabel(userId, options);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
const parts: string[] = [];
|
|
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
parts.push(
|
|
from
|
|
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
|
: `changed the status to ${humanizeValue(details.status)}`,
|
|
);
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
parts.push(
|
|
from
|
|
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
|
: `changed the priority to ${humanizeValue(details.priority)}`,
|
|
);
|
|
}
|
|
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
|
const assigneeName = formatAssigneeName(details, options);
|
|
parts.push(assigneeName ? `assigned the issue to ${assigneeName}` : "unassigned the issue");
|
|
}
|
|
if (details.title !== undefined) parts.push("updated the title");
|
|
if (details.description !== undefined) parts.push("updated the description");
|
|
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
function formatStructuredIssueChange(input: {
|
|
action: string;
|
|
details: ActivityDetails;
|
|
options: ActivityFormatOptions;
|
|
forIssueDetail: boolean;
|
|
}): string | null {
|
|
const details = input.details;
|
|
if (!details) return null;
|
|
|
|
if (input.action === "issue.blockers_updated") {
|
|
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
|
|
}
|
|
|
|
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
|
|
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
|
|
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function formatActivityVerb(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
|
if (issueUpdatedVerb) return issueUpdatedVerb;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: false,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|
|
|
|
export function formatIssueActivityAction(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedAction = formatIssueUpdatedAction(details, options);
|
|
if (issueUpdatedAction) return issueUpdatedAction;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: true,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
if (action.startsWith("issue.monitor_") && details) {
|
|
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
|
? details.serviceName.trim()
|
|
: null;
|
|
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
return serviceName ? `${base} for ${serviceName}` : base;
|
|
}
|
|
|
|
if (
|
|
(
|
|
action === "issue.document_created" ||
|
|
action === "issue.document_updated" ||
|
|
action === "issue.document_locked" ||
|
|
action === "issue.document_unlocked" ||
|
|
action === "issue.document_deleted"
|
|
) &&
|
|
details
|
|
) {
|
|
const key = typeof details.key === "string" ? details.key : "document";
|
|
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
|
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
|
|
}
|
|
|
|
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|