bazis 0.134.0 → 0.136.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/access/contracts/References.ts +2 -0
- package/core/agent/AgentToolExecutor.ts +3 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/tools/StandardTools.module.ts +10 -2
- package/core/tools/contracts/Tools.config.ts +30 -0
- package/core/tools/generated/deps.ts +5 -0
- package/core/tools/http/HttpRequest.tool.ts +246 -0
- package/core/tools/http/publicAddress.ts +94 -0
- package/core/tools/index.ts +5 -2
- package/core/tools/orm/OrmEntity.references.ts +11 -7
- package/core/tools/secrets/SecretStore.ts +28 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
- package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +23 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +56 -6
- package/core/workflow/generated/deps.ts +5 -2
- package/core/workflow/internal/WorkflowReferences.ts +69 -0
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
- package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +4 -3
- package/core/workflow/runtime_module/internal/ToolStep.ts +45 -5
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +20 -7
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +13 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +11 -1
- package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
- package/library/redaction/index.ts +28 -0
- package/library/workflow/expressions.ts +8 -1
- package/library/workflow/index.ts +2 -1
- package/library/workflow/paths.ts +21 -1
- package/library/workflow/references.ts +106 -0
- package/library/workflow/validateProcess.ts +6 -5
- package/package.json +1 -1
|
@@ -92,6 +92,24 @@ export interface SavedDraft {
|
|
|
92
92
|
readonly draftUpdatedAt: Date;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export interface ProcessReferenceQuery {
|
|
96
|
+
readonly kind: string;
|
|
97
|
+
readonly id: string;
|
|
98
|
+
/** The area of the value: the table of a field. Without it any area goes. */
|
|
99
|
+
readonly within?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A process that refers to a value, and where. */
|
|
103
|
+
export interface ProcessReference {
|
|
104
|
+
readonly processKey: string;
|
|
105
|
+
readonly version: number;
|
|
106
|
+
readonly node?: string;
|
|
107
|
+
readonly variable?: string;
|
|
108
|
+
readonly within?: string;
|
|
109
|
+
/** Where in the input of the step: `/values/status`. */
|
|
110
|
+
readonly path: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
/**
|
|
96
114
|
* The process catalog: drafts and immutable published versions
|
|
97
115
|
* (WORKFLOW-001 §3). Errors are {@link WorkflowError}: `not_found`, `conflict`,
|
|
@@ -132,6 +150,11 @@ export abstract class IWorkflowDefinitions {
|
|
|
132
150
|
abstract published(): Promise<readonly VersionDetails[]>;
|
|
133
151
|
/** A version by id, for the engine; `undefined` if there is none. */
|
|
134
152
|
abstract versionById(id: string): Promise<VersionDetails | undefined>;
|
|
153
|
+
/**
|
|
154
|
+
* Who refers to a value (WORKFLOW-002 §6.3), by the latest published versions: the
|
|
155
|
+
* application forbids or warns before it removes a field or a table.
|
|
156
|
+
*/
|
|
157
|
+
abstract referencing(reference: ProcessReferenceQuery): Promise<readonly ProcessReference[]>;
|
|
135
158
|
/** Processes with a retention period: what the retention service deletes. */
|
|
136
159
|
abstract retentionPolicies(): Promise<readonly { readonly key: string; readonly retentionDays: number }[]>;
|
|
137
160
|
/** The Tools a service task may call, with their input and output schemas. */
|
|
@@ -2,7 +2,7 @@ import { grantsToWhere } from "../../../access";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { paginate, UniqueViolationError } from "../../../orm";
|
|
4
4
|
import type { AgentToolContract } from "../../../agent";
|
|
5
|
-
import { allTexts, checkLocalizedText, emptyProcess, hasErrors, processPalette, validateProcess, type LocalizedText, type ProcessDocument, type ProcessIssue, type ProcessPalette } from "../../../../library/workflow";
|
|
5
|
+
import { allTexts, checkLocalizedText, emptyProcess, hasErrors, processPalette, validateProcess, type DocumentReference, type LocalizedText, type ProcessDocument, type ProcessIssue, type ProcessPalette } from "../../../../library/workflow";
|
|
6
6
|
import type { ServiceProvider } from "../../../di";
|
|
7
7
|
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
|
|
8
8
|
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
@@ -13,6 +13,7 @@ import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
|
|
|
13
13
|
import { canonicalJson } from "../../internal/canonicalJson";
|
|
14
14
|
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
15
15
|
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
16
|
+
import type { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
16
17
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
17
18
|
import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
|
|
18
19
|
import type { OwnedCodeProcess } from "../code/codeProcesses";
|
|
@@ -25,6 +26,8 @@ import {
|
|
|
25
26
|
type CreateProcess,
|
|
26
27
|
type ProcessDetails,
|
|
27
28
|
type ProcessListQuery,
|
|
29
|
+
type ProcessReference,
|
|
30
|
+
type ProcessReferenceQuery,
|
|
28
31
|
type ProcessSummary,
|
|
29
32
|
type SavedDraft,
|
|
30
33
|
type SaveDraftOptions,
|
|
@@ -48,6 +51,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
48
51
|
private readonly clock: WorkflowClock,
|
|
49
52
|
private readonly events: WorkflowEvents,
|
|
50
53
|
private readonly languages: WorkflowLanguages,
|
|
54
|
+
private readonly references: WorkflowReferences,
|
|
51
55
|
/** The scope: the port is asked here, so that the application's own registration replaces the default. */
|
|
52
56
|
private readonly services: ServiceProvider,
|
|
53
57
|
) {
|
|
@@ -100,7 +104,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
100
104
|
const published = await this.db.processes.asNoTracking().where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull())).select((p) => ({ key: p.key })).toList();
|
|
101
105
|
const processes = new Set([...published.map((row) => row.key), ...options.processes ?? []]);
|
|
102
106
|
const issues = validateProcess(document, { tools: (name) => this.workflowTools.has(name), processes: (key) => processes.has(key), extensions: (namespace) => namespaces.has(namespace) });
|
|
103
|
-
if (
|
|
107
|
+
if (issues.some((issue) => issue.code === "too_large" || issue.code === "schema")) return issues;
|
|
104
108
|
const context: WorkflowPortContext = { partition: null, language: options.language ?? this.languages.default, actor: options.actor ?? null };
|
|
105
109
|
const found: ProcessIssue[] = [...issues];
|
|
106
110
|
const { extensions, variables, nodes } = document as ProcessDocument;
|
|
@@ -111,9 +115,52 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
111
115
|
for (const message of await port.check(namespace, value, target, context)) found.push({ severity: "error", code: "extension_invalid", message: `${namespace}: ${message}`, ...target });
|
|
112
116
|
}
|
|
113
117
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
if (namespaces.size > 0) {
|
|
119
|
+
await checkAll(extensions, {});
|
|
120
|
+
if (Array.isArray(variables)) for (const variable of variables) await checkAll(variable?.extensions, { variable: variable?.name });
|
|
121
|
+
for (const [node, raw] of Object.entries(nodes)) await checkAll(raw?.extensions, { node });
|
|
122
|
+
}
|
|
123
|
+
found.push(...await this.brokenReferences(document as ProcessDocument, context.language));
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The literal references no owner knows. The processes a document calls are told above as `unknown_process`. */
|
|
128
|
+
private async brokenReferences(document: ProcessDocument, language: string): Promise<ProcessIssue[]> {
|
|
129
|
+
let references: DocumentReference[];
|
|
130
|
+
try {
|
|
131
|
+
references = this.references.of(document);
|
|
132
|
+
} catch {
|
|
133
|
+
// A document that is not whole yet: its shape has been told above.
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const missing = await this.references.missing(references.filter((reference) => reference.kind !== "workflow.process"), { scope: null, language });
|
|
137
|
+
return missing.map((reference) => ({
|
|
138
|
+
severity: "error" as const,
|
|
139
|
+
code: "broken_ref" as const,
|
|
140
|
+
message: this.references.owned(reference.kind)
|
|
141
|
+
? `${reference.kind} ${JSON.stringify(reference.id)}${reference.within === undefined ? "" : ` in ${reference.within}`} does not exist`
|
|
142
|
+
: `no module owns the reference kind ${reference.kind}`,
|
|
143
|
+
...(reference.node === undefined ? {} : { node: reference.node }),
|
|
144
|
+
...(reference.variable === undefined ? {} : { variable: reference.variable }),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async referencing(reference: ProcessReferenceQuery): Promise<readonly ProcessReference[]> {
|
|
149
|
+
const found: ProcessReference[] = [];
|
|
150
|
+
for (const version of await this.published()) {
|
|
151
|
+
for (const item of this.references.of(version.document)) {
|
|
152
|
+
if (item.kind !== reference.kind || item.id !== reference.id) continue;
|
|
153
|
+
if (reference.within !== undefined && item.within !== reference.within) continue;
|
|
154
|
+
found.push({
|
|
155
|
+
processKey: version.key,
|
|
156
|
+
version: version.version,
|
|
157
|
+
path: item.path,
|
|
158
|
+
...(item.node === undefined ? {} : { node: item.node }),
|
|
159
|
+
...(item.variable === undefined ? {} : { variable: item.variable }),
|
|
160
|
+
...(item.within === undefined ? {} : { within: item.within }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
117
164
|
return found;
|
|
118
165
|
}
|
|
119
166
|
|
|
@@ -199,12 +246,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
199
246
|
}
|
|
200
247
|
|
|
201
248
|
async publish(key: string, actor: WorkflowActor, options: WorkflowAuditOptions & Pick<ValidateOptions, "language"> = {}): Promise<VersionDetails> {
|
|
249
|
+
// Before the transaction: the owners of references and the ports read their own contexts.
|
|
250
|
+
const draft = await this.process(key, false);
|
|
251
|
+
const issues = await this.validate(draft.draft, { actor, processes: [key], ...(options.language === undefined ? {} : { language: options.language }) });
|
|
202
252
|
return await this.db.transactionScope(async (transaction) => {
|
|
203
253
|
const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
|
|
204
254
|
if (!row) throw notFound(key);
|
|
205
255
|
this.assertNotCode(key);
|
|
206
256
|
if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
|
|
207
|
-
|
|
257
|
+
if (canonicalJson(row.draft) !== canonicalJson(draft.draft)) throw new WorkflowError("conflict", `The draft of ${key} was saved while it was being published`);
|
|
208
258
|
if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
|
|
209
259
|
const { version, created } = await this.publishDraft(row, actor);
|
|
210
260
|
if (created) transaction.afterCommit(() => this.changed("published", key, actor, options, version.version));
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Do not edit manually.
|
|
3
3
|
import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
4
4
|
import { DbContextOptions } from "../../../library/orm/DbContextOptions";
|
|
5
|
+
import { IAccessCatalog } from "../../access/catalog/IAccessCatalog.service";
|
|
5
6
|
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
6
7
|
import { EventBus } from "../../kernel/events/EventBus";
|
|
7
8
|
import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
@@ -18,6 +19,7 @@ import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDe
|
|
|
18
19
|
import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
|
|
19
20
|
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
20
21
|
import { WorkflowLanguages } from "../internal/WorkflowLanguages";
|
|
22
|
+
import { WorkflowReferences } from "../internal/WorkflowReferences";
|
|
21
23
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
22
24
|
import { WorkflowRetention } from "../runtime_module/background/WorkflowRetention";
|
|
23
25
|
import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
|
|
@@ -63,11 +65,12 @@ registerFrameworkClassDeps([
|
|
|
63
65
|
[TasksController, [IWorkflowTasks]],
|
|
64
66
|
[TasksDbContext, [DbContextOptions]],
|
|
65
67
|
[TimerStep, [RuntimeDbContext, WorkflowEngine]],
|
|
66
|
-
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools]],
|
|
67
|
-
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, ServiceProvider]],
|
|
68
|
+
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools, WorkflowReferences, WorkflowLanguages, ServiceProvider]],
|
|
69
|
+
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowReferences, ServiceProvider]],
|
|
68
70
|
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
|
|
69
71
|
[WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
|
|
70
72
|
[WorkflowLanguages, ["Config:workflow"]],
|
|
73
|
+
[WorkflowReferences, [IAccessCatalog, WorkflowTools, ServiceProvider]],
|
|
71
74
|
[WorkflowRetention, [ServiceProvider, "Config:workflow"]],
|
|
72
75
|
[WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, EventDelivery, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
|
|
73
76
|
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { IAccessCatalog } from "../../access";
|
|
2
|
+
import type { ServiceProvider } from "../../di";
|
|
3
|
+
import { documentReferences, schemaReferenceKinds, type DocumentReference, type InputReference, type ProcessDocument } from "../../../library/workflow";
|
|
4
|
+
import type { WorkflowTools } from "./WorkflowTools";
|
|
5
|
+
|
|
6
|
+
/** Whose references: the partition of the instance, when there is one. */
|
|
7
|
+
export interface ReferenceCheckContext {
|
|
8
|
+
readonly scope: string | null;
|
|
9
|
+
readonly language: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Asks the owners of reference kinds (ACCESS-001 §6) about the references of
|
|
14
|
+
* processes (WORKFLOW-002 §6.3). The owners are resolved in the scope of the call.
|
|
15
|
+
*/
|
|
16
|
+
export class WorkflowReferences {
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly catalog: IAccessCatalog,
|
|
19
|
+
private readonly tools: WorkflowTools,
|
|
20
|
+
private readonly services: ServiceProvider,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/** The input schema of a Tool, in JSON Schema, if the Tool has one. */
|
|
24
|
+
inputSchemaOf(tool: string): unknown {
|
|
25
|
+
const input = this.tools.bound?.describe(tool)?.input;
|
|
26
|
+
return input?.kind === "json-schema" ? input.schema : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The literal references of a document. */
|
|
30
|
+
of(document: ProcessDocument): DocumentReference[] {
|
|
31
|
+
return documentReferences(document, (tool) => this.inputSchemaOf(tool));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reference kinds the Tools name and no module owns: the application is built wrong. */
|
|
35
|
+
unownedToolKinds(): readonly { readonly tool: string; readonly kind: string }[] {
|
|
36
|
+
const found: { tool: string; kind: string }[] = [];
|
|
37
|
+
for (const contract of this.tools.list()) {
|
|
38
|
+
const schema = contract.input?.kind === "json-schema" ? contract.input.schema : undefined;
|
|
39
|
+
for (const kind of schemaReferenceKinds(schema)) if (!this.owned(kind)) found.push({ tool: contract.name, kind });
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
owned(kind: string): boolean {
|
|
45
|
+
return this.catalog.referenceKind(kind) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Those of `references` their owners do not know. A kind nobody owns is reported as not found. */
|
|
49
|
+
async missing<T extends InputReference>(references: readonly T[], context: ReferenceCheckContext): Promise<readonly T[]> {
|
|
50
|
+
const groups = new Map<string, T[]>();
|
|
51
|
+
for (const reference of references) {
|
|
52
|
+
const key = JSON.stringify([reference.kind, reference.within ?? null]);
|
|
53
|
+
groups.set(key, [...groups.get(key) ?? [], reference]);
|
|
54
|
+
}
|
|
55
|
+
const out: T[] = [];
|
|
56
|
+
for (const group of groups.values()) {
|
|
57
|
+
const { kind, within } = group[0]!;
|
|
58
|
+
const owner = this.catalog.referenceKind(kind);
|
|
59
|
+
if (owner === undefined) {
|
|
60
|
+
out.push(...group);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const ids = [...new Set(group.map((reference) => reference.id))];
|
|
64
|
+
const known = await this.services.resolve(owner.type).exists(ids, { scope: context.scope, language: context.language, ...(within === undefined ? {} : { within }) });
|
|
65
|
+
out.push(...group.filter((reference) => !known.has(reference.id)));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -14,7 +14,7 @@ export const runtimePermissions = definePermissions({
|
|
|
14
14
|
"workflow.instances.read": {
|
|
15
15
|
title: { en: "See instances", ru: "Видеть экземпляры" },
|
|
16
16
|
scope: { column: "partition" },
|
|
17
|
-
on: { process, folder },
|
|
17
|
+
on: { process, folder, test: { type: "boolean" } },
|
|
18
18
|
owner: "startedById",
|
|
19
19
|
},
|
|
20
20
|
"workflow.instances.manage": {
|
|
@@ -4,7 +4,9 @@ import { EventBus, type ConfigView } from "../../../kernel";
|
|
|
4
4
|
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
|
+
import type { EntityQuery } from "../../../orm";
|
|
7
8
|
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
9
|
+
import type { WfInstance } from "../model/WfInstance.model";
|
|
8
10
|
import type { WorkflowSettings } from "../../contracts/Workflow.config";
|
|
9
11
|
|
|
10
12
|
const DAY_MS = 86_400_000;
|
|
@@ -13,6 +15,7 @@ const DAY_MS = 86_400_000;
|
|
|
13
15
|
* Deletes `completed`, `cancelled` and `timedOut` instances older than their process's
|
|
14
16
|
* `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
|
|
15
17
|
* `retention.batchSize` at a time. `failed` instances wait for a person and stay.
|
|
18
|
+
* Trial runs are gone after a day, whatever their status and process.
|
|
16
19
|
* Each deleted batch is published as `WORKFLOW_INSTANCES_DELETED`, so parts
|
|
17
20
|
* with their own data (tasks) drop theirs.
|
|
18
21
|
*/
|
|
@@ -47,22 +50,23 @@ export class WorkflowRetention extends BackgroundService {
|
|
|
47
50
|
const definitions = scope.resolve(IWorkflowDefinitions);
|
|
48
51
|
const db = scope.resolve(RuntimeDbContext);
|
|
49
52
|
const now = scope.resolve(WorkflowClock).now().getTime();
|
|
50
|
-
|
|
51
|
-
const cutoff = new Date(now - policy.retentionDays * DAY_MS);
|
|
53
|
+
const drop = async (select: () => EntityQuery<WfInstance>): Promise<boolean> => {
|
|
52
54
|
for (;;) {
|
|
53
|
-
if (signal?.aborted) return
|
|
54
|
-
const ids = (await
|
|
55
|
-
|
|
56
|
-
.orderBy((i) => i.id)
|
|
57
|
-
.take(batchSize)
|
|
58
|
-
.select((i) => ({ id: i.id }))
|
|
59
|
-
.toList()).map((row) => row.id);
|
|
60
|
-
if (ids.length === 0) break;
|
|
55
|
+
if (signal?.aborted) return false;
|
|
56
|
+
const ids = (await select().orderBy((i) => i.id).take(batchSize).select((i) => ({ id: i.id })).toList()).map((row) => row.id);
|
|
57
|
+
if (ids.length === 0) return true;
|
|
61
58
|
await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
|
|
62
59
|
deleted += ids.length;
|
|
63
60
|
await scope.resolve(EventBus).publishScoped(scope, WORKFLOW_INSTANCES_DELETED, { instanceIds: ids });
|
|
64
|
-
if (ids.length < batchSize)
|
|
61
|
+
if (ids.length < batchSize) return true;
|
|
65
62
|
}
|
|
63
|
+
};
|
|
64
|
+
const dayAgo = new Date(now - DAY_MS);
|
|
65
|
+
if (!await drop(() => db.instances.asNoTracking().where((i) => i.test.eq(true).and(i.createdAt.lt(dayAgo))))) return deleted;
|
|
66
|
+
for (const policy of await definitions.retentionPolicies()) {
|
|
67
|
+
const cutoff = new Date(now - policy.retentionDays * DAY_MS);
|
|
68
|
+
const kept = await drop(() => db.instances.asNoTracking().where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled", "timedOut"])).and(i.endedAt.lt(cutoff))));
|
|
69
|
+
if (!kept) return deleted;
|
|
66
70
|
}
|
|
67
71
|
} finally {
|
|
68
72
|
await scope.dispose();
|
|
@@ -68,3 +68,10 @@ export const WORKFLOW_INSTANCES_DELETED = createEventToken<WorkflowInstancesDele
|
|
|
68
68
|
export const WORKFLOW_INSTANCE_STARTED = createEventToken<WorkflowInstanceStarted>("workflow.instance-started");
|
|
69
69
|
export const WORKFLOW_INSTANCE_CANCELLED = createEventToken<WorkflowInstanceCancelled>("workflow.instance-cancelled");
|
|
70
70
|
export const WORKFLOW_INSTANCE_RETRIED = createEventToken<WorkflowInstanceRetried>("workflow.instance-retried");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Only for the part with the tasks: a trial run publishes nothing to the
|
|
74
|
+
* application, but its tasks still come and go (WORKFLOW-002 §5.4).
|
|
75
|
+
*/
|
|
76
|
+
export const WORKFLOW_TRIAL_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.trial-task-requested");
|
|
77
|
+
export const WORKFLOW_TRIAL_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.trial-token-closed");
|
|
@@ -2,6 +2,7 @@ import { contextOnlyController } from "../../../http/Routing/contextOnlyControll
|
|
|
2
2
|
import { can, type AccessObject } from "../../../access";
|
|
3
3
|
import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../../../http";
|
|
4
4
|
import { buildListDocument } from "../../../../library/jsonapi";
|
|
5
|
+
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
5
6
|
import { body, callerOf, filterOf, listQuery, optionalString, pagingOfList, param, requiredString } from "../../internal/http";
|
|
6
7
|
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
7
8
|
import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
@@ -29,6 +30,7 @@ export class InstancesController {
|
|
|
29
30
|
actor,
|
|
30
31
|
request: from,
|
|
31
32
|
language,
|
|
33
|
+
...(request.test === true ? { test: true } : {}),
|
|
32
34
|
});
|
|
33
35
|
return Created(`${ctx.path}/${instance.id}`, instance);
|
|
34
36
|
}
|
|
@@ -49,9 +51,11 @@ export class InstancesController {
|
|
|
49
51
|
async list(ctx: HttpContext) {
|
|
50
52
|
const { access } = await callerOf(ctx);
|
|
51
53
|
const list = listQuery(ctx, {
|
|
52
|
-
filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"], parentInstanceId: ["eq"] },
|
|
54
|
+
filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"], parentInstanceId: ["eq"], test: ["eq"] },
|
|
53
55
|
sort: ["createdAt", "endedAt", "status", "processKey"],
|
|
54
56
|
});
|
|
57
|
+
const test = filterOf(list, "test");
|
|
58
|
+
if (test !== undefined && test !== "true" && test !== "false") throw new WorkflowError("invalid_input", "test must be true or false");
|
|
55
59
|
const page = await this.runtime.instances({
|
|
56
60
|
...pagingOfList(list),
|
|
57
61
|
access: await access.filter("workflow.instances.read"),
|
|
@@ -61,6 +65,7 @@ export class InstancesController {
|
|
|
61
65
|
businessKey: filterOf(list, "businessKey"),
|
|
62
66
|
startedById: filterOf(list, "startedById"),
|
|
63
67
|
parentInstanceId: filterOf(list, "parentInstanceId"),
|
|
68
|
+
...(test === undefined ? {} : { test: test === "true" }),
|
|
64
69
|
});
|
|
65
70
|
return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
|
|
66
71
|
}
|
|
@@ -100,7 +105,7 @@ export class InstancesController {
|
|
|
100
105
|
|
|
101
106
|
/** The instance as the object of a permission. */
|
|
102
107
|
private async objectOf(instance: InstanceSummary): Promise<AccessObject> {
|
|
103
|
-
return { scope: instance.partition, process: instance.processKey, folder: await this.definitions.folderOf(instance.processKey), startedById: instance.startedBy.id };
|
|
108
|
+
return { scope: instance.partition, process: instance.processKey, folder: await this.definitions.folderOf(instance.processKey), startedById: instance.startedBy.id, test: instance.test };
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
111
|
contextOnlyController(InstancesController);
|
|
@@ -108,6 +108,7 @@ export class ChildCalls {
|
|
|
108
108
|
const current = step.tokens.find((t) => t.id === token.id);
|
|
109
109
|
if (current?.status !== "waiting" || step.instance.status !== "running") return;
|
|
110
110
|
const { id } = await this.engine.begin(childGraph, start[0], {
|
|
111
|
+
test: instance.test,
|
|
111
112
|
input: value,
|
|
112
113
|
partition: step.instance.partition,
|
|
113
114
|
businessKey: null,
|
|
@@ -39,7 +39,7 @@ export class RunLimits {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
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();
|
|
42
|
+
const waiting = await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.status.eq("pending"))).orderBy((i) => i.id).take(RELEASE_BATCH).toList();
|
|
43
43
|
if (waiting.length === 0) return;
|
|
44
44
|
// Graphs live in the Definitions store, out of reach inside the transaction; `inStep` finds them cached.
|
|
45
45
|
const graphs = new Map(await Promise.all(waiting.map(async (row) => [row.id, await this.engine.graphOf(row.versionId)] as const)));
|
|
@@ -65,7 +65,7 @@ export class RunLimits {
|
|
|
65
65
|
/** Whether an instance older than this one still works on the key: it goes first. */
|
|
66
66
|
private async keyTaken(processKey: string, businessKey: string, id: string): Promise<boolean> {
|
|
67
67
|
const same = await this.db.instances.asNoTracking()
|
|
68
|
-
.where((i) => i.processKey.eq(processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
|
|
68
|
+
.where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
|
|
69
69
|
.count();
|
|
70
70
|
return same > 0;
|
|
71
71
|
}
|
|
@@ -73,7 +73,7 @@ export class RunLimits {
|
|
|
73
73
|
/** Whether a scheduled run of the partition goes on, or waits since before this one. */
|
|
74
74
|
private async scheduleTaken(processKey: string, partition: string | null, id: string): Promise<boolean> {
|
|
75
75
|
const same = await this.db.instances.asNoTracking()
|
|
76
|
-
.where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
|
|
76
|
+
.where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
|
|
77
77
|
.count();
|
|
78
78
|
return same > 0;
|
|
79
79
|
}
|
|
@@ -113,6 +113,7 @@ export class RunLimits {
|
|
|
113
113
|
await this.engine.inStep(failed.id, async (step) => {
|
|
114
114
|
await this.dropJob(job);
|
|
115
115
|
const { id } = await this.engine.begin(graph, start[0], {
|
|
116
|
+
test: failed.test,
|
|
116
117
|
input: value,
|
|
117
118
|
partition: failed.partition,
|
|
118
119
|
businessKey: null,
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
|
|
2
|
-
import {
|
|
2
|
+
import { redactValues, rememberSecretValue } from "../../../../library/redaction";
|
|
3
|
+
import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, secretNames, type ProcessDocument } from "../../../../library/workflow";
|
|
4
|
+
import type { ServiceProvider } from "../../../di";
|
|
5
|
+
import { SecretStore } from "../../../tools/secrets/SecretStore";
|
|
3
6
|
import type { WorkflowToolMetadata } from "../../contracts/types";
|
|
7
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
8
|
+
import type { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
4
9
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
10
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
11
|
import type { WfJob } from "../model/WfJob.model";
|
|
@@ -23,6 +28,10 @@ export class ToolStep {
|
|
|
23
28
|
private readonly db: RuntimeDbContext,
|
|
24
29
|
private readonly engine: WorkflowEngine,
|
|
25
30
|
private readonly tools: WorkflowTools,
|
|
31
|
+
private readonly references: WorkflowReferences,
|
|
32
|
+
private readonly languages: WorkflowLanguages,
|
|
33
|
+
/** The scope: the application's own `SecretStore` replaces the default. */
|
|
34
|
+
private readonly services: ServiceProvider,
|
|
26
35
|
) {}
|
|
27
36
|
|
|
28
37
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
@@ -35,26 +44,41 @@ export class ToolStep {
|
|
|
35
44
|
const graph = await this.engine.graphOf(instance.versionId);
|
|
36
45
|
const node = graph.document.nodes[token.nodeId];
|
|
37
46
|
const name = String(node?.config?.tool ?? "");
|
|
38
|
-
const
|
|
47
|
+
const { values: secrets, missing: noSecret } = await this.secretsOf(node?.config?.input, instance.partition);
|
|
48
|
+
const revealed = Object.values(secrets);
|
|
49
|
+
const input = resolveInput(node?.config?.input ?? {}, { ...dataOf(instance, this.engine.now(), token), secrets });
|
|
39
50
|
const timeoutMs = stepTimeoutMs(node?.config?.timeout, graph.document);
|
|
40
51
|
// A step may run longer than the lease: another worker must not take the job and call the Tool again meanwhile.
|
|
41
52
|
if (timeoutMs !== undefined && timeoutMs >= LEASE_MS) {
|
|
42
53
|
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
|
|
43
54
|
}
|
|
44
55
|
const runner = this.tools.bound;
|
|
45
|
-
const
|
|
56
|
+
const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
|
|
57
|
+
const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
|
|
58
|
+
const raw: AgentToolResult = runner === undefined
|
|
46
59
|
? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
|
|
60
|
+
: noSecret !== undefined
|
|
61
|
+
? { callId: token.id, name, status: "error", error: { code: "secret_not_found", message: `There is no secret ${noSecret}`, details: { reason: "secret_not_found" } }, metadata: {} } as AgentToolResult
|
|
62
|
+
: missing.length > 0
|
|
63
|
+
// A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
|
|
64
|
+
? { callId: token.id, name, status: "error", error: { code: "ref_not_found", message: `${missing[0]!.kind} ${JSON.stringify(missing[0]!.id)} does not exist`, details: { reason: "ref_not_found", path: missing[0]!.path } }, metadata: {} } as AgentToolResult
|
|
65
|
+
: trial === "unsupported"
|
|
66
|
+
// A trial never calls a Tool that cannot run without changing anything.
|
|
67
|
+
? { callId: token.id, name, status: "success", output: { dryRun: true }, metadata: {} } as AgentToolResult
|
|
47
68
|
: await runner.run(name, input, {
|
|
48
69
|
idempotencyKey: token.id,
|
|
49
70
|
actor: runActorOf(graph.document, instance),
|
|
50
|
-
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test:
|
|
71
|
+
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: instance.test } satisfies WorkflowToolMetadata,
|
|
51
72
|
signal,
|
|
73
|
+
...(trial === "supported" ? { dryRun: true } : {}),
|
|
52
74
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
53
75
|
});
|
|
54
76
|
|
|
77
|
+
// What the Tool got back from the outside may echo a secret: it goes no further than the Tool.
|
|
78
|
+
const result = redactValues(raw, revealed);
|
|
55
79
|
const timedOut = TIMEOUT_CODES.has(result.error?.code ?? "");
|
|
56
80
|
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) : {};
|
|
81
|
+
const payloads = graph.document.settings?.log?.toolPayloads === true ? redactValues(this.payloads(graph.document, name, node?.config?.input, input, result), revealed) : {};
|
|
58
82
|
await this.engine.inStep(instance.id, async (step) => {
|
|
59
83
|
const current = step.tokens.find((t) => t.id === token.id);
|
|
60
84
|
const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && (reason === undefined || timedOut)
|
|
@@ -72,6 +96,7 @@ export class ToolStep {
|
|
|
72
96
|
status: result.status,
|
|
73
97
|
attempt: job.attempts,
|
|
74
98
|
durationMs: result.durationMs ?? null,
|
|
99
|
+
...(trial === undefined ? {} : { dryRun: trial === "supported" ? "called" : "not called" }),
|
|
75
100
|
...payloads,
|
|
76
101
|
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
|
|
77
102
|
...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
|
|
@@ -89,6 +114,21 @@ export class ToolStep {
|
|
|
89
114
|
});
|
|
90
115
|
}
|
|
91
116
|
|
|
117
|
+
/** The values of `$secrets` this input names, and the first one there is not. Remembered, so that a log masks them too. */
|
|
118
|
+
private async secretsOf(input: unknown, partition: string | null): Promise<{ readonly values: Record<string, string>; readonly missing?: string }> {
|
|
119
|
+
const values: Record<string, string> = {};
|
|
120
|
+
const names = secretNames(input);
|
|
121
|
+
if (names.length === 0) return { values };
|
|
122
|
+
const store = this.services.tryResolve(SecretStore);
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
const value = await store?.get(name, { partition });
|
|
125
|
+
if (value === undefined) return { values, missing: name };
|
|
126
|
+
rememberSecretValue(value);
|
|
127
|
+
values[name] = value;
|
|
128
|
+
}
|
|
129
|
+
return { values };
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
/** The input and output for the history under `log.toolPayloads`; personal data is masked unless `log.maskPersonal` is `false`. */
|
|
93
133
|
private payloads(document: ProcessDocument, name: string, mapping: unknown, input: unknown, result: AgentToolResult): Record<string, unknown> {
|
|
94
134
|
if (!masksPersonal(document)) return { input, output: result.output ?? null };
|
|
@@ -4,7 +4,7 @@ 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";
|
|
6
6
|
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
7
|
-
import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
7
|
+
import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
8
8
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
9
9
|
import { WfHistory, type HistoryType } from "../model/WfHistory.model";
|
|
10
10
|
import { WfInstance, type StartedVia } from "../model/WfInstance.model";
|
|
@@ -53,6 +53,8 @@ export interface BeginOptions {
|
|
|
53
53
|
readonly queued?: boolean;
|
|
54
54
|
/** The firing a scheduled start belongs to: `$trigger.scheduledAt`. */
|
|
55
55
|
readonly scheduledAt?: Date;
|
|
56
|
+
/** A trial run (WORKFLOW-002 §5.4): takes no place among real ones and tells the application nothing. */
|
|
57
|
+
readonly test?: boolean;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** Why a step failed. `reason` is the business reason an error boundary may name. */
|
|
@@ -162,7 +164,7 @@ export class WorkflowEngine {
|
|
|
162
164
|
*/
|
|
163
165
|
async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<Begun> {
|
|
164
166
|
return await this.db.transactionScope(async (transaction) => {
|
|
165
|
-
const admission = await this.admit(graph, options.businessKey);
|
|
167
|
+
const admission = options.test === true ? "start" : await this.admit(graph, options.businessKey);
|
|
166
168
|
if (typeof admission === "object") return { id: admission.skippedBy, skipped: true };
|
|
167
169
|
const waits = admission === "queue" || options.queued === true;
|
|
168
170
|
const instance = this.newInstance(graph, options, waits ? "pending" : "running");
|
|
@@ -211,7 +213,7 @@ export class WorkflowEngine {
|
|
|
211
213
|
await this.lockProcess(graph.processKey);
|
|
212
214
|
if (keyed) {
|
|
213
215
|
const same = await this.db.instances.asNoTracking()
|
|
214
|
-
.where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])))
|
|
216
|
+
.where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.test.eq(false)).and(i.status.in(["pending", "running", "failed"])))
|
|
215
217
|
.firstOrDefault();
|
|
216
218
|
if (same) return sameKey === "skip" ? { skippedBy: same.id } : "queue";
|
|
217
219
|
}
|
|
@@ -224,13 +226,13 @@ export class WorkflowEngine {
|
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
async runningOf(processKey: string): Promise<number> {
|
|
227
|
-
return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("running"))).count();
|
|
229
|
+
return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.status.eq("running"))).count();
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
/** Scheduled runs of a process that still go on in a partition, for `overlap`. A failed one waits for a person and takes no place. */
|
|
231
233
|
async scheduledLive(processKey: string, partition: string | null): Promise<number> {
|
|
232
234
|
return await this.db.instances.asNoTracking()
|
|
233
|
-
.where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
|
|
235
|
+
.where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
|
|
234
236
|
.count();
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -255,6 +257,7 @@ export class WorkflowEngine {
|
|
|
255
257
|
processKey: graph.processKey,
|
|
256
258
|
version: graph.version,
|
|
257
259
|
partition: options.partition,
|
|
260
|
+
test: options.test === true,
|
|
258
261
|
input: options.input,
|
|
259
262
|
vars: initialVarsOf(graph.document),
|
|
260
263
|
event: options.event?.payload ?? null,
|
|
@@ -928,14 +931,24 @@ export class WorkflowEngine {
|
|
|
928
931
|
|
|
929
932
|
/** Events go out after the commit, so a handler never sees a step that rolled back. */
|
|
930
933
|
private publishAfter(transaction: { afterCommit(callback: () => void | Promise<void>): void }, step: Step): void {
|
|
931
|
-
|
|
932
|
-
|
|
934
|
+
const events = step.instance.test ? trialEvents(step) : [...step.events];
|
|
935
|
+
if (events.length === 0) return;
|
|
933
936
|
transaction.afterCommit(async () => {
|
|
934
937
|
for (const { event, payload } of events) await this.events.publish(event, payload);
|
|
935
938
|
});
|
|
936
939
|
}
|
|
937
940
|
}
|
|
938
941
|
|
|
942
|
+
/** What a trial run tells: only what its tasks need, and they go to who started it. */
|
|
943
|
+
function trialEvents(step: Step): PendingEvent[] {
|
|
944
|
+
const { instance } = step;
|
|
945
|
+
return step.events.flatMap(({ event, payload }): PendingEvent[] => {
|
|
946
|
+
if (event === (WORKFLOW_TOKEN_CLOSED as EventToken<unknown>)) return [{ event: WORKFLOW_TRIAL_TOKEN_CLOSED as EventToken<unknown>, payload }];
|
|
947
|
+
if (event !== (WORKFLOW_TASK_REQUESTED as EventToken<unknown>)) return [];
|
|
948
|
+
return [{ event: WORKFLOW_TRIAL_TASK_REQUESTED as EventToken<unknown>, payload: { ...(payload as object), assignee: { user: instance.startedById } } }];
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
939
952
|
/** When a timer fires: `duration` from now, or the date at the `at` path; `undefined` if neither is valid. */
|
|
940
953
|
export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData, now: Date): Date | undefined {
|
|
941
954
|
if (typeof config.duration === "string") {
|