bazis 0.132.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 (36) hide show
  1. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  2. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  3. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
  4. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
  5. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
  6. package/core/workflow/generated/deps.ts +4 -0
  7. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
  8. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  9. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  10. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  11. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  12. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  13. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  14. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  15. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  16. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
  17. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
  18. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  19. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  20. package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
  21. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  22. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
  23. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
  24. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  25. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  26. package/library/workflow/ProcessDocument.ts +14 -0
  27. package/library/workflow/expressions.ts +5 -1
  28. package/library/workflow/index.ts +1 -1
  29. package/library/workflow/masking.ts +17 -3
  30. package/library/workflow/mermaid.ts +22 -6
  31. package/library/workflow/nodeKinds.ts +59 -0
  32. package/library/workflow/palette.ts +3 -1
  33. package/library/workflow/paths.ts +8 -2
  34. package/library/workflow/structure.ts +33 -4
  35. package/library/workflow/validateProcess.ts +129 -17
  36. package/package.json +1 -1
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.132.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.133.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -10,8 +10,8 @@ import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
10
10
  /**
11
11
  * At start, after the tables exist: brings over the catalog of the old store
12
12
  * (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
13
- * (WORKFLOW-001 §14). A document with an unknown Tool or an `extensions` namespace
14
- * nobody checks stops the start with every finding of every process; the same
13
+ * (WORKFLOW-001 §14). A document with an unknown Tool or process, or an `extensions`
14
+ * namespace nobody checks, stops the start with every finding of every process; the same
15
15
  * document again gives no new version.
16
16
  */
