bazis 0.131.0 → 0.133.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 (71) 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 +27 -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 +66 -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 +35 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
  21. package/core/workflow/generated/deps.ts +15 -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 +4 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  33. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  35. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  36. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
  40. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  41. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  42. package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
  43. package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
  44. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  45. package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
  46. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
  47. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
  48. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  49. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  50. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  51. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  52. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  53. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
  54. package/library/openapi/codegen.ts +3 -0
  55. package/library/validation/types/ValidatorOptions.ts +2 -0
  56. package/library/workflow/ProcessDocument.ts +73 -23
  57. package/library/workflow/expressionSyntax.ts +243 -0
  58. package/library/workflow/expressions.ts +347 -0
  59. package/library/workflow/formFields.ts +118 -33
  60. package/library/workflow/index.ts +48 -9
  61. package/library/workflow/localized.ts +35 -0
  62. package/library/workflow/masking.ts +72 -0
  63. package/library/workflow/mermaid.ts +39 -13
  64. package/library/workflow/nodeKinds.ts +147 -17
  65. package/library/workflow/palette.ts +110 -0
  66. package/library/workflow/paths.ts +37 -6
  67. package/library/workflow/structure.ts +311 -0
  68. package/library/workflow/upgradeDocument.ts +84 -0
  69. package/library/workflow/validateProcess.ts +246 -52
  70. package/package.json +1 -1
  71. package/library/workflow/conditions.ts +0 -44
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Business processes without a UI (WORKFLOW-001): the composite `WorkflowModule`
2
+ * Business processes without a UI (WORKFLOW-001, WORKFLOW-002): the composite `WorkflowModule`
3
3
  * with the Definitions and Runtime parts, their services, HTTP, events and
4
4
  * extension points. The document format is `bazis/library/workflow`.
5
5
  */
@@ -26,6 +26,7 @@ export {
26
26
  type SavedDraft,
27
27
  type SaveDraftOptions,
28
28
  type UpdateProcess,
29
+ type ValidateOptions,
29
30
  type VersionDetails,
30
31
  type VersionSummary,
31
32
  } from "./definitions_module/services/IWorkflowDefinitions.service";
@@ -58,11 +59,12 @@ export {
58
59
  type WorkflowTaskRequested,
59
60
  type WorkflowTokenClosed,
60
61
  } from "./runtime_module/events/Runtime.events";
61
- export { workflowRuntimeConfig } from "./runtime_module/config/Runtime.config";
62
+ export { workflowConfig, type WorkflowSettings } from "./contracts/Workflow.config";
62
63
  export type { HistoryType } from "./runtime_module/model/WfHistory.model";
63
64
  export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstance.model";
64
65
  export type { TokenStatus } from "./runtime_module/model/WfToken.model";
65
66
  export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
67
+ export { WorkflowExtensions, DefaultWorkflowExtensions, type ExtensionTarget, type WorkflowPortContext } from "./contracts/WorkflowExtensions";
66
68
  export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
67
69
  export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
68
70
 
@@ -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,10 +5,11 @@ 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";
10
+ import { ChildCalls } from "./internal/ChildCalls";
11
11
  import { GraphCache } from "./internal/GraphCache";
12
+ import { RunLimits } from "./internal/RunLimits";
12
13
  import { Schedules } from "./internal/Schedules";
13
14
  import { TimerStep } from "./internal/TimerStep";
14
15
  import { ToolStep } from "./internal/ToolStep";
