Merge v3-t51: automations and pause as world extensions, host.chain (T51, P3-n)
This commit is contained in:
@@ -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.
|
||||
@@ -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')
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)}]}
|
||||
}})]})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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']
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);},
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user