diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index 25186f2e8a..95b59620f3 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -480,7 +480,7 @@ export class AcServer extends EventEmitter { const reportedModel = event.message?.model || event.model; if (typeof reportedModel === "string" && reportedModel) { - this.emit("notification", { method: "model/reported", params: { requested: this.model, reported: reportedModel } }); + this.emit("notification", { method: "model/reported", params: { requested: this.model, reported: reportedModel, ...(event.message?.id ? {providerRequestID: event.message.id} : {}) } }); } const counts = event.usage || event.message?.usage; if (counts) Object.assign(usage, counts); diff --git a/aesel/src/attempt-receipt.mjs b/aesel/src/attempt-receipt.mjs new file mode 100644 index 0000000000..1772837c6b --- /dev/null +++ b/aesel/src/attempt-receipt.mjs @@ -0,0 +1,71 @@ +// Content-free, client-observed receipts. They are not authoritative billing. +export const RECEIPT_LIMIT = 100; +const number = n => Number.isFinite(n) && n >= 0 ? n : null; +const label = s => typeof s === 'string' && /^[a-zA-Z0-9_.:/-]{1,160}$/.test(s) ? s : null; +export async function hashSource(source) { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source)); + return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join(''); +} +export class ReceiptJournal { + constructor(storage, key) { + this.storage = storage; this.key = key + '-receipts'; this.rows = []; + try { const rows = JSON.parse(storage.getItem(this.key) || '[]'); if (Array.isArray(rows)) this.rows = rows.filter(r=>r?.receipt?.format===1 && Array.isArray(r.receipt.rounds)).slice(-RECEIPT_LIMIT); } catch {} + for (const row of this.rows) if (row.receipt.status === 'running') { + Object.assign(row.receipt, {status: 'interrupted', finishedAt: new Date().toISOString(), elapsedMs: null}); row.pending = true; + } + this.persist(); + } + persist() { try { this.storage.setItem(this.key, JSON.stringify(this.rows)); } catch { /* Generation must survive a full storage quota. */ } } + save(receipt) { + const row = {receipt: structuredClone(receipt), pending: receipt.status !== 'running'}; + const index = this.rows.findIndex(r => r.receipt.id === receipt.id); + if (index < 0) this.rows.push(row); else this.rows[index] = row; + this.rows = this.rows.slice(-RECEIPT_LIMIT); this.persist(); + } + pending() { return this.rows.find(r => r.pending)?.receipt ?? null; } + acknowledge(id) { const row = this.rows.find(r => r.receipt.id === id); if (row) { row.pending = false; this.persist(); } } +} +export class AttemptReceipt { + constructor({requestID, parent, parentHash, path, model, journal, now = () => performance.now()}) { + this.now = now; this.start = now(); this.journal = journal; + this.value = {format: 1, id: crypto.randomUUID(), requestID, provenance: 'client-observed', parent, parentHash, resultHash: null, + path, model, startedAt: new Date().toISOString(), finishedAt: null, elapsedMs: null, status: 'running', + firstOutputMs: null, firstMatchingPaintMs: null, checkpoints: 0, repairs: 0, acceptance: 'unreviewed', rounds: [], checks: [], observations: []}; + this.save(); + } + elapsed() { return Math.round(this.now() - this.start); } + save() { this.journal.save(this.value); } + request() { + const round = {index: this.value.rounds.length, startedMs: this.elapsed(), headersMs: null, httpStatus: null, reportedModel: null, providerRequestID: null, usage: null, + reasoning: 'none', thinking: 'disabled'}; + this.value.rounds.push(round); this.save(); return round; + } + headers(round, response) { Object.assign(round, {headersMs: this.elapsed(), httpStatus: response.status, providerRequestID: label(response.headers?.get?.('x-request-id'))}); this.save(); } + notify(method, params) { + const round = this.value.rounds.at(-1); + if (!['item/modelCode/delta', 'item/agentMessage/delta', 'model/reported', 'turn/usage'].includes(method)) return; + if (['item/modelCode/delta', 'item/agentMessage/delta'].includes(method)) { + if(this.value.firstOutputMs!==null)return; + this.value.firstOutputMs=this.elapsed(); + } + if (round && method === 'model/reported') { round.reportedModel = label(params.reported); round.providerRequestID = label(params.providerRequestID) || round.providerRequestID; } + if (round && method === 'turn/usage') { + const u = params.usage || {}; + // Replace the round receipt; duplicate delivery never doubles totals. + round.usage = {inputTokens: number(u.input_tokens), outputTokens: number(u.output_tokens), costUSD: number(u.cost), + cacheReadTokens: number(u.cache_read_input_tokens), cacheWriteTokens: number(u.cache_creation_input_tokens), + thinkingTokens: number(u.output_tokens_details?.thinking_tokens)}; + } + this.save(); + } + painted() { this.value.firstMatchingPaintMs ??= this.elapsed(); this.save(); } + observe(event) { + if(!['painted','invalidated','console'].includes(event.kind))return; + if(event.kind==='console'&&!['error','warn'].includes(event.event?.level))return; + this.value.observations.push({sourceHash:event.sourceHash,renderID:event.requestID,kind:event.kind,level:event.event?.level||null,atMs:this.elapsed()}); + this.value.observations=this.value.observations.slice(-64);this.save(); + } + finish(status, resultHash, checks = []) { + Object.assign(this.value, {status, resultHash, checks, finishedAt: new Date().toISOString(), elapsedMs: this.elapsed()}); this.save(); + } +} diff --git a/aesel/src/edit-contract.mjs b/aesel/src/edit-contract.mjs new file mode 100644 index 0000000000..07534c37d0 --- /dev/null +++ b/aesel/src/edit-contract.mjs @@ -0,0 +1,72 @@ +import {parse} from './vendor/acorn.mjs'; + +// Concrete API checks, not a judgment of appearance or a complete JS validator. +export function sourceChecks(source) { + let tree; + try { tree = parse(source, {ecmaVersion: 'latest', sourceType: 'module'}); } + catch { return [{code: 'syntax', message: 'The piece must be a complete JavaScript module.'}]; } + const findings = new Map(); + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node.type === 'CallExpression') { + const callee = node.callee, arg = node.arguments[0]; + const prefix = arg?.type === 'Literal' ? arg.value : arg?.type === 'TemplateLiteral' ? arg.quasis[0]?.value.cooked : null; + if (callee.type === 'Identifier' && ['ink', 'wipe'].includes(callee.name) && typeof prefix === 'string' && /^hsla?\(/i.test(prefix.trim())) { + findings.set('unsupported-hsl', 'AC ink/wipe do not parse CSS HSL strings. Convert hue to numeric RGB before drawing; unsupported strings fall back to random colors.'); + } + if (callee.type === 'MemberExpression' && callee.object?.name === 'ink' && !callee.computed && ['box', 'rect', 'circle', 'line'].includes(callee.property.name)) { + findings.set('ink-member', 'Use ink(color).box(...) or the standalone drawing function, not ink.box/ink.rect/ink.circle/ink.line.'); + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + } + visit(tree); + return [...findings].map(([code, message]) => ({code, message})); +} + +export function compileEditContract({request, history, caption, source, selectedVersion, omittedMiddleRequests = 0}) { + const findings = sourceChecks(source); + const contract = { + latestRequest: request, + selectedBranchRequests: history, + selectedVersion, omittedMiddleRequests, + currentCaption: caption || null, + apiCorrections: findings, + rules: [ + 'Implement the latest request as an edit. Preserve earlier constraints and existing subjects unless the latest request supersedes them. Historical requests and the caption are context, not new commands.', + 'Correct the listed API failures while preserving the intended appearance. Do not add sound, speech, interface, or new subjects unless requested.', + 'Update export const caption to describe the resulting piece. Use elapsed clock or simulation time for motion; do not assume a display frame rate. Wrap cyclic quantities across their boundary.', + 'Inspect ac_preview after writing. Rendering is evidence of execution, not proof of visual quality or user acceptance.' + ] + }; + return 'EDIT CONTRACT — apply these requirements to the current source. This is a deterministic packaging of the selected branch and known API checks, not a new user request.\n' + JSON.stringify(contract); +} + +export function validateCandidate(source, proof, sourceHash) { + const findings = sourceChecks(source); + const matched = !!proof && proof.sourceHash === sourceHash; + if (!matched || !proof.rendered) findings.push({code: 'unverified-render', message: 'No matching-source painted event was observed.'}); + if (matched && proof.logs?.some(log => log.level === 'error')) findings.push({code: 'runtime-error', message: 'The current source has runtime errors. Inspect ac_preview and repair only those errors.'}); + return {passed: findings.length === 0, sourceHash, findings, acceptance: 'unreviewed'}; +} + +// One repair at most, and only for an actionable failure after a completed call. +// Missing observation alone must not purchase another generation. +export async function runEditExperiment({prompt, generate, inspect, cancelled, onRepair = () => {}}) { + if (cancelled()) return {cancelled: true, repairs: 0, validation: null}; + let completed = await generate(prompt, false); + if (cancelled()) return {cancelled: true, repairs: 0, validation: null}; + let validation = await inspect(); + const actionable = validation.findings.some(f => f.code !== 'unverified-render'); + let repairs = 0; + if (completed && !validation.passed && actionable && !cancelled()) { + repairs = 1; onRepair(); + completed = await generate(prompt + '\n\nREPAIR THIS CANDIDATE ONCE. Preserve the edit contract; fix only these checks, inspect the resulting preview, and do not claim visual acceptance:\n' + JSON.stringify(validation.findings), true); + if (cancelled()) return {cancelled: true, repairs, validation: null}; + validation = await inspect(); + } + return {completed, repairs, validation, cancelled: false}; +} diff --git a/aesel/src/walkieware-thread.mjs b/aesel/src/walkieware-thread.mjs index b008ebdb17..90d4a13962 100644 --- a/aesel/src/walkieware-thread.mjs +++ b/aesel/src/walkieware-thread.mjs @@ -13,7 +13,8 @@ export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { const value={id:uuid(),code:null};storage.setItem(key+'-thread',JSON.stringify(value));return value; } export class WalkiewareThread { - constructor({storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/walkieware-stream',heartbeatMs=15000,maxIdleMs=45000,reconnectMs=3000}) { + constructor({storage,key,token,ledger,state,onStatus,onCommand,receipts=null,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/walkieware-stream',heartbeatMs=15000,maxIdleMs=45000,reconnectMs=3000}) { + this.receipts=receipts; Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url,heartbeatMs,maxIdleMs,reconnectMs}); this.identity=threadIdentity(storage,key);this.revision=Number(storage.getItem(key+'-cloud-revision')||0);this.last=storage.getItem(key+'-cloud-ledger')||''; this.active=false;this.sending=false;this.ready=false; @@ -30,18 +31,26 @@ export class WalkiewareThread { if(this.ws!==ws)return; this.lastSeen=Date.now(); if(m.type==='ready') { + this.receiptSupport=m.capabilities?.includes('attempt-receipts-v1')===true; this.identity.code=m.thread.code;this.storage.setItem(this.key+'-thread',JSON.stringify(this.identity)); const cloud=ledgerText(m.thread.ledger),local=ledgerText(this.ledger()); // Never silently replace local work with another device's history. if(m.thread.ledger&&cloud!==local&&m.thread.revision!==this.revision){this.onStatus(this.identity.code,'History conflict');return;} this.revision=m.thread.revision;this.ready=true; if(cloud===local){this.last=local;this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',local);} - this.onStatus(this.identity.code,'Connected');this.sync();this.update(); + this.onStatus(this.identity.code,'Connected');this.sync();this.update();this.flushReceipts(); } if(m.type==='saved') { this.revision=m.thread.revision;this.last=ledgerText(m.thread.ledger);this.sending=false; this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',this.last);this.sync(); } + if(m.type==='receiptSaved'&&m.id===this.receiptSending) { + this.receipts?.acknowledge(m.id);this.receiptSending=null; + this.receiptTimer=setTimeout(()=>this.flushReceipts(),150); + } + if(m.type==='receiptError'&&m.id===this.receiptSending) { + this.receiptSending=null;this.receiptTimer=setTimeout(()=>this.flushReceipts(),5000); + } if(m.type==='conflict'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,'History conflict');} if(m.type==='error'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,m.error);} if(m.type==='command') { @@ -56,7 +65,12 @@ export class WalkiewareThread { send(value){if(this.ws?.readyState===1)this.ws.send(JSON.stringify(value));} sync(){if(!this.ready||this.sending)return;const ledger=this.ledger(),next=ledgerText(ledger);if(next===this.last)return;this.sending=true;this.send({type:'sync',revision:this.revision,ledger});} update(){if(this.ready)this.send({type:'state',state:this.state()});} + flushReceipts(){ + if(!this.ready||!this.receiptSupport||this.receiptSending)return; + const receipt=this.receipts?.pending();if(!receipt)return; + this.receiptSending=receipt.id;this.send({type:'receipt',receipt}); + } async flush(){for(let i=0;i<100;i++){if(!this.ready)throw Error('Connection lost; inspect history before retrying');if(!this.sending&&this.last===ledgerText(this.ledger()))return;await new Promise(r=>setTimeout(r,100));}throw Error('Version sync pending; inspect before retrying');} - disconnect(ws){if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;clearInterval(this.heartbeat);this.onStatus(this.identity.code,'Offline');try{ws.close();}catch{}if(this.active)this.timer=setTimeout(()=>this.resume(),this.reconnectMs);} + disconnect(ws){if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;this.receiptSending=null;clearTimeout(this.receiptTimer);clearInterval(this.heartbeat);this.onStatus(this.identity.code,'Offline');try{ws.close();}catch{}if(this.active)this.timer=setTimeout(()=>this.resume(),this.reconnectMs);} suspend(){this.active=false;clearTimeout(this.timer);if(this.ws)this.disconnect(this.ws);} } diff --git a/aesel/test/attempt-receipt.test.mjs b/aesel/test/attempt-receipt.test.mjs new file mode 100644 index 0000000000..6f66849e1b --- /dev/null +++ b/aesel/test/attempt-receipt.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test';import assert from 'node:assert/strict'; +import {ReceiptJournal,AttemptReceipt,hashSource} from '../src/attempt-receipt.mjs'; +import {validateReceipt} from '../../system/backend/whistlegraph-receipt.mjs'; +const requestID='11111111-1111-4111-8111-111111111111'; +const storage=()=>{const values=new Map();return {getItem:k=>values.get(k),setItem:(k,v)=>values.set(k,v)};}; +export async function receiptFixture(){ + const journal=new ReceiptJournal(storage(),'piece'); + return new AttemptReceipt({journal,requestID,parent:0,parentHash:await hashSource('old'),path:'compiled',model:'tested/model'}); +} +test('per-round usage is replaced rather than double-counted; missing cost stays null',async()=>{ + const r=await receiptFixture();r.request(); + const usage={input_tokens:12,output_tokens:8};r.notify('turn/usage',{usage});r.notify('turn/usage',{usage}); + assert.equal(r.value.rounds.length,1);assert.equal(r.value.rounds[0].usage.outputTokens,8);assert.equal(r.value.rounds[0].usage.costUSD,null); + r.request();assert.equal(r.value.rounds[1].usage,null); + r.notify('model/reported',{reported:'actual/model',providerRequestID:'message-123'}); + assert.equal(r.value.rounds[1].providerRequestID,'message-123'); +}); +test('receipt boundary strips content and ignores client claims of acceptance',async()=>{ + const r=await receiptFixture();r.request();r.finish('failed',null); + const clean=validateReceipt({...r.value,prompt:'secret request',audio:'samples',source:'code',acceptance:'approved',rounds:[{...r.value.rounds[0],payload:'secret'}]}); + assert.equal(clean.acceptance,'unreviewed');assert.equal(clean.provenance,'client-observed');assert.equal(clean.rounds[0].usage,null); + assert.doesNotMatch(JSON.stringify(clean),/secret|samples|approved/); + assert.throws(()=>validateReceipt({...r.value,status:'running'}));assert.throws(()=>validateReceipt({...r.value,rounds:Array(33).fill({})})); +}); +test('interrupted attempts survive a restart and acknowledgements suppress resending',async()=>{ + const s=storage(),journal=new ReceiptJournal(s,'piece'); + const r=new AttemptReceipt({journal,requestID,parent:0,parentHash:await hashSource('old'),path:'current',model:'tested/model'});r.request(); + const resumed=new ReceiptJournal(s,'piece');assert.equal(resumed.pending().status,'interrupted');assert.equal(resumed.pending().elapsedMs,null); + resumed.acknowledge(r.value.id);assert.equal(new ReceiptJournal(s,'piece').pending(),null); +}); +test('retention is bounded and source-linked observations contain no console content',async()=>{ + const r=await receiptFixture(); + r.observe({kind:'console',sourceHash:await hashSource('new'),requestID:3,event:{level:'error',message:'private log'}}); + assert.doesNotMatch(JSON.stringify(r.value),/private log/); + for(let i=0;i<110;i++)r.journal.save({...r.value,id:crypto.randomUUID(),status:'failed'}); + assert.equal(r.journal.rows.length,100); +}); diff --git a/aesel/test/edit-contract.test.mjs b/aesel/test/edit-contract.test.mjs new file mode 100644 index 0000000000..4229d204d3 --- /dev/null +++ b/aesel/test/edit-contract.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {compileEditContract,sourceChecks,validateCandidate,runEditExperiment} from '../src/edit-contract.mjs'; +import {selectedBranch} from '../../apple/whistlegraph/Resources/Web/branch-context.mjs'; + +test('compiler preserves exact selected-branch requests, superseding edits and latest intent',()=>{ + const ledger={head:3,versions:[{id:0,parent:null,source:''},{id:1,parent:0,request:'draw cats',source:''},{id:2,parent:1,request:'add unwanted branch',source:''},{id:3,parent:1,request:'keep background solid',source:'export const caption="Cats";'}]}; + const request='Make the background flash now'; + const text=compileEditContract({request,...selectedBranch(ledger),source:ledger.versions[3].source}); + assert.match(text,/Make the background flash now/);assert.match(text,/keep background solid/); + assert.doesNotMatch(text,/unwanted branch/);assert.match(text,/unless the latest request supersedes/); +}); +test('API checks inspect calls, not comments, prose strings or unrelated HSL text',()=>{ + assert.deepEqual(sourceChecks('// ink(`hsl(0)`)\nconst prose="ink.box()"; export function paint({ink}) {ink(255,0,0).box(0,0,5,5);}'),[]); + assert.equal(sourceChecks('export function paint({ink}) {ink(`hsl(${30},100%,50%)`);ink.box(0,0,2,2);}').length,2); + assert.equal(sourceChecks('export function paint(')[0].code,'syntax'); +}); +test('a frame for another source cannot validate the candidate; painted does not erase errors',()=>{ + const source='export function paint({wipe}) {wipe(0);}'; + assert.equal(validateCandidate(source,{sourceHash:'old',rendered:true},'new').passed,false); + const proof={sourceHash:'new',rendered:true,logs:[{level:'error',text:'failed'}]}; + assert.equal(validateCandidate(source,proof,'new').findings[0].code,'runtime-error'); + assert.equal(validateCandidate(source,{...proof,logs:[]},'new').acceptance,'unreviewed'); +}); +test('repair is bounded to one, and only actionable findings spend another generation',async()=>{ + let calls=0; + const invalid={passed:false,sourceHash:'hash',findings:[{code:'unsupported-hsl',message:'Use RGB'}]}; + const result=await runEditExperiment({prompt:'cats',generate:async(prompt,repair)=>{calls++;if(repair)assert.match(prompt,/REPAIR THIS CANDIDATE ONCE/);return true;},inspect:async()=>invalid,cancelled:()=>false}); + assert.equal(calls,2);assert.equal(result.repairs,1);assert.equal(result.validation.passed,false); + calls=0; + await runEditExperiment({prompt:'cats',generate:async()=>{calls++;return true;},inspect:async()=>({...invalid,findings:[{code:'unverified-render'}]}),cancelled:()=>false}); + assert.equal(calls,1); + calls=0; + await runEditExperiment({prompt:'cats',generate:async()=>{calls++;return false;},inspect:async()=>invalid,cancelled:()=>false}); + assert.equal(calls,1); +}); +test('stop prevents validation and repair after generation returns',async()=>{ + let stopped=false,calls=0; + const result=await runEditExperiment({prompt:'cats',generate:async()=>{calls++;stopped=true;return true;},inspect:async()=>{throw Error('should not inspect');},cancelled:()=>stopped}); + assert.equal(calls,1);assert.equal(result.cancelled,true); +}); + +test('an already stopped attempt never purchases generation',async()=>{ + const result=await runEditExperiment({prompt:'cats',generate:async()=>{throw Error('should not generate');},inspect:async()=>{throw Error('should not inspect');},cancelled:()=>true}); + assert.equal(result.cancelled,true);assert.equal(result.repairs,0); +}); diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index 606b9c830c..b0601c654b 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -28,3 +28,29 @@ node apple/whistlegraph/Tests/native-bridge.test.cjs --native-shell ``` The browser check uses Puppeteer and Chrome with mock inference; it makes no model requests. Native UI tests live in the `WhistlegraphUITests` scheme. + +## Checked edits (experimental) + +Account settings → **Check edits (experimental)** enables a deterministic edit contract containing the selected branch requests, caption, and known API failures. It defaults off; the existing DeepSeek path with thinking disabled remains the default. Debug launches can set `WALKIE_COMPILED_TASK=1`. + +The experiment checks JavaScript syntax, direct unsupported HSL drawing calls, invalid `ink.box`-style calls, and matching-source runtime feedback. Each render carries a SHA-256 source hash and render ID; stale feedback is ignored. An actionable failure after a completed generation permits one repair. A failed or unverified result restores the previous piece without committing a version. A painted frame is execution evidence, not visual or semantic acceptance; animation timing still needs observation. + +Limits: four initial tool rounds with one output continuation, two repair rounds without continuations, 4,096 output tokens per provider call, and a 75-second generation/validation deadline. This allows at most seven provider calls per attempt; it is not a monetary cap. A resumed request is a new attempt. Existing deterministic local edits still bypass inference. + +## Attempt receipts + +The app retains the latest 100 attempt receipts per thread locally and uploads finalized receipts when the backend advertises `attempt-receipts-v1`. Older servers continue working while uploads remain pending. Reconnect retries are idempotent. The server retains the latest 100 receipts per thread; archives retain their separate local journal. Account deletion removes server threads and their receipts. + +Receipts contain source hashes, parent version, render IDs, timings, check codes, model identifiers, provider request IDs, and reported usage. Missing usage/cost remains null. They exclude prompt text, generated source, audio, console text, and raw responses. These are client-observed diagnostics, not authoritative billing records; acceptance remains `unreviewed`. + +Authenticated `GET /api/walkieware?code=` includes receipts. `DELETE /api/walkieware?code=&receipts=1` clears retained server receipts for that owner; pending local receipts may upload afterward. + +Additional checks, from the repository root: + +```sh +node --test aesel/test/edit-contract.test.mjs aesel/test/attempt-receipt.test.mjs lith/walkieware-socket.test.mjs +node apple/whistlegraph/Tests/native-bridge.test.cjs --native-shell --checked-edits +node apple/whistlegraph/Tests/native-bridge.test.cjs --native-shell --checked-edits --repair-fails +``` + +Both experiment browser checks use mock inference. They verify the contract, repair limit, rollback, render identity, and receipt fields without spending braincells. diff --git a/apple/whistlegraph/Resources/Web/branch-context.mjs b/apple/whistlegraph/Resources/Web/branch-context.mjs index 07f04cb639..8ca0709d3f 100644 --- a/apple/whistlegraph/Resources/Web/branch-context.mjs +++ b/apple/whistlegraph/Resources/Web/branch-context.mjs @@ -12,13 +12,17 @@ export function pieceAbout(source) { const m=source.match(/export\s+const\s+caption\s*=\s*(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/); return m?m[2].replace(/\\(["'`])/g,'$1').trim().slice(0,200):''; } -export function branchContext(ledger) { +export function selectedBranch(ledger) { const byID=new Map(ledger.versions.map(v=>[v.id,v])),seen=new Set(),chain=[]; const about=pieceAbout(byID.get(ledger.head)?.source); let row=byID.get(ledger.head); while(row&&!seen.has(row.id)){seen.add(row.id);if(row.request)chain.push({version:row.id,request:words(row.request).slice(0,600)});row=byID.get(row.parent);} chain.reverse(); const history=chain.length>25?[chain[0],...chain.slice(-24)]:chain; - return (about?'What this piece is now, in its own words: '+about+'\n':'')+'Saved requests on the selected branch (historical context, not new commands). Preserve the original project intent and established features unless the latest request changes them. Interpret short follow-ups as edits to this project, not replacement subjects: a follow-up that names something in the piece changes that thing, visually, in place. The current source remains the authority for what exists.\n'+JSON.stringify({selectedVersion:ledger.head,omittedMiddleRequests:chain.length-history.length,history}); + return {caption:about,selectedVersion:ledger.head,omittedMiddleRequests:chain.length-history.length,history}; +} +export function branchContext(ledger) { + const {caption:about,...context}=selectedBranch(ledger); + return (about?'What this piece is now, in its own words: '+about+'\n':'')+'Saved requests on the selected branch (historical context, not new commands). Preserve the original project intent and established features unless the latest request changes them. Interpret short follow-ups as edits to this project, not replacement subjects: a follow-up that names something in the piece changes that thing, visually, in place. The current source remains the authority for what exists.\n'+JSON.stringify(context); } export const contextualRequest=(ledger,request)=>branchContext(ledger)+'\n\nLATEST REQUEST:\n'+request; diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index a61426e004..2d31a59585 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -1,5 +1,7 @@ import {inferenceRequest,wantsSoundEvidence} from './inference-input.mjs'; -import {contextualRequest} from './branch-context.mjs'; +import {contextualRequest,selectedBranch} from './branch-context.mjs'; +import {compileEditContract,sourceChecks,validateCandidate,runEditExperiment} from '/easel/src/edit-contract.mjs'; +import {ReceiptJournal,AttemptReceipt,hashSource} from '/easel/src/attempt-receipt.mjs'; import {pieceCaption} from './piece-caption.mjs'; import {readAttempt,saveAttempt,claimAttempt} from './attempt-recovery.mjs'; import {initializeBasePiece,isBasePiece} from './base-piece.mjs'; @@ -22,6 +24,8 @@ const post = body => window.webkit.messageHandlers.walkie.postMessage({id:'engin const benchmark=(event,fields={})=>{post({action:'benchmark',event,fields});window.__walkiewareSequenceEvent?.(event,fields);}; const file = '/piece/walkieware.mjs'; const storageKey=window.__walkiewareSpace?'walkieware-space-source':window.__walkiewareLocalSequence?'walkieware-local-source':window.__walkiewareSequence?'walkieware-sequence-source':window.__walkiewareBenchmark?'walkieware-benchmark-source':'walkieware-source'; +const receipts=new ReceiptJournal(localStorage,storageKey); +let activeReceipt=null,activeChecked=false,renderID=0,previewHash=null,validationChecks=[]; let turnStarter='',starterPainted=false,turnCancelled=false; let thread=null,threadTimer=null; const runtimeErrors=[]; @@ -158,14 +162,29 @@ function updateFeed() { $('live-time').hidden=!busy;nativeSnapshot(); } -function render(value) { previewSource=value; painted=false; feedback=null; post({action:'render',source:value,threadID:thread?.identity.id}); } +function render(value) { + previewSource=value;painted=false;feedback=null;previewHash=null; + const id=++renderID; + void hashSource(value).then(hash=>{ + if(id!==renderID)return; + previewHash=hash; + feedback={rendered:false,sourceHash:hash,revision:hash,requestID:id,logs:activeChecked?sourceChecks(value).map(f=>({level:'error',text:f.message,code:f.code})):[],updatedAt:new Date().toISOString()}; + post({action:'render',source:value,threadID:thread?.identity.id,renderID:id}); + }).catch(()=>{if(id===renderID){turnRuntimeFailed=true;turnError='Could not identify preview source';phase(turnError);}}); +} +async function finishReceipt(status) { + if(!activeReceipt)return; + activeReceipt.value.checkpoints=checkpoints; + activeReceipt.finish(status,await hashSource(source).catch(()=>null),validationChecks); + activeReceipt=null;thread?.flushReceipts(); +} function compileStream() { if(compileTimer)return; compileTimer=setTimeout(()=>{ compileTimer=null; if(!busy)return; const candidate=runnablePrefix(partialSource(code)); - if(!candidate||candidate.trimEnd()===previewSource.trimEnd())return; + if(!candidate||candidate===previewSource)return; provisional=candidate; document.body.classList.add('live-preview'); benchmark('firstIncrementalCompile');log('Running streamed code'); @@ -200,15 +219,22 @@ vfs.setWriteHandler((path,value)=>{ $('play-deck').hidden=true; $('live-code').textContent=value; phase(ready?'Evaluating…':'Loading preview…'); log(`Checkpoint ${checkpoints} · valid JavaScript`); - if(value.trimEnd()!==previewSource.trimEnd())render(value); + if(value!==previewSource)render(value); }); const guides = vfs.preload(['pieces.md','screen.md','hand.md','kidlisp.md','api.json'].map(name=>'/easel/context/'+name)); -function makeServer(){ - const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source.trimEnd();for(let i=0;i<100;i++){if(painted&&lastPaintedSource.trimEnd()===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__walkiewareModel||DEFAULT_MODEL, - fetch:async(...args)=>{benchmark('requestDispatched');const response=await globalThis.fetch(...args);benchmark('inferenceHeaders',{status:response.status});return response;},preview:true,rounds:12,outputContinuations:4,reasoning:{effort:'none'},thinking:{type:'disabled'}, +function makeServer({checked=false,repair=false}={}){ + const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source;for(let i=0;i<100;i++){if(painted&&lastPaintedSource===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__walkiewareModel||DEFAULT_MODEL, + fetch:async(url,options)=>{ + benchmark('requestDispatched');const recorder=activeReceipt,round=recorder?.request(); + if(checked){const body=JSON.parse(options.body);body.max_tokens=4096;options={...options,body:JSON.stringify(body)};} + const response=await globalThis.fetch(url,options);if(round)recorder.headers(round,response); + benchmark('inferenceHeaders',{status:response.status});return response; + },preview:true,rounds:checked?(repair?2:4):12,outputContinuations:checked?(repair?0:1):4,reasoning:{effort:'none'},thinking:{type:'disabled'}, developerInstructions:GENERATION_INSTRUCTIONS}); + value.checked=checked; value.runtimeFeedback=()=>feedback; value.on('notification',({method,params})=>{ + activeReceipt?.notify(method,params); if(method==='turn/progress' && !firstDelta) phase(params.phase==='connecting'?'Connecting…':'Waiting for model…'); if(method==='item/modelCode/delta'){ delta('');if(codeItem!==params.itemId){benchmark('layerStarted',{tool:params.tool||'write_piece'});code='';codeItem=params.itemId;} @@ -246,12 +272,15 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text document.body.classList.add('live-mode');phase('Sending…');log('Submitted'); timer=setInterval(()=>$('live-time').textContent=((performance.now()-started)/1000).toFixed(1)+'s',100); let noChange=false; + activeChecked=window.__whistlegraphCheckedEdits===true;validationChecks=[];runtimeErrors.length=0; try{ + activeReceipt=new AttemptReceipt({requestID:activeAttempt.id,parent:turnParent,parentHash:await hashSource(previous),path:activeChecked?'compiled':'current',model:window.__walkiewareModel||DEFAULT_MODEL,journal:receipts}); text=inferenceRequest(text); if(recovered?.checkpoint){source=recovered.checkpoint;vfs.mount(file,source);render(source);text+='\nContinue the unfinished request from this saved checkpoint. Preserve its completed edits.';} const local=recovered?.checkpoint?null:localEdit(source,localText); if(local){ + activeReceipt.value.path='local';activeReceipt.value.model=null;activeReceipt.save(); if(!local.changed){noChange=true;return;} server?.close();server=null;source=local.source;vfs.mount(file,source);checkpoints=1; document.body.classList.add('live-preview');$('initial').hidden=true;$('play-deck').hidden=true; @@ -284,11 +313,31 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text const missing=await guides;if(missing.length)throw Error('Bundled piece guides are unavailable: '+missing.join(', ')); benchmark('guidesReady'); vfs.mount(file,source||'export function paint({wipe}) { wipe("black"); }'); - server??=makeServer();await server.startTurn(contextualRequest(versions.value,text)); + if(activeChecked){ + const prompt=compileEditContract({request:text,...selectedBranch(versions.value),source}); + const deadline=setTimeout(()=>{turnCancelled=true;turnError='Edit check timed out';server?.interrupt();},75000); + try{ + const result=await runEditExperiment({prompt,cancelled:()=>turnCancelled, + onRepair:()=>{activeReceipt.value.repairs=1;activeReceipt.save();phase('Repairing…');}, + generate:async(task,repair)=>{server?.close();server=makeServer({checked:true,repair});turnSucceeded=false;await server.startTurn(task);return turnSucceeded;}, + inspect:async()=>{ + const target=source,hash=await hashSource(target); + for(let i=0;i<100&&!turnCancelled&&(!painted||lastPaintedSource!==target);i++)await new Promise(resolve=>setTimeout(resolve,20)); + return validateCandidate(target,feedback,hash); + }}); + if(result.validation){ + validationChecks=result.validation.findings.map(f=>({code:f.code,sourceHash:result.validation.sourceHash})); + if(!result.validation.passed){turnSucceeded=false;turnRuntimeFailed=true;turnError='Edit check failed: '+result.validation.findings.map(f=>f.code).join(', ');} + } + }finally{clearTimeout(deadline);} + }else{ + if(server?.checked){server.close();server=null;} + server??=makeServer();await server.startTurn(contextualRequest(versions.value,text)); + } }catch(error){turnError=error.message;phase('Could not start');log(error.message);} finally{ - if(noChange){localStorage.removeItem(storageKey+'-inflight');activeAttempt=null;lastAttempt={...lastAttempt,status:'unchanged'};end();phase('Already there');benchmark('localEditUnchanged');return;} - if(!turnSucceeded&&!turnCancelled&&turnStarter&&starterPainted){ + if(noChange){await finishReceipt('unchanged');localStorage.removeItem(storageKey+'-inflight');activeAttempt=null;lastAttempt={...lastAttempt,status:'unchanged'};end();phase('Already there');benchmark('localEditUnchanged');return;} + if(!activeChecked&&!turnSucceeded&&!turnCancelled&&turnStarter&&starterPainted){ // A failed refinement must not erase the useful, verified first drawing. source=turnStarter;vfs.mount(file,source); if(previewSource!==source)render(source); @@ -296,13 +345,14 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text turnSucceeded=painted&&lastPaintedSource===source;turnRuntimeFailed=false; if(turnSucceeded){benchmark('refinementFailed',{message:turnError||'Refinement unavailable'});log('Refinement failed; kept the starter');server?.close();server=null;} } - if(turnSucceeded&&!turnRuntimeFailed&&painted&&lastPaintedSource.trimEnd()===source.trimEnd()){ + if(turnSucceeded&&!turnCancelled&&!turnRuntimeFailed&&painted&&lastPaintedSource===source){ try {const version=versions.commit({source,request:turnRequest,layers:checkpoints,parent:turnParent,requestID:activeAttempt?.id});saved();phase(`v${version.id} · Ready to play`);log(`Saved v${version.id} · ${checkpoints} layers`);benchmark('versionCommitted',{version:version.id,layers:checkpoints});} catch(error){turnSucceeded=false;turnError=error.message;phase('Could not save version');log(error.message);benchmark('generationFailed',{message:error.message});} }else turnSucceeded=false; - if(!turnSucceeded){source=previous;vfs.mount(file,source);saved();const restored=source||'export function paint({wipe}) {wipe("black");}';if(previewSource.trimEnd()!==restored.trimEnd())render(restored);server?.close();server=null;log('Restored previous version');} + if(!turnSucceeded){source=previous;vfs.mount(file,source);saved();const restored=source||'export function paint({wipe}) {wipe("black");}';if(previewSource!==restored)render(restored);server?.close();server=null;log('Restored previous version');} benchmark(turnSucceeded?'generationFinished':'generationFailed',{message:turnSucceeded?'':turnError||'No verified version was committed'}); lastAttempt={...lastAttempt,status:turnSucceeded?'completed':'failed',error:turnError||(!turnSucceeded?'No verified version was committed':''),runtimeErrors:[...runtimeErrors],finishedAt:new Date().toISOString()};threadUpdate(); + await finishReceipt(turnCancelled?'interrupted':turnSucceeded?'completed':'failed'); if(activeAttempt){ if(turnSucceeded||turnCancelled)localStorage.removeItem(storageKey+'-inflight'); else saveAttempt(localStorage,storageKey,{...activeAttempt,status:'failed'}); @@ -320,17 +370,20 @@ async function resumeAttempt(manual=false){ await ask(attempt.text,attempt.displayText,null,null,attempt.localText,attempt); } window.walkiewareEngineEvent=event=>{ + if(event.kind==='inferenceSettings')window.__whistlegraphCheckedEdits=event.checkedEdits===true; if(event.kind==='account') {token=event.token;if(token){musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.walkiewareAccountReady=!!token;accountIdentity(token);if(token&&pending)void ask(pending);else void resumeAttempt();} if(event.kind==='error'){phase('Sign-in needed');log(event.text);window.walkiewareWorkFinished?.();} if(event.kind==='previewReady'){ready=true;log('AC runtime ready');} if(event.kind==='previewEvent'){ - if(event.event.kind==='painted'){if(turnStarter&&previewSource===turnStarter&&!starterPainted){starterPainted=true;benchmark('starterPainted');}if(previewSource.trimEnd()!==previous.trimEnd())window.__walkiewareSequenceEvent?.('painted');painted=true;lastPaintedSource=previewSource;if(busy&&activeAttempt&&source===previewSource&&!turnRuntimeFailed){activeAttempt={...activeAttempt,checkpoint:source};try{saveAttempt(localStorage,storageKey,activeAttempt);}catch(error){log('Could not persist checkpoint: '+error.message);}}feedback={rendered:true,logs:[],updatedAt:new Date().toISOString()};log('Checkpoint painted');phase(busy?'Building…':'Ready to play');if(narrationPending!==null){post({action:'narrationReady',version:narrationPending});narrationPending=null;}void resumeAttempt();} - if(event.event.kind==='invalidated'){turnRuntimeFailed=true;window.__walkiewareSequenceEvent?.('runtimeError',{message:'Preview invalidated'});painted=false;feedback={rendered:false,logs:[{level:'error',text:'Preview invalidated'}],updatedAt:new Date().toISOString()};log('Preview failed; inspect activity');phase('Preview error');if(lastPaintedSource && lastPaintedSource!==previewSource){render(lastPaintedSource);log('Restored last painted checkpoint');}} + if(event.event?.sourceHash!==previewHash||event.event?.requestID!==renderID)return; + activeReceipt?.observe(event.event); + if(event.event.kind==='painted'){if(turnStarter&&previewSource===turnStarter&&!starterPainted){starterPainted=true;benchmark('starterPainted');}if(previewSource.trimEnd()!==previous.trimEnd())window.__walkiewareSequenceEvent?.('painted');painted=true;lastPaintedSource=previewSource;if(busy&&activeAttempt&&source===previewSource&&!turnRuntimeFailed){activeAttempt={...activeAttempt,checkpoint:source};try{saveAttempt(localStorage,storageKey,activeAttempt);}catch(error){log('Could not persist checkpoint: '+error.message);}}feedback={...feedback,rendered:true,updatedAt:new Date().toISOString()};if(activeReceipt&&previewSource.trimEnd()!==previous.trimEnd())activeReceipt.painted();log('Checkpoint painted');phase(busy?'Building…':'Ready to play');if(narrationPending!==null){post({action:'narrationReady',version:narrationPending});narrationPending=null;}void resumeAttempt();} + if(event.event.kind==='invalidated'){turnRuntimeFailed=true;window.__walkiewareSequenceEvent?.('runtimeError',{message:'Preview invalidated'});painted=false;feedback={...feedback,rendered:false,logs:[...(feedback?.logs||[]),{level:'error',text:'Preview invalidated'}],updatedAt:new Date().toISOString()};log('Preview failed; inspect activity');phase('Preview error');if(lastPaintedSource && lastPaintedSource!==previewSource){render(lastPaintedSource);log('Restored last painted checkpoint');}} if(event.event.kind==='console'&&['error','warn'].includes(event.event.event?.level)){ const entry={level:event.event.event.level,text:event.event.event.message||'Runtime error'}; if(/\b(?:Paint|Sim|Boot) failure\b/i.test(entry.text))entry.level='error'; log(entry.text);runtimeErrors.push(entry.text);if(runtimeErrors.length>20)runtimeErrors.shift(); - feedback={rendered:painted,logs:[...(feedback?.logs||[]),entry].slice(-20),updatedAt:new Date().toISOString()}; + feedback={...feedback,rendered:painted,logs:[...(feedback?.logs||[]),entry].slice(-20),updatedAt:new Date().toISOString()}; if(entry.level==='error'){turnRuntimeFailed=true;turnError=entry.text;window.__walkiewareSequenceEvent?.('runtimeError',{message:entry.text});} threadUpdate(); } @@ -383,7 +436,7 @@ if(typeof window.__walkiewareFixtureBusy==='string'){ updateFeed(); if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!window.__walkiewareDisableThread) { const label=codeLabel;label.setAttribute('aria-live','polite'); - thread=new WalkiewareThread({storage:localStorage,key:storageKey,token:()=>token,ledger:()=>versions.value, + thread=new WalkiewareThread({storage:localStorage,key:storageKey,receipts,token:()=>token,ledger:()=>versions.value, state:()=>({busy,phase:$('live-phase').textContent,head:versions.head.id,source:versions.head.source,errors:runtimeErrors,attempt:lastAttempt}), onStatus:(code,status)=>{label.textContent=code?'/'+code:'';label.title=status;label.dataset.status=status;post({action:'threadStatus',code:code||'',threadID:thread.identity.id,status});nativeSnapshot();}, onCommand:async command=>{ @@ -418,12 +471,12 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo }; // Every piece on this phone: the open one plus the archives "New piece" left. // Opening another swaps archives, so the current one is never lost. - const ARCHIVE='walkieware-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger']; + const ARCHIVE='walkieware-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger','-receipts']; function archiveCurrentPiece(){ const extras={};for(const suffix of ARCHIVE_SUFFIXES){const v=localStorage.getItem(storageKey+suffix);if(v!==null)extras[suffix]=v;} localStorage.setItem(ARCHIVE+thread.identity.id,JSON.stringify({identity:thread.identity,ledger:versions.value,source,extras,archivedAt:new Date().toISOString()})); thread.suspend(); - for(const suffix of ['', '-versions','-thread','-cloud-revision','-cloud-ledger','-attempt','-inflight'])localStorage.removeItem(storageKey+suffix); + for(const suffix of ['', '-versions','-thread','-cloud-revision','-cloud-ledger','-attempt','-inflight','-receipts'])localStorage.removeItem(storageKey+suffix); } function pieceSummary(id,identity,ledger,current){ const made=(ledger?.versions||[]).filter(v=>v.id>0),last=made.at(-1); @@ -456,13 +509,13 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo post({action:'account'}); setTimeout(()=>{if(!ready){ui.hidden=false;phase('Preview still loading');log('AC runtime has not reported ready. Check your connection.');}},20000); -if(window.__walkiewareSequence && !window.__walkiewareLocalSequence && window.__walkiewareReviewVersion===undefined) import("./sequence-benchmark.mjs").then(({runSequence})=>runSequence({ask,ready:()=>ready,source:()=>source,painted:()=>painted&&lastPaintedSource.trimEnd()===source.trimEnd(),interrupt:()=>server?.interrupt(),model:window.__walkiewareModel||DEFAULT_MODEL})); +if(window.__walkiewareSequence && !window.__walkiewareLocalSequence && window.__walkiewareReviewVersion===undefined) import("./sequence-benchmark.mjs").then(({runSequence})=>runSequence({ask,ready:()=>ready,source:()=>source,painted:()=>painted&&lastPaintedSource===source,interrupt:()=>server?.interrupt(),model:window.__walkiewareModel||DEFAULT_MODEL})); if(window.__walkiewareSequence && Number.isInteger(window.__walkiewareReviewVersion)) void (async()=>{ const revision=versions.value.versions.find(v=>v.id===window.__walkiewareReviewVersion); if(!revision){phase('Review version unavailable');return;} source=revision.source;render(source); - for(let i=0;i<300&&!(ready&&painted&&lastPaintedSource.trimEnd()===source.trimEnd());i++)await new Promise(r=>setTimeout(r,100)); + for(let i=0;i<300&&!(ready&&painted&&lastPaintedSource===source);i++)await new Promise(r=>setTimeout(r,100)); if(!painted){phase('Review preview unavailable');return;} phase(`Reviewing v${revision.id}`); const r=frame.getBoundingClientRect(); diff --git a/apple/whistlegraph/Sources/WhistlegraphAccount.swift b/apple/whistlegraph/Sources/WhistlegraphAccount.swift index 20c8a0c65c..cf1decc523 100644 --- a/apple/whistlegraph/Sources/WhistlegraphAccount.swift +++ b/apple/whistlegraph/Sources/WhistlegraphAccount.swift @@ -108,7 +108,7 @@ struct WhistlegraphPreview { window.acFORCE_NOGAP = true; let ready = false, revision = 0, paintedRevision = 0, sessionID = ''; const post = body => window.webkit.messageHandlers.walkie.postMessage(body); - window.walkiewareRender = async (source, threadID) => { + window.walkiewareRender = async (source, threadID, renderID) => { if (!ready) return; sessionID = threadID; const current = ++revision; @@ -117,7 +117,7 @@ struct WhistlegraphPreview { const hash = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join(''); window.acSEND({type:'dropped:piece',content:{name:'walkieware-preview',source, search:'noauth=true&noplot=true&nogap=true&nolabel=true',isKidLisp:false, - aeselPreview:{sessionID,revision:current,sourceHash:hash,requestID:current}}}); + aeselPreview:{sessionID,revision:current,sourceHash:hash,requestID:Number.isSafeInteger(renderID)?renderID:current}}}); }; window.addEventListener('aesel-preview', e => { if (e.detail?.sessionID !== sessionID || e.detail?.revision !== revision) return; diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index d19cbe3cca..5e720d1c67 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -43,6 +43,11 @@ struct Workspace: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.userContentController.addUserScript(WKUserScript(source: "window.__walkiewareNativeShell = true;", injectionTime: .atDocumentStart, forMainFrameOnly: true)) + var checkedEdits = UserDefaults.standard.bool(forKey: "whistlegraph-checked-edits") + #if DEBUG + if ProcessInfo.processInfo.environment["WALKIE_COMPILED_TASK"] == "1" { checkedEdits = true } + #endif + config.userContentController.addUserScript(WKUserScript(source: "window.__whistlegraphCheckedEdits = \(checkedEdits);", injectionTime: .atDocumentStart, forMainFrameOnly: true)) config.userContentController.add(context.coordinator, name: "walkie") config.setURLSchemeHandler(WhistlegraphBundle(), forURLScheme: "walkieware") // Custom-scheme fetch responses have status 0 on device. Seed the @@ -254,6 +259,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } private var previewFrame: WKFrameInfo? private var previewSource = "" + private var previewRequestID = 0 private var previewThreadID = UUID().uuidString func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { @@ -354,6 +360,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand case "render": guard let source = body["source"] as? String, source.utf8.count < 500_000 else { return } if let id = body["threadID"] as? String, UUID(uuidString: id) != nil { previewThreadID = id } + previewRequestID = body["renderID"] as? Int ?? 0 previewSource = source; renderPreview() case "start": capturePhase = .opening; captureError = nil; transcript = "" @@ -378,9 +385,12 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand guard let frame = previewFrame, !previewSource.isEmpty else { return } let source = previewSource let threadID = previewThreadID - Task { _ = try? await webView?.callAsyncJavaScript("window.walkiewareRender?.(source, threadID);", arguments: ["source": source, "threadID": threadID], in: frame, contentWorld: .page) } + let renderID = previewRequestID + Task { _ = try? await webView?.callAsyncJavaScript("window.walkiewareRender?.(source, threadID, renderID);", arguments: ["source": source, "threadID": threadID, "renderID": renderID], in: frame, contentWorld: .page) } } + func setCheckedEdits(_ enabled: Bool) { emitEngine(["kind": "inferenceSettings", "checkedEdits": enabled]) } + func emitEngine(_ event: [String: Any]) { guard let data = try? JSONSerialization.data(withJSONObject: event), let json = String(data: data, encoding: .utf8) else { return } diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index a4227272f4..e3e1159c9a 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -73,6 +73,8 @@ struct PiecesSheet: View { // Tapping the handle: who is signed in, and the door out. Settings grow here. struct AccountSheet: View { + var setCheckedEdits: (Bool) -> Void = { _ in } + @AppStorage("whistlegraph-checked-edits") private var checkedEdits = false let handle: String let colors: [[Double]] @Binding var appearance: String @@ -106,6 +108,11 @@ struct AccountSheet: View { Toggle("Interface sounds", isOn: $sounds).accessibilityIdentifier("account-sounds") .onChange(of: sounds) { _, on in if on { ButtonSounds.play(.tick) } } } footer: { Text("Keys and buttons respect silent mode. Haptics stay on.") } + Section { + Toggle("Check edits (experimental)", isOn: $checkedEdits) + .accessibilityIdentifier("account-checked-edits") + .onChange(of: checkedEdits) { _, enabled in setCheckedEdits(enabled) } + } footer: { Text("Checks generated code and may try one repair. May use more braincells.") } if !handle.isEmpty { Section { Button(role: .destructive) { confirmingSignOut = true } label: { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") } @@ -158,7 +165,7 @@ struct IdentityHeader: View { newPiece: { session.command("newPiece") }) } .sheet(isPresented: $showingAccount) { - AccountSheet(handle: session.snapshot.handle, colors: session.snapshot.colors, appearance: $appearance, + AccountSheet(setCheckedEdits: { session.setCheckedEdits($0) }, handle: session.snapshot.handle, colors: session.snapshot.colors, appearance: $appearance, signIn: { session.command("signIn") }, signOut: { session.signOut() }) } } diff --git a/apple/whistlegraph/Tests/native-bridge.test.cjs b/apple/whistlegraph/Tests/native-bridge.test.cjs index 17e16e4ea0..95f7af536c 100644 --- a/apple/whistlegraph/Tests/native-bridge.test.cjs +++ b/apple/whistlegraph/Tests/native-bridge.test.cjs @@ -13,7 +13,15 @@ const server = http.createServer(async (req, res) => { let incoming="";for await(const chunk of req)incoming+=chunk;inferenceBodies.push(JSON.parse(incoming)); res.writeHead(200, {'Content-Type':'text/event-stream'}); const send = event => res.write('data: '+JSON.stringify(event)+'\n\n'); - if(requests === 1 || (process.argv.includes("--native-shell") && requests === 2)) { + if(process.argv.includes('--checked-edits')) { + const invalid=requests===1||process.argv.includes('--repair-fails'); + const source=invalid?'export const caption="Cats"; export function paint({wipe,ink}) {wipe(0);ink(`hsl(30,100%,50%)`).circle(30,30,10);}':'export const caption="Bouncing cats"; export function paint({wipe,ink}) {wipe(0);ink(255,128,0).circle(30,30,10);}'; + send({type:'message_start',message:{id:'provider-'+requests,model:'fixture/reported',usage:{input_tokens:10}}}); + send({type:'content_block_start',index:0,content_block:{type:'tool_use',id:'checked-'+requests,name:'write_piece'}}); + send({type:'content_block_delta',index:0,delta:{type:'input_json_delta',partial_json:JSON.stringify({source})}}); + send({type:'content_block_stop',index:0}); + send({type:'message_delta',delta:{stop_reason:'end_turn'},usage:{output_tokens:20,cost:.001}}); + } else if(requests === 1 || (process.argv.includes("--native-shell") && requests === 2)) { send({type:'content_block_start',index:0,content_block:{type:'tool_use',id:'checkpoint-1',name:'write_piece'}}); const body=JSON.stringify({source:'export function paint({wipe}) { wipe("purple"); }'}); send({type:'content_block_delta',index:0,delta:{type:'input_json_delta',partial_json:body.slice(0,38)}}); @@ -62,11 +70,37 @@ const server = http.createServer(async (req, res) => { const fetchOriginal=window.fetch.bind(window); window.fetch=(url,init)=>{if(String(url).includes('/api/handle-colors'))return Promise.resolve(Response.json({colors:[{r:200,g:100,b:255}]}));if(String(url).includes('/userinfo'))return Promise.resolve(Response.json({sub:'fixture-user'}));if(String(url).includes('/handle?for='))return Promise.resolve(Response.json({handle:'fixture'}));if(String(url).includes('/api/easel-musical-jev')){const b=JSON.parse(init.body);return Promise.resolve(Response.json({schema:'walkieware-decision/v1',sessionId:b.sessionId,sequence:b.sequence,choice:'follow_speech',confidence:.95}));}if(String(url).startsWith('/easel/context/')){window.__guideFetches++;return Promise.resolve({ok:false,status:0});}return fetchOriginal(String(url).includes('/api/easel-inference')?'/mock-inference':url,init);}; window.__nativeMessages=[]; - window.webkit={messageHandlers:{walkie:{postMessage:m=>{window.__nativeMessages.push(m);if(m.action==='render')setTimeout(()=>window.walkiewareEngineEvent({kind:'previewEvent',event:{kind:'painted'}}),20);}}}}; + window.webkit={messageHandlers:{walkie:{postMessage:m=>{window.__nativeMessages.push(m);if(m.action==='render')setTimeout(async()=>{const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(m.source));const sourceHash=[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,'0')).join('');window.walkiewareEngineEvent({kind:'previewEvent',event:{kind:'painted',sourceHash,requestID:m.renderID}});},20);}}}}; },guideSeed,process.argv.includes('--native-shell')); await page.goto('http://127.0.0.1:'+server.address().port+'/index.html?walkie=1'); await page.waitForFunction(()=>typeof window.walkiewareAsk==='function'); assert.deepEqual(errors,[]); + if(process.argv.includes('--checked-edits')) { + await page.evaluate(()=>{walkiewareEngineEvent({kind:'inferenceSettings',checkedEdits:true});walkiewareEngineEvent({kind:'account',token:'fixture-only'});walkiewareEngineEvent({kind:'previewReady'});}); + const before=await page.evaluate(()=>localStorage.getItem('walkieware-source-versions')); + await page.evaluate(()=>walkiewareAsk('Make the cats bounce')); + await page.waitForFunction(()=>!walkiewareIsBusy()); + assert.equal(requests,2,'exactly one repair generation'); + assert.ok(inferenceBodies[0].messages.some(m=>JSON.stringify(m).includes('EDIT CONTRACT'))); + assert.ok(inferenceBodies[1].messages.some(m=>JSON.stringify(m).includes('REPAIR THIS CANDIDATE ONCE'))); + assert.ok(inferenceBodies.every(b=>b.max_tokens===4096&&b.thinking.type==='disabled')); + const receipt=await page.evaluate(()=>JSON.parse(localStorage.getItem('walkieware-source-receipts')).at(-1).receipt); + assert.equal(receipt.repairs,1);assert.equal(receipt.rounds.length,2);assert.equal(receipt.rounds[0].reportedModel,'fixture/reported'); + assert.equal(receipt.rounds[0].providerRequestID,'provider-1');assert.equal(receipt.rounds[1].usage.costUSD,.001); + assert.ok(receipt.observations.every(o=>o.sourceHash.length===64));assert.equal(receipt.acceptance,'unreviewed'); + assert.ok(!JSON.stringify(receipt).includes('Make the cats bounce'),'receipt has no prompt text'); + if(process.argv.includes('--repair-fails')){ + assert.equal(receipt.status,'failed');assert.equal(receipt.checks[0].code,'unsupported-hsl'); + assert.equal(await page.evaluate(()=>localStorage.getItem('walkieware-source-versions')),before,'failed repair cannot commit the invalid candidate'); + }else{ + assert.equal(receipt.status,'completed');assert.deepEqual(receipt.checks,[]); + assert.equal(await page.evaluate(()=>JSON.parse(localStorage.getItem('walkieware-source-versions')).head),1); + } + const last=await page.evaluate(()=>__nativeMessages.filter(m=>m.action==='render').at(-1)); + await page.evaluate(m=>walkiewareEngineEvent({kind:'previewEvent',event:{kind:'console',sourceHash:'0'.repeat(64),requestID:m.renderID,event:{level:'error',message:'stale error'}}}),last); + assert.equal(await page.evaluate(()=>document.getElementById('live-events').textContent.includes('stale error')),false); + assert.deepEqual(errors,[]);console.log('PASS: compiled edit contract, source-linked checks, one bounded repair, content-free receipts, and rollback');return; + } if(process.argv.includes('--native-shell')) { await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot')); assert.equal(await page.$eval('.top',e=>getComputedStyle(e).display),'none','Swift owns visible chrome'); diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index bcd061f73d..548e76558c 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -384,7 +384,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -469,7 +469,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 85; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 0f30221142..51d34f74ee 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -9,7 +9,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "84" + CURRENT_PROJECT_VERSION: "85" targets: Whistlegraph: type: application diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs index 46c504af3c..fd510a0a10 100644 --- a/lith/walkieware-socket.mjs +++ b/lith/walkieware-socket.mjs @@ -34,7 +34,7 @@ export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,li if(!room){room={clients:new Set(),device:null,state:null,commands:new Map()};rooms.set(row._id,room);} if(role==='device'&&room.device)throw Error('This thread is running on another device'); room.clients.add(ws);if(role==='device')room.device=ws; - send(ws,{type:'ready',thread:publicThread(row),online:!!room.device,state:room.state}); + send(ws,{type:'ready',thread:publicThread(row),online:!!room.device,state:room.state,capabilities:['attempt-receipts-v1']}); if(role==='device')broadcast({type:'presence',online:true}); return; } @@ -44,6 +44,11 @@ export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,li row=saved;broadcast({type:'saved',thread:publicThread(row)});return; } if(m.type==='ping'){send(ws,{type:'pong',online:!!room.device,role,peers:room.clients.size,attached:rooms.get(row._id)===room});return;} + if(m.type==='receipt'&&role==='device') { + try { const id=await (await store()).receipt(owner,row._id,m.receipt);send(ws,{type:'receiptSaved',id}); } + catch { send(ws,{type:'receiptError',id:m.receipt?.id}); } + return; + } if(m.type==='state'&&role==='device') { const state=m.state; if(!state||JSON.stringify(state).length>525000)throw Error('Invalid state'); diff --git a/lith/walkieware-socket.test.mjs b/lith/walkieware-socket.test.mjs index ba89549968..d9564a2992 100644 --- a/lith/walkieware-socket.test.mjs +++ b/lith/walkieware-socket.test.mjs @@ -4,6 +4,7 @@ import {createServer} from 'node:http'; import {once} from 'node:events'; import {WebSocket} from 'ws'; import {attachWalkiewareSocket} from './walkieware-socket.mjs'; +import {ReceiptJournal,AttemptReceipt,hashSource} from '../aesel/src/attempt-receipt.mjs'; import {attachMusicalSocket} from './musical-socket.mjs'; import {mongoWalkiewareStore,validateLedger,sourceHash} from '../system/backend/walkieware.mjs'; import {WalkiewareThread,threadIdentity,verifyThreadRevision} from '../aesel/src/walkieware-thread.mjs'; @@ -19,8 +20,8 @@ test('remote edits reject stale versions, mismatched source and a local ask star }); function memoryCollection(){ const docs=new Map(); - const find=query=>[...docs.values()].find(row=>Object.entries(query).every(([k,v])=>row[k]===v)); - return {createIndex:async()=>{},findOne:async q=>structuredClone(find(q)||null),insertOne:async row=>{if(docs.has(row._id)||find({codeKey:row.codeKey}))throw Object.assign(Error('duplicate'),{code:11000});docs.set(row._id,structuredClone(row));},updateOne:async(q,u)=>{const row=find(q);if(!row)return {modifiedCount:0};Object.assign(row,structuredClone(u.$set));for(const [k,v]of Object.entries(u.$inc||{}))row[k]+=v;return {modifiedCount:1};}}; + const find=query=>[...docs.values()].find(row=>Object.entries(query).every(([k,v])=>k==='receipts.id'?!row.receipts?.some(r=>r.id===v.$ne):row[k]===v)); + return {createIndex:async()=>{},findOne:async q=>structuredClone(find(q)||null),insertOne:async row=>{if(docs.has(row._id)||find({codeKey:row.codeKey}))throw Object.assign(Error('duplicate'),{code:11000});docs.set(row._id,structuredClone(row));},updateOne:async(q,u)=>{const row=find(q);if(!row)return {modifiedCount:0};Object.assign(row,structuredClone(u.$set));for(const [k,v]of Object.entries(u.$inc||{}))row[k]+=v;for(const [k,v]of Object.entries(u.$push||{}))row[k]=[...(row[k]||[]),...structuredClone(v.$each)].slice(v.$slice);for(const k of Object.keys(u.$unset||{}))delete row[k];return {modifiedCount:1};}}; } function inbox(ws){const queue=[],waiters=[];ws.on('message',raw=>{const m=JSON.parse(raw);const i=waiters.findIndex(w=>w.type===m.type);if(i<0)queue.push(m);else waiters.splice(i,1)[0].resolve(m);});return type=>{const i=queue.findIndex(m=>m.type===type);return i>=0?Promise.resolve(queue.splice(i,1)[0]):new Promise(resolve=>waiters.push({type,resolve}));};} async function client(url,auth){const ws=new WebSocket(url),next=inbox(ws);await once(ws,'open');ws.send(JSON.stringify({type:'authenticate',token:'owner',...auth}));return {ws,next,send:m=>ws.send(JSON.stringify(m))};} @@ -81,3 +82,52 @@ test('silent socket stalls reconnect even when close never emits; concurrent res const phone=new WalkiewareThread({storage,key:'stall',token:()=> 'owner',ledger:()=>ledger,state:()=>({}),onStatus:(_,status)=>{if(status==='Offline')offline++;},onCommand:async()=>({ok:true}),WebSocketImpl:SilentSocket,heartbeatMs:5,maxIdleMs:15,reconnectMs:1}); try{await Promise.all([phone.resume(),phone.resume()]);assert.equal(count,1);await new Promise(r=>setTimeout(r,55));assert.ok(count>=2);assert.ok(offline>=1);}finally{phone.suspend();} }); + +async function completedReceipt(){ + const values=new Map(),storage={getItem:k=>values.get(k),setItem:(k,v)=>values.set(k,v)}; + const recorder=new AttemptReceipt({journal:new ReceiptJournal(storage,'test'),requestID:id,parent:0,parentHash:await hashSource('before'),path:'current',model:'tested/model'}); + recorder.request();recorder.finish('failed',await hashSource('before'));return recorder.value; +} +test('receipt store is owner-scoped, immutable, retry-idempotent and bounded',async()=>{ + const store=mongoWalkiewareStore(memoryCollection(),{name:()=> 'wwRuboh'});await store.open('owner',id); + const receipt=await completedReceipt();await store.receipt('owner',id,receipt);await store.receipt('owner',id,receipt); + assert.equal((await store.read('owner','wwRuboh')).receipts.length,1); + await assert.rejects(store.receipt('owner',id,{...receipt,status:'completed'}),/immutable/); + await assert.rejects(store.receipt('stranger',id,receipt),/unavailable/); + for(let i=0;i<101;i++)await store.receipt('owner',id,{...receipt,id:crypto.randomUUID()}); + assert.equal((await store.read('owner','wwRuboh')).receipts.length,100); + assert.equal(await store.clearReceipts('stranger','wwRuboh'),false); + assert.equal(await store.clearReceipts('owner','wwRuboh'),true);assert.equal((await store.read('owner','wwRuboh')).receipts,undefined); +}); +test('socket acknowledges persisted receipts and rejects agent uploads',{timeout:5000},async t=>{ + const f=await fixture(t),device=await client(f.url,{role:'device',id}); + assert.ok((await device.next('ready')).capabilities.includes('attempt-receipts-v1')); + const receipt=await completedReceipt();device.send({type:'receipt',receipt});assert.equal((await device.next('receiptSaved')).id,receipt.id); + assert.equal((await f.store.read('owner','wwRuboh')).receipts[0].status,'failed'); + const agent=await client(f.url,{role:'agent',code:'wwRuboh'});await agent.next('ready'); + agent.send({type:'receipt',receipt});assert.match((await agent.next('error')).error,/Unsupported/); +}); + +test('receipt client defers old servers, retries after lost acknowledgement, and drains on acknowledgement',async()=>{ + const values=new Map(),storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; + const receipts=new ReceiptJournal(storage,'delivery'),receipt=await completedReceipt();receipts.save(receipt); + const sockets=[]; + class Socket { + constructor(){this.readyState=1;this.sent=[];sockets.push(this);} + send(text){this.sent.push(JSON.parse(text));} + close(){this.readyState=3;} + async ready(capabilities){await this.onmessage({data:JSON.stringify({type:'ready',capabilities,thread:{code:'wwRuboh',revision:0,ledger}})});} + } + const phone=new WalkiewareThread({storage,key:'delivery',receipts,token:()=> 'owner',ledger:()=>ledger,state:()=>({}),onStatus:()=>{},onCommand:async()=>({}),WebSocketImpl:Socket}); + try { + await phone.resume();await sockets[0].ready(undefined); + assert.equal(sockets[0].sent.some(m=>m.type==='receipt'),false);assert.equal(receipts.pending().id,receipt.id); + phone.suspend();await phone.resume();await sockets[1].ready(['attempt-receipts-v1']); + assert.equal(sockets[1].sent.filter(m=>m.type==='receipt').length,1); + phone.flushReceipts();assert.equal(sockets[1].sent.filter(m=>m.type==='receipt').length,1); + phone.suspend();await phone.resume();await sockets[2].ready(['attempt-receipts-v1']); + assert.equal(sockets[2].sent.find(m=>m.type==='receipt').receipt.id,receipt.id); + await sockets[2].onmessage({data:JSON.stringify({type:'receiptSaved',id:receipt.id})}); + assert.equal(receipts.pending(),null);assert.equal(phone.ready,true); + }finally{phone.suspend();} +}); diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index 5026db51d2..e7ed11a582 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -295,6 +295,7 @@ const DELETE = [ ["moods", (sub) => ({ user: sub })], ["push-tokens", (sub) => ({ user: sub })], ["easel-transcripts-private", (sub) => ({ owner: sub })], + ["walkieware-threads", (sub) => ({ owner: sub })], ["tells", (sub) => ({ $or: [{ to: sub }, { from: sub }] })], ["tapes", (sub) => ({ user: sub })], ["tape-drafts", (sub) => ({ user: sub })], diff --git a/system/backend/walkieware.mjs b/system/backend/walkieware.mjs index bca7c3645a..cfc1d83e1d 100644 --- a/system/backend/walkieware.mjs +++ b/system/backend/walkieware.mjs @@ -1,4 +1,5 @@ import {randomInt, createHash} from 'node:crypto'; +import {validateReceipt} from './whistlegraph-receipt.mjs'; export const validID = value => typeof value === 'string' && /^[a-f0-9-]{36}$/i.test(value); export const validCode = value => typeof value === 'string' && /^ww[a-z]{5,12}$/i.test(value); @@ -41,7 +42,23 @@ export function mongoWalkiewareStore(collection, {name=pronounceableCode}={}) { throw Error('Unable to reserve a name'); }, async read(owner,code) {if(!validCode(code))return null;return collection.findOne({owner,codeKey:code.toLowerCase()});}, - async list(owner) {return collection.find({owner},{projection:{owner:0,ledger:0}}).sort({updatedAt:-1}).limit(100).toArray();}, + async list(owner) {return collection.find({owner},{projection:{owner:0,ledger:0,receipts:0}}).sort({updatedAt:-1}).limit(100).toArray();}, + async receipt(owner,id,value) { + const receipt=validateReceipt(value); + // First write wins; retries are idempotent within the retained history. + await collection.updateOne({_id:id,owner,'receipts.id':{$ne:receipt.id}},{$push:{receipts:{$each:[receipt],$slice:-100}}}); + const row=await collection.findOne({_id:id,owner}); + const saved=row?.receipts?.find(r=>r.id===receipt.id); + if(!saved)throw Error('Thread unavailable'); + if(JSON.stringify(saved)!==JSON.stringify(receipt))throw Error('Attempt receipts are immutable'); + return receipt.id; + }, + async clearReceipts(owner,code) { + if(!validCode(code))return false; + const row=await collection.findOne({owner,codeKey:code.toLowerCase()}); + if(!row)return false; + await collection.updateOne({_id:row._id,owner},{$unset:{receipts:''}});return true; + }, async state(owner,id,state){await collection.updateOne({_id:id,owner},{$set:{diagnostics:state}});}, async save(owner,id,revision,ledger) { ledger=validateLedger(ledger); @@ -59,5 +76,5 @@ export function mongoWalkiewareStore(collection, {name=pronounceableCode}={}) { export function publicThread(row) { if(!row)return null; const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); - return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics}; + return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics,receipts:row.receipts}; } diff --git a/system/backend/whistlegraph-receipt.mjs b/system/backend/whistlegraph-receipt.mjs new file mode 100644 index 0000000000..b05d10442e --- /dev/null +++ b/system/backend/whistlegraph-receipt.mjs @@ -0,0 +1,26 @@ +// Strict allowlist: no prompts, generated source, logs, audio or raw responses. +const id = v => typeof v === 'string' && /^[a-f0-9-]{36}$/i.test(v); +const hash = v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v) ? v : null; +const label = v => typeof v === 'string' && /^[a-zA-Z0-9_.:/-]{1,160}$/.test(v) ? v : null; +const count = v => Number.isSafeInteger(v) && v >= 0 && v <= 1e9 ? v : null; +const amount = v => Number.isFinite(v) && v >= 0 && v <= 1e9 ? v : null; +const date = v => typeof v === 'string' && v.length <= 40 && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null; +export function validateReceipt(r) { + if (!r || JSON.stringify(r).length > 24000 || r.format !== 1 || !id(r.id) || !id(r.requestID) || + !['current', 'compiled', 'local'].includes(r.path) || !['completed', 'failed', 'interrupted', 'unchanged'].includes(r.status) || + !hash(r.parentHash) || count(r.parent) === null || !date(r.startedAt) || !date(r.finishedAt) || + !Array.isArray(r.rounds) || r.rounds.length > 32 || !Array.isArray(r.checks) || r.checks.length > 20) throw Error('Invalid attempt receipt'); + return {format: 1, id: r.id, requestID: r.requestID, provenance: 'client-observed', path: r.path, status: r.status, + parent: r.parent, parentHash: r.parentHash, resultHash: hash(r.resultHash), model: label(r.model), + startedAt: date(r.startedAt), finishedAt: date(r.finishedAt), elapsedMs: amount(r.elapsedMs), + firstOutputMs: amount(r.firstOutputMs), firstMatchingPaintMs: amount(r.firstMatchingPaintMs), + checkpoints: count(r.checkpoints), repairs: count(r.repairs), acceptance: 'unreviewed', + observations: (Array.isArray(r.observations)?r.observations:[]).slice(-64).filter(o=>hash(o.sourceHash)&&count(o.renderID)!==null&&['painted','invalidated','console'].includes(o.kind)).map(o=>({sourceHash:o.sourceHash,renderID:o.renderID,kind:o.kind,level:['error','warn'].includes(o.level)?o.level:null,atMs:amount(o.atMs)})), + checks: r.checks.map(c => ({code: label(c.code), sourceHash: hash(c.sourceHash)})).filter(c => c.code && c.sourceHash), + rounds: r.rounds.map((v, index) => ({index, startedMs: amount(v.startedMs), headersMs: amount(v.headersMs), httpStatus: count(v.httpStatus), + reportedModel: label(v.reportedModel), providerRequestID: label(v.providerRequestID), reasoning: v.reasoning === 'none' ? 'none' : null, + thinking: v.thinking === 'disabled' ? 'disabled' : null, usage: v.usage ? { + inputTokens: count(v.usage.inputTokens), outputTokens: count(v.usage.outputTokens), costUSD: amount(v.usage.costUSD), + cacheReadTokens: count(v.usage.cacheReadTokens), cacheWriteTokens: count(v.usage.cacheWriteTokens), thinkingTokens: count(v.usage.thinkingTokens) + } : null}))}; +} diff --git a/system/netlify/functions/walkieware.mjs b/system/netlify/functions/walkieware.mjs index c250de39e3..cb55226c09 100644 --- a/system/netlify/functions/walkieware.mjs +++ b/system/netlify/functions/walkieware.mjs @@ -5,14 +5,18 @@ export {authenticateMusical as authenticateWalkieware}; let pending; export const walkiewareStore=()=>pending??=(async()=>{const {db}=await connect();return mongoWalkiewareStore(db.collection('walkieware-threads'));})().catch(error=>{pending=null;throw error;}); export async function handler(event) { - const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'GET, OPTIONS'}; + const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'GET, DELETE, OPTIONS'}; const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); if(event.httpMethod==='OPTIONS')return reply(204,{}); - if(event.httpMethod!=='GET')return reply(405,{error:'Use GET; edits use the authenticated live socket'}); + if(!['GET','DELETE'].includes(event.httpMethod))return reply(405,{error:'Use GET; edits use the authenticated live socket'}); try { const owner=await authenticateMusical(event.headers||{}); if(!owner)return reply(401,{error:'Sign in to your AC account'}); const store=await walkiewareStore(),code=event.queryStringParameters?.code; + if(event.httpMethod==='DELETE') { + if(!code||event.queryStringParameters?.receipts!=='1')return reply(400,{error:'Specify a thread code and receipts=1'}); + return await store.clearReceipts(owner,code)?reply(200,{cleared:true}):reply(404,{error:'Thread unavailable'}); + } if(!code)return reply(200,{threads:(await store.list(owner)).map(publicThread)}); const row=await store.read(owner,code); return row?reply(200,publicThread(row)):reply(404,{error:'Thread unavailable'});