bazis 0.121.0 → 0.124.0

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.
Files changed (54) hide show
  1. package/cli/templates/module.ts +5 -4
  2. package/cli/templates/passport.ts +1 -1
  3. package/core/agent/AgentRuntime.ts +7 -2
  4. package/core/agent/AgentToolExecutor.ts +34 -11
  5. package/core/agent/README.md +10 -9
  6. package/core/agent/ToolRunner.ts +135 -0
  7. package/core/agent/index.ts +3 -0
  8. package/core/agent/internal/AgentExecutionDriver.ts +9 -2
  9. package/core/agent/internal/ToolAudit.projector.ts +16 -0
  10. package/core/agent/metadata.ts +25 -0
  11. package/core/agent/moduleContributions-v1.ts +5 -1
  12. package/core/agent/toolExecutionOptions.ts +15 -0
  13. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  14. package/core/workflow/Workflow.module.ts +15 -0
  15. package/core/workflow/access/WorkflowAccess.ts +52 -0
  16. package/core/workflow/contracts/WorkflowError.ts +28 -0
  17. package/core/workflow/contracts/WorkflowInputValidator.ts +24 -0
  18. package/core/workflow/contracts/types.ts +30 -0
  19. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +24 -0
  20. package/core/workflow/definitions_module/http/Processes.controller.ts +92 -0
  21. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +16 -0
  22. package/core/workflow/definitions_module/model/WfProcess.model.ts +52 -0
  23. package/core/workflow/definitions_module/model/WfVersion.model.ts +33 -0
  24. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +79 -0
  25. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +234 -0
  26. package/core/workflow/index.ts +43 -0
  27. package/core/workflow/internal/WorkflowTools.ts +36 -0
  28. package/core/workflow/internal/http.ts +63 -0
  29. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +53 -0
  30. package/core/workflow/runtime_module/background/WorkflowRetention.ts +66 -0
  31. package/core/workflow/runtime_module/background/WorkflowWorker.ts +92 -0
  32. package/core/workflow/runtime_module/config/Runtime.config.ts +27 -0
  33. package/core/workflow/runtime_module/events/Runtime.events.ts +36 -0
  34. package/core/workflow/runtime_module/http/Instances.controller.ts +72 -0
  35. package/core/workflow/runtime_module/internal/GraphCache.ts +37 -0
  36. package/core/workflow/runtime_module/internal/ToolStep.ts +60 -0
  37. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +356 -0
  38. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +25 -0
  39. package/core/workflow/runtime_module/model/WfHistory.model.ts +38 -0
  40. package/core/workflow/runtime_module/model/WfInstance.model.ts +70 -0
  41. package/core/workflow/runtime_module/model/WfJob.model.ts +49 -0
  42. package/core/workflow/runtime_module/model/WfSubscription.model.ts +30 -0
  43. package/core/workflow/runtime_module/model/WfToken.model.ts +39 -0
  44. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +82 -0
  45. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +167 -0
  46. package/library/workflow/ProcessDocument.ts +94 -0
  47. package/library/workflow/conditions.ts +44 -0
  48. package/library/workflow/duration.ts +10 -0
  49. package/library/workflow/formFields.ts +112 -0
  50. package/library/workflow/index.ts +24 -0
  51. package/library/workflow/nodeKinds.ts +111 -0
  52. package/library/workflow/paths.ts +39 -0
  53. package/library/workflow/validateProcess.ts +310 -0
  54. package/package.json +4 -2
