"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentState } from "@/features/agents/state/store"; import type { OfficeStandupController } from "@/features/office/hooks/useOfficeStandupController"; import { createCronJob, formatCronSchedule, listCronJobs, removeCronJob, runCronJobNow, sortCronJobsByUpdatedAt, type CronJobCreateInput, type CronJobSummary, } from "@/lib/cron/types"; import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient"; import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient"; type TemplateDefinition = { id: string; name: string; description: string; buildInput: (agent: AgentState, customName: string) => CronJobCreateInput; }; const PLAYBOOK_TEMPLATES: TemplateDefinition[] = [ { id: "daily-briefing", name: "Daily Morning Briefing", description: "Every day at 9am. Summarize priorities, blockers, and what changed overnight.", buildInput: (agent, customName) => ({ name: customName || "Daily Morning Briefing", agentId: agent.agentId, sessionKey: agent.sessionKey, enabled: true, schedule: { kind: "cron", expr: "0 9 * * *" }, sessionTarget: "main", wakeMode: "now", payload: { kind: "agentTurn", message: "Create a concise morning briefing for headquarters. Summarize current priorities, blocked work, recent notable changes, and the next recommended actions.", thinking: "high", }, }), }, { id: "nightly-code-review", name: "Nightly Code Review Digest", description: "Every night at midnight. Review the day and summarize risky changes or regressions.", buildInput: (agent, customName) => ({ name: customName || "Nightly Code Review Digest", agentId: agent.agentId, sessionKey: agent.sessionKey, enabled: true, schedule: { kind: "cron", expr: "0 0 * * *" }, sessionTarget: "main", wakeMode: "now", payload: { kind: "agentTurn", message: "Review the latest work available to you and produce a digest of risky changes, unresolved questions, and follow-up recommendations for the team.", thinking: "high", }, }), }, { id: "hourly-health-check", name: "Hourly Health Check", description: "Every 60 minutes. Report runtime health, failures, and anything that needs intervention.", buildInput: (agent, customName) => ({ name: customName || "Hourly Health Check", agentId: agent.agentId, sessionKey: agent.sessionKey, enabled: true, schedule: { kind: "every", everyMs: 60 * 60 * 1000 }, sessionTarget: "main", wakeMode: "now", payload: { kind: "agentTurn", message: "Run a health check. Summarize your current status, errors, blocked tasks, pending approvals, and whether a human needs to step in.", thinking: "medium", }, }), }, { id: "weekly-progress-report", name: "Weekly Progress Report", description: "Every Monday at 8am. Roll up wins, unfinished work, and next steps.", buildInput: (agent, customName) => ({ name: customName || "Weekly Progress Report", agentId: agent.agentId, sessionKey: agent.sessionKey, enabled: true, schedule: { kind: "cron", expr: "0 8 * * 1" }, sessionTarget: "main", wakeMode: "now", payload: { kind: "agentTurn", message: "Write a weekly progress report for headquarters. Include completed work, unfinished work, risks, and the most important next steps.", thinking: "high", }, }), }, { id: "continuous-monitor", name: "Continuous Monitor", description: "Every 15 minutes. Watch for drift, silent failures, or anything unusual.", buildInput: (agent, customName) => ({ name: customName || "Continuous Monitor", agentId: agent.agentId, sessionKey: agent.sessionKey, enabled: true, schedule: { kind: "every", everyMs: 15 * 60 * 1000 }, sessionTarget: "main", wakeMode: "now", payload: { kind: "agentTurn", message: "Monitor your current context and report only if you detect unusual behavior, blocked progress, repeated failures, or opportunities that need attention.", thinking: "medium", }, }), }, ]; const formatRelativeDateTime = (timestampMs?: number) => { if (!timestampMs || !Number.isFinite(timestampMs)) return "Unknown"; return new Date(timestampMs).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; export function PlaybooksPanel({ client, status, cronEnabled = true, agents, standup, }: { client: GatewayClient; status: GatewayStatus; cronEnabled?: boolean; agents: AgentState[]; standup: OfficeStandupController; }) { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(""); const [nameOverride, setNameOverride] = useState(""); const [createBusy, setCreateBusy] = useState(false); const [runBusyJobId, setRunBusyJobId] = useState(null); const [deleteBusyJobId, setDeleteBusyJobId] = useState(null); const [actionMessage, setActionMessage] = useState(null); const agentById = useMemo( () => new Map(agents.map((agent) => [agent.agentId, agent])), [agents] ); const activeTemplate = useMemo( () => PLAYBOOK_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null, [selectedTemplateId] ); const [standupAgentId, setStandupAgentId] = useState(""); const [standupCronExpr, setStandupCronExpr] = useState("0 9 * * 1-5"); const [standupTimezone, setStandupTimezone] = useState("UTC"); const [standupSpeakerSeconds, setStandupSpeakerSeconds] = useState("8"); const [standupAutoOpenBoard, setStandupAutoOpenBoard] = useState(true); const [standupScheduleEnabled, setStandupScheduleEnabled] = useState(false); const [jiraEnabled, setJiraEnabled] = useState(false); const [jiraBaseUrl, setJiraBaseUrl] = useState(""); const [jiraEmail, setJiraEmail] = useState(""); const [jiraApiToken, setJiraApiToken] = useState(""); const [jiraApiTokenConfigured, setJiraApiTokenConfigured] = useState(false); const [jiraProjectKey, setJiraProjectKey] = useState(""); const [jiraJql, setJiraJql] = useState(""); const [manualTask, setManualTask] = useState(""); const [manualBlockers, setManualBlockers] = useState(""); const [manualNote, setManualNote] = useState(""); const [manualJiraAssignee, setManualJiraAssignee] = useState(""); useEffect(() => { if (!standup.config) return; setStandupScheduleEnabled(standup.config.schedule.enabled); setStandupCronExpr(standup.config.schedule.cronExpr); setStandupTimezone(standup.config.schedule.timezone); setStandupSpeakerSeconds(String(standup.config.schedule.speakerSeconds)); setStandupAutoOpenBoard(standup.config.schedule.autoOpenBoard); setJiraEnabled(standup.config.jira.enabled); setJiraBaseUrl(standup.config.jira.baseUrl); setJiraEmail(standup.config.jira.email); setJiraApiToken(standup.config.jira.apiToken); setJiraApiTokenConfigured(standup.config.jira.apiTokenConfigured); setJiraProjectKey(standup.config.jira.projectKey); setJiraJql(standup.config.jira.jql); }, [standup.config]); useEffect(() => { if (standupAgentId || agents.length === 0) return; setStandupAgentId(agents[0]?.agentId ?? ""); }, [agents, standupAgentId]); useEffect(() => { if (!standup.config || !standupAgentId) return; const manual = standup.config.manualByAgentId[standupAgentId]; setManualTask(manual?.currentTask ?? ""); setManualBlockers(manual?.blockers ?? ""); setManualNote(manual?.note ?? ""); setManualJiraAssignee(manual?.jiraAssignee ?? ""); }, [standup.config, standupAgentId]); const loadJobs = useCallback(async () => { if (!cronEnabled || status !== "connected") { setJobs([]); setError(null); setLoading(false); return; } setLoading(true); setError(null); try { const result = await listCronJobs(client, { includeDisabled: true }); setJobs(sortCronJobsByUpdatedAt(result.jobs)); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load playbooks."; setError(message); if (!isGatewayDisconnectLikeError(err)) { console.error(message); } } finally { setLoading(false); } }, [client, cronEnabled, status]); useEffect(() => { void loadJobs(); }, [loadJobs]); const handleCreate = useCallback(async () => { if (!cronEnabled) { setError("This runtime does not expose scheduled playbooks."); return; } if (!activeTemplate) return; const agent = agentById.get(selectedAgentId); if (!agent) { setError("Pick an agent before launching a playbook."); return; } setCreateBusy(true); setError(null); setActionMessage(null); try { await createCronJob(client, activeTemplate.buildInput(agent, nameOverride.trim())); setActionMessage(`Created "${nameOverride.trim() || activeTemplate.name}".`); setSelectedTemplateId(null); setSelectedAgentId(""); setNameOverride(""); await loadJobs(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create playbook."; setError(message); } finally { setCreateBusy(false); } }, [activeTemplate, agentById, client, cronEnabled, loadJobs, nameOverride, selectedAgentId]); const handleRunNow = useCallback( async (jobId: string) => { if (!cronEnabled) { setError("This runtime does not expose scheduled playbooks."); return; } setRunBusyJobId(jobId); setError(null); setActionMessage(null); try { const result = await runCronJobNow(client, jobId); setActionMessage(result.ok ? "Playbook triggered." : "Playbook trigger failed."); await loadJobs(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to run playbook."); } finally { setRunBusyJobId(null); } }, [client, cronEnabled, loadJobs] ); const handleDelete = useCallback( async (jobId: string) => { if (!cronEnabled) { setError("This runtime does not expose scheduled playbooks."); return; } setDeleteBusyJobId(jobId); setError(null); setActionMessage(null); try { const result = await removeCronJob(client, jobId); setActionMessage(result.ok && result.removed ? "Playbook removed." : "Playbook was not removed."); await loadJobs(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete playbook."); } finally { setDeleteBusyJobId(null); } }, [client, cronEnabled, loadJobs] ); const handleSaveStandupConfig = useCallback(async () => { setError(null); setActionMessage(null); try { await standup.saveConfig({ schedule: { enabled: standupScheduleEnabled, cronExpr: standupCronExpr.trim() || "0 9 * * 1-5", timezone: standupTimezone.trim() || "UTC", speakerSeconds: Number(standupSpeakerSeconds) || 8, autoOpenBoard: standupAutoOpenBoard, }, jira: { enabled: jiraEnabled, baseUrl: jiraBaseUrl.trim(), email: jiraEmail.trim(), apiToken: jiraApiToken.trim(), projectKey: jiraProjectKey.trim().toUpperCase(), jql: jiraJql.trim(), }, }); setActionMessage("Standup settings saved."); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save standup settings."); } }, [ jiraApiToken, jiraBaseUrl, jiraEmail, jiraEnabled, jiraJql, jiraProjectKey, standup, standupAutoOpenBoard, standupCronExpr, standupScheduleEnabled, standupSpeakerSeconds, standupTimezone, ]); const handleSaveManualNotes = useCallback(async () => { if (!standupAgentId) { setError("Pick an agent before saving standup notes."); return; } setError(null); setActionMessage(null); try { await standup.updateManualEntry(standupAgentId, { jiraAssignee: manualJiraAssignee.trim() || null, currentTask: manualTask.trim(), blockers: manualBlockers.trim(), note: manualNote.trim(), }); setActionMessage("Standup notes saved."); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save standup notes."); } }, [ manualBlockers, manualJiraAssignee, manualNote, manualTask, standup, standupAgentId, ]); return (
Playbooks
Launch reusable schedules for the whole headquarters.
{!cronEnabled ? (
This runtime does not expose scheduled playbooks.
) : null} {error ?
{error}
: null} {actionMessage ? (
{actionMessage}
) : null}
Active Jobs
{loading ? (
Loading scheduled jobs.
) : jobs.length === 0 ? (
No active playbooks yet.
) : ( jobs.map((job) => { const agentName = agentById.get(job.agentId ?? "")?.name || job.agentId || "Unknown"; return (
{job.name}
{agentName}
{job.state.lastStatus ?? "ready"}
{formatCronSchedule(job.schedule)}
Next run: {formatRelativeDateTime(job.state.nextRunAtMs)}
Last run: {formatRelativeDateTime(job.state.lastRunAtMs)}
); }) )}
Automated Standup
Configure the daily meeting, Jira source, and manual notes board.