Merge v3-t51: automations and pause as world extensions, host.chain (T51, P3-n)

This commit is contained in:
2026-11-07 14:51:59 +00:00
29 changed files with 37148 additions and 403 deletions
+60
View File
@@ -0,0 +1,60 @@
# Automations
The `automation` tool admits schedules, domain events, context conditions, and shell scripts.
The ledger stores durable deadlines and fire counts.
A fire creates its subscriber deliveries in the same transaction.
Each delivery uses a stable request id, so a restart does not duplicate the subscriber input.
Prompt runs use owned subagent conversations.
## Definitions and scripts
Each automation occupies `/src/sausage/automations/A<n>/` in the world.
The tool writes `automation.json` there as a readable copy of its definition.
The durable ledger remains authoritative.
The extension does not import manual changes to this copy.
Deletion retains the files.
A script uses `script.sh` in that directory.
If the file exists, the extension executes it with world `sh`.
Otherwise, a watcher writes it from the `ask` request before execution.
The watcher inherits the creator's agent configuration.
The script receives a one-hour timeout.
The host cancels execution when the task aborts.
Only stdout lines with `FIRE` trigger deliveries.
An optional message follows `FIRE`.
Stderr lines never trigger deliveries.
`DONE` takes effect after a successful exit.
The extension retains a bounded log in `log`.
Script tasks deduplicate their fire sequence across restarts.
External script effects can repeat after a crash.
## Credentials
The conversation that creates an automation remains its execution conversation.
Every script launch uses `host.chain(creator)`.
The host delegates to `conversationChains` in `host/js/environment.ts`.
The host appends the branch starter's chain after the ownership or fork ancestry.
The host removes duplicate ids without changing their order.
Subscribe and unsubscribe change notification recipients only.
They never change the execution conversation.
Built-in automations without a creator use an empty chain.
No secret value enters a definition, script environment, or watcher prompt.
## Triggers
Schedules accept `cron`, `at`, `in`, and `every` forms.
The five imported schedule cases verify IANA zones, leap days, and daylight-saving transitions in V8.
Events use the durable event service and its reactor cursor.
Conditions use measured prompt tokens and the model's context window.
A decrease of ten percentage points below the threshold re-arms a condition.
The cooldown starts with a condition fire, not a manual fire.
For `idleFor`, admitted conversation inputs reset the idle period.
## Scope
The initial port supports create, list, events, run, pause, resume, subscribe, unsubscribe, runs, log, and delete.
It retains the schedule and prompt-run implementation from Ultimate.
It replaces native process groups with host Exec.
Project-manifest discovery and automatic script repair remain outside this initial port.
Credentials use grants, not named secret files.
+7
View File
@@ -0,0 +1,7 @@
/** Durable automation lifecycle events. */
import { defineEvent } from '@earendil-works/sausage-events'
export const Fired = defineEvent('automations.fired')
export const Started = defineEvent('automations.started')
export const Exited = defineEvent('automations.exited')
export const Done = defineEvent('automations.done')
export const Escalated = defineEvent('automations.escalated')
+154
View File
@@ -0,0 +1,154 @@
/** Calendar schedules, interval deadlines, and cron time zones. */
import type { Schedule } from './types.ts'
export type { Schedule } from './types.ts'
function duration(text: string) {
const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/.exec(text)
if (!match) throw new Error('Use a duration such as 30m, 2h, or 1d')
return Number(match[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]]!
}
function field(text: string, min: number, max: number) {
const values = new Set<number>()
for (const item of text.split(',')) {
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(item)
if (!match) throw new Error(`Invalid cron field: ${text}`)
const step = Number(match[2] ?? 1)
const [start, end] =
match[1] === '*'
? [min, max]
: match[1].includes('-')
? match[1].split('-').map(Number)
: [Number(match[1]), match[2] ? max : Number(match[1])]
if (step < 1 || start < min || end > max || start > end)
throw new Error(`Invalid cron field: ${text}`)
for (let value = start; value <= end; value += step) values.add(value)
}
return values
}
function cron(expr: string, tz: string) {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) throw new Error('Cron requires five fields')
const fields = parts.map((part, i) => field(part, [0, 0, 1, 1, 0][i], [59, 23, 31, 12, 7][i]))
if (fields[4].has(7)) fields[4].add(0)
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
minute: 'numeric',
hour: 'numeric',
day: 'numeric',
month: 'numeric',
weekday: 'short',
year: 'numeric',
hourCycle: 'h23',
})
const local = (time: number) => {
const p = Object.fromEntries(
formatter.formatToParts(time).map((part) => [part.type, part.value]),
)
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday)
const day = fields[2].has(Number(p.day))
const week = fields[4].has(dow)
return {
wall: Date.UTC(
Number(p.year),
Number(p.month) - 1,
Number(p.day),
Number(p.hour),
Number(p.minute),
),
minute: Number(p.minute),
hour: Number(p.hour),
dateMatches:
fields[3].has(Number(p.month)) &&
(parts[2].startsWith('*') || parts[4].startsWith('*') ? day && week : day || week),
}
}
return { local, fields }
}
export function parseSchedule(input: string | Schedule, now = Date.now()): Schedule {
let schedule: Schedule
if (typeof input !== 'string') schedule = { ...input }
else if (input.startsWith('in '))
schedule = { kind: 'at', time: new Date(now + duration(input.slice(3).trim())).toISOString() }
else if (input.startsWith('every '))
schedule = { kind: 'every', ms: duration(input.slice(6).trim()) }
else if (input.startsWith('cron ')) {
const match = /^cron\s+"([^"]+)"(?:\s+(\S+))?$/.exec(input)
if (!match) throw new Error('Use cron "0 9 * * 1-5" Europe/Vienna')
schedule = {
kind: 'cron',
expr: match[1],
tz: match[2] ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
}
} else schedule = { kind: 'at', time: input.replace(/^at\s+/, '') }
if (schedule.kind === 'every') {
if (!Number.isFinite(schedule.ms) || schedule.ms < 1000)
throw new Error('Intervals must be at least 1s')
} else if (schedule.kind === 'at') {
if (!/^\d{4}-\d\d-\d\dT/.test(schedule.time) || !Number.isFinite(Date.parse(schedule.time)))
throw new Error('Use an ISO time')
schedule.time = new Date(schedule.time).toISOString()
} else {
schedule.tz ||= Intl.DateTimeFormat().resolvedOptions().timeZone
cron(schedule.expr, schedule.tz)
}
return schedule
}
export function nextAt(schedule: Schedule, after: number, previous = after): number {
if (schedule.kind === 'at') return Date.parse(schedule.time)
if (schedule.kind === 'every') return after + schedule.ms
const { local, fields } = cron(schedule.expr, schedule.tz)
const previousWall = local(previous).wall
const start = Math.floor(after / 60000) * 60000 + 60000
let time = start
while (time <= start + 8 * 366 * 86400000) {
const p = local(time)
if (!p.dateMatches) {
const minutes = 1440 - p.hour * 60 - p.minute
const target = p.wall + minutes * 60000
const candidate = time + minutes * 60000
// Correct the midnight jump for an offset change, without crossing a matching day.
time = Math.max(time + 60000, candidate + target - local(candidate).wall)
} else if (!fields[1].has(p.hour)) {
time += (60 - p.minute) * 60000
} else if (fields[0].has(p.minute) && p.wall !== previousWall) return time
else time += 60000
}
throw new Error('Cron has no fire within 8 years')
}
export function describeSchedule(schedule: Schedule): string {
if (schedule.kind === 'at') return `Once at ${schedule.time}`
if (schedule.kind === 'cron') {
const [minute, hour, day, month, weekday] = schedule.expr.trim().split(/\s+/)
const clock =
/^\d+$/.test(minute) && /^\d+$/.test(hour)
? `At ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
: minute === '*' && hour === '*'
? 'Every minute'
: `At minutes ${minute} of hours ${hour}`
const days =
weekday === '1-5'
? ' on weekdays'
: weekday === '*'
? ''
: ` on weekdays ${weekday} (Sunday=0)`
const dates = day === '*' ? '' : ` on month days ${day}`
const months = month === '*' ? '' : ` in months ${month}`
return `${clock}${dates}${dates && days ? ' or' : ''}${days}${months} (${schedule.tz})`
}
for (const [unit, ms] of [
['day', 86400000],
['hour', 3600000],
['minute', 60000],
['second', 1000],
] as const)
if (schedule.ms % ms === 0) {
const n = schedule.ms / ms
return `Every ${n} ${unit}${n === 1 ? '' : 's'}`
}
return `Every ${schedule.ms / 1000} seconds`
}
+280
View File
@@ -0,0 +1,280 @@
/** Durable schedule deadlines and owned prompt runs. */
import {
AssistantEntry,
AgentDoc,
defineTask,
ROOT_CONVERSATION_ID,
} from '@earendil-works/pi-durable'
import type { ConversationId, Cursor, Tx } from '@earendil-works/pi-durable'
type ExtensionHost = any
import { SubagentsApi } from '../subagents/api.ts'
import { ModelsApi } from '../models/api.ts'
import { nextAt } from './schedule.ts'
import type { Automation, Run } from './types.ts'
import { RUN_TASK } from './types.ts'
import type { Automations as Ledger } from './server.ts'
import { Escalated, Fired } from './refs.ts'
import { RunLabel } from './types.ts'
const owner = {
ownership: { kind: 'conversation' as const },
conversationId: ROOT_CONVERSATION_ID,
background: true,
}
const terminal = {
status: 'terminal' as const,
outcome: { status: 'completed' as const, result: null },
}
async function definition(host: ExtensionHost, name: string) {
const found = (
await host.need(ModelsApi).discover('/src/sausage')
).definitions.find((d) => d.name === name)
if (!found) throw new Error(`Unknown agent: ${name}`)
return found
}
export function scheduler(
host: ExtensionHost,
Automations: typeof Ledger,
deliver: (
tx: Tx,
a: Automation,
event: number | string,
kind: 'fired' | 'escalated',
text: string,
) => Promise<void>,
) {
const RunTask = defineTask<
{ automation: Automation; firedAt: number; text?: string },
{ phase: 'create' } | { phase: 'deliver'; conversationId: ConversationId },
null
>({
name: RUN_TASK,
version: 1,
initial: () => ({ phase: 'create' }),
phases: {
create: async (task, runtime, context) => {
try {
const a = task.input.automation
const parent = await runtime.agent(context)
const profile = a.agent ? await definition(host, a.agent) : undefined
await runtime.commit(async (tx) => {
const child = await host.need(SubagentsApi).createChild(tx, {
owner: { kind: 'task', taskId: task.id },
definition: profile,
parentAgent: parent,
metadata: host.metadata,
label: RunLabel,
title: `${a.name} · ${new Date(task.input.firedAt).toLocaleString()}`,
excludeTools: a.agent ? ['automation', 'subagent'] : ['automation'],
excludeExtensions: ['automations'],
cwd: (await tx.doc(AgentDoc, runtime.conversationId)).cwd ?? '/src/sausage',
model: a.model,
thinking: a.thinking,
})
const run = (await tx.doc(Automations)).automations[a.id]?.runs.find(
(r) => r.id === String(task.id),
)
if (run) run.conversationId = child.id
return { status: 'running', checkpoint: { phase: 'deliver', conversationId: child.id } }
}, context)
} catch (error) {
if (runtime.signal.aborted) throw error
await runtime.commit(async (tx) => {
finish(
await tx.doc(Automations),
task.input.automation.id,
String(task.id),
'failed',
String(error),
)
return terminal
}, context)
}
},
deliver: async (task, runtime, context) => {
try {
const child = (await runtime.conversation(task.state.checkpoint.conversationId, context))!
const result = await (
await child.submit(
{
type: 'input',
content:
task.input.automation.prompt! +
(task.input.text ? `\n\nFired: ${task.input.text}` : ''),
requestId: `automation-run:${task.id}`,
},
context,
)
).wait(context)
await runtime.commit(async (tx) => {
const entry =
result.status === 'done' && result.type === 'input'
? await tx.entry(AssistantEntry, result.answer)
: undefined
const answer = entry?.model?.[0]
const text =
answer?.role === 'assistant'
? answer.content.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('')
: undefined
finish(
await tx.doc(Automations),
task.input.automation.id,
String(task.id),
result.status === 'done'
? 'done'
: result.status === 'unanswered' && result.reason === 'aborted'
? 'aborted'
: 'failed',
text,
)
return terminal
}, context)
} catch (error) {
if (runtime.signal.aborted) throw error
await runtime.commit(async (tx) => {
finish(
await tx.doc(Automations),
task.input.automation.id,
String(task.id),
'failed',
String(error),
)
return terminal
}, context)
}
},
},
abort: (task, runtime, context) =>
runtime.commit(async (tx) => {
finish(await tx.doc(Automations), task.input.automation.id, String(task.id), 'aborted')
return { status: 'terminal', outcome: { status: 'aborted' } }
}, context),
})
async function fire(tx: Tx, a: Automation, firedAt: number, text = '') {
a.recentFires = (a.recentFires ?? []).filter((at) => at > firedAt - 3600000)
if (a.recentFires.length >= 6) {
const reason = 'Fired more than 6 times in an hour; paused'
a.enabled = false
delete a.nextAt
if (a.script) {
a.script.state = 'stopped'
a.script.error = reason
}
const event = await host.emit(tx, Escalated, { id: a.id, reason })
await deliver(tx, a, event.id, 'escalated', reason)
return { id: a.id }
}
// Scan for overlapping runs before any task/entry writes in this transaction.
const result = a.prompt ? await fireRun(tx, a, firedAt, text) : { id: a.id }
a.recentFires.push(firedAt)
a.fires++
a.lastFireAt = firedAt
const event = await host.emit(tx, Fired, { id: a.id, text, fire: a.fires })
if (a.subscribers.length) await deliver(tx, a, event.id, 'fired', text)
return result
}
const activeRuns = new WeakMap<Tx, Set<string>>()
// One event can fire several automations. Read their overlaps before the first fire writes any entries/tasks.
async function prepareFires(tx: Tx): Promise<Set<string>> {
const cached = activeRuns.get(tx)
if (cached) return cached
const ids = new Set<string>()
// A long run can outlive the bounded run history.
for (const status of ['pending', 'running', 'waiting', 'completing'] as const) {
let cursor: Cursor | undefined
do {
const page = await tx.scanTasks(
{ conversationId: ROOT_CONVERSATION_ID, kind: RUN_TASK, status },
100,
cursor,
)
for (const task of page.items)
ids.add((task.input as { automation: Automation }).automation.id)
cursor = page.next
} while (cursor)
}
activeRuns.set(tx, ids)
return ids
}
async function fireRun(tx: Tx, a: Automation, firedAt: number, text: string) {
const running = a.runs.some((r) => r.status === 'running') || (await prepareFires(tx)).has(a.id)
if (running) {
a.runs.push({ id: `skipped:${firedAt}`, firedAt, finishedAt: firedAt, status: 'skipped' })
} else {
const id = await tx.createTask(
RunTask,
{
automation: { ...(JSON.parse(JSON.stringify(a)) as Automation), runs: [] },
firedAt,
text,
},
owner,
)
activeRuns.get(tx)!.add(a.id)
a.runs.push({ id: String(id), firedAt, status: 'running' })
}
a.runs = a.runs.slice(-50)
return a.runs.at(-1)!
}
const Scheduler = defineTask<
{ id: string },
{ phase: 'plan' } | { phase: 'sleep'; until: number } | { phase: 'fire'; until: number },
null
>({
name: 'ultimate.automation-scheduler',
version: 1,
initial: () => ({ phase: 'plan' }),
phases: {
plan: (task, runtime, context) =>
runtime.commit(async (tx) => {
const a = (await tx.doc(Automations)).automations[task.input.id]
if (!a?.enabled || a.scheduler !== task.id) return terminal
const until =
a.nextAt ??
nextAt(
a.schedule!,
Math.max(runtime.now(), a.lastFireAt ?? a.createdAt),
a.lastFireAt ?? a.createdAt,
)
a.nextAt = until
return { status: 'running', checkpoint: { phase: 'sleep', until } }
}, context),
sleep: (task, runtime, context) =>
runtime.sleepUntil(
task.state.checkpoint.until,
{ phase: 'fire', until: task.state.checkpoint.until },
context,
),
fire: (task, runtime, context) =>
runtime.commit(async (tx) => {
const a = (await tx.doc(Automations)).automations[task.input.id]
if (!a?.enabled || a.scheduler !== task.id) return terminal
await fire(tx, a, runtime.now())
delete a.nextAt
if (!a.enabled || a.schedule!.kind === 'at') {
a.enabled = false
delete a.scheduler
return terminal
}
return { status: 'running', checkpoint: { phase: 'plan' } }
}, context),
},
abort: (_task, runtime, context) =>
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
})
return { RunTask, Scheduler, fire, prepareFires }
}
function finish(
state: { automations: Record<string, Automation> },
id: string,
runId: string,
status: Run['status'],
answer?: string,
) {
const run = state.automations[id]?.runs.find((r) => r.id === runId)
if (run) {
run.status = status
run.finishedAt = Date.now()
if (answer !== undefined) run.answer = answer.trim().split('\n')[0].slice(0, 200)
}
}
+186
View File
@@ -0,0 +1,186 @@
/** 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)}]}
}})]})
}
+315
View File
@@ -0,0 +1,315 @@
/** Event and context conditions use the same transactional fire path. */
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
const onCommit = (harness: any, callback: any) => {
const observation = harness.observeCommits({advance: callback, end: () => {}})
return () => observation.detach()
}
import { ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
type ExtensionHost = any
import { defineEvent } from '@earendil-works/sausage-events'
import type { EventRecord } from '@earendil-works/sausage-events'
import { defineDoc } from '@earendil-works/pi-durable'
const UserInputEvent = defineEvent('conversations.user-input')
export type ContextUsage = {percent?: number; tokens?: number; window?: number}
export const ContextUsageDoc = defineDoc<ContextUsage>({kind:'conversations.context',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
import { Standing, SubagentsApi } from '../subagents/api.ts'
import { describeSchedule } from './schedule.ts'
import type { Automations as Ledger } from './server.ts'
import type { Automation, TriggerEvent } from './types.ts'
import { RUN_TASK, STANDING } from './types.ts'
export const TRIGGER_EVENTS: TriggerEvent[] = [
{
name: 'workspaces.landed',
description: 'A submission landed',
fields: {
sha: 'Landed commit',
title: 'Submission title',
target: 'Target branch',
conversationId: 'Submitting conversation',
},
},
{
name: 'workspaces.check',
description: 'A landing check finished',
fields: { result: 'passed | failed | flaky | timeout' },
},
{
name: 'health.changed',
description: 'Provider health changed',
fields: { provider: 'Provider name', state: 'ok | failing | unknown' },
},
{
name: 'models.login-changed',
description: 'Provider login changed',
fields: { provider: 'Provider name' },
},
{ name: 'update.applied', description: 'An update was applied', fields: {} },
{ name: 'update.refused', description: 'An update was refused', fields: {} },
{
name: 'cloud.backup',
description: 'Cloud backup changed phase',
fields: { phase: 'started | done | failed' },
},
{ name: 'lineage.branched', description: 'A branch was created', fields: {} },
{
name: 'conversations.compacted',
description: 'A conversation compacted',
fields: {
conversation: 'Conversation id, or @name for a standing agent',
reason: 'Compaction reason',
},
},
{
name: 'automations.escalated',
description: 'An automation escalated',
fields: { id: 'Automation id' },
},
]
export function describeTrigger(a: Automation): string {
if (a.schedule) return describeSchedule(a.schedule)
if (a.event)
return `on ${a.event.name}${
a.event.where && Object.keys(a.event.where).length
? ` where ${Object.entries(a.event.where)
.map(([key, value]) => `${key}=${value}`)
.join(', ')}`
: ''
}`
if (a.condition)
return `when ${a.condition.of} context ≥ ${a.condition.above}%${a.condition.idleFor ? ` after ${hours(a.condition.idleFor)} without the user's input` : ''}`
return a.script!.state
}
/** A pause as people say it: `8h`, `90m`, `1.5h`. */
export const hours = (ms: number) =>
ms % 3600_000 === 0 || ms >= 10 * 3600_000
? `${Math.round((ms / 3600_000) * 10) / 10}h`
: `${Math.round(ms / 60_000)}m`
/** How often idle conditions are looked at: a pause is crossed by time passing, not by a measurement. */
export const IDLE_TICK = 60_000
function summary(event: EventRecord): string {
const data = event.data as Record<string, unknown>
if (event.name === 'workspaces.landed')
return `${event.name} ${String(data.sha).slice(0, 7)} ${JSON.stringify(data.title)}`.slice(
0,
200,
)
return `${event.name} {${Object.entries(data)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join(' ')}}`
.replace(/\s+/g, ' ')
.slice(0, 200)
}
export function triggers(
host: ExtensionHost,
Automations: typeof Ledger,
fire: (tx: Tx, a: Automation, at: number, text?: string) => Promise<unknown>,
prepareFires: (tx: Tx) => Promise<unknown>,
) {
async function resolve(of: string): Promise<ConversationId | undefined> {
if (STANDING.test(of)) return host.need(SubagentsApi).standing(of.slice(1), BACKGROUND_CONTEXT)
if (/^#[1-9]\d*$/.test(of)) return Number(of.slice(1)) as ConversationId
return undefined
}
/** Evaluate `a`'s condition on `usage`, the measurement a commit published, else the conversation's current one. */
async function evaluate(tx: Tx, a: Automation, id?: number, measured?: ContextUsage) {
if (!a.enabled || !a.condition || !id) return
const usage = measured ?? (await tx.doc(ContextUsageDoc, id as ConversationId))
if (usage.percent === undefined) return
const condition = a.condition
condition.value = usage.percent
if (usage.percent < condition.above - 10) condition.armed = true
const now = Date.now()
const paused = condition.idleFor ? now - (condition.inputAt ?? a.createdAt) : undefined
if (
usage.percent >= condition.above &&
condition.armed &&
(paused === undefined || paused >= condition.idleFor!) &&
// A manual run is not a crossing: the cooldown counts from the condition's own fire.
(condition.firedAt === undefined || now - condition.firedAt >= condition.cooldownMs)
) {
const count = (n: number) =>
Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n)
await fire(
tx,
a,
now,
`${condition.of} context ${Math.round(usage.percent)}%${usage.tokens !== undefined && usage.window !== undefined ? ` (${count(usage.tokens)}/${count(usage.window)})` : ''}${paused === undefined ? '' : ` after ${hours(paused)} without the user's input`}`,
)
condition.armed = false
condition.firedAt = now
}
}
let stop: (() => void) | undefined
let queue = Promise.resolve()
let closed = false
/**
* Evaluate every enabled context condition: on the measurements one commit published (`changed`, by conversation),
* or on each conversation's current one; with `idle`, only those that wait for a pause. Each commit's measurements
* are evaluated in commit order, so a drop below the re-arm line followed by a crossing is seen as both, however far
* evaluation lags.
*/
async function check(changed?: Map<number, ContextUsage>, idle = false) {
const state = await host.harness().snapshot(Automations, BACKGROUND_CONTEXT)
for (const a of Object.values(state?.automations ?? {})) {
if (closed || !a.enabled || !a.condition || (idle && !a.condition.idleFor)) continue
const target = await resolve(a.condition.of)
if (!target || (changed && !changed.has(target))) continue
await host.harness().commit(async (tx) => {
const current = (await tx.doc(Automations)).automations[a.id]
if (current?.condition?.of === a.condition!.of)
await evaluate(tx, current, target, changed?.get(target))
}, BACKGROUND_CONTEXT)
}
}
/**
* Whether `event` came from one of `a`'s own prompt runs: its trace's conversation, or the conversation it is about
* (`conversation`, `conversationId` in its data), is a run conversation owned by one of `a`'s run tasks. Read from
* the conversation's owner, not the bounded run history, so an old or long run still counts.
*/
async function fromOwnRun(tx: Tx, a: Automation, event: EventRecord) {
// No shortcut on `a.prompt`: a run started before the prompt was removed can still emit.
const data = event.data as Record<string, unknown>
const ids = new Set(
[data.conversation, data.conversationId].filter(
(id): id is number => typeof id === 'number',
),
)
for (const id of ids) {
const creator = (await tx.conversation(id as ConversationId))?.owner
if (!creator) continue
const owner = await tx.task(creator.taskId)
if (
owner?.kind === RUN_TASK &&
(owner.input as { automation?: { id?: string } }).automation?.id === a.id
)
return true
}
return false
}
/** One minute's look at the conditions that wait for a pause: time alone may have crossed it. */
const idleTick = () => {
queue = queue
.then(() => check(undefined, true))
.catch((error: unknown) => {
host.notice(`Automation idle trigger: ${String(error)}`)
})
return queue
}
function install() {
// A pause counts from the user's latest input to the conversation a condition watches.
host.react(
'user-input',
[UserInputEvent],
async (tx, { data, at }) => {
const state = await tx.doc(Automations)
for (const a of Object.values(state.automations)) {
if (!a.condition?.idleFor) continue
const of = a.condition.of
const target = STANDING.test(of)
? (await tx.doc(Standing, ROOT_CONVERSATION_ID))[of.slice(1)]
: Number(of.slice(1))
if (target === data.conversation) a.condition.inputAt = Date.parse(at)
}
},
{ from: 'now' },
)
// Tokens name public events without loading other extensions' runtime refs.
host.react(
'events',
TRIGGER_EVENTS.map(({ name }) => defineEvent(name)),
async (tx, event) => {
const state = await tx.doc(Automations)
const matching: Automation[] = []
// `@name` matches the standing agent's conversation when the event comes: it changes with each chapter.
const standing = await tx.doc(Standing, ROOT_CONVERSATION_ID)
const expected = (value: string | number | boolean) =>
typeof value === 'string' && STANDING.test(value)
? Object.hasOwn(standing, value.slice(1))
? standing[value.slice(1)]
: null
: value
// Every read before the first fire: a transaction may not scan tasks after it wrote.
for (const a of Object.values(state.automations)) {
if (!a.enabled || a.event?.name !== event.name || event.id <= a.event.sinceEvent) continue
if (
!Object.entries(a.event.where ?? {}).every(
([key, value]) => (event.data as Record<string, unknown>)[key] === expected(value),
)
)
continue
if (!(await fromOwnRun(tx, a, event))) matching.push(a)
}
if (matching.some((a) => a.prompt)) await prepareFires(tx)
for (const a of matching) await fire(tx, a, Date.now(), summary(event))
},
{ from: 'now' },
)
}
async function open() {
closed = false
stop?.()
// Not a reactor: context usage is a document, not a domain event.
stop = onCommit(host.harness(), (publication) => {
const entries = publication.changes.filter((change: any) => change.type === 'entry').map((change: any) => change.value)
const measured = entries.flatMap((entry: any) => {
const message = entry.model?.[0]
if(message?.role !== 'assistant' || !message.usage) return []
const model = host.models().getModel(message.provider,message.model)
if(!model?.contextWindow) return []
const tokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite
return [{id:entry.conversationId,usage:{tokens,window:model.contextWindow,percent:tokens/model.contextWindow*100}}]
})
const inputs = entries.filter((entry: any) => entry.kind === 'pi.input')
if(measured.length || inputs.length) queue = queue.then(async () => {
if(closed) return
await host.harness().commit(async (tx: any) => {
const ledger = await tx.doc(Automations)
const standing = await tx.doc(Standing,ROOT_CONVERSATION_ID)
for(const entry of inputs) for(const a of Object.values(ledger.automations) as Automation[]) {
if(!a.condition?.idleFor) continue
const target=STANDING.test(a.condition.of) ? standing[a.condition.of.slice(1)] : Number(a.condition.of.slice(1))
if(target === entry.conversationId) a.condition.inputAt = entry.model?.[0]?.timestamp ?? Date.now()
}
for(const {id,usage} of measured) Object.assign(await tx.doc(ContextUsageDoc,id),usage)
},BACKGROUND_CONTEXT)
}).catch((error: unknown) => host.notice(`Automation context measurement: ${String(error)}`))
const changed = new Map<number, ContextUsage>()
for (const change of publication.changes)
if (
change.type === 'document' &&
change.kind === 'change' &&
change.record.kind === ContextUsageDoc.definition.kind &&
change.conversationId !== undefined
)
changed.set(change.conversationId, structuredClone(change.value as ContextUsage))
if (changed.size)
queue = queue
.then(() => check(changed))
.catch((error: unknown) => {
host.notice(`Automation context trigger: ${String(error)}`)
})
})
await check()
// A close while the first check ran ends here: no timer outlives it.
if (closed) return
clearInterval(timer)
timer = setInterval(idleTick, IDLE_TICK)
}
let timer: ReturnType<typeof setInterval> | undefined
async function close() {
closed = true
stop?.()
stop = undefined
clearInterval(timer)
timer = undefined
await queue
}
return { resolve, evaluate, install, open, close, idleTick }
}
+140
View File
@@ -0,0 +1,140 @@
/** Automation definitions, runtime records, and the stdout protocol. */
import type { ModelThinkingLevel } from '@earendil-works/pi-ai'
import type { TaskId, ConversationId } from '@earendil-works/pi-durable'
export type TriggerEvent = { name: string; description: string; fields: Record<string, string> }
/** The labels automations gives the conversations it creates: a prompt run's, and a script's watcher. */
export const RunLabel = 'automation'
export const WatcherLabel = 'watcher'
export type Schedule =
| { kind: 'cron'; expr: string; tz: string }
| { kind: 'at'; time: string }
| { kind: 'every'; ms: number }
export type Run = {
id: string
firedAt: number
conversationId?: ConversationId
status: 'running' | 'done' | 'failed' | 'aborted' | 'skipped'
finishedAt?: number
answer?: string
}
/** A conversation an automation wakes: `#id` as a number, or a standing agent's `@name`, resolved at each delivery. */
export type Subscriber = number | string
export type Automation = {
id: string
scope?: 'project'
/** The caller's idempotency key: a create with a key that exists returns that automation instead. */
key?: string
/** Fixed creator identity. Subscriptions only control notifications. */
executionConversation?: number
name: string
enabled: boolean
createdAt: number
updatedAt: number
schedule?: Schedule
event?: { name: string; where?: Record<string, string | number | boolean>; sinceEvent: number }
condition?: {
metric: 'context'
of: string
above: number
cooldownMs: number
armed: boolean
value?: number
/** Fire only once the user gave `of` no input for this long (ms): a pause, checked every minute. */
idleFor?: number
/** The user's latest input to `of` (ms), kept while `idleFor` is set; the pause counts from creation before one. */
inputAt?: number
/** When the condition itself last fired: the cooldown counts from it, not from a manual run. */
firedAt?: number
}
recentFires?: number[]
script?: {
ask: string
once: boolean
secrets?: string[]
/** A project script's committed `to`: the subscriber its manifest names. */
to?: string
conversationId: number
state: 'writing' | 'running' | 'investigating' | 'failed' | 'stopped' | 'done'
/** Unplanned exits in a row; the third escalates. A FIRE or an hour of running resets it. */
failures?: number
pid?: number
startedAt?: number
exitedAt?: number
exitCode?: number
lastOutputAt?: number
error?: string
}
prompt?: string
agent?: string
model?: { provider: string; modelId: string }
thinking?: ModelThinkingLevel
subscribers: Subscriber[]
nextAt?: number
scheduler?: TaskId
runs: Run[]
fires: number
lastFireAt?: number
}
export type AutomationListItem = Automation & { alive: boolean }
export type CreateArgs = {
name: string
schedule?: string | Schedule
ask?: string
event?: { name: string; where?: Record<string, string | number | boolean> }
condition?: {
metric: 'context'
of: string
above: number
cooldownMs?: number
/** 0 removes it. */
idleFor?: number
}
once?: boolean
secrets?: string[]
prompt?: string
agent?: string
model?: { provider: string; modelId: string }
thinking?: ModelThinkingLevel
subscribers?: Subscriber[]
scope?: 'user' | 'project'
slug?: string
/** A project script's standing agent to wake on every machine, `@name`; default `@ultimate`. */
to?: string
key?: string
}
export type Args = Partial<CreateArgs> & {
action: string
id?: string
runId?: string
to?: string
lines?: number
reason?: string
}
export type Manifest = { name: string; ask: string; once: boolean; secrets?: string[]; to: string }
/** A standing agent's name as a subscriber or target: `@ultimate`. */
export const STANDING = /^@[a-zA-Z0-9_-]+$/
/** How many unplanned script exits in a row escalate instead of waking the watcher again. */
export const FAILURE_LIMIT = 3
export type Effect = { id: string; action: string; previousPid?: number }
export const isSlug = (id: string) => /^[a-z][a-z0-9-]{0,62}$/.test(id) && !(id in Object.prototype)
export const isId = (id: string) => /^A[1-9]\d*$/.test(id) || isSlug(id)
/** Only stdout has a protocol; stderr never fires an automation. */
export function fireText(line: string, stdout: boolean): string | undefined {
return stdout && /^FIRE(?:\s|$)/.test(line) ? line.slice(4).trim() : undefined
}
/** DONE takes effect only when the script exits 0. */
export function doneText(line: string, stdout: boolean): string | undefined {
return stdout && /^DONE(?:\s|$)/.test(line) ? line.slice(4).trim() : undefined
}
/** Keep both bounds even for a single very long output line. */
export function trimLog(text: string): string {
const lines = text.split('\n').slice(-2001).join('\n')
const bytes = new TextEncoder().encode(lines)
return bytes.length > 256 * 1024 ? new TextDecoder().decode(bytes.subarray(-256 * 1024)) : lines
}
/** The task kind of an automation's prompt run, which owns the run's conversation. */
export const RUN_TASK = 'ultimate.automation-run'
+26
View File
@@ -0,0 +1,26 @@
# Pause
Pause holds the global scheduler.
Tasks that already execute finish their current invocation.
New model requests and tool calls remain pending.
Play releases the hold and resumes them.
The pause document survives restart.
The extension exposes no agent tool.
A host caller uses the `pause-controls` service for state, pause, play, and the auto-play setting.
Other extensions use the optional `pause` service to skip external work while paused.
Play writes one duration note for each run that was active when the pause began.
Idle conversations receive no note.
A stopped run does not transfer its note to a later run.
Rate limits pause only when Pi schedules a retry.
Quota errors and exhausted retries fail normally.
Auto-play remains off by default.
The user can select a delay of 1 to 1440 minutes.
Only an automatic rate-limit pause can play itself.
A manual pause always requires Play.
The timer operates outside the held scheduler.
The stored pause re-arms the timer after restart.
The test module ports all 19 Ultimate pause cases through snapshot-loaded modules.
+9
View File
@@ -0,0 +1,9 @@
/** Pause: whether the user paused this Ultimate, for timers that start work outside the task scheduler. */
const defineApi = <S>() => (name: string): {name: string; service?: S} => ({name})
export type PauseService = {
/** Whether the host is paused; a timer that starts work (a fetch, a ping, a backup) skips its tick while it is. */
paused(): boolean
}
/** The pause; `want` it, pause is optional. */
export const PauseApi = defineApi<PauseService>()('pause')
+7
View File
@@ -0,0 +1,7 @@
/** `2h 14m`, `5m`, `40s`: how long a pause lasted. Browser-safe. */
export function lasted(ms: number) {
const minutes = Math.floor(ms / 60_000)
if (minutes < 1) return `${Math.max(0, Math.round(ms / 1000))}s`
const hours = Math.floor(minutes / 60)
return hours ? `${hours}h ${minutes % 60}m` : `${minutes}m`
}
+87
View File
@@ -0,0 +1,87 @@
/** Pause in the browser: whether this Ultimate is paused, what still runs, and Pause and Play. Browser-safe. */
const actionRef = <A, R>(extension: string, name: string) => ({extension, name})
const docRef = <S>(kind: string) => ({kind})
/**
* The rate limit that paused Ultimate: the provider and model that answered with it, the error as Pi recorded it,
* when (ms), and the conversation it first hit. Also the data of the `pause.limit` card in @ultimate's transcript.
*/
export type RateLimit = {
provider: string
model: string
error: string
at: number
conversationId: number
}
/**
* The pause on conversation 1: `since` when it began; `runs` each conversation mid-turn then, by id, with its run's
* generation task and first input submission, so Play notes that run only; `limit` when a rate limit paused it.
*/
export type PauseDocState = {
paused: boolean
since?: number
runs?: Record<string, { task: number; input?: number }>
limit?: RateLimit
/** The user pressed Pause during this pause: it never plays by itself. */
manual?: boolean
/** The user's setting: minutes after a rate-limit pause began that Ultimate presses Play by itself; unset is off. */
autoPlay?: number
}
/** A task still running while paused: it finishes on its own, nothing new starts after it. */
export type RunningTask = {
task: number
conversationId: number
/** The conversation's label or title. */
title?: string
/** `model response`, the tool's name, or the task kind. */
what: string
/** When the task first ran (ms). */
startedAt?: number
}
export type PauseState = {
paused: boolean
since?: number
/** The rate limit that paused Ultimate, if one did. */
limit?: RateLimit
/** Minutes after a rate-limit pause began that Play is pressed by itself; unset is off. */
autoPlay?: number
/** When this pause plays by itself (ms): a rate-limit pause the user did not also pause, with the setting on. */
autoPlayAt?: number
/** Foreground tasks still running: while paused, the pause is safe once this is empty. */
running: RunningTask[]
}
/** The pause, on conversation 1. */
export const PauseDoc = docRef<PauseDocState>('pause.state')
/** No arguments → the pause and the foreground tasks still running. */
export const StateAction = actionRef<null, PauseState>('pause', 'state')
/**
* User-only: pause the whole host. Running tasks (a bash call, a model response) finish on their own; no task starts
* after them, and inputs queue. Survives a restart. Returns the state.
*/
export const PauseAction = actionRef<null, PauseState>('pause', 'pause')
/** User-only: continue. Each run that was mid-turn when paused gets one "Paused for …" note. Returns the state. */
export const PlayAction = actionRef<null, PauseState>('pause', 'play')
/**
* User-only `{ minutes }`: play by itself that many minutes (1 to 1440) after a rate limit paused Ultimate, once per
* pause; `null` turns it off. A pause the user pressed never plays by itself. Returns the state.
*/
export const AutoPlayAction = actionRef<{ minutes: number | null }, PauseState>('pause', 'autoPlay')
/**
* The note Play leaves on a run that was mid-turn: paused from `since` to `until` (ms); `finished` when the run gave
* its last answer while the pause drained.
*/
export type PauseNote = { since: number; until: number; finished?: boolean; auto?: boolean }
/**
* The `pause.held` entry in a subagent's parent, mid-turn when the subagent hit a rate limit: subagent `child` hit
* `provider`'s limit for `model` at `at` (ms), and Ultimate is paused until Play.
*/
export type HeldNote = { child: number; provider: string; model: string; at: number }
/** The `pause.autoplay` entry in @ultimate's transcript: the rate-limit pause from `since` played by itself at `at`. */
export type AutoPlayed = { since: number; at: number; minutes: number }
+493
View File
@@ -0,0 +1,493 @@
/** Global pause, drain status, and replay-safe notes for held runs. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
const onCommit = (harness: any, callback: any) => {
const observation = harness.observeCommits({advance: callback, end: () => {}})
return () => observation.detach()
}
import { defineDoc, LiveDoc, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationId, Cursor, SubmissionId } from '@earendil-works/pi-durable'
type ExtensionHost = any
const implementAction = (_token: any, handler: any) => handler
import { isRetryableAssistantError } from '@earendil-works/pi-ai'
const rateLimited = (error: string) => /(?<![a-z0-9])429(?![a-z0-9])|rate.?limit|too many requests/i.test(error) && isRetryableAssistantError({stopReason:'error',errorMessage:error} as any)
import { Standing } from '../subagents/api.ts'
import { PauseApi } from './api.ts'
import { lasted } from './lasted.ts'
import { AutoPlayAction, PauseAction, PauseDoc, PlayAction, StateAction } from './refs.ts'
import type {
AutoPlayed,
HeldNote,
PauseDocState,
PauseNote,
PauseState,
RateLimit,
RunningTask,
} from './refs.ts'
export const State = defineDoc<PauseDocState>({
kind: PauseDoc.kind,
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
history: 'latest',
fork: 'initial',
initial: () => ({ paused: false }),
})
export const docs = [State]
type Host = Pick<
ExtensionHost,
'harness' | 'resource' | 'onOpen' | 'onClose' | 'onRelease' | 'implement' | 'conversations'
>
/** The flag timers read synchronously; shared across reloads, set from the doc each time the harness opens. */
const flag = (host: Pick<ExtensionHost, 'resource'>) =>
host.resource('pause.flag', () => ({ paused: false }))
const read = async (host: Host) =>
(await host.harness().snapshot(State, ROOT_CONVERSATION_ID, context)) ?? { paused: false }
/** Run pause, play and the reopen hold one at a time, across reloads: an interleaving could release a paused host. */
function serial<T>(host: Pick<ExtensionHost, 'resource'>, work: () => Promise<T>): Promise<T> {
const queue = host.resource('pause.queue', () => ({
chain: Promise.resolve() as Promise<unknown>,
}))
const next = queue.chain.then(work)
// The caller gets the failure; the chain only waits for the work to settle.
queue.chain = next.catch(() => undefined)
return next
}
/**
* Background phases that only wait on other work (a run, a submission, a timer), which a pause holds: they never end
* while paused and execute nothing meanwhile, so they never keep the pause from being safe.
*/
const PASSIVE: Record<string, string[]> = {
'ultimate.subagent-reporter': ['deliver', 'retry'],
'ultimate.automation-scheduler': ['sleep'],
'ultimate.automation-run': ['deliver'],
'board.delivery': ['deliver', 'reply'],
'board.route': ['observe'],
'conversations.summary': ['observe'],
'lineage.notify': ['wait'],
}
export const passive = (record: { kind: string; state: unknown }) => {
const phase = (record.state as { checkpoint?: { phase?: string } }).checkpoint?.phase
return !!phase && (PASSIVE[record.kind] ?? []).includes(phase)
}
/**
* Tasks with a running invocation that execute something (a model response, a tool, a summary streaming): each
* finishes on its own, then nothing starts after it. Passive waiters never count.
*/
async function running(host: Host): Promise<RunningTask[]> {
const harness = host.harness()
const { tasks } = await harness.inspect(context)
const titles = new Map<number, string>()
for (const {record} of tasks) {
const metadata = await harness.snapshot(host.metadata, record.conversationId, context)
if (metadata?.label || metadata?.title) titles.set(record.conversationId, metadata.label ?? metadata.title)
}
const tools = new Map<number, Map<number, string>>()
const toolName = async (conversationId: ConversationId, task: number) => {
if (!tools.has(conversationId)) {
const live = await harness.snapshot(LiveDoc, conversationId, context)
const slots = [...(live?.tools ?? []), ...(live?.nestedTools ?? [])]
tools.set(
conversationId,
new Map(
slots.flatMap((slot) => (slot.taskId === undefined ? [] : [[slot.taskId, slot.name]])),
),
)
}
return tools.get(conversationId)!.get(task)
}
const result: RunningTask[] = []
for (const { record, state: inspected } of tasks) {
if (inspected.kind !== 'running' || (record.background && passive(record))) continue
const what =
record.kind === 'pi.generation'
? 'model response'
: record.kind === 'pi.tool'
? ((await toolName(record.conversationId, record.id)) ?? 'tool call')
: record.kind
const title = titles.get(record.conversationId)
result.push({
task: record.id,
conversationId: record.conversationId,
...(title ? { title } : {}),
what,
...(record.startedAt === undefined ? {} : { startedAt: record.startedAt }),
})
}
return result
}
async function state(host: Host): Promise<PauseState> {
const doc = await read(host)
return {
paused: doc.paused,
...(doc.since === undefined ? {} : { since: doc.since }),
...(doc.limit === undefined ? {} : { limit: doc.limit }),
...(doc.autoPlay === undefined ? {} : { autoPlay: doc.autoPlay }),
...(autoPlayAt(doc) === undefined ? {} : { autoPlayAt: autoPlayAt(doc) }),
running: await running(host),
}
}
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched Harness.hold()/release() keep the scheduler from starting tasks.
/**
* Hold the scheduler, then record the pause and each run mid-turn now (its task and first input), which Play notes;
* `limit` when a rate limit pauses it, also into a pause already recorded, so the limit shows.
*/
async function hold(host: Host, limit?: RateLimit) {
const harness = host.harness()
harness.hold()
flag(host).paused = true
await harness.commit(async (tx) => {
const doc = await tx.doc(State, ROOT_CONVERSATION_ID)
if (doc.paused) {
if (limit && !doc.limit) doc.limit = limit
// The user pressed Pause too: this pause waits for them.
if (!limit) doc.manual = true
return
}
const runs: NonNullable<PauseDocState['runs']> = {}
// Sleeping: a generation waiting out its retry backoff, as one that just hit a rate limit does.
for (const status of ['pending', 'running', 'waiting', 'sleeping'] as const) {
let cursor: Cursor | undefined
do {
const page = await tx.scanTasks({ kind: 'pi.generation', status }, 100, cursor)
for (const task of page.items) {
const run = (await tx.doc(LiveDoc, task.conversationId)).run
if (run?.taskId === task.id)
runs[task.conversationId] = { task: task.id, input: run.inputs[0] }
}
cursor = page.next
} while (cursor)
}
Object.assign(doc, {
paused: true,
since: Date.now(),
runs,
...(limit ? { limit } : { manual: true }),
})
}, context)
arm(host, await read(host))
}
async function pause(host: Host) {
await hold(host)
return state(host)
}
/** When `doc`'s pause plays by itself: a rate-limit pause the user did not also pause, with the setting on. */
const autoPlayAt = (doc: PauseDocState) =>
doc.paused && doc.limit && !doc.manual && doc.autoPlay && doc.since !== undefined
? doc.since + doc.autoPlay * 60_000
: undefined
/** The timer that plays a rate-limit pause by itself; outside the scheduler, which the pause holds. */
const timer = (host: Pick<ExtensionHost, 'resource'>) =>
host.resource('pause.autoplay', () => ({ handle: undefined as NodeJS.Timeout | undefined }))
/** Set the timer for `doc`'s pause, or clear it when that pause never plays by itself. */
function arm(host: Host, doc: PauseDocState) {
const slot = timer(host)
clearTimeout(slot.handle)
slot.handle = undefined
const at = autoPlayAt(doc)
if (at === undefined) return
slot.handle = setTimeout(
() => {
autoPlay(host).catch((error: unknown) => {
// The pause stands with Play in the status line; the user can still press it.
console.error('pause: playing by itself failed:', error)
})
},
Math.max(0, at - Date.now()),
)
// A pending Play keeps no process alive; the pause document re-arms it on the next open.
slot.handle.unref?.()
}
/**
* Play the pause by itself once its time came (`now`, for tests), once: Play ends the pause, and a limit that still
* holds pauses again with a new start and a full grace period. Tells @ultimate with a `pause.autoplay` entry first,
* while the pause is still recorded: a crash before Play reopens paused, re-arms the timer, and the entry's request ID
* keeps the second announcement from repeating.
*/
export async function autoPlay(host: Host, now = Date.now()) {
return serial(host, async () => {
const doc = await read(host)
const at = autoPlayAt(doc)
if (at === undefined || now < at) return false
const data: AutoPlayed = { since: doc.since!, at: now, minutes: doc.autoPlay! }
const harness = host.harness()
const ultimate = (await harness.snapshot(Standing, ROOT_CONVERSATION_ID, context))?.ultimate
if (ultimate !== undefined)
await (
await harness.conversation(ultimate, context)
)?.submit(
{
type: 'write',
requestId: `pause.autoplay:${data.since}`,
entry: {
kind: 'pause.autoplay',
data,
model: [
{
role: 'user',
content: `Ultimate played by itself ${lasted(now - data.since)} after the rate limit paused it (the user's setting: ${data.minutes} min).`,
timestamp: now,
},
],
},
},
context,
)
await play(host, true)
return true
})
}
/** The user's setting: play by itself `minutes` after a rate limit paused Ultimate; `null` turns it off. */
async function setAutoPlay(host: Host, minutes: unknown) {
if (
minutes !== null &&
(typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
)
throw new Error('minutes must be a whole number from 1 to 1440, or null to turn it off')
await host.harness().commit(async (tx) => {
const doc = await tx.doc(State, ROOT_CONVERSATION_ID)
if (minutes === null) delete doc.autoPlay
else doc.autoPlay = minutes
}, context)
arm(host, await read(host))
return state(host)
}
/**
* A model answered with a rate limit: pause, so nothing retries into it, and tell @ultimate with one `pause.limit`
* card. Pi has already scheduled that generation's retry; the hold parks it, and every other run at its next step,
* until Play. The caller holds the scheduler already, in the commit's publication, before that retry's timer can fire.
*/
async function limited(host: Host, limit: RateLimit) {
try {
await hold(host, limit)
} catch (error) {
// The commit recorded nothing, so nothing shows the pause or offers Play: let the work go on rather than hold it
// unseen. Pi's retry and the watchdog's report still stand.
flag(host).paused = false
host.harness().release()
throw error
}
await card(host, await read(host))
await held(host, limit)
}
/**
* The parent of a subagent that hit the limit, mid-turn, sits held with nothing in its transcript saying why: leave it
* one `pause.held` entry naming the child. Only kind and data, so it shows at once while the parent is held and wakes
* nobody; the parent's model learns of the pause from its Play note. Keyed by the child's run, so Plays that pause
* again into the same limit add none.
*/
async function held(host: Host, limit: RateLimit) {
const harness = host.harness()
const child = limit.conversationId as ConversationId
const parent = await harness.commit(
async (tx) => (await tx.conversation(child))?.owner?.conversationId,
context,
)
const input = (await harness.snapshot(LiveDoc, child, context))?.run?.inputs[0]
if (parent === undefined || input === undefined) return
if (!(await harness.snapshot(LiveDoc, parent, context))?.run) return
const data: HeldNote = { child, provider: limit.provider, model: limit.model, at: limit.at }
await (
await harness.conversation(parent, context)
)?.submit(
{
type: 'write',
requestId: `pause.held:${child}:${input}`,
entry: { kind: 'pause.held', data },
},
context,
)
}
/** The `pause.limit` card for the limit that paused the host, once per pause (its request ID is the pause's start). */
async function card(host: Host, doc: PauseDocState) {
const harness = host.harness()
const ultimate = (await harness.snapshot(Standing, ROOT_CONVERSATION_ID, context))?.ultimate
if (!doc.paused || !doc.limit || ultimate === undefined) return
// Only kind and data, so Pi appends it at once even while @ultimate's own run is held; the model learns of the limit
// from the note Play leaves on each held run.
await (
await harness.conversation(ultimate, context)
)?.submit(
{
type: 'write',
requestId: `pause.limit:${doc.since}`,
entry: { kind: 'pause.limit', data: doc.limit },
},
context,
)
}
/**
* Whether Play notes conversation `id`: its run recorded at the pause continues (the same task, or the run it handed
* over to, which keeps its inputs), or it completed during the drain. A run stopped meanwhile, or a new one, gets none.
*/
async function noted(host: Host, id: ConversationId, recorded: { task: number; input?: number }) {
const harness = host.harness()
const run = (await harness.snapshot(LiveDoc, id, context))?.run
if (
run &&
(run.taskId === recorded.task ||
(recorded.input !== undefined && run.inputs[0] === recorded.input))
)
return 'continued'
// Only the recorded input's own answer counts as the run finishing: a completed generation may have handed the run
// to a successor that was stopped, leaving the input unanswered.
if (recorded.input === undefined) return undefined
const input = await (
await harness.submission(recorded.input as SubmissionId, context)
)?.status(context)
return input?.status === 'done' ? 'finished' : undefined
}
/**
* Leave one note on each run that was mid-turn at the pause and still is, placed at its next step, then record the
* end and release the scheduler. A retry after a crash finds the notes by request ID and leaves no second one.
* `auto` when the user's setting played it, not the user.
*/
async function play(host: Host, auto = false) {
const harness = host.harness()
const doc = await read(host)
if (doc.paused && doc.since !== undefined) {
const until = Date.now()
const span = `${lasted(until - doc.since)}${doc.limit ? ` (${doc.limit.provider} rate limited ${doc.limit.model})` : ''}`
for (const [key, recorded] of Object.entries(doc.runs ?? {})) {
const id = Number(key) as ConversationId
const how = await noted(host, id, recorded)
if (!how) continue
const note: PauseNote = {
since: doc.since,
until,
...(how === 'finished' && { finished: true }),
...(auto && { auto: true }),
}
const content =
how === 'finished'
? `Paused for ${span} after this run's last answer.`
: `Paused for ${span}; the run continued after ${auto ? 'Ultimate played by itself' : 'Play'}.`
const conversation = await harness.conversation(id, context)
await conversation?.submit(
{
type: 'write',
requestId: `pause.note:${note.since}`,
entry: {
kind: 'pause.note',
data: note,
model: [{ role: 'user', content, timestamp: note.until }],
},
},
context,
)
}
}
await harness.commit(async (tx) => {
const current = await tx.doc(State, ROOT_CONVERSATION_ID)
current.paused = false
delete current.since
delete current.runs
delete current.limit
delete current.manual
}, context)
arm(host, await read(host))
flag(host).paused = false
harness.release()
return state(host)
}
export default function pauseExtension(host: Host) {
host.implement(PauseApi, { paused: () => flag(host).paused })
let unsubscribe: (() => void) | undefined
const close = async () => {
unsubscribe?.()
unsubscribe = undefined
clearTimeout(timer(host).handle)
}
host.onClose(close)
host.implement({name:'pause-controls'}, {
state: () => state(host), pause: () => serial(host, () => pause(host)),
play: () => serial(host, () => play(host)),
autoPlay: (minutes: number | null) => serial(host, () => setAutoPlay(host, minutes)),
})
// The server resumes scheduling only after every opener settled, so a paused host reopens with nothing started.
host.onOpen(async () => {
unsubscribe?.()
// Before watching: scheduling starts only after the openers, so no model answer arrives in between.
await serial(host, async () => {
const doc = await read(host)
flag(host).paused = doc.paused
if (!doc.paused) return
host.harness().hold()
arm(host, doc)
// A crash between the pause and its card left none; its request ID makes this a no-op otherwise.
await card(host, doc)
})
// Every model answer Pi records lands in a commit; the first rate-limited one pauses.
unsubscribe = onCommit(host.harness(), (publication) => {
// Pi commits a retried answer with its generation sleeping until the retry. The answer that spent the last retry
// ends the run in the same commit instead: holding then continues nothing and only delays the failure's report.
const retrying = new Set(
publication.changes.flatMap((change) =>
change.type === 'task' &&
change.value.kind === 'pi.generation' &&
change.value.state.status === 'sleeping'
? [change.value.conversationId]
: [],
),
)
for (const change of publication.changes) {
const message = change.type === 'entry' ? change.value.model?.[0] : undefined
// A host already paused stays paused: requests in flight that hit the limit park the same way, and only the
// first limit is recorded, into a pause the user started too.
if (
change.type !== 'entry' ||
!retrying.has(change.value.conversationId) ||
message?.role !== 'assistant' ||
message.stopReason !== 'error' ||
!rateLimited(message.errorMessage ?? '')
)
continue
const limit: RateLimit = {
provider: message.provider,
model: message.model,
error: message.errorMessage!,
at: message.timestamp,
conversationId: change.value.conversationId,
}
flag(host).paused = true
host.harness().hold()
serial(host, () => limited(host, limit)).catch((error: unknown) => {
// A failed pause released its hold (limited); a failed card leaves the pause standing in the status line.
console.error('pause: pausing for a rate limit failed:', error)
})
}
})
})
return { name: 'pause' }
}
export const actions = {
state: implementAction(StateAction, (_args, { host }) => state(host)),
pause: implementAction(PauseAction, (_args, { host }) => serial(host, () => pause(host))),
play: implementAction(PlayAction, (_args, { host }) => serial(host, () => play(host))),
autoPlay: implementAction(AutoPlayAction, (args, { host }) =>
serial(host, () => setAutoPlay(host, (args as { minutes?: unknown } | null)?.minutes)),
),
}
/** Pause, Play and the setting that plays by itself are the user's: no agent unpauses into a rate limit. */
export const userActions = ['pause', 'play', 'autoPlay']
+530
View File
@@ -0,0 +1,530 @@
/** All 19 Ultimate pause cases, executed against world snapshot modules. */
import {expect,it,until,setImmediate} from "./automations-test-support";
import {fixture} from "./automations-pause-fixture";
import {BACKGROUND_CONTEXT as context} from "@earendil-works/chord/context";
import {fauxAssistantMessage,fauxText,fauxToolCall} from "@earendil-works/pi-ai/providers/faux";
import {GenerationTask,hook,LiveDoc,ROOT_CONVERSATION_ID,defineExtension} from "@earendil-works/pi-durable";
import type {Harness,ConversationId} from "@earendil-works/pi-durable";
const {actions,autoPlay,passive,userActions}=await import(`${globalThis.automationBase}/pause/server.ts`);
const {lasted}=await import(`${globalThis.automationBase}/pause/lasted.ts`);
const {Standing}=await import(`${globalThis.automationBase}/subagents/api.ts`);
const generations = async (harness: Harness) =>
(await harness.inspect(context)).tasks.filter(({ record }) => record.kind === 'pi.generation')
it('lets a running tool finish, starts no generation after it while paused, and continues on play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
const pausing = await f.action('pause')
expect(pausing.paused).toBe(true)
expect(pausing.running.map((task) => task.what)).toEqual(['work'])
expect(f.paused()).toBe(true)
expect((await harness.inspect(context)).scheduling).toBe('paused')
// The tool finishes on its own; the generation that waited for it stays pending instead of asking for the next response.
f.finish!()
await until(async () => (await f.action('state')).running.length === 0)
expect(f.finished()).toBe(1)
expect(responses).toBe(1)
// Its tool ended, so the generation could take its next step; held, it stays pending.
const [generation] = await generations(harness)
expect(generation.state.kind).toBe('pending')
// A submit or wait asks Pi to resume scheduling; the hold wins.
await root.submit({ type: 'input', content: 'queued meanwhile' }, context)
harness.resume()
await Promise.resolve()
expect((await harness.inspect(context)).scheduling).toBe('paused')
expect(responses).toBe(1)
const played = await f.action('play')
expect(played.paused).toBe(false)
expect(f.paused()).toBe(false)
await submission.wait(context)
expect(responses).toBeGreaterThanOrEqual(2)
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
expect(notes[0].model?.[0]?.content).toMatch(/^Paused for \d+s; the run continued after Play\.$/)
})
it('reopens paused: nothing starts until play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage([fauxText('answer')])
},
])
await f.open()
await f.action('pause')
await f.close()
const reopened = await f.open()
expect(f.paused()).toBe(true)
expect((await reopened.harness.inspect(context)).scheduling).toBe('paused')
const submission = await reopened.root.submit({ type: 'input', content: 'hello' }, context)
await new Promise((resolve) => setImmediate(resolve))
expect(responses).toBe(0)
const [generation] = await generations(reopened.harness)
expect(generation.record.state.status).toBe('pending')
expect(generation.state.kind).toBe('pending')
await f.action('play')
await submission.wait(context)
expect(responses).toBe(1)
// Idle when paused: no note.
const entries = (await reopened.root.context(context)).entries
expect(entries.some((entry) => entry.kind === 'pause.note')).toBe(false)
})
it('leaves a note only on runs that were mid-turn, once, and none on idle conversations', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
() => fauxAssistantMessage([fauxText('done')]),
])
const { root, harness } = await f.open()
const idle = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
await f.action('pause')
f.finish!()
await until(async () => (await f.action('state')).running.length === 0)
await f.action('play')
// A second Play finds nothing paused and leaves nothing.
await f.action('play')
await submission.wait(context)
const notes = async (id: ConversationId) =>
(await (await harness.conversation(id, context))!.context(context)).entries.filter(
(entry) => entry.kind === 'pause.note',
)
expect(await notes(root.id)).toHaveLength(1)
expect(await notes(idle.id)).toEqual([])
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeUndefined()
})
it('serializes overlapping pause and play: the doc and the scheduler always agree', async () => {
const f = await fixture()
const { harness } = await f.open()
const agree = async () => {
const doc = (await f.action('state')).paused
expect((await harness.inspect(context)).scheduling).toBe(doc ? 'paused' : 'running')
expect(f.paused()).toBe(doc)
return doc
}
await Promise.all([f.action('pause'), f.action('play')])
expect(await agree()).toBe(false)
await Promise.all([f.action('play'), f.action('pause')])
expect(await agree()).toBe(true)
await Promise.all([f.action('pause'), f.action('play'), f.action('pause'), f.action('play')])
expect(await agree()).toBe(false)
})
it('leaves no note on a run that started after the paused one was stopped', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
() => fauxAssistantMessage([fauxText('B done')]),
])
const { root } = await f.open()
await root.submit({ type: 'input', content: 'A' }, context)
await until(() => f.finish !== undefined)
await f.action('pause')
// Stop A while paused: abort handlers run under the hold.
const stopping = root.abort(context)
f.finish!()
await stopping
const b = await root.submit({ type: 'input', content: 'B' }, context)
await f.action('play')
await b.wait(context)
const entries = (await root.context(context)).entries
expect(entries.filter((entry) => entry.kind === 'pause.note')).toEqual([])
})
it('notes a run whose last answer arrived while the pause drained', async () => {
const f = await fixture()
const answer = Promise.withResolvers<void>()
let asked = false
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage([fauxText('final answer')])
},
])
const { root, harness } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
const pausing = await f.action('pause')
expect(pausing.running.map((task) => task.what)).toEqual(['model response'])
answer.resolve()
await submission.wait(context)
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeUndefined()
await f.action('play')
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
expect(notes[0].data).toMatchObject({ finished: true })
expect(notes[0].model?.[0]?.content).toMatch(/^Paused for \d+s after this run's last answer\.$/)
})
it('leaves no note when the run handed over during the drain and its successor was stopped', async () => {
const f = await fixture()
// The first answer continues the run: its generation completes and hands the run to a successor, while paused.
let continued = false
f.prepared.registry.install(
defineExtension({
name: 'pause-handover',
hooks: [
hook(GenerationTask, {
onYield: () => {
if (continued) return undefined
continued = true
return { continue: 'keep going' }
},
}),
],
}),
)
const answer = Promise.withResolvers<void>()
let asked = false
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage([fauxText('first answer')])
},
() => fauxAssistantMessage([fauxText('B done')]),
])
const { root, harness } = await f.open()
const a = await root.submit({ type: 'input', content: 'A' }, context)
await until(() => asked)
await f.action('pause')
const recorded = (await f.action('state')).running[0].task
answer.resolve()
// The recorded generation completed by handing over; the successor is held.
await until(
async () => (await harness.getTask(recorded as never, context))?.state.status === 'terminal',
)
expect(continued).toBe(true)
await root.abort(context)
expect((await a.wait(context)).status).toBe('unanswered')
const b = await root.submit({ type: 'input', content: 'B' }, context)
await f.action('play')
await b.wait(context)
const entries = (await root.context(context)).entries
expect(entries.filter((entry) => entry.kind === 'pause.note')).toEqual([])
})
const limit =
"Incomplete Claude Code upstream response (status 429, capture incomplete, upstream said: This request would exceed your account's rate limit. Please try again later.)"
it('pauses on the first rate-limited answer: the run waits in its retry, fails nothing, and continues on play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
expect(await f.action('state')).toMatchObject({
paused: true,
limit: { error: limit, conversationId: root.id },
})
expect(f.paused()).toBe(true)
// Pi's retry was due after 1 ms; held, it never asks again, and the run neither fails nor ends.
for (let turn = 0; turn < 20; turn++) await new Promise((resolve) => setImmediate(resolve))
expect(responses).toBe(1)
const [generation] = await generations(harness)
expect(generation.record.state.status).not.toBe('terminal')
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeDefined()
const cards = async () =>
(await ultimate.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
await until(async () => (await cards()).length === 1)
expect((await cards())[0].data).toMatchObject({ error: limit, conversationId: root.id })
const played = await f.action('play')
expect(played.limit).toBeUndefined()
expect((await submission.wait(context)).status).toBe('done')
expect(responses).toBe(2)
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
// The held run's model learns why it was paused.
expect(notes[0].model?.[0]?.content).toMatch(
/^Paused for \d+s \(faux rate limited [\w-]+\); the run continued after Play\.$/,
)
expect(await cards()).toHaveLength(1)
})
it('does not pause again for the rate limit that ends its run: the failure reaches the watchdog without another Play', async () => {
const f = await fixture(undefined, { maxRetries: 1 })
let responses = 0
const limited = () => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
}
f.prepared.faux!.setResponses([limited, limited])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
// Play spends Pi's last retry into the limit, which ends the run: nothing is left to hold.
await f.action('play')
expect(await submission.wait(context)).toMatchObject({
status: 'unanswered',
reason: 'model_error',
})
expect(responses).toBe(2)
for (let turn = 0; turn < 20; turn++) await new Promise((resolve) => setImmediate(resolve))
expect(f.paused()).toBe(false)
expect((await f.action('state')).paused).toBe(false)
})
it('tells a held parent which subagent hit the limit, once across re-pauses, and nothing after Play', async () => {
const f = await fixture()
const limitedResponse = () =>
fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
f.prepared.faux!.setResponses([
// The parent calls a tool; while it runs, its subagent's first request hits the limit, twice across a Play.
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
limitedResponse,
limitedResponse,
() => fauxAssistantMessage([fauxText('child done')]),
() => fauxAssistantMessage([fauxText('parent done')]),
])
const { root: parent, harness } = await f.open()
const parentRun = await parent.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
const [tool] = (await harness.inspect(context)).tasks.filter(
({ record }) => record.kind === 'pi.tool',
)
const child = await harness.commit(
(tx) => tx.createConversation({ ownership: { kind: 'task', taskId: tool.record.id } }),
context,
)
const childRun = await (await harness.conversation(child.id, context))!.submit(
{ type: 'input', content: 'research' },
context,
)
const held = async () =>
(await parent.context(context)).entries.filter((entry) => entry.kind === 'pause.held')
await until(async () => (await held()).length === 1)
expect((await held())[0].data).toMatchObject({ child: child.id, provider: 'faux' })
// Play retries into the limit and pauses again: the same run, so no second note.
const { since } = await f.action('state')
await f.action('play')
await until(
async () =>
(await f.action('state')).since !== undefined && (await f.action('state')).since !== since,
)
expect(await held()).toHaveLength(1)
await f.action('play')
expect((await childRun.wait(context)).status).toBe('done')
f.finish!()
expect((await parentRun.wait(context)).status).toBe('done')
expect(await held()).toHaveLength(1)
})
it('leaves ordinary provider errors to Pi’s retry without pausing', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() =>
fauxAssistantMessage('', {
stopReason: 'error',
errorMessage: 'Our servers are currently overloaded. Please try again later.',
}),
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
expect(
(await (await root.submit({ type: 'input', content: 'go' }, context)).wait(context)).status,
).toBe('done')
expect(f.paused()).toBe(false)
})
it('lets a quota error fail its run without pausing: Pi never retries it, so Play would continue nothing', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() =>
fauxAssistantMessage('', {
stopReason: 'error',
errorMessage: '429 You exceeded your current quota (insufficient_quota)',
}),
])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
expect(await submission.wait(context)).toMatchObject({
status: 'unanswered',
reason: 'model_error',
})
expect(f.paused()).toBe(false)
})
it('records a limit into a pause the user started, so it still shows', async () => {
const f = await fixture()
let asked = false
const answer = Promise.withResolvers<void>()
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
await f.action('pause')
// The request in flight hits the limit after the user paused: the pause stands, now with the limit.
answer.resolve()
await until(async () => (await f.action('state')).limit !== undefined)
await f.action('play')
expect((await submission.wait(context)).status).toBe('done')
})
it('plays a rate-limit pause by itself once, N minutes in, only when the user switched it on', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
const { since } = await f.action('state')
// Off by default: no time ever plays it.
expect((await f.action('state')).autoPlay).toBeUndefined()
expect(await f.autoPlay(since! + 365 * 86_400_000)).toBe(false)
expect(f.paused()).toBe(true)
const set = await f.setAutoPlay(30)
expect(set).toMatchObject({ autoPlay: 30, autoPlayAt: since! + 30 * 60_000 })
// Not yet due.
expect(await f.autoPlay(since! + 29 * 60_000)).toBe(false)
expect(f.paused()).toBe(true)
expect(await f.autoPlay(since! + 30 * 60_000)).toBe(true)
expect(f.paused()).toBe(false)
expect((await submission.wait(context)).status).toBe('done')
expect(responses).toBe(2)
// Once: the pause is over, so a second due timer finds nothing to play.
expect(await f.autoPlay(since! + 31 * 60_000)).toBe(false)
const played = (await ultimate.context(context)).entries.filter(
(entry) => entry.kind === 'pause.autoplay',
)
expect(played).toHaveLength(1)
expect(played[0].data).toMatchObject({ since, at: since! + 30 * 60_000, minutes: 30 })
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes[0].data).toMatchObject({ auto: true })
expect(notes[0].model?.[0]?.content).toMatch(/continued after Ultimate played by itself\.$/)
// The setting stays for the next limit.
expect((await f.action('state')).autoPlay).toBe(30)
})
it('never plays a pause the user pressed, also when a rate limit joins it', async () => {
const f = await fixture()
let asked = false
const answer = Promise.withResolvers<void>()
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
await f.setAutoPlay(1)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
const { since } = await f.action('pause')
answer.resolve()
await until(async () => (await f.action('state')).limit !== undefined)
expect((await f.action('state')).autoPlayAt).toBeUndefined()
expect(await f.autoPlay(since! + 86_400_000)).toBe(false)
expect(f.paused()).toBe(true)
await f.action('play')
expect((await submission.wait(context)).status).toBe('done')
})
it('keeps Pause, Play and the play-by-itself setting from agents, and checks the minutes', async () => {
expect(userActions).toEqual(expect.arrayContaining(['pause', 'play', 'autoPlay']))
const f = await fixture()
await f.open()
await expect(f.setAutoPlay(0)).rejects.toThrow('minutes must be a whole number')
expect((await f.setAutoPlay(null)).autoPlay).toBeUndefined()
})
/** A task record as `running()` reads it: it decides by task kind and checkpoint phase only. */
const at = (kind: string, phase: string) => ({ kind, state: { checkpoint: { phase } } })
it('treats message deliveries and routes as passive waiters', () => {
expect(passive(at('board.delivery', 'deliver'))).toBe(true)
expect(passive(at('board.delivery', 'reply'))).toBe(true)
expect(passive(at('ultimate.subagent-reporter', 'deliver'))).toBe(true)
expect(passive(at('board.route', 'observe'))).toBe(true)
expect(passive(at('conversations.summary', 'generate'))).toBe(false)
})
it('stays Pausing… while a background task executes, not for passive waiters', async () => {
const f = await fixture()
const { root, harness } = await f.open()
await harness.commit(
(tx) =>
tx.createTask(f.stream, null, {
conversationId: root.id,
ownership: { kind: 'conversation' },
background: true,
}),
context,
)
await until(async () =>
(await harness.inspect(context)).tasks.some(
({ record, state }) => record.kind === 'pause-test.stream' && state.kind === 'running',
),
)
const pausing = await f.action('pause')
expect(pausing.running.map((task) => task.what)).toEqual(['pause-test.stream'])
f.background.resolve()
await until(async () => (await f.action('state')).running.length === 0)
})
it('words how long a pause lasted', () => {
expect(lasted(40_000)).toBe('40s')
expect(lasted(5 * 60_000)).toBe('5m')
expect(lasted((2 * 60 + 14) * 60_000)).toBe('2h 14m')
})
+33
View File
@@ -0,0 +1,33 @@
/** Pause cases use snapshot modules and isolated durable storage, as Ultimate's fixture does. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { obj } from '@earendil-works/pi-schema'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider } from '@earendil-works/pi-ai/providers/faux'
import { Harness,createRegistry,defineExtension,defineTask,defineTool } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { Metadata } from './extensions'
let serial=0
export const hosts:any[]=[]
export const streamTask=(released:Promise<void>)=>defineTask({name:'pause-test.stream',version:1,initial:()=>({phase:'stream'}),phases:{stream:async(_task:any,runtime:any,ctx:any)=>{await released;await runtime.commit(()=>({status:'terminal',outcome:{status:'completed',result:null}}),ctx)}},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
export async function fixture(home?:string,retry:any={}) {
home??=`${globalThis.storagePath}.pause-${++serial}`
const registry=createRegistry(),models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
let finish:any,finished=0,opened:any,storage:any
const background=Promise.withResolvers<void>(),openers:any[]=[],closers:any[]=[],services=new Map(),resources=new Map()
registry.install(defineExtension({name:'pause-test',tasks:[streamTask(background.promise)],tools:[defineTool({name:'work',description:'Work until released',parameters:obj({}),execute:async()=>{await new Promise(resolve=>{finish=resolve});finished++;return {content:[{type:'text',text:'worked'}]}}})]}))
const host:any={harness:()=>opened.harness,metadata:Metadata,implement:(api:any,value:any)=>services.set(api.name,value),want:(api:any)=>services.get(api.name),resource:(key:string,create:any)=>{if(!resources.has(key))resources.set(key,create());return resources.get(key)},onOpen:(cb:any)=>openers.push(cb),onClose:(cb:any)=>closers.push(cb)}
const module=await import(/* @vite-ignore */ `${globalThis.automationBase}/pause/server.ts`)
registry.install(module.default(host))
const open=async()=>{
storage=await openStorage(home!)
const harness=await Harness.open(storage,{models,registry,settings:{retry:{baseDelayMs:1,...retry},get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
opened={harness,root,close:async()=>{for(const cb of closers)await cb();await harness.close(context);await storage.close()}}
hosts.push(opened)
for(const cb of openers)await cb()
harness.resume()
return opened
}
return {home,prepared:{registry,faux},open,action:(name:string)=>module.actions[name](null,{host}),setAutoPlay:(minutes:number|null)=>module.actions.autoPlay({minutes},{host}),autoPlay:(now:number)=>module.autoPlay(host,now),paused:()=>services.get('pause').paused(),get opened(){return opened},get finish(){return finish},finished:()=>finished,background,stream:streamTask(background.promise),close:async()=>{await opened.close();hosts.splice(hosts.indexOf(opened),1)}}
}
export async function clean() {for(const host of hosts.splice(0))await host.close()}
+66
View File
@@ -0,0 +1,66 @@
/** The five Ultimate calendar cases execute inside deno_core's V8. */
import {describe, expect, it} from "./automations-test-support";
const {describeSchedule,nextAt,parseSchedule}=await import(`${globalThis.automationBase}/automations/schedule.ts`);
const time = Date.parse('2026-01-05T08:00:00Z')
const next = (expr: string, tz = 'UTC') =>
new Date(nextAt(parseSchedule(`cron "${expr}" ${tz}`), time)).toISOString()
describe('automation schedules', () => {
it('matches fields, lists, ranges, steps, Sunday 7 and timezones', () => {
expect(next('30 9 5 1 *')).toBe('2026-01-05T09:30:00.000Z')
expect(next('5,10 8-10 * * 1-5')).toBe('2026-01-05T08:05:00.000Z')
expect(next('*/15 * * * *')).toBe('2026-01-05T08:15:00.000Z')
expect(next('10-30/10 * * * *')).toBe('2026-01-05T08:10:00.000Z')
expect(next('0 9 * * 7')).toBe('2026-01-11T09:00:00.000Z')
expect(next('0 10 * * 1-5', 'Europe/Vienna')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 8 * * *')).toBe('2026-01-06T08:00:00.000Z')
expect(next('0 9 10 * 1')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 9 */2 * *')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 9 */3 * *')).toBe('2026-01-07T09:00:00.000Z')
})
it('jumps sparse dates, rejects impossible ones and finds leap days', () => {
const yearly = parseSchedule('cron "0 0 1 1 *" Europe/Vienna')
const impossible = parseSchedule('cron "0 0 31 4 *" Europe/Vienna')
expect(new Date(nextAt(yearly, time)).toISOString()).toBe('2026-12-31T23:00:00.000Z')
expect(() => nextAt(impossible, time)).toThrow('8 years')
expect(nextAt(parseSchedule('cron "0 0 29 2 *" UTC'), time)).toBe(
Date.parse('2028-02-29T00:00:00Z'),
)
})
it('fires once in the repeated hour and skips nonexistent spring minutes', () => {
const schedule = parseSchedule('cron "30 2 * * *" Europe/Vienna')
const first = nextAt(schedule, Date.parse('2026-10-24T23:00:00Z'))
expect(first).toBe(Date.parse('2026-10-25T00:30:00Z'))
expect(nextAt(schedule, first)).toBe(Date.parse('2026-10-26T01:30:00Z'))
expect(nextAt(schedule, Date.parse('2026-03-28T23:00:00Z'))).toBe(
Date.parse('2026-03-30T00:30:00Z'),
)
})
it('parses relative, at and interval schedules and describes them', () => {
expect(parseSchedule('in 30m', time)).toEqual({ kind: 'at', time: '2026-01-05T08:30:00.000Z' })
expect(parseSchedule('in 2h', time)).toEqual({ kind: 'at', time: '2026-01-05T10:00:00.000Z' })
expect(parseSchedule('in 1d', time)).toEqual({ kind: 'at', time: '2026-01-06T08:00:00.000Z' })
expect(nextAt(parseSchedule('every 2s'), time)).toBe(time + 2000)
expect(describeSchedule(parseSchedule('every 15m'))).toBe('Every 15 minutes')
expect(describeSchedule(parseSchedule('every 1h'))).toBe('Every 1 hour')
expect(describeSchedule(parseSchedule('in 30m', time))).toContain('Once at')
expect(describeSchedule(parseSchedule('cron "0 9 * * 1-5" Europe/Vienna'))).toContain(
'Europe/Vienna',
)
expect(parseSchedule('cron "* * * * *"').kind).toBe('cron')
})
it('rejects invalid input and impossible cron dates', () => {
for (const input of [
'every 0.5s',
'every nope',
'in nope',
'at nope',
'cron "60 * * * *"',
'cron "*/0 * * * *"',
'cron "0 9 * *"',
'cron "* * * * *" Mars',
'cron "0 9 * * 5-1"',
])
expect(() => parseSchedule(input)).toThrow(/.+/)
expect(() => nextAt(parseSchedule('cron "0 9 31 2 *" UTC'), time)).toThrow('8 years')
})
})
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
export const cases: {name:string;run:()=>any}[]=[]
export const it=(name:string,run:()=>any)=>cases.push({name,run})
export const describe=(_name:string,run:()=>any)=>run()
export const assert=(value:any,message:string)=>{if(!value)throw new Error(message)}
const same=(a:any,b:any):boolean=>b?.__contains ? b.values.every((v:any)=>a.includes(v)) : JSON.stringify(a)===JSON.stringify(b)
const subset=(a:any,b:any):boolean=>b && typeof b==='object' ? Object.entries(b).every(([k,v])=>subset(a?.[k],v)) : a===b
export function expect(value:any,negate=false):any {
const check=(ok:boolean)=>assert(negate?!ok:ok,`Expected ${String(value)}${negate?' not':''} to match`)
const result:any={
toBe:(b:any)=>check(value===b),toEqual:(b:any)=>check(same(value,b)),toHaveLength:(n:number)=>check(value.length===n),
toMatchObject:(b:any)=>check(subset(value,b)),toMatch:(b:RegExp)=>check(b.test(value)),toContain:(b:any)=>check(value.includes(b)),
toBeUndefined:()=>check(value===undefined),toBeDefined:()=>check(value!==undefined),toBeGreaterThanOrEqual:(b:number)=>check(value>=b),
toThrow:(b:any)=>{let error;try{value()}catch(e){error=e}check(error!==undefined && (b instanceof RegExp?b.test(String(error)):String(error).includes(b)))},
}
Object.defineProperty(result,'not',{get:()=>expect(value,!negate)})
Object.defineProperty(result,'rejects',{get:()=>({toThrow:async(b:string)=>{let error;try{await value}catch(e){error=e}check(error!==undefined&&String(error).includes(b))}})})
return result
}
expect.arrayContaining=(values:any[])=>({__contains:true,values})
export async function until(condition:()=>any) {
const deadline=Date.now()+5000
while(!await condition()) {assert(Date.now()<deadline,'Condition deadline');await new Promise(resolve=>setTimeout(resolve,0))}
}
export const setImmediate=(callback:any)=>setTimeout(callback,0)
+100
View File
@@ -0,0 +1,100 @@
/** Snapshot-loaded schedules, pause cases, and durable admission across SIGKILL. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider,fauxAssistantMessage,fauxToolCall } from '@earendil-works/pi-ai/providers/faux'
import { Harness,createRegistry } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { loadExtensions,fs } from './extensions'
import { cases,assert,until } from './automations-test-support'
import { clean } from './automations-pause-fixture'
import { emit,defineEvent } from './events'
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath)
const harness=await Harness.open(storage,{models,registry,now:()=>Date.now()+(globalThis.automationMode==='restart'?7200000:0),settings:{get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
const loader=await loadExtensions(harness,registry,storage,models,context)
assert(!loader.state().error,loader.state().error)
globalThis.automationBase=`file:///run/sausage/extensions/${loader.state().good}/extensions`
const {Automations}=await import(/* @vite-ignore */ `${globalThis.automationBase}/automations/server.ts`)
const call=async(args:any,conversation=root)=>{
faux.setResponses([fauxAssistantMessage([fauxToolCall('automation',args)],{stopReason:'toolUse'}),fauxAssistantMessage('done')])
await (await conversation.submit({type:'input',content:'Configure automation'},context)).wait(context)
const result=(await conversation.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)
assert(result && !result.model?.[0]?.isError,JSON.stringify(result))
}
harness.resume()
if(globalThis.automationMode==='prepare') {
await call({action:'create',name:'restart once',schedule:`at ${new Date(Date.now()+3600000).toISOString()}`,subscribers:[root.id]})
await until(async()=>Object.values((await harness.snapshot(Automations,context)).automations).length===1)
const a=Object.values((await harness.snapshot(Automations,context)).automations)[0] as any
await until(async()=>(await harness.getTask(a.scheduler,context)).state.status==='sleeping')
await fs.write('/automation-ready','ready')
await new Promise(()=>{})
} else if(globalThis.automationMode==='restart') {
faux.setResponses([fauxAssistantMessage('woken once')])
await until(async()=>(await harness.snapshot(Automations,context)).automations.A1.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
const ledger=await harness.snapshot(Automations,context)
assert(ledger.automations.A1.fires===1&&!ledger.automations.A1.enabled,'Exactly one durable fire')
const entries=(await root.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(entries.length===1,'Exactly one subscriber input')
await loader.close();await harness.close(context);await storage.close()
} else if(globalThis.automationMode==='triggers') {
await call({action:'create',name:'event',event:{name:'workspaces.landed',where:{title:'ship'}},subscribers:[root.id]})
await harness.commit((tx:any)=>emit(tx,defineEvent('workspaces.landed'),{sha:'abcdef',title:'ship'}),context)
await until(async()=>(await harness.snapshot(Automations,context)).automations.A1.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
await call({action:'create',name:'context',condition:{metric:'context',of:`#${root.id}`,above:75,cooldownMs:0},subscribers:[root.id]})
const {ContextUsageDoc}=await import(/* @vite-ignore */ `${globalThis.automationBase}/automations/triggers.ts`)
await harness.commit(async(tx:any)=>Object.assign(await tx.doc(ContextUsageDoc,root.id),{percent:80,tokens:800,window:1000}),context)
await until(async()=>(await harness.snapshot(Automations,context)).automations.A2.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A2 fired]'))))
await harness.waitForIdle(context)
await loader.close();await harness.close(context);await storage.close()
} else if(globalThis.automationMode==='grant') {
for(const path of ['/src/sausage/automations','/src/sausage/automations/A1']) await fs.mkdir(path)
await fs.write('/src/sausage/automations/A1/script.sh',`curl http://host.sausage/whoami\nprintf '\\n'\ncurl http://127.0.0.1:${globalThis.automationPort}/token\nprintf '\\nFIRE delivered\\n'\nprintf 'FIRE ignored\\n' >&2\nprintf 'DONE\\n'`)
const subscriber=await harness.createConversation({ownership:{kind:'ownerless'}},context)
await subscriber.configure({model:{provider:'faux',modelId:'faux-1'}},context)
const {Started}=await import(/* @vite-ignore */ `${globalThis.automationBase}/messages/addresses.ts`)
const entry=await harness.commit((tx:any)=>tx.appendEntry(subscriber.id,{kind:'automation.origin',data:{}}),context)
const creatorRecord=await harness.commit(async(tx:any)=>{
const fork=await tx.forkConversation(subscriber.id,entry.id,{ownership:{kind:'ownerless'}})
;(await tx.doc(Started,fork.id)).by=root.id
return fork
},context)
const creator=await harness.conversation(creatorRecord.id,context)
await call({action:'create',name:'creator grant',ask:'Fetch and fire',subscribers:[subscriber.id]},creator)
await until(async()=>(await harness.snapshot(Automations,context))?.automations.A1?.script?.state==='done')
await until(async()=>(await subscriber.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
const ledger=await harness.snapshot(Automations,context)
assert(ledger.automations.A1.fires===1,'Only stdout fires')
assert(ledger.automations.A1.executionConversation===creator.id,'The creator owns execution')
const log=await fs.read('/src/sausage/automations/A1/log')
assert(log.includes('accepted'),'The creator chain reaches the starter grant')
assert(JSON.stringify(JSON.parse(log.split('\n')[0]).conversations)===JSON.stringify([String(creator.id),String(subscriber.id),String(root.id)]),'The host appends the starter after fork ancestry')
const entries=(await root.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(entries.length===0,'The creator is not a subscriber')
const delivered=(await subscriber.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(delivered.length===1,'Exactly one script subscriber input')
const replacement=await harness.createConversation({ownership:{kind:'ownerless'}},context)
await call({action:'subscribe',id:'A1',to:`#${replacement.id}`})
await call({action:'unsubscribe',id:'A1',to:`#${subscriber.id}`})
const changed=(await harness.snapshot(Automations,context)).automations.A1
assert(changed.executionConversation===creator.id,'Subscription changes retain the creator')
assert(JSON.stringify(changed.subscribers)===JSON.stringify([replacement.id]),'Subscription ids normalize')
await loader.close();await harness.close(context);await storage.close()
} else {
await loader.close();await harness.close(context);await storage.close()
const zones=Intl.supportedValuesOf('timeZone')
assert(zones.includes('Europe/Vienna') && zones.includes('America/New_York') && zones.includes('Asia/Tokyo'),'V8 provides IANA zone data')
console.log(`IANA zones: ${zones.length}`)
await import('./automations-schedule-cases')
await import('./automations-pause-cases')
for(const test of cases) {
try {await test.run();console.log(`PASS ${test.name}`)} finally {await clean()}
}
}
+2
View File
@@ -54213,6 +54213,7 @@ function registerModules() {
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain2 = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -54264,6 +54265,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
},
+109 -70
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+109 -70
View File
@@ -6190,27 +6190,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -6241,8 +6241,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -6511,12 +6511,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -6585,14 +6585,14 @@ function applyOps(target, ops2) {
}
return root;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -6706,10 +6706,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -6959,9 +6959,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -7055,8 +7055,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -7067,7 +7067,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -7084,7 +7084,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -7197,9 +7197,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -11252,10 +11252,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -13079,8 +13079,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -14845,7 +14845,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -14853,11 +14853,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -15938,7 +15938,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -15946,7 +15946,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -25033,14 +25033,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -25064,7 +25064,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -25077,7 +25077,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -25086,7 +25086,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -25099,16 +25099,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -25334,19 +25334,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function StringEnum(values, options) {
};
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+109 -70
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root2 = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root2;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root2 = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+4 -1
View File
@@ -7,6 +7,7 @@ import * as chord from '@earendil-works/chord';
import * as contextApi from '@earendil-works/chord/context';
import * as schema from '@earendil-works/pi-schema';
import * as events from './events';
import { conversationChains } from './environment';
const ops = Deno.core.ops;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -16,6 +17,7 @@ export type Host = events.EventHost & {
harness(): durable.Harness; storage: Pick<durable.Storage, 'scanSubmissions'>;
models(): modelApi.Models; settings(): durable.JsonObject; metadata: typeof Metadata;
fs: typeof fs; exec: typeof exec; git: typeof git;
chain(conversationId:number):Promise<string[]>;
implement<S>(api: {name:string;readonly service?:S}, value:NoInfer<S>):void;
need<S>(api:{name:string;readonly service?:S}):S; want<S>(api:{name:string;readonly service?:S}):S|undefined;
resource<T>(key:string, create:()=>T):T;
@@ -73,6 +75,7 @@ export function registerModules() {
export async function loadExtensions(harness:any, registry:any, storage:any, models:any, context:any, builtins:any[]=[]) {
registerModules();
const eventService = events.eventLog(()=>harness);
const chain = conversationChains(harness,context);
const repo = '/src/sausage';
let providers = new Map<string,any>();
const resources = new Map<string,any>();
@@ -104,7 +107,7 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
}});
const host:Host = {
harness:()=>harness, storage, models:()=>models, settings:()=>settings, metadata:Metadata, fs,
exec:(command,options)=>exec(command,{...options,extension:name}),git,
exec:(command,options)=>exec(command,{...options,extension:name}),git,chain,
implement:(api,value)=>{staged.set(api.name,value);}, need:handle,
want:(api)=>providers.has(api.name)?handle(api):undefined,
resource:(key,create)=>{const id=`${name}/${key}`;if(!resources.has(id))resources.set(id,create());return resources.get(id);},
+54 -52
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool2) => ({
...tool2,
execute: (args, api, context) => tool2.execute(args, { ...api, env: api.env.forTool(api.callId, tool2.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries3) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -30697,43 +30736,6 @@ Last good: ${fallback}` : failed;
} };
}
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool2) => ({
...tool2,
execute: (args, api, context) => tool2.execute(args, { ...api, env: api.env.forTool(api.callId, tool2.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops2 = Deno.core.ops;
// node_modules/@earendil-works/chord/dist/sqlite/schema.js
function validateSqliteSchemaPrefix(prefix) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
+54 -15
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness, registry, storage, models, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness);
const chain = conversationChains(harness, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness, registry, storage, models, context, built
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness, registry, storage, models, context, built
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+111 -72
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root2 = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root2;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root2 = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base: base2 } of held2) {
for (const { loaded, ops: ops3, base: base2 } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base2)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call2 = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call2 = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call2, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call2 = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call2 = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call2, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -31671,7 +31710,7 @@ var todosModule = await import(
/* @vite-ignore */
`${base}/todos/server.ts`
);
var { Plan, TaskIndex, Branch, Started } = planModule;
var { Plan, TaskIndex, Branch, Started: Started2 } = planModule;
var { Todos, Standing, add, mark, noteFor, DONE_LIMIT } = todosModule;
var call = async (args, id = root.id, name = "plan") => {
const conversation = await harness.conversation(id, BACKGROUND_CONTEXT);
@@ -31776,7 +31815,7 @@ try {
}, BACKGROUND_CONTEXT);
assert(Object.keys((await harness.snapshot(Plan, branch.id, BACKGROUND_CONTEXT)).epics).length === 1, "Fork inherited plan");
equal(JSON.parse(text(await call({ action: "show" }, branch.id))), Plan.definition.initial());
assert((await harness.snapshot(Started, branch.id, BACKGROUND_CONTEXT)).started, "Branch started");
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).started, "Branch started");
const ULTIMATE = 5;
const fakeTx = () => {
const docs = /* @__PURE__ */ new Map();
+54 -52
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain2 = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain2(up),
...starter === void 0 ? [] : await chain2(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain2;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain2 = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -30697,43 +30736,6 @@ Last good: ${fallback}` : failed;
} };
}
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain2 = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain2(up),
...starter === void 0 ? [] : await chain2(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain2;
}
var ops2 = Deno.core.ops;
// node_modules/@earendil-works/chord/dist/sqlite/schema.js
function validateSqliteSchemaPrefix(prefix) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
+274
View File
@@ -0,0 +1,274 @@
//! World-loaded automation and pause cases, plus durable fire recovery after SIGKILL.
use anyhow::{Result, ensure};
use sausage::world::World;
use std::{
path::Path,
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/automations-test-bundle.js");
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
} else if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), destination.join(entry.file_name()))?;
}
}
Ok(())
}
fn setup(directory: &Path) -> Result<()> {
let world = World::open(directory)?;
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").is_file() {
ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd build failed"
);
}
ensure!(
Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"git",
"-p",
"curl",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(target)
.args(["-j", "2"])
.current_dir(&userland)
.status()?
.success(),
"guest build failed"
);
let release = target.join("wasm32-wasip2/release");
if !release.join("brush.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.current_dir(&userland)
.status()?
.success(),
"shell build failed"
);
}
let disk = directory.join("world");
std::fs::create_dir_all(disk.join("bin"))?;
for (source, name) in [
("git.wasm", "git.wasm"),
("curl.wasm", "curl.wasm"),
("brush.wasm", "sh.wasm"),
] {
std::fs::copy(release.join(source), disk.join("bin").join(name))?;
}
let repo = disk.join("src/sausage");
std::fs::create_dir_all(&repo)?;
ensure!(
Command::new("git")
.args(["init", "-b", "main"])
.arg(&repo)
.status()?
.success()
);
copy_tree(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
&repo.join("extensions"),
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
ensure!(
Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.status()?
.success()
);
ensure!(
Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"-m",
"automations fixture"
])
.current_dir(&repo)
.status()?
.success()
);
drop(world);
Ok(())
}
async fn run(directory: &Path, mode: &str) -> Result<()> {
let world = World::open(directory)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
sausage::harness::run_module(
world,
exec,
&format!("globalThis.automationMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn schedules_and_pause() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "cases").await
}
#[tokio::test(flavor = "current_thread")]
async fn event_and_context() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "triggers").await
}
#[tokio::test(flavor = "current_thread")]
async fn script_inherits_creator_grant() -> Result<()> {
use sausage::{
api::Api,
cred::{Caller, Cred, grants::Grant, types::Subject},
};
use std::sync::Arc;
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let world = World::open(directory.path())?;
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0")
.map_err(|_| anyhow::anyhow!("fixture bind failed"))?,
);
let port = server.server_addr().to_ip().unwrap().port();
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller::default();
let token = uuid::Uuid::new_v4().to_string();
let item = cred.create_item(serde_json::from_value(serde_json::json!({"name":"automation fixture","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":port,"path_prefixes":["/token"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text"}]}))?, &caller, cred.unlocked()?)?;
cred.set_field(
&item.id,
&item.fields[0].field_id,
&token,
&caller,
cred.unlocked()?,
)?;
cred.grant_create(
Grant {
id: String::new(),
items: vec![item.id],
subject: Subject::Conversation("1".into()),
exp: None,
max_uses: None,
uses: 0,
revoked: false,
validator: None,
note: None,
},
&caller,
cred.unlocked()?,
)?;
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?);
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?);
let serving = server.clone();
let expected = format!("Bearer {token}");
let thread = std::thread::spawn(move || {
let mut accepted = 0;
while let Ok(request) = serving.recv() {
let valid = request.headers().iter().any(|header| {
header.field.equiv("Authorization") && header.value.as_str() == expected
});
if valid {
accepted += 1;
}
let _ = request.respond(tiny_http::Response::from_string(if valid {
"accepted"
} else {
"missing"
}));
}
accepted
});
let source =
format!("globalThis.automationMode='grant'; globalThis.automationPort={port};\n{BUNDLE}");
let result = tokio::time::timeout(
Duration::from_secs(60),
sausage::harness::run_module_with_credentials(world, runtime, cred, &source),
)
.await;
server.unblock();
let accepted = thread.join().unwrap();
result??;
ensure!(
accepted == 1,
"expected one injected request, got {accepted}"
);
Ok(())
}
#[test]
fn automations_child() -> Result<()> {
let Some(directory) = std::env::var_os("SAUSAGE_AUTOMATIONS_CHILD") else {
return Ok(());
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(run(Path::new(&directory), "prepare"))
}
#[tokio::test(flavor = "current_thread")]
async fn fire_after_sigkill() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "automations_child", "--nocapture"])
.env("SAUSAGE_AUTOMATIONS_CHILD", directory.path())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(60);
let ready = directory.path().join("world/automation-ready");
while !ready.exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("child ended before ready: {status}");
}
if Instant::now() >= deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("child readiness deadline");
}
tokio::task::yield_now().await;
}
child.kill()?;
child.wait()?;
run(directory.path(), "restart").await?;
run(directory.path(), "restart").await
}