ai-workflow-logic 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,21 @@ import type {
11
11
  } from 'ai-workflow-contract';
12
12
  import type { WorkCtrlFlowSnapshot, WorkCtrlFlowStore } from 'work-ctrl-flow-contract';
13
13
  import { normalizeFlowRunCheckpoint } from './run-checkpoint.js';
14
+ import {
15
+ FlowRunCheckpointTransitionError,
16
+ FlowRunCheckpointVersionConflictError,
17
+ } from './errors.js';
18
+ import {
19
+ validateCheckpointAgainstFrozenProfile,
20
+ type FlowRunCheckpointAdmissionRuntime,
21
+ } from './checkpoint-profile.js';
22
+
23
+ export {
24
+ FlowRunCheckpointTransitionError,
25
+ FlowRunCheckpointVersionConflictError,
26
+ } from './errors.js';
27
+
28
+ const compareCodeUnits = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
14
29
 
15
30
  export interface WorkCtrlFlowCheckpointStoreOptions {
16
31
  readonly instanceId: string;
@@ -110,23 +125,6 @@ export function createWorkCtrlFlowCheckpointStore(
110
125
  };
111
126
  }
112
127
 
113
- export class FlowRunCheckpointVersionConflictError extends Error {
114
- constructor(
115
- public readonly expectedVersion: number | null,
116
- public readonly actualVersion: number | null,
117
- ) {
118
- super(`Flow run checkpoint version conflict: expected ${String(expectedVersion)}, actual ${String(actualVersion)}`);
119
- this.name = 'FlowRunCheckpointVersionConflictError';
120
- }
121
- }
122
-
123
- export class FlowRunCheckpointTransitionError extends Error {
124
- constructor(message: string) {
125
- super(message);
126
- this.name = 'FlowRunCheckpointTransitionError';
127
- }
128
- }
129
-
130
128
  function checkpointKey(key: FlowRunCheckpointKey): string {
131
129
  return JSON.stringify([key.instanceId, key.runId]);
132
130
  }
@@ -552,15 +550,71 @@ export function validateFlowRunCheckpointTransition(
552
550
  next as AIWorkflowFlowRunCheckpoint & { readonly profile: { readonly kind: 'AIDataWorkflow'; readonly runGraph: FlowClosedObject } },
553
551
  );
554
552
  }
553
+ validateStepExtensionTransition(current, next);
555
554
  validateAgentStateTransition(current, next);
556
555
  }
557
556
 
557
+ function validateStepExtensionTransition(
558
+ current: AIWorkflowFlowRunCheckpoint,
559
+ next: AIWorkflowFlowRunCheckpoint,
560
+ ): void {
561
+ const before = current.stepExtensions;
562
+ const after = next.stepExtensions;
563
+ if (before === undefined || after === undefined) {
564
+ if (before !== after) {
565
+ throw new FlowRunCheckpointTransitionError('Run step extension membership is immutable after checkpoint version zero.');
566
+ }
567
+ return;
568
+ }
569
+ const beforeSteps = Object.keys(before.byStepId).sort(compareCodeUnits);
570
+ const afterSteps = Object.keys(after.byStepId).sort(compareCodeUnits);
571
+ if (!sameClosedValue(beforeSteps, afterSteps)) {
572
+ throw new FlowRunCheckpointTransitionError('Run step extension step membership is immutable.');
573
+ }
574
+ let changed = 0;
575
+ for (const stepId of beforeSteps) {
576
+ const beforeKinds = Object.keys(before.byStepId[stepId]).sort(compareCodeUnits);
577
+ const afterKinds = Object.keys(after.byStepId[stepId]).sort(compareCodeUnits);
578
+ if (!sameClosedValue(beforeKinds, afterKinds)) {
579
+ throw new FlowRunCheckpointTransitionError(`Run step extension kind membership is immutable for ${stepId}.`);
580
+ }
581
+ for (const kind of beforeKinds) {
582
+ const prior = before.byStepId[stepId][kind];
583
+ const candidate = after.byStepId[stepId][kind];
584
+ if (prior.schemaRef !== candidate.schemaRef) {
585
+ throw new FlowRunCheckpointTransitionError(`Run step extension schema is immutable for ${stepId}/${kind}.`);
586
+ }
587
+ if (sameClosedValue(prior, candidate)) continue;
588
+ changed += 1;
589
+ if (candidate.revision !== prior.revision + 1) {
590
+ throw new FlowRunCheckpointTransitionError(`Run step extension revision must advance exactly once for ${stepId}/${kind}.`);
591
+ }
592
+ if (sameClosedValue(prior.value, candidate.value)) {
593
+ throw new FlowRunCheckpointTransitionError(`Run step extension ${stepId}/${kind} cannot advance without a value change.`);
594
+ }
595
+ }
596
+ }
597
+ if (changed > 1) {
598
+ throw new FlowRunCheckpointTransitionError('One checkpoint transition may mutate at most one run step extension fact.');
599
+ }
600
+ }
601
+
558
602
  /** In-memory semantic owner used to validate the port before physical CAS is introduced. */
