diff --git a/plugins/reporting-governance/README.md b/plugins/reporting-governance/README.md index 5507243..6b51c9b 100644 --- a/plugins/reporting-governance/README.md +++ b/plugins/reporting-governance/README.md @@ -21,6 +21,9 @@ plugins/reporting-governance/ README.md capabilities/ profiles/ + profiles-src/ + schemas/ + scripts/ docs/ examples/ src/ @@ -252,3 +255,19 @@ This package still does **not** claim full implementation of: - non-watchdog full runtime governance interception It now provides the first package-mainline evaluator / decision-runner core, a compatibility-envelope boundary, a minimal package profile artifact/binding slice, and one profile-driven orchestrator path, but the remaining enforcement surface is still intentionally honest about adapter gaps. + + +## Package-first smoke + +Minimal package-local smoke path: + +```bash +cd plugins/reporting-governance +npm test +npm run smoke +# or after package install/link +reporting-governance-package-smoke --compact +``` + +This smoke path uses package-local `profiles-src/`, `schemas/`, and `scripts/` only. +It writes temp runtime artifacts under a caller-provided or temp workspace and verifies the dry-run orchestrator path end to end. diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index e9c8a94..e9fd589 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -10,10 +10,15 @@ "./adapters/dispatcher": "./src/adapters/dispatcher.mjs", "./adapters/bridge-supervisor": "./src/adapters/bridge-supervisor.mjs", "./adapters/sender-binding": "./src/adapters/sender-binding.mjs", - "./adapters/orchestrator": "./src/adapters/orchestrator.mjs" + "./adapters/orchestrator": "./src/adapters/orchestrator.mjs", + "./package-smoke": "./scripts/package-smoke.mjs" + }, + "bin": { + "reporting-governance-package-smoke": "./scripts/package-smoke.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/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": "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", + "smoke": "node ./scripts/package-smoke.mjs --compact" }, "dependencies": { "ajv": "^8.17.1", diff --git a/plugins/reporting-governance/profiles-src/strict-manager-mode.yaml b/plugins/reporting-governance/profiles-src/strict-manager-mode.yaml new file mode 100644 index 0000000..b584e08 --- /dev/null +++ b/plugins/reporting-governance/profiles-src/strict-manager-mode.yaml @@ -0,0 +1,103 @@ +apiVersion: reporting-governance/v1alpha1 +kind: DeploymentProfile +metadata: + id: strict-manager-mode + title: Strict Manager Mode + version: 1.0.0 + runtime: openclaw + summary: >- + High-governance deployment for manager-led multi-agent operation with aggressive + checkpoint enforcement, watchdog recovery, and strong completion controls. +spec: + package: + pluginVersion: 0.1.0-mainline + compatibility: + adapterInterface: reporting-governance-adapter-interface + deploymentModel: reporting-governance-deployment-model + policies: + enabledPacks: + - no-silence + - no-fake-progress + - verified-completion-only + - mandatory-checkpoint-structure + overrides: + completion: + minQuality: strong + requireReviewOnVerifiedCompletion: true + progress: + minNewEvidenceItems: 1 + qualityFloor: moderate + checkpoints: + overdueAction: force_checkpoint + escalationAfterMisses: 1 + placeholder: + requireExplicitLabel: true + adapters: + hook: + enabled: true + requireReportAnchorBeforeDispatch: true + blockSilentTaskLaunch: true + downgradeUnsupportedCompletionClaims: true + watchdog: + enabled: true + scheduleMode: cron + intervalMinutes: 10 + emitsCanonicalEvent: true + queue: + enabled: true + outputState: queued + dispatcher: + enabled: true + spoolHandoffRequired: true + bridge: + enabled: true + truthfulDeliveryStates: + - dispatched + - pending_external_send + - acked + - blocked + sender: + mode: openclaw-cli + directAckPreferred: true + allowDryRunFallback: false + orchestrator: + enabled: true + executionOrder: + - runner + - queue + - dispatcher + - bridge + - sender + - ack_or_blocked_or_pending + notifications: + operatorVisibleRecoveryRequired: true + allowedTerminalStates: + - acked + - blocked + - pending_external_send + treatPendingExternalSendAsIncidentOpen: true + audit: + portableArtifactsRequired: true + exportManifestRequired: true + retainOriginalAttemptedMessageOnRewrite: true + retentionDays: 30 + requiredArtifacts: + - canonical_events + - evidence_records + - decision_records + - queue_items + - spool_artifacts + - bridge_receipts + - capability_descriptor + - profile_snapshot + capability_expectations: + required: + - emit_canonical_events + - evaluate_watchdog_overdue + - create_queue_items + - create_spool_handoff + - write_bridge_receipts + preferred: + - direct_sender_binding + - final_delivery_ack + - inline_dispatch_blocking diff --git a/plugins/reporting-governance/profiles/strict-manager-mode.profile.json b/plugins/reporting-governance/profiles/strict-manager-mode.profile.json index 16fbdec..4b5a522 100644 --- a/plugins/reporting-governance/profiles/strict-manager-mode.profile.json +++ b/plugins/reporting-governance/profiles/strict-manager-mode.profile.json @@ -1,12 +1,12 @@ { - "$schema": "../../../schemas/reporting-governance/deployment-profile.schema.json", + "$schema": "./schemas/reporting-governance/deployment-profile.schema.json", "apiVersion": "reporting-governance/v1alpha1", "kind": "DeploymentProfileArtifact", "metadata": { "id": "strict-manager-mode", "version": "1.0.0", "runtime": "openclaw", - "source_profile": "profiles/strict-manager-mode.yaml", + "source_profile": "profiles-src/strict-manager-mode.yaml", "compatibility_mode": "strict_envelope" }, "spec": { diff --git a/plugins/reporting-governance/schemas/reporting-governance/capability-descriptor.schema.json b/plugins/reporting-governance/schemas/reporting-governance/capability-descriptor.schema.json new file mode 100644 index 0000000..05f73a3 --- /dev/null +++ b/plugins/reporting-governance/schemas/reporting-governance/capability-descriptor.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cowbay.org/schemas/reporting-governance/capability-descriptor.schema.json", + "title": "Reporting Governance Capability Descriptor", + "description": "Canonical package-level capability descriptor for reporting-governance runtime compositions.", + "allOf": [ + { + "$ref": "./adapter-capabilities.schema.json" + } + ] +} diff --git a/plugins/reporting-governance/schemas/reporting-governance/deployment-profile.schema.json b/plugins/reporting-governance/schemas/reporting-governance/deployment-profile.schema.json new file mode 100644 index 0000000..1eed3b9 --- /dev/null +++ b/plugins/reporting-governance/schemas/reporting-governance/deployment-profile.schema.json @@ -0,0 +1,197 @@ +{ + "$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": ["hook", "watchdog", "queue", "dispatcher", "bridge", "sender", "orchestrator"], + "properties": { + "hook": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "watchdog": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "queue": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "dispatcher": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "bridge": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "sender": { + "type": "object", + "additionalProperties": true, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": ["openclaw-cli", "shim_or_openclaw_cli", "dry-run_or_external"] + }, + "enabled": { "type": "boolean" } + } + }, + "orchestrator": { + "type": "object", + "additionalProperties": true, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + } + } + }, + "notifications": { + "type": "object", + "additionalProperties": true, + "required": ["operatorVisibleRecoveryRequired", "allowedTerminalStates"], + "properties": { + "operatorVisibleRecoveryRequired": { "type": "boolean" }, + "allowedTerminalStates": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["acked", "blocked", "pending_external_send"] + } + } + } + }, + "audit": { + "type": "object", + "additionalProperties": true, + "required": ["portableArtifactsRequired", "requiredArtifacts"], + "properties": { + "portableArtifactsRequired": { "type": "boolean" }, + "requiredArtifacts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "canonical_events", + "evidence_records", + "decision_records", + "queue_items", + "spool_artifacts", + "bridge_receipts", + "capability_descriptor", + "profile_snapshot" + ] + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "portableArtifactsRequired": { "const": true } + }, + "required": ["portableArtifactsRequired"] + }, + "then": { + "properties": { + "requiredArtifacts": { + "allOf": [ + { + "contains": { "const": "canonical_events" } + }, + { + "contains": { "const": "queue_items" } + }, + { + "contains": { "const": "bridge_receipts" } + } + ] + } + } + } + } + ] + }, + "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/plugins/reporting-governance/scripts/long_task_watchdog.mjs b/plugins/reporting-governance/scripts/long_task_watchdog.mjs new file mode 100755 index 0000000..a70021b --- /dev/null +++ b/plugins/reporting-governance/scripts/long_task_watchdog.mjs @@ -0,0 +1,433 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import crypto from 'node:crypto'; + +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'); +const DEFAULT_EVENT_DIR = path.join(ROOT_DIR, 'state', 'long-task-watchdog-events'); +const DEFAULT_NOTIFICATION_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-queue'); + +function parseArgs(argv) { + const args = { + compact: false, + state: DEFAULT_STATE_PATH, + now: null, + evidenceDir: DEFAULT_EVIDENCE_DIR, + eventDir: DEFAULT_EVENT_DIR, + notificationDir: DEFAULT_NOTIFICATION_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; + } + if (token === '--event-dir') { + args.eventDir = argv[i + 1] ?? args.eventDir; + i += 1; + continue; + } + if (token.startsWith('--event-dir=')) { + args.eventDir = token.slice('--event-dir='.length) || args.eventDir; + continue; + } + if (token === '--notification-dir') { + args.notificationDir = argv[i + 1] ?? args.notificationDir; + i += 1; + continue; + } + if (token.startsWith('--notification-dir=')) { + args.notificationDir = token.slice('--notification-dir='.length) || args.notificationDir; + continue; + } + } + + return args; +} + +function printHelp() { + process.stdout.write([ + 'Usage: node scripts/long_task_watchdog.mjs [--compact] [--write-state] [--state ] [--now ] [--evidence-dir ] [--event-dir ] [--notification-dir ]', + '', + 'Minimal file-backed long-task watchdog runner with operator-notification queue output.', + ].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 makeEventId(prefix) { + return `${prefix}_${crypto.randomUUID()}`; +} + +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 writeJsonFile(dirPath, fileName, payload) { + ensureDir(dirPath); + const filePath = path.join(dirPath, fileName); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return filePath; +} + +function buildBaseRefs(evidencePath) { + return [ + { + kind: 'runtime_artifact', + path: evidencePath, + label: 'watchdog_evidence', + }, + ]; +} + +function writeEvidence(evidenceDir, watchdog, evaluation, nowIso) { + const fileName = `${nowIso.replace(/[:]/g, '').replace(/\.\d{3}Z$/, 'Z')}-${toSafeName(watchdog.id)}.json`; + 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', + ], + }; + return writeJsonFile(evidenceDir, fileName, payload); +} + +function buildWatchdogEvent(watchdog, evaluation, nowIso, evidencePath) { + const eventId = makeEventId('evt'); + return { + event_id: eventId, + event_type: 'watchdog_fired', + runtime: 'openclaw', + adapter_version: '1.1.0', + agent_id: watchdog.ownerSessionKey ?? watchdog.ownerAgentId ?? 'agent:unknown', + task_id: watchdog.id, + correlation_id: `watchdog:${watchdog.id}`, + timestamp: nowIso, + payload: { + watchdog_type: 'long_task_overdue', + trigger_reason: evaluation.reason, + triggered_at: nowIso, + policy_id: 'long-task-watchdog-overdue-v1', + severity: 'critical', + due_at: evaluation.dueAt ?? null, + minutes_overdue: evaluation.minutesOverdue ?? null, + }, + evidence_refs: buildBaseRefs(evidencePath), + operator_context: { + channel: watchdog.reportChannel ?? watchdog.channel ?? null, + operator_id: watchdog.reportTarget ?? watchdog.target ?? null, + reporting_mode: 'watchdog', + silent_task: true, + watchdog_policy_id: 'long-task-watchdog-overdue-v1', + }, + }; +} + +function writeEvent(eventDir, watchdog, nowIso, event) { + const fileName = `${nowIso.replace(/[:]/g, '').replace(/\.\d{3}Z$/, 'Z')}-${toSafeName(watchdog.id)}-watchdog-fired.json`; + return writeJsonFile(eventDir, fileName, event); +} + +function buildNotificationMessage(watchdog, evaluation, nowIso) { + const lines = [ + '【Watchdog 逾時告警】', + `task: ${watchdog.task ?? watchdog.id ?? 'unknown-task'}`, + `watchdog: ${watchdog.id ?? 'unknown-watchdog'}`, + `dueAt: ${evaluation.dueAt ?? 'unknown'}`, + `minutesOverdue: ${evaluation.minutesOverdue ?? 'unknown'}`, + `lastMilestoneAt: ${watchdog.lastMilestoneAt ?? 'unknown'}`, + `triggeredAt: ${nowIso}`, + `ownerSessionKey: ${watchdog.ownerSessionKey ?? watchdog.ownerSession ?? 'unknown'}`, + 'requiredAction: 請立即對 owner/operator 發出可見更新,或檢查/重派 stalled task。', + ]; + return lines.join('\n'); +} + +function buildNotificationQueueItem(watchdog, evaluation, nowIso, evidencePath, eventPath, eventId) { + const notificationId = makeEventId('notify'); + const channel = watchdog.reportChannel ?? watchdog.channel ?? null; + const target = watchdog.reportTarget ?? watchdog.target ?? null; + return { + notification_id: notificationId, + kind: 'notify_operator', + status: 'pending', + created_at: nowIso, + source_tool: 'long_task_watchdog', + severity: 'critical', + operator_notice: { + required: true, + channel, + target, + urgency: 'critical', + message: buildNotificationMessage(watchdog, evaluation, nowIso), + must_reference: ['watchdog_fired', 'forced_operator_update'], + }, + dispatch_hint: { + tool: 'message.send', + channel, + target, + message: buildNotificationMessage(watchdog, evaluation, nowIso), + }, + governance: { + task_id: watchdog.id, + correlation_id: `watchdog:${watchdog.id}`, + trigger_event_id: eventId, + trigger_event_type: 'watchdog_fired', + decision: 'force_checkpoint', + policy_id: 'long-task-watchdog-overdue-v1', + reason: evaluation.reason, + }, + evidence_refs: [ + ...buildBaseRefs(evidencePath), + { + kind: 'runtime_artifact', + path: eventPath, + label: 'watchdog_event', + }, + ], + blocked_gap: channel && target + ? null + : 'watchdog state does not define reportChannel/reportTarget, so dispatcher target is incomplete', + }; +} + +function writeNotificationQueueItem(notificationDir, watchdog, nowIso, queueItem) { + const fileName = `${nowIso.replace(/[:]/g, '').replace(/\.\d{3}Z$/, 'Z')}-${toSafeName(watchdog.id)}-notify-operator.json`; + return writeJsonFile(notificationDir, fileName, queueItem); +} + +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 eventWrites = []; + const notificationWrites = []; + 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 }); + + const event = buildWatchdogEvent(watchdog, evaluation, nowIso, evidencePath); + const eventPath = writeEvent(args.eventDir, watchdog, nowIso, event); + eventWrites.push({ watchdogId: watchdog.id, path: eventPath, eventId: event.event_id, eventType: event.event_type }); + + const notification = buildNotificationQueueItem(watchdog, evaluation, nowIso, evidencePath, eventPath, event.event_id); + const notificationPath = writeNotificationQueueItem(args.notificationDir, watchdog, nowIso, notification); + notificationWrites.push({ + watchdogId: watchdog.id, + path: notificationPath, + notificationId: notification.notification_id, + channel: notification.operator_notice.channel, + target: notification.operator_notice.target, + dispatchReady: notification.blocked_gap === null, + blockedGap: notification.blocked_gap, + }); + + return { + ...watchdog, + lastAlertAt: nowIso, + lastObservedActivityAt: watchdog.lastObservedActivityAt ?? watchdog.lastMilestoneAt ?? null, + lastNudgeAt: nowIso, + }; + }); + + 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-v2', + statePath: path.resolve(args.state), + evidenceDir: path.resolve(args.evidenceDir), + eventDir: path.resolve(args.eventDir), + notificationDir: path.resolve(args.notificationDir), + 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, + eventCount: eventWrites.length, + notificationCount: notificationWrites.length, + evaluations, + evidenceWrites, + eventWrites, + notificationWrites, + }, + }; + + process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`); +} + +main(); diff --git a/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs b/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs new file mode 100644 index 0000000..8ad22c2 --- /dev/null +++ b/plugins/reporting-governance/scripts/operator_notify_bridge_supervisor.mjs @@ -0,0 +1,344 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +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 DEFAULT_SPOOL_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-dispatch-spool'); +const DEFAULT_QUEUE_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-queue'); +const DEFAULT_RECEIPT_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-bridge-receipts'); +const DEFAULT_DISPATCHER_SCRIPT = path.join(ROOT_DIR, 'scripts', 'operator_notify_dispatcher.mjs'); + +function parseArgs(argv) { + const args = { + spoolDir: DEFAULT_SPOOL_DIR, + queueDir: DEFAULT_QUEUE_DIR, + receiptDir: DEFAULT_RECEIPT_DIR, + dispatcherScript: DEFAULT_DISPATCHER_SCRIPT, + now: null, + compact: false, + help: false, + dryRun: false, + senderCommand: null, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--compact') { + args.compact = true; + continue; + } + if (token === '--help' || token === '-h') { + args.help = true; + continue; + } + if (token === '--dry-run') { + args.dryRun = true; + continue; + } + if (token === '--spool-dir') { + args.spoolDir = argv[i + 1] ?? args.spoolDir; + i += 1; + continue; + } + if (token.startsWith('--spool-dir=')) { + args.spoolDir = token.slice('--spool-dir='.length) || args.spoolDir; + continue; + } + if (token === '--queue-dir') { + args.queueDir = argv[i + 1] ?? args.queueDir; + i += 1; + continue; + } + if (token.startsWith('--queue-dir=')) { + args.queueDir = token.slice('--queue-dir='.length) || args.queueDir; + continue; + } + if (token === '--receipt-dir') { + args.receiptDir = argv[i + 1] ?? args.receiptDir; + i += 1; + continue; + } + if (token.startsWith('--receipt-dir=')) { + args.receiptDir = token.slice('--receipt-dir='.length) || args.receiptDir; + continue; + } + if (token === '--dispatcher-script') { + args.dispatcherScript = argv[i + 1] ?? args.dispatcherScript; + i += 1; + continue; + } + if (token.startsWith('--dispatcher-script=')) { + args.dispatcherScript = token.slice('--dispatcher-script='.length) || args.dispatcherScript; + continue; + } + if (token === '--sender-command') { + args.senderCommand = argv[i + 1] ?? null; + i += 1; + continue; + } + if (token.startsWith('--sender-command=')) { + args.senderCommand = token.slice('--sender-command='.length) || null; + 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; + } + } + + return args; +} + +function printHelp() { + process.stdout.write([ + 'Usage:', + ' node scripts/operator_notify_bridge_supervisor.mjs [--spool-dir ] [--queue-dir ] [--receipt-dir ] [--sender-command ] [--dry-run] [--now ] [--compact]', + '', + 'Supervisor contract for consuming dispatch spool artifacts and delegating the real send step to an upper runtime.', + 'This script does not embed OpenClaw message.send itself.', + 'Instead it prepares env vars, executes an injected sender command, and then writes ack/block receipts via operator_notify_dispatcher.mjs.', + ].join('\n') + '\n'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +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 readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function listJsonFiles(dirPath) { + if (!fs.existsSync(dirPath)) return []; + return fs.readdirSync(dirPath) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => path.join(dirPath, name)); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +function buildEnv(spoolItem, spoolPath, queueDir, nowIso) { + return { + OPERATOR_NOTIFY_SPOOL_PATH: spoolPath, + OPERATOR_NOTIFY_QUEUE_ITEM_PATH: spoolItem.queue_item_path, + OPERATOR_NOTIFY_NOTIFICATION_ID: spoolItem.notification_id ?? '', + OPERATOR_NOTIFY_CHANNEL: spoolItem?.dispatch_contract?.channel ?? '', + OPERATOR_NOTIFY_TARGET: spoolItem?.dispatch_contract?.target ?? '', + OPERATOR_NOTIFY_MESSAGE: spoolItem?.dispatch_contract?.message ?? '', + OPERATOR_NOTIFY_QUEUE_DIR: queueDir, + OPERATOR_NOTIFY_NOW: nowIso, + }; +} + +function buildSuggestedCommand(spoolItem) { + return [ + 'openclaw message send', + `--channel ${shellQuote(spoolItem?.dispatch_contract?.channel ?? '')}`, + `--target ${shellQuote(spoolItem?.dispatch_contract?.target ?? '')}`, + `--message ${shellQuote(spoolItem?.dispatch_contract?.message ?? '')}`, + ].join(' '); +} + +function writeReceipt(receiptDir, spoolItem, nowIso, state, extra = {}) { + const notificationId = spoolItem.notification_id ?? 'unknown-notification'; + const receiptPath = path.join(receiptDir, `${notificationId}-${state}.json`); + writeJson(receiptPath, { + receipt_version: '1.0.0', + created_at: nowIso, + state, + notification_id: notificationId, + queue_item_path: spoolItem.queue_item_path, + spool_dispatch_path: extra.spoolPath ?? null, + dispatch_contract: spoolItem.dispatch_contract ?? null, + ...extra, + }); + return receiptPath; +} + +function runTransition(dispatcherScript, queueDir, mode, targetValue, note, nowIso) { + const args = [dispatcherScript, '--queue-dir', queueDir, '--compact', '--now', nowIso, `--${mode}`, targetValue]; + if (note) { + args.push('--note', note); + } + return spawnSync(process.execPath, args, { cwd: ROOT_DIR, encoding: 'utf8' }); +} + +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 spoolDir = path.resolve(args.spoolDir); + const queueDir = path.resolve(args.queueDir); + const receiptDir = path.resolve(args.receiptDir); + const dispatcherScript = path.resolve(args.dispatcherScript); + ensureDir(spoolDir); + ensureDir(receiptDir); + + const spoolFiles = listJsonFiles(spoolDir); + const scanned = []; + const acked = []; + const blocked = []; + const pending = []; + const skipped = []; + + for (const spoolPath of spoolFiles) { + const spoolItem = readJson(spoolPath); + const notificationId = spoolItem.notification_id ?? null; + const queueItemPath = spoolItem.queue_item_path ?? null; + const channel = spoolItem?.dispatch_contract?.channel ?? null; + const target = spoolItem?.dispatch_contract?.target ?? null; + const message = spoolItem?.dispatch_contract?.message ?? null; + scanned.push({ spoolPath, notificationId, queueItemPath }); + + if (!queueItemPath || !channel || !target || !message) { + const reason = 'spool artifact missing queue_item_path/channel/target/message'; + const receiptPath = writeReceipt(receiptDir, spoolItem, nowIso, 'blocked', { spoolPath, reason, blocked_by: 'bridge_supervisor' }); + const transition = runTransition(dispatcherScript, queueDir, 'block', queueItemPath || notificationId || '', reason, nowIso); + blocked.push({ spoolPath, notificationId, queueItemPath, reason, receiptPath, transitionStatus: transition.status }); + continue; + } + + const envPayload = buildEnv(spoolItem, spoolPath, queueDir, nowIso); + if (args.dryRun || !args.senderCommand) { + const receiptPath = writeReceipt(receiptDir, spoolItem, nowIso, 'pending_external_send', { + spoolPath, + supervisor_mode: args.dryRun ? 'dry_run' : 'no_sender_command', + suggested_command: buildSuggestedCommand(spoolItem), + env: envPayload, + }); + pending.push({ spoolPath, notificationId, queueItemPath, receiptPath, reason: args.dryRun ? 'dry_run' : 'sender_command_not_configured' }); + continue; + } + + const sender = spawnSync(args.senderCommand, { + cwd: ROOT_DIR, + encoding: 'utf8', + shell: true, + env: { + ...process.env, + ...envPayload, + }, + }); + + let senderBinding = null; + try { + senderBinding = sender.stdout ? JSON.parse(sender.stdout) : null; + } catch { + senderBinding = null; + } + + const bindingState = senderBinding?.state ?? null; + const bindingReason = senderBinding?.reason ?? null; + + if (sender.status === 0 && bindingState === 'sent') { + const note = `message.send delivered by upper runtime via bridge supervisor at ${nowIso}`; + const transition = runTransition(dispatcherScript, queueDir, 'ack', queueItemPath, note, nowIso); + const receiptPath = writeReceipt(receiptDir, spoolItem, nowIso, 'acked', { + spoolPath, + supervisor_mode: 'sender_command', + sender_binding: senderBinding, + sender_status: sender.status, + sender_stdout: sender.stdout ?? '', + sender_stderr: sender.stderr ?? '', + dispatcher_transition_status: transition.status, + }); + acked.push({ spoolPath, notificationId, queueItemPath, receiptPath, transitionStatus: transition.status }); + continue; + } + + if (sender.status === 0 && bindingState === 'pending') { + const receiptPath = writeReceipt(receiptDir, spoolItem, nowIso, 'pending_external_send', { + spoolPath, + supervisor_mode: 'sender_command', + sender_binding: senderBinding, + sender_status: sender.status, + sender_stdout: sender.stdout ?? '', + sender_stderr: sender.stderr ?? '', + reason: bindingReason ?? 'sender binding reported pending', + }); + pending.push({ spoolPath, notificationId, queueItemPath, receiptPath, reason: bindingReason ?? 'sender_binding_pending' }); + continue; + } + + const reason = bindingReason + ?? (sender.status === 0 && bindingState === 'blocked' + ? 'sender binding reported blocked' + : `upper runtime sender command failed with status ${sender.status ?? 'null'}`); + const transition = runTransition(dispatcherScript, queueDir, 'block', queueItemPath, reason, nowIso); + const receiptPath = writeReceipt(receiptDir, spoolItem, nowIso, 'blocked', { + spoolPath, + supervisor_mode: 'sender_command', + sender_binding: senderBinding, + reason, + sender_status: sender.status, + sender_stdout: sender.stdout ?? '', + sender_stderr: sender.stderr ?? '', + dispatcher_transition_status: transition.status, + }); + blocked.push({ spoolPath, notificationId, queueItemPath, reason, receiptPath, transitionStatus: transition.status }); + } + + const response = { + ok: true, + tool: 'operator_notify_bridge_supervisor', + version: 'mvp-v1', + now: nowIso, + spoolDir, + queueDir, + receiptDir, + senderCommandConfigured: Boolean(args.senderCommand), + dryRun: args.dryRun, + result: { + scannedCount: scanned.length, + ackedCount: acked.length, + blockedCount: blocked.length, + pendingCount: pending.length, + skippedCount: skipped.length, + scanned, + acked, + blocked, + pending, + skipped, + }, + }; + + process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`); +} + +main(); diff --git a/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs b/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs new file mode 100644 index 0000000..33acb37 --- /dev/null +++ b/plugins/reporting-governance/scripts/operator_notify_dispatcher.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import crypto from 'node:crypto'; + +const ROOT_DIR = path.resolve(import.meta.dirname, '..'); +const DEFAULT_QUEUE_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-queue'); +const DEFAULT_SPOOL_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-dispatch-spool'); + +function parseArgs(argv) { + const args = { + queueDir: DEFAULT_QUEUE_DIR, + spoolDir: DEFAULT_SPOOL_DIR, + now: null, + compact: false, + ack: null, + block: null, + note: null, + claim: 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 === '--claim') { + args.claim = true; + continue; + } + if (token === '--help' || token === '-h') { + args.help = true; + continue; + } + if (token === '--queue-dir') { + args.queueDir = argv[i + 1] ?? args.queueDir; + i += 1; + continue; + } + if (token.startsWith('--queue-dir=')) { + args.queueDir = token.slice('--queue-dir='.length) || args.queueDir; + continue; + } + if (token === '--spool-dir') { + args.spoolDir = argv[i + 1] ?? args.spoolDir; + i += 1; + continue; + } + if (token.startsWith('--spool-dir=')) { + args.spoolDir = token.slice('--spool-dir='.length) || args.spoolDir; + 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 === '--ack') { + args.ack = argv[i + 1] ?? null; + i += 1; + continue; + } + if (token.startsWith('--ack=')) { + args.ack = token.slice('--ack='.length) || null; + continue; + } + if (token === '--block') { + args.block = argv[i + 1] ?? null; + i += 1; + continue; + } + if (token.startsWith('--block=')) { + args.block = token.slice('--block='.length) || null; + continue; + } + if (token === '--note') { + args.note = argv[i + 1] ?? null; + i += 1; + continue; + } + if (token.startsWith('--note=')) { + args.note = token.slice('--note='.length) || null; + continue; + } + } + + return args; +} + +function printHelp() { + process.stdout.write([ + 'Usage:', + ' node scripts/operator_notify_dispatcher.mjs [--queue-dir ] [--spool-dir ] [--now ] [--compact] [--claim]', + ' node scripts/operator_notify_dispatcher.mjs --ack [--queue-dir ] [--now ] [--note ] [--compact]', + ' node scripts/operator_notify_dispatcher.mjs --block [--queue-dir ] [--now ] [--note ] [--compact]', + '', + 'File-backed dispatcher for state/operator-notify-queue.', + 'Default dispatch mode is spool/handoff only: it never claims to call message.send directly.', + ].join('\n') + '\n'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +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 safeSlug(value) { + return String(value || 'item') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) || 'item'; +} + +function listJsonFiles(dirPath) { + if (!fs.existsSync(dirPath)) return []; + return fs.readdirSync(dirPath) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => path.join(dirPath, name)); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function isPendingLike(status) { + return status === 'pending' || status === 'claimed'; +} + +function resolveChannel(payload) { + return payload?.operator_notice?.channel ?? payload?.dispatch_hint?.channel ?? null; +} + +function resolveTarget(payload) { + return payload?.operator_notice?.target ?? payload?.dispatch_hint?.target ?? null; +} + +function resolveMessage(payload) { + return payload?.operator_notice?.message ?? payload?.dispatch_hint?.message ?? null; +} + +function markBlocked(payload, nowIso, reason) { + return { + ...payload, + status: 'blocked', + blocked_gap: reason, + blocked_at: nowIso, + dispatch_result: { + mode: 'spool_only', + state: 'blocked', + blockedAt: nowIso, + reason, + }, + }; +} + +function claimPayload(payload, nowIso) { + return { + ...payload, + status: 'claimed', + dispatch_claimed_at: nowIso, + dispatch_result: { + ...(payload.dispatch_result ?? {}), + mode: 'spool_only', + state: 'claimed', + claimedAt: nowIso, + }, + }; +} + +function buildSpoolPayload(payload, queuePath, nowIso) { + const channel = resolveChannel(payload); + const target = resolveTarget(payload); + const message = resolveMessage(payload); + const notificationId = payload.notification_id ?? crypto.randomUUID(); + return { + handoff_version: '1.0.0', + created_at: nowIso, + queue_item_path: queuePath, + notification_id: notificationId, + dispatch_contract: { + executor: 'message.send', + mode: 'manual_handoff', + requires_human_or_upper_runtime: true, + channel, + target, + message, + }, + governance: payload.governance ?? null, + evidence_refs: payload.evidence_refs ?? [], + queue_status_before_dispatch: payload.status ?? 'pending', + ack_instructions: { + command: `node scripts/operator_notify_dispatcher.mjs --ack ${JSON.stringify(queuePath)} --note ${JSON.stringify('message.send delivered by upper runtime')}`, + accepted_status_transition: 'dispatched -> acked', + }, + block_instructions: { + command: `node scripts/operator_notify_dispatcher.mjs --block ${JSON.stringify(queuePath)} --note ${JSON.stringify('upper runtime could not safely deliver message.send')}`, + accepted_status_transition: 'dispatched -> blocked', + }, + }; +} + +function dispatchPayload(payload, nowIso, spoolPath) { + return { + ...payload, + status: 'dispatched', + dispatched_at: nowIso, + blocked_gap: null, + dispatch_result: { + mode: 'spool_only', + state: 'dispatched', + dispatchedAt: nowIso, + spoolPath, + executor: 'message.send', + delivery: 'handoff_pending_ack', + ackRequired: true, + }, + }; +} + +function findQueueItem(queueDir, targetValue) { + if (!targetValue) return null; + const directPath = path.isAbsolute(targetValue) ? targetValue : path.join(queueDir, targetValue); + if (fs.existsSync(directPath)) return directPath; + for (const filePath of listJsonFiles(queueDir)) { + try { + const payload = readJson(filePath); + if (payload?.notification_id === targetValue) return filePath; + } catch {} + } + return null; +} + +function transitionItem(queueDir, targetValue, nowIso, transition, note) { + const queuePath = findQueueItem(queueDir, targetValue); + if (!queuePath) { + return { + ok: false, + error: `queue item not found for transition target: ${targetValue}`, + }; + } + + const payload = readJson(queuePath); + const previousStatus = payload?.status ?? null; + + if (transition === 'ack') { + const nextPayload = { + ...payload, + status: 'acked', + acked_at: nowIso, + ack_note: note ?? null, + dispatch_result: { + ...(payload.dispatch_result ?? {}), + state: 'acked', + ackedAt: nowIso, + ackNote: note ?? null, + }, + }; + writeJson(queuePath, nextPayload); + return { + ok: true, + mode: 'ack', + queuePath, + notificationId: payload.notification_id ?? null, + previousStatus, + nextStatus: 'acked', + }; + } + + if (transition === 'block') { + const reason = note ?? payload.blocked_gap ?? 'blocked by upper runtime'; + const nextPayload = { + ...payload, + status: 'blocked', + blocked_at: nowIso, + blocked_gap: reason, + dispatch_result: { + ...(payload.dispatch_result ?? {}), + state: 'blocked', + blockedAt: nowIso, + reason, + }, + }; + writeJson(queuePath, nextPayload); + return { + ok: true, + mode: 'block', + queuePath, + notificationId: payload.notification_id ?? null, + previousStatus, + nextStatus: 'blocked', + reason, + }; + } + + return { + ok: false, + error: `unsupported transition: ${transition}`, + }; +} + +function ackItem(queueDir, ackValue, nowIso, note) { + return transitionItem(queueDir, ackValue, nowIso, 'ack', note); +} + +function blockItem(queueDir, targetValue, nowIso, note) { + return transitionItem(queueDir, targetValue, nowIso, 'block', note); +} + +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); + + if (args.ack) { + const ackResult = ackItem(path.resolve(args.queueDir), args.ack, nowIso, args.note); + if (!ackResult.ok) { + process.stderr.write(`${ackResult.error}\n`); + process.exit(1); + } + process.stdout.write(`${JSON.stringify({ ok: true, tool: 'operator_notify_dispatcher', mode: 'ack', now: nowIso, result: ackResult }, null, args.compact ? 0 : 2)}\n`); + return; + } + + if (args.block) { + const blockResult = blockItem(path.resolve(args.queueDir), args.block, nowIso, args.note); + if (!blockResult.ok) { + process.stderr.write(`${blockResult.error}\n`); + process.exit(1); + } + process.stdout.write(`${JSON.stringify({ ok: true, tool: 'operator_notify_dispatcher', mode: 'block', now: nowIso, result: blockResult }, null, args.compact ? 0 : 2)}\n`); + return; + } + + const queueDir = path.resolve(args.queueDir); + const spoolDir = path.resolve(args.spoolDir); + ensureDir(queueDir); + ensureDir(spoolDir); + + const files = listJsonFiles(queueDir); + const queueScanned = []; + const blocked = []; + const dispatched = []; + const skipped = []; + const claimed = []; + + for (const filePath of files) { + const payload = readJson(filePath); + queueScanned.push({ path: filePath, notificationId: payload.notification_id ?? null, status: payload.status ?? null }); + + if (!isPendingLike(payload.status)) { + skipped.push({ path: filePath, notificationId: payload.notification_id ?? null, status: payload.status ?? null, reason: 'not_pending_like' }); + continue; + } + + const channel = resolveChannel(payload); + const target = resolveTarget(payload); + const message = resolveMessage(payload); + + if (!channel || !target || !message) { + const reason = payload.blocked_gap || 'queue item missing channel/target/message for safe dispatch'; + const nextPayload = markBlocked(payload, nowIso, reason); + writeJson(filePath, nextPayload); + blocked.push({ path: filePath, notificationId: payload.notification_id ?? null, status: 'blocked', reason }); + continue; + } + + let workingPayload = payload; + if (args.claim && payload.status === 'pending') { + workingPayload = claimPayload(payload, nowIso); + writeJson(filePath, workingPayload); + claimed.push({ path: filePath, notificationId: payload.notification_id ?? null, status: 'claimed' }); + } + + const spoolPayload = buildSpoolPayload(workingPayload, filePath, nowIso); + const spoolName = `${safeSlug(payload.notification_id ?? path.basename(filePath, '.json'))}-dispatch.json`; + const spoolPath = path.join(spoolDir, spoolName); + writeJson(spoolPath, spoolPayload); + + const nextPayload = dispatchPayload(workingPayload, nowIso, spoolPath); + writeJson(filePath, nextPayload); + dispatched.push({ + path: filePath, + notificationId: payload.notification_id ?? null, + status: 'dispatched', + spoolPath, + delivery: 'handoff_pending_ack', + }); + } + + const response = { + ok: true, + tool: 'operator_notify_dispatcher', + version: 'mvp-v1', + mode: 'dispatch', + dispatchMode: 'spool_only', + queueDir, + spoolDir, + now: nowIso, + result: { + scannedCount: queueScanned.length, + blockedCount: blocked.length, + dispatchedCount: dispatched.length, + claimedCount: claimed.length, + skippedCount: skipped.length, + queueScanned, + claimed, + blocked, + dispatched, + skipped, + }, + }; + + process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`); +} + +main(); diff --git a/plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs b/plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs new file mode 100644 index 0000000..25f6160 --- /dev/null +++ b/plugins/reporting-governance/scripts/operator_notify_sender_binding.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +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 DEFAULT_ATTEMPT_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-sender-attempts'); + +function parseArgs(argv) { + const args = { + mode: 'shim', + attemptDir: DEFAULT_ATTEMPT_DIR, + now: null, + compact: false, + help: false, + openclawBin: 'openclaw', + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--help' || token === '-h') { + args.help = true; + continue; + } + if (token === '--compact') { + args.compact = true; + continue; + } + if (token === '--mode') { + args.mode = argv[i + 1] ?? args.mode; + i += 1; + continue; + } + if (token.startsWith('--mode=')) { + args.mode = token.slice('--mode='.length) || args.mode; + continue; + } + if (token === '--attempt-dir') { + args.attemptDir = argv[i + 1] ?? args.attemptDir; + i += 1; + continue; + } + if (token.startsWith('--attempt-dir=')) { + args.attemptDir = token.slice('--attempt-dir='.length) || args.attemptDir; + 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 === '--openclaw-bin') { + args.openclawBin = argv[i + 1] ?? args.openclawBin; + i += 1; + continue; + } + if (token.startsWith('--openclaw-bin=')) { + args.openclawBin = token.slice('--openclaw-bin='.length) || args.openclawBin; + continue; + } + } + + return args; +} + +function printHelp() { + process.stdout.write([ + 'Usage:', + ' node scripts/operator_notify_sender_binding.mjs [--mode shim|openclaw-cli] [--attempt-dir ] [--openclaw-bin ] [--now ] [--compact]', + '', + 'Reads OPERATOR_NOTIFY_* env vars prepared by operator_notify_bridge_supervisor.mjs and emits a JSON sender-binding result.', + 'States:', + ' sent -> bridge may ack queue item', + ' pending -> bridge should keep queue item dispatched and write pending_external_send receipt', + ' blocked -> bridge should mark queue item blocked', + ].join('\n') + '\n'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +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 safeSlug(value) { + return String(value || 'item') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) || 'item'; +} + +function writeJson(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function readContractFromEnv() { + return { + spoolPath: process.env.OPERATOR_NOTIFY_SPOOL_PATH ?? '', + queueItemPath: process.env.OPERATOR_NOTIFY_QUEUE_ITEM_PATH ?? '', + notificationId: process.env.OPERATOR_NOTIFY_NOTIFICATION_ID ?? '', + channel: process.env.OPERATOR_NOTIFY_CHANNEL ?? '', + target: process.env.OPERATOR_NOTIFY_TARGET ?? '', + message: process.env.OPERATOR_NOTIFY_MESSAGE ?? '', + queueDir: process.env.OPERATOR_NOTIFY_QUEUE_DIR ?? '', + now: process.env.OPERATOR_NOTIFY_NOW ?? '', + }; +} + +function validateContract(contract) { + const missing = []; + if (!contract.spoolPath) missing.push('OPERATOR_NOTIFY_SPOOL_PATH'); + if (!contract.queueItemPath) missing.push('OPERATOR_NOTIFY_QUEUE_ITEM_PATH'); + if (!contract.notificationId) missing.push('OPERATOR_NOTIFY_NOTIFICATION_ID'); + if (!contract.channel) missing.push('OPERATOR_NOTIFY_CHANNEL'); + if (!contract.target) missing.push('OPERATOR_NOTIFY_TARGET'); + if (!contract.message) missing.push('OPERATOR_NOTIFY_MESSAGE'); + return missing; +} + +function buildAttemptPath(attemptDir, notificationId, nowIso, mode) { + return path.join(attemptDir, `${safeSlug(notificationId)}-${safeSlug(nowIso)}-${safeSlug(mode)}.json`); +} + +function emit(payload, compact) { + process.stdout.write(`${JSON.stringify(payload, null, compact ? 0 : 2)}\n`); +} + +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 contract = readContractFromEnv(); + const missing = validateContract(contract); + + if (missing.length > 0) { + emit({ + ok: true, + tool: 'operator_notify_sender_binding', + version: 'mvp-v1', + mode: args.mode, + now: nowIso, + state: 'blocked', + reason: `missing sender env: ${missing.join(', ')}`, + contract, + }, args.compact); + return; + } + + const attemptDir = path.resolve(args.attemptDir); + ensureDir(attemptDir); + const attemptPath = buildAttemptPath(attemptDir, contract.notificationId, nowIso, args.mode); + + if (args.mode === 'shim') { + const payload = { + attempt_version: '1.0.0', + created_at: nowIso, + mode: 'shim', + state: 'pending', + reason: 'shim binding recorded a verifiable send attempt but cannot invoke privileged message.send from this repo/runtime boundary', + contract, + }; + writeJson(attemptPath, payload); + emit({ + ok: true, + tool: 'operator_notify_sender_binding', + version: 'mvp-v1', + mode: 'shim', + now: nowIso, + state: 'pending', + reason: payload.reason, + attemptPath, + }, args.compact); + return; + } + + if (args.mode === 'openclaw-cli') { + const commandArgs = [ + 'message', 'send', + '--channel', contract.channel, + '--target', contract.target, + '--message', contract.message, + ]; + const sender = spawnSync(args.openclawBin, commandArgs, { + cwd: ROOT_DIR, + encoding: 'utf8', + env: process.env, + }); + + const attemptPayload = { + attempt_version: '1.0.0', + created_at: nowIso, + mode: 'openclaw-cli', + command: [args.openclawBin, ...commandArgs], + contract, + sender_status: sender.status, + sender_stdout: sender.stdout ?? '', + sender_stderr: sender.stderr ?? '', + }; + + if (sender.status === 0) { + attemptPayload.state = 'sent'; + writeJson(attemptPath, attemptPayload); + emit({ + ok: true, + tool: 'operator_notify_sender_binding', + version: 'mvp-v1', + mode: 'openclaw-cli', + now: nowIso, + state: 'sent', + attemptPath, + sender_status: sender.status, + }, args.compact); + return; + } + + attemptPayload.state = 'blocked'; + attemptPayload.reason = `openclaw message send failed with status ${sender.status ?? 'null'}`; + writeJson(attemptPath, attemptPayload); + emit({ + ok: true, + tool: 'operator_notify_sender_binding', + version: 'mvp-v1', + mode: 'openclaw-cli', + now: nowIso, + state: 'blocked', + reason: attemptPayload.reason, + attemptPath, + sender_status: sender.status, + sender_stdout: sender.stdout ?? '', + sender_stderr: sender.stderr ?? '', + }, args.compact); + return; + } + + emit({ + ok: true, + tool: 'operator_notify_sender_binding', + version: 'mvp-v1', + mode: args.mode, + now: nowIso, + state: 'blocked', + reason: `unsupported sender binding mode: ${args.mode}`, + }, args.compact); +} + +main(); diff --git a/plugins/reporting-governance/scripts/package-smoke.mjs b/plugins/reporting-governance/scripts/package-smoke.mjs new file mode 100644 index 0000000..753e72a --- /dev/null +++ b/plugins/reporting-governance/scripts/package-smoke.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import { generateDeploymentProfileArtifactFromFile } from '../src/storage/profile-generator.mjs'; +import { runOrchestratorAdapter } from '../src/adapters/orchestrator.mjs'; + +const packageRoot = path.resolve(import.meta.dirname, '..'); + +function parseArgs(argv) { + const args = { + workspace: null, + now: '2026-05-07T08:20:00.000Z', + compact: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--compact') { args.compact = true; continue; } + if (token === '--workspace') { args.workspace = argv[i + 1] ?? null; i += 1; continue; } + if (token.startsWith('--workspace=')) { args.workspace = token.slice('--workspace='.length) || null; continue; } + if (token === '--now') { args.now = argv[i + 1] ?? args.now; i += 1; continue; } + if (token.startsWith('--now=')) { args.now = token.slice('--now='.length) || args.now; continue; } + } + + return args; +} + +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 package smoke', + 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; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const workspace = path.resolve(args.workspace ?? fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-package-smoke-'))); + mkdirs(workspace, ['evidence', 'events', 'queue', 'spool', 'receipts']); + const state = writeState(workspace); + + const profileSourcePath = path.join(packageRoot, 'profiles-src', 'strict-manager-mode.yaml'); + const artifact = generateDeploymentProfileArtifactFromFile(profileSourcePath); + const artifactPath = path.join(workspace, 'strict-manager-mode.profile.json'); + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + + const result = runOrchestratorAdapter({ + profileArtifact: artifact, + repoRootOverride: packageRoot, + state, + evidenceDir: path.join(workspace, 'evidence'), + eventDir: path.join(workspace, 'events'), + queueDir: path.join(workspace, 'queue'), + spoolDir: path.join(workspace, 'spool'), + receiptDir: path.join(workspace, 'receipts'), + writeState: true, + dryRun: true, + now: args.now, + }); + + const payload = { + ok: true, + tool: 'reporting-governance-package-smoke', + packageRoot, + workspace, + artifactPath, + generatedProfileSource: profileSourcePath, + orchestrator: { + ok: result?.ok === true, + dispatchedCount: result?.result?.dispatcher?.dispatchedCount ?? null, + pendingCount: result?.result?.supervisor?.pendingCount ?? null, + notificationCount: result?.result?.watchdog?.notificationCount ?? null, + executionOrder: result?.executionOrder ?? null, + }, + }; + + process.stdout.write(`${JSON.stringify(payload, null, args.compact ? 0 : 2)}\n`); +} + +main(); diff --git a/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs b/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs new file mode 100644 index 0000000..02cfb09 --- /dev/null +++ b/plugins/reporting-governance/scripts/watchdog_auto_notify_orchestrator.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +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 DEFAULT_STATE_PATH = path.join(ROOT_DIR, 'memory', 'watchdog-state.json'); +const DEFAULT_EVIDENCE_DIR = path.join(ROOT_DIR, 'state', 'long-task-watchdog'); +const DEFAULT_EVENT_DIR = path.join(ROOT_DIR, 'state', 'long-task-watchdog-events'); +const DEFAULT_QUEUE_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-queue'); +const DEFAULT_SPOOL_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-dispatch-spool'); +const DEFAULT_RECEIPT_DIR = path.join(ROOT_DIR, 'state', 'operator-notify-bridge-receipts'); +const DEFAULT_WATCHDOG_SCRIPT = path.join(ROOT_DIR, 'scripts', 'long_task_watchdog.mjs'); +const DEFAULT_DISPATCHER_SCRIPT = path.join(ROOT_DIR, 'scripts', 'operator_notify_dispatcher.mjs'); +const DEFAULT_SUPERVISOR_SCRIPT = path.join(ROOT_DIR, 'scripts', 'operator_notify_bridge_supervisor.mjs'); + +function parseArgs(argv) { + const args = { + state: DEFAULT_STATE_PATH, + evidenceDir: DEFAULT_EVIDENCE_DIR, + eventDir: DEFAULT_EVENT_DIR, + queueDir: DEFAULT_QUEUE_DIR, + spoolDir: DEFAULT_SPOOL_DIR, + receiptDir: DEFAULT_RECEIPT_DIR, + watchdogScript: DEFAULT_WATCHDOG_SCRIPT, + dispatcherScript: DEFAULT_DISPATCHER_SCRIPT, + supervisorScript: DEFAULT_SUPERVISOR_SCRIPT, + senderCommand: null, + senderMode: null, + openclawBin: 'openclaw', + now: null, + compact: false, + writeState: false, + claim: false, + dryRun: false, + help: false, + }; + + 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 === '--claim') { args.claim = true; continue; } + if (token === '--dry-run') { args.dryRun = true; continue; } + if (token === '--help' || token === '-h') { args.help = true; continue; } + + const pairs = [ + ['--state', 'state'], + ['--evidence-dir', 'evidenceDir'], + ['--event-dir', 'eventDir'], + ['--queue-dir', 'queueDir'], + ['--spool-dir', 'spoolDir'], + ['--receipt-dir', 'receiptDir'], + ['--watchdog-script', 'watchdogScript'], + ['--dispatcher-script', 'dispatcherScript'], + ['--supervisor-script', 'supervisorScript'], + ['--sender-command', 'senderCommand'], + ['--sender-mode', 'senderMode'], + ['--openclaw-bin', 'openclawBin'], + ['--now', 'now'], + ]; + + let matched = false; + for (const [flag, key] of pairs) { + if (token === flag) { + args[key] = argv[i + 1] ?? args[key]; + i += 1; + matched = true; + break; + } + if (token.startsWith(`${flag}=`)) { + args[key] = token.slice(flag.length + 1) || args[key]; + matched = true; + break; + } + } + if (matched) continue; + } + + return args; +} + +function printHelp() { + process.stdout.write([ + 'Usage:', + ' node scripts/watchdog_auto_notify_orchestrator.mjs [--write-state] [--claim] [--dry-run] [--sender-command ] [--sender-mode shim|openclaw-cli] [--openclaw-bin ] [--now ] [--compact]', + '', + 'Runs the full watchdog auto-notify chain in order:', + ' runner -> queue -> dispatcher -> bridge -> sender -> ack|blocked', + '', + 'If --sender-mode is given and --sender-command is omitted, a sender-binding command is constructed automatically.', + ].join('\n') + '\n'); +} + +function buildSenderCommand(args) { + if (args.senderCommand) return args.senderCommand; + if (!args.senderMode) return null; + const cmd = [ + JSON.stringify(process.execPath), + JSON.stringify(path.join(ROOT_DIR, 'scripts', 'operator_notify_sender_binding.mjs')), + '--mode', JSON.stringify(args.senderMode), + '--openclaw-bin', JSON.stringify(args.openclawBin), + '--compact', + ]; + return cmd.join(' '); +} + +function runNodeScript(scriptPath, scriptArgs) { + return spawnSync(process.execPath, [scriptPath, ...scriptArgs], { + cwd: ROOT_DIR, + encoding: 'utf8', + }); +} + +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)}`); + } +} + +function ensureSuccess(label, result) { + if (result.status !== 0) { + throw new Error(`${label} failed with status ${result.status ?? 'null'}: ${(result.stderr ?? '').trim() || '(no stderr)'}`); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + process.exit(0); + } + + const senderCommand = buildSenderCommand(args); + + try { + const watchdogArgs = [ + '--state', path.resolve(args.state), + '--evidence-dir', path.resolve(args.evidenceDir), + '--event-dir', path.resolve(args.eventDir), + '--notification-dir', path.resolve(args.queueDir), + '--compact', + ]; + if (args.writeState) watchdogArgs.push('--write-state'); + if (args.now) watchdogArgs.push('--now', args.now); + const watchdog = runNodeScript(path.resolve(args.watchdogScript), watchdogArgs); + ensureSuccess('watchdog runner', watchdog); + const watchdogPayload = parseJsonOutput('watchdog runner', watchdog); + + const dispatcherArgs = [ + '--queue-dir', path.resolve(args.queueDir), + '--spool-dir', path.resolve(args.spoolDir), + '--compact', + ]; + if (args.claim) dispatcherArgs.push('--claim'); + if (args.now) dispatcherArgs.push('--now', args.now); + const dispatcher = runNodeScript(path.resolve(args.dispatcherScript), dispatcherArgs); + ensureSuccess('dispatcher', dispatcher); + const dispatcherPayload = parseJsonOutput('dispatcher', dispatcher); + + const supervisorArgs = [ + '--queue-dir', path.resolve(args.queueDir), + '--spool-dir', path.resolve(args.spoolDir), + '--receipt-dir', path.resolve(args.receiptDir), + '--dispatcher-script', path.resolve(args.dispatcherScript), + '--compact', + ]; + if (args.dryRun) supervisorArgs.push('--dry-run'); + if (senderCommand) supervisorArgs.push('--sender-command', senderCommand); + if (args.now) supervisorArgs.push('--now', args.now); + const supervisor = runNodeScript(path.resolve(args.supervisorScript), supervisorArgs); + ensureSuccess('bridge supervisor', supervisor); + const supervisorPayload = parseJsonOutput('bridge supervisor', supervisor); + + const response = { + 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: { + script: path.resolve(import.meta.filename), + senderCommandConfigured: Boolean(senderCommand), + senderMode: args.senderMode ?? null, + dryRun: args.dryRun, + }, + result: { + watchdog: watchdogPayload?.result ?? null, + dispatcher: dispatcherPayload?.result ?? null, + supervisor: supervisorPayload?.result ?? null, + }, + }; + + process.stdout.write(`${JSON.stringify(response, null, args.compact ? 0 : 2)}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +main(); diff --git a/plugins/reporting-governance/src/adapters/runtime-binding.mjs b/plugins/reporting-governance/src/adapters/runtime-binding.mjs index 63fd64a..64380ed 100644 --- a/plugins/reporting-governance/src/adapters/runtime-binding.mjs +++ b/plugins/reporting-governance/src/adapters/runtime-binding.mjs @@ -4,7 +4,7 @@ import process from 'node:process'; const ENV_PREFIX = 'OPENCLAW_REPORTING_GOVERNANCE_'; const packageRoot = path.resolve(import.meta.dirname, '..', '..'); -const repoRoot = path.resolve(packageRoot, '..', '..'); +const repoRoot = packageRoot; const SCRIPT_NAMES = { watchdog: 'long_task_watchdog.mjs', diff --git a/plugins/reporting-governance/src/storage/profile-artifact.mjs b/plugins/reporting-governance/src/storage/profile-artifact.mjs index 0b34cdb..f0ec64c 100644 --- a/plugins/reporting-governance/src/storage/profile-artifact.mjs +++ b/plugins/reporting-governance/src/storage/profile-artifact.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; const packageRoot = path.resolve(import.meta.dirname, '..', '..'); -const repoRoot = path.resolve(packageRoot, '..', '..'); +const repoRoot = packageRoot; const EXPECTED_KIND = 'DeploymentProfileArtifact'; const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1'; diff --git a/plugins/reporting-governance/src/storage/profile-generator.mjs b/plugins/reporting-governance/src/storage/profile-generator.mjs index aacda24..abb8bb5 100644 --- a/plugins/reporting-governance/src/storage/profile-generator.mjs +++ b/plugins/reporting-governance/src/storage/profile-generator.mjs @@ -5,9 +5,11 @@ import Ajv2020 from 'ajv/dist/2020.js'; import YAML from 'yaml'; const packageRoot = path.resolve(import.meta.dirname, '..', '..'); -const repoRoot = path.resolve(packageRoot, '..', '..'); -const schemaPath = path.resolve(repoRoot, 'schemas', 'reporting-governance', 'deployment-profile.schema.json'); -const artifactSchemaRefPath = '../../../schemas/reporting-governance/deployment-profile.schema.json'; +const packageProfileSourceRoot = path.resolve(packageRoot, 'profiles-src'); +const schemaPath = path.resolve(packageRoot, 'schemas', 'reporting-governance', 'deployment-profile.schema.json'); +const artifactSchemaRefPath = './schemas/reporting-governance/deployment-profile.schema.json'; +const defaultSourceProfilePath = path.join('profiles-src', 'strict-manager-mode.yaml'); +const packageScriptsRoot = 'scripts'; function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); @@ -17,6 +19,10 @@ function loadDeploymentProfileSchema() { return JSON.parse(readText(schemaPath)); } +function relativizeToPackage(filePath) { + return path.relative(packageRoot, filePath).split(path.sep).join('/'); +} + const ajv = new Ajv2020({ allErrors: true, strict: false }); const validateDeploymentProfile = ajv.compile(loadDeploymentProfileSchema()); @@ -66,7 +72,7 @@ export function validateDeploymentProfileSchema(profile) { export function generateDeploymentProfileArtifact(profile, { sourceProfile } = {}) { const validated = validateDeploymentProfileSchema(profile); const id = validated.metadata.id; - const sourceProfilePath = sourceProfile ?? path.join('profiles', `${id}.yaml`); + const sourceProfilePath = sourceProfile ?? (id === 'strict-manager-mode' ? defaultSourceProfilePath : path.join('profiles-src', `${id}.yaml`)); return { $schema: artifactSchemaRefPath, @@ -85,13 +91,13 @@ export function generateDeploymentProfileArtifact(profile, { sourceProfile } = { }, bindings: { runtime: validated.metadata.runtime, - entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + entrypoint: `${packageScriptsRoot}/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', + watchdog: `${packageScriptsRoot}/long_task_watchdog.mjs`, + dispatcher: `${packageScriptsRoot}/operator_notify_dispatcher.mjs`, + bridgeSupervisor: `${packageScriptsRoot}/operator_notify_bridge_supervisor.mjs`, + senderBinding: `${packageScriptsRoot}/operator_notify_sender_binding.mjs`, + orchestrator: `${packageScriptsRoot}/watchdog_auto_notify_orchestrator.mjs`, }, artifact_roots: { watchdogEvidence: 'state/long-task-watchdog', @@ -109,9 +115,12 @@ export function generateDeploymentProfileArtifact(profile, { sourceProfile } = { export function generateDeploymentProfileArtifactFromFile(profilePath) { const absoluteProfilePath = path.resolve(profilePath); const profile = parseDeploymentProfileYaml(readText(absoluteProfilePath)); - return generateDeploymentProfileArtifact(profile, { - sourceProfile: path.relative(repoRoot, absoluteProfilePath), - }); + const relativeToPackageSource = path.relative(packageProfileSourceRoot, absoluteProfilePath); + const sourceProfile = relativeToPackageSource.startsWith(`..${path.sep}`) + ? relativizeToPackage(absoluteProfilePath) + : path.join('profiles-src', relativeToPackageSource).split(path.sep).join('/'); + + return generateDeploymentProfileArtifact(profile, { sourceProfile }); } export default { diff --git a/plugins/reporting-governance/test/exports-boundary.integration.test.mjs b/plugins/reporting-governance/test/exports-boundary.integration.test.mjs index bfe4c1a..77359f3 100644 --- a/plugins/reporting-governance/test/exports-boundary.integration.test.mjs +++ b/plugins/reporting-governance/test/exports-boundary.integration.test.mjs @@ -163,12 +163,12 @@ test('leaf subpath export resolves and can execute through injected runtime bind import { runOrchestratorAdapter } from '@openclaw/plugin-reporting-governance/adapters/orchestrator'; const payload = runOrchestratorAdapter({ runtimeBinding: { - cwd: ${JSON.stringify(path.resolve(packageRoot, '..', '..'))}, + cwd: ${JSON.stringify(path.resolve(packageRoot))}, scripts: { - orchestrator: ${JSON.stringify(path.resolve(packageRoot, '..', '..', 'scripts', 'watchdog_auto_notify_orchestrator.mjs'))}, - watchdog: ${JSON.stringify(path.resolve(packageRoot, '..', '..', 'scripts', 'long_task_watchdog.mjs'))}, - dispatcher: ${JSON.stringify(path.resolve(packageRoot, '..', '..', 'scripts', 'operator_notify_dispatcher.mjs'))}, - bridgeSupervisor: ${JSON.stringify(path.resolve(packageRoot, '..', '..', 'scripts', 'operator_notify_bridge_supervisor.mjs'))}, + orchestrator: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'watchdog_auto_notify_orchestrator.mjs'))}, + watchdog: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'long_task_watchdog.mjs'))}, + dispatcher: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_dispatcher.mjs'))}, + bridgeSupervisor: ${JSON.stringify(path.resolve(packageRoot, 'scripts', 'operator_notify_bridge_supervisor.mjs'))}, }, }, state: ${JSON.stringify(statePath)}, diff --git a/plugins/reporting-governance/test/package-structure.test.mjs b/plugins/reporting-governance/test/package-structure.test.mjs index ac25c58..981cbb4 100644 --- a/plugins/reporting-governance/test/package-structure.test.mjs +++ b/plugins/reporting-governance/test/package-structure.test.mjs @@ -25,7 +25,16 @@ const requiredPaths = [ 'src/reference/openclaw-watchdog-chain.md', 'capabilities/openclaw-watchdog-reference.json', 'examples/openclaw-watchdog-reference.descriptor.example.json', - 'profiles/strict-manager-mode.profile.json' + 'profiles/strict-manager-mode.profile.json', + 'profiles-src/strict-manager-mode.yaml', + 'schemas/reporting-governance/deployment-profile.schema.json', + 'schemas/reporting-governance/capability-descriptor.schema.json', + 'scripts/package-smoke.mjs', + 'scripts/watchdog_auto_notify_orchestrator.mjs', + 'scripts/long_task_watchdog.mjs', + 'scripts/operator_notify_dispatcher.mjs', + 'scripts/operator_notify_bridge_supervisor.mjs', + 'scripts/operator_notify_sender_binding.mjs' ]; test('reporting-governance package skeleton paths exist', () => { diff --git a/plugins/reporting-governance/test/profile-artifact.test.mjs b/plugins/reporting-governance/test/profile-artifact.test.mjs index 1df3c82..e539b6b 100644 --- a/plugins/reporting-governance/test/profile-artifact.test.mjs +++ b/plugins/reporting-governance/test/profile-artifact.test.mjs @@ -13,7 +13,7 @@ import { import { createRuntimeBinding } from '../src/adapters/index.mjs'; const packageRoot = path.resolve(import.meta.dirname, '..'); -const repoRoot = path.resolve(packageRoot, '..', '..'); +const repoRoot = path.resolve(packageRoot); function createArtifact(overrides = {}) { return { diff --git a/plugins/reporting-governance/test/profile-generator.test.mjs b/plugins/reporting-governance/test/profile-generator.test.mjs index b59d836..0f61c3b 100644 --- a/plugins/reporting-governance/test/profile-generator.test.mjs +++ b/plugins/reporting-governance/test/profile-generator.test.mjs @@ -81,12 +81,12 @@ spec: `; test('deployment profile yaml parser reads strict-manager-mode profile shape', () => { - const artifact = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml')); + const artifact = generateDeploymentProfileArtifactFromFile(path.join(packageRoot, 'profiles-src', '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'); + assert.equal(artifact.metadata.source_profile, 'profiles-src/strict-manager-mode.yaml'); }); test('deployment profile schema validator rejects malformed profile', () => { @@ -385,7 +385,7 @@ test('yaml to artifact generation produces validator-compatible package artifact }); test('strict-manager YAML generation matches checked-in package artifact snapshot', () => { - const generated = generateDeploymentProfileArtifactFromFile(path.join(repoRoot, 'profiles', 'strict-manager-mode.yaml')); + const generated = generateDeploymentProfileArtifactFromFile(path.join(packageRoot, 'profiles-src', 'strict-manager-mode.yaml')); const checkedIn = validateDeploymentProfileArtifact( JSON.parse( fs.readFileSync( diff --git a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs index e47b64c..e9abb52 100644 --- a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs +++ b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs @@ -127,7 +127,7 @@ test('orchestrator adapter can bootstrap from profile artifact loader path', () const result = runOrchestratorAdapter({ profileId: 'strict-manager-mode', - repoRootOverride: path.resolve(packageRoot, '..', '..'), + repoRootOverride: path.resolve(packageRoot), state: statePath, evidenceDir: path.join(root, 'evidence'), eventDir: path.join(root, 'events'), @@ -160,7 +160,7 @@ test('orchestrator adapter can use artifact_roots.queueItems as the default queu const result = runOrchestratorAdapter({ profileId: 'strict-manager-mode', - repoRootOverride: path.resolve(packageRoot, '..', '..'), + repoRootOverride: path.resolve(packageRoot), state: statePath, evidenceDir: path.join(root, 'evidence'), eventDir: path.join(root, 'events'), @@ -183,7 +183,7 @@ test('orchestrator adapter fails closed at use time when artifact_roots.queueIte try { const fakeRepoRoot = path.join(sandbox, 'repo'); const outsideRoot = path.join(sandbox, 'outside'); - const realRepoRoot = path.resolve(packageRoot, '..', '..'); + const realRepoRoot = path.resolve(packageRoot); fs.mkdirSync(path.join(fakeRepoRoot, 'scripts'), { recursive: true }); fs.mkdirSync(path.join(fakeRepoRoot, 'state', 'operator-notify-queue'), { recursive: true }); fs.mkdirSync(outsideRoot, { recursive: true });