feat(reporting-governance): add watchdog chain adapters
This commit is contained in:
@@ -5,9 +5,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Reporting governance plugin package skeleton with capability descriptors and OpenClaw reference adapter boundaries.",
|
"description": "Reporting governance plugin package skeleton with capability descriptors and OpenClaw reference adapter boundaries.",
|
||||||
"exports": {
|
"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": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
plugins/reporting-governance/src/adapters/_script-runner.mjs
Normal file
35
plugins/reporting-governance/src/adapters/_script-runner.mjs
Normal file
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
30
plugins/reporting-governance/src/adapters/dispatcher.mjs
Normal file
30
plugins/reporting-governance/src/adapters/dispatcher.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
5
plugins/reporting-governance/src/adapters/index.mjs
Normal file
5
plugins/reporting-governance/src/adapters/index.mjs
Normal file
@@ -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';
|
||||||
51
plugins/reporting-governance/src/adapters/orchestrator.mjs
Normal file
51
plugins/reporting-governance/src/adapters/orchestrator.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
28
plugins/reporting-governance/src/adapters/sender-binding.mjs
Normal file
28
plugins/reporting-governance/src/adapters/sender-binding.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
28
plugins/reporting-governance/src/adapters/watchdog.mjs
Normal file
28
plugins/reporting-governance/src/adapters/watchdog.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -30,3 +30,11 @@ export const packageBoundaries = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { evaluatePolicyPack, evaluatePolicies, planDecisionExecution } from './core/index.mjs';
|
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';
|
||||||
|
|||||||
0
plugins/reporting-governance/src/storage/.gitkeep
Normal file
0
plugins/reporting-governance/src/storage/.gitkeep
Normal file
32
plugins/reporting-governance/test/package-structure.test.mjs
Normal file
32
plugins/reporting-governance/test/package-structure.test.mjs
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user