Compare commits

..

10 Commits

28 changed files with 1585 additions and 443 deletions

View File

@@ -64,6 +64,9 @@ watchdog -> queue -> dispatcher -> bridge -> sender binding -> acked|blocked|pen
This is not just repo glue anymore.
It is the first real reference runtime composition that the package architecture is being shaped around.
For clarity: the architectural source of truth is now the package-owned runtime surface under `plugins/reporting-governance/`, including package scripts and package profile artifacts.
Repo-root wrappers may still exist, but they are migration shims rather than the primary runtime contract.
## Minimal package profile artifact trajectory
Architecture is also now advanced one notch from “profiles are external YAML docs” toward “profiles are package artifacts with a loader boundary”.

View File

@@ -34,6 +34,10 @@ Separately, the repo now also has real runtime infrastructure for anti-blackhole
Without an adapter interface, those runtime pieces remain implementation islands. This spec makes them first-class plugin architecture rather than incidental scripts.
For the current mainline slice, the package-owned runtime source of truth is the package adapter surface and package scripts under `plugins/reporting-governance/`.
Repo-root `scripts/*.mjs` files should be read as compatibility wrappers or operator conveniences unless explicitly stated otherwise.
When this spec names package entrypoints as `scripts/...`, that path is package-root-relative within `plugins/reporting-governance/`; repo-root wrappers remain the separate compatibility layer.
## Scope
This document defines:

View File

@@ -0,0 +1,412 @@
# Reporting Governance Capability Descriptor
## Purpose
This document defines the **capability descriptor** for the reporting-governance plugin.
It turns runtime support from implied prose into a machine-readable contract that can be:
- validated before deployment
- checked against deployment profiles
- attached to audit bundles
- used by the next implementation stage for evaluator / decision-runner wiring
This spec is intentionally the next step after:
- `docs/specs/reporting-governance-adapter-interface.md`
- `docs/specs/reporting-governance-deployment-model.md`
- `schemas/reporting-governance/adapter-capabilities.schema.json`
The adapter-interface spec defines **what adapters must be able to describe**.
This capability-descriptor spec defines **how one concrete runtime publishes that description as a package artifact**.
## Why this exists now
The current mainline already has:
- canonical event / evidence / decision / policy-pack contracts
- deployment profiles
- a validated watchdog reference runtime composition
But the plugin still lacks one package-level truth source answering:
- which runtime surfaces exist in this deployment
- which enforcement actions are actually available
- whether the runtime can only queue, can bridge, or can truly ack final delivery
- which storage and receipt guarantees are real
Without a capability descriptor, the next evaluator / decision-runner stage would still have to guess runtime powers from docs and scripts.
## Scope
This spec defines:
1. the package role of a capability descriptor
2. the minimum descriptor shape
3. the OpenClaw reference descriptor for the watchdog composition
4. package-boundary implications for `core/`, `adapters/`, `storage/`, `artifacts/`, and reference implementations
5. how evaluator / decision-runner should consume descriptor data next
This spec does **not** define:
- the policy evaluator itself
- the deployment-profile schema
- one mandatory programming language API
- the full audit export manifest format
## Mainline decision
The reporting-governance plugin now uses this package distinction:
- **schema**: validation contract for descriptor shape
- **capability descriptor**: one concrete runtime claim validated by that schema
- **deployment profile**: requested operating posture
- **adapter modules**: implementation surfaces that may satisfy some or all requested posture
In short:
- profiles express **desired behavior**
- capability descriptors express **actual runtime power**
- evaluator / decision-runner must operate from the intersection of both
## Package location
Capability descriptors belong under the plugin package boundary:
```text
plugins/reporting-governance/
capabilities/
openclaw-watchdog-reference.json
examples/
openclaw-watchdog-reference.descriptor.example.json
```
The canonical validation schema remains under:
```text
schemas/reporting-governance/capability-descriptor.schema.json
```
This keeps the split clear:
- `schemas/` = canonical validation contracts
- `plugins/reporting-governance/capabilities/` = package-owned runtime declarations; `runtime.entrypoint: "scripts/..."` is resolved relative to the package root
- `plugins/reporting-governance/examples/` = copyable reference inputs using the same package-root-relative `scripts/...` semantics
## Descriptor responsibilities
A capability descriptor must answer five questions.
### 1. What runtime is this?
Examples:
- `openclaw`
- future host runtimes
- future embedded or CI-only runtimes
### 2. Which adapter surfaces are present?
Examples:
- `watchdog`
- `queue`
- `dispatcher`
- `bridge`
- `sender_binding`
- `orchestrator`
### 3. Which governance actions are truly available?
Examples:
- block transition
- rewrite message
- annotate placeholder
- force checkpoint
- request review
- downgrade status
- escalate
### 4. What notification truth model is supported?
At minimum, whether the runtime distinguishes:
- `prepared`
- `queued`
- `dispatched`
- `pending_external_send`
- `acked`
- `blocked`
### 5. What persistence and audit guarantees exist?
Examples:
- can events be persisted?
- can receipts be written?
- can original attempted messages be preserved?
- can decision outputs be stored yet, or is that still pending the next phase?
## Descriptor shape
The canonical descriptor shape is validated by:
- `schemas/reporting-governance/capability-descriptor.schema.json`
The schema reuses the same top-level resource model already established for adapter capabilities:
- `apiVersion`
- `kind`
- `metadata`
- `runtime`
- `compatibility`
- `capabilities`
- optional `defaults`
- optional `notes`
## OpenClaw reference descriptor
The first package-mainline descriptor is the **OpenClaw watchdog reference composition**.
Descriptor identity:
- runtime: `openclaw`
- mode: `hybrid`
- runtime `entrypoint`: `scripts/watchdog_auto_notify_orchestrator.mjs` resolved from `plugins/reporting-governance/` package root
- surfaces:
- `watchdog`
- `queue`
- `dispatcher`
- `bridge`
- `sender_binding`
- `orchestrator`
- `scheduler`
- `storage`
### What it must claim
This reference descriptor should claim support for:
- watchdog overdue observation
- canonical watchdog event generation
- queue-backed operator notices
- spool/handoff dispatch
- bridge-supervised sender invocation
- truthful `acked | blocked | pending_external_send` outcomes
- persistence of events, evidence, queue items, spool artifacts, and receipts
### What it should not overclaim
At this stage, the reference descriptor should **not** overclaim:
- full inline hook interception as complete
- final delivery proof in every deployment
- fully packaged decision persistence if the evaluator / decision runner is not extracted yet
- all completion-claim governance paths
This is why some support levels remain `partial` rather than `full`.
## Package boundary decisions
This spec fixes the package skeleton boundaries needed for the next implementation round.
## `src/core/`
`core/` contains runtime-agnostic governance logic.
It should own:
- canonical event normalization
- evidence building / evidence-reference shaping
- policy evaluation
- decision execution planning
- capability/profile compatibility checks
It should **not** own:
- cron installation
- filesystem-specific queue scanning loops
- OpenClaw sender invocations
- repo-local wrapper scripts
Minimum package direction:
```text
src/core/
capability-profile-compatibility.mjs
decision-runner.mjs
evidence-builder.mjs
event-normalizer.mjs
policy-evaluator.mjs
```
## `src/adapters/`
`adapters/` contains runtime-facing integration modules.
It should own:
- OpenClaw watchdog adapter wrapper
- queue / dispatcher adapter wrapper
- bridge adapter wrapper
- sender-binding adapter wrapper
- orchestrator adapter wrapper
It may initially wrap existing repo scripts while package extraction is still in progress.
It should **not** absorb canonical policy semantics that belong in `core/`.
Minimum package direction:
```text
src/adapters/
bridge-adapter.mjs
dispatcher-adapter.mjs
orchestrator-adapter.mjs
sender-binding-adapter.mjs
watchdog-adapter.mjs
```
## `src/storage/`
`storage/` contains package-level readers/writers for durable governance artifacts.
It should own contracts for:
- event store
- evidence store
- receipt store
- queue-item store
- spool-artifact store
- future decision store
- future audit export manifest support
It should not define governance meaning; it only persists governed artifacts.
Minimum package direction:
```text
src/storage/
decision-store.mjs
event-store.mjs
evidence-store.mjs
queue-store.mjs
receipt-store.mjs
spool-store.mjs
```
## `artifacts/` boundary
The plugin package does not need a required checked-in `artifacts/` source directory yet.
Instead, **artifacts** are the runtime outputs produced by storage contracts and runtime bindings.
For current OpenClaw reference behavior, the artifact classes are still represented by runtime directories such as:
- `state/long-task-watchdog/`
- `state/long-task-watchdog-events/`
- `state/operator-notify-queue/`
- `state/operator-notify-dispatch-spool/`
- `state/operator-notify-bridge-receipts/`
- `state/operator-notify-sender-attempts/`
Mainline interpretation:
- these are **runtime artifact locations**
- package `src/storage/` should become the code boundary that reads/writes them
- future audit export bundles should consume these classes through storage abstractions rather than raw script coupling
## Reference implementations boundary
Reference implementations are executable, runtime-specific realizations of the package contracts.
For this MVP stage, the main reference implementation is the watchdog chain described in:
- `CODER_WATCHDOG_REPORT.md`
Within the package skeleton, it belongs under:
```text
plugins/reporting-governance/src/reference/openclaw-watchdog-chain.md
```
and is represented in code-adjacent terms by:
- `src/adapters/watchdog-adapter.mjs`
- `src/adapters/dispatcher-adapter.mjs`
- `src/adapters/bridge-adapter.mjs`
- `src/adapters/sender-binding-adapter.mjs`
- `src/adapters/orchestrator-adapter.mjs`
This means the **watchdog reference runtime composition is categorized as a reference implementation, not as plugin core**.
That is the key boundary that keeps the next evaluator / decision-runner work clean.
## Consumption by evaluator / decision runner
The next implementation stage should use the capability descriptor in two places.
### 1. Preflight compatibility
Before executing a policy action, the runtime should compare:
- requested profile behavior
- policy-required action
- actual adapter capability support
Examples:
- if `force_checkpoint` is requested but only queue/bridge notice is available, downgrade to honest deferred notice + receipt
- if direct send is requested but only `pending_external_send` is supportable, preserve the weaker truthful state
### 2. Action planning
The decision runner should use capability support to choose execution paths such as:
- inline block
- queue-only notice
- bridge-mediated send
- status downgrade + review request
- blocked-with-gap receipt
This keeps the evaluator portable and avoids encoding OpenClaw-specific assumptions inside `core/`.
## Verification expectations
Capability descriptor work should be verified at minimum by:
1. schema validation of the descriptor JSON
2. package skeleton presence checks
3. consistency review against:
- adapter-interface spec
- deployment model
- roadmap lane terminology
## Minimal synchronization with roadmap and deployment model
This capability-descriptor step advances:
- roadmap lane 3: package extraction
- roadmap lane 4: capability/profile/deployment binding
The deployment model does not need redesign.
It only needs to recognize that the first concrete package artifact for this lane is now:
- a package-owned OpenClaw capability descriptor
- plus the minimal package skeleton that gives it a stable home
## Summary
This spec establishes the reporting-governance capability descriptor as the package-level truth contract between:
- runtime-specific adapter power
- deployment-profile intent
- future evaluator / decision-runner behavior
It also fixes the package boundaries needed for the next phase:
- `core/` = runtime-agnostic governance logic
- `adapters/` = runtime integration surfaces
- `storage/` = durable artifact I/O contracts
- runtime `artifacts/` = produced outputs, not core source
- watchdog chain = reference implementation housed under the package skeleton, not plugin core

