mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to organize, govern, and understand AI-agent work > - Operators need concise, current status views across projects and execution workspaces without manually reading every issue and run > - Paperclip already has auditable issues, documents, built-in agents, routines, and live run events, but no first-class summary-slot workflow connecting those systems > - A built-in Summarizer can generate status prose through ordinary governed tasks while summary slots provide stable, revisioned destinations for that output > - The UI needs to show current summaries, generation progress, failures, revisions, and streaming draft status in the places operators already work > - This pull request adds the end-to-end summary-slot data, API, agent, orchestration, and UI surfaces behind an experimental setting > - The benefit is decision-oriented status context that remains company-scoped, auditable, retryable, and inexpensive by default ## Linked Issues or Issue Description No public GitHub issue exists for this feature. **Problem** Operators currently have to reconstruct project and workspace status by reading many issues, runs, and comments. This makes it hard to identify decisions, review queues, recent work, and the next event worth watching. **Proposed capability** Add an experimental summary system with revisioned summary slots for projects and workspaces, a paused-by-default built-in Summarizer agent, governed generation tasks, live draft status, and reusable UI cards. **Expected behavior** - Summary data remains company-scoped and revisions remain auditable. - Generation runs through normal issue/agent orchestration and deduplicates active requests. - Only the linked built-in Summarizer generation task can author a slot revision. - Operators can generate, retry, inspect revisions, and follow draft progress from project and workspace views. - The feature remains opt-in and background generation remains paused by default. ## What Changed - Added summary-slot schema, idempotent migrations, shared contracts, validators, API paths, and service tests. - Added company-scoped summary-slot routes for reading revisions, requesting generation, and guarded Summarizer writes with activity logging. - Added terminal generation finalization, failure reasons, assignment wakeups, and orchestration integration. - Added the paused-by-default built-in Summarizer bundle, low-cost runtime defaults, status-summarization skill, and stale-summary routine. - Added summary cards, revision selection, retry/configuration states, live draft streaming, transcript chunk handling, and project/workspace integrations. - Updated Claude local parsing for streamed status output and expanded server, adapter, shared, database, catalog, and UI coverage. ## Verification - `pnpm -r typecheck` - `pnpm exec vitest run packages/db/src/summary-slots-schema.test.ts packages/shared/src/summary-slot.test.ts server/src/__tests__/summary-slot-routes.test.ts server/src/__tests__/summary-slots.test.ts server/src/__tests__/built-in-agents.test.ts ui/src/components/SummarySlotCard.test.tsx ui/src/components/SummarySlotCard.status.test.tsx ui/src/components/useSummaryDraftStream.test.tsx ui/src/lib/summary-draft-stream.test.ts ui/src/lib/run-log-chunks.test.ts ui/src/context/LiveUpdatesProvider.hook.test.tsx` — 113 tests passed - `pnpm test:run` — server and UI suites passed; one CLI AWS doctor test was affected by inherited `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and passed when those host credentials were removed - `pnpm exec vitest run cli/src/__tests__/secrets.test.ts` with inherited AWS credential variables removed — 8 tests passed - `pnpm build` - `pnpm check:token-gates` currently reports nine `#9627` comment references introduced by current `master`; none are in this PR diff ## Risks - Database risk is limited by incrementally ordered, idempotent migrations and migration safety checks. - Summary generation creates normal issues/runs, so misconfiguration can produce failed slots; the UI exposes retryable failure reasons and agent configuration entry points. - Streaming draft parsing depends on the documented `STATUS:` protocol; final persisted revisions remain the source of truth. - The feature is experimental, opt-in, and its built-in routine is paused with no background token spend by default. > 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 GPT-5.3 Codex with reasoning, repository tool use, code execution, GitHub CLI, and Paperclip control-plane integration. Earlier branch commits also record Claude model co-authorship where applicable. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
8.9 KiB
TypeScript
250 lines
8.9 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Field } from "@/components/agent-config-primitives";
|
|
import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm";
|
|
import { InlineBanner } from "@/components/InlineBanner";
|
|
import { listAdapterOptions } from "@/adapters/metadata";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { ApiError } from "@/api/client";
|
|
import {
|
|
builtInAgentsApi,
|
|
type BuiltInAgentState,
|
|
} from "@/api/builtInAgents";
|
|
|
|
/** Adapters whose config completeness is keyed on a non-empty `model`. */
|
|
function isModelBasedAdapter(adapterType: string): boolean {
|
|
return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType);
|
|
}
|
|
|
|
function defaultAdapterType(state: BuiltInAgentState): string {
|
|
return state.definition.defaultAdapterType ?? state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
|
|
}
|
|
|
|
function parseBudgetMonthlyCents(value: string): number | undefined {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
const cents = Math.round(Number(trimmed) * 100);
|
|
return Number.isFinite(cents) && cents >= 0 ? cents : undefined;
|
|
}
|
|
|
|
export interface ConfigureBuiltInAgentModalProps {
|
|
companyId: string;
|
|
state: BuiltInAgentState;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** Called after a successful provision (e.g. to navigate to the agent). */
|
|
onConfigured?: (result: BuiltInAgentState) => void;
|
|
}
|
|
|
|
/**
|
|
* Configure-on-first-use modal for a built-in agent. Reuses the shared
|
|
* `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker),
|
|
* plus an optional monthly budget, and submits to the provision endpoint.
|
|
*/
|
|
export function ConfigureBuiltInAgentModal({
|
|
companyId,
|
|
state,
|
|
open,
|
|
onOpenChange,
|
|
onConfigured,
|
|
}: ConfigureBuiltInAgentModalProps) {
|
|
const queryClient = useQueryClient();
|
|
const { definition } = state;
|
|
|
|
const [adapterType, setAdapterType] = useState<string>(
|
|
() => state.agent?.adapterType ?? defaultAdapterType(state),
|
|
);
|
|
const [model, setModel] = useState<string>(() => {
|
|
const config = state.agent?.adapterConfig;
|
|
const configuredModel = typeof config === "object" && config !== null
|
|
? (config as Record<string, unknown>).model
|
|
: null;
|
|
if (typeof configuredModel === "string") return configuredModel;
|
|
const defaultModel = state.definition.defaultAdapterConfig?.model;
|
|
return typeof defaultModel === "string" ? defaultModel : "";
|
|
});
|
|
const [modelOpen, setModelOpen] = useState(false);
|
|
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
|
|
const cents = definition.defaultBudgetMonthlyCents ?? 0;
|
|
return cents > 0 ? String(cents / 100) : "";
|
|
});
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Restrict adapter choices to the registry's allow-list. Non-model adapters
|
|
// are still selectable: provisioning creates the row, then full agent config
|
|
// collects command/endpoint fields while the built-in remains `needs_setup`.
|
|
const disabledTypes = useMemo(() => {
|
|
const allowed = new Set(definition.allowedAdapterTypes ?? []);
|
|
return new Set(
|
|
listAdapterOptions()
|
|
.map((option) => option.value)
|
|
.filter((value) => allowed.size > 0 && !allowed.has(value)),
|
|
);
|
|
}, [definition.allowedAdapterTypes]);
|
|
|
|
const setupSupportedInModal = isModelBasedAdapter(adapterType);
|
|
|
|
const { data: fetchedModels } = useQuery({
|
|
queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null),
|
|
queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}),
|
|
enabled: open && Boolean(companyId) && setupSupportedInModal,
|
|
});
|
|
const models = fetchedModels ?? [];
|
|
|
|
const modelRequired = setupSupportedInModal;
|
|
const normalizedModel = model.trim();
|
|
const modelKnown =
|
|
!normalizedModel ||
|
|
models.length === 0 ||
|
|
models.some((candidate) => candidate.id === normalizedModel);
|
|
const modelError = modelKnown
|
|
? null
|
|
: `Model “${normalizedModel}” is not available for ${adapterType}. Choose a known model.`;
|
|
const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars);
|
|
const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined;
|
|
const canSubmit =
|
|
budgetValid &&
|
|
modelKnown &&
|
|
(setupSupportedInModal ? !modelRequired || normalizedModel.length > 0 : true);
|
|
const submitLabel = setupSupportedInModal
|
|
? `Configure & enable ${definition.displayName}`
|
|
: `Provision ${definition.displayName}`;
|
|
|
|
const provision = useMutation({
|
|
mutationFn: async () => {
|
|
const adapterConfig: Record<string, unknown> = {};
|
|
if (model.trim()) adapterConfig.model = model.trim();
|
|
const result = await builtInAgentsApi.provision(companyId, definition.key, {
|
|
adapterType,
|
|
adapterConfig,
|
|
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
|
|
});
|
|
return result;
|
|
},
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
|
if (result.agentId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) });
|
|
}
|
|
onConfigured?.(result);
|
|
onOpenChange(false);
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent.");
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => (provision.isPending ? undefined : onOpenChange(next))}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Set up the {definition.displayName}</DialogTitle>
|
|
<DialogDescription>{definition.shortPurpose}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<InlineBanner tone="info" compact>
|
|
Creates <strong>{definition.displayName}</strong> in your roster, badged{" "}
|
|
<strong>Built-in</strong>. Companies that require hire approval will queue this for the
|
|
board.
|
|
</InlineBanner>
|
|
|
|
<Field label="Adapter type">
|
|
<AdapterTypeDropdown
|
|
value={adapterType}
|
|
onChange={(next) => {
|
|
setAdapterType(next);
|
|
setModel("");
|
|
}}
|
|
disabledTypes={disabledTypes}
|
|
/>
|
|
</Field>
|
|
|
|
{modelRequired && (
|
|
// ModelDropdown supplies its own "Model" Field label + hint.
|
|
<ModelDropdown
|
|
models={models}
|
|
value={model}
|
|
onChange={setModel}
|
|
open={modelOpen}
|
|
onOpenChange={setModelOpen}
|
|
allowDefault={adapterType !== "opencode_local"}
|
|
required
|
|
groupByProvider={false}
|
|
creatable
|
|
/>
|
|
)}
|
|
|
|
{modelError && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{modelError}
|
|
</p>
|
|
)}
|
|
|
|
{!setupSupportedInModal && (
|
|
<InlineBanner tone="warning" compact>
|
|
This adapter needs command or endpoint fields before it can run. Provision the
|
|
built-in row now, then finish those fields from the full agent configuration.
|
|
</InlineBanner>
|
|
)}
|
|
|
|
<Field label="Monthly budget (optional)" hint="Leave blank for no cap.">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">$</span>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
inputMode="decimal"
|
|
placeholder="0"
|
|
value={budgetDollars}
|
|
onChange={(event) => setBudgetDollars(event.target.value)}
|
|
className="w-32"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">/ month</span>
|
|
</div>
|
|
</Field>
|
|
|
|
{error && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={provision.isPending}
|
|
>
|
|
Not now
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setError(null);
|
|
provision.mutate();
|
|
}}
|
|
disabled={!canSubmit || provision.isPending}
|
|
>
|
|
{provision.isPending ? "Configuring…" : submitLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|