bazis 0.133.0 → 0.135.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.
- package/core/access/contracts/References.ts +2 -0
- package/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +75 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +9 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
- package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +31 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +80 -6
- package/core/workflow/generated/deps.ts +13 -4
- package/core/workflow/index.ts +5 -0
- package/core/workflow/internal/WorkflowReferences.ts +69 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
- package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +16 -5
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/ToolStep.ts +16 -2
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +48 -9
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +34 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +40 -41
- package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/index.ts +3 -2
- package/library/workflow/nodeKinds.ts +64 -9
- package/library/workflow/paths.ts +2 -2
- package/library/workflow/references.ts +106 -0
- package/library/workflow/validateProcess.ts +13 -0
- package/package.json +2 -1
|
@@ -25,6 +25,8 @@ export interface ReferenceItem {
|
|
|
25
25
|
export interface ReferenceContext {
|
|
26
26
|
readonly scope: Scope;
|
|
27
27
|
readonly language?: string;
|
|
28
|
+
/** The area the ids are looked for in: the fields of one table. A kind without areas ignores it. */
|
|
29
|
+
readonly within?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface ReferencePage {
|
|
@@ -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
|
}
|
package/core/agent/metadata.ts
CHANGED
|
@@ -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.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.135.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,75 @@
|
|
|
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`, or `status` within `Order`. */
|
|
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[], context: ReferenceContext): Promise<ReadonlySet<string>> {
|
|
60
|
+
const known = new Set(this.ids());
|
|
61
|
+
const { within } = context;
|
|
62
|
+
return new Set(ids.filter((id) => known.has(within === undefined ? id : `${within}.${id}`)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async describe(ids: readonly string[], context: ReferenceContext): Promise<readonly ReferenceItem[]> {
|
|
66
|
+
return [...await this.exists(ids, context)].map((id) => ({ id, title: id }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async search(text: string, context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
|
|
70
|
+
const { within } = context;
|
|
71
|
+
if (within === undefined) return page(this.ids(), text, range);
|
|
72
|
+
const prefix = `${within}.`;
|
|
73
|
+
return page(this.ids().filter((id) => id.startsWith(prefix)).map((id) => id.slice(prefix.length)), text, range);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -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
|
};
|