bazis 0.134.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.
Files changed (27) hide show
  1. package/core/access/contracts/References.ts +2 -0
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/tools/orm/OrmEntity.references.ts +11 -7
  4. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
  5. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
  6. package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
  7. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +23 -0
  8. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +56 -6
  9. package/core/workflow/generated/deps.ts +5 -2
  10. package/core/workflow/internal/WorkflowReferences.ts +69 -0
  11. package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
  12. package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
  13. package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
  14. package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
  15. package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
  16. package/core/workflow/runtime_module/internal/RunLimits.ts +4 -3
  17. package/core/workflow/runtime_module/internal/ToolStep.ts +16 -2
  18. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +20 -7
  19. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +13 -0
  20. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +11 -1
  21. package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
  22. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
  23. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
  24. package/library/workflow/index.ts +1 -0
  25. package/library/workflow/references.ts +106 -0
  26. package/library/workflow/validateProcess.ts +1 -0
  27. package/package.json +1 -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 {
@@ -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.134.0" }, capabilities: { experimentalApi: true },
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");
@@ -46,7 +46,7 @@ export class OrmEntityReferences extends References {
46
46
  }
47
47
  }
48
48
 
49
- /** Reference kind `orm.field`: a field open to processes, `Order.status`. */
49
+ /** Reference kind `orm.field`: a field open to processes, `Order.status`, or `status` within `Order`. */
50
50
  export class OrmFieldReferences extends References {
51
51
  constructor(private readonly services: ServiceProvider) {
52
52
  super();
@@ -56,16 +56,20 @@ export class OrmFieldReferences extends References {
56
56
  return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
57
57
  }
58
58
 
59
- async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
59
+ async exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>> {
60
60
  const known = new Set(this.ids());
61
- return new Set(ids.filter((id) => known.has(id)));
61
+ const { within } = context;
62
+ return new Set(ids.filter((id) => known.has(within === undefined ? id : `${within}.${id}`)));
62
63
  }
63
64
 
64
- async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
65
- return [...await this.exists(ids)].map((id) => ({ id, title: id }));
65
+ async describe(ids: readonly string[], context: ReferenceContext): Promise<readonly ReferenceItem[]> {
66
+ return [...await this.exists(ids, context)].map((id) => ({ id, title: id }));
66
67
  }
67
68
 
68
- async search(text: string, _context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
69
- return page(this.ids(), text, range);
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);
70
74
  }
71
75
  }
@@ -6,6 +6,7 @@ import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/Work
6
6
  import { DefaultWorkflowPartitions, WorkflowPartitions } from "../contracts/WorkflowPartitions";
7
7
  import { WorkflowEvents } from "../internal/WorkflowEvents";
8
8
  import { WorkflowLanguages } from "../internal/WorkflowLanguages";
9
+ import { WorkflowReferences } from "../internal/WorkflowReferences";
9
10
  import { WorkflowTools } from "../internal/WorkflowTools";
10
11
  import { definitionsPermissions } from "./access/Definitions.permissions";
11
12
  import { FolderReferences } from "./access/Folder.references";
@@ -42,6 +43,7 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
42
43
  singleton(WorkflowExtensions, DefaultWorkflowExtensions),
43
44
  singleton(WorkflowPartitions, DefaultWorkflowPartitions),
44
45
  scoped(WorkflowEvents),
46
+ scoped(WorkflowReferences),
45
47
  scoped(CatalogImport),
46
48
  scoped(IWorkflowDefinitions, WorkflowDefinitions),
47
49
  scoped(ProcessReferences),
@@ -51,6 +53,6 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
51
53
  singleton(HOSTED_SERVICE, CodeProcessPublisher),
52
54
  ],
53
55
  controllers: [ProcessesController],
54
- exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
56
+ exports: [IWorkflowDefinitions, WorkflowReferences, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
55
57
  })
56
58
  export class WorkflowDefinitionsModule {}
@@ -1,6 +1,7 @@
1
1
  import { markConfigurationError, type ServiceProvider } from "../../../di";
2
2
  import type { HostedService } from "../../../di/extensions/hosted-service";
3
3
  import { LOGGER } from "../../../kernel";
4
+ import { WorkflowReferences } from "../../internal/WorkflowReferences";
4
5
  import { WorkflowTools } from "../../internal/WorkflowTools";
5
6
  import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
6
7
  import { CatalogImport } from "./CatalogImport";
