bazis 0.131.0 → 0.132.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 (66) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +26 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +65 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +33 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
  21. package/core/workflow/generated/deps.ts +11 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +0 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
  32. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  33. package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
  34. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
  35. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
  36. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  37. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  38. package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
  39. package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
  40. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
  44. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  45. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  46. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  47. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  48. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +3 -3
  49. package/library/openapi/codegen.ts +3 -0
  50. package/library/validation/types/ValidatorOptions.ts +2 -0
  51. package/library/workflow/ProcessDocument.ts +59 -23
  52. package/library/workflow/expressionSyntax.ts +243 -0
  53. package/library/workflow/expressions.ts +343 -0
  54. package/library/workflow/formFields.ts +118 -33
  55. package/library/workflow/index.ts +48 -9
  56. package/library/workflow/localized.ts +35 -0
  57. package/library/workflow/masking.ts +58 -0
  58. package/library/workflow/mermaid.ts +18 -8
  59. package/library/workflow/nodeKinds.ts +88 -17
  60. package/library/workflow/palette.ts +108 -0
  61. package/library/workflow/paths.ts +31 -6
  62. package/library/workflow/structure.ts +282 -0
  63. package/library/workflow/upgradeDocument.ts +84 -0
  64. package/library/workflow/validateProcess.ts +130 -48
  65. package/package.json +1 -1
  66. package/library/workflow/conditions.ts +0 -44
