187 lines
13 KiB
TypeScript
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:'ultimate.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})})
|
|
const Receipts = defineDoc<Record<string,any>>({kind:'ultimate.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).standing(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)}]}
|
|
}})]})
|
|
}
|