diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index f671ac8f..334ffc44 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md @@ -2,7 +2,7 @@ Turn a complex task into a reviewable multi-agent workflow. Inspect and edit the topology before execution, follow each agent's progress and output, then repair a failed script without discarding valid completed work. -Version **0.8.0** · Apache-2.0 · one English Skill and one local MCP server with 11 tools. +Version **0.8.0** · Apache-2.0 · one English Skill and one local MCP server with 13 tools. ## Try it @@ -82,6 +82,6 @@ Verification covers isolated demo execution, approval gating, selective reuse an ## MCP tools -`workflow_validate`, `workflow_start`, `workflow_update`, `workflow_repair`, `workflow_status`, `workflow_results`, `workflow_wait`, `workflow_pause`, `workflow_cancel`, `workflow_resume`, `workflow_dashboard`. +`workflow_validate`, `workflow_start`, `workflow_update`, `workflow_repair`, `workflow_status`, `workflow_results`, `workflow_wait`, `workflow_pause`, `workflow_cancel`, `workflow_resume`, `workflow_delete`, `workflow_restore`, `workflow_rerun`, `workflow_dashboard`. Read the [Skill](skills/dynamic-workflow/SKILL.md), [English example](examples/audit-en.js) and [Chinese example](examples/audit.js). The dashboard is a local web page, not a native Mini App or TUI extension. diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs new file mode 100644 index 00000000..c1f71553 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs @@ -0,0 +1,210 @@ +// Run-lifecycle archive suite: rotation compaction into the sidecar archive.db, +// manifest verification (including tamper detection), archive restore, startup +// threshold auto-rotation and the CLI maintenance faces. Real store, real +// subprocesses, real exit codes; events-row counts are pinned around every +// rotation because "events never move" is the program red line. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {createHash} from 'node:crypto'; +import {DatabaseSync} from 'node:sqlite'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +import {createToolHandler} from '../src/tools.mjs'; +const exec=promisify(execFile),binary=resolve('dist/main.mjs'); +const headers={'X-Workflow-Client':'1','Content-Type':'application/json'},DAY=86400000; +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-archive-'));const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute});return {dir,store,engine,cleanup:async()=>{await engine.close();store.close();await rm(dir,{recursive:true,force:true});}};} +async function finish(engine,id){for(let i=0;i<300;i++){if(!engine.active.has(id))return engine.snapshot(id);await delay(20);}throw Error('timeout');} +async function run(engine,script,requestId){const r=await engine.start({requestId,name:'Archive suite',executor:'demo',script,input:{}});await engine.approve(r.id,{revision:1});return finish(engine,r.id);} +const twoSteps='const a=await ctx.agent({id:"a",prompt:"a"});const b=await ctx.agent({id:"b",prompt:"b",dependsOn:["a"]});return {a:a.output,b:b.output};'; +// Independent recomputation of the manifest formula: canonical JSON (sorted +// keys, recursive) over [{run:{id,requestId,requestHash,body},steps:[{id,body}]}] +// in (runId, id) order, SHA-256 hex. Deliberately NOT the implementation's +// helper, so a drift between rotation and this check is a failure, not a mirror. +const canon=v=>Array.isArray(v)?v.map(canon):(v&&typeof v==='object')?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canon(v[k])])):v; +const localManifest=entries=>createHash('sha256').update(JSON.stringify(canon(entries))).digest('hex'); +const archiveEntries=archive=>archive.prepare('SELECT runId AS id,requestId,requestHash,body FROM archive_runs ORDER BY runId').all().map(run=>({run,steps:archive.prepare('SELECT id,body FROM archive_steps WHERE runId=? ORDER BY id').all(run.id)})); +const eventCount=store=>Number(store.db.prepare('SELECT COUNT(*) AS n FROM events').get().n); + +test('rotation exports only due tombstones, keeps every events row, and the manifest verifies independently',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,twoSteps,'rotate-1'); + await f.engine.deleteRun(end.id); + const tombstoned=f.store.get(end.id); + assert.equal(f.store.rotateDue({now:Date.now()}).rotated,false,'30-day retention: the tombstone is not due yet'); + assert.ok(f.store.get(end.id),'nothing left the library before expiry'); + const eventsBefore=eventCount(f.store),runEventsBefore=f.store.events(end.id).length,stepsBefore=Number(f.store.db.prepare('SELECT COUNT(*) AS n FROM steps WHERE runId=?').get(end.id).n); + f.engine.configureTrash({trashRetentionDays:0}); + const result=f.store.rotateDue({now:Date.now()}); + assert.equal(result.rotated,true);assert.equal(result.runCount,1);assert.deepEqual(result.runs,[end.id]); + assert.match(result.manifestHash,/^[0-9a-f]{64}$/);assert.ok(result.bytes>0); + assert.equal(f.store.get(end.id),null,'runs row physically reclaimed'); + assert.equal(Number(f.store.db.prepare('SELECT COUNT(*) AS n FROM steps WHERE runId=?').get(end.id).n),0,'steps rows physically reclaimed'); + assert.equal(eventCount(f.store),eventsBefore+1,'rotation appends exactly one archive.rotated audit event and removes nothing (red line: events rows never move)'); + assert.equal(f.store.events(end.id).length,runEventsBefore,'the rotated run keeps every one of its own events'); + assert.equal(f.store.listTrash().length,0); + const integrity=f.store.verifyIntegrity(); + assert.equal(integrity.events.verified,true,'events chain still valid after rotation'); + assert.equal(integrity.archive.verified,true); + const audit=f.store.events(result.rotationId); + assert.equal(audit.length,1);assert.equal(audit[0].type,'archive.rotated'); + assert.deepEqual(audit[0].runs,[end.id]);assert.equal(audit[0].manifestHash,result.manifestHash);assert.equal(audit[0].runCount,1);assert.equal(audit[0].bytes,result.bytes); + const archive=f.store.archive(); + const rotations=archive.prepare('SELECT * FROM rotations').all(); + assert.deepEqual(rotations.map(r=>({rotationId:r.rotationId,manifestHash:r.manifestHash,runCount:r.runCount})),[{rotationId:result.rotationId,manifestHash:result.manifestHash,runCount:1}]); + const archivedRun=JSON.parse(archive.prepare('SELECT body FROM archive_runs WHERE runId=?').get(end.id).body); + assert.equal(archivedRun.deletedAt,tombstoned.deletedAt,'the archived body is the tombstoned run verbatim'); + assert.equal(archive.prepare('SELECT COUNT(*) AS n FROM archive_steps').get().n,stepsBefore); + assert.equal(localManifest(archiveEntries(archive)),result.manifestHash,'independent recomputation matches the rotation manifest'); + assert.equal(localManifest(archiveEntries(archive)),audit[0].manifestHash,'and matches the chained event anchor'); + }finally{await f.cleanup();} +}); + +test('tampering an archived step, the rotations hash, or deleting a rotation record all fail verification',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,twoSteps,'rotate-2'); + await f.engine.deleteRun(end.id);f.engine.configureTrash({trashRetentionDays:0}); + const result=f.store.rotateDue({now:Date.now()}); + const archive=f.store.archive(); + assert.equal(f.store.verifyArchive().verified,true); + const step=archive.prepare('SELECT body FROM archive_steps WHERE id=?').get('a'); + archive.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(JSON.stringify({...JSON.parse(step.body),output:'tampered'}),'a'); + assert.equal(f.store.verifyArchive().verified,false); + assert.equal(f.store.verifyIntegrity().archive.verified,false); + archive.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(step.body,'a'); + assert.equal(f.store.verifyArchive().verified,true); + archive.prepare('UPDATE rotations SET manifestHash=?').run('0'.repeat(64)); + assert.equal(f.store.verifyArchive().verified,false,'a forged rotations hash cannot match the chained event'); + archive.prepare('UPDATE rotations SET manifestHash=?').run(result.manifestHash); + assert.equal(f.store.verifyArchive().verified,true); + archive.prepare('DELETE FROM rotations').run(); + const missing=f.store.verifyArchive(); + assert.equal(missing.verified,false,'a chained rotation whose archive record vanished fails closed'); + assert.ok(missing.divergences.some(d=>d.includes(result.rotationId))); + archive.prepare('INSERT INTO rotations VALUES(?,?,?,?,?)').run(result.rotationId,Date.now(),result.manifestHash,1,result.bytes); + assert.equal(f.store.verifyArchive().verified,true); + }finally{await f.cleanup();} +}); + +test('archive restore returns the run live with provenance; re-rotation keeps every old manifest verifiable',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,twoSteps,'rotate-3'); + await f.engine.deleteRun(end.id);f.engine.configureTrash({trashRetentionDays:0}); + const first=f.store.rotateDue({now:Date.now()}); + assert.throws(()=>f.engine.snapshot(end.id),/不存在/,'rotated run is gone from the live faces'); + const restored=await f.engine.restoreRun(end.id,{by:'cli'}); + assert.equal(restored.status,'succeeded');assert.deepEqual(restored.result,{a:'a',b:'b'}); + assert.equal(restored.steps.length,2);assert.equal(restored.deletedAt,undefined); + const events=f.store.events(end.id); + const restoredEvent=events.filter(e=>e.type==='run.restored').at(-1); + assert.equal(restoredEvent.origin,'archive');assert.equal(restoredEvent.rotationId,first.rotationId); + assert.equal(f.store.verifyArchive().verified,true,'the archive copy survives restore (copy-back, not move)'); + // Delete and rotate again: a fresh rotation must not disturb the old manifest. + await f.engine.deleteRun(restored.id);f.engine.configureTrash({trashRetentionDays:0}); + const second=f.store.rotateDue({now:Date.now()}); + assert.notEqual(second.rotationId,first.rotationId); + const verdict=f.store.verifyArchive(); + assert.equal(verdict.verified,true);assert.equal(verdict.rotations,2); + assert.ok(verdict.results.every(r=>r.verified)); + // Row-level idempotence: restoring again replaces rows and appends another event. + const again=await f.engine.restoreRun(end.id,{by:'cli'}); + assert.equal(again.status,'succeeded'); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.restored').length,2); + // A newer live run claiming the freed requestId blocks archive restore. + await f.engine.deleteRun(end.id);f.engine.configureTrash({trashRetentionDays:0}); + const third=f.store.rotateDue({now:Date.now()}); + const claimant=await f.engine.start({requestId:'rotate-3',name:'claimant',executor:'demo',script:'return 1;',input:{}}); + await assert.rejects(f.engine.restoreRun(end.id,{by:'cli'}),/requestId 已被新/); + // The MCP delete face reports the archive state truthfully for a rotated run. + const handler=createToolHandler(f.engine,()=>'http://127.0.0.1:1/'); + const archived=await handler('workflow_delete',{runId:end.id}); + assert.equal(archived.deleted,true);assert.equal(archived.alreadyDeleted,true);assert.equal(archived.archived,true); + assert.equal(archived.rotationId,third.rotationId,'the latest rotation owning the run is reported'); + }finally{await f.cleanup();} +}); + +async function connect(dir){ + const client=new Client({name:'archive-check',version:'1'}); + await client.connect(new StdioClientTransport({command:process.execPath,args:[binary,'--stdio','--workspace',dir,'--data-dir',dir],stderr:'pipe'})); + return client; +} +const call=async(client,name,args={})=>{const result=await client.callTool({name,arguments:args});assert.ok(!result.isError,result.content[0].text);return JSON.parse(result.content[0].text);}; +const stop=dir=>exec(process.execPath,[binary,'--stop-service','--workspace',dir,'--data-dir',dir]); + +test('service startup auto-rotates oversized expired trash but leaves moderate trash alone',{timeout:90000},async()=>{ + const dirs=[await mkdtemp(join(tmpdir(),'wf-autorot-')),await mkdtemp(join(tmpdir(),'wf-autorot-ctl-'))]; + const seed=async(dir,n)=>{const store=new Store(dir);const now=Date.now();for(let i=0;i{ + const dir=await mkdtemp(join(tmpdir(),'wf-rotate-cli-')); + const cli=async args=>{try{const {stdout}=await exec(process.execPath,[binary,'--workspace',dir,'--data-dir',dir,...args]);return {ok:true,stdout};}catch(e){return {ok:false,code:e.code,stdout:e.stdout??'',stderr:e.stderr??''};}}; + let runId; + try{ + {const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + const end=await run(engine,'return await ctx.agent({id:"a",prompt:"a"});','cli-rotate'); + await engine.deleteRun(end.id,{by:'cli'});await engine.configureTrash({trashRetentionDays:0}); + runId=end.id;await engine.close();store.close();} + const rotated=await cli(['--rotate-archive']); + assert.ok(rotated.ok,rotated.stderr);const summary=JSON.parse(rotated.stdout); + assert.equal(summary.rotated,true);assert.equal(summary.runCount,1);assert.deepEqual(summary.runs,[runId]); + const good=await cli(['--rotate-archive','--verify']); + assert.ok(good.ok,good.stderr); + assert.equal(JSON.parse(good.stdout).archive.verified,true); + {const adb=new DatabaseSync(join(dir,'archive.db')); + const body=adb.prepare('SELECT body FROM archive_steps WHERE id=?').get('a').body; + adb.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(JSON.stringify({...JSON.parse(body),output:'tampered'}),'a');adb.close();} + const bad=await cli(['--rotate-archive','--verify']); + assert.ok(!bad.ok);assert.equal(bad.code,1); + assert.equal(JSON.parse(bad.stdout).archive.verified,false,'failure detail stays on stdout with a nonzero exit'); + {const adb=new DatabaseSync(join(dir,'archive.db')); + const body=adb.prepare('SELECT body FROM archive_steps WHERE id=?').get('a').body; + adb.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(JSON.stringify({...JSON.parse(body),output:'a'}),'a');adb.close();} + const healed=await cli(['--rotate-archive','--verify']); + assert.ok(healed.ok,healed.stderr); + const restored=await cli(['--restore',runId]); + assert.ok(restored.ok,restored.stderr); + assert.equal(JSON.parse(restored.stdout).status,'succeeded'); + {const store=new Store(dir);try{assert.equal(store.get(runId).status,'succeeded');}finally{store.close();}} + // With a live service owning the directory the CLI must forward, not fail + // on the owner lock (a direct Store open would throw while held). + const client=await connect(dir); + try{ + await call(client,'workflow_dashboard'); + const forwarded=await cli(['--rotate-archive']); + assert.ok(forwarded.ok,forwarded.stderr); + assert.equal(JSON.parse(forwarded.stdout).rotated,false,'nothing due; forwarded rotation is a clean no-op'); + }finally{await client.close();await stop(dir);} + }finally{await rm(dir,{recursive:true,force:true});} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/canvas-fullscreen.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/canvas-fullscreen.check.mjs new file mode 100644 index 00000000..07c63523 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/canvas-fullscreen.check.mjs @@ -0,0 +1,43 @@ +// Regression nail for the canvas fullscreen button: the Fullscreen API is +// preferred with the opaque overlay as fallback (unavailable API, denied +// request, embedded iframe), Escape exits the overlay but never past an open +// dialog, and the browser-driven api form is tracked through fullscreenchange +// in both directions. DOM-level behaviour (button place, refit after the +// geometry change, poll keeping fullscreen alive) is verified through the +// egolite interaction pass on the real panel. +import test from 'node:test';import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {FULLSCREEN_OFF,FULLSCREEN_API,FULLSCREEN_OVERLAY,requestMode,rejectApi,apiChange,shouldExitOnKey} from '../web/fullscreen-model.mjs'; +test('entering prefers the Fullscreen API and falls back to the overlay when the page cannot request it',()=>{ + assert.equal(requestMode(FULLSCREEN_OFF,{apiSupported:true}),FULLSCREEN_API); + assert.equal(requestMode(FULLSCREEN_OFF,{apiSupported:false}),FULLSCREEN_OVERLAY); + assert.equal(requestMode(FULLSCREEN_OFF,{}),FULLSCREEN_OVERLAY); +}); +test('already-active modes are idempotent so a stray second click never re-enters',()=>{ + assert.equal(requestMode(FULLSCREEN_API,{apiSupported:true}),FULLSCREEN_API); + assert.equal(requestMode(FULLSCREEN_OVERLAY,{apiSupported:true}),FULLSCREEN_OVERLAY); +}); +test('a denied or failed API request drops to the overlay instead of stranding the canvas',()=>{ + assert.equal(rejectApi(FULLSCREEN_API),FULLSCREEN_OVERLAY); + assert.equal(rejectApi(FULLSCREEN_OFF),FULLSCREEN_OFF); + assert.equal(rejectApi(FULLSCREEN_OVERLAY),FULLSCREEN_OVERLAY); +}); +test('fullscreenchange tracks the panel in both directions without cancelling an unrelated overlay',()=>{ + assert.equal(apiChange(FULLSCREEN_OFF,true),FULLSCREEN_API); + assert.equal(apiChange(FULLSCREEN_API,true),FULLSCREEN_API); + assert.equal(apiChange(FULLSCREEN_API,false),FULLSCREEN_OFF); + assert.equal(apiChange(FULLSCREEN_OVERLAY,false),FULLSCREEN_OVERLAY,'a foreign element leaving fullscreen must not close our overlay'); + assert.equal(apiChange(FULLSCREEN_OVERLAY,true),FULLSCREEN_API); +}); +test('Escape exits only the overlay form and never past an open dialog',()=>{ + assert.equal(shouldExitOnKey(FULLSCREEN_OVERLAY,{key:'Escape'}),true); + assert.equal(shouldExitOnKey(FULLSCREEN_OVERLAY,{key:'Escape',dialogOpen:true}),false,'closing the node/report dialog keeps the canvas fullscreen'); + assert.equal(shouldExitOnKey(FULLSCREEN_API,{key:'Escape'}),false,'the api form exits through the browser itself'); + assert.equal(shouldExitOnKey(FULLSCREEN_OFF,{key:'Escape'}),false); + assert.equal(shouldExitOnKey(FULLSCREEN_OVERLAY,{key:'Enter'}),false); +}); +test('the toolbar carries the fullscreen button ahead of the topology toggle',async()=>{ + const html=await readFile('web/index.html','utf8'); + assert.ok(html.includes('id="graph-fullscreen"')); + assert.ok(html.indexOf('id="graph-fullscreen"'){for(let i=0;;i++){try{return await rm(dir,{recursive:true,force:true})}catch(e){if(!['EBUSY','ENOTEMPTY','EPERM'].includes(e?.code)||i>=5)throw e;await new Promise(res=>setTimeout(res,200*2**i))}}}; test('packaged MCP advertises reuseAcrossRuns and accepts it through the public tool surface',async()=>{ const dir=await mkdtemp(join(tmpdir(),'wf-cross-mcp-'));await writeFile(join(dir,'settings.json'),JSON.stringify({workspace:dir,dataDir:dir})); const client=new Client({name:'cross-reuse-mcp-test',version:'1'});const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--settings',join(dir,'settings.json')],stderr:'pipe'}); @@ -10,5 +13,5 @@ test('packaged MCP advertises reuseAcrossRuns and accepts it through the public assert.equal(run.reuseAcrossRuns,true,'the flag must survive the public tool surface'); const rejected=await client.callTool({name:'workflow_start',arguments:{requestId:'cross-mcp-bad',name:'Bad flag type',executor:'demo',reuseAcrossRuns:'yes',script:'return 1;'}}); assert.ok(rejected.isError,'a non-boolean flag must be rejected by the public surface'); - }finally{await client.close();await transport.close();await rm(dir,{recursive:true,force:true});} + }finally{let closeErr;try{await client.close();await transport.close();}catch(e){closeErr=e}try{await exec(process.execPath,[resolve('dist/main.mjs'),'--stop-service','--workspace',dir,'--data-dir',dir]);}catch{}await rmWithRetry(dir);if(closeErr)throw closeErr;} }); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs new file mode 100644 index 00000000..0152e1ef --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs @@ -0,0 +1,34 @@ +// Regression nail for the lineage panel render-idempotence bugs found by the +// egolite interaction pass: polling over an unchanged family must keep the +// user's compare pair and open diff (identity signature stable), while live +// member data (status, duration, trash/archive flags) must stay refreshable +// through the member cards without forcing a control rebuild. +import test from 'node:test';import assert from 'node:assert/strict'; +import {compareSignature,shouldRebuildCompare} from '../web/lineage-model.mjs'; +const member=(id,rerunSeq,name,extra={})=>({id,rerunSeq,name,status:'succeeded',createdAt:1,durationMs:1000,resultPreview:null,deleted:null,archived:null,...extra}); +const family=[member('run-root',0,'Family root'),member('run-rerun-1',1,'First rerun',{status:'running',durationMs:400})]; +test('poll-shaped refresh (fresh objects, moved live data, fixed identity) keeps the compare controls',()=>{ + const before=compareSignature('zh',family); + const poll=family.map(m=>({...m,status:'succeeded',durationMs:m.durationMs+9000,resultPreview:'done'})); + assert.equal(shouldRebuildCompare(before,compareSignature('zh',poll)),false); + assert.equal(compareSignature('zh',family),compareSignature('zh',[...family])); +}); +test('trash restore clears deleted flags without rebuilding the compare pair',()=>{ + const trashed=family.map(m=>m.id==='run-rerun-1'?{...m,deleted:{at:2,by:'studio'}}:m); + const restored=trashed.map(m=>({...m,deleted:null})); + assert.equal(shouldRebuildCompare(compareSignature('en',trashed),compareSignature('en',restored)),false); +}); +test('identity changes (member added, removed or reordered) rebuild the controls',()=>{ + const before=compareSignature('zh',family); + assert.equal(shouldRebuildCompare(before,compareSignature('zh',[...family,member('run-rerun-2',2,'Second rerun')])),true); + assert.equal(shouldRebuildCompare(before,compareSignature('zh',family.slice(0,1))),true); + assert.equal(shouldRebuildCompare(before,compareSignature('zh',[...family].reverse())),true); +}); +test('language switch and member renames rebuild so option labels stay faithful',()=>{ + assert.equal(shouldRebuildCompare(compareSignature('zh',family),compareSignature('en',family)),true); + assert.equal(shouldRebuildCompare(compareSignature('zh',family),compareSignature('zh',family.map(m=>m.id==='run-root'?{...m,name:'Renamed'}:m))),true); +}); +test('first render of any family rebuilds and empty/missing members are safe',()=>{ + assert.equal(shouldRebuildCompare('',compareSignature('zh',family)),true); + assert.equal(compareSignature('zh',[]),compareSignature('zh',null)); +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs index ab9cbe94..28f14b98 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs @@ -1,9 +1,9 @@ import test from 'node:test';import assert from 'node:assert/strict';import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';import {mkdtemp,rm,writeFile} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path'; -test('clean packaged MCP lists tools and completes a sandbox workflow',async()=>{const dir=await mkdtemp(join(tmpdir(),'wf-package-'));await writeFile(join(dir,'settings.json'),JSON.stringify({workspace:dir,dataDir:dir}));const client=new Client({name:'test',version:'1'});const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--settings',join(dir,'settings.json')],stderr:'pipe'});let stderr='';transport.stderr?.on('data',c=>stderr+=c);try{await client.connect(transport);const tools=await client.listTools();assert.equal(tools.tools.length,11);assert.equal(tools.tools.find(t=>t.name==='workflow_start').inputSchema.properties.maxSteps.maximum,1000);const dashboard=await client.callTool({name:'workflow_dashboard',arguments:{}});assert.match(dashboard.content[0].text,/127\.0\.0\.1/);const start=await client.callTool({name:'workflow_start',arguments:{requestId:'package',name:'Packaged',executor:'demo',maxSteps:160,stepTimeoutMs:1800000,script:'return await ctx.agent({id:"a",prompt:"p"});'}});assert.ok(!start.isError,start.content?.[0]?.text);const started=JSON.parse(start.content[0].text);assert.equal(started.maxSteps,160);assert.equal(started.stepTimeoutMs,1800000);const id=started.id;assert.equal(started.status,'pending_review');const u=new URL(JSON.parse(dashboard.content[0].text).url);const approved=await fetch(u.origin+`/api/runs/${id}/approve`,{method:'POST',headers:{'X-Workflow-Client':'1','Content-Type':'application/json'},body:JSON.stringify({revision:started.revision})});assert.equal(approved.status,200);let status;for(let i=0;i<15;i++){const r=await client.callTool({name:'workflow_wait',arguments:{runId:id,timeoutMs:500}});status=JSON.parse(r.content[0].text).status;if(status==='succeeded')break;await new Promise(r=>setTimeout(r,100));}assert.equal(status,'succeeded',stderr);const detail=JSON.parse((await client.callTool({name:'workflow_results',arguments:{runId:id,includeDefinition:true}})).content[0].text);const repair=await client.callTool({name:'workflow_repair',arguments:{runId:id,sourceUpdatedAt:detail.updatedAt,requestId:'package-repair',reason:'Correct synthesis',script:detail.definition.script,reuseStepIds:['a']}});assert.ok(!repair.isError,repair.content?.[0]?.text);assert.equal(JSON.parse(repair.content[0].text).status,'pending_review');}finally{await client.close();await transport.close();await stopService(dir);await rm(dir,{recursive:true,force:true});}}); +test('clean packaged MCP lists tools and completes a sandbox workflow',async()=>{const dir=await mkdtemp(join(tmpdir(),'wf-package-'));await writeFile(join(dir,'settings.json'),JSON.stringify({workspace:dir,dataDir:dir}));const client=new Client({name:'test',version:'1'});const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--settings',join(dir,'settings.json')],stderr:'pipe'});let stderr='';transport.stderr?.on('data',c=>stderr+=c);try{await client.connect(transport);const tools=await client.listTools();assert.equal(tools.tools.length,14);assert.equal(tools.tools.find(t=>t.name==='workflow_start').inputSchema.properties.maxSteps.maximum,1000);const dashboard=await client.callTool({name:'workflow_dashboard',arguments:{}});assert.match(dashboard.content[0].text,/127\.0\.0\.1/);const start=await client.callTool({name:'workflow_start',arguments:{requestId:'package',name:'Packaged',executor:'demo',maxSteps:160,stepTimeoutMs:1800000,script:'return await ctx.agent({id:"a",prompt:"p"});'}});assert.ok(!start.isError,start.content?.[0]?.text);const started=JSON.parse(start.content[0].text);assert.equal(started.maxSteps,160);assert.equal(started.stepTimeoutMs,1800000);const id=started.id;assert.equal(started.status,'pending_review');const u=new URL(JSON.parse(dashboard.content[0].text).url);const approved=await fetch(u.origin+`/api/runs/${id}/approve`,{method:'POST',headers:{'X-Workflow-Client':'1','Content-Type':'application/json'},body:JSON.stringify({revision:started.revision})});assert.equal(approved.status,200);let status;for(let i=0;i<15;i++){const r=await client.callTool({name:'workflow_wait',arguments:{runId:id,timeoutMs:500}});status=JSON.parse(r.content[0].text).status;if(status==='succeeded')break;await new Promise(r=>setTimeout(r,100));}assert.equal(status,'succeeded',stderr);const detail=JSON.parse((await client.callTool({name:'workflow_results',arguments:{runId:id,includeDefinition:true}})).content[0].text);const repair=await client.callTool({name:'workflow_repair',arguments:{runId:id,sourceUpdatedAt:detail.updatedAt,requestId:'package-repair',reason:'Correct synthesis',script:detail.definition.script,reuseStepIds:['a']}});assert.ok(!repair.isError,repair.content?.[0]?.text);assert.equal(JSON.parse(repair.content[0].text).status,'pending_review');}finally{await client.close();await transport.close();await stopService(dir);await rm(dir,{recursive:true,force:true});}}); test('second MCP connection proxies the existing owner, child workers expose no tools',async()=>{ const dir=await mkdtemp(join(tmpdir(),'wf-proxy-'));const clients=[],transports=[]; const connect=async(env)=>{const c=new Client({name:'proxy-test',version:'1'});const t=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--data-dir',dir,'--workspace',dir],stderr:'pipe',...(env?{env}:{})});clients.push(c);transports.push(t);await c.connect(t);return c;}; - try{const owner=await connect();const proxy=await connect();const a=await owner.callTool({name:'workflow_dashboard',arguments:{}}),b=await proxy.callTool({name:'workflow_dashboard',arguments:{}});assert.deepEqual(a,b);await proxy.close();assert.equal((await owner.listTools()).tools.length,11);const worker=await connect({...process.env,MCODE_WORKFLOW_CHILD:'1'});assert.equal((await worker.listTools()).tools.length,0);}finally{for(const c of clients.reverse())await c.close();for(const t of transports)await t.close();await stopService(dir);await rm(dir,{recursive:true,force:true});} + try{const owner=await connect();const proxy=await connect();const a=await owner.callTool({name:'workflow_dashboard',arguments:{}}),b=await proxy.callTool({name:'workflow_dashboard',arguments:{}});assert.deepEqual(a,b);await proxy.close();assert.equal((await owner.listTools()).tools.length,14);const worker=await connect({...process.env,MCODE_WORKFLOW_CHILD:'1'});assert.equal((await worker.listTools()).tools.length,0);}finally{for(const c of clients.reverse())await c.close();for(const t of transports)await t.close();await stopService(dir);await rm(dir,{recursive:true,force:true});} }); async function stopService(dir){const {execFile}=await import('node:child_process');const {promisify}=await import('node:util');await promisify(execFile)(process.execPath,[resolve('dist/main.mjs'),'--stop-service','--data-dir',dir,'--workspace',dir]);} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/rerun-lineage.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/rerun-lineage.check.mjs new file mode 100644 index 00000000..3f0f761e --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/rerun-lineage.check.mjs @@ -0,0 +1,179 @@ +// Run-lifecycle rerun suite: rerun lineage families. A rerun starts a new +// pending_review run from the source's script+input, never modifies the +// source, and joins a queryable family (rerunOf/lineageRoot/rerunSeq) that +// survives trash and archive rotation. Real store, real engine, real HTTP +// server; every assertion runs against persisted SQLite state. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {randomUUID} from 'node:crypto'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +import {startHTTP} from '../src/http.mjs'; +import {createToolHandler,TOOLS} from '../src/tools.mjs'; +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-rerun-'));const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute});return {dir,store,engine,cleanup:async()=>{await engine.close();store.close();await rm(dir,{recursive:true,force:true});}};} +async function finish(engine,id){for(let i=0;i<300;i++){if(!engine.active.has(id))return engine.snapshot(id);await delay(20);}throw Error('timeout');} +async function run(engine,script,input={},opts={}){const r=await engine.start({requestId:randomUUID(),name:'Rerun suite',executor:'demo',script,input,...opts});await engine.approve(r.id,{revision:1});return finish(engine,r.id);} +async function rerun(engine,id,opts={}){const draft=await engine.rerun(id,opts);await engine.approve(draft.id,{revision:1});return finish(engine,draft.id);} +const headers={'X-Workflow-Client':'1','Content-Type':'application/json'}; +const script='const a=await ctx.agent({id:"a",prompt:"a"});return {a:a.output};'; +const probe='return await ctx.agent({id:"a",prompt:"a"});'; +const rawBody=(store,id)=>store.db.prepare('SELECT body FROM runs WHERE id=?').get(id).body; + +test('one rerun leaves the source byte-identical and the child passes the review gate',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const source=await run(f.engine,script); + assert.equal(source.status,'succeeded');assert.deepEqual(source.result,{a:'a'}); + const before=rawBody(f.store,source.id),beforeSteps=f.store.steps(source.id).map(s=>JSON.stringify(s)),beforeEvents=f.store.events(source.id).map(e=>e.seq); + // The rerun action only creates a pending_review draft — never auto-executes. + const draft=await f.engine.rerun(source.id); + assert.equal(draft.status,'pending_review');assert.notEqual(draft.id,source.id); + assert.equal(draft.rerunOf,source.id);assert.equal(draft.lineageRoot,source.id);assert.equal(draft.rerunSeq,1); + assert.equal(draft.requestId,`${source.requestId}#rerun-1`); + assert.ok(f.store.list().some(r=>r.id===source.id)&&f.store.list().some(r=>r.id===draft.id),'both runs coexist'); + await f.engine.approve(draft.id,{revision:1}); + const child=await finish(f.engine,draft.id); + assert.equal(child.status,'succeeded');assert.deepEqual(child.result,{a:'a'}); + // The source is untouched down to the stored bytes: no body write, no step + // write, no event append, and the events chain stays valid. + assert.equal(rawBody(f.store,source.id),before); + assert.deepEqual(f.store.steps(source.id).map(s=>JSON.stringify(s)),beforeSteps); + assert.deepEqual(f.store.events(source.id).map(e=>e.seq),beforeEvents); + assert.equal(f.store.verifyIntegrity().events.verified,true); + }finally{await f.cleanup();} +}); + +test('three reruns grow one ordered family of four with unique requestIds',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const root=await run(f.engine,script,{topic:'original'}); + const c1=await rerun(f.engine,root.id); + const c2=await rerun(f.engine,c1.id,{input:{topic:'second'}}); + const c3=await rerun(f.engine,root.id); + assert.equal(c1.rerunOf,root.id);assert.equal(c1.lineageRoot,root.id);assert.equal(c1.rerunSeq,1); + assert.equal(c2.rerunOf,c1.id);assert.equal(c2.lineageRoot,root.id);assert.equal(c2.rerunSeq,2); + assert.deepEqual(c2.input,{topic:'second'});assert.deepEqual(root.input,{topic:'original'},'input override touches only the child'); + assert.equal(c3.rerunOf,root.id);assert.equal(c3.rerunSeq,3); + const family=f.engine.lineage(root.id); + assert.equal(family.lineageRoot,root.id); + assert.deepEqual(family.members.map(m=>[m.id,m.rerunSeq]),[[root.id,0],[c1.id,1],[c2.id,2],[c3.id,3]],'root first, members by seq'); + assert.equal(new Set(family.members.map(m=>m.requestId)).size,4,'no requestId ever collides'); + assert.ok(family.members.every(m=>m.status==='succeeded')); + }finally{await f.cleanup();} +}); + +test('rerun defaults to real execution; only the explicit flag adopts across runs',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const source=await run(f.engine,probe); + const fresh=await rerun(f.engine,source.id); + assert.deepEqual(calls,['a','a'],'the default rerun dispatches a real call'); + assert.equal(fresh.attempts,1);assert.ok(fresh.steps.every(s=>!s.reusedFrom)); + const adopted=await rerun(f.engine,source.id,{reuseAcrossRuns:true}); + assert.deepEqual(calls,['a','a'],'the explicit flag adds no new call'); + assert.equal(adopted.attempts,0); + const step=adopted.steps.find(s=>s.id==='a'); + assert.equal(step.reusedFrom.runId,fresh.id,'adoption picks the newest succeeded candidate'); + assert.equal(step.reusedFrom.crossRun,true); + }finally{await f.cleanup();} +}); + +test('trash and rotation never break the family; only live sources may rerun',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const root=await run(f.engine,script); + const child=await rerun(f.engine,root.id); + // Tombstoned members stay in the family, annotated, and cannot rerun. + await f.engine.deleteRun(child.id,{by:'studio'}); + await assert.rejects(f.engine.rerun(child.id),/回收站/); + let family=f.engine.lineage(root.id); + const trashed=family.members.find(m=>m.id===child.id); + assert.equal(family.members.length,2); + assert.equal(trashed.deleted.deletedBy,'studio');assert.equal(typeof trashed.deleted.purgeAfter,'number'); + assert.equal(trashed.archived,null); + // Rotation moves the member into the archive; the family stays complete and + // the tombstone remains visible on the archived copy (honest history). + f.engine.configureTrash({trashRetentionDays:0}); + const rotation=f.engine.rotateArchive(); + assert.ok(rotation.rotated);assert.deepEqual(rotation.runs,[child.id]); + await assert.rejects(f.engine.rerun(child.id),/归档/); + family=f.engine.lineage(root.id); + const archivedMember=family.members.find(m=>m.id===child.id); + assert.equal(archivedMember.archived.rotationId,rotation.rotationId); + assert.ok(archivedMember.deleted,'the tombstone stays readable on the archived copy'); + // Sequence numbering counts archived members, so the next rerun never + // reuses a seq (and its synthesized requestId skips the freed slot). + const next=await f.engine.rerun(root.id); + assert.equal(next.rerunSeq,2);assert.equal(next.requestId,`${root.requestId}#rerun-2`); + await f.engine.approve(next.id,{revision:1});await finish(f.engine,next.id); + // Restoring the archived member brings it back live, unannotated. + await f.engine.restoreRun(child.id,{by:'cli'}); + family=f.engine.lineage(root.id); + const restored=family.members.find(m=>m.id===child.id); + assert.equal(restored.archived,null);assert.equal(restored.deleted,null);assert.equal(restored.status,'succeeded'); + assert.deepEqual(family.members.map(m=>m.rerunSeq),[0,1,2]); + assert.equal(f.store.verifyIntegrity().events.verified,true,'the events chain stays valid across rerun, trash and rotation'); + }finally{await f.cleanup();} +}); + +test('a synthesized requestId that collides retries with a numeric suffix',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const draft=await f.engine.start({requestId:'collide-root',name:'Rerun suite',executor:'demo',script:probe,input:{}}); + await f.engine.approve(draft.id,{revision:1});const source=await finish(f.engine,draft.id); + await f.engine.start({requestId:'collide-root#rerun-1',name:'unrelated',executor:'demo',script:'return 1;',input:{}}); + const child=await f.engine.rerun(source.id); + assert.equal(child.requestId,'collide-root#rerun-1-2'); + assert.equal(child.rerunSeq,1); + assert.equal(f.engine.lineage(source.id).members.length,2,'the unrelated claimant is not family'); + }finally{await f.cleanup();} +}); + +test('HTTP exposes the lineage family face with summaries, trash annotation and opt-in full results',{timeout:10000},async()=>{ + const f=await fixture(async s=>({output:s.id}));const panel=await startHTTP(f.engine);try{ + const origin=new URL(panel.url).origin; + const root=await run(f.engine,script); + const child=await rerun(f.engine,root.id); + await f.engine.deleteRun(child.id,{by:'studio'}); + const missing=await fetch(`${origin}/api/runs/${randomUUID()}/lineage`,{headers}); + assert.equal(missing.status,400);assert.match((await missing.json()).error,/不存在/); + // Resolvable from any member id — here the tombstoned child. + const family=await(await fetch(`${origin}/api/runs/${child.id}/lineage`,{headers})).json(); + assert.equal(family.lineageRoot,root.id); + assert.deepEqual(family.members.map(m=>m.rerunSeq),[0,1]); + const [r,c]=family.members; + for(const key of ['id','requestId','name','rerunOf','rerunSeq','status','executor','createdAt','updatedAt','startedAt','finishedAt','durationMs','resultPreview','deleted','archived'])assert.ok(key in r,`member carries ${key}`); + assert.equal(r.rerunOf,null);assert.equal(r.deleted,null);assert.equal(r.archived,null); + assert.equal(c.deleted.deletedBy,'studio');assert.equal(c.archived,null); + assert.ok(r.resultPreview.includes('a'));assert.ok(typeof r.durationMs==='number'&&r.durationMs>=0); + assert.ok(!('result' in r),'full results are opt-in'); + // ?results=1 feeds the read-only compare face. + const withResults=await(await fetch(`${origin}/api/runs/${root.id}/lineage?results=1`,{headers})).json(); + assert.deepEqual(withResults.members.find(m=>m.id===root.id).result,{a:'a'}); + assert.deepEqual(withResults.members.find(m=>m.id===child.id).result,{a:'a'}); + }finally{await f.engine.close();await panel.close();await f.cleanup();} +}); + +test('MCP exposes workflow_rerun with a strict schema, honest errors and no auto-execution',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const handler=createToolHandler(f.engine,()=>'http://127.0.0.1:1/'); + const tool=TOOLS.find(t=>t.name==='workflow_rerun'); + assert.ok(tool,'workflow_rerun missing from TOOLS'); + assert.equal(tool.inputSchema.additionalProperties,false); + assert.deepEqual(tool.inputSchema.required,['runId']); + assert.equal(tool.inputSchema.properties.input.type,'object'); + assert.equal(tool.inputSchema.properties.reuseAcrossRuns.type,'boolean'); + assert.equal(typeof tool.inputSchema.properties.reuseAcrossRuns.description,'string'); + const source=await run(f.engine,script); + const draft=await handler('workflow_rerun',{runId:source.id}); + assert.equal(draft.status,'pending_review'); + assert.equal(draft.rerunOf,source.id);assert.equal(draft.lineageRoot,source.id);assert.equal(draft.rerunSeq,1); + assert.equal(draft.script,undefined);assert.equal(draft.result,undefined); + await f.engine.approve(draft.id,{revision:1}); + assert.equal((await finish(f.engine,draft.id)).status,'succeeded'); + await f.engine.deleteRun(source.id); + await assert.rejects(handler('workflow_rerun',{runId:source.id}),/回收站/); + await assert.rejects(handler('workflow_rerun',{runId:randomUUID()}),/不存在/); + await assert.rejects(handler('workflow_rerun',{runId:source.id,reuseAcrossRuns:'yes'}),/布尔/); + await assert.rejects(handler('workflow_rerun',{runId:source.id,input:[1]}),/input/); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/trash.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/trash.check.mjs new file mode 100644 index 00000000..937286d4 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/trash.check.mjs @@ -0,0 +1,163 @@ +// Run-lifecycle trash suite: tombstone soft delete, full query-face filtering, +// restore, retention, and the HTTP/MCP exposure. Real store, real engine, real +// HTTP server; every assertion runs against persisted SQLite state. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {randomUUID} from 'node:crypto'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +import {startHTTP} from '../src/http.mjs'; +import {createToolHandler,TOOLS} from '../src/tools.mjs'; +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-trash-'));const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute});return {dir,store,engine,cleanup:async()=>{await engine.close();store.close();await rm(dir,{recursive:true,force:true});}};} +async function finish(engine,id){for(let i=0;i<300;i++){if(!engine.active.has(id))return engine.snapshot(id);await delay(20);}throw Error('timeout');} +async function run(engine,script,input={},opts={}){const r=await engine.start({requestId:randomUUID(),name:'Trash suite',executor:'demo',script,input,...opts});await engine.approve(r.id,{revision:1});return finish(engine,r.id);} +const headers={'X-Workflow-Client':'1','Content-Type':'application/json'}; +const script='const a=await ctx.agent({id:"a",prompt:"a"});return {a:a.output};'; + +test('delete hides the run on every query face; restore brings back steps, events, result and audit verbatim',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,script); + assert.equal(end.status,'succeeded');assert.deepEqual(end.result,{a:'a'}); + const eventsBefore=f.store.events(end.id).map(e=>e.seq); + const deleted=await f.engine.deleteRun(end.id,{by:'studio'}); + assert.equal(deleted.deleted,true);assert.equal(deleted.alreadyDeleted,false); + assert.equal(deleted.purgeAfter,deleted.deletedAt+30*86400000,'default retention stamps purgeAfter=deletedAt+30d'); + // Every read face skips the tombstone; the stored rows themselves never leave. + assert.ok(!f.store.list().some(r=>r.id===end.id)); + assert.ok(f.store.listTrash().some(r=>r.id===end.id)); + assert.throws(()=>f.engine.snapshot(end.id),/回收站/); + assert.equal(f.store.step(end.id,'a').status,'succeeded'); + assert.equal(f.store.get(end.id).result.a,'a'); + // Restore: tombstone cleared, everything else untouched, one audit event each. + const restored=await f.engine.restoreRun(end.id,{by:'studio'}); + assert.equal(restored.id,end.id);assert.equal(restored.status,'succeeded');assert.deepEqual(restored.result,{a:'a'}); + assert.equal(restored.steps[0].id,'a');assert.equal(restored.deletedAt,undefined); + const events=f.store.events(end.id); + assert.deepEqual(events.slice(0,eventsBefore.length).map(e=>e.seq),eventsBefore,'earlier events are untouched'); + assert.deepEqual(events.slice(eventsBefore.length).map(e=>e.type),['run.deleted','run.restored'],'audit events append in order'); + assert.equal(events.find(e=>e.type==='run.deleted').by,'studio'); + assert.equal(f.store.verifyIntegrity().events.verified,true,'events chain stays valid across delete and restore'); + assert.ok(f.store.list().some(r=>r.id===end.id)); + assert.ok(!f.store.listTrash().some(r=>r.id===end.id)); + }finally{await f.cleanup();} +}); + +test('running and pending-review runs refuse deletion; a cancelled run deletes fine',async()=>{ + const f=await fixture(async(s,{signal})=>{await delay(3000,undefined,{signal});return {output:null};});try{ + const draft=await f.engine.start({requestId:randomUUID(),name:'draft',executor:'demo',script:'return 1;',input:{}}); + await assert.rejects(f.engine.deleteRun(draft.id),/仅已完成/); + const active=await f.engine.start({requestId:randomUUID(),name:'active',executor:'demo',script,input:{}}); + await f.engine.approve(active.id,{revision:1}); + await assert.rejects(f.engine.deleteRun(active.id),/仍在运行/); + await f.engine.stop(active.id,'cancelled'); + assert.equal(f.engine.snapshot(active.id).status,'cancelled'); + assert.equal((await f.engine.deleteRun(active.id)).deleted,true); + }finally{await f.cleanup();} +}); + +test('repeated delete is idempotent and never appends a second audit event',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,script); + const first=await f.engine.deleteRun(end.id); + const second=await f.engine.deleteRun(end.id); + assert.equal(second.deleted,true);assert.equal(second.alreadyDeleted,true); + assert.equal(second.deletedAt,first.deletedAt); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.deleted').length,1); + }finally{await f.cleanup();} +}); + +test('cross-run reuse never adopts from a tombstoned run and the restored run is a candidate again',async()=>{ + const calls=[],f=await fixture(async s=>{calls.push(s.id);return {output:s.id};});try{ + const probe='return await ctx.agent({id:"a",prompt:"a"});'; + const source=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + await f.engine.deleteRun(source.id); + const afterDelete=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + assert.deepEqual(calls,['a','a'],'the trashed source was the only candidate; the run must call fresh'); + assert.ok(afterDelete.steps.every(s=>!s.reusedFrom)); + await f.engine.restoreRun(source.id); + // Tombstone the newer run so the restored source is the only live candidate. + await f.engine.deleteRun(afterDelete.id); + const afterRestore=await run(f.engine,probe,{},{reuseAcrossRuns:true}); + assert.equal(afterRestore.steps.find(s=>s.id==='a').reusedFrom.runId,source.id,'restored run serves as a reuse candidate again'); + assert.deepEqual(calls,['a','a']); + }finally{await f.cleanup();} +}); + +test('repair, resume and requestId replay all refuse tombstoned runs (no ghost data)',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const broken='const a=await ctx.agent({id:"a",prompt:"a"});throw Error("boom");'; + const failed=await run(f.engine,broken); + await f.engine.deleteRun(failed.id); + await assert.rejects(f.engine.repair(failed.id,{requestId:randomUUID(),sourceUpdatedAt:f.store.get(failed.id).updatedAt,script:'return 1;',reason:'fix'}),/回收站/); + await assert.rejects(f.engine.resume(failed.id),/回收站/); + await assert.rejects(f.engine.start({requestId:failed.requestId,name:'replay',executor:'demo',script:'return 1;',input:{}}),/回收站/); + }finally{await f.cleanup();} +}); + +test('retention is configurable, restamps existing tombstones, and validates its range',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + assert.equal(f.engine.trashRetentionDays(),30,'default retention is 30 days'); + const end=await run(f.engine,script); + await f.engine.deleteRun(end.id); + const stamped=f.store.get(end.id); + assert.equal(stamped.purgeAfter,stamped.deletedAt+30*86400000); + assert.equal(f.engine.configureTrash({trashRetentionDays:7}).trashRetentionDays,7); + assert.equal(f.engine.trashRetentionDays(),7); + assert.equal(f.store.get(end.id).purgeAfter,stamped.deletedAt+7*86400000,'existing tombstones are restamped to the new clock'); + assert.equal(f.engine.configureTrash({trashRetentionDays:0}).trashRetentionDays,0); + assert.equal(f.store.get(end.id).purgeAfter,stamped.deletedAt,'0 disables expiry: purgeAfter collapses onto deletedAt'); + for(const bad of [-1,1.5,'7',3660,null])await assert.rejects(async()=>f.engine.configureTrash({trashRetentionDays:bad}),/整数/); + }finally{await f.cleanup();} +}); + +test('HTTP exposes DELETE, trash listing, restore and retention settings',{timeout:10000},async()=>{ + const f=await fixture(async s=>({output:s.id}));const panel=await startHTTP(f.engine);try{ + const end=await run(f.engine,script); + const origin=new URL(panel.url).origin; + const del=await fetch(`${origin}/api/runs/${end.id}`,{method:'DELETE',headers}); + assert.equal(del.status,200);assert.equal((await del.json()).deleted,true); + const list=await(await fetch(`${origin}/api/runs`,{headers})).json(); + assert.ok(!list.some(r=>r.id===end.id)); + const trash=await(await fetch(`${origin}/api/runs?trash=1`,{headers})).json(); + assert.deepEqual(trash.map(r=>r.id),[end.id]); + assert.equal(trash[0].script,undefined);assert.equal(trash[0].result,undefined); + assert.equal(typeof trash[0].deletedAt,'number');assert.equal(typeof trash[0].purgeAfter,'number'); + const detail=await fetch(`${origin}/api/runs/${end.id}`,{headers}); + assert.equal(detail.status,400);assert.match((await detail.json()).error,/回收站/); + assert.equal((await(await fetch(`${origin}/api/trash`,{headers})).json()).trashRetentionDays,30); + const saved=await fetch(`${origin}/api/trash`,{method:'POST',headers,body:JSON.stringify({trashRetentionDays:14})}); + assert.equal(saved.status,200);assert.equal((await saved.json()).trashRetentionDays,14); + const restore=await fetch(`${origin}/api/runs/${end.id}/restore`,{method:'POST',headers,body:JSON.stringify({by:'studio'})}); + assert.equal(restore.status,200);assert.equal((await restore.json()).status,'succeeded'); + assert.ok((await(await fetch(`${origin}/api/runs`,{headers})).json()).some(r=>r.id===end.id)); + }finally{await f.engine.close();await panel.close();await f.cleanup();} +}); + +test('MCP exposes workflow_delete and workflow_restore with strict schemas and honest errors',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const handler=createToolHandler(f.engine,()=>'http://127.0.0.1:1/'); + for(const name of ['workflow_delete','workflow_restore']){ + const tool=TOOLS.find(t=>t.name===name); + assert.ok(tool,`${name} missing from TOOLS`); + assert.equal(tool.inputSchema.additionalProperties,false); + assert.equal(tool.inputSchema.properties.runId.type,'string'); + assert.deepEqual(tool.inputSchema.required,['runId']); + } + const end=await run(f.engine,script); + const deleted=await handler('workflow_delete',{runId:end.id}); + assert.equal(deleted.deleted,true);assert.equal(deleted.alreadyDeleted,false); + assert.equal((await handler('workflow_delete',{runId:end.id})).alreadyDeleted,true); + const statusList=await handler('workflow_status',{}); + assert.ok(Array.isArray(statusList));assert.ok(!statusList.some(r=>r.id===end.id)); + await assert.rejects(handler('workflow_status',{runId:end.id}),/回收站/); + const restored=await handler('workflow_restore',{runId:end.id}); + assert.equal(restored.status,'succeeded');assert.deepEqual(restored.result,{a:'a'}); + await assert.rejects(handler('workflow_restore',{runId:end.id}),/无需恢复/); + await assert.rejects(handler('workflow_delete',{runId:randomUUID()}),/不存在/); + await assert.rejects(handler('workflow_delete',{runId:end.id,by:'nonsense'}),/来源/); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/workspace-router.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/workspace-router.check.mjs index 72d26d8f..e4ba4cdc 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/workspace-router.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/workspace-router.check.mjs @@ -33,7 +33,7 @@ test('packaged MCP launched in plugin root routes concurrent projects and execut async function connect(){const client=new Client({name:'project-test',version:'1'});clients.push(client);await client.connect(new StdioClientTransport({command:process.execPath,args:[binary,'--stdio','--data-dir',dataRoot,'--mcode-script',fake],cwd:pluginRoot,stderr:'pipe'}));return client;} try{ const a=await connect();const tools=(await a.listTools()).tools; - assert.equal(tools.length,11);for(const tool of tools.filter(t=>t.name!=='workflow_validate'))assert.ok(tool.inputSchema.required.includes('workspace')); + assert.equal(tools.length,14);for(const tool of tools.filter(t=>t.name!=='workflow_validate'))assert.ok(tool.inputSchema.required.includes('workspace')); await value(a,'workflow_validate',{script:'return 1;'}); assert.equal((await a.callTool({name:'workflow_dashboard',arguments:{}})).isError,true); await assert.rejects(readdir(dataRoot),{code:'ENOENT'}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 0b42f464..bc88a161 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7712,218 +7712,12 @@ import { spawn as spawn3 } from "node:child_process"; // src/store.mjs import { DatabaseSync } from "node:sqlite"; -import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; +import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { createHash, randomUUID } from "node:crypto"; -var Store = class { - constructor(dir) { - mkdirSync(dir, { recursive: true, mode: 448 }); - this.lock = join(dir, "owner.lock"); - try { - this.fd = openSync(this.lock, "wx", 384); - } catch (e) { - if (e.code !== "EEXIST") throw e; - let pid; - try { - pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; - } catch { - throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); - } - let alive2 = true; - try { - process.kill(pid, 0); - } catch (err) { - if (err.code === "ESRCH") alive2 = false; - } - if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); - unlinkSync(this.lock); - this.fd = openSync(this.lock, "wx", 384); - } - this.owner = randomUUID(); - this.txDepth = 0; - try { - writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); - this.db = new DatabaseSync(join(dir, "workflows.sqlite")); - this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); - this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; - CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); - CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); - const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); - for (const row of unfinished) { - const run = JSON.parse(row.body); - run.status = "needs_attention"; - run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; - this.save(run); - } - } catch (error2) { - this.db?.close(); - this.releaseLock(); - throw error2; - } - } - transaction(fn) { - if (this.txDepth) return fn(); - this.txDepth = 1; - this.db.exec("BEGIN IMMEDIATE"); - try { - const r = fn(); - this.db.exec("COMMIT"); - return r; - } catch (e) { - this.db.exec("ROLLBACK"); - throw e; - } finally { - this.txDepth = 0; - } - } - templates() { - return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); - } - template(id2) { - const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - saveTemplate(value) { - this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); - } - deleteTemplate(id2) { - return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; - } - setting(key) { - const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); - return row ? JSON.parse(row.body) : void 0; - } - saveSetting(key, value) { - this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); - } - save(run) { - this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); - } - get(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - byRequest(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - list() { - return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); - } - step(runId, id2) { - const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - steps(runId) { - return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); - } - // All match keys (contextHash, lineageHash) are stamped on the step body at - // creation, so filtering happens in SQL and LIMIT applies after the full match. - // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is - // an opt-in feature and older steps are not candidates. - findCrossRunReuse({ contextHash, requestHash, lineageHash, excludeRunId, limit = 20 }) { - return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId, requestHash, contextHash, lineageHash, limit).map((r) => { - const step = JSON.parse(r.stepBody); - return { runId: r.runId, stepId: step.id, step }; - }); - } - saveStep(runId, step) { - this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); - } - repairCandidate(runId, id2) { - const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - saveRepairCandidate(runId, step) { - this.transaction(() => { - const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); - this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); - }); - } - event(runId, type, data2 = {}) { - const event = { ...data2, type, time: Date.now() }; - return this.transaction(() => { - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - this.chainAdvance("event", "events", "SELECT seq AS pos,runId,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => `${r.runId}:${r.pos}`); - return { seq, ...event }; - }); - } - events(runId, after = 0, limit = 150) { - return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); - } - rowHash(prev, kind, key, body) { - return createHash("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); - } - // Bulk adoption of pre-existing rows is an initial-creation behavior only: it - // anchors whatever the table held when the chain first appears. Once a head - // exists, each write anchors ONLY its own new position — rows injected into the - // range between the head and a later write stay unanchored and verification - // keeps failing closed on them instead of silently legitimizing them. - chainAdvance(kind, surface, sql, newUpto, keyOf) { - const tail = this.setting(`integrity_${surface}`); - let prev = tail?.head ?? "0".repeat(64); - const range = tail ? `SELECT * FROM (${sql}) WHERE pos=${newUpto}` : sql; - for (const r of this.db.prepare(range).all(tail?.upto ?? 0, newUpto)) { - const k = keyOf(r); - prev = this.rowHash(prev, kind, k, r.body); - this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); - } - this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); - } - integrityHeads() { - return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; - } - verifyIntegrity() { - const genesis = "0".repeat(64); - const face = (kind, surface, table, posCol, rowSql, keyOf) => { - const skey = `integrity_${surface}`; - const rec = this.setting(skey); - const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); - if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; - const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); - const unchained = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n); - let prev = genesis, firstDivergence = null; - for (const r of rows) { - const row = this.db.prepare(rowSql).get(r.pos); - const key = row ? keyOf(row, r.pos) : null; - const actual = row ? this.rowHash(prev, kind, key, row.body) : null; - if (!firstDivergence && (!row || key !== r.key || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; - prev = r.hash; - } - if (!firstDivergence) { - const anchored = new Set(rows.map((r) => r.pos)); - const gap = this.db.prepare(`SELECT ${posCol} AS __pos, * FROM ${table} WHERE ${posCol}<=? ORDER BY ${posCol}`).all(rec.upto).find((r) => !anchored.has(r.__pos)); - if (gap) firstDivergence = { key: keyOf(gap, gap.__pos), expectedHead: null, actualHead: null }; - } - const verified = !firstDivergence && prev === rec.head && unchained === 0; - return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained, firstDivergence }; - }; - return { - events: face("event", "events", "events", "seq", "SELECT runId,body FROM events WHERE seq=?", (row, pos) => `${row.runId}:${pos}`), - repair: face("repair", "repair", "repair_cache", "rowid", "SELECT runId,id,body FROM repair_cache WHERE rowid=?", (row) => `${row.runId}/${row.id}`) - }; - } - releaseLock() { - closeSync(this.fd); - try { - if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); - } catch { - } - } - close() { - this.db.close(); - this.releaseLock(); - } -}; +import { createHash as createHash2, randomUUID } from "node:crypto"; // src/common.mjs -import { createHash as createHash2 } from "node:crypto"; +import { createHash } from "node:crypto"; // node_modules/acorn/dist/acorn.mjs var astralIdentifierCodes = [509, 0, 227, 0, 150, 4, 294, 9, 1368, 2, 2, 1, 6, 3, 41, 2, 5, 0, 166, 1, 574, 3, 9, 9, 7, 9, 32, 4, 318, 1, 78, 5, 71, 10, 50, 3, 123, 2, 54, 14, 32, 10, 3, 1, 11, 3, 46, 10, 8, 0, 46, 9, 7, 2, 37, 13, 2, 9, 6, 1, 45, 0, 13, 2, 49, 13, 9, 3, 2, 11, 83, 11, 7, 0, 3, 0, 158, 11, 6, 9, 7, 3, 56, 1, 2, 6, 3, 1, 3, 2, 10, 0, 11, 1, 3, 6, 4, 4, 68, 8, 2, 0, 3, 0, 2, 3, 2, 4, 2, 0, 15, 1, 83, 17, 10, 9, 5, 0, 82, 19, 13, 9, 214, 6, 3, 8, 28, 1, 83, 16, 16, 9, 82, 12, 9, 9, 7, 19, 58, 14, 5, 9, 243, 14, 166, 9, 71, 5, 2, 1, 3, 3, 2, 0, 2, 1, 13, 9, 120, 6, 3, 6, 4, 0, 29, 9, 41, 6, 2, 3, 9, 0, 10, 10, 47, 15, 199, 7, 137, 9, 54, 7, 2, 7, 17, 9, 57, 21, 2, 13, 123, 5, 4, 0, 2, 1, 2, 6, 2, 0, 9, 9, 49, 4, 2, 1, 2, 4, 9, 9, 55, 9, 266, 3, 10, 1, 2, 0, 49, 6, 4, 4, 14, 10, 5350, 0, 7, 14, 11465, 27, 2343, 9, 87, 9, 39, 4, 60, 6, 26, 9, 535, 9, 470, 0, 2, 54, 8, 3, 82, 0, 12, 1, 19628, 1, 4178, 9, 519, 45, 3, 22, 543, 4, 4, 5, 9, 7, 3, 6, 31, 3, 149, 2, 1418, 49, 513, 54, 5, 49, 9, 0, 15, 0, 23, 4, 2, 14, 1361, 6, 2, 16, 3, 6, 2, 1, 2, 4, 101, 0, 161, 6, 10, 9, 357, 0, 62, 13, 499, 13, 245, 1, 2, 9, 233, 0, 3, 0, 8, 1, 6, 0, 475, 6, 110, 6, 6, 9, 4759, 9, 787719, 239]; @@ -13623,7 +13417,7 @@ function parse3(input, options) { } // src/common.mjs -var hash = (value) => createHash2("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); +var hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); function stable(value) { return JSON.stringify(canonical(value)); } @@ -13658,6 +13452,393 @@ ${script} return { valid: true, scriptHash: hash(script), dslVersion: 1 }; } +// src/store.mjs +var ROTATE_TOMBSTONE_THRESHOLD = 500; +var ROTATE_RUNS_BYTES_THRESHOLD = 100 * 1024 * 1024; +function archiveManifestHash(entries) { + return hash(entries); +} +var Store = class { + constructor(dir) { + mkdirSync(dir, { recursive: true, mode: 448 }); + this.lock = join(dir, "owner.lock"); + try { + this.fd = openSync(this.lock, "wx", 384); + } catch (e) { + if (e.code !== "EEXIST") throw e; + let pid; + try { + pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; + } catch { + throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); + } + let alive2 = true; + try { + process.kill(pid, 0); + } catch (err) { + if (err.code === "ESRCH") alive2 = false; + } + if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); + unlinkSync(this.lock); + this.fd = openSync(this.lock, "wx", 384); + } + this.owner = randomUUID(); + this.txDepth = 0; + this.archivePath = join(dir, "archive.db"); + try { + writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); + this.db = new DatabaseSync(join(dir, "workflows.sqlite")); + this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); + this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); + CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); + CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); + const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); + for (const row of unfinished) { + const run = JSON.parse(row.body); + run.status = "needs_attention"; + run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; + this.save(run); + } + } catch (error2) { + this.db?.close(); + this.releaseLock(); + throw error2; + } + } + transaction(fn) { + if (this.txDepth) return fn(); + this.txDepth = 1; + this.db.exec("BEGIN IMMEDIATE"); + try { + const r = fn(); + this.db.exec("COMMIT"); + return r; + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } finally { + this.txDepth = 0; + } + } + templates() { + return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); + } + template(id2) { + const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + saveTemplate(value) { + this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); + } + deleteTemplate(id2) { + return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; + } + setting(key) { + const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); + return row ? JSON.parse(row.body) : void 0; + } + saveSetting(key, value) { + this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); + } + save(run) { + this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); + } + get(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + byRequest(id2) { + const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); + return r ? JSON.parse(r.body) : null; + } + // Tombstoned runs (deletedAt stamped) never appear in the live list; the + // trash listing below is their only index face. + list() { + return this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.deletedAt') IS NULL ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); + } + listTrash() { + return this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL ORDER BY json_extract(body,'$.deletedAt') DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); + } + restampTrashPurge(days) { + this.transaction(() => { + for (const row of this.db.prepare("SELECT id,body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL").all()) { + const run = JSON.parse(row.body); + this.db.prepare("UPDATE runs SET body=? WHERE id=?").run(JSON.stringify({ ...run, purgeAfter: run.deletedAt + days * 864e5 }), row.id); + } + }); + } + tombstoneCount() { + return Number(this.db.prepare("SELECT COUNT(*) AS n FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL").get().n); + } + // Lineage face: every family member of a root still in the live library. + // Tombstoned members are included on purpose — trash hides runs from the + // live listings but never breaks a family. + lineageMembers(root) { + return this.db.prepare("SELECT body FROM runs WHERE id=? OR json_extract(body,'$.lineageRoot')=? ORDER BY COALESCE(json_extract(body,'$.rerunSeq'),0),rowid").all(root, root).map((r) => JSON.parse(r.body)); + } + // Family members that exist only in the sidecar archive (rotated away, not + // yet restored): latest archive copy per runId, never including runs that + // are live again, so a restored member is never listed twice. + archivedLineageMembers(root) { + if (!existsSync(this.archivePath)) return []; + const live = new Set(this.db.prepare("SELECT id FROM runs").all().map((r) => r.id)); + const latest = /* @__PURE__ */ new Map(); + for (const row of this.archive().prepare("SELECT a.runId AS runId,a.rotationId AS rotationId,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? OR json_extract(a.body,'$.lineageRoot')=? ORDER BY r.rotatedAt,a.rotationId").all(root, root)) + if (!live.has(row.runId)) latest.set(row.runId, { run: JSON.parse(row.body), rotationId: row.rotationId }); + return [...latest.values()]; + } + // One archived run's latest copy, or null. Read-only; does not create the + // archive database just to answer negatively. + archivedRun(runId) { + if (!existsSync(this.archivePath)) return null; + const row = this.archive().prepare("SELECT a.rotationId AS rotationId,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1").get(runId); + return row ? { run: JSON.parse(row.body), rotationId: row.rotationId } : null; + } + // First run.started / last run.finished timestamps from the events ledger. + // Events never leave the live database (rotation only moves runs/steps), so + // archived family members keep resolving through this face. + runTimes(runId) { + return { + startedAt: this.db.prepare("SELECT json_extract(body,'$.time') AS t FROM events WHERE runId=? AND json_extract(body,'$.type')='run.started' ORDER BY seq LIMIT 1").get(runId)?.t ?? null, + finishedAt: this.db.prepare("SELECT json_extract(body,'$.time') AS t FROM events WHERE runId=? AND json_extract(body,'$.type')='run.finished' ORDER BY seq DESC LIMIT 1").get(runId)?.t ?? null + }; + } + // Size proxy for the runs table: body bytes plus a fixed per-row overhead + // allowance (row header, id/request columns). SQLite exposes no exact + // per-table page accounting; a proxy is sufficient for a coarse trigger. + runsBytes() { + return Number(this.db.prepare("SELECT COALESCE(SUM(LENGTH(body)),0)+COUNT(*)*100 AS n FROM runs").get().n); + } + step(runId, id2) { + const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + steps(runId) { + return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); + } + // All match keys (contextHash, lineageHash) are stamped on the step body at + // creation, so filtering happens in SQL and LIMIT applies after the full match. + // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is + // an opt-in feature and older steps are not candidates. Tombstoned source runs + // are excluded here too: a trashed run's steps must not resurface as reuse + // candidates (ghost data) until the run is restored. + findCrossRunReuse({ contextHash, requestHash, lineageHash, excludeRunId, limit = 20 }) { + return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? AND NOT EXISTS(SELECT 1 FROM runs WHERE runs.id=steps.runId AND json_extract(runs.body,'$.deletedAt') IS NOT NULL) ORDER BY rowid DESC LIMIT ?").all(excludeRunId, requestHash, contextHash, lineageHash, limit).map((r) => { + const step = JSON.parse(r.stepBody); + return { runId: r.runId, stepId: step.id, step }; + }); + } + saveStep(runId, step) { + this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); + } + repairCandidate(runId, id2) { + const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); + return r ? JSON.parse(r.body) : null; + } + saveRepairCandidate(runId, step) { + this.transaction(() => { + const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); + this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); + }); + } + event(runId, type, data2 = {}) { + const event = { ...data2, type, time: Date.now() }; + return this.transaction(() => { + const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); + this.chainAdvance("event", "events", "SELECT seq AS pos,runId,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => `${r.runId}:${r.pos}`); + return { seq, ...event }; + }); + } + events(runId, after = 0, limit = 150) { + return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); + } + rowHash(prev, kind, key, body) { + return createHash2("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); + } + // Bulk adoption of pre-existing rows is an initial-creation behavior only: it + // anchors whatever the table held when the chain first appears. Once a head + // exists, each write anchors ONLY its own new position — rows injected into the + // range between the head and a later write stay unanchored and verification + // keeps failing closed on them instead of silently legitimizing them. + chainAdvance(kind, surface, sql, newUpto, keyOf) { + const tail = this.setting(`integrity_${surface}`); + let prev = tail?.head ?? "0".repeat(64); + const range = tail ? `SELECT * FROM (${sql}) WHERE pos=${newUpto}` : sql; + for (const r of this.db.prepare(range).all(tail?.upto ?? 0, newUpto)) { + const k = keyOf(r); + prev = this.rowHash(prev, kind, k, r.body); + this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); + } + this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); + } + integrityHeads() { + return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; + } + verifyIntegrity() { + const genesis = "0".repeat(64); + const face = (kind, surface, table, posCol, rowSql, keyOf) => { + const skey = `integrity_${surface}`; + const rec = this.setting(skey); + const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); + if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; + const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); + const unchained = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n); + let prev = genesis, firstDivergence = null; + for (const r of rows) { + const row = this.db.prepare(rowSql).get(r.pos); + const key = row ? keyOf(row, r.pos) : null; + const actual = row ? this.rowHash(prev, kind, key, row.body) : null; + if (!firstDivergence && (!row || key !== r.key || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; + prev = r.hash; + } + if (!firstDivergence) { + const anchored = new Set(rows.map((r) => r.pos)); + const gap = this.db.prepare(`SELECT ${posCol} AS __pos, * FROM ${table} WHERE ${posCol}<=? ORDER BY ${posCol}`).all(rec.upto).find((r) => !anchored.has(r.__pos)); + if (gap) firstDivergence = { key: keyOf(gap, gap.__pos), expectedHead: null, actualHead: null }; + } + const verified = !firstDivergence && prev === rec.head && unchained === 0; + return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained, firstDivergence }; + }; + return { + events: face("event", "events", "events", "seq", "SELECT runId,body FROM events WHERE seq=?", (row, pos) => `${row.runId}:${pos}`), + repair: face("repair", "repair", "repair_cache", "rowid", "SELECT runId,id,body FROM repair_cache WHERE rowid=?", (row) => `${row.runId}/${row.id}`), + // Archive face: the events/repair ledgers are untouched by rotation + // (events never move), so this face only cross-checks the sidecar + // archive against the manifest hashes anchored on the events chain. + archive: this.verifyArchive() + }; + } + // Sidecar archive for rotated tombstones: same data directory, separate + // database. runs/steps rows move here verbatim; events NEVER leave the live + // database. Each rotation writes its own copies keyed by (rotationId, runId) + // rather than a plain runs PK, so a run that is restored, re-deleted and + // re-rotated can never rewrite rows an earlier rotation's manifestHash still + // covers — every historical manifest stays independently verifiable. + archive() { + if (this.archiveDb) return this.archiveDb; + const db = new DatabaseSync(this.archivePath); + db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS archive_runs(rotationId TEXT NOT NULL,runId TEXT NOT NULL,requestId TEXT NOT NULL,requestHash TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(rotationId,runId)); + CREATE TABLE IF NOT EXISTS archive_steps(rotationId TEXT NOT NULL,runId TEXT NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(rotationId,runId,id)); + CREATE TABLE IF NOT EXISTS rotations(rotationId TEXT PRIMARY KEY,rotatedAt INTEGER NOT NULL,manifestHash TEXT NOT NULL,runCount INTEGER NOT NULL,bytes INTEGER NOT NULL);`); + this.archiveDb = db; + return db; + } + // Rotate every due tombstone (purgeAfter <= now) into the archive and drop + // its live runs/steps rows. Ordering is archive-first, live-delete-second: a + // crash in between only leaves tombstones in place; the next rotation + // supersedes the stale archive copy under a fresh rotationId. The audit + // event is appended in the SAME live transaction as the deletes, so the + // events chain always reflects exactly what left the library. + rotateDue({ now = Date.now() } = {}) { + const due = this.db.prepare("SELECT id,requestId,requestHash,body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL AND json_extract(body,'$.purgeAfter')<=? ORDER BY id").all(now); + if (!due.length) return { rotated: false, runCount: 0, runs: [], rotationId: null, manifestHash: null, bytes: 0 }; + const rotationId = randomUUID(), readSteps = this.db.prepare("SELECT id,body FROM steps WHERE runId=? ORDER BY id"); + const entries = due.map((r) => ({ run: { id: r.id, requestId: r.requestId, requestHash: r.requestHash, body: r.body }, steps: readSteps.all(r.id) })); + const manifestHash = archiveManifestHash(entries); + const bytes = entries.reduce((n, e) => n + Buffer.byteLength(e.run.body) + e.steps.reduce((m2, s) => m2 + Buffer.byteLength(s.body), 0), 0); + const archive = this.archive(); + archive.exec("BEGIN IMMEDIATE"); + try { + const insertRun = archive.prepare("INSERT OR REPLACE INTO archive_runs VALUES(?,?,?,?,?)"), insertStep = archive.prepare("INSERT OR REPLACE INTO archive_steps VALUES(?,?,?,?)"); + for (const entry of entries) { + insertRun.run(rotationId, entry.run.id, entry.run.requestId, entry.run.requestHash, entry.run.body); + for (const step of entry.steps) insertStep.run(rotationId, entry.run.id, step.id, step.body); + } + archive.prepare("INSERT INTO rotations VALUES(?,?,?,?,?)").run(rotationId, Date.now(), manifestHash, entries.length, bytes); + archive.exec("COMMIT"); + } catch (e) { + archive.exec("ROLLBACK"); + throw e; + } + this.transaction(() => { + const deleteSteps = this.db.prepare("DELETE FROM steps WHERE runId=?"), deleteRun = this.db.prepare("DELETE FROM runs WHERE id=?"); + for (const entry of entries) { + deleteSteps.run(entry.run.id); + deleteRun.run(entry.run.id); + } + this.event(rotationId, "archive.rotated", { runs: entries.map((entry) => entry.run.id), manifestHash, runCount: entries.length, bytes }); + }); + return { rotated: true, runCount: entries.length, runs: entries.map((entry) => entry.run.id), rotationId, manifestHash, bytes }; + } + // Archive verification recomputes each rotation's manifest from the archived + // rows and compares it against BOTH the rotations record (tamperable sidecar) + // and the archive.rotated event anchored on the events hash chain (the trust + // anchor). Extra cross-checks: every chained rotation must still have its + // rotations record, and archive rows may not exist outside known rotations, + // so deleting archive history fails closed too. + verifyArchive() { + if (!existsSync(this.archivePath)) return { exists: false, rotations: 0, checked: 0, verified: true, results: [], divergences: [] }; + const archive = this.archive(); + const rotations = archive.prepare("SELECT rotationId,manifestHash,runCount FROM rotations ORDER BY rotationId").all(); + const results = [], divergences = []; + for (const rotation of rotations) { + const audit = this.db.prepare("SELECT body FROM events WHERE runId=?").all(rotation.rotationId).map((e) => JSON.parse(e.body)).filter((e) => e.type === "archive.rotated"); + const entries = archive.prepare("SELECT runId AS id,requestId,requestHash,body FROM archive_runs WHERE rotationId=? ORDER BY runId").all(rotation.rotationId).map((run) => ({ run, steps: archive.prepare("SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id").all(rotation.rotationId, run.id) })); + const actual = archiveManifestHash(entries), event = audit[0], problems = []; + if (audit.length !== 1) problems.push(`expected exactly one archive.rotated event, found ${audit.length}`); + if (event && event.manifestHash !== rotation.manifestHash) problems.push("rotations.manifestHash differs from the chained event"); + if (event && event.manifestHash !== actual) problems.push("archived rows recompute to a different manifest"); + if (event && event.runCount !== rotation.runCount) problems.push("event runCount differs from the rotations record"); + if (entries.length !== rotation.runCount) problems.push(`archived ${entries.length} run rows for runCount ${rotation.runCount}`); + if (problems.length) divergences.push(`rotation ${rotation.rotationId}: ${problems.join("; ")}`); + results.push({ rotationId: rotation.rotationId, runCount: rotation.runCount, verified: problems.length === 0 }); + } + const chained = this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map((e) => e.runId); + for (const runId of chained) if (!rotations.some((rotation) => rotation.rotationId === runId)) divergences.push(`rotation ${runId} is chained but missing from the archive`); + for (const orphan of archive.prepare("SELECT DISTINCT rotationId FROM archive_runs WHERE rotationId NOT IN (SELECT rotationId FROM rotations)").all()) divergences.push(`archive rows exist for unknown rotation ${orphan.rotationId}`); + return { exists: true, rotations: rotations.length, checked: rotations.length, verified: divergences.length === 0, results, divergences }; + } + // Latest rotation that archived the run, or null when the run was never + // archived. Does not create the archive database for a negative answer. + archiveOrigin(runId) { + if (!existsSync(this.archivePath)) return null; + const row = this.archive().prepare("SELECT a.rotationId AS rotationId FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1").get(runId); + return row ? { rotationId: row.rotationId } : null; + } + // Copy a run's archived runs/steps rows back into the live library (id + // idempotent through upserts), clear its tombstone and append one + // run.restored {origin:'archive'} audit event — all in one transaction. + // The archive keeps its copy: restore is a copy-back, not a move. + restoreArchived(runId, { by = "cli" } = {}) { + if (!existsSync(this.archivePath)) return null; + const row = this.archive().prepare("SELECT a.rotationId AS rotationId,a.requestId AS requestId,a.requestHash AS requestHash,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1").get(runId); + if (!row) return null; + const conflict = this.db.prepare("SELECT id FROM runs WHERE requestId=? AND id<>?").get(row.requestId, runId); + if (conflict) throw new Error(`requestId \u5DF2\u88AB\u65B0\u7684\u5DE5\u4F5C\u6D41\uFF08${conflict.id}\uFF09\u5360\u7528\uFF0C\u65E0\u6CD5\u4ECE\u5F52\u6863\u6062\u590D ${runId}\uFF1B\u8BF7\u5148\u5904\u7406\u5360\u7528\u7684\u8FD0\u884C`); + const run = JSON.parse(row.body); + delete run.deletedAt; + delete run.deletedBy; + delete run.purgeAfter; + const steps = this.archive().prepare("SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id").all(row.rotationId, runId); + this.transaction(() => { + this.save(run); + const insert = this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body"); + for (const step of steps) insert.run(runId, step.id, step.body); + this.event(runId, "run.restored", { by, origin: "archive", rotationId: row.rotationId }); + }); + return { id: runId, rotationId: row.rotationId, steps: steps.length }; + } + releaseLock() { + closeSync(this.fd); + try { + if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); + } catch { + } + } + close() { + this.archiveDb?.close(); + this.db.close(); + this.releaseLock(); + } +}; + // src/limits.mjs var DEFAULT_LIMITS = Object.freeze({ maxSteps: 120, stepTimeoutMs: 30 * 6e4, runTimeoutMs: 2 * 60 * 6e4 }); var LEGACY_LIMITS = Object.freeze({ maxSteps: 30, stepTimeoutMs: 10 * 6e4, runTimeoutMs: 30 * 6e4 }); @@ -14256,6 +14437,8 @@ ${JSON.stringify(spec.input ?? {})}`); } // src/engine.mjs +var DELETABLE = /* @__PURE__ */ new Set(["succeeded", "failed", "completed_with_gaps", "cancelled", "interrupted"]); +var TRASH_SOURCES = /* @__PURE__ */ new Set(["studio", "cli", "mcp"]); var Engine = class extends EventEmitter { constructor(store, options) { super(); @@ -14298,7 +14481,7 @@ var Engine = class extends EventEmitter { } return out; } - async start(request, repair = null, candidates = []) { + async start(request, repair = null, candidates = [], lineage = null) { check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); validateScript(request.script); boundedJSON(request.input ?? {}); @@ -14316,6 +14499,7 @@ var Engine = class extends EventEmitter { const requestHash = hash(repair ? { ...definition, repair } : definition); const existing = this.store.byRequest(request.requestId); if (existing) { + check(!existing.deletedAt, "requestId \u5DF2\u7528\u4E8E\u5DF2\u5220\u9664\u7684\u5DE5\u4F5C\u6D41\uFF1A\u8BF7\u5148\u5728\u56DE\u6536\u7AD9\u6062\u590D\u5B83\uFF0C\u6216\u66F4\u6362 requestId"); const legacyDefinition = { ...definition }; for (const key of Object.keys(DEFAULT_LIMITS)) delete legacyDefinition[key]; check(existing.requestHash === requestHash || existing.maxSteps === void 0 && Object.keys(DEFAULT_LIMITS).every((k) => request[k] === void 0) && existing.requestHash === hash(legacyDefinition), "requestId \u5DF2\u7528\u4E8E\u4E0D\u540C\u53C2\u6570"); @@ -14324,11 +14508,11 @@ var Engine = class extends EventEmitter { const fingerprints = {}; const topology = assertValidDependencies(previewTopology(request.script, request.input ?? {})); check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); - const run = { ...repair ? { repair } : {}, id: randomUUID2(), requestId: request.requestId, requestHash, ...definition, scriptHash: hash(request.script), fingerprints, workspace: this.options.workspace, revision: 1, topology, status: "pending_review", createdAt: Date.now(), updatedAt: Date.now(), attempts: 0, phases: [], result: null, error: null }; + const run = { ...repair ? { repair } : {}, ...lineage ? { rerunOf: lineage.rerunOf, lineageRoot: lineage.lineageRoot, rerunSeq: lineage.rerunSeq } : {}, id: randomUUID2(), requestId: request.requestId, requestHash, ...definition, scriptHash: hash(request.script), fingerprints, workspace: this.options.workspace, revision: 1, topology, status: "pending_review", createdAt: Date.now(), updatedAt: Date.now(), attempts: 0, phases: [], result: null, error: null }; this.store.transaction(() => { this.store.save(run); for (const step of candidates) this.store.saveRepairCandidate(run.id, step); - this.store.event(run.id, "run.created", { name: run.name }); + this.store.event(run.id, "run.created", { name: run.name, ...lineage ? { rerunOf: lineage.rerunOf, lineageRoot: lineage.lineageRoot, rerunSeq: lineage.rerunSeq } : {} }); }); return this.snapshot(run.id); } @@ -14336,6 +14520,7 @@ var Engine = class extends EventEmitter { check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); const source = this.store.get(id2); check(source, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + check(!source.deletedAt, "\u5DE5\u4F5C\u6D41\u5DF2\u5220\u9664\uFF0C\u8BF7\u5148\u5728\u56DE\u6536\u7AD9\u6062\u590D\u540E\u518D\u4FEE\u590D"); check(!this.active.has(id2) && ["failed", "paused", "interrupted", "cancelled", "completed_with_gaps", "succeeded"].includes(source.status), "\u8BF7\u5148\u505C\u6B62\u8FD0\u884C\uFF1B\u5F02\u5E38\u9000\u51FA\u987B\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\u5E76\u6062\u590D\u6216\u6682\u505C"); check(source.workspace === this.options.workspace, "\u5DE5\u4F5C\u533A\u4E0D\u5339\u914D"); check(request.sourceUpdatedAt === source.updatedAt, "\u6E90\u8FD0\u884C\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u518D\u4FEE\u590D"); @@ -14427,6 +14612,7 @@ var Engine = class extends EventEmitter { snapshot(id2) { const run = this.store.get(id2); check(run, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + check(!run.deletedAt, "\u5DE5\u4F5C\u6D41\u5DF2\u5220\u9664\uFF0C\u53EF\u5728\u56DE\u6536\u7AD9\u6062\u590D\u540E\u67E5\u770B"); const limits = runLimits(run), topology = run.topology?.version === 3 ? run.topology : previewTopology(run.script, run.input); return { ...run, topology, ...historicalFailure(run, topology), ...limits, legacyLimits: run.maxSteps === void 0, scheduler: this.schedulerStatus(), steps: this.store.steps(id2).map((stored) => { const s = stored.kind === "agent" ? { ...stored, maxSteps: stored.maxSteps ?? LEGACY_LIMITS.maxSteps, timeoutMs: stored.timeoutMs ?? LEGACY_LIMITS.stepTimeoutMs } : stored; @@ -14476,6 +14662,149 @@ var Engine = class extends EventEmitter { this.drain(); return this.schedulerStatus(); } + // Trash retention in days. Default 30; 0 disables the expiry clock entirely + // (tombstones then leave only through explicit manual rotation). A corrupted + // stored value falls back to the default instead of poisoning purge stamps. + trashRetentionDays() { + const value = this.store.setting("trashRetentionDays"); + return Number.isInteger(value) && value >= 0 && value <= 3650 ? value : 30; + } + configureTrash({ trashRetentionDays }) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + check(Number.isInteger(trashRetentionDays) && trashRetentionDays >= 0 && trashRetentionDays <= 3650, "\u56DE\u6536\u7AD9\u4FDD\u7559\u671F\u987B\u4E3A 0\u20133650 \u7684\u6574\u6570\u5929\uFF080 \u8868\u793A\u4EC5\u624B\u52A8\u8F6E\u8F6C\uFF09"); + this.store.saveSetting("trashRetentionDays", trashRetentionDays); + this.store.restampTrashPurge(trashRetentionDays); + return { trashRetentionDays }; + } + // Tombstone soft delete. Steps, events, result and the integrity ledger all + // stay untouched — only the run body gains deletedAt/deletedBy/purgeAfter and + // one append-only run.deleted audit event. Repeat deletes are idempotent and + // never append a second event. + async deleteRun(id2, { by = "studio" } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + check(TRASH_SOURCES.has(by), "\u65E0\u6548\u7684\u5220\u9664\u6765\u6E90"); + const run = this.store.get(id2); + if (!run) { + const archived = this.store.archiveOrigin(id2); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + return { id: id2, deleted: true, alreadyDeleted: true, archived: true, rotationId: archived.rotationId }; + } + if (run.deletedAt) return { id: id2, deleted: true, alreadyDeleted: true, deletedAt: run.deletedAt, deletedBy: run.deletedBy, purgeAfter: run.purgeAfter }; + check(!this.active.has(id2), "\u5DE5\u4F5C\u6D41\u4ECD\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u540E\u518D\u5220\u9664"); + check(DELETABLE.has(run.status), "\u4EC5\u5DF2\u5B8C\u6210\u7684\u5DE5\u4F5C\u6D41\u53EF\u5220\u9664\uFF08\u8FD0\u884C\u4E2D\u6216\u5F85\u5BA1\u6838\u4E0D\u53EF\u5220\u9664\uFF09"); + const days = this.trashRetentionDays(); + run.deletedAt = Date.now(); + run.deletedBy = by; + run.purgeAfter = run.deletedAt + days * 864e5; + this.save(run); + this.emitEvent(id2, "run.deleted", { by, purgeAfter: run.purgeAfter }); + return { id: id2, deleted: true, alreadyDeleted: false, deletedAt: run.deletedAt, purgeAfter: run.purgeAfter }; + } + // Restore clears the tombstone and appends run.restored. Everything else was + // never removed, so the run reappears byte-identical on every query face. + async restoreRun(id2, { by = "studio" } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + check(TRASH_SOURCES.has(by), "\u65E0\u6548\u7684\u6062\u590D\u6765\u6E90"); + const run = this.store.get(id2); + if (run) { + check(run.deletedAt, "\u5DE5\u4F5C\u6D41\u672A\u5220\u9664\uFF0C\u65E0\u9700\u6062\u590D"); + delete run.deletedAt; + delete run.deletedBy; + delete run.purgeAfter; + this.save(run); + this.emitEvent(id2, "run.restored", { by, origin: "trash" }); + return this.snapshot(id2); + } + const archived = this.store.restoreArchived(id2, { by }); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728\u6216\u672A\u5F52\u6863"); + return this.snapshot(id2); + } + // Rerun: start a NEW pending_review run from the source's script+input. The + // source is never modified — the child only carries rerunOf/lineageRoot/ + // rerunSeq so the family stays queryable and comparable. The synthesized + // requestId is `#rerun-` (n = highest existing seq + 1, tombstoned + // AND archived members counted so a seq is never reused); a collision with + // an already-claimed requestId retries with a numeric suffix. + async rerun(id2, { input, reuseAcrossRuns, name } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + check(input === void 0 || input && typeof input === "object" && !Array.isArray(input), "input \u5FC5\u987B\u4E3A JSON object"); + check(reuseAcrossRuns === void 0 || typeof reuseAcrossRuns === "boolean", "reuseAcrossRuns \u5FC5\u987B\u4E3A\u5E03\u5C14"); + check(name === void 0 || typeof name === "string" && name.trim().length > 0 && name.length <= 120, "\u540D\u79F0\u987B\u4E3A 1\u2013120 \u5B57\u7B26"); + const source = this.store.get(id2); + if (!source) { + const archived2 = this.store.archivedRun(id2); + check(archived2, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + check(false, "\u5DE5\u4F5C\u6D41\u5DF2\u8F6E\u8F6C\u5F52\u6863\uFF0C\u8BF7\u5148\u4ECE\u5F52\u6863\u6062\u590D\u540E\u518D\u590D\u8DD1"); + } + check(!source.deletedAt, "\u6E90\u5DE5\u4F5C\u6D41\u5DF2\u5220\u9664\uFF0C\u8BF7\u5148\u5728\u56DE\u6536\u7AD9\u6062\u590D\u540E\u518D\u590D\u8DD1"); + check(source.workspace === this.options.workspace, "\u5DE5\u4F5C\u533A\u4E0D\u5339\u914D"); + const rootId = source.lineageRoot ?? source.id; + const live = this.store.lineageMembers(rootId), archived = this.store.archivedLineageMembers(rootId); + const seq = Math.max(0, ...live.map((m2) => m2.rerunSeq ?? 0), ...archived.map(({ run }) => run.rerunSeq ?? 0)) + 1; + const root = live.find((m2) => m2.id === rootId)?.requestId ?? archived.find(({ run }) => run.id === rootId)?.run.requestId ?? source.requestId; + const suffix = `#rerun-${seq}`; + const base = (root.length + suffix.length <= 150 ? root : root.slice(0, 150 - suffix.length)) + suffix; + let requestId = base; + for (let attempt = 2; this.store.byRequest(requestId); attempt++) requestId = `${base}-${attempt}`.slice(0, 150); + return this.start({ ...templateDefinition(source), ...input !== void 0 ? { input } : {}, ...name !== void 0 ? { name } : {}, ...reuseAcrossRuns === void 0 ? {} : { reuseAcrossRuns }, requestId }, null, [], { rerunOf: source.id, lineageRoot: rootId, rerunSeq: seq }); + } + // Family face: the lineage root plus every member (live, tombstoned and + // archived), ordered by rerunSeq. Deleting or rotating a member never removes + // it from the family — it is only annotated. Resolvable from any member id, + // including tombstoned and archived ones. `results` adds each member's full + // result for the read-only compare face (summaries otherwise). + lineage(id2, { results = false } = {}) { + const liveHit = this.store.get(id2); + let rootId; + if (liveHit) rootId = liveHit.lineageRoot ?? liveHit.id; + else { + const archived = this.store.archivedRun(id2); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + rootId = archived.run.lineageRoot ?? archived.run.id; + } + const entry = (run, rotationId) => { + const times = this.store.runTimes(run.id); + return { + id: run.id, + requestId: run.requestId, + name: run.name, + rerunOf: run.rerunOf ?? null, + rerunSeq: run.rerunSeq ?? 0, + status: run.status, + executor: run.executor, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + startedAt: times.startedAt, + finishedAt: times.finishedAt, + durationMs: times.startedAt != null && times.finishedAt != null ? times.finishedAt - times.startedAt : null, + resultPreview: run.result == null ? null : String(typeof run.result === "string" ? run.result : JSON.stringify(run.result)).slice(0, 200), + ...results ? { result: run.result ?? null } : {}, + deleted: run.deletedAt ? { deletedAt: run.deletedAt, deletedBy: run.deletedBy, purgeAfter: run.purgeAfter } : null, + archived: rotationId ? { rotationId } : null + }; + }; + const members2 = [...this.store.lineageMembers(rootId).map((run) => entry(run, null)), ...this.store.archivedLineageMembers(rootId).map(({ run, rotationId }) => entry(run, rotationId))]; + members2.sort((a, b2) => a.rerunSeq - b2.rerunSeq || (a.createdAt ?? 0) - (b2.createdAt ?? 0) || (a.id < b2.id ? -1 : 1)); + return { lineageRoot: rootId, members: members2 }; + } + // Manual rotation face: rotate due tombstones now, optionally returning the + // archive and integrity verdicts alongside the rotation summary. + rotateArchive({ verify = false } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + const result = this.store.rotateDue({ now: Date.now() }); + return verify ? { ...result, archive: this.store.verifyArchive(), integrity: this.store.verifyIntegrity() } : result; + } + // Startup compaction: when trash volume or runs-table size crosses the + // documented thresholds, expired tombstones are rotated into archive.db + // before the dashboard starts serving. trashRetentionDays=0 disables the + // expiry clock entirely (manual rotation only) and with it this auto path. + autoRotateAtStartup() { + const days = this.trashRetentionDays(); + if (days <= 0) return { autoRotated: false, reason: "manual-only", tombstones: this.store.tombstoneCount(), bytes: this.store.runsBytes() }; + const tombstones = this.store.tombstoneCount(), bytes = this.store.runsBytes(); + if (tombstones <= ROTATE_TOMBSTONE_THRESHOLD && bytes <= ROTATE_RUNS_BYTES_THRESHOLD) return { autoRotated: false, tombstones, bytes }; + return { ...this.store.rotateDue({ now: Date.now() }), autoRotated: true, tombstones, bytes }; + } drain() { while (this.slots < this.globalConcurrency) { const ids = [...this.active.keys()], last = ids.indexOf(this.lastServedRun); @@ -14819,6 +15148,7 @@ var Engine = class extends EventEmitter { check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); const run = this.store.get(id2); check(run, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + check(!run.deletedAt, "\u5DE5\u4F5C\u6D41\u5DF2\u5220\u9664\uFF0C\u8BF7\u5148\u5728\u56DE\u6536\u7AD9\u6062\u590D"); check(!this.active.has(id2), "\u5DE5\u4F5C\u6D41\u4ECD\u5728\u8FD0\u884C"); check(run.workspace === this.options.workspace, "\u5DE5\u4F5C\u533A\u5DF2\u6539\u53D8\uFF0C\u8BF7\u521B\u5EFA\u65B0\u5DE5\u4F5C\u6D41"); check(!run.revision || run.approvedRevision === run.revision, "\u672A\u5BA1\u6838\u5DE5\u4F5C\u6D41\u4E0D\u80FD\u6062\u590D\uFF0C\u8BF7\u521B\u5EFA\u65B0\u8349\u7A3F"); @@ -15328,6 +15658,14 @@ Object.assign(messages.zh, { "templates": "\u6A21\u677F\u5E93", "saveTemplate": Object.assign(messages.en, { "templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps" }); Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "reviewCompactHelp": "\u68C0\u67E5\u4E0B\u65B9\u6D41\u7A0B\uFF0C\u786E\u8BA4\u540E\u5F00\u59CB\u3002", "reviewDetails": "\u4EFB\u52A1\u8BE6\u60C5\u4E0E\u6267\u884C\u8BBE\u7F6E", "reviewBudgets": "\u5E76\u53D1 {concurrency} \xB7 \u6700\u591A {calls} \u6B21\u8C03\u7528 \xB7 \u6BCF\u8282\u70B9 {steps} \u6B65 / {minutes} \u5206\u949F", "status.awaiting": "\u5C1A\u672A\u5F00\u59CB", "status.blocked": "\u4F9D\u8D56\u53D7\u963B", "status.not_run": "\u672A\u6267\u884C", "awaitingHelp": "\u8BE5\u8282\u70B9\u5C1A\u672A\u521B\u5EFA\u6267\u884C\u4EFB\u52A1\u3002\u542F\u52A8\u540E\u4F1A\u5728\u8FD9\u91CC\u66F4\u65B0\u72B6\u6001\u3002", "blockedHelp": "\u5DF2\u58F0\u660E\u7684\u4E0A\u6E38\u8282\u70B9\u672A\u6210\u529F\uFF0C\u5F53\u524D\u8282\u70B9\u5C1A\u672A\u6267\u884C\u3002", "notRunHelp": "\u672C\u6B21\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u672A\u89E6\u53D1\u8FD9\u4E2A\u8BA1\u5212\u8282\u70B9\u3002", "dynamicHelp": "\u8282\u70B9\u6570\u91CF\u7531\u8FD0\u884C\u7ED3\u679C\u51B3\u5B9A\uFF1B\u5DF2\u521B\u5EFA\u7684\u8282\u70B9\u4F1A\u5728\u6B64\u5206\u7EC4\u4E2D\u5C55\u5F00\u3002" }); Object.assign(messages.en, { "reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \xB7 Up to {calls} calls \xB7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group." }); +Object.assign(messages.zh, { "trash": "\u56DE\u6536\u7AD9", "trashHelp": "\u5220\u9664\u7684\u5DE5\u4F5C\u6D41\u5148\u8FDB\u5165\u56DE\u6536\u7AD9\uFF1A\u4E8B\u4EF6\u3001\u8282\u70B9\u4E0E\u7ED3\u679C\u5168\u90E8\u4FDD\u7559\uFF0C\u53EF\u968F\u65F6\u6062\u590D\u3002\u5230\u671F\u540E\u7531\u5F52\u6863\u8F6E\u8F6C\u56DE\u6536\u5B58\u50A8\uFF1B\u5BA1\u8BA1\u4E8B\u4EF6\u6C38\u4E0D\u5220\u9664\u3002", "trashEmpty": "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002", "trashRestore": "\u6062\u590D", "trashRemaining": "\u4FDD\u7559\u5269\u4F59 {days} \u5929", "trashExpired": "\u5DF2\u5230\u671F\uFF0C\u7B49\u5F85\u5F52\u6863\u8F6E\u8F6C", "trashDeleted": "\u5220\u9664\u4E8E {date}", "trashRetention": "\u56DE\u6536\u7AD9\u4FDD\u7559\u671F\uFF08\u5929\uFF09", "trashRetentionHelp": "\u9ED8\u8BA4 30 \u5929\uFF1B0 \u8868\u793A\u4E0D\u5230\u671F\uFF0C\u4EC5\u624B\u52A8\u8F6E\u8F6C\u5F52\u6863\u3002\u4FEE\u6539\u4F1A\u540C\u6B65\u66F4\u65B0\u56DE\u6536\u7AD9\u4E2D\u5DF2\u6709\u6761\u76EE\u7684\u5230\u671F\u65F6\u95F4\u3002", "event.run.deleted": "\u5DF2\u5220\u9664\u5230\u56DE\u6536\u7AD9", "event.run.restored": "\u5DF2\u6062\u590D" }); +Object.assign(messages.en, { "trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored" }); +Object.assign(messages.zh, { "lineage": "\u590D\u8DD1\u8C31\u7CFB", "lineageCount": "{count} \u6B21\u8FD0\u884C", "lineageRootRun": "\u539F\u59CB\u8FD0\u884C", "lineageRerun": "\u590D\u8DD1 \u7B2C {seq} \u6B21", "lineageOpen": "\u6253\u5F00", "lineageMemberDeleted": "\u5DF2\u5220\u9664\uFF08\u56DE\u6536\u7AD9\uFF09", "lineageMemberArchived": "\u5DF2\u5F52\u6863", "lineageCompareHint": "\u540C\u8C31\u7CFB\u5386\u6B21\u8FD0\u884C", "lineageCompare": "\u5BF9\u6BD4\u7ED3\u679C", "lineageCompareLeft": "\u5BF9\u6BD4\u5DE6\u4FA7\u8FD0\u884C", "lineageCompareRight": "\u5BF9\u6BD4\u53F3\u4FA7\u8FD0\u884C", "lineageVersus": "\u5BF9\u6BD4", "lineageNoResult": "\u6682\u65E0\u7ED3\u679C" }); +Object.assign(messages.zh, { "event.archive.rotated": "\u56DE\u6536\u7AD9\u5F52\u6863\u8F6E\u8F6C" }); +Object.assign(messages.en, { "lineage": "Rerun lineage", "lineageCount": "{count} runs", "lineageRootRun": "Original run", "lineageRerun": "Rerun #{seq}", "lineageOpen": "Open", "lineageMemberDeleted": "Deleted (in trash)", "lineageMemberArchived": "Archived", "lineageCompareHint": "Every rerun of this workflow", "lineageCompare": "Compare results", "lineageCompareLeft": "Left run to compare", "lineageCompareRight": "Right run to compare", "lineageVersus": "vs", "lineageNoResult": "No result yet" }); +Object.assign(messages.en, { "event.archive.rotated": "Archive rotation" }); +Object.assign(messages.zh, { "canvasFullscreen": "\u5168\u5C4F", "canvasExitFullscreen": "\u9000\u51FA\u5168\u5C4F" }); +Object.assign(messages.en, { "canvasFullscreen": "Fullscreen", "canvasExitFullscreen": "Exit fullscreen" }); function translate(language, key, vars = {}) { if (language === "en" && vars.count === 1 && ["tasks", "eventsCount"].includes(key)) return key === "tasks" ? "1 task" : "1 event"; return (messages[language]?.[key] ?? messages.en[key] ?? key).replace(/\{(\w+)\}/g, (_2, name) => String(vars[name] ?? `{${name}}`)); @@ -26560,6 +26898,9 @@ var TOOLS = [ { name: "workflow_cancel", description: "\u53D6\u6D88\u672C\u63D2\u4EF6\u5DE5\u4F5C\u6D41\uFF0C\u7B49\u5F85\u5728\u9014 exec \u9000\u51FA\uFF1B\u4E0D\u53D6\u6D88\u5176\u4ED6 MCode \u4F1A\u8BDD\u3002", inputSchema: obj(id, ["runId"]) }, { name: "workflow_pause", description: "\u505C\u6B62\u6D3E\u53D1\u5E76\u4E2D\u65AD\u5728\u9014\u8C03\u7528\uFF0C\u4FDD\u7559\u5DF2\u5B8C\u6210\u8282\u70B9\uFF0C\u53EF\u6062\u590D\u3002", inputSchema: obj(id, ["runId"]) }, { name: "workflow_resume", description: "\u539F\u811A\u672C\u4E0E\u539F\u8F93\u5165\u6062\u590D\uFF0C\u590D\u7528\u5DF2\u6210\u529F\u8282\u70B9\u3002\u53EF\u8C03\u6574 maxSteps/stepTimeoutMs/runTimeoutMs/maxCalls \u540E\u91CD\u8BD5\uFF0C\u6210\u529F\u8282\u70B9\u590D\u7528\uFF0C\u5931\u8D25\u8282\u70B9\u4ECE\u5934\u6267\u884C\u3002\u5F02\u5E38\u9000\u51FA\u9700\u8981\u7528\u6237\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\u3002", inputSchema: obj({ ...id, confirmStopped: { type: "boolean" }, ...LIMIT_SCHEMAS, maxCalls: { type: "integer", minimum: 1, maximum: 100 } }, ["runId"]) }, + { name: "workflow_delete", description: "\u5220\u9664\u5DF2\u5B8C\u6210\u7684\u5DE5\u4F5C\u6D41\u5230\u56DE\u6536\u7AD9\uFF08\u5893\u7891\u8F6F\u5220\uFF09\uFF1A\u4E8B\u4EF6\u3001\u8282\u70B9\u4E0E\u7ED3\u679C\u5168\u90E8\u4FDD\u7559\uFF0C\u53EF\u968F\u65F6\u6062\u590D\uFF1B\u8FD0\u884C\u4E2D\u6216\u5F85\u5BA1\u6838\u7684\u5DE5\u4F5C\u6D41\u62D2\u7EDD\u5220\u9664\uFF1B\u91CD\u590D\u5220\u9664\u5E42\u7B49\u3002\u5230\u671F\u540E\u7531\u5F52\u6863\u8F6E\u8F6C\u56DE\u6536\u5B58\u50A8\uFF0C\u4E8B\u4EF6\u94FE\u6C38\u4E0D\u5220\u9664\u3002", inputSchema: obj({ ...id, by: { type: "string", description: "\u5220\u9664\u6765\u6E90\uFF08studio/cli/mcp\uFF09\uFF0C\u9ED8\u8BA4 mcp" } }, ["runId"]) }, + { name: "workflow_restore", description: "\u4ECE\u56DE\u6536\u7AD9\u6216\u5F52\u6863\u6062\u590D\u5DE5\u4F5C\u6D41\uFF1A\u56DE\u6536\u7AD9\u6062\u590D\u6E05\u9664\u5893\u7891\uFF1B\u5DF2\u8F6E\u8F6C\u5F52\u6863\u7684\u4ECE\u672C\u673A\u5F52\u6863\u5E93\u5BFC\u56DE\u8282\u70B9\u6570\u636E\u3002\u8FD4\u56DE\u6062\u590D\u540E\u7684\u8FD0\u884C\u72B6\u6001\uFF1B\u5DF2\u5F52\u6863\u672A\u6062\u590D\u524D workflow_status \u4E0D\u8FD4\u56DE\u8BE5\u8FD0\u884C\u3002", inputSchema: obj({ ...id, by: { type: "string", description: "\u6062\u590D\u6765\u6E90\uFF08studio/cli/mcp\uFF09\uFF0C\u9ED8\u8BA4 mcp" } }, ["runId"]) }, + { name: "workflow_rerun", description: "\u590D\u8DD1\u5DE5\u4F5C\u6D41\uFF1A\u4EE5\u539F\u8FD0\u884C\u7684\u811A\u672C\u4E0E\u8F93\u5165\u521B\u5EFA\u65B0\u7684\u5F85\u5BA1\u6838\u8FD0\u884C\uFF0C\u539F\u8FD0\u884C\u6C38\u4E0D\u6539\u5199\u3002\u65B0\u8FD0\u884C\u5E26 rerunOf/lineageRoot/rerunSeq \u5F52\u5165\u540C\u4E00\u8C31\u7CFB\uFF0C\u53EF\u65E0\u9650\u6B21\u590D\u8DD1\uFF0C\u65CF\u8C31\u7ECF GET /api/runs/:id/lineage \u67E5\u8BE2\u3002\u65B0\u8FD0\u884C\u987B\u9762\u677F\u5BA1\u6838\u540E\u624D\u4F1A\u6267\u884C\u3002\u9ED8\u8BA4\u4E0D\u590D\u7528\u8DE8\u8FD0\u884C\u7ED3\u679C\uFF08\u4FDD\u8BC1\u771F\u5B9E\u91CD\u8DD1\u4E0E\u7ED3\u679C\u5BF9\u6BD4\uFF09\uFF1B\u663E\u5F0F reuseAcrossRuns:true \u624D\u590D\u7528\u3002\u6E90\u8FD0\u884C\u5728\u56DE\u6536\u7AD9\u6216\u5DF2\u5F52\u6863\u65F6\u5148\u6062\u590D\u3002", inputSchema: obj({ ...id, input: { type: "object", description: "\u53EF\u9009\uFF1A\u66FF\u6362\u539F\u8FD0\u884C\u7684 input" }, reuseAcrossRuns: { type: "boolean", description: "Opt-in: adopt succeeded nodes from prior runs when context and spec hashes match; default false so reruns execute for real" } }, ["runId"]) }, { name: "workflow_dashboard", description: "\u8FD4\u56DE\u53EF\u6536\u85CF\u7684\u672C\u673A\u53EF\u89C6\u5316\u9762\u677F\u5730\u5740\uFF0C\u65E0\u9700 token\u3002\u670D\u52A1\u72EC\u7ACB\u4E8E\u804A\u5929\u4F1A\u8BDD\uFF0C\u91CD\u542F\u540E\u590D\u7528\u7AEF\u53E3\u3002", inputSchema: obj({}) } ]; function summary(snapshot) { @@ -26615,6 +26956,12 @@ function createToolHandler(engine, getURL) { return summary(await engine.stop(args.runId)); case "workflow_pause": return summary(await engine.stop(args.runId, "paused")); + case "workflow_delete": + return await engine.deleteRun(args.runId, { by: args.by ?? "mcp" }); + case "workflow_restore": + return await engine.restoreRun(args.runId, { by: args.by ?? "mcp" }); + case "workflow_rerun": + return summary(await engine.rerun(args.runId, args)); case "workflow_resume": return summary(await engine.resume(args.runId, args)); case "workflow_dashboard": @@ -26655,7 +27002,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import const url = new URL(req.url, origin); if (url.pathname.startsWith("/api/")) { if (req.headers["x-workflow-client"] !== "1" || ["cross-site", "same-site"].includes(req.headers["sec-fetch-site"])) return json({ error: "\u8BF7\u4ECE\u672C\u5730 Workflow Studio \u9762\u677F\u8BBF\u95EE\u3002" }, 403); - if (req.method === "GET" && url.pathname === "/api/config") return json({ serviceProtocol: 2, features: { workflowRepair: true }, pid: process.pid, workspace: engine.options.workspace, executor: engine.options.command, defaults: engine.defaults, scheduler: engine.schedulerStatus(), mcodeAvailable: !!await resolveMcode(engine.options.command ?? "mcode"), example: await readFile(new URL("audit.js", exampleRoot), "utf8") }); + if (req.method === "GET" && url.pathname === "/api/config") return json({ serviceProtocol: 2, features: { workflowRepair: true, trashManagement: true, rerunLineage: true }, pid: process.pid, workspace: engine.options.workspace, executor: engine.options.command, defaults: engine.defaults, scheduler: engine.schedulerStatus(), mcodeAvailable: !!await resolveMcode(engine.options.command ?? "mcode"), example: await readFile(new URL("audit.js", exampleRoot), "utf8") }); if (req.method === "GET" && url.pathname === "/api/templates") return json(engine.store.templates().map(({ definition, ...t }) => ({ ...t, objective: definition.metadata?.objective ?? "" }))); const template = url.pathname.match(/^\/api\/templates\/([a-f0-9-]+)$/); if (template && req.method === "GET") { @@ -26670,12 +27017,18 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import return res.end(file.body); } if (req.method === "GET" && url.pathname === "/api/scheduler") return json(engine.schedulerStatus()); - if (req.method === "GET" && url.pathname === "/api/runs") return json(engine.store.list().map(({ script, input, result, fingerprints, ...r }) => r)); - const match = url.pathname.match(/^\/api\/runs\/([a-f0-9-]+)(?:\/(wait|pause|cancel|resume|edit|approve|repair))?$/); + if (req.method === "GET" && url.pathname === "/api/trash") return json({ trashRetentionDays: engine.trashRetentionDays() }); + if (req.method === "GET" && url.pathname === "/api/runs") { + if (url.searchParams.get("trash") === "1") return json(engine.store.listTrash().map(({ script, input, result, fingerprints, ...r }) => r)); + return json(engine.store.list().map(({ script, input, result, fingerprints, ...r }) => r)); + } + const match = url.pathname.match(/^\/api\/runs\/([a-f0-9-]+)(?:\/(wait|pause|cancel|resume|edit|approve|repair|restore|lineage))?$/); if (match && req.method === "GET") { if (match[2] === "wait") return json(await waitEvents(engine, match[1], Math.max(0, Number(url.searchParams.get("after")) || 0), 2e4)); + if (match[2] === "lineage") return json(engine.lineage(match[1], { results: url.searchParams.get("results") === "1" })); return json(engine.snapshot(match[1])); } + if (match && req.method === "DELETE") return json(await engine.deleteRun(match[1], { by: url.searchParams.get("by") ?? "studio" })); if (req.method === "POST") { check(req.headers["content-type"]?.startsWith("application/json"), "\u9700\u8981 application/json"); req.setEncoding("utf8"); @@ -26692,10 +27045,13 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import return json({ deleted: true }); } if (url.pathname === "/api/scheduler") return json(engine.configureScheduler(data3)); + if (url.pathname === "/api/trash") return json(engine.configureTrash(data3)); + if (url.pathname === "/api/archive/rotate") return json(engine.rotateArchive({ verify: data3.verify === true })); if (url.pathname === "/api/tools") return json(await createToolHandler(engine, () => `${origin}/`)(data3.name, data3.arguments)); if (url.pathname === "/api/validate") return json(assertValidDependencies(previewTopology(data3.script))); if (url.pathname === "/api/runs") return json(await engine.start(data3), 201); if (match && match[2] === "repair") return json(await engine.repair(match[1], data3)); + if (match && match[2] === "restore") return json(await engine.restoreRun(match[1], { by: data3.by ?? "studio" })); if (match && match[2] === "edit") return json(await engine.update(match[1], data3)); if (match && match[2] === "approve") return json(await engine.approve(match[1], data3)); if (match && match[2] === "resume") return json(await engine.resume(match[1], data3)); @@ -27645,7 +28001,7 @@ function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) } // src/main.mjs -var { values } = parseArgs({ options: { stdio: { type: "boolean" }, "stop-service": { type: "boolean" }, settings: { type: "string" }, workspace: { type: "string" }, "data-dir": { type: "string" }, port: { type: "string" }, "mcode-script": { type: "string" }, "worker-config": { type: "string" } } }); +var { values } = parseArgs({ options: { stdio: { type: "boolean" }, "stop-service": { type: "boolean" }, settings: { type: "string" }, workspace: { type: "string" }, "data-dir": { type: "string" }, port: { type: "string" }, "mcode-script": { type: "string" }, "worker-config": { type: "string" }, "rotate-archive": { type: "boolean" }, restore: { type: "string" }, verify: { type: "boolean" } } }); var settings = values.settings ? JSON.parse(await readFile2(resolve3(values.settings), "utf8")) : {}; for (const key of Object.keys(settings)) if (!["workspace", "dataDir"].includes(key) || typeof settings[key] !== "string") throw Error("settings \u53EA\u5141\u8BB8 workspace/dataDir \u5B57\u7B26\u4E32"); if (values.port !== void 0 && (!/^\d+$/.test(values.port) || Number(values.port) > 65535)) throw Error("port \u5FC5\u987B\u662F 0\u201365535 \u7684\u6574\u6570"); @@ -27746,13 +28102,49 @@ if (values.stdio && process.env.MCODE_WORKFLOW_CHILD === "1") { if (!service) throw Error(`\u672C\u5730\u670D\u52A1\u542F\u52A8\u5931\u8D25\u3002\u4E0A\u6B21\u7AEF\u53E3\u53EF\u80FD\u88AB\u5360\u7528\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u66F4\u6362\u5730\u5740\u3002\u8BF7\u67E5\u770B ${join4(dataDir, "service.log")}`); } const mcp = await startStdio(async (name, args) => { - if ((name === "workflow_repair" || name === "workflow_results" && args?.includeDefinition) && !service.config.features?.workflowRepair) throw Error("WORKFLOW_SERVICE_UPGRADE_REQUIRED: \u5F53\u524D\u540E\u53F0\u670D\u52A1\u7248\u672C\u4E0D\u652F\u6301\u811A\u672C\u4FEE\u590D\u3002\u9000\u51FA\u804A\u5929\u4E0D\u4F1A\u91CD\u542F\u670D\u52A1\u3002\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u6D3B\u52A8\u5DE5\u4F5C\u6D41\uFF0C\u4F7F\u7528\u65B0\u7248\u63D2\u4EF6\u7684 --stop-service\uFF08\u76F8\u540C --workspace \u548C --data-dir\uFF09\u505C\u6B62\u6B64\u9879\u76EE\u670D\u52A1\uFF0C\u518D\u91CD\u65B0\u8FDE\u63A5 MCP\uFF1B\u7AEF\u53E3\u548C\u5386\u53F2\u4F1A\u4FDD\u7559\u3002data-dir: " + dataDir); + if ((name === "workflow_repair" || name === "workflow_results" && args?.includeDefinition) && !service.config.features?.workflowRepair || (name === "workflow_delete" || name === "workflow_restore") && !service.config.features?.trashManagement || name === "workflow_rerun" && !service.config.features?.rerunLineage) throw Error("WORKFLOW_SERVICE_UPGRADE_REQUIRED: \u5F53\u524D\u540E\u53F0\u670D\u52A1\u7248\u672C\u4E0D\u652F\u6301\u6B64\u64CD\u4F5C\u3002\u9000\u51FA\u804A\u5929\u4E0D\u4F1A\u91CD\u542F\u670D\u52A1\u3002\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u6D3B\u52A8\u5DE5\u4F5C\u6D41\uFF0C\u4F7F\u7528\u65B0\u7248\u63D2\u4EF6\u7684 --stop-service\uFF08\u76F8\u540C --workspace \u548C --data-dir\uFF09\u505C\u6B62\u6B64\u9879\u76EE\u670D\u52A1\uFF0C\u518D\u91CD\u65B0\u8FDE\u63A5 MCP\uFF1B\u7AEF\u53E3\u548C\u5386\u53F2\u4F1A\u4FDD\u7559\u3002data-dir: " + dataDir); const res = await fetch(service.u.origin + "/api/tools", { method: "POST", headers: headersFor(service.u), body: JSON.stringify({ name, arguments: args }), signal: AbortSignal.timeout(65e3) }); const v2 = await res.json(); if (!res.ok) throw Error(v2.error); return v2; }); process.stdin.once("end", () => void mcp.close()); + } else if (values["rotate-archive"] || values.restore !== void 0) { + const service = await existing(); + if (service) { + const call = async (path, init) => { + const res = await fetch(new URL(path, service.u.origin), { ...init, headers: headersFor(service.u), signal: AbortSignal.timeout(65e3) }); + const v2 = await res.json(); + if (!res.ok) throw Error(v2.error); + return v2; + }; + if (values.restore !== void 0) { + const v2 = await call(`/api/runs/${encodeURIComponent(values.restore)}/restore`, { method: "POST", body: JSON.stringify({ by: "cli" }) }); + process.stdout.write(JSON.stringify({ id: v2.id, status: v2.status, restored: true }) + "\n"); + } else { + const v2 = await call("/api/archive/rotate", { method: "POST", body: JSON.stringify({ verify: values.verify === true }) }); + process.stdout.write(JSON.stringify(v2) + "\n"); + if (values.verify === true && (v2.archive?.verified === false || v2.integrity?.events?.verified === false || v2.integrity?.repair?.verified === false)) process.exitCode = 1; + } + } else { + await mkdir(dataDir, { recursive: true, mode: 448 }); + const store = new Store(dataDir); + let engine; + try { + engine = new Engine(store, { workspace }); + if (values.restore !== void 0) { + const v2 = await engine.restoreRun(values.restore, { by: "cli" }); + process.stdout.write(JSON.stringify({ id: v2.id, status: v2.status, restored: true }) + "\n"); + } else { + const v2 = engine.rotateArchive({ verify: values.verify === true }); + process.stdout.write(JSON.stringify(v2) + "\n"); + if (values.verify === true && (v2.archive?.verified === false || v2.integrity?.events?.verified === false || v2.integrity?.repair?.verified === false)) process.exitCode = 1; + } + } finally { + await engine?.close(); + store.close(); + } + } } else { const previous = await readJSON(endpointPath) ?? await readJSON(addressPath); if (previous?.workspace && previous.workspace !== workspace) throw Error("\u72B6\u6001\u76EE\u5F55\u7ED1\u5B9A\u4E86\u4E0D\u540C\u5DE5\u4F5C\u533A\uFF0C\u8BF7\u914D\u7F6E\u72EC\u7ACB data-dir"); @@ -27762,6 +28154,9 @@ if (values.stdio && process.env.MCODE_WORKFLOW_CHILD === "1") { let engine, panel; try { engine = new Engine(store, { workspace, command: values["mcode-script"] ? process.execPath : "mcode", args: values["mcode-script"] ? [resolve3(values["mcode-script"])] : [], configPath: values["worker-config"] ? resolve3(values["worker-config"]) : void 0 }); + const compaction = engine.autoRotateAtStartup(); + if (compaction.rotated) process.stdout.write(`Rotated ${compaction.runCount} trashed workflow(s) into archive.db (rotation ${compaction.rotationId}). +`); panel = await startHTTP(engine, { port }); await saveAddress(panel.url); const temp = endpointPath + "." + process.pid + ".tmp"; diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index a482e4a9..84287f1f 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -16,6 +16,11 @@ import { resolveMcode } from './availability.mjs'; import { DEFAULT_LIMITS, LEGACY_LIMITS, resolveLimits, runLimits, durationLabel } from './limits.mjs'; import { agentFailure,failureError } from './failure.mjs'; import { demoExecute, mcodeExecute } from './executor.mjs'; +import { ROTATE_TOMBSTONE_THRESHOLD,ROTATE_RUNS_BYTES_THRESHOLD } from './store.mjs'; +// Only finished runs may enter the trash. needs_attention stays out: its old +// agents may still require user confirmation, and the run is not done yet. +const DELETABLE=new Set(['succeeded','failed','completed_with_gaps','cancelled','interrupted']); +const TRASH_SOURCES=new Set(['studio','cli','mcp']); export class Engine extends EventEmitter { constructor(store,options){super();this.store=store;this.options=options;this.defaults=resolveLimits(options,DEFAULT_LIMITS);this.globalConcurrency=this.store.setting('globalConcurrency')??8;this.lastServedRun=null;this.approving=new Set();this.active=new Map();this.slots=0;this.queue=[];this.closing=false;} async fingerprints(files=[]) { @@ -34,7 +39,7 @@ export class Engine extends EventEmitter { }finally{await file.close();} }return out; } - async start(request,repair=null,candidates=[]) { + async start(request,repair=null,candidates=[],lineage=null) { check(!this.closing,'服务正在关闭');validateScript(request.script);boundedJSON(request.input??{}); check(typeof request.requestId==='string'&&request.requestId.length<=150&&request.requestId.length>0,'必须提供 requestId'); check(request.input===undefined||(request.input&&typeof request.input==='object'&&!Array.isArray(request.input)),'input 必须为 JSON object'); @@ -47,14 +52,15 @@ export class Engine extends EventEmitter { // Absent unless explicitly true: an always-present default key would change requestHash and break legacy idempotent replays. const reuseAcrossRuns=request.reuseAcrossRuns===undefined?false:(check(typeof request.reuseAcrossRuns==='boolean','reuseAcrossRuns 必须为布尔'),request.reuseAcrossRuns); const definition={...limits,...metadata,name:request.name,script:request.script,input:request.input??{},executor:request.executor,concurrency,maxCalls,...(reuseAcrossRuns?{reuseAcrossRuns:true}:{})};const requestHash=hash(repair?{...definition,repair}:definition); - const existing=this.store.byRequest(request.requestId);if(existing){const legacyDefinition={...definition};for(const key of Object.keys(DEFAULT_LIMITS))delete legacyDefinition[key];check(existing.requestHash===requestHash||(existing.maxSteps===undefined&&Object.keys(DEFAULT_LIMITS).every(k=>request[k]===undefined)&&existing.requestHash===hash(legacyDefinition)),'requestId 已用于不同参数');return this.snapshot(existing.id);} + const existing=this.store.byRequest(request.requestId);if(existing){check(!existing.deletedAt,'requestId 已用于已删除的工作流:请先在回收站恢复它,或更换 requestId');const legacyDefinition={...definition};for(const key of Object.keys(DEFAULT_LIMITS))delete legacyDefinition[key];check(existing.requestHash===requestHash||(existing.maxSteps===undefined&&Object.keys(DEFAULT_LIMITS).every(k=>request[k]===undefined)&&existing.requestHash===hash(legacyDefinition)),'requestId 已用于不同参数');return this.snapshot(existing.id);} const fingerprints={};const topology=assertValidDependencies(previewTopology(request.script,request.input??{})); check(!this.closing,'服务正在关闭'); - const run={...(repair?{repair}:{}),id:randomUUID(),requestId:request.requestId,requestHash,...definition,scriptHash:hash(request.script),fingerprints,workspace:this.options.workspace,revision:1,topology,status:'pending_review',createdAt:Date.now(),updatedAt:Date.now(),attempts:0,phases:[],result:null,error:null}; - this.store.transaction(()=>{this.store.save(run);for(const step of candidates)this.store.saveRepairCandidate(run.id,step);this.store.event(run.id,'run.created',{name:run.name});});return this.snapshot(run.id); + const run={...(repair?{repair}:{}),...(lineage?{rerunOf:lineage.rerunOf,lineageRoot:lineage.lineageRoot,rerunSeq:lineage.rerunSeq}:{}),id:randomUUID(),requestId:request.requestId,requestHash,...definition,scriptHash:hash(request.script),fingerprints,workspace:this.options.workspace,revision:1,topology,status:'pending_review',createdAt:Date.now(),updatedAt:Date.now(),attempts:0,phases:[],result:null,error:null}; + this.store.transaction(()=>{this.store.save(run);for(const step of candidates)this.store.saveRepairCandidate(run.id,step);this.store.event(run.id,'run.created',{name:run.name,...(lineage?{rerunOf:lineage.rerunOf,lineageRoot:lineage.lineageRoot,rerunSeq:lineage.rerunSeq}:{})});});return this.snapshot(run.id); } async repair(id,request) { check(!this.closing,'服务正在关闭');const source=this.store.get(id);check(source,'工作流不存在'); + check(!source.deletedAt,'工作流已删除,请先在回收站恢复后再修复'); check(!this.active.has(id)&&['failed','paused','interrupted','cancelled','completed_with_gaps','succeeded'].includes(source.status),'请先停止运行;异常退出须先确认旧 Agent 已停止并恢复或暂停'); check(source.workspace===this.options.workspace,'工作区不匹配'); check(request.sourceUpdatedAt===source.updatedAt,'源运行已更新,请刷新后再修复'); @@ -103,7 +109,7 @@ export class Engine extends EventEmitter { Object.assign(run,{fingerprints,approvedRevision:revision,approvedAt:Date.now()});this.save(run);this.emitEvent(id,'run.approved',{revision});this.launch(run);return this.snapshot(id); } finally {this.approving.delete(id);} } - snapshot(id){const run=this.store.get(id);check(run,'工作流不存在');const limits=runLimits(run),topology=run.topology?.version===3?run.topology:previewTopology(run.script,run.input);return {...run,topology,...historicalFailure(run,topology),...limits,legacyLimits:run.maxSteps===undefined,scheduler:this.schedulerStatus(),steps:this.store.steps(id).map(stored=>{const s=stored.kind==='agent'?{...stored,maxSteps:stored.maxSteps??LEGACY_LIMITS.maxSteps,timeoutMs:stored.timeoutMs??LEGACY_LIMITS.stepTimeoutMs}:stored;if(!s.errorDetails&&/^MCode (limit_exceeded|timeout)$/.test(s.error??'')){const errorDetails=agentFailure(s.error.slice(6),{maxSteps:s.maxSteps??limits.maxSteps,timeoutMs:s.timeoutMs??limits.stepTimeoutMs,sessionId:s.sessionId,turnId:s.turnId});return {...s,error:errorDetails.message,errorDetails};}return s.status==='queued'?{...s,queueInfo:this.queueInfo(id,s.id)}:s;})};} + snapshot(id){const run=this.store.get(id);check(run,'工作流不存在');check(!run.deletedAt,'工作流已删除,可在回收站恢复后查看');const limits=runLimits(run),topology=run.topology?.version===3?run.topology:previewTopology(run.script,run.input);return {...run,topology,...historicalFailure(run,topology),...limits,legacyLimits:run.maxSteps===undefined,scheduler:this.schedulerStatus(),steps:this.store.steps(id).map(stored=>{const s=stored.kind==='agent'?{...stored,maxSteps:stored.maxSteps??LEGACY_LIMITS.maxSteps,timeoutMs:stored.timeoutMs??LEGACY_LIMITS.stepTimeoutMs}:stored;if(!s.errorDetails&&/^MCode (limit_exceeded|timeout)$/.test(s.error??'')){const errorDetails=agentFailure(s.error.slice(6),{maxSteps:s.maxSteps??limits.maxSteps,timeoutMs:s.timeoutMs??limits.stepTimeoutMs,sessionId:s.sessionId,turnId:s.turnId});return {...s,error:errorDetails.message,errorDetails};}return s.status==='queued'?{...s,queueInfo:this.queueInfo(id,s.id)}:s;})};} queueInfo(runId,stepId){ const ticket=this.queue.find(t=>t.ctx.run.id===runId&&t.stepId===stepId),ctx=this.active.get(runId); return {reason:this.slots>=this.globalConcurrency?'global_capacity':ctx&&ctx.slots>=ctx.run.concurrency?'run_capacity':'dispatch',globalActive:this.slots,globalLimit:this.globalConcurrency,runActive:ctx?.slots??0,runLimit:ctx?.run.concurrency??0,position:ticket?this.queue.indexOf(ticket)+1:null,blockingRuns:[...this.active.values()].filter(c=>c.slots>0).map(c=>({id:c.run.id,name:c.run.name,active:c.slots}))}; @@ -116,10 +122,129 @@ export class Engine extends EventEmitter { });} schedulerStatus(){return {active:this.slots,limit:this.globalConcurrency,queued:this.queue.length,perRunDefault:4,perRunMax:16};} configureScheduler({globalConcurrency}){ - check(!this.closing,'服务正在关闭');check(Number.isInteger(globalConcurrency)&&globalConcurrency>=1&&globalConcurrency<=32,'全局并发须为 1–32 的整数'); - this.store.saveSetting('globalConcurrency',globalConcurrency);this.globalConcurrency=globalConcurrency; - // Reducing capacity never interrupts a running agent. Only new dispatch is limited. - this.drain();return this.schedulerStatus(); + check(!this.closing,'服务正在关闭');check(Number.isInteger(globalConcurrency)&&globalConcurrency>=1&&globalConcurrency<=32,'全局并发须为 1–32 的整数'); + this.store.saveSetting('globalConcurrency',globalConcurrency);this.globalConcurrency=globalConcurrency; + // Reducing capacity never interrupts a running agent. Only new dispatch is limited. + this.drain();return this.schedulerStatus(); + } + // Trash retention in days. Default 30; 0 disables the expiry clock entirely + // (tombstones then leave only through explicit manual rotation). A corrupted + // stored value falls back to the default instead of poisoning purge stamps. + trashRetentionDays() {const value=this.store.setting('trashRetentionDays');return Number.isInteger(value)&&value>=0&&value<=3650?value:30;} + configureTrash({trashRetentionDays}) { + check(!this.closing,'服务正在关闭');check(Number.isInteger(trashRetentionDays)&&trashRetentionDays>=0&&trashRetentionDays<=3650,'回收站保留期须为 0–3650 的整数天(0 表示仅手动轮转)'); + this.store.saveSetting('trashRetentionDays',trashRetentionDays); + // The setting governs trash that already exists, not only future deletions: + // pending tombstones are restamped so the displayed countdown stays truthful. + this.store.restampTrashPurge(trashRetentionDays);return {trashRetentionDays}; + } + // Tombstone soft delete. Steps, events, result and the integrity ledger all + // stay untouched — only the run body gains deletedAt/deletedBy/purgeAfter and + // one append-only run.deleted audit event. Repeat deletes are idempotent and + // never append a second event. + async deleteRun(id,{by='studio'}={}) { + check(!this.closing,'服务正在关闭');check(TRASH_SOURCES.has(by),'无效的删除来源'); + const run=this.store.get(id); + if(!run){ + // Already rotated past the trash: report the archive state truthfully. + const archived=this.store.archiveOrigin(id);check(archived,'工作流不存在'); + return {id,deleted:true,alreadyDeleted:true,archived:true,rotationId:archived.rotationId}; + } + if(run.deletedAt)return {id,deleted:true,alreadyDeleted:true,deletedAt:run.deletedAt,deletedBy:run.deletedBy,purgeAfter:run.purgeAfter}; + check(!this.active.has(id),'工作流仍在运行,请先暂停或取消后再删除'); + check(DELETABLE.has(run.status),'仅已完成的工作流可删除(运行中或待审核不可删除)'); + const days=this.trashRetentionDays(); + run.deletedAt=Date.now();run.deletedBy=by;run.purgeAfter=run.deletedAt+days*86400000; + this.save(run);this.emitEvent(id,'run.deleted',{by,purgeAfter:run.purgeAfter}); + return {id,deleted:true,alreadyDeleted:false,deletedAt:run.deletedAt,purgeAfter:run.purgeAfter}; + } + // Restore clears the tombstone and appends run.restored. Everything else was + // never removed, so the run reappears byte-identical on every query face. + async restoreRun(id,{by='studio'}={}) { + check(!this.closing,'服务正在关闭');check(TRASH_SOURCES.has(by),'无效的恢复来源'); + const run=this.store.get(id); + if(run){ + check(run.deletedAt,'工作流未删除,无需恢复'); + delete run.deletedAt;delete run.deletedBy;delete run.purgeAfter; + this.save(run);this.emitEvent(id,'run.restored',{by,origin:'trash'}); + return this.snapshot(id); + } + // Not live: restore from the sidecar archive when the run was rotated. + const archived=this.store.restoreArchived(id,{by}); + check(archived,'工作流不存在或未归档'); + return this.snapshot(id); + } + // Rerun: start a NEW pending_review run from the source's script+input. The + // source is never modified — the child only carries rerunOf/lineageRoot/ + // rerunSeq so the family stays queryable and comparable. The synthesized + // requestId is `#rerun-` (n = highest existing seq + 1, tombstoned + // AND archived members counted so a seq is never reused); a collision with + // an already-claimed requestId retries with a numeric suffix. + async rerun(id,{input,reuseAcrossRuns,name}={}) { + check(!this.closing,'服务正在关闭'); + check(input===undefined||(input&&typeof input==='object'&&!Array.isArray(input)),'input 必须为 JSON object'); + check(reuseAcrossRuns===undefined||typeof reuseAcrossRuns==='boolean','reuseAcrossRuns 必须为布尔'); + check(name===undefined||(typeof name==='string'&&name.trim().length>0&&name.length<=120),'名称须为 1–120 字符'); + const source=this.store.get(id); + if(!source){ + const archived=this.store.archivedRun(id); + check(archived,'工作流不存在'); + check(false,'工作流已轮转归档,请先从归档恢复后再复跑'); + } + check(!source.deletedAt,'源工作流已删除,请先在回收站恢复后再复跑'); + check(source.workspace===this.options.workspace,'工作区不匹配'); + const rootId=source.lineageRoot??source.id; + const live=this.store.lineageMembers(rootId),archived=this.store.archivedLineageMembers(rootId); + const seq=Math.max(0,...live.map(m=>m.rerunSeq??0),...archived.map(({run})=>run.rerunSeq??0))+1; + const root=live.find(m=>m.id===rootId)?.requestId??archived.find(({run})=>run.id===rootId)?.run.requestId??source.requestId; + const suffix=`#rerun-${seq}`; + const base=(root.length+suffix.length<=150?root:root.slice(0,150-suffix.length))+suffix; + let requestId=base; + for(let attempt=2;this.store.byRequest(requestId);attempt++)requestId=`${base}-${attempt}`.slice(0,150); + // reuseAcrossRuns defaults to false on reruns: silently adopting the source's + // nodes would turn the rerun into a fake execution and poison comparison. + return this.start({...templateDefinition(source),...(input!==undefined?{input}:{}),...(name!==undefined?{name}:{}),...(reuseAcrossRuns===undefined?{}:{reuseAcrossRuns}),requestId},null,[],{rerunOf:source.id,lineageRoot:rootId,rerunSeq:seq}); + } + // Family face: the lineage root plus every member (live, tombstoned and + // archived), ordered by rerunSeq. Deleting or rotating a member never removes + // it from the family — it is only annotated. Resolvable from any member id, + // including tombstoned and archived ones. `results` adds each member's full + // result for the read-only compare face (summaries otherwise). + lineage(id,{results=false}={}) { + const liveHit=this.store.get(id); + let rootId; + if(liveHit)rootId=liveHit.lineageRoot??liveHit.id; + else{const archived=this.store.archivedRun(id);check(archived,'工作流不存在');rootId=archived.run.lineageRoot??archived.run.id;} + const entry=(run,rotationId)=>{ + const times=this.store.runTimes(run.id); + return {id:run.id,requestId:run.requestId,name:run.name,rerunOf:run.rerunOf??null,rerunSeq:run.rerunSeq??0,status:run.status,executor:run.executor, + createdAt:run.createdAt,updatedAt:run.updatedAt,startedAt:times.startedAt,finishedAt:times.finishedAt, + durationMs:times.startedAt!=null&×.finishedAt!=null?times.finishedAt-times.startedAt:null, + resultPreview:run.result==null?null:String(typeof run.result==='string'?run.result:JSON.stringify(run.result)).slice(0,200), + ...(results?{result:run.result??null}:{}), + deleted:run.deletedAt?{deletedAt:run.deletedAt,deletedBy:run.deletedBy,purgeAfter:run.purgeAfter}:null, + archived:rotationId?{rotationId}:null};}; + const members=[...this.store.lineageMembers(rootId).map(run=>entry(run,null)),...this.store.archivedLineageMembers(rootId).map(({run,rotationId})=>entry(run,rotationId))]; + members.sort((a,b)=>a.rerunSeq-b.rerunSeq||(a.createdAt??0)-(b.createdAt??0)||(a.id({...t,objective:definition.metadata?.objective??''}))); const template=url.pathname.match(/^\/api\/templates\/([a-f0-9-]+)$/); if(template&&req.method==='GET'){const value=engine.store.template(template[1]);check(value,'模板不存在');return json(value);} const report=url.pathname.match(/^\/api\/runs\/([a-f0-9-]+)\/report$/); if(report&&req.method==='GET'){const run=engine.snapshot(report[1]),file=exportReport(run,run.steps,{format:url.searchParams.get('format')??'html',language:url.searchParams.get('language')??'en'});res.writeHead(200,{'Content-Type':file.contentType,'Content-Disposition':`attachment; filename="${file.filename}"`,'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});return res.end(file.body);} if(req.method==='GET'&&url.pathname==='/api/scheduler')return json(engine.schedulerStatus()); - if(req.method==='GET'&&url.pathname==='/api/runs')return json(engine.store.list().map(({script,input,result,fingerprints,...r})=>r)); - const match=url.pathname.match(/^\/api\/runs\/([a-f0-9-]+)(?:\/(wait|pause|cancel|resume|edit|approve|repair))?$/); - if(match&&req.method==='GET'){if(match[2]==='wait')return json(await waitEvents(engine,match[1],Math.max(0,Number(url.searchParams.get('after'))||0),20000));return json(engine.snapshot(match[1]));} + if(req.method==='GET'&&url.pathname==='/api/trash')return json({trashRetentionDays:engine.trashRetentionDays()}); + if(req.method==='GET'&&url.pathname==='/api/runs'){if(url.searchParams.get('trash')==='1')return json(engine.store.listTrash().map(({script,input,result,fingerprints,...r})=>r));return json(engine.store.list().map(({script,input,result,fingerprints,...r})=>r));} + const match=url.pathname.match(/^\/api\/runs\/([a-f0-9-]+)(?:\/(wait|pause|cancel|resume|edit|approve|repair|restore|lineage))?$/); + if(match&&req.method==='GET'){if(match[2]==='wait')return json(await waitEvents(engine,match[1],Math.max(0,Number(url.searchParams.get('after'))||0),20000));if(match[2]==='lineage')return json(engine.lineage(match[1],{results:url.searchParams.get('results')==='1'}));return json(engine.snapshot(match[1]));} + if(match&&req.method==='DELETE')return json(await engine.deleteRun(match[1],{by:url.searchParams.get('by')??'studio'})); if(req.method==='POST'){ check(req.headers['content-type']?.startsWith('application/json'),'需要 application/json');req.setEncoding('utf8');let body='';for await(const chunk of req){body+=chunk;check(Buffer.byteLength(body)<=700_000,'请求过大');}const data=JSON.parse(body||'{}'); if(url.pathname==='/api/templates')return json(engine.saveTemplate(data.runId,data),201); if(template){check(data.action==='delete','模板操作无效');check(engine.store.deleteTemplate(template[1]),'模板不存在');return json({deleted:true});} if(url.pathname==='/api/scheduler')return json(engine.configureScheduler(data)); + if(url.pathname==='/api/trash')return json(engine.configureTrash(data)); + if(url.pathname==='/api/archive/rotate')return json(engine.rotateArchive({verify:data.verify===true})); if(url.pathname==='/api/tools')return json(await createToolHandler(engine,()=>`${origin}/`)(data.name,data.arguments)); if(url.pathname==='/api/validate')return json(assertValidDependencies(previewTopology(data.script))); if(url.pathname==='/api/runs')return json(await engine.start(data),201); if(match&&match[2]==='repair')return json(await engine.repair(match[1],data)); + if(match&&match[2]==='restore')return json(await engine.restoreRun(match[1],{by:data.by??'studio'})); if(match&&match[2]==='edit')return json(await engine.update(match[1],data)); if(match&&match[2]==='approve')return json(await engine.approve(match[1],data)); if(match&&match[2]==='resume')return json(await engine.resume(match[1],data)); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs index de57baca..48e24eda 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs @@ -9,7 +9,7 @@ import { Engine } from './engine.mjs'; import { startHTTP } from './http.mjs'; import { startStdio } from './tools.mjs'; import {createWorkspaceRouter,PROJECT_TOOLS} from './workspace-router.mjs'; -const {values}=parseArgs({options:{stdio:{type:'boolean'},'stop-service':{type:'boolean'},settings:{type:'string'},workspace:{type:'string'},'data-dir':{type:'string'},port:{type:'string'},'mcode-script':{type:'string'},'worker-config':{type:'string'}}}); +const {values}=parseArgs({options:{stdio:{type:'boolean'},'stop-service':{type:'boolean'},settings:{type:'string'},workspace:{type:'string'},'data-dir':{type:'string'},port:{type:'string'},'mcode-script':{type:'string'},'worker-config':{type:'string'},'rotate-archive':{type:'boolean'},restore:{type:'string'},verify:{type:'boolean'}}}); const settings=values.settings?JSON.parse(await readFile(resolve(values.settings),'utf8')):{}; for(const key of Object.keys(settings))if(!['workspace','dataDir'].includes(key)||typeof settings[key]!=='string')throw Error('settings 只允许 workspace/dataDir 字符串'); if(values.port!==undefined&&(!/^\d+$/.test(values.port)||Number(values.port)>65535))throw Error('port 必须是 0–65535 的整数'); @@ -70,12 +70,49 @@ if(values.stdio&&process.env.MCODE_WORKFLOW_CHILD==='1'){ if(!service)throw Error(`本地服务启动失败。上次端口可能被占用;不会自动更换地址。请查看 ${join(dataDir,'service.log')}`); } const mcp=await startStdio(async(name,args)=>{ - if((name==='workflow_repair'||(name==='workflow_results'&&args?.includeDefinition))&&!service.config.features?.workflowRepair)throw Error('WORKFLOW_SERVICE_UPGRADE_REQUIRED: 当前后台服务版本不支持脚本修复。退出聊天不会重启服务。请先暂停或取消活动工作流,使用新版插件的 --stop-service(相同 --workspace 和 --data-dir)停止此项目服务,再重新连接 MCP;端口和历史会保留。data-dir: '+dataDir); + if(((name==='workflow_repair'||(name==='workflow_results'&&args?.includeDefinition))&&!service.config.features?.workflowRepair) + ||((name==='workflow_delete'||name==='workflow_restore')&&!service.config.features?.trashManagement) + ||(name==='workflow_rerun'&&!service.config.features?.rerunLineage))throw Error('WORKFLOW_SERVICE_UPGRADE_REQUIRED: 当前后台服务版本不支持此操作。退出聊天不会重启服务。请先暂停或取消活动工作流,使用新版插件的 --stop-service(相同 --workspace 和 --data-dir)停止此项目服务,再重新连接 MCP;端口和历史会保留。data-dir: '+dataDir); const res=await fetch(service.u.origin+'/api/tools',{method:'POST',headers:headersFor(service.u),body:JSON.stringify({name,arguments:args}),signal:AbortSignal.timeout(65000)}); const v=await res.json();if(!res.ok)throw Error(v.error);return v; }); // A chat owns only this transport. The service, workers and dashboard outlive it. process.stdin.once('end',()=>void mcp.close()); + }else if(values['rotate-archive']||values.restore!==undefined){ + // One-shot maintenance face: rotate due tombstones into the archive + // (--rotate-archive, optionally --verify) or restore a run from trash or + // archive (--restore ). Forwards to a live service when one owns the + // directory; otherwise takes the owner lock directly. Never both. + const service=await existing(); + if(service){ + const call=async(path,init)=>{const res=await fetch(new URL(path,service.u.origin),{...init,headers:headersFor(service.u),signal:AbortSignal.timeout(65000)});const v=await res.json();if(!res.ok)throw Error(v.error);return v;}; + if(values.restore!==undefined){ + const v=await call(`/api/runs/${encodeURIComponent(values.restore)}/restore`,{method:'POST',body:JSON.stringify({by:'cli'})}); + process.stdout.write(JSON.stringify({id:v.id,status:v.status,restored:true})+'\n'); + }else{ + const v=await call('/api/archive/rotate',{method:'POST',body:JSON.stringify({verify:values.verify===true})}); + process.stdout.write(JSON.stringify(v)+'\n'); + // verified:null means "no chain rows to verify" (e.g. an empty repair face); + // only an explicit false verdict is a failure. + if(values.verify===true&&(v.archive?.verified===false||v.integrity?.events?.verified===false||v.integrity?.repair?.verified===false))process.exitCode=1; + } + }else{ + await mkdir(dataDir,{recursive:true,mode:0o700}); + const store=new Store(dataDir);let engine; + try{ + engine=new Engine(store,{workspace}); + if(values.restore!==undefined){ + const v=await engine.restoreRun(values.restore,{by:'cli'}); + process.stdout.write(JSON.stringify({id:v.id,status:v.status,restored:true})+'\n'); + }else{ + const v=engine.rotateArchive({verify:values.verify===true}); + process.stdout.write(JSON.stringify(v)+'\n'); + // verified:null means "no chain rows to verify" (e.g. an empty repair face); + // only an explicit false verdict is a failure. + if(values.verify===true&&(v.archive?.verified===false||v.integrity?.events?.verified===false||v.integrity?.repair?.verified===false))process.exitCode=1; + } + }finally{await engine?.close();store.close();} + } }else{ const previous=(await readJSON(endpointPath))??await readJSON(addressPath); if(previous?.workspace&&previous.workspace!==workspace)throw Error('状态目录绑定了不同工作区,请配置独立 data-dir'); @@ -84,6 +121,11 @@ if(values.stdio&&process.env.MCODE_WORKFLOW_CHILD==='1'){ const store=new Store(dataDir);let engine,panel; try{ engine=new Engine(store,{workspace,command:values['mcode-script']?process.execPath:'mcode',args:values['mcode-script']?[resolve(values['mcode-script'])]:[],configPath:values['worker-config']?resolve(values['worker-config']):undefined}); + // Startup compaction: expired tombstones past the volume thresholds are + // rotated into archive.db before the dashboard starts serving. Failure is + // not swallowed — an unrotatable library fails the service start loudly. + const compaction=engine.autoRotateAtStartup(); + if(compaction.rotated)process.stdout.write(`Rotated ${compaction.runCount} trashed workflow(s) into archive.db (rotation ${compaction.rotationId}).\n`); panel=await startHTTP(engine,{port}); await saveAddress(panel.url); const temp=endpointPath+'.'+process.pid+'.tmp';await writeFile(temp,JSON.stringify({pid:process.pid,url:panel.url,workspace,serviceProtocol:2}),{mode:0o600});await rename(temp,endpointPath); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 5ac9fc16..598dc262 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -1,7 +1,22 @@ import { DatabaseSync } from 'node:sqlite'; -import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; +import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; +import { hash } from './common.mjs'; +// Startup auto-rotation thresholds (checked once per service start). Trash +// volume: 500 tombstones is roughly 1.5 years at ten finished runs per day — +// past that the trash listing and the due-scan stop being small state. Library +// volume: ~100MB of runs rows is where list()/snapshot() JSON payloads start +// to tax the dashboard on commodity hardware. Both are coarse trip-wires, not +// quotas: below them the retention clock (purgeAfter) alone decides, and +// rotation otherwise happens only through the explicit CLI face. +export const ROTATE_TOMBSTONE_THRESHOLD=500; +export const ROTATE_RUNS_BYTES_THRESHOLD=100*1024*1024; +// Canonical manifest hash over exported runs+steps rows in deterministic +// (runId, step id) order. Rotation and verification share this single +// implementation so the two hashes can never drift; checks recomputes it +// independently over the raw archive rows. +export function archiveManifestHash(entries) {return hash(entries);} export class Store { constructor(dir) { mkdirSync(dir,{recursive:true,mode:0o700}); this.lock=join(dir,'owner.lock'); @@ -12,7 +27,7 @@ export class Store { if(alive) throw new Error('同一状态目录已有运行中的服务,请连接既有服务'); unlinkSync(this.lock); this.fd=openSync(this.lock,'wx',0o600); } - this.owner=randomUUID();this.txDepth=0; + this.owner=randomUUID();this.txDepth=0;this.archivePath=join(dir,'archive.db'); try { writeFileSync(this.fd,JSON.stringify({pid:process.pid,owner:this.owner})); this.db=new DatabaseSync(join(dir,'workflows.sqlite')); @@ -45,14 +60,53 @@ export class Store { save(run) {this.db.prepare('INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body').run(run.id,run.requestId,run.requestHash,JSON.stringify(run));} get(id) {const r=this.db.prepare('SELECT body FROM runs WHERE id=?').get(id);return r ? JSON.parse(r.body):null;} byRequest(id) {const r=this.db.prepare('SELECT body FROM runs WHERE requestId=?').get(id);return r ? JSON.parse(r.body):null;} - list() {return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map(r=>JSON.parse(r.body));} + // Tombstoned runs (deletedAt stamped) never appear in the live list; the + // trash listing below is their only index face. + list() {return this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.deletedAt') IS NULL ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map(r=>JSON.parse(r.body));} + listTrash() {return this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL ORDER BY json_extract(body,'$.deletedAt') DESC LIMIT 100").all().map(r=>JSON.parse(r.body));} + restampTrashPurge(days) {this.transaction(()=>{for(const row of this.db.prepare("SELECT id,body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL").all()){const run=JSON.parse(row.body);this.db.prepare('UPDATE runs SET body=? WHERE id=?').run(JSON.stringify({...run,purgeAfter:run.deletedAt+days*86400000}),row.id);}});} + tombstoneCount() {return Number(this.db.prepare("SELECT COUNT(*) AS n FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL").get().n);} + // Lineage face: every family member of a root still in the live library. + // Tombstoned members are included on purpose — trash hides runs from the + // live listings but never breaks a family. + lineageMembers(root) {return this.db.prepare("SELECT body FROM runs WHERE id=? OR json_extract(body,'$.lineageRoot')=? ORDER BY COALESCE(json_extract(body,'$.rerunSeq'),0),rowid").all(root,root).map(r=>JSON.parse(r.body));} + // Family members that exist only in the sidecar archive (rotated away, not + // yet restored): latest archive copy per runId, never including runs that + // are live again, so a restored member is never listed twice. + archivedLineageMembers(root) { + if(!existsSync(this.archivePath))return []; + const live=new Set(this.db.prepare('SELECT id FROM runs').all().map(r=>r.id)); + const latest=new Map(); + for(const row of this.archive().prepare("SELECT a.runId AS runId,a.rotationId AS rotationId,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? OR json_extract(a.body,'$.lineageRoot')=? ORDER BY r.rotatedAt,a.rotationId").all(root,root)) + if(!live.has(row.runId))latest.set(row.runId,{run:JSON.parse(row.body),rotationId:row.rotationId}); + return [...latest.values()]; + } + // One archived run's latest copy, or null. Read-only; does not create the + // archive database just to answer negatively. + archivedRun(runId) { + if(!existsSync(this.archivePath))return null; + const row=this.archive().prepare('SELECT a.rotationId AS rotationId,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1').get(runId); + return row?{run:JSON.parse(row.body),rotationId:row.rotationId}:null; + } + // First run.started / last run.finished timestamps from the events ledger. + // Events never leave the live database (rotation only moves runs/steps), so + // archived family members keep resolving through this face. + runTimes(runId) {return { + startedAt:this.db.prepare("SELECT json_extract(body,'$.time') AS t FROM events WHERE runId=? AND json_extract(body,'$.type')='run.started' ORDER BY seq LIMIT 1").get(runId)?.t??null, + finishedAt:this.db.prepare("SELECT json_extract(body,'$.time') AS t FROM events WHERE runId=? AND json_extract(body,'$.type')='run.finished' ORDER BY seq DESC LIMIT 1").get(runId)?.t??null};} + // Size proxy for the runs table: body bytes plus a fixed per-row overhead + // allowance (row header, id/request columns). SQLite exposes no exact + // per-table page accounting; a proxy is sufficient for a coarse trigger. + runsBytes() {return Number(this.db.prepare('SELECT COALESCE(SUM(LENGTH(body)),0)+COUNT(*)*100 AS n FROM runs').get().n);} step(runId,id) {const r=this.db.prepare('SELECT body FROM steps WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} steps(runId) {return this.db.prepare('SELECT body FROM steps WHERE runId=? ORDER BY rowid').all(runId).map(r=>JSON.parse(r.body));} // All match keys (contextHash, lineageHash) are stamped on the step body at // creation, so filtering happens in SQL and LIMIT applies after the full match. // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is - // an opt-in feature and older steps are not candidates. - findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} + // an opt-in feature and older steps are not candidates. Tombstoned source runs + // are excluded here too: a trashed run's steps must not resurface as reuse + // candidates (ghost data) until the run is restored. + findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? AND NOT EXISTS(SELECT 1 FROM runs WHERE runs.id=steps.runId AND json_extract(runs.body,'$.deletedAt') IS NOT NULL) ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} saveStep(runId,step) {this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body').run(runId,step.id,JSON.stringify(step));} repairCandidate(runId,id) {const r=this.db.prepare('SELECT body FROM repair_cache WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} saveRepairCandidate(runId,step) {this.transaction(()=>{const rowid=Number(this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step)).lastInsertRowid);this.chainAdvance('repair','repair','SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid',rowid,r=>`${r.runId}/${r.id}`);});} @@ -93,8 +147,114 @@ export class Store { const verified=!firstDivergence&&prev===rec.head&&unchained===0; return {head:rec.head,upto:rec.upto,verified,checked:rows.length,unchained,firstDivergence};}; return {events:face('event','events','events','seq','SELECT runId,body FROM events WHERE seq=?',(row,pos)=>`${row.runId}:${pos}`), - repair:face('repair','repair','repair_cache','rowid','SELECT runId,id,body FROM repair_cache WHERE rowid=?',row=>`${row.runId}/${row.id}`)}; + repair:face('repair','repair','repair_cache','rowid','SELECT runId,id,body FROM repair_cache WHERE rowid=?',row=>`${row.runId}/${row.id}`), + // Archive face: the events/repair ledgers are untouched by rotation + // (events never move), so this face only cross-checks the sidecar + // archive against the manifest hashes anchored on the events chain. + archive:this.verifyArchive()}; + } + // Sidecar archive for rotated tombstones: same data directory, separate + // database. runs/steps rows move here verbatim; events NEVER leave the live + // database. Each rotation writes its own copies keyed by (rotationId, runId) + // rather than a plain runs PK, so a run that is restored, re-deleted and + // re-rotated can never rewrite rows an earlier rotation's manifestHash still + // covers — every historical manifest stays independently verifiable. + archive() { + if(this.archiveDb)return this.archiveDb; + const db=new DatabaseSync(this.archivePath); + db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS archive_runs(rotationId TEXT NOT NULL,runId TEXT NOT NULL,requestId TEXT NOT NULL,requestHash TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(rotationId,runId)); + CREATE TABLE IF NOT EXISTS archive_steps(rotationId TEXT NOT NULL,runId TEXT NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(rotationId,runId,id)); + CREATE TABLE IF NOT EXISTS rotations(rotationId TEXT PRIMARY KEY,rotatedAt INTEGER NOT NULL,manifestHash TEXT NOT NULL,runCount INTEGER NOT NULL,bytes INTEGER NOT NULL);`); + this.archiveDb=db;return db; + } + // Rotate every due tombstone (purgeAfter <= now) into the archive and drop + // its live runs/steps rows. Ordering is archive-first, live-delete-second: a + // crash in between only leaves tombstones in place; the next rotation + // supersedes the stale archive copy under a fresh rotationId. The audit + // event is appended in the SAME live transaction as the deletes, so the + // events chain always reflects exactly what left the library. + rotateDue({now=Date.now()}={}) { + const due=this.db.prepare("SELECT id,requestId,requestHash,body FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL AND json_extract(body,'$.purgeAfter')<=? ORDER BY id").all(now); + if(!due.length)return {rotated:false,runCount:0,runs:[],rotationId:null,manifestHash:null,bytes:0}; + const rotationId=randomUUID(),readSteps=this.db.prepare('SELECT id,body FROM steps WHERE runId=? ORDER BY id'); + const entries=due.map(r=>({run:{id:r.id,requestId:r.requestId,requestHash:r.requestHash,body:r.body},steps:readSteps.all(r.id)})); + const manifestHash=archiveManifestHash(entries); + const bytes=entries.reduce((n,e)=>n+Buffer.byteLength(e.run.body)+e.steps.reduce((m,s)=>m+Buffer.byteLength(s.body),0),0); + const archive=this.archive(); + archive.exec('BEGIN IMMEDIATE'); + try{ + const insertRun=archive.prepare('INSERT OR REPLACE INTO archive_runs VALUES(?,?,?,?,?)'),insertStep=archive.prepare('INSERT OR REPLACE INTO archive_steps VALUES(?,?,?,?)'); + for(const entry of entries){insertRun.run(rotationId,entry.run.id,entry.run.requestId,entry.run.requestHash,entry.run.body);for(const step of entry.steps)insertStep.run(rotationId,entry.run.id,step.id,step.body);} + archive.prepare('INSERT INTO rotations VALUES(?,?,?,?,?)').run(rotationId,Date.now(),manifestHash,entries.length,bytes); + archive.exec('COMMIT'); + }catch(e){archive.exec('ROLLBACK');throw e;} + this.transaction(()=>{ + const deleteSteps=this.db.prepare('DELETE FROM steps WHERE runId=?'),deleteRun=this.db.prepare('DELETE FROM runs WHERE id=?'); + for(const entry of entries){deleteSteps.run(entry.run.id);deleteRun.run(entry.run.id);} + this.event(rotationId,'archive.rotated',{runs:entries.map(entry=>entry.run.id),manifestHash,runCount:entries.length,bytes}); + }); + return {rotated:true,runCount:entries.length,runs:entries.map(entry=>entry.run.id),rotationId,manifestHash,bytes}; + } + // Archive verification recomputes each rotation's manifest from the archived + // rows and compares it against BOTH the rotations record (tamperable sidecar) + // and the archive.rotated event anchored on the events hash chain (the trust + // anchor). Extra cross-checks: every chained rotation must still have its + // rotations record, and archive rows may not exist outside known rotations, + // so deleting archive history fails closed too. + verifyArchive() { + if(!existsSync(this.archivePath))return {exists:false,rotations:0,checked:0,verified:true,results:[],divergences:[]}; + const archive=this.archive(); + const rotations=archive.prepare('SELECT rotationId,manifestHash,runCount FROM rotations ORDER BY rotationId').all(); + const results=[],divergences=[]; + for(const rotation of rotations){ + const audit=this.db.prepare('SELECT body FROM events WHERE runId=?').all(rotation.rotationId).map(e=>JSON.parse(e.body)).filter(e=>e.type==='archive.rotated'); + const entries=archive.prepare('SELECT runId AS id,requestId,requestHash,body FROM archive_runs WHERE rotationId=? ORDER BY runId').all(rotation.rotationId) + .map(run=>({run,steps:archive.prepare('SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id').all(rotation.rotationId,run.id)})); + const actual=archiveManifestHash(entries),event=audit[0],problems=[]; + if(audit.length!==1)problems.push(`expected exactly one archive.rotated event, found ${audit.length}`); + if(event&&event.manifestHash!==rotation.manifestHash)problems.push('rotations.manifestHash differs from the chained event'); + if(event&&event.manifestHash!==actual)problems.push('archived rows recompute to a different manifest'); + if(event&&event.runCount!==rotation.runCount)problems.push('event runCount differs from the rotations record'); + if(entries.length!==rotation.runCount)problems.push(`archived ${entries.length} run rows for runCount ${rotation.runCount}`); + if(problems.length)divergences.push(`rotation ${rotation.rotationId}: ${problems.join('; ')}`); + results.push({rotationId:rotation.rotationId,runCount:rotation.runCount,verified:problems.length===0}); + } + const chained=this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map(e=>e.runId); + for(const runId of chained)if(!rotations.some(rotation=>rotation.rotationId===runId))divergences.push(`rotation ${runId} is chained but missing from the archive`); + for(const orphan of archive.prepare('SELECT DISTINCT rotationId FROM archive_runs WHERE rotationId NOT IN (SELECT rotationId FROM rotations)').all())divergences.push(`archive rows exist for unknown rotation ${orphan.rotationId}`); + return {exists:true,rotations:rotations.length,checked:rotations.length,verified:divergences.length===0,results,divergences}; + } + // Latest rotation that archived the run, or null when the run was never + // archived. Does not create the archive database for a negative answer. + archiveOrigin(runId) { + if(!existsSync(this.archivePath))return null; + const row=this.archive().prepare('SELECT a.rotationId AS rotationId FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1').get(runId); + return row?{rotationId:row.rotationId}:null; + } + // Copy a run's archived runs/steps rows back into the live library (id + // idempotent through upserts), clear its tombstone and append one + // run.restored {origin:'archive'} audit event — all in one transaction. + // The archive keeps its copy: restore is a copy-back, not a move. + restoreArchived(runId,{by='cli'}={}) { + if(!existsSync(this.archivePath))return null; + const row=this.archive().prepare('SELECT a.rotationId AS rotationId,a.requestId AS requestId,a.requestHash AS requestHash,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1').get(runId); + if(!row)return null; + // Rotation frees the requestId; a newer live run may have claimed it since. + // Restoring must not silently evict that run — fail loud instead. + const conflict=this.db.prepare('SELECT id FROM runs WHERE requestId=? AND id<>?').get(row.requestId,runId); + if(conflict)throw new Error(`requestId 已被新的工作流(${conflict.id})占用,无法从归档恢复 ${runId};请先处理占用的运行`); + const run=JSON.parse(row.body); + delete run.deletedAt;delete run.deletedBy;delete run.purgeAfter; + const steps=this.archive().prepare('SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id').all(row.rotationId,runId); + this.transaction(()=>{ + this.save(run); + const insert=this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body'); + for(const step of steps)insert.run(runId,step.id,step.body); + this.event(runId,'run.restored',{by,origin:'archive',rotationId:row.rotationId}); + }); + return {id:runId,rotationId:row.rotationId,steps:steps.length}; } releaseLock() {closeSync(this.fd);try{if(JSON.parse(readFileSync(this.lock,'utf8')).owner===this.owner)unlinkSync(this.lock);}catch{}} - close() {this.db.close();this.releaseLock();} + close() {this.archiveDb?.close();this.db.close();this.releaseLock();} } diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index 09731d5f..03980422 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs @@ -19,6 +19,9 @@ export const TOOLS=[ {name:'workflow_cancel',description:'取消本插件工作流,等待在途 exec 退出;不取消其他 MCode 会话。',inputSchema:obj(id,['runId'])}, {name:'workflow_pause',description:'停止派发并中断在途调用,保留已完成节点,可恢复。',inputSchema:obj(id,['runId'])}, {name:'workflow_resume',description:'原脚本与原输入恢复,复用已成功节点。可调整 maxSteps/stepTimeoutMs/runTimeoutMs/maxCalls 后重试,成功节点复用,失败节点从头执行。异常退出需要用户先确认旧 Agent 已停止。',inputSchema:obj({...id,confirmStopped:{type:'boolean'},...LIMIT_SCHEMAS,maxCalls:{type:'integer',minimum:1,maximum:100}},['runId'])}, + {name:'workflow_delete',description:'删除已完成的工作流到回收站(墓碑软删):事件、节点与结果全部保留,可随时恢复;运行中或待审核的工作流拒绝删除;重复删除幂等。到期后由归档轮转回收存储,事件链永不删除。',inputSchema:obj({...id,by:{type:'string',description:'删除来源(studio/cli/mcp),默认 mcp'}},['runId'])}, + {name:'workflow_restore',description:'从回收站或归档恢复工作流:回收站恢复清除墓碑;已轮转归档的从本机归档库导回节点数据。返回恢复后的运行状态;已归档未恢复前 workflow_status 不返回该运行。',inputSchema:obj({...id,by:{type:'string',description:'恢复来源(studio/cli/mcp),默认 mcp'}},['runId'])}, + {name:'workflow_rerun',description:'复跑工作流:以原运行的脚本与输入创建新的待审核运行,原运行永不改写。新运行带 rerunOf/lineageRoot/rerunSeq 归入同一谱系,可无限次复跑,族谱经 GET /api/runs/:id/lineage 查询。新运行须面板审核后才会执行。默认不复用跨运行结果(保证真实重跑与结果对比);显式 reuseAcrossRuns:true 才复用。源运行在回收站或已归档时先恢复。',inputSchema:obj({...id,input:{type:'object',description:'可选:替换原运行的 input'},reuseAcrossRuns:{type:'boolean',description:'Opt-in: adopt succeeded nodes from prior runs when context and spec hashes match; default false so reruns execute for real'}},['runId'])}, {name:'workflow_dashboard',description:'返回可收藏的本机可视化面板地址,无需 token。服务独立于聊天会话,重启后复用端口。',inputSchema:obj({})} ]; export function summary(snapshot){return {...snapshot,script:undefined,input:undefined,fingerprints:undefined,requestHash:undefined,steps:snapshot.steps?.map(({prompt,input,output,rawOutput,requestHash,...s})=>s),result:undefined};} @@ -34,6 +37,9 @@ export function createToolHandler(engine,getURL){return async(name,args={})=>{ case 'workflow_wait':{const t=args.timeoutMs??25000,a=args.afterSequence??0;check(Number.isInteger(t)&&t>=0&&t<=25000&&Number.isInteger(a)&&a>=0,'等待参数无效');return waitEvents(engine,args.runId,a,t);} case 'workflow_cancel':return summary(await engine.stop(args.runId)); case 'workflow_pause':return summary(await engine.stop(args.runId,'paused')); + case 'workflow_delete':return await engine.deleteRun(args.runId,{by:args.by??'mcp'}); + case 'workflow_restore':return await engine.restoreRun(args.runId,{by:args.by??'mcp'}); + case 'workflow_rerun':return summary(await engine.rerun(args.runId,args)); case 'workflow_resume':return summary(await engine.resume(args.runId,args)); case 'workflow_dashboard':return {url:getURL(),localOnly:true}; default:throw new Error('未知工具');} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/test/package.test.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/test/package.test.mjs index fce9fc82..a527bd9e 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/test/package.test.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/test/package.test.mjs @@ -22,7 +22,7 @@ test('packaged public MCP creates review drafts and repairs with reused results const done=async id=>{for(let i=0;i<100;i++){const r=await call('workflow_status',{runId:id});if(!['running','queued'].includes(r.status))return r;await new Promise(r=>setTimeout(r,30));}throw Error('run did not finish');}; try{ await request('initialize',{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'public-package-test',version:'1'}});child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n'); - assert.equal((await request('tools/list')).tools.length,11);url=(await call('workflow_dashboard')).url; + assert.equal((await request('tools/list')).tools.length,14);url=(await call('workflow_dashboard')).url; const script=`const a=await ctx.agent({id:'evidence',prompt:'demo evidence'});throw Error('Synthesis needs a guard');`; const original=await call('workflow_start',{requestId:'original',name:'Public demo',executor:'demo',script});assert.equal(original.status,'pending_review');assert.equal(original.attempts,0); await post(`/api/runs/${original.id}/approve`,{revision:1});assert.equal((await done(original.id)).status,'failed'); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js index e0f43015..aab5e154 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1407,6 +1407,33 @@ function workflowGraph(run, { planOnly = false } = {}) { return { nodes, phases }; } +// web/lineage-model.mjs +function compareSignature(language2, members) { + return JSON.stringify([language2, ...(members ?? []).map((member) => [member.id, member.rerunSeq, member.name])]); +} +function shouldRebuildCompare(previous, next) { + return previous !== next; +} + +// web/fullscreen-model.mjs +var FULLSCREEN_OFF = "off"; +var FULLSCREEN_API = "api"; +var FULLSCREEN_OVERLAY = "overlay"; +function requestMode(state, { apiSupported = false } = {}) { + if (state === FULLSCREEN_API || state === FULLSCREEN_OVERLAY) return state; + return apiSupported ? FULLSCREEN_API : FULLSCREEN_OVERLAY; +} +function rejectApi(state) { + return state === FULLSCREEN_API ? FULLSCREEN_OVERLAY : state; +} +function apiChange(state, onPanel) { + if (onPanel) return FULLSCREEN_API; + return state === FULLSCREEN_API ? FULLSCREEN_OFF : state; +} +function shouldExitOnKey(state, { key = "", dialogOpen = false } = {}) { + return state === FULLSCREEN_OVERLAY && key === "Escape" && !dialogOpen; +} + // web/i18n.mjs var messages = { "zh": { @@ -1850,6 +1877,14 @@ Object.assign(messages.zh, { "templates": "\u6A21\u677F\u5E93", "saveTemplate": Object.assign(messages.en, { "templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps" }); Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "reviewCompactHelp": "\u68C0\u67E5\u4E0B\u65B9\u6D41\u7A0B\uFF0C\u786E\u8BA4\u540E\u5F00\u59CB\u3002", "reviewDetails": "\u4EFB\u52A1\u8BE6\u60C5\u4E0E\u6267\u884C\u8BBE\u7F6E", "reviewBudgets": "\u5E76\u53D1 {concurrency} \xB7 \u6700\u591A {calls} \u6B21\u8C03\u7528 \xB7 \u6BCF\u8282\u70B9 {steps} \u6B65 / {minutes} \u5206\u949F", "status.awaiting": "\u5C1A\u672A\u5F00\u59CB", "status.blocked": "\u4F9D\u8D56\u53D7\u963B", "status.not_run": "\u672A\u6267\u884C", "awaitingHelp": "\u8BE5\u8282\u70B9\u5C1A\u672A\u521B\u5EFA\u6267\u884C\u4EFB\u52A1\u3002\u542F\u52A8\u540E\u4F1A\u5728\u8FD9\u91CC\u66F4\u65B0\u72B6\u6001\u3002", "blockedHelp": "\u5DF2\u58F0\u660E\u7684\u4E0A\u6E38\u8282\u70B9\u672A\u6210\u529F\uFF0C\u5F53\u524D\u8282\u70B9\u5C1A\u672A\u6267\u884C\u3002", "notRunHelp": "\u672C\u6B21\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u672A\u89E6\u53D1\u8FD9\u4E2A\u8BA1\u5212\u8282\u70B9\u3002", "dynamicHelp": "\u8282\u70B9\u6570\u91CF\u7531\u8FD0\u884C\u7ED3\u679C\u51B3\u5B9A\uFF1B\u5DF2\u521B\u5EFA\u7684\u8282\u70B9\u4F1A\u5728\u6B64\u5206\u7EC4\u4E2D\u5C55\u5F00\u3002" }); Object.assign(messages.en, { "reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \xB7 Up to {calls} calls \xB7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group." }); +Object.assign(messages.zh, { "trash": "\u56DE\u6536\u7AD9", "trashHelp": "\u5220\u9664\u7684\u5DE5\u4F5C\u6D41\u5148\u8FDB\u5165\u56DE\u6536\u7AD9\uFF1A\u4E8B\u4EF6\u3001\u8282\u70B9\u4E0E\u7ED3\u679C\u5168\u90E8\u4FDD\u7559\uFF0C\u53EF\u968F\u65F6\u6062\u590D\u3002\u5230\u671F\u540E\u7531\u5F52\u6863\u8F6E\u8F6C\u56DE\u6536\u5B58\u50A8\uFF1B\u5BA1\u8BA1\u4E8B\u4EF6\u6C38\u4E0D\u5220\u9664\u3002", "trashEmpty": "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002", "trashRestore": "\u6062\u590D", "trashRemaining": "\u4FDD\u7559\u5269\u4F59 {days} \u5929", "trashExpired": "\u5DF2\u5230\u671F\uFF0C\u7B49\u5F85\u5F52\u6863\u8F6E\u8F6C", "trashDeleted": "\u5220\u9664\u4E8E {date}", "trashRetention": "\u56DE\u6536\u7AD9\u4FDD\u7559\u671F\uFF08\u5929\uFF09", "trashRetentionHelp": "\u9ED8\u8BA4 30 \u5929\uFF1B0 \u8868\u793A\u4E0D\u5230\u671F\uFF0C\u4EC5\u624B\u52A8\u8F6E\u8F6C\u5F52\u6863\u3002\u4FEE\u6539\u4F1A\u540C\u6B65\u66F4\u65B0\u56DE\u6536\u7AD9\u4E2D\u5DF2\u6709\u6761\u76EE\u7684\u5230\u671F\u65F6\u95F4\u3002", "event.run.deleted": "\u5DF2\u5220\u9664\u5230\u56DE\u6536\u7AD9", "event.run.restored": "\u5DF2\u6062\u590D" }); +Object.assign(messages.en, { "trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored" }); +Object.assign(messages.zh, { "lineage": "\u590D\u8DD1\u8C31\u7CFB", "lineageCount": "{count} \u6B21\u8FD0\u884C", "lineageRootRun": "\u539F\u59CB\u8FD0\u884C", "lineageRerun": "\u590D\u8DD1 \u7B2C {seq} \u6B21", "lineageOpen": "\u6253\u5F00", "lineageMemberDeleted": "\u5DF2\u5220\u9664\uFF08\u56DE\u6536\u7AD9\uFF09", "lineageMemberArchived": "\u5DF2\u5F52\u6863", "lineageCompareHint": "\u540C\u8C31\u7CFB\u5386\u6B21\u8FD0\u884C", "lineageCompare": "\u5BF9\u6BD4\u7ED3\u679C", "lineageCompareLeft": "\u5BF9\u6BD4\u5DE6\u4FA7\u8FD0\u884C", "lineageCompareRight": "\u5BF9\u6BD4\u53F3\u4FA7\u8FD0\u884C", "lineageVersus": "\u5BF9\u6BD4", "lineageNoResult": "\u6682\u65E0\u7ED3\u679C" }); +Object.assign(messages.zh, { "event.archive.rotated": "\u56DE\u6536\u7AD9\u5F52\u6863\u8F6E\u8F6C" }); +Object.assign(messages.en, { "lineage": "Rerun lineage", "lineageCount": "{count} runs", "lineageRootRun": "Original run", "lineageRerun": "Rerun #{seq}", "lineageOpen": "Open", "lineageMemberDeleted": "Deleted (in trash)", "lineageMemberArchived": "Archived", "lineageCompareHint": "Every rerun of this workflow", "lineageCompare": "Compare results", "lineageCompareLeft": "Left run to compare", "lineageCompareRight": "Right run to compare", "lineageVersus": "vs", "lineageNoResult": "No result yet" }); +Object.assign(messages.en, { "event.archive.rotated": "Archive rotation" }); +Object.assign(messages.zh, { "canvasFullscreen": "\u5168\u5C4F", "canvasExitFullscreen": "\u9000\u51FA\u5168\u5C4F" }); +Object.assign(messages.en, { "canvasFullscreen": "Fullscreen", "canvasExitFullscreen": "Exit fullscreen" }); var LANGUAGE_KEY = "workflow-language"; function normalizePreference(value) { return ["zh", "en"].includes(value) ? value : "auto"; @@ -1904,6 +1939,8 @@ var zoom = 0; var zoomAuto = true; var tab = "output"; var busy = false; +var lineage = null; +var lineageSignature = ""; var defaults = { maxSteps: 120, stepTimeoutMs: 18e5, runTimeoutMs: 72e5 }; var ns = "http://www.w3.org/2000/svg"; var labels = new Proxy({}, { get: (_2, key) => { @@ -1996,9 +2033,11 @@ async function selectRun(id) { after = 0; zoom = 0; zoomAuto = true; + lineage = null; error(""); renderList(); renderRun(); + void loadLineage(id, version); await loadEvents(id, version); } catch (e) { if (version === selectionVersion) throw e; @@ -2024,6 +2063,7 @@ async function refreshCurrent() { if (viewing(id, version)) { current = next; renderRun(); + if ($2("#lineage-panel").open) void loadLineage(id, version, true); } } catch (e) { if (viewing(id, version)) throw e; @@ -2033,12 +2073,12 @@ function renderRun() { renderBrief(); const r = current; $2("#repair-run").hidden = !r || !["failed", "paused", "interrupted", "cancelled", "completed_with_gaps", "succeeded"].includes(r.status); - const lineage = $2("#repair-lineage"); - lineage.hidden = !r?.repair; - lineage.replaceChildren(); + const lineage2 = $2("#repair-lineage"); + lineage2.hidden = !r?.repair; + lineage2.replaceChildren(); if (r?.repair) { const link = el("a", { href: "?run=" + encodeURIComponent(r.repair.sourceRunId) }, t("repairSource")); - lineage.append(link, document.createTextNode(" \xB7 " + r.repair.reason + " \xB7 " + t("repairCandidates", { count: r.repair.reuseStepIds.length }))); + lineage2.append(link, document.createTextNode(" \xB7 " + r.repair.reason + " \xB7 " + t("repairCandidates", { count: r.repair.reuseStepIds.length }))); } const review = r?.status === "pending_review"; document.querySelector("main").classList.toggle("is-review", review); @@ -2053,11 +2093,13 @@ function renderRun() { $2("#review-version").textContent = review ? `v${r.revision}` : ""; $2("#graph-mode").hidden = !r?.topology || review; $2("#graph-mode").textContent = t(showPlan ? "showExecution" : "showPlan"); + syncCanvasFullscreenButton(); $2("#topology-note").hidden = !r?.topology; $2("#topology-warnings").textContent = r?.topology?.warnings.map((w) => t("topology." + w)).join(" ") ?? ""; $2("#empty").hidden = !!r; $2("#run-view").hidden = !r; if (!r) { + setCanvasFullscreen(FULLSCREEN_OFF); $2("#run-title").textContent = t("canvas"); $2("#executor-badge").textContent = t("noRun"); for (const id of ["pause", "cancel", "resume"]) $2("#" + id).hidden = true; @@ -2085,6 +2127,7 @@ function renderRun() { renderGraph(); renderNode(); renderRead(); + renderLineage(); } function graphSteps() { return workflowGraph(current, { planOnly: showPlan }).nodes; @@ -2245,6 +2288,89 @@ function renderEvents() { if (nearBottom) list.scrollTop = list.scrollHeight; renderNode(); } +async function loadLineage(id, version = selectionVersion, background = false) { + try { + const data = await api(`/runs/${id}/lineage`); + if (viewing(id, version)) { + lineage = data; + renderLineage(); + } + } catch { + if (viewing(id, version) && !background) { + lineage = null; + renderLineage(); + } + } +} +function lineageLabel(m2) { + return `${m2.rerunSeq > 0 ? t("lineageRerun", { seq: m2.rerunSeq }) : t("lineageRootRun")} \xB7 ${m2.name}`; +} +function renderLineage() { + const panel = $2("#lineage-panel"), members = lineage?.members ?? []; + const show = !!current && (members.length > 1 || !!current.rerunOf); + panel.hidden = !show; + if (!show) { + lineageSignature = ""; + return; + } + $2("#lineage-count").textContent = t("lineageCount", { count: members.length }); + const list = $2("#lineage-members"); + list.replaceChildren(); + for (const m2 of members) { + const row2 = el("article", { class: `lineage-member${m2.id === current.id ? " current" : ""}` }), text = el("div"); + text.append(el("h3", {}, lineageLabel(m2)), el("p", {}, `${labels[m2.status] ?? m2.status} \xB7 ${new Date(m2.createdAt).toLocaleString(language === "zh" ? "zh-CN" : "en-US")}${m2.durationMs != null ? ` \xB7 ${(m2.durationMs / 1e3).toFixed(1)}s` : ""}`)); + if (m2.resultPreview) text.append(el("p", { class: "lineage-preview" }, m2.resultPreview)); + const flags = [m2.deleted ? t("lineageMemberDeleted") : null, m2.archived ? t("lineageMemberArchived") : null].filter(Boolean).join(" \xB7 "); + if (flags) text.append(el("p", { class: "lineage-flag" }, flags)); + const open = el("button", { type: "button" }, t("lineageOpen")); + open.disabled = !!(m2.deleted || m2.archived); + open.onclick = () => selectRun(m2.id).catch((e) => error(e.message)); + const actions = el("div", { class: "template-actions" }); + actions.append(open); + row2.append(text, actions); + list.append(row2); + } + const signature = compareSignature(language, members); + if (!shouldRebuildCompare(lineageSignature, signature)) return; + lineageSignature = signature; + const row = $2("#lineage-compare-row"), left = $2("#lineage-left"), right = $2("#lineage-right"), compare = $2("#lineage-compare"); + row.hidden = members.length < 2; + compare.disabled = members.length < 2; + $2("#lineage-diff").hidden = true; + if (members.length < 2) { + left.replaceChildren(); + right.replaceChildren(); + return; + } + const ids = members.map((m2) => m2.id), keep = (value) => ids.includes(value) ? value : null; + left.replaceChildren(...members.map((m2) => el("option", { value: m2.id }, lineageLabel(m2)))); + right.replaceChildren(...members.map((m2) => el("option", { value: m2.id }, lineageLabel(m2)))); + left.value = keep(left.value) ?? members[0].id; + right.value = keep(right.value) && keep(right.value) !== left.value ? keep(right.value) : (members.find((m2) => m2.id !== left.value) ?? members[0]).id; +} +$2("#lineage-compare").onclick = async () => { + if (!current || !lineage) return; + const button = $2("#lineage-compare"); + button.disabled = true; + try { + const data = await api(`/runs/${current.id}/lineage?results=1`); + const column = (id) => { + const member = data.members.find((m2) => m2.id === id), pane = el("div", { class: "lineage-column" }); + pane.append(el("h3", {}, member ? lineageLabel(member) : ""), el("pre", {}, member ? member.result == null ? t("lineageNoResult") : JSON.stringify(member.result, null, 2) : "")); + return pane; + }; + const box = $2("#lineage-diff"); + box.replaceChildren(column($2("#lineage-left").value), column($2("#lineage-right").value)); + box.hidden = false; + } catch (e) { + error(e.message); + } finally { + button.disabled = false; + } +}; +$2("#lineage-panel").addEventListener("toggle", () => { + if ($2("#lineage-panel").open && current) void loadLineage(current.id); +}); function showCreate() { const f2 = $2("#create-form"); f2.reset(); @@ -2356,6 +2482,58 @@ $2("#graph-mode").onclick = () => { zoomAuto = true; renderRun(); }; +var canvasFullscreen = FULLSCREEN_OFF; +function canvasFullscreenElement() { + return document.fullscreenElement ?? document.webkitFullscreenElement ?? null; +} +function syncCanvasFullscreenButton() { + const active = canvasFullscreen !== FULLSCREEN_OFF, button = $2("#graph-fullscreen"); + button.textContent = t(active ? "canvasExitFullscreen" : "canvasFullscreen"); + button.setAttribute("aria-pressed", String(active)); +} +function refitCanvasFullscreen() { + if (current) { + zoomAuto = true; + renderGraph(); + } +} +function setCanvasFullscreen(mode) { + if (mode === canvasFullscreen) return; + canvasFullscreen = mode; + $2(".canvas-panel").classList.toggle("canvas-overlay", mode === FULLSCREEN_OVERLAY); + syncCanvasFullscreenButton(); + refitCanvasFullscreen(); +} +$2("#graph-fullscreen").onclick = async () => { + const panel = $2(".canvas-panel"); + if (canvasFullscreen === FULLSCREEN_OFF) { + const request = panel.requestFullscreen ?? panel.webkitRequestFullscreen; + setCanvasFullscreen(requestMode(canvasFullscreen, { apiSupported: typeof request === "function" })); + if (typeof request === "function") { + try { + await request.call(panel); + } catch { + setCanvasFullscreen(rejectApi(canvasFullscreen)); + } + } + } else if (canvasFullscreenElement()) { + try { + await (document.exitFullscreen ?? document.webkitExitFullscreen).call(document); + } catch { + } + } else setCanvasFullscreen(FULLSCREEN_OFF); +}; +document.addEventListener("fullscreenchange", () => { + const onPanel = canvasFullscreenElement() === $2(".canvas-panel"); + setCanvasFullscreen(apiChange(canvasFullscreen, onPanel)); + if (onPanel || !canvasFullscreenElement()) refitCanvasFullscreen(); +}); +document.addEventListener("webkitfullscreenerror", () => { + setCanvasFullscreen(rejectApi(canvasFullscreen)); +}); +document.addEventListener("keydown", (e) => { + if (shouldExitOnKey(canvasFullscreen, { key: e.key, dialogOpen: !!document.querySelector("dialog[open]") })) setCanvasFullscreen(FULLSCREEN_OFF); +}); $2("#new-run").onclick = showCreate; $2("#empty-start").onclick = showCreate; $2("#close-create").onclick = () => $2("#create-dialog").close(); @@ -2647,6 +2825,7 @@ function applyLanguage() { renderRun(); renderEvents(); renderRead(); + renderLineage(); if (!current) error(lastAlert); if (lastFormMessage) $2("#form-error").textContent = lastFormMessage.key ? t(lastFormMessage.key) : apiMessage(lastFormMessage.error); if ($2("#resume-dialog").open && current) $2("#resume-note").textContent = t("resumeNote", { used: current.attempts, max: current.maxCalls }) + (current.legacyLimits ? " " + t("legacyNote") : ""); @@ -2835,6 +3014,63 @@ $2("#template-form").onsubmit = async (e) => { button.disabled = false; } }; +async function renderTrash() { + const list = $2("#trash-list"); + list.replaceChildren(); + const runs2 = await api("/runs?trash=1"); + if (!runs2.length) list.append(el("p", { class: "subtle" }, t("trashEmpty"))); + for (const r of runs2) { + const row = el("article", { class: "template-card" }), text = el("div"); + const days = Math.ceil((r.purgeAfter - Date.now()) / 864e5); + text.append(el("h3", {}, r.name), el("p", {}, `${labels[r.status] ?? r.status} \xB7 ${t("trashDeleted", { date: new Date(r.deletedAt).toLocaleString(language === "zh" ? "zh-CN" : "en-US") })} \xB7 ${days > 0 ? t("trashRemaining", { days }) : t("trashExpired")}`)); + const restore = el("button", { type: "button" }, t("trashRestore")); + restore.onclick = async () => { + restore.disabled = true; + try { + await api(`/runs/${r.id}/restore`, "POST", { by: "studio" }); + await renderTrash(); + await refreshList(); + if (current && $2("#lineage-panel").open) void loadLineage(current.id); + } catch (e) { + $2("#trash-error").hidden = false; + $2("#trash-error").textContent = apiMessage(e.message); + restore.disabled = false; + } + }; + const actions = el("div", { class: "template-actions" }); + actions.append(restore); + row.append(text, actions); + list.append(row); + } +} +$2("#open-trash").onclick = async () => { + $2("#trash-error").hidden = true; + $2("#trash-dialog").showModal(); + try { + const { trashRetentionDays } = await api("/trash"); + $2("#trash-form").elements.trashRetentionDays.value = String(trashRetentionDays); + await renderTrash(); + } catch (e) { + $2("#trash-error").hidden = false; + $2("#trash-error").textContent = apiMessage(e.message); + } +}; +$2("#close-trash").onclick = () => $2("#trash-dialog").close(); +$2("#trash-form").onsubmit = async (e) => { + e.preventDefault(); + const button = e.submitter ?? $2("#trash-form").querySelector("button[type=submit]"); + button.disabled = true; + try { + await api("/trash", "POST", { trashRetentionDays: Number(e.currentTarget.elements.trashRetentionDays.value) }); + $2("#trash-error").hidden = true; + await renderTrash(); + } catch (e2) { + $2("#trash-error").hidden = false; + $2("#trash-error").textContent = apiMessage(e2.message); + } finally { + button.disabled = false; + } +}; function renderLegend(steps) { const box = $2("#graph-legend"); box.replaceChildren(); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs index 87f30cbc..5e8c558b 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs @@ -1,12 +1,14 @@ import {readableHTML,rawText,escapeHTML} from './readable.mjs'; import {workflowGraph} from './graph-model.mjs'; +import {compareSignature,shouldRebuildCompare} from './lineage-model.mjs'; +import {FULLSCREEN_OFF,FULLSCREEN_OVERLAY,requestMode,rejectApi,apiChange,shouldExitOnKey} from './fullscreen-model.mjs'; import {translate,resolveLanguage,readPreference,savePreference,describeFailure,LANGUAGE_KEY} from './i18n.mjs'; const $=s=>document.querySelector(s); let nodeRaw=false,nodeSignature='',copyValue='',copyTimer; let scheduler={active:0,limit:8,queued:0}; let showPlan=false,readSignature="",selectionVersion=0; -let runs=[],current=null,selected=null,events=[],after=0,zoom=0,zoomAuto=true,tab='output',busy=false,defaults={maxSteps:120,stepTimeoutMs:1800000,runTimeoutMs:7200000};const ns='http://www.w3.org/2000/svg'; +let runs=[],current=null,selected=null,events=[],after=0,zoom=0,zoomAuto=true,tab='output',busy=false,lineage=null,lineageSignature='',defaults={maxSteps:120,stepTimeoutMs:1800000,runTimeoutMs:7200000};const ns='http://www.w3.org/2000/svg'; const labels=new Proxy({}, {get:(_,key)=>{const v=t('status.'+key);return v==='status.'+key?key:v}}); const eventLabels=new Proxy({}, {get:(_,key)=>{const v=t('event.'+key);return v==='event.'+key?key:v}}); let preference=readPreference(safeStorage()),language=resolveLanguage(preference,navigator.languages?.length?navigator.languages:[navigator.language]),connectionKey='connecting',mcodeAvailable=true,readMode=null,lastAlert='',lastFormMessage=null; @@ -26,7 +28,7 @@ async function selectRun(id){ const version=++selectionVersion; try{ const next=await api(`/runs/${id}`);if(version!==selectionVersion)return; - current=next;history.replaceState(null,'',`${location.pathname}?run=${encodeURIComponent(id)}`);showPlan=false;selected=null;events=[];after=0;zoom=0;zoomAuto=true;error('');renderList();renderRun();await loadEvents(id,version); + current=next;history.replaceState(null,'',`${location.pathname}?run=${encodeURIComponent(id)}`);showPlan=false;selected=null;events=[];after=0;zoom=0;zoomAuto=true;lineage=null;error('');renderList();renderRun();void loadLineage(id,version);await loadEvents(id,version); }catch(e){if(version===selectionVersion)throw e;} } async function loadEvents(id,version=selectionVersion){ @@ -37,10 +39,10 @@ async function loadEvents(id,version=selectionVersion){ } async function refreshCurrent(){ const id=current?.id,version=selectionVersion;if(!id)return; - try{const next=await api(`/runs/${id}`);if(viewing(id,version)){current=next;renderRun();}} + try{const next=await api(`/runs/${id}`);if(viewing(id,version)){current=next;renderRun();if($('#lineage-panel').open)void loadLineage(id,version,true);}} catch(e){if(viewing(id,version))throw e;} } -function renderRun(){renderBrief();const r=current;$('#repair-run').hidden=!r||!['failed','paused','interrupted','cancelled','completed_with_gaps','succeeded'].includes(r.status);const lineage=$('#repair-lineage');lineage.hidden=!r?.repair;lineage.replaceChildren();if(r?.repair){const link=el('a',{href:'?run='+encodeURIComponent(r.repair.sourceRunId)},t('repairSource'));lineage.append(link,document.createTextNode(' · '+r.repair.reason+' · '+t('repairCandidates',{count:r.repair.reuseStepIds.length})));}const review=r?.status==='pending_review';document.querySelector('main').classList.toggle('is-review',review);$('.metrics').hidden=review;$('.timeline').hidden=review;$('#report').hidden=review;$('#save-template').hidden=!r||review;$('#review-budgets').textContent=r?t('reviewBudgets',{concurrency:r.concurrency,calls:r.maxCalls,steps:r.maxSteps,minutes:r.stepTimeoutMs/60000}):'';$('#review-banner').hidden=!review;$('#edit-draft').hidden=!review;$('#approve').hidden=!review;$('#review-version').textContent=review?`v${r.revision}`:'';$('#graph-mode').hidden=!r?.topology||review;$('#graph-mode').textContent=t(showPlan?'showExecution':'showPlan');$('#topology-note').hidden=!r?.topology;$('#topology-warnings').textContent=r?.topology?.warnings.map(w=>t('topology.'+w)).join(' ')??'';$('#empty').hidden=!!r;$('#run-view').hidden=!r;if(!r){$('#run-title').textContent=t('canvas');$('#executor-badge').textContent=t('noRun');for(const id of ['pause','cancel','resume'])$('#'+id).hidden=true;return;}$('#run-title').textContent=r.name;$('#executor-badge').textContent=r.executor==='demo'?t('demoRun'):t('realRun');$('#metric-status').textContent=labels[r.status]??r.status;$('.status-metric').dataset.status=r.status;const tasks=r.steps.filter(s=>s.kind==='agent');const visibleNodes=graphSteps(),knownNodes=visibleNodes.filter(n=>!n.placeholder||!n.dynamic).length;$('#metric-nodes').textContent=`${tasks.filter(s=>s.status==='succeeded').length} / ${knownNodes}${visibleNodes.some(n=>n.placeholder&&n.dynamic)?'+':''}`;$('#metric-calls').textContent=`${r.attempts} / ${r.maxCalls}`;const usage=tasks.flatMap(s=>[...(s.usageHistory??[]),...(s.usage?[s.usage]:[])]);$('#metric-tokens').textContent=r.executor==='demo'?'—':usage.length?usage.reduce((n,s)=>n+(s.totalTokens??((s.inputTokens??0)+(s.outputTokens??0))),0).toLocaleString(language==='zh'?'zh-CN':'en-US')+(usage.lengtht('topology.'+w)).join(' ')??'';$('#empty').hidden=!!r;$('#run-view').hidden=!r;if(!r){setCanvasFullscreen(FULLSCREEN_OFF);$('#run-title').textContent=t('canvas');$('#executor-badge').textContent=t('noRun');for(const id of ['pause','cancel','resume'])$('#'+id).hidden=true;return;}$('#run-title').textContent=r.name;$('#executor-badge').textContent=r.executor==='demo'?t('demoRun'):t('realRun');$('#metric-status').textContent=labels[r.status]??r.status;$('.status-metric').dataset.status=r.status;const tasks=r.steps.filter(s=>s.kind==='agent');const visibleNodes=graphSteps(),knownNodes=visibleNodes.filter(n=>!n.placeholder||!n.dynamic).length;$('#metric-nodes').textContent=`${tasks.filter(s=>s.status==='succeeded').length} / ${knownNodes}${visibleNodes.some(n=>n.placeholder&&n.dynamic)?'+':''}`;$('#metric-calls').textContent=`${r.attempts} / ${r.maxCalls}`;const usage=tasks.flatMap(s=>[...(s.usageHistory??[]),...(s.usage?[s.usage]:[])]);$('#metric-tokens').textContent=r.executor==='demo'?'—':usage.length?usage.reduce((n,s)=>n+(s.totalTokens??((s.inputTokens??0)+(s.outputTokens??0))),0).toLocaleString(language==='zh'?'zh-CN':'en-US')+(usage.lengths.kind==='agent');$('#graph-wait').hidden=steps.length>0;renderLegend(steps); @@ -91,12 +93,100 @@ function renderNode(){ if(!dialog.open)dialog.showModal(); } function renderEvents(){const list=$('#events'),nearBottom=list.scrollHeight-list.scrollTop-list.clientHeight<60;list.replaceChildren();$('#event-count').textContent=t('eventsCount',{count:events.length});for(const e of events.slice(-100)){const li=el('li');li.append(el('time',{},new Date(e.time).toLocaleTimeString(language==='zh'?'zh-CN':'en-US',{hour12:false})),el('span',{},`${eventLabels[e.type]??e.type}${e.message||e.text?` · ${short(e.message??e.text,90)}`:e.status?` · ${labels[e.status]??e.status}`:''}`),el('span',{class:'event-node'},e.stepId??e.label??''));list.append(li);}if(nearBottom)list.scrollTop=list.scrollHeight;renderNode();} +async function loadLineage(id,version=selectionVersion,background=false){ + try{const data=await api(`/runs/${id}/lineage`);if(viewing(id,version)){lineage=data;renderLineage();}} + catch{if(viewing(id,version)&&!background){lineage=null;renderLineage();}} +} +function lineageLabel(m){return `${m.rerunSeq>0?t('lineageRerun',{seq:m.rerunSeq}):t('lineageRootRun')} · ${m.name}`;} +function renderLineage(){ + const panel=$('#lineage-panel'),members=lineage?.members??[]; + // A family face only exists once the run is (or belongs to) a rerun; a lone + // standalone run keeps the panel out of the layout. + const show=!!current&&(members.length>1||!!current.rerunOf); + panel.hidden=!show; + if(!show){lineageSignature='';return;} + $('#lineage-count').textContent=t('lineageCount',{count:members.length}); + // Member cards are pure data (status, duration, trash/archive flags) and + // re-render on every pass, so a refetched lineage can never leave a + // restored member wearing a stale deleted flag. + const list=$('#lineage-members');list.replaceChildren(); + for(const m of members){ + const row=el('article',{class:`lineage-member${m.id===current.id?' current':''}`}),text=el('div'); + text.append(el('h3',{},lineageLabel(m)),el('p',{},`${labels[m.status]??m.status} · ${new Date(m.createdAt).toLocaleString(language==='zh'?'zh-CN':'en-US')}${m.durationMs!=null?` · ${(m.durationMs/1000).toFixed(1)}s`:''}`)); + if(m.resultPreview)text.append(el('p',{class:'lineage-preview'},m.resultPreview)); + const flags=[m.deleted?t('lineageMemberDeleted'):null,m.archived?t('lineageMemberArchived'):null].filter(Boolean).join(' · '); + if(flags)text.append(el('p',{class:'lineage-flag'},flags)); + const open=el('button',{type:'button'},t('lineageOpen')); + open.disabled=!!(m.deleted||m.archived); + open.onclick=()=>selectRun(m.id).catch(e=>error(e.message)); + const actions=el('div',{class:'template-actions'});actions.append(open); + row.append(text,actions);list.append(row); + } + // The compare controls hold user interaction state (chosen pair, open + // diff). Rebuild them only when member identity changes: a poll over an + // unchanged family must keep the diff the user opened. + const signature=compareSignature(language,members); + if(!shouldRebuildCompare(lineageSignature,signature))return; + lineageSignature=signature; + const row=$('#lineage-compare-row'),left=$('#lineage-left'),right=$('#lineage-right'),compare=$('#lineage-compare'); + row.hidden=members.length<2;compare.disabled=members.length<2;$('#lineage-diff').hidden=true; + if(members.length<2){left.replaceChildren();right.replaceChildren();return;} + const ids=members.map(m=>m.id),keep=value=>ids.includes(value)?value:null; + left.replaceChildren(...members.map(m=>el('option',{value:m.id},lineageLabel(m)))); + right.replaceChildren(...members.map(m=>el('option',{value:m.id},lineageLabel(m)))); + left.value=keep(left.value)??members[0].id; + right.value=(keep(right.value)&&keep(right.value)!==left.value)?keep(right.value):(members.find(m=>m.id!==left.value)??members[0]).id; +} +$('#lineage-compare').onclick=async()=>{ + if(!current||!lineage)return; + const button=$('#lineage-compare');button.disabled=true; + try{ + const data=await api(`/runs/${current.id}/lineage?results=1`); + const column=id=>{ + const member=data.members.find(m=>m.id===id),pane=el('div',{class:'lineage-column'}); + pane.append(el('h3',{},member?lineageLabel(member):''),el('pre',{},member?(member.result==null?t('lineageNoResult'):JSON.stringify(member.result,null,2)):'')); + return pane;}; + const box=$('#lineage-diff');box.replaceChildren(column($('#lineage-left').value),column($('#lineage-right').value));box.hidden=false; + }catch(e){error(e.message);}finally{button.disabled=false;} +}; +$('#lineage-panel').addEventListener('toggle',()=>{if($('#lineage-panel').open&¤t)void loadLineage(current.id);}); function showCreate(){const f=$('#create-form');f.reset();delete f.dataset.repairId;delete f.dataset.sourceUpdatedAt;$('#repair-context').hidden=true;f.elements.repairReason.required=false;f.elements.maxSteps.value=defaults.maxSteps;f.elements.stepTimeoutMinutes.value=defaults.stepTimeoutMs/60000;f.elements.runTimeoutMinutes.value=defaults.runTimeoutMs/60000;delete f.dataset.runId;delete f.dataset.revision;f.elements.name.value=t('defaultName');setMetadataForm({objective:t('defaultObjective'),inputDescription:t('defaultInputDescription'),deliverables:t('defaultDeliverables').split('\n')});delete f.elements.name.dataset.edited;$('#script-input').value=defaultScripts[language];delete $('#script-input').dataset.edited;$('#create-title').textContent=t('newHeading');$('#save-draft').textContent=t('createDraft');$('#form-error').hidden=true;lastFormMessage=null;updateLimitSummary();renderModeNote();$('#create-dialog').showModal();} function editRun(repair=false){if(!current)return;const f=$('#create-form');delete f.dataset.repairId;delete f.dataset.sourceUpdatedAt;$('#repair-context').hidden=!repair;f.elements.repairReason.required=repair;f.dataset.runId=current.id;f.dataset.revision=current.revision;for(const name of ['name','executor','concurrency','maxCalls','maxSteps'])f.elements[name].value=current[name];f.elements.name.dataset.edited='true';f.elements.stepTimeoutMinutes.value=current.stepTimeoutMs/60000;f.elements.runTimeoutMinutes.value=current.runTimeoutMs/60000;f.elements.inputJSON.value=JSON.stringify(current.input,null,2);setMetadataForm(current.metadata);$('#script-input').value=current.script;$('#script-input').dataset.edited='true';$('#script-editor').open=true;$('#create-title').textContent=t('editDraft');$('#save-draft').textContent=t('saveDraft');$('#form-error').hidden=true;lastFormMessage=null;updateLimitSummary();renderModeNote();$('#create-dialog').showModal();} $('#edit-draft').onclick=()=>{editRun();if(current?.repair){$('#repair-context').hidden=false;const f=$('#create-form');f.elements.repairReason.required=true;f.elements.repairReason.value=current.repair.reason;$('#repair-diagnostic').textContent=[current.repair.sourceError,...current.repair.failures.map(s=>s.id+': '+(s.error??s.status))].filter(Boolean).join('\n')||t('repairNoError');const list=$('#repair-candidates');list.replaceChildren();for(const id of current.repair.candidateStepIds??current.repair.reuseStepIds){const label=el('label',{class:'repair-candidate'}),checkbox=el('input',{type:'checkbox',name:'reuseStepId',value:id});checkbox.checked=current.repair.reuseStepIds.includes(id);label.append(checkbox,el('span',{},id));list.append(label);}}}; $('#repair-run').onclick=()=>{editRun(true);const f=$('#create-form');delete f.dataset.runId;delete f.dataset.revision;f.dataset.repairId=current.id;f.dataset.sourceUpdatedAt=current.updatedAt;f.elements.repairReason.value='';$('#create-title').textContent=t('repairRun');$('#save-draft').textContent=t('createRepair');$('#repair-diagnostic').textContent=[current.error,...current.steps.filter(s=>s.error).map(s=>s.id+': '+s.error)].filter(Boolean).join('\n')||t('repairNoError');const list=$('#repair-candidates');list.replaceChildren();for(const step of current.steps.filter(s=>s.kind==='agent'&&s.status==='succeeded'&&Array.isArray(s.dependsOn))){const label=el('label',{class:'repair-candidate'}),checkbox=el('input',{type:'checkbox',name:'reuseStepId',value:step.id});label.append(checkbox,el('span',{},step.label+' · '+step.id));list.append(label);}if(!list.childElementCount)list.append(el('p',{class:'subtle'},t('repairNoCandidates')));}; $('#approve').onclick=async()=>{if(!current||busy)return;const id=current.id,revision=current.revision;busy=true;$('#approve').disabled=true;try{await api(`/runs/${id}/approve`,'POST',{revision});await selectRun(id);await refreshList();}catch(e){error(e.message);}finally{busy=false;$('#approve').disabled=false;}}; $('#graph-mode').onclick=()=>{showPlan=!showPlan;selected=null;zoomAuto=true;renderRun();}; +// Canvas fullscreen: request the native Fullscreen API first and fall back to +// a fixed overlay when the page cannot (or is not allowed to) go fullscreen. +// Either way the canvas keeps its pan/zoom interaction and refits once after +// the geometry change; polling re-renders content without leaving fullscreen. +let canvasFullscreen=FULLSCREEN_OFF; +function canvasFullscreenElement(){return document.fullscreenElement??document.webkitFullscreenElement??null;} +function syncCanvasFullscreenButton(){const active=canvasFullscreen!==FULLSCREEN_OFF,button=$('#graph-fullscreen');button.textContent=t(active?'canvasExitFullscreen':'canvasFullscreen');button.setAttribute('aria-pressed',String(active));} +function refitCanvasFullscreen(){if(current){zoomAuto=true;renderGraph();}} +function setCanvasFullscreen(mode){ + if(mode===canvasFullscreen)return; + canvasFullscreen=mode; + $('.canvas-panel').classList.toggle('canvas-overlay',mode===FULLSCREEN_OVERLAY); + syncCanvasFullscreenButton(); + refitCanvasFullscreen(); +} +$('#graph-fullscreen').onclick=async()=>{ + const panel=$('.canvas-panel'); + if(canvasFullscreen===FULLSCREEN_OFF){ + const request=panel.requestFullscreen??panel.webkitRequestFullscreen; + setCanvasFullscreen(requestMode(canvasFullscreen,{apiSupported:typeof request==='function'})); + if(typeof request==='function'){try{await request.call(panel);}catch{setCanvasFullscreen(rejectApi(canvasFullscreen));}} + }else if(canvasFullscreenElement()){try{await (document.exitFullscreen??document.webkitExitFullscreen).call(document);}catch{/* fullscreenchange still syncs the state */}} + else setCanvasFullscreen(FULLSCREEN_OFF); +}; +document.addEventListener('fullscreenchange',()=>{ + const onPanel=canvasFullscreenElement()===$('.canvas-panel'); + setCanvasFullscreen(apiChange(canvasFullscreen,onPanel)); + if(onPanel||!canvasFullscreenElement())refitCanvasFullscreen(); +}); +document.addEventListener('webkitfullscreenerror',()=>{setCanvasFullscreen(rejectApi(canvasFullscreen));}); +document.addEventListener('keydown',e=>{if(shouldExitOnKey(canvasFullscreen,{key:e.key,dialogOpen:!!document.querySelector('dialog[open]')}))setCanvasFullscreen(FULLSCREEN_OFF);}); $('#new-run').onclick=showCreate;$('#empty-start').onclick=showCreate;$('#close-create').onclick=()=>$('#create-dialog').close();$('#close-read').onclick=()=>$('#read-dialog').close(); $('#create-form [name=executor]').onchange=renderModeNote; function renderModeNote(){$('#mode-note').textContent=t($('#create-form').elements.executor.value==='demo'?'demoNote':'mcodeNote');} @@ -158,7 +248,7 @@ function applyLanguage(){ const name=$('#create-form').elements.name; if(!name.dataset.edited)name.value=t('defaultName'); const option=$('#create-form option[value=mcode]');option.disabled=false;option.textContent=t(mcodeAvailable?'mcodeOption':'missingOption'); - if($('#create-dialog').open){$('#create-title').textContent=t($('#create-form').dataset.repairId?'repairRun':$('#create-form').dataset.runId?'editDraft':'newHeading');$('#save-draft').textContent=t($('#create-form').dataset.repairId?'createRepair':$('#create-form').dataset.runId?'saveDraft':'createDraft');}setConnection(connectionKey);renderScheduler();renderModeNote();updateLimitSummary();renderList();renderRun();renderEvents();renderRead(); + if($('#create-dialog').open){$('#create-title').textContent=t($('#create-form').dataset.repairId?'repairRun':$('#create-form').dataset.runId?'editDraft':'newHeading');$('#save-draft').textContent=t($('#create-form').dataset.repairId?'createRepair':$('#create-form').dataset.runId?'saveDraft':'createDraft');}setConnection(connectionKey);renderScheduler();renderModeNote();updateLimitSummary();renderList();renderRun();renderEvents();renderRead();renderLineage(); if(!current)error(lastAlert); if(lastFormMessage)$('#form-error').textContent=lastFormMessage.key?t(lastFormMessage.key):apiMessage(lastFormMessage.error); if($('#resume-dialog').open&¤t)$('#resume-note').textContent=t('resumeNote',{used:current.attempts,max:current.maxCalls})+(current.legacyLimits?' '+t('legacyNote'):''); @@ -200,4 +290,19 @@ async function openTemplates(){const f=$('#template-form');f.hidden=!current;f.e $('#open-templates').onclick=openTemplates;$('#save-template').onclick=openTemplates;$('#close-templates').onclick=()=>$('#templates-dialog').close(); $('#template-form').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget,button=e.submitter??f.querySelector('button[type=submit]');button.disabled=true;try{await api('/templates','POST',{runId:f.dataset.runId,name:f.elements.name.value,...(f.dataset.revision?{revision:Number(f.dataset.revision)}:{})});await renderTemplates();$('#templates-error').hidden=false;$('#templates-error').textContent=t('templateSaved');}catch(e){$('#templates-error').hidden=false;$('#templates-error').textContent=apiMessage(e.message);}finally{button.disabled=false;}}; +async function renderTrash(){ + const list=$('#trash-list');list.replaceChildren(); + const runs=await api('/runs?trash=1'); + if(!runs.length)list.append(el('p',{class:'subtle'},t('trashEmpty'))); + for(const r of runs){const row=el('article',{class:'template-card'}),text=el('div'); + const days=Math.ceil((r.purgeAfter-Date.now())/86400000); + text.append(el('h3',{},r.name),el('p',{},`${labels[r.status]??r.status} · ${t('trashDeleted',{date:new Date(r.deletedAt).toLocaleString(language==='zh'?'zh-CN':'en-US')})} · ${days>0?t('trashRemaining',{days}):t('trashExpired')}`)); + const restore=el('button',{type:'button'},t('trashRestore')); + restore.onclick=async()=>{restore.disabled=true;try{await api(`/runs/${r.id}/restore`,'POST',{by:'studio'});await renderTrash();await refreshList();if(current&&$('#lineage-panel').open)void loadLineage(current.id);}catch(e){$('#trash-error').hidden=false;$('#trash-error').textContent=apiMessage(e.message);restore.disabled=false;}}; + const actions=el('div',{class:'template-actions'});actions.append(restore);row.append(text,actions);list.append(row);} +} +$('#open-trash').onclick=async()=>{$('#trash-error').hidden=true;$('#trash-dialog').showModal();try{const {trashRetentionDays}=await api('/trash');$('#trash-form').elements.trashRetentionDays.value=String(trashRetentionDays);await renderTrash();}catch(e){$('#trash-error').hidden=false;$('#trash-error').textContent=apiMessage(e.message);}}; +$('#close-trash').onclick=()=>$('#trash-dialog').close(); +$('#trash-form').onsubmit=async e=>{e.preventDefault();const button=e.submitter??$('#trash-form').querySelector('button[type=submit]');button.disabled=true;try{await api('/trash','POST',{trashRetentionDays:Number(e.currentTarget.elements.trashRetentionDays.value)});$('#trash-error').hidden=true;await renderTrash();}catch(e){$('#trash-error').hidden=false;$('#trash-error').textContent=apiMessage(e.message);}finally{button.disabled=false;}}; + function renderLegend(steps){const box=$('#graph-legend');box.replaceChildren();const states=showPlan||current?.status==='pending_review'?['planned']:['awaiting','queued','running','succeeded','failed',...(steps.some(s=>['blocked','not_run','interrupted'].includes(s.status))?['not_run']:[])];for(const state of states){const item=el('span',{class:`legend-state ${state}`});item.append(el('i',{'aria-hidden':'true'}),document.createTextNode(labels[state]));box.append(item);}} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/fullscreen-model.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/fullscreen-model.mjs new file mode 100644 index 00000000..01a1cb5e --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/fullscreen-model.mjs @@ -0,0 +1,34 @@ +// Canvas fullscreen mode transitions, extracted from the DOM wiring so the +// fallback order (Fullscreen API first, fixed overlay when unavailable or +// denied) and the Escape routing stay regression-testable without a browser. +// The API form is confirmed by the browser's fullscreenchange event; Esc in +// that form is handled natively by the user agent, so only the overlay form +// routes Escape through the page — and never past an open dialog. +export const FULLSCREEN_OFF='off',FULLSCREEN_API='api',FULLSCREEN_OVERLAY='overlay'; + +// Entering from off picks the native API when the page can request it and the +// fixed overlay otherwise; an already-active mode is idempotent. +export function requestMode(state,{apiSupported=false}={}){ + if(state===FULLSCREEN_API||state===FULLSCREEN_OVERLAY)return state; + return apiSupported?FULLSCREEN_API:FULLSCREEN_OVERLAY; +} + +// A requestFullscreen() rejection (iframe without allow="fullscreen", +// permission denied) drops the optimistic API mode to the overlay instead of +// stranding the canvas; other states pass through untouched. +export function rejectApi(state){return state===FULLSCREEN_API?FULLSCREEN_OVERLAY:state;} + +// fullscreenchange verdict: the panel in the top layer means API mode, our API +// mode losing the top layer means off, and an unrelated state (overlay) is +// left alone so a foreign element going fullscreen cannot cancel the overlay. +export function apiChange(state,onPanel){ + if(onPanel)return FULLSCREEN_API; + return state===FULLSCREEN_API?FULLSCREEN_OFF:state; +} + +// Escape exits only the overlay form — the API form exits through the browser +// — and never while a modal dialog is open, so closing a node or report keeps +// the canvas fullscreen (data refreshes, interaction state survives). +export function shouldExitOnKey(state,{key='',dialogOpen=false}={}){ + return state===FULLSCREEN_OVERLAY&&key==='Escape'&&!dialogOpen; +} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs index 8d56ed9a..15144ae0 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs @@ -442,6 +442,14 @@ Object.assign(messages.zh,{"templates": "模板库", "saveTemplate": "保存为 Object.assign(messages.en,{"templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps"}); Object.assign(messages.zh,{"reviewCompact": "等待审核", "reviewCompactHelp": "检查下方流程,确认后开始。", "reviewDetails": "任务详情与执行设置", "reviewBudgets": "并发 {concurrency} · 最多 {calls} 次调用 · 每节点 {steps} 步 / {minutes} 分钟", "status.awaiting": "尚未开始", "status.blocked": "依赖受阻", "status.not_run": "未执行", "awaitingHelp": "该节点尚未创建执行任务。启动后会在这里更新状态。", "blockedHelp": "已声明的上游节点未成功,当前节点尚未执行。", "notRunHelp": "本次运行已经结束,未触发这个计划节点。", "dynamicHelp": "节点数量由运行结果决定;已创建的节点会在此分组中展开。"}); Object.assign(messages.en,{"reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \u00b7 Up to {calls} calls \u00b7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group."}); +Object.assign(messages.zh,{"trash": "回收站", "trashHelp": "删除的工作流先进入回收站:事件、节点与结果全部保留,可随时恢复。到期后由归档轮转回收存储;审计事件永不删除。", "trashEmpty": "回收站为空。", "trashRestore": "恢复", "trashRemaining": "保留剩余 {days} 天", "trashExpired": "已到期,等待归档轮转", "trashDeleted": "删除于 {date}", "trashRetention": "回收站保留期(天)", "trashRetentionHelp": "默认 30 天;0 表示不到期,仅手动轮转归档。修改会同步更新回收站中已有条目的到期时间。", "event.run.deleted": "已删除到回收站", "event.run.restored": "已恢复"}); +Object.assign(messages.en,{"trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored"}); +Object.assign(messages.zh,{"lineage": "复跑谱系", "lineageCount": "{count} 次运行", "lineageRootRun": "原始运行", "lineageRerun": "复跑 第 {seq} 次", "lineageOpen": "打开", "lineageMemberDeleted": "已删除(回收站)", "lineageMemberArchived": "已归档", "lineageCompareHint": "同谱系历次运行", "lineageCompare": "对比结果", "lineageCompareLeft": "对比左侧运行", "lineageCompareRight": "对比右侧运行", "lineageVersus": "对比", "lineageNoResult": "暂无结果"}); +Object.assign(messages.zh,{"event.archive.rotated": "回收站归档轮转"}); +Object.assign(messages.en,{"lineage": "Rerun lineage", "lineageCount": "{count} runs", "lineageRootRun": "Original run", "lineageRerun": "Rerun #{seq}", "lineageOpen": "Open", "lineageMemberDeleted": "Deleted (in trash)", "lineageMemberArchived": "Archived", "lineageCompareHint": "Every rerun of this workflow", "lineageCompare": "Compare results", "lineageCompareLeft": "Left run to compare", "lineageCompareRight": "Right run to compare", "lineageVersus": "vs", "lineageNoResult": "No result yet"}); +Object.assign(messages.en,{"event.archive.rotated": "Archive rotation"}); +Object.assign(messages.zh,{"canvasFullscreen": "全屏", "canvasExitFullscreen": "退出全屏"}); +Object.assign(messages.en,{"canvasFullscreen": "Fullscreen", "canvasExitFullscreen": "Exit fullscreen"}); export const LANGUAGE_KEY = 'workflow-language'; export function normalizePreference(value) { return ['zh','en'].includes(value) ? value : 'auto'; } export function resolveLanguage(preference, languages = []) { diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html b/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html index 165723a3..cbed33f2 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html @@ -3,7 +3,7 @@
@@ -12,14 +12,15 @@
WORKFLOW STUDIO

