bazis 0.133.0 → 0.134.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 (38) hide show
  1. package/core/agent/AgentToolExecutor.ts +5 -0
  2. package/core/agent/Tool.contract.ts +2 -2
  3. package/core/agent/metadata.ts +11 -0
  4. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  5. package/core/scripts/di-generate.ts +8 -0
  6. package/core/tools/StandardTools.module.ts +14 -0
  7. package/core/tools/generated/deps.ts +10 -0
  8. package/core/tools/index.ts +8 -0
  9. package/core/tools/orm/OrmEntity.references.ts +71 -0
  10. package/core/tools/orm/ormEntityTools.ts +423 -0
  11. package/core/workflow/contracts/WorkflowError.ts +2 -1
  12. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  13. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  14. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
  15. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  16. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  17. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  18. package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +8 -0
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +24 -0
  21. package/core/workflow/generated/deps.ts +8 -2
  22. package/core/workflow/index.ts +5 -0
  23. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
  24. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  25. package/core/workflow/runtime_module/internal/RunLimits.ts +13 -3
  26. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  27. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  28. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +29 -3
  29. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
  30. package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
  31. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +21 -1
  32. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +29 -40
  33. package/library/workflow/cron.ts +103 -24
  34. package/library/workflow/index.ts +2 -2
  35. package/library/workflow/nodeKinds.ts +64 -9
  36. package/library/workflow/paths.ts +2 -2
  37. package/library/workflow/validateProcess.ts +12 -0
  38. package/package.json +2 -1
@@ -60,6 +60,8 @@ export interface AgentToolExecutionContext {
60
60
  readonly approval: AgentToolExecutionApproval;
61
61
  /** On whose behalf the call runs, when the caller says so. */
62
62
  readonly actor?: ToolActor;
63
+ /** A trial: the Tool, one with `dryRun: "supported"`, changes nothing (WORKFLOW-002 §5.4). */
64
+ readonly dryRun?: boolean;
63
65
  readonly metadata: JsonObject;
64
66
  readonly signal: AbortSignal;
65
67
  }
@@ -135,6 +137,8 @@ export interface AgentToolExecutionOptions {
135
137
  readonly caller?: string;
136
138
  /** On whose behalf the call runs; reaches the approval policy and the audit. */
137
139
  readonly actor?: ToolActor;
140
+ /** Tells the Tool to change nothing: `context.dryRun`. */
141
+ readonly dryRun?: boolean;
138
142
  readonly invocationId?: string;
139
143
  readonly metadata?: unknown;
140
144
  readonly approvalPolicy?: AgentToolApprovalPolicy;
@@ -1667,6 +1671,7 @@ export class AgentToolExecutor {
1667
1671
  ...(attempt.idempotencyKey !== undefined ? { idempotencyKey: attempt.idempotencyKey } : {}),
1668
1672
  approval,
1669
1673
  ...(options.actor !== undefined ? { actor: options.actor } : {}),
1674
+ ...(options.dryRun === true ? { dryRun: true } : {}),
1670
1675
  metadata,
1671
1676
  signal,
1672
1677
  });
@@ -13,8 +13,8 @@ export function describeTool(tool: ToolDefinition) {
13
13
  };
14
14
  return agentToolContract({
15
15
  name: tool.metadata.name, description: tool.metadata.description,
16
- input: tool.metadata.input ? schema(tool.metadata.input) : undefined,
17
- output: tool.metadata.output ? schema(tool.metadata.output) : undefined,
16
+ input: tool.metadata.input ? schema(tool.metadata.input) : tool.metadata.inputSchema ? agentJsonSchema(`${tool.metadata.name}.input`, tool.metadata.inputSchema) : undefined,
17
+ output: tool.metadata.output ? schema(tool.metadata.output) : tool.metadata.outputSchema ? agentJsonSchema(`${tool.metadata.name}.output`, tool.metadata.outputSchema) : undefined,
18
18
  sideEffect: tool.metadata.sideEffect, approval: tool.metadata.approval, timeoutMs: tool.metadata.timeoutMs, dryRun: tool.metadata.dryRun,
19
19
  });
20
20
  }
