bazis 0.133.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/core/access/contracts/References.ts +2 -0
  2. package/core/agent/AgentToolExecutor.ts +5 -0
  3. package/core/agent/Tool.contract.ts +2 -2
  4. package/core/agent/metadata.ts +11 -0
  5. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  6. package/core/scripts/di-generate.ts +8 -0
  7. package/core/tools/StandardTools.module.ts +14 -0
  8. package/core/tools/generated/deps.ts +10 -0
  9. package/core/tools/index.ts +8 -0
  10. package/core/tools/orm/OrmEntity.references.ts +75 -0
  11. package/core/tools/orm/ormEntityTools.ts +423 -0
  12. package/core/workflow/contracts/WorkflowError.ts +2 -1
  13. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  14. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  15. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +9 -2
  16. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  17. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  18. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
  19. package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
  20. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  21. package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
  22. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +31 -0
  23. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +80 -6
  24. package/core/workflow/generated/deps.ts +13 -4
  25. package/core/workflow/index.ts +5 -0
  26. package/core/workflow/internal/WorkflowReferences.ts +69 -0
  27. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
  28. package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
  29. package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
  33. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +16 -5
  35. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  36. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +16 -2
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +48 -9
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
  40. package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
  41. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +34 -1
  42. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +40 -41
  43. package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
  44. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
  45. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
  46. package/library/workflow/cron.ts +103 -24
  47. package/library/workflow/index.ts +3 -2
  48. package/library/workflow/nodeKinds.ts +64 -9
  49. package/library/workflow/paths.ts +2 -2
  50. package/library/workflow/references.ts +106 -0
  51. package/library/workflow/validateProcess.ts +13 -0
  52. package/package.json +2 -1
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { AccessModule } from "../../access";
2
2
  import { Module, scoped, singleton } from "../../di";
3
3
  import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
4
+ import { WorkflowProcessUsage } from "../contracts/WorkflowProcessUsage";
4
5
  import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
5
6
  import { runtimePermissions } from "./access/Runtime.permissions";
6
7
  import { WorkflowRetention } from "./background/WorkflowRetention";
@@ -8,8 +9,10 @@ import { WorkflowWorker } from "./background/WorkflowWorker";
8
9
  import { EventsController } from "./http/Events.controller";
9
10
  import { InstancesController } from "./http/Instances.controller";
10
11
  import { ChildCalls } from "./internal/ChildCalls";
12
+ import { EventDelivery } from "./internal/EventDelivery";
11
13
  import { GraphCache } from "./internal/GraphCache";
12
14
  import { RunLimits } from "./internal/RunLimits";
15
+ import { RuntimeProcessUsage } from "./internal/RuntimeProcessUsage";
13
16
  import { Schedules } from "./internal/Schedules";
14
17
  import { TimerStep } from "./internal/TimerStep";
15
18
  import { ToolStep } from "./internal/ToolStep";
@@ -35,6 +38,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
35
38
  scoped(Schedules),
36
39
  scoped(ChildCalls),
37
40
  scoped(RunLimits),
41
+ scoped(EventDelivery),
42
+ scoped(WorkflowProcessUsage, RuntimeProcessUsage),
38
43
  singleton(WorkflowInputValidator, DefaultWorkflowInputValidator),
39
44
  scoped(IWorkflowRuntime, WorkflowRuntime),
40
45
  ],
