mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 16:14:28 +00:00
Compare commits
7 Commits
fix/patch-
...
feat/progr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354b074780 | ||
|
|
678d9368d6 | ||
|
|
865ef21b5b | ||
|
|
4cba9e2020 | ||
|
|
d343fd1735 | ||
|
|
63fed8e79b | ||
|
|
1fdad55b4a |
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleBoundary,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
@@ -33,19 +37,57 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { AgentChat } from './AgentChat'
|
||||
import { AgentTerminal } from './AgentTerminal'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import { AgentProgramsPage } from './programs/AgentProgramsPage'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type OpenClawStatus,
|
||||
type RoleTemplateSummary,
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
useOpenClawRoles,
|
||||
useOpenClawStatus,
|
||||
usePodmanOverrides,
|
||||
} from './useOpenClaw'
|
||||
|
||||
const CUSTOM_ROLE_VALUE = '__custom__'
|
||||
const PLAIN_AGENT_VALUE = '__plain__'
|
||||
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
|
||||
|
||||
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
|
||||
return [
|
||||
{
|
||||
key: 'draft-external-comms',
|
||||
label: 'Draft external communications',
|
||||
description: 'May prepare outbound messages for review.',
|
||||
defaultMode: 'allow',
|
||||
},
|
||||
{
|
||||
key: 'send-external-comms',
|
||||
label: 'Send external communications',
|
||||
description: 'Should require approval before sending messages.',
|
||||
defaultMode: 'ask',
|
||||
},
|
||||
{
|
||||
key: 'calendar-mutations',
|
||||
label: 'Modify calendar events',
|
||||
description: 'Should ask before moving or creating calendar events.',
|
||||
defaultMode: 'ask',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function parseCommaSeparatedList(input: string): string[] {
|
||||
return input
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<
|
||||
OpenClawStatus['controlPlaneStatus'],
|
||||
{
|
||||
@@ -375,6 +417,7 @@ export const AgentsPage: FC = () => {
|
||||
loading: agentsLoading,
|
||||
error: agentsError,
|
||||
} = useOpenClawAgents(agentsQueryEnabled)
|
||||
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent,
|
||||
@@ -392,15 +435,43 @@ export const AgentsPage: FC = () => {
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false)
|
||||
const [setupProviderId, setSetupProviderId] = useState('')
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [selectedRoleValue, setSelectedRoleValue] = useState<
|
||||
| RoleTemplateSummary['id']
|
||||
| typeof CUSTOM_ROLE_VALUE
|
||||
| typeof PLAIN_AGENT_VALUE
|
||||
>('chief-of-staff')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [createProviderId, setCreateProviderId] = useState('')
|
||||
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
const compatibleProviders = getOpenClawSupportedProviders(providers)
|
||||
const creationMode: AgentCreationMode =
|
||||
selectedRoleValue === CUSTOM_ROLE_VALUE
|
||||
? 'custom'
|
||||
: selectedRoleValue === PLAIN_AGENT_VALUE
|
||||
? 'plain'
|
||||
: 'builtin'
|
||||
const isCustomRole = creationMode === 'custom'
|
||||
const isPlainAgent = creationMode === 'plain'
|
||||
const selectedRole =
|
||||
creationMode === 'builtin'
|
||||
? (roles.find((role) => role.id === selectedRoleValue) ??
|
||||
roles[0] ??
|
||||
null)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (compatibleProviders.length === 0) return
|
||||
@@ -419,13 +490,48 @@ export const AgentsPage: FC = () => {
|
||||
defaultProviderId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen || roles.length === 0) return
|
||||
|
||||
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
|
||||
const nextRole = defaultRole ?? roles[0]
|
||||
|
||||
setSelectedRoleValue((current) => {
|
||||
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
|
||||
return current
|
||||
const hasCurrent = roles.some((role) => role.id === current)
|
||||
return hasCurrent ? current : nextRole.id
|
||||
})
|
||||
setNewName((current) => current || nextRole.defaultAgentName)
|
||||
}, [createOpen, roles])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen])
|
||||
|
||||
if (isCustomRole) {
|
||||
setNewName(
|
||||
(current) =>
|
||||
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlainAgent) {
|
||||
setNewName((current) => current || 'agent')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedRole) {
|
||||
setNewName((current) => current || selectedRole.defaultAgentName)
|
||||
}
|
||||
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
|
||||
|
||||
const inlineError =
|
||||
error ?? statusError?.message ?? agentsError?.message ?? null
|
||||
error ??
|
||||
statusError?.message ??
|
||||
agentsError?.message ??
|
||||
rolesError?.message ??
|
||||
null
|
||||
|
||||
const gatewayUiState = useMemo(() => {
|
||||
if (!status) {
|
||||
@@ -491,10 +597,34 @@ export const AgentsPage: FC = () => {
|
||||
(item) => item.id === createProviderId,
|
||||
)
|
||||
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
const customRolePayload = isCustomRole
|
||||
? {
|
||||
...customRole,
|
||||
name: customRole.name.trim(),
|
||||
shortDescription: customRole.shortDescription.trim(),
|
||||
longDescription: customRole.longDescription.trim(),
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (
|
||||
isCustomRole &&
|
||||
(!customRolePayload?.name ||
|
||||
!customRolePayload.shortDescription ||
|
||||
!customRolePayload.longDescription)
|
||||
) {
|
||||
setError(
|
||||
'Custom roles require a role name, short description, and long description.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (creationMode === 'builtin' && !selectedRole) return
|
||||
|
||||
await runWithErrorHandling(async () => {
|
||||
await createAgent({
|
||||
name: normalizedName,
|
||||
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
|
||||
customRole: isCustomRole ? customRolePayload : undefined,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
baseUrl: provider?.baseUrl,
|
||||
@@ -503,6 +633,13 @@ export const AgentsPage: FC = () => {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setCustomRole({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -550,6 +687,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">
|
||||
@@ -793,13 +939,32 @@ export const AgentsPage: FC = () => {
|
||||
<CardTitle className="text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
{agent.role && (
|
||||
<Badge variant="secondary">
|
||||
{agent.role.roleName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-mono text-muted-foreground text-xs">
|
||||
{agent.workspace}
|
||||
</p>
|
||||
{agent.role && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.role.shortDescription}
|
||||
</p>
|
||||
)}
|
||||
</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"
|
||||
@@ -865,6 +1030,246 @@ export const AgentsPage: FC = () => {
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="agent-role">
|
||||
Agent Role
|
||||
</label>
|
||||
<Select
|
||||
value={selectedRoleValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === CUSTOM_ROLE_VALUE) {
|
||||
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
|
||||
setNewName(
|
||||
customRole.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-') || 'custom-agent',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === PLAIN_AGENT_VALUE) {
|
||||
setSelectedRoleValue(PLAIN_AGENT_VALUE)
|
||||
setNewName('agent')
|
||||
return
|
||||
}
|
||||
|
||||
const role = roles.find((item) => item.id === value)
|
||||
if (!role) return
|
||||
|
||||
setSelectedRoleValue(role.id)
|
||||
setNewName(role.defaultAgentName)
|
||||
}}
|
||||
disabled={rolesLoading}
|
||||
>
|
||||
<SelectTrigger id="agent-role">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
rolesLoading ? 'Loading roles...' : 'Select a role'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
|
||||
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRole && !isCustomRole && (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{selectedRole.name}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedRole.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-xs">
|
||||
Recommended Apps
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedRole.recommendedApps.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-xs">
|
||||
Default Boundaries
|
||||
</div>
|
||||
<ul className="space-y-1 text-muted-foreground text-xs">
|
||||
{selectedRole.boundaries.map((boundary) => (
|
||||
<li key={boundary.key}>
|
||||
{boundary.label}: {boundary.defaultMode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{isPlainAgent && (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-4">
|
||||
<div className="font-medium text-sm">Plain Agent</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No role bootstrap or defaults. Intended for temporary
|
||||
development and testing only.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomRole && (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-name"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Custom Role Name
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-name"
|
||||
value={customRole.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value
|
||||
setCustomRole((current) => ({ ...current, name }))
|
||||
setNewName(
|
||||
name.trim().toLowerCase().replace(/\s+/g, '-') ||
|
||||
'custom-agent',
|
||||
)
|
||||
}}
|
||||
placeholder="Board Prep Operator"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-short-description"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Short Description
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-short-description"
|
||||
value={customRole.shortDescription}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
shortDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Prepares executive briefs and weekly follow-ups."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-long-description"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Long Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="custom-role-long-description"
|
||||
value={customRole.longDescription}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
longDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="custom-role-apps"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Recommended Apps
|
||||
</label>
|
||||
<Input
|
||||
id="custom-role-apps"
|
||||
value={customRole.recommendedApps.join(', ')}
|
||||
onChange={(event) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
recommendedApps: parseCommaSeparatedList(
|
||||
event.target.value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="gmail, slack, notion"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Comma-separated. Used as role guidance only in this
|
||||
milestone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
Boundary Defaults
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Set the starting behavior for common high-impact
|
||||
actions.
|
||||
</p>
|
||||
</div>
|
||||
{customRole.boundaries.map((boundary) => (
|
||||
<div
|
||||
key={boundary.key}
|
||||
className="grid gap-2 rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{boundary.label}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{boundary.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={boundary.defaultMode}
|
||||
onValueChange={(value) =>
|
||||
setCustomRole((current) => ({
|
||||
...current,
|
||||
boundaries: current.boundaries.map((item) =>
|
||||
item.key === boundary.key
|
||||
? {
|
||||
...item,
|
||||
defaultMode:
|
||||
value as BrowserOSRoleBoundary['defaultMode'],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="block">Block</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="agent-name"
|
||||
@@ -898,8 +1303,10 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
rolesLoading ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
compatibleProviders.length === 0
|
||||
compatibleProviders.length === 0 ||
|
||||
(creationMode === 'builtin' && !selectedRole)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import type {
|
||||
BrowserOSAgentProgram,
|
||||
BrowserOSProgramRun,
|
||||
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'
|
||||
import { ProgramRunResultDialog } from './ProgramRunResultDialog'
|
||||
|
||||
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 [viewingRunId, setViewingRunId] = useState<string | null>(null)
|
||||
|
||||
const programNames = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(programs.map((program) => [program.id, program.name])),
|
||||
[programs],
|
||||
)
|
||||
const viewingRun: BrowserOSProgramRun | null = viewingRunId
|
||||
? (runs.find((run) => run.id === viewingRunId) ?? null)
|
||||
: null
|
||||
|
||||
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">
|
||||
Define reusable responsibilities for this agent. Programs can run
|
||||
on schedule or be triggered manually for validation.
|
||||
</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}
|
||||
onViewRun={(run) => setViewingRunId(run.id)}
|
||||
/>
|
||||
|
||||
<ProgramFormDialog
|
||||
open={dialogOpen}
|
||||
program={editingProgram}
|
||||
saving={saving}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) {
|
||||
setEditingProgram(null)
|
||||
}
|
||||
}}
|
||||
onSave={handleCreate}
|
||||
/>
|
||||
|
||||
<ProgramRunResultDialog
|
||||
run={viewingRun}
|
||||
programName={
|
||||
viewingRun
|
||||
? (programNames[viewingRun.programId] ?? 'Unknown Program')
|
||||
: undefined
|
||||
}
|
||||
onOpenChange={(open) => !open && setViewingRunId(null)}
|
||||
/>
|
||||
</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,121 @@
|
||||
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
|
||||
import { AlertCircle, CheckCircle2, Clock3, Loader2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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>
|
||||
onViewRun: (run: BrowserOSProgramRun) => void
|
||||
}
|
||||
|
||||
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,
|
||||
onViewRun,
|
||||
}: 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>
|
||||
)}
|
||||
|
||||
{(run.finalResult || run.error) && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewRun(run)}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
interface ProgramRunResultDialogProps {
|
||||
run: BrowserOSProgramRun | null
|
||||
programName?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr: string) =>
|
||||
dayjs(dateStr).format('MMM D, YYYY, h:mm A')
|
||||
|
||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
||||
if (!completedAt) return 'Still running'
|
||||
const diff = dayjs(completedAt).diff(dayjs(startedAt))
|
||||
const d = dayjs.duration(diff)
|
||||
const mins = Math.floor(d.asMinutes())
|
||||
const secs = d.seconds()
|
||||
if (mins === 0) return `${secs} seconds`
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
export const ProgramRunResultDialog: FC<ProgramRunResultDialogProps> = ({
|
||||
run,
|
||||
programName,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const content = run?.finalResult ?? run?.error ?? ''
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!run) return null
|
||||
|
||||
return (
|
||||
<Dialog open={!!run} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{run.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : run.status === 'failed' ? (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-accent-orange" />
|
||||
)}
|
||||
{programName || 'Program Run'}
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDateTime(run.startedAt)} •{' '}
|
||||
{formatDuration(run.startedAt, run.completedAt)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
{run.status === 'failed' && run.error ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="font-medium text-sm">Program failed</span>
|
||||
</div>
|
||||
<p className="text-destructive text-sm">{run.error}</p>
|
||||
</div>
|
||||
) : run.finalResult ? (
|
||||
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
|
||||
<MessageResponse>{run.finalResult}</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 text-muted-foreground text-sm">
|
||||
No result available
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
{content && (
|
||||
<Button variant="outline" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
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'
|
||||
@@ -7,6 +17,27 @@ export interface AgentEntry {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
role?: {
|
||||
roleSource: 'builtin' | 'custom'
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
roleName: string
|
||||
shortDescription: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoleTemplateSummary {
|
||||
id: BrowserOSAgentRoleId
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
defaultAgentName: string
|
||||
boundaries: Array<{
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
defaultMode: 'allow' | 'ask' | 'block'
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
@@ -16,6 +47,10 @@ export interface OpenClawStatus {
|
||||
port: number | null
|
||||
agentCount: number
|
||||
error: string | null
|
||||
scheduler?: {
|
||||
running: boolean
|
||||
activeProgramCount: number
|
||||
}
|
||||
controlPlaneStatus:
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
@@ -36,6 +71,8 @@ export interface OpenClawStatus {
|
||||
|
||||
export interface OpenClawAgentMutationInput {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
@@ -51,6 +88,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
|
||||
@@ -59,6 +99,9 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
roles: 'openclaw-roles',
|
||||
programs: 'openclaw-programs',
|
||||
programRuns: 'openclaw-program-runs',
|
||||
podmanOverrides: 'openclaw-podman-overrides',
|
||||
} as const
|
||||
|
||||
@@ -95,6 +138,38 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
return data.agents ?? []
|
||||
}
|
||||
|
||||
async function fetchOpenClawRoles(
|
||||
baseUrl: string,
|
||||
): Promise<RoleTemplateSummary[]> {
|
||||
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
|
||||
baseUrl,
|
||||
'/roles',
|
||||
)
|
||||
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> {
|
||||
@@ -104,6 +179,27 @@ 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],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
export function useOpenClawStatus(pollMs = 5000) {
|
||||
const {
|
||||
baseUrl,
|
||||
@@ -147,6 +243,71 @@ export function useOpenClawAgents(enabled = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawRoles() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<RoleTemplateSummary[], Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
|
||||
queryFn: () => fetchOpenClawRoles(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return {
|
||||
roles: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -159,6 +320,8 @@ export function useOpenClawMutations() {
|
||||
}
|
||||
|
||||
const onSuccess = () => invalidateOpenClawQueries(queryClient)
|
||||
const invalidateProgramsFor = (agentId: string) =>
|
||||
invalidateAgentProgramQueries(queryClient, ensureBaseUrl(), agentId)
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: async (input: OpenClawSetupInput) =>
|
||||
@@ -224,6 +387,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,
|
||||
@@ -232,6 +477,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 ||
|
||||
@@ -239,11 +488,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,14 @@
|
||||
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { getOpenClawDir } from '../../lib/browseros-dir'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { MonitoringChatTurn } from '../../monitoring/types'
|
||||
@@ -22,13 +28,32 @@ import {
|
||||
OpenClawProtectedAgentError,
|
||||
} from '../services/openclaw/errors'
|
||||
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
|
||||
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 getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
return 'Name is required'
|
||||
}
|
||||
return null
|
||||
function isValidBoundaryMode(
|
||||
value: unknown,
|
||||
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
|
||||
return value === 'allow' || value === 'ask' || value === 'block'
|
||||
}
|
||||
|
||||
function isValidCustomRoleBoundary(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const boundary = value as Record<string, unknown>
|
||||
return (
|
||||
typeof boundary.key === 'string' &&
|
||||
typeof boundary.label === 'string' &&
|
||||
typeof boundary.description === 'string' &&
|
||||
isValidBoundaryMode(boundary.defaultMode)
|
||||
)
|
||||
}
|
||||
|
||||
function getPodmanOverrideValidationError(body: {
|
||||
@@ -52,6 +77,19 @@ function getPodmanOverrideValidationError(body: {
|
||||
return null
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -168,23 +206,240 @@ 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)
|
||||
await getOpenClawService().refreshScheduledProgramsForAgent(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)
|
||||
await getOpenClawService().refreshScheduledProgramsForAgent(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)
|
||||
await getOpenClawService().refreshScheduledProgramsForAgent(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)
|
||||
await getOpenClawService().refreshScheduledProgramsForAgent(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) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps: role.recommendedApps,
|
||||
boundaries: role.boundaries,
|
||||
defaultAgentName: role.defaultAgentName,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
.post('/agents', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
const validationError = getCreateAgentValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
const name = body.name?.trim()
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
if (body.roleId && body.customRole) {
|
||||
return c.json(
|
||||
{ error: 'Provide either roleId or customRole, not both' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!body.customRole.name?.trim() ||
|
||||
!body.customRole.shortDescription?.trim() ||
|
||||
!body.customRole.longDescription?.trim())
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom roles require name, shortDescription, and longDescription',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!Array.isArray(body.customRole.recommendedApps) ||
|
||||
!Array.isArray(body.customRole.boundaries))
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom roles require recommendedApps and boundaries arrays',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom role recommendedApps must be an array of strings',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom role boundaries must include key, label, description, and a valid defaultMode',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name: body.name.trim(),
|
||||
name,
|
||||
roleId: body.roleId,
|
||||
customRole: body.customRole,
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
baseUrl: body.baseUrl,
|
||||
|
||||
@@ -15,6 +15,15 @@ import {
|
||||
OPENCLAW_GATEWAY_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
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 type { MonitoringChatTurn } from '../../../monitoring/types'
|
||||
@@ -48,6 +57,17 @@ import {
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
|
||||
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
|
||||
import { OpenClawProgramMaterializer } from './program-materializer'
|
||||
import {
|
||||
OpenClawProgramScheduler,
|
||||
type OpenClawSchedulerSnapshot,
|
||||
} from './program-scheduler'
|
||||
import { OpenClawProgramStorage } from './program-storage'
|
||||
import {
|
||||
buildRoleBootstrapFiles,
|
||||
resolveRoleTemplate,
|
||||
toRoleSummary,
|
||||
} from './role-bootstrap'
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
@@ -87,9 +107,12 @@ export interface OpenClawStatusResponse {
|
||||
controlPlaneStatus: OpenClawControlPlaneStatus
|
||||
lastGatewayError: string | null
|
||||
lastRecoveryReason: OpenClawGatewayRecoveryReason | null
|
||||
scheduler: OpenClawSchedulerSnapshot
|
||||
}
|
||||
|
||||
export type OpenClawAgentEntry = OpenClawAgentRecord
|
||||
export interface OpenClawAgentEntry extends OpenClawAgentRecord {
|
||||
role?: BrowserOSAgentRoleSummary
|
||||
}
|
||||
|
||||
export interface SetupInput {
|
||||
providerType?: string
|
||||
@@ -130,6 +153,9 @@ export class OpenClawService {
|
||||
private lastGatewayError: string | null = null
|
||||
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
|
||||
private stopLogTail: (() => void) | null = null
|
||||
private programStorage: OpenClawProgramStorage
|
||||
private programMaterializer: OpenClawProgramMaterializer
|
||||
private scheduler: OpenClawProgramScheduler
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
@@ -141,6 +167,17 @@ export class OpenClawService {
|
||||
this.port,
|
||||
async () => this.token,
|
||||
)
|
||||
this.programStorage = new OpenClawProgramStorage(this.openclawDir)
|
||||
this.programMaterializer = new OpenClawProgramMaterializer(
|
||||
this.openclawDir,
|
||||
this.programStorage,
|
||||
)
|
||||
this.scheduler = new OpenClawProgramScheduler(
|
||||
this.programStorage,
|
||||
this.programMaterializer,
|
||||
(agentId, program, trigger) => this.runProgram(agentId, program, trigger),
|
||||
async () => this.listAgents(),
|
||||
)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
@@ -252,6 +289,7 @@ export class OpenClawService {
|
||||
)
|
||||
}
|
||||
|
||||
await this.startScheduler()
|
||||
this.lastError = null
|
||||
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
|
||||
logger.info('OpenClaw setup complete', { port: this.port })
|
||||
@@ -284,12 +322,14 @@ export class OpenClawService {
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
await this.startScheduler()
|
||||
this.lastError = null
|
||||
logger.info('OpenClaw gateway started', { port: this.port })
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping OpenClaw service', { port: this.port })
|
||||
await this.scheduler.stop()
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.stopGateway()
|
||||
@@ -309,6 +349,7 @@ export class OpenClawService {
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
await this.scheduler.stop()
|
||||
await this.runtime.restartGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
@@ -324,6 +365,7 @@ export class OpenClawService {
|
||||
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
await this.startScheduler()
|
||||
this.lastError = null
|
||||
logProgress('Gateway restarted successfully')
|
||||
logger.info('OpenClaw gateway restarted', { port: this.port })
|
||||
@@ -352,6 +394,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.scheduler.stop()
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.stopGatewayLogTail()
|
||||
try {
|
||||
@@ -378,6 +421,7 @@ export class OpenClawService {
|
||||
controlPlaneStatus: 'disconnected',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
scheduler: this.scheduler.getSnapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +438,7 @@ export class OpenClawService {
|
||||
controlPlaneStatus: 'disconnected',
|
||||
lastGatewayError: this.lastGatewayError,
|
||||
lastRecoveryReason: this.lastRecoveryReason,
|
||||
scheduler: this.scheduler.getSnapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +469,7 @@ export class OpenClawService {
|
||||
controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected',
|
||||
lastGatewayError: this.lastGatewayError,
|
||||
lastRecoveryReason: this.lastRecoveryReason,
|
||||
scheduler: this.scheduler.getSnapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +477,8 @@ export class OpenClawService {
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
@@ -444,6 +492,8 @@ export class OpenClawService {
|
||||
|
||||
logger.debug('Creating OpenClaw agent', {
|
||||
name,
|
||||
roleId: input.roleId,
|
||||
roleSource: input.customRole ? 'custom' : input.roleId ? 'builtin' : null,
|
||||
providerType: input.providerType,
|
||||
providerName: input.providerName,
|
||||
hasBaseUrl: !!input.baseUrl,
|
||||
@@ -482,11 +532,28 @@ export class OpenClawService {
|
||||
throw error
|
||||
}
|
||||
|
||||
const roleTemplate = input.roleId
|
||||
? resolveRoleTemplate(input.roleId)
|
||||
: input.customRole
|
||||
|
||||
if (roleTemplate) {
|
||||
await this.writeRoleBootstrapFiles(name, roleTemplate)
|
||||
}
|
||||
|
||||
logger.info('Agent created via CLI', {
|
||||
agentId: agent.agentId,
|
||||
roleId: input.roleId,
|
||||
roleSource: roleTemplate
|
||||
? 'id' in roleTemplate
|
||||
? 'builtin'
|
||||
: 'custom'
|
||||
: null,
|
||||
providerType: input.providerType,
|
||||
})
|
||||
return agent
|
||||
return {
|
||||
...agent,
|
||||
role: roleTemplate ? toRoleSummary(roleTemplate) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async removeAgent(agentId: string): Promise<void> {
|
||||
@@ -511,7 +578,15 @@ export class OpenClawService {
|
||||
async listAgents(): Promise<OpenClawAgentEntry[]> {
|
||||
await this.assertGatewayReady()
|
||||
logger.debug('Listing OpenClaw agents')
|
||||
return this.runControlPlaneCall(() => this.cliClient.listAgents())
|
||||
const agents = await this.runControlPlaneCall(() =>
|
||||
this.cliClient.listAgents(),
|
||||
)
|
||||
return Promise.all(
|
||||
agents.map(async (agent) => ({
|
||||
...agent,
|
||||
role: await this.readRoleSummary(agent.name),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat Stream (HTTP) ───────────────────────────────────────────────
|
||||
@@ -539,6 +614,109 @@ export class OpenClawService {
|
||||
)
|
||||
}
|
||||
|
||||
async runProgram(
|
||||
agentId: string,
|
||||
program: BrowserOSAgentProgram,
|
||||
trigger: 'manual' | 'schedule' | 'retry',
|
||||
): 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,
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
await this.programStorage.updateProgram(
|
||||
agent.name,
|
||||
program.id,
|
||||
{ lastRunAt: completedAt },
|
||||
{ touchUpdatedAt: false },
|
||||
)
|
||||
|
||||
if (!updatedRun) {
|
||||
throw new Error('Program run record disappeared during completion')
|
||||
}
|
||||
|
||||
await this.refreshScheduledProgramsForAgent(agent.name)
|
||||
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) {
|
||||
await this.refreshScheduledProgramsForAgent(agent.name)
|
||||
return updatedRun
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runProgramOnce(
|
||||
agentId: string,
|
||||
program: BrowserOSAgentProgram,
|
||||
): Promise<BrowserOSProgramRun> {
|
||||
return this.runProgram(agentId, program, 'manual')
|
||||
}
|
||||
|
||||
async refreshScheduledProgramsForAgent(agentName: string): Promise<void> {
|
||||
try {
|
||||
await this.scheduler.refreshAgent(agentName)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh scheduled programs for agent', {
|
||||
agentName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Podman Overrides ─────────────────────────────────────────────────
|
||||
|
||||
async applyPodmanOverrides(input: {
|
||||
@@ -647,6 +825,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
await this.startScheduler()
|
||||
logger.info('OpenClaw gateway auto-started')
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
@@ -1080,6 +1259,95 @@ export class OpenClawService {
|
||||
onLog?.(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const reader = stream.getReader()
|
||||
let text = ''
|
||||
let finalText: string | null = null
|
||||
|
||||
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 'error': {
|
||||
const message =
|
||||
(value.data.message as string) ??
|
||||
(value.data.error as string) ??
|
||||
'Program run failed'
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
|
||||
return { finalResult: finalText ?? text.trim() }
|
||||
}
|
||||
|
||||
private async startScheduler(): Promise<void> {
|
||||
try {
|
||||
await this.scheduler.start()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to start OpenClaw program scheduler', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async writeRoleBootstrapFiles(
|
||||
agentName: string,
|
||||
roleTemplate:
|
||||
| BrowserOSCustomRoleInput
|
||||
| ReturnType<typeof resolveRoleTemplate>,
|
||||
): Promise<void> {
|
||||
const workspaceDir = this.getHostWorkspaceDir(agentName)
|
||||
await mkdir(workspaceDir, { recursive: true })
|
||||
const files = buildRoleBootstrapFiles({
|
||||
role: roleTemplate,
|
||||
agentName,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(files).map(([fileName, content]) =>
|
||||
writeFile(`${workspaceDir}/${fileName}`, content as string, 'utf-8'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private async readRoleSummary(
|
||||
agentName: string,
|
||||
): Promise<BrowserOSAgentRoleSummary | undefined> {
|
||||
const metadataPath = `${this.getHostWorkspaceDir(agentName)}/.browseros-role.json`
|
||||
|
||||
try {
|
||||
const content = await readFile(metadataPath, 'utf-8')
|
||||
const parsed = JSON.parse(content) as BrowserOSAgentRoleSummary
|
||||
if (!parsed || typeof parsed !== 'object') return undefined
|
||||
if (typeof parsed.roleName !== 'string') return undefined
|
||||
if (typeof parsed.shortDescription !== 'string') return undefined
|
||||
return parsed
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let service: OpenClawService | null = null
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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)}
|
||||
- Next run: ${program.nextRunAt ?? 'not scheduled'}
|
||||
- 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,
|
||||
lastRunAt: program.lastRunAt,
|
||||
nextRunAt: program.nextRunAt,
|
||||
})),
|
||||
},
|
||||
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,118 @@
|
||||
import type {
|
||||
BrowserOSAgentProgram,
|
||||
BrowserOSProgramSchedule,
|
||||
} from '@browseros/shared/types/role-programs'
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const HOUR_MS = 60 * MINUTE_MS
|
||||
|
||||
function toValidDate(value?: string): Date | null {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function getIntervalMs(
|
||||
schedule: Extract<BrowserOSProgramSchedule, { type: 'minutes' | 'hourly' }>,
|
||||
): number {
|
||||
return schedule.type === 'minutes'
|
||||
? schedule.interval * MINUTE_MS
|
||||
: schedule.interval * HOUR_MS
|
||||
}
|
||||
|
||||
function getAnchorDate(program: BrowserOSAgentProgram, now: Date): Date {
|
||||
return (
|
||||
toValidDate(program.lastRunAt) ??
|
||||
toValidDate(program.updatedAt) ??
|
||||
toValidDate(program.createdAt) ??
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
function getNextIntervalRunAt(
|
||||
program: BrowserOSAgentProgram,
|
||||
now: Date,
|
||||
): Date | null {
|
||||
const schedule = program.schedule
|
||||
if (schedule.type !== 'minutes' && schedule.type !== 'hourly') return null
|
||||
|
||||
const intervalMs = getIntervalMs(schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
|
||||
const anchor = getAnchorDate(program, now)
|
||||
let nextRunAt = new Date(anchor.getTime() + intervalMs)
|
||||
|
||||
while (nextRunAt.getTime() <= now.getTime()) {
|
||||
nextRunAt = new Date(nextRunAt.getTime() + intervalMs)
|
||||
}
|
||||
|
||||
return nextRunAt
|
||||
}
|
||||
|
||||
function getNextDailyRunAt(
|
||||
program: BrowserOSAgentProgram,
|
||||
now: Date,
|
||||
): Date | null {
|
||||
if (program.schedule.type !== 'daily') return null
|
||||
|
||||
const [hoursString, minutesString] = program.schedule.time.split(':')
|
||||
const hours = Number.parseInt(hoursString ?? '', 10)
|
||||
const minutes = Number.parseInt(minutesString ?? '', 10)
|
||||
|
||||
if (
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes) ||
|
||||
hours < 0 ||
|
||||
hours > 23 ||
|
||||
minutes < 0 ||
|
||||
minutes > 59
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allowedDays = program.schedule.daysOfWeek?.length
|
||||
? new Set(program.schedule.daysOfWeek)
|
||||
: null
|
||||
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
const candidate = new Date(now)
|
||||
candidate.setDate(now.getDate() + offset)
|
||||
candidate.setHours(hours, minutes, 0, 0)
|
||||
|
||||
if (
|
||||
allowedDays &&
|
||||
!allowedDays.has(candidate.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (candidate.getTime() > now.getTime()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isSchedulableProgram(program: BrowserOSAgentProgram): boolean {
|
||||
return program.schedule.type !== 'manual'
|
||||
}
|
||||
|
||||
export function getNextProgramRunAt(
|
||||
program: BrowserOSAgentProgram,
|
||||
now = new Date(),
|
||||
): Date | null {
|
||||
if (!program.enabled || !isSchedulableProgram(program)) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (program.schedule.type) {
|
||||
case 'minutes':
|
||||
case 'hourly':
|
||||
return getNextIntervalRunAt(program, now)
|
||||
case 'daily':
|
||||
return getNextDailyRunAt(program, now)
|
||||
case 'manual':
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type {
|
||||
BrowserOSAgentProgram,
|
||||
BrowserOSProgramRun,
|
||||
} from '@browseros/shared/types/role-programs'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { OpenClawProgramMaterializer } from './program-materializer'
|
||||
import { getNextProgramRunAt, isSchedulableProgram } from './program-schedule'
|
||||
import type { OpenClawProgramStorage } from './program-storage'
|
||||
|
||||
interface ScheduledProgramHandle {
|
||||
programId: string
|
||||
agentId: string
|
||||
agentName: string
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
nextRunAt: string
|
||||
}
|
||||
|
||||
export interface OpenClawSchedulerSnapshot {
|
||||
running: boolean
|
||||
activeProgramCount: number
|
||||
}
|
||||
|
||||
export class OpenClawProgramScheduler {
|
||||
private handles = new Map<string, ScheduledProgramHandle>()
|
||||
private running = false
|
||||
|
||||
constructor(
|
||||
private programStorage: OpenClawProgramStorage,
|
||||
private programMaterializer: OpenClawProgramMaterializer,
|
||||
private runProgram: (
|
||||
agentId: string,
|
||||
program: BrowserOSAgentProgram,
|
||||
trigger: 'manual' | 'schedule' | 'retry',
|
||||
) => Promise<BrowserOSProgramRun>,
|
||||
private listAgents: () => Promise<Array<{ agentId: string; name: string }>>,
|
||||
) {}
|
||||
|
||||
getSnapshot(): OpenClawSchedulerSnapshot {
|
||||
return {
|
||||
running: this.running,
|
||||
activeProgramCount: this.handles.size,
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.running = true
|
||||
await this.rehydrate()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false
|
||||
this.clearAllHandles()
|
||||
}
|
||||
|
||||
async rehydrate(): Promise<void> {
|
||||
this.clearAllHandles()
|
||||
if (!this.running) return
|
||||
|
||||
const agents = await this.listAgents()
|
||||
for (const agent of agents) {
|
||||
await this.refreshAgent(agent.name)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAgent(agentName: string): Promise<void> {
|
||||
this.clearAgentHandles(agentName)
|
||||
|
||||
const programs = await this.programStorage.listPrograms(agentName)
|
||||
let materialized = false
|
||||
|
||||
for (const program of programs) {
|
||||
const changed = await this.syncProgram(program)
|
||||
materialized = materialized || changed
|
||||
}
|
||||
|
||||
if (materialized) {
|
||||
await this.programMaterializer.syncAgentPrograms(agentName)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProgram(agentName: string, programId: string): Promise<void> {
|
||||
this.clearProgramHandle(programId)
|
||||
|
||||
const program = await this.programStorage.getProgram(agentName, programId)
|
||||
const changed = program ? await this.syncProgram(program) : false
|
||||
|
||||
if (changed) {
|
||||
await this.programMaterializer.syncAgentPrograms(agentName)
|
||||
}
|
||||
}
|
||||
|
||||
async removeProgram(programId: string): Promise<void> {
|
||||
this.clearProgramHandle(programId)
|
||||
}
|
||||
|
||||
private clearAllHandles(): void {
|
||||
for (const handle of this.handles.values()) {
|
||||
clearTimeout(handle.timeout)
|
||||
}
|
||||
this.handles.clear()
|
||||
}
|
||||
|
||||
private clearAgentHandles(agentName: string): void {
|
||||
for (const [programId, handle] of this.handles.entries()) {
|
||||
if (handle.agentName !== agentName) continue
|
||||
clearTimeout(handle.timeout)
|
||||
this.handles.delete(programId)
|
||||
}
|
||||
}
|
||||
|
||||
private clearProgramHandle(programId: string): void {
|
||||
const handle = this.handles.get(programId)
|
||||
if (!handle) return
|
||||
|
||||
clearTimeout(handle.timeout)
|
||||
this.handles.delete(programId)
|
||||
}
|
||||
|
||||
private async syncProgram(program: BrowserOSAgentProgram): Promise<boolean> {
|
||||
if (!this.running) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!program.enabled || !isSchedulableProgram(program)) {
|
||||
if (!program.nextRunAt) return false
|
||||
|
||||
await this.programStorage.updateProgram(
|
||||
program.agentName,
|
||||
program.id,
|
||||
{ nextRunAt: undefined },
|
||||
{ touchUpdatedAt: false },
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const nextRunAt = getNextProgramRunAt(program)
|
||||
const nextRunAtIso = nextRunAt?.toISOString()
|
||||
let changed = false
|
||||
|
||||
if (program.nextRunAt !== nextRunAtIso) {
|
||||
await this.programStorage.updateProgram(
|
||||
program.agentName,
|
||||
program.id,
|
||||
{ nextRunAt: nextRunAtIso },
|
||||
{ touchUpdatedAt: false },
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!nextRunAt) {
|
||||
return changed
|
||||
}
|
||||
|
||||
const delayMs = Math.max(1000, nextRunAt.getTime() - Date.now())
|
||||
const timeout = setTimeout(() => {
|
||||
void this.executeScheduledProgram({
|
||||
agentId: program.agentId,
|
||||
agentName: program.agentName,
|
||||
programId: program.id,
|
||||
})
|
||||
}, delayMs)
|
||||
|
||||
this.handles.set(program.id, {
|
||||
programId: program.id,
|
||||
agentId: program.agentId,
|
||||
agentName: program.agentName,
|
||||
timeout,
|
||||
nextRunAt: nextRunAtIso ?? program.nextRunAt ?? new Date().toISOString(),
|
||||
})
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
private async executeScheduledProgram(input: {
|
||||
agentId: string
|
||||
agentName: string
|
||||
programId: string
|
||||
}): Promise<void> {
|
||||
this.handles.delete(input.programId)
|
||||
if (!this.running) return
|
||||
|
||||
const program = await this.programStorage.getProgram(
|
||||
input.agentName,
|
||||
input.programId,
|
||||
)
|
||||
|
||||
if (!program || !program.enabled || !isSchedulableProgram(program)) {
|
||||
await this.refreshProgram(input.agentName, input.programId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runProgram(input.agentId, program, 'schedule')
|
||||
logger.info('Scheduled program run completed', {
|
||||
agentId: input.agentId,
|
||||
agentName: input.agentName,
|
||||
programId: input.programId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Scheduled program run failed', {
|
||||
agentId: input.agentId,
|
||||
agentName: input.agentName,
|
||||
programId: input.programId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} finally {
|
||||
if (this.running) {
|
||||
await this.refreshProgram(input.agentName, input.programId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateProgramOptions {
|
||||
touchUpdatedAt?: boolean
|
||||
}
|
||||
|
||||
const MAX_PROGRAM_RUNS_PER_AGENT = 100
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRuns(runs: BrowserOSProgramRun[]) {
|
||||
return [...runs]
|
||||
.sort((left, right) => right.startedAt.localeCompare(left.startedAt))
|
||||
.slice(0, MAX_PROGRAM_RUNS_PER_AGENT)
|
||||
}
|
||||
|
||||
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,
|
||||
options?: UpdateProgramOptions,
|
||||
): 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:
|
||||
options?.touchUpdatedAt === false
|
||||
? current.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 normalizeRuns(runs)
|
||||
}
|
||||
|
||||
async writeRuns(
|
||||
agentName: string,
|
||||
runs: BrowserOSProgramRun[],
|
||||
): Promise<void> {
|
||||
await writeJsonFile(this.getProgramRunsFile(agentName), normalizeRuns(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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
type BROWSEROS_ROLE_TEMPLATES,
|
||||
getBrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSAgentRoleSummary,
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
|
||||
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
|
||||
interface BootstrapRenderableRole {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
bootstrap: BrowserOSRoleTemplate['bootstrap']
|
||||
}
|
||||
|
||||
export interface RoleBootstrapFiles {
|
||||
'AGENTS.md': string
|
||||
'SOUL.md': string
|
||||
'TOOLS.md': string
|
||||
'.browseros-role.json': string
|
||||
}
|
||||
|
||||
export function resolveRoleTemplate(
|
||||
roleId: BrowserOSAgentRoleId,
|
||||
): RoleTemplate {
|
||||
const role = getBrowserOSRoleTemplate(roleId)
|
||||
if (!role) {
|
||||
throw new Error(`Unknown BrowserOS role: ${roleId}`)
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
export function buildRoleBootstrapFiles(input: {
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
|
||||
agentName: string
|
||||
}): RoleBootstrapFiles {
|
||||
const normalizedRole = normalizeRoleForBootstrap(input.role)
|
||||
const roleId = 'id' in input.role ? input.role.id : undefined
|
||||
return {
|
||||
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
|
||||
'SOUL.md': normalizedRole.bootstrap.soulMd,
|
||||
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
|
||||
'.browseros-role.json': `${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
roleSource: roleId ? 'builtin' : 'custom',
|
||||
roleId,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
createdBy: 'browseros',
|
||||
agentName: input.agentName,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
export function toRoleSummary(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BrowserOSAgentRoleSummary {
|
||||
const normalizedRole = normalizeRoleForBootstrap(role)
|
||||
return {
|
||||
roleSource: 'id' in role ? 'builtin' : 'custom',
|
||||
roleId: 'id' in role ? role.id : undefined,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCustomRole(
|
||||
role: BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
const recommendedApps = Array.isArray(role.recommendedApps)
|
||||
? role.recommendedApps.filter(
|
||||
(app): app is string => typeof app === 'string',
|
||||
)
|
||||
: []
|
||||
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
|
||||
|
||||
return {
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps,
|
||||
boundaries,
|
||||
bootstrap: {
|
||||
agentsMd:
|
||||
role.bootstrap?.agentsMd?.trim() ||
|
||||
buildAgentsMd({
|
||||
name: role.name,
|
||||
longDescription: role.longDescription,
|
||||
boundaries,
|
||||
}),
|
||||
soulMd:
|
||||
role.bootstrap?.soulMd?.trim() ||
|
||||
buildSoulMd({
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
}),
|
||||
toolsMd:
|
||||
role.bootstrap?.toolsMd?.trim() ||
|
||||
buildToolsMd({
|
||||
boundaries,
|
||||
recommendedApps,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoleForBootstrap(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
return 'id' in role ? role : normalizeCustomRole(role)
|
||||
}
|
||||
|
||||
function buildAgentsMd(input: {
|
||||
name: string
|
||||
longDescription: string
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map(
|
||||
(boundary) =>
|
||||
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `# ${input.name}
|
||||
|
||||
You are the ${input.name} specialist for this workspace.
|
||||
|
||||
## Core Purpose
|
||||
${input.longDescription}
|
||||
|
||||
## Operating Rules
|
||||
${boundaryLines}
|
||||
|
||||
## Default Output Style
|
||||
- concise
|
||||
- action-oriented
|
||||
- explicit about blockers and approvals
|
||||
`
|
||||
}
|
||||
|
||||
function buildSoulMd(input: {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
}): string {
|
||||
return `# Operating Style
|
||||
|
||||
You act like a trusted ${input.name}.
|
||||
|
||||
## Working Posture
|
||||
- calm
|
||||
- structured
|
||||
- direct
|
||||
- explicit about tradeoffs
|
||||
|
||||
## Role Framing
|
||||
${input.shortDescription}
|
||||
|
||||
${input.longDescription}
|
||||
`
|
||||
}
|
||||
|
||||
function buildToolsMd(input: {
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
recommendedApps: string[]
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
|
||||
.join('\n')
|
||||
|
||||
const appsLine =
|
||||
input.recommendedApps.length > 0
|
||||
? input.recommendedApps.join(', ')
|
||||
: 'No specific apps configured yet.'
|
||||
|
||||
return `# Tooling Guidelines
|
||||
|
||||
- Use BrowserOS MCP for browser and connected SaaS tasks.
|
||||
- Prefer read, summarize, and draft flows.
|
||||
- Keep outputs in the workspace when possible so work remains inspectable.
|
||||
|
||||
## Recommended Apps
|
||||
${appsLine}
|
||||
|
||||
## Boundary Defaults
|
||||
${boundaryLines}
|
||||
`
|
||||
}
|
||||
@@ -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,75 @@
|
||||
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
|
||||
nextRunAt?: 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
|
||||
nextRunAt?: string
|
||||
}
|
||||
Reference in New Issue
Block a user