@@ -25,7 +26,6 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
25
26
  @Module({
26
27
  imports: [AccessModule, WorkflowDefinitionsModule],
27
28
  permissions: runtimePermissions,
28
- config: [workflowRuntimeConfig],
29
29
  ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
30
30
  providers: [
31
31
  singleton(GraphCache),
@@ -33,6 +33,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
33
33
  scoped(ToolStep),
34
34
  scoped(TimerStep),
35
35
  scoped(Schedules),
36
+ scoped(ChildCalls),
37
+ scoped(RunLimits),
36
38
  singleton(WorkflowInputValidator, DefaultWorkflowInputValidator),
37
39
  scoped(IWorkflowRuntime, WorkflowRuntime),
38
40
  ],
@@ -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,12 +5,12 @@ 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
 
12
12
  /**
13
- * Deletes `completed` and `cancelled` instances older than their process's
13
+ * Deletes `completed`, `cancelled` and `timedOut` instances older than their process's
14
14
  * `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
15
15
  * `retention.batchSize` at a time. `failed` instances wait for a person and stay.
16
16
  * Each deleted batch is published as `WORKFLOW_INSTANCES_DELETED`, so parts
@@ -52,7 +52,7 @@ export class WorkflowRetention extends BackgroundService {
52
52
  for (;;) {
53
53
  if (signal?.aborted) return deleted;
54
54
  const ids = (await db.instances.asNoTracking()
55
- .where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled"])).and(i.endedAt.lt(cutoff)))
55
+ .where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled", "timedOut"])).and(i.endedAt.lt(cutoff)))
56
56
  .orderBy((i) => i.id)
57
57
  .take(batchSize)
58
58
  .select((i) => ({ id: i.id }))
@@ -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`. */
@@ -21,16 +21,16 @@ export interface WorkflowTaskRequested {
21
21
  export interface WorkflowTokenClosed {
22
22
  readonly instanceId: string;
23
23
  readonly tokenId: string;
24
- /** `cancelled` — the instance was cancelled; `interrupted` — a boundary timer took over the step. */
25
- readonly reason: "cancelled" | "interrupted";
24
+ /** `cancelled` — the instance was cancelled; `interrupted` — a boundary event or a skipped iteration closed the step; `timedOut` — the run took too long. */
25
+ readonly reason: "cancelled" | "interrupted" | "timedOut";
26
26
  }
27
27
 
28
- /** An instance finished: completed or cancelled. */
28
+ /** An instance finished: completed, cancelled or out of time. */
29
29
  export interface WorkflowInstanceFinished {
30
30
  readonly instanceId: string;
31
31
  readonly processKey: string;
32
32
  readonly partition: string | null;
33
- readonly status: Extract<InstanceStatus, "completed" | "cancelled">;
33
+ readonly status: Extract<InstanceStatus, "completed" | "cancelled" | "timedOut">;
34
34
  }
35
35
 
36
36
  /** Retention deleted these finished instances with their tokens and history. */
@@ -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 { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
7
+ import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
8
+ import { IWorkflowRuntime, type InstanceSummary, type StartManyItem } 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,23 +21,35 @@ 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
  }
30
35
 
36
+ /** A start per element, for an action over many records (WORKFLOW-002 §5.1). */
37
+ @Post("bulk")
38
+ @Authorize(can("workflow.instances.start"))
39
+ async startMany(ctx: HttpContext) {
40
+ const request = await body(ctx);
41
+ const processKey = requiredString(request.processKey, "processKey", 64);
42
+ const partition = optionalString(request.partition, "partition") ?? null;
43
+ const { actor, request: from, language } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey, folder: await this.definitions.folderOf(processKey) });
44
+ return { data: await this.runtime.startMany(processKey, { items: request.items as readonly StartManyItem[], partition, actor, request: from, language }) };
45
+ }
46
+
31
47
  @Get()
32
48
  @Authorize(can("workflow.instances.read"))
33
49
  async list(ctx: HttpContext) {
34
50
  const { access } = await callerOf(ctx);
35
51
  const list = listQuery(ctx, {
36
- filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"] },
52
+ filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"], parentInstanceId: ["eq"] },
37
53
  sort: ["createdAt", "endedAt", "status", "processKey"],
38
54
  });
39
55
  const page = await this.runtime.instances({
@@ -44,6 +60,7 @@ export class InstancesController {
44
60
  status: filterOf(list, "status") as InstanceStatus | undefined,
45
61
  businessKey: filterOf(list, "businessKey"),
46
62
  startedById: filterOf(list, "startedById"),
63
+ parentInstanceId: filterOf(list, "parentInstanceId"),
47
64
  });
48
65
  return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
49
66
  }
@@ -52,7 +69,7 @@ export class InstancesController {
52
69
  @Authorize(can("workflow.instances.read"))
53
70
  async get(ctx: HttpContext) {
54
71
  const instance = await this.runtime.instance(param(ctx, "id"));
55
- await callerOf(ctx, "workflow.instances.read", objectOf(instance));
72
+ await callerOf(ctx, "workflow.instances.read", await this.objectOf(instance));
56
73
  return instance;
57
74
  }
58
75
 
@@ -60,7 +77,7 @@ export class InstancesController {
60
77
  @Authorize(can("workflow.instances.read"))
61
78
  async history(ctx: HttpContext) {
62
79
  const instance = await this.runtime.instance(param(ctx, "id"));
63
- await callerOf(ctx, "workflow.instances.read", objectOf(instance));
80
+ await callerOf(ctx, "workflow.instances.read", await this.objectOf(instance));
64
81
  return { data: await this.runtime.history(instance.id) };
65
82
  }
66
83
 
@@ -69,7 +86,7 @@ export class InstancesController {
69
86
  async cancel(ctx: HttpContext) {
70
87
  const instance = await this.runtime.instance(param(ctx, "id"));
71
88
  const request = await body(ctx);
72
- const { actor, request: from } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
89
+ const { actor, request: from } = await callerOf(ctx, "workflow.instances.manage", await this.objectOf(instance));
73
90
  return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor, request: from });
74
91
  }
75
92
 
@@ -77,14 +94,14 @@ export class InstancesController {
77
94
  @Authorize(can("workflow.instances.manage"))
78
95
  async retry(ctx: HttpContext) {
79
96
  const instance = await this.runtime.instance(param(ctx, "id"));
80
- const { actor, request } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
97
+ const { actor, request } = await callerOf(ctx, "workflow.instances.manage", await this.objectOf(instance));
81
98
  return await this.runtime.retry(instance.id, { actor, request });
82
99
  }
83
- }
84
- contextOnlyController(InstancesController);
85
100
 
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 };
101
+ /** The instance as the object of a permission. */
102
+ 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 };
104
+ }
89
105
  }