View File

@@ -312,6 +312,11 @@ Examples:
The completed watchdog chain is now treated as the first reference deployment composition.
For the current mainline slice, the package-owned source of truth is the package entrypoint set under `plugins/reporting-governance/scripts/` together with package profile artifacts under `plugins/reporting-governance/profiles/`.
Within package-owned artifacts such as capability descriptors, `runtime.entrypoint: "scripts/..."` means package-root-relative to `plugins/reporting-governance/`.
Within deployment profile artifacts, `spec.bindings.entrypoint` and `spec.bindings.scripts.*` remain repo-root-relative paths like `plugins/reporting-governance/scripts/...`, because they bind the installed package back into a concrete workspace/repo runtime.
Repo-root `scripts/*.mjs` wrappers and install helpers remain migration/deployment conveniences, not the architectural source of runtime truth.
### Reference composition
```text
@@ -328,16 +333,16 @@ watchdog runner
| Runtime piece | Deployment role |
| --- | --- |
| `scripts/long_task_watchdog.mjs` | watchdog adapter executable in the deployed package |
| package `scripts/long_task_watchdog.mjs` | watchdog adapter executable in the deployed package (`scripts/...` here means package-root-relative inside `plugins/reporting-governance/`) |
| `state/long-task-watchdog/*.json` | portable runtime-artifact evidence |
| `state/long-task-watchdog-events/*.json` | portable canonical event artifacts |
| `state/operator-notify-queue/*.json` | queue-layer audit artifacts and deferred notice obligations |
| `scripts/operator_notify_dispatcher.mjs` | dispatcher adapter for handoff generation |
| package `scripts/operator_notify_dispatcher.mjs` | dispatcher adapter for handoff generation |
| `state/operator-notify-dispatch-spool/*.json` | spool/handoff audit artifacts proving dispatch, not delivery |
| `scripts/operator_notify_bridge_supervisor.mjs` | bridge adapter for upper-runtime send boundary |
| `scripts/operator_notify_sender_binding.mjs` | sender-binding adapter selectable by deployment profile |
| package `scripts/operator_notify_bridge_supervisor.mjs` | bridge adapter for upper-runtime send boundary |
| package `scripts/operator_notify_sender_binding.mjs` | sender-binding adapter selectable by deployment profile |
| `state/operator-notify-bridge-receipts/*.json` | portable delivery-state receipts |
| `scripts/watchdog_auto_notify_orchestrator.mjs` | orchestrator adapter and single deployment entrypoint |
| package `scripts/watchdog_auto_notify_orchestrator.mjs` | orchestrator adapter and single deployment entrypoint |
| cron installer / wrapper | runtime binding for scheduled execution |
### Mainline conclusion

View File

@@ -0,0 +1,108 @@
# Reporting Governance Package-Owned Runtime / Operator / Deployment Story
## Purpose
This note closes the current mainline slice by stating the runtime story in package-owned terms.
It is deliberately a **documentation / deployment-story closure**, not a new runtime feature.
## What is now package-owned
The `plugins/reporting-governance/` package now owns these deployable truths:
- canonical schemas
- policy packs
- adapter modules
- package entry scripts under `plugins/reporting-governance/scripts/`
- package profile artifacts under `plugins/reporting-governance/profiles/`
- package capability examples under `plugins/reporting-governance/examples/`
- storage/binding loaders that resolve package artifacts into runtime bindings
Repo-root `scripts/*.mjs` should now be read as **compatibility shims / operator convenience wrappers**, not the architectural source of truth.
## Runtime story
The current reference runtime composition is:
```text
package-owned watchdog
-> canonical event artifact
-> package-declared queue artifact
-> package-owned dispatcher handoff
-> package-owned bridge supervisor
-> package-owned sender binding
-> honest terminal receipt: acked | blocked | pending_external_send
```
The important boundary is:
- **package owns the contract and primary entrypoints**
- **deployment binds environment-local schedule / target / sender mode**
- **runtime executes and emits artifacts**
- **operator consumes visible updates and audit outputs**
## Operator story
For operators, the package now tells a clearer truth:
1. Governance can require an operator-visible notice.
2. The local runtime may only be able to queue or hand off that notice.
3. Final delivery may depend on an upper runtime or privileged sender boundary.
4. The system must therefore report terminal state honestly as:
- `acked`
- `blocked`
- `pending_external_send`
5. No profile or document should imply that queue write == human-visible delivery.
## Deployment story
A deployment is now best understood as four layers:
1. **Package**
- ships schemas, policy, adapters, package scripts, profile artifacts
2. **Profile artifact**
- selects operating posture and binding contract
3. **Runtime binding**
- supplies repo/workspace-local paths, schedule shape, sender mode, operator target
4. **Live runtime instance**
- runs orchestrator/watchdog/bridge flow and emits audit artifacts
For the current OpenClaw reference path, the preferred deployable entrypoint is the package-owned orchestrator:
- `plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs`
Repo-root wrappers may remain for migration and operator convenience, but they are not the package boundary.
## Completed and claimable now
The following is honest to claim now:
- package boundary is real enough to carry profile artifacts and package scripts
- deployment binding can be derived from package-owned artifacts
- orchestrator/runtime path can consume that binding and emit real queue/receipt side effects
- operator-notice truth model distinguishes `acked`, `blocked`, and `pending_external_send`
- repo-root script path is no longer the only source of runtime wiring truth
## Not completed and must not be overstated
The following is **not** done and should not be claimed:
- not a finished general-purpose deployment system
- not full runtime/vendor portability across many runtimes
- not complete inline interception for every governance action
- not proof that every deployment has direct privileged send
- not a full operator UX / install UX / packaging distribution story
- not elimination of repo-root shims yet
## Current risk / remaining gap
Main remaining gaps after this closure:
- some compatibility shims and examples still exist, so readers can still misread migration wrappers as mainline source unless docs stay explicit
- capability/example artifacts are still reference-level, not a full published runtime catalog
- deployment activation remains partly operator-script / cron-wrapper driven
- broader packaging/export/install ergonomics are still follow-up work
## One-line summary
This slice finishes the **documentation semantics and deployment story** for a package-owned reference runtime path; it does **not** add new core runtime capability.

View File

@@ -91,6 +91,10 @@ Current **public package surface** is intentionally narrow:
- `@openclaw/plugin-reporting-governance/adapters/sender-binding`
- `@openclaw/plugin-reporting-governance/adapters/orchestrator`
`@openclaw/plugin-reporting-governance/adapters` 目前只代表 **runtime adapter entrypoints**
`createRuntimeBinding(...)``loadDeploymentProfileArtifact(...)``createDeploymentBindingContract(...)` 不再掛在 `./adapters` barrel前者仍由 root export 提供,後兩者屬於 storage/profile artifact slice。
目前 `storage` / `profile-artifact` 也**不是公開 subpath**;若要使用其能力,請以 root export / 已宣告 exports 為準,不要 deep import `src/storage/*`
What is currently exposed from the root export:
- `evaluatePolicyPack(...)`

View File

@@ -27,7 +27,7 @@
"src/"
],
"scripts": {
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/decision-store.test.mjs test/decision-store-runtime.integration.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs test/packed-consumer-install.smoke.test.mjs",
"test": "node --test test/package-structure.test.mjs test/descriptor-entrypoint-resolution.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/decision-store.test.mjs test/decision-store-runtime.integration.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/orchestrator-execution.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs test/packed-consumer-install.smoke.test.mjs",
"smoke": "node ./scripts/package-smoke.mjs --compact"
},
"dependencies": {

View File

@@ -68,8 +68,16 @@ function main() {
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
const result = runOrchestratorAdapter({
profileArtifact: artifact,
repoRootOverride: packageRoot,
runtimeBinding: {
cwd: process.cwd(),
scripts: {
orchestrator: path.join(packageRoot, 'scripts', 'watchdog_auto_notify_orchestrator.mjs'),
watchdog: path.join(packageRoot, 'scripts', 'long_task_watchdog.mjs'),
dispatcher: path.join(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'),
bridgeSupervisor: path.join(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs'),
senderBinding: path.join(packageRoot, 'scripts', 'operator_notify_sender_binding.mjs'),
},
},
state,
evidenceDir: path.join(workspace, 'evidence'),
eventDir: path.join(workspace, 'events'),

View File

@@ -3,7 +3,3 @@ export { runDispatcherAdapter } from './dispatcher.mjs';
export { runBridgeSupervisorAdapter } from './bridge-supervisor.mjs';
export { runSenderBindingAdapter } from './sender-binding.mjs';
export { runOrchestratorAdapter } from './orchestrator.mjs';
export { parseOrchestratorCliArgs, formatOrchestratorHelp, runWatchdogAutoNotifyOrchestrator, runOrchestratorCli } from './orchestrator-cli.mjs';
export { createRuntimeBinding } from './runtime-binding.mjs';
export { loadDeploymentProfileArtifact, createDeploymentBindingContract } from '../storage/profile-artifact.mjs';

View File

@@ -1,41 +1,18 @@
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import {
buildSenderCommand,
createDefaultOrchestratorExecutionArgs,
runOrchestratorExecution,
} from './orchestrator-execution.mjs';
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const DEFAULT_STATE_PATH = path.join(packageRoot, 'memory', 'watchdog-state.json');
const DEFAULT_EVIDENCE_DIR = path.join(packageRoot, 'state', 'long-task-watchdog');
const DEFAULT_EVENT_DIR = path.join(packageRoot, 'state', 'long-task-watchdog-events');
const DEFAULT_QUEUE_DIR = path.join(packageRoot, 'state', 'operator-notify-queue');
const DEFAULT_SPOOL_DIR = path.join(packageRoot, 'state', 'operator-notify-dispatch-spool');
const DEFAULT_RECEIPT_DIR = path.join(packageRoot, 'state', 'operator-notify-bridge-receipts');
const DEFAULT_WATCHDOG_SCRIPT = path.join(packageRoot, 'scripts', 'long_task_watchdog.mjs');
const DEFAULT_DISPATCHER_SCRIPT = path.join(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs');
const DEFAULT_SUPERVISOR_SCRIPT = path.join(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs');
const DEFAULT_SENDER_BINDING_SCRIPT = path.join(packageRoot, 'scripts', 'operator_notify_sender_binding.mjs');
const DEFAULT_ARGS = createDefaultOrchestratorExecutionArgs({ packageRoot });
export function parseOrchestratorCliArgs(argv) {
const args = {
state: DEFAULT_STATE_PATH,
evidenceDir: DEFAULT_EVIDENCE_DIR,
eventDir: DEFAULT_EVENT_DIR,
queueDir: DEFAULT_QUEUE_DIR,
spoolDir: DEFAULT_SPOOL_DIR,
receiptDir: DEFAULT_RECEIPT_DIR,
watchdogScript: DEFAULT_WATCHDOG_SCRIPT,
dispatcherScript: DEFAULT_DISPATCHER_SCRIPT,
supervisorScript: DEFAULT_SUPERVISOR_SCRIPT,
senderCommand: null,
senderMode: null,
openclawBin: 'openclaw',
now: null,
compact: false,
writeState: false,
claim: false,
dryRun: false,
help: false,
};
const args = { ...DEFAULT_ARGS };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
@@ -97,105 +74,13 @@ export function printOrchestratorHelp(options = {}) {
process.stdout.write(`${formatOrchestratorHelp(options)}\n`);
}
export function buildSenderCommand(args) {
if (args.senderCommand) return args.senderCommand;
if (!args.senderMode) return null;
const cmd = [
JSON.stringify(process.execPath),
JSON.stringify(DEFAULT_SENDER_BINDING_SCRIPT),
'--mode', JSON.stringify(args.senderMode),
'--openclaw-bin', JSON.stringify(args.openclawBin),
'--compact',
];
return cmd.join(' ');
}
export function runNodeScript(scriptPath, scriptArgs) {
return spawnSync(process.execPath, [scriptPath, ...scriptArgs], {
cwd: packageRoot,
encoding: 'utf8',
});
}
export function parseJsonOutput(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 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 runWatchdogAutoNotifyOrchestrator(args) {
const senderCommand = buildSenderCommand(args);
const watchdogArgs = [
'--state', path.resolve(args.state),
'--evidence-dir', path.resolve(args.evidenceDir),
'--event-dir', path.resolve(args.eventDir),
'--notification-dir', path.resolve(args.queueDir),
'--compact',
];
if (args.writeState) watchdogArgs.push('--write-state');
if (args.now) watchdogArgs.push('--now', args.now);
const watchdog = runNodeScript(path.resolve(args.watchdogScript), watchdogArgs);
ensureSuccess('watchdog runner', watchdog);
const watchdogPayload = parseJsonOutput('watchdog runner', watchdog);
const dispatcherArgs = [
'--queue-dir', path.resolve(args.queueDir),
'--spool-dir', path.resolve(args.spoolDir),
'--compact',
];
if (args.claim) dispatcherArgs.push('--claim');
if (args.now) dispatcherArgs.push('--now', args.now);
const dispatcher = runNodeScript(path.resolve(args.dispatcherScript), dispatcherArgs);
ensureSuccess('dispatcher', dispatcher);
const dispatcherPayload = parseJsonOutput('dispatcher', dispatcher);
const supervisorArgs = [
'--queue-dir', path.resolve(args.queueDir),
'--spool-dir', path.resolve(args.spoolDir),
'--receipt-dir', path.resolve(args.receiptDir),
'--dispatcher-script', path.resolve(args.dispatcherScript),
'--compact',
];
if (args.dryRun) supervisorArgs.push('--dry-run');
if (senderCommand) supervisorArgs.push('--sender-command', senderCommand);
if (args.now) supervisorArgs.push('--now', args.now);
const supervisor = runNodeScript(path.resolve(args.supervisorScript), supervisorArgs);
ensureSuccess('bridge supervisor', supervisor);
const supervisorPayload = parseJsonOutput('bridge supervisor', supervisor);
const payload = runOrchestratorExecution(args, { senderBindingScript: DEFAULT_SENDER_BINDING_SCRIPT });
return {
ok: true,
tool: 'watchdog_auto_notify_orchestrator',
version: 'mvp-v1',
now: args.now ?? null,
executionOrder: [
'runner',
'queue',
'dispatcher',
'bridge',
senderCommand ? 'sender' : 'sender_unconfigured',
'ack_or_blocked_or_pending',
],
...payload,
orchestration: {
script: path.resolve(import.meta.filename),
senderCommandConfigured: Boolean(senderCommand),
senderMode: args.senderMode ?? null,
dryRun: args.dryRun,
},
result: {
watchdog: watchdogPayload?.result ?? null,
dispatcher: dispatcherPayload?.result ?? null,
supervisor: supervisorPayload?.result ?? null,
...payload.orchestration,
},
};
}
@@ -222,4 +107,4 @@ export function main(argv = process.argv.slice(2), options = {}) {
}
}
export { packageRoot };
export { buildSenderCommand, packageRoot };

View File

@@ -0,0 +1,104 @@
import path from 'node:path';
import process from 'node:process';
import { runWatchdogAdapter } from './watchdog.mjs';
import { runDispatcherAdapter } from './dispatcher.mjs';
import { runBridgeSupervisorAdapter } from './bridge-supervisor.mjs';
export const DEFAULT_OPENCLAW_BIN = 'openclaw';
export function createDefaultOrchestratorExecutionArgs({ packageRoot }) {
return {
state: path.join(packageRoot, 'memory', 'watchdog-state.json'),
evidenceDir: path.join(packageRoot, 'state', 'long-task-watchdog'),
eventDir: path.join(packageRoot, 'state', 'long-task-watchdog-events'),
queueDir: path.join(packageRoot, 'state', 'operator-notify-queue'),
spoolDir: path.join(packageRoot, 'state', 'operator-notify-dispatch-spool'),
receiptDir: path.join(packageRoot, 'state', 'operator-notify-bridge-receipts'),
watchdogScript: path.join(packageRoot, 'scripts', 'long_task_watchdog.mjs'),
dispatcherScript: path.join(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'),
supervisorScript: path.join(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs'),
senderCommand: null,
senderMode: null,
openclawBin: DEFAULT_OPENCLAW_BIN,
now: null,
compact: false,
writeState: false,
claim: false,
dryRun: false,
help: false,
};
}
export function buildSenderCommand(args, { senderBindingScript }) {
if (args.senderCommand) return args.senderCommand;
if (!args.senderMode) return null;
const cmd = [
JSON.stringify(process.execPath),
JSON.stringify(senderBindingScript),
'--mode', JSON.stringify(args.senderMode),
'--openclaw-bin', JSON.stringify(args.openclawBin),
'--compact',
];
return cmd.join(' ');
}
export function runOrchestratorExecution(args, { senderBindingScript } = {}) {
const senderCommand = buildSenderCommand(args, { senderBindingScript });
const watchdogPayload = runWatchdogAdapter({
scriptPath: path.resolve(args.watchdogScript),
state: args.state,
evidenceDir: args.evidenceDir,
eventDir: args.eventDir,
notificationDir: args.queueDir,
now: args.now,
compact: true,
writeState: args.writeState,
});
const dispatcherPayload = runDispatcherAdapter({
scriptPath: path.resolve(args.dispatcherScript),
queueDir: args.queueDir,
spoolDir: args.spoolDir,
now: args.now,
compact: true,
claim: args.claim,
});
const supervisorPayload = runBridgeSupervisorAdapter({
scriptPath: path.resolve(args.supervisorScript),
queueDir: args.queueDir,
spoolDir: args.spoolDir,
receiptDir: args.receiptDir,
dispatcherScript: path.resolve(args.dispatcherScript),
senderCommand,
now: args.now,
compact: true,
dryRun: args.dryRun,
});
return {
ok: true,
tool: 'watchdog_auto_notify_orchestrator',
version: 'mvp-v1',
now: args.now ?? null,
executionOrder: [
'runner',
'queue',
'dispatcher',
'bridge',
senderCommand ? 'sender' : 'sender_unconfigured',
'ack_or_blocked_or_pending',
],
orchestration: {
senderCommandConfigured: Boolean(senderCommand),
senderMode: args.senderMode ?? null,
dryRun: args.dryRun,
},
result: {
watchdog: watchdogPayload?.result ?? null,
dispatcher: dispatcherPayload?.result ?? null,
supervisor: supervisorPayload?.result ?? null,
},
};
}

View File

@@ -1,7 +1,7 @@
import path from 'node:path';
import { ensureSuccess, parseJsonStdout, runNodeScript } from './_script-runner.mjs';
import { createRuntimeBinding, resolveScriptPath } from './runtime-binding.mjs';
import { loadDeploymentProfileArtifact, createDeploymentBindingContract, assertUseTimePathWithinRepoRoot } from '../storage/profile-artifact.mjs';
import { runOrchestratorExecution } from './orchestrator-execution.mjs';
export function runOrchestratorAdapter({
scriptPath = null,
@@ -42,10 +42,11 @@ export function runOrchestratorAdapter({
cwd: repoRootOverride,
scripts: resolvedDeploymentBinding?.scripts,
});
const resolvedScriptPath = path.resolve(scriptPath ?? resolvedDeploymentBinding?.entrypoint ?? resolveScriptPath('orchestrator', { runtimeBinding: binding }));
const resolvedOrchestratorScriptPath = path.resolve(scriptPath ?? resolvedDeploymentBinding?.entrypoint ?? resolveScriptPath('orchestrator', { runtimeBinding: binding }));
const resolvedWatchdogScript = path.resolve(watchdogScript ?? resolveScriptPath('watchdog', { runtimeBinding: binding }));
const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding }));
const resolvedSupervisorScript = path.resolve(supervisorScript ?? resolveScriptPath('bridgeSupervisor', { runtimeBinding: binding }));
const resolvedSenderBindingScript = path.resolve(senderCommand ? (binding?.scripts?.senderBinding ?? resolveScriptPath('senderBinding', { runtimeBinding: binding })) : resolveScriptPath('senderBinding', { runtimeBinding: binding }));
const resolvedQueueDir = queueDir
? path.resolve(queueDir)
@@ -59,26 +60,33 @@ export function runOrchestratorAdapter({
? path.resolve(receiptDir)
: null;
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 (resolvedQueueDir) args.push('--queue-dir', resolvedQueueDir);
if (resolvedSpoolDir) args.push('--spool-dir', resolvedSpoolDir);
if (resolvedReceiptDir) args.push('--receipt-dir', resolvedReceiptDir);
if (resolvedWatchdogScript) args.push('--watchdog-script', resolvedWatchdogScript);
if (resolvedDispatcherScript) args.push('--dispatcher-script', resolvedDispatcherScript);
if (resolvedSupervisorScript) args.push('--supervisor-script', resolvedSupervisorScript);
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 payload = runOrchestratorExecution({
state,
evidenceDir,
eventDir,
queueDir: resolvedQueueDir,
spoolDir: resolvedSpoolDir,
receiptDir: resolvedReceiptDir,
watchdogScript: resolvedWatchdogScript,
dispatcherScript: resolvedDispatcherScript,
supervisorScript: resolvedSupervisorScript,
senderCommand,
senderMode,
openclawBin,
now,
compact,
writeState,
claim,
dryRun,
}, {
senderBindingScript: resolvedSenderBindingScript,
});
const result = runNodeScript(resolvedScriptPath, args, { runtimeBinding: binding });
ensureSuccess('orchestrator adapter', result);
return parseJsonStdout('orchestrator adapter', result);
return {
...payload,
orchestration: {
script: resolvedOrchestratorScriptPath,
...payload.orchestration,
},
};
}

View File

@@ -42,8 +42,8 @@ export {
executeRuntimeIntegratedGovernance,
runCompatibilityPreflight,
} from './core/index.mjs';
export { createRuntimeBinding } from './adapters/runtime-binding.mjs';
export {
createRuntimeBinding,
runWatchdogAdapter,
runDispatcherAdapter,
runBridgeSupervisorAdapter,

View File

@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' };
import exampleDescriptor from '../examples/openclaw-watchdog-reference.descriptor.example.json' with { type: 'json' };
import { createDeploymentBindingContract } from '../src/storage/profile-artifact.mjs';
import profileArtifact from '../profiles/strict-manager-mode.profile.json' with { type: 'json' };
const packageRoot = path.resolve(import.meta.dirname, '..');
const repoRoot = path.resolve(packageRoot, '..', '..');
function resolvePackageEntrypoint(entrypoint) {
return path.resolve(packageRoot, entrypoint);
}
test('capability descriptor and example entrypoint resolve from package root to the package-owned orchestrator', () => {
const canonicalPath = path.resolve(packageRoot, 'scripts/watchdog_auto_notify_orchestrator.mjs');
const descriptorEntrypoint = resolvePackageEntrypoint(capabilityDescriptor.runtime.entrypoint);
const exampleEntrypoint = resolvePackageEntrypoint(exampleDescriptor.runtime.entrypoint);
assert.equal(capabilityDescriptor.runtime.entrypoint, 'scripts/watchdog_auto_notify_orchestrator.mjs');
assert.equal(exampleDescriptor.runtime.entrypoint, 'scripts/watchdog_auto_notify_orchestrator.mjs');
assert.equal(descriptorEntrypoint, canonicalPath);
assert.equal(exampleEntrypoint, canonicalPath);
assert.equal(fs.existsSync(canonicalPath), true);
});
test('deployment profile binding stays repo-root-relative while targeting the same package-owned orchestrator', () => {
const binding = createDeploymentBindingContract({ artifact: profileArtifact, repoRootOverride: repoRoot });
const canonicalPath = path.resolve(packageRoot, 'scripts/watchdog_auto_notify_orchestrator.mjs');
assert.equal(profileArtifact.spec.bindings.entrypoint, 'plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs');
assert.equal(binding.entrypoint, canonicalPath);
assert.equal(binding.scripts.orchestrator, canonicalPath);
});

View File

@@ -71,6 +71,7 @@ test('package root export resolves public package surface only', () => {
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
hasCreateRuntimeBinding: typeof plugin.createRuntimeBinding,
hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact,
hasCreateFileDecisionStore: typeof plugin.createFileDecisionStore,
}));
@@ -85,6 +86,7 @@ test('package root export resolves public package surface only', () => {
assert.equal(result.hasPlanDecisionExecution, 'function');
assert.equal(result.hasExecuteGovernanceContract, 'function');
assert.equal(result.hasExecuteRuntimeIntegratedGovernance, 'function');
assert.equal(result.hasCreateRuntimeBinding, 'function');
assert.equal(result.hasCreateDecisionRecordArtifact, 'function');
assert.equal(result.hasCreateFileDecisionStore, 'function');
} finally {
@@ -92,7 +94,7 @@ test('package root export resolves public package surface only', () => {
}
});
test('adapters subpath export resolves package-owned adapter index plus profile artifact loader helpers', () => {
test('adapters subpath export resolves package-owned adapter entrypoints only', () => {
const root = createFixtureRoot();
try {
installPackageAlias(root);
@@ -104,9 +106,6 @@ test('adapters subpath export resolves package-owned adapter index plus profile
`);
assert.deepEqual(result.adapterKeys, [
'createDeploymentBindingContract',
'createRuntimeBinding',
'loadDeploymentProfileArtifact',
'runBridgeSupervisorAdapter',
'runDispatcherAdapter',
'runOrchestratorAdapter',
@@ -118,6 +117,36 @@ test('adapters subpath export resolves package-owned adapter index plus profile
}
});
test('storage/profile-artifact deep subpaths stay outside package exports boundary', () => {
const root = createFixtureRoot();
try {
installPackageAlias(root);
const result = runNodeEval(root, `
import('@openclaw/plugin-reporting-governance/src/storage/profile-artifact.mjs')
.then(() => {
process.stdout.write(JSON.stringify({ ok: true }));
})
.catch((error) => {
process.stdout.write(JSON.stringify({
ok: false,
code: error?.code ?? null,
name: error?.name ?? null,
message: error?.message ?? null,
}));
});
`);
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse((result.stdout ?? '').trim());
assert.equal(payload.ok, false);
assert.equal(payload.code, 'ERR_PACKAGE_PATH_NOT_EXPORTED');
assert.equal(payload.name, 'Error');
assert.match(payload.message ?? '', /Package subpath '\.\/src\/storage\/profile-artifact\.mjs' is not defined by "exports"/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('deep runtime-binding subpath stays outside package exports boundary', () => {
const root = createFixtureRoot();
try {
@@ -169,6 +198,7 @@ test('leaf subpath export resolves and can execute through injected runtime bind
watchdog: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'long_task_watchdog.mjs'))},
dispatcher: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'))},
bridgeSupervisor: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs'))},
senderBinding: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_sender_binding.mjs'))},
},
},
state: ${JSON.stringify(statePath)},

View File

@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runOrchestratorExecution } from '../src/adapters/orchestrator-execution.mjs';
const packageRoot = path.resolve(import.meta.dirname, '..');
function createFixtureRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-orchestrator-core-'));
}
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;
}
test('execution core runs adapter chain directly without orchestrator script shell hop', () => {
const root = createFixtureRoot();
try {
mkdirs(root, ['evidence', 'events', 'queue', 'spool', 'receipts']);
const statePath = writeState(root);
const payload = runOrchestratorExecution({
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'),
watchdogScript: path.join(packageRoot, 'scripts', 'long_task_watchdog.mjs'),
dispatcherScript: path.join(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'),
supervisorScript: path.join(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs'),
senderCommand: `node -e "process.stdout.write(JSON.stringify({state:'sent'}))"`,
now: '2026-05-07T08:20:00.000Z',
writeState: true,
}, {
senderBindingScript: path.join(packageRoot, 'scripts', 'operator_notify_sender_binding.mjs'),
});
assert.equal(payload.ok, true);
assert.deepEqual(payload.executionOrder, ['runner', 'queue', 'dispatcher', 'bridge', 'sender', 'ack_or_blocked_or_pending']);
assert.equal(payload.orchestration.senderCommandConfigured, true);
assert.equal(payload.result.watchdog.notificationCount, 1);
assert.equal(payload.result.dispatcher.dispatchedCount, 1);
assert.equal(payload.result.supervisor.ackedCount, 1);
const queueFiles = fs.readdirSync(path.join(root, 'queue')).filter((name) => name.endsWith('.json'));
assert.equal(queueFiles.length, 1);
const queueItem = JSON.parse(fs.readFileSync(path.join(root, 'queue', queueFiles[0]), 'utf8'));
assert.equal(queueItem.status, 'acked');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

View File

@@ -10,7 +10,7 @@ import {
validateDeploymentProfileArtifact,
assertUseTimePathWithinRepoRoot,
} from '../src/storage/profile-artifact.mjs';
import { createRuntimeBinding } from '../src/adapters/index.mjs';
import { createRuntimeBinding } from '../src/adapters/runtime-binding.mjs';
const packageRoot = path.resolve(import.meta.dirname, '..');
const repoRoot = path.resolve(packageRoot, '..', '..');

View File

@@ -4,15 +4,28 @@ set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
CRON_FILE="$ROOT_DIR/state/cron/long-task-watchdog.cron"
LOG_DIR="$ROOT_DIR/state/long-task-watchdog"
RUNNER="$ROOT_DIR/scripts/long_task_watchdog.mjs"
ORCHESTRATOR="$ROOT_DIR/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs"
STATE_FILE="$ROOT_DIR/memory/watchdog-state.json"
WATCHDOG_EVIDENCE_DIR="$ROOT_DIR/state/long-task-watchdog"
WATCHDOG_EVENT_DIR="$ROOT_DIR/state/long-task-watchdog-events"
WATCHDOG_QUEUE_DIR="$ROOT_DIR/state/operator-notify-queue"
WATCHDOG_SPOOL_DIR="$ROOT_DIR/state/operator-notify-dispatch-spool"
WATCHDOG_RECEIPT_DIR="$ROOT_DIR/state/operator-notify-bridge-receipts"
PACKAGE_WATCHDOG_SCRIPT="$ROOT_DIR/plugins/reporting-governance/scripts/long_task_watchdog.mjs"
PACKAGE_DISPATCHER_SCRIPT="$ROOT_DIR/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs"
PACKAGE_SUPERVISOR_SCRIPT="$ROOT_DIR/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs"
PACKAGE_SENDER_BINDING_SCRIPT="$ROOT_DIR/plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs"
mkdir -p "$(dirname "$CRON_FILE")" "$LOG_DIR"
cat >"$CRON_FILE" <<EOF
*/10 * * * * cd "$ROOT_DIR" && /usr/bin/env node "$RUNNER" --write-state --state "$STATE_FILE" --evidence-dir "$LOG_DIR" >> "$LOG_DIR/cron.log" 2>&1
*/10 * * * * cd "$ROOT_DIR" && /usr/bin/env node "$ORCHESTRATOR" --write-state --state "$STATE_FILE" --evidence-dir "$WATCHDOG_EVIDENCE_DIR" --event-dir "$WATCHDOG_EVENT_DIR" --queue-dir "$WATCHDOG_QUEUE_DIR" --spool-dir "$WATCHDOG_SPOOL_DIR" --receipt-dir "$WATCHDOG_RECEIPT_DIR" --watchdog-script "$PACKAGE_WATCHDOG_SCRIPT" --dispatcher-script "$PACKAGE_DISPATCHER_SCRIPT" --supervisor-script "$PACKAGE_SUPERVISOR_SCRIPT" --sender-command "/usr/bin/env node \"$PACKAGE_SENDER_BINDING_SCRIPT\" --queue-item \"__QUEUE_ITEM_PATH__\"" --dry-run >> "$LOG_DIR/cron.log" 2>&1
EOF
printf 'Wrote cron snippet: %s\n' "$CRON_FILE"
printf 'Default mode is dry-run orchestration: runner -> dispatcher -> bridge, without pretending message.send was delivered.\n'
printf 'To install for current user, run:\n'
printf ' (crontab -l 2>/dev/null; cat "%s") | crontab -\n' "$CRON_FILE"
printf '\nTo enable real delivery after you have a trusted sender binding, replace --dry-run with either:\n'
printf ' --sender-mode openclaw-cli\n'
printf 'or an explicit --sender-command <shell>.\n'

