Files
fusion/packages/core/src/task-fields.ts
2026-06-04 13:19:55 -07:00

356 lines
14 KiB
TypeScript

/**
* Custom task field validation & reconciliation authority (U11 / KTD-13).
*
* Workflows declare typed custom task fields ({@link WorkflowFieldDefinition});
* task values live in `tasks.customFields` (a JSON object keyed by field id).
* This module is the single, side-effect-free validation core that the store
* write authority (`updateTaskCustomFields` / `updateTask`) delegates to. It
* mirrors the `TransitionRejection` style: a flat, JSON-safe typed rejection
* with a machine-stable `code`, the offending `fieldId`, and a non-localized
* `detail` string for audit/logs.
*
* Three operations:
* - {@link validateCustomFieldPatch} — validate a `Record<string, unknown>`
* patch against a field schema, normalizing accepted values. `null`/`undefined`
* in the patch is a delete sentinel for that field (always accepted).
* - {@link applyFieldDefaults} — fill `default` for required fields absent from
* the current values (task create / workflow selection).
* - {@link reconcileFieldsOnWorkflowChange} — partition existing values into
* `kept` (same id, type-compatible) and `orphaned` (everything else) when a
* workflow's fields change or the task switches workflows. Orphans are
* RETAINED in storage — this only computes the partition so the UI can render
* the orphaned-fields disclosure.
*/
import type {
WorkflowFieldDefinition,
} from "./workflow-ir-types.js";
// ---------------------------------------------------------------------------
// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class)
// ---------------------------------------------------------------------------
/**
* Reason codes for a rejected custom-field write. Stable string literals — they
* cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so
* they must not change without migrating consumers.
*/
export type CustomFieldRejectionCode =
| "no-fields-defined"
| "unknown-field"
| "type-mismatch"
| "enum-violation";
/** The full, immutable set of custom-field rejection codes. */
export const CUSTOM_FIELD_REJECTION_CODES: readonly CustomFieldRejectionCode[] = [
"no-fields-defined",
"unknown-field",
"type-mismatch",
"enum-violation",
] as const;
/**
* A typed custom-field rejection. Flat and JSON-safe by construction — mirrors
* {@link import("./transition-types.js").TransitionRejection}.
*
* - `code` — machine-stable {@link CustomFieldRejectionCode}.
* - `fieldId` — the offending field id (the patch key that failed).
* - `detail` — non-localized diagnostic context for audit/logs.
*/
export interface CustomFieldRejection {
code: CustomFieldRejectionCode;
fieldId: string;
detail: string;
}
/** Result of validating a custom-field patch. Discriminated on `ok`. */
export type CustomFieldPatchResult =
| { ok: true; normalized: Record<string, unknown> }
| { ok: false; rejection: CustomFieldRejection };
/** Construct a {@link CustomFieldRejection}. */
export function makeCustomFieldRejection(
code: CustomFieldRejectionCode,
fieldId: string,
detail: string,
): CustomFieldRejection {
return { code, fieldId, detail };
}
/**
* Thrown by the throw-based write paths (`updateTask` with a `customFields`
* patch) when validation rejects. `updateTaskCustomFields` returns the typed
* rejection instead; this wrapper exists for the legacy throw contract so a bad
* `updateTask` write fails loudly rather than silently round-tripping an invalid
* value (the U4 opaque behavior). Carries the structured rejection so HTTP/agent
* surfaces can recover the field path and code.
*/
export class CustomFieldRejectionError extends Error {
readonly rejection: CustomFieldRejection;
constructor(rejection: CustomFieldRejection) {
super(`custom field '${rejection.fieldId}' rejected (${rejection.code}): ${rejection.detail}`);
this.name = "CustomFieldRejectionError";
this.rejection = rejection;
}
}
// ---------------------------------------------------------------------------
// Per-type value validation
// ---------------------------------------------------------------------------
/** True iff `value` is a non-empty option-value member of `field.options`. */
function isEnumMember(field: WorkflowFieldDefinition, value: string): boolean {
return (field.options ?? []).some((o) => o.value === value);
}
/**
* Validate (and normalize) a single non-null value against a field's type.
* Returns the normalized value on success, or a rejection. The caller has
* already resolved the field definition.
*/
function validateValue(
field: WorkflowFieldDefinition,
value: unknown,
): { ok: true; value: unknown } | { ok: false; rejection: CustomFieldRejection } {
const reject = (
code: CustomFieldRejectionCode,
detail: string,
): { ok: false; rejection: CustomFieldRejection } => ({
ok: false,
rejection: makeCustomFieldRejection(code, field.id, detail),
});
switch (field.type) {
case "string":
case "text": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' expects a string, got ${typeof value}`);
}
return { ok: true, value };
}
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
return reject(
"type-mismatch",
`field '${field.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`,
);
}
return { ok: true, value };
}
case "boolean": {
if (typeof value !== "boolean") {
return reject("type-mismatch", `field '${field.id}' expects a boolean, got ${typeof value}`);
}
return { ok: true, value };
}
case "enum": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (enum) expects a string option value, got ${typeof value}`);
}
if (!isEnumMember(field, value)) {
return reject("enum-violation", `field '${field.id}' value '${value}' is not a declared option`);
}
return { ok: true, value };
}
case "multi-enum": {
if (!Array.isArray(value)) {
return reject("type-mismatch", `field '${field.id}' (multi-enum) expects an array, got ${typeof value}`);
}
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") {
return reject("type-mismatch", `field '${field.id}' (multi-enum) members must be strings`);
}
if (!isEnumMember(field, item)) {
return reject("enum-violation", `field '${field.id}' member '${item}' is not a declared option`);
}
if (seen.has(item)) {
return reject("enum-violation", `field '${field.id}' has duplicate member '${item}'`);
}
seen.add(item);
}
return { ok: true, value: [...value] as string[] };
}
case "date": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (date) expects an ISO date string, got ${typeof value}`);
}
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
return reject("type-mismatch", `field '${field.id}' value '${value}' is not a parseable date`);
}
return { ok: true, value };
}
case "url": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (url) expects a string, got ${typeof value}`);
}
try {
new URL(value);
} catch {
return reject("type-mismatch", `field '${field.id}' value '${value}' is not a valid URL`);
}
return { ok: true, value };
}
default: {
// Exhaustiveness guard — an unknown type cannot validate.
const _exhaustive: never = field.type;
return reject("type-mismatch", `field '${field.id}' has unsupported type '${String(_exhaustive)}'`);
}
}
}
// ---------------------------------------------------------------------------
// Patch validation authority
// ---------------------------------------------------------------------------
/**
* Validate a custom-field `patch` against a workflow's field `fields`.
*
* - A `null`/`undefined` patch value is a DELETE sentinel: the field's stored
* value should be removed. It is always accepted (even for required fields —
* required is not a write-time gate this round, KTD-13) and surfaces in
* `normalized` as `null` so the caller can apply the delete uniformly.
* - A non-null value is validated/normalized per the field's type.
* - A patch key that names no declared field → `unknown-field`.
* - When `fields` is undefined/empty and the patch carries any key → the whole
* patch is rejected `no-fields-defined` (the default workflow declares no
* fields; nothing can be written). An empty patch against no fields is `ok`.
*
* Validation is fail-fast: the first offending key produces the rejection.
*/
export function validateCustomFieldPatch(
fields: WorkflowFieldDefinition[] | undefined,
patch: Record<string, unknown>,
): CustomFieldPatchResult {
const keys = Object.keys(patch);
const byId = new Map<string, WorkflowFieldDefinition>((fields ?? []).map((f) => [f.id, f]));
if (byId.size === 0) {
if (keys.length === 0) return { ok: true, normalized: {} };
return {
ok: false,
rejection: makeCustomFieldRejection(
"no-fields-defined",
keys[0]!,
"the resolved workflow declares no custom fields; no values may be written",
),
};
}
const normalized: Record<string, unknown> = {};
for (const key of keys) {
const value = patch[key];
const field = byId.get(key);
if (!field) {
return {
ok: false,
rejection: makeCustomFieldRejection(
"unknown-field",
key,
`field '${key}' is not declared by the task's workflow`,
),
};
}
// null/undefined = delete this field's value.
if (value === null || value === undefined) {
normalized[key] = null;
continue;
}
const res = validateValue(field, value);
if (!res.ok) return res;
normalized[key] = res.value;
}
return { ok: true, normalized };
}
// ---------------------------------------------------------------------------
// Defaults at create / workflow selection
// ---------------------------------------------------------------------------
/**
* Fill `default` values for REQUIRED fields that are absent from `current`.
* Returns a NEW merged object (does not mutate `current`); existing values win.
* Non-required fields and fields without a declared `default` are left absent.
*
* Used at task create / workflow selection so a workflow with required+default
* fields lands sensible initial values. Defaults are taken on trust from the
* (already-validated-at-save) field schema.
*/
export function applyFieldDefaults(
fields: WorkflowFieldDefinition[] | undefined,
current: Record<string, unknown> | undefined,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(current ?? {}) };
for (const field of fields ?? []) {
if (!field.required) continue;
if (field.default === undefined) continue;
if (Object.prototype.hasOwnProperty.call(out, field.id) && out[field.id] !== undefined) {
continue;
}
out[field.id] = field.default;
}
return out;
}
// ---------------------------------------------------------------------------
// Reconciliation on workflow edit / switch
// ---------------------------------------------------------------------------
/**
* A stored value for `field` is type-compatible with a new field definition iff
* the new value re-validates cleanly. For enum-kind fields, compatibility also
* requires the value still be a member of the new options (handled by
* re-validation). This is the same gate {@link validateValue} applies on write,
* so "kept" values are guaranteed re-writable under the new schema.
*/
function valueCompatible(newField: WorkflowFieldDefinition, value: unknown): boolean {
if (value === null || value === undefined) return true;
return validateValue(newField, value).ok;
}
/** Partition of existing values produced by {@link reconcileFieldsOnWorkflowChange}. */
export interface FieldReconciliation {
/** Values whose id survives in the new schema AND remain type-compatible. */
kept: Record<string, unknown>;
/**
* Values that no longer fit: id removed from the new schema, or the type
* changed incompatibly (including an enum value no longer in the new options).
* RETAINED in storage — listed here only so the UI can render them under the
* orphaned-fields disclosure.
*/
orphaned: Record<string, unknown>;
}
/**
* Reconcile stored `values` when a workflow's field schema changes (edit) or a
* task switches workflows. Same-id values are KEPT when the new field is
* type-compatible (same type, or both enum-kind with the value still a member —
* enforced by re-validation); everything else is ORPHANED.
*
* Storage keeps EVERYTHING — this function only computes the partition. Callers
* persist `{...kept, ...orphaned}` (i.e. the original values, unchanged) and use
* `orphaned` purely for UI disclosure. `oldFields` is accepted for symmetry and
* future heuristics; the decision is driven entirely by `newFields` + the value.
*/
export function reconcileFieldsOnWorkflowChange(
oldFields: WorkflowFieldDefinition[] | undefined,
newFields: WorkflowFieldDefinition[] | undefined,
values: Record<string, unknown> | undefined,
): FieldReconciliation {
void oldFields; // reserved for future migration heuristics; intentionally unused
const newById = new Map<string, WorkflowFieldDefinition>((newFields ?? []).map((f) => [f.id, f]));
const kept: Record<string, unknown> = {};
const orphaned: Record<string, unknown> = {};
for (const [id, value] of Object.entries(values ?? {})) {
const newField = newById.get(id);
if (newField && valueCompatible(newField, value)) {
kept[id] = value;
} else {
orphaned[id] = value;
}
}
return { kept, orphaned };
}