Load world extensions from commit snapshots with last-good recovery

This commit is contained in:
2026-11-07 10:38:20 +00:00
parent 534fa50615
commit bcb3e4d8e5
17 changed files with 42251 additions and 399 deletions
+9995 -361
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
// A world git repo exercises commit reloads, old calls, registry defaults, and recovery.
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, git, exec } from './extensions';
const assert = (value:any,message:string)=>{if(!value)throw new Error(message);};
const until = async (test:()=>Promise<boolean>|boolean)=>{const deadline=Date.now()+15000;while(!await test()){if(Date.now()>deadline)throw new Error('Reload deadline');await new Promise(r=>setTimeout(r,0));}};
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,settings:{get extensions(){return registry.snapshot().installed();}}},context);
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}});
globalThis.shared={gate:Promise.withResolvers(),started:false,wait:false};
const loader=await loadExtensions(harness,registry,storage,models,context);
harness.resume();
const turn = async()=> {
faux.setResponses([fauxAssistantMessage([fauxToolCall('demo',{})],{stopReason:'toolUse'}),fauxAssistantMessage('done')]);
const submission=await root.submit({type:'input',content:'call demo'},context);
await submission.wait(context);
const entries=(await root.context(context)).entries;
const result=entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1);
return JSON.stringify(result);
};
try {
if(globalThis.extensionRestart) {
assert(loader.state().error,'Broken tip must report its error');
assert(await fs.read('/src/sausage/.git/sausage-last-good')===globalThis.extensionGood,'Last good must survive');
assert((await turn()).includes('result C'),'Restart must use C');
} else {
const env = await exec(['env'],{cwd:'/',env:[['EXTENSION_OPTION','yes']],conversations:['1'],timeout:3});
assert(env.code===0 && env.stdout.includes('EXTENSION_OPTION=yes'),'Exec environment');
await fs.write('/src/sausage/temporary','one');await fs.append('/src/sausage/temporary','two');
await fs.rename('/src/sausage/temporary','/src/sausage/renamed');
assert(await fs.read('/src/sausage/renamed')==='onetwo','File mutation');
assert((await fs.lstat('/src/sausage/renamed')).kind==='file','lstat');await fs.remove('/src/sausage/renamed');
const source=(version:string)=>`import {defineExtension,defineTool} from '@earendil-works/pi-durable';
import {obj} from '@earendil-works/pi-schema';
export default host=>{if(defineTool!==globalThis.__sausage.modules['@earendil-works/pi-durable'].defineTool)throw new Error('Duplicate durable instance');host.implement({name:'demo.service'},{value:()=> '${version}'});
globalThis.shared.handle=host.resource('handle',()=>host.need({name:'demo.service'}));
return defineExtension({name:'demo',tools:[defineTool({name:'demo',description:'Demo',parameters:obj({}),execute:async()=>{if(globalThis.shared.wait){globalThis.shared.started=true;await globalThis.shared.gate.promise;}return {output:[{type:'text',text:'result ${version}'}]};}})]});};`;
await fs.mkdir('/src/sausage/extensions');await fs.mkdir('/src/sausage/extensions/demo');
await fs.write('/src/sausage/extensions/demo/server.ts',source('A'));
await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','A');
const a=await git('/src/sausage','rev-parse','HEAD');
await until(()=>loader.state().good===a);
assert((await turn()).includes('result A'),'Existing conversation must see A');
const held=globalThis.shared.handle;
assert(held.value()==='A','A service');
const retainedMethod=held.value;
await fs.write('/src/sausage/extensions/demo/server.ts','syntax !!! error');
await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','B');
const b=await git('/src/sausage','rev-parse','HEAD');
await until(()=>loader.state().current===b && !!loader.state().error);
assert(loader.state().good===a,'Broken B must keep A');
assert(JSON.parse(await fs.read('/proc/extensions')).error,'Status must report B');
assert(held.value()==='A','A service remains');
assert((await turn()).includes('result A'),'A tool remains after broken B');
globalThis.shared.wait=true;
const running=turn();await until(()=>globalThis.shared.started);
await fs.write('/src/sausage/extensions/demo/server.ts',source('C'));
await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','C');
const c=await git('/src/sausage','rev-parse','HEAD');
await until(()=>loader.state().good===c);
assert(held.value()==='C','A need handle must resolve C');
assert(retainedMethod()==='C','A retained method must resolve C');
globalThis.shared.wait=false;globalThis.shared.gate.resolve();
assert((await running).includes('result A'),'Old call must finish on A');
assert((await turn()).includes('result C'),'Next call must use C');
await git('/src/sausage','update-ref','refs/heads/main',b);
await until(()=>loader.state().current===b && !!loader.state().error);
assert((await exec(['extensions-revert'],{cwd:'/'})).code===0,'Revert command');
await until(()=>loader.state().current===c && !loader.state().error);
await git('/src/sausage','update-ref','refs/heads/main',b);
await until(()=>loader.state().current===b && !!loader.state().error);
await fs.write('/src/sausage/good.txt',c);
}
} finally {globalThis.shared.gate.resolve();await loader.close();await harness.close(context);}
+161
View File
@@ -0,0 +1,161 @@
// World extension snapshots, staged services, lifecycle, and last-good recovery.
import * as durable from '@earendil-works/pi-durable';
import * as tools from '@earendil-works/pi-durable/tools';
import * as ai from '@earendil-works/pi-ai';
import * as modelApi from '@earendil-works/pi-ai/models';
import * as chord from '@earendil-works/chord';
import * as contextApi from '@earendil-works/chord/context';
import * as schema from '@earendil-works/pi-schema';
const ops = Deno.core.ops;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export const ExtensionState = durable.defineDoc({ kind: 'sausage.extensions', version: 1, scope: 'session', initial: () => ({good:null, current:null, error:null}) });
export const Metadata = durable.defineDoc({ kind: 'sausage.metadata', version: 1, scope: 'conversation', history:'latest', fork:'current', initial:()=>({label:'',title:''}), checkpointWhen:()=>false });
export type Host = {
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;
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;
onOpen(cb:()=>Promise<void>):void; onClose(cb:()=>Promise<void>):void;
notice(message:string):void;
// P3-d adds emit/react/eventLog here, with atomic events and durable cursors.
};
async function file(kind:string, path:string, bytes:number[]=[]):Promise<any> {
const result = await ops.op_file(kind,path,bytes);
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}
export const fs = {
read: async (path:string) => decoder.decode(new Uint8Array(await file('read',path))),
write: (path:string, text:string) => file('write',path,Array.from(encoder.encode(text))),
append: (path:string, text:string) => file('append',path,Array.from(encoder.encode(text))),
list: (path:string) => file('list',path), stat: (path:string) => file('stat',path),
lstat: (path:string) => file('lstat',path), mkdir: (path:string) => file('mkdir',path),
remove: (path:string) => file('remove',path),
rename: (from:string,to:string) => file('rename',from,Array.from(encoder.encode(to))),
};
export async function exec(command:string|string[], options:{cwd:string;env?:[string,string][];timeout?:number;conversations?:string[];signal?:AbortSignal;onOutput?:(text:string,stream:string)=>void}) {
options.signal?.throwIfAborted();
const rid = ops.op_exec(command, options.cwd, options.timeout, undefined, options.env, options.conversations);
const abort = () => Deno.core.close(rid);
options.signal?.addEventListener('abort',abort,{once:true});
const decoders:any = {stdout:new TextDecoder(),stderr:new TextDecoder()};
const result = {code:0,stdout:'',stderr:''};
try {
for (;;) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) {
if (chunk.error) throw new Error(chunk.error);
result.code = chunk.exitCode;
for (const stream of ['stdout','stderr']) { const text = decoders[stream].decode(); result[stream] += text; if(text) options.onOutput?.(text,stream); }
return result;
}
const text = decoders[chunk.stream].decode(new Uint8Array(chunk.bytes),{stream:true});
result[chunk.stream] += text;
options.onOutput?.(text,chunk.stream);
}
} finally { options.signal?.removeEventListener('abort',abort); }
}
export async function git(cwd:string,...args:string[]) {
const result = await exec(['git',...args],{cwd,timeout:30});
if (result.code) throw new Error(result.stderr || `git exited ${result.code}`);
return result.stdout.trim();
}
export function registerModules() {
const modules = {'@earendil-works/pi-schema':schema,'@earendil-works/pi-durable':durable,'@earendil-works/pi-durable/tools':tools,'@earendil-works/pi-ai':ai,'@earendil-works/pi-ai/models':modelApi,'@earendil-works/chord':chord,'@earendil-works/chord/context':contextApi};
globalThis.__sausage = {modules};
ops.op_modules(Object.fromEntries(Object.entries(modules).map(([name,value])=>[name,Object.keys(value)])));
}
export async function loadExtensions(harness:any, registry:any, storage:any, models:any, context:any, builtins:any[]=[]) {
registerModules();
const repo = '/src/sausage';
let providers = new Map<string,any>();
const resources = new Map<string,any>();
let facets:any[] = [];
let state = {...(await harness.snapshot(ExtensionState,context) ?? {good:null,current:null,error:null})};
let settings:any = {};
let active = true;
let queue = Promise.resolve();
const publish = async () => {
await harness.commit(async (tx:any)=>Object.assign(await tx.doc(ExtensionState),state),context);
ops.op_extension_status(JSON.stringify(state));
await fs.write('/src/sausage/.git/sausage-last-good',state.good ?? '');
};
const dispose = async (entries:any[]) => { for(const facet of entries) for(const cleanup of facet.close) { try {await cleanup();} catch(error) {ops.op_output(`Extension cleanup: ${String(error)}\n`);} } };
const scope = (name:string, staged:Map<string,any>) => {
const open:any[] = [], close:any[] = [];
const handle = (api:any) => new Proxy({}, {get:(_,key)=> {
if (typeof key === 'symbol' || key === 'then') return undefined;
const value = providers.get(api.name);
if(!value) throw new Error(`No provider for ${api.name}`);
const member = value[key];
return typeof member === 'function' ? (...args:any[]) => {
const current = providers.get(api.name);
if(!current) throw new Error(`No provider for ${api.name}`);
return Reflect.apply(current[key],current,args);
} : member;
}});
const host:Host = {
harness:()=>harness, storage, models:()=>models, settings:()=>settings, metadata:Metadata, fs,exec,git,
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);},
onOpen:cb=>open.push(cb),onClose:cb=>close.push(cb),notice:message=>{state.notice=`${name}: ${message}`;ops.op_extension_status(JSON.stringify(state));ops.op_output(`${name}: ${message}\n`);},
};
return {host,open,close};
};
const load = async (sha:string) => {
const next:any[] = [];
const staged = new Map<string,any>();
try {
const snapshot = `/run/sausage/extensions/${sha}`;
for(const path of ['/run/sausage','/run/sausage/extensions']) {try {await fs.mkdir(path);} catch(error) {if((error as any).code!=='already_exists')throw error;} }
try {await fs.stat(snapshot);} catch {await git(repo,'worktree','prune');await git(repo,'worktree','add','--detach',snapshot,sha);}
const modules:any[] = [];
for(const entry of await fs.list(`${snapshot}/extensions`).catch(error=>{if(error.code==='not_found')return [];throw error;})) {
if(entry.kind!=='directory')continue;
const path = `${snapshot}/extensions/${entry.name}/server.ts`;
try {await fs.stat(path);} catch(error) {if((error as any).code==='not_found')continue;throw error;}
const module = await import(/* @vite-ignore */ `file://${path}`);
modules.push({name:entry.name,module});
}
modules.sort((a,b)=>(a.module.order??0)-(b.module.order??0)||a.name.localeCompare(b.name));
for(const {name,module} of modules) {
const facet = scope(name,staged); next.push(facet);
facet.extension = typeof module.default==='function'?await module.default(facet.host):module.default;
if(!facet.extension || facet.extension.name!==name)throw new Error(`Extension name must match ${name}`);
}
// Validate the complete commit before the live registry changes.
const probe = durable.createRegistry();
for(const extension of [...builtins,...next.map(f=>f.extension)])probe.install(extension);
for(const old of facets)registry.uninstall(old.extension);
for(const extension of [...builtins,...next.map(f=>f.extension)])registry.install(extension);
const old = facets; facets=next; providers=staged;
await dispose(old);
for(const facet of next)for(const cb of facet.open) {try {await cb();} catch(error) {facet.host.notice(`onOpen: ${String(error)}`);} }
state.good=sha;
return null;
} catch(error) {await dispose(next); return String(error);}
};
const reload = async () => {
let tip:string;
try {tip=await git(repo,'rev-parse','refs/heads/main');} catch(error) {state.error=String(error);ops.op_extension_status(JSON.stringify(state));return;}
try {settings=JSON.parse(await fs.read(`${repo}/settings.json`));} catch(error) {if((error as any).code==='not_found')settings={};else {state.notice=`Settings: ${String(error)}`;ops.op_extension_status(JSON.stringify(state));ops.op_output(`${state.notice}\n`);}}
if(state.current===tip && facets.length)return;
state.current=tip;
state.error=await load(tip);
if(state.error && !facets.length && state.good && state.good!==tip) {
const failed=state.error;
const fallback=await load(state.good);
state.error=fallback?`${failed}\nLast good: ${fallback}`:failed;
}
await publish();
};
const enqueue = () => queue=queue.then(reload);
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]);
await enqueue();
const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})();
return {reload:enqueue, state:()=>({...state}), close:async()=>{active=false;Deno.core.close(rid);await pending;await queue;await dispose(facets);}};
}
+12 -3
View File
@@ -2,12 +2,13 @@
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { openStorage } from './sqlite';
import { environment } from './environment';
import { CredentialRequests, startRequests } from './credentials';
import { serve } from './input';
import { loadExtensions } from './extensions';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
const models = createModels();
@@ -30,10 +31,16 @@ registry.install(CodingTools);
registry.install(CredentialRequests);
export const storage = await openStorage(globalThis.storagePath);
const harness = await Harness.open(storage, {
models, registry, settings: { followUpMode: 'all' },
models, registry, settings: { followUpMode: 'all', get extensions() { return registry.snapshot().installed(); } },
env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
}, context);
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } });
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/' } });
// T32 stored this exact built-in list in the console root. Use registry defaults now.
const storedAgent = await harness.snapshot(AgentDoc, root.id, context);
if (Array.isArray(storedAgent?.extensions) && storedAgent.extensions.length === 2 &&
[CodingTools, CredentialRequests].every(extension => storedAgent.extensions.includes(extension.name))) {
await root.configure({ extensions: null }, context);
}
const watch = await root.watch(context);
const emitted = new Map<number, string>();
let partial = '';
@@ -58,6 +65,7 @@ for (const entry of watch.value.entries) {
}
if (globalThis.consoleEnabled) watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
const stopRequests = await startRequests(harness, context);
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
harness.resume();
await serve(harness, context, globalThis.consoleEnabled ? async () => {
await harness.waitForIdle(context);
@@ -66,6 +74,7 @@ await serve(harness, context, globalThis.consoleEnabled ? async () => {
await final.stop();
Deno.core.ops.op_output('\n');
} : undefined);
await extensions.close();
await stopRequests();
await watch.stop();
await harness.close(context);
+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" },
"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" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",