17
17
  export class CodeProcessPublisher implements HostedService {
@@ -30,10 +30,11 @@ export class CodeProcessPublisher implements HostedService {
30
30
  const tools = this.services.resolve(WorkflowTools);
31
31
  const definitions = scope.resolve(IWorkflowDefinitions);
32
32
  const problems: string[] = [];
33
+ const processes = entries.map(({ process }) => process.key);
33
34
  for (const { process, module } of entries) {
34
35
  const found = [...definitionProblems(process, (name) => tools.has(name))];
35
- for (const issue of await definitions.validate(process.document)) {
36
- if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid")) found.push(`${issue.message} (${issue.code})`);
36
+ for (const issue of await definitions.validate(process.document, { processes })) {
37
+ if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid" || issue.code === "unknown_process")) found.push(`${issue.message} (${issue.code})`);
37
38
  }
38
39
  if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
39
40
  }
@@ -122,7 +122,8 @@ export class ProcessesController {
122
122
  async validate(ctx: HttpContext) {
123
123
  const request = await body(ctx);
124
124
  const { actor, language } = await callerOf(ctx);
125
- return { issues: await this.definitions.validate(request.document, { actor, language }) };
125
+ const key = optionalString(request.key, "key", 64);
126
+ return { issues: await this.definitions.validate(request.document, { actor, language, ...(key === undefined ? {} : { processes: [key] }) }) };
126
127
  }
127
128
 
128
129
  /** What a designer builds its forms from (WORKFLOW-002 §4.2). */
@@ -76,6 +76,8 @@ export interface ProcessListQuery extends WorkflowPaging {
76
76
  export interface ValidateOptions {
77
77
  readonly language?: string;
78
78
  readonly actor?: WorkflowActor;
79
+ /** Keys that count as published besides the catalog: the process itself, processes of the code about to be published. */
80
+ readonly processes?: readonly string[];
79
81
  }
80
82
 
81
83
  export interface SaveDraftOptions extends WorkflowAuditOptions, Pick<ValidateOptions, "language"> {
@@ -86,7 +86,9 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
86
86
  async validate(document: unknown, options: ValidateOptions = {}): Promise<readonly ProcessIssue[]> {
87
87
  const port = this.services.resolve(WorkflowExtensions);
88
88
  const namespaces = new Set(port.namespaces());
89
- const issues = validateProcess(document, { tools: (name) => this.workflowTools.has(name), extensions: (namespace) => namespaces.has(namespace) });
89
+ const published = await this.db.processes.asNoTracking().where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull())).select((p) => ({ key: p.key })).toList();
90
+ const processes = new Set([...published.map((row) => row.key), ...options.processes ?? []]);
91
+ const issues = validateProcess(document, { tools: (name) => this.workflowTools.has(name), processes: (key) => processes.has(key), extensions: (namespace) => namespaces.has(namespace) });
90
92
  if (namespaces.size === 0 || issues.some((issue) => issue.code === "too_large" || issue.code === "schema")) return issues;
91
93
  const context: WorkflowPortContext = { partition: null, language: options.language ?? this.languages.default, actor: options.actor ?? null };
92
94
  const found: ProcessIssue[] = [...issues];
@@ -178,7 +180,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
178
180
  if (!row) throw notFound(key);
179
181
  this.assertNotCode(key);
180
182
  if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
181
- const issues = await this.validate(row.draft, { actor, ...(options.language === undefined ? {} : { language: options.language }) });
183
+ const issues = await this.validate(row.draft, { actor, processes: [key], ...(options.language === undefined ? {} : { language: options.language }) });
182
184
  if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
183
185
  const { version, created } = await this.publishDraft(row, actor);
184
186
  if (created) transaction.afterCommit(() => this.changed("published", key, actor, options, version.version));
@@ -22,7 +22,9 @@ import { WorkflowRetention } from "../runtime_module/background/WorkflowRetentio
22
22
  import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
23
23
  import { EventsController } from "../runtime_module/http/Events.controller";
24
24
  import { InstancesController } from "../runtime_module/http/Instances.controller";
25
+ import { ChildCalls } from "../runtime_module/internal/ChildCalls";
25
26
  import { GraphCache } from "../runtime_module/internal/GraphCache";
27
+ import { RunLimits } from "../runtime_module/internal/RunLimits";
26
28
  import { Schedules } from "../runtime_module/internal/Schedules";
27
29
  import { TimerStep } from "../runtime_module/internal/TimerStep";
28
30
  import { ToolStep } from "../runtime_module/internal/ToolStep";
@@ -38,6 +40,7 @@ import { WorkflowTasks } from "../tasks_module/services/WorkflowTasks.service";
38
40
 
39
41
  registerFrameworkClassDeps([
40
42
  [CatalogImport, [DefinitionsDbContext, WorkflowLanguages]],
43
+ [ChildCalls, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
41
44
  [CodeProcessCatalog, [ServiceProvider]],
42
45
  [CodeProcessPublisher, [ServiceProvider]],
43
46
  [DefinitionsDbContext, [DbContextOptions]],
@@ -47,6 +50,7 @@ registerFrameworkClassDeps([
47
50
  [ProcessesController, [IWorkflowDefinitions]],
48
51
  [ProcessFolderLookup, [DefinitionsDbContext]],
49
52
  [ProcessReferences, [DefinitionsDbContext]],
53
+ [RunLimits, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
50
54
  [RuntimeDbContext, [DbContextOptions]],
51
55
  [Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
52
56
  [TaskEvents, [TasksDbContext]],
@@ -7,7 +7,9 @@ import { WorkflowRetention } from "./background/WorkflowRetention";
7
7
  import { WorkflowWorker } from "./background/WorkflowWorker";
8
8
  import { EventsController } from "./http/Events.controller";
9
9
  import { InstancesController } from "./http/Instances.controller";
10
+ import { ChildCalls } from "./internal/ChildCalls";
10
11
  import { GraphCache } from "./internal/GraphCache";
12
+ import { RunLimits } from "./internal/RunLimits";
11
13
  import { Schedules } from "./internal/Schedules";
12
14
  import { TimerStep } from "./internal/TimerStep";
13
15
  import { ToolStep } from "./internal/ToolStep";
@@ -31,6 +33,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
31
33
  scoped(ToolStep),
32
34
  scoped(TimerStep),
33
35
  scoped(Schedules),
36
+ scoped(ChildCalls),
37
+ scoped(RunLimits),
34
38
  singleton(WorkflowInputValidator, DefaultWorkflowInputValidator),
35
39
  scoped(IWorkflowRuntime, WorkflowRuntime),
36
40
  ],
@@ -10,7 +10,7 @@ import type { WorkflowSettings } from "../../contracts/Workflow.config";
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 }))
@@ -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. */
@@ -5,7 +5,7 @@ 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
7
  import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
8
- import { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
8
+ import { IWorkflowRuntime, type InstanceSummary, type StartManyItem } from "../services/IWorkflowRuntime.service";
9
9
 
10
10
  /** Instances over HTTP (WORKFLOW-001 §4); rights are `workflow.instances.*` (WORKFLOW-002 §6.2). */
11
11
  @Controller("workflow/instances")
@@ -33,12 +33,23 @@ export class InstancesController {
33
33
  return Created(`${ctx.path}/${instance.id}`, instance);
34
34
  }
35
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
+
36
47
  @Get()
37
48
  @Authorize(can("workflow.instances.read"))
38
49
  async list(ctx: HttpContext) {
39
50
  const { access } = await callerOf(ctx);
40
51
  const list = listQuery(ctx, {
41
- 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"] },
42
53
  sort: ["createdAt", "endedAt", "status", "processKey"],
43
54
  });
44
55
  const page = await this.runtime.instances({
@@ -49,6 +60,7 @@ export class InstancesController {
49
60
  status: filterOf(list, "status") as InstanceStatus | undefined,
50
61
  businessKey: filterOf(list, "businessKey"),
51
62
  startedById: filterOf(list, "startedById"),
63
+ parentInstanceId: filterOf(list, "parentInstanceId"),
52
64
  });
53
65
  return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
54
66
  }
@@ -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. */
@@ -0,0 +1,124 @@
1
+ import { inputSpecOf, validateForm } from "../../../../library/workflow";
2
+ import { WorkflowError } from "../../contracts/WorkflowError";
3
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
4
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
5
+ import type { WfJob } from "../model/WfJob.model";
6
+ import type { WorkflowEngine } from "./WorkflowEngine";
7
+
8
+ /** How many waiting instances of a process one pass looks at. */
9
+ const RELEASE_BATCH = 1000;
10
+
11
+ /** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `errorHandler`. */
12
+ export class RunLimits {
13
+ constructor(
14
+ private readonly db: RuntimeDbContext,
15
+ private readonly engine: WorkflowEngine,
16
+ private readonly definitions: IWorkflowDefinitions,
17
+ ) {}
18
+
19
+ /** The run's time is out. A `failed` instance waits for a person; its retry plans the job again. */
20
+ async runTimeout(job: WfJob): Promise<void> {
21
+ const done = job.instanceId === null ? null : await this.engine.inStep(job.instanceId, async (step) => {
22
+ await this.dropJob(job);
23
+ if (step.instance.status === "running") await this.engine.timeOut(step);
24
+ return true;
25
+ });
26
+ if (done === null) await this.dropJob(job);
27
+ }
28
+
29
+ /** A place may have come free: instances of the process that wait for one start, oldest first. */
30
+ async release(job: WfJob): Promise<void> {
31
+ await this.releaseProcess((job.payload as { processKey: string }).processKey);
32
+ await this.dropJob(job);
33
+ }
34
+
35
+ /** Every process with waiting instances: the safety net for what no ending instance tells, such as a new version without a limit. */
36
+ async releaseWaiting(): Promise<void> {
37
+ const rows = await this.db.instances.asNoTracking().where((i) => i.status.eq("pending")).select((i) => ({ processKey: i.processKey })).toList();
38
+ for (const key of new Set(rows.map((row) => row.processKey))) await this.releaseProcess(key);
39
+ }
40
+
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();
43
+ if (waiting.length === 0) return;
44
+ // Graphs live in the Definitions store, out of reach inside the transaction; `inStep` finds them cached.
45
+ const graphs = new Map(await Promise.all(waiting.map(async (row) => [row.id, await this.engine.graphOf(row.versionId)] as const)));
46
+ await this.db.transactionScope(async () => {
47
+ await this.engine.lockProcess(processKey);
48
+ let running = await this.engine.runningOf(processKey);
49
+ for (const row of waiting) {
50
+ const { maxConcurrent, sameKey } = graphs.get(row.id)!.document.settings ?? {};
51
+ if (maxConcurrent !== undefined && running >= maxConcurrent) continue;
52
+ if (sameKey === "queue" && row.businessKey !== null && await this.keyTaken(processKey, row.businessKey, row.id)) continue;
53
+ const released = await this.engine.inStep(row.id, async (step) => {
54
+ if (step.instance.status !== "pending") return false;
55
+ await this.engine.release(step);
56
+ return true;
57
+ });
58
+ if (released === true) running += 1;
59
+ }
60
+ });
61
+ }
62
+
63
+ /** Whether an instance older than this one still works on the key: it goes first. */
64
+ private async keyTaken(processKey: string, businessKey: string, id: string): Promise<boolean> {
65
+ const same = await this.db.instances.asNoTracking()
66
+ .where((i) => i.processKey.eq(processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
67
+ .count();
68
+ return same > 0;
69
+ }
70
+
71
+ /** An `errorHandler` job: the handler starts with `$event` telling what happened. What keeps it from starting goes to the history of the instance that failed. */
72
+ async errorHandler(job: WfJob): Promise<void> {
73
+ const failed = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
74
+ if (!failed) {
75
+ await this.dropJob(job);
76
+ return;
77
+ }
78
+ const process = (job.payload as { process: string }).process;
79
+ const note = (data: Record<string, unknown>) => this.engine.inStep(failed.id, async (step) => {
80
+ await this.dropJob(job);
81
+ this.engine.history(step, null, null, "handler", { process, ...data });
82
+ });
83
+ let version: VersionDetails;
84
+ try {
85
+ version = await this.definitions.latest(process);
86
+ } catch (error) {
87
+ if (!(error instanceof WorkflowError)) throw error;
88
+ await note({ error: error.message });
89
+ return;
90
+ }
91
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
92
+ if (!start) {
93
+ await note({ error: "the process has no manual start" });
94
+ return;
95
+ }
96
+ const { value, issues } = validateForm(inputSpecOf(version.document), {});
97
+ if (issues.length > 0) {
98
+ await note({ error: "the process needs input", issues });
99
+ return;
100
+ }
101
+ const graph = this.engine.cache(version);
102
+ // In one transaction with the job, so that a repeat of the job does not start a second handler.
103
+ await this.engine.inStep(failed.id, async (step) => {
104
+ await this.dropJob(job);
105
+ const { id } = await this.engine.begin(graph, start[0], {
106
+ input: value,
107
+ partition: failed.partition,
108
+ businessKey: null,
109
+ startedVia: "errorHandler",
110
+ actor: { kind: "system", id: `process:${failed.processKey}` },
111
+ event: {
112
+ key: "workflow.instance-stopped",
113
+ payload: { instanceId: failed.id, processKey: failed.processKey, version: failed.version, partition: failed.partition, businessKey: failed.businessKey, status: failed.status, node: failed.error?.node ?? null, code: failed.error?.code ?? null, message: failed.error?.message ?? null },
114
+ },
115
+ cause: { instanceId: failed.id, processKey: failed.processKey },
116
+ });
117
+ this.engine.history(step, null, null, "handler", { process, instanceId: id });
118
+ });
119
+ }
120
+
121
+ private async dropJob(job: WfJob): Promise<void> {
122
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
123
+ }
124
+ }
@@ -2,7 +2,7 @@ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
2
2
  import type { WfJob } from "../model/WfJob.model";
3
3
  import type { WorkflowEngine } from "./WorkflowEngine";
4
4
 
5
- /** One `timer` job: a `timerCatch` step or a boundary timer is due. A closed token drops it. */
5
+ /** One `timer` job: a `timerCatch` step or a boundary timer is due. A closed token drops it. A `loop` job works the same way. */
6
6
  export class TimerStep {
7
7
  constructor(
8
8
  private readonly db: RuntimeDbContext,
@@ -18,4 +18,14 @@ export class TimerStep {
18
18
  });
19
19
  if (fired === null) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
20
20
  }
21
+
22
+ async continueLoop(job: WfJob): Promise<void> {
23
+ const went = job.instanceId === null || job.tokenId === null
24
+ ? null
25
+ : await this.engine.inStep(job.instanceId, async (step) => {
26
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
27
+ return await this.engine.continueLoop(step, job.tokenId!);
28
+ });
29
+ if (went === null) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
30
+ }
21
31
  }
@@ -4,13 +4,13 @@ import type { WorkflowToolMetadata } from "../../contracts/types";
4
4
  import type { WorkflowTools } from "../../internal/WorkflowTools";
5
5
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
6
6
  import type { WfJob } from "../model/WfJob.model";
7
- import { dataOf, type WorkflowEngine } from "./WorkflowEngine";
7
+ import { dataOf, runActorOf, type WorkflowEngine } from "./WorkflowEngine";
8
8
 
9
9
  /**
10
10
  * One `tool` job: call the service task's Tool and apply the result. The Tool
11
11
  * runs outside a transaction, the result is applied in a step transaction; a
12
12
  * token closed meanwhile (a cancel) drops it. The Tool runs on behalf of who
13
- * started the instance, with the token id as its idempotency key: after a crash
13
+ * started the instance or of the process (`runAs`), 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
16
  /** How long a claimed job belongs to its worker; if it dies, another one takes the job after that. */
@@ -35,7 +35,7 @@ export class ToolStep {
35
35
  const graph = await this.engine.graphOf(instance.versionId);
36
36
  const node = graph.document.nodes[token.nodeId];
37
37
  const name = String(node?.config?.tool ?? "");
38
- const input = resolveInput(node?.config?.input ?? {}, dataOf(instance, this.engine.now()));
38
+ const input = resolveInput(node?.config?.input ?? {}, dataOf(instance, this.engine.now(), token));
39
39
  const timeoutMs = stepTimeoutMs(node?.config?.timeout, graph.document);
40
40
  // A step may run longer than the lease: another worker must not take the job and call the Tool again meanwhile.
41
41
  if (timeoutMs !== undefined && timeoutMs >= LEASE_MS) {
@@ -46,7 +46,7 @@ export class ToolStep {
46
46
  ? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
47
47
  : await runner.run(name, input, {
48
48
  idempotencyKey: token.id,
49
- actor: { kind: instance.startedByKind, id: instance.startedById },
49
+ actor: runActorOf(graph.document, instance),
50
50
  metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: false } satisfies WorkflowToolMetadata,
51
51
  signal,
52
52
  ...(timeoutMs === undefined ? {} : { timeoutMs }),