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/archive-recovery.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-recovery.check.mjs new file mode 100644 index 00000000..8f0fb3b6 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-recovery.check.mjs @@ -0,0 +1,133 @@ +// Cross-database crash recovery suite (PR #58 re-review point 2). Rotation +// commits the archive side first and the live side second; no transaction can +// span both files, so the crash window between the two commits must leave a +// state that fails verification visibly, loses nothing, and is repaired +// deterministically. Failures are injected for real — an SQLite RAISE(ABORT) +// lands inside the live transaction after the archive commit, and a child +// process is SIGKILLed through the production seam exactly between the two +// commits — never mocked. +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 {fileURLToPath,pathToFileURL} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +const exec=promisify(execFile),DAY=86400000; +// The crash child imports the source Store via `node --input-type=module -e`. +// The import specifier MUST be a file:// URL: a bare absolute Windows path +// ("D:\\...\\store.mjs") dies at module load with ERR_UNSUPPORTED_ESM_URL_SCHEME +// (the drive letter parses as protocol 'd:') before any injection logic runs — +// fork preview runs 35696536334 and its predecessor both failed on this, with +// the true stderr hidden behind "Command failed". pathToFileURL().href is the +// portable spelling and is equally legal on POSIX. +const storeURL=pathToFileURL(fileURLToPath(new URL('../src/store.mjs',import.meta.url))).href; +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-recover-'));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:'Recovery 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};'; +const chainedCount=store=>Number(store.db.prepare("SELECT COUNT(*) AS n FROM events WHERE json_extract(body,'$.type')='archive.rotated'").get().n); + +test('a live-transaction abort after the archive commit leaves a reconcilable orphan, not corruption',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,twoSteps,'recover-1'); + await f.engine.deleteRun(end.id);f.engine.configureTrash({trashRetentionDays:0}); + // REAL failure injection: the archive.rotated insert aborts at the SQLite + // level while the archive side has already committed — the exact durable + // state of a crash in the un-transactionable gap between the two commits. + f.store.db.exec("CREATE TRIGGER inject_rotated_fail BEFORE INSERT ON events WHEN json_extract(new.body,'$.type')='archive.rotated' BEGIN SELECT RAISE(ABORT,'injected live-transaction failure'); END"); + await assert.rejects(async()=>f.store.rotateDue({now:Date.now()}),/injected live-transaction failure/); + f.store.db.exec('DROP TRIGGER inject_rotated_fail'); + const archive=f.store.archive(); + assert.equal(archive.prepare('SELECT COUNT(*) AS n FROM rotations').get().n,1,'the archive side committed before the abort'); + assert.ok(f.store.get(end.id),'the live rows never left: the live transaction rolled back whole'); + assert.equal(chainedCount(f.store),0,'no archive.rotated event landed'); + assert.equal(f.store.verifyIntegrity().archive.verified,false,'the crash window is visible: an unchained archive copy fails closed'); + assert.equal(f.store.tombstoneCount(),1,'the tombstone is still in the trash'); + // Recovery is part of the next rotation: reconcileOrphans rolls the orphan + // back (nothing was lost — the live side was never touched), then the + // rotation simply runs again under a fresh rotationId. + const redo=f.store.rotateDue({now:Date.now()}); + assert.equal(redo.rotated,true);assert.equal(redo.runCount,1);assert.deepEqual(redo.runs,[end.id]); + assert.equal(f.store.get(end.id),null,'the redone rotation reclaims the live rows'); + assert.equal(archive.prepare('SELECT COUNT(*) AS n FROM rotations').get().n,1,'the orphan was replaced by the redo, not accumulated'); + const verdict=f.store.verifyIntegrity(); + assert.equal(verdict.archive.verified,true);assert.equal(verdict.events.verified,true); + assert.equal(f.store.events(redo.rotationId).filter(e=>e.type==='archive.rotated').length,1,'exactly one chained event for the surviving rotation'); + }finally{await f.cleanup();} +}); + +test('a hard kill in the commit gap is repaired by the next startup and the rotation redoes',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-sigkill-'));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"});','recover-2'); + runId=end.id;await engine.deleteRun(runId,{by:'cli'});await engine.configureTrash({trashRetentionDays:0}); + await engine.close();store.close();} + // A child process takes the owner lock, starts the rotation, and dies + // through the production injection seam (store.afterArchiveCommit) exactly + // after the archive COMMIT and before the live transaction — a real crash, + // not a mocked one. The committed archive copy must survive it. + // + // Cross-platform equivalence of the kill primitive: on POSIX SIGKILL is an + // uninterceptable hard kill. On Windows, Node maps a self-SIGKILL onto + // TerminateProcess with a death shape the execFile error does not expose + // as `signal` (fork preview run 35696536334), so win32 uses process.exit(9) + // instead: it terminates immediately and synchronously — no stack unwinding, + // so the try/finally and store.close() never run and no further DB + // statement executes. Because node:sqlite writes synchronously and the + // archive transaction already committed with PRAGMA synchronous=FULL + // before the seam fires, the durable crash-window state (archive side on + // disk, live transaction never begun) is byte-identical under both + // primitives — and exit(9)'s exit code, unlike TerminateProcess's, is + // portably observable as code 9. + const die=process.platform==='win32'?'process.exit(9)':'process.kill(process.pid,\'SIGKILL\')'; + const crash=`import {Store} from ${JSON.stringify(storeURL)}; +const store=new Store(${JSON.stringify(dir)}); +store.afterArchiveCommit=()=>${die}; +try{store.rotateDue({now:Date.now()});}finally{store.close();}`; + await assert.rejects(exec(process.execPath,['--input-type=module','-e',crash]),e=>process.platform==='win32'?e.code===9:e.signal==='SIGKILL'); + // Next startup: the Store constructor reconciles the orphan away, the live + // tombstone is intact, and the rotation redoes cleanly. After an abnormal + // child death, the kernel can lag slightly behind releasing the SQLite file + // handles on win32 (and the stale owner.lock recheck can transiently report + // the dead pid as alive); the reopen retries with backoff instead of failing + // the recovery assertions on that timing — test-only, product semantics + // untouched, non-win32 keeps the single-shot open. + let store;for(let i=0;;i++){ + try{store=new Store(dir);break;} + catch(e){if(process.platform!=='win32'||i>=19)throw e;await delay(100);} + } + const engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + try{ + const archive=store.archive(); + assert.deepEqual(archive.prepare('SELECT rotationId FROM rotations').all(),[],'startup reconciliation removed the orphaned rotation'); + assert.equal(archive.prepare('SELECT COUNT(*) AS n FROM archive_runs').get().n,0); + assert.equal(chainedCount(store),0); + const tombstoned=store.get(runId); + assert.ok(tombstoned?.deletedAt,'the live tombstone survived the crash untouched'); + assert.equal(store.tombstoneCount(),1); + const redo=store.rotateDue({now:Date.now()}); + assert.equal(redo.rotated,true);assert.equal(redo.runCount,1);assert.deepEqual(redo.runs,[runId]); + assert.equal(store.get(runId),null); + assert.equal(archive.prepare('SELECT COUNT(*) AS n FROM rotations').get().n,1); + const verdict=store.verifyIntegrity(); + assert.equal(verdict.archive.verified,true);assert.equal(verdict.events.verified,true); + }finally{await engine.close();store.close();} + }finally{await rm(dir,{recursive:true,force:true});} +}); + +test('reconcileOrphans is a no-op for healthy rotations and an archive-less store',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + assert.deepEqual(f.store.reconcileOrphans(),{removed:[]},'no archive, nothing to do'); + const end=await run(f.engine,twoSteps,'recover-3'); + await f.engine.deleteRun(end.id);f.engine.configureTrash({trashRetentionDays:0}); + f.store.rotateDue({now:Date.now()}); + assert.deepEqual(f.store.reconcileOrphans(),{removed:[]},'a fully chained rotation is never an orphan'); + assert.equal(f.store.verifyArchive().verified,true); + }finally{await f.cleanup();} +}); 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..c1005116 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/archive-rotate.check.mjs @@ -0,0 +1,336 @@ +// 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,ROTATE_BATCH_RUNS,ROTATE_BATCH_BYTES,ROTATE_MAX_BATCHES} 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();} +}); + +test('deleting the entire archive sidecar fails closed against the chained rotations; a clean install stays green',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-gone-'));let store=new Store(dir),engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + try{ + // Clean install: no chain promise plus no sidecar verifies green — it is a + // verdict about nothing to verify, never a silent skip past the anchor. + assert.deepEqual(store.verifyArchive(),{exists:false,rotations:0,checked:0,verified:true,results:[],divergences:[]}); + const end=await run(engine,twoSteps,'gone-1'); + await engine.deleteRun(end.id);engine.configureTrash({trashRetentionDays:0}); + assert.equal(store.rotateDue({now:Date.now()}).rotated,true); + assert.equal(store.verifyArchive().verified,true); + await engine.close();store.close(); + // Delete the whole sidecar — main database and its WAL siblings — with + // everything closed, exactly as a whole-archive deletion would look. + for(const suffix of ['','-wal','-shm'])await rm(join(dir,`archive.db${suffix}`),{force:true}); + store=new Store(dir);engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + const verdict=store.verifyArchive(); + assert.equal(verdict.exists,false); + assert.equal(verdict.verified,false,'the live chain still promises the rotation: a missing archive is not an all-clear'); + assert.ok(verdict.divergences.some(d=>d.includes('whole-archive-deleted')),'the divergence names the cause'); + assert.equal(store.verifyIntegrity().archive.verified,false,'verifyIntegrity surfaces the same fail-closed verdict'); + // The rotated run is gone from live faces and cannot be resurrected: with + // the chain promising an archive that is not there, restore refuses. + assert.equal(store.get(end.id),null); + assert.equal(store.restoreArchived(end.id),null); + }finally{await engine.close();store.close();await rm(dir,{recursive:true,force:true});} +}); + +test('rotation is bounded: one call rotates one batch and reports the honest remainder',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-batch-'));const store=new Store(dir); + try{ + const now=Date.now(); + for(let i=0;i<120;i++)store.save({id:`batch-${String(i).padStart(3,'0')}`,requestId:`breq-${i}`,requestHash:`h${i}`,status:'succeeded',name:'batch',deletedAt:now-40*DAY,purgeAfter:now-10*DAY,deletedBy:'cli'}); + const first=store.rotateDue({now:Date.now()}); + assert.equal(first.rotated,true);assert.equal(first.runCount,ROTATE_BATCH_RUNS,'exactly one batch of 50 per call'); + assert.equal(first.remaining,70,'the remainder is reported honestly'); + assert.equal(first.rotations.length,1);assert.match(first.rotationId,/^[0-9a-f-]{36}$/);assert.match(first.manifestHash,/^[0-9a-f]{64}$/); + assert.deepEqual(first.runs.map(id=>id.slice(-3)),Array.from({length:50},(_,i)=>String(i).padStart(3,'0')),'the id-ordered cursor takes the first 50'); + const second=store.rotateDue({now:Date.now()}); + assert.equal(second.runCount,50);assert.equal(second.remaining,20); + assert.notEqual(second.rotationId,first.rotationId,'each batch is its own rotation'); + const third=store.rotateDue({now:Date.now()}); + assert.equal(third.runCount,20);assert.equal(third.remaining,0); + assert.equal(store.tombstoneCount(),0,'nothing due is left behind'); + const verdict=store.verifyIntegrity(); + assert.equal(verdict.archive.verified,true);assert.equal(verdict.archive.rotations,3); + assert.equal(verdict.events.verified,true,'three chained archive.rotated events anchor the three batches'); + }finally{store.close();await rm(dir,{recursive:true,force:true});} +}); + +test('the per-batch byte budget splits oversized rotations; a single oversized run still rotates alone',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-batchbytes-'));const store=new Store(dir); + try{ + const now=Date.now(),big='x'.repeat(1024*1024); + for(let i=0;i<30;i++)store.save({id:`big-${String(i).padStart(3,'0')}`,requestId:`zreq-${i}`,requestHash:`h${i}`,status:'succeeded',name:'big',big,deletedAt:now-40*DAY,purgeAfter:now-10*DAY,deletedBy:'cli'}); + const first=store.rotateDue({now:Date.now()}); + assert.equal(first.rotated,true); + assert.ok(first.runCount>=4&&first.runCount<30,`the byte budget cuts the batch short (${first.runCount} of 30)`); + assert.equal(first.remaining,30-first.runCount); + // Drain the rest one bounded call at a time; every rotation stays within + // the budget and the totals reconcile exactly. + let total=first.runCount,calls=1; + while(total<30){ + const next=store.rotateDue({now:Date.now()}); + assert.ok(next.rotated);assert.ok(next.rotations.every(r=>r.bytes<=ROTATE_BATCH_BYTES),'no batch exceeds the byte budget'); + total+=next.runCount;calls++; + assert.ok(calls<=12,'the drain must converge'); + } + assert.equal(total,30);assert.equal(store.rotateDue({now:Date.now()}).remaining,0); + // A single tombstone larger than the whole budget still rotates — alone. + store.save({id:'huge',requestId:'zreq-huge',requestHash:'huge',status:'succeeded',name:'huge',big:'y'.repeat(ROTATE_BATCH_BYTES+65536),deletedAt:now-40*DAY,purgeAfter:now-10*DAY,deletedBy:'cli'}); + const huge=store.rotateDue({now:Date.now()}); + assert.equal(huge.runCount,1);assert.deepEqual(huge.runs,['huge']);assert.ok(huge.bytes>ROTATE_BATCH_BYTES); + const verdict=store.verifyIntegrity(); + assert.equal(verdict.archive.verified,true); + assert.equal(verdict.events.verified,true); + }finally{store.close();await rm(dir,{recursive:true,force:true});} +}); + +test('startup auto-rotation is bounded to one batch; the backlog drains in the background',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-startbatch-'));const store=new Store(dir);const engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + try{ + const now=Date.now(); + for(let i=0;i<501;i++)store.save({id:`sb-${String(i).padStart(3,'0')}`,requestId:`sreq-${i}`,requestHash:`h${i}`,status:'succeeded',name:'sb',deletedAt:now-40*DAY,purgeAfter:now-10*DAY,deletedBy:'cli'}); + const compaction=engine.autoRotateAtStartup(); + assert.equal(compaction.autoRotated,true); + assert.equal(compaction.runCount,ROTATE_BATCH_RUNS,'the synchronous startup pass rotates exactly one batch'); + assert.equal(compaction.remaining,451,'the rest of the backlog is reported, not silently rotated before serving'); + assert.equal(store.verifyIntegrity().archive.verified,true); + // The background drain empties the trash one throttled batch at a time. + engine.drainRotationsInBackground({intervalMs:1}); + for(let i=0;i<600&&store.tombstoneCount()>0;i++)await delay(10); + assert.equal(store.tombstoneCount(),0,'the drain completes'); + const verdict=store.verifyIntegrity(); + assert.equal(verdict.archive.rotations,Math.ceil(501/ROTATE_BATCH_RUNS),'501 tombstones rotate in 11 bounded batches'); + assert.equal(verdict.archive.verified,true); + assert.equal(verdict.events.verified,true); + }finally{await engine.close();store.close();await rm(dir,{recursive:true,force:true});} +}); + +test('the manual rotate face chains bounded batches per call',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'wf-batchface-'));const store=new Store(dir);const engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + try{ + const now=Date.now(); + for(let i=0;i<130;i++)store.save({id:`bf-${String(i).padStart(3,'0')}`,requestId:`freq-${i}`,requestHash:`h${i}`,status:'succeeded',name:'bf',deletedAt:now-40*DAY,purgeAfter:now-10*DAY,deletedBy:'cli'}); + const capped=engine.rotateArchive({batches:2}); + assert.equal(capped.runCount,100,'an explicit two-batch request rotates exactly two batches'); + assert.equal(capped.remaining,30); + assert.equal(capped.rotationId,null);assert.equal(capped.manifestHash,null,'multi-batch results carry their hashes per rotation, not flat'); + assert.equal(capped.rotations.length,2);assert.ok(capped.rotations.every(r=>r.manifestHash&&r.rotationId)); + const rest=engine.rotateArchive({}); + assert.equal(rest.runCount,30);assert.equal(rest.remaining,0); + assert.equal(rest.rotationId,rest.rotations[0].rotationId,'the final single-batch call keeps the flat shape'); + const verdict=store.verifyIntegrity(); + assert.equal(verdict.archive.verified,true);assert.equal(verdict.events.verified,true); + for(const bad of [0,-1,1.5,'3',ROTATE_MAX_BATCHES+1])await assert.rejects(async()=>engine.rotateArchive({batches:bad}),/批数/); + }finally{await engine.close();store.close();await rm(dir,{recursive:true,force:true});} +}); + +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 in bounded batches; moderate trash stays',{timeout:90000},async()=>{ + const dirs=[await mkdtemp(join(tmpdir(),'wf-autorot-')),await mkdtemp(join(tmpdir(),'wf-autorot-ctl-'))]; + const seed=async(dir,n)=>{const store=new Store(dir);const now=Date.now();for(let i=0;i{ + const dir=await mkdtemp(join(tmpdir(),'wf-rotate-cli-')); + const cli=async args=>{try{const {stdout}=await exec(process.execPath,[binary,'--workspace',dir,'--data-dir',dir,...args]);return {ok:true,stdout};}catch(e){return {ok:false,code:e.code,stdout:e.stdout??'',stderr:e.stderr??''};}}; + let runId; + try{ + {const store=new Store(dir),engine=new Engine(store,{workspace:dir,execute:async s=>({output:s.id})}); + const end=await run(engine,'return await ctx.agent({id:"a",prompt:"a"});','cli-rotate'); + await engine.deleteRun(end.id,{by:'cli'});await engine.configureTrash({trashRetentionDays:0}); + runId=end.id;await engine.close();store.close();} + const rotated=await cli(['--rotate-archive']); + assert.ok(rotated.ok,rotated.stderr);const summary=JSON.parse(rotated.stdout); + assert.equal(summary.rotated,true);assert.equal(summary.runCount,1);assert.deepEqual(summary.runs,[runId]); + const good=await cli(['--rotate-archive','--verify']); + assert.ok(good.ok,good.stderr); + assert.equal(JSON.parse(good.stdout).archive.verified,true); + {const adb=new DatabaseSync(join(dir,'archive.db')); + const body=adb.prepare('SELECT body FROM archive_steps WHERE id=?').get('a').body; + adb.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(JSON.stringify({...JSON.parse(body),output:'tampered'}),'a');adb.close();} + const bad=await cli(['--rotate-archive','--verify']); + assert.ok(!bad.ok);assert.equal(bad.code,1); + assert.equal(JSON.parse(bad.stdout).archive.verified,false,'failure detail stays on stdout with a nonzero exit'); + {const adb=new DatabaseSync(join(dir,'archive.db')); + const body=adb.prepare('SELECT body FROM archive_steps WHERE id=?').get('a').body; + adb.prepare('UPDATE archive_steps SET body=? WHERE id=?').run(JSON.stringify({...JSON.parse(body),output:'a'}),'a');adb.close();} + const healed=await cli(['--rotate-archive','--verify']); + assert.ok(healed.ok,healed.stderr); + const restored=await cli(['--restore',runId]); + assert.ok(restored.ok,restored.stderr); + assert.equal(JSON.parse(restored.stdout).status,'succeeded'); + {const store=new Store(dir);try{assert.equal(store.get(runId).status,'succeeded');}finally{store.close();}} + // With a live service owning the directory the CLI must forward, not fail + // on the owner lock (a direct Store open would throw while held). + const client=await connect(dir); + try{ + await call(client,'workflow_dashboard'); + const forwarded=await cli(['--rotate-archive']); + assert.ok(forwarded.ok,forwarded.stderr); + assert.equal(JSON.parse(forwarded.stdout).rotated,false,'nothing due; forwarded rotation is a clean no-op'); + }finally{await client.close();await stop(dir);} + }finally{await rm(dir,{recursive:true,force:true});} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/cross-reuse-mcp.check.mjs index a036360a..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,4 +1,7 @@ -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 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})); const client=new Client({name:'cross-reuse-mcp-test',version:'1'});const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/main.mjs'),'--stdio','--settings',join(dir,'settings.json')],stderr:'pipe'}); @@ -10,5 +13,5 @@ test('packaged MCP advertises reuseAcrossRuns and accepts it through the public assert.equal(run.reuseAcrossRuns,true,'the flag must survive the public tool surface'); const rejected=await client.callTool({name:'workflow_start',arguments:{requestId:'cross-mcp-bad',name:'Bad flag type',executor:'demo',reuseAcrossRuns:'yes',script:'return 1;'}}); assert.ok(rejected.isError,'a non-boolean flag must be rejected by the public surface'); - }finally{await client.close();await transport.close();await rm(dir,{recursive:true,force:true});} + }finally{let closeErr;try{await client.close();await transport.close();}catch(e){closeErr=e}try{await exec(process.execPath,[resolve('dist/main.mjs'),'--stop-service','--workspace',dir,'--data-dir',dir]);}catch{}await rmWithRetry(dir);if(closeErr)throw closeErr;} }); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/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..bada70cb --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/trash.check.mjs @@ -0,0 +1,208 @@ +// 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();} +}); + +// REAL failure injection helper (PR #58 re-review point 3): a SQLite RAISE +// trigger makes the audit-event insert genuinely fail inside the live +// transaction, so the tombstone/restore primitives must roll back whole. +const injectEventFailure=store=>store.db.exec("CREATE TRIGGER inject_event_fail BEFORE INSERT ON events BEGIN SELECT RAISE(ABORT,'injected event failure'); END"); + +test('delete fails closed atomically when the audit event cannot be inserted',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,script); + const before=f.store.get(end.id),eventsBefore=f.store.events(end.id); + injectEventFailure(f.store); + await assert.rejects(f.engine.deleteRun(end.id),/injected event failure/); + f.store.db.exec('DROP TRIGGER inject_event_fail'); + // Zero state change: no tombstone fields, no audit event, run byte-identical. + assert.deepEqual(f.store.get(end.id),before,'the run body is exactly as it was'); + assert.deepEqual(f.store.events(end.id),eventsBefore,'no run.deleted event landed'); + assert.ok(f.store.list().some(r=>r.id===end.id)); + assert.ok(!f.store.listTrash().some(r=>r.id===end.id)); + assert.equal(f.store.verifyIntegrity().events.verified,true,'the events chain is untouched by the rollback'); + // Normal path is unaffected once the failure clears. + assert.equal((await f.engine.deleteRun(end.id)).deleted,true); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.deleted').length,1); + }finally{await f.cleanup();} +}); + +test('restore fails closed atomically when the audit event cannot be inserted',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const end=await run(f.engine,script); + await f.engine.deleteRun(end.id); + const tombstoned=f.store.get(end.id),eventsAtTombstone=f.store.events(end.id); + injectEventFailure(f.store); + await assert.rejects(f.engine.restoreRun(end.id),/injected event failure/); + f.store.db.exec('DROP TRIGGER inject_event_fail'); + // Zero state change: the tombstone survives intact, no run.restored event. + assert.deepEqual(f.store.get(end.id),tombstoned,'the tombstone is exactly as it was'); + assert.deepEqual(f.store.events(end.id),eventsAtTombstone,'no run.restored event landed'); + assert.ok(f.store.listTrash().some(r=>r.id===end.id)); + assert.ok(!f.store.list().some(r=>r.id===end.id)); + assert.equal(f.store.verifyIntegrity().events.verified,true); + // Normal path is unaffected once the failure clears. + const restored=await f.engine.restoreRun(end.id); + assert.equal(restored.status,'succeeded');assert.equal(restored.deletedAt,undefined); + assert.equal(f.store.events(end.id).filter(e=>e.type==='run.restored').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..a46f8918 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7712,218 +7712,12 @@ import { spawn as spawn3 } from "node:child_process"; // src/store.mjs import { DatabaseSync } from "node:sqlite"; -import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; +import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { createHash, randomUUID } from "node:crypto"; -var Store = class { - constructor(dir) { - mkdirSync(dir, { recursive: true, mode: 448 }); - this.lock = join(dir, "owner.lock"); - try { - this.fd = openSync(this.lock, "wx", 384); - } catch (e) { - if (e.code !== "EEXIST") throw e; - let pid; - try { - pid = JSON.parse(readFileSync(this.lock, "utf8")).pid; - } catch { - throw new Error("\u72B6\u6001\u76EE\u5F55\u9501\u635F\u574F\uFF0C\u8BF7\u4EBA\u5DE5\u68C0\u67E5 owner.lock"); - } - let alive2 = true; - try { - process.kill(pid, 0); - } catch (err) { - if (err.code === "ESRCH") alive2 = false; - } - if (alive2) throw new Error("\u540C\u4E00\u72B6\u6001\u76EE\u5F55\u5DF2\u6709\u8FD0\u884C\u4E2D\u7684\u670D\u52A1\uFF0C\u8BF7\u8FDE\u63A5\u65E2\u6709\u670D\u52A1"); - unlinkSync(this.lock); - this.fd = openSync(this.lock, "wx", 384); - } - this.owner = randomUUID(); - this.txDepth = 0; - try { - writeFileSync(this.fd, JSON.stringify({ pid: process.pid, owner: this.owner })); - this.db = new DatabaseSync(join(dir, "workflows.sqlite")); - this.db.exec("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;"); - this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; - CREATE TABLE IF NOT EXISTS templates(id TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,requestId TEXT UNIQUE,requestHash TEXT NOT NULL,body TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS steps(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS repair_cache(runId TEXT,id TEXT,body TEXT NOT NULL,PRIMARY KEY(runId,id)); - CREATE TABLE IF NOT EXISTS events(seq INTEGER PRIMARY KEY AUTOINCREMENT,runId TEXT,body TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS run_events ON events(runId,seq); - CREATE TABLE IF NOT EXISTS integrity_rows(surface TEXT NOT NULL,pos INTEGER NOT NULL,key TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(surface,pos));`); - const unfinished = this.db.prepare("SELECT body FROM runs WHERE json_extract(body,'$.status') IN ('running','queued','stopping','pausing')").all(); - for (const row of unfinished) { - const run = JSON.parse(row.body); - run.status = "needs_attention"; - run.error = "\u4E0A\u6B21\u670D\u52A1\u5F02\u5E38\u7EC8\u6B62\u3002\u5148\u786E\u8BA4\u65E7 Agent \u5DF2\u505C\u6B62\uFF0C\u518D\u6062\u590D\u3002"; - this.save(run); - } - } catch (error2) { - this.db?.close(); - this.releaseLock(); - throw error2; - } - } - transaction(fn) { - if (this.txDepth) return fn(); - this.txDepth = 1; - this.db.exec("BEGIN IMMEDIATE"); - try { - const r = fn(); - this.db.exec("COMMIT"); - return r; - } catch (e) { - this.db.exec("ROLLBACK"); - throw e; - } finally { - this.txDepth = 0; - } - } - templates() { - return this.db.prepare("SELECT body FROM templates ORDER BY rowid DESC").all().map((r) => JSON.parse(r.body)); - } - template(id2) { - const r = this.db.prepare("SELECT body FROM templates WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - saveTemplate(value) { - this.db.prepare("INSERT INTO templates VALUES(?,?)").run(value.id, JSON.stringify(value)); - } - deleteTemplate(id2) { - return this.db.prepare("DELETE FROM templates WHERE id=?").run(id2).changes > 0; - } - setting(key) { - const row = this.db.prepare("SELECT body FROM settings WHERE key=?").get(key); - return row ? JSON.parse(row.body) : void 0; - } - saveSetting(key, value) { - this.db.prepare("INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET body=excluded.body").run(key, JSON.stringify(value)); - } - save(run) { - this.db.prepare("INSERT INTO runs VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body").run(run.id, run.requestId, run.requestHash, JSON.stringify(run)); - } - get(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE id=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - byRequest(id2) { - const r = this.db.prepare("SELECT body FROM runs WHERE requestId=?").get(id2); - return r ? JSON.parse(r.body) : null; - } - list() { - return this.db.prepare("SELECT body FROM runs ORDER BY CASE WHEN json_extract(body,'$.status') IN ('running','queued','stopping','pausing') THEN 0 WHEN json_extract(body,'$.status')='needs_attention' THEN 1 ELSE 2 END, rowid DESC LIMIT 100").all().map((r) => JSON.parse(r.body)); - } - step(runId, id2) { - const r = this.db.prepare("SELECT body FROM steps WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - steps(runId) { - return this.db.prepare("SELECT body FROM steps WHERE runId=? ORDER BY rowid").all(runId).map((r) => JSON.parse(r.body)); - } - // All match keys (contextHash, lineageHash) are stamped on the step body at - // creation, so filtering happens in SQL and LIMIT applies after the full match. - // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is - // an opt-in feature and older steps are not candidates. - findCrossRunReuse({ contextHash, requestHash, lineageHash, excludeRunId, limit = 20 }) { - return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId, requestHash, contextHash, lineageHash, limit).map((r) => { - const step = JSON.parse(r.stepBody); - return { runId: r.runId, stepId: step.id, step }; - }); - } - saveStep(runId, step) { - this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body").run(runId, step.id, JSON.stringify(step)); - } - repairCandidate(runId, id2) { - const r = this.db.prepare("SELECT body FROM repair_cache WHERE runId=? AND id=?").get(runId, id2); - return r ? JSON.parse(r.body) : null; - } - saveRepairCandidate(runId, step) { - this.transaction(() => { - const rowid = Number(this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(step)).lastInsertRowid); - this.chainAdvance("repair", "repair", "SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid", rowid, (r) => `${r.runId}/${r.id}`); - }); - } - event(runId, type, data2 = {}) { - const event = { ...data2, type, time: Date.now() }; - return this.transaction(() => { - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - this.chainAdvance("event", "events", "SELECT seq AS pos,runId,body FROM events WHERE seq>? AND seq<=? ORDER BY seq", seq, (r) => `${r.runId}:${r.pos}`); - return { seq, ...event }; - }); - } - events(runId, after = 0, limit = 150) { - return this.db.prepare("SELECT seq,body FROM events WHERE runId=? AND seq>? ORDER BY seq LIMIT ?").all(runId, after, limit).map((e) => ({ seq: e.seq, ...JSON.parse(e.body) })); - } - rowHash(prev, kind, key, body) { - return createHash("sha256").update(`${prev}:${kind}:${key}:${body}`).digest("hex"); - } - // Bulk adoption of pre-existing rows is an initial-creation behavior only: it - // anchors whatever the table held when the chain first appears. Once a head - // exists, each write anchors ONLY its own new position — rows injected into the - // range between the head and a later write stay unanchored and verification - // keeps failing closed on them instead of silently legitimizing them. - chainAdvance(kind, surface, sql, newUpto, keyOf) { - const tail = this.setting(`integrity_${surface}`); - let prev = tail?.head ?? "0".repeat(64); - const range = tail ? `SELECT * FROM (${sql}) WHERE pos=${newUpto}` : sql; - for (const r of this.db.prepare(range).all(tail?.upto ?? 0, newUpto)) { - const k = keyOf(r); - prev = this.rowHash(prev, kind, k, r.body); - this.db.prepare("INSERT OR REPLACE INTO integrity_rows VALUES(?,?,?,?)").run(surface, r.pos, k, prev); - } - this.saveSetting(`integrity_${surface}`, { head: prev, upto: newUpto }); - } - integrityHeads() { - return { events: this.setting("integrity_events") ?? null, repair: this.setting("integrity_repair") ?? null }; - } - verifyIntegrity() { - const genesis = "0".repeat(64); - const face = (kind, surface, table, posCol, rowSql, keyOf) => { - const skey = `integrity_${surface}`; - const rec = this.setting(skey); - const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n); - if (!rec) return { head: null, upto: 0, verified: null, checked: 0, unchained: total, firstDivergence: null }; - const rows = this.db.prepare("SELECT pos,key,hash FROM integrity_rows WHERE surface=? ORDER BY pos").all(surface); - const unchained = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${posCol}>?`).get(rec.upto).n); - let prev = genesis, firstDivergence = null; - for (const r of rows) { - const row = this.db.prepare(rowSql).get(r.pos); - const key = row ? keyOf(row, r.pos) : null; - const actual = row ? this.rowHash(prev, kind, key, row.body) : null; - if (!firstDivergence && (!row || key !== r.key || actual !== r.hash)) firstDivergence = { key: r.key, expectedHead: r.hash, actualHead: actual }; - prev = r.hash; - } - if (!firstDivergence) { - const anchored = new Set(rows.map((r) => r.pos)); - const gap = this.db.prepare(`SELECT ${posCol} AS __pos, * FROM ${table} WHERE ${posCol}<=? ORDER BY ${posCol}`).all(rec.upto).find((r) => !anchored.has(r.__pos)); - if (gap) firstDivergence = { key: keyOf(gap, gap.__pos), expectedHead: null, actualHead: null }; - } - const verified = !firstDivergence && prev === rec.head && unchained === 0; - return { head: rec.head, upto: rec.upto, verified, checked: rows.length, unchained, firstDivergence }; - }; - return { - events: face("event", "events", "events", "seq", "SELECT runId,body FROM events WHERE seq=?", (row, pos) => `${row.runId}:${pos}`), - repair: face("repair", "repair", "repair_cache", "rowid", "SELECT runId,id,body FROM repair_cache WHERE rowid=?", (row) => `${row.runId}/${row.id}`) - }; - } - releaseLock() { - closeSync(this.fd); - try { - if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); - } catch { - } - } - close() { - this.db.close(); - this.releaseLock(); - } -}; +import { createHash as createHash2, randomUUID } from "node:crypto"; // src/common.mjs -import { createHash as createHash2 } from "node:crypto"; +import { createHash } from "node:crypto"; // node_modules/acorn/dist/acorn.mjs var astralIdentifierCodes = [509, 0, 227, 0, 150, 4, 294, 9, 1368, 2, 2, 1, 6, 3, 41, 2, 5, 0, 166, 1, 574, 3, 9, 9, 7, 9, 32, 4, 318, 1, 78, 5, 71, 10, 50, 3, 123, 2, 54, 14, 32, 10, 3, 1, 11, 3, 46, 10, 8, 0, 46, 9, 7, 2, 37, 13, 2, 9, 6, 1, 45, 0, 13, 2, 49, 13, 9, 3, 2, 11, 83, 11, 7, 0, 3, 0, 158, 11, 6, 9, 7, 3, 56, 1, 2, 6, 3, 1, 3, 2, 10, 0, 11, 1, 3, 6, 4, 4, 68, 8, 2, 0, 3, 0, 2, 3, 2, 4, 2, 0, 15, 1, 83, 17, 10, 9, 5, 0, 82, 19, 13, 9, 214, 6, 3, 8, 28, 1, 83, 16, 16, 9, 82, 12, 9, 9, 7, 19, 58, 14, 5, 9, 243, 14, 166, 9, 71, 5, 2, 1, 3, 3, 2, 0, 2, 1, 13, 9, 120, 6, 3, 6, 4, 0, 29, 9, 41, 6, 2, 3, 9, 0, 10, 10, 47, 15, 199, 7, 137, 9, 54, 7, 2, 7, 17, 9, 57, 21, 2, 13, 123, 5, 4, 0, 2, 1, 2, 6, 2, 0, 9, 9, 49, 4, 2, 1, 2, 4, 9, 9, 55, 9, 266, 3, 10, 1, 2, 0, 49, 6, 4, 4, 14, 10, 5350, 0, 7, 14, 11465, 27, 2343, 9, 87, 9, 39, 4, 60, 6, 26, 9, 535, 9, 470, 0, 2, 54, 8, 3, 82, 0, 12, 1, 19628, 1, 4178, 9, 519, 45, 3, 22, 543, 4, 4, 5, 9, 7, 3, 6, 31, 3, 149, 2, 1418, 49, 513, 54, 5, 49, 9, 0, 15, 0, 23, 4, 2, 14, 1361, 6, 2, 16, 3, 6, 2, 1, 2, 4, 101, 0, 161, 6, 10, 9, 357, 0, 62, 13, 499, 13, 245, 1, 2, 9, 233, 0, 3, 0, 8, 1, 6, 0, 475, 6, 110, 6, 6, 9, 4759, 9, 787719, 239]; @@ -13623,7 +13417,7 @@ function parse3(input, options) { } // src/common.mjs -var hash = (value) => createHash2("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); +var hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); function stable(value) { return JSON.stringify(canonical(value)); } @@ -13658,6 +13452,451 @@ ${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; +var ROTATE_BATCH_RUNS = 50; +var ROTATE_BATCH_BYTES = 8 * 1024 * 1024; +var ROTATE_MAX_BATCHES = 20; +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"); + this.afterArchiveCommit = null; + 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); + } + this.reconcileOrphans(); + } 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); + } + }); + } + // Atomic tombstone/restore primitives: the run body change and its + // run.deleted/run.restored audit event land in ONE live transaction. An + // event-insert failure (the last write in the transaction) rolls the body + // change back with it, so the operation fails closed with zero state + // change instead of leaving a mutated run whose promised audit event never + // landed. The caller mutates the run object in memory first; on failure the + // exception propagates and the persisted state is untouched. + tombstoneRun(run, eventData = {}) { + return this.transaction(() => { + this.save(run); + return this.event(run.id, "run.deleted", eventData); + }); + } + untombstoneRun(run, eventData = {}) { + return this.transaction(() => { + this.save(run); + return this.event(run.id, "run.restored", eventData); + }); + } + 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; + } + // Crash-window reconciliation across the two databases. Rotation commits + // the archive side FIRST (archive_runs + archive_steps + the rotations + // record) and the live side SECOND (row deletes + archive.rotated event in + // ONE live transaction); no SQLite transaction can span both files. A crash + // in between leaves exactly one corrupt shape: a rotations record whose + // archive.rotated event never landed. The live transaction never ran, so + // the runs/steps rows are still in place and untouched — rolling the + // archive copy back is therefore always safe and loses nothing: + // reconcileOrphans() deletes such orphaned rotations (archive rows + + // rotations record) and leaves the live library alone. It runs at startup + // (Store constructor) and before every rotation, after which the rotation + // simply runs again under a fresh rotationId, so redo is idempotent by + // construction. + reconcileOrphans() { + if (!existsSync(this.archivePath)) return { removed: [] }; + const archive = this.archive(); + const chained = new Set(this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map((e) => e.runId)); + const removed = []; + for (const { rotationId } of archive.prepare("SELECT rotationId FROM rotations").all()) { + if (chained.has(rotationId)) continue; + archive.exec("BEGIN IMMEDIATE"); + try { + archive.prepare("DELETE FROM archive_runs WHERE rotationId=?").run(rotationId); + archive.prepare("DELETE FROM archive_steps WHERE rotationId=?").run(rotationId); + archive.prepare("DELETE FROM rotations WHERE rotationId=?").run(rotationId); + archive.exec("COMMIT"); + } catch (e) { + archive.exec("ROLLBACK"); + throw e; + } + removed.push(rotationId); + } + return { removed }; + } + dueTombstoneCount(now = Date.now()) { + return Number(this.db.prepare("SELECT COUNT(*) AS n FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL AND json_extract(body,'$.purgeAfter')<=?").get(now).n); + } + // Rotate due tombstones (purgeAfter <= now) into the archive and drop their + // live runs/steps rows, in bounded batches. Within a batch the ordering is + // fixed and crash-safe: archive-first (rows + rotations record, one archive + // transaction), then live-second (deletes + the audit event in one live + // transaction). Each batch is its own rotation and its own recovery unit + // (see reconcileOrphans). maxBatches bounds one call — the startup path + // uses exactly 1 so service start never blocks on a backlog; the API/CLI + // face uses ROTATE_MAX_BATCHES — and `remaining` reports honestly how many + // due tombstones are still unrotated. The cursor is implicit: processed + // rows are deleted inside the batch, so re-issuing the same ordered query + // advances on its own. Single-batch results keep the historical flat + // rotationId/manifestHash shape; multi-batch callers read `rotations`. + rotateDue({ now = Date.now(), maxBatches = 1 } = {}) { + this.reconcileOrphans(); + const readSteps = this.db.prepare("SELECT id,body FROM steps WHERE runId=? ORDER BY id"), dueQuery = 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 LIMIT ?"); + const rotations = []; + for (let batch = 0; batch < maxBatches; batch++) { + const due = dueQuery.all(now, ROTATE_BATCH_RUNS); + if (!due.length) break; + const entries = []; + let bytes = 0; + for (const r of due) { + const steps = readSteps.all(r.id); + const entryBytes = Buffer.byteLength(r.body) + steps.reduce((m2, s) => m2 + Buffer.byteLength(s.body), 0); + if (entries.length && bytes + entryBytes > ROTATE_BATCH_BYTES) break; + entries.push({ run: { id: r.id, requestId: r.requestId, requestHash: r.requestHash, body: r.body }, steps }); + bytes += entryBytes; + } + const rotationId = randomUUID(), manifestHash = archiveManifestHash(entries); + 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.afterArchiveCommit?.(rotationId); + 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 }); + }); + rotations.push({ rotationId, manifestHash, runCount: entries.length, runs: entries.map((entry) => entry.run.id), bytes }); + } + const remaining = this.dueTombstoneCount(now); + if (!rotations.length) return { rotated: false, runCount: 0, runs: [], rotationId: null, manifestHash: null, bytes: 0, remaining, rotations: [] }; + const summary2 = rotations.reduce((acc, r) => ({ runCount: acc.runCount + r.runCount, runs: [...acc.runs, ...r.runs], bytes: acc.bytes + r.bytes }), { runCount: 0, runs: [], bytes: 0 }); + const flat = rotations.length === 1 ? { rotationId: rotations[0].rotationId, manifestHash: rotations[0].manifestHash } : { rotationId: null, manifestHash: null }; + return { rotated: true, ...summary2, ...flat, remaining, rotations }; + } + // 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). The chain is consulted FIRST and is the gate: every rotation the + // chain promises must exist in the archive, so a missing archive.db + // (whole-archive deletion) or a missing rotations record fails closed BEFORE + // any archive-side read could return an all-clear. Extra cross-checks: + // archive rows may not exist outside known rotations, so deleting archive + // history fails closed from both directions. Only a chain that promises + // nothing plus a missing sidecar (a clean install) verifies green. + verifyArchive() { + const chained = this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map((e) => e.runId); + if (!existsSync(this.archivePath)) { + if (!chained.length) return { exists: false, rotations: 0, checked: 0, verified: true, results: [], divergences: [] }; + return { exists: false, rotations: 0, checked: 0, verified: false, results: [], divergences: [`whole-archive-deleted: the events chain anchors ${chained.length} rotation(s) but archive.db is missing`] }; + } + 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 }); + } + for (const runId of chained) if (!rotations.some((rotation) => rotation.rotationId === runId)) divergences.push(`rotation-missing: chained rotation ${runId} has no rotations record in the archive`); + for (const orphan of archive.prepare("SELECT DISTINCT rotationId FROM archive_runs WHERE rotationId NOT IN (SELECT rotationId FROM rotations)").all()) divergences.push(`archive rows exist for unknown rotation ${orphan.rotationId}`); + return { exists: true, rotations: rotations.length, checked: rotations.length, verified: divergences.length === 0, results, divergences }; + } + // Latest rotation that archived the run, or null when the run was never + // archived. Does not create the archive database for a negative answer. + archiveOrigin(runId) { + if (!existsSync(this.archivePath)) return null; + const row = this.archive().prepare("SELECT a.rotationId AS rotationId FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1").get(runId); + return row ? { rotationId: row.rotationId } : null; + } + // Copy a run's archived runs/steps rows back into the live library (id + // idempotent through upserts), clear its tombstone and append one + // run.restored {origin:'archive'} audit event — all in one transaction. + // The archive keeps its copy: restore is a copy-back, not a move. + restoreArchived(runId, { by = "cli" } = {}) { + if (!existsSync(this.archivePath)) return null; + const row = this.archive().prepare("SELECT a.rotationId AS rotationId,a.requestId AS requestId,a.requestHash AS requestHash,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1").get(runId); + if (!row) return null; + const conflict = this.db.prepare("SELECT id FROM runs WHERE requestId=? AND id<>?").get(row.requestId, runId); + if (conflict) throw new Error(`requestId \u5DF2\u88AB\u65B0\u7684\u5DE5\u4F5C\u6D41\uFF08${conflict.id}\uFF09\u5360\u7528\uFF0C\u65E0\u6CD5\u4ECE\u5F52\u6863\u6062\u590D ${runId}\uFF1B\u8BF7\u5148\u5904\u7406\u5360\u7528\u7684\u8FD0\u884C`); + const run = JSON.parse(row.body); + delete run.deletedAt; + delete run.deletedBy; + delete run.purgeAfter; + const steps = this.archive().prepare("SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id").all(row.rotationId, runId); + this.transaction(() => { + this.save(run); + const insert = this.db.prepare("INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body"); + for (const step of steps) insert.run(runId, step.id, step.body); + this.event(runId, "run.restored", { by, origin: "archive", rotationId: row.rotationId }); + }); + return { id: runId, rotationId: row.rotationId, steps: steps.length }; + } + releaseLock() { + closeSync(this.fd); + try { + if (JSON.parse(readFileSync(this.lock, "utf8")).owner === this.owner) unlinkSync(this.lock); + } catch { + } + } + close() { + this.archiveDb?.close(); + this.db.close(); + this.releaseLock(); + } +}; + // src/limits.mjs var DEFAULT_LIMITS = Object.freeze({ maxSteps: 120, stepTimeoutMs: 30 * 6e4, runTimeoutMs: 2 * 60 * 6e4 }); var LEGACY_LIMITS = Object.freeze({ maxSteps: 30, stepTimeoutMs: 10 * 6e4, runTimeoutMs: 30 * 6e4 }); @@ -14256,6 +14495,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(); @@ -14269,6 +14510,7 @@ var Engine = class extends EventEmitter { this.slots = 0; this.queue = []; this.closing = false; + this.rotationDrain = false; } async fingerprints(files = []) { check(Array.isArray(files) && files.length <= 100, "files \u6700\u591A 100 \u9879"); @@ -14316,6 +14558,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 +14579,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 +14671,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 +14721,123 @@ 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. Body change and audit event are + // committed by ONE transaction (store.tombstoneRun): an event-insert failure + // rolls the tombstone back too, so the operation fails closed with zero state + // change. Repeat deletes are idempotent and never append a second event. + async deleteRun(id2, { by = "studio" } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + check(TRASH_SOURCES.has(by), "\u65E0\u6548\u7684\u5220\u9664\u6765\u6E90"); + const run = this.store.get(id2); + if (!run) { + const archived = this.store.archiveOrigin(id2); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728"); + return { id: id2, deleted: true, alreadyDeleted: true, archived: true, rotationId: archived.rotationId }; + } + if (run.deletedAt) return { id: id2, deleted: true, alreadyDeleted: true, deletedAt: run.deletedAt, deletedBy: run.deletedBy, purgeAfter: run.purgeAfter }; + check(!this.active.has(id2), "\u5DE5\u4F5C\u6D41\u4ECD\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u540E\u518D\u5220\u9664"); + check(DELETABLE.has(run.status), "\u4EC5\u5DF2\u5B8C\u6210\u7684\u5DE5\u4F5C\u6D41\u53EF\u5220\u9664\uFF08\u8FD0\u884C\u4E2D\u6216\u5F85\u5BA1\u6838\u4E0D\u53EF\u5220\u9664\uFF09"); + const days = this.trashRetentionDays(); + run.deletedAt = Date.now(); + run.deletedBy = by; + run.purgeAfter = run.deletedAt + days * 864e5; + run.updatedAt = Date.now(); + const event = this.store.tombstoneRun(run, { by, purgeAfter: run.purgeAfter }); + this.emit("change", { runId: id2, ...event }); + return { id: id2, deleted: true, alreadyDeleted: false, deletedAt: run.deletedAt, purgeAfter: run.purgeAfter }; + } + // Restore clears the tombstone and appends run.restored — atomically via + // store.untombstoneRun, so a failing audit event leaves the tombstone exactly + // as it was. 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; + run.updatedAt = Date.now(); + const event = this.store.untombstoneRun(run, { by, origin: "trash" }); + this.emit("change", { runId: id2, ...event }); + return this.snapshot(id2); + } + const archived = this.store.restoreArchived(id2, { by }); + check(archived, "\u5DE5\u4F5C\u6D41\u4E0D\u5B58\u5728\u6216\u672A\u5F52\u6863"); + return this.snapshot(id2); + } + // Manual rotation face: rotate due tombstones now in bounded batches (at + // most ROTATE_MAX_BATCHES per call; callers may pass fewer), reporting the + // remaining backlog honestly in `remaining`, optionally returning the + // archive and integrity verdicts alongside the rotation summary. + rotateArchive({ verify = false, batches } = {}) { + check(!this.closing, "\u670D\u52A1\u6B63\u5728\u5173\u95ED"); + const limit = batches === void 0 ? ROTATE_MAX_BATCHES : (check(Number.isInteger(batches) && batches >= 1 && batches <= ROTATE_MAX_BATCHES, `\u5355\u6B21\u8F6E\u8F6C\u6279\u6570\u987B\u4E3A 1\u2013${ROTATE_MAX_BATCHES} \u7684\u6574\u6570`), batches); + const result = this.store.rotateDue({ now: Date.now(), maxBatches: limit }); + 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 — bounded to exactly ONE batch so + // startup blocking time is bounded by one batch, never by the backlog size; + // the honest `remaining` count tells the caller how much is still due. + // 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(), remaining: this.store.dueTombstoneCount() }; + const tombstones = this.store.tombstoneCount(), bytes = this.store.runsBytes(); + if (tombstones <= ROTATE_TOMBSTONE_THRESHOLD && bytes <= ROTATE_RUNS_BYTES_THRESHOLD) return { autoRotated: false, tombstones, bytes, remaining: this.store.dueTombstoneCount() }; + return { ...this.store.rotateDue({ now: Date.now(), maxBatches: 1 }), autoRotated: true, tombstones, bytes }; + } + // Background drain for the backlog the bounded startup pass leaves behind: + // one batch per tick, throttled by intervalMs, until nothing is due. The + // service is already serving when this runs, so a failing batch is logged + // to stderr and stops the drain — rotation is idempotent, so the next + // startup pass or manual rotation retries without loss. + drainRotationsInBackground({ intervalMs = 250 } = {}) { + if (this.closing || this.rotationDrain) return; + this.rotationDrain = true; + const tick = () => { + if (this.closing) { + this.rotationDrain = false; + return; + } + let result; + try { + result = this.store.rotateDue({ now: Date.now(), maxBatches: 1 }); + } catch (e) { + this.rotationDrain = false; + process.stderr.write(`Archive rotation drain failed: ${e.message} +`); + return; + } + if (result.remaining > 0) { + const timer2 = setTimeout(tick, intervalMs); + timer2.unref?.(); + } else this.rotationDrain = false; + }; + const timer = setTimeout(tick, intervalMs); + timer.unref?.(); + } drain() { while (this.slots < this.globalConcurrency) { const ids = [...this.active.keys()], last = ids.indexOf(this.lastServedRun); @@ -14819,6 +15181,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 +15691,10 @@ Object.assign(messages.zh, { "templates": "\u6A21\u677F\u5E93", "saveTemplate": Object.assign(messages.en, { "templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps" }); Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "reviewCompactHelp": "\u68C0\u67E5\u4E0B\u65B9\u6D41\u7A0B\uFF0C\u786E\u8BA4\u540E\u5F00\u59CB\u3002", "reviewDetails": "\u4EFB\u52A1\u8BE6\u60C5\u4E0E\u6267\u884C\u8BBE\u7F6E", "reviewBudgets": "\u5E76\u53D1 {concurrency} \xB7 \u6700\u591A {calls} \u6B21\u8C03\u7528 \xB7 \u6BCF\u8282\u70B9 {steps} \u6B65 / {minutes} \u5206\u949F", "status.awaiting": "\u5C1A\u672A\u5F00\u59CB", "status.blocked": "\u4F9D\u8D56\u53D7\u963B", "status.not_run": "\u672A\u6267\u884C", "awaitingHelp": "\u8BE5\u8282\u70B9\u5C1A\u672A\u521B\u5EFA\u6267\u884C\u4EFB\u52A1\u3002\u542F\u52A8\u540E\u4F1A\u5728\u8FD9\u91CC\u66F4\u65B0\u72B6\u6001\u3002", "blockedHelp": "\u5DF2\u58F0\u660E\u7684\u4E0A\u6E38\u8282\u70B9\u672A\u6210\u529F\uFF0C\u5F53\u524D\u8282\u70B9\u5C1A\u672A\u6267\u884C\u3002", "notRunHelp": "\u672C\u6B21\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u672A\u89E6\u53D1\u8FD9\u4E2A\u8BA1\u5212\u8282\u70B9\u3002", "dynamicHelp": "\u8282\u70B9\u6570\u91CF\u7531\u8FD0\u884C\u7ED3\u679C\u51B3\u5B9A\uFF1B\u5DF2\u521B\u5EFA\u7684\u8282\u70B9\u4F1A\u5728\u6B64\u5206\u7EC4\u4E2D\u5C55\u5F00\u3002" }); Object.assign(messages.en, { "reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \xB7 Up to {calls} calls \xB7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group." }); +Object.assign(messages.zh, { "trash": "\u56DE\u6536\u7AD9", "trashHelp": "\u5220\u9664\u7684\u5DE5\u4F5C\u6D41\u5148\u8FDB\u5165\u56DE\u6536\u7AD9\uFF1A\u4E8B\u4EF6\u3001\u8282\u70B9\u4E0E\u7ED3\u679C\u5168\u90E8\u4FDD\u7559\uFF0C\u53EF\u968F\u65F6\u6062\u590D\u3002\u5230\u671F\u540E\u7531\u5F52\u6863\u8F6E\u8F6C\u56DE\u6536\u5B58\u50A8\uFF1B\u5BA1\u8BA1\u4E8B\u4EF6\u6C38\u4E0D\u5220\u9664\u3002", "trashEmpty": "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002", "trashRestore": "\u6062\u590D", "trashRemaining": "\u4FDD\u7559\u5269\u4F59 {days} \u5929", "trashExpired": "\u5DF2\u5230\u671F\uFF0C\u7B49\u5F85\u5F52\u6863\u8F6E\u8F6C", "trashDeleted": "\u5220\u9664\u4E8E {date}", "trashRetention": "\u56DE\u6536\u7AD9\u4FDD\u7559\u671F\uFF08\u5929\uFF09", "trashRetentionHelp": "\u9ED8\u8BA4 30 \u5929\uFF1B0 \u8868\u793A\u4E0D\u5230\u671F\uFF0C\u4EC5\u624B\u52A8\u8F6E\u8F6C\u5F52\u6863\u3002\u4FEE\u6539\u4F1A\u540C\u6B65\u66F4\u65B0\u56DE\u6536\u7AD9\u4E2D\u5DF2\u6709\u6761\u76EE\u7684\u5230\u671F\u65F6\u95F4\u3002", "event.run.deleted": "\u5DF2\u5220\u9664\u5230\u56DE\u6536\u7AD9", "event.run.restored": "\u5DF2\u6062\u590D" }); +Object.assign(messages.en, { "trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored" }); +Object.assign(messages.zh, { "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}}`)); @@ -26560,6 +26927,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 +26984,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 +27028,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 +27043,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 +27070,13 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import return json({ deleted: true }); } if (url.pathname === "/api/scheduler") return json(engine.configureScheduler(data3)); + if (url.pathname === "/api/trash") return json(engine.configureTrash(data3)); + if (url.pathname === "/api/archive/rotate") return json(engine.rotateArchive({ verify: data3.verify === true, batches: data3.batches })); if (url.pathname === "/api/tools") return json(await createToolHandler(engine, () => `${origin}/`)(data3.name, data3.arguments)); if (url.pathname === "/api/validate") return json(assertValidDependencies(previewTopology(data3.script))); if (url.pathname === "/api/runs") return json(await engine.start(data3), 201); if (match && match[2] === "repair") return json(await engine.repair(match[1], data3)); + if (match && match[2] === "restore") return json(await engine.restoreRun(match[1], { by: data3.by ?? "studio" })); if (match && match[2] === "edit") return json(await engine.update(match[1], data3)); if (match && match[2] === "approve") return json(await engine.approve(match[1], data3)); if (match && match[2] === "resume") return json(await engine.resume(match[1], data3)); @@ -27645,7 +28026,7 @@ function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) } // src/main.mjs -var { values } = parseArgs({ options: { stdio: { type: "boolean" }, "stop-service": { type: "boolean" }, settings: { type: "string" }, workspace: { type: "string" }, "data-dir": { type: "string" }, port: { type: "string" }, "mcode-script": { type: "string" }, "worker-config": { type: "string" } } }); +var { values } = parseArgs({ options: { stdio: { type: "boolean" }, "stop-service": { type: "boolean" }, settings: { type: "string" }, workspace: { type: "string" }, "data-dir": { type: "string" }, port: { type: "string" }, "mcode-script": { type: "string" }, "worker-config": { type: "string" }, "rotate-archive": { type: "boolean" }, restore: { type: "string" }, verify: { type: "boolean" } } }); var settings = values.settings ? JSON.parse(await readFile2(resolve3(values.settings), "utf8")) : {}; for (const key of Object.keys(settings)) if (!["workspace", "dataDir"].includes(key) || typeof settings[key] !== "string") throw Error("settings \u53EA\u5141\u8BB8 workspace/dataDir \u5B57\u7B26\u4E32"); if (values.port !== void 0 && (!/^\d+$/.test(values.port) || Number(values.port) > 65535)) throw Error("port \u5FC5\u987B\u662F 0\u201365535 \u7684\u6574\u6570"); @@ -27746,13 +28127,49 @@ if (values.stdio && process.env.MCODE_WORKFLOW_CHILD === "1") { if (!service) throw Error(`\u672C\u5730\u670D\u52A1\u542F\u52A8\u5931\u8D25\u3002\u4E0A\u6B21\u7AEF\u53E3\u53EF\u80FD\u88AB\u5360\u7528\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u66F4\u6362\u5730\u5740\u3002\u8BF7\u67E5\u770B ${join4(dataDir, "service.log")}`); } const mcp = await startStdio(async (name, args) => { - if ((name === "workflow_repair" || name === "workflow_results" && args?.includeDefinition) && !service.config.features?.workflowRepair) throw Error("WORKFLOW_SERVICE_UPGRADE_REQUIRED: \u5F53\u524D\u540E\u53F0\u670D\u52A1\u7248\u672C\u4E0D\u652F\u6301\u811A\u672C\u4FEE\u590D\u3002\u9000\u51FA\u804A\u5929\u4E0D\u4F1A\u91CD\u542F\u670D\u52A1\u3002\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u6D3B\u52A8\u5DE5\u4F5C\u6D41\uFF0C\u4F7F\u7528\u65B0\u7248\u63D2\u4EF6\u7684 --stop-service\uFF08\u76F8\u540C --workspace \u548C --data-dir\uFF09\u505C\u6B62\u6B64\u9879\u76EE\u670D\u52A1\uFF0C\u518D\u91CD\u65B0\u8FDE\u63A5 MCP\uFF1B\u7AEF\u53E3\u548C\u5386\u53F2\u4F1A\u4FDD\u7559\u3002data-dir: " + dataDir); + if ((name === "workflow_repair" || name === "workflow_results" && args?.includeDefinition) && !service.config.features?.workflowRepair || (name === "workflow_delete" || name === "workflow_restore") && !service.config.features?.trashManagement) throw Error("WORKFLOW_SERVICE_UPGRADE_REQUIRED: \u5F53\u524D\u540E\u53F0\u670D\u52A1\u7248\u672C\u4E0D\u652F\u6301\u6B64\u64CD\u4F5C\u3002\u9000\u51FA\u804A\u5929\u4E0D\u4F1A\u91CD\u542F\u670D\u52A1\u3002\u8BF7\u5148\u6682\u505C\u6216\u53D6\u6D88\u6D3B\u52A8\u5DE5\u4F5C\u6D41\uFF0C\u4F7F\u7528\u65B0\u7248\u63D2\u4EF6\u7684 --stop-service\uFF08\u76F8\u540C --workspace \u548C --data-dir\uFF09\u505C\u6B62\u6B64\u9879\u76EE\u670D\u52A1\uFF0C\u518D\u91CD\u65B0\u8FDE\u63A5 MCP\uFF1B\u7AEF\u53E3\u548C\u5386\u53F2\u4F1A\u4FDD\u7559\u3002data-dir: " + dataDir); const res = await fetch(service.u.origin + "/api/tools", { method: "POST", headers: headersFor(service.u), body: JSON.stringify({ name, arguments: args }), signal: AbortSignal.timeout(65e3) }); const v2 = await res.json(); if (!res.ok) throw Error(v2.error); return v2; }); process.stdin.once("end", () => void mcp.close()); + } else if (values["rotate-archive"] || values.restore !== void 0) { + const service = await existing(); + if (service) { + const call = async (path, init) => { + const res = await fetch(new URL(path, service.u.origin), { ...init, headers: headersFor(service.u), signal: AbortSignal.timeout(65e3) }); + const v2 = await res.json(); + if (!res.ok) throw Error(v2.error); + return v2; + }; + if (values.restore !== void 0) { + const v2 = await call(`/api/runs/${encodeURIComponent(values.restore)}/restore`, { method: "POST", body: JSON.stringify({ by: "cli" }) }); + process.stdout.write(JSON.stringify({ id: v2.id, status: v2.status, restored: true }) + "\n"); + } else { + const v2 = await call("/api/archive/rotate", { method: "POST", body: JSON.stringify({ verify: values.verify === true }) }); + process.stdout.write(JSON.stringify(v2) + "\n"); + if (values.verify === true && (v2.archive?.verified === false || v2.integrity?.events?.verified === false || v2.integrity?.repair?.verified === false)) process.exitCode = 1; + } + } else { + await mkdir(dataDir, { recursive: true, mode: 448 }); + const store = new Store(dataDir); + let engine; + try { + engine = new Engine(store, { workspace }); + if (values.restore !== void 0) { + const v2 = await engine.restoreRun(values.restore, { by: "cli" }); + process.stdout.write(JSON.stringify({ id: v2.id, status: v2.status, restored: true }) + "\n"); + } else { + const v2 = engine.rotateArchive({ verify: values.verify === true }); + process.stdout.write(JSON.stringify(v2) + "\n"); + if (values.verify === true && (v2.archive?.verified === false || v2.integrity?.events?.verified === false || v2.integrity?.repair?.verified === false)) process.exitCode = 1; + } + } finally { + await engine?.close(); + store.close(); + } + } } else { const previous = await readJSON(endpointPath) ?? await readJSON(addressPath); if (previous?.workspace && previous.workspace !== workspace) throw Error("\u72B6\u6001\u76EE\u5F55\u7ED1\u5B9A\u4E86\u4E0D\u540C\u5DE5\u4F5C\u533A\uFF0C\u8BF7\u914D\u7F6E\u72EC\u7ACB data-dir"); @@ -27762,11 +28179,15 @@ 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}); ${compaction.remaining} still due. +`); 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: 384 }); await rename(temp, endpointPath); + if (compaction.autoRotated && compaction.remaining > 0) engine.drainRotationsInBackground(); } catch (e) { await engine?.close(); await panel?.close(); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs index a482e4a9..a696b01a 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/engine.mjs @@ -16,8 +16,13 @@ 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,ROTATE_MAX_BATCHES } from './store.mjs'; +// Only finished runs may enter the trash. needs_attention stays out: its old +// agents may still require user confirmation, and the run is not done yet. +const DELETABLE=new Set(['succeeded','failed','completed_with_gaps','cancelled','interrupted']); +const TRASH_SOURCES=new Set(['studio','cli','mcp']); export class Engine extends EventEmitter { - constructor(store,options){super();this.store=store;this.options=options;this.defaults=resolveLimits(options,DEFAULT_LIMITS);this.globalConcurrency=this.store.setting('globalConcurrency')??8;this.lastServedRun=null;this.approving=new Set();this.active=new Map();this.slots=0;this.queue=[];this.closing=false;} + 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;this.rotationDrain=false;} async fingerprints(files=[]) { check(Array.isArray(files)&&files.length<=100,'files 最多 100 项');const root=await realpath(this.options.workspace);const out=Object.create(null); for(const path of files){ @@ -47,7 +52,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 +60,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 +109,7 @@ export class Engine extends EventEmitter { Object.assign(run,{fingerprints,approvedRevision:revision,approvedAt:Date.now()});this.save(run);this.emitEvent(id,'run.approved',{revision});this.launch(run);return this.snapshot(id); } finally {this.approving.delete(id);} } - snapshot(id){const run=this.store.get(id);check(run,'工作流不存在');const limits=runLimits(run),topology=run.topology?.version===3?run.topology:previewTopology(run.script,run.input);return {...run,topology,...historicalFailure(run,topology),...limits,legacyLimits:run.maxSteps===undefined,scheduler:this.schedulerStatus(),steps:this.store.steps(id).map(stored=>{const s=stored.kind==='agent'?{...stored,maxSteps:stored.maxSteps??LEGACY_LIMITS.maxSteps,timeoutMs:stored.timeoutMs??LEGACY_LIMITS.stepTimeoutMs}:stored;if(!s.errorDetails&&/^MCode (limit_exceeded|timeout)$/.test(s.error??'')){const errorDetails=agentFailure(s.error.slice(6),{maxSteps:s.maxSteps??limits.maxSteps,timeoutMs:s.timeoutMs??limits.stepTimeoutMs,sessionId:s.sessionId,turnId:s.turnId});return {...s,error:errorDetails.message,errorDetails};}return s.status==='queued'?{...s,queueInfo:this.queueInfo(id,s.id)}:s;})};} + snapshot(id){const run=this.store.get(id);check(run,'工作流不存在');check(!run.deletedAt,'工作流已删除,可在回收站恢复后查看');const limits=runLimits(run),topology=run.topology?.version===3?run.topology:previewTopology(run.script,run.input);return {...run,topology,...historicalFailure(run,topology),...limits,legacyLimits:run.maxSteps===undefined,scheduler:this.schedulerStatus(),steps:this.store.steps(id).map(stored=>{const s=stored.kind==='agent'?{...stored,maxSteps:stored.maxSteps??LEGACY_LIMITS.maxSteps,timeoutMs:stored.timeoutMs??LEGACY_LIMITS.stepTimeoutMs}:stored;if(!s.errorDetails&&/^MCode (limit_exceeded|timeout)$/.test(s.error??'')){const errorDetails=agentFailure(s.error.slice(6),{maxSteps:s.maxSteps??limits.maxSteps,timeoutMs:s.timeoutMs??limits.stepTimeoutMs,sessionId:s.sessionId,turnId:s.turnId});return {...s,error:errorDetails.message,errorDetails};}return s.status==='queued'?{...s,queueInfo:this.queueInfo(id,s.id)}:s;})};} queueInfo(runId,stepId){ const ticket=this.queue.find(t=>t.ctx.run.id===runId&&t.stepId===stepId),ctx=this.active.get(runId); return {reason:this.slots>=this.globalConcurrency?'global_capacity':ctx&&ctx.slots>=ctx.run.concurrency?'run_capacity':'dispatch',globalActive:this.slots,globalLimit:this.globalConcurrency,runActive:ctx?.slots??0,runLimit:ctx?.run.concurrency??0,position:ticket?this.queue.indexOf(ticket)+1:null,blockingRuns:[...this.active.values()].filter(c=>c.slots>0).map(c=>({id:c.run.id,name:c.run.name,active:c.slots}))}; @@ -116,10 +122,104 @@ 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. Body change and audit event are + // committed by ONE transaction (store.tombstoneRun): an event-insert failure + // rolls the tombstone back too, so the operation fails closed with zero state + // change. Repeat deletes are idempotent and never append a second event. + async deleteRun(id,{by='studio'}={}) { + check(!this.closing,'服务正在关闭');check(TRASH_SOURCES.has(by),'无效的删除来源'); + const run=this.store.get(id); + if(!run){ + // Already rotated past the trash: report the archive state truthfully. + const archived=this.store.archiveOrigin(id);check(archived,'工作流不存在'); + return {id,deleted:true,alreadyDeleted:true,archived:true,rotationId:archived.rotationId}; + } + if(run.deletedAt)return {id,deleted:true,alreadyDeleted:true,deletedAt:run.deletedAt,deletedBy:run.deletedBy,purgeAfter:run.purgeAfter}; + check(!this.active.has(id),'工作流仍在运行,请先暂停或取消后再删除'); + check(DELETABLE.has(run.status),'仅已完成的工作流可删除(运行中或待审核不可删除)'); + const days=this.trashRetentionDays(); + run.deletedAt=Date.now();run.deletedBy=by;run.purgeAfter=run.deletedAt+days*86400000;run.updatedAt=Date.now(); + const event=this.store.tombstoneRun(run,{by,purgeAfter:run.purgeAfter}); + this.emit('change',{runId:id,...event}); + return {id,deleted:true,alreadyDeleted:false,deletedAt:run.deletedAt,purgeAfter:run.purgeAfter}; + } + // Restore clears the tombstone and appends run.restored — atomically via + // store.untombstoneRun, so a failing audit event leaves the tombstone exactly + // as it was. 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;run.updatedAt=Date.now(); + const event=this.store.untombstoneRun(run,{by,origin:'trash'}); + this.emit('change',{runId:id,...event}); + return this.snapshot(id); + } + // Not live: restore from the sidecar archive when the run was rotated. + const archived=this.store.restoreArchived(id,{by}); + check(archived,'工作流不存在或未归档'); + return this.snapshot(id); + } + // Manual rotation face: rotate due tombstones now in bounded batches (at + // most ROTATE_MAX_BATCHES per call; callers may pass fewer), reporting the + // remaining backlog honestly in `remaining`, optionally returning the + // archive and integrity verdicts alongside the rotation summary. + rotateArchive({verify=false,batches}={}) { + check(!this.closing,'服务正在关闭'); + const limit=batches===undefined?ROTATE_MAX_BATCHES:(check(Number.isInteger(batches)&&batches>=1&&batches<=ROTATE_MAX_BATCHES,`单次轮转批数须为 1–${ROTATE_MAX_BATCHES} 的整数`),batches); + const result=this.store.rotateDue({now:Date.now(),maxBatches:limit}); + 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 — bounded to exactly ONE batch so + // startup blocking time is bounded by one batch, never by the backlog size; + // the honest `remaining` count tells the caller how much is still due. + // 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(),remaining:this.store.dueTombstoneCount()}; + const tombstones=this.store.tombstoneCount(),bytes=this.store.runsBytes(); + if(tombstones<=ROTATE_TOMBSTONE_THRESHOLD&&bytes<=ROTATE_RUNS_BYTES_THRESHOLD)return {autoRotated:false,tombstones,bytes,remaining:this.store.dueTombstoneCount()}; + return {...this.store.rotateDue({now:Date.now(),maxBatches:1}),autoRotated:true,tombstones,bytes}; + } + // Background drain for the backlog the bounded startup pass leaves behind: + // one batch per tick, throttled by intervalMs, until nothing is due. The + // service is already serving when this runs, so a failing batch is logged + // to stderr and stops the drain — rotation is idempotent, so the next + // startup pass or manual rotation retries without loss. + drainRotationsInBackground({intervalMs=250}={}) { + if(this.closing||this.rotationDrain)return; + this.rotationDrain=true; + const tick=()=>{ + if(this.closing){this.rotationDrain=false;return;} + let result;try{result=this.store.rotateDue({now:Date.now(),maxBatches:1});} + catch(e){this.rotationDrain=false;process.stderr.write(`Archive rotation drain failed: ${e.message}\n`);return;} + if(result.remaining>0){const timer=setTimeout(tick,intervalMs);timer.unref?.();} + else this.rotationDrain=false; + }; + const timer=setTimeout(tick,intervalMs);timer.unref?.(); } 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/archive/rotate')return json(engine.rotateArchive({verify:data.verify===true,batches:data.batches})); 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..96ed3785 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/main.mjs @@ -9,7 +9,7 @@ import { Engine } from './engine.mjs'; import { startHTTP } from './http.mjs'; import { startStdio } from './tools.mjs'; import {createWorkspaceRouter,PROJECT_TOOLS} from './workspace-router.mjs'; -const {values}=parseArgs({options:{stdio:{type:'boolean'},'stop-service':{type:'boolean'},settings:{type:'string'},workspace:{type:'string'},'data-dir':{type:'string'},port:{type:'string'},'mcode-script':{type:'string'},'worker-config':{type:'string'}}}); +const {values}=parseArgs({options:{stdio:{type:'boolean'},'stop-service':{type:'boolean'},settings:{type:'string'},workspace:{type:'string'},'data-dir':{type:'string'},port:{type:'string'},'mcode-script':{type:'string'},'worker-config':{type:'string'},'rotate-archive':{type:'boolean'},restore:{type:'string'},verify:{type:'boolean'}}}); const settings=values.settings?JSON.parse(await readFile(resolve(values.settings),'utf8')):{}; for(const key of Object.keys(settings))if(!['workspace','dataDir'].includes(key)||typeof settings[key]!=='string')throw Error('settings 只允许 workspace/dataDir 字符串'); if(values.port!==undefined&&(!/^\d+$/.test(values.port)||Number(values.port)>65535))throw Error('port 必须是 0–65535 的整数'); @@ -70,12 +70,48 @@ 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; }); // A chat owns only this transport. The service, workers and dashboard outlive it. process.stdin.once('end',()=>void mcp.close()); + }else if(values['rotate-archive']||values.restore!==undefined){ + // One-shot maintenance face: rotate due tombstones into the archive + // (--rotate-archive, optionally --verify) or restore a run from trash or + // archive (--restore ). Forwards to a live service when one owns the + // directory; otherwise takes the owner lock directly. Never both. + const service=await existing(); + if(service){ + const call=async(path,init)=>{const res=await fetch(new URL(path,service.u.origin),{...init,headers:headersFor(service.u),signal:AbortSignal.timeout(65000)});const v=await res.json();if(!res.ok)throw Error(v.error);return v;}; + if(values.restore!==undefined){ + const v=await call(`/api/runs/${encodeURIComponent(values.restore)}/restore`,{method:'POST',body:JSON.stringify({by:'cli'})}); + process.stdout.write(JSON.stringify({id:v.id,status:v.status,restored:true})+'\n'); + }else{ + const v=await call('/api/archive/rotate',{method:'POST',body:JSON.stringify({verify:values.verify===true})}); + process.stdout.write(JSON.stringify(v)+'\n'); + // verified:null means "no chain rows to verify" (e.g. an empty repair face); + // only an explicit false verdict is a failure. + if(values.verify===true&&(v.archive?.verified===false||v.integrity?.events?.verified===false||v.integrity?.repair?.verified===false))process.exitCode=1; + } + }else{ + await mkdir(dataDir,{recursive:true,mode:0o700}); + const store=new Store(dataDir);let engine; + try{ + engine=new Engine(store,{workspace}); + if(values.restore!==undefined){ + const v=await engine.restoreRun(values.restore,{by:'cli'}); + process.stdout.write(JSON.stringify({id:v.id,status:v.status,restored:true})+'\n'); + }else{ + const v=engine.rotateArchive({verify:values.verify===true}); + process.stdout.write(JSON.stringify(v)+'\n'); + // verified:null means "no chain rows to verify" (e.g. an empty repair face); + // only an explicit false verdict is a failure. + if(values.verify===true&&(v.archive?.verified===false||v.integrity?.events?.verified===false||v.integrity?.repair?.verified===false))process.exitCode=1; + } + }finally{await engine?.close();store.close();} + } }else{ const previous=(await readJSON(endpointPath))??await readJSON(addressPath); if(previous?.workspace&&previous.workspace!==workspace)throw Error('状态目录绑定了不同工作区,请配置独立 data-dir'); @@ -84,9 +120,21 @@ 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 — bounded to + // exactly ONE batch so startup never blocks on a large backlog. 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}); ${compaction.remaining} still due.\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); + // The bounded startup pass may leave due tombstones behind once its batch + // budget is spent; now that the dashboard is listening, the backlog drains + // in the background — one throttled batch at a time, never holding the + // service hostage. Rotation batches are idempotent recovery units, so an + // interrupted drain simply resumes on the next start or manual rotation. + if(compaction.autoRotated&&compaction.remaining>0)engine.drainRotationsInBackground(); }catch(e){await engine?.close();await panel?.close();store.close();throw e;} process.stdout.write(`Workflow Studio: ${panel.url}\n`); let closing=false;async function close(){if(closing)return;closing=true;await engine.close();await panel.close();store.close();process.exitCode=0;} diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index 5ac9fc16..1b55d34a 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -1,7 +1,36 @@ 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; +// Bounded rotation budgets. One batch is the unit of rotation work AND of +// crash recovery: at most ROTATE_BATCH_RUNS tombstones or ROTATE_BATCH_BYTES +// of row data (whichever binds first) is materialized in memory, and each +// batch commits its own archive-copy + live-delete transaction pair. 50 runs +// is roughly one busy dashboard day of finished runs and keeps a batch well +// inside an HTTP request; 8 MiB caps the JSON held in memory per batch (a +// single run larger than the budget still rotates alone in its own batch so +// one oversized tombstone can never wedge rotation). +export const ROTATE_BATCH_RUNS=50; +export const ROTATE_BATCH_BYTES=8*1024*1024; +// Upper bound of batches one API/CLI rotate call may chain before reporting +// the honest remainder: 20 batches = 1000 tombstones per call. Rotation work +// stays bounded per request; a larger backlog drains over repeated calls. +export const ROTATE_MAX_BATCHES=20; +// 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 +41,13 @@ 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'); + // Real-failure injection seam (undefined in production): rotateDue calls + // it exactly after the archive database has committed a batch and before + // the live transaction starts — the one gap no SQLite transaction can + // cover. The crash-window checks set it to kill/throw so the durable + // on-disk crash state is produced by a genuine failure, not a mock. + this.afterArchiveCommit=null; try { writeFileSync(this.fd,JSON.stringify({pid:process.pid,owner:this.owner})); this.db=new DatabaseSync(join(dir,'workflows.sqlite')); @@ -33,6 +68,10 @@ export class Store { for(const row of unfinished) {const run=JSON.parse(row.body); run.status='needs_attention';run.error='上次服务异常终止。先确认旧 Agent 已停止,再恢复。';this.save(run); } + // Crash-window reconciliation (see reconcileOrphans): repair any rotation + // that committed its archive copy but died before the live commit, so the + // store this connection serves is never carrying an orphaned archive. + this.reconcileOrphans(); }catch(error){this.db?.close();this.releaseLock();throw error;} } 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;}} @@ -45,14 +84,34 @@ 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);}});} + // Atomic tombstone/restore primitives: the run body change and its + // run.deleted/run.restored audit event land in ONE live transaction. An + // event-insert failure (the last write in the transaction) rolls the body + // change back with it, so the operation fails closed with zero state + // change instead of leaving a mutated run whose promised audit event never + // landed. The caller mutates the run object in memory first; on failure the + // exception propagates and the persisted state is untouched. + tombstoneRun(run,eventData={}) {return this.transaction(()=>{this.save(run);return this.event(run.id,'run.deleted',eventData);});} + untombstoneRun(run,eventData={}) {return this.transaction(()=>{this.save(run);return this.event(run.id,'run.restored',eventData);});} + 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 // creation, so filtering happens in SQL and LIMIT applies after the full match. // Rows without the stamped hashes (legacy runs) never match: cross-run reuse is - // an opt-in feature and older steps are not candidates. - findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} + // an opt-in feature and older steps are not candidates. Tombstoned source runs + // are excluded here too: a trashed run's steps must not resurface as reuse + // candidates (ghost data) until the run is restored. + findCrossRunReuse({contextHash,requestHash,lineageHash,excludeRunId,limit=20}) {return this.db.prepare("SELECT runId,body AS stepBody FROM steps WHERE runId<>? AND json_extract(body,'$.kind')='agent' AND json_extract(body,'$.status')='succeeded' AND json_extract(body,'$.requestHash')=? AND json_extract(body,'$.contextHash')=? AND json_extract(body,'$.lineageHash')=? AND NOT EXISTS(SELECT 1 FROM runs WHERE runs.id=steps.runId AND json_extract(runs.body,'$.deletedAt') IS NOT NULL) ORDER BY rowid DESC LIMIT ?").all(excludeRunId,requestHash,contextHash,lineageHash,limit).map(r=>{const step=JSON.parse(r.stepBody);return {runId:r.runId,stepId:step.id,step};});} saveStep(runId,step) {this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body').run(runId,step.id,JSON.stringify(step));} repairCandidate(runId,id) {const r=this.db.prepare('SELECT body FROM repair_cache WHERE runId=? AND id=?').get(runId,id);return r?JSON.parse(r.body):null;} saveRepairCandidate(runId,step) {this.transaction(()=>{const rowid=Number(this.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step)).lastInsertRowid);this.chainAdvance('repair','repair','SELECT rowid AS pos,runId,id,body FROM repair_cache WHERE rowid>? AND rowid<=? ORDER BY rowid',rowid,r=>`${r.runId}/${r.id}`);});} @@ -93,8 +152,180 @@ 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; + } + // Crash-window reconciliation across the two databases. Rotation commits + // the archive side FIRST (archive_runs + archive_steps + the rotations + // record) and the live side SECOND (row deletes + archive.rotated event in + // ONE live transaction); no SQLite transaction can span both files. A crash + // in between leaves exactly one corrupt shape: a rotations record whose + // archive.rotated event never landed. The live transaction never ran, so + // the runs/steps rows are still in place and untouched — rolling the + // archive copy back is therefore always safe and loses nothing: + // reconcileOrphans() deletes such orphaned rotations (archive rows + + // rotations record) and leaves the live library alone. It runs at startup + // (Store constructor) and before every rotation, after which the rotation + // simply runs again under a fresh rotationId, so redo is idempotent by + // construction. + reconcileOrphans() { + if(!existsSync(this.archivePath))return {removed:[]}; + const archive=this.archive(); + const chained=new Set(this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map(e=>e.runId)); + const removed=[]; + for(const {rotationId} of archive.prepare('SELECT rotationId FROM rotations').all()){ + if(chained.has(rotationId))continue; + archive.exec('BEGIN IMMEDIATE'); + try{ + archive.prepare('DELETE FROM archive_runs WHERE rotationId=?').run(rotationId); + archive.prepare('DELETE FROM archive_steps WHERE rotationId=?').run(rotationId); + archive.prepare('DELETE FROM rotations WHERE rotationId=?').run(rotationId); + archive.exec('COMMIT'); + }catch(e){archive.exec('ROLLBACK');throw e;} + removed.push(rotationId); + } + return {removed}; + } + dueTombstoneCount(now=Date.now()) {return Number(this.db.prepare("SELECT COUNT(*) AS n FROM runs WHERE json_extract(body,'$.deletedAt') IS NOT NULL AND json_extract(body,'$.purgeAfter')<=?").get(now).n);} + // Rotate due tombstones (purgeAfter <= now) into the archive and drop their + // live runs/steps rows, in bounded batches. Within a batch the ordering is + // fixed and crash-safe: archive-first (rows + rotations record, one archive + // transaction), then live-second (deletes + the audit event in one live + // transaction). Each batch is its own rotation and its own recovery unit + // (see reconcileOrphans). maxBatches bounds one call — the startup path + // uses exactly 1 so service start never blocks on a backlog; the API/CLI + // face uses ROTATE_MAX_BATCHES — and `remaining` reports honestly how many + // due tombstones are still unrotated. The cursor is implicit: processed + // rows are deleted inside the batch, so re-issuing the same ordered query + // advances on its own. Single-batch results keep the historical flat + // rotationId/manifestHash shape; multi-batch callers read `rotations`. + rotateDue({now=Date.now(),maxBatches=1}={}) { + this.reconcileOrphans(); + const readSteps=this.db.prepare('SELECT id,body FROM steps WHERE runId=? ORDER BY id'),dueQuery=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 LIMIT ?"); + const rotations=[]; + for(let batch=0;batchm+Buffer.byteLength(s.body),0); + if(entries.length&&bytes+entryBytes>ROTATE_BATCH_BYTES)break; + entries.push({run:{id:r.id,requestId:r.requestId,requestHash:r.requestHash,body:r.body},steps});bytes+=entryBytes; + } + const rotationId=randomUUID(),manifestHash=archiveManifestHash(entries); + 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;} + // The un-transactionable gap between the two commits — the injection + // seam fires here (undefined in production; the checks use it to land + // the real crash-window state on disk). + this.afterArchiveCommit?.(rotationId); + 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}); + }); + rotations.push({rotationId,manifestHash,runCount:entries.length,runs:entries.map(entry=>entry.run.id),bytes}); + } + const remaining=this.dueTombstoneCount(now); + if(!rotations.length)return {rotated:false,runCount:0,runs:[],rotationId:null,manifestHash:null,bytes:0,remaining,rotations:[]}; + const summary=rotations.reduce((acc,r)=>({runCount:acc.runCount+r.runCount,runs:[...acc.runs,...r.runs],bytes:acc.bytes+r.bytes}),{runCount:0,runs:[],bytes:0}); + const flat=rotations.length===1?{rotationId:rotations[0].rotationId,manifestHash:rotations[0].manifestHash}:{rotationId:null,manifestHash:null}; + return {rotated:true,...summary,...flat,remaining,rotations}; + } + // 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). The chain is consulted FIRST and is the gate: every rotation the + // chain promises must exist in the archive, so a missing archive.db + // (whole-archive deletion) or a missing rotations record fails closed BEFORE + // any archive-side read could return an all-clear. Extra cross-checks: + // archive rows may not exist outside known rotations, so deleting archive + // history fails closed from both directions. Only a chain that promises + // nothing plus a missing sidecar (a clean install) verifies green. + verifyArchive() { + const chained=this.db.prepare("SELECT runId FROM events WHERE json_extract(body,'$.type')='archive.rotated'").all().map(e=>e.runId); + if(!existsSync(this.archivePath)){ + if(!chained.length)return {exists:false,rotations:0,checked:0,verified:true,results:[],divergences:[]}; + return {exists:false,rotations:0,checked:0,verified:false,results:[],divergences:[`whole-archive-deleted: the events chain anchors ${chained.length} rotation(s) but archive.db is missing`]}; + } + 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}); + } + for(const runId of chained)if(!rotations.some(rotation=>rotation.rotationId===runId))divergences.push(`rotation-missing: chained rotation ${runId} has no rotations record in the archive`); + for(const orphan of archive.prepare('SELECT DISTINCT rotationId FROM archive_runs WHERE rotationId NOT IN (SELECT rotationId FROM rotations)').all())divergences.push(`archive rows exist for unknown rotation ${orphan.rotationId}`); + return {exists:true,rotations:rotations.length,checked:rotations.length,verified:divergences.length===0,results,divergences}; + } + // Latest rotation that archived the run, or null when the run was never + // archived. Does not create the archive database for a negative answer. + archiveOrigin(runId) { + if(!existsSync(this.archivePath))return null; + const row=this.archive().prepare('SELECT a.rotationId AS rotationId FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1').get(runId); + return row?{rotationId:row.rotationId}:null; + } + // Copy a run's archived runs/steps rows back into the live library (id + // idempotent through upserts), clear its tombstone and append one + // run.restored {origin:'archive'} audit event — all in one transaction. + // The archive keeps its copy: restore is a copy-back, not a move. + restoreArchived(runId,{by='cli'}={}) { + if(!existsSync(this.archivePath))return null; + const row=this.archive().prepare('SELECT a.rotationId AS rotationId,a.requestId AS requestId,a.requestHash AS requestHash,a.body AS body FROM archive_runs a JOIN rotations r ON r.rotationId=a.rotationId WHERE a.runId=? ORDER BY r.rotatedAt DESC,a.rotationId DESC LIMIT 1').get(runId); + if(!row)return null; + // Rotation frees the requestId; a newer live run may have claimed it since. + // Restoring must not silently evict that run — fail loud instead. + const conflict=this.db.prepare('SELECT id FROM runs WHERE requestId=? AND id<>?').get(row.requestId,runId); + if(conflict)throw new Error(`requestId 已被新的工作流(${conflict.id})占用,无法从归档恢复 ${runId};请先处理占用的运行`); + const run=JSON.parse(row.body); + delete run.deletedAt;delete run.deletedBy;delete run.purgeAfter; + const steps=this.archive().prepare('SELECT id,body FROM archive_steps WHERE rotationId=? AND runId=? ORDER BY id').all(row.rotationId,runId); + this.transaction(()=>{ + this.save(run); + const insert=this.db.prepare('INSERT INTO steps VALUES(?,?,?) ON CONFLICT(runId,id) DO UPDATE SET body=excluded.body'); + for(const step of steps)insert.run(runId,step.id,step.body); + this.event(runId,'run.restored',{by,origin:'archive',rotationId:row.rotationId}); + }); + return {id:runId,rotationId:row.rotationId,steps:steps.length}; } releaseLock() {closeSync(this.fd);try{if(JSON.parse(readFileSync(this.lock,'utf8')).owner===this.owner)unlinkSync(this.lock);}catch{}} - close() {this.db.close();this.releaseLock();} + close() {this.archiveDb?.close();this.db.close();this.releaseLock();} } diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index 09731d5f..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..c13627cd 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js @@ -1850,6 +1850,10 @@ Object.assign(messages.zh, { "templates": "\u6A21\u677F\u5E93", "saveTemplate": Object.assign(messages.en, { "templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps" }); Object.assign(messages.zh, { "reviewCompact": "\u7B49\u5F85\u5BA1\u6838", "reviewCompactHelp": "\u68C0\u67E5\u4E0B\u65B9\u6D41\u7A0B\uFF0C\u786E\u8BA4\u540E\u5F00\u59CB\u3002", "reviewDetails": "\u4EFB\u52A1\u8BE6\u60C5\u4E0E\u6267\u884C\u8BBE\u7F6E", "reviewBudgets": "\u5E76\u53D1 {concurrency} \xB7 \u6700\u591A {calls} \u6B21\u8C03\u7528 \xB7 \u6BCF\u8282\u70B9 {steps} \u6B65 / {minutes} \u5206\u949F", "status.awaiting": "\u5C1A\u672A\u5F00\u59CB", "status.blocked": "\u4F9D\u8D56\u53D7\u963B", "status.not_run": "\u672A\u6267\u884C", "awaitingHelp": "\u8BE5\u8282\u70B9\u5C1A\u672A\u521B\u5EFA\u6267\u884C\u4EFB\u52A1\u3002\u542F\u52A8\u540E\u4F1A\u5728\u8FD9\u91CC\u66F4\u65B0\u72B6\u6001\u3002", "blockedHelp": "\u5DF2\u58F0\u660E\u7684\u4E0A\u6E38\u8282\u70B9\u672A\u6210\u529F\uFF0C\u5F53\u524D\u8282\u70B9\u5C1A\u672A\u6267\u884C\u3002", "notRunHelp": "\u672C\u6B21\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u672A\u89E6\u53D1\u8FD9\u4E2A\u8BA1\u5212\u8282\u70B9\u3002", "dynamicHelp": "\u8282\u70B9\u6570\u91CF\u7531\u8FD0\u884C\u7ED3\u679C\u51B3\u5B9A\uFF1B\u5DF2\u521B\u5EFA\u7684\u8282\u70B9\u4F1A\u5728\u6B64\u5206\u7EC4\u4E2D\u5C55\u5F00\u3002" }); Object.assign(messages.en, { "reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \xB7 Up to {calls} calls \xB7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group." }); +Object.assign(messages.zh, { "trash": "\u56DE\u6536\u7AD9", "trashHelp": "\u5220\u9664\u7684\u5DE5\u4F5C\u6D41\u5148\u8FDB\u5165\u56DE\u6536\u7AD9\uFF1A\u4E8B\u4EF6\u3001\u8282\u70B9\u4E0E\u7ED3\u679C\u5168\u90E8\u4FDD\u7559\uFF0C\u53EF\u968F\u65F6\u6062\u590D\u3002\u5230\u671F\u540E\u7531\u5F52\u6863\u8F6E\u8F6C\u56DE\u6536\u5B58\u50A8\uFF1B\u5BA1\u8BA1\u4E8B\u4EF6\u6C38\u4E0D\u5220\u9664\u3002", "trashEmpty": "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002", "trashRestore": "\u6062\u590D", "trashRemaining": "\u4FDD\u7559\u5269\u4F59 {days} \u5929", "trashExpired": "\u5DF2\u5230\u671F\uFF0C\u7B49\u5F85\u5F52\u6863\u8F6E\u8F6C", "trashDeleted": "\u5220\u9664\u4E8E {date}", "trashRetention": "\u56DE\u6536\u7AD9\u4FDD\u7559\u671F\uFF08\u5929\uFF09", "trashRetentionHelp": "\u9ED8\u8BA4 30 \u5929\uFF1B0 \u8868\u793A\u4E0D\u5230\u671F\uFF0C\u4EC5\u624B\u52A8\u8F6E\u8F6C\u5F52\u6863\u3002\u4FEE\u6539\u4F1A\u540C\u6B65\u66F4\u65B0\u56DE\u6536\u7AD9\u4E2D\u5DF2\u6709\u6761\u76EE\u7684\u5230\u671F\u65F6\u95F4\u3002", "event.run.deleted": "\u5DF2\u5220\u9664\u5230\u56DE\u6536\u7AD9", "event.run.restored": "\u5DF2\u6062\u590D" }); +Object.assign(messages.en, { "trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored" }); +Object.assign(messages.zh, { "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"; @@ -2835,6 +2839,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..7f72c9d4 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/web/i18n.mjs @@ -442,6 +442,10 @@ Object.assign(messages.zh,{"templates": "模板库", "saveTemplate": "保存为 Object.assign(messages.en,{"templates": "Templates", "saveTemplate": "Save as template", "latestProgress": "Latest progress", "taskBrief": "Task brief", "objective": "Objective", "inputDescription": "Input description", "deliverables": "Expected deliverables", "deliverablesHelp": "One per line, up to 12 items of 300 characters each.", "downloadHTML": "Download HTML report", "downloadMD": "Download Markdown report", "reportExportHelp": "Export saved results with run status, node outputs, and failures. Execution does not prove factual accuracy.", "templatesHelp": "Local templates include the script, current input, brief, and budgets, but no run results. Using a template opens an editable form; saving still requires review.", "templateName": "Template name", "saveCurrentTemplate": "Save current workflow as template", "useTemplate": "Use template", "deleteTemplate": "Delete", "emptyTemplates": "No templates yet. Open a workflow to save one.", "templateSaved": "Template saved", "deleteTemplateConfirm": "Delete this local template? Run history is unaffected.", "exportUnavailable": "Reports can be downloaded after completion or pause.", "demoReportNotice": "Demo results: no model was called. These are not real task findings.", "reportCoverage": "{done}/{total} agents succeeded; failed or incomplete: {failed}.", "reportResult": "Result", "reportFailures": "Failed or incomplete nodes", "defaultObjective": "Review material from several perspectives, verify each independently, and synthesize findings.", "defaultInputDescription": "Provide the code or material to review in the JSON material field.", "defaultDeliverables": "Review and verification results\nA synthesis report with coverage gaps"}); Object.assign(messages.zh,{"reviewCompact": "等待审核", "reviewCompactHelp": "检查下方流程,确认后开始。", "reviewDetails": "任务详情与执行设置", "reviewBudgets": "并发 {concurrency} · 最多 {calls} 次调用 · 每节点 {steps} 步 / {minutes} 分钟", "status.awaiting": "尚未开始", "status.blocked": "依赖受阻", "status.not_run": "未执行", "awaitingHelp": "该节点尚未创建执行任务。启动后会在这里更新状态。", "blockedHelp": "已声明的上游节点未成功,当前节点尚未执行。", "notRunHelp": "本次运行已经结束,未触发这个计划节点。", "dynamicHelp": "节点数量由运行结果决定;已创建的节点会在此分组中展开。"}); Object.assign(messages.en,{"reviewCompact": "Ready for review", "reviewCompactHelp": "Check the flow below, then start.", "reviewDetails": "Task details & execution settings", "reviewBudgets": "Concurrency {concurrency} \u00b7 Up to {calls} calls \u00b7 {steps} steps / {minutes} min per agent", "status.awaiting": "Not started", "status.blocked": "Dependency blocked", "status.not_run": "Not executed", "awaitingHelp": "This planned node has not been dispatched. Its status will update here when it starts.", "blockedHelp": "A declared upstream node did not succeed; this node has not executed.", "notRunHelp": "This run ended without triggering this planned node.", "dynamicHelp": "The number of nodes depends on runtime results. Created nodes expand within this group."}); +Object.assign(messages.zh,{"trash": "回收站", "trashHelp": "删除的工作流先进入回收站:事件、节点与结果全部保留,可随时恢复。到期后由归档轮转回收存储;审计事件永不删除。", "trashEmpty": "回收站为空。", "trashRestore": "恢复", "trashRemaining": "保留剩余 {days} 天", "trashExpired": "已到期,等待归档轮转", "trashDeleted": "删除于 {date}", "trashRetention": "回收站保留期(天)", "trashRetentionHelp": "默认 30 天;0 表示不到期,仅手动轮转归档。修改会同步更新回收站中已有条目的到期时间。", "event.run.deleted": "已删除到回收站", "event.run.restored": "已恢复"}); +Object.assign(messages.en,{"trash": "Trash", "trashHelp": "Deleted workflows move to the trash first: events, nodes, and results are all kept and restorable at any time. Expired entries are rotated into the local archive to reclaim storage; audit events are never deleted.", "trashEmpty": "Trash is empty.", "trashRestore": "Restore", "trashRemaining": "{days} days left", "trashExpired": "Expired; waiting for archive rotation", "trashDeleted": "Deleted {date}", "trashRetention": "Trash retention (days)", "trashRetentionHelp": "Default 30 days; 0 disables expiry, leaving only manual archive rotation. Changes restamp entries already in the trash.", "event.run.deleted": "Moved to trash", "event.run.restored": "Restored"}); +Object.assign(messages.zh,{"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 = []) { 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 @@

调整限制并恢复

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

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

-

+

+