Compare commits

...

3 Commits

Author SHA1 Message Date
DaniAkash
d343fd1735 fix: remove openclaw role bootstrap assertion 2026-04-15 12:20:44 +05:30
DaniAkash
63fed8e79b feat: add agent program management ui 2026-04-15 12:19:54 +05:30
Dani Akash
1fdad55b4a feat: add agent program management backend 2026-04-14 20:15:21 +05:30
12 changed files with 1958 additions and 7 deletions

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { AgentProgramsPage } from './programs/AgentProgramsPage'
import {
type AgentEntry,
type OpenClawStatus,
@@ -317,6 +318,7 @@ export const AgentsPage: FC = () => {
})
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [programAgent, setProgramAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -552,6 +554,15 @@ export const AgentsPage: FC = () => {
)
}
if (programAgent) {
return (
<AgentProgramsPage
agent={programAgent}
onBack={() => setProgramAgent(null)}
/>
)
}
if (statusLoading && !status) {
return (
<div className="flex items-center justify-center py-20">
@@ -805,6 +816,15 @@ export const AgentsPage: FC = () => {
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setProgramAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
>
<Wrench className="mr-1 size-4" />
Programs
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -0,0 +1,342 @@
import type {
BrowserOSAgentProgram,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import {
ArrowLeft,
CalendarClock,
Loader2,
Play,
Plus,
Trash2,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import type { AgentEntry } from '../useOpenClaw'
import {
useOpenClawMutations,
useOpenClawProgramRuns,
useOpenClawPrograms,
} from '../useOpenClaw'
import { ProgramFormDialog } from './ProgramFormDialog'
import { ProgramRunHistory } from './ProgramRunHistory'
interface AgentProgramsPageProps {
agent: AgentEntry
onBack: () => void
}
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'Manual only'
case 'daily':
return `Daily at ${program.schedule.time}`
case 'hourly':
return `Every ${program.schedule.interval} hour(s)`
case 'minutes':
return `Every ${program.schedule.interval} minute(s)`
}
}
export function AgentProgramsPage({ agent, onBack }: AgentProgramsPageProps) {
const {
programs,
loading: programsLoading,
error: programsError,
} = useOpenClawPrograms(agent.agentId)
const {
runs,
loading: runsLoading,
error: runsError,
} = useOpenClawProgramRuns(agent.agentId)
const {
createProgram,
updateProgram,
deleteProgram,
runProgram,
creatingProgram,
updatingProgram,
deletingProgram,
runningProgram,
} = useOpenClawMutations()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProgram, setEditingProgram] =
useState<BrowserOSAgentProgram | null>(null)
const programNames = useMemo(
() =>
Object.fromEntries(programs.map((program) => [program.id, program.name])),
[programs],
)
const saving = creatingProgram || updatingProgram
const handleCreate = async (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => {
try {
if (editingProgram) {
await updateProgram({
agentId: agent.agentId,
programId: editingProgram.id,
input: input as UpdateAgentProgramInput,
})
toast.success('Program updated')
} else {
await createProgram({
agentId: agent.agentId,
input: input as CreateAgentProgramInput,
})
toast.success('Program created')
}
setDialogOpen(false)
setEditingProgram(null)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to save program',
)
}
}
const handleToggle = async (
program: BrowserOSAgentProgram,
enabled: boolean,
) => {
try {
await updateProgram({
agentId: agent.agentId,
programId: program.id,
input: { enabled },
})
toast.success(enabled ? 'Program enabled' : 'Program disabled')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update program',
)
}
}
const handleDelete = async (program: BrowserOSAgentProgram) => {
try {
await deleteProgram({
agentId: agent.agentId,
programId: program.id,
})
toast.success(`Deleted "${program.name}"`)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete program',
)
}
}
const handleRunNow = async (program: BrowserOSAgentProgram) => {
try {
const result = await runProgram({
agentId: agent.agentId,
programId: program.id,
})
if (result.run.status === 'failed') {
toast.error(
result.run.error ?? `Program run failed for "${program.name}"`,
)
return
}
toast.success(`Completed "${program.name}"`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Program run failed')
}
}
const inlineError = programsError?.message ?? runsError?.message ?? null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">{agent.name} Programs</h1>
<p className="text-muted-foreground text-sm">
Define and manually test reusable responsibilities for this agent.
</p>
</div>
</div>
{inlineError && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive text-sm">
{inlineError}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">Programs</CardTitle>
<p className="text-muted-foreground text-sm">
Save schedules now and use manual runs to validate the workflow.
Automatic schedule execution lands in the next milestone.
</p>
</div>
<Button
onClick={() => {
setEditingProgram(null)
setDialogOpen(true)
}}
>
<Plus className="mr-2 size-4" />
New Program
</Button>
</CardHeader>
<CardContent>
{programsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : programs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No programs yet. Create your first program to define a recurring
responsibility for this agent.
</div>
) : (
<div className="space-y-4">
{programs.map((program) => (
<div key={program.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{program.name}</div>
<Badge
variant={program.enabled ? 'default' : 'outline'}
>
{program.enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Badge variant="secondary">
{describeSchedule(program)}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{program.description}
</p>
<p className="line-clamp-4 text-sm">{program.prompt}</p>
<div className="text-muted-foreground text-xs">
Last run:{' '}
{program.lastRunAt
? new Date(program.lastRunAt).toLocaleString()
: 'Never'}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={program.enabled}
onCheckedChange={(checked) =>
void handleToggle(program, checked)
}
disabled={updatingProgram}
/>
</div>
<Button
variant="outline"
onClick={() => void handleRunNow(program)}
disabled={runningProgram}
>
<Play className="mr-2 size-4" />
Run Now
</Button>
<Button
variant="outline"
onClick={() => {
setEditingProgram(program)
setDialogOpen(true)
}}
>
<CalendarClock className="mr-2 size-4" />
Edit
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleDelete(program)}
disabled={deletingProgram}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
{program.standingOrders.length > 0 && (
<>
<Separator className="my-4" />
<div className="space-y-2">
<div className="font-medium text-sm">
Standing Orders
</div>
<div className="space-y-2">
{program.standingOrders.map((order) => (
<div
key={order.id}
className="rounded-md bg-muted/40 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium">
{order.title}
</span>
<Badge
variant={
order.enabled ? 'secondary' : 'outline'
}
>
{order.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-xs">
{order.instruction}
</p>
</div>
))}
</div>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<ProgramRunHistory
runs={runs}
loading={runsLoading}
programNames={programNames}
/>
<ProgramFormDialog
open={dialogOpen}
program={editingProgram}
saving={saving}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) {
setEditingProgram(null)
}
}}
onSave={handleCreate}
/>
</div>
)
}

View File

@@ -0,0 +1,448 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
type ProgramScheduleType = BrowserOSProgramSchedule['type']
interface ProgramDraft {
name: string
description: string
prompt: string
enabled: boolean
scheduleType: ProgramScheduleType
scheduleTime: string
scheduleInterval: number
standingOrders: BrowserOSStandingOrder[]
}
interface ProgramFormDialogProps {
open: boolean
program: BrowserOSAgentProgram | null
saving: boolean
onOpenChange: (open: boolean) => void
onSave: (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => Promise<void>
}
function createEmptyStandingOrder(): BrowserOSStandingOrder {
return {
id: crypto.randomUUID(),
title: '',
instruction: '',
enabled: true,
}
}
function toDraft(program: BrowserOSAgentProgram | null): ProgramDraft {
if (!program) {
return {
name: '',
description: '',
prompt: '',
enabled: true,
scheduleType: 'manual',
scheduleTime: '09:00',
scheduleInterval: 1,
standingOrders: [],
}
}
return {
name: program.name,
description: program.description,
prompt: program.prompt,
enabled: program.enabled,
scheduleType: program.schedule.type,
scheduleTime:
program.schedule.type === 'daily' ? program.schedule.time : '09:00',
scheduleInterval:
program.schedule.type === 'hourly' || program.schedule.type === 'minutes'
? program.schedule.interval
: 1,
standingOrders: program.standingOrders,
}
}
function toSchedule(draft: ProgramDraft): BrowserOSProgramSchedule {
switch (draft.scheduleType) {
case 'daily':
return {
type: 'daily',
time: draft.scheduleTime,
}
case 'hourly':
return {
type: 'hourly',
interval: draft.scheduleInterval,
}
case 'minutes':
return {
type: 'minutes',
interval: draft.scheduleInterval,
}
case 'manual':
default:
return { type: 'manual' }
}
}
export function ProgramFormDialog({
open,
program,
saving,
onOpenChange,
onSave,
}: ProgramFormDialogProps) {
const [draft, setDraft] = useState<ProgramDraft>(() => toDraft(program))
useEffect(() => {
if (!open) return
setDraft(toDraft(program))
}, [open, program])
const isEditing = !!program
const canSave = useMemo(() => {
return (
draft.name.trim() !== '' &&
draft.description.trim() !== '' &&
draft.prompt.trim() !== ''
)
}, [draft])
const handleSave = async () => {
const payload = {
name: draft.name.trim(),
description: draft.description.trim(),
prompt: draft.prompt.trim(),
schedule: toSchedule(draft),
enabled: draft.enabled,
standingOrders: draft.standingOrders
.filter(
(order) =>
order.title.trim() !== '' || order.instruction.trim() !== '',
)
.map((order) => ({
...order,
title: order.title.trim(),
instruction: order.instruction.trim(),
})),
}
await onSave(payload)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Program' : 'Create Program'}
</DialogTitle>
<DialogDescription>
Define a reusable responsibility for this agent. Automatic schedule
execution lands in the next milestone, but you can save and run it
manually now.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-name">
Program Name
</label>
<Input
id="program-name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="Morning Brief"
/>
</div>
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-description"
>
Description
</label>
<Input
id="program-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Prepare the executive morning brief."
/>
</div>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-prompt">
Prompt
</label>
<Textarea
id="program-prompt"
rows={6}
value={draft.prompt}
onChange={(event) =>
setDraft((current) => ({
...current,
prompt: event.target.value,
}))
}
placeholder="Review email, Slack, calendar, Linear, and Notion for urgent updates..."
/>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-schedule">
Schedule
</label>
<Select
value={draft.scheduleType}
onValueChange={(value) =>
setDraft((current) => ({
...current,
scheduleType: value as ProgramScheduleType,
}))
}
>
<SelectTrigger id="program-schedule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual only</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="minutes">Every N minutes</SelectItem>
</SelectContent>
</Select>
</div>
{draft.scheduleType === 'daily' && (
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-time">
Time
</label>
<Input
id="program-time"
type="time"
value={draft.scheduleTime}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleTime: event.target.value,
}))
}
/>
</div>
)}
{(draft.scheduleType === 'hourly' ||
draft.scheduleType === 'minutes') && (
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-interval"
>
Interval
</label>
<Input
id="program-interval"
type="number"
min={1}
value={draft.scheduleInterval}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleInterval: Math.max(
1,
Number(event.target.value) || 1,
),
}))
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<div className="font-medium text-sm">Enabled</div>
<p className="text-muted-foreground text-xs">
Save this program as active for future scheduling.
</p>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Standing Orders</div>
<p className="text-muted-foreground text-xs">
Persistent instructions that should always guide this program.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: [
...current.standingOrders,
createEmptyStandingOrder(),
],
}))
}
>
<Plus className="mr-2 size-4" />
Add Order
</Button>
</div>
{draft.standingOrders.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No standing orders yet.
</div>
) : (
<div className="space-y-3">
{draft.standingOrders.map((order) => (
<div
key={order.id}
className="space-y-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<Input
value={order.title}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, title: event.target.value }
: item,
),
}))
}
placeholder="Keep it concise"
/>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.filter(
(item) => item.id !== order.id,
),
}))
}
>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={3}
value={order.instruction}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map((item) =>
item.id === order.id
? { ...item, instruction: event.target.value }
: item,
),
}))
}
placeholder="Keep the output concise and action-oriented."
/>
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={order.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, enabled: checked }
: item,
),
}))
}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
>
{saving
? 'Saving...'
: isEditing
? 'Save Changes'
: 'Create Program'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,106 @@
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
import { AlertCircle, CheckCircle2, Clock3, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
interface ProgramRunHistoryProps {
runs: BrowserOSProgramRun[]
loading: boolean
programNames: Record<string, string>
}
function formatDateTime(value?: string): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function RunStatusBadge({ status }: { status: BrowserOSProgramRun['status'] }) {
switch (status) {
case 'running':
return <Badge variant="secondary">Running</Badge>
case 'completed':
return <Badge variant="default">Completed</Badge>
case 'failed':
return <Badge variant="destructive">Failed</Badge>
case 'cancelled':
return <Badge variant="outline">Cancelled</Badge>
case 'pending':
default:
return <Badge variant="outline">Pending</Badge>
}
}
export function ProgramRunHistory({
runs,
loading,
programNames,
}: ProgramRunHistoryProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Runs</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : runs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No runs yet. Run a program manually to validate it.
</div>
) : (
<ScrollArea className="h-[340px] pr-3">
<div className="space-y-3">
{runs.map((run) => (
<div key={run.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-sm">
{programNames[run.programId] ?? 'Unknown Program'}
</div>
<div className="text-muted-foreground text-xs">
Trigger: {run.trigger}
</div>
</div>
<RunStatusBadge status={run.status} />
</div>
<div className="mt-3 space-y-1 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<Clock3 className="size-3.5" />
Started: {formatDateTime(run.startedAt)}
</div>
<div className="flex items-center gap-2">
{run.status === 'failed' ? (
<AlertCircle className="size-3.5 text-destructive" />
) : (
<CheckCircle2 className="size-3.5 text-muted-foreground" />
)}
Completed: {formatDateTime(run.completedAt)}
</div>
</div>
{run.summary && <p className="mt-3 text-sm">{run.summary}</p>}
{!run.summary && run.finalResult && (
<p className="mt-3 line-clamp-4 text-sm">
{run.finalResult}
</p>
)}
{run.error && (
<p className="mt-3 text-destructive text-sm">{run.error}</p>
)}
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -2,6 +2,12 @@ import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -78,6 +84,9 @@ export interface OpenClawSetupInput {
modelId?: string
}
export interface AgentProgramEntry extends BrowserOSAgentProgram {}
export interface AgentProgramRunEntry extends BrowserOSProgramRun {}
export function getModelDisplayName(model: unknown): string | undefined {
if (typeof model === 'string') return model.split('/').pop()
return undefined
@@ -87,6 +96,8 @@ export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
programs: 'openclaw-programs',
programRuns: 'openclaw-program-runs',
} as const
async function clawFetch<T>(
@@ -127,6 +138,28 @@ async function fetchOpenClawRoles(
return data.roles ?? []
}
async function fetchOpenClawPrograms(
baseUrl: string,
agentId: string,
): Promise<AgentProgramEntry[]> {
const data = await clawFetch<{ programs: AgentProgramEntry[] }>(
baseUrl,
`/agents/${agentId}/programs`,
)
return data.programs ?? []
}
async function fetchOpenClawProgramRuns(
baseUrl: string,
agentId: string,
): Promise<AgentProgramRunEntry[]> {
const data = await clawFetch<{ runs: AgentProgramRunEntry[] }>(
baseUrl,
`/agents/${agentId}/program-runs`,
)
return data.runs ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -136,6 +169,24 @@ async function invalidateOpenClawQueries(
])
}
async function invalidateAgentProgramQueries(
queryClient: ReturnType<typeof useQueryClient>,
baseUrl: string,
agentId: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
}),
])
}
export function useOpenClawStatus(pollMs = 5000) {
const {
baseUrl,
@@ -201,6 +252,49 @@ export function useOpenClawRoles() {
}
}
export function useOpenClawPrograms(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
queryFn: () => fetchOpenClawPrograms(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
programs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawProgramRuns(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramRunEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
queryFn: () =>
fetchOpenClawProgramRuns(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
runs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -213,6 +307,8 @@ export function useOpenClawMutations() {
}
const onSuccess = () => invalidateOpenClawQueries(queryClient)
const invalidateProgramsFor = (agentId: string) =>
invalidateAgentProgramQueries(queryClient, ensureBaseUrl(), agentId)
const setupMutation = useMutation({
mutationFn: async (input: OpenClawSetupInput) =>
@@ -278,6 +374,88 @@ export function useOpenClawMutations() {
onSuccess,
})
const createProgramMutation = useMutation({
mutationFn: async ({
agentId,
input,
}: {
agentId: string
input: CreateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const updateProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
input,
}: {
agentId: string
programId: string
input: UpdateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const deleteProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ success: boolean }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'DELETE',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const runProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ run: AgentProgramRunEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}/run`,
{
method: 'POST',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
@@ -286,6 +464,10 @@ export function useOpenClawMutations() {
stopOpenClaw: stopMutation.mutateAsync,
restartOpenClaw: restartMutation.mutateAsync,
reconnectOpenClaw: reconnectMutation.mutateAsync,
createProgram: createProgramMutation.mutateAsync,
updateProgram: updateProgramMutation.mutateAsync,
deleteProgram: deleteProgramMutation.mutateAsync,
runProgram: runProgramMutation.mutateAsync,
actionInProgress:
setupMutation.isPending ||
createMutation.isPending ||
@@ -293,11 +475,19 @@ export function useOpenClawMutations() {
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
reconnectMutation.isPending,
reconnectMutation.isPending ||
createProgramMutation.isPending ||
updateProgramMutation.isPending ||
deleteProgramMutation.isPending ||
runProgramMutation.isPending,
settingUp: setupMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
creatingProgram: createProgramMutation.isPending,
updatingProgram: updateProgramMutation.isPending,
deletingProgram: deleteProgramMutation.isPending,
runningProgram: runProgramMutation.isPending,
}
}

View File

@@ -15,13 +15,23 @@ import type {
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getOpenClawDir } from '../../lib/browseros-dir'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
import {
getOpenClawService,
type OpenClawAgentEntry,
} from '../services/openclaw/openclaw-service'
import { OpenClawProgramMaterializer } from '../services/openclaw/program-materializer'
import { OpenClawProgramStorage } from '../services/openclaw/program-storage'
import {
validateCreateProgramInput,
validateUpdateProgramInput,
} from '../services/openclaw/program-validation'
function isValidBoundaryMode(
value: unknown,
@@ -40,6 +50,19 @@ function isValidCustomRoleBoundary(value: unknown): boolean {
)
}
const openclawProgramStorage = new OpenClawProgramStorage(getOpenClawDir())
const openclawProgramMaterializer = new OpenClawProgramMaterializer(
getOpenClawDir(),
openclawProgramStorage,
)
async function findOpenClawAgent(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await getOpenClawService().listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -133,6 +156,146 @@ export function createOpenClawRoutes() {
}
})
.get('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const programs = await openclawProgramStorage.listPrograms(agent.name)
return c.json({ programs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateCreateProgramInput(await c.req.json())
const program = await openclawProgramStorage.createProgram(agent, input)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ program }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.patch('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateUpdateProgramInput(await c.req.json())
const program = await openclawProgramStorage.updateProgram(
agent.name,
c.req.param('programId'),
input,
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ program })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid') ||
message.includes('At least one')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const deleted = await openclawProgramStorage.deleteProgram(
agent.name,
c.req.param('programId'),
)
if (!deleted) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/program-runs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const runs = await openclawProgramStorage.listRuns(agent.name)
return c.json({ runs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs/:programId/run', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const program = await openclawProgramStorage.getProgram(
agent.name,
c.req.param('programId'),
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
const run = await getOpenClawService().runProgramOnce(
agent.agentId,
program,
)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ run })
} catch (err) {
if (err instanceof OpenClawAgentNotFoundError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({

View File

@@ -18,6 +18,10 @@ import type {
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
} from '@browseros/shared/types/role-programs'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { ContainerRuntime } from './container-runtime'
@@ -43,6 +47,7 @@ import {
resolveProviderModel,
} from './openclaw-config'
import { getPodmanRuntime } from './podman-runtime'
import { OpenClawProgramStorage } from './program-storage'
import {
buildRoleBootstrapFiles,
resolveRoleTemplate,
@@ -116,12 +121,14 @@ export class OpenClawService {
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
private gatewayReconnectPromise: Promise<void> | null = null
private programStorage: OpenClawProgramStorage
constructor(browserosServerPort?: number) {
this.openclawDir = getOpenClawDir()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.token = crypto.randomUUID()
this.browserosServerPort = browserosServerPort ?? DEFAULT_PORTS.server
this.programStorage = new OpenClawProgramStorage(this.openclawDir)
}
// ── Lifecycle ────────────────────────────────────────────────────────
@@ -423,11 +430,12 @@ export class OpenClawService {
throw error
}
if (input.roleId || input.customRole) {
await this.writeRoleBootstrapFiles(
name,
input.roleId ? resolveRoleTemplate(input.roleId) : input.customRole!,
)
const roleTemplate = input.roleId
? resolveRoleTemplate(input.roleId)
: input.customRole
if (roleTemplate) {
await this.writeRoleBootstrapFiles(name, roleTemplate)
}
const roleSummary = input.roleId
@@ -493,6 +501,82 @@ export class OpenClawService {
return this.gateway!.chatStream(agentId, sessionKey, message)
}
async runProgramOnce(
agentId: string,
program: BrowserOSAgentProgram,
): Promise<BrowserOSProgramRun> {
const agent = await this.findAgentById(agentId)
if (!agent) {
throw new OpenClawAgentNotFoundError(agentId)
}
const runId = crypto.randomUUID()
const sessionKey = crypto.randomUUID()
const startedAt = new Date().toISOString()
await this.programStorage.appendRun(agent.name, {
id: runId,
programId: program.id,
agentId,
startedAt,
status: 'running',
trigger: 'manual',
sessionKey,
})
try {
const stream = await this.chatStream(agentId, sessionKey, program.prompt)
const result = await this.collectProgramRunResult(stream)
const completedAt = new Date().toISOString()
const summary =
result.finalResult
?.split('\n')
.find((line) => line.trim())
?.trim() ?? `Completed ${program.name}`
const updatedRun = await this.programStorage.updateRun(
agent.name,
runId,
{
completedAt,
status: 'completed',
finalResult: result.finalResult,
summary: summary.slice(0, 280),
sessionKey: result.sessionKey ?? sessionKey,
},
)
await this.programStorage.updateProgram(agent.name, program.id, {
lastRunAt: completedAt,
})
if (!updatedRun) {
throw new Error('Program run record disappeared during completion')
}
return updatedRun
} catch (error) {
const completedAt = new Date().toISOString()
const message = error instanceof Error ? error.message : String(error)
const updatedRun = await this.programStorage.updateRun(
agent.name,
runId,
{
completedAt,
status: 'failed',
error: message,
},
)
if (updatedRun) {
return updatedRun
}
throw error
}
}
// ── Provider Keys ────────────────────────────────────────────────────
async updateProviderKeys(input: {
@@ -607,6 +691,58 @@ export class OpenClawService {
}
}
private async findAgentById(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await this.listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
private async collectProgramRunResult(
stream: ReadableStream<OpenClawStreamEvent>,
): Promise<{ finalResult: string; sessionKey?: string }> {
const reader = stream.getReader()
let text = ''
let finalText: string | null = null
let sessionKey: string | undefined
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
switch (value.type) {
case 'text-delta':
text += (value.data.text as string) ?? ''
break
case 'done':
finalText = ((value.data.text as string) ?? '').trim() || null
break
case 'lifecycle':
if (typeof value.data.sessionKey === 'string') {
sessionKey = value.data.sessionKey
}
break
case 'error': {
const message =
(value.data.message as string) ??
(value.data.error as string) ??
'Program run failed'
throw new Error(message)
}
}
}
} finally {
await reader.cancel()
}
const resolvedText = finalText ?? text.trim()
return {
finalResult: resolvedText,
sessionKey,
}
}
// ── Internal ─────────────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,116 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BrowserOSAgentProgram } from '@browseros/shared/types/role-programs'
import type { OpenClawProgramStorage } from './program-storage'
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'manual only'
case 'daily': {
const weekdaySummary = program.schedule.daysOfWeek?.length
? ` on ${program.schedule.daysOfWeek.join(', ')}`
: ''
return `daily at ${program.schedule.time}${weekdaySummary}`
}
case 'hourly':
return `every ${program.schedule.interval} hour(s)`
case 'minutes':
return `every ${program.schedule.interval} minute(s)`
}
}
function buildProgramsMd(programs: BrowserOSAgentProgram[]): string {
const sections =
programs.length === 0
? ['No BrowserOS-managed programs configured yet.']
: programs.map(
(program) => `## ${program.name}
- Status: ${program.enabled ? 'enabled' : 'disabled'}
- Schedule: ${describeSchedule(program)}
- Goal: ${program.description}
- Prompt: ${program.prompt}
`,
)
return `# BrowserOS Programs
This file is generated by BrowserOS. Edit program settings in BrowserOS, not here.
${sections.join('\n')}
`
}
function buildStandingOrdersMd(programs: BrowserOSAgentProgram[]): string {
const sections = programs.flatMap((program) => {
if (program.standingOrders.length === 0) return []
const lines = program.standingOrders
.map(
(order) =>
`- ${order.title} (${order.enabled ? 'enabled' : 'disabled'}): ${order.instruction}`,
)
.join('\n')
return [`## ${program.name}\n${lines}`]
})
return `# Standing Orders
This file is generated by BrowserOS. Edit standing orders in BrowserOS, not here.
${sections.length > 0 ? sections.join('\n\n') : 'No standing orders configured yet.'}
`
}
function buildProgramsMetadata(
agentName: string,
programs: BrowserOSAgentProgram[],
): string {
return `${JSON.stringify(
{
version: 1,
agentName,
programs: programs.map((program) => ({
id: program.id,
name: program.name,
enabled: program.enabled,
schedule: program.schedule,
updatedAt: program.updatedAt,
})),
},
null,
2,
)}\n`
}
export class OpenClawProgramMaterializer {
constructor(
private openclawDir: string,
private storage: OpenClawProgramStorage,
) {}
private getHostWorkspaceDir(agentName: string): string {
return join(
this.openclawDir,
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
async syncAgentPrograms(agentName: string): Promise<void> {
const programs = await this.storage.listPrograms(agentName)
const workspaceDir = this.getHostWorkspaceDir(agentName)
await mkdir(workspaceDir, { recursive: true })
await Promise.all([
writeFile(join(workspaceDir, 'PROGRAMS.md'), buildProgramsMd(programs)),
writeFile(
join(workspaceDir, 'STANDING-ORDERS.md'),
buildStandingOrdersMd(programs),
),
writeFile(
join(workspaceDir, '.browseros-programs.json'),
buildProgramsMetadata(agentName, programs),
),
])
}
}

View File

@@ -0,0 +1,174 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
interface ProgramStorageAgent {
agentId: string
name: string
role?: {
roleId?: string
}
}
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const content = await readFile(filePath, 'utf-8')
return JSON.parse(content) as T
} catch {
return fallback
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function sortPrograms(programs: BrowserOSAgentProgram[]) {
return [...programs].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
)
}
export class OpenClawProgramStorage {
constructor(private openclawDir: string) {}
private getProgramsFile(agentName: string): string {
return join(this.openclawDir, 'programs', `${agentName}.json`)
}
private getProgramRunsFile(agentName: string): string {
return join(this.openclawDir, 'program-runs', `${agentName}.json`)
}
async listPrograms(agentName: string): Promise<BrowserOSAgentProgram[]> {
const programs = await readJsonFile<BrowserOSAgentProgram[]>(
this.getProgramsFile(agentName),
[],
)
return sortPrograms(programs)
}
async getProgram(
agentName: string,
programId: string,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
return programs.find((program) => program.id === programId) ?? null
}
async createProgram(
agent: ProgramStorageAgent,
input: CreateAgentProgramInput,
): Promise<BrowserOSAgentProgram> {
const programs = await this.listPrograms(agent.name)
const now = new Date().toISOString()
const program: BrowserOSAgentProgram = {
id: crypto.randomUUID(),
agentId: agent.agentId,
agentName: agent.name,
roleId: agent.role?.roleId,
name: input.name,
description: input.description,
prompt: input.prompt,
schedule: input.schedule,
enabled: input.enabled ?? true,
standingOrders: input.standingOrders ?? [],
createdAt: now,
updatedAt: now,
}
await writeJsonFile(this.getProgramsFile(agent.name), [
...programs,
program,
])
return program
}
async updateProgram(
agentName: string,
programId: string,
input: UpdateAgentProgramInput,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
const current = programs.find((program) => program.id === programId)
if (!current) return null
const nextProgram: BrowserOSAgentProgram = {
...current,
...input,
updatedAt: new Date().toISOString(),
}
await writeJsonFile(
this.getProgramsFile(agentName),
programs.map((program) =>
program.id === programId ? nextProgram : program,
),
)
return nextProgram
}
async deleteProgram(agentName: string, programId: string): Promise<boolean> {
const programs = await this.listPrograms(agentName)
const remaining = programs.filter((program) => program.id !== programId)
if (remaining.length === programs.length) return false
await writeJsonFile(this.getProgramsFile(agentName), remaining)
return true
}
async listRuns(agentName: string): Promise<BrowserOSProgramRun[]> {
const runs = await readJsonFile<BrowserOSProgramRun[]>(
this.getProgramRunsFile(agentName),
[],
)
return [...runs].sort((left, right) =>
right.startedAt.localeCompare(left.startedAt),
)
}
async writeRuns(
agentName: string,
runs: BrowserOSProgramRun[],
): Promise<void> {
await writeJsonFile(this.getProgramRunsFile(agentName), runs)
}
async appendRun(
agentName: string,
run: BrowserOSProgramRun,
): Promise<BrowserOSProgramRun> {
const runs = await this.listRuns(agentName)
await this.writeRuns(agentName, [run, ...runs])
return run
}
async updateRun(
agentName: string,
runId: string,
input: Partial<BrowserOSProgramRun>,
): Promise<BrowserOSProgramRun | null> {
const runs = await this.listRuns(agentName)
const current = runs.find((run) => run.id === runId)
if (!current) return null
const nextRun: BrowserOSProgramRun = {
...current,
...input,
}
await this.writeRuns(
agentName,
runs.map((run) => (run.id === runId ? nextRun : run)),
)
return nextRun
}
}

View File

@@ -0,0 +1,179 @@
import type {
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function assertNonEmptyString(
value: unknown,
field: string,
): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} is required`)
}
}
function validateStandingOrder(value: unknown): BrowserOSStandingOrder {
if (!isRecord(value)) {
throw new Error('Standing orders must be objects')
}
assertNonEmptyString(value.title, 'Standing order title')
assertNonEmptyString(value.instruction, 'Standing order instruction')
if (typeof value.enabled !== 'boolean') {
throw new Error('Standing order enabled must be a boolean')
}
return {
id:
typeof value.id === 'string' && value.id.trim() !== ''
? value.id
: crypto.randomUUID(),
title: value.title.trim(),
instruction: value.instruction.trim(),
enabled: value.enabled,
}
}
function validateStandingOrders(
value: unknown,
): BrowserOSStandingOrder[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value)) {
throw new Error('standingOrders must be an array')
}
return value.map(validateStandingOrder)
}
function isValidTime(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
}
function validateDaysOfWeek(value: unknown): Array<0 | 1 | 2 | 3 | 4 | 5 | 6> {
if (!Array.isArray(value)) {
throw new Error('schedule.daysOfWeek must be an array')
}
return value.map((day) => {
if (
typeof day !== 'number' ||
!Number.isInteger(day) ||
day < 0 ||
day > 6
) {
throw new Error('schedule.daysOfWeek must contain values from 0 to 6')
}
return day as 0 | 1 | 2 | 3 | 4 | 5 | 6
})
}
function validateSchedule(value: unknown): BrowserOSProgramSchedule {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new Error('schedule is required')
}
switch (value.type) {
case 'manual':
return { type: 'manual' }
case 'daily': {
assertNonEmptyString(value.time, 'schedule.time')
if (!isValidTime(value.time)) {
throw new Error('schedule.time must be in HH:MM format')
}
return {
type: 'daily',
time: value.time,
daysOfWeek:
value.daysOfWeek === undefined
? undefined
: validateDaysOfWeek(value.daysOfWeek),
}
}
case 'hourly':
case 'minutes': {
if (
typeof value.interval !== 'number' ||
!Number.isInteger(value.interval) ||
value.interval < 1
) {
throw new Error('schedule.interval must be an integer >= 1')
}
return {
type: value.type,
interval: value.interval,
}
}
default:
throw new Error('schedule.type is invalid')
}
}
export function validateCreateProgramInput(
value: unknown,
): CreateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
assertNonEmptyString(value.name, 'name')
assertNonEmptyString(value.description, 'description')
assertNonEmptyString(value.prompt, 'prompt')
return {
name: value.name.trim(),
description: value.description.trim(),
prompt: value.prompt.trim(),
schedule: validateSchedule(value.schedule),
enabled: value.enabled === undefined ? true : !!value.enabled,
standingOrders: validateStandingOrders(value.standingOrders) ?? [],
}
}
export function validateUpdateProgramInput(
value: unknown,
): UpdateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
const output: UpdateAgentProgramInput = {}
if (value.name !== undefined) {
assertNonEmptyString(value.name, 'name')
output.name = value.name.trim()
}
if (value.description !== undefined) {
assertNonEmptyString(value.description, 'description')
output.description = value.description.trim()
}
if (value.prompt !== undefined) {
assertNonEmptyString(value.prompt, 'prompt')
output.prompt = value.prompt.trim()
}
if (value.enabled !== undefined) {
if (typeof value.enabled !== 'boolean') {
throw new Error('enabled must be a boolean')
}
output.enabled = value.enabled
}
if (value.schedule !== undefined) {
output.schedule = validateSchedule(value.schedule)
}
if (value.standingOrders !== undefined) {
output.standingOrders = validateStandingOrders(value.standingOrders)
}
if (Object.keys(output).length === 0) {
throw new Error('At least one program field must be provided')
}
return output
}

View File

@@ -57,6 +57,10 @@
"types": "./src/types/role-aware-agents.ts",
"default": "./src/types/role-aware-agents.ts"
},
"./types/role-programs": {
"types": "./src/types/role-programs.ts",
"default": "./src/types/role-programs.ts"
},
"./schemas/llm": {
"types": "./src/schemas/llm.ts",
"default": "./src/schemas/llm.ts"

View File

@@ -0,0 +1,73 @@
export type BrowserOSProgramSchedule =
| {
type: 'daily'
time: string
daysOfWeek?: Array<0 | 1 | 2 | 3 | 4 | 5 | 6>
}
| {
type: 'hourly'
interval: number
}
| {
type: 'minutes'
interval: number
}
| {
type: 'manual'
}
export interface BrowserOSStandingOrder {
id: string
title: string
instruction: string
enabled: boolean
}
export interface BrowserOSAgentProgram {
id: string
agentId: string
agentName: string
roleId?: string
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled: boolean
standingOrders: BrowserOSStandingOrder[]
createdAt: string
updatedAt: string
lastRunAt?: string
}
export interface BrowserOSProgramRun {
id: string
programId: string
agentId: string
startedAt: string
completedAt?: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
trigger: 'manual' | 'schedule' | 'retry'
summary?: string
finalResult?: string
error?: string
sessionKey?: string
}
export interface CreateAgentProgramInput {
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
}
export interface UpdateAgentProgramInput {
name?: string
description?: string
prompt?: string
schedule?: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
lastRunAt?: string
}