diff --git a/.env.example b/.env.example index 34a172be..f0ca4129 100644 --- a/.env.example +++ b/.env.example @@ -67,9 +67,22 @@ WEB_HOST_BIND=127.0.0.1 # `WEB_PORT` with a deterministic per-worktree port automatically. # Deprecated fallback names still work: WEBHOOK_INGEST_HOST, WEBHOOK_INGEST_PORT, # WEBHOOK_INGEST_HOST_BIND, WEBHOOK_INGEST_HOST_PORT. -# Required for protected non-dashboard API routes. Webhook routes use this only -# when WEBHOOK_SHARED_SECRET is unset. +# Required for protected non-dashboard API routes, including /agent/*. Webhook +# routes use this only when WEBHOOK_SHARED_SECRET is unset. API_SHARED_SECRET= +# Production agent RBAC uses the existing single DISCORD_SERVER_ID plus +# immutable Discord role IDs (not role names). Comma-separate role IDs if a +# bundle intentionally has more than one role. A combined Billing / ERP Dev +# role ID must appear in both applicable bundle variables. Never use the guild +# ID: Discord assigns it to the @everyone role, which would grant every member. +AGENT_DISCORD_ADMIN_ROLE_IDS= +AGENT_DISCORD_STEERING_COMMITTEE_ROLE_IDS= +AGENT_DISCORD_BILLING_ROLE_IDS= +AGENT_DISCORD_ERP_DEVELOPER_ROLE_IDS= +AGENT_DISCORD_PROJECT_MANAGER_ROLE_IDS= +AGENT_DISCORD_ENGINEER_ROLE_IDS= +# Local/test migration aid only. Never effective outside local/dev/test. +AGENT_ALLOW_ROLE_NAME_FALLBACK=false # Env-only key used to encrypt admin-dashboard-managed secret config values. CONFIG_SECRET_KEY= # Optional: separate secret for external /webhooks/* callers. When unset or @@ -103,6 +116,10 @@ OUTLINE_CONTENTS_API_KEY= ERPNEXT_BASE_URL=https://erp.example.com/ ERPNEXT_API_TIMEOUT_SECONDS=20.0 ERPNEXT_API_KEY= +# Required before the agent exposes Billing/ERP read tools. Set this to the +# single Discord organization/guild ID that is authorized to use these ERPNext +# credentials; agent ERP reads fail closed when it is unset or does not match. +AGENT_ERP_ORGANIZATION_ID= # Optional: Discord logs webhook for operator-visible logs from commands/jobs DISCORD_LOGS_WEBHOOK_URL= # Optional: wait for Discord server confirmation before returning from webhook call @@ -182,6 +199,27 @@ AGENT_STRUCTURED_PLANNER_ENABLED=true AGENT_STRUCTURED_PLANNER_TIMEOUT_SECONDS=6.0 AGENT_INTENT_NORMALIZER_ENABLED=true AGENT_INTENT_NORMALIZER_TIMEOUT_SECONDS=3.0 +# Maximum public-web tool/planner turns in one request. Writes never run in this loop. +AGENT_PLANNING_MAX_STEPS=3 +# Caller-visible budget for synchronous agent planning/read requests. This is +# intentionally below AGENT_API_TIMEOUT_SECONDS; a timed-out sync worker may +# still be unwinding an upstream DNS or HTTP call in the background. +AGENT_REQUEST_RESPONSE_BUDGET_SECONDS=55.0 +# Best-effort public-web operation budget. It must not exceed the response +# budget; the API response budget is the hard Discord-facing bound. +AGENT_PUBLIC_WEB_DEADLINE_SECONDS=50.0 +# Sweep expired durable agent-memory facts even when no user sends a request. +# The cleanup worker only returns a deletion count and never exposes fact data. +AGENT_MEMORY_CLEANUP_ENABLED=true +AGENT_MEMORY_CLEANUP_INTERVAL_SECONDS=86400 +# Durable recurring agent reports use five-field cron expressions and persist +# a fixed read-only capability catalog. The API dispatcher is safe to enable +# with no schedules configured. Five minutes is the minimum allowed cadence. +AGENT_SCHEDULE_ENABLED=true +AGENT_SCHEDULE_DISPATCH_INTERVAL_SECONDS=30 +AGENT_SCHEDULE_DISPATCH_BATCH_SIZE=25 +AGENT_SCHEDULE_MIN_INTERVAL_SECONDS=300 +AGENT_SCHEDULE_EXECUTION_TIMEOUT_SECONDS=120.0 AGENT_FAST_MODEL= AGENT_FAST_BASE_URL= AGENT_FAST_API_KEY= @@ -197,13 +235,12 @@ JOB_LEAD_CLASSIFIER_ENABLED=true JOB_LEAD_CLASSIFIER_MODEL= JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS=8.0 # Optional deterministic agent tool integrations. -# GitHub Issues are the canonical todo backend. Members can read and write the -# default repo; Steering Committee/Admin/Owner can work across every repository -# selected for the GitHub App installation and the organization's Projects. +# GitHub Issues are the canonical todo backend. Regular Members receive no +# agent access; Engineering uses the legacy configured-repository path, while +# Steering Committee/Admin/Owner can work across selected GitHub App repos and +# the organization's Projects. GITHUB_DEFAULT_REPO=508-dev/todos GITHUB_ORGANIZATION=508-dev -# Optional additional repos that every Discord Member may work in. -GITHUB_MEMBER_EXTRA_REPOS= # Keep true for Steering Committee/Admin/Owner access to every App-selected repo. GITHUB_STEERING_ALL_INSTALLED_REPOS=true # Used only when the preceding setting is false. @@ -222,6 +259,22 @@ GITHUB_APP_PRIVATE_KEY= # Temporary legacy fallback while moving to the GitHub App. GITHUB_API_TOKEN= GITHUB_ALLOWED_REPOS= +# Optional public-web research. Providers are tried in this fallback order only +# when configured. Set one or more of SearXNG, Brave, and Firecrawl credentials. +AGENT_WEB_SEARCH_PROVIDER_ORDER=searxng,brave,firecrawl +AGENT_WEB_SEARCH_TIMEOUT_SECONDS=5.0 +AGENT_WEB_DEFAULT_RESULT_LIMIT=5 +# SearXNG may be a private in-cluster HTTP endpoint, for example http://searxng:8080. +# Its SearXNG settings must enable the JSON search response format. +SEARXNG_BASE_URL= +SEARXNG_SEARCH_LANGUAGE= +# Brave and Firecrawl endpoints must be HTTPS; their API keys remain backend-only. +BRAVE_SEARCH_API_KEY= +BRAVE_SEARCH_BASE_URL=https://api.search.brave.com +BRAVE_SEARCH_COUNTRY= +BRAVE_SEARCH_LANGUAGE= +FIRECRAWL_API_KEY= +FIRECRAWL_BASE_URL=https://api.firecrawl.dev # Resume extraction can use a different provider from agent planning. # Leave RESUME_AI_API_KEY/BASE_URL unset to use OPENAI_API_KEY/OPENAI_BASE_URL. # Model names without provider prefix are OpenRouter-prefixed to openai/. @@ -252,9 +305,17 @@ DISCORD_DEFAULT_JOB_FORUM_CHANNELS=gigs:part_time,fulltime-roles:full_time # export HEALTHCHECK_PORT in your shell. HEALTHCHECK_PORT=3000 BACKEND_API_BASE_URL=http://127.0.0.1:8090 -AGENT_API_TIMEOUT_SECONDS=8.0 +# The bot defers Discord interactions; allow the bounded web-research loop to +# complete provider fallback and planning turns. +AGENT_API_TIMEOUT_SECONDS=60.0 AUDIT_API_BASE_URL= AUDIT_API_TIMEOUT_SECONDS=2.0 +# Worker-to-API handoff for durable recurring agent runs. Host-run development +# uses the same backend address as the Discord bot; compose overrides this. +# This must cover schedule execution plus 60 seconds for role refresh, summary, +# and Discord delivery, and fit inside the durable worker job lease. +AGENT_SCHEDULE_API_BASE_URL=http://127.0.0.1:8090 +AGENT_SCHEDULE_API_TIMEOUT_SECONDS=360.0 # Worker mailbox resume intake (required if email intake is enabled) CHECK_EMAIL_WAIT=2 EMAIL_USERNAME=your_email@example.com diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index de2ccfdf..46c6db54 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -36,7 +36,9 @@ Pydantic import errors. - `Optional`: `REDIS_KEY_PREFIX` (default: `jobs`) - `Optional`: `REDIS_HOST_BIND` (default: `127.0.0.1`) - `Optional`: `REDIS_HOST_PORT` (default when unset: `CONDUCTOR_PORT + 0` inside Conductor, otherwise computed per worktree as `12000 + WORKTREE_ENV_SLOT`; use `6379` only if explicitly pinned via env/.env; see `./scripts/docker-compose.sh print-ports`) -- `Optional`: `JOB_TIMEOUT_SECONDS` (default: `600`) +- `Optional`: `JOB_TIMEOUT_SECONDS` (default: `600`, minimum: `6`; the worker + reserves five seconds before the durable lease expires for cancellation and + recovery) - `Optional`: `JOB_RESULT_TTL_SECONDS` (default: `3600`) - `Optional`: `JOB_MAX_ATTEMPTS` (default: `8`) - `Optional`: `JOB_RETRY_BASE_SECONDS` (default: `5`) diff --git a/apps/admin_dashboard/src/agent-schedule-run-status.test.ts b/apps/admin_dashboard/src/agent-schedule-run-status.test.ts new file mode 100644 index 00000000..0d801777 --- /dev/null +++ b/apps/admin_dashboard/src/agent-schedule-run-status.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" + +import { agentScheduleRunToastMessage } from "./agent-schedule-run-status" + +describe("agentScheduleRunToastMessage", () => { + it("distinguishes newly queued work from coalesced requests", () => { + expect(agentScheduleRunToastMessage({ status: "queued" })).toBe( + "Queued recurring agent schedule run", + ) + expect(agentScheduleRunToastMessage({ status: "already_queued" })).toBe( + "Recurring agent schedule run is already queued", + ) + expect(agentScheduleRunToastMessage({ status: "already_requested" })).toBe( + "A recent recurring agent schedule run already exists", + ) + }) +}) diff --git a/apps/admin_dashboard/src/agent-schedule-run-status.ts b/apps/admin_dashboard/src/agent-schedule-run-status.ts new file mode 100644 index 00000000..f6bb2ddc --- /dev/null +++ b/apps/admin_dashboard/src/agent-schedule-run-status.ts @@ -0,0 +1,18 @@ +export type AgentScheduleRunResponse = { + status?: string + job_id?: string | null + dispatch_pending?: boolean + run?: { + id?: string + status?: string + job_id?: string | null + } +} + +export function agentScheduleRunToastMessage(response: AgentScheduleRunResponse) { + if (response.status === "queued") return "Queued recurring agent schedule run" + if (response.status === "already_queued") return "Recurring agent schedule run is already queued" + if (response.status === "already_requested") + return "A recent recurring agent schedule run already exists" + return "Recurring agent schedule run request accepted" +} diff --git a/apps/admin_dashboard/src/agent-schedules-view.test.tsx b/apps/admin_dashboard/src/agent-schedules-view.test.tsx new file mode 100644 index 00000000..c0e21ea4 --- /dev/null +++ b/apps/admin_dashboard/src/agent-schedules-view.test.tsx @@ -0,0 +1,94 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + type AgentSchedule, + type AgentScheduleRun, + AgentSchedulesView, +} from "./views/agent-schedules-view" + +afterEach(cleanup) + +const schedule: AgentSchedule = { + id: "e5e5e5e5-0000-4000-8000-000000000001", + guild_id: "123", + owner_discord_user_id: "456", + name: "Weekly GitHub triage", + cron_expression: "0 9 * * 1", + timezone: "Asia/Tokyo", + status: "active", + next_run_at: "2026-08-03T00:00:00Z", + last_run_at: null, + definition: { + prompt: "Inspect onboarding and ERP health.", + execution_mode: "agent_loop", + summary_mode: "deterministic", + sources_are_public: false, + tool_allowlist: ["onboarding_read.get_summary", "erp_read.search_projects"], + delivery: { channel_id: "789" }, + actions: [], + }, +} + +const staleDelivery: AgentScheduleRun = { + id: "e5e5e5e5-0000-4000-8000-000000000002", + schedule_id: schedule.id, + status: "running", + delivery_status: "claimed", + delivery_claimed_at: "2026-08-03T00:00:00Z", +} + +describe("AgentSchedulesView", () => { + it("renders the frozen envelope and sends lifecycle controls", () => { + const onControl = vi.fn() + render( + , + ) + + expect(screen.getByText("Weekly GitHub triage")).toBeVisible() + expect(screen.getByText("Bounded agent loop · 2 read-only tools")).toBeVisible() + expect(screen.getByText(/fixed catalog of read-only tools/i)).toBeVisible() + expect(screen.getByText(/CRM, ERP, billing, onboarding, and public-web/i)).toBeVisible() + expect(screen.getByText("/schedule-github-issues")).toBeVisible() + + fireEvent.click(screen.getByRole("button", { name: "Pause" })) + + expect(onControl).toHaveBeenCalledWith(schedule.id, "pause") + }) + + it("makes stale claims visible and lets an operator mark them unknown", () => { + const onResolveDelivery = vi.fn() + render( + , + ) + + expect(screen.getByText("Delivery attention required")).toBeVisible() + fireEvent.click(screen.getByRole("button", { name: "Mark outcome unknown" })) + + expect(onResolveDelivery).toHaveBeenCalledWith(staleDelivery.id) + }) +}) diff --git a/apps/admin_dashboard/src/discord-diagnostics-view.test.tsx b/apps/admin_dashboard/src/discord-diagnostics-view.test.tsx new file mode 100644 index 00000000..eeea7dc3 --- /dev/null +++ b/apps/admin_dashboard/src/discord-diagnostics-view.test.tsx @@ -0,0 +1,98 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + type DiscordDiagnosticsResponse, + DiscordDiagnosticsView, +} from "./views/discord-diagnostics-view" + +afterEach(cleanup) + +const diagnostics: DiscordDiagnosticsResponse = { + guild: { + id: "123", + name: "508.dev", + configured_server_matches: true, + }, + snapshot: { + created_at: "2026-07-28T00:00:00Z", + source: "discord_api", + }, + bot: { + manage_roles: true, + top_role: { id: "900", name: "Bot", position: 10 }, + }, + agent: { + configured_role_count: 2, + resolved_role_count: 1, + missing_role_count: 1, + unconfigured_binding_count: 5, + api_shared_secret_status: "configured", + role_bindings: [ + { + bundle: "admin", + label: "Admin", + environment_variable: "AGENT_DISCORD_ADMIN_ROLE_IDS", + role_ids: ["456"], + roles: [ + { + id: "456", + name: "Admin", + status: "resolved", + managed: false, + manageable_by_bot: true, + }, + ], + status: "resolved", + }, + { + bundle: "billing", + label: "Billing", + environment_variable: "AGENT_DISCORD_BILLING_ROLE_IDS", + role_ids: ["999"], + roles: [{ id: "999", status: "missing" }], + status: "attention", + }, + ], + }, + roles: [ + { + id: "456", + name: "Admin", + position: 9, + managed: false, + is_default: false, + manageable_by_bot: true, + }, + { + id: "123", + name: "@everyone", + position: 0, + managed: false, + is_default: true, + manageable_by_bot: false, + }, + ], +} + +describe("DiscordDiagnosticsView", () => { + it("shows copyable role IDs, binding health, and filters the role catalog", () => { + render( + , + ) + + expect(screen.getByText("508.dev")).toBeVisible() + expect(screen.getByText("AGENT_DISCORD_ADMIN_ROLE_IDS")).toBeVisible() + expect(screen.getByRole("table", { name: "Discord server roles" })).toBeVisible() + expect(screen.getAllByText("456").length).toBeGreaterThan(0) + expect(screen.getByText("Secret values are never displayed.")).toBeVisible() + + fireEvent.change(screen.getByLabelText("Search roles"), { + target: { value: "everyone" }, + }) + + const catalog = screen.getByRole("table", { name: "Discord server roles" }) + expect(within(catalog).getAllByText("@everyone")[0]).toBeVisible() + expect(within(catalog).queryByRole("cell", { name: "Admin" })).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index a1c5e05e..82850e7e 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Bell, BriefcaseBusiness, + CalendarClock, ClipboardList, ExternalLink, FileClock, @@ -13,6 +14,7 @@ import { RefreshCw, Search, Send, + Server, Settings, ShieldCheck, UserMinus, @@ -22,6 +24,10 @@ import { } from "lucide-react" import { type ReactNode, StrictMode, useEffect, useMemo, useRef, useState } from "react" import { createRoot } from "react-dom/client" +import { + type AgentScheduleRunResponse, + agentScheduleRunToastMessage, +} from "@/agent-schedule-run-status" import { Empty } from "@/components/empty" import { SortableTableHead } from "@/components/sortable-table-head" import { Badge } from "@/components/ui/badge" @@ -53,6 +59,13 @@ import { toneForOnboardingState, } from "@/dashboard-utils" import { cn } from "@/lib/utils" +import { + type AgentSchedule, + type AgentScheduleCreateValues, + type AgentScheduleRun, + type AgentSchedulesResponse, + AgentSchedulesView, +} from "@/views/agent-schedules-view" import { type ConfigurationItem, type ConfigurationResponse, @@ -61,6 +74,10 @@ import { type JobPostChannel, type JobPostChannelTag, } from "@/views/configuration-view" +import { + type DiscordDiagnosticsResponse, + DiscordDiagnosticsView, +} from "@/views/discord-diagnostics-view" import { type NewsletterStatus, type NewsletterSuppression, @@ -80,6 +97,8 @@ type View = | "jobs" | "agent" | "audit" + | "schedules" + | "diagnostics" | "configuration" type SortDirection = "asc" | "desc" type GigTab = "gigs" | "leads" @@ -534,6 +553,8 @@ const routes: Record = { jobs: "/dashboard/jobs", agent: "/dashboard/agent", audit: "/dashboard/audit", + schedules: "/dashboard/schedules", + diagnostics: "/dashboard/diagnostics", configuration: "/dashboard/configuration", } @@ -546,6 +567,8 @@ const routePermissions: Record = { jobs: "jobs:read", agent: "audit:read", audit: "audit:read", + schedules: "configuration:read", + diagnostics: "configuration:read", configuration: "configuration:read", } @@ -946,6 +969,14 @@ function App() { const [onboarding, setOnboarding] = useState([]) const [auditEvents, setAuditEvents] = useState([]) const [agentReport, setAgentReport] = useState(null) + const [agentSchedules, setAgentSchedules] = useState([]) + const [agentScheduleDeliveryAttention, setAgentScheduleDeliveryAttention] = useState< + AgentScheduleRun[] + >([]) + const [agentScheduleDispatcherEnabled, setAgentScheduleDispatcherEnabled] = useState(false) + const [discordDiagnostics, setDiscordDiagnostics] = useState( + null, + ) const [configurationItems, setConfigurationItems] = useState([]) const [configurationFocus, setConfigurationFocus] = useState<{ category: string @@ -968,6 +999,8 @@ function App() { people: { key: "name", direction: "asc" }, agent: { key: "occurred_at", direction: "desc" }, audit: { key: "occurred_at", direction: "desc" }, + schedules: { key: "next_run_at", direction: "asc" }, + diagnostics: { key: "position", direction: "desc" }, configuration: { key: "category", direction: "asc" }, }) @@ -2133,6 +2166,94 @@ function App() { } } + async function loadAgentSchedules() { + setBusy("agentSchedules", true) + try { + const payload = await requestJson("/dashboard/api/agent-schedules") + setAgentSchedules(payload.schedules || []) + setAgentScheduleDeliveryAttention(payload.delivery_attention || []) + setAgentScheduleDispatcherEnabled(Boolean(payload.scheduler_enabled)) + } catch (error) { + showError(error, "Unable to load recurring agent schedules") + } finally { + setBusy("agentSchedules", false) + } + } + + async function createAgentSchedule(values: AgentScheduleCreateValues) { + setBusy("createAgentSchedule", true) + try { + await requestJson("/dashboard/api/agent-schedules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }) + showToast("Created recurring agent schedule", "ok") + await loadAgentSchedules() + return true + } catch (error) { + showError(error, "Unable to create the recurring agent schedule") + return false + } finally { + setBusy("createAgentSchedule", false) + } + } + + async function controlAgentSchedule(scheduleId: string, action: "pause" | "resume" | "archive") { + const key = `agentSchedule:${scheduleId}` + setBusy(key, true) + try { + await requestJson( + `/dashboard/api/agent-schedules/${encodeURIComponent(scheduleId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }, + ) + showToast(`${action[0].toUpperCase()}${action.slice(1)}d recurring schedule`, "ok") + await loadAgentSchedules() + } catch (error) { + showError(error, "Unable to update the recurring agent schedule") + } finally { + setBusy(key, false) + } + } + + async function runAgentSchedule(scheduleId: string) { + const key = `agentScheduleRun:${scheduleId}` + setBusy(key, true) + try { + const response = await requestJson( + `/dashboard/api/agent-schedules/${encodeURIComponent(scheduleId)}/run`, + { method: "POST" }, + ) + showToast(agentScheduleRunToastMessage(response), "ok") + await loadAgentSchedules() + } catch (error) { + showError(error, "Unable to queue the recurring agent schedule") + } finally { + setBusy(key, false) + } + } + + async function resolveAgentScheduleDelivery(runId: string) { + const key = `agentScheduleDelivery:${runId}` + setBusy(key, true) + try { + await requestJson( + `/dashboard/api/agent-schedules/runs/${encodeURIComponent(runId)}/delivery/resolve`, + { method: "POST" }, + ) + showToast("Marked the uncertain report delivery as unknown", "warning") + await loadAgentSchedules() + } catch (error) { + showError(error, "Unable to resolve the uncertain report delivery") + } finally { + setBusy(key, false) + } + } + async function loadConfiguration() { setBusy("configuration", true) try { @@ -2145,6 +2266,20 @@ function App() { } } + async function loadDiscordDiagnostics(refresh = false) { + setBusy("diagnostics", true) + try { + const query = refresh ? "?refresh=true" : "" + setDiscordDiagnostics( + await requestJson(`/dashboard/api/discord-diagnostics${query}`), + ) + } catch (error) { + showError(error, "Unable to load Discord diagnostics") + } finally { + setBusy("diagnostics", false) + } + } + async function updateConfigurationValue(key: string, value: string) { setBusy(`configuration:${key}`, true) try { @@ -2523,6 +2658,8 @@ function App() { if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() if (view === "audit") void loadAuditEvents() + if (view === "schedules") void loadAgentSchedules() + if (view === "diagnostics") void loadDiscordDiagnostics() if (view === "configuration") { void loadConfiguration() void loadJobPostChannels({ includeAvailable: true }) @@ -2546,6 +2683,8 @@ function App() { if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() if (view === "audit") void loadAuditEvents() + if (view === "schedules") void loadAgentSchedules() + if (view === "diagnostics") void loadDiscordDiagnostics() if (view === "configuration") { void loadConfiguration() void loadJobPostChannels({ includeAvailable: true }) @@ -2814,7 +2953,9 @@ function App() { ["newsletter", "Newsletter", Mail], ["jobs", "Background tasks", Activity], ["agent", "Agent", ShieldCheck], + ["schedules", "Agent schedules", CalendarClock], ["audit", "Audit", FileClock], + ["diagnostics", "Discord diagnostics", Server], ["configuration", "Configuration", Settings], ] as const ) @@ -3061,6 +3202,31 @@ function App() { ) : null} + {view === "schedules" ? ( + + ) : null} + + {view === "diagnostics" ? ( + loadDiscordDiagnostics(true)} + onNotice={(message, tone = "ok") => showToast(message, tone)} + /> + ) : null} + {view === "configuration" ? ( + }> + } +} + +export type AgentScheduleRun = { + id: string + schedule_id: string + status: string + delivery_status: string + delivery_claimed_at?: string | null +} + +export type AgentSchedulesResponse = { + scheduler_enabled: boolean + schedules: AgentSchedule[] + delivery_attention?: AgentScheduleRun[] +} + +export type AgentScheduleCreateValues = { + name: string + cron_expression: string + timezone: string + prompt: string + channel_id: string + execution_mode: "agent_loop" +} + +function timestamp(value?: string | null) { + if (!value) return "—" + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString() +} + +function statusVariant(status: string) { + if (status === "active") return "succeeded" as const + if (status === "paused") return "neutral" as const + return "missing" as const +} + +function scheduleActionSummary(schedule: AgentSchedule) { + if (schedule.definition.execution_mode === "agent_loop") { + const toolCount = schedule.definition.tool_allowlist?.length || 0 + return toolCount ? `Bounded agent loop · ${toolCount} read-only tools` : "Bounded agent loop" + } + const action = schedule.definition.actions.find( + (candidate) => candidate.tool_name === "github_issue.search_issues", + ) + if (!action) return "Frozen read-only action" + const repository = String(action.arguments.repository || "repository") + const query = String(action.arguments.query || "").trim() + return query ? `${repository} · ${query}` : repository +} + +export function AgentSchedulesView({ + schedules, + deliveryAttention, + schedulerEnabled, + loading, + canWrite, + canCreate, + onRefresh, + onCreate, + onControl, + onRun, + onResolveDelivery, +}: { + schedules: AgentSchedule[] + deliveryAttention: AgentScheduleRun[] + schedulerEnabled: boolean + loading: Record + canWrite: boolean + canCreate: boolean + onRefresh: () => void + onCreate: (values: AgentScheduleCreateValues) => Promise + onControl: (scheduleId: string, action: "pause" | "resume" | "archive") => void + onRun: (scheduleId: string) => void + onResolveDelivery: (runId: string) => void +}) { + const [name, setName] = useState("") + const [cronExpression, setCronExpression] = useState("0 9 * * 1") + const [timezone, setTimezone] = useState("UTC") + const [channelId, setChannelId] = useState("") + const [prompt, setPrompt] = useState("") + + const canSubmit = + canCreate && + name.trim() && + cronExpression.trim() && + timezone.trim() && + channelId.trim() && + prompt.trim() && + !loading.createAgentSchedule + const scheduleNameById = new Map(schedules.map((schedule) => [schedule.id, schedule.name])) + + async function submit() { + if (!canSubmit) return + const created = await onCreate({ + name: name.trim(), + cron_expression: cronExpression.trim(), + timezone: timezone.trim(), + channel_id: channelId.trim(), + prompt: prompt.trim(), + execution_mode: "agent_loop", + }) + if (!created) return + setName("") + setChannelId("") + setPrompt("") + } + + return ( +
+ + +
+ Recurring agent reports +

