Files
paperclip/ui/storybook/stories/agent-management.stories.tsx
T
Jannes Stubbemann 4ad94d0bde feat(server): kubernetes execution integration for sandbox-provider plugins (stage 2/3) (#7938)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The execution subsystem runs those agents in environments (local,
ssh, sandbox), and sandbox-provider plugins let an environment
materialize per-run sandboxes
> - Stage 1 (#5790) contributed a first-party Kubernetes
sandbox-provider plugin, but the server core has no way to adopt it
operationally: no per-run adapter selection, no way to force an instance
onto sandboxed execution, no declarative adapter/model configuration,
and the plugin must be installed by hand
> - Without this, a multi-tenant or security-conscious deployment cannot
guarantee that agent runs never execute on the host, and a single
environment cannot serve agents with different harnesses
> - This pull request adds the server + SDK integration: per-run
adapterType on the lease protocol, an env-gated forced-Kubernetes
execution policy with provisioning and a per-run allowlist guard, a
declarative adapter registry and model list, in-cluster env passthrough
for sandbox plugin workers, fail-safe auto-install of the bundled
plugin, and the matching UI affordance
> - The benefit is that sandbox-provider plugins become fully usable for
Kubernetes execution: operators configure everything via environment
variables and GitOps, while self-hosters who set none of the variables
see exactly the behavior they have today

## Linked Issues or Issue Description

Refs #5790 (stage 1 of 3: the Kubernetes sandbox-provider plugin
package).

No existing issue. Feature description: the server core lacks the
integration seams to operate a sandbox-provider plugin as the mandatory
execution path of an instance. This PR is stage 2 of 3 of the staged
Kubernetes contribution; stage 3 will contribute the agent runtime
images and their build pipeline.

## What Changed

One line per piece:

- `packages/plugins/sdk/protocol.ts`: optional `adapterType` on
`PluginEnvironmentAcquireLeaseParams` so a provider can select the
runtime image per run; existing providers simply ignore it
- `server/services/environment-runtime.ts` +
`environment-run-orchestrator.ts`: thread the agent's adapter type into
both lease-acquiring drivers, including the heartbeat path (the two call
sites have historically drifted, hence the pinned test)
- `server/services/environments.ts`: `ensureKubernetesEnvironment` /
`findKubernetesEnvironment`, an idempotent managed Kubernetes
environment per company, identified by a metadata marker and refreshed
(not recreated) on config change; `timeoutMs` rides on the config for
slow cold-start leases
- `server/services/execution-allowlist.ts`: pure (driver, provider,
policy) -> allow/deny guard; `executionMode=kubernetes` only allows the
kubernetes sandbox provider
- `server/services/execution-policy-bootstrap.ts` + startup hook in
`server/index.ts`: parse `PAPERCLIP_EXECUTION_MODE` / `PAPERCLIP_K8S_*`,
persist `executionMode` into instance general settings, and provision
the managed environment for every company; fails loud on
misconfiguration
- `server/services/heartbeat.ts`: when the policy forces Kubernetes, pin
run selection to the managed environment (also overriding any persisted
workspace environment id), refuse to fall back to local, and re-check
the actually acquired environment against the allowlist as defense in
depth
- `server/services/adapter-registry-bootstrap.ts` + shared
`AdapterRegistryEntry` type/validator: declarative `PAPERCLIP_ADAPTERS`
registry (inline JSON or file) that reconciles adapter availability at
startup and rides on the Kubernetes environment config
- `server/services/adapter-models-env.ts` + `adapters/registry.ts`:
`PAPERCLIP_ADAPTER_MODELS` lets an operator declare picker model lists
the server cannot CLI-discover
- `server/services/plugin-loader.ts`: pass
`KUBERNETES_SERVICE_HOST/PORT(_HTTPS)` through to plugin workers that
register environment drivers, so in-cluster API clients can be
constructed; all other host env stays stripped
- `server/app.ts`: fail-safe auto-install of the bundled kubernetes
plugin at boot; no-ops when the bundle is absent and never blocks
startup on error
- `packages/shared` types/validators: `InstanceExecutionMode` on general
settings (optional, strict schema)
- `ui/lib/forced-kubernetes-environment.ts` + `AgentConfigForm`: when
the policy is active, show a read-only Kubernetes environment instead of
the environment picker and default new agents onto the managed
environment
- Tests for every new module plus the adapterType pin in
`heartbeat-plugin-environment` and the managed-environment lifecycle in
`environment-service`

