feat: validate continuity config and extract receipt contract
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 normalizedConfig = normalizeContinuityConfig(input);
|
||||
const errors = [];
|
||||
|
||||
if (!isPlainObject(input)) {
|
||||
errors.push('config: expected plain object');
|
||||
return {
|
||||
ok: true,
|
||||
errors: [],
|
||||
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: errors.length === 0,
|
||||
errors,
|
||||
normalizedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
23
plugins/continuity/src/continuity/types.md
Normal file
23
plugins/continuity/src/continuity/types.md
Normal file
@@ -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.
|
||||
133
plugins/continuity/test/continuity.config.test.mjs
Normal file
133
plugins/continuity/test/continuity.config.test.mjs
Normal file
@@ -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');
|
||||
Reference in New Issue
Block a user