From de2d5d97b8778f7ef685158e8521483e130c8a80 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 12:23:02 +0800 Subject: [PATCH] feat: add deployment profile generation slice --- plugins/reporting-governance/package.json | 2 +- .../src/storage/index.mjs | 11 +- .../src/storage/profile-generator.mjs | 227 ++++++++++++++++++ .../test/profile-generator.test.mjs | 71 ++++++ .../deployment-profile.schema.json | 75 ++++++ ..._reporting_governance_profile_artifact.mjs | 25 ++ 6 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 plugins/reporting-governance/src/storage/profile-generator.mjs create mode 100644 plugins/reporting-governance/test/profile-generator.test.mjs create mode 100644 schemas/reporting-governance/deployment-profile.schema.json create mode 100644 scripts/generate_reporting_governance_profile_artifact.mjs diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index adbe27b..f4f6efa 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -14,6 +14,6 @@ "./adapters/orchestrator": "./src/adapters/orchestrator.mjs" }, "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/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/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs" } } diff --git a/plugins/reporting-governance/src/storage/index.mjs b/plugins/reporting-governance/src/storage/index.mjs index 78afa2c..116364c 100644 --- a/plugins/reporting-governance/src/storage/index.mjs +++ b/plugins/reporting-governance/src/storage/index.mjs @@ -1 +1,10 @@ -export { loadDeploymentProfileArtifact, createDeploymentBindingContract } from './profile-artifact.mjs'; +export { + loadDeploymentProfileArtifact, + createDeploymentBindingContract, +} from './profile-artifact.mjs'; +export { + parseDeploymentProfileYaml, + validateDeploymentProfileSchema, + generateDeploymentProfileArtifact, + generateDeploymentProfileArtifactFromFile, +} from './profile-generator.mjs'; diff --git a/plugins/reporting-governance/src/storage/profile-generator.mjs b/plugins/reporting-governance/src/storage/profile-generator.mjs new file mode 100644 index 0000000..3e6ba02 --- /dev/null +++ b/plugins/reporting-governance/src/storage/profile-generator.mjs @@ -0,0 +1,227 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const packageRoot = path.resolve(import.meta.dirname, '..', '..'); +const repoRoot = path.resolve(packageRoot, '..', '..'); +const schemaPath = '../../../schemas/reporting-governance/deployment-profile.schema.json'; + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function countIndent(line) { + let count = 0; + while (count < line.length && line[count] === ' ') count += 1; + return count; +} + +function parseScalar(raw) { + const value = raw.trim(); + if (value === 'true') return true; + if (value === 'false') return false; + if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + return value; +} + +function parseBlock(lines, startIndex = 0, baseIndent = 0) { + const firstMeaningful = lines.slice(startIndex).find((line) => line.trim() !== ''); + const container = firstMeaningful?.trim().startsWith('- ') ? [] : {}; + let index = startIndex; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + index += 1; + continue; + } + + const indent = countIndent(line); + if (indent < baseIndent) break; + if (indent > baseIndent) { + throw new Error(`unexpected indentation at line ${index + 1}`); + } + + if (Array.isArray(container)) { + if (!trimmed.startsWith('- ')) { + throw new Error(`expected array item at line ${index + 1}`); + } + const itemBody = trimmed.slice(2); + if (itemBody === '') { + const nested = parseBlock(lines, index + 1, baseIndent + 2); + container.push(nested.value); + index = nested.nextIndex; + continue; + } + if (itemBody.includes(':')) { + const separator = itemBody.indexOf(':'); + const key = itemBody.slice(0, separator).trim(); + const remainder = itemBody.slice(separator + 1).trim(); + const objectItem = {}; + if (remainder === '') { + const nested = parseBlock(lines, index + 1, baseIndent + 4); + objectItem[key] = nested.value; + container.push(objectItem); + index = nested.nextIndex; + continue; + } + objectItem[key] = parseScalar(remainder); + container.push(objectItem); + index += 1; + continue; + } + container.push(parseScalar(itemBody)); + index += 1; + continue; + } + + const separator = trimmed.indexOf(':'); + if (separator === -1) { + throw new Error(`expected key/value pair at line ${index + 1}`); + } + + const key = trimmed.slice(0, separator).trim(); + let remainder = trimmed.slice(separator + 1).trim(); + + if (remainder === '>-') { + const blockLines = []; + index += 1; + while (index < lines.length) { + const nestedLine = lines[index]; + const nestedTrimmed = nestedLine.trim(); + if (nestedTrimmed === '') { + blockLines.push(''); + index += 1; + continue; + } + const nestedIndent = countIndent(nestedLine); + if (nestedIndent <= baseIndent) break; + blockLines.push(nestedLine.slice(baseIndent + 2).trim()); + index += 1; + } + container[key] = blockLines.join(' ').replace(/\s+/g, ' ').trim(); + continue; + } + + if (remainder === '') { + const nested = parseBlock(lines, index + 1, baseIndent + 2); + container[key] = nested.value; + index = nested.nextIndex; + continue; + } + + container[key] = parseScalar(remainder); + index += 1; + } + + return { value: container, nextIndex: index }; +} + +export function parseDeploymentProfileYaml(yamlText) { + const lines = yamlText.replace(/\r\n/g, '\n').split('\n'); + return parseBlock(lines, 0, 0).value; +} + +export function validateDeploymentProfileSchema(profile) { + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + throw new Error('deployment profile must be an object'); + } + if (profile.apiVersion !== 'reporting-governance/v1alpha1') { + throw new Error('deployment profile apiVersion must be reporting-governance/v1alpha1'); + } + if (profile.kind !== 'DeploymentProfile') { + throw new Error('deployment profile kind must be DeploymentProfile'); + } + if (typeof profile?.metadata?.id !== 'string' || profile.metadata.id.trim() === '') { + throw new Error('deployment profile metadata.id must be a non-empty string'); + } + if (typeof profile?.metadata?.version !== 'string' || profile.metadata.version.trim() === '') { + throw new Error('deployment profile metadata.version must be a non-empty string'); + } + if (typeof profile?.metadata?.runtime !== 'string' || profile.metadata.runtime.trim() === '') { + throw new Error('deployment profile metadata.runtime must be a non-empty string'); + } + if (typeof profile?.spec?.package?.pluginVersion !== 'string' || profile.spec.package.pluginVersion.trim() === '') { + throw new Error('deployment profile spec.package.pluginVersion must be a non-empty string'); + } + if (!profile?.spec?.adapters || typeof profile.spec.adapters !== 'object' || Array.isArray(profile.spec.adapters)) { + throw new Error('deployment profile spec.adapters must be an object'); + } + if (!profile?.spec?.notifications || typeof profile.spec.notifications !== 'object' || Array.isArray(profile.spec.notifications)) { + throw new Error('deployment profile spec.notifications must be an object'); + } + if (!profile?.spec?.audit || typeof profile.spec.audit !== 'object' || Array.isArray(profile.spec.audit)) { + throw new Error('deployment profile spec.audit must be an object'); + } + if (!Array.isArray(profile?.spec?.audit?.requiredArtifacts) || profile.spec.audit.requiredArtifacts.length === 0) { + throw new Error('deployment profile spec.audit.requiredArtifacts must be a non-empty array'); + } + if (!profile?.spec?.capability_expectations || typeof profile.spec.capability_expectations !== 'object' || Array.isArray(profile.spec.capability_expectations)) { + throw new Error('deployment profile spec.capability_expectations must be an object'); + } + if (!Array.isArray(profile.spec.capability_expectations.required)) { + throw new Error('deployment profile spec.capability_expectations.required must be an array'); + } + return profile; +} + +export function generateDeploymentProfileArtifact(profile, { sourceProfile } = {}) { + const validated = validateDeploymentProfileSchema(profile); + const id = validated.metadata.id; + const sourceProfilePath = sourceProfile ?? path.join('profiles', `${id}.yaml`); + + return { + $schema: schemaPath, + apiVersion: validated.apiVersion, + kind: 'DeploymentProfileArtifact', + metadata: { + id, + version: validated.metadata.version, + runtime: validated.metadata.runtime, + source_profile: sourceProfilePath, + compatibility_mode: 'strict_envelope', + }, + spec: { + package: { + pluginVersion: validated.spec.package.pluginVersion, + }, + bindings: { + runtime: validated.metadata.runtime, + entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + scripts: { + watchdog: 'scripts/long_task_watchdog.mjs', + dispatcher: 'scripts/operator_notify_dispatcher.mjs', + bridgeSupervisor: 'scripts/operator_notify_bridge_supervisor.mjs', + senderBinding: 'scripts/operator_notify_sender_binding.mjs', + orchestrator: 'scripts/watchdog_auto_notify_orchestrator.mjs', + }, + artifact_roots: { + watchdogEvidence: 'state/long-task-watchdog', + canonicalEvents: 'state/long-task-watchdog-events', + queueItems: 'state/operator-notify-queue', + spoolArtifacts: 'state/operator-notify-dispatch-spool', + bridgeReceipts: 'state/operator-notify-bridge-receipts', + senderAttempts: 'state/operator-notify-sender-attempts', + }, + }, + }, + }; +} + +export function generateDeploymentProfileArtifactFromFile(profilePath) { + const absoluteProfilePath = path.resolve(profilePath); + const profile = parseDeploymentProfileYaml(readText(absoluteProfilePath)); + return generateDeploymentProfileArtifact(profile, { + sourceProfile: path.relative(repoRoot, absoluteProfilePath), + }); +} + +export default { + parseDeploymentProfileYaml, + validateDeploymentProfileSchema, + generateDeploymentProfileArtifact, + generateDeploymentProfileArtifactFromFile, +}; diff --git a/plugins/reporting-governance/test/profile-generator.test.mjs b/plugins/reporting-governance/test/profile-generator.test.mjs new file mode 100644 index 0000000..30072ab --- /dev/null +++ b/plugins/reporting-governance/test/profile-generator.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + parseDeploymentProfileYaml, + validateDeploymentProfileSchema, + generateDeploymentProfileArtifact, + generateDeploymentProfileArtifactFromFile, +} from '../src/storage/profile-generator.mjs'; +import { validateDeploymentProfileArtifact } from '../src/storage/profile-artifact.mjs'; + +const packageRoot = path.resolve(import.meta.dirname, '..'); +const repoRoot = path.resolve(packageRoot, '..', '..'); + +test('deployment profile yaml parser reads strict-manager-mode profile shape', () => { + const artifact = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml')); + + assert.equal(artifact.metadata.id, 'strict-manager-mode'); + assert.equal(artifact.metadata.runtime, 'openclaw'); + assert.equal(artifact.spec.package.pluginVersion, '0.1.0-mainline'); + assert.equal(artifact.metadata.source_profile, 'profiles/strict-manager-mode.yaml'); +}); + +test('deployment profile schema validator rejects malformed profile', () => { + assert.throws( + () => validateDeploymentProfileSchema({ kind: 'DeploymentProfile' }), + /deployment profile apiVersion must be reporting-governance\/v1alpha1/ + ); + + assert.throws( + () => validateDeploymentProfileSchema({ + apiVersion: 'reporting-governance/v1alpha1', + kind: 'DeploymentProfile', + metadata: { id: 'x', version: '1.0.0', runtime: 'openclaw' }, + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + adapters: {}, + notifications: {}, + audit: { requiredArtifacts: [] }, + capability_expectations: { required: [] }, + }, + }), + /deployment profile spec\.audit\.requiredArtifacts must be a non-empty array/ + ); +}); + +test('yaml to artifact generation produces validator-compatible package artifact', () => { + const profile = parseDeploymentProfileYaml(`apiVersion: reporting-governance/v1alpha1\nkind: DeploymentProfile\nmetadata:\n id: demo\n version: 1.0.0\n runtime: openclaw\nspec:\n package:\n pluginVersion: 0.1.0-mainline\n adapters:\n watchdog:\n enabled: true\n queue:\n enabled: true\n dispatcher:\n enabled: true\n bridge:\n enabled: true\n sender:\n mode: openclaw-cli\n orchestrator:\n enabled: true\n notifications:\n operatorVisibleRecoveryRequired: true\n allowedTerminalStates:\n - acked\n audit:\n portableArtifactsRequired: true\n requiredArtifacts:\n - queue_items\n capability_expectations:\n required:\n - create_queue_items\n`); + const artifact = generateDeploymentProfileArtifact(profile, { sourceProfile: 'profiles/demo.yaml' }); + + assert.equal(artifact.kind, 'DeploymentProfileArtifact'); + assert.equal(artifact.metadata.source_profile, 'profiles/demo.yaml'); + assert.equal(artifact.spec.bindings.entrypoint, 'scripts/watchdog_auto_notify_orchestrator.mjs'); + assert.doesNotThrow(() => validateDeploymentProfileArtifact(artifact)); +}); + +test('strict-manager YAML generation matches checked-in package artifact snapshot', () => { + const generated = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml')); + const checkedIn = validateDeploymentProfileArtifact( + JSON.parse( + fs.readFileSync( + path.join(packageRoot, 'profiles', 'strict-manager-mode.profile.json'), + 'utf8' + ) + ) + ); + + assert.deepEqual(generated, checkedIn); +}); diff --git a/schemas/reporting-governance/deployment-profile.schema.json b/schemas/reporting-governance/deployment-profile.schema.json new file mode 100644 index 0000000..575a35a --- /dev/null +++ b/schemas/reporting-governance/deployment-profile.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cowbay.org/schemas/reporting-governance/deployment-profile.schema.json", + "title": "Reporting Governance Deployment Profile", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "metadata", "spec"], + "properties": { + "apiVersion": { + "type": "string", + "const": "reporting-governance/v1alpha1" + }, + "kind": { + "type": "string", + "const": "DeploymentProfile" + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "required": ["id", "version", "runtime"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 } + } + }, + "spec": { + "type": "object", + "additionalProperties": true, + "required": ["package", "adapters", "notifications", "audit", "capability_expectations"], + "properties": { + "package": { + "type": "object", + "additionalProperties": true, + "required": ["pluginVersion"], + "properties": { + "pluginVersion": { "type": "string", "minLength": 1 } + } + }, + "adapters": { + "type": "object", + "additionalProperties": true, + "required": ["watchdog", "queue", "dispatcher", "bridge", "sender", "orchestrator"] + }, + "notifications": { + "type": "object", + "additionalProperties": true, + "required": ["operatorVisibleRecoveryRequired", "allowedTerminalStates"] + }, + "audit": { + "type": "object", + "additionalProperties": true, + "required": ["portableArtifactsRequired", "requiredArtifacts"] + }, + "capability_expectations": { + "type": "object", + "additionalProperties": true, + "required": ["required"], + "properties": { + "required": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "preferred": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + } +} diff --git a/scripts/generate_reporting_governance_profile_artifact.mjs b/scripts/generate_reporting_governance_profile_artifact.mjs new file mode 100644 index 0000000..fa7c4cf --- /dev/null +++ b/scripts/generate_reporting_governance_profile_artifact.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +import { generateDeploymentProfileArtifactFromFile } from '../plugins/reporting-governance/src/storage/profile-generator.mjs'; +import { validateDeploymentProfileArtifact } from '../plugins/reporting-governance/src/storage/profile-artifact.mjs'; + +function main() { + const [, , sourcePathArg, outputPathArg] = process.argv; + if (!sourcePathArg || !outputPathArg) { + console.error('usage: node scripts/generate_reporting_governance_profile_artifact.mjs '); + process.exit(1); + } + + const sourcePath = path.resolve(sourcePathArg); + const outputPath = path.resolve(outputPathArg); + const artifact = generateDeploymentProfileArtifactFromFile(sourcePath); + validateDeploymentProfileArtifact(artifact); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8'); + console.log(JSON.stringify({ ok: true, sourcePath, outputPath, profileId: artifact.metadata.id }, null, 2)); +} + +main();