@@ -12,7 +13,9 @@ import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
12
13
  * (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
13
14
  * (WORKFLOW-001 §14). A document with an unknown Tool or process, or an `extensions`
14
15
  * namespace nobody checks, stops the start with every finding of every process; the same
15
- * document again gives no new version.
16
+ * document again gives no new version. So does a reference kind no module owns, in a Tool
17
+ * or a document. A value that does not exist only warns: it may be data that is yet to come,
18
+ * and the step then fails with `ref_not_found`.
16
19
  */
17
20
  export class CodeProcessPublisher implements HostedService {
18
21
  readonly diagnosticName = "workflow:processes";
@@ -25,6 +28,11 @@ export class CodeProcessPublisher implements HostedService {
25
28
  try {
26
29
  const imported = await scope.resolve(CatalogImport).run();
27
30
  if (imported > 0) logger?.info("workflow catalog brought over from the old store", { processes: imported });
31
+ const references = scope.resolve(WorkflowReferences);
32
+ const unowned = references.unownedToolKinds();
33
+ if (unowned.length > 0) {
34
+ throw markConfigurationError(new WorkflowSetupError(`Tools refer to kinds no module owns:\n${unowned.map(({ tool, kind }) => ` - ${tool}: ${kind}${kind.startsWith("orm.") ? " (import StandardToolsModule from bazis/core/tools)" : " (declare it in @Module({ references }))"}`).join("\n")}`));
35
+ }
28
36
  const entries = this.services.resolve(CodeProcessCatalog).list();
29
37
  if (entries.length === 0) return;
30
38
  const tools = this.services.resolve(WorkflowTools);
@@ -35,6 +43,10 @@ export class CodeProcessPublisher implements HostedService {
35
43
  const found = [...definitionProblems(process, (name) => tools.has(name))];
36
44
  for (const issue of await definitions.validate(process.document, { processes })) {
37
45
  if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid" || issue.code === "unknown_process")) found.push(`${issue.message} (${issue.code})`);
46
+ else if (issue.code === "broken_ref") {
47
+ if (issue.message.startsWith("no module owns")) found.push(`${issue.message} (${issue.code})`);
48
+ else logger?.warn("workflow process refers to what does not exist", { process: process.key, node: issue.node, message: issue.message });
49
+ }
38
50
  }
39
51
  if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
40
52
  }
@@ -1,6 +1,6 @@
1
1
  import { expandModuleMetadata, type BazisModuleRef } from "../../../di";
2
- import { toolMetadataOf } from "../../../agent";
3
- import { validateProcess } from "../../../../library/workflow";
2
+ import { describeTool, toolMetadataOf } from "../../../agent";
3
+ import { documentReferences, schemaReferenceKinds, validateProcess } from "../../../../library/workflow";
4
4
  import { WORKFLOW_CALLER } from "../../internal/WorkflowTools";
5
5
  import { WorkflowDefinitionsModule } from "../WorkflowDefinitions.module";
6
6
  import { definitionProblems, type OwnedCodeProcess } from "./codeProcesses";
@@ -28,16 +28,23 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
28
28
  const found: OwnedCodeProcess[] = [];
29
29
  const errors: string[] = [];
30
30
  const tools = new Set<string>();
31
+ const schemas = new Map<string, unknown>();
32
+ const kinds = new Set<string>();
31
33
  const visited = new Set<BazisModuleRef>();
32
34
  const visit = (moduleRef: BazisModuleRef): void => {
33
35
  if (visited.has(moduleRef)) return;
34
36
  visited.add(moduleRef);
35
37
  const module = typeof moduleRef === "function" ? moduleRef.name || "<anonymous module>" : "<module>";
36
- const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown };
38
+ const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown; readonly references?: unknown };
39
+ if (typeof fields.references === "object" && fields.references !== null) for (const kind of Object.keys(fields.references)) kinds.add(kind);
37
40
  if (Array.isArray(fields.tools)) {
38
41
  for (const target of fields.tools) {
39
42
  const metadata = typeof target === "function" ? toolMetadataOf(target as new () => object) : undefined;
40
- if (metadata?.callers.includes(WORKFLOW_CALLER)) tools.add(metadata.name);
43
+ if (metadata?.callers.includes(WORKFLOW_CALLER)) {
44
+ tools.add(metadata.name);
45
+ const input = describeTool({ target: target as new () => object, metadata }).input;
46
+ if (input?.kind === "json-schema") schemas.set(metadata.name, input.schema);
47
+ }
41
48
  }
42
49
  }
43
50
  if (fields.processes !== undefined) {
@@ -54,6 +61,10 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
54
61
  if (found.length > 0 && !visited.has(WorkflowDefinitionsModule)) {
55
62
  errors.push(`${found[0]!.module} declares processes, but the application does not import WorkflowModule (bazis/core/workflow).`);
56
63
  }
64
+ // The values live in the database: `check` knows only whether someone owns their kind.
65
+ for (const [tool, schema] of schemas) {
66
+ for (const kind of schemaReferenceKinds(schema)) if (!kinds.has(kind)) errors.push(`Tool ${tool} refers to the kind ${kind}, and no module owns it.`);
67
+ }
57
68
  const owners = new Map<string, string>();
58
69
  for (const { process, module } of found) {
59
70
  const other = owners.get(process.key);
@@ -92,6 +92,24 @@ export interface SavedDraft {
92
92
  readonly draftUpdatedAt: Date;
93
93
  }
94
94
 
95
+ export interface ProcessReferenceQuery {
96
+ readonly kind: string;
97
+ readonly id: string;
98
+ /** The area of the value: the table of a field. Without it any area goes. */
99
+ readonly within?: string;
100
+ }
101
+
102
+ /** A process that refers to a value, and where. */
103
+ export interface ProcessReference {
104
+ readonly processKey: string;
105
+ readonly version: number;
106
+ readonly node?: string;
107
+ readonly variable?: string;
108
+ readonly within?: string;
109
+ /** Where in the input of the step: `/values/status`. */
110
+ readonly path: string;
111
+ }
112
+
95
113
  /**
96
114
  * The process catalog: drafts and immutable published versions
97
115
  * (WORKFLOW-001 §3). Errors are {@link WorkflowError}: `not_found`, `conflict`,
@@ -132,6 +150,11 @@ export abstract class IWorkflowDefinitions {
132
150
  abstract published(): Promise<readonly VersionDetails[]>;
133
151
  /** A version by id, for the engine; `undefined` if there is none. */
134
152
  abstract versionById(id: string): Promise<VersionDetails | undefined>;
153
+ /**
154
+ * Who refers to a value (WORKFLOW-002 §6.3), by the latest published versions: the
155
+ * application forbids or warns before it removes a field or a table.
156
+ */
157
+ abstract referencing(reference: ProcessReferenceQuery): Promise<readonly ProcessReference[]>;
135
158
  /** Processes with a retention period: what the retention service deletes. */
136
159
  abstract retentionPolicies(): Promise<readonly { readonly key: string; readonly retentionDays: number }[]>;
137
160
  /** The Tools a service task may call, with their input and output schemas. */
@@ -2,7 +2,7 @@ import { grantsToWhere } from "../../../access";
2
2
  import { createHash } from "node:crypto";
3
3
  import { paginate, UniqueViolationError } from "../../../orm";
4
4
  import type { AgentToolContract } from "../../../agent";
5
- import { allTexts, checkLocalizedText, emptyProcess, hasErrors, processPalette, validateProcess, type LocalizedText, type ProcessDocument, type ProcessIssue, type ProcessPalette } from "../../../../library/workflow";
5
+ import { allTexts, checkLocalizedText, emptyProcess, hasErrors, processPalette, validateProcess, type DocumentReference, type LocalizedText, type ProcessDocument, type ProcessIssue, type ProcessPalette } from "../../../../library/workflow";
6
6
  import type { ServiceProvider } from "../../../di";
7
7
  import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
8
8
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
@@ -13,6 +13,7 @@ import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
13
13
  import { canonicalJson } from "../../internal/canonicalJson";
14
14
  import type { WorkflowEvents } from "../../internal/WorkflowEvents";
15
15
  import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
16
+ import type { WorkflowReferences } from "../../internal/WorkflowReferences";
16
17
  import type { WorkflowTools } from "../../internal/WorkflowTools";
17
18
  import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
18
19
  import type { OwnedCodeProcess } from "../code/codeProcesses";
@@ -25,6 +26,8 @@ import {
25
26
  type CreateProcess,
26
27
  type ProcessDetails,
27
28
  type ProcessListQuery,
29
+ type ProcessReference,
30
+ type ProcessReferenceQuery,
28
31
  type ProcessSummary,
29
32
  type SavedDraft,
30
33
  type SaveDraftOptions,
@@ -48,6 +51,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
48
51
  private readonly clock: WorkflowClock,
49
52
  private readonly events: WorkflowEvents,
50
53
  private readonly languages: WorkflowLanguages,
54
+ private readonly references: WorkflowReferences,
51
55
  /** The scope: the port is asked here, so that the application's own registration replaces the default. */
52
56
  private readonly services: ServiceProvider,
53
57
  ) {
@@ -100,7 +104,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
100
104
  const published = await this.db.processes.asNoTracking().where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull())).select((p) => ({ key: p.key })).toList();
101
105
  const processes = new Set([...published.map((row) => row.key), ...options.processes ?? []]);
102
106
  const issues = validateProcess(document, { tools: (name) => this.workflowTools.has(name), processes: (key) => processes.has(key), extensions: (namespace) => namespaces.has(namespace) });
103
- if (namespaces.size === 0 || issues.some((issue) => issue.code === "too_large" || issue.code === "schema")) return issues;
107
+ if (issues.some((issue) => issue.code === "too_large" || issue.code === "schema")) return issues;
104
108
  const context: WorkflowPortContext = { partition: null, language: options.language ?? this.languages.default, actor: options.actor ?? null };
105
109
  const found: ProcessIssue[] = [...issues];
106
110
  const { extensions, variables, nodes } = document as ProcessDocument;
@@ -111,9 +115,52 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
111
115
  for (const message of await port.check(namespace, value, target, context)) found.push({ severity: "error", code: "extension_invalid", message: `${namespace}: ${message}`, ...target });
112
116
  }
113
117
  };
114
- await checkAll(extensions, {});
115
- if (Array.isArray(variables)) for (const variable of variables) await checkAll(variable?.extensions, { variable: variable?.name });
116
- for (const [node, raw] of Object.entries(nodes)) await checkAll(raw?.extensions, { node });
118
+ if (namespaces.size > 0) {
119
+ await checkAll(extensions, {});
120
+ if (Array.isArray(variables)) for (const variable of variables) await checkAll(variable?.extensions, { variable: variable?.name });
121
+ for (const [node, raw] of Object.entries(nodes)) await checkAll(raw?.extensions, { node });
122
+ }
123
+ found.push(...await this.brokenReferences(document as ProcessDocument, context.language));
124
+ return found;
125
+ }
126
+
127
+ /** The literal references no owner knows. The processes a document calls are told above as `unknown_process`. */
128
+ private async brokenReferences(document: ProcessDocument, language: string): Promise<ProcessIssue[]> {
129
+ let references: DocumentReference[];
130
+ try {
131
+ references = this.references.of(document);
132
+ } catch {
133
+ // A document that is not whole yet: its shape has been told above.
134
+ return [];
135
+ }
136
+ const missing = await this.references.missing(references.filter((reference) => reference.kind !== "workflow.process"), { scope: null, language });
137
+ return missing.map((reference) => ({
138
+ severity: "error" as const,
139
+ code: "broken_ref" as const,
140
+ message: this.references.owned(reference.kind)
141
+ ? `${reference.kind} ${JSON.stringify(reference.id)}${reference.within === undefined ? "" : ` in ${reference.within}`} does not exist`
142
+ : `no module owns the reference kind ${reference.kind}`,
143
+ ...(reference.node === undefined ? {} : { node: reference.node }),
144
+ ...(reference.variable === undefined ? {} : { variable: reference.variable }),
145
+ }));
146
+ }
147
+
148
+ async referencing(reference: ProcessReferenceQuery): Promise<readonly ProcessReference[]> {
149
+ const found: ProcessReference[] = [];
150
+ for (const version of await this.published()) {
151
+ for (const item of this.references.of(version.document)) {
152
+ if (item.kind !== reference.kind || item.id !== reference.id) continue;
153
+ if (reference.within !== undefined && item.within !== reference.within) continue;
154
+ found.push({
155
+ processKey: version.key,
156
+ version: version.version,
157
+ path: item.path,
158
+ ...(item.node === undefined ? {} : { node: item.node }),
159
+ ...(item.variable === undefined ? {} : { variable: item.variable }),
160
+ ...(item.within === undefined ? {} : { within: item.within }),
161
+ });
162
+ }
163
+ }
117
164
  return found;
118
165
  }
119
166
 
@@ -199,12 +246,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
199
246
  }
