From de851085ddf186fed31b1e5b6ea03b2c94e63fde Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:56:56 +0800 Subject: [PATCH] Validate imported reference graphs and report malformed canvas input --- apps/studio/Dockerfile | 1 + apps/studio/src/lib/canvas-document.ts | 5 +++++ apps/studio/tests/canvas-document.test.mjs | 6 ++++++ apps/waterfall-wiki/src/lib/canvas-document.ts | 5 +++++ apps/waterfall-wiki/src/lib/canvas-store.ts | 9 +++++---- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index c1e0eb4..e05abfa 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -11,6 +11,7 @@ COPY apps/studio/ . # Compare the two public parameter validators inside the isolated CI build. COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts +COPY apps/waterfall-wiki/src/lib/canvas-document.ts /waterfall-wiki/src/lib/canvas-document.ts RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke FROM node:24-bookworm-slim WORKDIR /app diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts index 3cc5ff6..f7f55da 100644 --- a/apps/studio/src/lib/canvas-document.ts +++ b/apps/studio/src/lib/canvas-document.ts @@ -35,6 +35,11 @@ export function parseCanvasDocument(value:unknown):CanvasDocument { if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; }); + const outgoing=new Map(),indegree=new Map(cards.map(n=>[n.id,0])); + for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)} + const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0; + while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}} + if(visited!==cards.length)throw Error('引用不能形成循环'); return {version:1,cards,links}; } diff --git a/apps/studio/tests/canvas-document.test.mjs b/apps/studio/tests/canvas-document.test.mjs index 7f4bff3..26db818 100644 --- a/apps/studio/tests/canvas-document.test.mjs +++ b/apps/studio/tests/canvas-document.test.mjs @@ -1,4 +1,5 @@ import test from 'node:test'; +import {readFileSync} from 'node:fs'; import assert from 'node:assert/strict'; import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts'; const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50}; @@ -13,6 +14,7 @@ test('derived image and script nodes preserve directed references across save an const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'}))); assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture'); assert.throws(()=>connectNodes(restored,'script','brief'),/循环/); + assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/); const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2); }); test('agent task captures current referenced content and validates upscale inputs',()=>{ @@ -33,3 +35,7 @@ test('editing an image passes the current image to the external agent',()=>{ const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'}; assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image); }); + +test('agent and browser parse the same canvas document contract',()=>{ + assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8')); +}); diff --git a/apps/waterfall-wiki/src/lib/canvas-document.ts b/apps/waterfall-wiki/src/lib/canvas-document.ts index 3cc5ff6..f7f55da 100644 --- a/apps/waterfall-wiki/src/lib/canvas-document.ts +++ b/apps/waterfall-wiki/src/lib/canvas-document.ts @@ -35,6 +35,11 @@ export function parseCanvasDocument(value:unknown):CanvasDocument { if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; }); + const outgoing=new Map(),indegree=new Map(cards.map(n=>[n.id,0])); + for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)} + const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0; + while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}} + if(visited!==cards.length)throw Error('引用不能形成循环'); return {version:1,cards,links}; } diff --git a/apps/waterfall-wiki/src/lib/canvas-store.ts b/apps/waterfall-wiki/src/lib/canvas-store.ts index 604703b..8067e18 100644 --- a/apps/waterfall-wiki/src/lib/canvas-store.ts +++ b/apps/waterfall-wiki/src/lib/canvas-store.ts @@ -5,6 +5,7 @@ import {storage,bucket,getObject,removeObject} from './storage'; import {newToken,tokenDigest} from './account-crypto'; import {parseCanvasDocument,agentTask,type CanvasDocument} from './canvas-document'; export class CanvasError extends Error { constructor(public status:number,message:string){super(message)} } +const inputDocument=(input:unknown)=>{try{return parseCanvasDocument(input)}catch{throw new CanvasError(400,'画布或结果参数无效')}}; export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(v); export const revision=(v:unknown):v is number=>typeof v==='number'&&Number.isSafeInteger(v)&&v>0&&v<2147483647; export async function readCanvasBody(key:string):Promise{const o=await getObject(key);return parseCanvasDocument(await o.Body!.transformToString())} @@ -25,11 +26,11 @@ export async function canvasState(record:{id:string;revision:number;name:string; } export async function createCanvas(owner:string,input:unknown,name='未命名画布'){ if(typeof name!=='string'||!name.trim()||name.length>200)throw new CanvasError(400,'画布名称无效'); - const document=parseCanvasDocument(input),id=randomUUID(),key=await putJson(id,document); + const document=inputDocument(input),id=randomUUID(),key=await putJson(id,document); try{const r=await db().query('INSERT INTO canvas_documents(id,owner_id,name,document_key) VALUES($1,$2,$3,$4) RETURNING *',[id,owner,name.trim(),key]);return canvasState(r.rows[0])}catch(e){await removeObject(key).catch(()=>{});throw e} } export async function saveCanvas(id:string,expected:number,input:unknown){ - if(!revision(expected))throw new CanvasError(400,'版本无效');const document=parseCanvasDocument(input),key=await putJson(id,document); + if(!revision(expected))throw new CanvasError(400,'版本无效');const document=inputDocument(input),key=await putJson(id,document); try{return await transaction(async client=>{const r=await client.query('UPDATE canvas_documents SET document_key=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING *',[id,expected,key]);if(!r.rowCount)throw new CanvasError(409,'在线画布已有更新,请先读取新版本');return r.rows[0]})}catch(e){await removeObject(key).catch(()=>{});throw e} } export async function createCanvasLink(id:string){ @@ -42,7 +43,7 @@ export async function queueCanvasTask(id:string,expected:number,target:string){ let key:string|undefined; try{return await transaction(async client=>{ const row=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];if(!row)throw new CanvasError(404,'画布不存在');if(row.revision!==expected)throw new CanvasError(409,'画布已有更新,请先同步'); - const body=await readCanvasBody(row.document_key),request=agentTask(body,target);key=await putJson(id,request); + const body=await readCanvasBody(row.document_key);let request;try{request=agentTask(body,target)}catch(e){throw new CanvasError(400,e instanceof Error?e.message:'创作要求无效')}key=await putJson(id,request); const result=await client.query('INSERT INTO canvas_tasks(id,canvas_id,target_id,source_revision,request_key) VALUES($1,$2,$3,$4,$5) RETURNING id,status,target_id AS "targetId"',[randomUUID(),id,target,expected,key]);return result.rows[0]; })}catch(e){if(key)await removeObject(key).catch(()=>{});if((e as {code?:string}).code==='23505')throw new CanvasError(409,'该节点已有待处理任务');throw e} } @@ -73,7 +74,7 @@ export async function updateCanvasTask(id:string,task:string,input:{status?:unkn if(record.revision!==row.source_revision)throw new CanvasError(409,'画布在任务创建后已修改,请重新派发任务'); const doc=await readCanvasBody(record.document_key),node=doc.cards.find(n=>n.id===row.target_id);if(!node)throw new CanvasError(409,'目标节点已移除'); if(result.image!==undefined&&node.kind!=='image')throw new CanvasError(400,'该节点不接收图片结果'); - const next=parseCanvasDocument({...doc,cards:doc.cards.map(n=>n.id===node.id?{...n,...(result.text===undefined?{}:{text:result.text}),...(result.image===undefined?{}:{image:result.image})}:n)}); + const next=inputDocument({...doc,cards:doc.cards.map(n=>n.id===node.id?{...n,...(result.text===undefined?{}:{text:result.text}),...(result.image===undefined?{}:{image:result.image})}:n)}); key=await putJson(id,next);await client.query('UPDATE canvas_documents SET document_key=$2,revision=revision+1,updated_at=now() WHERE id=$1',[id,key]); await client.query("UPDATE canvas_tasks SET status='succeeded',result_digest=$2,error=NULL,updated_at=now() WHERE id=$1",[task,digest]);return {id:task,status:'succeeded',revision:record.revision+1}; })}catch(e){if(key)await removeObject(key).catch(()=>{});throw e}