/** 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}>({kind:'standing.automations',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({next:1,automations:{}})}) const Receipts = defineDoc>({kind:'standing.automation-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})}) const ScriptFires = defineDoc>({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({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 = {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)}]} }})]}) }