108 lines
6.5 KiB
TypeScript
108 lines
6.5 KiB
TypeScript
// The world chat API runs in the same harness as turns and extension actions.
|
|
import { AgentDoc, LiveDoc, defineDoc } from '@earendil-works/pi-durable';
|
|
import { Metadata } from './extensions';
|
|
import { eventLog } from './events';
|
|
const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({ids:[]})});
|
|
const Started = defineDoc({kind:'board.started',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})});
|
|
const fail = (status:number,message:string):never => { throw Object.assign(new Error(message),{status}); };
|
|
const integer = (value:any, fallback?:number) => {
|
|
if (value === null && fallback !== undefined) return fallback;
|
|
const n=Number(value);
|
|
if (!Number.isSafeInteger(n) || n<0) fail(400,'Invalid cursor or conversation');
|
|
return n;
|
|
};
|
|
export function harnessApi(harness:any, models:any, extensions:any, context:any) {
|
|
const streams = new Map<string,()=>Promise<void>>();
|
|
const log = eventLog(()=>harness).eventLog;
|
|
const send = (id:string,value:any) => Deno.core.ops.op_harness_reply(id,value);
|
|
async function entries(conversation:any,after:number) {
|
|
const found:any[]=[];
|
|
let cursor:any;
|
|
do {
|
|
const page=await conversation.entries({minEntryId:after+1,order:'ascending'},256,cursor,context);
|
|
found.push(...page.items); cursor=page.next;
|
|
} while(cursor);
|
|
return found;
|
|
}
|
|
async function follow(input:any, read:(after:number)=>Promise<any[]>, cursor:number) {
|
|
// Observe before the snapshot. Serialize scans to close the snapshot/subscription race.
|
|
let active=true, queue=Promise.resolve();
|
|
const scan=()=>queue=queue.then(async()=>{
|
|
if (!active) return;
|
|
for (;;) {
|
|
const page=await read(cursor);
|
|
if(!active || !page.length) return;
|
|
for (const entry of page) {
|
|
cursor=entry.id; send(input.id,entry);
|
|
}
|
|
}
|
|
});
|
|
const observation=harness.observeCommits({advance:(publication:any)=>{if(publication.changes.some((change:any)=>change.type==='entry'))void scan();},end:()=>{void stop();}});
|
|
const stop=async()=>{if(!active)return;active=false;observation.detach();await queue;send(input.id,null);streams.delete(input.id);};
|
|
streams.set(input.id,stop);
|
|
send(input.id,{status:200,stream:true});
|
|
void Deno.core.ops.op_harness_closed(input.id).then(stop);
|
|
await scan();
|
|
}
|
|
async function handle(input:any) {
|
|
const url=new URL(input.path,'http://host.sausage');
|
|
const path=url.pathname.slice('/harness'.length);
|
|
const data=input.body??{};
|
|
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
|
|
try {
|
|
if(input.method==='GET' && path==='/conversations') {
|
|
const standing=await extensions.standing?.() ?? null;
|
|
const records=await harness.commit(async(tx:any)=>{
|
|
const all:any[]=[];let cursor:any;
|
|
do {const page=await tx.scanConversations({},256,cursor);all.push(...page.items);cursor=page.next;}while(cursor);
|
|
return all;
|
|
},context);
|
|
const concluded=new Set((await harness.snapshot(Concluded,context))?.ids??[]);
|
|
const conversations=await Promise.all(records.map(async(record:any)=>{
|
|
const agent=await harness.snapshot(AgentDoc,record.id,context);
|
|
const metadata=await harness.snapshot(Metadata,record.id,context);
|
|
const live=await harness.snapshot(LiveDoc,record.id,context);
|
|
const started:any=await harness.snapshot(Started,record.id,context);
|
|
return {...record,title:metadata?.title??'',agent,starter:started?.by??null,concluded:concluded.has(record.id),running:!!live?.run};
|
|
}));
|
|
reply(200,{conversations,standing});return;
|
|
}
|
|
if(input.method==='GET' && path==='/events') {
|
|
const cursor=integer(url.searchParams.get('after'),0);
|
|
await follow(input,async(after)=>log.recent({since:after,order:'ascending',limit:256}),cursor);return;
|
|
}
|
|
if(input.method==='GET' && path==='/extensions') {reply(200,extensions.state());return;}
|
|
if(input.method==='GET' && path==='/models') {reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
|
|
const action=/^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path);
|
|
if(input.method==='POST' && action) {
|
|
if(data.conversationId!==undefined && (!Number.isSafeInteger(data.conversationId)||data.conversationId<1)) fail(400,'Invalid conversation id');
|
|
const controller=new AbortController();
|
|
void Deno.core.ops.op_harness_closed(input.id).then(()=>controller.abort());
|
|
reply(200,{result:await extensions.invoke(decodeURIComponent(action[1]),decodeURIComponent(action[2]),data.args??{},controller.signal,data.conversationId)});return;
|
|
}
|
|
const match=/^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path);
|
|
if(!match) fail(404,'Unknown harness route');
|
|
const conversation=await harness.conversation(integer(match![1]),context);
|
|
if(!conversation) fail(404,'Unknown conversation');
|
|
const after=integer(url.searchParams.get('after'),0);
|
|
if(input.method==='GET' && match![2]==='entries') {reply(200,{entries:await entries(conversation,after)});return;}
|
|
if(input.method==='GET' && match![2]==='stream') {await follow(input,(cursor)=>entries(conversation,cursor),after);return;}
|
|
if(input.method==='POST' && match![2]==='submit') {
|
|
if(typeof data.requestId!=='string'||!data.requestId||typeof data.content!=='string'||!data.content.trim()) fail(400,'Expected content and requestId');
|
|
const submission=await conversation.submit({type:'input',content:data.content,requestId:data.requestId},context);
|
|
reply(202,{submissionId:submission.id});return;
|
|
}
|
|
if(input.method==='POST' && match![2]==='stop') {
|
|
for (;;) {
|
|
const own=(await harness.inspect(context)).tasks.filter(({record}:any)=>record.conversationId===conversation.id&&!record.owner&&!record.background);
|
|
if(!own.length) break;
|
|
for(const {record} of own) {await harness.abortTask(record.id,context);await harness.waitForTask(record.id,context);}
|
|
}
|
|
reply(200,{ok:true});return;
|
|
}
|
|
fail(404,'Unknown harness route');
|
|
} catch(error) {reply((error as any).status??500,{error:(error as Error).message});}
|
|
}
|
|
return {handle,async close(){await Promise.all([...streams.values()].map(stop=>stop()));}};
|
|
}
|