forked from farhoodlabs/paperclip
16b2b84d84
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The heartbeat runtime, agent import path, and agent configuration defaults determine whether work is dispatched safely and predictably. > - Several accumulated fixes all touched agent execution recovery, wake routing, import behavior, and runtime concurrency defaults. > - Those changes need to land together so the heartbeat service and agent creation defaults stay internally consistent. > - This pull request groups the runtime/governance changes from the split branch into one standalone branch. > - The benefit is safer recovery for stranded runs, bounded high-volume reads, imported-agent approval correctness, skill-template support, and a clearer default concurrency policy. ## What Changed - Fixed stranded continuation recovery so successful automatic retries are requeued instead of incorrectly blocking the issue. - Bounded high-volume issue/log reads across issue, heartbeat, agent, project, and workspace paths. - Fixed imported-agent approval and instruction-path permission handling. - Quarantined seeded worktree execution state during worktree provisioning. - Queued approval follow-up wakes and hardened SQL_ASCII heartbeat output handling. - Added reusable agent instruction templates for hiring flows. - Set the default max concurrent agent runs to five and updated related UI/tests/docs. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/company-portability.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-comment-wake-batching.test.ts server/src/__tests__/heartbeat-list.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/agent-permissions-routes.test.ts packages/adapter-utils/src/server-utils.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - Split integration check: merged this branch first, followed by the other [PAP-1614](/PAP/issues/PAP-1614) branches, with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches heartbeat recovery, queueing, and issue list bounds in central runtime paths. - Imported-agent and concurrency default behavior changes may affect existing automation that assumes one-at-a-time default runs. - No database migrations are included. > 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.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## 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 - [x] 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>
213 lines
6.4 KiB
TypeScript
213 lines
6.4 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { accessApi } from "../api/access";
|
|
import { ApiError } from "../api/client";
|
|
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { authApi } from "../api/auth";
|
|
import { dashboardApi } from "../api/dashboard";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { issuesApi } from "../api/issues";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import {
|
|
buildInboxDismissedAtByKey,
|
|
computeInboxBadgeData,
|
|
getRecentTouchedIssues,
|
|
loadDismissedInboxAlerts,
|
|
saveDismissedInboxAlerts,
|
|
loadReadInboxItems,
|
|
saveReadInboxItems,
|
|
READ_ITEMS_KEY,
|
|
} from "../lib/inbox";
|
|
|
|
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
|
const INBOX_BADGE_ISSUE_LIMIT = 500;
|
|
const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200;
|
|
|
|
export function useDismissedInboxAlerts() {
|
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
|
|
|
useEffect(() => {
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
|
setDismissed(loadDismissedInboxAlerts());
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, []);
|
|
|
|
const dismiss = (id: string) => {
|
|
setDismissed((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
saveDismissedInboxAlerts(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return { dismissed, dismiss };
|
|
}
|
|
|
|
export function useInboxDismissals(companyId: string | null | undefined) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = companyId
|
|
? queryKeys.inboxDismissals(companyId)
|
|
: ["inbox-dismissals", "__disabled__"] as const;
|
|
|
|
const { data: dismissals = [] } = useQuery({
|
|
queryKey,
|
|
queryFn: () => inboxDismissalsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const dismissMutation = useMutation({
|
|
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
|
onMutate: async ({ itemKey }) => {
|
|
if (!companyId) return { previous: [] as typeof dismissals };
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
|
const now = new Date();
|
|
queryClient.setQueryData(queryKey, [
|
|
{
|
|
id: `optimistic:${itemKey}`,
|
|
companyId,
|
|
userId: "me",
|
|
itemKey,
|
|
dismissedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
|
]);
|
|
return { previous };
|
|
},
|
|
onError: (_error, _variables, context) => {
|
|
if (!context) return;
|
|
queryClient.setQueryData(queryKey, context.previous);
|
|
},
|
|
onSettled: () => {
|
|
if (!companyId) return;
|
|
queryClient.invalidateQueries({ queryKey });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
|
},
|
|
});
|
|
|
|
const dismissedAtByKey = useMemo(
|
|
() => buildInboxDismissedAtByKey(dismissals),
|
|
[dismissals],
|
|
);
|
|
|
|
return {
|
|
dismissals,
|
|
dismissedAtByKey,
|
|
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
|
isPending: dismissMutation.isPending,
|
|
};
|
|
}
|
|
|
|
export function useReadInboxItems() {
|
|
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
|
|
|
useEffect(() => {
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== READ_ITEMS_KEY) return;
|
|
setReadItems(loadReadInboxItems());
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, []);
|
|
|
|
const markRead = (id: string) => {
|
|
setReadItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
saveReadInboxItems(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const markUnread = (id: string) => {
|
|
setReadItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
saveReadInboxItems(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return { readItems, markRead, markUnread };
|
|
}
|
|
|
|
export function useInboxBadge(companyId: string | null | undefined) {
|
|
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
|
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
|
|
const { data: approvals = [] } = useQuery({
|
|
queryKey: queryKeys.approvals.list(companyId!),
|
|
queryFn: () => approvalsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const { data: joinRequests = [] } = useQuery({
|
|
queryKey: queryKeys.access.joinRequests(companyId!),
|
|
queryFn: async () => {
|
|
try {
|
|
return await accessApi.listJoinRequests(companyId!, "pending_approval");
|
|
} catch (err) {
|
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
enabled: !!companyId,
|
|
retry: false,
|
|
});
|
|
|
|
const { data: dashboard } = useQuery({
|
|
queryKey: queryKeys.dashboard(companyId!),
|
|
queryFn: () => dashboardApi.summary(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const { data: mineIssuesRaw = [] } = useQuery({
|
|
queryKey: queryKeys.issues.listMineByMe(companyId!),
|
|
queryFn: () =>
|
|
issuesApi.list(companyId!, {
|
|
touchedByUserId: "me",
|
|
inboxArchivedByUserId: "me",
|
|
status: INBOX_ISSUE_STATUSES,
|
|
limit: INBOX_BADGE_ISSUE_LIMIT,
|
|
}),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
|
|
|
const { data: heartbeatRuns = [] } = useQuery({
|
|
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
|
|
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
return useMemo(
|
|
() =>
|
|
computeInboxBadgeData({
|
|
approvals,
|
|
joinRequests,
|
|
dashboard,
|
|
heartbeatRuns,
|
|
mineIssues,
|
|
dismissedAlerts,
|
|
dismissedAtByKey,
|
|
currentUserId,
|
|
}),
|
|
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey, currentUserId],
|
|
);
|
|
}
|