Add plain-language status doc and minimal decision store contract
This commit is contained in:
@@ -0,0 +1,231 @@
|
|||||||
|
# Reporting Governance Plugin:現在做到哪、為什麼這樣切、還沒做到什麼
|
||||||
|
|
||||||
|
這份文件是給**不想先看程式碼的人**看的。
|
||||||
|
|
||||||
|
一句話先講白:
|
||||||
|
**這個 plugin 在做的事,是把「代理有沒有老實回報、有没有卡住不講、有没有把該交接的事真的交出去」這些規則,從口頭要求,慢慢做成可驗證的程式與紀錄。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) 目前已經做了什麼
|
||||||
|
|
||||||
|
現在不是只有概念,而是已經有一條**可以跑、會留下痕跡、能被測試證明**的最小鏈路。
|
||||||
|
|
||||||
|
### 已經有的骨架
|
||||||
|
|
||||||
|
`plugins/reporting-governance/` 這個 package 已經拉出基本邊界:
|
||||||
|
|
||||||
|
- `core/`:放規則判斷與決策邏輯
|
||||||
|
- `adapters/`:接現有 runtime / script
|
||||||
|
- `storage/`:放可保存的 artifact 與 store contract
|
||||||
|
- `profiles/`:放部署設定 artifact
|
||||||
|
- `capabilities/`:放能力描述
|
||||||
|
|
||||||
|
這很重要,因為它代表這件事開始不是散在 repo 各角落,而是有自己的 package 家。
|
||||||
|
|
||||||
|
### 已經有的決策能力
|
||||||
|
|
||||||
|
目前已經有最小可跑的:
|
||||||
|
|
||||||
|
- policy evaluator:根據事件/證據判斷要不要出手
|
||||||
|
- decision runner:把判斷結果轉成可執行意圖
|
||||||
|
- compatibility preflight:先檢查輸入/相容性,不對就 fail closed
|
||||||
|
|
||||||
|
白話講,就是:
|
||||||
|
**系統已經不只是「看到問題」,而是能把問題翻成明確決策,例如要不要強制 checkpoint、要不要擋下某動作。**
|
||||||
|
|
||||||
|
### 已經有的 OpenClaw 參考鏈
|
||||||
|
|
||||||
|
目前 repo 裡最真實的一條鏈,是 watchdog 逾時告警路徑:
|
||||||
|
|
||||||
|
`watchdog -> queue -> dispatcher -> bridge -> sender -> receipt`
|
||||||
|
|
||||||
|
這條鏈已經能做到:
|
||||||
|
|
||||||
|
- 產生 canonical event
|
||||||
|
- 進 queue / spool
|
||||||
|
- 經過 bridge / sender binding
|
||||||
|
- 留下 receipt
|
||||||
|
- 在不能真的送出的情況下,誠實標示成 `pending_external_send`,不是假裝已送達
|
||||||
|
|
||||||
|
這件事的價值很實際:
|
||||||
|
**它已經能防止「其實沒送到,卻說送到了」這種黑箱假成功。**
|
||||||
|
|
||||||
|
### 已經有的 package-owned profile artifact
|
||||||
|
|
||||||
|
目前已經有一個 package 自己擁有的部署設定 artifact:
|
||||||
|
|
||||||
|
- `plugins/reporting-governance/profiles/strict-manager-mode.profile.json`
|
||||||
|
|
||||||
|
而且不是擺著好看,已經有:
|
||||||
|
|
||||||
|
- loader
|
||||||
|
- validator
|
||||||
|
- binding contract projector
|
||||||
|
- 測試
|
||||||
|
|
||||||
|
白話講:
|
||||||
|
**這個 plugin 已經開始自己宣告:我依賴哪些腳本、哪些 artifact 目錄、哪些 runtime 邊界。**
|
||||||
|
|
||||||
|
不是再靠「大家心裡知道」或 README 暗示。
|
||||||
|
|
||||||
|
### 這一輪新補的最小 storage contract slice
|
||||||
|
|
||||||
|
這輪再往前推了一小刀:
|
||||||
|
|
||||||
|
- 定義 `DecisionRecordArtifact`
|
||||||
|
- 定義 decision artifact validator / filename contract
|
||||||
|
- 落一個最小 file-based decision store
|
||||||
|
- 用測試證明 decision 可以被寫入、讀回、驗證,而且不能亂寫出 repo root
|
||||||
|
|
||||||
|
白話講:
|
||||||
|
**決策紀錄開始有 package 自己的保存格式,而不是每次臨時拼 JSON。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 為什麼要這樣設計 / 為什麼要這樣切 slices
|
||||||
|
|
||||||
|
### 原因一:這題太大,不能一口氣抽乾淨
|
||||||
|
|
||||||
|
Reporting governance 牽到的東西很多:
|
||||||
|
|
||||||
|
- 事件模型
|
||||||
|
- 證據模型
|
||||||
|
- 決策模型
|
||||||
|
- queue / spool / receipt
|
||||||
|
- runtime adapter
|
||||||
|
- 真正外送通知
|
||||||
|
- 未來的 audit/export
|
||||||
|
|
||||||
|
如果一次硬做成完整 framework,很容易出現兩種壞事:
|
||||||
|
|
||||||
|
1. 做很大,但沒有任何一塊真的可驗證
|
||||||
|
2. 名字切漂亮,實際上還是靠舊 repo 腳本在撐
|
||||||
|
|
||||||
|
所以現在的主線策略是:
|
||||||
|
**每次只切一塊最小、能被測試證明、而且不會假裝完成的 slice。**
|
||||||
|
|
||||||
|
### 原因二:先把「誠實邊界」做出來
|
||||||
|
|
||||||
|
這個 plugin 的核心不是炫技,而是**不能說謊**。
|
||||||
|
|
||||||
|
所以設計上優先做的是:
|
||||||
|
|
||||||
|
- capability descriptor:先講清楚 runtime 有什麼能力
|
||||||
|
- compatibility preflight:能力不夠就 fail,不硬裝能做
|
||||||
|
- truthful receipt states:沒送到就是沒送到,不能灌水成 acked
|
||||||
|
- package-owned artifacts:讓輸入/輸出有固定契約
|
||||||
|
|
||||||
|
這就是為什麼現在會先看到 profile artifact、receipt truth model、decision record 這些東西,而不是先衝一個很大的「全能治理引擎」。
|
||||||
|
|
||||||
|
### 原因三:把 package 真正養成自己的邊界
|
||||||
|
|
||||||
|
如果 `core`、`adapters`、`storage` 不分開,未來就會很難回答:
|
||||||
|
|
||||||
|
- 哪些是可重用的治理邏輯?
|
||||||
|
- 哪些只是 OpenClaw 這個 runtime 的接線?
|
||||||
|
- 哪些是資料保存契約?
|
||||||
|
|
||||||
|
現在這樣切,是為了讓未來能逐步達到:
|
||||||
|
|
||||||
|
- `core` 不依賴特定 runtime
|
||||||
|
- `adapters` 負責接現場
|
||||||
|
- `storage` 擁有 artifact contract
|
||||||
|
|
||||||
|
這樣才有機會從「repo 內一套特殊腳本」長成「可攜的 plugin」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 這些工作帶來什麼實際好處
|
||||||
|
|
||||||
|
### 好處一:比較不容易再發生黑箱失聯
|
||||||
|
|
||||||
|
watchdog / queue / receipt 這條鏈已經讓「任務太久沒回報」有一條比較正式的處理路。
|
||||||
|
|
||||||
|
不是再靠人記得盯。
|
||||||
|
|
||||||
|
### 好處二:可以區分「真的做到」和「只是說做到」
|
||||||
|
|
||||||
|
receipt truth model 的意義很直接:
|
||||||
|
|
||||||
|
- 有證據送出,才叫 acked
|
||||||
|
- 沒證據,就停在 pending / blocked / dispatched 等誠實狀態
|
||||||
|
|
||||||
|
這會直接降低假完成、假外送、假交接。
|
||||||
|
|
||||||
|
### 好處三:未來比較能換 runtime,不用整組重寫
|
||||||
|
|
||||||
|
先把 core / adapter / storage 切開,未來若不是 OpenClaw,也比較有機會重用核心規則。
|
||||||
|
|
||||||
|
### 好處四:決策開始可保存、可追查
|
||||||
|
|
||||||
|
這輪 decision store slice 的實際價值是:
|
||||||
|
|
||||||
|
- 決策不只存在記憶體
|
||||||
|
- 可落成 artifact
|
||||||
|
- 檔名與內容有固定 contract
|
||||||
|
- 可追 policy、decision、receipt、source event
|
||||||
|
|
||||||
|
這對 audit、debug、回放都很關鍵。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) 目前明確還沒做到什麼
|
||||||
|
|
||||||
|
這裡要講老實話。
|
||||||
|
|
||||||
|
### 還沒做到完整 storage framework
|
||||||
|
|
||||||
|
現在只有先落一個最小 decision store slice。
|
||||||
|
|
||||||
|
**還沒有**完整抽成:
|
||||||
|
|
||||||
|
- event store
|
||||||
|
- evidence store
|
||||||
|
- queue store
|
||||||
|
- spool store
|
||||||
|
- receipt store
|
||||||
|
- 統一 manifest / audit export
|
||||||
|
|
||||||
|
### 還沒做到所有決策都能真的執行
|
||||||
|
|
||||||
|
目前 package `core` 已經能做判斷與規劃,
|
||||||
|
但很多實際 side effects 還是 adapter / runtime 責任。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- 真正 inline 攔截所有訊息/狀態變更
|
||||||
|
- 完整 rewrite / placeholder / review / downgrade_status 執行
|
||||||
|
- 非 watchdog 路徑的全面治理攔截
|
||||||
|
|
||||||
|
### 還沒完全脫離 repo 既有 scripts
|
||||||
|
|
||||||
|
現在 adapter 仍大量包既有 scripts。
|
||||||
|
這代表 package 邊界雖然成形,但 runtime 實作還在過渡期。
|
||||||
|
|
||||||
|
### 還沒形成完整對外穩定 API
|
||||||
|
|
||||||
|
現在還是 `0.1.0-mainline`,屬於 pre-1.0。
|
||||||
|
|
||||||
|
意思是:
|
||||||
|
|
||||||
|
- package surface 還在收斂
|
||||||
|
- 深層 `src/` import 不算穩定公開 API
|
||||||
|
- 現在能跑,不代表介面已經定版
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 現在最準確的定位
|
||||||
|
|
||||||
|
如果要一句話描述現在狀態:
|
||||||
|
|
||||||
|
**它已經不是只有規格文件;已經有最小可跑、可測、會留痕跡的治理鏈。**
|
||||||
|
|
||||||
|
但同時:
|
||||||
|
|
||||||
|
**它也還不是完整成熟的 reporting-governance framework。**
|
||||||
|
|
||||||
|
現在主線是在做一件對的事:
|
||||||
|
**把最重要、最容易說謊、最需要證據的那幾刀,先變成 package-owned contracts。**
|
||||||
|
|
||||||
|
這樣後面每往前一步,才比較不會變成另一個大而空的治理殼。
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
||||||
},
|
},
|
||||||
"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/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
"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/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
export const packageName = '@openclaw/plugin-reporting-governance';
|
export const packageName = '@openclaw/plugin-reporting-governance';
|
||||||
export const packageVersion = '0.1.0-mainline';
|
export const packageVersion = '0.1.0-mainline';
|
||||||
|
|
||||||
|
export const artifactKinds = {
|
||||||
|
deploymentProfile: 'DeploymentProfileArtifact',
|
||||||
|
decisionRecord: 'DecisionRecordArtifact',
|
||||||
|
};
|
||||||
|
|
||||||
export const packageBoundaries = {
|
export const packageBoundaries = {
|
||||||
core: [
|
core: [
|
||||||
'event normalization',
|
'event normalization',
|
||||||
@@ -46,3 +51,9 @@ export {
|
|||||||
runOrchestratorAdapter,
|
runOrchestratorAdapter,
|
||||||
} from './adapters/index.mjs';
|
} from './adapters/index.mjs';
|
||||||
export { runOrchestratorAdapter as runWatchdogChain } from './adapters/orchestrator.mjs';
|
export { runOrchestratorAdapter as runWatchdogChain } from './adapters/orchestrator.mjs';
|
||||||
|
export {
|
||||||
|
createDecisionRecordArtifact,
|
||||||
|
createDecisionRecordFileName,
|
||||||
|
createFileDecisionStore,
|
||||||
|
validateDecisionRecordArtifact,
|
||||||
|
} from './storage/index.mjs';
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const EXPECTED_KIND = 'DecisionRecordArtifact';
|
||||||
|
const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1';
|
||||||
|
|
||||||
|
function assertNonEmptyString(value, label) {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new Error(`${label} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObjectRecord(value, label) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be an object record`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileSegment(value, fallback) {
|
||||||
|
const normalized = String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDecisionRecordArtifact(artifact) {
|
||||||
|
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
||||||
|
throw new Error('decision record artifact must be an object');
|
||||||
|
}
|
||||||
|
if (artifact.kind !== EXPECTED_KIND) {
|
||||||
|
throw new Error(`decision record artifact kind must be ${EXPECTED_KIND}`);
|
||||||
|
}
|
||||||
|
if (artifact.apiVersion !== EXPECTED_API_VERSION) {
|
||||||
|
throw new Error(`decision record artifact apiVersion must be ${EXPECTED_API_VERSION}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = assertObjectRecord(artifact.metadata, 'decision record artifact metadata');
|
||||||
|
const spec = assertObjectRecord(artifact.spec, 'decision record artifact spec');
|
||||||
|
const decision = assertObjectRecord(spec.decision, 'decision record artifact spec.decision');
|
||||||
|
const receipt = assertObjectRecord(spec.receipt, 'decision record artifact spec.receipt');
|
||||||
|
|
||||||
|
assertNonEmptyString(metadata.recorded_at, 'decision record artifact metadata.recorded_at');
|
||||||
|
assertNonEmptyString(metadata.policy_id, 'decision record artifact metadata.policy_id');
|
||||||
|
assertNonEmptyString(metadata.decision, 'decision record artifact metadata.decision');
|
||||||
|
assertNonEmptyString(decision.policy_id, 'decision record artifact spec.decision.policy_id');
|
||||||
|
assertNonEmptyString(decision.decision, 'decision record artifact spec.decision.decision');
|
||||||
|
assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state');
|
||||||
|
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDecisionRecordArtifact({ decision, receipt, recordedAt = new Date().toISOString(), source = {} } = {}) {
|
||||||
|
const normalizedDecision = assertObjectRecord(decision, 'decision');
|
||||||
|
const normalizedReceipt = assertObjectRecord(receipt, 'receipt');
|
||||||
|
const policyId = assertNonEmptyString(normalizedDecision.policy_id, 'decision.policy_id');
|
||||||
|
const decisionName = assertNonEmptyString(normalizedDecision.decision, 'decision.decision');
|
||||||
|
|
||||||
|
return validateDecisionRecordArtifact({
|
||||||
|
kind: EXPECTED_KIND,
|
||||||
|
apiVersion: EXPECTED_API_VERSION,
|
||||||
|
metadata: {
|
||||||
|
record_id: `dec_${crypto.randomUUID()}`,
|
||||||
|
recorded_at: assertNonEmptyString(recordedAt, 'recordedAt'),
|
||||||
|
policy_id: policyId,
|
||||||
|
decision: decisionName,
|
||||||
|
correlation_id: source.correlation_id ?? null,
|
||||||
|
task_id: source.task_id ?? null,
|
||||||
|
event_id: source.event_id ?? null,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
decision: normalizedDecision,
|
||||||
|
receipt: normalizedReceipt,
|
||||||
|
source: {
|
||||||
|
event_id: source.event_id ?? null,
|
||||||
|
task_id: source.task_id ?? null,
|
||||||
|
correlation_id: source.correlation_id ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDecisionRecordFileName(artifact) {
|
||||||
|
const validatedArtifact = validateDecisionRecordArtifact(artifact);
|
||||||
|
return [
|
||||||
|
validatedArtifact.metadata.recorded_at.replace(/[.:]/g, '-'),
|
||||||
|
sanitizeFileSegment(validatedArtifact.metadata.policy_id, 'policy'),
|
||||||
|
sanitizeFileSegment(validatedArtifact.metadata.decision, 'decision'),
|
||||||
|
`${validatedArtifact.metadata.record_id}.json`,
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __testables = {
|
||||||
|
EXPECTED_KIND,
|
||||||
|
EXPECTED_API_VERSION,
|
||||||
|
sanitizeFileSegment,
|
||||||
|
};
|
||||||
43
plugins/reporting-governance/src/storage/decision-store.mjs
Normal file
43
plugins/reporting-governance/src/storage/decision-store.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDecisionRecordArtifact,
|
||||||
|
createDecisionRecordFileName,
|
||||||
|
validateDecisionRecordArtifact,
|
||||||
|
} from './decision-artifact.mjs';
|
||||||
|
import { assertUseTimePathWithinRepoRoot } from './profile-artifact.mjs';
|
||||||
|
|
||||||
|
export function createFileDecisionStore({ decisionsDir, repoRootOverride } = {}) {
|
||||||
|
const resolvedDecisionsDir = assertUseTimePathWithinRepoRoot(
|
||||||
|
path.resolve(decisionsDir ?? 'state/reporting-governance-decisions'),
|
||||||
|
'decision store decisionsDir',
|
||||||
|
{ repoRootOverride, allowMissingLeaf: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
decisionsDir: resolvedDecisionsDir,
|
||||||
|
write({ decision, receipt, recordedAt, source } = {}) {
|
||||||
|
fs.mkdirSync(resolvedDecisionsDir, { recursive: true });
|
||||||
|
const artifact = createDecisionRecordArtifact({ decision, receipt, recordedAt, source });
|
||||||
|
const artifactPath = path.join(resolvedDecisionsDir, createDecisionRecordFileName(artifact));
|
||||||
|
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||||
|
return {
|
||||||
|
artifact,
|
||||||
|
artifactPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
load(artifactPath) {
|
||||||
|
const resolvedPath = assertUseTimePathWithinRepoRoot(
|
||||||
|
path.resolve(artifactPath),
|
||||||
|
'decision store artifactPath',
|
||||||
|
{ repoRootOverride }
|
||||||
|
);
|
||||||
|
const artifact = validateDecisionRecordArtifact(JSON.parse(fs.readFileSync(resolvedPath, 'utf8')));
|
||||||
|
return {
|
||||||
|
artifact,
|
||||||
|
artifactPath: resolvedPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,3 +8,9 @@ export {
|
|||||||
generateDeploymentProfileArtifact,
|
generateDeploymentProfileArtifact,
|
||||||
generateDeploymentProfileArtifactFromFile,
|
generateDeploymentProfileArtifactFromFile,
|
||||||
} from './profile-generator.mjs';
|
} from './profile-generator.mjs';
|
||||||
|
export {
|
||||||
|
createDecisionRecordArtifact,
|
||||||
|
createDecisionRecordFileName,
|
||||||
|
validateDecisionRecordArtifact,
|
||||||
|
} from './decision-artifact.mjs';
|
||||||
|
export { createFileDecisionStore } from './decision-store.mjs';
|
||||||
|
|||||||
133
plugins/reporting-governance/test/decision-store.test.mjs
Normal file
133
plugins/reporting-governance/test/decision-store.test.mjs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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 {
|
||||||
|
createDecisionRecordArtifact,
|
||||||
|
createDecisionRecordFileName,
|
||||||
|
createFileDecisionStore,
|
||||||
|
validateDecisionRecordArtifact,
|
||||||
|
} from '../src/storage/index.mjs';
|
||||||
|
import { planDecisionExecution } from '../src/core/decision-runner.mjs';
|
||||||
|
|
||||||
|
const packageRoot = path.resolve(import.meta.dirname, '..');
|
||||||
|
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||||
|
|
||||||
|
const capabilityDescriptor = {
|
||||||
|
capabilities: {
|
||||||
|
enforcement: {
|
||||||
|
force_checkpoint: { supported: true, level: 'partial' },
|
||||||
|
escalate: { supported: true, level: 'full' }
|
||||||
|
},
|
||||||
|
notification_path: {
|
||||||
|
queue_items: { supported: true, level: 'full' },
|
||||||
|
spool_handoff: { supported: true, level: 'full' },
|
||||||
|
sender_binding: { supported: true, level: 'full' },
|
||||||
|
direct_send: { supported: false, level: 'none' },
|
||||||
|
truth_model: {
|
||||||
|
delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'],
|
||||||
|
ack_requires_proven_send: true,
|
||||||
|
pending_external_send_supported: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPlannedDecision() {
|
||||||
|
return planDecisionExecution({
|
||||||
|
decision: {
|
||||||
|
decision: 'force_checkpoint',
|
||||||
|
policy_id: 'no-silence.missed-checkpoint',
|
||||||
|
severity: 'high',
|
||||||
|
reason: 'checkpoint overdue',
|
||||||
|
required_actions: [
|
||||||
|
{ action: 'notify_operator', target: 'operator_channel', mandatory: true },
|
||||||
|
{ action: 'emit_event', target: 'event_stream', mandatory: true }
|
||||||
|
],
|
||||||
|
operator_notice: {
|
||||||
|
required: true,
|
||||||
|
channel: 'telegram',
|
||||||
|
urgency: 'high',
|
||||||
|
message: 'Required update.',
|
||||||
|
deadline: '2026-01-01T00:00:00.000Z'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
capabilityDescriptor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('decision artifact validates minimal package-owned contract', () => {
|
||||||
|
const planned = createPlannedDecision();
|
||||||
|
const artifact = createDecisionRecordArtifact({
|
||||||
|
decision: planned.decision,
|
||||||
|
receipt: planned.receipt,
|
||||||
|
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||||
|
source: {
|
||||||
|
event_id: 'evt_watchdog_001',
|
||||||
|
task_id: 'task-reporting-governance',
|
||||||
|
correlation_id: 'corr-001',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(artifact.kind, 'DecisionRecordArtifact');
|
||||||
|
assert.equal(artifact.apiVersion, 'reporting-governance/v1alpha1');
|
||||||
|
assert.equal(artifact.metadata.policy_id, 'no-silence.missed-checkpoint');
|
||||||
|
assert.equal(artifact.spec.receipt.delivery_state, 'pending_external_send');
|
||||||
|
assert.equal(validateDecisionRecordArtifact(artifact), artifact);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decision artifact filename is stable and readable', () => {
|
||||||
|
const planned = createPlannedDecision();
|
||||||
|
const artifact = createDecisionRecordArtifact({
|
||||||
|
decision: planned.decision,
|
||||||
|
receipt: planned.receipt,
|
||||||
|
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = createDecisionRecordFileName(artifact);
|
||||||
|
assert.match(fileName, /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file decision store writes and reloads a validated decision artifact inside repo root', async (t) => {
|
||||||
|
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-decision-store-'));
|
||||||
|
t.after(() => fs.rmSync(sandbox, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const fakeRepoRoot = path.join(sandbox, 'repo');
|
||||||
|
fs.mkdirSync(fakeRepoRoot, { recursive: true });
|
||||||
|
|
||||||
|
const planned = createPlannedDecision();
|
||||||
|
const store = createFileDecisionStore({
|
||||||
|
decisionsDir: path.join(fakeRepoRoot, 'state', 'decisions'),
|
||||||
|
repoRootOverride: fakeRepoRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const written = store.write({
|
||||||
|
decision: planned.decision,
|
||||||
|
receipt: planned.receipt,
|
||||||
|
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||||
|
source: {
|
||||||
|
event_id: 'evt_watchdog_001',
|
||||||
|
task_id: 'task-reporting-governance',
|
||||||
|
correlation_id: 'corr-001',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(written.artifactPath), true);
|
||||||
|
assert.match(path.basename(written.artifactPath), /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/);
|
||||||
|
|
||||||
|
const loaded = store.load(written.artifactPath);
|
||||||
|
assert.equal(loaded.artifact.metadata.event_id, 'evt_watchdog_001');
|
||||||
|
assert.equal(loaded.artifact.spec.receipt.delivery_state, 'pending_external_send');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file decision store rejects decision directory escaping repo root', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createFileDecisionStore({
|
||||||
|
decisionsDir: path.resolve(repoRoot, '..', 'escape'),
|
||||||
|
repoRootOverride: repoRoot,
|
||||||
|
}),
|
||||||
|
/decision store decisionsDir must stay within repo root/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -66,18 +66,27 @@ test('package root export resolves public package surface only', () => {
|
|||||||
import * as plugin from '@openclaw/plugin-reporting-governance';
|
import * as plugin from '@openclaw/plugin-reporting-governance';
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
packageName: plugin.packageName,
|
packageName: plugin.packageName,
|
||||||
|
artifactKinds: plugin.artifactKinds,
|
||||||
hasRunWatchdogChain: typeof plugin.runWatchdogChain,
|
hasRunWatchdogChain: typeof plugin.runWatchdogChain,
|
||||||
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
|
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
|
||||||
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
|
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
|
||||||
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
|
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
|
||||||
|
hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact,
|
||||||
|
hasCreateFileDecisionStore: typeof plugin.createFileDecisionStore,
|
||||||
}));
|
}));
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assert.equal(result.packageName, '@openclaw/plugin-reporting-governance');
|
assert.equal(result.packageName, '@openclaw/plugin-reporting-governance');
|
||||||
|
assert.deepEqual(result.artifactKinds, {
|
||||||
|
deploymentProfile: 'DeploymentProfileArtifact',
|
||||||
|
decisionRecord: 'DecisionRecordArtifact',
|
||||||
|
});
|
||||||
assert.equal(result.hasRunWatchdogChain, 'function');
|
assert.equal(result.hasRunWatchdogChain, 'function');
|
||||||
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.hasCreateDecisionRecordArtifact, 'function');
|
||||||
|
assert.equal(result.hasCreateFileDecisionStore, 'function');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(root, { recursive: true, force: true });
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const requiredPaths = [
|
|||||||
'src/adapters/orchestrator.mjs',
|
'src/adapters/orchestrator.mjs',
|
||||||
'src/storage',
|
'src/storage',
|
||||||
'src/storage/profile-artifact.mjs',
|
'src/storage/profile-artifact.mjs',
|
||||||
|
'src/storage/decision-artifact.mjs',
|
||||||
|
'src/storage/decision-store.mjs',
|
||||||
'src/reference/openclaw-watchdog-chain.md',
|
'src/reference/openclaw-watchdog-chain.md',
|
||||||
'capabilities/openclaw-watchdog-reference.json',
|
'capabilities/openclaw-watchdog-reference.json',
|
||||||
'examples/openclaw-watchdog-reference.descriptor.example.json',
|
'examples/openclaw-watchdog-reference.descriptor.example.json',
|
||||||
|
|||||||
Reference in New Issue
Block a user