200
247
 
201
248
  async publish(key: string, actor: WorkflowActor, options: WorkflowAuditOptions & Pick<ValidateOptions, "language"> = {}): Promise<VersionDetails> {
249
+ // Before the transaction: the owners of references and the ports read their own contexts.
250
+ const draft = await this.process(key, false);
251
+ const issues = await this.validate(draft.draft, { actor, processes: [key], ...(options.language === undefined ? {} : { language: options.language }) });
202
252
  return await this.db.transactionScope(async (transaction) => {
203
253
  const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
204
254
  if (!row) throw notFound(key);
205
255
  this.assertNotCode(key);
206
256
  if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
207
- const issues = await this.validate(row.draft, { actor, processes: [key], ...(options.language === undefined ? {} : { language: options.language }) });
257
+ if (canonicalJson(row.draft) !== canonicalJson(draft.draft)) throw new WorkflowError("conflict", `The draft of ${key} was saved while it was being published`);
208
258
  if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
209
259
  const { version, created } = await this.publishDraft(row, actor);
210
260
  if (created) transaction.afterCommit(() => this.changed("published", key, actor, options, version.version));
@@ -2,6 +2,7 @@
2
2
  // Do not edit manually.
3
3
  import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
4
  import { DbContextOptions } from "../../../library/orm/DbContextOptions";
5
+ import { IAccessCatalog } from "../../access/catalog/IAccessCatalog.service";
5
6
  import { ServiceProvider } from "../../di/ServiceProvider";
6
7
  import { EventBus } from "../../kernel/events/EventBus";
7
8
  import { WorkflowClock } from "../contracts/WorkflowClock";
@@ -18,6 +19,7 @@ import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDe
18
19
  import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
19
20
  import { WorkflowEvents } from "../internal/WorkflowEvents";
20
21
  import { WorkflowLanguages } from "../internal/WorkflowLanguages";
22
+ import { WorkflowReferences } from "../internal/WorkflowReferences";
21
23
  import { WorkflowTools } from "../internal/WorkflowTools";
22
24
  import { WorkflowRetention } from "../runtime_module/background/WorkflowRetention";
23
25
  import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
@@ -63,11 +65,12 @@ registerFrameworkClassDeps([
63
65
  [TasksController, [IWorkflowTasks]],
64
66
  [TasksDbContext, [DbContextOptions]],
65
67
  [TimerStep, [RuntimeDbContext, WorkflowEngine]],
66
- [ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools]],
67
- [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, ServiceProvider]],
68
+ [ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools, WorkflowReferences, WorkflowLanguages]],
69
+ [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowReferences, ServiceProvider]],
68
70
  [WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
69
71
  [WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
70
72
  [WorkflowLanguages, ["Config:workflow"]],
73
+ [WorkflowReferences, [IAccessCatalog, WorkflowTools, ServiceProvider]],
71
74
  [WorkflowRetention, [ServiceProvider, "Config:workflow"]],
72
75
  [WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, EventDelivery, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
73
76
  [WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
@@ -0,0 +1,69 @@
1
+ import { IAccessCatalog } from "../../access";
2
+ import type { ServiceProvider } from "../../di";
3
+ import { documentReferences, schemaReferenceKinds, type DocumentReference, type InputReference, type ProcessDocument } from "../../../library/workflow";
4
+ import type { WorkflowTools } from "./WorkflowTools";
5
+
6
+ /** Whose references: the partition of the instance, when there is one. */
7
+ export interface ReferenceCheckContext {
8
+ readonly scope: string | null;
9
+ readonly language: string;
10
+ }
11
+
12
+ /**
13
+ * Asks the owners of reference kinds (ACCESS-001 §6) about the references of
14
+ * processes (WORKFLOW-002 §6.3). The owners are resolved in the scope of the call.
15
+ */
16
+ export class WorkflowReferences {
17
+ constructor(
18
+ private readonly catalog: IAccessCatalog,
19
+ private readonly tools: WorkflowTools,
20
+ private readonly services: ServiceProvider,
21
+ ) {}
22
+
23
+ /** The input schema of a Tool, in JSON Schema, if the Tool has one. */
24
+ inputSchemaOf(tool: string): unknown {
25
+ const input = this.tools.bound?.describe(tool)?.input;
26
+ return input?.kind === "json-schema" ? input.schema : undefined;
27
+ }
28
+
29
+ /** The literal references of a document. */
30
+ of(document: ProcessDocument): DocumentReference[] {
31
+ return documentReferences(document, (tool) => this.inputSchemaOf(tool));
32
+ }
33
+
34
+ /** Reference kinds the Tools name and no module owns: the application is built wrong. */
35
+ unownedToolKinds(): readonly { readonly tool: string; readonly kind: string }[] {
36
+ const found: { tool: string; kind: string }[] = [];
37
+ for (const contract of this.tools.list()) {
38
+ const schema = contract.input?.kind === "json-schema" ? contract.input.schema : undefined;
39
+ for (const kind of schemaReferenceKinds(schema)) if (!this.owned(kind)) found.push({ tool: contract.name, kind });
40
+ }
41
+ return found;
42
+ }
43
+
44
+ owned(kind: string): boolean {
45
+ return this.catalog.referenceKind(kind) !== undefined;
46
+ }
47
+
48
+ /** Those of `references` their owners do not know. A kind nobody owns is reported as not found. */
49
+ async missing<T extends InputReference>(references: readonly T[], context: ReferenceCheckContext): Promise<readonly T[]> {
50
+ const groups = new Map<string, T[]>();
51
+ for (const reference of references) {
52
+ const key = JSON.stringify([reference.kind, reference.within ?? null]);
53
+ groups.set(key, [...groups.get(key) ?? [], reference]);
54
+ }
55
+ const out: T[] = [];
56
+ for (const group of groups.values()) {
57
+ const { kind, within } = group[0]!;
58
+ const owner = this.catalog.referenceKind(kind);
59
+ if (owner === undefined) {
60
+ out.push(...group);
61
+ continue;
62
+ }
63
+ const ids = [...new Set(group.map((reference) => reference.id))];
64
+ const known = await this.services.resolve(owner.type).exists(ids, { scope: context.scope, language: context.language, ...(within === undefined ? {} : { within }) });
65
+ out.push(...group.filter((reference) => !known.has(reference.id)));
66
+ }
67
+ return out;
68
+ }
69
+ }
@@ -14,7 +14,7 @@ export const runtimePermissions = definePermissions({
14
14
  "workflow.instances.read": {
15
15
  title: { en: "See instances", ru: "Видеть экземпляры" },
16
16
  scope: { column: "partition" },
17
- on: { process, folder },
17
+ on: { process, folder, test: { type: "boolean" } },
18
18
  owner: "startedById",
19
19
  },
20
20
  "workflow.instances.manage": {
@@ -4,7 +4,9 @@ import { EventBus, type ConfigView } from "../../../kernel";
4
4
  import { WorkflowClock } from "../../contracts/WorkflowClock";
5
5
  import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
6
  import { WORKFLOW_INSTANCES_DELETED } from "../events/Runtime.events";
7
+ import type { EntityQuery } from "../../../orm";
7
8
  import { RuntimeDbContext } from "../model/Runtime.dbContext";
9
+ import type { WfInstance } from "../model/WfInstance.model";
8
10
  import type { WorkflowSettings } from "../../contracts/Workflow.config";
9
11
 
10
12
  const DAY_MS = 86_400_000;
@@ -13,6 +15,7 @@ const DAY_MS = 86_400_000;
13
15
  * Deletes `completed`, `cancelled` and `timedOut` instances older than their process's
14
16
  * `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
15
17
  * `retention.batchSize` at a time. `failed` instances wait for a person and stay.
18
+ * Trial runs are gone after a day, whatever their status and process.
16
19
  * Each deleted batch is published as `WORKFLOW_INSTANCES_DELETED`, so parts
17
20
  * with their own data (tasks) drop theirs.
18
21
  */
@@ -47,22 +50,23 @@ export class WorkflowRetention extends BackgroundService {
47
50
  const definitions = scope.resolve(IWorkflowDefinitions);
48
51
  const db = scope.resolve(RuntimeDbContext);
49
52
  const now = scope.resolve(WorkflowClock).now().getTime();
50
- for (const policy of await definitions.retentionPolicies()) {
51
- const cutoff = new Date(now - policy.retentionDays * DAY_MS);
53
+ const drop = async (select: () => EntityQuery<WfInstance>): Promise<boolean> => {
52
54
  for (;;) {
53
- if (signal?.aborted) return deleted;
54
- const ids = (await db.instances.asNoTracking()
55
- .where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled", "timedOut"])).and(i.endedAt.lt(cutoff)))
56
- .orderBy((i) => i.id)
57
- .take(batchSize)
58
- .select((i) => ({ id: i.id }))
59
- .toList()).map((row) => row.id);
60
- if (ids.length === 0) break;
55
+ if (signal?.aborted) return false;
56
+ const ids = (await select().orderBy((i) => i.id).take(batchSize).select((i) => ({ id: i.id })).toList()).map((row) => row.id);
57
+ if (ids.length === 0) return true;
61
58
  await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
62
59
  deleted += ids.length;
63
60
  await scope.resolve(EventBus).publishScoped(scope, WORKFLOW_INSTANCES_DELETED, { instanceIds: ids });
64
- if (ids.length < batchSize) break;
61
+ if (ids.length < batchSize) return true;
65
62
  }
63
+ };
64
+ const dayAgo = new Date(now - DAY_MS);
65
+ if (!await drop(() => db.instances.asNoTracking().where((i) => i.test.eq(true).and(i.createdAt.lt(dayAgo))))) return deleted;
66
+ for (const policy of await definitions.retentionPolicies()) {
67
+ const cutoff = new Date(now - policy.retentionDays * DAY_MS);
68
+ const kept = await drop(() => db.instances.asNoTracking().where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled", "timedOut"])).and(i.endedAt.lt(cutoff))));
69
+ if (!kept) return deleted;
66
70
  }
67
71
  } finally {
68
72
  await scope.dispose();
@@ -68,3 +68,10 @@ export const WORKFLOW_INSTANCES_DELETED = createEventToken<WorkflowInstancesDele
68
68
  export const WORKFLOW_INSTANCE_STARTED = createEventToken<WorkflowInstanceStarted>("workflow.instance-started");
69
69
  export const WORKFLOW_INSTANCE_CANCELLED = createEventToken<WorkflowInstanceCancelled>("workflow.instance-cancelled");
70
70
  export const WORKFLOW_INSTANCE_RETRIED = createEventToken<WorkflowInstanceRetried>("workflow.instance-retried");
71
+
72
+ /**
73
+ * Only for the part with the tasks: a trial run publishes nothing to the
74
+ * application, but its tasks still come and go (WORKFLOW-002 §5.4).
75
+ */
76
+ export const WORKFLOW_TRIAL_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.trial-task-requested");
77
+ export const WORKFLOW_TRIAL_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.trial-token-closed");
@@ -2,6 +2,7 @@ import { contextOnlyController } from "../../../http/Routing/contextOnlyControll
2
2
  import { can, type AccessObject } from "../../../access";
3
3
  import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../../../http";
4
4
  import { buildListDocument } from "../../../../library/jsonapi";
5
+ import { WorkflowError } from "../../contracts/WorkflowError";
5
6
  import { body, callerOf, filterOf, listQuery, optionalString, pagingOfList, param, requiredString } from "../../internal/http";
6
7
  import type { InstanceStatus } from "../model/WfInstance.model";
7
8
  import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
@@ -29,6 +30,7 @@ export class InstancesController {
29
30
  actor,
30
31
  request: from,
31
32
  language,
33
+ ...(request.test === true ? { test: true } : {}),
32
34
  });
33
35
  return Created(`${ctx.path}/${instance.id}`, instance);
34
36
  }
@@ -49,9 +51,11 @@ export class InstancesController {
49
51
  async list(ctx: HttpContext) {
50
52
  const { access } = await callerOf(ctx);
51
53
  const list = listQuery(ctx, {
52
- filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"], parentInstanceId: ["eq"] },
54
+ filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"], parentInstanceId: ["eq"], test: ["eq"] },
53
55
  sort: ["createdAt", "endedAt", "status", "processKey"],
54
56
  });
57
+ const test = filterOf(list, "test");
58
+ if (test !== undefined && test !== "true" && test !== "false") throw new WorkflowError("invalid_input", "test must be true or false");
55
59
  const page = await this.runtime.instances({
56
60
  ...pagingOfList(list),
57
61
  access: await access.filter("workflow.instances.read"),
@@ -61,6 +65,7 @@ export class InstancesController {
61
65
  businessKey: filterOf(list, "businessKey"),
62
66
  startedById: filterOf(list, "startedById"),
63
67
  parentInstanceId: filterOf(list, "parentInstanceId"),
68
+ ...(test === undefined ? {} : { test: test === "true" }),
64
69
  });
65
70
  return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
66
71
  }
@@ -100,7 +105,7 @@ export class InstancesController {
100
105
 
101
106
  /** The instance as the object of a permission. */
102
107
  private async objectOf(instance: InstanceSummary): Promise<AccessObject> {
103
- return { scope: instance.partition, process: instance.processKey, folder: await this.definitions.folderOf(instance.processKey), startedById: instance.startedBy.id };
108
+ return { scope: instance.partition, process: instance.processKey, folder: await this.definitions.folderOf(instance.processKey), startedById: instance.startedBy.id, test: instance.test };
104
109
  }
105
110
  }
106
111
  contextOnlyController(InstancesController);
@@ -108,6 +108,7 @@ export class ChildCalls {
108
108
  const current = step.tokens.find((t) => t.id === token.id);
109
109
  if (current?.status !== "waiting" || step.instance.status !== "running") return;
110
110
  const { id } = await this.engine.begin(childGraph, start[0], {
111
+ test: instance.test,
111
112
  input: value,
112
113
  partition: step.instance.partition,
113
114
  businessKey: null,
@@ -39,7 +39,7 @@ export class RunLimits {
39
39
  }
40
40
 
41
41
  private async releaseProcess(processKey: string): Promise<void> {
42
- const waiting = await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("pending"))).orderBy((i) => i.id).take(RELEASE_BATCH).toList();
42
+ const waiting = await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.status.eq("pending"))).orderBy((i) => i.id).take(RELEASE_BATCH).toList();
43
43
  if (waiting.length === 0) return;
44
44
  // Graphs live in the Definitions store, out of reach inside the transaction; `inStep` finds them cached.
45
45
  const graphs = new Map(await Promise.all(waiting.map(async (row) => [row.id, await this.engine.graphOf(row.versionId)] as const)));
@@ -65,7 +65,7 @@ export class RunLimits {
65
65
  /** Whether an instance older than this one still works on the key: it goes first. */
66
66
  private async keyTaken(processKey: string, businessKey: string, id: string): Promise<boolean> {
67
67
  const same = await this.db.instances.asNoTracking()
68
- .where((i) => i.processKey.eq(processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
68
+ .where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
69
69
  .count();
70
70
  return same > 0;
71
71
  }
@@ -73,7 +73,7 @@ export class RunLimits {
73
73
  /** Whether a scheduled run of the partition goes on, or waits since before this one. */
74
74
  private async scheduleTaken(processKey: string, partition: string | null, id: string): Promise<boolean> {
75
75
  const same = await this.db.instances.asNoTracking()
76
- .where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
76
+ .where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
77
77
  .count();
78
78
  return same > 0;
79
79
  }
@@ -113,6 +113,7 @@ export class RunLimits {
113
113
  await this.engine.inStep(failed.id, async (step) => {
114
114
  await this.dropJob(job);
115
115
  const { id } = await this.engine.begin(graph, start[0], {
116
+ test: failed.test,
116
117
  input: value,
117
118
  partition: failed.partition,
118
119
  businessKey: null,
@@ -1,6 +1,8 @@
1
1
  import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
2
- import { durationMs, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
2
+ import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
3
3
  import type { WorkflowToolMetadata } from "../../contracts/types";
4
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
5
+ import type { WorkflowReferences } from "../../internal/WorkflowReferences";
4
6
  import type { WorkflowTools } from "../../internal/WorkflowTools";
5
7
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
6
8
  import type { WfJob } from "../model/WfJob.model";
@@ -23,6 +25,8 @@ export class ToolStep {
23
25
  private readonly db: RuntimeDbContext,
24
26
  private readonly engine: WorkflowEngine,
25
27
  private readonly tools: WorkflowTools,
28
+ private readonly references: WorkflowReferences,
29
+ private readonly languages: WorkflowLanguages,
26
30
  ) {}
27
31
 
28
32
  async run(job: WfJob, signal: AbortSignal): Promise<void> {
@@ -42,13 +46,22 @@ export class ToolStep {
42
46
  await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
43
47
  }
44
48
  const runner = this.tools.bound;
49
+ const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
50
+ const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
45
51
  const result: AgentToolResult = runner === undefined
46
52
  ? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
53
+ : missing.length > 0
54
+ // A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
55
+ ? { callId: token.id, name, status: "error", error: { code: "ref_not_found", message: `${missing[0]!.kind} ${JSON.stringify(missing[0]!.id)} does not exist`, details: { reason: "ref_not_found", path: missing[0]!.path } }, metadata: {} } as AgentToolResult
56
+ : trial === "unsupported"
57
+ // A trial never calls a Tool that cannot run without changing anything.
58
+ ? { callId: token.id, name, status: "success", output: { dryRun: true }, metadata: {} } as AgentToolResult
47
59
  : await runner.run(name, input, {
48
60
  idempotencyKey: token.id,
49
61
  actor: runActorOf(graph.document, instance),
50
- metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: false } satisfies WorkflowToolMetadata,
62
+ metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: instance.test } satisfies WorkflowToolMetadata,
51
63
  signal,
64
+ ...(trial === "supported" ? { dryRun: true } : {}),
52
65
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
53
66
  });
54
67
 
@@ -72,6 +85,7 @@ export class ToolStep {
72
85
  status: result.status,
73
86
  attempt: job.attempts,
74
87
  durationMs: result.durationMs ?? null,
88
+ ...(trial === undefined ? {} : { dryRun: trial === "supported" ? "called" : "not called" }),
75
89
  ...payloads,
76
90
  ...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
77
91
  ...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
@@ -4,7 +4,7 @@ import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
4
4
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
5
5
  import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
6
  import type { WorkflowEvents } from "../../internal/WorkflowEvents";
7
- import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
7
+ import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED } from "../events/Runtime.events";
8
8
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
9
9
  import { WfHistory, type HistoryType } from "../model/WfHistory.model";
10
10
  import { WfInstance, type StartedVia } from "../model/WfInstance.model";
@@ -53,6 +53,8 @@ export interface BeginOptions {
53
53
  readonly queued?: boolean;
54
54
  /** The firing a scheduled start belongs to: `$trigger.scheduledAt`. */
55
55
  readonly scheduledAt?: Date;
56
+ /** A trial run (WORKFLOW-002 §5.4): takes no place among real ones and tells the application nothing. */
57
+ readonly test?: boolean;
56
58
  }
57
59
 
58
60
  /** Why a step failed. `reason` is the business reason an error boundary may name. */
@@ -162,7 +164,7 @@ export class WorkflowEngine {
162
164
  */
163
165
  async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<Begun> {
164
166
  return await this.db.transactionScope(async (transaction) => {
165
- const admission = await this.admit(graph, options.businessKey);
167
+ const admission = options.test === true ? "start" : await this.admit(graph, options.businessKey);
166
168
  if (typeof admission === "object") return { id: admission.skippedBy, skipped: true };
167
169
  const waits = admission === "queue" || options.queued === true;
168
170
  const instance = this.newInstance(graph, options, waits ? "pending" : "running");
@@ -211,7 +213,7 @@ export class WorkflowEngine {
211
213
  await this.lockProcess(graph.processKey);
212
214
  if (keyed) {
213
215
  const same = await this.db.instances.asNoTracking()
214
- .where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])))
216
+ .where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.test.eq(false)).and(i.status.in(["pending", "running", "failed"])))
215
217
  .firstOrDefault();
216
218
  if (same) return sameKey === "skip" ? { skippedBy: same.id } : "queue";
217
219
  }
@@ -224,13 +226,13 @@ export class WorkflowEngine {
224
226
  }
225
227
 
226
228
  async runningOf(processKey: string): Promise<number> {
227
- return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("running"))).count();
229
+ return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.status.eq("running"))).count();
228
230
  }
229
231
 
230
232
  /** Scheduled runs of a process that still go on in a partition, for `overlap`. A failed one waits for a person and takes no place. */
231
233
  async scheduledLive(processKey: string, partition: string | null): Promise<number> {
232
234
  return await this.db.instances.asNoTracking()
233
- .where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
235
+ .where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
234
236
  .count();
235
237
  }
236
238
 
@@ -255,6 +257,7 @@ export class WorkflowEngine {
255
257
  processKey: graph.processKey,
256
258
  version: graph.version,
257
259
  partition: options.partition,
260
+ test: options.test === true,
258
261
  input: options.input,
259
262
  vars: initialVarsOf(graph.document),
260
263
  event: options.event?.payload ?? null,
@@ -928,14 +931,24 @@ export class WorkflowEngine {
928
931
 
929
932
  /** Events go out after the commit, so a handler never sees a step that rolled back. */
930
933
  private publishAfter(transaction: { afterCommit(callback: () => void | Promise<void>): void }, step: Step): void {
931
- if (step.events.length === 0) return;
932
- const events = [...step.events];
934
+ const events = step.instance.test ? trialEvents(step) : [...step.events];
935
+ if (events.length === 0) return;
933
936
  transaction.afterCommit(async () => {
934
937
  for (const { event, payload } of events) await this.events.publish(event, payload);
935
938
  });
936
939
  }
937
940
  }
938
941
 
942
+ /** What a trial run tells: only what its tasks need, and they go to who started it. */
943
+ function trialEvents(step: Step): PendingEvent[] {
944
+ const { instance } = step;
945
+ return step.events.flatMap(({ event, payload }): PendingEvent[] => {
946
+ if (event === (WORKFLOW_TOKEN_CLOSED as EventToken<unknown>)) return [{ event: WORKFLOW_TRIAL_TOKEN_CLOSED as EventToken<unknown>, payload }];
947
+ if (event !== (WORKFLOW_TASK_REQUESTED as EventToken<unknown>)) return [];
948
+ return [{ event: WORKFLOW_TRIAL_TASK_REQUESTED as EventToken<unknown>, payload: { ...(payload as object), assignee: { user: instance.startedById } } }];
949
+ });
950
+ }
951
+
939
952
  /** When a timer fires: `duration` from now, or the date at the `at` path; `undefined` if neither is valid. */
940
953
  export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData, now: Date): Date | undefined {
941
954
  if (typeof config.duration === "string") {
@@ -16,6 +16,11 @@ export interface StartInstance extends WorkflowAuditOptions {
16
16
  readonly actor: WorkflowActor;
17
17
  /** The language of the request, for the application's validator; `workflow.language` by default. */
18
18
  readonly language?: string;
19
+ /**
20
+ * A trial run (WORKFLOW-002 §5.4): Tools change nothing, the application hears
21
+ * nothing, tasks go only to who started it. It lives for a day.
22
+ */
23
+ readonly test?: boolean;
19
24
  }
20
25
 
21
26
  /** One element of a mass start. */
@@ -63,8 +68,14 @@ export interface InstanceSummary {
63
68
  /** The instance whose `callProcess` started this one. */
64
69
  readonly parentInstanceId: string | null;
65
70
  readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
71
+ /** The step it failed on. */
72
+ readonly failedNode: string | null;
73
+ /** A trial run. */
74
+ readonly test: boolean;
66
75
  readonly createdAt: Date;
67
76
  readonly endedAt: Date | null;
77
+ /** How long it took by the engine's clock; `null` until it ends. */
78
+ readonly durationMs: number | null;
68
79
  }
69
80
 
70
81
  export interface TokenDetails {
@@ -141,6 +152,8 @@ export interface InstanceListQuery extends WorkflowPaging {
141
152
  readonly businessKey?: string;
142
153
  readonly startedById?: string;
143
154
  readonly parentInstanceId?: string;
155
+ /** Trial runs are hidden unless asked for: `true` gives only them. Children of a named parent are shown as they are. */
156
+ readonly test?: boolean;
144
157
  }
145
158
 
146
159
  /**
@@ -55,7 +55,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
55
55
  if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
56
56
  const { value, issues } = await this.validator().validate(inputSpecOf(version.document), options.input, { purpose: "input", processKey, partition, language: options.language ?? this.languages.default, actor: options.actor });
57
57
  if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
58
- const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
58
+ const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null, ...(options.test === true ? { test: true } : {}) });
59
59
  if (skipped) throw new WorkflowError("conflict", `Instance ${id} with this businessKey is not finished`);
60
60
  return await this.instance(id);
61
61
  }
@@ -160,6 +160,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
160
160
  if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
161
161
  if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
162
162
  if (query.parentInstanceId !== undefined) source = source.where((i) => i.parentInstanceId.eq(query.parentInstanceId!));
163
+ if (query.test !== undefined || query.parentInstanceId === undefined) source = source.where((i) => i.test.eq(query.test === true));
163
164
  const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
164
165
  return { items: items.map(summaryOf), total };
165
166
  }
@@ -231,11 +232,20 @@ function summaryOf(row: WfInstance): InstanceSummary {
231
232
  startedBy: { kind: row.startedByKind, id: row.startedById },
232
233
  parentInstanceId: row.parentInstanceId,
233
234
  error: row.error,
235
+ failedNode: row.error === null || row.error.node === "" ? null : row.error.node,
236
+ test: row.test,
234
237
  createdAt: row.createdAt,
235
238
  endedAt: row.endedAt,
239
+ durationMs: durationOf(row),
236
240
  };
237
241
  }
238
242
 
243
+ /** From the start by the engine's clock: `createdAt` is the clock of the database. */
244
+ function durationOf(row: WfInstance): number | null {
245
+ const startedAt = typeof row.trigger?.at === "string" ? Date.parse(row.trigger.at) : Number.NaN;
246
+ return row.endedAt === null || Number.isNaN(startedAt) ? null : row.endedAt.getTime() - startedAt;
247
+ }
248
+
239
249
  function tokenOf(row: WfToken) {
240
250
  return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, parentTokenId: row.parentTokenId, iteration: row.iteration, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
241
251
  }
@@ -1,5 +1,5 @@
1
1
  import { OnEvent } from "../../../kernel";
2
- import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
2
+ import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
3
3
  import type { TasksDbContext } from "../model/Tasks.dbContext";
4
4
  import { WfTask } from "../model/WfTask.model";
5
5
 
@@ -10,6 +10,16 @@ export class TaskEvents {
10
10
  /** A step started waiting for a person: its task. Repeated events of one token make one task. */
11
11
  @OnEvent(WORKFLOW_TASK_REQUESTED)
12
12
  async requested(event: WorkflowTaskRequested): Promise<void> {
13
+ await this.add(event, false);
14
+ }
15
+
16
+ /** The task of a trial run: it goes to who started it, and is marked. */
17
+ @OnEvent(WORKFLOW_TRIAL_TASK_REQUESTED)
18
+ async trialRequested(event: WorkflowTaskRequested): Promise<void> {
19
+ await this.add(event, true);
20
+ }
21
+
22
+ private async add(event: WorkflowTaskRequested, test: boolean): Promise<void> {
13
23
  const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
14
24
  // Past the change tracker: the scope may go on to update tasks directly, which a context holding one would refuse.
15
25
  await this.db.tasks.insertIfAbsent(Object.assign(new WfTask(), {
@@ -18,6 +28,7 @@ export class TaskEvents {
18
28
  processKey: event.processKey,
19
29
  nodeId: event.nodeId,
20
30
  partition: event.partition,
31
+ test,
21
32
  title: event.title,
22
33
  assignee,
23
34
  form: event.form,
@@ -32,6 +43,11 @@ export class TaskEvents {
32
43
  await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(event.tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status: "cancelled" });
33
44
  }
34
45
 
46
+ @OnEvent(WORKFLOW_TRIAL_TOKEN_CLOSED)
47
+ async trialClosed(event: WorkflowTokenClosed): Promise<void> {
48
+ await this.closed(event);
49
+ }
50
+
35
51
  /** Retention deleted the instances: their tasks go too. */
36
52
  @OnEvent(WORKFLOW_INSTANCES_DELETED)
37
53
  async deleted(event: WorkflowInstancesDeleted): Promise<void> {
@@ -11,6 +11,8 @@ export interface TaskDetails {
11
11
  readonly nodeId: string;
12
12
  readonly partition: string | null;
13
13
  readonly title: LocalizedText;
14
+ /** A task of a trial run. */
15
+ readonly test: boolean;
14
16
  readonly assignee: string;
15
17
  readonly form: FormSpec | null;
16
18
  readonly dueAt: Date | null;
@@ -100,7 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
100
100
  completedAt: this.clock.now(),
101
101
  output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
102
102
  });
103
- await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
103
+ if (!task.test) await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
104
104
  return await this.get(task.id);
105
105
  }
106
106
 
@@ -157,6 +157,7 @@ function detailsOf(row: WfTask): TaskDetails {
157
157
  nodeId: row.nodeId,
158
158
  partition: row.partition,
159
159
  title: row.title,
160
+ test: row.test,
160
161
  assignee: row.assignee,
161
162
  form: row.form,
162
163
  dueAt: row.dueAt,
@@ -42,6 +42,7 @@ export {
42
42
  } from "./expressions";
43
43
  export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
44
44
  export { durationMs } from "./duration";
45
+ export { documentReferences, inputReferences, schemaReferenceKinds, type DocumentReference, type InputReference } from "./references";
45
46
  export { isTimeZone, parseCron, type CronSchedule } from "./cron";
46
47
  export { processToMermaid } from "./mermaid";
47
48
  export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
@@ -0,0 +1,106 @@
1
+ import { isExpressionNode } from "./expressions";
2
+ import { isPath } from "./paths";
3
+ import type { ProcessDocument } from "./ProcessDocument";
4
+
5
+ /** One reference in a Tool input or a document: what `x-ref` and `x-ref-keys` of a schema mark. */
6
+ export interface InputReference {
7
+ readonly kind: string;
8
+ readonly id: string;
9
+ /** The area it is looked for in: `x-ref-scope`. */
10
+ readonly within?: string;
11
+ /** Where in the input: `/values/status`. */
12
+ readonly path: string;
13
+ }
14
+
15
+ /** A reference of a document, and whose it is. */
16
+ export interface DocumentReference extends InputReference {
17
+ readonly node?: string;
18
+ readonly variable?: string;
19
+ }
20
+
21
+ type Json = Record<string, unknown>;
22
+
23
+ function isObject(value: unknown): value is Json {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ /** Whether a value is known only when the step runs. */
28
+ function isComputed(value: unknown): boolean {
29
+ return isPath(value) || isExpressionNode(value);
30
+ }
31
+
32
+ function isId(value: unknown): value is string | number {
33
+ return (typeof value === "string" && value !== "") || typeof value === "number";
34
+ }
35
+
36
+ /**
37
+ * The references in a Tool input by the JSON Schema of the input. A value computed
38
+ * when the step runs is left out: it is checked then. `x-ref-scope` beginning
39
+ * with `/` is a JSON Pointer to another field of the input; otherwise it is the area
40
+ * itself. A reference whose area is computed is left out too.
41
+ */
42
+ export function inputReferences(schema: unknown, input: unknown): InputReference[] {
43
+ const out: InputReference[] = [];
44
+ const within = (source: Json): { readonly known: boolean; readonly value?: string } => {
45
+ const scope = source["x-ref-scope"];
46
+ if (typeof scope !== "string") return { known: true };
47
+ if (!scope.startsWith("/")) return { known: true, value: scope };
48
+ let current: unknown = input;
49
+ for (const key of scope.slice(1).split("/")) {
50
+ current = isObject(current) && Object.hasOwn(current, key) ? current[key] : undefined;
51
+ }
52
+ return isId(current) && !isComputed(current) ? { known: true, value: String(current) } : { known: false };
53
+ };
54
+ const walk = (source: unknown, value: unknown, path: string): void => {
55
+ if (!isObject(source) || value === undefined || value === null || isComputed(value)) return;
56
+ const area = within(source);
57
+ const add = (kind: string, id: string, at: string) => out.push({ kind, id, ...(area.value === undefined ? {} : { within: area.value }), path: at });
58
+ if (typeof source["x-ref"] === "string" && isId(value) && area.known) add(source["x-ref"], String(value), path);
59
+ if (Array.isArray(value)) {
60
+ value.forEach((item, index) => walk(source.items, item, `${path}/${index}`));
61
+ return;
62
+ }
63
+ if (!isObject(value)) return;
64
+ if (typeof source["x-ref-keys"] === "string" && area.known) for (const key of Object.keys(value)) add(source["x-ref-keys"], key, `${path}/${key}`);
65
+ if (isObject(source.properties)) {
66
+ for (const [key, item] of Object.entries(source.properties)) if (Object.hasOwn(value, key)) walk(item, value[key], `${path}/${key}`);
67
+ }
68
+ };
69
+ walk(schema, input, "");
70
+ return out;
71
+ }
72
+
73
+ /** The kinds a schema refers to, whatever the values: each must have an owner. */
74
+ export function schemaReferenceKinds(schema: unknown): string[] {
75
+ const kinds = new Set<string>();
76
+ const walk = (source: unknown): void => {
77
+ if (!isObject(source)) return;
78
+ for (const key of ["x-ref", "x-ref-keys"]) if (typeof source[key] === "string") kinds.add(source[key]);
79
+ if (isObject(source.properties)) Object.values(source.properties).forEach(walk);
80
+ walk(source.items);
81
+ };
82
+ walk(schema);
83
+ return [...kinds];
84
+ }
85
+
86
+ /**
87
+ * The references a document holds as literals (WORKFLOW-002 §6.3): in the inputs of
88
+ * its Tools, by their schemas; the defaults of variables of type `ref`; the processes
89
+ * it calls. `inputSchemaOf` gives the input schema of a Tool, if it is known.
90
+ */
91
+ export function documentReferences(document: ProcessDocument, inputSchemaOf: (tool: string) => unknown): DocumentReference[] {
92
+ const out: DocumentReference[] = [];
93
+ for (const variable of document.variables ?? []) {
94
+ if (variable.type === "ref" && typeof variable.ref === "string" && isId(variable.default)) {
95
+ out.push({ kind: variable.ref, id: String(variable.default), path: "/default", variable: variable.name });
96
+ }
97
+ }
98
+ const handler = document.settings?.errorHandler;
99
+ if (handler !== undefined) out.push({ kind: "workflow.process", id: handler, path: "/settings/errorHandler" });
100
+ for (const [node, { kind, config }] of Object.entries(document.nodes)) {
101
+ if (kind === "callProcess" && typeof config?.process === "string") out.push({ kind: "workflow.process", id: config.process, path: "/process", node });
102
+ if (kind !== "serviceTask" || typeof config?.tool !== "string") continue;
103
+ for (const reference of inputReferences(inputSchemaOf(config.tool), config.input)) out.push({ ...reference, node });
104
+ }
105
+ return out;
106
+ }
@@ -33,6 +33,7 @@ export type ProcessIssueCode =
33
33
  | "config_invalid"
34
34
  | "unknown_tool"
35
35
  | "unknown_process"
36
+ | "broken_ref"
36
37
  | "container_invalid"
37
38
  | "flow_across_container"
38
39
  | "loop_body"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.134.0",
3
+ "version": "0.135.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",