From dcee39412d3ac7ea1e05e20c01bc1b6574967168 Mon Sep 17 00:00:00 2001 From: moc Date: Mon, 21 Sep 2026 20:31:53 +0800 Subject: [PATCH 1/7] feat(dynamic-workflow): tombstone trash with restore for finished runs Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, first half). - Tombstone soft delete on terminal runs (succeeded/failed/ completed_with_gaps/cancelled/interrupted): run body gains deletedAt/deletedBy/purgeAfter plus an append-only run.deleted audit event; steps, events, result and the integrity ledger stay untouched. - Query faces all filter tombstones: run list, snapshot (workflow_status, HTTP GET), cross-run reuse candidate scan, repair source scan, resume, and requestId idempotent replay (rejected with a recovery hint instead of resurrecting ghost data). - Restore clears the tombstone and appends run.restored; the run reappears byte-identical. Repeat deletes are idempotent and never append a second event. - Retention: trashRetentionDays setting (default 30, 0 = manual-only expiry) via the store settings face; changing it restamps tombstones already in trash so the displayed countdown stays truthful. - Exposure: HTTP DELETE /api/runs/:id, POST /api/runs/:id/restore, GET /api/runs?trash=1, GET/POST /api/trash; MCP workflow_delete / workflow_restore (old daemons gated via the trashManagement feature flag); Studio trash view with restore button and retention countdown. - checks/trash.check.mjs pins hide/restore round trips, every filtered face, idempotence, the reuse interaction, retention math and the HTTP/MCP surfaces; tool-count assertions move from 11 to 13. --- .../mcode-dynamic-workflows/README.md | 4 +- .../checks/package.check.mjs | 4 +- .../checks/trash.check.mjs | 163 ++++++++++++++++++ .../checks/workspace-router.check.mjs | 2 +- .../mcode-dynamic-workflows/dist/main.mjs | 101 ++++++++++- .../mcode-dynamic-workflows/src/engine.mjs | 58 ++++++- .../mcode-dynamic-workflows/src/http.mjs | 10 +- .../mcode-dynamic-workflows/src/main.mjs | 3 +- .../mcode-dynamic-workflows/src/store.mjs | 12 +- .../mcode-dynamic-workflows/src/tools.mjs | 4 + .../test/package.test.mjs | 2 +- .../mcode-dynamic-workflows/web/app.js | 58 +++++++ .../web/app.source.mjs | 15 ++ .../mcode-dynamic-workflows/web/i18n.mjs | 2 + .../mcode-dynamic-workflows/web/index.html | 5 +- 15 files changed, 414 insertions(+), 29 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/trash.check.mjs diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index f671ac8f..4da599a8 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_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/package.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs index ab9cbe94..16b6ae7f 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,13);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,13);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/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..5fab677c 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,13);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..2839bbf4 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7813,8 +7813,21 @@ var Store = class { 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 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)); + 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); + } + }); } step(runId, id2) { const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); @@ -7826,9 +7839,11 @@ var Store = class { // 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. + // 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')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId, requestHash, contextHash, lineageHash, limit).map((r) => { + 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 }; }); @@ -14256,6 +14271,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(); @@ -14316,6 +14333,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"); @@ -14336,6 +14354,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 +14446,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 +14496,57 @@ 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); + check(run, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + 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); + } + check(false, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + } drain() { while (this.slots < this.globalConcurrency) { const ids = [...this.active.keys()], last = ids.indexOf(this.lastServedRun); @@ -14819,6 +14890,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 +15400,8 @@ 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" }); 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 +26634,8 @@ 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_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 +26691,10 @@ 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_resume": return summary(await engine.resume(args.runId, args)); case "workflow_dashboard": @@ -26655,7 +26735,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 }, 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 +26750,17 @@ 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))?$/); 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)); 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 +26777,12 @@ 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/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)); @@ -27746,7 +27833,7 @@ 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) 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); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index a482e4a9..5d6447d9 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -16,6 +16,10 @@ 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'; +// 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=[]) { @@ -47,7 +51,7 @@ 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}; @@ -55,6 +59,7 @@ export class Engine extends EventEmitter { } 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 +108,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 +121,49 @@ 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);check(run,'工作流不存在'); + 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); + } + check(false,'工作流不存在'); } drain(){ while(this.slots({...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(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))?$/); 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(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/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..4a8eada6 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs @@ -70,7 +70,8 @@ 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))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; }); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 5ac9fc16..8616ed7c 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -45,14 +45,20 @@ 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);}});} 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}`);});} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index 09731d5f..64eb32dc 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs @@ -19,6 +19,8 @@ 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_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 +36,8 @@ 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_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..037e3842 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,13);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..1203b2fb 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1850,6 +1850,8 @@ 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" }); var LANGUAGE_KEY = "workflow-language"; function normalizePreference(value) { return ["zh", "en"].includes(value) ? value : "auto"; @@ -2835,6 +2837,62 @@ $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(); + } 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..7d1e4260 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs @@ -200,4 +200,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();}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/i18n.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs index 8d56ed9a..24ad1681 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs @@ -442,6 +442,8 @@ 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"}); 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..a1c03d16 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html @@ -3,7 +3,7 @@
@@ -22,4 +22,5 @@

调整限制并恢复

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

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

-

+

+

From af2d18731bf569b19ed6789d98f77a8087a7263d Mon Sep 17 00:00:00 2001 From: moc Date: Mon, 21 Sep 2026 20:40:51 +0800 Subject: [PATCH 2/7] feat(dynamic-workflow): rotate expired trash into a verifiable archive Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, second half). - Rotation compaction exports due tombstones (purgeAfter <= now) into the sidecar /archive.db and physically deletes their live runs/steps rows; the events table never loses a row (program red line). The audit event archive.rotated {rotationId, runs, manifestHash, runCount, bytes} is appended in the same transaction as the deletes, anchoring the manifest on the #48 events hash chain. - manifestHash is computed by one shared implementation (archiveManifestHash: canonical JSON over runs+steps rows in (runId, id) order) used by both rotation and verification, so the two hashes cannot drift; checks/archive-rotate.check.mjs recomputes it independently. - Archive rows are keyed by (rotationId, runId) instead of a bare runs PK: a run that is restored, re-deleted and re-rotated writes a fresh copy and can never rewrite rows an earlier rotation's manifest still covers. - Verification (verifyArchive) recomputes every rotation's manifest from the archived rows and compares it against both the rotations record and the chained event; chained-but-missing rotations and orphan archive rows fail closed. verifyIntegrity() gains an archive face alongside events/repair without touching their semantics. - Restore from archive copies rows back (idempotent upserts), clears the tombstone and appends run.restored {origin:'archive', rotationId}; a requestId claimed by a newer live run blocks the restore loudly. workflow_delete on a rotated run reports the archive state truthfully. - Triggers: CLI --rotate-archive (with --verify for the shared verify outlet; null verdicts from empty chains are not failures) and --restore , both forwarding to a live service instead of fighting the owner lock; service startup auto-rotates when tombstones exceed 500 or the runs table exceeds ~100MB (constants documented in store.mjs), skipped entirely when trashRetentionDays=0 (manual-only). HTTP face: POST /api/archive/rotate. - checks/archive-rotate.check.mjs pins due-only rotation, the events-row red line, tamper detection (step body, forged rotations hash, deleted rotation record), archive restore and re-rotation verifiability, the 501-vs-400 startup threshold against a real daemon, and the CLI faces with real exit codes. --- .../checks/archive-rotate.check.mjs | 210 ++++++ .../mcode-dynamic-workflows/dist/main.mjs | 655 ++++++++++++------ .../mcode-dynamic-workflows/src/engine.mjs | 31 +- .../mcode-dynamic-workflows/src/http.mjs | 1 + .../mcode-dynamic-workflows/src/main.mjs | 42 +- .../mcode-dynamic-workflows/src/store.mjs | 134 +++- .../mcode-dynamic-workflows/web/app.js | 2 + .../mcode-dynamic-workflows/web/i18n.mjs | 2 + 8 files changed, 842 insertions(+), 235 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs 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/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 2839bbf4..d307cc3d 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7712,233 +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; - } - // 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); - } - }); - } - 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 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]; @@ -13638,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)); } @@ -13673,6 +13452,360 @@ ${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); + } + // 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 }); @@ -14518,7 +14651,11 @@ var Engine = class extends EventEmitter { 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); - check(run, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + 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"); @@ -14545,7 +14682,27 @@ var Engine = class extends EventEmitter { this.emitEvent(id2, "run.restored", { by, origin: "trash" }); return this.snapshot(id2); } - check(false, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + const archived = this.store.restoreArchived(id2, { by }); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728\u6216\u672A\u5F52\u6863"); + return this.snapshot(id2); + } + // 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) { @@ -15402,6 +15559,8 @@ Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "revie 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, { "event.archive.rotated": "\u56DE\u6536\u7AD9\u5F52\u6863\u8F6E\u8F6C" }); +Object.assign(messages.en, { "event.archive.rotated": "Archive rotation" }); 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}}`)); @@ -26778,6 +26937,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import } 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); @@ -27732,7 +27892,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"); @@ -27840,6 +28000,42 @@ if (values.stdio && process.env.MCODE_WORKFLOW_CHILD === "1") { 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"); @@ -27849,6 +28045,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 5d6447d9..2a370ba8 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -16,6 +16,7 @@ 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']); @@ -143,7 +144,12 @@ export class Engine extends EventEmitter { // never append a second event. async deleteRun(id,{by='studio'}={}) { check(!this.closing,'服务正在关闭');check(TRASH_SOURCES.has(by),'无效的删除来源'); - const run=this.store.get(id);check(run,'工作流不存在'); + 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),'仅已完成的工作流可删除(运行中或待审核不可删除)'); @@ -163,7 +169,28 @@ export class Engine extends EventEmitter { this.save(run);this.emitEvent(id,'run.restored',{by,origin:'trash'}); return this.snapshot(id); } - check(false,'工作流不存在'); + // 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); + } + // Manual rotation face: rotate due tombstones now, optionally returning the + // archive and integrity verdicts alongside the rotation summary. + rotateArchive({verify=false}={}) { + check(!this.closing,'服务正在关闭'); + 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`${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); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs index 4a8eada6..147e3df5 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 的整数'); @@ -77,6 +77,41 @@ if(values.stdio&&process.env.MCODE_WORKFLOW_CHILD==='1'){ }); // 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'); @@ -85,6 +120,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 8616ed7c..b5e8d49c 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')); @@ -50,6 +65,11 @@ export class Store { 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);} + // 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 @@ -99,8 +119,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/web/app.js b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js index 1203b2fb..c13627cd 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1852,6 +1852,8 @@ Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "revie 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, { "event.archive.rotated": "\u56DE\u6536\u7AD9\u5F52\u6863\u8F6E\u8F6C" }); +Object.assign(messages.en, { "event.archive.rotated": "Archive rotation" }); var LANGUAGE_KEY = "workflow-language"; function normalizePreference(value) { return ["zh", "en"].includes(value) ? value : "auto"; diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs index 24ad1681..7f72c9d4 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs @@ -444,6 +444,8 @@ Object.assign(messages.zh,{"reviewCompact": "等待审核", "reviewCompactHelp": 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,{"event.archive.rotated": "回收站归档轮转"}); +Object.assign(messages.en,{"event.archive.rotated": "Archive rotation"}); export const LANGUAGE_KEY = 'workflow-language'; export function normalizePreference(value) { return ['zh','en'].includes(value) ? value : 'auto'; } export function resolveLanguage(preference, languages = []) { From 5633c91b2544e4e9be87ce0e077ac0d772095bad Mon Sep 17 00:00:00 2001 From: moc Date: Mon, 21 Sep 2026 20:54:28 +0800 Subject: [PATCH 3/7] feat(dynamic-workflow): rerun finished workflows into a queryable lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系), stacked on PR1's tombstone/restore/rotation commits. A rerun starts a NEW pending_review run from the source's script+input and never writes the source. The child carries rerunOf/lineageRoot/rerunSeq on its body, so the family survives unlimited reruns, trash and archive rotation (members are annotated, never removed). Key decisions: - requestId is synthesized as `#rerun-`; n counts live, tombstoned AND archived family members so a seq is never reused after rotation, with a numeric-suffix retry when the id is already claimed (150-char cap kept). - reuseAcrossRuns defaults to false on reruns (explicit opt-in only): silent adoption would turn a rerun into a fake execution and poison comparison. - Reruns pass through the existing pending_review/approve gate; the rerun action itself only creates the draft, it never auto-executes. Tombstoned sources are refused until restored; rerunning straight from the archive is deliberately not offered (restore first). - GET /api/runs/:id/lineage resolves the family from any member id (live, tombstoned or archived), ordered root-first then by seq, with per-member status/duration/timestamps/result summary; ?results=1 adds full results for the read-only compare face. Durations come from the events ledger, which rotation never moves. - MCP workflow_rerun (input override + reuseAcrossRuns flag) is gated behind a rerunLineage feature flag, mirroring the trash tools' old-daemon guard. - Studio gains a lineage panel (member list with trash/archive flags) and a two-column read-only JSON result compare. - New checks/rerun-lineage.check.mjs pins: source byte-identity across a rerun, family ordering over 3 reruns, default no-reuse vs explicit reuse, trash/rotation refusal with family integrity, requestId collision retry, HTTP lineage shape and the MCP schema. Files: src/store.mjs src/engine.mjs src/http.mjs src/tools.mjs src/main.mjs web/index.html web/app.source.mjs web/app.js web/i18n.mjs web/style.css checks/rerun-lineage.check.mjs checks/package.check.mjs checks/workspace-router.check.mjs test/package.test.mjs README.md dist/main.mjs --- .../mcode-dynamic-workflows/README.md | 2 +- .../checks/package.check.mjs | 4 +- .../checks/rerun-lineage.check.mjs | 179 ++++++++++++++++++ .../checks/workspace-router.check.mjs | 2 +- .../mcode-dynamic-workflows/dist/main.mjs | 119 +++++++++++- .../mcode-dynamic-workflows/src/engine.mjs | 60 +++++- .../mcode-dynamic-workflows/src/http.mjs | 6 +- .../mcode-dynamic-workflows/src/main.mjs | 3 +- .../mcode-dynamic-workflows/src/store.mjs | 28 +++ .../mcode-dynamic-workflows/src/tools.mjs | 2 + .../test/package.test.mjs | 2 +- .../mcode-dynamic-workflows/web/app.js | 92 ++++++++- .../web/app.source.mjs | 56 +++++- .../mcode-dynamic-workflows/web/i18n.mjs | 2 + .../mcode-dynamic-workflows/web/index.html | 2 +- .../mcode-dynamic-workflows/web/style.css | 3 + 16 files changed, 535 insertions(+), 27 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/rerun-lineage.check.mjs diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index 4da599a8..334ffc44 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md @@ -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_delete`, `workflow_restore`, `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/package.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/package.check.mjs index 16b6ae7f..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,13);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,13);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/workspace-router.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/workspace-router.check.mjs index 5fab677c..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,13);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 d307cc3d..58448a83 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -13576,6 +13576,39 @@ var Store = class { 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. @@ -14448,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 ?? {}); @@ -14475,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); } @@ -14686,6 +14719,74 @@ var Engine = class extends EventEmitter { 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 } = {}) { @@ -15559,7 +15660,9 @@ Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "revie 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" }); function translate(language, key, vars = {}) { if (language === "en" && vars.count === 1 && ["tasks", "eventsCount"].includes(key)) return key === "tasks" ? "1 task" : "1 event"; @@ -26795,6 +26898,7 @@ var TOOLS = [ { 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) { @@ -26854,6 +26958,8 @@ function createToolHandler(engine, getURL) { 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": @@ -26894,7 +27000,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, trashManagement: 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") { @@ -26914,9 +27020,10 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import 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))?$/); + 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" })); @@ -27993,7 +28100,7 @@ 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 || (name === "workflow_delete" || name === "workflow_restore") && !service.config.features?.trashManagement) 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); + 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); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index 2a370ba8..84287f1f 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -39,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'); @@ -55,8 +55,8 @@ export class Engine extends EventEmitter { 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,'工作流不存在'); @@ -174,6 +174,60 @@ export class Engine extends EventEmitter { 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);} @@ -28,8 +28,8 @@ export async function startHTTP(engine,{port=0,webRoot=new URL('../web/',import. if(req.method==='GET'&&url.pathname==='/api/scheduler')return json(engine.schedulerStatus()); 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))?$/); - 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]));} + 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||'{}'); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs index 147e3df5..48e24eda 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs @@ -71,7 +71,8 @@ if(values.stdio&&process.env.MCODE_WORKFLOW_CHILD==='1'){ } const mcp=await startStdio(async(name,args)=>{ if(((name==='workflow_repair'||(name==='workflow_results'&&args?.includeDefinition))&&!service.config.features?.workflowRepair) - ||((name==='workflow_delete'||name==='workflow_restore')&&!service.config.features?.trashManagement))throw Error('WORKFLOW_SERVICE_UPGRADE_REQUIRED: 当前后台服务版本不支持此操作。退出聊天不会重启服务。请先暂停或取消活动工作流,使用新版插件的 --stop-service(相同 --workspace 和 --data-dir)停止此项目服务,再重新连接 MCP;端口和历史会保留。data-dir: '+dataDir); + ||((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; }); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index b5e8d49c..598dc262 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -66,6 +66,34 @@ export class Store { 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. diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index 64eb32dc..03980422 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs @@ -21,6 +21,7 @@ export const TOOLS=[ {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};} @@ -38,6 +39,7 @@ export function createToolHandler(engine,getURL){return async(name,args={})=>{ 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 037e3842..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,13);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 c13627cd..0959e161 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1852,7 +1852,9 @@ Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "revie 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" }); var LANGUAGE_KEY = "workflow-language"; function normalizePreference(value) { @@ -1908,6 +1910,7 @@ var zoom = 0; var zoomAuto = true; var tab = "output"; var busy = false; +var lineage = null; var defaults = { maxSteps: 120, stepTimeoutMs: 18e5, runTimeoutMs: 72e5 }; var ns = "http://www.w3.org/2000/svg"; var labels = new Proxy({}, { get: (_2, key) => { @@ -2000,9 +2003,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; @@ -2037,12 +2042,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); @@ -2089,6 +2094,7 @@ function renderRun() { renderGraph(); renderNode(); renderRead(); + renderLineage(); } function graphSteps() { return workflowGraph(current, { planOnly: showPlan }).nodes; @@ -2249,6 +2255,83 @@ function renderEvents() { if (nearBottom) list.scrollTop = list.scrollHeight; renderNode(); } +async function loadLineage(id, version = selectionVersion) { + try { + const data = await api(`/runs/${id}/lineage`); + if (viewing(id, version)) { + lineage = data; + renderLineage(); + } + } catch { + if (viewing(id, version)) { + 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) 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 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(); @@ -2651,6 +2734,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") : ""); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs index 7d1e4260..ec7e588a 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs @@ -6,7 +6,7 @@ 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,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 +26,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){ @@ -40,7 +40,7 @@ async function refreshCurrent(){ try{const next=await api(`/runs/${id}`);if(viewing(id,version)){current=next;renderRun();}} 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){$('#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,6 +91,54 @@ 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){ + try{const data=await api(`/runs/${id}/lineage`);if(viewing(id,version)){lineage=data;renderLineage();}} + catch{if(viewing(id,version)){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)return; + $('#lineage-count').textContent=t('lineageCount',{count:members.length}); + 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); + } + 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);}}}; @@ -158,7 +206,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'):''); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs index 7f72c9d4..7de66769 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs @@ -444,7 +444,9 @@ Object.assign(messages.zh,{"reviewCompact": "等待审核", "reviewCompactHelp": 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"}); export const LANGUAGE_KEY = 'workflow-language'; export function normalizePreference(value) { return ['zh','en'].includes(value) ? value : 'auto'; } diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html b/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html index a1c03d16..b85edc7e 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/index.html @@ -16,7 +16,7 @@
等待任务展开…
滚动浏览 · 选择节点查看详情
-

