From 145371fd23ded570721f750de9fc714c1ee6d630 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 08:42:52 +0800 Subject: [PATCH] feat(reporting-governance): add watchdog chain adapters --- plugins/reporting-governance/package.json | 10 +- .../src/adapters/_script-runner.mjs | 35 ++++++ .../src/adapters/bridge-supervisor.mjs | 31 +++++ .../src/adapters/dispatcher.mjs | 30 +++++ .../src/adapters/index.mjs | 5 + .../src/adapters/orchestrator.mjs | 51 ++++++++ .../src/adapters/sender-binding.mjs | 28 +++++ .../src/adapters/watchdog.mjs | 28 +++++ plugins/reporting-governance/src/index.mjs | 8 ++ .../reporting-governance/src/storage/.gitkeep | 0 .../test/package-structure.test.mjs | 32 +++++ .../test/watchdog-chain.integration.test.mjs | 117 ++++++++++++++++++ 12 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 plugins/reporting-governance/src/adapters/_script-runner.mjs create mode 100644 plugins/reporting-governance/src/adapters/bridge-supervisor.mjs create mode 100644 plugins/reporting-governance/src/adapters/dispatcher.mjs create mode 100644 plugins/reporting-governance/src/adapters/index.mjs create mode 100644 plugins/reporting-governance/src/adapters/orchestrator.mjs create mode 100644 plugins/reporting-governance/src/adapters/sender-binding.mjs create mode 100644 plugins/reporting-governance/src/adapters/watchdog.mjs create mode 100644 plugins/reporting-governance/src/storage/.gitkeep create mode 100644 plugins/reporting-governance/test/package-structure.test.mjs create mode 100644 plugins/reporting-governance/test/watchdog-chain.integration.test.mjs diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index 7cbc56c..c64a043 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -5,9 +5,15 @@ "type": "module", "description": "Reporting governance plugin package skeleton with capability descriptors and OpenClaw reference adapter boundaries.", "exports": { - ".": "./src/index.mjs" + ".": "./src/index.mjs", + "./adapters": "./src/adapters/index.mjs", + "./adapters/watchdog": "./src/adapters/watchdog.mjs", + "./adapters/dispatcher": "./src/adapters/dispatcher.mjs", + "./adapters/bridge-supervisor": "./src/adapters/bridge-supervisor.mjs", + "./adapters/sender-binding": "./src/adapters/sender-binding.mjs", + "./adapters/orchestrator": "./src/adapters/orchestrator.mjs" }, "scripts": { - "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/decision-runner.test.mjs" + "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/decision-runner.test.mjs test/watchdog-chain.integration.test.mjs" } } diff --git a/plugins/reporting-governance/src/adapters/_script-runner.mjs b/plugins/reporting-governance/src/adapters/_script-runner.mjs new file mode 100644 index 0000000..d8b2223 --- /dev/null +++ b/plugins/reporting-governance/src/adapters/_script-runner.mjs @@ -0,0 +1,35 @@ +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; + +const packageRoot = path.resolve(import.meta.dirname, '..', '..'); +const repoRoot = path.resolve(packageRoot, '..', '..'); + +export function resolveRepoPath(...segments) { + return path.join(repoRoot, ...segments); +} + +export function runNodeScript(scriptPath, args = [], options = {}) { + return spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + ...options, + }); +} + +export function ensureSuccess(label, result) { + if (result.status !== 0) { + throw new Error(`${label} failed with status ${result.status ?? 'null'}: ${(result.stderr ?? '').trim() || '(no stderr)'}`); + } +} + +export function parseJsonStdout(label, result) { + const stdout = result.stdout ?? ''; + try { + return stdout.trim() ? JSON.parse(stdout) : null; + } catch (error) { + throw new Error(`${label} emitted non-JSON stdout: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export { packageRoot, repoRoot }; diff --git a/plugins/reporting-governance/src/adapters/bridge-supervisor.mjs b/plugins/reporting-governance/src/adapters/bridge-supervisor.mjs new file mode 100644 index 0000000..82635da --- /dev/null +++ b/plugins/reporting-governance/src/adapters/bridge-supervisor.mjs @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { ensureSuccess, parseJsonStdout, resolveRepoPath, runNodeScript } from './_script-runner.mjs'; + +const DEFAULT_SCRIPT = resolveRepoPath('scripts', 'operator_notify_bridge_supervisor.mjs'); +const DEFAULT_DISPATCHER_SCRIPT = resolveRepoPath('scripts', 'operator_notify_dispatcher.mjs'); + +export function runBridgeSupervisorAdapter({ + scriptPath = DEFAULT_SCRIPT, + spoolDir, + queueDir, + receiptDir, + dispatcherScript = DEFAULT_DISPATCHER_SCRIPT, + senderCommand = null, + now = null, + compact = true, + dryRun = false, +} = {}) { + const args = []; + if (spoolDir) args.push('--spool-dir', path.resolve(spoolDir)); + if (queueDir) args.push('--queue-dir', path.resolve(queueDir)); + if (receiptDir) args.push('--receipt-dir', path.resolve(receiptDir)); + if (dispatcherScript) args.push('--dispatcher-script', path.resolve(dispatcherScript)); + if (senderCommand) args.push('--sender-command', senderCommand); + if (now) args.push('--now', now); + if (dryRun) args.push('--dry-run'); + if (compact) args.push('--compact'); + + const result = runNodeScript(path.resolve(scriptPath), args); + ensureSuccess('bridge supervisor adapter', result); + return parseJsonStdout('bridge supervisor adapter', result); +} diff --git a/plugins/reporting-governance/src/adapters/dispatcher.mjs b/plugins/reporting-governance/src/adapters/dispatcher.mjs new file mode 100644 index 0000000..57164a2 --- /dev/null +++ b/plugins/reporting-governance/src/adapters/dispatcher.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { ensureSuccess, parseJsonStdout, resolveRepoPath, runNodeScript } from './_script-runner.mjs'; + +const DEFAULT_SCRIPT = resolveRepoPath('scripts', 'operator_notify_dispatcher.mjs'); + +export function runDispatcherAdapter({ + scriptPath = DEFAULT_SCRIPT, + queueDir, + spoolDir, + now = null, + compact = true, + claim = false, + ack = null, + block = null, + note = null, +} = {}) { + const args = []; + if (queueDir) args.push('--queue-dir', path.resolve(queueDir)); + if (spoolDir) args.push('--spool-dir', path.resolve(spoolDir)); + if (now) args.push('--now', now); + if (claim) args.push('--claim'); + if (ack) args.push('--ack', ack); + if (block) args.push('--block', block); + if (note) args.push('--note', note); + if (compact) args.push('--compact'); + + const result = runNodeScript(path.resolve(scriptPath), args); + ensureSuccess('dispatcher adapter', result); + return parseJsonStdout('dispatcher adapter', result); +} diff --git a/plugins/reporting-governance/src/adapters/index.mjs b/plugins/reporting-governance/src/adapters/index.mjs new file mode 100644 index 0000000..e4bbf3c --- /dev/null +++ b/plugins/reporting-governance/src/adapters/index.mjs @@ -0,0 +1,5 @@ +export { runWatchdogAdapter } from './watchdog.mjs'; +export { runDispatcherAdapter } from './dispatcher.mjs'; +export { runBridgeSupervisorAdapter } from './bridge-supervisor.mjs'; +export { runSenderBindingAdapter } from './sender-binding.mjs'; +export { runOrchestratorAdapter } from './orchestrator.mjs'; diff --git a/plugins/reporting-governance/src/adapters/orchestrator.mjs b/plugins/reporting-governance/src/adapters/orchestrator.mjs new file mode 100644 index 0000000..93f10de --- /dev/null +++ b/plugins/reporting-governance/src/adapters/orchestrator.mjs @@ -0,0 +1,51 @@ +import path from 'node:path'; +import { ensureSuccess, parseJsonStdout, resolveRepoPath, runNodeScript } from './_script-runner.mjs'; + +const DEFAULT_SCRIPT = resolveRepoPath('scripts', 'watchdog_auto_notify_orchestrator.mjs'); +const DEFAULT_WATCHDOG_SCRIPT = resolveRepoPath('scripts', 'long_task_watchdog.mjs'); +const DEFAULT_DISPATCHER_SCRIPT = resolveRepoPath('scripts', 'operator_notify_dispatcher.mjs'); +const DEFAULT_SUPERVISOR_SCRIPT = resolveRepoPath('scripts', 'operator_notify_bridge_supervisor.mjs'); + +export function runOrchestratorAdapter({ + scriptPath = DEFAULT_SCRIPT, + state, + evidenceDir, + eventDir, + queueDir, + spoolDir, + receiptDir, + watchdogScript = DEFAULT_WATCHDOG_SCRIPT, + dispatcherScript = DEFAULT_DISPATCHER_SCRIPT, + supervisorScript = DEFAULT_SUPERVISOR_SCRIPT, + senderCommand = null, + senderMode = null, + openclawBin = 'openclaw', + now = null, + compact = true, + writeState = false, + claim = false, + dryRun = false, +} = {}) { + const args = []; + if (state) args.push('--state', path.resolve(state)); + if (evidenceDir) args.push('--evidence-dir', path.resolve(evidenceDir)); + if (eventDir) args.push('--event-dir', path.resolve(eventDir)); + if (queueDir) args.push('--queue-dir', path.resolve(queueDir)); + if (spoolDir) args.push('--spool-dir', path.resolve(spoolDir)); + if (receiptDir) args.push('--receipt-dir', path.resolve(receiptDir)); + if (watchdogScript) args.push('--watchdog-script', path.resolve(watchdogScript)); + if (dispatcherScript) args.push('--dispatcher-script', path.resolve(dispatcherScript)); + if (supervisorScript) args.push('--supervisor-script', path.resolve(supervisorScript)); + if (senderCommand) args.push('--sender-command', senderCommand); + if (senderMode) args.push('--sender-mode', senderMode); + if (openclawBin) args.push('--openclaw-bin', openclawBin); + if (now) args.push('--now', now); + if (writeState) args.push('--write-state'); + if (claim) args.push('--claim'); + if (dryRun) args.push('--dry-run'); + if (compact) args.push('--compact'); + + const result = runNodeScript(path.resolve(scriptPath), args); + ensureSuccess('orchestrator adapter', result); + return parseJsonStdout('orchestrator adapter', result); +} diff --git a/plugins/reporting-governance/src/adapters/sender-binding.mjs b/plugins/reporting-governance/src/adapters/sender-binding.mjs new file mode 100644 index 0000000..b196b74 --- /dev/null +++ b/plugins/reporting-governance/src/adapters/sender-binding.mjs @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { ensureSuccess, parseJsonStdout, resolveRepoPath, runNodeScript } from './_script-runner.mjs'; + +const DEFAULT_SCRIPT = resolveRepoPath('scripts', 'operator_notify_sender_binding.mjs'); + +export function runSenderBindingAdapter({ + scriptPath = DEFAULT_SCRIPT, + mode = 'shim', + attemptDir, + openclawBin = 'openclaw', + now = null, + compact = true, + env = {}, +} = {}) { + const args = ['--mode', mode, '--openclaw-bin', openclawBin]; + if (attemptDir) args.push('--attempt-dir', path.resolve(attemptDir)); + if (now) args.push('--now', now); + if (compact) args.push('--compact'); + + const result = runNodeScript(path.resolve(scriptPath), args, { + env: { + ...process.env, + ...env, + }, + }); + ensureSuccess('sender binding adapter', result); + return parseJsonStdout('sender binding adapter', result); +} diff --git a/plugins/reporting-governance/src/adapters/watchdog.mjs b/plugins/reporting-governance/src/adapters/watchdog.mjs new file mode 100644 index 0000000..83d989d --- /dev/null +++ b/plugins/reporting-governance/src/adapters/watchdog.mjs @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { ensureSuccess, parseJsonStdout, resolveRepoPath, runNodeScript } from './_script-runner.mjs'; + +const DEFAULT_SCRIPT = resolveRepoPath('scripts', 'long_task_watchdog.mjs'); + +export function runWatchdogAdapter({ + scriptPath = DEFAULT_SCRIPT, + state, + evidenceDir, + eventDir, + notificationDir, + now = null, + compact = true, + writeState = false, +} = {}) { + const args = []; + if (state) args.push('--state', path.resolve(state)); + if (evidenceDir) args.push('--evidence-dir', path.resolve(evidenceDir)); + if (eventDir) args.push('--event-dir', path.resolve(eventDir)); + if (notificationDir) args.push('--notification-dir', path.resolve(notificationDir)); + if (now) args.push('--now', now); + if (writeState) args.push('--write-state'); + if (compact) args.push('--compact'); + + const result = runNodeScript(path.resolve(scriptPath), args); + ensureSuccess('watchdog adapter', result); + return parseJsonStdout('watchdog adapter', result); +} diff --git a/plugins/reporting-governance/src/index.mjs b/plugins/reporting-governance/src/index.mjs index 0b22c93..f816c73 100644 --- a/plugins/reporting-governance/src/index.mjs +++ b/plugins/reporting-governance/src/index.mjs @@ -30,3 +30,11 @@ export const packageBoundaries = { }; export { evaluatePolicyPack, evaluatePolicies, planDecisionExecution } from './core/index.mjs'; +export { + runWatchdogAdapter, + runDispatcherAdapter, + runBridgeSupervisorAdapter, + runSenderBindingAdapter, + runOrchestratorAdapter, +} from './adapters/index.mjs'; +export { runOrchestratorAdapter as runWatchdogChain } from './adapters/orchestrator.mjs'; diff --git a/plugins/reporting-governance/src/storage/.gitkeep b/plugins/reporting-governance/src/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/reporting-governance/test/package-structure.test.mjs b/plugins/reporting-governance/test/package-structure.test.mjs new file mode 100644 index 0000000..6a45e6e --- /dev/null +++ b/plugins/reporting-governance/test/package-structure.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(__dirname, '..'); + +const requiredPaths = [ + 'README.md', + 'package.json', + 'src/index.mjs', + 'src/core', + 'src/adapters', + 'src/adapters/watchdog.mjs', + 'src/adapters/dispatcher.mjs', + 'src/adapters/bridge-supervisor.mjs', + 'src/adapters/sender-binding.mjs', + 'src/adapters/orchestrator.mjs', + 'src/storage', + 'src/reference/openclaw-watchdog-chain.md', + 'capabilities/openclaw-watchdog-reference.json', + 'examples/openclaw-watchdog-reference.descriptor.example.json' +]; + +test('reporting-governance package skeleton paths exist', () => { + for (const relativePath of requiredPaths) { + const fullPath = path.join(packageRoot, relativePath); + assert.equal(fs.existsSync(fullPath), true, `expected path to exist: ${relativePath}`); + } +}); diff --git a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs new file mode 100644 index 0000000..2b3107c --- /dev/null +++ b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { + runOrchestratorAdapter, + runWatchdogChain, +} from '../src/index.mjs'; + +function createFixtureRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-plugin-')); +} + +function mkdirs(root, names) { + for (const name of names) { + fs.mkdirSync(path.join(root, name), { recursive: true }); + } +} + +function writeState(root) { + const statePath = path.join(root, 'watchdog-state.json'); + fs.writeFileSync(statePath, `${JSON.stringify({ + version: 1, + watchdogs: [ + { + id: 'reporting-governance-plugin-watchdog', + task: 'reporting-governance plugin spec development', + status: 'active', + ownerSessionKey: 'agent:coder:main', + reportChannel: 'telegram', + reportTarget: '864811879', + intervalMinutes: 10, + lastMilestoneAt: '2026-05-07T08:00:00.000Z', + lastAlertAt: null, + }, + ], + }, null, 2)}\n`, 'utf8'); + return statePath; +} + +function readSingleJson(dirPath) { + const files = fs.readdirSync(dirPath).filter((name) => name.endsWith('.json')).sort(); + assert.equal(files.length, 1, `expected exactly one json file in ${dirPath}`); + return { + fileName: files[0], + payload: JSON.parse(fs.readFileSync(path.join(dirPath, files[0]), 'utf8')), + }; +} + +test('package entrypoint can run watchdog chain through orchestrator adapter', () => { + const root = createFixtureRoot(); + try { + mkdirs(root, ['evidence', 'events', 'queue', 'spool', 'receipts']); + const statePath = writeState(root); + + const result = runWatchdogChain({ + state: statePath, + evidenceDir: path.join(root, 'evidence'), + eventDir: path.join(root, 'events'), + queueDir: path.join(root, 'queue'), + spoolDir: path.join(root, 'spool'), + receiptDir: path.join(root, 'receipts'), + writeState: true, + senderCommand: `node -e "process.stdout.write(JSON.stringify({state:'sent'}))"`, + now: '2026-05-07T08:20:00.000Z', + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.executionOrder, ['runner', 'queue', 'dispatcher', 'bridge', 'sender', 'ack_or_blocked_or_pending']); + assert.equal(result.result.watchdog.notificationCount, 1); + assert.equal(result.result.dispatcher.dispatchedCount, 1); + assert.equal(result.result.supervisor.ackedCount, 1); + + const queueItem = readSingleJson(path.join(root, 'queue')).payload; + assert.equal(queueItem.status, 'acked'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('dry-run path produces verifiable pending receipt via package adapter', () => { + const root = createFixtureRoot(); + try { + mkdirs(root, ['evidence', 'events', 'queue', 'spool', 'receipts']); + const statePath = writeState(root); + + const result = runOrchestratorAdapter({ + state: statePath, + evidenceDir: path.join(root, 'evidence'), + eventDir: path.join(root, 'events'), + queueDir: path.join(root, 'queue'), + spoolDir: path.join(root, 'spool'), + receiptDir: path.join(root, 'receipts'), + writeState: true, + dryRun: true, + now: '2026-05-07T08:20:00.000Z', + }); + + assert.equal(result.ok, true); + assert.equal(result.orchestration.dryRun, true); + assert.equal(result.result.watchdog.notificationCount, 1); + assert.equal(result.result.dispatcher.dispatchedCount, 1); + assert.equal(result.result.supervisor.pendingCount, 1); + + const queueItem = readSingleJson(path.join(root, 'queue')).payload; + assert.equal(queueItem.status, 'dispatched'); + + const receipt = readSingleJson(path.join(root, 'receipts')).payload; + assert.equal(receipt.state, 'pending_external_send'); + assert.equal(receipt.supervisor_mode, 'dry_run'); + assert.ok(receipt.suggested_command.includes('openclaw message send')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});