+ Each report saves a goal and a fixed catalog of read-only tools. At run time the agent + can plan a short loop over that catalog, but it can never write, add a tool, widen + permissions, or change its delivery destination. +

+
+
+ + Dispatcher {schedulerEnabled ? "enabled" : "disabled"} + + +
+
+
+ + {deliveryAttention.length > 0 ? ( + + +
+ Delivery attention required +

+ These report claims outlived the execution lease. Discord may or may not have + accepted them, so they will never be sent automatically. Mark the old outcome + unknown before using Run now to request a fresh report. +

+
+
+ + {deliveryAttention.map((run) => { + const resolveKey = `agentScheduleDelivery:${run.id}` + return ( +
+
+ {scheduleNameById.get(run.schedule_id) || "Archived schedule"} + {run.id} + + Claimed {timestamp(run.delivery_claimed_at)} + +
+ +
+ ) + })} +
+
+ ) : null} + + {canWrite ? ( + + +
+ New agent report +

+ Runs at most once every five minutes. The saved catalog includes every currently + supported read-only CRM, ERP, billing, onboarding, and public-web tool that your + Discord permissions permit. Create GitHub reports with{" "} + /schedule-github-issues in Discord. +

+
+
+ + {!canCreate ? ( +
+ Open this dashboard through the Discord admin link to create or control schedules. +
+ ) : null} +
+ + + + +
+