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