Everything is gated: with `PAPERCLIP_EXECUTION_MODE`,
`PAPERCLIP_ADAPTERS`, and `PAPERCLIP_ADAPTER_MODELS` unset (and no
bundled plugin present), every code path reduces to current behavior.
The per-run `adapterType` is an optional SDK parameter that existing
providers ignore.

## Verification

- `cd server && npx tsc --noEmit`: clean (0 errors); `ui` typecheck also
clean
- Targeted suites all green (11 files, 90 tests): `npx vitest run
server/src/__tests__/heartbeat-plugin-environment.test.ts
server/src/__tests__/environment-service.test.ts
server/src/__tests__/environment-runtime.test.ts
server/src/__tests__/environment-run-orchestrator.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/services/execution-policy-bootstrap.test.ts
server/src/services/execution-allowlist.test.ts
server/src/services/adapter-registry-bootstrap.test.ts
server/src/services/adapter-registry-bootstrap.reconcile.test.ts
server/src/services/adapter-models-env.test.ts
packages/shared/src/validators/adapter-registry.test.ts`
- `npx vitest run ui/src/components/AgentConfigForm.test.ts`: green (6
tests)
- Full `npx vitest run server/src/__tests__`: 2323 passed, 1 skipped;
the only failures (heartbeat-process-recovery pid-retry,
workspace-runtime symbolic-ref/git tests) reproduce identically on
pristine `master` in the same environment, so they are
machine-environment issues unrelated to this change;
`server-startup-feedback-export` needed its `services/index.js` mock
extended with the new export and is green
- This integration has been running in production on a hosted
multi-tenant deployment, where it executes agent runs across five
different harnesses through the stage 1 plugin

## Risks

- Low for existing deployments: every behavior is env-gated and the
defaults preserve current semantics; the auto-install block is wrapped
fail-safe and skips silently when the plugin bundle is absent
- `executionMode` is a new optional field on a strict zod schema; absent
input normalizes exactly as before
- The forced policy intentionally fails runs loudly (rather than falling
back to local) when no managed Kubernetes environment exists; this only
affects instances that explicitly set
`PAPERCLIP_EXECUTION_MODE=kubernetes`

## Model Used

Claude Opus 4.8 (claude-opus-4-8, 1M context), extended thinking,
agentic tool use via Claude Code.

## UI screenshots

The UI change is a new read-only "Execution" section in
`AgentConfigForm`, shown only when the instance execution policy forces
Kubernetes (`executionMode=kubernetes`); there is no "before" state for
it (the section did not exist, and instances without the forced policy
render the existing picker unchanged). Captured from the new Storybook
stories added in this PR (`Product/Agent Management`):

Managed Kubernetes environment present (read-only display, no local/SSH
picker):

![AgentConfigForm with forced Kubernetes
execution](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes.png)

No managed environment available yet (warning notice, no silent local
fallback):

![AgentConfigForm forced Kubernetes, missing environment
warning](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes-missing-env.png)

## 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 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:09:02 -07:00

863 lines
29 KiB
TypeScript

import { useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useQueryClient } from "@tanstack/react-query";
import { Edit3, RotateCcw, Settings2 } from "lucide-react";
import {
AGENT_ICON_NAMES,
type Agent,
type AgentRuntimeState,
type CompanySecret,
type EnvBinding,
type Environment,
} from "@paperclipai/shared";
import { ActiveAgentsPanel } from "@/components/ActiveAgentsPanel";
import { AgentConfigForm, type CreateConfigValues } from "@/components/AgentConfigForm";
import { defaultCreateValues } from "@/components/agent-config-defaults";
import {
DraftInput,
DraftTextarea,
Field,
ToggleField,
help,
} from "@/components/agent-config-primitives";
import { AgentIcon, AgentIconPicker } from "@/components/AgentIconPicker";
import { AgentProperties } from "@/components/AgentProperties";
import { RunButton, PauseResumeButton } from "@/components/AgentActionButtons";
import type { LiveRunForIssue } from "@/api/heartbeats";
import type { AdapterInfo } from "@/api/adapters";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { storybookAgents, storybookIssues } from "../fixtures/paperclipData";
const COMPANY_ID = "company-storybook";
const now = new Date("2026-04-20T12:00:00.000Z");
const recent = (minutesAgo: number) => new Date(now.getTime() - minutesAgo * 60_000);
function Section({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: ReactNode;
}) {
return (
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label">{eyebrow}</div>
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function agentWith(overrides: Partial<Agent>): Agent {
return {
...storybookAgents[0]!,
...overrides,
adapterConfig: {
...storybookAgents[0]!.adapterConfig,
...(overrides.adapterConfig ?? {}),
},
runtimeConfig: {
...storybookAgents[0]!.runtimeConfig,
...(overrides.runtimeConfig ?? {}),
},
permissions: {
...storybookAgents[0]!.permissions,
...(overrides.permissions ?? {}),
},
metadata: overrides.metadata ?? storybookAgents[0]!.metadata,
};
}
const agentManagementAgents: Agent[] = [
agentWith({
id: "agent-codex",
name: "CodexCoder",
urlKey: "codexcoder",
status: "running",
icon: "code",
role: "engineer",
title: "Senior Product Engineer",
reportsTo: "agent-cto",
capabilities: "Owns full-stack product changes, Storybook coverage, and local verification loops.",
adapterType: "codex_local",
adapterConfig: {
command: "codex",
model: "gpt-5.4",
modelReasoningEffort: "high",
search: true,
dangerouslyBypassApprovalsAndSandbox: true,
promptTemplate:
"You are {{ agent.name }}. Work only on the checked-out issue, keep comments concise, and verify before handoff.",
instructionsFilePath: "agents/codexcoder/AGENTS.md",
extraArgs: ["--full-auto"],
env: {
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
PAPERCLIP_TRACE: { type: "plain", value: "storybook" },
} satisfies Record<string, EnvBinding>,
timeoutSec: 7200,
graceSec: 20,
},
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 900,
wakeOnDemand: true,
cooldownSec: 30,
maxConcurrentRuns: 2,
},
},
lastHeartbeatAt: recent(2),
updatedAt: recent(2),
}),
agentWith({
id: "agent-qa",
name: "QAChecker",
urlKey: "qachecker",
status: "idle",
icon: "shield",
role: "qa",
title: "QA Engineer",
reportsTo: "agent-cto",
capabilities: "Runs targeted browser checks, release smoke tests, and visual Storybook reviews.",
adapterType: "claude_local",
adapterConfig: {
command: "claude",
model: "claude-sonnet-4.5",
effort: "medium",
dangerouslySkipPermissions: false,
chrome: true,
instructionsFilePath: "agents/qachecker/AGENTS.md",
env: {
PLAYWRIGHT_HEADLESS: { type: "plain", value: "false" },
} satisfies Record<string, EnvBinding>,
},
runtimeConfig: {
heartbeat: {
enabled: false,
intervalSec: 1800,
wakeOnDemand: true,
cooldownSec: 60,
maxConcurrentRuns: 1,
},
},
lastHeartbeatAt: recent(31),
updatedAt: recent(31),
}),
agentWith({
id: "agent-cto",
name: "CTO",
urlKey: "cto",
status: "paused",
icon: "crown",
role: "cto",
title: "CTO",
reportsTo: null,
capabilities: "Reviews engineering strategy, architecture risk, and high-impact implementation tradeoffs.",
adapterType: "codex_local",
pauseReason: "manual",
pausedAt: recent(18),
permissions: { canCreateAgents: true },
adapterConfig: {
command: "codex",
model: "gpt-5.4",
modelReasoningEffort: "xhigh",
search: false,
},
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: false,
cooldownSec: 120,
maxConcurrentRuns: 1,
},
},
lastHeartbeatAt: recent(57),
updatedAt: recent(18),
}),
agentWith({
id: "agent-observability",
name: "OpsWatch",
urlKey: "opswatch",
status: "error",
icon: "radar",
role: "devops",
title: "Runtime Operations Engineer",
reportsTo: "agent-cto",
capabilities: "Monitors local runners, workspace services, and stuck-run recovery signals.",
adapterType: "http",
pauseReason: null,
pausedAt: null,
adapterConfig: {
webhookUrl: "https://ops.internal.example/heartbeat",
payloadTemplateJson: JSON.stringify({ channel: "paperclip-storybook", priority: "normal" }, null, 2),
env: {
OPS_WEBHOOK_TOKEN: { type: "secret_ref", secretId: "secret-ops-webhook", version: 3 },
} satisfies Record<string, EnvBinding>,
},
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 600,
wakeOnDemand: true,
cooldownSec: 45,
maxConcurrentRuns: 1,
},
},
lastHeartbeatAt: recent(9),
updatedAt: recent(9),
}),
];
const runtimeState: AgentRuntimeState = {
agentId: "agent-codex",
companyId: COMPANY_ID,
adapterType: "codex_local",
sessionId: "session-codex-storybook-management-20260420",
sessionDisplayId: "codex-storybook-20260420",
sessionParamsJson: {
issueIdentifier: "PAP-1670",
workspaceStrategy: "git_worktree",
},
stateJson: {
currentIssue: "PAP-1670",
workspace: "PAP-1641-create-super-detailed-storybooks-for-our-project",
},
lastRunId: "run-agent-management-live",
lastRunStatus: "running",
totalInputTokens: 286_400,
totalOutputTokens: 42_900,
totalCachedInputTokens: 113_200,
totalCostCents: 4320,
lastError: "Previous run lost its Storybook Vite websocket after a local server restart.",
createdAt: recent(8_000),
updatedAt: recent(2),
};
const storybookSecrets: CompanySecret[] = [
{
id: "secret-openai",
companyId: COMPANY_ID,
key: "openai-api-key",
name: "OPENAI_API_KEY",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 5,
description: "Primary coding model key for local Codex agents.",
lastResolvedAt: recent(600),
lastRotatedAt: recent(21_000),
deletedAt: null,
createdByAgentId: null,
createdByUserId: "user-board",
createdAt: recent(21_000),
updatedAt: recent(400),
},
{
id: "secret-ops-webhook",
companyId: COMPANY_ID,
key: "ops-webhook-token",
name: "OPS_WEBHOOK_TOKEN",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 3,
description: "Webhook token for runtime observability callbacks.",
lastResolvedAt: recent(120),
lastRotatedAt: recent(12_000),
deletedAt: null,
createdByAgentId: "agent-cto",
createdByUserId: null,
createdAt: recent(12_000),
updatedAt: recent(80),
},
];
const adapterFixtures: AdapterInfo[] = [
{
type: "codex_local",
label: "Codex Local",
source: "builtin",
modelsCount: 3,
loaded: true,
disabled: false,
capabilities: {
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: true,
supportsModelProfiles: true,
},
},
{
type: "claude_local",
label: "Claude Local",
source: "builtin",
modelsCount: 2,
loaded: true,
disabled: false,
capabilities: {
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: true,
supportsModelProfiles: true,
},
},
{
type: "http",
label: "HTTP Webhook",
source: "builtin",
modelsCount: 0,
loaded: true,
disabled: false,
capabilities: {
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: false,
},
},
];
const liveRuns: LiveRunForIssue[] = [
{
id: "run-agent-management-live",
status: "running",
invocationSource: "assignment",
triggerDetail: "issue_assigned",
startedAt: recent(8).toISOString(),
finishedAt: null,
createdAt: recent(8).toISOString(),
agentId: "agent-codex",
agentName: "CodexCoder",
adapterType: "codex_local",
issueId: "issue-storybook-1",
livenessState: "advanced",
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: recent(1).toISOString(),
nextAction: "Run a targeted Storybook static build.",
},
{
id: "run-agent-management-queued",
status: "queued",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: null,
finishedAt: null,
createdAt: recent(3).toISOString(),
agentId: "agent-qa",
agentName: "QAChecker",
adapterType: "claude_local",
issueId: "issue-storybook-3",
livenessState: null,
livenessReason: "Waiting for current visual review to finish.",
continuationAttempt: 0,
lastUsefulActionAt: null,
nextAction: "Open the Storybook preview and capture mobile screenshots.",
},
{
id: "run-agent-management-succeeded",
status: "succeeded",
invocationSource: "timer",
triggerDetail: "scheduler",
startedAt: recent(48).toISOString(),
finishedAt: recent(39).toISOString(),
createdAt: recent(48).toISOString(),
agentId: "agent-cto",
agentName: "CTO",
adapterType: "codex_local",
issueId: "issue-storybook-2",
livenessState: "completed",
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: recent(39).toISOString(),
nextAction: null,
},
{
id: "run-agent-management-failed",
status: "failed",
invocationSource: "automation",
triggerDetail: "routine",
startedAt: recent(76).toISOString(),
finishedAt: recent(70).toISOString(),
createdAt: recent(76).toISOString(),
agentId: "agent-observability",
agentName: "OpsWatch",
adapterType: "http",
issueId: null,
livenessState: "blocked",
livenessReason: "Webhook returned 503 during local runtime restart.",
continuationAttempt: 1,
lastUsefulActionAt: recent(72).toISOString(),
nextAction: "Retry after runtime service health check recovers.",
},
];
function StorybookQueryFixtures({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), agentManagementAgents);
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
queryClient.setQueryData(queryKeys.adapters.all, adapterFixtures);
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
queryClient.setQueryData([...queryKeys.issues.list(COMPANY_ID), "with-routine-executions"], storybookIssues);
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard"], liveRuns);
queryClient.setQueryData(queryKeys.instance.generalSettings, { censorUsernameInLogs: false });
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini" },
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
]);
queryClient.setQueryData(queryKeys.agents.detectModel(COMPANY_ID, "codex_local"), {
model: "gpt-5.4",
provider: "openai",
source: "config",
candidates: ["gpt-5.4", "gpt-5.4-mini"],
});
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "claude_local"), [
{ id: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" },
{ id: "claude-opus-4.1", label: "Claude Opus 4.1" },
]);
return children;
}
function AgentConfigFormStory() {
const [values, setValues] = useState<CreateConfigValues>({
...defaultCreateValues,
adapterType: "codex_local",
command: "codex",
model: "gpt-5.4",
thinkingEffort: "high",
search: true,
dangerouslyBypassSandbox: true,
promptTemplate:
"You are {{ agent.name }}. Read the assigned issue, make a small verified change, and update the task.",
extraArgs: "--full-auto, --search",
envBindings: {
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
PAPERCLIP_TRACE: { type: "plain", value: "storybook" },
},
runtimeServicesJson: JSON.stringify(
[
{
name: "storybook",
command: "pnpm storybook",
url: "http://localhost:6006",
},
],
null,
2,
),
heartbeatEnabled: true,
intervalSec: 900,
});
return (
<AgentConfigForm
mode="create"
values={values}
onChange={(patch) => setValues((current) => ({ ...current, ...patch }))}
sectionLayout="cards"
showAdapterTestEnvironmentButton={false}
/>
);
}
function IconPickerMatrix() {
const [selectedIcon, setSelectedIcon] = useState("code");
const visibleIcons = AGENT_ICON_NAMES.slice(0, 28);
return (
<div className="grid gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
<Card className="shadow-none">
<CardHeader>
<CardTitle>Selected identity</CardTitle>
<CardDescription>The real picker trigger updates the selected fixture state.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-border bg-background/70 p-4">
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-border bg-accent/40">
<AgentIcon icon={selectedIcon} className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-medium">StorybookEngineer</div>
<div className="font-mono text-xs text-muted-foreground">{selectedIcon}</div>
</div>
</div>
<AgentIconPicker value={selectedIcon} onChange={setSelectedIcon}>
<Button variant="outline" className="w-full justify-start">
<Settings2 className="h-4 w-4" />
Open icon picker
</Button>
</AgentIconPicker>
</CardContent>
</Card>
<div className="rounded-xl border border-border bg-background/70 p-4">
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 md:grid-cols-14">
{visibleIcons.map((name) => (
<button
key={name}
type="button"
title={name}
onClick={() => setSelectedIcon(name)}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:bg-accent",
selectedIcon === name && "border-primary bg-primary/10 text-primary ring-1 ring-primary",
)}
>
<AgentIcon icon={name} className="h-4 w-4" />
</button>
))}
</div>
</div>
</div>
);
}
function AgentActionsMatrix() {
const actionAgents = [
agentManagementAgents[0]!,
agentManagementAgents[1]!,
agentManagementAgents[2]!,
agentManagementAgents[3]!,
];
return (
<div className="grid gap-4 xl:grid-cols-4">
{actionAgents.map((agent) => {
const paused = agent.status === "paused";
const runDisabled = agent.status === "running" || agent.status === "paused";
const restartDisabled = agent.status === "paused";
return (
<Card key={agent.id} className="shadow-none">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-accent/40">
<AgentIcon icon={agent.icon} className="h-4 w-4" />
</span>
<div>
<CardTitle className="text-base">{agent.name}</CardTitle>
<CardDescription>{agent.title}</CardDescription>
</div>
</div>
<Badge variant={agent.status === "error" ? "destructive" : "outline"}>{agent.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<PauseResumeButton
isPaused={paused}
onPause={() => undefined}
onResume={() => undefined}
disabled={agent.status === "running"}
/>
<RunButton
label={agent.status === "running" ? "Running" : "Run now"}
onClick={() => undefined}
disabled={runDisabled}
/>
<Button variant="outline" size="sm" disabled={restartDisabled}>
<RotateCcw className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Restart</span>
</Button>
<Button variant="ghost" size="sm">
<Edit3 className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Edit</span>
</Button>
</CardContent>
</Card>
);
})}
</div>
);
}
function ConfigPrimitivesStory() {
const [textValue, setTextValue] = useState("gpt-5.4");
const [selectValue, setSelectValue] = useState("git_worktree");
const [toggleValue, setToggleValue] = useState(true);
const [jsonValue, setJsonValue] = useState(JSON.stringify({
runtimeServices: [
{ name: "api", command: "pnpm dev:once", healthUrl: "http://localhost:3100/api/health" },
],
env: { PAPERCLIP_BIND: "lan" },
}, null, 2));
return (
<div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-4 rounded-xl border border-border bg-background/70 p-4">
<Field label="Text field" hint={help.model}>
<DraftInput
value={textValue}
onCommit={setTextValue}
immediate
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 font-mono text-sm outline-none"
/>
</Field>
<Field label="Select field" hint={help.workspaceStrategy}>
<Select value={selectValue} onValueChange={setSelectValue}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Workspace strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="project_primary">Project primary</SelectItem>
<SelectItem value="git_worktree">Git worktree</SelectItem>
<SelectItem value="agent_home">Agent home</SelectItem>
</SelectContent>
</Select>
</Field>
<ToggleField
label="Toggle field"
hint={help.wakeOnDemand}
checked={toggleValue}
onChange={setToggleValue}
/>
</div>
<div className="rounded-xl border border-border bg-background/70 p-4">
<Field label="JSON editor" hint={help.runtimeServicesJson}>
<DraftTextarea
value={jsonValue}
onCommit={setJsonValue}
immediate
minRows={10}
placeholder='{"runtimeServices":[]}'
/>
</Field>
</div>
</div>
);
}
function AgentManagementStories() {
return (
<StorybookQueryFixtures>
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-6">
<section className="paperclip-story__frame p-6">
<div className="flex flex-wrap items-start justify-between gap-5">
<div>
<div className="paperclip-story__label">Agent management</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Agent details, controls, and config surfaces</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Management stories exercise the dense pieces of the agent lifecycle: status detail panels,
adapter configuration, icon identity, run controls, live-agent cards, and the config-field primitives
used inside the form.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">adapter config</Badge>
<Badge variant="outline">runtime policy</Badge>
<Badge variant="outline">env bindings</Badge>
</div>
</div>
</section>
<Section eyebrow="AgentProperties" title="Full detail panel with runtime and reporting data">
<div className="grid gap-5 lg:grid-cols-[380px_minmax(0,1fr)]">
<Card className="shadow-none">
<CardHeader>
<div className="flex items-start gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-lg border border-border bg-accent/40">
<AgentIcon icon={agentManagementAgents[0]!.icon} className="h-5 w-5" />
</span>
<div>
<CardTitle>{agentManagementAgents[0]!.name}</CardTitle>
<CardDescription>{agentManagementAgents[0]!.capabilities}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<AgentProperties agent={agentManagementAgents[0]!} runtimeState={runtimeState} />
</CardContent>
</Card>
<div className="rounded-xl border border-border bg-background/70 p-5">
<div className="mb-4 flex flex-wrap gap-2">
<Badge variant="secondary">session populated</Badge>
<Badge variant="secondary">last error shown</Badge>
<Badge variant="secondary">manager lookup seeded</Badge>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border border-border p-3">
<div className="text-xs text-muted-foreground">Budget</div>
<div className="mt-1 font-mono">${(agentManagementAgents[0]!.budgetMonthlyCents / 100).toFixed(0)} / month</div>
</div>
<div className="rounded-lg border border-border p-3">
<div className="text-xs text-muted-foreground">Spent</div>
<div className="mt-1 font-mono">${(agentManagementAgents[0]!.spentMonthlyCents / 100).toFixed(0)}</div>
</div>
<div className="rounded-lg border border-border p-3">
<div className="text-xs text-muted-foreground">Instructions</div>
<div className="mt-1 break-all font-mono text-xs">
{String(agentManagementAgents[0]!.adapterConfig.instructionsFilePath)}
</div>
</div>
<div className="rounded-lg border border-border p-3">
<div className="text-xs text-muted-foreground">Runtime policy</div>
<div className="mt-1 font-mono text-xs">heartbeat / 900s / max 2</div>
</div>
</div>
</div>
</div>
</Section>
<Section eyebrow="AgentConfigForm" title="Adapter selection, runtime config, and env vars">
<div className="max-w-4xl">
<AgentConfigFormStory />
</div>
</Section>
<Section eyebrow="AgentIconPicker" title="Available icon grid with selected state">
<IconPickerMatrix />
</Section>
<Section eyebrow="AgentActionButtons" title="Pause, resume, restart, edit, and run actions by state">
<AgentActionsMatrix />
</Section>
<Section eyebrow="ActiveAgentsPanel" title="Mixed live, queued, succeeded, and failed agent runs">
<ActiveAgentsPanel companyId={COMPANY_ID} />
</Section>
<Section eyebrow="agent-config-primitives" title="Individual text, select, toggle, and JSON field types">
<ConfigPrimitivesStory />
</Section>
<Separator />
</main>
</div>
</StorybookQueryFixtures>
);
}
const meta = {
title: "Product/Agent Management",
component: AgentManagementStories,
parameters: {
docs: {
description: {
component:
"Agent management stories cover detail, configuration, icon, action, live-run, and config primitive states using extended Paperclip fixtures.",
},
},
},
} satisfies Meta<typeof AgentManagementStories>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ManagementMatrix: Story = {};
/* ---- Forced Kubernetes execution (instance executionMode=kubernetes) ---- */
const managedKubernetesEnvironment: Environment = {
id: "env-k8s-storybook",
companyId: COMPANY_ID,
name: "Kubernetes Sandbox",
description: "Managed Kubernetes sandbox environment for hosted tenant execution.",
driver: "sandbox",
status: "active",
config: {
provider: "kubernetes",
backend: "job",
inCluster: true,
runtimeClassName: "gvisor",
egressMode: "cilium",
},
metadata: { managedByPaperclip: true, managedKubernetesSandbox: true },
createdAt: recent(2_000),
updatedAt: recent(60),
};
function ForcedKubernetesFixtures({
environmentFixtures,
children,
}: {
environmentFixtures: Environment[];
children: ReactNode;
}) {
const queryClient = useQueryClient();
queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), agentManagementAgents);
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
queryClient.setQueryData(queryKeys.adapters.all, adapterFixtures);
// The instance-level execution policy that forces all agent execution onto
// the managed Kubernetes sandbox (PAPERCLIP_EXECUTION_MODE=kubernetes).
queryClient.setQueryData(queryKeys.instance.generalSettings, {
censorUsernameInLogs: false,
executionMode: "kubernetes",
});
queryClient.setQueryData(queryKeys.environments.list(COMPANY_ID), environmentFixtures);
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini" },
]);
return children;
}
function ForcedKubernetesStory({ environmentFixtures }: { environmentFixtures: Environment[] }) {
return (
<ForcedKubernetesFixtures environmentFixtures={environmentFixtures}>
<div className="min-h-screen bg-background text-foreground">
<main className="mx-auto max-w-4xl space-y-8 px-6 py-10">
<Section
eyebrow="AgentConfigForm"
title="Execution pinned to the managed Kubernetes sandbox (executionMode=kubernetes)"
>
<div className="max-w-4xl">
<AgentConfigFormStory />
</div>
</Section>
</main>
</div>
</ForcedKubernetesFixtures>
);
}
/**
* Instance execution policy forces Kubernetes and the company has a managed
* Kubernetes sandbox environment: the Execution section renders the
* environment read-only (no local/SSH picker).
*/
export const ForcedKubernetesExecution: Story = {
render: () => <ForcedKubernetesStory environmentFixtures={[managedKubernetesEnvironment]} />,
};
/**
* Instance execution policy forces Kubernetes but no managed environment is
* available for the company yet: the Execution section shows the warning
* notice instead of silently falling back to local execution.
*/
export const ForcedKubernetesMissingEnvironment: Story = {
render: () => <ForcedKubernetesStory environmentFixtures={[]} />,
};