simulate-S5-completion.mjs 11 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. // Captured deployed handlers in RAM only. No imports of the production client.
  2. import fs from 'node:fs';
  3. import vm from 'node:vm';
  4. import crypto from 'node:crypto';
  5. import assert from 'node:assert/strict';
  6. import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  7. const output='work/S5-completion-isolated-simulation-20261103.json';assert.equal(fs.existsSync(output),false);
  8. const dir='work/S5-classroom-completion-20261103/';
  9. const rb=JSON.parse(fs.readFileSync(dir+'runbook.json','utf8'));
  10. const template=JSON.parse(fs.readFileSync(dir+'01-project-complete.template.json','utf8'));
  11. const audit=JSON.parse(fs.readFileSync('work/S5-completion-live-preflight-20261103.json','utf8'));assert.equal(audit.ok,true);
  12. const sha=x=>crypto.createHash('sha256').update(x).digest('hex'),copy=structuredClone;
  13. function embedded(code,name){
  14. const marker='runDatabaseFunction('+JSON.stringify(name)+',request,response,',at=code.lastIndexOf(marker)+marker.length;assert.ok(at>=marker.length);
  15. let depth=0,quoted=false,escaped=false;
  16. for(let i=at;i<code.length;i++){const c=code[i];if(quoted){if(escaped)escaped=false;else if(c==='\\')escaped=true;else if(c==='"')quoted=false;continue;}
  17. if(c==='"')quoted=true;else if(c==='{')depth++;else if(c==='}'&&--depth===0)return JSON.parse(code.slice(at,i+1));}
  18. throw Error('Missing embedded runtime schema');
  19. }
  20. const modules=new Map(),proof=[];let data,step='load captured deployed code';
  21. for(const s of audit.deployedCodeChecks){const code=fs.readFileSync(s.file,'utf8');assert.equal(sha(code),s.sha256);const runtime=embedded(code,s.name);if(data)assert.deepEqual(runtime,data);else data=runtime;
  22. modules.set(s.name,vm.runInNewContext(code+'\n;({createApplication,attachWorkshopHandlers});',{Buffer,structuredClone,URL,URLSearchParams,TextEncoder,TextDecoder},{timeout:5000}));proof.push({name:s.name,sha256:s.sha256});}
  23. const actor={id:'isolated-completion-service',kind:'service',scopes:['ingest:write','data:read'],projectIds:['*']};
  24. const attribution={agentKey:'agent-account:'+'1'.repeat(64),agentName:'隔离模拟助手',agentKeyOrigin:'runtime_config',participantKey:'isolated-completion-participant',participantName:'隔离模拟参与者',channel:'wecom',agentRuntimePlatform:'hermes',agentChannel:'wecom'};
  25. const agentProfile={key:attribution.agentKey,name:attribution.agentName,identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'wecom'};
  26. const sessionId='simCompletionSession',projectId='simCompletionProject',conceptId='simCompletionConcept',ptr=(className,objectId)=>({__type:'Pointer',className,objectId});
  27. const db=new Map(),rows=n=>{if(!db.has(n))db.set(n,[]);return db.get(n);};let serial=0;
  28. const equal=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
  29. const store={
  30. async transaction(fn){const before=copy(db);try{return await fn(this);}catch(e){db.clear();for(const [k,v]of before)db.set(k,v);throw e;}},
  31. async lock(){},async get(name,id){return copy(rows(name).find(r=>r.objectId===id)??null);},
  32. async list(name,{where={},afterId,limit=1000}={}){return copy(rows(name).filter(r=>Object.entries(where).every(([k,v])=>equal(r[k],v))).filter(r=>!afterId||r.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
  33. async create(name,input){for(const k of Object.keys(input))assert.ok(data.tables[name][k],name+'.'+k);const r={...copy(input),objectId:'simComplete'+String(++serial).padStart(5,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};rows(name).push(r);return copy(r);},
  34. async update(name,id,input){for(const k of Object.keys(input))assert.ok(data.tables[name][k],name+'.'+k);const r=rows(name).find(x=>x.objectId===id);assert.ok(r);Object.assign(r,copy(input),{updatedAt:new Date().toISOString()});return copy(r);}
  35. };
  36. rows('KfSession').push({objectId:sessionId,dataMode:'live',workshopKey:'isolated-completion-workshop',stageCode:'S5',groupId:'',meta:{ownerId:actor.id,attribution}});
  37. rows('KfProject').push({objectId:projectId,dataMode:'live',workshopKey:'isolated-completion-workshop',stageCode:'S5',stage:'S5 场景与卖点设计',status:'workshop',progressState:'in_progress',ownerSession:sessionId,sessionRef:ptr('KfSession',sessionId),currentConceptRef:ptr('KfConcept',conceptId),artifactCount:3,groupId:'',meta:{ownerId:actor.id,participantKey:attribution.participantKey,attribution,pendingQuestions:copy(rb.pendingQuestions)}});
  38. rows('KfConcept').push({objectId:conceptId,projectRef:ptr('KfProject',projectId),version:1,reviewState:'pending',specification:{netWeightG:null,noodleWeightG:null,packCount:null,currentSku:null},productionSteps:[]});
  39. for(const [i,s]of ['S3','S4','S5'].entries())rows('KfArtifact').push({objectId:'simReport'+i,stageCode:s,projectRef:ptr('KfProject',projectId),version:1,rawText:'unchanged report '+s,meta:{workshop:{pendingQuestions:copy(i===2?rb.pendingQuestions:rb.pendingQuestions.slice(0,8)),formalConfirmation:false}},dashboardPublishedAt:null});
  40. for(const [i,t]of ['project_created','task_submitted','stage_changed','task_submitted','stage_changed','concept_saved','task_submitted'].entries())rows('KfStageEvent').push({objectId:'simOldEvent'+i,eventType:t,sequence:i+1,projectRef:ptr('KfProject',projectId)});
  41. const initial=copy(db),reset=()=>{db.clear();for(const [k,v]of copy(initial))db.set(k,v);};
  42. const apps=new Map();for(const [name,impl]of modules){const app=impl.createApplication({store,crypto,Parse:{},data,settings:{},files:{}});impl.attachWorkshopHandlers(app,{store,data});apps.set(name,app);}
  43. const call=async(name,params)=>copy((await (apps.get(name)||apps.get('ksfTransitionProject')).dispatch(name,copy(params),actor)).data);
  44. const input={...copy(template),sessionId,projectId},statusInput={sessionId,projectId};
  45. const checks=[],calls=[];const check=(name,fn)=>{step=name;fn();checks.push({name,passed:true});};
  46. const execute=async(i,task)=>{const s=rb.steps[i];const result=await runWorkshop(s.command,copy(task),{requestId:s.requestId,agentProfile,call});calls.push({step:s.step,command:s.command,requestId:s.requestId,ok:true});return result;};
  47. try{
  48. for(const control of ['missing-S5-report','missing-concept','not-S5']){
  49. reset();if(control==='missing-S5-report')rows('KfArtifact').splice(2,1);else if(control==='missing-concept')delete rows('KfProject')[0].currentConceptRef;else rows('KfProject')[0].stageCode='S4';
  50. const p={...copy(input),requestId:'isolated-completion-negative-'+control,...(control==='not-S5'?{toStage:'S4'}:{})};const before=copy(db);let code;
  51. try{await call('ksfTransitionProject',p);}catch(e){code=e.code;}
  52. check(control+' refuses completion without any mutation',()=>{assert.equal(code,control==='not-S5'?'VALIDATION_FAILED':'STAGE_REQUIREMENTS_NOT_MET');assert.deepEqual(db,before);assert.equal((db.get('KsfOperation')||[]).length,0);});
  53. }
  54. reset();const before=await execute(0,statusInput);
  55. check('precheck has S5 in progress, three reports, one concept, seven events and thirteen questions',()=>{assert.equal(before.project.stageCode,'S5');assert.equal(before.project.progressState,'in_progress');assert.equal(before.artifacts.length,3);assert.equal(before.concept.objectId,conceptId);assert.equal(before.events.length,7);assert.deepEqual(before.project.meta.pendingQuestions,rb.pendingQuestions);});
  56. const completed=await execute(1,input),final=await execute(2,statusInput);
  57. check('one completion updates progress and emits exact eighth event without approval',()=>{
  58. assert.equal(completed.projectId,projectId);assert.equal(completed.stageCode,'S5');assert.equal(completed.progressState,'completed');assert.equal(completed.enterpriseApproval,false);
  59. assert.equal(final.project.progressState,'completed');assert.equal(final.project.status,'workshop');assert.equal(final.events.length,8);const ev=final.events[7];assert.equal(ev.objectId,completed.eventId);assert.equal(ev.eventType,'completed');assert.equal(ev.sequence,8);assert.equal(ev.fromStage,'S5');assert.equal(ev.toStage,'S5');assert.equal(ev.note,template.note);
  60. });
  61. check('reports, concept, formula state and thirteen questions remain unchanged',()=>{
  62. assert.deepEqual(rows('KfArtifact'),initial.get('KfArtifact'));assert.deepEqual(rows('KfConcept'),initial.get('KfConcept'));assert.deepEqual(final.formulaItems,[]);assert.deepEqual(final.project.meta.pendingQuestions,rb.pendingQuestions);assert.equal(final.artifacts.length,3);assert.deepEqual(final.events.slice(0,7),initial.get('KfStageEvent'));assert.equal(rows('KfSource').length,0);
  63. });
  64. const snapshot=copy(db);const replay=await call('ksfTransitionProject',{...copy(input),requestId:rb.steps[1].requestId});
  65. check('RAM-only replay of exact key returns same receipt without a second event',()=>{assert.deepEqual(replay,completed);assert.deepEqual(db,snapshot);assert.equal(rows('KsfOperation').length,1);});
  66. let duplicateCode;try{await call('ksfTransitionProject',{...copy(input),requestId:'isolated-completion-different-key'});}catch(e){duplicateCode=e.code;}
  67. check('different key after completed is refused without another write',()=>{assert.equal(duplicateCode,'VERSION_CONFLICT');assert.deepEqual(db,snapshot);assert.equal(rows('KsfOperation').length,1);});
  68. check('three CLI steps, one committed operation and no new business entities',()=>{
  69. assert.equal(calls.length,3);assert.deepEqual(calls.map(c=>c.requestId),rb.steps.map(s=>s.requestId));assert.equal(rows('KsfOperation').length,1);assert.equal(rows('KsfOperation')[0].requestId,rb.steps[1].requestId);
  70. for(const n of ['KfOpportunity','KfFormulaItem','KfProduct','KfSku','KfMaterial','KfProcess'])assert.equal(rows(n).length,0);
  71. });
  72. const r={ok:true,at:new Date().toISOString(),simulatedOnly:true,productionCalls:0,productionWrites:0,checksPassed:checks.length,checks,calls,deployedCode:proof,
  73. negativeControls:['S5 report required','current concept required','only S5 may complete'],idempotencyControls:['Same-key replay in RAM only','Different key after completed refused'],
  74. expectedAfter:rb.expectedAfter,limits:['Synthetic identity and RAM store, not a production transaction or current-message identity test.','Exact-key replay here is an isolated test; production runbook still forbids automatic retries.']};
  75. fs.writeFileSync(output,JSON.stringify(r,null,2)+'\n',{flag:'wx'});console.log(JSON.stringify({ok:true,checksPassed:checks.length,commandSteps:3,negativeControls:3,idempotencyControls:2,productionCalls:0,productionWrites:0}));
  76. }catch(error){const r={ok:false,step,checksPassed:checks.length,commandsCompleted:calls.length,errorCode:'S5_COMPLETION_SIMULATION_STOPPED',failureCode:error.code??null,checkLine:error.stack?.match(/simulate-S5-completion\.mjs:(\d+):/)?.[1]??null,productionCalls:0,productionWrites:0};fs.writeFileSync(output,JSON.stringify(r,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(r));process.exitCode=1;}