mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
## Thinking Path > - Paperclip orchestrates AI agents through issue checkout, heartbeat runs, routines, and auditable control-plane state > - The runtime path has to recover from lost local processes, transient adapter failures, blocked dependencies, and routine coalescing without stranding work > - The existing branch carried several reliability fixes across heartbeat scheduling, issue runtime controls, routine dispatch, and operator-facing run state > - These changes belong together because they share backend contracts, migrations, and runtime status semantics > - This pull request groups the control-plane/runtime slice so it can merge independently from board UI polish and adapter sandbox work > - The benefit is safer heartbeat recovery, clearer runtime controls, and more predictable recurring execution behavior ## What Changed - Adds bounded heartbeat retry scheduling, scheduled retry state, and Codex transient failure recovery handling. - Tightens heartbeat process recovery, blocker wake behavior, issue comment wake handling, routine dispatch coalescing, and activity/dashboard bounds. - Adds runtime-control MCP tools and Paperclip skill docs for issue workspace runtime management. - Adds migrations `0061_lively_thor_girl.sql` and `0062_routine_run_dispatch_fingerprint.sql`. - Surfaces retry state in run ledger/agent UI and keeps related shared types synchronized. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/routines-service.test.ts` - `pnpm exec vitest run src/tools.test.ts` from `packages/mcp-server` ## Risks - Medium risk: this touches heartbeat recovery and routine dispatch, which are central execution paths. - Migration order matters if split branches land out of order: merge this PR before branches that assume the new runtime/routine fields. - Runtime retry behavior should be watched in CI and in local operator smoke tests because it changes how transient failures are resumed. > 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 runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat environment. ## 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
160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { ActivityEvent, Agent } from "@paperclipai/shared";
|
|
import { activityApi } from "../api/activity";
|
|
import { accessApi } from "../api/access";
|
|
import { agentsApi } from "../api/agents";
|
|
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { ActivityRow } from "../components/ActivityRow";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { History } from "lucide-react";
|
|
|
|
const ACTIVITY_PAGE_LIMIT = 200;
|
|
|
|
function detailString(event: ActivityEvent, ...keys: string[]) {
|
|
const details = event.details;
|
|
for (const key of keys) {
|
|
const value = details?.[key];
|
|
if (typeof value === "string" && value.trim()) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function activityEntityName(event: ActivityEvent) {
|
|
if (event.entityType === "issue") return detailString(event, "identifier", "issueIdentifier");
|
|
if (event.entityType === "project") return detailString(event, "projectName", "name", "title");
|
|
if (event.entityType === "goal") return detailString(event, "goalTitle", "title", "name");
|
|
return detailString(event, "name", "title");
|
|
}
|
|
|
|
function activityEntityTitle(event: ActivityEvent) {
|
|
if (event.entityType === "issue") return detailString(event, "issueTitle", "title");
|
|
return null;
|
|
}
|
|
|
|
export function Activity() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const [filter, setFilter] = useState("all");
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Activity" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: [...queryKeys.activity(selectedCompanyId!), { limit: ACTIVITY_PAGE_LIMIT }],
|
|
queryFn: () => activityApi.list(selectedCompanyId!, { limit: ACTIVITY_PAGE_LIMIT }),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: companyMembers } = useQuery({
|
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const userProfileMap = useMemo(
|
|
() => buildCompanyUserProfileMap(companyMembers?.users),
|
|
[companyMembers?.users],
|
|
);
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
const entityNameMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name);
|
|
for (const event of data ?? []) {
|
|
const name = activityEntityName(event);
|
|
if (name) map.set(`${event.entityType}:${event.entityId}`, name);
|
|
}
|
|
return map;
|
|
}, [data, agents]);
|
|
|
|
const entityTitleMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const event of data ?? []) {
|
|
const title = activityEntityTitle(event);
|
|
if (title) map.set(`${event.entityType}:${event.entityId}`, title);
|
|
}
|
|
return map;
|
|
}, [data]);
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={History} message="Select a company to view activity." />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="list" />;
|
|
}
|
|
|
|
const filtered =
|
|
data && filter !== "all"
|
|
? data.filter((e) => e.entityType === filter)
|
|
: data;
|
|
|
|
const entityTypes = data
|
|
? [...new Set(data.map((e) => e.entityType))].sort()
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-end">
|
|
<Select value={filter} onValueChange={setFilter}>
|
|
<SelectTrigger className="w-[140px] h-8 text-xs">
|
|
<SelectValue placeholder="Filter by type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All types</SelectItem>
|
|
{entityTypes.map((type) => (
|
|
<SelectItem key={type} value={type}>
|
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
{filtered && filtered.length === 0 && (
|
|
<EmptyState icon={History} message="No activity yet." />
|
|
)}
|
|
|
|
{filtered && filtered.length > 0 && (
|
|
<div className="border border-border divide-y divide-border">
|
|
{filtered.map((event) => (
|
|
<ActivityRow
|
|
key={event.id}
|
|
event={event}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
entityNameMap={entityNameMap}
|
|
entityTitleMap={entityTitleMap}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|