@@ -28,6 +28,13 @@ export interface ToolOptions {
28
28
  readonly timeoutMs?: number;
29
29
  /** `"unsupported"` by default. */
30
30
  readonly dryRun?: ToolDryRun;
31
+ /**
32
+ * The contract of a Tool made at run time, which has no class to generate a
33
+ * schema from, instead of `input` and `output`. It describes the Tool to callers;
34
+ * such a Tool checks its input itself.
35
+ */
36
+ readonly inputSchema?: Readonly<Record<string, unknown>>;
37
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
31
38
  /**
32
39
  * Who may call the Tool: `"agent"` for LLM agents, any other id for code that
33
40
  * runs Tools itself (`ToolRunner.as("workflow")`). Required and non-empty;
@@ -47,6 +54,8 @@ export interface ToolMetadata {
47
54
  readonly approval: ToolApproval;
48
55
  readonly timeoutMs?: number;
49
56
  readonly dryRun: ToolDryRun;
57
+ readonly inputSchema?: Readonly<Record<string, unknown>>;
58
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
50
59
  readonly callers: readonly string[];
51
60
  readonly tags: readonly string[];
52
61
  }
@@ -120,6 +129,8 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
120
129
  approval: approvalFor(sideEffect, options.approval),
121
130
  timeoutMs: positiveInteger(options.timeoutMs, "@Tool.timeoutMs"),
122
131
  dryRun: options.dryRun ?? "unsupported",
132
+ ...(options.inputSchema === undefined ? {} : { inputSchema: options.inputSchema }),
133
+ ...(options.outputSchema === undefined ? {} : { outputSchema: options.outputSchema }),
123
134
  // Checked when the module registers the Tool, so a missing list prints as
124
135
  // one configuration-error line at start instead of failing the import.
125
136
  callers: Array.isArray(options.callers) ? Object.freeze(options.callers.map((caller) => callerId(caller))) : Object.freeze([]),
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.133.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.134.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -1086,6 +1086,13 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
1086
1086
  }
1087
1087
  fatalErrors.push(`BAZIS_DI_PROVIDER_FORM: ${sourceLocation(element)}: providers of ${module}: "${text(element)}" is not an agreed registration. A computed service is a class with a constructor: scoped(IService, Service). Agreed forms: ${AGREED_PROVIDERS}.`);
1088
1088
  };
1089
+ /** Tools made from an entity: they are classes too, and carry their own wiring. */
1090
+ const isOrmEntityTools = (element: ts.Expression): boolean => {
1091
+ if (!ts.isSpreadElement(element)) return false;
1092
+ const call = resolved(element.expression, ts.isCallExpression);
1093
+ const target = call && callee(call);
1094
+ return target?.name === "ormEntityTools" && target.file?.endsWith("/core/tools/orm/ormEntityTools.ts") === true;
1095
+ };
1089
1096
  const checkModule = (module: string, argument: ts.Expression | undefined): void => {
1090
1097
  const object = argument && resolved(argument, ts.isObjectLiteralExpression);
1091
1098
  if (!object) return;
@@ -1100,6 +1107,7 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
1100
1107
  }
1101
1108
  for (const element of list.elements) {
1102
1109
  if (field === "providers") checkProvider(module, element);
1110
+ else if (field === "tools" && isOrmEntityTools(element)) continue;
1103
1111
  else if (!ts.isIdentifier(element) && !ts.isPropertyAccessExpression(element)) {
1104
1112
  fatalErrors.push(`BAZIS_DI_PROVIDER_FORM: ${sourceLocation(element)}: ${field} of ${module}: "${text(element)}" — list the class itself; its constructor dependencies come from codegen.`);
1105
1113
  }
@@ -0,0 +1,14 @@
1
+ import { AccessModule } from "../access";
2
+ import { Module, singleton } from "../di";
3
+ import { OrmEntityReferences, OrmFieldReferences } from "./orm/OrmEntity.references";
4
+
5
+ /**
6
+ * Tools that come with the framework (WORKFLOW-002 §7): not needed until an
7
+ * application imports it. For now it owns the reference kinds of `ormEntityTools`.
8
+ */
9
+ @Module({
10
+ imports: [AccessModule],
11
+ references: { "orm.entity": OrmEntityReferences, "orm.field": OrmFieldReferences },
12
+ providers: [singleton(OrmEntityReferences), singleton(OrmFieldReferences)],
13
+ })
14
+ export class StandardToolsModule {}
@@ -0,0 +1,10 @@
1
+ // This file is auto-generated by `bun run di:generate` (framework.packs of bazis.config.json).
2
+ // Do not edit manually.
3
+ import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
+ import { ServiceProvider } from "../../di/ServiceProvider";
5
+ import { OrmEntityReferences, OrmFieldReferences } from "../orm/OrmEntity.references";
6
+
7
+ registerFrameworkClassDeps([
8
+ [OrmEntityReferences, [ServiceProvider]],
9
+ [OrmFieldReferences, [ServiceProvider]],
10
+ ]);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tools that come with the framework (WORKFLOW-002 §7): actions over the
3
+ * application's entities for processes and agents, closed by default.
4
+ */
5
+ import "./generated/deps";
6
+
7
+ export { StandardToolsModule } from "./StandardTools.module";
8
+ export { ormEntityTools, type OrmEntityOperation, type OrmEntityToolsOptions } from "./orm/ormEntityTools";
@@ -0,0 +1,71 @@
1
+ import type { ReferenceContext, ReferenceItem, ReferencePage } from "../../access";
2
+ import { References } from "../../access";
3
+ import { AgentRegistry } from "../../agent/AgentRegistry";
4
+ import type { ServiceProvider } from "../../di";
5
+ import { ormEntityToolInfo, type OrmEntityToolInfo } from "./ormEntityTools";
6
+
7
+ /** What `ormEntityTools` opened in this application: only that is referenced. */
8
+ function opened(services: ServiceProvider): readonly OrmEntityToolInfo[] {
9
+ const entities = new Map<string, Set<string>>();
10
+ for (const tool of AgentRegistry.fromContainer(services).listTools()) {
11
+ const info = ormEntityToolInfo(tool.target);
12
+ if (info === undefined) continue;
13
+ const fields = entities.get(info.entity) ?? new Set<string>();
14
+ for (const field of info.fields) fields.add(field);
15
+ entities.set(info.entity, fields);
16
+ }
17
+ return [...entities].map(([entity, fields]) => ({ entity, fields: [...fields] }));
18
+ }
19
+
20
+ function page(ids: readonly string[], text: string, { limit, offset }: ReferencePage): readonly ReferenceItem[] {
21
+ const found = text.trim().toLowerCase();
22
+ return ids.filter((id) => id.toLowerCase().includes(found)).sort().slice(offset, offset + limit).map((id) => ({ id, title: id }));
23
+ }
24
+
25
+ /** Reference kind `orm.entity`: an entity open to processes, `Order`. */
26
+ export class OrmEntityReferences extends References {
27
+ constructor(private readonly services: ServiceProvider) {
28
+ super();
29
+ }
30
+
31
+ private ids(): readonly string[] {
32
+ return opened(this.services).map((item) => item.entity);
33
+ }
34
+
35
+ async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
36
+ const known = new Set(this.ids());
37
+ return new Set(ids.filter((id) => known.has(id)));
38
+ }
39
+
40
+ async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
41
+ return [...await this.exists(ids)].map((id) => ({ id, title: id }));
42
+ }
43
+
44
+ async search(text: string, _context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
45
+ return page(this.ids(), text, range);
46
+ }
47
+ }
48
+
49
+ /** Reference kind `orm.field`: a field open to processes, `Order.status`. */
50
+ export class OrmFieldReferences extends References {
51
+ constructor(private readonly services: ServiceProvider) {
52
+ super();
53
+ }
54
+
55
+ private ids(): readonly string[] {
56
+ return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
57
+ }
58
+
59
+ async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
60
+ const known = new Set(this.ids());
61
+ return new Set(ids.filter((id) => known.has(id)));
62
+ }
63
+
64
+ async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
65
+ return [...await this.exists(ids)].map((id) => ({ id, title: id }));
66
+ }
67
+
68
+ async search(text: string, _context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
69
+ return page(this.ids(), text, range);
70
+ }
71
+ }
@@ -0,0 +1,423 @@
1
+ import { Tool, ToolError, type AgentToolExecutionContext } from "../../agent";
2
+ import { type Class, ServiceProvider } from "../../di";
3
+ import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
+ import { ModelBuilder, type DbContext, type DbSet, type EntityModel, type FieldSelector, type Operand, type Predicate, type PropertyModel } from "../../orm";
5
+
6
+ /** What a process may do with an entity. */
7
+ export type OrmEntityOperation = "find" | "create" | "update" | "delete";
8
+
9
+ export interface OrmEntityToolsOptions {
10
+ /** The context of the entity, from `@Module({ ormBazis })`. */
11
+ readonly context: Class<DbContext>;
12
+ readonly ops: readonly OrmEntityOperation[];
13
+ /** The only fields a Tool reads, filters by, sorts by and writes. The key is always read and filtered by, never written. */
14
+ readonly fields: readonly string[];
15
+ /**
16
+ * Ties the Tools to the partition of the instance: every query is filtered by it,
17
+ * a new record takes it, an instance without a partition is refused. The field
18
+ * is neither read nor written by hand.
19
+ */
20
+ readonly partitionField?: string;
21
+ /** Who may call the Tools; `["workflow"]` by default. */
22
+ readonly callers?: readonly string[];
23
+ /** The middle of the Tool names: `orm.<name>.find`. By default from the class: `OrderItem` → `order-item`. */
24
+ readonly name?: string;
25
+ /** The most records `find` returns, 1–1000; 100 by default. */
26
+ readonly maxRows?: number;
27
+ /** The most records one `update` or `delete` touches, or it is refused with `too_many_records`; 100 by default. */
28
+ readonly maxChanges?: number;
29
+ }
30
+
31
+ /** What the reference kinds `orm.entity` and `orm.field` read about a made Tool. */
32
+ export interface OrmEntityToolInfo {
33
+ readonly entity: string;
34
+ readonly fields: readonly string[];
35
+ }
36
+
37
+ const INFO = new WeakMap<object, OrmEntityToolInfo>();
38
+
39
+ /** Which entity a Tool made by {@link ormEntityTools} opens. */
40
+ export function ormEntityToolInfo(tool: object): OrmEntityToolInfo | undefined {
41
+ return INFO.get(tool);
42
+ }
43
+
44
+ const TOOL_NAME_PART = /^[a-z][a-z0-9_-]*$/;
45
+ const OPERATORS = new Set(["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "startsWith", "isNull"]);
46
+
47
+ type Json = Record<string, unknown>;
48
+ type EntityClass<T extends object> = new () => T;
49
+
50
+ interface Spec {
51
+ readonly model: EntityModel;
52
+ readonly entity: EntityClass<object>;
53
+ readonly context: Class<DbContext>;
54
+ readonly name: string;
55
+ /** Fields that may be written. */
56
+ readonly writable: ReadonlySet<string>;
57
+ /** Fields that are read and filtered by: the key and the writable ones. */
58
+ readonly readable: readonly string[];
59
+ readonly partitionField: string | undefined;
60
+ readonly maxRows: number;
61
+ readonly maxChanges: number;
62
+ }
63
+
64
+ type Run = (set: DbSet<object>, input: Json, context: AgentToolExecutionContext, db: DbContext) => Promise<unknown>;
65
+
66
+ /**
67
+ * Tools `orm.<entity>.find|create|update|delete` over an entity of the application
68
+ * (WORKFLOW-002 §7.4). Nothing is open by itself: the application names the
69
+ * operations and the fields. The result goes to `@Module({ tools })`, and the
70
+ * module imports `StandardToolsModule` for the reference kinds `orm.entity` and
71
+ * `orm.field`. All of them run with `context.dryRun`: nothing is written then.
72
+ *
73
+ * ```ts
74
+ * @Module({ imports: [StandardToolsModule], tools: [...ormEntityTools(Order, { context: OrdersDbContext, ops: ["find", "update"], fields: ["status", "amount"], partitionField: "shopId" })] })
75
+ * ```
76
+ */
77
+ export function ormEntityTools(entity: EntityClass<object>, options: OrmEntityToolsOptions): Class<object>[] {
78
+ const model = ModelBuilder.build(entity);
79
+ const name = options.name ?? entity.name.replace(/(?<=[a-z0-9])[A-Z]/g, (letter) => `-${letter}`).toLowerCase();
80
+ const fail = (problem: string) => new TypeError(`ormEntityTools(${entity.name}): ${problem}`);
81
+ if (!TOOL_NAME_PART.test(name)) throw fail(`${JSON.stringify(name)} cannot be a part of a Tool name`);
82
+ if (!Array.isArray(options.ops) || options.ops.length === 0) throw fail("ops must name at least one operation");
83
+ const keys = model.key.map((property) => property.propertyName);
84
+ for (const field of options.fields) {
85
+ if (model.propertyByName(field) === undefined) throw fail(`it has no field ${field}`);
86
+ if (keys.includes(field)) throw fail(`${field} is the key: it is read without being listed and is never written`);
87
+ if (field === options.partitionField) throw fail(`${field} is the partition field and is not open to processes`);
88
+ }
89
+ if (options.partitionField !== undefined && model.propertyByName(options.partitionField) === undefined) throw fail(`it has no field ${options.partitionField}`);
90
+ const maxRows = limit(options.maxRows, 100, 1000, fail, "maxRows");
91
+ const maxChanges = limit(options.maxChanges, 100, 100_000, fail, "maxChanges");
92
+ const spec: Spec = {
93
+ model,
94
+ entity,
95
+ context: options.context,
96
+ name,
97
+ writable: new Set(options.fields),
98
+ readable: [...keys, ...options.fields],
99
+ partitionField: options.partitionField,
100
+ maxRows,
101
+ maxChanges,
102
+ };
103
+ const callers = options.callers ?? ["workflow"];
104
+ return [...new Set(options.ops)].map((op) => {
105
+ if (!Object.hasOwn(OPERATIONS, op)) throw fail(`unknown operation ${JSON.stringify(op)}`);
106
+ return toolClass(spec, op, callers);
107
+ });
108
+ }
109
+
110
+ function limit(value: number | undefined, fallback: number, max: number, fail: (problem: string) => Error, name: string): number {
111
+ if (value === undefined) return fallback;
112
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) throw fail(`${name} must be an integer 1–${max}`);
113
+ return value;
114
+ }
115
+
116
+ function toolClass(spec: Spec, op: OrmEntityOperation, callers: readonly string[]): Class<object> {
117
+ const { describe, run, sideEffect, input, output } = OPERATIONS[op];
118
+
119
+ @Tool({
120
+ name: `orm.${spec.name}.${op}`,
121
+ description: describe(spec.entity.name),
122
+ sideEffect,
123
+ callers,
124
+ dryRun: "supported",
125
+ inputSchema: input(spec),
126
+ outputSchema: output(spec),
127
+ })
128
+ class OrmEntityTool {
129
+ constructor(private readonly services: ServiceProvider) {}
130
+
131
+ async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
132
+ const db = this.services.resolve(spec.context) as DbContext;
133
+ return await run(spec)(db.setOf(spec.entity), inputOf(raw), context, db);
134
+ }
135
+ }
136
+
137
+ registerFrameworkClassDeps([[OrmEntityTool, [ServiceProvider]]]);
138
+ INFO.set(OrmEntityTool, { entity: spec.entity.name, fields: spec.readable });
139
+ return OrmEntityTool;
140
+ }
141
+
142
+ interface Operation {
143
+ readonly describe: (entity: string) => string;
144
+ readonly sideEffect: "read" | "write";
145
+ readonly input: (spec: Spec) => Json;
146
+ readonly output: (spec: Spec) => Json;
147
+ readonly run: (spec: Spec) => Run;
148
+ }
149
+
150
+ const OPERATIONS: Readonly<Record<OrmEntityOperation, Operation>> = {
151
+ find: {
152
+ describe: (entity) => `Finds records of ${entity}.`,
153
+ sideEffect: "read",
154
+ input: (spec) => objectSchema({
155
+ where: whereSchema(spec),
156
+ sort: { type: "array", items: objectSchema({ field: { enum: spec.readable }, dir: { enum: ["asc", "desc"] } }, ["field"]) },
157
+ limit: { type: "integer", minimum: 1, maximum: spec.maxRows },
158
+ offset: { type: "integer", minimum: 0 },
159
+ }),
160
+ output: (spec) => objectSchema({ items: { type: "array", items: recordSchema(spec) }, total: { type: "integer" } }, ["items", "total"]),
161
+ run: (spec) => async (set, input, context) => {
162
+ const where = predicateOf(spec, input.where, context, false);
163
+ const take = pageSize(input.limit, spec.maxRows);
164
+ const skip = offsetOf(input.offset);
165
+ const sorts = sortOf(spec, input.sort);
166
+ let query = set.asNoTracking().where(where);
167
+ const total = await query.count();
168
+ for (const { field, dir } of sorts) {
169
+ query = dir === "desc" ? query.orderByDescending((row) => operandOf(row, field)) : query.orderBy((row) => operandOf(row, field));
170
+ }
171
+ for (const key of spec.model.key) query = query.orderBy((row) => operandOf(row, key.propertyName));
172
+ const rows = await query.skip(skip).take(take).toList();
173
+ return { items: rows.map((row) => recordOf(spec, row)), total };
174
+ },
175
+ },
176
+ create: {
177
+ describe: (entity) => `Creates a record of ${entity}.`,
178
+ sideEffect: "write",
179
+ input: (spec) => objectSchema({ values: valuesSchema(spec) }, ["values"]),
180
+ output: (spec) => objectSchema({ item: recordSchema(spec), dryRun: { type: "boolean" } }, ["item"]),
181
+ run: (spec) => async (set, input, context, db) => {
182
+ const row = Object.assign(new spec.entity(), valuesOf(spec, input.values), spec.partitionField === undefined ? {} : { [spec.partitionField]: partitionOf(context) });
183
+ if (context.dryRun === true) return { item: recordOf(spec, row), dryRun: true };
184
+ set.add(row);
185
+ await db.saveChanges();
186
+ return { item: recordOf(spec, row) };
187
+ },
188
+ },
189
+ update: {
190
+ describe: (entity) => `Changes records of ${entity}.`,
191
+ sideEffect: "write",
192
+ input: (spec) => objectSchema({ where: { ...whereSchema(spec), minProperties: 1 }, values: valuesSchema(spec) }, ["where", "values"]),
193
+ output: () => objectSchema({ updated: { type: "integer" }, dryRun: { type: "boolean" } }, ["updated"]),
194
+ run: (spec) => async (set, input, context, db) => {
195
+ const values = valuesOf(spec, input.values);
196
+ if (Object.keys(values).length === 0) throw invalid("values must name at least one field");
197
+ const where = predicateOf(spec, input.where, context, true);
198
+ return await db.transactionScope(async () => {
199
+ const query = set.asNoTracking().where(where);
200
+ const count = await checkedCount(spec, query);
201
+ if (context.dryRun === true) return { updated: count, dryRun: true };
202
+ const { affectedRows } = await query.executeUpdate(values);
203
+ return { updated: affectedRows };
204
+ });
205
+ },
206
+ },
207
+ delete: {
208
+ describe: (entity) => `Deletes records of ${entity}.`,
209
+ sideEffect: "write",
210
+ input: (spec) => objectSchema({ where: { ...whereSchema(spec), minProperties: 1 } }, ["where"]),
211
+ output: () => objectSchema({ deleted: { type: "integer" }, dryRun: { type: "boolean" } }, ["deleted"]),
212
+ run: (spec) => async (set, input, context, db) => {
213
+ const where = predicateOf(spec, input.where, context, true);
214
+ return await db.transactionScope(async () => {
215
+ const query = set.asNoTracking().where(where);
216
+ const count = await checkedCount(spec, query);
217
+ if (context.dryRun === true) return { deleted: count, dryRun: true };
218
+ const { affectedRows } = await query.executeDelete();
219
+ return { deleted: affectedRows };
220
+ });
221
+ },
222
+ },
223
+ };
224
+
225
+ function invalid(message: string): ToolError {
226
+ return new ToolError(message, { reason: "invalid_input" });
227
+ }
228
+
229
+ function inputOf(raw: unknown): Json {
230
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw invalid("the input must be an object");
231
+ return raw as Json;
232
+ }
233
+
234
+ function pageSize(value: unknown, max: number): number {
235
+ if (value === undefined || value === null) return max;
236
+ if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) throw invalid(`limit must be an integer 1–${max}`);
237
+ return value as number;
238
+ }
239
+
240
+ function offsetOf(value: unknown): number {
241
+ if (value === undefined || value === null) return 0;
242
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw invalid("offset must be an integer from 0");
243
+ return value as number;
244
+ }
245
+
246
+ function sortOf(spec: Spec, value: unknown): readonly { readonly field: string; readonly dir: "asc" | "desc" }[] {
247
+ if (value === undefined || value === null) return [];
248
+ if (!Array.isArray(value)) throw invalid("sort must be a list of { field, dir }");
249
+ return value.map((item) => {
250
+ const { field, dir = "asc" } = (item ?? {}) as { field?: unknown; dir?: unknown };
251
+ if (typeof field !== "string" || !spec.readable.includes(field)) throw invalid(`sort: field ${JSON.stringify(field)} is not open`);
252
+ if (dir !== "asc" && dir !== "desc") throw invalid("sort: dir must be asc or desc");
253
+ return { field, dir };
254
+ });
255
+ }
256
+
257
+ function partitionOf(context: AgentToolExecutionContext): string {
258
+ const { partition } = (context.metadata ?? {}) as { partition?: unknown };
259
+ if (typeof partition !== "string" || partition === "") throw new ToolError("This Tool works inside a partition and the call has none", { reason: "partition_required" });
260
+ return partition;
261
+ }
262
+
263
+ function operandOf(row: FieldSelector<object>, field: string): Operand {
264
+ return (row as unknown as Record<string, Operand>)[field]!;
265
+ }
266
+
267
+ /** The filter of a call: its conditions and the partition. `required`: an empty filter would touch everything. */
268
+ function predicateOf(spec: Spec, value: unknown, context: AgentToolExecutionContext, required: boolean): (row: FieldSelector<object>) => Predicate {
269
+ const where = value === undefined || value === null ? {} : value;
270
+ if (typeof where !== "object" || Array.isArray(where)) throw invalid("where must be an object: { field: value } or { field: { op: value } }");
271
+ const conditions = Object.entries(where as Json);
272
+ if (required && conditions.length === 0) throw invalid("where must name at least one field");
273
+ const partition = spec.partitionField === undefined ? undefined : partitionOf(context);
274
+ const tests = conditions.flatMap(([field, test]) => {
275
+ const property = spec.readable.includes(field) ? spec.model.propertyByName(field) : undefined;
276
+ if (property === undefined) throw invalid(`where: field ${JSON.stringify(field)} is not open`);
277
+ const operators = typeof test === "object" && test !== null && !Array.isArray(test) ? Object.entries(test as Json) : [["eq", test] as const];
278
+ if (operators.length === 0) throw invalid(`where.${field} names no operator`);
279
+ return operators.map(([op, operand]) => {
280
+ if (!OPERATORS.has(op)) throw invalid(`where.${field}: unknown operator ${JSON.stringify(op)}`);
281
+ return { field, op, operand: operandValue(property, op, operand, field) };
282
+ });
283
+ });
284
+ return (row) => {
285
+ let result: Predicate | undefined = partition === undefined ? undefined : operandOf(row, spec.partitionField!).eq(partition);
286
+ for (const { field, op, operand } of tests) {
287
+ const next = applyOperator(operandOf(row, field), op, operand);
288
+ result = result === undefined ? next : result.and(next);
289
+ }
290
+ // `1 = 1` has no form here: a key never empty is what "everything" means.
291
+ return result ?? operandOf(row, spec.model.key[0].propertyName).isNotNull();
292
+ };
293
+ }
294
+
295
+ function operandValue(property: PropertyModel, op: string, value: unknown, field: string): unknown {
296
+ if (op === "isNull") {
297
+ if (typeof value !== "boolean") throw invalid(`where.${field}.isNull must be true or false`);
298
+ return value;
299
+ }
300
+ if (op === "in" || op === "nin") {
301
+ if (!Array.isArray(value)) throw invalid(`where.${field}.${op} must be a list`);
302
+ return value.map((item) => columnValue(property, item, `where.${field}`));
303
+ }
304
+ if (op === "contains" || op === "startsWith") {
305
+ if (typeof value !== "string") throw invalid(`where.${field}.${op} must be a string`);
306
+ return value;
307
+ }
308
+ return columnValue(property, value, `where.${field}`);
309
+ }
310
+
311
+ function applyOperator(operand: Operand, op: string, value: unknown): Predicate {
312
+ switch (op) {
313
+ case "ne": return value === null ? operand.isNotNull() : operand.ne(value as never);
314
+ case "gt": return operand.gt(value as never);
315
+ case "gte": return operand.gte(value as never);
316
+ case "lt": return operand.lt(value as never);
317
+ case "lte": return operand.lte(value as never);
318
+ case "in": return operand.in(value as never[]);
319
+ case "nin": return operand.in(value as never[]).not();
320
+ case "contains": return operand.contains(value as string);
321
+ case "startsWith": return operand.startsWith(value as string);
322
+ case "isNull": return value === true ? operand.isNull() : operand.isNotNull();
323
+ default: return value === null ? operand.isNull() : operand.eq(value as never);
324
+ }
325
+ }
326
+
327
+ /** A JSON value as the column takes it. */
328
+ function columnValue(property: PropertyModel, value: unknown, at: string): unknown {
329
+ if (value === null) {
330
+ if (property.required && !at.startsWith("where")) throw invalid(`${at} cannot be null`);
331
+ return null;
332
+ }
333
+ switch (property.type) {
334
+ case "integer":
335
+ if (!Number.isSafeInteger(value)) throw invalid(`${at} must be an integer`);
336
+ return value;
337
+ case "real":
338
+ case "decimal":
339
+ if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(`${at} must be a number`);
340
+ return value;
341
+ case "boolean":
342
+ if (typeof value !== "boolean") throw invalid(`${at} must be true or false`);
343
+ return value;
344
+ case "datetime": {
345
+ const date = typeof value === "string" ? new Date(value) : undefined;
346
+ if (date === undefined || Number.isNaN(date.getTime())) throw invalid(`${at} must be a date`);
347
+ return date;
348
+ }
349
+ case "json":
350
+ return value;
351
+ default:
352
+ if (typeof value !== "string") throw invalid(`${at} must be a string`);
353
+ return value;
354
+ }
355
+ }
356
+
357
+ function valuesOf(spec: Spec, value: unknown): Json {
358
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid("values must be an object");
359
+ const out: Json = {};
360
+ for (const [field, item] of Object.entries(value)) {
361
+ if (!spec.writable.has(field)) throw invalid(`values: field ${JSON.stringify(field)} is not open for writing`);
362
+ out[field] = columnValue(spec.model.propertyByName(field)!, item, `values.${field}`);
363
+ }
364
+ return out;
365
+ }
366
+
367
+ async function checkedCount(spec: Spec, query: { count(): Promise<number> }): Promise<number> {
368
+ const count = await query.count();
369
+ if (count > spec.maxChanges) throw new ToolError(`The filter takes ${count} records of ${spec.entity.name}, at most ${spec.maxChanges} may change at once`, { reason: "too_many_records" });
370
+ return count;
371
+ }
372
+
373
+ /** What goes back to the process: open fields only, in JSON. */
374
+ function recordOf(spec: Spec, row: object): Json {
375
+ const source = row as Json;
376
+ return Object.fromEntries(spec.readable.map((field) => {
377
+ const value = source[field];
378
+ return [field, value instanceof Date ? value.toISOString() : typeof value === "bigint" ? value.toString() : value ?? null];
379
+ }));
380
+ }
381
+
382
+ function objectSchema(properties: Json, required: readonly string[] = []): Json {
383
+ return { type: "object", properties, additionalProperties: false, ...(required.length === 0 ? {} : { required }) };
384
+ }
385
+
386
+ function columnSchema(property: PropertyModel): Json {
387
+ const nullable = (type: string): Json => ({ type: !property.required ? [type, "null"] : type });
388
+ switch (property.type) {
389
+ case "integer": return nullable("integer");
390
+ case "real":
391
+ case "decimal": return nullable("number");
392
+ case "boolean": return nullable("boolean");
393
+ case "datetime": return { ...nullable("string"), format: "date-time" };
394
+ case "json": return {};
395
+ default: return nullable("string");
396
+ }
397
+ }
398
+
399
+ function whereSchema(spec: Spec): Json {
400
+ return {
401
+ type: "object",
402
+ description: "{ field: value } or { field: { op: value } }; ops: eq, ne, gt, gte, lt, lte, in, nin, contains, startsWith, isNull",
403
+ "x-ref-keys": "orm.field",
404
+ "x-ref-scope": spec.entity.name,
405
+ properties: Object.fromEntries(spec.readable.map((field) => [field, {}])),
406
+ additionalProperties: false,
407
+ };
408
+ }
409
+
410
+ function valuesSchema(spec: Spec): Json {
411
+ return {
412
+ type: "object",
413
+ "x-ref-keys": "orm.field",
414
+ "x-ref-scope": spec.entity.name,
415
+ properties: Object.fromEntries([...spec.writable].map((field) => [field, columnSchema(spec.model.propertyByName(field)!)])),
416
+ additionalProperties: false,
417
+ };
418
+ }
419
+
420
+ function recordSchema(spec: Spec): Json {
421
+ return objectSchema(Object.fromEntries(spec.readable.map((field) => [field, columnSchema(spec.model.propertyByName(field)!)])));
422
+ }
423
+
@@ -1,13 +1,14 @@
1
1
  import { HttpError } from "../../http";
2
2
  import type { FormIssue, ProcessIssue } from "../../../library/workflow";
3
3
 
4
- export type WorkflowErrorCode = "not_found" | "conflict" | "wrong_status" | "invalid_document" | "invalid_input" | "archived";
4
+ export type WorkflowErrorCode = "not_found" | "conflict" | "wrong_status" | "invalid_document" | "invalid_input" | "archived" | "in_use";
5
5
 
6
6
  const STATUS: Readonly<Record<WorkflowErrorCode, number>> = {
7
7
  not_found: 404,
8
8
  conflict: 409,
9
9
  wrong_status: 409,
10
10
  archived: 409,
11
+ in_use: 409,
11
12
  invalid_document: 422,
12
13
  invalid_input: 422,
13
14
  };