workshop-full-chain.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. // Full sample acceptance, executed on the real workshop Agent after tool registration.
  2. // No Agent/employee identity creation here; no credentials in inputs or output.
  3. import fs from 'node:fs';
  4. import path from 'node:path';
  5. import {fileURLToPath} from 'node:url';
  6. import {spawnSync} from 'node:child_process';
  7. const fail=code=>{throw Object.assign(Error(code),{code});};
  8. const check=(ok,code='ACCEPTANCE_MISMATCH')=>{if(!ok)fail(code);};
  9. const KEY='KSF-企微实测-20261101-K';
  10. export function validateContext(c){
  11. check(c&&typeof c==='object'&&!Array.isArray(c),'INVALID_ACCEPTANCE_CONTEXT');
  12. check(Object.keys(c).every(k=>['version','workshopKey','agentProfileKey','participantProfileKey','conversationKey','groupId'].includes(k)),'INVALID_ACCEPTANCE_CONTEXT');
  13. check(c.version==='ksf.full-workshop-acceptance.v1'&&c.workshopKey===KEY,'INVALID_ACCEPTANCE_CONTEXT');
  14. check(/^agent-account:[a-f0-9]{64}$/.test(c.agentProfileKey)&&/^participant-account:[a-f0-9]{64}$/.test(c.participantProfileKey),'TRUSTED_PROFILE_KEYS_REQUIRED');
  15. check(typeof c.conversationKey==='string'&&c.conversationKey.trim()&&c.conversationKey.length<=200,'CURRENT_CONVERSATION_REQUIRED');
  16. check(c.groupId===undefined||typeof c.groupId==='string','INVALID_ACCEPTANCE_CONTEXT');return c;
  17. }
  18. export async function runFullChain({context,invoke,publicRead,checkpoint=()=>{}}){
  19. const c=validateContext(context),key=c.workshopKey,results={},artifacts=[],ids={};
  20. const run=async(command,task,suffix)=>{
  21. const requestId=key+':'+suffix;checkpoint({status:'in-flight',command,requestId,ids:{...ids}});
  22. const result=await invoke(command,task,requestId);
  23. results[suffix]=result;checkpoint({status:'received',command,requestId,result,ids:{...ids}});return result;
  24. };
  25. const contract=await run('workshop-contract',{},'contract');
  26. check(contract.workshop?.identitySchema==='ksf.agent-self.v1'&&contract.workshop?.channelIdentitySchema==='ksf.wecom-aibot-sender.v1','IDENTITY_CAPABILITY_UNAVAILABLE');
  27. const start=await run('workshop-start',{workshopKey:key,participant:{profileKey:c.participantProfileKey},conversation:{key:c.conversationKey,channel:'wecom'},groupId:c.groupId||'',dataMode:'sample'},'start');
  28. ids.sessionId=start.sessionId;check(ids.sessionId,'MISSING_SESSION_ID');
  29. const imported=await run('workshop-import',{source:{title:'【样例】全流程验收素材定义,无真实产品/工厂依据',sourceKind:'dataset',dataMode:'sample',rawText:'仅为链路验收构造。品类:拌面;产品:演练拌面占位;包装:袋装待验证;原料:面粉占位;工艺:混合占位。所有人群、需求、规格、配方、工艺、成本均未验证。'},records:[
  30. {itemKey:'category',className:'KfCategory',externalKey:key+':category',data:{dataMode:'sample',code:'KSF-K-20261101-NOODLE',name:'【样例】拌面验收分类',taxonomyVersion:'ksf-acceptance-v1',axis:'产品形态',scopeNote:'构造分类,仅供工作坊链路验证'}},
  31. {itemKey:'product',className:'KfProduct',externalKey:key+':product',data:{dataMode:'sample',name:'【样例】演练拌面占位',brandName:'演练占位品牌',categoryRef:{$item:'category'},isOwnBrand:false,productLevel:'product',packaging:'袋装待验证',sellingPoints:['待验证占位'],targetAudience:'待研究',usageScenarios:['待验证场景']}},
  32. {itemKey:'material',className:'KfMaterial',externalKey:key+':material',data:{dataMode:'sample',name:'【样例】面粉关联占位',materialType:'ingredient',specification:'未知,未获供应链依据',function:'检查配料引用'}},
  33. {itemKey:'process',className:'KfProcess',externalKey:key+':process',data:{dataMode:'sample',name:'【样例】混合路线占位',processType:'mixing',processKind:'route',description:'无设备/温度/时间依据,仅检查工艺引用'}}
  34. ]},'library');
  35. for(const name of ['category','product','material','process']){ids[name+'Id']=imported.items?.find(x=>x.itemKey===name)?.objectId;check(ids[name+'Id'],'IMPORT_RECEIPT_MISMATCH');}
  36. const search=async(className,keyword,suffix)=>run('workshop-search',{className,keyword,filters:{dataMode:'sample'},limit:100},suffix);
  37. const materialSearch=await search('KfMaterial','面粉关联占位','search-material');
  38. check(materialSearch.records?.some(r=>r.objectId===ids.materialId),'MATERIAL_SEARCH_MISMATCH');
  39. const task=(stage,title,type,projectId,questions)=>({sessionId:ids.sessionId,...(projectId?{projectId}:{}),taskKey:key+':'+stage+(projectId?':project':''),version:1,stageCode:stage,title:'【样例】'+title,artifactType:type,category:'拌面',rawText:'构造的全流程验收材料:'+title+'。无真实消费者资料,不能作为市场判断。',sourceIds:[],pendingQuestions:questions,report:{executive:['构造样例,仅验证数字人—员工—项目—阶段—方案—看板关联;未经员工正式确认。'],chapters:[{id:stage.toLowerCase(),title,stage,sections:[{kind:'para',title:'本阶段成果',text:'本阶段仅记录待验证事项,不构成市场结论、企业审批或量产判断。'},{kind:'table',title:'待验证清单',data:{cols:['问题','状态'],rows:questions.map(q=>[q,'待验证'])}}]}]}});
  40. const save=async(stage,title,type,projectId,suffix,questions,extra={})=>{const r=await run('workshop-save',{...task(stage,title,type,projectId,questions),...extra},suffix);check(r.artifactId,'MISSING_ARTIFACT_ID');artifacts.push({stage,artifactId:r.artifactId,projectId:projectId||null});return r;};
  41. await save('S1','面类边界与竞品待查清单','category_report',null,'s1',['拌面/汤面/湿面分类口径待确认','品牌/SKU/规格/包装/渠道价格待采集']);
  42. await save('S2','人群场景与痛点待验证','pain_list',null,'s2',['真实消费者原文尚未采集','目标人群、场景、痛点和需求强度待验证']);
  43. const opportunity=await save('S3','待检索选择的机会卡','opportunity_card',null,'s3',['赛道×用户×场景假设待验证'],{opportunity:{key:key+':opportunity',title:'【样例】K轮拌面机会待验证',demandSummary:'构造的选题演练,不是消费者结论',targetAudience:'待研究人群',usageScenario:'待研究场景',fusionDefinition:'拌面赛道×待研究人群×待研究场景,均待证据验证'}});
  44. ids.opportunityId=opportunity.opportunityId;check(ids.opportunityId,'MISSING_OPPORTUNITY_ID');
  45. const found=await search('KfOpportunity','K轮拌面机会待验证','search-opportunity');
  46. const selected=found.records?.find(r=>r.objectId===ids.opportunityId);check(selected&&selected.dataMode==='sample','OPPORTUNITY_SEARCH_MISMATCH');
  47. const project=await run('project-create',{sessionId:ids.sessionId,opportunityId:selected.objectId,projectKey:key+':project',title:'【样例】真实消息驱动拌面新品全流程验收'},'project');
  48. ids.projectId=project.projectId;check(ids.projectId,'MISSING_PROJECT_ID');
  49. await save('S3','所选机会与项目边界','opportunity_card',ids.projectId,'project-s3',['已选样例机会,真实需求与竞争证据仍缺失']);
  50. const status=async(suffix,expected)=>{const r=await run('project-status',{sessionId:ids.sessionId,projectId:ids.projectId},suffix);check(r.project?.progressState===expected,'PROJECT_STATE_MISMATCH');return r;};
  51. const progress=async(toStage,state,note,suffix)=>run('project-progress',{sessionId:ids.sessionId,projectId:ids.projectId,toStage,state,note},suffix);
  52. await status('project-s3-read','in_progress');
  53. await progress('S4','in_progress','S3成果已保存,进入验证练习','s4-start');
  54. await progress('S4','blocked','缺真实消费者、供应链和成本依据;验证看板卡住状态','blocked');
  55. await status('blocked-read','blocked');
  56. const blockedBoard=await publicRead();check(blockedBoard.project?.some(p=>p.objectId===ids.projectId&&p.progressState==='blocked'),'PUBLIC_BLOCKED_MISMATCH');checkpoint({status:'public-blocked-verified',ids:{...ids}});
  57. await progress('S4','in_progress','继续sample技术流程,资料缺口仍保留,不声明真实验证通过','resume');
  58. await save('S4','需求工艺成本验证缺口','assessment_report',ids.projectId,'s4',['消费者证据未采集','配方比例和工艺参数未核实','成本/法规/包装适配未验证']);
  59. await progress('S5','in_progress','S4缺口已记录,进入方案组装练习','s5-start');
  60. const concept=await run('concept-save',{sessionId:ids.sessionId,projectId:ids.projectId,version:1,concept:{name:'【样例】拌面新品方案字段联调',targetAudience:'待研究,非消费者结论',usageScenario:'待验证场景',specification:{packaging:'袋装候选,待验证',netWeightG:null,categoryId:ids.categoryId,referenceProductId:ids.productId},valueProposition:'待验证买点',flavor:'待验证口味',sellingPoints:['待验证卖点'],productionSteps:[{processId:ids.processId,step:'样例混合路线,无工艺参数依据'}],pendingQuestions:['真实人群与需求证据缺失','净含量、配方比例、加工参数、包装适配和成本待验证']},formulaItems:[{materialId:ids.materialId,component:'面体',basis:'用量未知;仅验证原料关系',note:'不填虚构比例/用量'}]},'concept');
  61. ids.conceptId=concept.conceptId;check(ids.conceptId,'MISSING_CONCEPT_ID');
  62. const final=await save('S5','新品方案与未决事项','concept',ids.projectId,'s5',['sample课堂链路完成不等于量产可行','真实消费者证据、工厂试验、成本与合规待完成']);ids.artifactId=final.artifactId;
  63. await progress('S5','completed','仅sample课堂任务全流程完成,不代表企业审批或量产','complete');
  64. const complete=await status('final-read','completed');
  65. check(complete.project.stageCode==='S5'&&complete.concept?.objectId===ids.conceptId,'FINAL_PROJECT_MISMATCH');
  66. check(complete.formulaItems?.some(x=>x.materialRef?.objectId===ids.materialId),'FORMULA_REFERENCE_MISMATCH');
  67. check(complete.concept.productionSteps?.some(x=>x.processId===ids.processId),'PROCESS_REFERENCE_MISMATCH');
  68. check(complete.artifacts?.length===3&&complete.artifacts.every(x=>x.dataMode==='sample'&&x.dashboardState==='workshop'),'PROJECT_ARTIFACT_MISMATCH');
  69. check(complete.events?.some(x=>x.eventType==='blocked'||x.type==='blocked')||results['blocked-read'].project.progressState==='blocked','BLOCKED_EVENT_MISMATCH');
  70. for(const row of artifacts){const read=await run('workshop-read',{className:'KfArtifact',objectId:row.artifactId},'read-'+row.artifactId);check(read.record?.dataMode==='sample'&&read.record?.sessionRef?.objectId===ids.sessionId,'ARTIFACT_READBACK_MISMATCH');}
  71. const raw=await publicRead();
  72. check(raw.project?.some(p=>p.objectId===ids.projectId&&p.progressState==='completed'&&p.stageCode==='S5'),'PUBLIC_PROJECT_MISMATCH');
  73. check(raw.opportunity?.some(p=>p.objectId===ids.opportunityId),'PUBLIC_OPPORTUNITY_MISMATCH');
  74. for(const row of artifacts)check(raw.artifact?.some(a=>a.objectId===row.artifactId&&a.dataMode==='sample'),'PUBLIC_ARTIFACT_MISMATCH');
  75. const receipt={ok:true,operation:'full-workshop-api-chain-verified',...ids,artifacts,reportUrl:'https://kangshifu.fmode.cn/report/'+ids.artifactId,membersUrl:'https://kangshifu.fmode.cn/members',stage:'S5',progressState:'completed',projectArtifactCount:complete.artifacts.length,eventCount:complete.events.length,dataMode:'sample',formalReportPublished:false,publicBoardDataVerified:true,browserVerified:false,actualPlatformCollectionPerformed:false};
  76. checkpoint({status:'complete',receipt});return receipt;
  77. }
  78. async function readPublic(){
  79. const raw={project:[],artifact:[],opportunity:[],session:[]};let cursor=null;
  80. for(let i=0;i<500;i++){
  81. const response=await fetch('https://kangshifu.fmode.cn/api/functions',{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':'FKieRbIoGt_main'},body:JSON.stringify({path:'/ksfGetPublicBoard',params:{limit:500,...(cursor?{cursor}:{})}}),redirect:'error',signal:AbortSignal.timeout(30000)});
  82. const result=(await response.json())?.result;check(response.ok&&result?.ok&&result.data,'PUBLIC_READ_FAILED');
  83. for(const k of Object.keys(raw)){check(Array.isArray(result.data[k]),'PUBLIC_SHAPE_MISMATCH');raw[k].push(...result.data[k]);}
  84. cursor=result.data.nextCursor;if(!cursor)return raw;
  85. }fail('PUBLIC_PAGINATION_LIMIT');
  86. }
  87. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  88. try{
  89. const a=process.argv.slice(2);check(a.length===5&&a[0]==='--input'&&a[2]==='--state-dir'&&a[4]==='--execute','USAGE');
  90. check(process.platform==='linux'&&process.geteuid()===10000,'HERMES_UID_REQUIRED');
  91. check(path.isAbsolute(a[1])&&path.isAbsolute(a[3]),'ABSOLUTE_PATH_REQUIRED');
  92. for(const file of [a[1],path.dirname(a[3])]){check(fs.realpathSync(file)===file,'UNSAFE_ACCEPTANCE_PATH');}
  93. const st=fs.lstatSync(a[1]);check(st.isFile()&&st.uid===process.geteuid()&&(st.mode&0o777)===0o600,'PRIVATE_CONTEXT_REQUIRED');
  94. const context=validateContext(JSON.parse(fs.readFileSync(a[1],'utf8')));
  95. const worker='/opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
  96. // This runner never auto-retries a remote write or resumes an ambiguous operation.
  97. fs.mkdirSync(a[3],{mode:0o700});check(fs.realpathSync(a[3])===a[3],'UNSAFE_ACCEPTANCE_PATH');
  98. const journal={version:'ksf.full-workshop-journal.v1',at:new Date().toISOString(),steps:[],status:'running'};
  99. const log=path.join(a[3],'journal.json'),persist=()=>fs.writeFileSync(log,JSON.stringify(journal,null,2),{mode:0o600});persist();
  100. let sequence=0;
  101. const invoke=async(command,task,requestId)=>{
  102. const name=String(++sequence).padStart(2,'0')+'-'+command;
  103. const input=path.join(a[3],name+'.input.json');fs.writeFileSync(input,JSON.stringify(task,null,2),{flag:'wx',mode:0o600});
  104. const readonly=['workshop-contract','workshop-search','workshop-read','project-status'].includes(command);
  105. const args=[worker,command,...(readonly?[]:['--agent-profile',context.agentProfileKey]),'--input',input,'--request-id',requestId];
  106. const p=spawnSync(process.execPath,args,{encoding:'utf8',timeout:660000,maxBuffer:4*1024*1024,windowsHide:true});
  107. if(p.error)fail('ACCEPTANCE_REQUEST_UNCERTAIN');
  108. let receipt;try{receipt=JSON.parse(p.status===0?p.stdout:p.stderr);}catch{fail('ACCEPTANCE_RECEIPT_INVALID');}
  109. fs.writeFileSync(path.join(a[3],name+'.receipt.json'),JSON.stringify(receipt,null,2),{flag:'wx',mode:0o600});
  110. check(p.status===0&&receipt.ok&&receipt.entryResult?.ok,receipt.error?.code||receipt.entryResult?.error?.code||'ACCEPTANCE_COMMAND_FAILED');
  111. return receipt.entryResult.result;
  112. };
  113. try{
  114. const receipt=await runFullChain({context,invoke,publicRead:readPublic,checkpoint:event=>{journal.steps.push(event);journal.status=event.status;persist();}});
  115. console.log(JSON.stringify(receipt,null,2));
  116. }catch(e){journal.status='stopped';journal.error={code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'ACCEPTANCE_FAILED',message:'保留已落库编号与原业务键,不自动重试、清理或换键;查journal及逐步回执。'};persist();throw e;}
  117. }catch(e){console.error(JSON.stringify({ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'ACCEPTANCE_FAILED',message:'完整链路验收停止;保留现场与已有编号,不输出账号或凭据。'}}));process.exitCode=1;}
  118. }