diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md index b49ae566..80f72dfd 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/README.md +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/README.md @@ -54,6 +54,7 @@ The repaired script runs from its beginning; checkpoints are recomputed and unre - Real agents run through the user's MCode CLI with its configured provider, tools and smart permissions. Project materials and prompts may be sent to that provider; agents may access other destinations and modify files as the task permits. These destinations depend on the user's configuration and task. Credentials remain managed by the CLI; the plugin does not ask for or store credentials, but prompts/outputs/logs can contain sensitive information supplied by users or tools. - QuickJS isolates the orchestration script from direct Node/file/network access. **The spawned MCode agents are not an OS sandbox** and do not inherit the full parent conversation. Review prompts, budgets, side effects and permissions before execution or retries. - A lifetime SQLite lock enforces one state owner even if a discovery lockfile is lost. The run list prioritizes active runs and recovery attention within its 100-entry window; crash recovery inspects every unfinished run. +- Tamper evidence: the append-only `events` and `repair_cache` surfaces each carry a SHA-256 hash chain whose per-row links are written in the same transaction as the insert. Digests bind each row's identity (`runId` plus `seq`/`id`) as well as its body, so moving a row to another run is detected like any body edit. `workflow_status` with `verifyIntegrity: true` recomputes both chains and returns heads, per-face verdicts and the first divergence. Verification covers the anchored prefix; any unanchored row fails closed (`unchained > 0` → `verified: false`). The default run list stays a plain JSON array; the object form with `integrityHeads` is only returned for `verifyIntegrity: true`. - The project service and approved workflows survive a chat disconnect. No OS autostart is installed; machine shutdown interrupts execution. After abnormal termination, verify old agents have stopped before recovery. ## Source, build and tests diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/integrity.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/integrity.check.mjs new file mode 100644 index 00000000..5119f34c --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/integrity.check.mjs @@ -0,0 +1,229 @@ +// SDD contract-first suite: dual hash-chain integrity audit (Store.integrityHeads / Store.verifyIntegrity + workflow_status tools surface). +// The implementation lands in parallel; until then these tests are the executable contract. Node >= 22 (node:sqlite). +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 {createHash,randomUUID} from 'node:crypto'; +import {Store} from '../src/store.mjs'; +import {Engine} from '../src/engine.mjs'; +import {createToolHandler,TOOLS} from '../src/tools.mjs'; +async function fixture(execute){const dir=await mkdtemp(join(tmpdir(),'wf-integrity-'));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 start(engine,script,input={}){const r=await engine.start({requestId:randomUUID(),name:'Integrity',executor:'demo',script,input});await engine.approve(r.id,{revision:1});return finish(engine,r.id);} +const GENESIS='0'.repeat(64); +// Independent recomputation of the contracted digest formulas over raw stored rows. +// r3 contract: digests bind row identity (events runId+seq, repair runId/id) as well as body. +function recomputeEvents(store){let prev=GENESIS;for(const r of store.db.prepare('SELECT seq,runId,body FROM events ORDER BY seq').all())prev=createHash('sha256').update(`${prev}:event:${r.runId}:${r.seq}:${r.body}`).digest('hex');return prev;} +function recomputeRepair(store){let prev=GENESIS;for(const r of store.db.prepare('SELECT rowid,runId,id,body FROM repair_cache ORDER BY rowid').all())prev=createHash('sha256').update(`${prev}:repair:${r.runId}/${r.id}:${r.body}`).digest('hex');return prev;} +const prefix=`const a=await ctx.agent({id:'a',prompt:'a'});const b=await ctx.agent({id:'b',prompt:'b',dependsOn:['a']});`; +const broken=prefix+`throw Error('bad synthesis');`; +const repaired=prefix+`return {a:a.output,b:b.output};`; +const candidate=(id,body='x')=>({id,kind:'agent',body}); +const rawRepair=(store,runId,id,body)=>store.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,id,body); + +test('fresh store reports null heads; first event and candidate anchor both chains verifiably from genesis',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + assert.deepEqual(f.store.integrityHeads(),{events:null,repair:null}); + assert.equal(f.store.event(runId,'run.created',{name:'n'}).seq,1); + f.store.saveRepairCandidate(runId,candidate('a')); + const heads=f.store.integrityHeads(); + assert.equal(heads.events.upto,1);assert.equal(heads.events.head,recomputeEvents(f.store)); + assert.equal(heads.repair.upto,1);assert.equal(heads.repair.head,recomputeRepair(f.store)); + const v=f.store.verifyIntegrity(); + for(const face of ['events','repair']){assert.equal(v[face].verified,true);assert.equal(v[face].head,heads[face].head);assert.equal(v[face].checked,1);assert.equal(v[face].unchained,0);assert.equal(v[face].firstDivergence,null);} + }finally{await f.cleanup();} +}); + +test('single-byte repair_cache tamper is detected at its row key and restoring the body heals verification',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + f.store.saveRepairCandidate(runId,candidate('a','original')); + const original=f.store.db.prepare('SELECT body FROM repair_cache WHERE id=?').get('a').body; + assert.equal(f.store.verifyIntegrity().repair.verified,true); + f.store.db.prepare('UPDATE repair_cache SET body=? WHERE id=?').run(original.replace('o','0'),'a'); + const v=f.store.verifyIntegrity(); + assert.equal(v.repair.verified,false); + assert.equal(v.repair.firstDivergence.key,`${runId}/a`); + assert.notEqual(v.repair.firstDivergence.expectedHead,v.repair.firstDivergence.actualHead); + f.store.db.prepare('UPDATE repair_cache SET body=? WHERE id=?').run(original,'a'); + assert.equal(f.store.verifyIntegrity().repair.verified,true); + }finally{await f.cleanup();} +}); + +test('re-attributing events.runId and repair_cache runId/id without touching bodies, chains or heads is caught on both faces; restoring attribution heals',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runA=randomUUID(),runB=randomUUID(); + f.store.event(runA,'run.created',{name:'n'}); + f.store.saveRepairCandidate(runA,candidate('a')); + assert.equal(f.store.verifyIntegrity().events.verified,true); + assert.equal(f.store.verifyIntegrity().repair.verified,true); + // Maintainer reproduction: only the identity columns move; body, chain rows and heads stay. + f.store.db.prepare('UPDATE events SET runId=? WHERE seq=?').run(runB,1); + f.store.db.prepare('UPDATE repair_cache SET runId=?,id=? WHERE runId=? AND id=?').run(runB,'b',runA,'a'); + const v=f.store.verifyIntegrity(); + assert.equal(v.events.verified,false); + assert.equal(v.events.firstDivergence.key,`${runA}:1`); + assert.match(v.events.firstDivergence.expectedHead,/^[0-9a-f]{64}$/); + assert.match(v.events.firstDivergence.actualHead,/^[0-9a-f]{64}$/); + assert.notEqual(v.events.firstDivergence.expectedHead,v.events.firstDivergence.actualHead); + assert.equal(v.repair.verified,false); + assert.equal(v.repair.firstDivergence.key,`${runA}/a`); + assert.match(v.repair.firstDivergence.expectedHead,/^[0-9a-f]{64}$/); + assert.match(v.repair.firstDivergence.actualHead,/^[0-9a-f]{64}$/); + assert.notEqual(v.repair.firstDivergence.expectedHead,v.repair.firstDivergence.actualHead); + f.store.db.prepare('UPDATE events SET runId=? WHERE seq=?').run(runA,1); + f.store.db.prepare('UPDATE repair_cache SET runId=?,id=? WHERE runId=? AND id=?').run(runA,'a',runB,'b'); + const healed=f.store.verifyIntegrity(); + assert.equal(healed.events.verified,true);assert.equal(healed.events.firstDivergence,null); + assert.equal(healed.repair.verified,true);assert.equal(healed.repair.firstDivergence,null); + }finally{await f.cleanup();} +}); + +test('deleting the smaller of two event rows reports the first divergence at its identity key',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + f.store.event(runId,'run.created',{name:'n'}); + f.store.event(runId,'run.started'); + f.store.db.prepare('DELETE FROM events WHERE seq=?').run(1); + const v=f.store.verifyIntegrity(); + assert.equal(v.events.verified,false); + assert.equal(v.events.firstDivergence.key,`${runId}:1`); + }finally{await f.cleanup();} +}); + +test('a forged integrity_events head fails verification while integrityHeads light-read mirrors settings',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + f.store.event(runId,'run.created',{name:'n'}); + // saveSetting stringifies its value, so the object form stores body exactly as the JSON {head,upto} the contract specifies. + f.store.saveSetting('integrity_events',{head:'f'.repeat(64),upto:999}); + const heads=f.store.integrityHeads(); + assert.equal(heads.events.head,'f'.repeat(64));assert.equal(heads.events.upto,999); + assert.equal(f.store.verifyIntegrity().events.verified,false); + }finally{await f.cleanup();} +}); + +test('a raw-inserted repair row beyond upto counts as unchained and fails closed without a chain divergence',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + f.store.saveRepairCandidate(runId,candidate('a')); + rawRepair(f.store,runId,'ghost','{"id":"ghost"}'); + const v=f.store.verifyIntegrity(); + assert.equal(v.repair.verified,false); + assert.equal(v.repair.checked,1);assert.equal(v.repair.unchained,1);assert.equal(v.repair.firstDivergence,null); + }finally{await f.cleanup();} +}); + +test('the first anchoring write implicitly commits pre-existing rows into the repair chain',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + rawRepair(f.store,runId,'pre','{"id":"pre"}'); + f.store.saveRepairCandidate(runId,candidate('a')); + assert.equal(f.store.integrityHeads().repair.upto,2); + assert.equal(f.store.verifyIntegrity().repair.verified,true); + f.store.db.prepare('UPDATE repair_cache SET body=? WHERE id=?').run('{"id":"pre","tampered":true}','pre'); + const v=f.store.verifyIntegrity(); + assert.equal(v.repair.verified,false); + assert.equal(v.repair.firstDivergence.key,`${runId}/pre`); + }finally{await f.cleanup();} +}); + +test('workflow_status list form stays a plain array by default; verifyIntegrity:true opts into the object form with heads and verdicts',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const runId=randomUUID(); + f.store.event(runId,'run.created',{name:'seed'}); + f.store.saveRepairCandidate(runId,candidate('a','seed')); + const handler=createToolHandler(f.engine,()=>'http://127.0.0.1:1/'); + const list=await handler('workflow_status',{}); + assert.ok(Array.isArray(list)); + assert.equal(list.integrityHeads,undefined); + assert.equal(list.integrity,undefined); + const audited=await handler('workflow_status',{verifyIntegrity:true}); + assert.ok(audited&&Array.isArray(audited.runs)); + assert.ok(audited.integrityHeads&&audited.integrityHeads.events&&audited.integrityHeads.repair); + assert.deepEqual(audited.integrityHeads,f.store.integrityHeads()); + assert.equal(typeof audited.integrity.events.verified,'boolean'); + assert.equal(typeof audited.integrity.repair.verified,'boolean'); + assert.deepEqual(audited.integrity,f.store.verifyIntegrity()); + const source=await start(f.engine,broken); + assert.equal(source.status,'failed'); + const plain=await handler('workflow_status',{}); + assert.ok(Array.isArray(plain));assert.deepEqual(plain.map(r=>r.id),[source.id]); + const single=await handler('workflow_status',{runId:source.id}); + assert.equal(single.id,source.id);assert.equal(single.integrityHeads,undefined);assert.equal(single.integrity,undefined); + const def=TOOLS.find(t=>t.name==='workflow_status'); + assert.equal(def.inputSchema.properties.verifyIntegrity.type,'boolean'); + }finally{await f.cleanup();} +}); + +test('full broken-repair-approve-finish flow keeps both chains verified and honest',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + const source=await start(f.engine,broken); + assert.equal(source.status,'failed'); + const draft=await f.engine.repair(source.id,{requestId:randomUUID(),sourceUpdatedAt:source.updatedAt,script:repaired,reason:'Fix final synthesis',reuseStepIds:['a','b']}); + assert.equal(draft.status,'pending_review'); + await f.engine.approve(draft.id,{revision:1}); + const end=await finish(f.engine,draft.id); + assert.equal(end.status,'succeeded'); + const heads=f.store.integrityHeads(); + assert.ok(heads.events.head&&heads.events.upto>0&&heads.repair.head&&heads.repair.upto>0); + const v=f.store.verifyIntegrity(); + assert.equal(v.events.verified,true);assert.equal(v.repair.verified,true); + assert.equal(v.events.unchained,0);assert.equal(v.repair.unchained,0); + }finally{await f.cleanup();} +}); + +test('a row restored into an older sequence gap fails closed on both surfaces',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + // Seed rows at seq 1 and 3 directly (legacy, pre-ledger), then anchor 1,3,4 via API writes. + const ins=(seq,runId)=>f.store.db.prepare('INSERT INTO events(seq,runId,body) VALUES(?,?,?)').run(seq,runId,JSON.stringify({type:'seed',seq})); + ins(1,'run-a');ins(3,'run-a');f.store.event('run-a','anchor',{}); + assert.equal(f.store.db.prepare('SELECT COUNT(*) n FROM events').get().n,3,'anchored set is 1,3,4'); + // Simulate a restore/import that fills the gap at seq 2. + ins(2,'run-b'); + const v=f.store.verifyIntegrity().events; + assert.equal(v.verified,false,'gap row inside the anchored range must fail closed'); + assert.deepEqual(v.firstDivergence,{key:'run-b:2',expectedHead:null,actualHead:null}); + f.store.db.prepare('DELETE FROM events WHERE seq=2').run(); + assert.equal(f.store.verifyIntegrity().events.verified,true,'restoring the anchored set heals'); + // Same shape on repair_cache: legacy rows at rowid 1 and 3, anchor, then fill rowid 2. + const insr=(rowid,runId,id)=>f.store.db.prepare('INSERT INTO repair_cache(rowid,runId,id,body) VALUES(?,?,?,?)').run(rowid,runId,id,JSON.stringify({id,kind:'agent'})); + insr(1,'run-a','a');insr(3,'run-a','c');f.store.saveRepairCandidate('run-a',{id:'d',kind:'agent'}); + insr(2,'run-b','b'); + const r=f.store.verifyIntegrity().repair; + assert.equal(r.verified,false);assert.equal(r.firstDivergence.key,'run-b/b'); + }finally{await f.cleanup();} +}); + +test('a raw tail insert stays rejected across subsequent normal writes (no silent adoption)',async()=>{ + const f=await fixture(async s=>({output:s.id}));try{ + // events: legit pos1 -> raw pos2 (rejected) -> normal pos3 must NOT absorb pos2. + f.store.event('run-a','legit',{}); + f.store.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run('run-raw',JSON.stringify({type:'raw'})); + let v=f.store.verifyIntegrity().events; + assert.equal(v.verified,false);assert.equal(v.unchained,1); + f.store.event('run-a','after',{}); + v=f.store.verifyIntegrity().events; + assert.equal(v.verified,false,'normal write must not silently anchor the injected row'); + assert.equal(v.unchained,0);assert.equal(v.checked,2); + assert.equal(v.firstDivergence.key,'run-raw:2','in-row gap is reported with the injected identity'); + // repair_cache: same shape. + f.store.saveRepairCandidate('run-a',{id:'legit',kind:'agent'}); + f.store.db.prepare('INSERT INTO repair_cache(runId,id,body) VALUES(?,?,?)').run('run-raw','raw',JSON.stringify({id:'raw',kind:'agent'})); + let r=f.store.verifyIntegrity().repair; + assert.equal(r.verified,false);assert.equal(r.unchained,1); + f.store.saveRepairCandidate('run-a',{id:'after',kind:'agent'}); + r=f.store.verifyIntegrity().repair; + assert.equal(r.verified,false);assert.equal(r.unchained,0);assert.equal(r.checked,2); + assert.equal(r.firstDivergence.key,'run-raw/raw'); + // Removing the injected rows heals both surfaces. + f.store.db.prepare('DELETE FROM events WHERE runId=?').run('run-raw'); + f.store.db.prepare('DELETE FROM repair_cache WHERE runId=?').run('run-raw'); + assert.equal(f.store.verifyIntegrity().events.verified,true); + assert.equal(f.store.verifyIntegrity().repair.verified,true); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index fd6a0739..e3808ede 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7714,7 +7714,7 @@ import { spawn as spawn3 } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; var Store = class { constructor(dir) { mkdirSync(dir, { recursive: true, mode: 448 }); @@ -7740,6 +7740,7 @@ var Store = class { 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")); @@ -7751,7 +7752,8 @@ var Store = class { 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 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); @@ -7766,6 +7768,8 @@ var Store = class { } } transaction(fn) { + if (this.txDepth) return fn(); + this.txDepth = 1; this.db.exec("BEGIN IMMEDIATE"); try { const r = fn(); @@ -7774,6 +7778,8 @@ var Store = class { } catch (e) { this.db.exec("ROLLBACK"); throw e; + } finally { + this.txDepth = 0; } } templates() { @@ -7825,16 +7831,74 @@ var Store = class { return r ? JSON.parse(r.body) : null; } saveRepairCandidate(runId, step) { - this.db.prepare("INSERT INTO repair_cache VALUES(?,?,?)").run(runId, step.id, JSON.stringify(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() }; - const seq = Number(this.db.prepare("INSERT INTO events(runId,body) VALUES(?,?)").run(runId, JSON.stringify(event)).lastInsertRowid); - return { seq, ...event }; + 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 { @@ -7849,7 +7913,7 @@ var Store = class { }; // src/common.mjs -import { createHash } from "node:crypto"; +import { createHash as createHash2 } 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]; @@ -13549,7 +13613,7 @@ function parse3(input, options) { } // src/common.mjs -var hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); +var hash = (value) => createHash2("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : stable(value)).digest("hex"); function stable(value) { return JSON.stringify(canonical(value)); } @@ -14723,7 +14787,7 @@ var Engine = class extends EventEmitter { }; // src/http.mjs -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; // web/graph-model.mjs var finished = /* @__PURE__ */ new Set(["succeeded", "completed_with_gaps", "failed", "cancelled"]); @@ -26432,7 +26496,7 @@ var TOOLS = [ { name: "workflow_start", description: "\u521B\u5EFA\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u548C\u7ED3\u6784\u62D3\u6251\uFF0C\u4E0D\u6267\u884C Agent\u3002\u5FC5\u987B\u63D0\u4F9B\u9762\u677F\u8BA9\u7528\u6237\u5BA1\u9605\u3001\u4FEE\u6539\u5E76\u70B9\u51FB\u5F00\u59CB\u6267\u884C\u3002mcode \u6A21\u5F0F\u4F1A\u542F\u52A8\u771F\u5B9E MCode\uFF0C\u4F1A\u4F7F\u7528\u5DF2\u767B\u5F55\u8EAB\u4EFD\u4E0E smart \u6743\u9650\uFF0C\u4E0D\u63D0\u4F9B\u53EA\u8BFB OS \u6C99\u7BB1\u3002demo \u6A21\u5F0F\u4E0D\u8C03\u7528\u6A21\u578B\u3002\u663E\u5F0F requestId \u5E42\u7B49\u3002", inputSchema: obj({ requestId: string3, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, ...LIMIT_SCHEMAS }, ["requestId", "name", "script", "executor"]) }, { name: "workflow_update", description: "\u4FEE\u6539\u5F85\u5BA1\u6838\u5DE5\u4F5C\u6D41\u7684\u811A\u672C\u3001\u8F93\u5165\u6216\u9884\u7B97\u5E76\u91CD\u5EFA\u62D3\u6251\uFF0C\u4FDD\u5B58\u540E\u4ECD\u5F85\u5BA1\u6838\uFF1Brevision \u5FC5\u987B\u5339\u914D\u5F53\u524D\u7248\u672C\u3002\u4E0D\u53EF\u4FEE\u6539\u5DF2\u5F00\u59CB\u7684\u8FD0\u884C\u3002", inputSchema: obj({ ...id, revision: { type: "integer", minimum: 1 }, reason: { type: "string", maxLength: 2e3 }, reuseStepIds: { type: "array", items: string3, maxItems: 100, uniqueItems: true }, name: string3, script: string3, input: { type: "object" }, metadata: METADATA_SCHEMA, executor: { enum: ["mcode", "demo"] }, concurrency: { type: "integer", minimum: 1, maximum: 16 }, maxCalls: { type: "integer", minimum: 1, maximum: 100 }, ...LIMIT_SCHEMAS }, ["runId", "revision"]) }, { name: "workflow_repair", description: "\u57FA\u4E8E\u505C\u6B62\u540E\u7684\u8FD0\u884C\u521B\u5EFA\u4FEE\u590D\u8349\u7A3F\uFF0C\u4FDD\u7559\u6E90\u8FD0\u884C\uFF1B\u63D0\u4F9B\u5B8C\u6574\u4FEE\u590D\u811A\u672C\u3001\u5931\u8D25\u539F\u56E0\u4E0E sourceUpdatedAt\u3002\u663E\u5F0F reuseStepIds \u4EC5\u9009\u62E9\u786E\u8BA4\u4ECD\u9002\u7528\u7684\u6210\u529F\u8282\u70B9\uFF0C\u9ED8\u8BA4\u4E0D\u590D\u7528\u3002\u8FD0\u884C\u65F6\u91CD\u65B0\u6821\u9A8C\u8F93\u5165\u3001\u6587\u4EF6\u3001\u53C2\u6570\u4E0E\u4F9D\u8D56\uFF1B\u53D8\u66F4\u6216\u91CD\u8DD1\u7684\u4E0A\u6E38\u4F7F\u4E0B\u6E38\u5931\u6548\u3002\u5FC5\u987B\u6253\u5F00\u9762\u677F\u4EA4\u7528\u6237\u5BA1\u6838\u540E\u5F00\u59CB\uFF0C\u4E0D\u80FD\u81EA\u52A8\u6267\u884C\u3002", inputSchema: obj({ ...id, requestId: string3, sourceUpdatedAt: { type: "integer" }, script: string3, reason: { type: "string", maxLength: 2e3 }, reuseStepIds: { type: "array", items: string3, maxItems: 100, uniqueItems: true }, input: { type: "object" }, ...LIMIT_SCHEMAS, maxCalls: { type: "integer", minimum: 1, maximum: 100 } }, ["runId", "requestId", "sourceUpdatedAt", "script", "reason"]) }, - { name: "workflow_status", description: "\u8BFB\u53D6\u8FD0\u884C\u72B6\u6001\u3001\u9636\u6BB5\u548C\u8282\u70B9\uFF1B\u8F93\u51FA\u4E0D\u542B\u5B8C\u6574 prompt/result\u3002\u65E0 runId \u65F6\u5217\u51FA\u6700\u8FD1\u8FD0\u884C\u3002", inputSchema: obj(id) }, + { name: "workflow_status", description: "\u8BFB\u53D6\u8FD0\u884C\u72B6\u6001\u3001\u9636\u6BB5\u548C\u8282\u70B9\uFF1B\u8F93\u51FA\u4E0D\u542B\u5B8C\u6574 prompt/result\u3002\u65E0 runId \u65F6\u5217\u51FA\u6700\u8FD1\u8FD0\u884C\uFF0C\u9ED8\u8BA4\u8FD4\u56DE\u6570\u7EC4\uFF08\u65E2\u6709\u5F62\u72B6\u4E0D\u53D8\uFF09\u3002verifyIntegrity:true \u65F6\u6539\u8FD4 {runs,integrityHeads,integrity} \u5BF9\u8C61\u5F62\u5E76\u5168\u91CF\u91CD\u7B97\u4E24\u6761\u5B8C\u6574\u6027\u94FE\uFF1B\u6821\u9A8C\u8986\u76D6\u5DF2\u951A\u5B9A\u524D\u7F00\uFF0C\u4EFB\u4F55\u672A\u951A\u5B9A\u884C fail-closed\uFF08unchained>0 \u5373 verified:false\uFF09\u3002", inputSchema: obj({ ...id, verifyIntegrity: { type: "boolean", description: "\u5168\u91CF\u91CD\u7B97\u5B8C\u6574\u6027\u94FE\uFF0C\u8FD4\u56DE {runs,integrityHeads,integrity} \u5BF9\u8C61\u5F62\uFF08\u9ED8\u8BA4\u4E3A\u7EAF\u6570\u7EC4\uFF09" } }) }, { name: "workflow_results", description: "\u5206\u9875\u8BFB\u53D6\u8282\u70B9\u7ED3\u679C\uFF1B\u7EC8\u6001\u62A5\u544A\u4E0E\u5931\u8D25\u660E\u786E\u5206\u5F00\u3002", inputSchema: obj({ ...id, includeDefinition: { type: "boolean" }, offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["runId"]) }, { name: "workflow_wait", description: "\u6309\u4E8B\u4EF6\u6E38\u6807\u7B49\u5F85\u53D8\u5316\uFF0C\u6700\u957F25\u79D2\u3002\u9700\u8981\u7EE7\u7EED\u5173\u6CE8\u65F6\u4F7F\u7528\u8FD4\u56DE\u7684nextSequence\u3002", inputSchema: obj({ ...id, afterSequence: { type: "integer", minimum: 0 }, timeoutMs: { type: "integer", minimum: 0, maximum: 25e3 } }, ["runId"]) }, { 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"]) }, @@ -26475,7 +26539,9 @@ function createToolHandler(engine, getURL) { case "workflow_repair": return summary(await engine.repair(args.runId, args)); case "workflow_status": - return args.runId ? summary(engine.snapshot(args.runId)) : engine.store.list().map((r) => summary(r)); + if (args.runId) return summary(engine.snapshot(args.runId)); + if (args.verifyIntegrity === true) return { runs: engine.store.list().map((r) => summary(r)), integrityHeads: engine.store.integrityHeads(), integrity: engine.store.verifyIntegrity() }; + return engine.store.list().map((r) => summary(r)); case "workflow_results": { const r = engine.snapshot(args.runId); const offset2 = args.offset ?? 0, limit = args.limit ?? 10; @@ -26517,7 +26583,7 @@ async function startStdio(handler, tools = TOOLS) { // src/http.mjs async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import.meta.url), exampleRoot = new URL("../examples/", import.meta.url) } = {}) { - const reportStyleHash = createHash2("sha256").update(REPORT_STYLES).digest("base64"); + const reportStyleHash = createHash3("sha256").update(REPORT_STYLES).digest("base64"); let origin; const sockets = /* @__PURE__ */ new Set(); const server = http.createServer(async (req, res) => { @@ -26610,7 +26676,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import } // src/workspace-router.mjs -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { realpath as realpath2, stat as stat2 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, relative as relative2, join as join3, sep as sep2 } from "node:path"; @@ -27473,7 +27539,7 @@ async function canonicalWorkspace(value, pluginRoot) { return workspace; } function projectDataDir(base, workspace) { - return join3(base, "projects", createHash3("sha256").update(workspace).digest("hex")); + return join3(base, "projects", createHash4("sha256").update(workspace).digest("hex")); } function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) { const connections = /* @__PURE__ */ new Map(); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs index cebb8e61..9e0dafc9 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs @@ -1,7 +1,7 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; export class Store { constructor(dir) { mkdirSync(dir,{recursive:true,mode:0o700}); this.lock=join(dir,'owner.lock'); @@ -12,7 +12,7 @@ export class Store { if(alive) throw new Error('同一状态目录已有运行中的服务,请连接既有服务'); unlinkSync(this.lock); this.fd=openSync(this.lock,'wx',0o600); } - this.owner=randomUUID(); + 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')); @@ -26,7 +26,8 @@ export class Store { 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 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));`); // Recovery must inspect every unfinished run, not just the dashboard page. 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); @@ -34,7 +35,7 @@ export class Store { } }catch(error){this.db?.close();this.releaseLock();throw error;} } - transaction(fn) { this.db.exec('BEGIN IMMEDIATE');try{const r=fn();this.db.exec('COMMIT');return r;}catch(e){this.db.exec('ROLLBACK');throw e;} } + 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(id) {const r=this.db.prepare('SELECT body FROM templates WHERE id=?').get(id);return r?JSON.parse(r.body):null;} saveTemplate(value) {this.db.prepare('INSERT INTO templates VALUES(?,?)').run(value.id,JSON.stringify(value));} @@ -49,9 +50,46 @@ export class Store { steps(runId) {return this.db.prepare('SELECT body FROM steps WHERE runId=? ORDER BY rowid').all(runId).map(r=>JSON.parse(r.body));} 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.db.prepare('INSERT INTO repair_cache VALUES(?,?,?)').run(runId,step.id,JSON.stringify(step));} - event(runId,type,data={}) {const event={...data,type,time:Date.now()};const seq=Number(this.db.prepare('INSERT INTO events(runId,body) VALUES(?,?)').run(runId,JSON.stringify(event)).lastInsertRowid);return {seq,...event};} + 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,data={}) {const event={...data,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() { + // Each ledger link is re-checked against the live row's own identity columns: + // the key is re-derived from the row and must equal the recorded key before that + // recorded key may take part in any digest recomputation, so re-attributing a + // row (events.runId / repair_cache runId+id) is detected like any body edit. + 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;} + // Coverage: every source row inside the anchored range must carry a ledger + // link. A row restored into an older sequence gap (pos<=upto, no link) would + // otherwise be invisible to both the walk above and the unchained tail count. + 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};} + // Verification covers the anchored prefix; any unanchored row fails closed. + 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();} } diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs index daa3b549..9d7a1b1c 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/tools.mjs @@ -13,7 +13,7 @@ export const TOOLS=[ {name:'workflow_start',description:'创建待审核工作流和结构拓扑,不执行 Agent。必须提供面板让用户审阅、修改并点击开始执行。mcode 模式会启动真实 MCode,会使用已登录身份与 smart 权限,不提供只读 OS 沙箱。demo 模式不调用模型。显式 requestId 幂等。',inputSchema:obj({requestId:string,name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},...LIMIT_SCHEMAS},['requestId','name','script','executor'])}, {name:'workflow_update',description:'修改待审核工作流的脚本、输入或预算并重建拓扑,保存后仍待审核;revision 必须匹配当前版本。不可修改已开始的运行。',inputSchema:obj({...id,revision:{type:'integer',minimum:1},reason:{type:'string',maxLength:2000},reuseStepIds:{type:'array',items:string,maxItems:100,uniqueItems:true},name:string,script:string,input:{type:'object'},metadata:METADATA_SCHEMA,executor:{enum:['mcode','demo']},concurrency:{type:'integer',minimum:1,maximum:16},maxCalls:{type:'integer',minimum:1,maximum:100},...LIMIT_SCHEMAS},['runId','revision'])}, {name:'workflow_repair',description:'基于停止后的运行创建修复草稿,保留源运行;提供完整修复脚本、失败原因与 sourceUpdatedAt。显式 reuseStepIds 仅选择确认仍适用的成功节点,默认不复用。运行时重新校验输入、文件、参数与依赖;变更或重跑的上游使下游失效。必须打开面板交用户审核后开始,不能自动执行。',inputSchema:obj({...id,requestId:string,sourceUpdatedAt:{type:'integer'},script:string,reason:{type:'string',maxLength:2000},reuseStepIds:{type:'array',items:string,maxItems:100,uniqueItems:true},input:{type:'object'},...LIMIT_SCHEMAS,maxCalls:{type:'integer',minimum:1,maximum:100}},['runId','requestId','sourceUpdatedAt','script','reason'])}, - {name:'workflow_status',description:'读取运行状态、阶段和节点;输出不含完整 prompt/result。无 runId 时列出最近运行。',inputSchema:obj(id)}, + {name:'workflow_status',description:'读取运行状态、阶段和节点;输出不含完整 prompt/result。无 runId 时列出最近运行,默认返回数组(既有形状不变)。verifyIntegrity:true 时改返 {runs,integrityHeads,integrity} 对象形并全量重算两条完整性链;校验覆盖已锚定前缀,任何未锚定行 fail-closed(unchained>0 即 verified:false)。',inputSchema:obj({...id,verifyIntegrity:{type:'boolean',description:'全量重算完整性链,返回 {runs,integrityHeads,integrity} 对象形(默认为纯数组)'}})}, {name:'workflow_results',description:'分页读取节点结果;终态报告与失败明确分开。',inputSchema:obj({...id,includeDefinition:{type:'boolean'},offset:{type:'integer',minimum:0},limit:{type:'integer',minimum:1,maximum:20}},['runId'])}, {name:'workflow_wait',description:'按事件游标等待变化,最长25秒。需要继续关注时使用返回的nextSequence。',inputSchema:obj({...id,afterSequence:{type:'integer',minimum:0},timeoutMs:{type:'integer',minimum:0,maximum:25000}},['runId'])}, {name:'workflow_cancel',description:'取消本插件工作流,等待在途 exec 退出;不取消其他 MCode 会话。',inputSchema:obj(id,['runId'])}, @@ -29,7 +29,7 @@ export function createToolHandler(engine,getURL){return async(name,args={})=>{ case 'workflow_start':return summary(await engine.start(args)); case 'workflow_update':return summary(await engine.update(args.runId,args)); case 'workflow_repair':return summary(await engine.repair(args.runId,args)); - case 'workflow_status':return args.runId?summary(engine.snapshot(args.runId)):engine.store.list().map(r=>summary(r)); + case 'workflow_status':if(args.runId)return summary(engine.snapshot(args.runId));if(args.verifyIntegrity===true)return {runs:engine.store.list().map(r=>summary(r)),integrityHeads:engine.store.integrityHeads(),integrity:engine.store.verifyIntegrity()};return engine.store.list().map(r=>summary(r)); case 'workflow_results':{const r=engine.snapshot(args.runId);const offset=args.offset??0,limit=args.limit??10;check(Number.isInteger(offset)&&offset>=0&&Number.isInteger(limit)&&limit>=1&&limit<=20,'分页参数无效');return {status:r.status,updatedAt:r.updatedAt,...(args.includeDefinition?{definition:templateDefinition(r)}:{}),result:r.result,steps:r.steps.slice(offset,offset+limit).map(({prompt,input,...s})=>s),nextOffset:offset+limit=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));