559
603
  export class InMemoryFlowRunCheckpointStore implements FlowRunCheckpointStore<FlowRunProfileState> {
560
604
  readonly #checkpoints = new Map<string, AIWorkflowFlowRunCheckpoint>();
605
+ readonly #admission: FlowRunCheckpointAdmissionRuntime;
606
+
607
+ constructor(options: Partial<FlowRunCheckpointAdmissionRuntime> = {}) {
608
+ this.#admission = Object.freeze({
609
+ ...(options.profileAuthority === undefined ? {} : { profileAuthority: options.profileAuthority }),
610
+ extensionCodecs: options.extensionCodecs ?? Object.freeze({ resolve: () => undefined }),
611
+ });
612
+ }
561
613
 
562
614
  async load(key: FlowRunCheckpointKey): Promise<AIWorkflowFlowRunCheckpoint | undefined> {
563
- return this.#checkpoints.get(checkpointKey(key));
615
+ const checkpoint = this.#checkpoints.get(checkpointKey(key));
616
+ if (checkpoint) validateCheckpointAgainstFrozenProfile(checkpoint, this.#admission);
617
+ return checkpoint;
564
618
  }
565
619
 
566
620
  async commit(input: FlowRunCheckpointCommit<FlowRunProfileState>): Promise<AIWorkflowFlowRunCheckpoint> {
@@ -572,6 +626,7 @@ export class InMemoryFlowRunCheckpointStore implements FlowRunCheckpointStore<Fl
572
626
  throw new FlowRunCheckpointVersionConflictError(input.expectedVersion, actualVersion);
573
627
  }
574
628
  validateFlowRunCheckpointTransition(current, checkpoint);
629
+ validateCheckpointAgainstFrozenProfile(checkpoint, this.#admission);
575
630
  this.#checkpoints.set(key, checkpoint);
576
631
  return checkpoint;
577
632
  }
package/src/errors.ts CHANGED
@@ -13,3 +13,20 @@ export class AIWorkflowResourceProjectionError extends AIWorkflowFoundationError
13
13
  this.name = 'AIWorkflowResourceProjectionError';
14
14
  }
15
15
  }
16
+
17
+ export class FlowRunCheckpointVersionConflictError extends Error {
18
+ constructor(
19
+ public readonly expectedVersion: number | null,
20
+ public readonly actualVersion: number | null,
21
+ ) {
22
+ super(`Flow run checkpoint version conflict: expected ${String(expectedVersion)}, actual ${String(actualVersion)}`);
23
+ this.name = 'FlowRunCheckpointVersionConflictError';
24
+ }
25
+ }
26
+
27
+ export class FlowRunCheckpointTransitionError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'FlowRunCheckpointTransitionError';
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -66,6 +66,24 @@ export {
66
66
  projectFlowRunCheckpoint,
67
67
  } from './checkpoint-store.js';
68
68
  export type { WorkCtrlFlowCheckpointStoreOptions } from './checkpoint-store.js';