@@ -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
- for (const policy of await definitions.retentionPolicies()) {
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 deleted;
54
- const ids = (await db.instances.asNoTracking()
55
- .where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled", "timedOut"])).and(i.endedAt.lt(cutoff)))
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) break;
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,
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { durationMs, evaluateExpression, inputSpecOf, isTruthy, type Expr, type FormIssue, type InstanceData, type ProcessNode } from "../../../../library/workflow";
3
+ import { LOGGER } from "../../../kernel";
4
+ import type { ServiceProvider } from "../../../di";
5
+ import { isUuid, type WorkflowActor, type WorkflowRequest } from "../../contracts/types";
6
+ import { WorkflowError } from "../../contracts/WorkflowError";
7
+ import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
8
+ import { WorkflowPartitions } from "../../contracts/WorkflowPartitions";
9
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
10
+ import { canonicalJson } from "../../internal/canonicalJson";
11
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
12
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
13
+ import { WfJob } from "../model/WfJob.model";
14
+ import type { EmitCause, EmitResult } from "../services/IWorkflowRuntime.service";
15
+ import type { WorkflowEngine } from "./WorkflowEngine";
16
+
17
+ /** An event on its way: what `emit` got, with the defaults in place. */
18
+ export interface EventMessage {
19
+ readonly eventKey: string;
20
+ readonly payload: Record<string, unknown>;
21
+ readonly correlation: string | null;
22
+ readonly partition: string | null;
23
+ readonly actor: WorkflowActor;
24
+ readonly request: WorkflowRequest | null;
25
+ readonly cause: EmitCause | null;
26
+ }
27
+
28
+ interface EventStartConfig {
29
+ readonly filter?: Expr;
30
+ readonly debounce?: { readonly key: Expr; readonly window: string };
31
+ readonly delay?: string;
32
+ readonly ignoreOwn?: boolean;
33
+ }
34
+
35
+ /** A process the event is to start, with its checked input. */
36
+ interface PlannedStart {
37
+ readonly version: VersionDetails;
38
+ readonly nodeId: string;
39
+ readonly config: EventStartConfig;
40
+ readonly input: Record<string, unknown>;
41
+ }
42
+
43
+ /**
44
+ * Takes an event to whoever waits for it and to the processes it starts
45
+ * (WORKFLOW-002 §5.1, §5.2). An `event` job (`emitLater`) turns into an
46
+ * `eventStart` job per process in one transaction, and each of those starts its
47
+ * instance in one transaction with its own removal: a repeat after a crash starts
48
+ * nothing twice. Waits are safe as they are: a subscription leaves with its step.
49
+ */
50
+ export class EventDelivery {
51
+ constructor(
52
+ private readonly db: RuntimeDbContext,
53
+ private readonly engine: WorkflowEngine,
54
+ private readonly definitions: IWorkflowDefinitions,
55
+ private readonly languages: WorkflowLanguages,
56
+ /** The scope: the application's own ports replace the defaults. */
57
+ private readonly services: ServiceProvider,
58
+ ) {}
59
+
60
+ /** Moves the waits and starts the processes, now or, under `debounce` and `delay`, by a job. */
61
+ async emit(message: EventMessage): Promise<EmitResult> {
62
+ const continued = await this.continueWaits(message);
63
+ const { planned, rejected } = await this.plan(message);
64
+ const started: string[] = [];
65
+ const skipped: { processKey: string; instanceId: string }[] = [];
66
+ const deferred: { processKey: string }[] = [];
67
+ for (const start of planned) {
68
+ if (start.config.debounce !== undefined || start.config.delay !== undefined) {
69
+ await this.defer(start, message);
70
+ deferred.push({ processKey: start.version.key });
71
+ continue;
72
+ }
73
+ const begun = await this.begin(start.version, start.nodeId, start.input, message);
74
+ if (begun.skipped) skipped.push({ processKey: start.version.key, instanceId: begun.id });
75
+ else started.push(begun.id);
76
+ }
77
+ return { started, skipped, deferred, continued, rejected };
78
+ }
79
+
80
+ /** An `event` job: the waits move, and the job becomes an `eventStart` job per process it starts. */
81
+ async deliverLater(job: WfJob): Promise<void> {
82
+ const message = job.payload as EventMessage;
83
+ await this.continueWaits(message);
84
+ const { planned, rejected } = await this.plan(message);
85
+ for (const { processKey, issues } of rejected) this.services.tryResolve(LOGGER)?.warn("workflow event did not start a process", { event: message.eventKey, processKey, issues });
86
+ await this.db.transactionScope(async () => {
87
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
88
+ for (const start of planned) await this.defer(start, message);
89
+ });
90
+ }
91
+
92
+ /** An `eventStart` job: its instance starts together with the removal of the job. An archived process takes nothing. */
93
+ async startLater(job: WfJob): Promise<void> {
94
+ const { message, input } = job.payload as { readonly message: EventMessage; readonly input: Record<string, unknown> };
95
+ const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
96
+ if (version === undefined || job.nodeId === null || !await this.available(version.key)) {
97
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
98
+ return;
99
+ }
100
+ const graph = this.engine.cache(version);
101
+ await this.db.transactionScope(async () => {
102
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
103
+ await this.engine.begin(graph, job.nodeId!, this.beginOptions(input, message));
104
+ });
105
+ }
106
+
107
+ private async available(processKey: string): Promise<boolean> {
108
+ try {
109
+ await this.definitions.latest(processKey);
110
+ return true;
111
+ } catch (error) {
112
+ if (error instanceof WorkflowError) return false;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ /** Every wait of the event: `eventWait.until` may leave a step waiting. */
118
+ private async continueWaits(message: EventMessage): Promise<string[]> {
119
+ const { eventKey, correlation, partition, payload } = message;
120
+ let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
121
+ waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
122
+ if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
123
+ const continued = new Set<string>();
124
+ for (const wait of await waits.orderBy((s) => s.id).toList()) {
125
+ const moved = await this.engine.inStep(wait.instanceId, async (step) => {
126
+ if (!this.engine.eventAccepted(step, wait.tokenId, payload)) return false;
127
+ await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
128
+ return await this.engine.eventReceived(step, wait.tokenId, eventKey, payload);
129
+ });
130
+ if (moved === true) continued.add(wait.instanceId);
131
+ }
132
+ return [...continued];
133
+ }
134
+
135
+ /** The `eventStart` nodes that take the event: connected to its partition, past `ignoreOwn` and `filter`, with a valid input. */
136
+ private async plan(message: EventMessage): Promise<{ readonly planned: PlannedStart[]; readonly rejected: { processKey: string; issues: readonly FormIssue[] }[] }> {
137
+ const { eventKey, payload, partition, actor } = message;
138
+ const planned: PlannedStart[] = [];
139
+ const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
140
+ const data = eventData(message, this.engine.now());
141
+ const own = await this.causeProcess(message.cause);
142
+ for (const version of await this.definitions.published()) {
143
+ for (const [nodeId, node] of Object.entries(version.document.nodes)) {
144
+ if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
145
+ const config = eventStartConfig(node);
146
+ if (!await this.connected(version.key, partition)) continue;
147
+ if (config.ignoreOwn === true && own === version.key) continue;
148
+ if (config.filter !== undefined && !isTruthy(evaluateExpression(config.filter, data))) continue;
149
+ // An event carries more than a process needs: fields the input does not declare are dropped, not refused.
150
+ const { value, issues: all } = await this.services.resolve(WorkflowInputValidator).validate(inputSpecOf(version.document), payload, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
151
+ const issues = all.filter((issue) => issue.code !== "unknown");
152
+ if (issues.length > 0) rejected.push({ processKey: version.key, issues });
153
+ else planned.push({ version, nodeId, config, input: value });
154
+ }
155
+ }
156
+ return { planned, rejected };
157
+ }
158
+
159
+ /** Whether the process works in the partition of the event; an event without a partition is for processes without them. */
160
+ private async connected(processKey: string, partition: string | null): Promise<boolean> {
161
+ const partitions = await this.services.resolve(WorkflowPartitions).of(processKey, { partition, language: this.languages.default, actor: null });
162
+ return partitions.some((item) => item.enabled && item.partition === partition);
163
+ }
164
+
165
+ /** The process of the instance that caused the event. */
166
+ private async causeProcess(cause: EmitCause | null): Promise<string | undefined> {
167
+ const id = cause?.instanceId;
168
+ if (!isUuid(id)) return undefined;
169
+ const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(id));
170
+ return row?.processKey;
171
+ }
172
+
173
+ private async begin(version: VersionDetails, nodeId: string, input: Record<string, unknown>, message: EventMessage) {
174
+ return await this.engine.begin(this.engine.cache(version), nodeId, this.beginOptions(input, message));
175
+ }
176
+
177
+ private beginOptions(input: Record<string, unknown>, message: EventMessage) {
178
+ return {
179
+ input,
180
+ partition: message.partition,
181
+ businessKey: message.correlation,
182
+ startedVia: "event" as const,
183
+ actor: message.actor,
184
+ request: message.request,
185
+ event: { key: message.eventKey, payload: message.payload },
186
+ ...(message.cause === null ? {} : { cause: message.cause }),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * An `eventStart` job due after `delay` and the `debounce` window. The window runs
192
+ * from its first event and later ones replace what the job carries, so a steady
193
+ * stream of events still starts a process once per window. A job a worker holds
194
+ * takes no more events: the next one opens a new window.
195
+ */
196
+ private async defer(start: PlannedStart, message: EventMessage): Promise<void> {
197
+ const { version, nodeId, config, input } = start;
198
+ const now = this.engine.now();
199
+ const dueAt = new Date(now.getTime() + (config.debounce === undefined ? 0 : durationMs(config.debounce.window) ?? 0) + (config.delay === undefined ? 0 : durationMs(config.delay) ?? 0));
200
+ const payload = { message, input };
201
+ const dedupeKey = config.debounce === undefined ? null : debounceKey(version.key, nodeId, message, config.debounce.key, now);
202
+ const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "eventStart" as const, versionId: version.id, nodeId, partition: message.partition, dueAt, payload, dedupeKey, createdAt: now });
203
+ if (dedupeKey === null) {
204
+ await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
205
+ return;
206
+ }
207
+ await this.db.transactionScope(async () => {
208
+ await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext({0}))", dedupeKey);
209
+ const merged = await this.db.jobs.asNoTracking().where((j) => j.dedupeKey.eq(dedupeKey).and(j.lockedUntil.isNull())).executeUpdate({ payload, versionId: version.id });
210
+ if (merged.affectedRows === 0) await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
211
+ });
212
+ }
213
+ }
214
+
215
+ function eventStartConfig(node: ProcessNode): EventStartConfig {
216
+ return (node.config ?? {}) as unknown as EventStartConfig;
217
+ }
218
+
219
+ /** What the expressions of an `eventStart` read: there is no instance yet. */
220
+ function eventData(message: EventMessage, now: Date): InstanceData {
221
+ return {
222
+ input: {},
223
+ vars: {},
224
+ nodes: {},
225
+ event: message.payload,
226
+ trigger: { via: "event", at: now.toISOString(), actor: message.actor, eventKey: message.eventKey, ...(message.cause === null ? {} : { cause: message.cause }) },
227
+ now: now.toISOString(),
228
+ };
229
+ }
230
+
231
+ function debounceKey(processKey: string, nodeId: string, message: EventMessage, key: Expr, now: Date): string {
232
+ const value = evaluateExpression(key, eventData(message, now)) ?? null;
233
+ return createHash("sha256").update(canonicalJson([processKey, nodeId, message.partition, value])).digest("hex");
234
+ }
@@ -3,12 +3,12 @@ import { WorkflowError } from "../../contracts/WorkflowError";
3
3
  import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
4
4
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
5
5
  import type { WfJob } from "../model/WfJob.model";
6
- import type { WorkflowEngine } from "./WorkflowEngine";
6
+ import { queuesSchedule, type WorkflowEngine } from "./WorkflowEngine";
7
7
 
8
8
  /** How many waiting instances of a process one pass looks at. */
9
9
  const RELEASE_BATCH = 1000;
10
10
 
11
- /** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `errorHandler`. */
11
+ /** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `overlap: "queue"`, `errorHandler`. */
12
12
  export class RunLimits {
13
13
  constructor(
14
14
  private readonly db: RuntimeDbContext,
@@ -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)));
@@ -47,9 +47,11 @@ export class RunLimits {
47
47
  await this.engine.lockProcess(processKey);
48
48
  let running = await this.engine.runningOf(processKey);
49
49
  for (const row of waiting) {
50
- const { maxConcurrent, sameKey } = graphs.get(row.id)!.document.settings ?? {};
50
+ const { document } = graphs.get(row.id)!;
51
+ const { maxConcurrent, sameKey } = document.settings ?? {};
51
52
  if (maxConcurrent !== undefined && running >= maxConcurrent) continue;
52
53
  if (sameKey === "queue" && row.businessKey !== null && await this.keyTaken(processKey, row.businessKey, row.id)) continue;
54
+ if (row.startedVia === "schedule" && queuesSchedule(document) && await this.scheduleTaken(processKey, row.partition, row.id)) continue;
53
55
  const released = await this.engine.inStep(row.id, async (step) => {
54
56
  if (step.instance.status !== "pending") return false;
55
57
  await this.engine.release(step);
@@ -63,7 +65,15 @@ export class RunLimits {
63
65
  /** Whether an instance older than this one still works on the key: it goes first. */
64
66
  private async keyTaken(processKey: string, businessKey: string, id: string): Promise<boolean> {
65
67
  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)))
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
+ .count();
70
+ return same > 0;
71
+ }
72
+
73
+ /** Whether a scheduled run of the partition goes on, or waits since before this one. */
74
+ private async scheduleTaken(processKey: string, partition: string | null, id: string): Promise<boolean> {
75
+ const same = await this.db.instances.asNoTracking()
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)))))
67
77
  .count();
68
78
  return same > 0;
69
79
  }
@@ -103,6 +113,7 @@ export class RunLimits {
103
113
  await this.engine.inStep(failed.id, async (step) => {
104
114
  await this.dropJob(job);
105
115
  const { id } = await this.engine.begin(graph, start[0], {
116
+ test: failed.test,
106
117
  input: value,
107
118
  partition: failed.partition,
108
119
  businessKey: null,
@@ -0,0 +1,13 @@
1
+ import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
2
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
3
+
4
+ /** Unfinished instances of a process: they stop it from being deleted. */
5
+ export class RuntimeProcessUsage extends WorkflowProcessUsage {
6
+ constructor(private readonly db: RuntimeDbContext) {
7
+ super();
8
+ }
9
+
10
+ async liveInstances(processKey: string): Promise<number> {
11
+ return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.in(["pending", "running", "failed"]))).count();
12
+ }
13
+ }