106
+ contextOnlyController(InstancesController);
90
107
 
@@ -0,0 +1,186 @@
1
+ import { inputSpecOf, resolveInput } from "../../../../library/workflow";
2
+ import type { ServiceProvider } from "../../../di";
3
+ import { WorkflowError } from "../../contracts/WorkflowError";
4
+ import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
5
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
7
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
8
+ import type { WfInstance } from "../model/WfInstance.model";
9
+ import type { WfJob } from "../model/WfJob.model";
10
+ import type { WfToken } from "../model/WfToken.model";
11
+ import { dataOf, runActorOf, type StepError, type WorkflowEngine } from "./WorkflowEngine";
12
+
13
+ /** How deep processes may call each other: a process that calls itself has to stop somewhere. */
14
+ const MAX_DEPTH = 16;
15
+
16
+ /**
17
+ * `callProcess`: the jobs between a caller and the process it called. Each runs in
18
+ * the transaction of one instance, so caller and child never wait for each other.
19
+ * The child runs on behalf of the caller's actor, in its partition.
20
+ */
21
+ export class ChildCalls {
22
+ constructor(
23
+ private readonly db: RuntimeDbContext,
24
+ private readonly engine: WorkflowEngine,
25
+ private readonly definitions: IWorkflowDefinitions,
26
+ /** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
27
+ private readonly services: ServiceProvider,
28
+ private readonly languages: WorkflowLanguages,
29
+ ) {}
30
+
31
+ /**
32
+ * A `call` job: the step starts its process. If it has called already (a retry, a
33
+ * repeat after a crash), it goes on with that child: a failed one is retried, so
34
+ * that its finished steps do not run twice. A cancelled or timed out one is called
35
+ * anew only when the step was entered again (`fresh`).
36
+ */
37
+ async call(job: WfJob): Promise<void> {
38
+ const instance = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
39
+ const token = job.tokenId === null ? undefined : await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(job.tokenId!));
40
+ if (!instance || !token || instance.status !== "running" || token.status !== "waiting") {
41
+ await this.dropJob(job);
42
+ return;
43
+ }
44
+ const child = await this.lastChild(instance.id, token.id);
45
+ if (child?.status === "running" || child?.status === "pending") {
46
+ await this.dropJob(job);
47
+ return;
48
+ }
49
+ if (child?.status === "failed") {
50
+ const actor = runActorOf((await this.engine.graphOf(instance.versionId)).document, instance);
51
+ await this.engine.inStep(child.id, async (step) => {
52
+ await this.dropJob(job);
53
+ if (step.instance.status === "failed") await this.engine.retry(step, actor);
54
+ });
55
+ return;
56
+ }
57
+ if (child?.status === "completed") {
58
+ await this.engine.inStep(instance.id, async (step) => {
59
+ await this.dropJob(job);
60
+ await this.engine.complete(step, token.id, child.output ?? {});
61
+ });
62
+ return;
63
+ }
64
+ if (child && (job.payload as { fresh?: boolean } | null)?.fresh !== true) {
65
+ await this.applyEnd(job, instance.id, token.id, child);
66
+ return;
67
+ }
68
+ await this.start(job, instance, token);
69
+ }
70
+
71
+ private async start(job: WfJob, instance: WfInstance, token: WfToken): Promise<void> {
72
+ const graph = await this.engine.graphOf(instance.versionId);
73
+ const config = graph.document.nodes[token.nodeId]?.config ?? {};
74
+ const key = String(config.process ?? "");
75
+ const depth = (typeof instance.trigger?.depth === "number" ? instance.trigger.depth : 0) + 1;
76
+ const actor = runActorOf(graph.document, instance);
77
+ const refuse = (error: StepError) => this.engine.inStep(instance.id, async (step) => {
78
+ await this.dropJob(job);
79
+ const current = step.tokens.find((t) => t.id === token.id);
80
+ if (current?.status === "waiting" && step.instance.status === "running") await this.engine.failStep(step, current, error);
81
+ });
82
+ if (depth > MAX_DEPTH) {
83
+ await refuse({ code: "call_depth", message: `Processes call each other deeper than ${MAX_DEPTH} levels` });
84
+ return;
85
+ }
86
+ let version: VersionDetails;
87
+ try {
88
+ version = await this.definitions.latest(key);
89
+ } catch (error) {
90
+ if (!(error instanceof WorkflowError)) throw error;
91
+ await refuse({ code: "process_unavailable", message: error.message, reason: error.code });
92
+ return;
93
+ }
94
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
95
+ if (!start) {
96
+ await refuse({ code: "process_unavailable", message: `Process ${key} has no manual start` });
97
+ return;
98
+ }
99
+ const input = resolveInput(config.input ?? {}, dataOf(instance, this.engine.now(), token));
100
+ const { value, issues } = await this.services.resolve(WorkflowInputValidator).validate(inputSpecOf(version.document), input, { purpose: "input", processKey: key, partition: instance.partition, language: this.languages.default, actor });
101
+ if (issues.length > 0) {
102
+ await refuse({ code: "invalid_input", message: `The input of process ${key} is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join("; ")}` });
103
+ return;
104
+ }
105
+ const childGraph = this.engine.cache(version);
106
+ await this.engine.inStep(instance.id, async (step) => {
107
+ await this.dropJob(job);
108
+ const current = step.tokens.find((t) => t.id === token.id);
109
+ if (current?.status !== "waiting" || step.instance.status !== "running") return;
110
+ const { id } = await this.engine.begin(childGraph, start[0], {
111
+ input: value,
112
+ partition: step.instance.partition,
113
+ businessKey: null,
114
+ startedVia: "callProcess",
115
+ actor,
116
+ parent: { instanceId: step.instance.id, tokenId: current.id, depth },
117
+ cause: { instanceId: step.instance.id, processKey: step.instance.processKey, nodeId: current.nodeId },
118
+ });
119
+ this.engine.history(step, current, current.nodeId, "called", { process: key, version: version.version, instanceId: id });
120
+ });
121
+ }
122
+
123
+ /** A `returned` job: a child stopped. If its caller no longer waits, the job drops; a caller that is itself stopped looks again on its retry. */
124
+ async returned(job: WfJob): Promise<void> {
125
+ const { parentInstanceId, parentTokenId } = job.payload as { parentInstanceId: string; parentTokenId: string };
126
+ const child = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
127
+ if (!child) {
128
+ await this.dropJob(job);
129
+ return;
130
+ }
131
+ await this.applyEnd(job, parentInstanceId, parentTokenId, child);
132
+ }
133
+
134
+ private async applyEnd(job: WfJob, instanceId: string, tokenId: string, child: WfInstance): Promise<void> {
135
+ const done = await this.engine.inStep(instanceId, async (step) => {
136
+ await this.dropJob(job);
137
+ const token = step.tokens.find((t) => t.id === tokenId);
138
+ if (token?.status !== "waiting" || step.instance.status !== "running") return;
139
+ if (child.status === "completed") {
140
+ this.engine.history(step, token, token.nodeId, "returned", { instanceId: child.id });
141
+ await this.engine.complete(step, tokenId, child.output ?? {});
142
+ return;
143
+ }
144
+ const error = errorOf(child);
145
+ if (error) await this.engine.failStep(step, token, error);
146
+ });
147
+ if (done === null) await this.dropJob(job);
148
+ }
149
+
150
+ /** A `cancelChildren` job: the step that called is closed, so is what it called. */
151
+ async cancelChildren(job: WfJob): Promise<void> {
152
+ const { tokenId, reason } = job.payload as { tokenId: string; reason: string };
153
+ const parent = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
154
+ if (parent) {
155
+ const children = await this.db.instances.asNoTracking()
156
+ .where((i) => i.parentInstanceId.eq(parent.id).and(i.parentTokenId.eq(tokenId)).and(i.status.in(["pending", "running", "failed"])))
157
+ .toList();
158
+ const actor = { kind: "system" as const, id: `process:${parent.processKey}` };
159
+ for (const child of children) {
160
+ await this.engine.inStep(child.id, async (step) => {
161
+ if (step.instance.status === "pending" || step.instance.status === "running" || step.instance.status === "failed") await this.engine.cancel(step, reason, actor);
162
+ });
163
+ }
164
+ }
165
+ await this.dropJob(job);
166
+ }
167
+
168
+ /** The newest child of a step. */
169
+ private async lastChild(instanceId: string, tokenId: string): Promise<WfInstance | undefined> {
170
+ const children = await this.db.instances.asNoTracking().where((i) => i.parentInstanceId.eq(instanceId).and(i.parentTokenId.eq(tokenId))).orderBy((i) => i.id).toList();
171
+ return children.at(-1);
172
+ }
173
+
174
+ private async dropJob(job: WfJob): Promise<void> {
175
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
176
+ }
177
+ }
178
+
179
+ /** The failure of a step whose child stopped otherwise than by completing; `undefined` while it runs again. */
180
+ function errorOf(child: WfInstance): StepError | undefined {
181
+ const about = `Instance ${child.id} of process ${child.processKey}`;
182
+ if (child.status === "failed") return { code: "child_failed", message: `${about} failed: ${child.error?.message ?? ""}`, ...(child.error ? { reason: child.error.code } : {}) };
183
+ if (child.status === "cancelled") return { code: "child_cancelled", message: `${about} was cancelled` };
184
+ if (child.status === "timedOut") return { code: "child_timed_out", message: `${about} took too long`, reason: "timeout" };
185
+ return undefined;
186
+ }
@@ -10,6 +10,8 @@ export interface ProcessGraph {
10
10
  readonly incoming: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
11
11
  /** Boundary nodes by the step they are attached to. */
12
12
  readonly boundaries: ReadonlyMap<string, readonly (readonly [string, ProcessNode])[]>;
13
+ /** The node an iteration begins with, by loop. */
14
+ readonly entries: ReadonlyMap<string, string>;
13
15
  }
14
16
 
15
17
  export function buildGraph(versionId: string, processKey: string, version: number, document: ProcessDocument): ProcessGraph {
@@ -23,7 +25,11 @@ export function buildGraph(versionId: string, processKey: string, version: numbe
23
25
  for (const [id, node] of Object.entries(document.nodes)) {
24
26
  if (node.attachedTo !== undefined) boundaries.set(node.attachedTo, [...boundaries.get(node.attachedTo) ?? [], [id, node]]);
25
27
  }
26
- return { versionId, processKey, version, document, outgoing, incoming, boundaries };
28
+ const entries = new Map<string, string>();
29
+ for (const [id, node] of Object.entries(document.nodes)) {
30
+ if (node.parent !== undefined && node.attachedTo === undefined && !incoming.has(id)) entries.set(node.parent, id);
31
+ }
32
+ return { versionId, processKey, version, document, outgoing, incoming, boundaries, entries };
27
33
  }
28
34
 
29
35
  /** Graphs of versions in memory. Versions are immutable, so entries never go stale; the oldest go first past the limit. */