From b336958fc052a96288a786c3adcdec0b88a2eb24 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 24 Apr 2026 16:54:47 +0800 Subject: [PATCH] feat: validate continuity config and extract receipt contract --- plugins/continuity/README.md | 24 +++- plugins/continuity/README.zh-TW.md | 24 +++- plugins/continuity/src/config/schema.mjs | 127 ++++++++++++++++- .../src/continuity/receipt-validator.mjs | 72 +++++++++- plugins/continuity/src/continuity/types.md | 23 +++ .../test/continuity.config.test.mjs | 133 ++++++++++++++++++ 6 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 plugins/continuity/src/continuity/types.md create mode 100644 plugins/continuity/test/continuity.config.test.mjs diff --git a/plugins/continuity/README.md b/plugins/continuity/README.md index 0dea7e4..990bb78 100644 --- a/plugins/continuity/README.md +++ b/plugins/continuity/README.md @@ -6,7 +6,10 @@ This package is the skeleton for extracting the current approved-plan continuity - Task 2: package skeleton created - Task 3: config schema contract scaffolded -- Plugin logic intentionally not implemented yet +- Task 4: config validation tests added +- Task 5: minimal config validator implemented +- Task 6: receipt validator contract extracted +- Plugin evaluator / adapter logic intentionally not implemented yet ## Layout @@ -34,6 +37,25 @@ plugins/continuity/ See `examples/openclaw.continuity.example.json`. +## Receipt validator contract + +The MVP receipt validator currently defines this minimum shape: + +- `planId` +- `currentTask` +- `nextDerivedAction` +- `dispatchedAt` +- `dispatchRunId` +- `childSessionKey` +- `replyClosureState` + +API surface: + +- `validateReceipt(receipt)` +- `isValidReceipt(receipt)` + +See `src/continuity/types.md` for the extracted contract note. + ## Notes - Current terminal states preserved by default: `waiting_user`, `blocked`, `pending_verification` diff --git a/plugins/continuity/README.zh-TW.md b/plugins/continuity/README.zh-TW.md index f9f5891..57acbab 100644 --- a/plugins/continuity/README.zh-TW.md +++ b/plugins/continuity/README.zh-TW.md @@ -6,7 +6,10 @@ - Task 2:已建立 package skeleton - Task 3:已先放入 config schema contract 骨架 -- 目前刻意不實作 plugin logic +- Task 4:已補 config validation 測試 +- Task 5:已實作 minimal config validator +- Task 6:已抽出 receipt validator contract +- evaluator / adapter 邏輯目前仍未實作 ## 目錄 @@ -34,6 +37,25 @@ plugins/continuity/ 請參考 `examples/openclaw.continuity.example.json`。 +## Receipt validator contract + +目前 MVP receipt validator 最小欄位如下: + +- `planId` +- `currentTask` +- `nextDerivedAction` +- `dispatchedAt` +- `dispatchRunId` +- `childSessionKey` +- `replyClosureState` + +API 介面: + +- `validateReceipt(receipt)` +- `isValidReceipt(receipt)` + +抽出的 contract 說明見 `src/continuity/types.md`。 + ## 備註 - 預設保留目前 terminal states:`waiting_user`、`blocked`、`pending_verification` diff --git a/plugins/continuity/src/config/schema.mjs b/plugins/continuity/src/config/schema.mjs index db5402c..f6f069d 100644 --- a/plugins/continuity/src/config/schema.mjs +++ b/plugins/continuity/src/config/schema.mjs @@ -16,10 +16,49 @@ export const continuityConfigSchema = Object.freeze({ }, }); +const TOP_LEVEL_KEYS = new Set([ + 'enabled', + 'planMatchers', + 'legalTerminalStates', + 'receiptDir', + 'requireRealDispatchReceipt', + 'allowReplyClosureWithoutDispatch', + 'debug', + 'adapter', +]); + +const ADAPTER_KEYS = new Set(['forceRecall']); +const FORCE_RECALL_KEYS = new Set(['enabled', 'injectBlockLabel']); + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function pushUnknownKeyErrors(errors, input, allowedKeys, pathPrefix = '') { + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + errors.push(`${pathPrefix}${key}: unknown config key`); + } + } +} + +function validateStringArray(errors, value, fieldName) { + if (!Array.isArray(value)) { + errors.push(`${fieldName}: expected array of strings`); + return; + } + + value.forEach((entry, index) => { + if (!isNonEmptyString(entry)) { + errors.push(`${fieldName}[${index}]: expected non-empty string`); + } + }); +} + export function normalizeContinuityConfig(input = {}) { const base = cloneDefaultConfig(); @@ -27,13 +66,16 @@ export function normalizeContinuityConfig(input = {}) { return base; } - return { + const normalized = { ...base, ...input, - planMatchers: Array.isArray(input.planMatchers) ? [...input.planMatchers] : [...base.planMatchers], + planMatchers: Array.isArray(input.planMatchers) + ? input.planMatchers.map((entry) => (typeof entry === 'string' ? entry.trim() : entry)) + : [...base.planMatchers], legalTerminalStates: Array.isArray(input.legalTerminalStates) - ? [...input.legalTerminalStates] + ? input.legalTerminalStates.map((entry) => (typeof entry === 'string' ? entry.trim() : entry)) : [...base.legalTerminalStates], + receiptDir: typeof input.receiptDir === 'string' ? input.receiptDir.trim() : base.receiptDir, adapter: { ...base.adapter, ...(isPlainObject(input.adapter) ? input.adapter : {}), @@ -43,13 +85,88 @@ export function normalizeContinuityConfig(input = {}) { }, }, }; + + if (typeof normalized.adapter.forceRecall.injectBlockLabel === 'string') { + normalized.adapter.forceRecall.injectBlockLabel = normalized.adapter.forceRecall.injectBlockLabel.trim(); + } + + return normalized; } export function validateContinuityConfig(input = {}) { + const errors = []; + + if (!isPlainObject(input)) { + errors.push('config: expected plain object'); + return { + ok: false, + errors, + normalizedConfig: cloneDefaultConfig(), + }; + } + + pushUnknownKeyErrors(errors, input, TOP_LEVEL_KEYS); + + if ('enabled' in input && typeof input.enabled !== 'boolean') { + errors.push('enabled: expected boolean'); + } + + if ('planMatchers' in input) { + validateStringArray(errors, input.planMatchers, 'planMatchers'); + } + + if ('legalTerminalStates' in input) { + validateStringArray(errors, input.legalTerminalStates, 'legalTerminalStates'); + } + + if ('receiptDir' in input && !isNonEmptyString(input.receiptDir)) { + errors.push('receiptDir: expected non-empty string'); + } + + if ('requireRealDispatchReceipt' in input && typeof input.requireRealDispatchReceipt !== 'boolean') { + errors.push('requireRealDispatchReceipt: expected boolean'); + } + + if ('allowReplyClosureWithoutDispatch' in input && typeof input.allowReplyClosureWithoutDispatch !== 'boolean') { + errors.push('allowReplyClosureWithoutDispatch: expected boolean'); + } + + if ('debug' in input && typeof input.debug !== 'boolean') { + errors.push('debug: expected boolean'); + } + + if ('adapter' in input) { + if (!isPlainObject(input.adapter)) { + errors.push('adapter: expected object'); + } else { + pushUnknownKeyErrors(errors, input.adapter, ADAPTER_KEYS, 'adapter.'); + + if ('forceRecall' in input.adapter) { + if (!isPlainObject(input.adapter.forceRecall)) { + errors.push('adapter.forceRecall: expected object'); + } else { + pushUnknownKeyErrors(errors, input.adapter.forceRecall, FORCE_RECALL_KEYS, 'adapter.forceRecall.'); + + if ('enabled' in input.adapter.forceRecall && typeof input.adapter.forceRecall.enabled !== 'boolean') { + errors.push('adapter.forceRecall.enabled: expected boolean'); + } + + if ( + 'injectBlockLabel' in input.adapter.forceRecall + && !isNonEmptyString(input.adapter.forceRecall.injectBlockLabel) + ) { + errors.push('adapter.forceRecall.injectBlockLabel: expected non-empty string'); + } + } + } + } + } + const normalizedConfig = normalizeContinuityConfig(input); + return { - ok: true, - errors: [], + ok: errors.length === 0, + errors, normalizedConfig, }; } diff --git a/plugins/continuity/src/continuity/receipt-validator.mjs b/plugins/continuity/src/continuity/receipt-validator.mjs index ed77817..0b45fe4 100644 --- a/plugins/continuity/src/continuity/receipt-validator.mjs +++ b/plugins/continuity/src/continuity/receipt-validator.mjs @@ -1,7 +1,71 @@ -export function validateReceipt() { - throw new Error('Not implemented: receipt validator contract placeholder'); +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -export function isValidReceipt() { - throw new Error('Not implemented: receipt validator contract placeholder'); +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : value; +} + +export function validateReceipt(receipt) { + const errors = []; + + if (!isPlainObject(receipt)) { + return { + ok: false, + errors: ['receipt: expected object'], + normalizedReceipt: null, + }; + } + + const normalizedReceipt = { + planId: normalizeString(receipt.planId), + currentTask: normalizeString(receipt.currentTask), + nextDerivedAction: isPlainObject(receipt.nextDerivedAction) ? receipt.nextDerivedAction : receipt.nextDerivedAction, + dispatchedAt: normalizeString(receipt.dispatchedAt), + dispatchRunId: normalizeString(receipt.dispatchRunId), + childSessionKey: normalizeString(receipt.childSessionKey), + replyClosureState: normalizeString(receipt.replyClosureState), + }; + + if (!isNonEmptyString(normalizedReceipt.planId)) { + errors.push('planId: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.currentTask)) { + errors.push('currentTask: expected non-empty string'); + } + + if (!isPlainObject(normalizedReceipt.nextDerivedAction)) { + errors.push('nextDerivedAction: expected object'); + } + + if (!isNonEmptyString(normalizedReceipt.dispatchedAt)) { + errors.push('dispatchedAt: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.dispatchRunId)) { + errors.push('dispatchRunId: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.childSessionKey)) { + errors.push('childSessionKey: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.replyClosureState)) { + errors.push('replyClosureState: expected non-empty string'); + } + + return { + ok: errors.length === 0, + errors, + normalizedReceipt, + }; +} + +export function isValidReceipt(receipt) { + return validateReceipt(receipt).ok; } diff --git a/plugins/continuity/src/continuity/types.md b/plugins/continuity/src/continuity/types.md new file mode 100644 index 0000000..0af2f78 --- /dev/null +++ b/plugins/continuity/src/continuity/types.md @@ -0,0 +1,23 @@ +# Continuity Types (MVP) + +## Receipt contract + +The MVP receipt validator contract uses this minimum shape: + +- `planId`: string +- `currentTask`: string +- `nextDerivedAction`: object +- `dispatchedAt`: string +- `dispatchRunId`: string +- `childSessionKey`: string +- `replyClosureState`: string + +## Validator API + +- `validateReceipt(receipt)` → `{ ok, errors, normalizedReceipt }` +- `isValidReceipt(receipt)` → `boolean` + +## Notes + +- This contract is intentionally minimal and keeps file I/O separate. +- It mirrors the current approved-plan dispatch receipt fields used by the existing continuity scripts. diff --git a/plugins/continuity/test/continuity.config.test.mjs b/plugins/continuity/test/continuity.config.test.mjs new file mode 100644 index 0000000..de1c934 --- /dev/null +++ b/plugins/continuity/test/continuity.config.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; + +import defaultConfig from '../src/config/defaults.mjs'; +import { + normalizeContinuityConfig, + validateContinuityConfig, +} from '../src/config/schema.mjs'; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +test('accepts default config', () => { + const result = validateContinuityConfig(defaultConfig); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.normalizedConfig, defaultConfig); +}); + +test('accepts custom receipt directory', () => { + const result = validateContinuityConfig({ + receiptDir: 'tmp/continuity-receipts', + }); + + assert.equal(result.ok, true); + assert.equal(result.normalizedConfig.receiptDir, 'tmp/continuity-receipts'); + assert.deepEqual(result.errors, []); +}); + +test('accepts custom legal terminal states list', () => { + const result = validateContinuityConfig({ + legalTerminalStates: ['waiting_user', 'blocked', 'done_elsewhere'], + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.normalizedConfig.legalTerminalStates, ['waiting_user', 'blocked', 'done_elsewhere']); +}); + +test('normalizes missing fields from defaults', () => { + const normalized = normalizeContinuityConfig({ + debug: true, + }); + + assert.equal(normalized.debug, true); + assert.equal(normalized.enabled, defaultConfig.enabled); + assert.equal(normalized.receiptDir, defaultConfig.receiptDir); + assert.deepEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); + assert.notEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); +}); + +test('rejects non-array legalTerminalStates', () => { + const result = validateContinuityConfig({ + legalTerminalStates: 'waiting_user', + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /legalTerminalStates/); + assert.match(result.errors.join('\n'), /array/i); +}); + +test('rejects invalid legalTerminalStates entry types', () => { + const result = validateContinuityConfig({ + legalTerminalStates: ['waiting_user', 123], + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /legalTerminalStates\[1\]/); + assert.match(result.errors.join('\n'), /string/i); +}); + +test('rejects empty receiptDir', () => { + const result = validateContinuityConfig({ + receiptDir: ' ', + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /receiptDir/); +}); + +test('rejects malformed adapter.forceRecall shape', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: false, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.forceRecall/); +}); + +test('rejects malformed adapter.forceRecall.enabled type', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: { + enabled: 'yes', + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.forceRecall\.enabled/); +}); + +test('rejects malformed adapter.forceRecall.injectBlockLabel type', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: { + injectBlockLabel: 42, + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /injectBlockLabel/); +}); + +test('rejects unknown top-level key', () => { + const result = validateContinuityConfig({ + unexpected: true, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /unknown/i); + assert.match(result.errors.join('\n'), /unexpected/); +}); + +console.log('continuity.config.test.mjs PASS');