Compare commits
10 Commits
72397df976
...
bdd33bd42d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdd33bd42d | ||
|
|
6f2ea9b3b3 | ||
|
|
1e81db04cb | ||
|
|
626786b670 | ||
|
|
5f0b77d8d9 | ||
|
|
61a32b0857 | ||
|
|
6af759eec4 | ||
|
|
c3b95ba3b7 | ||
|
|
9c6afad864 | ||
|
|
55fe51483b |
@@ -64,6 +64,9 @@ watchdog -> queue -> dispatcher -> bridge -> sender binding -> acked|blocked|pen
|
|||||||
This is not just repo glue anymore.
|
This is not just repo glue anymore.
|
||||||
It is the first real reference runtime composition that the package architecture is being shaped around.
|
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
|
## 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”.
|
Architecture is also now advanced one notch from “profiles are external YAML docs” toward “profiles are package artifacts with a loader boundary”.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Scope
|
||||||
|
|
||||||
This document defines:
|
This document defines:
|
||||||
|
|||||||
412
docs/specs/reporting-governance-capability-descriptor.md
Normal file
412
docs/specs/reporting-governance-capability-descriptor.md
Normal 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
|
||||||
@@ -312,6 +312,11 @@ Examples:
|
|||||||
|
|
||||||
The completed watchdog chain is now treated as the first reference deployment composition.
|
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
|
### Reference composition
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -328,16 +333,16 @@ watchdog runner
|
|||||||
|
|
||||||
| Runtime piece | Deployment role |
|
| 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/*.json` | portable runtime-artifact evidence |
|
||||||
| `state/long-task-watchdog-events/*.json` | portable canonical event artifacts |
|
| `state/long-task-watchdog-events/*.json` | portable canonical event artifacts |
|
||||||
| `state/operator-notify-queue/*.json` | queue-layer audit artifacts and deferred notice obligations |
|
| `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 |
|
| `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 |
|
| package `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_sender_binding.mjs` | sender-binding adapter selectable by deployment profile |
|
||||||
| `state/operator-notify-bridge-receipts/*.json` | portable delivery-state receipts |
|
| `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 |
|
| cron installer / wrapper | runtime binding for scheduled execution |
|
||||||
|
|
||||||
### Mainline conclusion
|
### Mainline conclusion
|
||||||
|
|||||||
108
docs/specs/reporting-governance-package-owned-runtime-story.md
Normal file
108
docs/specs/reporting-governance-package-owned-runtime-story.md
Normal 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.
|
||||||
@@ -91,6 +91,10 @@ Current **public package surface** is intentionally narrow:
|
|||||||
- `@openclaw/plugin-reporting-governance/adapters/sender-binding`
|
- `@openclaw/plugin-reporting-governance/adapters/sender-binding`
|
||||||
- `@openclaw/plugin-reporting-governance/adapters/orchestrator`
|
- `@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:
|
What is currently exposed from the root export:
|
||||||
|
|
||||||
- `evaluatePolicyPack(...)`
|
- `evaluatePolicyPack(...)`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"src/"
|
"src/"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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"
|
"smoke": "node ./scripts/package-smoke.mjs --compact"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -68,8 +68,16 @@ function main() {
|
|||||||
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
const result = runOrchestratorAdapter({
|
const result = runOrchestratorAdapter({
|
||||||
profileArtifact: artifact,
|
runtimeBinding: {
|
||||||
repoRootOverride: packageRoot,
|
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,
|
state,
|
||||||
evidenceDir: path.join(workspace, 'evidence'),
|
evidenceDir: path.join(workspace, 'evidence'),
|
||||||
eventDir: path.join(workspace, 'events'),
|
eventDir: path.join(workspace, 'events'),
|
||||||
|
|||||||
@@ -3,7 +3,3 @@ export { runDispatcherAdapter } from './dispatcher.mjs';
|
|||||||
export { runBridgeSupervisorAdapter } from './bridge-supervisor.mjs';
|
export { runBridgeSupervisorAdapter } from './bridge-supervisor.mjs';
|
||||||
export { runSenderBindingAdapter } from './sender-binding.mjs';
|
export { runSenderBindingAdapter } from './sender-binding.mjs';
|
||||||
export { runOrchestratorAdapter } from './orchestrator.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';
|
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 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_SENDER_BINDING_SCRIPT = path.join(packageRoot, 'scripts', 'operator_notify_sender_binding.mjs');
|
||||||
|
const DEFAULT_ARGS = createDefaultOrchestratorExecutionArgs({ packageRoot });
|
||||||
|
|
||||||
export function parseOrchestratorCliArgs(argv) {
|
export function parseOrchestratorCliArgs(argv) {
|
||||||
const args = {
|
const args = { ...DEFAULT_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,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const token = argv[i];
|
const token = argv[i];
|
||||||
@@ -97,105 +74,13 @@ export function printOrchestratorHelp(options = {}) {
|
|||||||
process.stdout.write(`${formatOrchestratorHelp(options)}\n`);
|
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) {
|
export function runWatchdogAutoNotifyOrchestrator(args) {
|
||||||
const senderCommand = buildSenderCommand(args);
|
const payload = runOrchestratorExecution(args, { senderBindingScript: DEFAULT_SENDER_BINDING_SCRIPT });
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
...payload,
|
||||||
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: {
|
orchestration: {
|
||||||
script: path.resolve(import.meta.filename),
|
script: path.resolve(import.meta.filename),
|
||||||
senderCommandConfigured: Boolean(senderCommand),
|
...payload.orchestration,
|
||||||
senderMode: args.senderMode ?? null,
|
|
||||||
dryRun: args.dryRun,
|
|
||||||
},
|
|
||||||
result: {
|
|
||||||
watchdog: watchdogPayload?.result ?? null,
|
|
||||||
dispatcher: dispatcherPayload?.result ?? null,
|
|
||||||
supervisor: supervisorPayload?.result ?? null,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -222,4 +107,4 @@ export function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { packageRoot };
|
export { buildSenderCommand, packageRoot };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureSuccess, parseJsonStdout, runNodeScript } from './_script-runner.mjs';
|
|
||||||
import { createRuntimeBinding, resolveScriptPath } from './runtime-binding.mjs';
|
import { createRuntimeBinding, resolveScriptPath } from './runtime-binding.mjs';
|
||||||
import { loadDeploymentProfileArtifact, createDeploymentBindingContract, assertUseTimePathWithinRepoRoot } from '../storage/profile-artifact.mjs';
|
import { loadDeploymentProfileArtifact, createDeploymentBindingContract, assertUseTimePathWithinRepoRoot } from '../storage/profile-artifact.mjs';
|
||||||
|
import { runOrchestratorExecution } from './orchestrator-execution.mjs';
|
||||||
|
|
||||||
export function runOrchestratorAdapter({
|
export function runOrchestratorAdapter({
|
||||||
scriptPath = null,
|
scriptPath = null,
|
||||||
@@ -42,10 +42,11 @@ export function runOrchestratorAdapter({
|
|||||||
cwd: repoRootOverride,
|
cwd: repoRootOverride,
|
||||||
scripts: resolvedDeploymentBinding?.scripts,
|
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 resolvedWatchdogScript = path.resolve(watchdogScript ?? resolveScriptPath('watchdog', { runtimeBinding: binding }));
|
||||||
const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding }));
|
const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding }));
|
||||||
const resolvedSupervisorScript = path.resolve(supervisorScript ?? resolveScriptPath('bridgeSupervisor', { 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
|
const resolvedQueueDir = queueDir
|
||||||
? path.resolve(queueDir)
|
? path.resolve(queueDir)
|
||||||
@@ -59,26 +60,33 @@ export function runOrchestratorAdapter({
|
|||||||
? path.resolve(receiptDir)
|
? path.resolve(receiptDir)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const args = [];
|
const payload = runOrchestratorExecution({
|
||||||
if (state) args.push('--state', path.resolve(state));
|
state,
|
||||||
if (evidenceDir) args.push('--evidence-dir', path.resolve(evidenceDir));
|
evidenceDir,
|
||||||
if (eventDir) args.push('--event-dir', path.resolve(eventDir));
|
eventDir,
|
||||||
if (resolvedQueueDir) args.push('--queue-dir', resolvedQueueDir);
|
queueDir: resolvedQueueDir,
|
||||||
if (resolvedSpoolDir) args.push('--spool-dir', resolvedSpoolDir);
|
spoolDir: resolvedSpoolDir,
|
||||||
if (resolvedReceiptDir) args.push('--receipt-dir', resolvedReceiptDir);
|
receiptDir: resolvedReceiptDir,
|
||||||
if (resolvedWatchdogScript) args.push('--watchdog-script', resolvedWatchdogScript);
|
watchdogScript: resolvedWatchdogScript,
|
||||||
if (resolvedDispatcherScript) args.push('--dispatcher-script', resolvedDispatcherScript);
|
dispatcherScript: resolvedDispatcherScript,
|
||||||
if (resolvedSupervisorScript) args.push('--supervisor-script', resolvedSupervisorScript);
|
supervisorScript: resolvedSupervisorScript,
|
||||||
if (senderCommand) args.push('--sender-command', senderCommand);
|
senderCommand,
|
||||||
if (senderMode) args.push('--sender-mode', senderMode);
|
senderMode,
|
||||||
if (openclawBin) args.push('--openclaw-bin', openclawBin);
|
openclawBin,
|
||||||
if (now) args.push('--now', now);
|
now,
|
||||||
if (writeState) args.push('--write-state');
|
compact,
|
||||||
if (claim) args.push('--claim');
|
writeState,
|
||||||
if (dryRun) args.push('--dry-run');
|
claim,
|
||||||
if (compact) args.push('--compact');
|
dryRun,
|
||||||
|
}, {
|
||||||
|
senderBindingScript: resolvedSenderBindingScript,
|
||||||
|
});
|
||||||
|
|
||||||
const result = runNodeScript(resolvedScriptPath, args, { runtimeBinding: binding });
|
return {
|
||||||
ensureSuccess('orchestrator adapter', result);
|
...payload,
|
||||||
return parseJsonStdout('orchestrator adapter', result);
|
orchestration: {
|
||||||
|
script: resolvedOrchestratorScriptPath,
|
||||||
|
...payload.orchestration,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export {
|
|||||||
executeRuntimeIntegratedGovernance,
|
executeRuntimeIntegratedGovernance,
|
||||||
runCompatibilityPreflight,
|
runCompatibilityPreflight,
|
||||||
} from './core/index.mjs';
|
} from './core/index.mjs';
|
||||||
|
export { createRuntimeBinding } from './adapters/runtime-binding.mjs';
|
||||||
export {
|
export {
|
||||||
createRuntimeBinding,
|
|
||||||
runWatchdogAdapter,
|
runWatchdogAdapter,
|
||||||
runDispatcherAdapter,
|
runDispatcherAdapter,
|
||||||
runBridgeSupervisorAdapter,
|
runBridgeSupervisorAdapter,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -71,6 +71,7 @@ test('package root export resolves public package surface only', () => {
|
|||||||
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
|
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
|
||||||
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
|
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
|
||||||
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
|
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
|
||||||
|
hasCreateRuntimeBinding: typeof plugin.createRuntimeBinding,
|
||||||
hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact,
|
hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact,
|
||||||
hasCreateFileDecisionStore: typeof plugin.createFileDecisionStore,
|
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.hasPlanDecisionExecution, 'function');
|
||||||
assert.equal(result.hasExecuteGovernanceContract, 'function');
|
assert.equal(result.hasExecuteGovernanceContract, 'function');
|
||||||
assert.equal(result.hasExecuteRuntimeIntegratedGovernance, 'function');
|
assert.equal(result.hasExecuteRuntimeIntegratedGovernance, 'function');
|
||||||
|
assert.equal(result.hasCreateRuntimeBinding, 'function');
|
||||||
assert.equal(result.hasCreateDecisionRecordArtifact, 'function');
|
assert.equal(result.hasCreateDecisionRecordArtifact, 'function');
|
||||||
assert.equal(result.hasCreateFileDecisionStore, 'function');
|
assert.equal(result.hasCreateFileDecisionStore, 'function');
|
||||||
} finally {
|
} 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();
|
const root = createFixtureRoot();
|
||||||
try {
|
try {
|
||||||
installPackageAlias(root);
|
installPackageAlias(root);
|
||||||
@@ -104,9 +106,6 @@ test('adapters subpath export resolves package-owned adapter index plus profile
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
assert.deepEqual(result.adapterKeys, [
|
assert.deepEqual(result.adapterKeys, [
|
||||||
'createDeploymentBindingContract',
|
|
||||||
'createRuntimeBinding',
|
|
||||||
'loadDeploymentProfileArtifact',
|
|
||||||
'runBridgeSupervisorAdapter',
|
'runBridgeSupervisorAdapter',
|
||||||
'runDispatcherAdapter',
|
'runDispatcherAdapter',
|
||||||
'runOrchestratorAdapter',
|
'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', () => {
|
test('deep runtime-binding subpath stays outside package exports boundary', () => {
|
||||||
const root = createFixtureRoot();
|
const root = createFixtureRoot();
|
||||||
try {
|
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'))},
|
watchdog: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'long_task_watchdog.mjs'))},
|
||||||
dispatcher: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'))},
|
dispatcher: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'))},
|
||||||
bridgeSupervisor: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.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)},
|
state: ${JSON.stringify(statePath)},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
validateDeploymentProfileArtifact,
|
validateDeploymentProfileArtifact,
|
||||||
assertUseTimePathWithinRepoRoot,
|
assertUseTimePathWithinRepoRoot,
|
||||||
} from '../src/storage/profile-artifact.mjs';
|
} 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 packageRoot = path.resolve(import.meta.dirname, '..');
|
||||||
const repoRoot = path.resolve(packageRoot, '..', '..');
|
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||||
|
|||||||
@@ -4,15 +4,28 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
CRON_FILE="$ROOT_DIR/state/cron/long-task-watchdog.cron"
|
CRON_FILE="$ROOT_DIR/state/cron/long-task-watchdog.cron"
|
||||||
LOG_DIR="$ROOT_DIR/state/long-task-watchdog"
|
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"
|
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"
|
mkdir -p "$(dirname "$CRON_FILE")" "$LOG_DIR"
|
||||||
|
|
||||||
cat >"$CRON_FILE" <<EOF
|
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
|
EOF
|
||||||
|
|
||||||
printf 'Wrote cron snippet: %s\n' "$CRON_FILE"
|
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 'To install for current user, run:\n'
|
||||||
printf ' (crontab -l 2>/dev/null; cat "%s") | crontab -\n' "$CRON_FILE"
|
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'
|
||||||
|
|||||||
@@ -1,263 +1,3 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import '../plugins/reporting-governance/scripts/long_task_watchdog.mjs';
|
||||||
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();
|
|
||||||
|
|||||||
3
scripts/operator_notify_bridge_supervisor.mjs
Normal file
3
scripts/operator_notify_bridge_supervisor.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import '../plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs';
|
||||||
3
scripts/operator_notify_dispatcher.mjs
Normal file
3
scripts/operator_notify_dispatcher.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import '../plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs';
|
||||||
3
scripts/operator_notify_sender_binding.mjs
Normal file
3
scripts/operator_notify_sender_binding.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import '../plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs';
|
||||||
17
scripts/run_watchdog_auto_notify.sh
Executable file
17
scripts/run_watchdog_auto_notify.sh
Executable 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
|
||||||
@@ -8,13 +8,18 @@ import process from 'node:process';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
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 fixtureRoot = mkdtempSync(path.join(tmpdir(), 'long-task-watchdog-test-'));
|
||||||
const statePath = path.join(fixtureRoot, 'watchdog-state.json');
|
const statePath = path.join(fixtureRoot, 'watchdog-state.json');
|
||||||
const evidenceDir = path.join(fixtureRoot, 'evidence');
|
const evidenceDir = path.join(fixtureRoot, 'evidence');
|
||||||
|
const eventDir = path.join(fixtureRoot, 'events');
|
||||||
|
const notificationDir = path.join(fixtureRoot, 'notifications');
|
||||||
mkdirSync(evidenceDir, { recursive: true });
|
mkdirSync(evidenceDir, { recursive: true });
|
||||||
|
mkdirSync(eventDir, { recursive: true });
|
||||||
|
mkdirSync(notificationDir, { recursive: true });
|
||||||
|
|
||||||
function writeState(content) {
|
function writeState(content) {
|
||||||
const body = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
const body = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||||
@@ -23,10 +28,21 @@ function createFixtureRunner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function run(args = []) {
|
function run(args = []) {
|
||||||
const result = spawnSync(process.execPath, [WATCHDOG_SCRIPT, '--state', statePath, '--evidence-dir', evidenceDir, ...args], {
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
scriptPath,
|
||||||
|
'--state', statePath,
|
||||||
|
'--evidence-dir', evidenceDir,
|
||||||
|
'--event-dir', eventDir,
|
||||||
|
'--notification-dir', notificationDir,
|
||||||
|
...args,
|
||||||
|
],
|
||||||
|
{
|
||||||
cwd: ROOT_DIR,
|
cwd: ROOT_DIR,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: result.status,
|
status: result.status,
|
||||||
stdout: result.stdout ?? '',
|
stdout: result.stdout ?? '',
|
||||||
@@ -42,11 +58,36 @@ function createFixtureRunner() {
|
|||||||
return readdirSync(evidenceDir).sort();
|
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() {
|
function cleanup() {
|
||||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
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 = [];
|
const tests = [];
|
||||||
@@ -56,8 +97,59 @@ function printResult(prefix, name, detail = '') {
|
|||||||
process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`);
|
process.stdout.write(`${prefix} ${name}${detail ? ` ${detail}` : ''}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('inactive watchdogs do not emit evidence', () => {
|
function normalizePayload(payload) {
|
||||||
const runner = createFixtureRunner();
|
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 {
|
try {
|
||||||
runner.writeState({
|
runner.writeState({
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -76,14 +168,18 @@ test('inactive watchdogs do not emit evidence', () => {
|
|||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
const payload = JSON.parse(result.stdout);
|
const payload = JSON.parse(result.stdout);
|
||||||
assert.equal(payload.result.emittedCount, 0);
|
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.listEvidence(), []);
|
||||||
|
assert.deepEqual(runner.listEvents(), []);
|
||||||
|
assert.deepEqual(runner.listNotifications(), []);
|
||||||
} finally {
|
} finally {
|
||||||
runner.cleanup();
|
runner.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overdue active watchdog emits external evidence and updates lastAlertAt when write-state is enabled', () => {
|
test('overdue active watchdog emits evidence, canonical event, notification queue item, and updates lastAlertAt', () => {
|
||||||
const runner = createFixtureRunner();
|
const runner = createFixtureRunner(PACKAGE_ENTRY);
|
||||||
try {
|
try {
|
||||||
runner.writeState({
|
runner.writeState({
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -106,18 +202,39 @@ test('overdue active watchdog emits external evidence and updates lastAlertAt wh
|
|||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
const payload = JSON.parse(result.stdout);
|
const payload = JSON.parse(result.stdout);
|
||||||
assert.equal(payload.result.emittedCount, 1);
|
assert.equal(payload.result.emittedCount, 1);
|
||||||
|
assert.equal(payload.result.eventCount, 1);
|
||||||
|
assert.equal(payload.result.notificationCount, 1);
|
||||||
|
|
||||||
const evidenceFiles = runner.listEvidence();
|
const evidenceFiles = runner.listEvidence();
|
||||||
|
const eventFiles = runner.listEvents();
|
||||||
|
const notificationFiles = runner.listNotifications();
|
||||||
assert.equal(evidenceFiles.length, 1);
|
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();
|
const nextState = runner.readState();
|
||||||
assert.equal(nextState.watchdogs[0].lastAlertAt, '2026-05-07T08:20:00.000Z');
|
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 {
|
} finally {
|
||||||
runner.cleanup();
|
runner.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('same interval is not alerted twice once lastAlertAt covers the overdue window', () => {
|
test('same interval is not alerted twice once lastAlertAt covers the overdue window', () => {
|
||||||
const runner = createFixtureRunner();
|
const runner = createFixtureRunner(PACKAGE_ENTRY);
|
||||||
try {
|
try {
|
||||||
runner.writeState({
|
runner.writeState({
|
||||||
version: 1,
|
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);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
const payload = JSON.parse(result.stdout);
|
const payload = JSON.parse(result.stdout);
|
||||||
assert.equal(payload.result.emittedCount, 0);
|
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.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 {
|
} finally {
|
||||||
runner.cleanup();
|
runner.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
149
scripts/test_operator_notify_bridge_supervisor.mjs
Normal file
149
scripts/test_operator_notify_bridge_supervisor.mjs
Normal 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);
|
||||||
|
}
|
||||||
267
scripts/test_operator_notify_dispatcher.mjs
Normal file
267
scripts/test_operator_notify_dispatcher.mjs
Normal 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);
|
||||||
|
}
|
||||||
114
scripts/test_operator_notify_sender_binding_shim_regression.mjs
Normal file
114
scripts/test_operator_notify_sender_binding_shim_regression.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user