gongxiangp2/apps/detail-agent-pi/scripts/client-interrupt-recovery.mjs
WuYongJie 1b6425b552 feat(desktop): connect native Pi sessions with scoped rules and recovery
Track the fixed PhotoCraft source baseline with the existing native client integration. Use the actual Pi SDK for normal sends, durable session recovery, demand-loaded enterprise rules and checked undoable canvas tools. Retain request identities and never resend unknown inference. Include local packaging and real-call acceptance evidence.
2026-10-22 10:49:58 +08:00

60 lines
4.6 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import { NativeCanvas } from '../src/native-bridge.mjs';
import { atomic } from '../src/store.mjs';
const output = path.resolve(process.argv[2]);
const label = process.argv[3] || 'initial';
if (!/^[a-z0-9-]{1,30}$/.test(label)) throw Error('验收阶段编号无效');
const nonce = label === 'initial' ? 'RECOVERY-1022-ONLY-ONCE' : `RECOVERY-1022-${label.toUpperCase()}`;
if (label === 'initial' && await fs.access(path.join(output,'interrupt-attempt.json')).then(()=>true,()=>false)) throw Error('初次验收已有现场,不能重复提交');
const native = JSON.parse(await fs.readFile(path.join(output, 'native-process.json'), 'utf8'));
const accounts = path.join(native.profile, 'jiansun/accounts');
const scopes = await fs.readdir(accounts); assert.equal(scopes.length,1);
const ids = await fs.readdir(path.join(accounts,scopes[0],'projects')); assert.equal(ids.length,1);
const requests = path.join(accounts, scopes[0], 'pi-validation', ids[0], 'requests');
const initialIds = new Set(await fs.readdir(requests));
const canvas = await NativeCanvas.connect({ port:native.port, tokenFile:path.join(output,'control.token') });
const records = async () => Promise.all((await fs.readdir(requests)).filter(n=>!initialIds.has(n)&&n.endsWith('.json')).map(async name=>({name,...JSON.parse(await fs.readFile(path.join(requests,name),'utf8'))})));
try {
const state = await canvas.call('jiansun.state');
assert.ok(!state.busy && !state.unknown && !state.queued);
await fs.writeFile(path.join(output,`interrupt-${label}-attempt.json`),JSON.stringify({at:new Date().toISOString(),nativePid:native.pid,kind:'controlled-local-host-interruption-after-original-response-is-durable',notRealUpstreamOutage:true}),{flag:'wx'});
const submitted = await canvas.action('agent',{text:`本轮只验证客户端中断恢复(${label})。不要使用任何工具、不要保存新事实、不要修改画布;只回复 ${nonce}。`});
assert.equal(submitted.submitted,true);
let pid, interrupted, record;
const deadline=Date.now()+180000;
while(Date.now()<deadline){
if(!pid){
const command=`Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.ParentProcessId -eq ${native.pid} -and $_.CommandLine -like '*desktop-host.mjs*' } | Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress`;
const value=execFileSync('powershell.exe',['-NoProfile','-Command',command],{encoding:'utf8',windowsHide:true}).trim();
if(value){const p=JSON.parse(value);assert.ok(Number.isInteger(p));pid=p;}
}
const found=await records();
if(!found.length){const status=await canvas.call('jiansun.state');if(!status.busy&&status.error)throw Error(`模型尚未派发,预检失败:${status.error}`);}
record=found.find(r=>r.phase==='received' && r.terminal===true && r.raw);
if(pid && record){process.kill(pid);interrupted=true;break;}
if(found.some(r=>r.phase==='unknown'))throw Error('原请求未知,停止;不得重新提交');
if(found.some(r=>r.phase==='complete'))throw Error('未捕捉到安全中断窗口,原请求已完成;不重发');
await new Promise(r=>setTimeout(r,15));
}
assert.equal(interrupted,true);
let pending;
for(let i=0;i<100;i++){pending=await canvas.call('jiansun.state');if(pending.unknown&&!pending.busy)break;await new Promise(r=>setTimeout(r,150));}
assert.equal(pending.unknown,true);
assert.equal((await records()).length,1);
await canvas.call('ui.screenshot',{path:'interrupt-pending.png',focus:false});
// The normal recovery button uses retained local bytes. It never calls POST again.
await canvas.action('recover');
let recovered;
for(let i=0;i<180;i++){recovered=await canvas.call('jiansun.state');if(!recovered.busy&&!recovered.unknown)break;await new Promise(r=>setTimeout(r,200));}
assert.equal(recovered.unknown,false); assert.ok(!recovered.busy);
const final=await records();assert.equal(final.length,1);assert.equal(final[0].id,record.id);assert.equal(final[0].phase,'complete');
assert.ok(recovered.messages.some(m=>m.role==='assistant'&&m.text?.includes(nonce)));
await canvas.call('ui.screenshot',{path:'interrupt-restored.png',focus:false});
const result={passed:true,controlledLocalInterruption:true,upstreamOutageSimulated:false,originalId:record.id,requestId:record.requestId,originalPosts:1,recoveryPosts:0,automaticCanvasWrites:0,restoredOriginalReply:true,phase:final[0].phase};
await atomic(path.join(output,'interrupt-recovery-passed.json'),result);console.log(JSON.stringify(result));
}finally{canvas.close();}