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

This commit is contained in:
2026-11-07 14:51:59 +00:00
29 changed files with 37148 additions and 403 deletions
+530
View File
@@ -0,0 +1,530 @@
/** All 19 Ultimate pause cases, executed against world snapshot modules. */
import {expect,it,until,setImmediate} from "./automations-test-support";
import {fixture} from "./automations-pause-fixture";
import {BACKGROUND_CONTEXT as context} from "@earendil-works/chord/context";
import {fauxAssistantMessage,fauxText,fauxToolCall} from "@earendil-works/pi-ai/providers/faux";
import {GenerationTask,hook,LiveDoc,ROOT_CONVERSATION_ID,defineExtension} from "@earendil-works/pi-durable";
import type {Harness,ConversationId} from "@earendil-works/pi-durable";
const {actions,autoPlay,passive,userActions}=await import(`${globalThis.automationBase}/pause/server.ts`);
const {lasted}=await import(`${globalThis.automationBase}/pause/lasted.ts`);
const {Standing}=await import(`${globalThis.automationBase}/subagents/api.ts`);
const generations = async (harness: Harness) =>
(await harness.inspect(context)).tasks.filter(({ record }) => record.kind === 'pi.generation')
it('lets a running tool finish, starts no generation after it while paused, and continues on play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
const pausing = await f.action('pause')
expect(pausing.paused).toBe(true)
expect(pausing.running.map((task) => task.what)).toEqual(['work'])
expect(f.paused()).toBe(true)
expect((await harness.inspect(context)).scheduling).toBe('paused')
// The tool finishes on its own; the generation that waited for it stays pending instead of asking for the next response.
f.finish!()
await until(async () => (await f.action('state')).running.length === 0)
expect(f.finished()).toBe(1)
expect(responses).toBe(1)
// Its tool ended, so the generation could take its next step; held, it stays pending.
const [generation] = await generations(harness)
expect(generation.state.kind).toBe('pending')
// A submit or wait asks Pi to resume scheduling; the hold wins.
await root.submit({ type: 'input', content: 'queued meanwhile' }, context)
harness.resume()
await Promise.resolve()
expect((await harness.inspect(context)).scheduling).toBe('paused')
expect(responses).toBe(1)
const played = await f.action('play')
expect(played.paused).toBe(false)
expect(f.paused()).toBe(false)
await submission.wait(context)
expect(responses).toBeGreaterThanOrEqual(2)
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
expect(notes[0].model?.[0]?.content).toMatch(/^Paused for \d+s; the run continued after Play\.$/)
})
it('reopens paused: nothing starts until play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage([fauxText('answer')])
},
])
await f.open()
await f.action('pause')
await f.close()
const reopened = await f.open()
expect(f.paused()).toBe(true)
expect((await reopened.harness.inspect(context)).scheduling).toBe('paused')
const submission = await reopened.root.submit({ type: 'input', content: 'hello' }, context)
await new Promise((resolve) => setImmediate(resolve))
expect(responses).toBe(0)
const [generation] = await generations(reopened.harness)
expect(generation.record.state.status).toBe('pending')
expect(generation.state.kind).toBe('pending')
await f.action('play')
await submission.wait(context)
expect(responses).toBe(1)
// Idle when paused: no note.
const entries = (await reopened.root.context(context)).entries
expect(entries.some((entry) => entry.kind === 'pause.note')).toBe(false)
})
it('leaves a note only on runs that were mid-turn, once, and none on idle conversations', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
() => fauxAssistantMessage([fauxText('done')]),
])
const { root, harness } = await f.open()
const idle = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
await f.action('pause')
f.finish!()
await until(async () => (await f.action('state')).running.length === 0)
await f.action('play')
// A second Play finds nothing paused and leaves nothing.
await f.action('play')
await submission.wait(context)
const notes = async (id: ConversationId) =>
(await (await harness.conversation(id, context))!.context(context)).entries.filter(
(entry) => entry.kind === 'pause.note',
)
expect(await notes(root.id)).toHaveLength(1)
expect(await notes(idle.id)).toEqual([])
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeUndefined()
})
it('serializes overlapping pause and play: the doc and the scheduler always agree', async () => {
const f = await fixture()
const { harness } = await f.open()
const agree = async () => {
const doc = (await f.action('state')).paused
expect((await harness.inspect(context)).scheduling).toBe(doc ? 'paused' : 'running')
expect(f.paused()).toBe(doc)
return doc
}
await Promise.all([f.action('pause'), f.action('play')])
expect(await agree()).toBe(false)
await Promise.all([f.action('play'), f.action('pause')])
expect(await agree()).toBe(true)
await Promise.all([f.action('pause'), f.action('play'), f.action('pause'), f.action('play')])
expect(await agree()).toBe(false)
})
it('leaves no note on a run that started after the paused one was stopped', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
() => fauxAssistantMessage([fauxText('B done')]),
])
const { root } = await f.open()
await root.submit({ type: 'input', content: 'A' }, context)
await until(() => f.finish !== undefined)
await f.action('pause')
// Stop A while paused: abort handlers run under the hold.
const stopping = root.abort(context)
f.finish!()
await stopping
const b = await root.submit({ type: 'input', content: 'B' }, context)
await f.action('play')
await b.wait(context)
const entries = (await root.context(context)).entries
expect(entries.filter((entry) => entry.kind === 'pause.note')).toEqual([])
})
it('notes a run whose last answer arrived while the pause drained', async () => {
const f = await fixture()
const answer = Promise.withResolvers<void>()
let asked = false
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage([fauxText('final answer')])
},
])
const { root, harness } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
const pausing = await f.action('pause')
expect(pausing.running.map((task) => task.what)).toEqual(['model response'])
answer.resolve()
await submission.wait(context)
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeUndefined()
await f.action('play')
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
expect(notes[0].data).toMatchObject({ finished: true })
expect(notes[0].model?.[0]?.content).toMatch(/^Paused for \d+s after this run's last answer\.$/)
})
it('leaves no note when the run handed over during the drain and its successor was stopped', async () => {
const f = await fixture()
// The first answer continues the run: its generation completes and hands the run to a successor, while paused.
let continued = false
f.prepared.registry.install(
defineExtension({
name: 'pause-handover',
hooks: [
hook(GenerationTask, {
onYield: () => {
if (continued) return undefined
continued = true
return { continue: 'keep going' }
},
}),
],
}),
)
const answer = Promise.withResolvers<void>()
let asked = false
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage([fauxText('first answer')])
},
() => fauxAssistantMessage([fauxText('B done')]),
])
const { root, harness } = await f.open()
const a = await root.submit({ type: 'input', content: 'A' }, context)
await until(() => asked)
await f.action('pause')
const recorded = (await f.action('state')).running[0].task
answer.resolve()
// The recorded generation completed by handing over; the successor is held.
await until(
async () => (await harness.getTask(recorded as never, context))?.state.status === 'terminal',
)
expect(continued).toBe(true)
await root.abort(context)
expect((await a.wait(context)).status).toBe('unanswered')
const b = await root.submit({ type: 'input', content: 'B' }, context)
await f.action('play')
await b.wait(context)
const entries = (await root.context(context)).entries
expect(entries.filter((entry) => entry.kind === 'pause.note')).toEqual([])
})
const limit =
"Incomplete Claude Code upstream response (status 429, capture incomplete, upstream said: This request would exceed your account's rate limit. Please try again later.)"
it('pauses on the first rate-limited answer: the run waits in its retry, fails nothing, and continues on play', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
expect(await f.action('state')).toMatchObject({
paused: true,
limit: { error: limit, conversationId: root.id },
})
expect(f.paused()).toBe(true)
// Pi's retry was due after 1 ms; held, it never asks again, and the run neither fails nor ends.
for (let turn = 0; turn < 20; turn++) await new Promise((resolve) => setImmediate(resolve))
expect(responses).toBe(1)
const [generation] = await generations(harness)
expect(generation.record.state.status).not.toBe('terminal')
expect((await harness.snapshot(LiveDoc, root.id, context))?.run).toBeDefined()
const cards = async () =>
(await ultimate.context(context)).entries.filter((entry) => entry.kind === 'pause.limit')
await until(async () => (await cards()).length === 1)
expect((await cards())[0].data).toMatchObject({ error: limit, conversationId: root.id })
const played = await f.action('play')
expect(played.limit).toBeUndefined()
expect((await submission.wait(context)).status).toBe('done')
expect(responses).toBe(2)
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes).toHaveLength(1)
// The held run's model learns why it was paused.
expect(notes[0].model?.[0]?.content).toMatch(
/^Paused for \d+s \(faux rate limited [\w-]+\); the run continued after Play\.$/,
)
expect(await cards()).toHaveLength(1)
})
it('does not pause again for the rate limit that ends its run: the failure reaches the watchdog without another Play', async () => {
const f = await fixture(undefined, { maxRetries: 1 })
let responses = 0
const limited = () => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
}
f.prepared.faux!.setResponses([limited, limited])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
// Play spends Pi's last retry into the limit, which ends the run: nothing is left to hold.
await f.action('play')
expect(await submission.wait(context)).toMatchObject({
status: 'unanswered',
reason: 'model_error',
})
expect(responses).toBe(2)
for (let turn = 0; turn < 20; turn++) await new Promise((resolve) => setImmediate(resolve))
expect(f.paused()).toBe(false)
expect((await f.action('state')).paused).toBe(false)
})
it('tells a held parent which subagent hit the limit, once across re-pauses, and nothing after Play', async () => {
const f = await fixture()
const limitedResponse = () =>
fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
f.prepared.faux!.setResponses([
// The parent calls a tool; while it runs, its subagent's first request hits the limit, twice across a Play.
() => fauxAssistantMessage([fauxToolCall('work', {})], { stopReason: 'toolUse' }),
limitedResponse,
limitedResponse,
() => fauxAssistantMessage([fauxText('child done')]),
() => fauxAssistantMessage([fauxText('parent done')]),
])
const { root: parent, harness } = await f.open()
const parentRun = await parent.submit({ type: 'input', content: 'go' }, context)
await until(() => f.finish !== undefined)
const [tool] = (await harness.inspect(context)).tasks.filter(
({ record }) => record.kind === 'pi.tool',
)
const child = await harness.commit(
(tx) => tx.createConversation({ ownership: { kind: 'task', taskId: tool.record.id } }),
context,
)
const childRun = await (await harness.conversation(child.id, context))!.submit(
{ type: 'input', content: 'research' },
context,
)
const held = async () =>
(await parent.context(context)).entries.filter((entry) => entry.kind === 'pause.held')
await until(async () => (await held()).length === 1)
expect((await held())[0].data).toMatchObject({ child: child.id, provider: 'faux' })
// Play retries into the limit and pauses again: the same run, so no second note.
const { since } = await f.action('state')
await f.action('play')
await until(
async () =>
(await f.action('state')).since !== undefined && (await f.action('state')).since !== since,
)
expect(await held()).toHaveLength(1)
await f.action('play')
expect((await childRun.wait(context)).status).toBe('done')
f.finish!()
expect((await parentRun.wait(context)).status).toBe('done')
expect(await held()).toHaveLength(1)
})
it('leaves ordinary provider errors to Pi’s retry without pausing', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() =>
fauxAssistantMessage('', {
stopReason: 'error',
errorMessage: 'Our servers are currently overloaded. Please try again later.',
}),
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
expect(
(await (await root.submit({ type: 'input', content: 'go' }, context)).wait(context)).status,
).toBe('done')
expect(f.paused()).toBe(false)
})
it('lets a quota error fail its run without pausing: Pi never retries it, so Play would continue nothing', async () => {
const f = await fixture()
f.prepared.faux!.setResponses([
() =>
fauxAssistantMessage('', {
stopReason: 'error',
errorMessage: '429 You exceeded your current quota (insufficient_quota)',
}),
])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
expect(await submission.wait(context)).toMatchObject({
status: 'unanswered',
reason: 'model_error',
})
expect(f.paused()).toBe(false)
})
it('records a limit into a pause the user started, so it still shows', async () => {
const f = await fixture()
let asked = false
const answer = Promise.withResolvers<void>()
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
await f.action('pause')
// The request in flight hits the limit after the user paused: the pause stands, now with the limit.
answer.resolve()
await until(async () => (await f.action('state')).limit !== undefined)
await f.action('play')
expect((await submission.wait(context)).status).toBe('done')
})
it('plays a rate-limit pause by itself once, N minutes in, only when the user switched it on', async () => {
const f = await fixture()
let responses = 0
f.prepared.faux!.setResponses([
() => {
responses++
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => {
responses++
return fauxAssistantMessage([fauxText('done')])
},
])
const { root, harness } = await f.open()
const ultimate = await harness.createConversation({ ownership: { kind: 'ownerless' } }, context)
await harness.commit(async (tx) => {
;(await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate = ultimate.id
}, context)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(async () => (await f.action('state')).limit !== undefined)
const { since } = await f.action('state')
// Off by default: no time ever plays it.
expect((await f.action('state')).autoPlay).toBeUndefined()
expect(await f.autoPlay(since! + 365 * 86_400_000)).toBe(false)
expect(f.paused()).toBe(true)
const set = await f.setAutoPlay(30)
expect(set).toMatchObject({ autoPlay: 30, autoPlayAt: since! + 30 * 60_000 })
// Not yet due.
expect(await f.autoPlay(since! + 29 * 60_000)).toBe(false)
expect(f.paused()).toBe(true)
expect(await f.autoPlay(since! + 30 * 60_000)).toBe(true)
expect(f.paused()).toBe(false)
expect((await submission.wait(context)).status).toBe('done')
expect(responses).toBe(2)
// Once: the pause is over, so a second due timer finds nothing to play.
expect(await f.autoPlay(since! + 31 * 60_000)).toBe(false)
const played = (await ultimate.context(context)).entries.filter(
(entry) => entry.kind === 'pause.autoplay',
)
expect(played).toHaveLength(1)
expect(played[0].data).toMatchObject({ since, at: since! + 30 * 60_000, minutes: 30 })
const notes = (await root.context(context)).entries.filter((entry) => entry.kind === 'pause.note')
expect(notes[0].data).toMatchObject({ auto: true })
expect(notes[0].model?.[0]?.content).toMatch(/continued after Ultimate played by itself\.$/)
// The setting stays for the next limit.
expect((await f.action('state')).autoPlay).toBe(30)
})
it('never plays a pause the user pressed, also when a rate limit joins it', async () => {
const f = await fixture()
let asked = false
const answer = Promise.withResolvers<void>()
f.prepared.faux!.setResponses([
async () => {
asked = true
await answer.promise
return fauxAssistantMessage('', { stopReason: 'error', errorMessage: limit })
},
() => fauxAssistantMessage([fauxText('done')]),
])
const { root } = await f.open()
await f.setAutoPlay(1)
const submission = await root.submit({ type: 'input', content: 'go' }, context)
await until(() => asked)
const { since } = await f.action('pause')
answer.resolve()
await until(async () => (await f.action('state')).limit !== undefined)
expect((await f.action('state')).autoPlayAt).toBeUndefined()
expect(await f.autoPlay(since! + 86_400_000)).toBe(false)
expect(f.paused()).toBe(true)
await f.action('play')
expect((await submission.wait(context)).status).toBe('done')
})
it('keeps Pause, Play and the play-by-itself setting from agents, and checks the minutes', async () => {
expect(userActions).toEqual(expect.arrayContaining(['pause', 'play', 'autoPlay']))
const f = await fixture()
await f.open()
await expect(f.setAutoPlay(0)).rejects.toThrow('minutes must be a whole number')
expect((await f.setAutoPlay(null)).autoPlay).toBeUndefined()
})
/** A task record as `running()` reads it: it decides by task kind and checkpoint phase only. */
const at = (kind: string, phase: string) => ({ kind, state: { checkpoint: { phase } } })
it('treats message deliveries and routes as passive waiters', () => {
expect(passive(at('board.delivery', 'deliver'))).toBe(true)
expect(passive(at('board.delivery', 'reply'))).toBe(true)
expect(passive(at('ultimate.subagent-reporter', 'deliver'))).toBe(true)
expect(passive(at('board.route', 'observe'))).toBe(true)
expect(passive(at('conversations.summary', 'generate'))).toBe(false)
})
it('stays Pausing… while a background task executes, not for passive waiters', async () => {
const f = await fixture()
const { root, harness } = await f.open()
await harness.commit(
(tx) =>
tx.createTask(f.stream, null, {
conversationId: root.id,
ownership: { kind: 'conversation' },
background: true,
}),
context,
)
await until(async () =>
(await harness.inspect(context)).tasks.some(
({ record, state }) => record.kind === 'pause-test.stream' && state.kind === 'running',
),
)
const pausing = await f.action('pause')
expect(pausing.running.map((task) => task.what)).toEqual(['pause-test.stream'])
f.background.resolve()
await until(async () => (await f.action('state')).running.length === 0)
})
it('words how long a pause lasted', () => {
expect(lasted(40_000)).toBe('40s')
expect(lasted(5 * 60_000)).toBe('5m')
expect(lasted((2 * 60 + 14) * 60_000)).toBe('2h 14m')
})
+33
View File
@@ -0,0 +1,33 @@
/** Pause cases use snapshot modules and isolated durable storage, as Ultimate's fixture does. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { obj } from '@earendil-works/pi-schema'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider } from '@earendil-works/pi-ai/providers/faux'
import { Harness,createRegistry,defineExtension,defineTask,defineTool } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { Metadata } from './extensions'
let serial=0
export const hosts:any[]=[]
export const streamTask=(released:Promise<void>)=>defineTask({name:'pause-test.stream',version:1,initial:()=>({phase:'stream'}),phases:{stream:async(_task:any,runtime:any,ctx:any)=>{await released;await runtime.commit(()=>({status:'terminal',outcome:{status:'completed',result:null}}),ctx)}},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
export async function fixture(home?:string,retry:any={}) {
home??=`${globalThis.storagePath}.pause-${++serial}`
const registry=createRegistry(),models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
let finish:any,finished=0,opened:any,storage:any
const background=Promise.withResolvers<void>(),openers:any[]=[],closers:any[]=[],services=new Map(),resources=new Map()
registry.install(defineExtension({name:'pause-test',tasks:[streamTask(background.promise)],tools:[defineTool({name:'work',description:'Work until released',parameters:obj({}),execute:async()=>{await new Promise(resolve=>{finish=resolve});finished++;return {content:[{type:'text',text:'worked'}]}}})]}))
const host:any={harness:()=>opened.harness,metadata:Metadata,implement:(api:any,value:any)=>services.set(api.name,value),want:(api:any)=>services.get(api.name),resource:(key:string,create:any)=>{if(!resources.has(key))resources.set(key,create());return resources.get(key)},onOpen:(cb:any)=>openers.push(cb),onClose:(cb:any)=>closers.push(cb)}
const module=await import(/* @vite-ignore */ `${globalThis.automationBase}/pause/server.ts`)
registry.install(module.default(host))
const open=async()=>{
storage=await openStorage(home!)
const harness=await Harness.open(storage,{models,registry,settings:{retry:{baseDelayMs:1,...retry},get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
opened={harness,root,close:async()=>{for(const cb of closers)await cb();await harness.close(context);await storage.close()}}
hosts.push(opened)
for(const cb of openers)await cb()
harness.resume()
return opened
}
return {home,prepared:{registry,faux},open,action:(name:string)=>module.actions[name](null,{host}),setAutoPlay:(minutes:number|null)=>module.actions.autoPlay({minutes},{host}),autoPlay:(now:number)=>module.autoPlay(host,now),paused:()=>services.get('pause').paused(),get opened(){return opened},get finish(){return finish},finished:()=>finished,background,stream:streamTask(background.promise),close:async()=>{await opened.close();hosts.splice(hosts.indexOf(opened),1)}}
}
export async function clean() {for(const host of hosts.splice(0))await host.close()}
+66
View File
@@ -0,0 +1,66 @@
/** The five Ultimate calendar cases execute inside deno_core's V8. */
import {describe, expect, it} from "./automations-test-support";
const {describeSchedule,nextAt,parseSchedule}=await import(`${globalThis.automationBase}/automations/schedule.ts`);
const time = Date.parse('2026-01-05T08:00:00Z')
const next = (expr: string, tz = 'UTC') =>
new Date(nextAt(parseSchedule(`cron "${expr}" ${tz}`), time)).toISOString()
describe('automation schedules', () => {
it('matches fields, lists, ranges, steps, Sunday 7 and timezones', () => {
expect(next('30 9 5 1 *')).toBe('2026-01-05T09:30:00.000Z')
expect(next('5,10 8-10 * * 1-5')).toBe('2026-01-05T08:05:00.000Z')
expect(next('*/15 * * * *')).toBe('2026-01-05T08:15:00.000Z')
expect(next('10-30/10 * * * *')).toBe('2026-01-05T08:10:00.000Z')
expect(next('0 9 * * 7')).toBe('2026-01-11T09:00:00.000Z')
expect(next('0 10 * * 1-5', 'Europe/Vienna')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 8 * * *')).toBe('2026-01-06T08:00:00.000Z')
expect(next('0 9 10 * 1')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 9 */2 * *')).toBe('2026-01-05T09:00:00.000Z')
expect(next('0 9 */3 * *')).toBe('2026-01-07T09:00:00.000Z')
})
it('jumps sparse dates, rejects impossible ones and finds leap days', () => {
const yearly = parseSchedule('cron "0 0 1 1 *" Europe/Vienna')
const impossible = parseSchedule('cron "0 0 31 4 *" Europe/Vienna')
expect(new Date(nextAt(yearly, time)).toISOString()).toBe('2026-12-31T23:00:00.000Z')
expect(() => nextAt(impossible, time)).toThrow('8 years')
expect(nextAt(parseSchedule('cron "0 0 29 2 *" UTC'), time)).toBe(
Date.parse('2028-02-29T00:00:00Z'),
)
})
it('fires once in the repeated hour and skips nonexistent spring minutes', () => {
const schedule = parseSchedule('cron "30 2 * * *" Europe/Vienna')
const first = nextAt(schedule, Date.parse('2026-10-24T23:00:00Z'))
expect(first).toBe(Date.parse('2026-10-25T00:30:00Z'))
expect(nextAt(schedule, first)).toBe(Date.parse('2026-10-26T01:30:00Z'))
expect(nextAt(schedule, Date.parse('2026-03-28T23:00:00Z'))).toBe(
Date.parse('2026-03-30T00:30:00Z'),
)
})
it('parses relative, at and interval schedules and describes them', () => {
expect(parseSchedule('in 30m', time)).toEqual({ kind: 'at', time: '2026-01-05T08:30:00.000Z' })
expect(parseSchedule('in 2h', time)).toEqual({ kind: 'at', time: '2026-01-05T10:00:00.000Z' })
expect(parseSchedule('in 1d', time)).toEqual({ kind: 'at', time: '2026-01-06T08:00:00.000Z' })
expect(nextAt(parseSchedule('every 2s'), time)).toBe(time + 2000)
expect(describeSchedule(parseSchedule('every 15m'))).toBe('Every 15 minutes')
expect(describeSchedule(parseSchedule('every 1h'))).toBe('Every 1 hour')
expect(describeSchedule(parseSchedule('in 30m', time))).toContain('Once at')
expect(describeSchedule(parseSchedule('cron "0 9 * * 1-5" Europe/Vienna'))).toContain(
'Europe/Vienna',
)
expect(parseSchedule('cron "* * * * *"').kind).toBe('cron')
})
it('rejects invalid input and impossible cron dates', () => {
for (const input of [
'every 0.5s',
'every nope',
'in nope',
'at nope',
'cron "60 * * * *"',
'cron "*/0 * * * *"',
'cron "0 9 * *"',
'cron "* * * * *" Mars',
'cron "0 9 * * 5-1"',
])
expect(() => parseSchedule(input)).toThrow(/.+/)
expect(() => nextAt(parseSchedule('cron "0 9 31 2 *" UTC'), time)).toThrow('8 years')
})
})
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
export const cases: {name:string;run:()=>any}[]=[]
export const it=(name:string,run:()=>any)=>cases.push({name,run})
export const describe=(_name:string,run:()=>any)=>run()
export const assert=(value:any,message:string)=>{if(!value)throw new Error(message)}
const same=(a:any,b:any):boolean=>b?.__contains ? b.values.every((v:any)=>a.includes(v)) : JSON.stringify(a)===JSON.stringify(b)
const subset=(a:any,b:any):boolean=>b && typeof b==='object' ? Object.entries(b).every(([k,v])=>subset(a?.[k],v)) : a===b
export function expect(value:any,negate=false):any {
const check=(ok:boolean)=>assert(negate?!ok:ok,`Expected ${String(value)}${negate?' not':''} to match`)
const result:any={
toBe:(b:any)=>check(value===b),toEqual:(b:any)=>check(same(value,b)),toHaveLength:(n:number)=>check(value.length===n),
toMatchObject:(b:any)=>check(subset(value,b)),toMatch:(b:RegExp)=>check(b.test(value)),toContain:(b:any)=>check(value.includes(b)),
toBeUndefined:()=>check(value===undefined),toBeDefined:()=>check(value!==undefined),toBeGreaterThanOrEqual:(b:number)=>check(value>=b),
toThrow:(b:any)=>{let error;try{value()}catch(e){error=e}check(error!==undefined && (b instanceof RegExp?b.test(String(error)):String(error).includes(b)))},
}
Object.defineProperty(result,'not',{get:()=>expect(value,!negate)})
Object.defineProperty(result,'rejects',{get:()=>({toThrow:async(b:string)=>{let error;try{await value}catch(e){error=e}check(error!==undefined&&String(error).includes(b))}})})
return result
}
expect.arrayContaining=(values:any[])=>({__contains:true,values})
export async function until(condition:()=>any) {
const deadline=Date.now()+5000
while(!await condition()) {assert(Date.now()<deadline,'Condition deadline');await new Promise(resolve=>setTimeout(resolve,0))}
}
export const setImmediate=(callback:any)=>setTimeout(callback,0)
+100
View File
@@ -0,0 +1,100 @@
/** Snapshot-loaded schedules, pause cases, and durable admission across SIGKILL. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider,fauxAssistantMessage,fauxToolCall } from '@earendil-works/pi-ai/providers/faux'
import { Harness,createRegistry } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { loadExtensions,fs } from './extensions'
import { cases,assert,until } from './automations-test-support'
import { clean } from './automations-pause-fixture'
import { emit,defineEvent } from './events'
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath)
const harness=await Harness.open(storage,{models,registry,now:()=>Date.now()+(globalThis.automationMode==='restart'?7200000:0),settings:{get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
const loader=await loadExtensions(harness,registry,storage,models,context)
assert(!loader.state().error,loader.state().error)
globalThis.automationBase=`file:///run/sausage/extensions/${loader.state().good}/extensions`
const {Automations}=await import(/* @vite-ignore */ `${globalThis.automationBase}/automations/server.ts`)
const call=async(args:any,conversation=root)=>{
faux.setResponses([fauxAssistantMessage([fauxToolCall('automation',args)],{stopReason:'toolUse'}),fauxAssistantMessage('done')])
await (await conversation.submit({type:'input',content:'Configure automation'},context)).wait(context)
const result=(await conversation.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)
assert(result && !result.model?.[0]?.isError,JSON.stringify(result))
}
harness.resume()
if(globalThis.automationMode==='prepare') {
await call({action:'create',name:'restart once',schedule:`at ${new Date(Date.now()+3600000).toISOString()}`,subscribers:[root.id]})
await until(async()=>Object.values((await harness.snapshot(Automations,context)).automations).length===1)
const a=Object.values((await harness.snapshot(Automations,context)).automations)[0] as any
await until(async()=>(await harness.getTask(a.scheduler,context)).state.status==='sleeping')
await fs.write('/automation-ready','ready')
await new Promise(()=>{})
} else if(globalThis.automationMode==='restart') {
faux.setResponses([fauxAssistantMessage('woken once')])
await until(async()=>(await harness.snapshot(Automations,context)).automations.A1.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
const ledger=await harness.snapshot(Automations,context)
assert(ledger.automations.A1.fires===1&&!ledger.automations.A1.enabled,'Exactly one durable fire')
const entries=(await root.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(entries.length===1,'Exactly one subscriber input')
await loader.close();await harness.close(context);await storage.close()
} else if(globalThis.automationMode==='triggers') {
await call({action:'create',name:'event',event:{name:'workspaces.landed',where:{title:'ship'}},subscribers:[root.id]})
await harness.commit((tx:any)=>emit(tx,defineEvent('workspaces.landed'),{sha:'abcdef',title:'ship'}),context)
await until(async()=>(await harness.snapshot(Automations,context)).automations.A1.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
await call({action:'create',name:'context',condition:{metric:'context',of:`#${root.id}`,above:75,cooldownMs:0},subscribers:[root.id]})
const {ContextUsageDoc}=await import(/* @vite-ignore */ `${globalThis.automationBase}/automations/triggers.ts`)
await harness.commit(async(tx:any)=>Object.assign(await tx.doc(ContextUsageDoc,root.id),{percent:80,tokens:800,window:1000}),context)
await until(async()=>(await harness.snapshot(Automations,context)).automations.A2.fires===1)
await until(async()=>(await root.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A2 fired]'))))
await harness.waitForIdle(context)
await loader.close();await harness.close(context);await storage.close()
} else if(globalThis.automationMode==='grant') {
for(const path of ['/src/sausage/automations','/src/sausage/automations/A1']) await fs.mkdir(path)
await fs.write('/src/sausage/automations/A1/script.sh',`curl http://host.sausage/whoami\nprintf '\\n'\ncurl http://127.0.0.1:${globalThis.automationPort}/token\nprintf '\\nFIRE delivered\\n'\nprintf 'FIRE ignored\\n' >&2\nprintf 'DONE\\n'`)
const subscriber=await harness.createConversation({ownership:{kind:'ownerless'}},context)
await subscriber.configure({model:{provider:'faux',modelId:'faux-1'}},context)
const {Started}=await import(/* @vite-ignore */ `${globalThis.automationBase}/messages/addresses.ts`)
const entry=await harness.commit((tx:any)=>tx.appendEntry(subscriber.id,{kind:'automation.origin',data:{}}),context)
const creatorRecord=await harness.commit(async(tx:any)=>{
const fork=await tx.forkConversation(subscriber.id,entry.id,{ownership:{kind:'ownerless'}})
;(await tx.doc(Started,fork.id)).by=root.id
return fork
},context)
const creator=await harness.conversation(creatorRecord.id,context)
await call({action:'create',name:'creator grant',ask:'Fetch and fire',subscribers:[subscriber.id]},creator)
await until(async()=>(await harness.snapshot(Automations,context))?.automations.A1?.script?.state==='done')
await until(async()=>(await subscriber.context(context)).entries.some((entry:any)=>entry.model?.some((m:any)=>typeof m.content==='string'&&m.content.includes('[automation A1 fired]'))))
await harness.waitForIdle(context)
const ledger=await harness.snapshot(Automations,context)
assert(ledger.automations.A1.fires===1,'Only stdout fires')
assert(ledger.automations.A1.executionConversation===creator.id,'The creator owns execution')
const log=await fs.read('/src/sausage/automations/A1/log')
assert(log.includes('accepted'),'The creator chain reaches the starter grant')
assert(JSON.stringify(JSON.parse(log.split('\n')[0]).conversations)===JSON.stringify([String(creator.id),String(subscriber.id),String(root.id)]),'The host appends the starter after fork ancestry')
const entries=(await root.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(entries.length===0,'The creator is not a subscriber')
const delivered=(await subscriber.context(context)).entries.filter((entry:any)=>entry.model?.some((m:any)=>m.role==='user'&&typeof m.content==='string'&&m.content.includes('[automation A1 fired]')))
assert(delivered.length===1,'Exactly one script subscriber input')
const replacement=await harness.createConversation({ownership:{kind:'ownerless'}},context)
await call({action:'subscribe',id:'A1',to:`#${replacement.id}`})
await call({action:'unsubscribe',id:'A1',to:`#${subscriber.id}`})
const changed=(await harness.snapshot(Automations,context)).automations.A1
assert(changed.executionConversation===creator.id,'Subscription changes retain the creator')
assert(JSON.stringify(changed.subscribers)===JSON.stringify([replacement.id]),'Subscription ids normalize')
await loader.close();await harness.close(context);await storage.close()
} else {
await loader.close();await harness.close(context);await storage.close()
const zones=Intl.supportedValuesOf('timeZone')
assert(zones.includes('Europe/Vienna') && zones.includes('America/New_York') && zones.includes('Asia/Tokyo'),'V8 provides IANA zone data')
console.log(`IANA zones: ${zones.length}`)
await import('./automations-schedule-cases')
await import('./automations-pause-cases')
for(const test of cases) {
try {await test.run();console.log(`PASS ${test.name}`)} finally {await clean()}
}
}
+2
View File
@@ -54213,6 +54213,7 @@ function registerModules() {
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain2 = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -54264,6 +54265,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
},
+109 -70
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+109 -70
View File
@@ -6190,27 +6190,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -6241,8 +6241,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -6511,12 +6511,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -6585,14 +6585,14 @@ function applyOps(target, ops2) {
}
return root;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root = op2[1];
@@ -6706,10 +6706,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -6959,9 +6959,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -7055,8 +7055,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -7067,7 +7067,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -7084,7 +7084,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -7197,9 +7197,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -11252,10 +11252,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -13079,8 +13079,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -14845,7 +14845,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -14853,11 +14853,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -15938,7 +15938,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -15946,7 +15946,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -25033,14 +25033,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -25064,7 +25064,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -25077,7 +25077,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -25086,7 +25086,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -25099,16 +25099,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -25334,19 +25334,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function StringEnum(values, options) {
};
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+109 -70
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root2 = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root2;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root2 = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base } of held2) {
for (const { loaded, ops: ops3, base } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+4 -1
View File
@@ -7,6 +7,7 @@ import * as chord from '@earendil-works/chord';
import * as contextApi from '@earendil-works/chord/context';
import * as schema from '@earendil-works/pi-schema';
import * as events from './events';
import { conversationChains } from './environment';
const ops = Deno.core.ops;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -16,6 +17,7 @@ export type Host = events.EventHost & {
harness(): durable.Harness; storage: Pick<durable.Storage, 'scanSubmissions'>;
models(): modelApi.Models; settings(): durable.JsonObject; metadata: typeof Metadata;
fs: typeof fs; exec: typeof exec; git: typeof git;
chain(conversationId:number):Promise<string[]>;
implement<S>(api: {name:string;readonly service?:S}, value:NoInfer<S>):void;
need<S>(api:{name:string;readonly service?:S}):S; want<S>(api:{name:string;readonly service?:S}):S|undefined;
resource<T>(key:string, create:()=>T):T;
@@ -73,6 +75,7 @@ export function registerModules() {
export async function loadExtensions(harness:any, registry:any, storage:any, models:any, context:any, builtins:any[]=[]) {
registerModules();
const eventService = events.eventLog(()=>harness);
const chain = conversationChains(harness,context);
const repo = '/src/sausage';
let providers = new Map<string,any>();
const resources = new Map<string,any>();
@@ -104,7 +107,7 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
}});
const host:Host = {
harness:()=>harness, storage, models:()=>models, settings:()=>settings, metadata:Metadata, fs,
exec:(command,options)=>exec(command,{...options,extension:name}),git,
exec:(command,options)=>exec(command,{...options,extension:name}),git,chain,
implement:(api,value)=>{staged.set(api.name,value);}, need:handle,
want:(api)=>providers.has(api.name)?handle(api):undefined,
resource:(key,create)=>{const id=`${name}/${key}`;if(!resources.has(id))resources.set(id,create());return resources.get(id);},
+54 -52
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool2) => ({
...tool2,
execute: (args, api, context) => tool2.execute(args, { ...api, env: api.env.forTool(api.callId, tool2.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries3) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -30697,43 +30736,6 @@ Last good: ${fallback}` : failed;
} };
}
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool2) => ({
...tool2,
execute: (args, api, context) => tool2.execute(args, { ...api, env: api.env.forTool(api.callId, tool2.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops2 = Deno.core.ops;
// node_modules/@earendil-works/chord/dist/sqlite/schema.js
function validateSqliteSchemaPrefix(prefix) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
+54 -15
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness, registry, storage, models, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness);
const chain = conversationChains(harness, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness, registry, storage, models, context, built
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness, registry, storage, models, context, built
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+111 -72
View File
@@ -8946,27 +8946,27 @@ var REPLACE_TAKE = { kind: "replace" };
var PendingOps = class {
#state = EMPTY;
/** Queue the ops of one commit. */
push(ops2) {
if (ops2.length === 0)
push(ops3) {
if (ops3.length === 0)
return;
const state = this.#state;
if (ops2[0][0] === "r") {
this.#state = { kind: "batch", ops: ops2 };
if (ops3[0][0] === "r") {
this.#state = { kind: "batch", ops: ops3 };
return;
}
switch (state.kind) {
case "empty":
this.#state = { kind: "batch", ops: ops2 };
this.#state = { kind: "batch", ops: ops3 };
return;
case "batch": {
const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() };
this.#state = folding;
if (this.#fold(state.ops, folding))
this.#fold(ops2, folding);
this.#fold(ops3, folding);
return;
}
case "folding":
this.#fold(ops2, state);
this.#fold(ops3, state);
return;
case "replace":
return;
@@ -8997,8 +8997,8 @@ var PendingOps = class {
}
}
/** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */
#fold(ops2, folding) {
for (const op2 of ops2) {
#fold(ops3, folding) {
for (const op2 of ops3) {
switch (op2[0]) {
case "r":
folding.ops.length = 0;
@@ -9267,12 +9267,12 @@ var PathError = class extends Error {
this.name = "PathError";
}
};
function apply(target, ops2) {
return applyOps(target, ops2);
function apply(target, ops3) {
return applyOps(target, ops3);
}
function applyOps(target, ops2) {
function applyOps(target, ops3) {
let root2 = target;
for (const op2 of ops2) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9341,14 +9341,14 @@ function applyOps(target, ops2) {
}
return root2;
}
function applyImmutable(target, ops2) {
return applyImmutableBatches(target, [ops2]);
function applyImmutable(target, ops3) {
return applyImmutableBatches(target, [ops3]);
}
function applyImmutableBatches(target, batches) {
let root2 = target;
const owned = /* @__PURE__ */ new WeakSet();
for (const ops2 of batches) {
for (const op2 of ops2) {
for (const ops3 of batches) {
for (const op2 of ops3) {
assertValidOp(op2);
if (op2[0] === "r") {
root2 = op2[1];
@@ -9462,10 +9462,10 @@ function encoder() {
let nextId = 0;
let previous;
return {
encode(ops2) {
encode(ops3) {
previous = void 0;
const out = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r") {
out.push(op2);
seen.clear();
@@ -9715,9 +9715,9 @@ var Source = class {
return [];
}
};
function unbox(ops2, value2) {
function unbox(ops3, value2) {
const result = [];
for (const op2 of ops2) {
for (const op2 of ops3) {
if (op2[0] === "r" || op2[1].length === 0)
return [["r", value2]];
const path = op2[1].slice(1);
@@ -9811,8 +9811,8 @@ var Cell = class extends Source {
throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value2);
const ops2 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops2);
const ops3 = diff ? diffRevisions(before, value2) : void 0;
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "ops", true, ops3);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
@@ -9823,7 +9823,7 @@ var Cell = class extends Source {
this.#publish(this.#tracker.prepareReplace({ v: value2 }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(prepared, type, validate2 = true, ops2) {
#publish(prepared, type, validate2 = true, ops3) {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
@@ -9840,7 +9840,7 @@ var Cell = class extends Source {
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops2 !== void 0 && ops2.length > 0 ? ops2 : unbox(prepared.ops, value2);
const published = ops3 !== void 0 && ops3.length > 0 ? ops3 : unbox(prepared.ops, value2);
this.fanout.emit(type === "reset" ? { type, value: value2 } : { type, ops: published, value: value2 });
for (const [derived, next] of candidates)
derived.install(next);
@@ -9953,9 +9953,9 @@ var Derived = class extends Source {
install(next) {
const before = this.#value;
this.#value = next;
const ops2 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops2.length > 0)
this.fanout.emit({ type: "ops", ops: ops2, value: next });
const ops3 = before === void 0 || next === void 0 ? [["r", next ?? null]] : diffRevisions(before, next);
if (ops3.length > 0)
this.fanout.emit({ type: "ops", ops: ops3, value: next });
}
};
var isSource = (value2) => value2 instanceof Source;
@@ -14008,10 +14008,10 @@ var RemoteBinding = class {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === void 0)
return;
const ops2 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops2);
const ops3 = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops3);
if (target === this.members.get(name))
this.#deliver(name, { type: "ops", ops: ops2, value: target.value });
this.#deliver(name, { type: "ops", ops: ops3, value: target.value });
return;
}
const fresh = { value: frame.value, decoder: decoder() };
@@ -15835,8 +15835,8 @@ var Watch = class extends WatchLifecycle {
this.#retired = false;
if (lone !== void 0)
return lone;
const ops2 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops2, retired };
const ops3 = batch.kind === "ops" ? batch.ops : [["r", value2]];
return { kind: "value", value: value2, ops: ops3, retired };
}
/** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */
#nextAfterFailure() {
@@ -17601,7 +17601,7 @@ function describe(plan, record2) {
const change = plan.change;
if (change === void 0 || !publishes(plan))
return void 0;
const ops2 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
const ops3 = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops;
return {
type: "document",
kind: "change",
@@ -17609,11 +17609,11 @@ function describe(plan, record2) {
conversationId,
version: change.version,
value: change.prepared.value,
ops: ops2
ops: ops3
};
}
function storeHeld(hold, ops2, value2, version) {
hold.pending.push(ops2);
function storeHeld(hold, ops3, value2, version) {
hold.pending.push(ops3);
const taken = hold.pending.take();
if (taken.kind === "replace")
return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] };
@@ -18694,7 +18694,7 @@ var SessionImpl = class {
return { kind: "adoption", error };
}
const deadline = this.#clock() + withinMs;
for (const { loaded, ops: ops2, base: base2 } of held2) {
for (const { loaded, ops: ops3, base: base2 } of held2) {
const id = loaded.record.id;
let hold = this.#holds.get(id);
if (hold === void 0) {
@@ -18702,7 +18702,7 @@ var SessionImpl = class {
hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin };
this.#holds.set(id, hold);
}
hold.pending.push(ops2);
hold.pending.push(ops3);
if (base2)
hold.base = true;
if (deadline < hold.deadline)
@@ -26917,14 +26917,14 @@ var TaskGraphView = class {
const mount = this.#mount;
if (mount === void 0)
return;
const ops2 = [];
const ops3 = [];
for (const [key, record2] of mount.records) {
const blocked = this.#blocking.blocked(record2);
if (blocked === mount.value.tasks[key]?.blocked)
continue;
ops2.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
ops3.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]);
}
publish(mount, ops2, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
publish(mount, ops3, BACKGROUND_CONTEXT, (error) => this.#session.report(error));
}).catch(() => {
});
}
@@ -26948,7 +26948,7 @@ var TaskGraphView = class {
}
};
function advance(mount, publication, context, blocked, report2) {
const ops2 = [];
const ops3 = [];
const changed = /* @__PURE__ */ new Map();
const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key];
for (const change of publication.changes) {
@@ -26961,7 +26961,7 @@ function advance(mount, publication, context, blocked, report2) {
mount.records.delete(key);
if (previous === void 0)
continue;
ops2.push(["d", ["tasks", key]]);
ops3.push(["d", ["tasks", key]]);
changed.set(key, void 0);
continue;
}
@@ -26970,7 +26970,7 @@ function advance(mount, publication, context, blocked, report2) {
const next = nodeOf2(record2, previous?.conversations ?? [], blocked(subject));
if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous))
continue;
ops2.push(["s", ["tasks", key], next]);
ops3.push(["s", ["tasks", key], next]);
changed.set(key, next);
}
const created = /* @__PURE__ */ new Map();
@@ -26983,16 +26983,16 @@ function advance(mount, publication, context, blocked, report2) {
}
for (const [key, ids] of created) {
const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b);
ops2.push(["s", ["tasks", key, "conversations"], conversations]);
ops3.push(["s", ["tasks", key, "conversations"], conversations]);
}
publish(mount, ops2, context, report2);
publish(mount, ops3, context, report2);
}
function publish(mount, ops2, context, report2) {
if (ops2.length === 0)
function publish(mount, ops3, context, report2) {
if (ops3.length === 0)
return;
mount.value = applyImmutable(mount.value, ops2);
mount.value = applyImmutable(mount.value, ops3);
const frameContext = withoutAbortSignal(context);
const call2 = { kind: "value", value: mount.value, ops: ops2, retired: false };
const call2 = { kind: "value", value: mount.value, ops: ops3, retired: false };
for (const observer of [...mount.observers])
contained(() => observer.advance(call2, frameContext), report2);
}
@@ -27218,19 +27218,19 @@ function advance2(id, mount, publication, context, report2) {
const previous = windowed.value;
let entries2 = previous.entries;
let before = previous.before;
let ops2 = docOps;
let ops3 = docOps;
if (appended.length > 0) {
ops2 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
ops3 = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]];
entries2 = entries2.concat(appended);
if (entries2.length > windowed.max) {
const drop = entries2.length - windowed.min;
before = entries2[drop - 1].id;
entries2 = entries2.slice(drop);
ops2.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
ops3.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]);
}
}
windowed.value = view(mount.conversation, entries2, before, mount.docs);
const call2 = { kind: "value", value: windowed.value, ops: ops2, retired: false };
const call2 = { kind: "value", value: windowed.value, ops: ops3, retired: false };
contained(() => windowed.observer.advance(call2, frameContext), report2);
}
}
@@ -31368,14 +31368,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain(up),
...starter === void 0 ? [] : await chain(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -31398,14 +31435,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -31432,11 +31469,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -31447,7 +31485,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -31455,7 +31493,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -31488,6 +31526,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -31510,12 +31549,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -31594,7 +31633,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -31603,8 +31642,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -31620,11 +31659,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -31671,7 +31710,7 @@ var todosModule = await import(
/* @vite-ignore */
`${base}/todos/server.ts`
);
var { Plan, TaskIndex, Branch, Started } = planModule;
var { Plan, TaskIndex, Branch, Started: Started2 } = planModule;
var { Todos, Standing, add, mark, noteFor, DONE_LIMIT } = todosModule;
var call = async (args, id = root.id, name = "plan") => {
const conversation = await harness.conversation(id, BACKGROUND_CONTEXT);
@@ -31776,7 +31815,7 @@ try {
}, BACKGROUND_CONTEXT);
assert(Object.keys((await harness.snapshot(Plan, branch.id, BACKGROUND_CONTEXT)).epics).length === 1, "Fork inherited plan");
equal(JSON.parse(text(await call({ action: "show" }, branch.id))), Plan.definition.initial());
assert((await harness.snapshot(Started, branch.id, BACKGROUND_CONTEXT)).started, "Branch started");
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).started, "Branch started");
const ULTIMATE = 5;
const fakeTx = () => {
const docs = /* @__PURE__ */ new Map();
+54 -52
View File
@@ -30428,14 +30428,51 @@ function eventLog(harness2) {
}
}
// extensions.ts
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain2 = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain2(up),
...starter === void 0 ? [] : await chain2(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain2;
}
var ops = Deno.core.ops;
// extensions.ts
var ops2 = Deno.core.ops;
var encoder4 = new TextEncoder();
var decoder4 = new TextDecoder();
var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) });
var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false });
async function file(kind, path, bytes2 = []) {
const result = await ops.op_file(kind, path, bytes2);
const result = await ops2.op_file(kind, path, bytes2);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
@@ -30458,14 +30495,14 @@ async function exec(command, options) {
program: `extension:${options.extension}`,
conversations: options.conversations ?? []
} : void 0;
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
const result = { code: 0, stdout: "", stderr: "" };
try {
for (; ; ) {
const chunk = await ops.op_exec_next(rid);
const chunk = await ops2.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
@@ -30492,11 +30529,12 @@ async function git(cwd, ...args) {
function registerModules() {
const modules = { "@earendil-works/sausage-events": events_exports, "@earendil-works/pi-schema": dist_exports, "@earendil-works/pi-durable": dist_exports3, "@earendil-works/pi-durable/tools": tools_exports, "@earendil-works/pi-ai": dist_exports4, "@earendil-works/pi-ai/models": models_exports, "@earendil-works/chord": dist_exports2, "@earendil-works/chord/context": context_exports };
globalThis.__sausage = { modules };
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
ops2.op_modules(Object.fromEntries(Object.entries(modules).map(([name, value2]) => [name, Object.keys(value2)])));
}
async function loadExtensions(harness2, registry2, storage2, models2, context, builtins = []) {
registerModules();
const eventService = eventLog(() => harness2);
const chain2 = conversationChains(harness2, context);
const repo = "/src/sausage";
let providers = /* @__PURE__ */ new Map();
const resources = /* @__PURE__ */ new Map();
@@ -30507,7 +30545,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
let queue2 = Promise.resolve();
const publish2 = async () => {
await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
await fs.write("/src/sausage/.git/sausage-last-good", state.good ?? "");
};
const dispose = async (entries2) => {
@@ -30515,7 +30553,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
try {
await cleanup();
} catch (error) {
ops.op_output(`Extension cleanup: ${String(error)}
ops2.op_output(`Extension cleanup: ${String(error)}
`);
}
}
@@ -30548,6 +30586,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
fs,
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
},
@@ -30570,12 +30609,12 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
onClose: (cb) => close.push(cb),
notice: (message) => {
state.notice = `${name}: ${message}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${name}: ${message}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${name}: ${message}
`);
if (!notices.has(message)) {
notices.add(message);
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops.op_output(`Recording a notice failed: ${String(error)}
void harness2.commit((tx) => eventService.emit(tx, NoticeEvent, { extension: name, message, level: "warning" }), context).catch((error) => ops2.op_output(`Recording a notice failed: ${String(error)}
`));
}
}
@@ -30654,7 +30693,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
tip = await git(repo, "rev-parse", "refs/heads/main");
} catch (error) {
state.error = String(error);
ops.op_extension_status(JSON.stringify(state));
ops2.op_extension_status(JSON.stringify(state));
return;
}
try {
@@ -30663,8 +30702,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
if (error.code === "not_found") settings = {};
else {
state.notice = `Settings: ${String(error)}`;
ops.op_extension_status(JSON.stringify(state));
ops.op_output(`${state.notice}
ops2.op_extension_status(JSON.stringify(state));
ops2.op_output(`${state.notice}
`);
}
}
@@ -30680,11 +30719,11 @@ Last good: ${fallback}` : failed;
await publish2();
};
const enqueue2 = () => queue2 = queue2.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]);
await enqueue2();
const pending = (async () => {
while (active) {
await ops.op_watch_next(rid);
await ops2.op_watch_next(rid);
if (active) await enqueue2();
}
})();
@@ -30697,43 +30736,6 @@ Last good: ${fallback}` : failed;
} };
}
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
version: 1,
checkpointWhen: () => false,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({})
});
function conversationChains(harness2, context) {
const cache = /* @__PURE__ */ new Map();
const chain2 = (id) => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId;
const starter = (await harness2.snapshot(Started, id, context))?.by;
return [.../* @__PURE__ */ new Set([
String(id),
...up === void 0 ? [] : await chain2(up),
...starter === void 0 ? [] : await chain2(starter)
])];
})();
cache.set(id, result);
}
return result;
};
return chain2;
}
var ops2 = Deno.core.ops;
// node_modules/@earendil-works/chord/dist/sqlite/schema.js
function validateSqliteSchemaPrefix(prefix) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
+274
View File
@@ -0,0 +1,274 @@
//! World-loaded automation and pause cases, plus durable fire recovery after SIGKILL.
use anyhow::{Result, ensure};
use sausage::world::World;
use std::{
path::Path,
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/automations-test-bundle.js");
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
} else if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), destination.join(entry.file_name()))?;
}
}
Ok(())
}
fn setup(directory: &Path) -> Result<()> {
let world = World::open(directory)?;
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").is_file() {
ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd build failed"
);
}
ensure!(
Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"git",
"-p",
"curl",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(target)
.args(["-j", "2"])
.current_dir(&userland)
.status()?
.success(),
"guest build failed"
);
let release = target.join("wasm32-wasip2/release");
if !release.join("brush.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.current_dir(&userland)
.status()?
.success(),
"shell build failed"
);
}
let disk = directory.join("world");
std::fs::create_dir_all(disk.join("bin"))?;
for (source, name) in [
("git.wasm", "git.wasm"),
("curl.wasm", "curl.wasm"),
("brush.wasm", "sh.wasm"),
] {
std::fs::copy(release.join(source), disk.join("bin").join(name))?;
}
let repo = disk.join("src/sausage");
std::fs::create_dir_all(&repo)?;
ensure!(
Command::new("git")
.args(["init", "-b", "main"])
.arg(&repo)
.status()?
.success()
);
copy_tree(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
&repo.join("extensions"),
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
ensure!(
Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.status()?
.success()
);
ensure!(
Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"-m",
"automations fixture"
])
.current_dir(&repo)
.status()?
.success()
);
drop(world);
Ok(())
}
async fn run(directory: &Path, mode: &str) -> Result<()> {
let world = World::open(directory)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
sausage::harness::run_module(
world,
exec,
&format!("globalThis.automationMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn schedules_and_pause() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "cases").await
}
#[tokio::test(flavor = "current_thread")]
async fn event_and_context() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "triggers").await
}
#[tokio::test(flavor = "current_thread")]
async fn script_inherits_creator_grant() -> Result<()> {
use sausage::{
api::Api,
cred::{Caller, Cred, grants::Grant, types::Subject},
};
use std::sync::Arc;
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let world = World::open(directory.path())?;
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0")
.map_err(|_| anyhow::anyhow!("fixture bind failed"))?,
);
let port = server.server_addr().to_ip().unwrap().port();
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller::default();
let token = uuid::Uuid::new_v4().to_string();
let item = cred.create_item(serde_json::from_value(serde_json::json!({"name":"automation fixture","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":port,"path_prefixes":["/token"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text"}]}))?, &caller, cred.unlocked()?)?;
cred.set_field(
&item.id,
&item.fields[0].field_id,
&token,
&caller,
cred.unlocked()?,
)?;
cred.grant_create(
Grant {
id: String::new(),
items: vec![item.id],
subject: Subject::Conversation("1".into()),
exp: None,
max_uses: None,
uses: 0,
revoked: false,
validator: None,
note: None,
},
&caller,
cred.unlocked()?,
)?;
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?);
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?);
let serving = server.clone();
let expected = format!("Bearer {token}");
let thread = std::thread::spawn(move || {
let mut accepted = 0;
while let Ok(request) = serving.recv() {
let valid = request.headers().iter().any(|header| {
header.field.equiv("Authorization") && header.value.as_str() == expected
});
if valid {
accepted += 1;
}
let _ = request.respond(tiny_http::Response::from_string(if valid {
"accepted"
} else {
"missing"
}));
}
accepted
});
let source =
format!("globalThis.automationMode='grant'; globalThis.automationPort={port};\n{BUNDLE}");
let result = tokio::time::timeout(
Duration::from_secs(60),
sausage::harness::run_module_with_credentials(world, runtime, cred, &source),
)
.await;
server.unblock();
let accepted = thread.join().unwrap();
result??;
ensure!(
accepted == 1,
"expected one injected request, got {accepted}"
);
Ok(())
}
#[test]
fn automations_child() -> Result<()> {
let Some(directory) = std::env::var_os("SAUSAGE_AUTOMATIONS_CHILD") else {
return Ok(());
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(run(Path::new(&directory), "prepare"))
}
#[tokio::test(flavor = "current_thread")]
async fn fire_after_sigkill() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "automations_child", "--nocapture"])
.env("SAUSAGE_AUTOMATIONS_CHILD", directory.path())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(60);
let ready = directory.path().join("world/automation-ready");
while !ready.exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("child ended before ready: {status}");
}
if Instant::now() >= deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("child readiness deadline");
}
tokio::task::yield_now().await;
}
child.kill()?;
child.wait()?;
run(directory.path(), "restart").await?;
run(directory.path(), "restart").await
}