Compare commits

...

1 Commits

Author SHA1 Message Date
Dani Akash
1fdad55b4a feat: add agent program management backend 2026-04-14 20:15:21 +05:30
6 changed files with 645 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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