Add plain-language status doc and minimal decision store contract
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
export const packageName = '@openclaw/plugin-reporting-governance';
|
||||
export const packageVersion = '0.1.0-mainline';
|
||||
|
||||
export const artifactKinds = {
|
||||
deploymentProfile: 'DeploymentProfileArtifact',
|
||||
decisionRecord: 'DecisionRecordArtifact',
|
||||
};
|
||||
|
||||
export const packageBoundaries = {
|
||||
core: [
|
||||
'event normalization',
|
||||
@@ -46,3 +51,9 @@ export {
|
||||
runOrchestratorAdapter,
|
||||
} from './adapters/index.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,
|
||||
generateDeploymentProfileArtifactFromFile,
|
||||
} from './profile-generator.mjs';
|
||||
export {
|
||||
createDecisionRecordArtifact,
|
||||
createDecisionRecordFileName,
|
||||
validateDecisionRecordArtifact,
|
||||
} from './decision-artifact.mjs';
|
||||
export { createFileDecisionStore } from './decision-store.mjs';
|
||||
|
||||
Reference in New Issue
Block a user