运行日志

展开执行记录
    +

    运行日志

    展开执行记录

      创建工作流

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

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

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

      调整限制并恢复

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

      diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css b/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css index 450af539..ecb3748b 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/style.css @@ -37,3 +37,6 @@ 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}} From 22def56b2a0ff3bfea3134ce03c30e4fe6e9c11d Mon Sep 17 00:00:00 2001 From: moc Date: Sun, 20 Sep 2026 18:54:40 +0800 Subject: [PATCH 4/7] fix(checks): retry win32 tmpdir cleanup after MCP transport close race Fork preview run 35506090983 (first full windows-latest pass) failed check #9 "packaged MCP advertises reuseAcrossRuns and accepts it through the public tool surface" with: EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wf-cross-mcp-NCVTMk' Every assertion passed; the failure came from the finally block. StdioClientTransport.close() resolving does not mean the spawned dist/main.mjs server process has exited, so the child still held the dataDir (the mkdtemp tmp dir) handle when rm() ran, and win32 refuses to rmdir a busy directory. Linux/darwin unlink open files, which is why all local runs were green. - add a local rmWithRetry helper: retry rm on EBUSY/ENOTEMPTY/EPERM with exponential backoff (200ms base, x2, 5 retries, ~6s total budget), then rethrow as-is -- cleanup failures stay loud, we only give the OS time to release handles - finally: wrap client/transport close in try/catch so a close error cannot skip rm; rethrow the close error after cleanup so it still fails the test Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no other checks, no new dependencies. Local verification: npm test exit=0, 126/126. --- .../mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs index a036360a..2c229680 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs @@ -1,4 +1,6 @@ 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'; +// win32 EBUSY race in tmpdir cleanup: StdioClientTransport.close() resolving does not mean the spawned server process has exited, so the child can still hold the dataDir handle when rm() runs and win32 refuses to rmdir a busy directory (fork preview run 35506090983, first full windows-latest pass; Linux/darwin unlink open files, so this only bites on Windows). Retry the transient lock with exponential backoff, then fail loud — a cleanup failure stays visible, we only give the OS time to release handles. +const rmWithRetry=async(dir)=>{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 +12,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}await rmWithRetry(dir);if(closeErr)throw closeErr;} }); From c421aaa116603bfbd8ccf1aec402fa6979626e43 Mon Sep 17 00:00:00 2001 From: moc Date: Sun, 20 Sep 2026 19:02:58 +0800 Subject: [PATCH 5/7] fix(checks): stop the detached service before tmpdir cleanup in cross-reuse MCP check Round two of the same failure. Fork preview run 35506404071 (with 14a7fd5) failed the same check #9 with the same code: EBUSY: resource busy or locked, rmdir '...\wf-cross-mcp-3VJDll' The test ran 7945ms -- the full ~6.2s retry budget burned while the directory stayed locked. Not a transient lock, so retrying cannot fix it. Real root cause (src/main.mjs, stdio branch): when no existing service is found, --stdio spawns a detached daemon -- spawn(process.execPath, args, {cwd:workspace, detached:true, ...}); child.unref() -- with cwd set to the workspace (the mkdtemp tmp dir), service.log and the database inside dataDir (the same tmp dir). By design "the service, workers and dashboard outlive" the chat transport, so client.close()/transport.close() only tear down the stdio face and the daemon keeps running, holding its cwd handle plus service.log/DB handles inside the tree. win32 refuses to rmdir a directory tree a live process is sitting in; Linux/darwin unlink open files, which is why local runs stay green. Layer two, from run 35506090983: transport.close() resolving does not mean a child has fully exited, so handle release can lag an instant even after the daemon is gone. The codebase's own convention already stops the service first (checks/fail-loud.check.mjs:80, checks/lifecycle.check.mjs:19): run dist/main.mjs --stop-service --workspace --data-dir after closing the client. Identity matches this test because its --settings file sets workspace=dataDir=dir, the same endpoint.json the daemon registered under. - finally: after client/transport close (still deferred-rethrow), run --stop-service wrapped in try/catch so a stop failure cannot block rm - keep rmWithRetry as the release-lag fallback, still failing loud when exhausted - rewrite the comment to the two-layer root cause with both run ids Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no new dependencies. Local verification: npm test exit=0, 126/126; ps confirms the daemon spawned by the check is actually terminated by the stop-service step (no stray wf-cross-mcp-* processes remain). --- .../checks/cross-reuse-mcp.check.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs index 2c229680..83da9a40 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs @@ -1,5 +1,6 @@ -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'; -// win32 EBUSY race in tmpdir cleanup: StdioClientTransport.close() resolving does not mean the spawned server process has exited, so the child can still hold the dataDir handle when rm() runs and win32 refuses to rmdir a busy directory (fork preview run 35506090983, first full windows-latest pass; Linux/darwin unlink open files, so this only bites on Windows). Retry the transient lock with exponential backoff, then fail loud — a cleanup failure stays visible, we only give the OS time to release handles. +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';import {execFile} from 'node:child_process';import {promisify} from 'node:util'; +// win32 EBUSY on tmpdir cleanup has two layers. (1) --stdio spawns a detached daemon (src/main.mjs: spawn with cwd:workspace, service.log and the DB in dataDir, child.unref(); "the service, workers and dashboard outlive" the chat transport by design), so closing client/transport leaves that daemon alive with its cwd and handles inside the mkdtemp dir — win32 refuses to rmdir a tree a live process sits in, and retrying alone cannot fix it: run 35506404071 burned the full ~6s retry budget and still failed EBUSY. Stop the service first, same as checks/fail-loud.check.mjs (--stop-service matches this test's --settings identity because workspace=dataDir=dir in both). (2) Even after the daemon is gone, StdioClientTransport.close() resolving does not mean the child has fully exited, so handle release can lag an instant: run 35506090983. Hence stop-service, then rmWithRetry as a release-lag fallback, then fail loud — cleanup failures stay visible. Linux/darwin unlink open files, so none of this bites locally. +const exec=promisify(execFile); const rmWithRetry=async(dir)=>{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})); @@ -12,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{let closeErr;try{await client.close();await transport.close();}catch(e){closeErr=e}await rmWithRetry(dir);if(closeErr)throw closeErr;} + }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;} }); From 55b8f48e341648d73692721d76a159c55fbb8921 Mon Sep 17 00:00:00 2001 From: moc Date: Mon, 21 Sep 2026 21:44:20 +0800 Subject: [PATCH 6/7] fix(studio): keep lineage compare open across polls and refresh member flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系), UI patch from the egolite real-panel interaction pass. Two bugs, one render path, fixed as one decision: data refreshes, interaction state survives. Bug 1 (compare view wiped by polling): with two members selected, clicking 对比结果 shows the two-column diff (measured 1090x138 visible); ~6s later the poll re-render hides it again (hidden:true, 0x0). Root cause: renderLineage() ran unconditionally on every poll (refreshCurrent -> renderRun -> renderLineage) and executed `#lineage-diff.hidden=true` plus a full select rebuild each pass, resetting the user's diff visibility and left/right selection every few seconds. Bug 2 (stale deleted flag after trash restore): restoring rerun-1 left its lineage card wearing the 已删除(回收站) flag across 8s+ and multiple poll cycles even though the lineage API already returned deleted:null (three members live, trash empty). Root cause: the flag renders from the client's lineage cache, which was only refetched on selectRun and panel toggle - neither happens on restore, so polls kept painting stale data forever (not a DOM-residue issue: rows were rebuilt from stale input). Fix: - renderLineage() now splits data from interaction. Member cards (status, duration, trash/archive flags, preview) re-render on every pass from whatever the cache holds. The compare controls (selects, open diff) are rebuilt only when member identity changes, gated by a pure signature (language + ordered id:rerunSeq:name, web/lineage-model.mjs). Signature unchanged -> selects untouched, #lineage-diff.hidden untouched. Member added/removed/reordered/renamed or language switch -> full rebuild and the diff resets (member set changed, reset is legitimate). First render is unchanged (empty previous signature always rebuilds). - refreshCurrent() refetches the lineage payload (background mode) when the panel is open, so member state can no longer go stale across polls; background errors keep the last good cache instead of blanking the open panel. Trash restore additionally triggers an immediate refetch. - Regression nail: checks/lineage-panel.check.mjs pins the gate logic in node:test (poll-shaped refresh and flag clears never rebuild; identity/ language/name changes always do). The DOM-level outcome (diff stays visible across 6s+ of polling) cannot be carried by node:test (no DOM); to be re-verified with the egolite interaction protocol: click compare -> still visible after 6s+ of polling. Files: plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js (rebuilt via scripts/build-web.mjs) plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs --- .../checks/lineage-panel.check.mjs | 34 +++++++++++++++++++ .../mcode-dynamic-workflows/web/app.js | 23 +++++++++++-- .../web/app.source.mjs | 22 ++++++++---- .../web/lineage-model.mjs | 11 ++++++ 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs create mode 100644 plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs 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/web/app.js b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js index 0959e161..8f7a7e20 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1407,6 +1407,14 @@ 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/i18n.mjs var messages = { "zh": { @@ -1911,6 +1919,7 @@ 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) => { @@ -2033,6 +2042,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; @@ -2255,7 +2265,7 @@ function renderEvents() { if (nearBottom) list.scrollTop = list.scrollHeight; renderNode(); } -async function loadLineage(id, version = selectionVersion) { +async function loadLineage(id, version = selectionVersion, background = false) { try { const data = await api(`/runs/${id}/lineage`); if (viewing(id, version)) { @@ -2263,7 +2273,7 @@ async function loadLineage(id, version = selectionVersion) { renderLineage(); } } catch { - if (viewing(id, version)) { + if (viewing(id, version) && !background) { lineage = null; renderLineage(); } @@ -2276,7 +2286,10 @@ function renderLineage() { const panel = $2("#lineage-panel"), members = lineage?.members ?? []; const show = !!current && (members.length > 1 || !!current.rerunOf); panel.hidden = !show; - if (!show) return; + if (!show) { + lineageSignature = ""; + return; + } $2("#lineage-count").textContent = t("lineageCount", { count: members.length }); const list = $2("#lineage-members"); list.replaceChildren(); @@ -2294,6 +2307,9 @@ function renderLineage() { 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; @@ -2939,6 +2955,7 @@ async function renderTrash() { 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); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs index ec7e588a..244424ef 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs @@ -1,12 +1,13 @@ import {readableHTML,rawText,escapeHTML} from './readable.mjs'; import {workflowGraph} from './graph-model.mjs'; +import {compareSignature,shouldRebuildCompare} from './lineage-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,lineage=null,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; @@ -37,7 +38,7 @@ 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.length0?t('lineageRerun',{seq:m.rerunSeq}):t('lineageRootRun')} · ${m.name}`;} function renderLineage(){ @@ -102,8 +103,11 @@ function renderLineage(){ // standalone run keeps the panel out of the layout. const show=!!current&&(members.length>1||!!current.rerunOf); panel.hidden=!show; - if(!show)return; + 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'); @@ -117,6 +121,12 @@ function renderLineage(){ 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;} @@ -256,7 +266,7 @@ async function renderTrash(){ 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();}catch(e){$('#trash-error').hidden=false;$('#trash-error').textContent=apiMessage(e.message);restore.disabled=false;}}; + 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);}}; 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; +} From b1c797c5fc2bb7b813b250d9dd0583073805456d Mon Sep 17 00:00:00 2001 From: moc Date: Tue, 22 Sep 2026 14:25:12 +0800 Subject: [PATCH 7/7] fix(store): verify the rotation before restoring archived runs PR #59 re-review: restoreArchived() selected/parsed the latest archived run and wrote it into the live database without verifying that rotation against its chained archive.rotated event first, so a valid-JSON but modified archived run/step could be restored as trusted live data even though verifyArchive() would report the tampering. Verify-then-write: extract the per-rotation check (manifest recompute plus chained archive.rotated event cross-check, same archiveManifestHash face) into verifyRotation(), shared by verifyArchive() and by a fail-closed gate at the top of restoreArchived(). A failed verdict throws before any live write: no runs/steps rows, no run.restored event, and the error names the rotation id plus the reasons. Regression (archive-rotate.check.mjs): tampering an archived step or run body makes restore refuse with the rotation id while the live library keeps zero rows and zero run.restored events; healing the archive lets the same restore pass again (round-trip). Existing healthy-restore coverage unchanged and green. Also correct the README tool-count mismatch (line 5 said 13 tools; the TOOLS array and README's own MCP tool list both have 14). - src/store.mjs: verifyRotation() shared check + restore gate - checks/archive-rotate.check.mjs: tampered-restore fail-closed + round-trip - dist/main.mjs: rebuilt bundle - README.md: 13 -> 14 tools --- .../mcode-dynamic-workflows/README.md | 2 +- .../checks/archive-rotate.check.mjs | 40 +++++++++++++++ .../mcode-dynamic-workflows/dist/main.mjs | 45 +++++++++++----- .../mcode-dynamic-workflows/src/store.mjs | 51 ++++++++++++++----- 4 files changed, 112 insertions(+), 26 deletions(-) diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index 334ffc44..edb325b1 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 13 tools. +Version **0.8.0** · Apache-2.0 · one English Skill and one local MCP server with 14 tools. ## Try it diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs index c1f71553..c3c1c3f6 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs @@ -94,6 +94,46 @@ test('tampering an archived step, the rotations hash, or deleting a rotation rec }finally{await f.cleanup();} }); +test('restore refuses tampered archive data fail-closed and passes again once the archive is healed',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,twoSteps,'rotate-guard'); + 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); + // Tampered archived STEP body: restore must refuse BEFORE any live write. + 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'); + const eventsBefore=eventCount(f.store); + await assert.rejects(()=>f.engine.restoreRun(end.id,{by:'cli'}),error=>{ + assert.ok(error.message.includes(result.rotationId),'the refusal names the failing rotation id'); + assert.ok(error.message.includes(end.id),'the refusal names the refused run'); + assert.match(error.message,/different manifest|chained event|runCount|archive\.rotated/,'the refusal states the verification reason'); + return true;}); + assert.equal(f.store.get(end.id),null,'tampered archive data never becomes a live run row'); + assert.equal(Number(f.store.db.prepare('SELECT COUNT(*) AS n FROM steps WHERE runId=?').get(end.id).n),0,'and never live steps rows'); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.restored').length,0,'no run.restored event was appended'); + assert.equal(eventCount(f.store),eventsBefore,'fail-closed restore writes nothing at all'); + // Heal the archive: the same restore passes again (round-trip). + archive.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(step.body,'a'); + const restored=await f.engine.restoreRun(end.id,{by:'cli'}); + assert.equal(restored.status,'succeeded'); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.restored').length,1,'the healed restore appends exactly one run.restored'); + // Tampered archived RUN body fails the same way under a fresh rotation. + await f.engine.deleteRun(restored.id,{by:'cli'});f.engine.configureTrash({trashRetentionDays:0}); + const second=f.store.rotateDue({now:Date.now()}); + const runRow=archive.prepare('SELECT body FROM archive_runs WHERE rotationId=? AND runId=?').get(second.rotationId,end.id); + archive.prepare('UPDATE archive_runs SET body=? WHERE rotationId=? AND runId=?').run(JSON.stringify({...JSON.parse(runRow.body),name:'tampered'}),second.rotationId,end.id); + await assert.rejects(()=>f.engine.restoreRun(end.id,{by:'cli'}),error=>error.message.includes(second.rotationId)); + assert.equal(f.store.get(end.id),null,'still no live row from the tampered rotation'); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.restored').length,1,'still exactly the earlier healthy restore event'); + archive.prepare('UPDATE archive_runs SET body=? WHERE rotationId=? AND runId=?').run(runRow.body,second.rotationId,end.id); + const again=await f.engine.restoreRun(end.id,{by:'cli'}); + assert.equal(again.status,'succeeded'); + assert.equal(f.store.verifyArchive().verified,true,'both healed rotations verify'); + }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'); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index 58448a83..8cc4a8f4 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -13767,6 +13767,27 @@ var Store = class { }); return { rotated: true, runCount: entries.length, runs: entries.map((entry) => entry.run.id), rotationId, manifestHash, bytes }; } + // Single-rotation verification shared by the whole-archive sweep below and + // by restoreArchived()'s fail-closed gate: recomputes THIS rotation's + // manifest from the archived rows (same archiveManifestHash face rotation + // itself uses — never a second hash implementation) and cross-checks it + // against BOTH the rotations record (tamperable sidecar) and the chained + // archive.rotated event (the trust anchor). Returns {verified,problems} and + // never throws — callers decide what a failed verdict means. + verifyRotation(rotationId) { + const archive = this.archive(); + const rotation = archive.prepare("SELECT rotationId,manifestHash,runCount FROM rotations WHERE rotationId=?").get(rotationId); + if (!rotation) return { rotationId, runCount: null, verified: false, problems: ["rotations record missing"] }; + const audit = this.db.prepare("SELECT body FROM events WHERE runId=?").all(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(rotationId).map((run) => ({ run, steps: archive.prepare("SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id").all(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}`); + return { rotationId, runCount: rotation.runCount, verified: problems.length === 0, problems }; + } // 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 @@ -13776,19 +13797,12 @@ var Store = class { 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 rotations = archive.prepare("SELECT rotationId 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 }); + for (const { rotationId } of rotations) { + const verdict = this.verifyRotation(rotationId); + if (!verdict.verified) divergences.push(`rotation ${rotationId}: ${verdict.problems.join("; ")}`); + results.push({ rotationId, runCount: verdict.runCount, verified: verdict.verified }); } 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`); @@ -13806,10 +13820,17 @@ var Store = class { // 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. + // Before ANY live write, the rotation this copy was exported under must + // re-verify (verifyRotation): a valid-JSON but modified archived run/step + // would otherwise re-enter the library as trusted live data even though + // verifyArchive() reports the tampering. Refusal is fail-closed: nothing is + // written and no run.restored event is appended. 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 verdict = this.verifyRotation(row.rotationId); + if (!verdict.verified) throw new Error(`\u8F6E\u8F6C ${row.rotationId} \u6821\u9A8C\u5931\u8D25\uFF0C\u62D2\u7EDD\u4ECE\u5F52\u6863\u6062\u590D ${runId}\uFF1A${verdict.problems.join("\uFF1B")}`); 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); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 598dc262..914eb897 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -196,6 +196,28 @@ export class Store { }); return {rotated:true,runCount:entries.length,runs:entries.map(entry=>entry.run.id),rotationId,manifestHash,bytes}; } + // Single-rotation verification shared by the whole-archive sweep below and + // by restoreArchived()'s fail-closed gate: recomputes THIS rotation's + // manifest from the archived rows (same archiveManifestHash face rotation + // itself uses — never a second hash implementation) and cross-checks it + // against BOTH the rotations record (tamperable sidecar) and the chained + // archive.rotated event (the trust anchor). Returns {verified,problems} and + // never throws — callers decide what a failed verdict means. + verifyRotation(rotationId) { + const archive=this.archive(); + const rotation=archive.prepare('SELECT rotationId,manifestHash,runCount FROM rotations WHERE rotationId=?').get(rotationId); + if(!rotation)return {rotationId,runCount:null,verified:false,problems:['rotations record missing']}; + const audit=this.db.prepare('SELECT body FROM events WHERE runId=?').all(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(rotationId) + .map(run=>({run,steps:archive.prepare('SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id').all(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}`); + return {rotationId,runCount:rotation.runCount,verified:problems.length===0,problems}; + } // 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 @@ -205,20 +227,12 @@ export class Store { 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 rotations=archive.prepare('SELECT rotationId 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}); + for(const {rotationId} of rotations){ + const verdict=this.verifyRotation(rotationId); + if(!verdict.verified)divergences.push(`rotation ${rotationId}: ${verdict.problems.join('; ')}`); + results.push({rotationId,runCount:verdict.runCount,verified:verdict.verified}); } 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`); @@ -236,10 +250,21 @@ export class Store { // 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. + // Before ANY live write, the rotation this copy was exported under must + // re-verify (verifyRotation): a valid-JSON but modified archived run/step + // would otherwise re-enter the library as trusted live data even though + // verifyArchive() reports the tampering. Refusal is fail-closed: nothing is + // written and no run.restored event is appended. 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; + // Verify-then-write: the archived rows must still recompute to the manifest + // anchored by this rotation's chained archive.rotated event. This gate + // deliberately precedes every other restore action — tampered archive data + // is refused before the live library is even consulted for conflicts. + const verdict=this.verifyRotation(row.rotationId); + if(!verdict.verified)throw new Error(`轮转 ${row.rotationId} 校验失败,拒绝从归档恢复 ${runId}:${verdict.problems.join(';')}`); // 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);