mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
3 Commits
dev1/claud
...
feat/progr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d343fd1735 | ||
|
|
63fed8e79b | ||
|
|
1fdad55b4a |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user