69
+ export {
70
+ createInitialRunStepExtensions,
71
+ validateCheckpointAgainstFrozenProfile,
72
+ } from './checkpoint-profile.js';
73
+ export type {
74
+ FlowRunCheckpointAdmissionRuntime,
75
+ FlowRunProfileAuthorityPort,
76
+ FlowRunProfileAuthorityRequest,
77
+ FrozenFlowRunProfileAuthority,
78
+ FrozenRunStepExtensionDefinition,
79
+ } from './checkpoint-profile.js';
80
+ export { mutateRunStepExtension } from './step-extension.js';
81
+ export type {
82
+ RunStepExtensionMutationConfig,
83
+ RunStepExtensionMutationInvocation,
84
+ RunStepExtensionMutationRuntime,
85
+ RunStepExtensionSelector,
86
+ } from './step-extension.js';
69
87
 
70
88
  export class AIWorkflowEffectHandlerNotFoundError extends AIWorkflowFoundationError {
71
89
  constructor(
@@ -65,7 +65,7 @@ export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunChe
65
65
  'nodeSidecars',
66
66
  'profile',
67
67
  ],
68
- [],
68
+ ['stepExtensions'],
69
69
  'checkpoint',
70
70
  );
71
71
  const schemaVersion = dataField(record, 'schemaVersion', 'checkpoint.schemaVersion');
@@ -88,6 +88,9 @@ export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunChe
88
88
  dataField(record, 'nodeSidecars', 'checkpoint.nodeSidecars'),
89
89
  'checkpoint.nodeSidecars',
90
90
  );
91
+ const stepExtensions = Object.prototype.hasOwnProperty.call(record, 'stepExtensions')
92
+ ? normalizeRunStepExtensions(dataField(record, 'stepExtensions', 'checkpoint.stepExtensions'))
93
+ : undefined;
91
94
  const profile = normalizeProfile(dataField(record, 'profile', 'checkpoint.profile'), runId);
92
95
  validateProfileProjection(profile, { input, state, output, controllerSidecars, nodeSidecars });
93
96
  return Object.freeze({
@@ -102,10 +105,49 @@ export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunChe
102
105
  output,
103
106
  controllerSidecars,
104
107
  nodeSidecars,
108
+ ...(stepExtensions === undefined ? {} : { stepExtensions }),
105
109
  profile,
106
110
  });
107
111
  }
108
112
 
