feat: validate continuity config and extract receipt contract

This commit is contained in:
Eve
2026-04-24 16:54:47 +08:00
parent b3483098c1
commit b336958fc0
6 changed files with 392 additions and 11 deletions

View File

@@ -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,
};
}

View File

@@ -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;
}

View 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.