ksf-workshop.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. import {readAgentProfileByKey,readParticipantProfile,bindOwnAgent,bindRegisteredParticipant,assertOwnSession,redactIdentity} from './ksf-identity.mjs';
  6. const self=fileURLToPath(import.meta.url);
  7. const commands={
  8. 'workshop-start':'ksfStartWorkshopSession','workshop-save':'ksfSaveWorkshopTask',
  9. 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject','employee-read':'ksfReadWecomEmployee','employee-name':'ksfEmployeeDisplayName',
  10. 'concept-save':'ksfSaveWorkshopConcept','project-status':'ksfGetWorkshopProject','workshop-search':'ksfSearchRecords',
  11. 'workshop-import':null,'workshop-dedup':'ksfCheckDuplicates','workshop-read':'ksfGetRecord','workshop-contract':'ksfGetIngestContract','workshop-schema':'ksfGetDataDictionary'
  12. };
  13. const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
  14. const fail=(code,message)=>{throw Object.assign(new Error(message||code),{code});};
  15. const canonical=x=>x&&typeof x==='object'?Array.isArray(x)?'['+x.map(canonical).join(',')+']':'{'+Object.keys(x).sort().map(k=>JSON.stringify(k)+':'+canonical(x[k])).join(',')+'}':JSON.stringify(x);
  16. export async function runWorkshop(command,task,{requestId,call,agentProfile,participantProfile}={}){
  17. if(!Object.hasOwn(commands,command)||!task||typeof task!=='object'||Array.isArray(task))fail('INVALID_INPUT');
  18. if(typeof requestId!=='string'||!requestId||task.requestId&&task.requestId!==requestId)fail('BAD_REQUEST_ID');
  19. if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','workshop-schema'].includes(command)&&!agentProfile)fail('AGENT_PROFILE_REQUIRED');
  20. if(['workshop-start','employee-read','employee-name'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
  21. if(['employee-read','employee-name'].includes(command)){
  22. const naming=command==='employee-name',hasName=Object.hasOwn(task,'displayName'),hasSkip=Object.hasOwn(task,'skip'),writing=hasName||hasSkip;
  23. if(Object.keys(task).some(k=>!['sessionId','participant','requestId',...(naming?['displayName','skip']:[])].includes(k))||typeof task.sessionId!=='string'||!task.sessionId)fail('INVALID_INPUT');
  24. if(naming&&(hasName&&hasSkip||hasName&&(typeof task.displayName!=='string'||!task.displayName.trim()||[...task.displayName.trim()].length>40||/[\p{Cc}\p{Cf}]/u.test(task.displayName))||hasSkip&&task.skip!==true))fail('INVALID_INPUT');
  25. if(agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
  26. bindRegisteredParticipant(task.participant,participantProfile);
  27. const current=await call('ksfGetRecord',{className:'KfSession',objectId:task.sessionId});
  28. assertOwnSession(current.record,agentProfile);
  29. if(current.record?.meta?.attribution?.participantKey!=='participant:'+sha(canonical([current.record.workshopKey,participantProfile.key])))fail('PARTICIPANT_SESSION_MISMATCH');
  30. const result=await call(commands[command],{sessionId:task.sessionId,participantProfileKey:participantProfile.key,agentProfileKey:agentProfile.key,requestId,...(naming&&hasName?{displayName:task.displayName}:{}),...(naming&&hasSkip?{skip:true}:{})});
  31. if(naming){
  32. if(result?.sessionId!==task.sessionId||result.readOnly!==!writing||result.persisted!==writing||!['not_asked','provided','skipped'].includes(result.promptState)||result.businessRecordsChanged!==0||!writing&&result.databaseWrites!==0||writing&&hasName&&result.source!=='wecom_directory'&&result.displayName!==task.displayName.trim().normalize('NFC'))fail('EMPLOYEE_NAME_RECEIPT_MISMATCH');
  33. if(typeof result.nameVerified!=='boolean'||result.nameVerified&&result.source!=='wecom_directory'||!result.nameVerified&&![null,'self_reported'].includes(result.source)||result.promptState==='provided'&&(typeof result.displayName!=='string'||!result.displayName.trim())||result.promptState!=='provided'&&result.displayName!==null)fail('EMPLOYEE_NAME_RECEIPT_MISMATCH');
  34. return redactIdentity(result);
  35. }
  36. if(result?.readOnly!==true||result.databaseWrites!==0||result.sessionId!==task.sessionId||result.employeeIdentity?.status!=='verified'||result.employeeIdentity.persisted!==false||result.employee?.nameVerified!==true||result.employee.source!=='wecom_directory'||typeof result.employee.name!=='string'||!result.employee.name.trim())fail('EMPLOYEE_READ_RECEIPT_MISMATCH');
  37. return redactIdentity(result);
  38. }
  39. if(agentProfile){
  40. if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','workshop-schema'].includes(command)&&agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
  41. if(command==='workshop-start'){
  42. task={...task,participant:bindRegisteredParticipant(task.participant,participantProfile)};
  43. task=bindOwnAgent(task,agentProfile);
  44. const contract=await call('ksfGetIngestContract',{});
  45. if(contract.workshop?.identitySchema!=='ksf.agent-self.v1')fail('IDENTITY_CAPABILITY_UNAVAILABLE','服务端身份扩展尚未就绪;未创建会话');
  46. if(task.participant.account?.identifierType==='wecom_aibot_sender'){
  47. if(contract.workshop?.channelIdentitySchema!=='ksf.wecom-aibot-sender.v1')fail('CHANNEL_IDENTITY_CAPABILITY_UNAVAILABLE');
  48. if(task.participant.account.scopeId!==agentProfile.account.accountId||agentProfile.account.platform!=='wecom'||task.conversation?.channel!=='wecom')fail('CHANNEL_AGENT_MISMATCH');
  49. }
  50. }
  51. else if(['workshop-save','project-create','project-progress','concept-save'].includes(command)){
  52. const current=await call('ksfGetRecord',{className:'KfSession',objectId:task.sessionId});
  53. assertOwnSession(current.record,agentProfile);
  54. }else if(command==='workshop-import'&&agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
  55. }
  56. if(command!=='workshop-import')return redactIdentity(await call(commands[command],{...task,requestId}));
  57. const allowed=['source','records','requestId'];if(Object.keys(task).some(k=>!allowed.includes(k)))fail('INVALID_INPUT');
  58. if(!task.source?.rawText||typeof task.source.rawText!=='string'||!Array.isArray(task.records)||task.records.length<1||task.records.length>200)fail('INVALID_INPUT','需要原始资料 source 和 1–200 条 records');
  59. const contract=await call('ksfGetIngestContract',{});const classes=new Map(contract.allowedClasses.map(x=>[x.className,x]));
  60. const excluded=['KfArtifact','KfConcept','KfFormulaItem','KfOpportunity','KfProject','KfSession','KfStageEvent'];
  61. const keys=new Set();
  62. for(const item of task.records){
  63. if(!item.itemKey||keys.has(item.itemKey)||!item.externalKey||!classes.has(item.className)||excluded.includes(item.className)||!item.data||item.data.dataMode!==task.source.dataMode)fail('INVALID_INPUT','条目键、类型或取得模式不合法;报告/机会/方案请用对应工作坊命令');keys.add(item.itemKey);
  64. }
  65. if(contract.deduplication?.schema!=='ksf.ingest-dedup.v1')fail('DEDUP_CAPABILITY_UNAVAILABLE','先升级查重服务,未登记来源或提交导入');
  66. const deduplication=await call('ksfCheckDuplicates',{source:task.source,records:task.records});
  67. if(deduplication.schema!=='ksf.ingest-dedup.v1'||deduplication.readOnly!==true)fail('DEDUP_RECEIPT_MISMATCH');
  68. const registered=await call('ksfRegisterSource',{requestId:requestId+':source',source:task.source,contentHash:sha(Buffer.from(task.source.rawText,'utf8'))});
  69. const refs={},pending=task.records.slice(),items=[];
  70. const resolve=value=>{
  71. if(Array.isArray(value))return value.map(resolve);
  72. if(value&&typeof value==='object'){
  73. if(Object.keys(value).length===1&&typeof value.$item==='string'){if(!refs[value.$item])fail('UNRESOLVED_DEPENDENCY');return refs[value.$item];}
  74. return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,resolve(v)]));
  75. }return value;
  76. };
  77. let active=null;
  78. try { while(pending.length){
  79. let progress=false;
  80. for(let i=0;i<pending.length;i++){
  81. const item=pending[i];if((item.dependsOn||[]).some(k=>!refs[k]))continue;
  82. active={itemKey:item.itemKey,className:item.className,requestId:requestId+':item:'+item.itemKey};
  83. const record=resolve(item.data);const def=classes.get(item.className);if(def.fields.some(x=>x.name==='sourceRef'))record.sourceRef={__type:'Pointer',className:'KfSource',objectId:registered.sourceId};
  84. const p={requestId:requestId+':item:'+item.itemKey,schemaVersion:contract.schemaVersion,sourceId:registered.sourceId,producer:{skillName:'ksf-workshop-runtime',skillVersion:'2026.11.02',...(agentProfile?{agent:bindOwnAgent({},agentProfile).agent}:{})},items:[{itemKey:item.itemKey,className:item.className,externalKey:item.externalKey,data:record}]};
  85. const checked=await call('ksfValidateImport',p);if(!checked.valid)fail(checked.itemResults?.[0]?.errors?.[0]?.code||'VALIDATION_FAILED','结构化条目预检失败,保留已返回编号和业务键');
  86. const result=await call('ksfSubmitImport',p);active.jobId=result.jobId;const receipt=await call('ksfGetImportResult',{jobId:result.jobId,limit:200});const row=receipt.items?.[0];
  87. if(receipt.job?.status!=='succeeded'||receipt.items?.length!==1||receipt.nextCursor||row.itemKey!==item.itemKey||row.status!=='succeeded'||row.snapshotState!=='available'||!row.objectId)fail('RECEIPT_MISMATCH');
  88. refs[item.itemKey]={__type:'Pointer',className:item.className,objectId:row.objectId};items.push({itemKey:item.itemKey,className:item.className,objectId:row.objectId,jobId:result.jobId,status:row.status,operation:row.operation});
  89. pending.splice(i--,1);progress=true;active=null;
  90. }
  91. if(!progress)fail('UNRESOLVED_DEPENDENCY','依赖缺失或循环;保留业务键,不自动换键重试');
  92. }} catch(error) {
  93. error.partial={sourceId:registered.sourceId,items,pendingItemKeys:pending.map(x=>x.itemKey),...(active?{active}:{}),status:'stopped',dataMode:task.source.dataMode};
  94. throw error;
  95. }
  96. return{sourceId:registered.sourceId,sourceOperation:registered.operation,items,counts:{created:items.filter(x=>x.operation==='created').length,unchanged:items.filter(x=>x.operation==='unchanged').length},deduplication:{schema:deduplication.schema,checked:deduplication.items.length},status:'succeeded',dataMode:task.source.dataMode,formalReportPublished:false};
  97. }
  98. export function createWorkshopCaller({token,url='https://kangshifu.fmode.cn/api/functions',appId='FKieRbIoGt_main',fetchImpl=fetch}){
  99. if(!token)fail('MISSING_SERVICE_TOKEN');if(url!=='https://kangshifu.fmode.cn/api/functions'||appId!=='FKieRbIoGt_main')fail('TARGET_MISMATCH');
  100. return async(name,params)=>{
  101. let r;try{r=await fetchImpl(url,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Ksf-Service-Token':token},body:JSON.stringify({path:'/'+name,params}),redirect:'error',signal:AbortSignal.timeout(45000)});}catch{fail('REMOTE_REQUEST_UNCERTAIN','请求结果未知,请保持原 requestId 查回状态;不自动重试或换键');}
  102. const result=(await r.json().catch(()=>null))?.result;
  103. if(!r.ok||result?.ok!==true||result.errors?.length)throw Object.assign(new Error('服务调用失败'),{code:result?.errors?.[0]?.code||'REMOTE_FAILED',remoteFunction:name,remoteResponseReceived:true});return result.data;
  104. };
  105. }
  106. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  107. const base={skill:'ksf-workshop-runtime',invocationId:crypto.randomUUID(),at:new Date().toISOString(),entry:self,entrySha256:sha(fs.readFileSync(self))};
  108. try{
  109. const [command,...args]=process.argv.slice(2);const flags={};for(let i=0;i<args.length;i+=2){if(!['--input','--request-id','--agent-profile'].includes(args[i])||!args[i+1]||flags[args[i]])fail('USAGE');flags[args[i]]=args[i+1];}
  110. if(!flags['--input']||!path.isAbsolute(flags['--input']))fail('INPUT_PATH_MUST_BE_ABSOLUTE');
  111. const task=JSON.parse(fs.readFileSync(flags['--input'],'utf8'));
  112. const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','workshop-schema','employee-read'].includes(command)||command==='employee-name'&&!Object.hasOwn(task,'displayName')&&!Object.hasOwn(task,'skip');
  113. if(flags['--agent-profile']&&!/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(flags['--agent-profile']))fail('INVALID_AGENT_KEY');
  114. if((!readOnly||['employee-read','employee-name'].includes(command))&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
  115. const agentProfile=flags['--agent-profile']?readAgentProfileByKey(flags['--agent-profile']):null;
  116. if(['workshop-start','employee-read','employee-name'].includes(command)&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
  117. const participantProfile=['workshop-start','employee-read','employee-name'].includes(command)?readParticipantProfile(task.participant?.profileKey):null;
  118. if(['workshop-start','employee-read','employee-name'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
  119. const result=await runWorkshop(command,task,{requestId:flags['--request-id'],agentProfile,participantProfile,call:createWorkshopCaller({token:process.env.KSF_SERVICE_TOKEN})});
  120. const out=JSON.stringify({...base,command,requestId:flags['--request-id'],ok:true,readOnly,...(agentProfile?{executingAgent:{key:agentProfile.key,name:agentProfile.name,identityBasis:agentProfile.identityBasis}}:{}),result},null,2);
  121. if(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN))fail('OUTPUT_CONTAINS_SECRET');console.log(out);
  122. }catch(e){const out=JSON.stringify({...base,command:process.argv[2],requestId:process.argv[process.argv.indexOf('--request-id')+1],ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'WORKSHOP_FAILED',message:'执行停止,保留原任务及业务键核查;未输出原始异常或凭据。',...(typeof e.remoteFunction==='string'?{remoteFunction:e.remoteFunction,remoteResponseReceived:e.remoteResponseReceived===true}:{})},...(e.partial?{partial:e.partial}:{})},null,2);console.error(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN)?JSON.stringify({...base,ok:false,error:{code:'OUTPUT_CONTAINS_SECRET'}}):out);process.exitCode=1;}
  123. }