View File

@@ -1,263 +1,3 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const DEFAULT_STATE_PATH = path.join(ROOT_DIR, 'memory', 'watchdog-state.json');
const DEFAULT_EVIDENCE_DIR = path.join(ROOT_DIR, 'state', 'long-task-watchdog');
function parseArgs(argv) {
const args = {
compact: false,
state: DEFAULT_STATE_PATH,
now: null,
evidenceDir: DEFAULT_EVIDENCE_DIR,
writeState: false,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === '--compact') {
args.compact = true;
continue;
}
if (token === '--write-state') {
args.writeState = true;
continue;
}
if (token === '--help' || token === '-h') {
args.help = true;
continue;
}
if (token === '--state') {
args.state = argv[i + 1] ?? args.state;
i += 1;
continue;
}
if (token.startsWith('--state=')) {
args.state = token.slice('--state='.length) || args.state;
continue;
}
if (token === '--now') {
args.now = argv[i + 1] ?? null;
i += 1;
continue;
}
if (token.startsWith('--now=')) {
args.now = token.slice('--now='.length) || null;
continue;
}
if (token === '--evidence-dir') {
args.evidenceDir = argv[i + 1] ?? args.evidenceDir;
i += 1;
continue;
}
if (token.startsWith('--evidence-dir=')) {
args.evidenceDir = token.slice('--evidence-dir='.length) || args.evidenceDir;
continue;
}
}
return args;
}
function printHelp() {
process.stdout.write([
'Usage: node scripts/long_task_watchdog.mjs [--compact] [--write-state] [--state <path>] [--now <iso>] [--evidence-dir <path>]',
'',
'Minimal file-backed long-task watchdog runner.',
].join('\n') + '\n');
}
function parseJsonFile(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
}
function parseTime(value) {
if (typeof value !== 'string' || value.length === 0) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function toIso(value) {
return new Date(value).toISOString();
}
function toSafeName(value) {
return String(value || 'watchdog')
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'watchdog';
}
function evaluateWatchdog(watchdog, nowMs) {
const intervalMinutes = Number.isFinite(watchdog?.intervalMinutes)
? watchdog.intervalMinutes
: Number.parseInt(String(watchdog?.intervalMinutes ?? '0'), 10);
const intervalMs = intervalMinutes > 0 ? intervalMinutes * 60 * 1000 : 0;
const milestoneMs = parseTime(watchdog?.lastMilestoneAt);
const lastAlertMs = parseTime(watchdog?.lastAlertAt);
const active = watchdog?.status === 'active';
if (!active) {
return {
id: watchdog?.id ?? null,
active: false,
overdue: false,
action: 'skip_inactive',
reason: 'watchdog is not active',
};
}
if (!intervalMs || milestoneMs === null) {
return {
id: watchdog?.id ?? null,
active: true,
overdue: false,
action: 'invalid_contract',
reason: 'intervalMinutes or lastMilestoneAt is missing/invalid',
};
}
const dueAtMs = milestoneMs + intervalMs;
const overdue = nowMs >= dueAtMs;
if (!overdue) {
return {
id: watchdog?.id ?? null,
active: true,
overdue: false,
action: 'within_interval',
reason: 'last milestone is still within interval',
dueAt: toIso(dueAtMs),
minutesOverdue: 0,
};
}
const lastAlertStillFresh = lastAlertMs !== null && lastAlertMs >= dueAtMs;
if (lastAlertStillFresh) {
return {
id: watchdog?.id ?? null,
active: true,
overdue: true,
action: 'already_alerted_this_interval',
reason: 'lastAlertAt already covers current overdue interval',
dueAt: toIso(dueAtMs),
minutesOverdue: Math.floor((nowMs - dueAtMs) / 60000),
};
}
return {
id: watchdog?.id ?? null,
active: true,
overdue: true,
action: 'emit_external_evidence',
reason: 'active watchdog is overdue and has not been externally evidenced for this interval',
dueAt: toIso(dueAtMs),
minutesOverdue: Math.floor((nowMs - dueAtMs) / 60000),
};
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function writeEvidence(evidenceDir, watchdog, evaluation, nowIso) {
ensureDir(evidenceDir);
const fileName = `${nowIso.replace(/[:]/g, '').replace(/\.\d{3}Z$/, 'Z')}-${toSafeName(watchdog.id)}.json`;
const filePath = path.join(evidenceDir, fileName);
const payload = {
generatedAt: nowIso,
tool: 'long_task_watchdog',
watchdog: {
id: watchdog.id,
task: watchdog.task,
ownerSession: watchdog.ownerSession ?? null,
ownerSessionKey: watchdog.ownerSessionKey ?? null,
reportChannel: watchdog.reportChannel ?? watchdog.channel ?? null,
reportTarget: watchdog.reportTarget ?? watchdog.target ?? null,
intervalMinutes: watchdog.intervalMinutes,
lastMilestoneAt: watchdog.lastMilestoneAt ?? null,
lastAlertAt: watchdog.lastAlertAt ?? null,
},
evaluation,
nextExpectedExternalAction: [
'nudge owner session',
'report owner-visible checkpoint',
'or respawn / inspect locally if owner appears stalled',
],
};
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return filePath;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
process.exit(0);
}
const nowMs = args.now ? parseTime(args.now) : Date.now();
if (nowMs === null) {
process.stderr.write('Invalid --now value\n');
process.exit(1);
}
const nowIso = toIso(nowMs);
const state = parseJsonFile(args.state);
const watchdogs = Array.isArray(state.watchdogs) ? state.watchdogs : [];
const evaluations = watchdogs.map((watchdog) => ({
watchdogId: watchdog?.id ?? null,
...evaluateWatchdog(watchdog, nowMs),
}));
const evidenceWrites = [];
const nextWatchdogs = watchdogs.map((watchdog, index) => {
const evaluation = evaluations[index];
if (evaluation.action !== 'emit_external_evidence') {
return watchdog;
}
const evidencePath = writeEvidence(args.evidenceDir, watchdog, evaluation, nowIso);
evidenceWrites.push({ watchdogId: watchdog.id, path: evidencePath });
return {
...watchdog,
lastAlertAt: nowIso,
lastObservedActivityAt: watchdog.lastObservedActivityAt ?? watchdog.lastMilestoneAt ?? null,
lastNudgeAt: watchdog.lastNudgeAt ?? null,
};
});
if (args.writeState) {
const nextState = {
...state,
watchdogs: nextWatchdogs,
};
fs.writeFileSync(args.state, `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
}
const response = {
ok: true,
tool: 'long_task_watchdog',
version: 'mvp-v1',
statePath: path.resolve(args.state),
evidenceDir: path.resolve(args.evidenceDir),
now: nowIso,
writeState: args.writeState,
result: {
activeCount: watchdogs.filter((item) => item?.status === 'active').length,
overdueCount: evaluations.filter((item) => item.overdue === true).length,
emittedCount: evidenceWrites.length,
evaluations,
evidenceWrites,
},
};
process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`);
}
main();
import '../plugins/reporting-governance/scripts/long_task_watchdog.mjs';

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import '../plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs';

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import '../plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs';

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import '../plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs';

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin"
cd "$ROOT"
/usr/bin/env node "$ROOT/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs" \
--write-state \
--state "$ROOT/memory/watchdog-state.json" \
--evidence-dir "$ROOT/state/long-task-watchdog" \
--event-dir "$ROOT/state/long-task-watchdog-events" \
--queue-dir "$ROOT/state/operator-notify-queue" \
--spool-dir "$ROOT/state/operator-notify-dispatch-spool" \
--receipt-dir "$ROOT/state/operator-notify-bridge-receipts" \
--watchdog-script "$ROOT/plugins/reporting-governance/scripts/long_task_watchdog.mjs" \
--dispatcher-script "$ROOT/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs" \
--supervisor-script "$ROOT/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs" \
--sender-mode openclaw-cli \
>> "$ROOT/state/long-task-watchdog/cron.log" 2>&1

View File

@@ -8,13 +8,18 @@ import process from 'node:process';
import { spawnSync } from 'node:child_process';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const WATCHDOG_SCRIPT = path.join(ROOT_DIR, 'scripts', 'long_task_watchdog.mjs');
const REPO_SHIM = path.join(ROOT_DIR, 'scripts', 'long_task_watchdog.mjs');
const PACKAGE_ENTRY = path.join(ROOT_DIR, 'plugins', 'reporting-governance', 'scripts', 'long_task_watchdog.mjs');
function createFixtureRunner() {
function createFixtureRunner(scriptPath) {
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'long-task-watchdog-test-'));
const statePath = path.join(fixtureRoot, 'watchdog-state.json');
const evidenceDir = path.join(fixtureRoot, 'evidence');
const eventDir = path.join(fixtureRoot, 'events');
const notificationDir = path.join(fixtureRoot, 'notifications');
mkdirSync(evidenceDir, { recursive: true });
mkdirSync(eventDir, { recursive: true });
mkdirSync(notificationDir, { recursive: true });
function writeState(content) {
const body = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
@@ -23,10 +28,21 @@ function createFixtureRunner() {
}
function run(args = []) {
const result = spawnSync(process.execPath, [WATCHDOG_SCRIPT, '--state', statePath, '--evidence-dir', evidenceDir, ...args], {
cwd: ROOT_DIR,
encoding: 'utf8',
});
const result = spawnSync(
process.execPath,
[
scriptPath,
'--state', statePath,
'--evidence-dir', evidenceDir,
'--event-dir', eventDir,
'--notification-dir', notificationDir,
...args,
],
{
cwd: ROOT_DIR,
encoding: 'utf8',
},
);
return {
status: result.status,
stdout: result.stdout ?? '',
@@ -42,11 +58,36 @@ function createFixtureRunner() {
return readdirSync(evidenceDir).sort();
}
function listEvents() {
return readdirSync(eventDir).sort();
}
function listNotifications() {
return readdirSync(notificationDir).sort();
}
function readJson(dirPath, fileName) {
return JSON.parse(readFileSync(path.join(dirPath, fileName), 'utf8'));
}
function cleanup() {
rmSync(fixtureRoot, { recursive: true, force: true });
}
return { statePath, evidenceDir, writeState, run, readState, listEvidence, cleanup };
return {
statePath,
evidenceDir,
eventDir,
notificationDir,
writeState,
run,
readState,
listEvidence,
listEvents,
listNotifications,
readJson,
cleanup,
};
}
const tests = [];
@@ -56,8 +97,59 @@ function printResult(prefix, name, detail = '') {
process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`);
}
test('inactive watchdogs do not emit evidence', () => {
const runner = createFixtureRunner();
function normalizePayload(payload) {
return {
...payload,
statePath: '<state>',
evidenceDir: '<evidence>',
eventDir: '<events>',
notificationDir: '<notifications>',
result: {
...payload.result,
evidenceWrites: payload.result.evidenceWrites.map((item) => ({ ...item, path: '<evidence>' })),
eventWrites: payload.result.eventWrites.map((item) => ({ ...item, path: '<event>', eventId: '<event-id>' })),
notificationWrites: payload.result.notificationWrites.map((item) => ({ ...item, path: '<notification>', notificationId: '<notification-id>' })),
},
};
}
test('repo shim matches package entrypoint for same overdue watchdog payload', () => {
const shim = createFixtureRunner(REPO_SHIM);
const pkg = createFixtureRunner(PACKAGE_ENTRY);
const state = {
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,
},
],
};
try {
shim.writeState(state);
pkg.writeState(state);
const shimResult = shim.run(['--compact', '--write-state', '--now', '2026-05-07T08:20:00.000Z']);
const pkgResult = pkg.run(['--compact', '--write-state', '--now', '2026-05-07T08:20:00.000Z']);
assert.equal(shimResult.status, 0, shimResult.stderr);
assert.equal(pkgResult.status, 0, pkgResult.stderr);
assert.deepEqual(normalizePayload(JSON.parse(shimResult.stdout)), normalizePayload(JSON.parse(pkgResult.stdout)));
assert.deepEqual(shim.readState(), pkg.readState());
} finally {
shim.cleanup();
pkg.cleanup();
}
});
test('inactive watchdogs do not emit evidence, event, or notification queue items', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeState({
version: 1,
@@ -76,14 +168,18 @@ test('inactive watchdogs do not emit evidence', () => {
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.emittedCount, 0);
assert.equal(payload.result.eventCount, 0);
assert.equal(payload.result.notificationCount, 0);
assert.deepEqual(runner.listEvidence(), []);
assert.deepEqual(runner.listEvents(), []);
assert.deepEqual(runner.listNotifications(), []);
} finally {
runner.cleanup();
}
});
test('overdue active watchdog emits external evidence and updates lastAlertAt when write-state is enabled', () => {
const runner = createFixtureRunner();
test('overdue active watchdog emits evidence, canonical event, notification queue item, and updates lastAlertAt', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeState({
version: 1,
@@ -106,18 +202,39 @@ test('overdue active watchdog emits external evidence and updates lastAlertAt wh
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.emittedCount, 1);
assert.equal(payload.result.eventCount, 1);
assert.equal(payload.result.notificationCount, 1);
const evidenceFiles = runner.listEvidence();
const eventFiles = runner.listEvents();
const notificationFiles = runner.listNotifications();
assert.equal(evidenceFiles.length, 1);
assert.equal(eventFiles.length, 1);
assert.equal(notificationFiles.length, 1);
const eventPayload = runner.readJson(runner.eventDir, eventFiles[0]);
assert.equal(eventPayload.event_type, 'watchdog_fired');
assert.equal(eventPayload.operator_context.channel, 'telegram');
assert.equal(eventPayload.operator_context.operator_id, '864811879');
const notificationPayload = runner.readJson(runner.notificationDir, notificationFiles[0]);
assert.equal(notificationPayload.kind, 'notify_operator');
assert.equal(notificationPayload.status, 'pending');
assert.equal(notificationPayload.operator_notice.channel, 'telegram');
assert.equal(notificationPayload.operator_notice.target, '864811879');
assert.equal(notificationPayload.blocked_gap, null);
assert.equal(notificationPayload.dispatch_hint.tool, 'message.send');
const nextState = runner.readState();
assert.equal(nextState.watchdogs[0].lastAlertAt, '2026-05-07T08:20:00.000Z');
assert.equal(nextState.watchdogs[0].lastNudgeAt, '2026-05-07T08:20:00.000Z');
} finally {
runner.cleanup();
}
});
test('same interval is not alerted twice once lastAlertAt covers the overdue window', () => {
const runner = createFixtureRunner();
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeState({
version: 1,
@@ -137,7 +254,43 @@ test('same interval is not alerted twice once lastAlertAt covers the overdue win
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.emittedCount, 0);
assert.equal(payload.result.eventCount, 0);
assert.equal(payload.result.notificationCount, 0);
assert.deepEqual(runner.listEvidence(), []);
assert.deepEqual(runner.listEvents(), []);
assert.deepEqual(runner.listNotifications(), []);
} finally {
runner.cleanup();
}
});
test('notification queue item records blocked gap when report channel/target is missing', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeState({
version: 1,
watchdogs: [
{
id: 'missing-target-watchdog',
task: 'targetless task',
status: 'active',
ownerSessionKey: 'agent:coder:main',
intervalMinutes: 10,
lastMilestoneAt: '2026-05-07T08:00:00.000Z',
lastAlertAt: null,
},
],
});
const result = runner.run(['--compact', '--now', '2026-05-07T08:20:00.000Z']);
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.notificationCount, 1);
assert.equal(payload.result.notificationWrites[0].dispatchReady, false);
const notificationFiles = runner.listNotifications();
const notificationPayload = runner.readJson(runner.notificationDir, notificationFiles[0]);
assert.match(notificationPayload.blocked_gap, /reportChannel\/reportTarget/);
} finally {
runner.cleanup();
}

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const REPO_SHIM = path.join(ROOT_DIR, 'scripts', 'operator_notify_bridge_supervisor.mjs');
const PACKAGE_ENTRY = path.join(ROOT_DIR, 'plugins', 'reporting-governance', 'scripts', 'operator_notify_bridge_supervisor.mjs');
function createFixture() {
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'operator-notify-bridge-supervisor-shim-regression-'));
const queueDir = path.join(fixtureRoot, 'queue');
const spoolDir = path.join(fixtureRoot, 'spool');
const receiptDir = path.join(fixtureRoot, 'receipts');
const attemptDir = path.join(fixtureRoot, 'attempts');
[queueDir, spoolDir, receiptDir, attemptDir].forEach((dir) => mkdirSync(dir, { recursive: true }));
const queuePath = path.join(queueDir, 'queue-item.json');
writeFileSync(queuePath, `${JSON.stringify({
notification_id: 'notify-supervisor-shim-regression-1',
status: 'dispatched',
dispatch_result: { state: 'dispatched', mode: 'spool_only' },
}, null, 2)}\n`, 'utf8');
const spoolPath = path.join(spoolDir, 'notify-supervisor-shim-regression-1-dispatch.json');
writeFileSync(spoolPath, `${JSON.stringify({
notification_id: 'notify-supervisor-shim-regression-1',
queue_item_path: queuePath,
dispatch_contract: {
executor: 'message.send',
channel: 'telegram',
target: '864811879',
message: 'watchdog overdue',
},
}, null, 2)}\n`, 'utf8');
return { fixtureRoot, queueDir, spoolDir, receiptDir, attemptDir };
}
function run(script, args = []) {
const result = spawnSync(process.execPath, [script, ...args], {
cwd: ROOT_DIR,
encoding: 'utf8',
});
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function printResult(prefix, name, detail = '') { process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`); }
function scrub(value) {
if (Array.isArray(value)) return value.map(scrub);
if (!value || typeof value !== 'object') return value;
const next = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === 'string') {
if (key.toLowerCase().includes('path') || key.toLowerCase().endsWith('dir')) {
next[key] = '<path>';
continue;
}
if (key === 'notificationId' || key === 'notification_id') {
next[key] = '<notification-id>';
continue;
}
if (key === 'created_at' || key === 'now') {
next[key] = '<iso>';
continue;
}
}
next[key] = scrub(raw);
}
return next;
}
function normalizeStdout(stdout) {
return scrub(JSON.parse(stdout));
}
function buildArgs(fixture) {
return [
'--queue-dir', fixture.queueDir,
'--spool-dir', fixture.spoolDir,
'--receipt-dir', fixture.receiptDir,
'--dispatcher-script', path.join(ROOT_DIR, 'scripts', 'operator_notify_dispatcher.mjs'),
'--sender-command', `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(ROOT_DIR, 'scripts', 'operator_notify_sender_binding.mjs'))} --mode shim --attempt-dir ${JSON.stringify(fixture.attemptDir)} --compact`,
'--now', '2026-05-07T10:02:00.000Z',
'--compact',
];
}
test('repo-root shim forwards help text and exits like package entrypoint', () => {
const shim = run(REPO_SHIM, ['--help']);
const pkg = run(PACKAGE_ENTRY, ['--help']);
assert.equal(shim.status, 0);
assert.equal(pkg.status, 0);
assert.equal(shim.stderr, '');
assert.equal(pkg.stderr, '');
assert.equal(shim.stdout, pkg.stdout);
});
test('repo-root shim forwards args and preserves success payload semantics', () => {
const shimFixture = createFixture();
const pkgFixture = createFixture();
try {
const shim = run(REPO_SHIM, buildArgs(shimFixture));
const pkg = run(PACKAGE_ENTRY, buildArgs(pkgFixture));
assert.equal(shim.status, 0, shim.stderr);
assert.equal(pkg.status, 0, pkg.stderr);
assert.deepEqual(normalizeStdout(shim.stdout), normalizeStdout(pkg.stdout));
} finally {
rmSync(shimFixture.fixtureRoot, { recursive: true, force: true });
rmSync(pkgFixture.fixtureRoot, { recursive: true, force: true });
}
});
test('repo-root shim preserves non-zero exit semantics from package core', () => {
const shim = run(REPO_SHIM, ['--now', 'not-an-iso']);
const pkg = run(PACKAGE_ENTRY, ['--now', 'not-an-iso']);
assert.equal(shim.status, 1);
assert.equal(pkg.status, 1);
assert.equal(shim.stdout, '');
assert.equal(pkg.stdout, '');
assert.equal(shim.stderr, pkg.stderr);
});
let failures = 0;
for (const { name, fn } of tests) {
try {
fn();
printResult('ok', name);
} catch (error) {
failures += 1;
printResult('not ok', name, `- ${error instanceof Error ? error.message : String(error)}`);
}
}
if (failures > 0) {
process.exit(1);
}

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const REPO_SHIM = path.join(ROOT_DIR, 'scripts', 'operator_notify_dispatcher.mjs');
const PACKAGE_ENTRY = path.join(ROOT_DIR, 'plugins', 'reporting-governance', 'scripts', 'operator_notify_dispatcher.mjs');
function createFixtureRunner(scriptPath) {
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'operator-notify-dispatcher-test-'));
const queueDir = path.join(fixtureRoot, 'queue');
const spoolDir = path.join(fixtureRoot, 'spool');
mkdirSync(queueDir, { recursive: true });
mkdirSync(spoolDir, { recursive: true });
function writeQueueItem(fileName, payload) {
const filePath = path.join(queueDir, fileName);
writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return filePath;
}
function run(args = []) {
const result = spawnSync(
process.execPath,
[scriptPath, '--queue-dir', queueDir, '--spool-dir', spoolDir, ...args],
{ cwd: ROOT_DIR, encoding: 'utf8' },
);
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function readQueueItem(fileName) {
return JSON.parse(readFileSync(path.join(queueDir, fileName), 'utf8'));
}
function readSpoolItem(fileName) {
return JSON.parse(readFileSync(path.join(spoolDir, fileName), 'utf8'));
}
function listSpoolFiles() {
return readdirSync(spoolDir).sort();
}
function cleanup() {
rmSync(fixtureRoot, { recursive: true, force: true });
}
return {
queueDir,
spoolDir,
writeQueueItem,
run,
readQueueItem,
readSpoolItem,
listSpoolFiles,
cleanup,
};
}
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function printResult(prefix, name, detail = '') {
process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`);
}
function normalizePayload(payload) {
return {
...payload,
queueDir: '<queue>',
spoolDir: '<spool>',
result: {
...payload.result,
queueScanned: payload.result.queueScanned.map((item) => ({ ...item, path: '<queue-item>' })),
claimed: payload.result.claimed.map((item) => ({ ...item, path: '<queue-item>' })),
blocked: payload.result.blocked.map((item) => ({ ...item, path: '<queue-item>' })),
dispatched: payload.result.dispatched.map((item) => ({ ...item, path: '<queue-item>', spoolPath: '<spool-item>' })),
skipped: payload.result.skipped.map((item) => ({ ...item, path: '<queue-item>' })),
},
};
}
function normalizeQueueItem(payload) {
return {
...payload,
dispatch_result: payload?.dispatch_result
? {
...payload.dispatch_result,
spoolPath: payload.dispatch_result.spoolPath ? '<spool-item>' : payload.dispatch_result.spoolPath,
}
: payload.dispatch_result,
};
}
test('repo shim matches package entrypoint for same ready queue item', () => {
const shim = createFixtureRunner(REPO_SHIM);
const pkg = createFixtureRunner(PACKAGE_ENTRY);
const payload = {
notification_id: 'notify-ready-1',
kind: 'notify_operator',
status: 'pending',
operator_notice: {
channel: 'telegram',
target: '864811879',
message: 'watchdog overdue',
},
dispatch_hint: {
tool: 'message.send',
channel: 'telegram',
target: '864811879',
message: 'watchdog overdue',
},
governance: {
task_id: 'watchdog-1',
},
evidence_refs: [],
blocked_gap: null,
};
try {
shim.writeQueueItem('ready.json', payload);
pkg.writeQueueItem('ready.json', payload);
const shimResult = shim.run(['--compact', '--now', '2026-05-07T09:00:00.000Z']);
const pkgResult = pkg.run(['--compact', '--now', '2026-05-07T09:00:00.000Z']);
assert.equal(shimResult.status, 0, shimResult.stderr);
assert.equal(pkgResult.status, 0, pkgResult.stderr);
assert.deepEqual(normalizePayload(JSON.parse(shimResult.stdout)), normalizePayload(JSON.parse(pkgResult.stdout)));
assert.deepEqual(normalizeQueueItem(shim.readQueueItem('ready.json')), normalizeQueueItem(pkg.readQueueItem('ready.json')));
} finally {
shim.cleanup();
pkg.cleanup();
}
});
test('dispatches pending ready queue item into spool and marks queue item dispatched', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeQueueItem('ready.json', {
notification_id: 'notify-ready-1',
kind: 'notify_operator',
status: 'pending',
operator_notice: {
channel: 'telegram',
target: '864811879',
message: 'watchdog overdue',
},
dispatch_hint: {
tool: 'message.send',
channel: 'telegram',
target: '864811879',
message: 'watchdog overdue',
},
governance: {
task_id: 'watchdog-1',
},
evidence_refs: [],
blocked_gap: null,
});
const result = runner.run(['--compact', '--now', '2026-05-07T09:00:00.000Z']);
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.dispatchedCount, 1);
assert.equal(payload.result.blockedCount, 0);
const queueItem = runner.readQueueItem('ready.json');
assert.equal(queueItem.status, 'dispatched');
assert.equal(queueItem.dispatch_result.mode, 'spool_only');
assert.equal(queueItem.dispatch_result.delivery, 'handoff_pending_ack');
const spoolFiles = runner.listSpoolFiles();
assert.equal(spoolFiles.length, 1);
const spoolItem = runner.readSpoolItem(spoolFiles[0]);
assert.equal(spoolItem.dispatch_contract.executor, 'message.send');
assert.equal(spoolItem.dispatch_contract.channel, 'telegram');
assert.equal(spoolItem.dispatch_contract.target, '864811879');
} finally {
runner.cleanup();
}
});
test('marks incomplete pending queue item blocked instead of pretending it was dispatched', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
runner.writeQueueItem('blocked.json', {
notification_id: 'notify-blocked-1',
kind: 'notify_operator',
status: 'pending',
operator_notice: {
channel: 'telegram',
target: null,
message: 'watchdog overdue',
},
dispatch_hint: {
tool: 'message.send',
channel: 'telegram',
target: null,
message: 'watchdog overdue',
},
blocked_gap: 'watchdog state does not define reportChannel/reportTarget, so dispatcher target is incomplete',
});
const result = runner.run(['--compact', '--now', '2026-05-07T09:00:00.000Z']);
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.dispatchedCount, 0);
assert.equal(payload.result.blockedCount, 1);
assert.deepEqual(runner.listSpoolFiles(), []);
const queueItem = runner.readQueueItem('blocked.json');
assert.equal(queueItem.status, 'blocked');
assert.match(queueItem.blocked_gap, /reportChannel\/reportTarget|channel\/target\/message/);
} finally {
runner.cleanup();
}
});
test('ack command marks dispatched queue item acked with note', () => {
const runner = createFixtureRunner(PACKAGE_ENTRY);
try {
const queuePath = runner.writeQueueItem('acked.json', {
notification_id: 'notify-ack-1',
kind: 'notify_operator',
status: 'dispatched',
dispatch_result: {
mode: 'spool_only',
state: 'dispatched',
},
});
const result = runner.run(['--compact', '--ack', queuePath, '--note', 'message.send delivered by upper runtime', '--now', '2026-05-07T09:05:00.000Z']);
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.result.nextStatus, 'acked');
const queueItem = runner.readQueueItem('acked.json');
assert.equal(queueItem.status, 'acked');
assert.equal(queueItem.ack_note, 'message.send delivered by upper runtime');
assert.equal(queueItem.dispatch_result.state, 'acked');
} finally {
runner.cleanup();
}
});
let failures = 0;
for (const { name, fn } of tests) {
try {
fn();
printResult('ok', name);
} catch (error) {
failures += 1;
printResult('not ok', name, `- ${error instanceof Error ? error.message : String(error)}`);
}
}
if (failures > 0) {
process.exit(1);
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const REPO_SHIM = path.join(ROOT_DIR, 'scripts', 'operator_notify_sender_binding.mjs');
const PACKAGE_ENTRY = path.join(ROOT_DIR, 'plugins', 'reporting-governance', 'scripts', 'operator_notify_sender_binding.mjs');
function run(script, args = [], env = {}) {
const result = spawnSync(process.execPath, [script, ...args], {
cwd: ROOT_DIR,
encoding: 'utf8',
env: { ...process.env, ...env },
});
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function createFixture() {
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'operator-notify-sender-binding-shim-regression-'));
const attemptDir = path.join(fixtureRoot, 'attempts');
mkdirSync(attemptDir, { recursive: true });
return { fixtureRoot, attemptDir };
}
function normalizeJson(text) {
return JSON.parse(text);
}
function normalizeAttempt(filePath) {
const json = JSON.parse(readFileSync(filePath, 'utf8'));
json.contract.spoolPath = '<path>';
json.contract.queueItemPath = '<path>';
return json;
}
const contractEnv = {
OPERATOR_NOTIFY_SPOOL_PATH: '/tmp/spool-item.json',
OPERATOR_NOTIFY_QUEUE_ITEM_PATH: '/tmp/queue-item.json',
OPERATOR_NOTIFY_NOTIFICATION_ID: 'notify-sender-binding-shim-regression-1',
OPERATOR_NOTIFY_CHANNEL: 'telegram',
OPERATOR_NOTIFY_TARGET: '864811879',
OPERATOR_NOTIFY_MESSAGE: 'watchdog overdue',
OPERATOR_NOTIFY_QUEUE_DIR: '/tmp/queue',
OPERATOR_NOTIFY_NOW: '2026-05-08T12:34:56.000Z',
};
const tests = [];
function test(name, fn) { tests.push({ name, fn }); }
function printResult(prefix, name, detail = '') { process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`); }
test('repo-root shim forwards help text and exits like package entrypoint', () => {
const shim = run(REPO_SHIM, ['--help']);
const pkg = run(PACKAGE_ENTRY, ['--help']);
assert.equal(shim.status, 0);
assert.equal(pkg.status, 0);
assert.equal(shim.stderr, '');
assert.equal(pkg.stderr, '');
assert.equal(shim.stdout, pkg.stdout);
});
test('repo-root shim forwards args and preserves shim-mode payload semantics', () => {
const shimFixture = createFixture();
const pkgFixture = createFixture();
try {
const argsA = ['--mode', 'shim', '--attempt-dir', shimFixture.attemptDir, '--now', '2026-05-08T12:34:56.000Z', '--compact'];
const argsB = ['--mode', 'shim', '--attempt-dir', pkgFixture.attemptDir, '--now', '2026-05-08T12:34:56.000Z', '--compact'];
const shim = run(REPO_SHIM, argsA, contractEnv);
const pkg = run(PACKAGE_ENTRY, argsB, contractEnv);
assert.equal(shim.status, 0, shim.stderr);
assert.equal(pkg.status, 0, pkg.stderr);
const shimOut = normalizeJson(shim.stdout);
const pkgOut = normalizeJson(pkg.stdout);
assert.deepEqual({ ...shimOut, attemptPath: '<path>' }, { ...pkgOut, attemptPath: '<path>' });
assert.deepEqual(normalizeAttempt(shimOut.attemptPath), normalizeAttempt(pkgOut.attemptPath));
} finally {
rmSync(shimFixture.fixtureRoot, { recursive: true, force: true });
rmSync(pkgFixture.fixtureRoot, { recursive: true, force: true });
}
});
test('repo-root shim preserves non-zero exit semantics from package core', () => {
const shim = run(REPO_SHIM, ['--now', 'not-an-iso']);
const pkg = run(PACKAGE_ENTRY, ['--now', 'not-an-iso']);
assert.equal(shim.status, 1);
assert.equal(pkg.status, 1);
assert.equal(shim.stdout, '');
assert.equal(pkg.stdout, '');
assert.equal(shim.stderr, pkg.stderr);
});
let failures = 0;
for (const { name, fn } of tests) {
try {
fn();
printResult('ok', name);
} catch (error) {
failures += 1;
printResult('not ok', name, `- ${error instanceof Error ? error.message : String(error)}`);
}
}
if (failures > 0) {
process.exit(1);
}

View File

@@ -1 +1 @@
*/10 * * * * cd "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin" && /usr/bin/env node "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/scripts/long_task_watchdog.mjs" --write-state --state "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/memory/watchdog-state.json" --evidence-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/long-task-watchdog" >> "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/long-task-watchdog/cron.log" 2>&1
*/10 * * * * cd "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin" && /usr/bin/env node "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs" --write-state --state "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/memory/watchdog-state.json" --evidence-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/long-task-watchdog" --event-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/long-task-watchdog-events" --queue-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/operator-notify-queue" --spool-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/operator-notify-dispatch-spool" --receipt-dir "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/operator-notify-bridge-receipts" --watchdog-script "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/plugins/reporting-governance/scripts/long_task_watchdog.mjs" --dispatcher-script "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs" --supervisor-script "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs" --sender-command "/usr/bin/env node \"/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs\" --queue-item \"__QUEUE_ITEM_PATH__\"" --dry-run >> "/home/alice/.openclaw/workspace/.worktrees/reporting-governance-plugin/state/long-task-watchdog/cron.log" 2>&1