113
+ function normalizeRunStepExtensions(value: unknown) {
114
+ const root = exactRecord(value, ['schemaVersion', 'byStepId'], [], 'checkpoint.stepExtensions');
115
+ if (dataField(root, 'schemaVersion', 'checkpoint.stepExtensions.schemaVersion') !== 'depa.flow-run-step-extensions/v1') {
116
+ invalid('FLOW_RUN_STEP_EXTENSIONS_SCHEMA_INVALID', 'checkpoint.stepExtensions.schemaVersion', 'Step extension state schema is invalid.');
117
+ }
118
+ const byStepIdValue = plainRecord(dataField(root, 'byStepId', 'checkpoint.stepExtensions.byStepId'), 'checkpoint.stepExtensions.byStepId');
119
+ const byStepId: Record<string, Record<string, { readonly schemaRef?: string; readonly revision: number; readonly value: FlowClosedValue }>> = {};
120
+ for (const stepId of stringDataKeys(byStepIdValue, 'checkpoint.stepExtensions.byStepId').sort(compareCodeUnits)) {
121
+ canonicalIdentity(stepId, `checkpoint.stepExtensions.byStepId.${stepId}`);
122
+ const extensionsValue = plainRecord(dataField(byStepIdValue, stepId, `checkpoint.stepExtensions.byStepId.${stepId}`), `checkpoint.stepExtensions.byStepId.${stepId}`);
123
+ const extensionFacts: Record<string, { readonly schemaRef?: string; readonly revision: number; readonly value: FlowClosedValue }> = {};
124
+ for (const kind of stringDataKeys(extensionsValue, `checkpoint.stepExtensions.byStepId.${stepId}`).sort(compareCodeUnits)) {
125
+ canonicalIdentity(kind, `checkpoint.stepExtensions.byStepId.${stepId}.${kind}`);
126
+ const location = `checkpoint.stepExtensions.byStepId.${stepId}.${kind}`;
127
+ const fact = exactRecord(dataField(extensionsValue, kind, location), ['revision', 'value'], ['schemaRef'], location);
128
+ const schemaRef = Object.prototype.hasOwnProperty.call(fact, 'schemaRef')
129
+ ? canonicalIdentity(dataField(fact, 'schemaRef', `${location}.schemaRef`), `${location}.schemaRef`)
130
+ : undefined;
131
+ extensionFacts[kind] = Object.freeze({
132
+ ...(schemaRef === undefined ? {} : { schemaRef }),
133
+ revision: nonNegativeSafeInteger(dataField(fact, 'revision', `${location}.revision`), `${location}.revision`),
134
+ value: cloneClosedValue(dataField(fact, 'value', `${location}.value`), `${location}.value`),
135
+ });
136
+ }
137
+ if (Object.keys(extensionFacts).length === 0) {
138
+ invalid('FLOW_RUN_STEP_EXTENSIONS_EMPTY_STEP', `checkpoint.stepExtensions.byStepId.${stepId}`, 'Extension step entries cannot be empty.');
139
+ }
140
+ byStepId[stepId] = Object.freeze(extensionFacts);
141
+ }
142
+ if (Object.keys(byStepId).length === 0) {
143
+ invalid('FLOW_RUN_STEP_EXTENSIONS_EMPTY', 'checkpoint.stepExtensions.byStepId', 'Empty extension state must be omitted.');
144
+ }
145
+ return Object.freeze({
146
+ schemaVersion: 'depa.flow-run-step-extensions/v1' as const,
147
+ byStepId: Object.freeze(byStepId),
148
+ });
149
+ }
150
+
109
151
  function validateProfileProjection(
110
152
  profile: FlowRunProfileState,
111
153
  envelope: {
@@ -0,0 +1,121 @@
1
+ import type {
2
+ FlowClosedValue,
3
+ FlowRunCheckpointRuntime,
4
+ FlowRunStepExtensions,
5
+ } from 'ai-workflow-contract';
6
+ import type { DefinitionStepExtensionCodecRegistryPort } from 'flow-step-space-contract';
7
+ import {
8
+ validateCheckpointAgainstFrozenProfile,
9
+ type FlowRunProfileAuthorityPort,
10
+ } from './checkpoint-profile.js';
11
+ import { normalizeFlowClosedValue } from './run-checkpoint.js';
12
+
13
+ export interface RunStepExtensionMutationRuntime extends FlowRunCheckpointRuntime {
14
+ readonly profileAuthority: FlowRunProfileAuthorityPort;
15
+ readonly extensionCodecs: DefinitionStepExtensionCodecRegistryPort;
16
+ }
17
+
18
+ export interface RunStepExtensionSelector {
19
+ readonly instanceId: string;
20
+ readonly runId: string;
21
+ readonly stepId: string;
22
+ readonly kind: string;
23
+ }
24
+
25
+ export interface RunStepExtensionMutationInvocation {
26
+ readonly expectedRevision: number;
27
+ readonly value: FlowClosedValue;
28
+ }
29
+
30
+ export interface RunStepExtensionMutationConfig {}
31
+
32
+ /** Targeted Processor: output = fn(runtime, selector, invocation, config). */
33
+ export async function mutateRunStepExtension(
34
+ runtime: RunStepExtensionMutationRuntime,
35
+ selector: RunStepExtensionSelector,
36
+ invocation: RunStepExtensionMutationInvocation,
37
+ config: RunStepExtensionMutationConfig,
38
+ ) {
39
+ exactOwnData(config, [], 'config');
40
+ const selectorRecord = exactOwnData(selector, ['instanceId', 'runId', 'stepId', 'kind'], 'selector');
41
+ const normalizedSelector = Object.fromEntries(['instanceId', 'runId', 'stepId', 'kind'].map((name) => [
42
+ name,
43
+ ownDataValue(selectorRecord, name, `selector.${name}`),
44
+ ])) as unknown as RunStepExtensionSelector;
45
+ for (const [name, value] of Object.entries(normalizedSelector)) {
46
+ if (typeof value !== 'string' || !value || value !== value.trim()) throw new Error(`Run step extension selector ${name} is invalid.`);
47
+ }
48
+ const invocationRecord = exactOwnData(invocation, ['expectedRevision', 'value'], 'invocation');
49
+ const expectedRevision = ownDataValue(invocationRecord, 'expectedRevision', 'invocation.expectedRevision');
50
+ const invocationValue = ownDataValue(invocationRecord, 'value', 'invocation.value') as FlowClosedValue;
51
+ if (!Number.isSafeInteger(expectedRevision) || (expectedRevision as number) < 0) {
52
+ throw new Error('Run step extension expectedRevision is invalid.');
53
+ }
54
+ const checkpoint = await runtime.checkpointStore.load({
55
+ instanceId: normalizedSelector.instanceId,
56
+ runId: normalizedSelector.runId,
57
+ });
58
+ if (!checkpoint) throw new Error(`Run ${normalizedSelector.runId} does not exist.`);
59
+ const normalized = checkpoint;
60
+ const authority = validateCheckpointAgainstFrozenProfile(normalized, runtime);
61
+ if (!authority) throw new Error('Run step extension mutation requires frozen profile authority.');
62
+ const definition = authority.extensionsByStepId[normalizedSelector.stepId]?.[normalizedSelector.kind];
63
+ const fact = normalized.stepExtensions?.byStepId[normalizedSelector.stepId]?.[normalizedSelector.kind];
64
+ if (!definition || !fact) throw new Error(`Run step extension ${normalizedSelector.stepId}/${normalizedSelector.kind} is not admitted.`);
65
+ if (fact.revision !== expectedRevision) {
66
+ throw new Error(`Run step extension revision conflict: expected ${String(expectedRevision)}, actual ${fact.revision}.`);
67
+ }
68
+ const registration = runtime.extensionCodecs.resolve(normalizedSelector.kind);
69
+ if (!registration?.codec || registration.schemaRef !== definition.schemaRef) {
70
+ throw new Error(`Run step extension codec is unavailable for ${normalizedSelector.stepId}/${normalizedSelector.kind}.`);
71
+ }
72
+ const value = normalizeFlowClosedValue(registration.codec.normalize(invocationValue as never, {
73
+ kind: normalizedSelector.kind,
74
+ ...(definition.schemaRef === undefined ? {} : { schemaRef: definition.schemaRef }),
75
+ sourceRef: `frozen-step://${normalizedSelector.stepId}/${normalizedSelector.kind}`,
76
+ stepId: normalizedSelector.stepId,
77
+ }), `runStepExtension.${normalizedSelector.stepId}.${normalizedSelector.kind}`);
78
+ const stepExtensions: FlowRunStepExtensions = {
79
+ schemaVersion: 'depa.flow-run-step-extensions/v1',
80
+ byStepId: {
81
+ ...normalized.stepExtensions!.byStepId,
82
+ [normalizedSelector.stepId]: {
83
+ ...normalized.stepExtensions!.byStepId[normalizedSelector.stepId],
84
+ [normalizedSelector.kind]: {
85
+ ...(definition.schemaRef === undefined ? {} : { schemaRef: definition.schemaRef }),
86
+ revision: fact.revision + 1,
87
+ value,
88
+ },
89
+ },
90
+ },
91
+ };
92
+ return runtime.checkpointStore.commit({
93
+ expectedVersion: normalized.version,
94
+ checkpoint: {
95
+ ...normalized,
96
+ version: normalized.version + 1,
97
+ stepExtensions,
98
+ },
99
+ });
100
+ }
101
+
102
+ function exactOwnData(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
103
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be a plain own-data object.`);
104
+ const prototype = Object.getPrototypeOf(value);
105
+ if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} must be a plain own-data object.`);
106
+ const keys = Reflect.ownKeys(value);
107
+ if (keys.some((key) => typeof key !== 'string')) throw new Error(`${label} contains a symbol field.`);
108
+ const actual = (keys as string[]).sort(compareCodeUnits);
109
+ const expected = [...fields].sort(compareCodeUnits);
110
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label} fields are invalid.`);
111
+ for (const key of actual) ownDataValue(value as Record<string, unknown>, key, `${label}.${key}`);
112
+ return value as Record<string, unknown>;
113
+ }
114
+
115
+ function ownDataValue(record: Record<string, unknown>, key: string, label: string): unknown {
116
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
117
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) throw new Error(`${label} must be an enumerable own-data field.`);
118
+ return descriptor.value;
119
+ }
120
+
121
+ const compareCodeUnits = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;