把复杂任务,编排得清晰有序。

拆解、并行、复核、汇总。在一张画布里,掌握每个 Agent 的进展。

也可以在 MCode 对话中使用 dynamic-workflow 创建编排。
+

运行日志

展开执行记录

    创建工作流

    确定任务与预算,开始一次可追踪的执行。

    步数是单个 Agent 的模型决策轮数;调用数是工作流启动 Agent 的次数。

    脚本是异步函数体。ctx.agent 返回 status/output/error;依赖须显式声明 dependsOn,且先 await 上游结果。

    调整限制并恢复

    成功节点复用,失败节点从头重跑,可能再次消耗模型用量。原脚本和输入保持不变。

    任务内容、模型输出与原始诊断保留原文。

    -

    +

    +

    diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs new file mode 100644 index 00000000..6583595a --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs @@ -0,0 +1,11 @@ +// Pure model for the rerun-lineage panel's compare controls. The member +// selects and the diff box carry user interaction state (chosen pair, open +// comparison), so they may only be rebuilt when member identity changes; +// live member data (status, duration, trash/archive flags, preview) +// refreshes through the member cards on every render instead. +export function compareSignature(language,members){ + return JSON.stringify([language,...(members??[]).map(member=>[member.id,member.rerunSeq,member.name])]); +} +export function shouldRebuildCompare(previous,next){ + return previous!==next; +} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css b/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css index 450af539..de3f48de 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css @@ -37,3 +37,13 @@ main{min-width:0;display:flex;flex-direction:column;height:100dvh}.utility-bar{h @media(max-width:620px){#node-dialog{width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:0;border-radius:0}.node-reader-header{padding:12px 16px;gap:8px}.node-identity{padding:20px 20px 16px}.node-identity h2{font-size:21px}.node-controls{padding:0 20px 14px;flex-wrap:wrap}.node-controls .tabs{width:100%;min-width:0}.node-reading-actions{width:100%;justify-content:flex-end}.node-scroll{padding:22px 20px}.node-facts{gap:12px;flex-wrap:wrap}#node-meta{grid-template-columns:100px minmax(0,1fr)}#read-dialog.report-dialog{width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;padding:14px;border-radius:0}.report-dialog .report-downloads p{flex-basis:100%}} .repair-context{padding:18px;border:1px solid var(--line);border-radius:12px;margin-bottom:20px}.repair-context pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:150px;overflow:auto;font-size:12px}.repair-context .repair-candidate{display:flex;align-items:center;gap:10px;margin:6px 0;padding:5px 0}.repair-context .repair-candidate input{width:16px;height:16px;min-height:0;flex:none;margin:0;padding:0}.repair-candidate span{overflow-wrap:anywhere}#repair-lineage{margin:0 0 16px;overflow-wrap:anywhere}#repair-lineage a{color:inherit} + +/* Rerun lineage: the family ledger plus a two-column read-only result compare. */ +.lineage-body{padding:2px 18px 16px}.lineage-compare{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 0 6px}.lineage-compare select{margin-top:0;min-height:32px;font-size:11px;flex:1;min-width:150px}.lineage-versus{color:#a196b4;font-size:11px}.lineage-member{display:flex;gap:16px;align-items:center;border-top:1px solid #f0eaf6;padding:13px 0}.lineage-member:first-of-type{border-top:0}.lineage-member>div:first-child{flex:1;min-width:0}.lineage-member h3{font-size:13px;margin:0 0 4px;overflow-wrap:anywhere;color:#584a66}.lineage-member.current h3{color:#6a54a8}.lineage-member p{font-size:11px;color:#8a7d9c;margin:0}.lineage-preview{font:10px/1.6 var(--mono);color:#9a8ca8;white-space:pre-wrap;overflow-wrap:anywhere;max-height:48px;overflow:hidden}.lineage-flag{color:#b0803f}.lineage-diff{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.lineage-column{border:1px solid #e9e2f2;border-radius:9px;background:#fbf9fd;min-width:0;display:flex;flex-direction:column}.lineage-column h3{font-size:12px;margin:0;padding:9px 12px;border-bottom:1px solid #eee6f5;overflow-wrap:anywhere;color:#584a66}.lineage-column pre{margin:0;padding:12px;font:11px/1.7 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;max-height:340px;overflow:auto;color:#6f5f80}@media(max-width:620px){.lineage-diff{grid-template-columns:minmax(0,1fr)}.lineage-compare select{min-width:0}} + +/* Canvas fullscreen: the Fullscreen API puts the panel in the top layer; the + opaque fixed overlay is the fallback when that request is unavailable or + denied (embedded iframe, permission refusal). Pan/zoom and the toolbar with + the exit control stay available in both forms. */ +.canvas-panel.canvas-overlay{position:fixed;inset:0;z-index:40;height:100dvh;margin:0;border:0;border-radius:0;box-shadow:none} +.canvas-panel:fullscreen{border:0;border-radius:0}