@@ -0,0 +1,35 @@
1
+ import type { ConfigView } from "../../kernel";
2
+ import type { WorkflowSettings } from "../contracts/Workflow.config";
3
+
4
+ /**
5
+ * The language of a call (WORKFLOW-002 §6): over HTTP from `Accept-Language`
6
+ * among `workflow.languages`, otherwise `workflow.language`; in events, schedules
7
+ * and the background always `workflow.language`.
8
+ */
9
+ export class WorkflowLanguages {
10
+ constructor(private readonly config: ConfigView<WorkflowSettings>) {}
11
+
12
+ get default(): string {
13
+ return this.config.get("language");
14
+ }
15
+
16
+ /** The best language of an `Accept-Language` header: by weight, then by order; `ru-RU` also finds `ru`. */
17
+ of(header: string | undefined): string {
18
+ if (header === undefined) return this.default;
19
+ const known = new Set([this.default, ...this.config.get("languages").split(",").map((item) => item.trim())]);
20
+ const wanted = header.split(",")
21
+ .map((part, index) => {
22
+ const [tag = "", ...parameters] = part.trim().split(";");
23
+ const weight = parameters.map((item) => /^\s*q=([\d.]+)\s*$/.exec(item)?.[1]).find((item) => item !== undefined);
24
+ return { tag: tag.trim(), weight: weight === undefined ? 1 : Number(weight), index };
25
+ })
26
+ .filter(({ tag, weight }) => tag !== "" && weight > 0)
27
+ .sort((a, b) => b.weight - a.weight || a.index - b.index);
28
+ for (const { tag } of wanted) {
29
+ if (known.has(tag)) return tag;
30
+ const base = tag.split("-")[0]!;
31
+ if (known.has(base)) return base;
32
+ }
33
+ return this.default;
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ /** JSON with sorted keys: the same document gives the same hash whatever the key order. */
2
+ export function canonicalJson(value: unknown): string {
3
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
4
+ if (typeof value === "object" && value !== null) {
5
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
6
+ }
7
+ return JSON.stringify(value) ?? "null";
8
+ }
@@ -2,14 +2,28 @@ import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
2
2
  import { ForbiddenError, type HttpContext } from "../../http";
3
3
  import { frameworkBadRequest } from "../../http/Errors/HttpError";
4
4
  import { ListQueryError, parseListQuery, type ListQuery, type ListQueryOptions, type ListQueryProblem } from "../../../library/jsonapi";
5
+ import { textOf, type LocalizedText } from "../../../library/workflow";
5
6
  import type { WorkflowActor, WorkflowPaging, WorkflowRequest } from "../contracts/types";
6
7
  import { WorkflowError } from "../contracts/WorkflowError";
8
+ import { WorkflowLanguages } from "./WorkflowLanguages";
7
9
 
8
10
  /** Who calls a route: their rights and how they are recorded as an actor. */
9
11
  export interface WorkflowCaller {
10
12
  readonly access: SubjectAccess;
11
13
  readonly actor: WorkflowActor;
12
14
  readonly request: WorkflowRequest;
15
+ /** The language of the request: `Accept-Language` among `workflow.languages`. */
16
+ readonly language: string;
17
+ }
18
+
19
+ /** A record with `titleText`: its `title` in the language of the request. `title` itself stays for editing. */
20
+ export function withTitleText<T extends { readonly title: LocalizedText }>(item: T, language: string, fallback: string): T & { readonly titleText: string } {
21
+ return { ...item, titleText: textOf(item.title, language, fallback) };
22
+ }
23
+
24
+ /** The default language, for {@link withTitleText}. */
25
+ export function defaultLanguage(ctx: HttpContext): string {
26
+ return ctx.services.resolve(WorkflowLanguages).default;
13
27
  }
14
28
 
15
29
  /**
@@ -23,7 +37,7 @@ export async function callerOf(ctx: HttpContext, permission?: string, object?: A
23
37
  if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
24
38
  const sub = access.subject.sub;
25
39
  const [id = sub.slice(sub.lastIndexOf(":") + 1)] = await access.ownerIds();
26
- return { access, actor: { kind: "user", id }, request: { ip: ctx.clientIp ?? null, userAgent: ctx.header("user-agent") ?? null } };
40
+ return { access, actor: { kind: "user", id }, request: { ip: ctx.clientIp ?? null, userAgent: ctx.header("user-agent") ?? null }, language: ctx.services.resolve(WorkflowLanguages).of(ctx.header("accept-language")) };
27
41
  }
28
42
 
29
43
  /** A route parameter as a string. */
@@ -31,6 +45,11 @@ export function param(ctx: HttpContext, name: string): string {
31
45
  return String(ctx.params[name] ?? "");
32
46
  }
33
47
 
48
+ /** A title or a description from a body; the service checks it. */
49
+ export function optionalText(value: unknown): LocalizedText | null | undefined {
50
+ return value as LocalizedText | null | undefined;
51
+ }
52
+
34
53
  /** The JSON body as an object; anything else is `invalid_input`. */
35
54
  export async function body(ctx: HttpContext): Promise<Record<string, unknown>> {
36
55
  const text = await ctx.text();
@@ -5,7 +5,6 @@ import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinit
5
5
  import { runtimePermissions } from "./access/Runtime.permissions";
6
6
  import { WorkflowRetention } from "./background/WorkflowRetention";
7
7
  import { WorkflowWorker } from "./background/WorkflowWorker";
8
- import { workflowRuntimeConfig } from "./config/Runtime.config";
9
8
  import { EventsController } from "./http/Events.controller";
10
9
  import { InstancesController } from "./http/Instances.controller";
11
10
  import { GraphCache } from "./internal/GraphCache";
@@ -25,7 +24,6 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
25
24
  @Module({
26
25
  imports: [AccessModule, WorkflowDefinitionsModule],
27
26
  permissions: runtimePermissions,
28
- config: [workflowRuntimeConfig],
29
27
  ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
30
28
  providers: [
31
29
  singleton(GraphCache),
@@ -1,24 +1,26 @@
1
1
  import { definePermissions } from "../../../access";
2
+ import { ProcessFolderLookup } from "../../definitions_module/access/ProcessFolder.lookup";
2
3
 
3
4
  const process = { ref: "workflow.process", column: "processKey" };
5
+ const folder = { ref: "workflow.folder", lookup: ProcessFolderLookup };
4
6
 
5
7
  /** The rights of running processes (WORKFLOW-002 §6.2). */
6
8
  export const runtimePermissions = definePermissions({
7
9
  "workflow.instances.start": {
8
10
  title: { en: "Start processes", ru: "Запускать процессы" },
9
11
  scope: "request",
10
- on: { process },
12
+ on: { process, folder },
11
13
  },
12
14
  "workflow.instances.read": {
13
15
  title: { en: "See instances", ru: "Видеть экземпляры" },
14
16
  scope: { column: "partition" },
15
- on: { process },
17
+ on: { process, folder },
16
18
  owner: "startedById",
17
19
  },
18
20
  "workflow.instances.manage": {
19
21
  title: { en: "Cancel and retry instances", ru: "Отменять и повторять экземпляры" },
20
22
  scope: { column: "partition" },
21
- on: { process },
23
+ on: { process, folder },
22
24
  },
23
25
  "workflow.events.emit": {
24
26
  title: { en: "Send events to processes", ru: "Отправлять события процессам" },
@@ -5,7 +5,7 @@ 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
7
  import { RuntimeDbContext } from "../model/Runtime.dbContext";
8
- import type { WorkflowSettings } from "../config/Runtime.config";
8
+ import type { WorkflowSettings } from "../../contracts/Workflow.config";
9
9
 
10
10
  const DAY_MS = 86_400_000;
11
11
 
@@ -2,14 +2,14 @@ import { BackgroundService, delay } from "../../../background";
2
2
  import type { ServiceProvider } from "../../../di";
3
3
  import { WorkflowClock } from "../../contracts/WorkflowClock";
4
4
  import type { ConfigView } from "../../../kernel";
5
- import type { WorkflowSettings } from "../config/Runtime.config";
5
+ import type { WorkflowSettings } from "../../contracts/Workflow.config";
6
6
  import { WorkflowJobs } from "../internal/WorkflowJobs";
7
7
 
8
8
  /** How often the scheduled starts are matched against the published versions. */
9
9
  const RECONCILE_MS = 60_000;
10
10
 
11
11
  /**
12
- * Takes due jobs of `bazis_wf_run_jobs` every `worker.intervalMs`, up to
12
+ * Takes due jobs of `bazis_wf2_run_jobs` every `worker.intervalMs`, up to
13
13
  * `worker.batchSize` at a time ({@link WorkflowJobs}). A full batch is
14
14
  * followed by the next one at once.
15
15
  */
@@ -1,5 +1,5 @@
1
1
  import { createEventToken } from "../../../kernel";
2
- import type { FormSpec } from "../../../../library/workflow";
2
+ import type { FormSpec, LocalizedText } from "../../../../library/workflow";
3
3
  import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
4
4
  import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
5
5
 
@@ -10,7 +10,7 @@ export interface WorkflowTaskRequested {
10
10
  readonly processKey: string;
11
11
  readonly nodeId: string;
12
12
  readonly partition: string | null;
13
- readonly title: string;
13
+ readonly title: LocalizedText;
14
14
  readonly assignee: { readonly role: string } | { readonly user: string };
15
15
  readonly form: FormSpec | null;
16
16
  /** When the task is due, from the node's `dueIn`. */
@@ -4,12 +4,16 @@ import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../
4
4
  import { buildListDocument } from "../../../../library/jsonapi";
5
5
  import { body, callerOf, filterOf, listQuery, optionalString, pagingOfList, param, requiredString } from "../../internal/http";
6
6
  import type { InstanceStatus } from "../model/WfInstance.model";
7
+ import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
7
8
  import { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
8
9
 
9
10
  /** Instances over HTTP (WORKFLOW-001 §4); rights are `workflow.instances.*` (WORKFLOW-002 §6.2). */
10
11
  @Controller("workflow/instances")
11
12
  export class InstancesController {
12
- constructor(private readonly runtime: IWorkflowRuntime) {}
13
+ constructor(
14
+ private readonly runtime: IWorkflowRuntime,
15
+ private readonly definitions: IWorkflowDefinitions,
16
+ ) {}
13
17
 
14
18
  @Post()
15
19
  @Authorize(can("workflow.instances.start"))
@@ -17,13 +21,14 @@ export class InstancesController {
17
21
  const request = await body(ctx);
18
22
  const processKey = requiredString(request.processKey, "processKey", 64);
19
23
  const partition = optionalString(request.partition, "partition") ?? null;
20
- const { actor, request: from } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey });
24
+ const { actor, request: from, language } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey, folder: await this.definitions.folderOf(processKey) });
21
25
  const instance = await this.runtime.start(processKey, {
22
26
  input: request.input,
23
27
  partition,
24
28
  businessKey: optionalString(request.businessKey, "businessKey") ?? null,
25
29
  actor,
26
30
  request: from,
31
+ language,
27
32
  });
28
33
  return Created(`${ctx.path}/${instance.id}`, instance);
29
34
  }
@@ -52,7 +57,7 @@ export class InstancesController {
52
57
  @Authorize(can("workflow.instances.read"))
53
58
  async get(ctx: HttpContext) {
54
59
  const instance = await this.runtime.instance(param(ctx, "id"));
55
- await callerOf(ctx, "workflow.instances.read", objectOf(instance));
60
+ await callerOf(ctx, "workflow.instances.read", await this.objectOf(instance));
56
61
  return instance;
57
62
  }
58
63
 
@@ -60,7 +65,7 @@ export class InstancesController {
60
65
  @Authorize(can("workflow.instances.read"))
61
66
  async history(ctx: HttpContext) {
62
67
  const instance = await this.runtime.instance(param(ctx, "id"));
63
- await callerOf(ctx, "workflow.instances.read", objectOf(instance));
68
+ await callerOf(ctx, "workflow.instances.read", await this.objectOf(instance));
64
69
  return { data: await this.runtime.history(instance.id) };
65
70
  }
66
71
 
@@ -69,7 +74,7 @@ export class InstancesController {
69
74
  async cancel(ctx: HttpContext) {
70
75
  const instance = await this.runtime.instance(param(ctx, "id"));
71
76
  const request = await body(ctx);
72
- const { actor, request: from } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
77
+ const { actor, request: from } = await callerOf(ctx, "workflow.instances.manage", await this.objectOf(instance));
73
78
  return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor, request: from });
74
79
  }
75
80
 
@@ -77,14 +82,14 @@ export class InstancesController {
77
82
  @Authorize(can("workflow.instances.manage"))
78
83
  async retry(ctx: HttpContext) {
79
84
  const instance = await this.runtime.instance(param(ctx, "id"));
80
- const { actor, request } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
85
+ const { actor, request } = await callerOf(ctx, "workflow.instances.manage", await this.objectOf(instance));
81
86
  return await this.runtime.retry(instance.id, { actor, request });
82
87
  }
83
- }
84
- contextOnlyController(InstancesController);
85
88
 
86
- /** The instance as the object of a permission. */
87
- function objectOf(instance: InstanceSummary): AccessObject {
88
- return { scope: instance.partition, process: instance.processKey, startedById: instance.startedBy.id };
89
+ /** The instance as the object of a permission. */
90
+ private async objectOf(instance: InstanceSummary): Promise<AccessObject> {
91
+ return { scope: instance.partition, process: instance.processKey, folder: await this.definitions.folderOf(instance.processKey), startedById: instance.startedBy.id };
92
+ }
89
93
  }
94
+ contextOnlyController(InstancesController);
90
95
 
@@ -1,4 +1,4 @@
1
- import { parseCron } from "../../../../library/workflow";
1
+ import { inputSpecOf, parseCron, validateForm } from "../../../../library/workflow";
2
2
  import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
3
3
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
4
4
  import { WfJob } from "../model/WfJob.model";
@@ -65,7 +65,7 @@ export class Schedules {
65
65
  else await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: next, lockedUntil: null, attempts: 0, lastError: null });
66
66
  for (const [nodeId, cron] of scheduleNodes(latest)) {
67
67
  if (!cron.matches(due)) continue;
68
- await this.engine.begin(graph, nodeId, { input: {}, partition: null, businessKey: null, startedVia: "schedule", actor: SCHEDULE_ACTOR });
68
+ await this.engine.begin(graph, nodeId, { input: validateForm(inputSpecOf(latest.document), {}).value, partition: null, businessKey: null, startedVia: "schedule", actor: SCHEDULE_ACTOR });
69
69
  }
70
70
  });
71
71
  }
@@ -1,5 +1,5 @@
1
- import type { AgentToolResult } from "../../../agent";
2
- import { durationMs, resolveInput } from "../../../../library/workflow";
1
+ import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
2
+ import { durationMs, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
3
3
  import type { WorkflowToolMetadata } from "../../contracts/types";
4
4
  import type { WorkflowTools } from "../../internal/WorkflowTools";
5
5
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
@@ -13,6 +13,11 @@ import { dataOf, type WorkflowEngine } from "./WorkflowEngine";
13
13
  * started the instance, with the token id as its idempotency key: after a crash
14
14
  * between the call and the step the call may repeat (TOOLS-001 §7).
15
15
  */
16
+ /** How long a claimed job belongs to its worker; if it dies, another one takes the job after that. */
17
+ export const LEASE_MS = 5 * 60_000;
18
+
19
+ const TIMEOUT_CODES: ReadonlySet<string> = new Set(["TOOL_TIMEOUT", "TOOL_TIMEOUT_OUTCOME_UNKNOWN"]);
20
+
16
21
  export class ToolStep {
17
22
  constructor(
18
23
  private readonly db: RuntimeDbContext,
@@ -30,7 +35,12 @@ export class ToolStep {
30
35
  const graph = await this.engine.graphOf(instance.versionId);
31
36
  const node = graph.document.nodes[token.nodeId];
32
37
  const name = String(node?.config?.tool ?? "");
33
- const input = resolveInput(node?.config?.input ?? {}, dataOf(instance));
38
+ const input = resolveInput(node?.config?.input ?? {}, dataOf(instance, this.engine.now()));
39
+ const timeoutMs = stepTimeoutMs(node?.config?.timeout, graph.document);
40
+ // A step may run longer than the lease: another worker must not take the job and call the Tool again meanwhile.
41
+ if (timeoutMs !== undefined && timeoutMs >= LEASE_MS) {
42
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
43
+ }
34
44
  const runner = this.tools.bound;
35
45
  const result: AgentToolResult = runner === undefined
36
46
  ? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
@@ -39,12 +49,15 @@ export class ToolStep {
39
49
  actor: { kind: instance.startedByKind, id: instance.startedById },
40
50
  metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: false } satisfies WorkflowToolMetadata,
41
51
  signal,
52
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
42
53
  });
43
54
 
44
- const reason = reasonOf(result);
55
+ const timedOut = TIMEOUT_CODES.has(result.error?.code ?? "");
56
+ 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) : {};
45
58
  await this.engine.inStep(instance.id, async (step) => {
46
59
  const current = step.tokens.find((t) => t.id === token.id);
47
- const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && reason === undefined
60
+ const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && (reason === undefined || timedOut)
48
61
  ? nextAttempt(node?.config?.retry, job.attempts, this.engine.now())
49
62
  : undefined;
50
63
  if (retryAt !== undefined) {
@@ -59,6 +72,7 @@ export class ToolStep {
59
72
  status: result.status,
60
73
  attempt: job.attempts,
61
74
  durationMs: result.durationMs ?? null,
75
+ ...payloads,
62
76
  ...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
63
77
  ...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
64
78
  });
@@ -74,6 +88,15 @@ export class ToolStep {
74
88
  }
75
89
  });
76
90
  }
91
+
92
+ /** The input and output for the history under `log.toolPayloads`; personal data is masked unless `log.maskPersonal` is `false`. */
93
+ private payloads(document: ProcessDocument, name: string, mapping: unknown, input: unknown, result: AgentToolResult): Record<string, unknown> {
94
+ if (!masksPersonal(document)) return { input, output: result.output ?? null };
95
+ const contract = this.tools.bound?.describe(name);
96
+ const schemaOf = (side: AgentSchemaContract | undefined) => (side?.kind === "json-schema" ? side.schema : undefined);
97
+ const masked = maskByMapping(mapping ?? {}, maskBySchema(input, schemaOf(contract?.input)), personalPaths(document));
98
+ return { input: masked, output: maskBySchema(result.output ?? null, schemaOf(contract?.output)) };
99
+ }
77
100
  }
78
101
 
79
102
  /** The business reason of a `ToolError`, if the Tool gave one. */
@@ -83,15 +106,23 @@ function reasonOf(result: AgentToolResult): string | undefined {
83
106
  return typeof reason === "string" ? reason : undefined;
84
107
  }
85
108
 
109
+ /** How long a step's Tool may run: its `timeout`, else `settings.stepTimeout`; `undefined` leaves the Tool's own limit. */
110
+ function stepTimeoutMs(timeout: unknown, document: ProcessDocument): number | undefined {
111
+ const value = typeof timeout === "string" ? timeout : document.settings?.stepTimeout;
112
+ return value === undefined ? undefined : durationMs(value);
113
+ }
114
+
86
115
  /**
87
116
  * When to try a failed call again under the node's `retry` policy, or
88
- * `undefined` when the attempts are used up. The pause doubles from `backoff`
89
- * (10 seconds by default) and stops growing at a day.
117
+ * `undefined` when the attempts are used up. The pause starts at `backoff` (10
118
+ * seconds by default), grows by `factor` (2 by default) unless `growth` is
119
+ * `fixed`, and stops growing at a day.
90
120
  */
91
121
  export function nextAttempt(policy: unknown, attempts: number, now: Date): Date | undefined {
92
122
  if (typeof policy !== "object" || policy === null) return undefined;
93
- const { maxAttempts, backoff } = policy as { maxAttempts?: unknown; backoff?: unknown };
123
+ const { maxAttempts, backoff, growth, factor } = policy as { maxAttempts?: unknown; backoff?: unknown; growth?: unknown; factor?: unknown };
94
124
  if (typeof maxAttempts !== "number" || attempts >= maxAttempts) return undefined;
95
125
  const base = typeof backoff === "string" ? durationMs(backoff) ?? 10_000 : 10_000;
96
- return new Date(now.getTime() + Math.min(base * 2 ** Math.max(attempts - 1, 0), 86_400_000));
126
+ const scale = growth === "fixed" ? 1 : (typeof factor === "number" ? factor : 2) ** Math.max(attempts - 1, 0);
127
+ return new Date(now.getTime() + Math.min(base * scale, 86_400_000));
97
128
  }
@@ -1,5 +1,5 @@
1
1
  import type { EventToken } from "../../../kernel";
2
- import { durationMs, evaluateCondition, nodeKindOf, readPath, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
2
+ import { durationMs, evaluateExpression, initialVarsOf, isTruthy, masksPersonal, maskByMapping, nodeKindOf, outputOf, personalPaths, personalVariables, PERSONAL_MASK, readPath, type Expr, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
3
3
  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";
@@ -38,6 +38,8 @@ export interface BeginOptions {
38
38
  readonly actor: WorkflowActor;
39
39
  /** Where the start came from; `null` for code, schedules and the worker. */
40
40
  readonly request?: WorkflowRequest | null;
41
+ /** The event that starts it: `$event` and `$trigger.eventKey`. */
42
+ readonly event?: { readonly key: string; readonly payload: unknown };
41
43
  }
42
44
 
43
45
  const LIVE = new Set(["active", "waiting", "failed"]);
@@ -86,6 +88,14 @@ export class WorkflowEngine {
86
88
  version: graph.version,
87
89
  partition: options.partition,
88
90
  input: options.input,
91
+ vars: initialVarsOf(graph.document),
92
+ event: options.event?.payload ?? null,
93
+ trigger: {
94
+ via: options.startedVia,
95
+ at: this.now().toISOString(),
96
+ actor: options.actor,
97
+ ...(options.event === undefined ? {} : { eventKey: options.event.key }),
98
+ },
89
99
  businessKey: options.businessKey,
90
100
  startedVia: options.startedVia,
91
101
  startedByKind: options.actor.kind,
@@ -150,13 +160,20 @@ export class WorkflowEngine {
150
160
  /**
151
161
  * A step's Tool failed for good. An error boundary attached to the step takes
152
162
  * the token (first one whose `errorCode` equals the Tool's `reason` or error
153
- * code, else one without `errorCode`); otherwise the instance fails.
163
+ * code, else one without `errorCode`); otherwise `onError: "skip"` moves on with
164
+ * `{ skipped: true, error }`; otherwise the instance fails.
154
165
  */
155
166
  async failStep(step: Step, token: WfToken, error: { readonly code: string; readonly message: string; readonly reason?: string }): Promise<void> {
156
167
  const boundaries = (step.graph.boundaries.get(token.nodeId) ?? []).filter(([, node]) => node.kind === "boundaryError");
157
168
  const matching = boundaries.find(([, node]) => node.config?.errorCode !== undefined && (node.config.errorCode === error.reason || node.config.errorCode === error.code))
158
169
  ?? boundaries.find(([, node]) => node.config?.errorCode === undefined);
159
170
  if (!matching) {
171
+ if (step.graph.document.nodes[token.nodeId]?.config?.onError === "skip") {
172
+ const skipped = { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
173
+ this.history(step, token, token.nodeId, "skipped", skipped);
174
+ await this.complete(step, token.id, { skipped: true, error: skipped });
175
+ return;
176
+ }
160
177
  this.fail(step, token, error.code, error.message);
161
178
  return;
162
179
  }
@@ -197,6 +214,7 @@ export class WorkflowEngine {
197
214
  const token = step.tokens.find((t) => t.id === tokenId);
198
215
  if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
199
216
  const node = step.graph.document.nodes[token.nodeId];
217
+ if (node?.kind === "eventWait" || node?.kind === "boundaryEvent") step.instance.event = payload;
200
218
  if (node?.kind === "eventWait") {
201
219
  this.history(step, token, token.nodeId, "event", { event: eventKey });
202
220
  return await this.complete(step, tokenId, payload);
@@ -298,6 +316,7 @@ export class WorkflowEngine {
298
316
  if (step.instance.status === "running" && !step.tokens.some((t) => LIVE.has(t.status))) {
299
317
  step.instance.status = "completed";
300
318
  step.instance.endedAt = this.now();
319
+ step.instance.output = outputOf(step.graph.document, step.instance.vars);
301
320
  this.history(step, null, null, "completed", null);
302
321
  this.finished(step, "completed");
303
322
  }
@@ -319,6 +338,8 @@ export class WorkflowEngine {
319
338
  // An error boundary that caught a step's failure: it only leads on.
320
339
  if (role === "boundary") return this.leave(step, token, this.single(step, token.nodeId));
321
340
  switch (node.kind) {
341
+ case "assign":
342
+ return this.assign(step, token, node);
322
343
  case "serviceTask":
323
344
  return this.arm(step, token, this.waitForTool(step, token));
324
345
  case "userTask":
@@ -337,8 +358,28 @@ export class WorkflowEngine {
337
358
  }
338
359
  }
339
360
 
361
+ /**
362
+ * An `assign` step: each value sees the ones before it. The history names the
363
+ * variables; their values go there only under `log.variables`, personal ones masked.
364
+ */
365
+ private assign(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
366
+ const { document } = step.graph;
367
+ const assignments = (node.config?.assignments ?? []) as readonly { readonly var: string; readonly value: Expr }[];
368
+ const personal = masksPersonal(document) ? personalVariables(document) : new Set<string>();
369
+ const reads = masksPersonal(document) ? personalPaths(document) : new Set<string>();
370
+ const logged: Record<string, unknown> = {};
371
+ for (const { var: name, value } of assignments) {
372
+ const result = evaluateExpression(value, dataOf(step.instance, this.now())) ?? null;
373
+ step.instance.vars = { ...step.instance.vars, [name]: result };
374
+ logged[name] = personal.has(name) ? PERSONAL_MASK : maskByMapping(value, result, reads);
375
+ }
376
+ const log = document.settings?.log?.variables === true;
377
+ this.history(step, token, token.nodeId, "assigned", log ? { values: logged } : { vars: assignments.map((item) => item.var) });
378
+ return this.leave(step, token, this.single(step, token.nodeId));
379
+ }
380
+
340
381
  private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
341
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
382
+ const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now()), this.now());
342
383
  if (dueAt === undefined) {
343
384
  this.fail(step, token, "bad_timer", `the timer of ${token.nodeId} has no valid date`);
344
385
  return [];
@@ -356,7 +397,7 @@ export class WorkflowEngine {
356
397
  if (host.status !== "waiting") return next;
357
398
  for (const [id, node] of step.graph.boundaries.get(host.nodeId) ?? []) {
358
399
  if (node.kind === "boundaryEvent") {
359
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
400
+ const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now()));
360
401
  if (correlation === undefined) {
361
402
  this.fail(step, host, "bad_correlation", `the correlation of ${id} has no value`);
362
403
  return [];
@@ -367,7 +408,7 @@ export class WorkflowEngine {
367
408
  continue;
368
409
  }
369
410
  if (node.kind !== "boundaryTimer") continue;
370
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
411
+ const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now()), this.now());
371
412
  if (dueAt === undefined) {
372
413
  this.fail(step, host, "bad_timer", `the timer of ${id} has no valid date`);
373
414
  return [];
@@ -389,7 +430,7 @@ export class WorkflowEngine {
389
430
  }
390
431
 
391
432
  private waitForEvent(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
392
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
433
+ const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now()));
393
434
  if (correlation === undefined) {
394
435
  this.fail(step, token, "bad_correlation", `the correlation of ${token.nodeId} has no value`);
395
436
  return [];
@@ -441,8 +482,8 @@ export class WorkflowEngine {
441
482
  private choose(step: Step, token: WfToken): WfToken[] {
442
483
  const flows = step.graph.outgoing.get(token.nodeId) ?? [];
443
484
  if (flows.length === 1) return this.leave(step, token, flows[0]!);
444
- const data = dataOf(step.instance);
445
- const chosen = flows.find(([, flow]) => flow.condition !== undefined && evaluateCondition(flow.condition, data))
485
+ const data = dataOf(step.instance, this.now());
486
+ const chosen = flows.find(([, flow]) => flow.condition !== undefined && isTruthy(evaluateExpression(flow.condition, data)))
446
487
  ?? flows.find(([, flow]) => flow.isDefault === true);
447
488
  if (!chosen) {
448
489
  this.fail(step, token, "no_branch", "no condition matched and there is no default flow");
@@ -539,6 +580,15 @@ export function correlationOf(config: Readonly<Record<string, unknown>>, data: I
539
580
  return value === undefined || value === null || value === "" || typeof value === "object" ? undefined : String(value);
540
581
  }
541
582
 
542
- export function dataOf(instance: WfInstance): InstanceData {
543
- return { input: instance.input, vars: instance.vars, nodes: instance.nodes };
583
+ /** What expressions read in an instance at this moment of the engine's clock. */
584
+ export function dataOf(instance: WfInstance, now: Date): InstanceData {
585
+ return {
586
+ input: instance.input,
587
+ vars: instance.vars,
588
+ nodes: instance.nodes,
589
+ event: instance.event,
590
+ trigger: instance.trigger,
591
+ run: { id: instance.id, processKey: instance.processKey, version: instance.version, partition: instance.partition, test: instance.test },
592
+ now: now.toISOString(),
593
+ };
544
594
  }
@@ -4,11 +4,9 @@ import { RuntimeDbContext } from "../model/Runtime.dbContext";
4
4
  import type { WfJob } from "../model/WfJob.model";
5
5
  import { Schedules } from "./Schedules";
6
6
  import { TimerStep } from "./TimerStep";
7
- import { ToolStep } from "./ToolStep";
7
+ import { LEASE_MS, ToolStep } from "./ToolStep";
8
8
  import { WorkflowEngine } from "./WorkflowEngine";
9
9
 
10
- /** How long a claimed job belongs to its worker; if it dies, another one takes the job after that. */
11
- const LEASE_MS = 5 * 60_000;
12
10
  /** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
13
11
  const MAX_JOB_ATTEMPTS = 5;
14
12
 
@@ -16,7 +14,7 @@ const MAX_JOB_ATTEMPTS = 5;
16
14
  export type JobFailureReport = (message: string, error: unknown, fields?: Readonly<Record<string, unknown>>) => void;
17
15
 
18
16
  /**
19
- * Claiming and running jobs of `bazis_wf_run_jobs`: the work of the worker, and
17
+ * Claiming and running jobs of `bazis_wf2_run_jobs`: the work of the worker, and
20
18
  * of `drain` in tests. Every job runs in its own DI scope.
21
19
  */
22
20
  export class WorkflowJobs {
@@ -19,7 +19,7 @@ export const RUNTIME_ENTITIES = [WfInstance, WfToken, WfJob, WfSubscription, WfH
19
19
  /** The tables of the Runtime part: bazis creates and checks them in the application database. */
20
20
  export const runtimeStore = defineOrmOwnedStoreV1({
21
21
  contract: "bazis.orm-owned-store/v1",
22
- storeKey: "bazis.workflow.runtime",
22
+ storeKey: "bazis.workflow.runtime.v2",
23
23
  formatVersion: 1,
24
- ownedScope: { schema: "public", tablePrefix: "bazis_wf_run_" },
24
+ ownedScope: { schema: "public", tablePrefix: "bazis_wf2_run_" },
25
25
  });
@@ -1,12 +1,12 @@
1
1
  import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
2
2
  import { WfInstance } from "./WfInstance.model";
3
3
 
4
- export type HistoryType = "started" | "entered" | "left" | "tool" | "timer" | "event" | "error" | "retried" | "cancelled" | "completed";
4
+ export type HistoryType = "started" | "entered" | "left" | "tool" | "assigned" | "skipped" | "timer" | "event" | "error" | "retried" | "cancelled" | "completed";
5
5
 
6
6
  /** The journal of an instance, append only. Its v7 ids give the order. */
7
7
  @ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
8
8
  @Index(["instanceId", "id"])
9
- @Entity({ table: "bazis_wf_run_history" })
9
+ @Entity({ table: "bazis_wf2_run_history" })
10
10
  export class WfHistory {
11
11
  @UUID({ version: "v7" })
12
12
  id = "";