bazis 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/core/access/contracts/References.ts +2 -0
  2. package/core/agent/AgentToolExecutor.ts +3 -0
  3. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  4. package/core/tools/StandardTools.module.ts +10 -2
  5. package/core/tools/contracts/Tools.config.ts +30 -0
  6. package/core/tools/generated/deps.ts +5 -0
  7. package/core/tools/http/HttpRequest.tool.ts +246 -0
  8. package/core/tools/http/publicAddress.ts +94 -0
  9. package/core/tools/index.ts +5 -2
  10. package/core/tools/orm/OrmEntity.references.ts +11 -7
  11. package/core/tools/secrets/SecretStore.ts +28 -0
  12. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
  13. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
  14. package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
  15. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +23 -0
  16. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +56 -6
  17. package/core/workflow/generated/deps.ts +5 -2
  18. package/core/workflow/internal/WorkflowReferences.ts +69 -0
  19. package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
  20. package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
  21. package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
  22. package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
  23. package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
  24. package/core/workflow/runtime_module/internal/RunLimits.ts +4 -3
  25. package/core/workflow/runtime_module/internal/ToolStep.ts +45 -5
  26. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +20 -7
  27. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +13 -0
  28. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +11 -1
  29. package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
  30. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
  31. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
  32. package/library/redaction/index.ts +28 -0
  33. package/library/workflow/expressions.ts +8 -1
  34. package/library/workflow/index.ts +2 -1
  35. package/library/workflow/paths.ts +21 -1
  36. package/library/workflow/references.ts +106 -0
  37. package/library/workflow/validateProcess.ts +6 -5
  38. package/package.json +1 -1
@@ -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, ServiceProvider]],
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,11 @@
1
1
  import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
2
- import { durationMs, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
2
+ import { redactValues, rememberSecretValue } from "../../../../library/redaction";
3
+ import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, secretNames, type ProcessDocument } from "../../../../library/workflow";
4
+ import type { ServiceProvider } from "../../../di";
5
+ import { SecretStore } from "../../../tools/secrets/SecretStore";
3
6
  import type { WorkflowToolMetadata } from "../../contracts/types";
7
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
8
+ import type { WorkflowReferences } from "../../internal/WorkflowReferences";
4
9
  import type { WorkflowTools } from "../../internal/WorkflowTools";
5
10
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
6
11
  import type { WfJob } from "../model/WfJob.model";
@@ -23,6 +28,10 @@ export class ToolStep {
23
28
  private readonly db: RuntimeDbContext,
24
29
  private readonly engine: WorkflowEngine,
25
30
  private readonly tools: WorkflowTools,
31
+ private readonly references: WorkflowReferences,
32
+ private readonly languages: WorkflowLanguages,
33
+ /** The scope: the application's own `SecretStore` replaces the default. */
34
+ private readonly services: ServiceProvider,
26
35
  ) {}
27
36
 
28
37
  async run(job: WfJob, signal: AbortSignal): Promise<void> {
@@ -35,26 +44,41 @@ export class ToolStep {
35
44
  const graph = await this.engine.graphOf(instance.versionId);
36
45
  const node = graph.document.nodes[token.nodeId];
37
46
  const name = String(node?.config?.tool ?? "");
38
- const input = resolveInput(node?.config?.input ?? {}, dataOf(instance, this.engine.now(), token));
47
+ const { values: secrets, missing: noSecret } = await this.secretsOf(node?.config?.input, instance.partition);
48
+ const revealed = Object.values(secrets);
49
+ const input = resolveInput(node?.config?.input ?? {}, { ...dataOf(instance, this.engine.now(), token), secrets });
39
50
  const timeoutMs = stepTimeoutMs(node?.config?.timeout, graph.document);
40
51
  // A step may run longer than the lease: another worker must not take the job and call the Tool again meanwhile.
41
52
  if (timeoutMs !== undefined && timeoutMs >= LEASE_MS) {
42
53
  await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
43
54
  }
44
55
  const runner = this.tools.bound;
45
- const result: AgentToolResult = runner === undefined
56
+ const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
57
+ const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
58
+ const raw: AgentToolResult = runner === undefined
46
59
  ? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
60
+ : noSecret !== undefined
61
+ ? { callId: token.id, name, status: "error", error: { code: "secret_not_found", message: `There is no secret ${noSecret}`, details: { reason: "secret_not_found" } }, metadata: {} } as AgentToolResult
62
+ : missing.length > 0
63
+ // A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
64
+ ? { 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
65
+ : trial === "unsupported"
66
+ // A trial never calls a Tool that cannot run without changing anything.
67
+ ? { callId: token.id, name, status: "success", output: { dryRun: true }, metadata: {} } as AgentToolResult
47
68
  : await runner.run(name, input, {
48
69
  idempotencyKey: token.id,
49
70
  actor: runActorOf(graph.document, instance),
50
- metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: false } satisfies WorkflowToolMetadata,
71
+ metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: instance.test } satisfies WorkflowToolMetadata,
51
72
  signal,
73
+ ...(trial === "supported" ? { dryRun: true } : {}),
52
74
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
53
75
  });
54
76
 
77
+ // What the Tool got back from the outside may echo a secret: it goes no further than the Tool.
78
+ const result = redactValues(raw, revealed);
55
79
  const timedOut = TIMEOUT_CODES.has(result.error?.code ?? "");
56
80
  const reason = timedOut ? "timeout" : reasonOf(result);
57
- const payloads = graph.document.settings?.log?.toolPayloads === true ? this.payloads(graph.document, name, node?.config?.input, input, result) : {};
81
+ const payloads = graph.document.settings?.log?.toolPayloads === true ? redactValues(this.payloads(graph.document, name, node?.config?.input, input, result), revealed) : {};
58
82
  await this.engine.inStep(instance.id, async (step) => {
59
83
  const current = step.tokens.find((t) => t.id === token.id);
60
84
  const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && (reason === undefined || timedOut)
@@ -72,6 +96,7 @@ export class ToolStep {
72
96
  status: result.status,
73
97
  attempt: job.attempts,
74
98
  durationMs: result.durationMs ?? null,
99
+ ...(trial === undefined ? {} : { dryRun: trial === "supported" ? "called" : "not called" }),
75
100
  ...payloads,
76
101
  ...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
77
102
  ...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
@@ -89,6 +114,21 @@ export class ToolStep {
89
114
  });
90
115
  }
91
116
 
117
+ /** The values of `$secrets` this input names, and the first one there is not. Remembered, so that a log masks them too. */
118
+ private async secretsOf(input: unknown, partition: string | null): Promise<{ readonly values: Record<string, string>; readonly missing?: string }> {
119
+ const values: Record<string, string> = {};
120
+ const names = secretNames(input);
121
+ if (names.length === 0) return { values };
122
+ const store = this.services.tryResolve(SecretStore);
123
+ for (const name of names) {
124
+ const value = await store?.get(name, { partition });
125
+ if (value === undefined) return { values, missing: name };
126
+ rememberSecretValue(value);
127
+ values[name] = value;
128
+ }
129
+ return { values };
130
+ }
131
+
92
132
  /** The input and output for the history under `log.toolPayloads`; personal data is masked unless `log.maskPersonal` is `false`. */
93
133
  private payloads(document: ProcessDocument, name: string, mapping: unknown, input: unknown, result: AgentToolResult): Record<string, unknown> {
94
134
  if (!masksPersonal(document)) return { input, output: result.output ?? null };
@@ -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") {