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.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
- package/core/workflow/generated/deps.ts +4 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
- package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
- package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
- package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
- package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
- package/library/workflow/ProcessDocument.ts +14 -0
- package/library/workflow/expressions.ts +5 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/masking.ts +17 -3
- package/library/workflow/mermaid.ts +22 -6
- package/library/workflow/nodeKinds.ts +59 -0
- package/library/workflow/palette.ts +3 -1
- package/library/workflow/paths.ts +8 -2
- package/library/workflow/structure.ts +33 -4
- package/library/workflow/validateProcess.ts +129 -17
- 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.
|
|
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`
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 }),
|