Files
sausage/extensions/automations/server.ts
T

187 lines
13 KiB
TypeScript

/** World-file definitions, durable admission, script execution, and subscriber delivery. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { arr, bool, int, map, obj, opt, str, union } from '@earendil-works/pi-schema'
import { defineDoc, defineExtension, defineTask, defineTool, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { SubagentsApi } from '../subagents/api.ts'
import { scheduler } from './scheduler.ts'
import { triggers, TRIGGER_EVENTS } from './triggers.ts'
import { parseSchedule } from './schedule.ts'
import { fireText, doneText, trimLog } from './types.ts'
import type { Args, Automation, Subscriber } from './types.ts'
export type { Automation, Run } from './types.ts'
export const AutomationsApi = {name:'automations'}
export const Automations = defineDoc<{next:number;automations:Record<string,Automation>}>({kind:'main-agent.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})})
const Receipts = defineDoc<Record<string,any>>({kind:'main-agent.automation-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
const ScriptFires = defineDoc<Record<string,boolean>>({kind:'automations.script-fires',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
const owner = {ownership:{kind:'conversation'},conversationId:ROOT,background:true} as const
const terminal = {status:'terminal',outcome:{status:'completed',result:null}} as const
const json = (value:any) => JSON.parse(JSON.stringify(value))
export default function automations(host:any) {
const now = () => host.harness().options?.now?.() ?? Date.now()
const directory = (id:string) => `/src/sausage/automations/${id}`
const path = (id:string) => `${directory(id)}/script.sh`
function subscriber(target:Subscriber):Subscriber {
if(typeof target==='number' || /^@[a-zA-Z0-9_-]+$/.test(target)) return target
if(/^#[1-9]\d*$/.test(target)) return Number(target.slice(1))
throw new Error('Subscriber must be #id or @name')
}
async function resolve(target:Subscriber) {
return typeof target === 'number' ? target : host.need(SubagentsApi).mainAgent(target.slice(1),context)
}
const Delivery = defineTask<any,{phase:'deliver'},null>({name:'automations.delivery',version:1,initial:()=>({phase:'deliver'}),phases:{deliver:async(task,runtime,ctx)=>{
const id = await resolve(task.input.target)
const conversation = await runtime.conversation(id,ctx)
await conversation!.submit({type:'input',requestId:`automation:${task.input.event}:${String(task.input.target)}`,content:task.input.text},ctx)
await runtime.commit(()=>terminal,ctx)
}},abort:(_task,runtime,ctx)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
async function deliver(tx:any,a:Automation,event:number|string,kind:string,text:string) {
for (const target of a.subscribers) await tx.createTask(Delivery,{target,event,text:`[automation ${a.id} ${kind}] ${a.name}${text ? `\n${text}` : ''}`},owner)
}
const clock = scheduler(host,Automations,deliver)
const conditions = triggers(host,Automations,clock.fire,clock.prepareFires)
const Script = defineTask<{id:string},{phase:'prepare'}|{phase:'write';watcher:number}|{phase:'execute'},null>({name:'automations.script',version:1,initial:()=>({phase:'prepare'}),phases:{
prepare:async(task,runtime,ctx)=>{
const a = (await host.harness().snapshot(Automations,ctx))?.automations[task.input.id]
if (!a?.enabled || !a.script || a.scheduler!==task.id) return runtime.commit(()=>terminal,ctx)
await persist(a)
try { await host.fs.stat(path(a.id)); return runtime.commit(()=>({status:'running',checkpoint:{phase:'execute'}}),ctx) } catch(error) { if((error as any).code !== 'not_found') throw error }
const parent = a.executionConversation === undefined ? await runtime.agent(ctx) : await (await runtime.conversation(a.executionConversation,ctx))!.agent(ctx)
await runtime.commit(async(tx)=>{
const child = await host.need(SubagentsApi).createChild(tx,{owner:{kind:'task',taskId:task.id},parentAgent:parent,label:'watcher',title:a.name,excludeTools:['automation'],excludeExtensions:['automations'],cwd:directory(a.id)})
;(await tx.doc(Automations)).automations[a.id].script!.conversationId = child.id
return {status:'running',checkpoint:{phase:'write',watcher:child.id}}
},ctx)
},
write:async(task,runtime,ctx)=>{
const a = (await host.harness().snapshot(Automations,ctx))!.automations[task.input.id]
const watcher = await runtime.conversation(task.state.checkpoint.watcher,ctx)
await (await watcher!.submit({type:'input',requestId:`automation-write:${task.id}`,content:`Write ${path(a.id)} for this request: ${a.script!.ask}\nUse stdout FIRE followed by an optional message to trigger the automation. Only stdout has a protocol. Print DONE and exit 0 when complete. Never read host secrets. Use curl normally. The host injects the creator's grants.`},ctx)).wait(ctx)
await runtime.commit(()=>({status:'running',checkpoint:{phase:'execute'}}),ctx)
},
execute:async(task,runtime,ctx)=>{
const a = (await host.harness().snapshot(Automations,ctx))!.automations[task.input.id]
if (!a?.enabled || !a.script || a.scheduler!==task.id) return runtime.commit(()=>terminal,ctx)
const conversations = a.executionConversation === undefined ? [] : await host.chain(a.executionConversation)
let queue = Promise.resolve(), log='', done=false, sequence=0
const pending:Record<string,string> = {stdout:'',stderr:''}
function line(text:string,stream:string) {
log=trimLog(log+text+'\n')
if(doneText(text,stream==='stdout') !== undefined) done=true
const fire=fireText(text,stream==='stdout')
if(fire === undefined) return
const key=String(++sequence)
queue=queue.then(()=>host.harness().commit(async(tx:any)=>{
const receipts=await tx.doc(ScriptFires,task.id)
if(receipts[key]) return
const current=(await tx.doc(Automations)).automations[a.id]
if(current?.enabled && current.scheduler===task.id) await clock.fire(tx,current,runtime.now(),fire)
receipts[key]=true
},ctx))
}
const result=await host.exec(['sh',path(a.id)],{cwd:directory(a.id),conversations,timeout:3600,signal:runtime.signal,task:task.id,onOutput:(text:string,stream:string)=>{
pending[stream]+=text
const lines=pending[stream].split('\n'); pending[stream]=lines.pop()!
for(const text of lines) line(text.replace(/\r$/,''),stream)
}})
for(const stream of ['stdout','stderr']) if(pending[stream]) line(pending[stream],stream)
await queue
await host.fs.write(`${directory(a.id)}/log`,log)
await runtime.commit(async(tx)=>{
const current=(await tx.doc(Automations)).automations[a.id]
if(current?.script) {
current.script.exitCode=result.code
current.script.state=result.code===0 ? 'done' : 'failed'
current.script.exitedAt=runtime.now()
if(result.code!==0) current.script.error=result.stderr
if(done || current.script.once) current.enabled=false
}
return terminal
},ctx)
},
},abort:(_task,runtime,ctx)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
function required(value:any,name:string):string { if(typeof value!=='string'||!value.trim()) throw new Error(`${name} is required`); return value.trim() }
async function change(tx:any,args:Args,creator?:number) {
const ledger=await tx.doc(Automations)
if(args.action==='events') return TRIGGER_EVENTS
if(args.action==='list') return Object.values(ledger.automations)
if(args.action==='create') {
if(args.key) { const existing=Object.values(ledger.automations).find((a:Automation)=>a.key===args.key); if(existing) return existing }
const name=required(args.name,'name')
if([args.schedule,args.ask,args.event,args.condition].filter(Boolean).length!==1) throw new Error('Exactly one trigger is required')
const subscribers=(args.subscribers ?? (args.prompt ? [] : creator===undefined ? [] : [creator])).map(subscriber)
if(!args.prompt&&!subscribers.length) throw new Error('At least one of prompt or subscribers is required')
const id=`A${ledger.next++}`, at=now()
const a:Automation={id,name,enabled:true,createdAt:at,updatedAt:at,subscribers,runs:[],fires:0,
...(creator===undefined?{}:{executionConversation:creator}),...(args.key?{key:args.key}:{}),
...(args.prompt?{prompt:args.prompt}:{}),...(args.agent?{agent:args.agent}:{}),...(args.model?{model:args.model}:{}),...(args.thinking?{thinking:args.thinking}:{}),
...(args.schedule?{schedule:parseSchedule(args.schedule,at)}:{}),
...(args.event?{event:{...args.event,sinceEvent:0}}:{}),
...(args.condition?{condition:{...args.condition,cooldownMs:args.condition.cooldownMs??3600000,armed:true}}:{}),
...(args.ask?{script:{ask:args.ask,once:args.once??true,conversationId:0,state:'writing'}}:{})}
if(a.schedule) a.scheduler=await tx.createTask(clock.Scheduler,{id},owner)
if(a.script) a.scheduler=await tx.createTask(Script,{id},owner)
ledger.automations[id]=a
return json(a)
}
const a=ledger.automations[required(args.id,'id')]
if(!a) throw new Error('Unknown automation')
let abort
if(args.action==='runs') return a.runs
if(args.action==='subscribe'||args.action==='unsubscribe') {
const requested=args.to ?? creator
if(requested===undefined) throw new Error('Subscriber conversation is missing')
const target=subscriber(requested)
a.subscribers=args.action==='subscribe'?[...new Set([...a.subscribers,target])]:a.subscribers.filter((s:Subscriber)=>s!==target)
if(!a.prompt&&!a.subscribers.length) throw new Error('At least one of prompt or subscribers is required')
} else if(args.action==='pause'||args.action==='delete') {
a.enabled=false
abort=a.scheduler
delete a.scheduler
if(args.action==='delete') delete ledger.automations[a.id]
} else if(args.action==='resume') {
if(a.enabled && a.scheduler) return json(a)
a.enabled=true
if(a.schedule) a.scheduler=await tx.createTask(clock.Scheduler,{id:a.id},owner)
if(a.script) a.scheduler=await tx.createTask(Script,{id:a.id},owner)
} else if(args.action==='run') {
if(a.script) {
abort=a.scheduler
a.enabled=true
a.scheduler=await tx.createTask(Script,{id:a.id},owner)
} else return clock.fire(tx,a,now())
} else throw new Error(`Unknown action ${args.action}`)
a.updatedAt=now()
return {...json(a),...(abort===undefined?{}:{_abort:abort})}
}
async function persist(a:Automation) {
for(const part of ['/src/sausage/automations',directory(a.id)]) {
try { await host.fs.mkdir(part) } catch(error) { if((error as any).code !== 'already_exists') throw error }
}
await host.fs.write(`${directory(a.id)}/automation.json`,JSON.stringify(a,null,2))
}
const operate=async(args:Args,creator?:number)=>{
if(args.action==='log') return host.fs.read(`${directory(required(args.id,'id'))}/log`)
const result=await host.harness().commit(async(tx:any)=>json(await change(tx,args,creator)),context)
if(result?._abort) await host.harness().abortTask(result._abort,context)
if(result && !Array.isArray(result)) delete result._abort
if(result?.id && args.action!=='delete') await persist(result)
return result
}
host.implement(AutomationsApi,{operate,directory,script:path})
conditions.install()
host.onOpen(conditions.open)
host.onClose(conditions.close)
return defineExtension({name:'automations',tasks:[Delivery,clock.RunTask,clock.Scheduler,Script],tools:[defineTool({name:'automation',description:'Create schedule, event, context, or script automations. Scripts retain the creator identity. Actions: create, list, events, run, pause, resume, subscribe, unsubscribe, runs, log, delete.',parameters:obj({action:str(),id:opt(str()),name:opt(str()),schedule:opt(str()),ask:opt(str()),prompt:opt(str()),once:opt(bool()),key:opt(str()),subscribers:opt(arr(union([int({minimum:1}),str()]))),to:opt(str()),event:opt(obj({name:str(),where:opt(map(union([str(),int(),bool()])))})),condition:opt(obj({metric:str(),of:str(),above:int({minimum:1,maximum:99}),cooldownMs:opt(int()),idleFor:opt(int())}))}),execute:async(args,api,ctx)=>{
if(args.action==='log') return {content:[{type:'text',text:await host.fs.read(`${directory(required(args.id,'id'))}/log`)}]}
const result=await api.commit(async(tx:any)=>{
const receipts=await tx.doc(Receipts,api.taskId)
if(receipts[api.callId]===undefined) receipts[api.callId]=json(await change(tx,args,api.conversationId))
return json(receipts[api.callId])
},ctx)
if(result?._abort) await host.harness().abortTask(result._abort,ctx)
const output=json(result); delete output?._abort
if(output?.id && args.action!=='delete') await persist(output)
return {content:[{type:'text',text:JSON.stringify(output)}]}
}})]})
}