From 1dd8a09b89b3bb9ec50eebea2c473cd6d5d59946 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 18:35:17 -0700 Subject: [PATCH] Add private Walkieware thread codes, version sync and live agent access --- aesel/src/walkieware-thread.mjs | 50 +++++++++++ lith/musical-socket.mjs | 3 +- lith/server.mjs | 4 + lith/walkieware-socket.mjs | 85 +++++++++++++++++++ lith/walkieware-socket.test.mjs | 55 ++++++++++++ slab/bin/ww.mjs | 33 +++++++ system/backend/walkieware.mjs | 62 ++++++++++++++ system/netlify/functions/walkieware.mjs | 20 +++++ system/public/aesthetic.computer/disks/ww.mjs | 34 ++++++++ .../public/aesthetic.computer/lib/parse.mjs | 2 + 10 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 aesel/src/walkieware-thread.mjs create mode 100644 lith/walkieware-socket.mjs create mode 100644 lith/walkieware-socket.test.mjs create mode 100644 slab/bin/ww.mjs create mode 100644 system/backend/walkieware.mjs create mode 100644 system/netlify/functions/walkieware.mjs create mode 100644 system/public/aesthetic.computer/disks/ww.mjs diff --git a/aesel/src/walkieware-thread.mjs b/aesel/src/walkieware-thread.mjs new file mode 100644 index 0000000000..01f9d67a45 --- /dev/null +++ b/aesel/src/walkieware-thread.mjs @@ -0,0 +1,50 @@ +// Stable local identity; the server atomically reserves its pronounceable code. +const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; +export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { + const saved=storage.getItem(key+'-thread'); + if(saved){const value=JSON.parse(saved);if(typeof value.id!=='string')throw Error('Invalid thread identity');return value;} + 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'}) { + Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url}); + 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; + } + async resume() { + this.active=true;if(this.ws)return; + const token=await this.token();if(!token||!this.active)return; + const ws=this.ws=new this.WebSocketImpl(this.url); + ws.onopen=()=>this.send({type:'authenticate',role:'device',token,id:this.identity.id}); + ws.onmessage=async event=>{ + let m;try{m=JSON.parse(event.data);}catch{return;} + if(m.type==='ready') { + 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(); + } + 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==='conflict'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,'History conflict');} + if(m.type==='error'){this.sending=false;this.onStatus(this.identity.code,m.error);} + if(m.type==='command') { + try{if(!this.ready||this.sending)throw Error('Thread is not synchronized');const result=await this.onCommand(m);this.sync();await this.flush();this.send({type:'result',id:m.id,...result});} + catch(error){this.send({type:'result',id:m.id,ok:false,error:error.message});} + this.sync();this.update(); + } + }; + ws.onclose=()=>{if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;this.onStatus(this.identity.code,'Offline');if(this.active)this.timer=setTimeout(()=>this.resume(),3000);}; + ws.onerror=()=>{}; + } + 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()});} + 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');} + suspend(){this.active=false;clearTimeout(this.timer);this.ws?.close();} +} diff --git a/lith/musical-socket.mjs b/lith/musical-socket.mjs index 2d0ce84ea9..5433398e0f 100644 --- a/lith/musical-socket.mjs +++ b/lith/musical-socket.mjs @@ -5,7 +5,8 @@ export function attachMusicalSocket(server, {authenticate, decide, authMs=5000, const wss=new WebSocketServer({noServer:true,maxPayload:8192,perMessageDeflate:false}); const accounts=new Map(); const upgrade=(req,socket,head)=>{ - if(req.url!=='/api/easel-musical-stream'||wss.clients.size>=64){socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');return;} + if(req.url!=='/api/easel-musical-stream')return; + if(wss.clients.size>=64){socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');return;} wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws)); }; server.on('upgrade',upgrade); diff --git a/lith/server.mjs b/lith/server.mjs index b707e910f5..c53aa6bdea 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -48,6 +48,7 @@ if (typeof globalThis.awslambda === "undefined") { import express from "express"; import {attachMusicalSocket} from "./musical-socket.mjs"; +import {attachWalkiewareSocket} from "./walkieware-socket.mjs"; import { sendStream } from "./stream-response.mjs"; import { userMediaTarget } from "./media-path.mjs"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, statSync } from "fs"; @@ -1455,6 +1456,8 @@ if (DEV && HAS_SSL) { // Share authentication, concurrency guards and durable quotas with the HTTP lane. const musicalAPI = await import(pathToFileURL(join(SYSTEM, "netlify/functions/easel-musical-jev.mjs")).href); const musicalSocket = attachMusicalSocket(server, {authenticate:musicalAPI.authenticateMusical, decide:musicalAPI.musicalDecision}); +const walkiewareAPI = await import(pathToFileURL(join(SYSTEM, "netlify/functions/walkieware.mjs")).href); +const walkiewareSocket = attachWalkiewareSocket(server, {authenticate:walkiewareAPI.authenticateWalkieware, store:walkiewareAPI.walkiewareStore}); // --- Account deletions --- // Purges accounts whose grace period has ended (system/backend/ @@ -1532,6 +1535,7 @@ const DRAIN_TIMEOUT = 10_000; // 10s max wait function gracefulShutdown(signal) { console.log(`[lith] ${signal} received, draining connections...`); musicalSocket.close(); + walkiewareSocket.close(); server.close(() => { console.log("[lith] all connections drained, exiting"); process.exit(0); diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs new file mode 100644 index 0000000000..4812ff231b --- /dev/null +++ b/lith/walkieware-socket.mjs @@ -0,0 +1,85 @@ +import {WebSocketServer,WebSocket} from 'ws'; +import {randomUUID} from 'node:crypto'; +import {publicThread,sourceHash} from '../system/backend/walkieware.mjs'; + +export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,lifetimeMs=600000}={}) { + const wss=new WebSocketServer({noServer:true,maxPayload:8_100_000,perMessageDeflate:false}); + const rooms=new Map(); + const send=(ws,value)=>{if(ws?.readyState===WebSocket.OPEN){if(ws.bufferedAmount>8_100_000)ws.close(1008,'Slow reader');else ws.send(JSON.stringify(value));}}; + const upgrade=(req,socket,head)=>{ + if(req.url!=='/api/walkieware-stream')return; + if(wss.clients.size>=64){socket.destroy();return;} + wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws)); + }; + server.on('upgrade',upgrade); + wss.on('connection',ws=>{ + let owner,row,room,role,closed=false,chain=Promise.resolve(),pending=0,alive=true,messages=0,windowAt=Date.now(); + const authTimer=setTimeout(()=>ws.close(1008,'Authenticate first'),authMs); + const lifeTimer=setTimeout(()=>ws.close(1000,'Renew session'),lifetimeMs); + const heartbeat=setInterval(()=>{if(!alive)return ws.terminate();alive=false;ws.ping();},20000); + for(const timer of [authTimer,lifeTimer,heartbeat])timer.unref?.(); + ws.on('pong',()=>alive=true); + const broadcast=value=>{for(const client of room?.clients||[])send(client,value);}; + async function message(m) { + if(closed)return; + if(!owner) { + if(m.type!=='authenticate'||typeof m.token!=='string'||m.token.length>7000||!['device','agent'].includes(m.role))throw Error('Authenticate first'); + const user=await authenticate({authorization:`Bearer ${m.token}`}); + if(!user||closed)throw Error('Authentication failed'); + const db=await store();role=m.role; + row=role==='device'?await db.open(user,m.id):await db.read(user,m.code); + if(!row||closed)throw Error('Thread unavailable'); + owner=user;clearTimeout(authTimer); + room=rooms.get(row._id); + 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}); + if(role==='device')broadcast({type:'presence',online:true}); + return; + } + if(m.type==='sync'&&role==='device') { + const saved=await (await store()).save(owner,row._id,m.revision,m.ledger); + if(!saved){send(ws,{type:'conflict',thread:publicThread(await (await store()).read(owner,row.code))});return;} + row=saved;broadcast({type:'saved',thread:publicThread(row)});return; + } + if(m.type==='state'&&role==='device') { + const state=m.state; + if(!state||JSON.stringify(state).length>525000)throw Error('Invalid state'); + room.state={busy:!!state.busy,phase:String(state.phase||'').slice(0,200),head:state.head,sourceHash:typeof state.source==='string'?sourceHash(state.source):null,source:typeof state.source==='string'?state.source.slice(0,500000):'',errors:Array.isArray(state.errors)?state.errors.slice(-20).map(e=>String(e).slice(0,1000)):[],attempt:state.attempt&&typeof state.attempt==='object'?{request:String(state.attempt.request||'').slice(0,20000),status:String(state.attempt.status||'').slice(0,40),error:String(state.attempt.error||'').slice(0,1000),parent:state.attempt.parent,startedAt:state.attempt.startedAt,finishedAt:state.attempt.finishedAt}:null,updatedAt:new Date().toISOString()}; + broadcast({type:'state',state:room.state}); + await (await store()).state?.(owner,row._id,room.state);return; + } + if(m.type==='command'&&role==='agent') { + const id=typeof m.id==='string'&&/^[a-zA-Z0-9-]{1,80}$/.test(m.id)?m.id:randomUUID(); + if(!room.device){send(ws,{type:'result',id,ok:false,error:'Device offline; saved versions remain available'});return;} + if(room.commands.size||room.state?.busy){send(ws,{type:'result',id,ok:false,error:'Device busy'});return;} + if(!['ask','undo'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000)))throw Error('Invalid command'); + const timer=setTimeout(()=>{room.commands.delete(id);send(ws,{type:'result',id,ok:false,error:'Timed out; inspect history before retrying'});},180000);timer.unref?.(); + room.commands.set(id,{ws,timer}); + send(room.device,{type:'command',id,action:m.action,text:m.text,baseVersion:m.baseVersion,baseHash:m.baseHash}); + send(ws,{type:'accepted',id});return; + } + if(m.type==='result'&&role==='device') { + const command=room.commands.get(m.id);if(!command)return; + clearTimeout(command.timer);room.commands.delete(m.id); + send(command.ws,{type:'result',id:m.id,ok:m.ok===true,head:m.head,error:String(m.error||'').slice(0,1000)});return; + } + throw Error('Unsupported message'); + } + ws.on('message',(data,binary)=>{ + if(Date.now()-windowAt>=1000){messages=0;windowAt=Date.now();} + if(++messages>30){ws.close(1008,'Message rate exceeded');return;} + if(binary||++pending>16){ws.close(1008,'Message limit');return;} + chain=chain.then(async()=>{try{await message(JSON.parse(data.toString()));}catch(error){send(ws,{type:'error',error:error.message});if(!room?.clients.has(ws))ws.close(1008,'Session unavailable');}finally{pending--;}}); + }); + ws.on('error',()=>{}); + ws.on('close',()=>{ + closed=true;clearTimeout(authTimer);clearTimeout(lifeTimer);clearInterval(heartbeat); + room?.clients.delete(ws); + if(room?.device===ws){room.device=null;room.state=null;broadcast({type:'presence',online:false});for(const [id,c] of room.commands){clearTimeout(c.timer);send(c.ws,{type:'result',id,ok:false,error:'Device disconnected; inspect history before retrying'});}room.commands.clear();} + if(room&&!room.clients.size)rooms.delete(row._id); + }); + }); + return {wss,close(){server.off('upgrade',upgrade);for(const ws of wss.clients)ws.terminate();wss.close();}}; +} diff --git a/lith/walkieware-socket.test.mjs b/lith/walkieware-socket.test.mjs new file mode 100644 index 0000000000..85bb326472 --- /dev/null +++ b/lith/walkieware-socket.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {once} from 'node:events'; +import {WebSocket} from 'ws'; +import {attachWalkiewareSocket} from './walkieware-socket.mjs'; +import {attachMusicalSocket} from './musical-socket.mjs'; +import {mongoWalkiewareStore,validateLedger,sourceHash} from '../system/backend/walkieware.mjs'; +import {WalkiewareThread,threadIdentity} from '../aesel/src/walkieware-thread.mjs'; +const id='11111111-1111-4111-8111-111111111111'; +const ledger={format:1,head:0,versions:[{id:0,parent:null,source:'export function paint({wipe}){wipe(0);}',request:null,createdAt:'today',layers:0}]}; +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};}}; +} +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))};} +async function fixture(t){const server=createServer();const store=mongoWalkiewareStore(memoryCollection(),{name:()=> 'wwRuboh'});const auth=async h=>h.authorization==='Bearer owner'?'owner':h.authorization==='Bearer stranger'?'stranger':null; + const musical=attachMusicalSocket(server,{authenticate:auth,decide:async()=>{}}); + const binding=attachWalkiewareSocket(server,{authenticate:auth,store:async()=>store});server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>{binding.close();musical.close();server.close();});return {url:`ws://127.0.0.1:${server.address().port}/api/walkieware-stream`,store};} +test('names are reserved, versions immutable, undo retains history, stale writes rejected',async()=>{ + let n=0;const store=mongoWalkiewareStore(memoryCollection(),{name:()=>++n<3?'wwRuboh':'wwLemop'}); + assert.equal((await store.open('owner',id)).code,'wwRuboh'); + assert.equal((await store.open('owner','22222222-2222-4222-8222-222222222222')).code,'wwLemop'); + await assert.rejects(store.open('other',id));assert.equal(await store.read('other','wwRuboh'),null); + assert.ok(await store.save('owner',id,0,ledger));assert.equal(await store.save('owner',id,0,ledger),null); + const changed=structuredClone(ledger);changed.versions[0].source='tampered';await assert.rejects(store.save('owner',id,1,changed),/immutable/); + const next=structuredClone(ledger);next.versions.push({id:1,parent:0,source:'new',request:'edit',createdAt:'today',layers:1});next.head=1; + assert.ok(await store.save('owner',id,1,next));next.head=0;assert.equal((await store.save('owner',id,2,next)).ledger.versions.length,2); + assert.throws(()=>validateLedger({...ledger,head:99})); +}); +test('real sockets isolate accounts, persist versions and relay checked commands to the running device',{timeout:5000},async t=>{ + const f=await fixture(t),device=await client(f.url,{role:'device',id});assert.equal((await device.next('ready')).thread.code,'wwRuboh'); + device.send({type:'sync',revision:0,ledger});await device.next('saved'); + const intruder=await client(f.url,{role:'agent',code:'wwRuboh',token:'stranger'});assert.match((await intruder.next('error')).error,/unavailable/); + const agent=await client(f.url,{role:'agent',code:'wwRuboh'});assert.equal((await agent.next('ready')).thread.ledger.head,0); + agent.send({type:'command',id:'edit-1',action:'ask',text:'Make it 3D',baseVersion:0,baseHash:sourceHash(ledger.versions[0].source)}); + const command=await device.next('command');assert.equal(command.text,'Make it 3D');await agent.next('accepted'); + agent.send({type:'command',id:'edit-2',action:'undo',baseVersion:0,baseHash:'stale'});assert.equal((await agent.next('result')).error,'Device busy'); + device.send({type:'result',id:'edit-1',ok:true,head:1});assert.equal((await agent.next('result')).ok,true); + device.ws.close();assert.equal((await agent.next('presence')).online,false); + agent.send({type:'command',id:'edit-3',action:'undo',baseVersion:0,baseHash:'stale'});assert.match((await agent.next('result')).error,/offline/); +}); +test('phone client persists identity, coalesces ledger sync without echo loop and survives reconnect',{timeout:5000},async t=>{ + const f=await fixture(t),values=new Map(),storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; + const identity=threadIdentity(storage,'piece',()=>id);assert.equal(threadIdentity(storage,'piece',()=> 'wrong').id,identity.id); + let connectedResolve;const connected=new Promise(r=>connectedResolve=r); + const phone=new WalkiewareThread({storage,key:'piece',token:()=> 'owner',ledger:()=>ledger,state:()=>({busy:false,head:0,source:ledger.versions[0].source}),onStatus:(code,status)=>{if(status==='Connected')connectedResolve(code);},onCommand:async()=>({ok:true}),WebSocketImpl:WebSocket,url:f.url});t.after(()=>phone.suspend()); + await phone.resume();assert.equal(await connected,'wwRuboh'); + for(let i=0;i<50&&phone.sending;i++)await new Promise(r=>setTimeout(r,10)); + await new Promise(r=>setTimeout(r,50));assert.equal((await f.store.read('owner','wwRuboh')).revision,1); + phone.sync();await new Promise(r=>setTimeout(r,30));assert.equal((await f.store.read('owner','wwRuboh')).revision,1); + phone.suspend();await new Promise(r=>setTimeout(r,30));await phone.resume();await new Promise(r=>setTimeout(r,80));assert.equal(phone.ready,true);assert.equal((await f.store.read('owner','wwRuboh')).revision,1); +}); diff --git a/slab/bin/ww.mjs b/slab/bin/ww.mjs new file mode 100644 index 0000000000..ca40af8008 --- /dev/null +++ b/slab/bin/ww.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Agent-friendly access to a running Walkieware thread. Tokens never enter argv. +import {ACSession,USER_AGENT} from '../../aesel/src/ac-session.mjs'; +import {WebSocket} from 'ws'; +import {createHash,randomUUID} from 'node:crypto'; +const [command='list',code,...words]=process.argv.slice(2); +if(!['list','inspect','watch','ask','undo'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE'); +const session=new ACSession(),token=await session.token(); +if(!token)throw Error('Sign in with ac-login first'); +const origin=process.env.WALKIE_ORIGIN||'https://aesthetic.computer'; +if(command==='list'||command==='inspect') { + const response=await fetch(origin+'/api/walkieware'+(code?'?code='+encodeURIComponent(code):''),{headers:{Authorization:`Bearer ${token}`,'User-Agent':USER_AGENT}}); + if(!response.ok)throw Error(`Thread request failed (${response.status})`); + console.log(JSON.stringify(await response.json(),null,2)); +}else { + const ws=new WebSocket(origin.replace(/^http/,'ws')+'/api/walkieware-stream',{headers:{'User-Agent':USER_AGENT}}); + const timer=command==='watch'?null:setTimeout(()=>{console.error('Timed out; inspect before retrying');ws.close();process.exitCode=1;},185000); + ws.on('open',()=>ws.send(JSON.stringify({type:'authenticate',role:'agent',token,code}))); + ws.on('message',raw=>{ + const m=JSON.parse(raw); + if(command==='watch'){console.log(JSON.stringify(m));return;} + if(m.type==='ready') { + if(!m.online){console.error('Device offline');process.exitCode=1;ws.close();return;} + const v=m.thread.ledger?.versions.find(v=>v.id===m.thread.ledger.head); + if(!v){console.error('No synchronized version');process.exitCode=1;ws.close();return;} + ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:words.join(' '),baseVersion:v.id,baseHash:createHash('sha256').update(v.source).digest('hex')})); + } + if(['result','error','accepted'].includes(m.type))console.log(JSON.stringify(m)); + if(m.type==='result'||m.type==='error'){if(m.type==='error'||!m.ok)process.exitCode=1;ws.close();} + }); + ws.on('error',()=>{console.error('Walkieware connection failed');process.exitCode=1;}); + ws.on('close',()=>clearTimeout(timer)); +} diff --git a/system/backend/walkieware.mjs b/system/backend/walkieware.mjs new file mode 100644 index 0000000000..390973dee9 --- /dev/null +++ b/system/backend/walkieware.mjs @@ -0,0 +1,62 @@ +import {randomInt, createHash} from 'node:crypto'; + +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); +export function pronounceableCode() { + const consonants='bdfghklmnprstvz', vowels='aeiou'; + let word=''; + for (let i=0;i<5;i++) word+=(i%2?vowels:consonants)[randomInt(i%2?vowels.length:consonants.length)]; + return 'ww'+word[0].toUpperCase()+word.slice(1); +} +export const sourceHash = source => createHash('sha256').update(source).digest('hex'); +export function validateLedger(ledger) { + if(ledger?.format!==1 || !Array.isArray(ledger.versions) || !ledger.versions.length || ledger.versions.length>256) throw Error('Invalid version history'); + if(Buffer.byteLength(JSON.stringify(ledger))>8_000_000) throw Error('Version history exceeds 8 MB'); + const ids=new Set(); + for(const v of ledger.versions) { + if(!Number.isSafeInteger(v.id)||v.id<0||ids.has(v.id)||typeof v.source!=='string'||Buffer.byteLength(v.source)>500_000||!(v.parent===null||ids.has(v.parent))||!(v.request==null||typeof v.request==='string'&&v.request.length<=20000)) throw Error('Invalid version'); + ids.add(v.id); + } + if(!ids.has(ledger.head)) throw Error('Missing head'); + return {format:1,head:ledger.head,versions:ledger.versions.map(({id,parent,source,request,createdAt,layers})=>({id,parent,source,request:request??null,createdAt:typeof createdAt==='string'?createdAt:'',layers:Number.isInteger(layers)?layers:0}))}; +} +// A single Mongo document makes head + immutable version additions atomic. +export function mongoWalkiewareStore(collection, {name=pronounceableCode}={}) { + let indexes; + const ready=()=>indexes??=collection.createIndex({codeKey:1},{unique:true}); + return { + async open(owner,id) { + if(!validID(id))throw Error('Invalid thread ID'); + await ready(); + const existing=await collection.findOne({_id:id,owner}); + if(existing)return existing; + for(let attempt=0;attempt<100;attempt++) { + const code=name(), row={_id:id,owner,code,codeKey:code.toLowerCase(),revision:0,ledger:null,updatedAt:new Date().toISOString()}; + try {await collection.insertOne(row);return row;} catch(error) { + if(error.code!==11000)throw error; + const claimed=await collection.findOne({_id:id}); + if(claimed){if(claimed.owner!==owner)throw Error('Thread unavailable');return claimed;} + } + } + 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 state(owner,id,state){await collection.updateOne({_id:id,owner},{$set:{diagnostics:state}});}, + async save(owner,id,revision,ledger) { + ledger=validateLedger(ledger); + const previous=await collection.findOne({_id:id,owner,revision}); + if(!previous)return null; + for(const v of previous.ledger?.versions||[]) { + const next=ledger.versions.find(n=>n.id===v.id); + if(!next||JSON.stringify(next)!==JSON.stringify(v))throw Error('Saved versions are immutable'); + } + const result=await collection.updateOne({_id:id,owner,revision},{$set:{ledger,updatedAt:new Date().toISOString()},$inc:{revision:1}}); + return result.modifiedCount?{...previous,ledger,revision:revision+1}:null; + } + }; +} +export function publicThread(row) { + if(!row)return null; + return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,updatedAt:row.updatedAt,diagnostics:row.diagnostics}; +} diff --git a/system/netlify/functions/walkieware.mjs b/system/netlify/functions/walkieware.mjs new file mode 100644 index 0000000000..c250de39e3 --- /dev/null +++ b/system/netlify/functions/walkieware.mjs @@ -0,0 +1,20 @@ +import {connect} from '../../backend/database.mjs'; +import {authenticateMusical} from './easel-musical-jev.mjs'; +import {mongoWalkiewareStore,publicThread} from '../../backend/walkieware.mjs'; +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 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'}); + 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(!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'}); + }catch{return reply(503,{error:'Thread storage unavailable'});} +} diff --git a/system/public/aesthetic.computer/disks/ww.mjs b/system/public/aesthetic.computer/disks/ww.mjs new file mode 100644 index 0000000000..76471ae3ea --- /dev/null +++ b/system/public/aesthetic.computer/disks/ww.mjs @@ -0,0 +1,34 @@ +// Walkieware, 26.09.30 +// Open your running Walkieware piece by its ww code. +let socket,child,api,problem='Connecting…',generation=0,stopped=false; +export async function boot($) { + api=$;const code=$.params[0]; + if(!/^ww[a-z]{5,12}$/i.test(code||'')){problem='Enter a ww code';return;} + const token=await $.authorize(); + if(!token){problem='Sign in to open your Walkieware piece';return;} + socket=new WebSocket('wss://aesthetic.computer/api/walkieware-stream'); + socket.onopen=()=>socket.send(JSON.stringify({type:'authenticate',role:'agent',token,code})); + let current=''; + socket.onmessage=async event=>{ + const m=JSON.parse(event.data); + if(m.type==='error'){problem=m.error;return;} + if(!['ready','saved'].includes(m.type))return; + const ledger=m.thread.ledger,source=ledger?.versions.find(v=>v.id===ledger.head)?.source; + if(!source||source===current)return; + const turn=++generation; + try { + const next=await import('data:text/javascript;charset=utf-8,'+encodeURIComponent(source)); + if(stopped||turn!==generation)return; + child?.leave?.(api);child=null; + await next.boot?.(api); + if(stopped||turn!==generation){next.leave?.(api);return;} + child=next;current=source;problem=''; + }catch(error){problem=error.message;} + }; + socket.onerror=()=>problem='Connection unavailable'; + socket.onclose=()=>{if(!stopped)problem='Disconnected · reopen this code to reconnect';}; +} +export function paint($){if(child?.paint)try{return child.paint($);}catch(error){problem=error.message;child=null;}$.wipe(24,18,30);$.ink(240,230,255).write(problem,8,12);} +export function sim($){try{child?.sim?.($);}catch(error){problem=error.message;child=null;}} +export function act($){child?.act?.($);} +export function leave($){stopped=true;generation++;socket?.close();child?.leave?.($);} diff --git a/system/public/aesthetic.computer/lib/parse.mjs b/system/public/aesthetic.computer/lib/parse.mjs index c70fb5e2f6..e2feafb631 100644 --- a/system/public/aesthetic.computer/lib/parse.mjs +++ b/system/public/aesthetic.computer/lib/parse.mjs @@ -131,6 +131,8 @@ function parse(text, location = self?.location) { .pop(); } text = text.trim(); // Clear any spaces. + // Walkieware codes retain their stable identity when opened from the prompt or URL. + if (/^ww[a-z]{5,12}$/i.test(text)) text = "ww~" + text; // 🚨 Special case for prompt~ slugs - ALWAYS route to prompt piece // This prevents prompt~(wipe blue) from being treated as kidlisp function call -- 2.51.2