@@ -0,0 +1,82 @@
1
+ import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
2
+ import type { HistoryType } from "../model/WfHistory.model";
3
+ import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
4
+ import type { TokenStatus } from "../model/WfToken.model";
5
+
6
+ export interface StartInstance {
7
+ readonly input?: unknown;
8
+ /** The application's scope: a space, an organization. */
9
+ readonly partition?: string | null;
10
+ /** The application's own key of what the instance is about: `order:42`. */
11
+ readonly businessKey?: string | null;
12
+ /** On whose behalf the steps run. */
13
+ readonly actor: WorkflowActor;
14
+ }
15
+
16
+ export interface InstanceSummary {
17
+ readonly id: string;
18
+ readonly processKey: string;
19
+ readonly version: number;
20
+ readonly partition: string | null;
21
+ readonly status: InstanceStatus;
22
+ readonly businessKey: string | null;
23
+ readonly startedVia: StartedVia;
24
+ readonly startedBy: WorkflowActor;
25
+ readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
26
+ readonly createdAt: Date;
27
+ readonly endedAt: Date | null;
28
+ }
29
+
30
+ export interface TokenDetails {
31
+ readonly id: string;
32
+ readonly nodeId: string;
33
+ readonly viaFlowId: string | null;
34
+ readonly status: TokenStatus;
35
+ readonly output: unknown;
36
+ readonly createdAt: Date;
37
+ readonly endedAt: Date | null;
38
+ }
39
+
40
+ export interface InstanceDetails extends InstanceSummary {
41
+ readonly input: Record<string, unknown>;
42
+ readonly vars: Record<string, unknown>;
43
+ readonly nodes: Record<string, unknown>;
44
+ /** Every token, oldest first. */
45
+ readonly tokens: readonly TokenDetails[];
46
+ }
47
+
48
+ export interface HistoryEntry {
49
+ readonly id: string;
50
+ readonly tokenId: string | null;
51
+ readonly nodeId: string | null;
52
+ readonly type: HistoryType;
53
+ readonly data: Record<string, unknown> | null;
54
+ readonly actor: WorkflowActor | null;
55
+ readonly at: Date;
56
+ }
57
+
58
+ export interface InstanceListQuery extends WorkflowPaging {
59
+ readonly partition?: string;
60
+ readonly status?: InstanceStatus;
61
+ readonly processKey?: string;
62
+ readonly businessKey?: string;
63
+ readonly startedById?: string;
64
+ }
65
+
66
+ /**
67
+ * Runs published versions (WORKFLOW-001 §4). Errors are {@link WorkflowError}:
68
+ * `not_found`, `archived`, `invalid_input` with the issues, `wrong_status`.
69
+ */
70
+ export abstract class IWorkflowRuntime {
71
+ /** Starts the latest version of a process from its manual start. */
72
+ abstract start(processKey: string, options: StartInstance): Promise<InstanceDetails>;
73
+ abstract cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails>;
74
+ /** Retries the failed steps of a `failed` instance. */
75
+ abstract retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails>;
76
+ /** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
77
+ abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
78
+ /** Instances, newest first. */
79
+ abstract instances(query?: InstanceListQuery): Promise<WorkflowPage<InstanceSummary>>;
80
+ abstract instance(instanceId: string): Promise<InstanceDetails>;
81
+ abstract history(instanceId: string): Promise<readonly HistoryEntry[]>;
82
+ }
@@ -0,0 +1,167 @@
1
+ import type { FormSpec } from "../../../../library/workflow";
2
+ import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
3
+ import { WorkflowError } from "../../contracts/WorkflowError";
4
+ import type { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
5
+ import type { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
+ import type { WorkflowEngine } from "../internal/WorkflowEngine";
7
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
8
+ import type { WfHistory } from "../model/WfHistory.model";
9
+ import type { WfInstance } from "../model/WfInstance.model";
10
+ import type { WfToken } from "../model/WfToken.model";
11
+ import {
12
+ IWorkflowRuntime,
13
+ type HistoryEntry,
14
+ type InstanceDetails,
15
+ type InstanceListQuery,
16
+ type InstanceSummary,
17
+ type StartInstance,
18
+ } from "./IWorkflowRuntime.service";
19
+
20
+ const STATUSES = new Set(["running", "completed", "cancelled", "failed"]);
21
+
22
+ export class WorkflowRuntime extends IWorkflowRuntime {
23
+ constructor(
24
+ private readonly db: RuntimeDbContext,
25
+ private readonly engine: WorkflowEngine,
26
+ private readonly definitions: IWorkflowDefinitions,
27
+ private readonly validator: WorkflowInputValidator,
28
+ ) {
29
+ super();
30
+ }
31
+
32
+ async start(processKey: string, options: StartInstance): Promise<InstanceDetails> {
33
+ actorOf(options.actor);
34
+ const partition = optionalText(options.partition, "partition");
35
+ const businessKey = optionalText(options.businessKey, "businessKey");
36
+ const version = await this.definitions.latest(processKey);
37
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
38
+ if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
39
+ const { value, issues } = await this.validator.validate(version.document.input, options.input, { purpose: "input", processKey, partition });
40
+ if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
41
+ const id = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor });
42
+ return await this.instance(id);
43
+ }
44
+
45
+ async cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails> {
46
+ actorOf(options.actor);
47
+ const reason = optionalText(options.reason, "reason", 1000);
48
+ if (!isUuid(instanceId)) throw notFound(instanceId);
49
+ const done = await this.engine.inStep(instanceId, async (step) => {
50
+ if (step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
51
+ await this.engine.cancel(step, reason, options.actor);
52
+ return true;
53
+ });
54
+ if (done === null) throw notFound(instanceId);
55
+ return await this.instance(instanceId);
56
+ }
57
+
58
+ async retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails> {
59
+ actorOf(options.actor);
60
+ if (!isUuid(instanceId)) throw notFound(instanceId);
61
+ const done = await this.engine.inStep(instanceId, async (step) => {
62
+ if (step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Only a failed instance is retried; this one is ${step.instance.status}`);
63
+ await this.engine.retry(step, options.actor);
64
+ return true;
65
+ });
66
+ if (done === null) throw notFound(instanceId);
67
+ return await this.instance(instanceId);
68
+ }
69
+
70
+ async completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails> {
71
+ actorOf(actor);
72
+ if (!isUuid(tokenId)) throw new WorkflowError("not_found", `Token ${tokenId} does not exist`);
73
+ const token = await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(tokenId));
74
+ if (!token) throw new WorkflowError("not_found", `Token ${tokenId} does not exist`);
75
+ const done = await this.engine.inStep(token.instanceId, async (step) => {
76
+ const node = step.graph.document.nodes[token.nodeId];
77
+ const current = step.tokens.find((t) => t.id === tokenId);
78
+ if (node?.kind !== "userTask" || !current || current.status !== "waiting" || step.instance.status !== "running") {
79
+ throw new WorkflowError("wrong_status", "The token does not wait for a task");
80
+ }
81
+ const { value, issues } = await this.validator.validate(node.config?.form as FormSpec | undefined, output, { purpose: "task", processKey: step.instance.processKey, partition: step.instance.partition });
82
+ if (issues.length > 0) throw new WorkflowError("invalid_input", "The task form is invalid", issues);
83
+ await this.engine.complete(step, tokenId, value, actor);
84
+ return true;
85
+ });
86
+ if (done === null) throw notFound(token.instanceId);
87
+ return await this.instance(token.instanceId);
88
+ }
89
+
90
+ async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
91
+ const { limit, offset } = pagingOf(query);
92
+ if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
93
+ let source = this.db.instances.asNoTracking();
94
+ if (query.partition !== undefined) source = source.where((i) => i.partition.eq(query.partition!));
95
+ if (query.status !== undefined) source = source.where((i) => i.status.eq(query.status!));
96
+ if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
97
+ if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
98
+ if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
99
+ const total = await source.count();
100
+ const rows = await source.orderByDescending((i) => i.id).skip(offset).take(limit).toList();
101
+ return { items: rows.map(summaryOf), total };
102
+ }
103
+
104
+ async instance(instanceId: string): Promise<InstanceDetails> {
105
+ if (!isUuid(instanceId)) throw notFound(instanceId);
106
+ const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(instanceId));
107
+ if (!row) throw notFound(instanceId);
108
+ const tokens = await this.db.tokens.asNoTracking().where((t) => t.instanceId.eq(instanceId)).orderBy((t) => t.id).toList();
109
+ return { ...summaryOf(row), input: row.input, vars: row.vars, nodes: row.nodes, tokens: tokens.map(tokenOf) };
110
+ }
111
+
112
+ async history(instanceId: string): Promise<readonly HistoryEntry[]> {
113
+ if (!isUuid(instanceId)) throw notFound(instanceId);
114
+ const exists = await this.db.instances.asNoTracking().where((i) => i.id.eq(instanceId)).count();
115
+ if (exists === 0) throw notFound(instanceId);
116
+ const rows = await this.db.history.asNoTracking().where((h) => h.instanceId.eq(instanceId)).orderBy((h) => h.id).toList();
117
+ return rows.map(historyOf);
118
+ }
119
+ }
120
+
121
+ function notFound(instanceId: string): WorkflowError {
122
+ return new WorkflowError("not_found", `Instance ${instanceId} does not exist`);
123
+ }
124
+
125
+ function actorOf(actor: WorkflowActor): void {
126
+ if (typeof actor !== "object" || actor === null || (actor.kind !== "user" && actor.kind !== "system") || typeof actor.id !== "string" || actor.id === "" || actor.id.length > 200) {
127
+ throw new WorkflowError("invalid_input", "actor must be { kind: \"user\" | \"system\", id }");
128
+ }
129
+ }
130
+
131
+ function optionalText(value: unknown, name: string, max = 200): string | null {
132
+ if (value === undefined || value === null) return null;
133
+ if (typeof value !== "string" || value.length > max) throw new WorkflowError("invalid_input", `${name} must be a string up to ${max} characters`);
134
+ return value;
135
+ }
136
+
137
+ function summaryOf(row: WfInstance): InstanceSummary {
138
+ return {
139
+ id: row.id,
140
+ processKey: row.processKey,
141
+ version: row.version,
142
+ partition: row.partition,
143
+ status: row.status,
144
+ businessKey: row.businessKey,
145
+ startedVia: row.startedVia,
146
+ startedBy: { kind: row.startedByKind, id: row.startedById },
147
+ error: row.error,
148
+ createdAt: row.createdAt,
149
+ endedAt: row.endedAt,
150
+ };
151
+ }
152
+
153
+ function tokenOf(row: WfToken) {
154
+ return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
155
+ }
156
+
157
+ function historyOf(row: WfHistory): HistoryEntry {
158
+ return {
159
+ id: row.id,
160
+ tokenId: row.tokenId,
161
+ nodeId: row.nodeId,
162
+ type: row.type,
163
+ data: row.data,
164
+ actor: row.actorId === null ? null : { kind: row.actorKind === "system" ? "system" : "user", id: row.actorId },
165
+ at: row.at,
166
+ };
167
+ }
@@ -0,0 +1,94 @@
1
+ import type { FormSpec } from "./formFields";
2
+
3
+ /**
4
+ * A process document: the framework's own JSON, not BPMN XML (a designer may
5
+ * render BPMN from it). One object holds the process input (`input`), the graph
6
+ * (`nodes`, `flows`) and the canvas layout (`diagram`).
7
+ */
8
+ export const PROCESS_SCHEMA_VERSION = "bazis.workflow/v1";
9
+
10
+ /** The node kinds of version 1; closed: a new kind comes with a new document version. */
11
+ export type NodeKind =
12
+ | "manualStart"
13
+ | "scheduleStart"
14
+ | "eventStart"
15
+ | "userTask"
16
+ | "serviceTask"
17
+ | "timerCatch"
18
+ | "eventWait"
19
+ | "exclusiveGateway"
20
+ | "parallelGateway"
21
+ | "boundaryError"
22
+ | "boundaryTimer"
23
+ | "boundaryEvent"
24
+ | "end";
25
+
26
+ export interface ProcessNode {
27
+ readonly kind: NodeKind;
28
+ readonly title: string;
29
+ /** Boundary nodes only: the step the node is attached to. */
30
+ readonly attachedTo?: string;
31
+ readonly config?: Readonly<Record<string, unknown>>;
32
+ }
33
+
34
+ export interface ProcessFlow {
35
+ readonly source: string;
36
+ readonly target: string;
37
+ readonly title?: string;
38
+ /** Condition of a flow that leaves an exclusive gateway. */
39
+ readonly condition?: Condition;
40
+ /** The flow an exclusive gateway takes when no condition holds. */
41
+ readonly isDefault?: boolean;
42
+ }
43
+
44
+ /** Comparison operators of conditions. */
45
+ export type ConditionOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "contains" | "empty" | "notEmpty";
46
+
47
+ /**
48
+ * A condition: a tree of `all` / `any` / `not` and comparisons. `path` addresses
49
+ * instance data: `$input.<field>`, `$vars.<name>`, `$nodes.<node>.<field>`.
50
+ */
51
+ export type Condition =
52
+ | { readonly all: readonly Condition[] }
53
+ | { readonly any: readonly Condition[] }
54
+ | { readonly not: Condition }
55
+ | { readonly path: string; readonly op: ConditionOp; readonly value?: unknown };
56
+
57
+ export interface Bounds {
58
+ readonly x: number;
59
+ readonly y: number;
60
+ readonly width: number;
61
+ readonly height: number;
62
+ }
63
+
64
+ export interface Point {
65
+ readonly x: number;
66
+ readonly y: number;
67
+ }
68
+
69
+ /** Canvas layout: stored and returned, never read by the engine. */
70
+ export interface ProcessDiagram {
71
+ readonly shapes: Readonly<Record<string, Bounds>>;
72
+ readonly edges: Readonly<Record<string, { readonly waypoints: readonly Point[] }>>;
73
+ }
74
+
75
+ export interface ProcessDocument {
76
+ readonly schemaVersion: typeof PROCESS_SCHEMA_VERSION;
77
+ /** Fields the process starts with; without them it starts with no data. */
78
+ readonly input?: FormSpec;
79
+ readonly nodes: Readonly<Record<string, ProcessNode>>;
80
+ readonly flows: Readonly<Record<string, ProcessFlow>>;
81
+ readonly diagram?: ProcessDiagram;
82
+ }
83
+
84
+ /** A new process: a manual start leading straight to the end. It already passes validation. */
85
+ export function emptyProcess(): ProcessDocument {
86
+ return {
87
+ schemaVersion: PROCESS_SCHEMA_VERSION,
88
+ nodes: {
89
+ Start: { kind: "manualStart", title: "Start" },
90
+ End: { kind: "end", title: "End" },
91
+ },
92
+ flows: { StartToEnd: { source: "Start", target: "End" } },
93
+ };
94
+ }
@@ -0,0 +1,44 @@
1
+ import type { Condition } from "./ProcessDocument";
2
+ import { isPath, readPath, type InstanceData } from "./paths";
3
+
4
+ /** Evaluates a flow condition. Pure: no calls, no side effects. */
5
+ export function evaluateCondition(condition: Condition, data: InstanceData): boolean {
6
+ if ("all" in condition) return condition.all.every((item) => evaluateCondition(item, data));
7
+ if ("any" in condition) return condition.any.some((item) => evaluateCondition(item, data));
8
+ if ("not" in condition) return !evaluateCondition(condition.not, data);
9
+ const actual = readPath(data, condition.path);
10
+ const expected = isPath(condition.value) ? readPath(data, condition.value) : condition.value;
11
+ switch (condition.op) {
12
+ case "eq": return same(actual, expected);
13
+ case "ne": return !same(actual, expected);
14
+ case "gt": return compare(actual, expected, (a, b) => a > b);
15
+ case "gte": return compare(actual, expected, (a, b) => a >= b);
16
+ case "lt": return compare(actual, expected, (a, b) => a < b);
17
+ case "lte": return compare(actual, expected, (a, b) => a <= b);
18
+ case "in": return Array.isArray(expected) && expected.some((item) => same(actual, item));
19
+ case "contains":
20
+ if (typeof actual === "string") return typeof expected === "string" && actual.includes(expected);
21
+ return Array.isArray(actual) && actual.some((item) => same(item, expected));
22
+ case "empty": return isEmpty(actual);
23
+ case "notEmpty": return !isEmpty(actual);
24
+ }
25
+ }
26
+
27
+ function same(a: unknown, b: unknown): boolean {
28
+ if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return Number(a) === Number(b);
29
+ return JSON.stringify(a) === JSON.stringify(b);
30
+ }
31
+
32
+ /** Numbers compare as numbers (a decimal arrives as a string), other strings as strings (ISO dates compare right). */
33
+ function compare(a: unknown, b: unknown, test: (a: number | string, b: number | string) => boolean): boolean {
34
+ if (a === null || a === undefined || b === null || b === undefined) return false;
35
+ const na = Number(a), nb = Number(b);
36
+ if (typeof a !== "boolean" && typeof b !== "boolean" && a !== "" && b !== "" && Number.isFinite(na) && Number.isFinite(nb)) return test(na, nb);
37
+ return typeof a === "string" && typeof b === "string" ? test(a, b) : false;
38
+ }
39
+
40
+ function isEmpty(value: unknown): boolean {
41
+ if (value === null || value === undefined || value === "") return true;
42
+ if (Array.isArray(value)) return value.length === 0;
43
+ return typeof value === "object" && Object.keys(value).length === 0;
44
+ }
@@ -0,0 +1,10 @@
1
+ import { ISO_DURATION } from "./nodeKinds";
2
+
3
+ /** Milliseconds of an ISO 8601 duration without months and years (`P2D`, `PT1H30M`); `undefined` if it is not one. */
4
+ export function durationMs(value: string): number | undefined {
5
+ const match = ISO_DURATION.exec(value);
6
+ if (!match) return undefined;
7
+ const number = (part: string | undefined) => (part === undefined ? 0 : Number.parseInt(part, 10));
8
+ const weeks = number(match[1]), days = number(match[2]), hours = number(match[4]), minutes = number(match[5]), seconds = number(match[6]);
9
+ return ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
10
+ }
@@ -0,0 +1,112 @@
1
+ /** Field types of a process input and of a task form. */
2
+ export type FormFieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum";
3
+
4
+ export interface FormField {
5
+ /** The key in the value: `amount`, `reason`. */
6
+ readonly code: string;
7
+ readonly type: FormFieldType;
8
+ readonly title?: string;
9
+ readonly required?: boolean;
10
+ /** Allowed values of an `enum` field. */
11
+ readonly values?: readonly (string | number)[];
12
+ }
13
+
14
+ /** The fields of a process input or a task form. */
15
+ export interface FormSpec {
16
+ readonly fields: readonly FormField[];
17
+ }
18
+
19
+ /** One problem of a value: which field and why. */
20
+ export interface FormIssue {
21
+ readonly field: string;
22
+ readonly code: "required" | "type" | "enum" | "unknown";
23
+ readonly message: string;
24
+ }
25
+
26
+ const TYPES = new Set<FormFieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum"]);
27
+ const CODE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
28
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
29
+ const MAX_FIELDS = 200;
30
+
31
+ function isObject(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ /** Checks the shape of a form spec; returns a problem or `null`. `at` names it in the text. */
36
+ export function checkFormSpec(value: unknown, at: string): string | null {
37
+ if (!isObject(value) || !Array.isArray(value.fields)) return `${at} must be an object with a fields list`;
38
+ if (value.fields.length > MAX_FIELDS) return `${at} has more than ${MAX_FIELDS} fields`;
39
+ const seen = new Set<string>();
40
+ for (const [index, field] of value.fields.entries()) {
41
+ const where = `${at}.fields[${index}]`;
42
+ if (!isObject(field)) return `${where} must be an object`;
43
+ if (typeof field.code !== "string" || !CODE.test(field.code) || field.code === "__proto__") return `${where}.code is invalid`;
44
+ if (seen.has(field.code)) return `${where}.code ${field.code} is repeated`;
45
+ seen.add(field.code);
46
+ if (typeof field.type !== "string" || !TYPES.has(field.type as FormFieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
47
+ if (field.title !== undefined && (typeof field.title !== "string" || field.title.length > 200)) return `${where}.title must be up to 200 characters`;
48
+ if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
49
+ if (field.type === "enum") {
50
+ if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
51
+ return `${where}.values must be a non-empty list of strings or numbers`;
52
+ }
53
+ } else if (field.values !== undefined) {
54
+ return `${where}.values is only for enum fields`;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** The field codes of a form spec. */
61
+ export function formFieldCodes(spec: FormSpec): string[] {
62
+ return spec.fields.map((field) => field.code);
63
+ }
64
+
65
+ /**
66
+ * Checks a value against a form spec: required fields, types, enum values and
67
+ * unknown fields. Returns the value with only the declared fields and every issue.
68
+ * Without a spec the form takes no data: any field is unknown.
69
+ */
70
+ export function validateForm(spec: FormSpec | undefined, value: unknown): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } {
71
+ const issues: FormIssue[] = [];
72
+ const source = value === undefined || value === null ? {} : value;
73
+ if (!isObject(source)) return { value: {}, issues: [{ field: "", code: "type", message: "value must be an object" }] };
74
+ const fields = spec?.fields ?? [];
75
+ const declared = new Set(fields.map((field) => field.code));
76
+ for (const key of Object.keys(source)) {
77
+ if (!declared.has(key)) issues.push({ field: key, code: "unknown", message: `field ${key} is not declared` });
78
+ }
79
+ const out: Record<string, unknown> = {};
80
+ for (const field of fields) {
81
+ const item = Object.hasOwn(source, field.code) ? source[field.code] : undefined;
82
+ if (item === undefined || item === null || item === "") {
83
+ if (field.required === true) issues.push({ field: field.code, code: "required", message: `field ${field.code} is required` });
84
+ continue;
85
+ }
86
+ const problem = checkType(field, item);
87
+ if (problem) issues.push({ field: field.code, ...problem });
88
+ else out[field.code] = item;
89
+ }
90
+ return { value: out, issues };
91
+ }
92
+
93
+ function checkType(field: FormField, value: unknown): { code: "type" | "enum"; message: string } | null {
94
+ const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.code} must be ${expected}` });
95
+ switch (field.type) {
96
+ case "string":
97
+ case "text":
98
+ return typeof value === "string" ? null : wrong("a string");
99
+ case "number":
100
+ return typeof value === "number" && Number.isFinite(value) ? null : wrong("a number");
101
+ case "integer":
102
+ return Number.isSafeInteger(value) ? null : wrong("an integer");
103
+ case "boolean":
104
+ return typeof value === "boolean" ? null : wrong("true or false");
105
+ case "date":
106
+ return typeof value === "string" && DATE.test(value) && !Number.isNaN(Date.parse(value)) ? null : wrong("a date YYYY-MM-DD");
107
+ case "datetime":
108
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? null : wrong("an ISO 8601 date and time");
109
+ case "enum":
110
+ return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.code} must be one of ${(field.values ?? []).join(", ")}` };
111
+ }
112
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The process document format `bazis.workflow/v1` (WORKFLOW-001 §2): types,
3
+ * node kinds, validation, data paths and conditions. Pure functions without DI
4
+ * or a database: usable by a designer, a CLI or a browser.
5
+ */
6
+ export {
7
+ PROCESS_SCHEMA_VERSION,
8
+ emptyProcess,
9
+ type Bounds,
10
+ type Condition,
11
+ type ConditionOp,
12
+ type NodeKind,
13
+ type Point,
14
+ type ProcessDiagram,
15
+ type ProcessDocument,
16
+ type ProcessFlow,
17
+ type ProcessNode,
18
+ } from "./ProcessDocument";
19
+ export { NODE_KINDS, nodeKindOf, ISO_DURATION, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
20
+ export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
21
+ export { DATA_PATH, isPath, readPath, resolveInput, type InstanceData } from "./paths";
22
+ export { evaluateCondition } from "./conditions";
23
+ export { durationMs } from "./duration";
24
+ export { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";
@@ -0,0 +1,111 @@
1
+ import { checkFormSpec } from "./formFields";
2
+ import { DATA_PATH } from "./paths";
3
+ import type { NodeKind } from "./ProcessDocument";
4
+
5
+ /** The role of a node in the graph: it sets the rules of incoming and outgoing flows. */
6
+ export type NodeRole = "start" | "activity" | "gateway" | "boundary" | "end";
7
+
8
+ export interface NodeKindDefinition {
9
+ readonly kind: NodeKind;
10
+ readonly role: NodeRole;
11
+ /** Checks the node `config`; returns a problem or `null`. */
12
+ readonly check: (config: Record<string, unknown>) => string | null;
13
+ }
14
+
15
+ /** ISO 8601 duration without months and years: P1D, PT2H30M, PT45S. */
16
+ export const ISO_DURATION = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
17
+ /** An event key or a Tool name: `payments.received`, `payments.charge`. */
18
+ export const DOTTED_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
19
+ /** A process variable name. */
20
+ export const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
21
+ const ROLE = /^[a-z][a-z0-9_.-]{0,63}$/;
22
+ const USER = /^[^\s:][^\s]{0,127}$/;
23
+
24
+ const none = () => null;
25
+
26
+ function string(config: Record<string, unknown>, key: string, pattern: RegExp, required = true): string | null {
27
+ const value = config[key];
28
+ if (value === undefined) return required ? `config.${key} is required` : null;
29
+ return typeof value === "string" && pattern.test(value) && value !== "__proto__" ? null : `config.${key} is invalid`;
30
+ }
31
+
32
+ function boolean(config: Record<string, unknown>, key: string): string | null {
33
+ const value = config[key];
34
+ return value === undefined || typeof value === "boolean" ? null : `config.${key} must be a boolean`;
35
+ }
36
+
37
+ function first(...checks: (string | null)[]): string | null {
38
+ return checks.find((check) => check !== null) ?? null;
39
+ }
40
+
41
+ /** Five cron fields: minute, hour, day of month, month, day of week. */
42
+ function cron(config: Record<string, unknown>): string | null {
43
+ const value = config.cron;
44
+ if (typeof value !== "string") return "config.cron is required";
45
+ const parts = value.trim().split(/\s+/);
46
+ return parts.length === 5 && parts.every((part) => /^[\d*/,\-]+$/.test(part)) ? null : "config.cron must have five fields";
47
+ }
48
+
49
+ /** A timer: either `duration` (ISO 8601) or `at`, a path to a date in instance data. */
50
+ function timer(config: Record<string, unknown>): string | null {
51
+ const hasDuration = config.duration !== undefined;
52
+ const hasAt = config.at !== undefined;
53
+ if (hasDuration === hasAt) return "config needs exactly one of duration or at";
54
+ return hasDuration ? string(config, "duration", ISO_DURATION) : string(config, "at", DATA_PATH);
55
+ }
56
+
57
+ /** Who gets a user task: a role code or a user id. */
58
+ function assignee(config: Record<string, unknown>): string | null {
59
+ const value = config.assignee;
60
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.assignee is required";
61
+ const target = value as Record<string, unknown>;
62
+ if (Object.keys(target).length !== 1) return "config.assignee needs exactly one of role or user";
63
+ if (typeof target.role === "string") return ROLE.test(target.role) ? null : "config.assignee.role is invalid";
64
+ if (typeof target.user === "string") return USER.test(target.user) ? null : "config.assignee.user is invalid";
65
+ return "config.assignee needs exactly one of role or user";
66
+ }
67
+
68
+ function form(config: Record<string, unknown>): string | null {
69
+ return config.form === undefined ? null : checkFormSpec(config.form, "config.form");
70
+ }
71
+
72
+ /** Tool input: parameter name → instance path or literal. */
73
+ function inputMapping(config: Record<string, unknown>): string | null {
74
+ const value = config.input;
75
+ if (value === undefined) return null;
76
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.input must be an object";
77
+ return Object.keys(value).every((key) => VAR_NAME.test(key)) ? null : "config.input has an invalid parameter name";
78
+ }
79
+
80
+ /** Automatic retries of a failed Tool call. */
81
+ function retry(config: Record<string, unknown>): string | null {
82
+ const value = config.retry;
83
+ if (value === undefined) return null;
84
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.retry must be an object";
85
+ const policy = value as Record<string, unknown>;
86
+ if (!Number.isSafeInteger(policy.maxAttempts) || (policy.maxAttempts as number) < 1 || (policy.maxAttempts as number) > 10) return "config.retry.maxAttempts must be 1–10";
87
+ return string(policy, "backoff", ISO_DURATION, false)?.replace("config.", "config.retry.") ?? null;
88
+ }
89
+
90
+ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
91
+ { kind: "manualStart", role: "start", check: none },
92
+ { kind: "scheduleStart", role: "start", check: cron },
93
+ { kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY) },
94
+ { kind: "userTask", role: "activity", check: (c) => first(assignee(c), form(c), string(c, "dueIn", ISO_DURATION, false)) },
95
+ { kind: "serviceTask", role: "activity", check: (c) => first(string(c, "tool", DOTTED_KEY), inputMapping(c), string(c, "saveTo", VAR_NAME, false), retry(c)) },
96
+ { kind: "timerCatch", role: "activity", check: timer },
97
+ { kind: "eventWait", role: "activity", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", VAR_NAME, false)) },
98
+ { kind: "exclusiveGateway", role: "gateway", check: none },
99
+ { kind: "parallelGateway", role: "gateway", check: none },
100
+ { kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false) },
101
+ { kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")) },
102
+ { kind: "boundaryEvent", role: "boundary", check: (c) => first(string(c, "event", DOTTED_KEY), boolean(c, "interrupting")) },
103
+ { kind: "end", role: "end", check: none },
104
+ ] satisfies NodeKindDefinition[]);
105
+
106
+ const BY_KIND = new Map(NODE_KINDS.map((definition) => [definition.kind, definition]));
107
+
108
+ /** The definition of a node kind, or `undefined` for an unknown kind. */
109
+ export function nodeKindOf(kind: unknown): NodeKindDefinition | undefined {
110
+ return typeof kind === "string" ? BY_KIND.get(kind as NodeKind) : undefined;
111
+ }