bazis 0.134.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.
- package/core/access/contracts/References.ts +2 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/tools/orm/OrmEntity.references.ts +11 -7
- 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 +16 -2
- 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/workflow/index.ts +1 -0
- package/library/workflow/references.ts +106 -0
- package/library/workflow/validateProcess.ts +1 -0
- package/package.json +1 -1
|
@@ -25,6 +25,8 @@ export interface ReferenceItem {
|
|
|
25
25
|
export interface ReferenceContext {
|
|
26
26
|
readonly scope: Scope;
|
|
27
27
|
readonly language?: string;
|
|
28
|
+
/** The area the ids are looked for in: the fields of one table. A kind without areas ignores it. */
|
|
29
|
+
readonly within?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface ReferencePage {
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.135.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -46,7 +46,7 @@ export class OrmEntityReferences extends References {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Reference kind `orm.field`: a field open to processes, `Order.status`. */
|
|
49
|
+
/** Reference kind `orm.field`: a field open to processes, `Order.status`, or `status` within `Order`. */
|
|
50
50
|
export class OrmFieldReferences extends References {
|
|
51
51
|
constructor(private readonly services: ServiceProvider) {
|
|
52
52
|
super();
|
|
@@ -56,16 +56,20 @@ export class OrmFieldReferences extends References {
|
|
|
56
56
|
return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
|
|
59
|
+
async exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>> {
|
|
60
60
|
const known = new Set(this.ids());
|
|
61
|
-
|
|
61
|
+
const { within } = context;
|
|
62
|
+
return new Set(ids.filter((id) => known.has(within === undefined ? id : `${within}.${id}`)));
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
|
|
65
|
-
return [...await this.exists(ids)].map((id) => ({ id, title: id }));
|
|
65
|
+
async describe(ids: readonly string[], context: ReferenceContext): Promise<readonly ReferenceItem[]> {
|
|
66
|
+
return [...await this.exists(ids, context)].map((id) => ({ id, title: id }));
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
async search(text: string,
|
|
69
|
-
|
|
69
|
+
async search(text: string, context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
|
|
70
|
+
const { within } = context;
|
|
71
|
+
if (within === undefined) return page(this.ids(), text, range);
|
|
72
|
+
const prefix = `${within}.`;
|
|
73
|
+
return page(this.ids().filter((id) => id.startsWith(prefix)).map((id) => id.slice(prefix.length)), text, range);
|
|
70
74
|
}
|
|
71
75
|
}
|
|
@@ -6,6 +6,7 @@ import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/Work
|
|
|
6
6
|
import { DefaultWorkflowPartitions, WorkflowPartitions } from "../contracts/WorkflowPartitions";
|
|
7
7
|
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
8
8
|
import { WorkflowLanguages } from "../internal/WorkflowLanguages";
|
|
9
|
+
import { WorkflowReferences } from "../internal/WorkflowReferences";
|
|
9
10
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
10
11
|
import { definitionsPermissions } from "./access/Definitions.permissions";
|
|
11
12
|
import { FolderReferences } from "./access/Folder.references";
|
|
@@ -42,6 +43,7 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
42
43
|
singleton(WorkflowExtensions, DefaultWorkflowExtensions),
|
|
43
44
|
singleton(WorkflowPartitions, DefaultWorkflowPartitions),
|
|
44
45
|
scoped(WorkflowEvents),
|
|
46
|
+
scoped(WorkflowReferences),
|
|
45
47
|
scoped(CatalogImport),
|
|
46
48
|
scoped(IWorkflowDefinitions, WorkflowDefinitions),
|
|
47
49
|
scoped(ProcessReferences),
|
|
@@ -51,6 +53,6 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
51
53
|
singleton(HOSTED_SERVICE, CodeProcessPublisher),
|
|
52
54
|
],
|
|
53
55
|
controllers: [ProcessesController],
|
|
54
|
-
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
|
|
56
|
+
exports: [IWorkflowDefinitions, WorkflowReferences, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
|
|
55
57
|
})
|
|
56
58
|
export class WorkflowDefinitionsModule {}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { markConfigurationError, type ServiceProvider } from "../../../di";
|
|
2
2
|
import type { HostedService } from "../../../di/extensions/hosted-service";
|
|
3
3
|
import { LOGGER } from "../../../kernel";
|
|
4
|
+
import { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
4
5
|
import { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
6
|
import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
|
|
6
7
|
import { CatalogImport } from "./CatalogImport";
|
|
@@ -12,7 +13,9 @@ import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
|
|
|
12
13
|
* (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
|
|
13
14
|
* (WORKFLOW-001 §14). A document with an unknown Tool or process, or an `extensions`
|
|
14
15
|
* namespace nobody checks, stops the start with every finding of every process; the same
|
|
15
|
-
* document again gives no new version.
|
|
16
|
+
* document again gives no new version. So does a reference kind no module owns, in a Tool
|
|
17
|
+
* or a document. A value that does not exist only warns: it may be data that is yet to come,
|
|
18
|
+
* and the step then fails with `ref_not_found`.
|
|
16
19
|
*/
|
|
17
20
|
export class CodeProcessPublisher implements HostedService {
|
|
18
21
|
readonly diagnosticName = "workflow:processes";
|
|
@@ -25,6 +28,11 @@ export class CodeProcessPublisher implements HostedService {
|
|
|
25
28
|
try {
|
|
26
29
|
const imported = await scope.resolve(CatalogImport).run();
|
|
27
30
|
if (imported > 0) logger?.info("workflow catalog brought over from the old store", { processes: imported });
|
|
31
|
+
const references = scope.resolve(WorkflowReferences);
|
|
32
|
+
const unowned = references.unownedToolKinds();
|
|
33
|
+
if (unowned.length > 0) {
|
|
34
|
+
throw markConfigurationError(new WorkflowSetupError(`Tools refer to kinds no module owns:\n${unowned.map(({ tool, kind }) => ` - ${tool}: ${kind}${kind.startsWith("orm.") ? " (import StandardToolsModule from bazis/core/tools)" : " (declare it in @Module({ references }))"}`).join("\n")}`));
|
|
35
|
+
}
|
|
28
36
|
const entries = this.services.resolve(CodeProcessCatalog).list();
|
|
29
37
|
if (entries.length === 0) return;
|
|
30
38
|
const tools = this.services.resolve(WorkflowTools);
|
|
@@ -35,6 +43,10 @@ export class CodeProcessPublisher implements HostedService {
|
|
|
35
43
|
const found = [...definitionProblems(process, (name) => tools.has(name))];
|
|
36
44
|
for (const issue of await definitions.validate(process.document, { processes })) {
|
|
37
45
|
if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid" || issue.code === "unknown_process")) found.push(`${issue.message} (${issue.code})`);
|
|
46
|
+
else if (issue.code === "broken_ref") {
|
|
47
|
+
if (issue.message.startsWith("no module owns")) found.push(`${issue.message} (${issue.code})`);
|
|
48
|
+
else logger?.warn("workflow process refers to what does not exist", { process: process.key, node: issue.node, message: issue.message });
|
|
49
|
+
}
|
|
38
50
|
}
|
|
39
51
|
if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
|
|
40
52
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expandModuleMetadata, type BazisModuleRef } from "../../../di";
|
|
2
|
-
import { toolMetadataOf } from "../../../agent";
|
|
3
|
-
import { validateProcess } from "../../../../library/workflow";
|
|
2
|
+
import { describeTool, toolMetadataOf } from "../../../agent";
|
|
3
|
+
import { documentReferences, schemaReferenceKinds, validateProcess } from "../../../../library/workflow";
|
|
4
4
|
import { WORKFLOW_CALLER } from "../../internal/WorkflowTools";
|
|
5
5
|
import { WorkflowDefinitionsModule } from "../WorkflowDefinitions.module";
|
|
6
6
|
import { definitionProblems, type OwnedCodeProcess } from "./codeProcesses";
|
|
@@ -28,16 +28,23 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
|
|
|
28
28
|
const found: OwnedCodeProcess[] = [];
|
|
29
29
|
const errors: string[] = [];
|
|
30
30
|
const tools = new Set<string>();
|
|
31
|
+
const schemas = new Map<string, unknown>();
|
|
32
|
+
const kinds = new Set<string>();
|
|
31
33
|
const visited = new Set<BazisModuleRef>();
|
|
32
34
|
const visit = (moduleRef: BazisModuleRef): void => {
|
|
33
35
|
if (visited.has(moduleRef)) return;
|
|
34
36
|
visited.add(moduleRef);
|
|
35
37
|
const module = typeof moduleRef === "function" ? moduleRef.name || "<anonymous module>" : "<module>";
|
|
36
|
-
const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown };
|
|
38
|
+
const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown; readonly references?: unknown };
|
|
39
|
+
if (typeof fields.references === "object" && fields.references !== null) for (const kind of Object.keys(fields.references)) kinds.add(kind);
|
|
37
40
|
if (Array.isArray(fields.tools)) {
|
|
38
41
|
for (const target of fields.tools) {
|
|
39
42
|
const metadata = typeof target === "function" ? toolMetadataOf(target as new () => object) : undefined;
|
|
40
|
-
if (metadata?.callers.includes(WORKFLOW_CALLER))
|
|
43
|
+
if (metadata?.callers.includes(WORKFLOW_CALLER)) {
|
|
44
|
+
tools.add(metadata.name);
|
|
45
|
+
const input = describeTool({ target: target as new () => object, metadata }).input;
|
|
46
|
+
if (input?.kind === "json-schema") schemas.set(metadata.name, input.schema);
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
if (fields.processes !== undefined) {
|
|
@@ -54,6 +61,10 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
|
|
|
54
61
|
if (found.length > 0 && !visited.has(WorkflowDefinitionsModule)) {
|
|
55
62
|
errors.push(`${found[0]!.module} declares processes, but the application does not import WorkflowModule (bazis/core/workflow).`);
|
|
56
63
|
}
|
|
64
|
+
// The values live in the database: `check` knows only whether someone owns their kind.
|
|
65
|
+
for (const [tool, schema] of schemas) {
|
|
66
|
+
for (const kind of schemaReferenceKinds(schema)) if (!kinds.has(kind)) errors.push(`Tool ${tool} refers to the kind ${kind}, and no module owns it.`);
|
|
67
|
+
}
|
|
57
68
|
const owners = new Map<string, string>();
|
|
58
69
|
for (const { process, module } of found) {
|
|
59
70
|
const other = owners.get(process.key);
|
|
@@ -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]],
|
|
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,8 @@
|
|
|
1
1
|
import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
|
|
2
|
-
import { durationMs, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
|
|
2
|
+
import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
|
|
3
3
|
import type { WorkflowToolMetadata } from "../../contracts/types";
|
|
4
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
5
|
+
import type { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
4
6
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
7
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
8
|
import type { WfJob } from "../model/WfJob.model";
|
|
@@ -23,6 +25,8 @@ export class ToolStep {
|
|
|
23
25
|
private readonly db: RuntimeDbContext,
|
|
24
26
|
private readonly engine: WorkflowEngine,
|
|
25
27
|
private readonly tools: WorkflowTools,
|
|
28
|
+
private readonly references: WorkflowReferences,
|
|
29
|
+
private readonly languages: WorkflowLanguages,
|
|
26
30
|
) {}
|
|
27
31
|
|
|
28
32
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
@@ -42,13 +46,22 @@ export class ToolStep {
|
|
|
42
46
|
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
|
|
43
47
|
}
|
|
44
48
|
const runner = this.tools.bound;
|
|
49
|
+
const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
|
|
50
|
+
const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
|
|
45
51
|
const result: AgentToolResult = runner === undefined
|
|
46
52
|
? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
|
|
53
|
+
: missing.length > 0
|
|
54
|
+
// A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
|
|
55
|
+
? { 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
|
|
56
|
+
: trial === "unsupported"
|
|
57
|
+
// A trial never calls a Tool that cannot run without changing anything.
|
|
58
|
+
? { callId: token.id, name, status: "success", output: { dryRun: true }, metadata: {} } as AgentToolResult
|
|
47
59
|
: await runner.run(name, input, {
|
|
48
60
|
idempotencyKey: token.id,
|
|
49
61
|
actor: runActorOf(graph.document, instance),
|
|
50
|
-
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test:
|
|
62
|
+
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: instance.test } satisfies WorkflowToolMetadata,
|
|
51
63
|
signal,
|
|
64
|
+
...(trial === "supported" ? { dryRun: true } : {}),
|
|
52
65
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
53
66
|
});
|
|
54
67
|
|
|
@@ -72,6 +85,7 @@ export class ToolStep {
|
|
|
72
85
|
status: result.status,
|
|
73
86
|
attempt: job.attempts,
|
|
74
87
|
durationMs: result.durationMs ?? null,
|
|
88
|
+
...(trial === undefined ? {} : { dryRun: trial === "supported" ? "called" : "not called" }),
|
|
75
89
|
...payloads,
|
|
76
90
|
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
|
|
77
91
|
...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
|
|
@@ -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") {
|
|
@@ -16,6 +16,11 @@ export interface StartInstance extends WorkflowAuditOptions {
|
|
|
16
16
|
readonly actor: WorkflowActor;
|
|
17
17
|
/** The language of the request, for the application's validator; `workflow.language` by default. */
|
|
18
18
|
readonly language?: string;
|
|
19
|
+
/**
|
|
20
|
+
* A trial run (WORKFLOW-002 §5.4): Tools change nothing, the application hears
|
|
21
|
+
* nothing, tasks go only to who started it. It lives for a day.
|
|
22
|
+
*/
|
|
23
|
+
readonly test?: boolean;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/** One element of a mass start. */
|
|
@@ -63,8 +68,14 @@ export interface InstanceSummary {
|
|
|
63
68
|
/** The instance whose `callProcess` started this one. */
|
|
64
69
|
readonly parentInstanceId: string | null;
|
|
65
70
|
readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
|
|
71
|
+
/** The step it failed on. */
|
|
72
|
+
readonly failedNode: string | null;
|
|
73
|
+
/** A trial run. */
|
|
74
|
+
readonly test: boolean;
|
|
66
75
|
readonly createdAt: Date;
|
|
67
76
|
readonly endedAt: Date | null;
|
|
77
|
+
/** How long it took by the engine's clock; `null` until it ends. */
|
|
78
|
+
readonly durationMs: number | null;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
export interface TokenDetails {
|
|
@@ -141,6 +152,8 @@ export interface InstanceListQuery extends WorkflowPaging {
|
|
|
141
152
|
readonly businessKey?: string;
|
|
142
153
|
readonly startedById?: string;
|
|
143
154
|
readonly parentInstanceId?: string;
|
|
155
|
+
/** Trial runs are hidden unless asked for: `true` gives only them. Children of a named parent are shown as they are. */
|
|
156
|
+
readonly test?: boolean;
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
/**
|
|
@@ -55,7 +55,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
55
55
|
if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
|
|
56
56
|
const { value, issues } = await this.validator().validate(inputSpecOf(version.document), options.input, { purpose: "input", processKey, partition, language: options.language ?? this.languages.default, actor: options.actor });
|
|
57
57
|
if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
|
|
58
|
-
const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
|
|
58
|
+
const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null, ...(options.test === true ? { test: true } : {}) });
|
|
59
59
|
if (skipped) throw new WorkflowError("conflict", `Instance ${id} with this businessKey is not finished`);
|
|
60
60
|
return await this.instance(id);
|
|
61
61
|
}
|
|
@@ -160,6 +160,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
160
160
|
if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
|
|
161
161
|
if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
|
|
162
162
|
if (query.parentInstanceId !== undefined) source = source.where((i) => i.parentInstanceId.eq(query.parentInstanceId!));
|
|
163
|
+
if (query.test !== undefined || query.parentInstanceId === undefined) source = source.where((i) => i.test.eq(query.test === true));
|
|
163
164
|
const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
|
|
164
165
|
return { items: items.map(summaryOf), total };
|
|
165
166
|
}
|
|
@@ -231,11 +232,20 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
231
232
|
startedBy: { kind: row.startedByKind, id: row.startedById },
|
|
232
233
|
parentInstanceId: row.parentInstanceId,
|
|
233
234
|
error: row.error,
|
|
235
|
+
failedNode: row.error === null || row.error.node === "" ? null : row.error.node,
|
|
236
|
+
test: row.test,
|
|
234
237
|
createdAt: row.createdAt,
|
|
235
238
|
endedAt: row.endedAt,
|
|
239
|
+
durationMs: durationOf(row),
|
|
236
240
|
};
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
/** From the start by the engine's clock: `createdAt` is the clock of the database. */
|
|
244
|
+
function durationOf(row: WfInstance): number | null {
|
|
245
|
+
const startedAt = typeof row.trigger?.at === "string" ? Date.parse(row.trigger.at) : Number.NaN;
|
|
246
|
+
return row.endedAt === null || Number.isNaN(startedAt) ? null : row.endedAt.getTime() - startedAt;
|
|
247
|
+
}
|
|
248
|
+
|
|
239
249
|
function tokenOf(row: WfToken) {
|
|
240
250
|
return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, parentTokenId: row.parentTokenId, iteration: row.iteration, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
|
|
241
251
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OnEvent } from "../../../kernel";
|
|
2
|
-
import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
2
|
+
import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
3
3
|
import type { TasksDbContext } from "../model/Tasks.dbContext";
|
|
4
4
|
import { WfTask } from "../model/WfTask.model";
|
|
5
5
|
|
|
@@ -10,6 +10,16 @@ export class TaskEvents {
|
|
|
10
10
|
/** A step started waiting for a person: its task. Repeated events of one token make one task. */
|
|
11
11
|
@OnEvent(WORKFLOW_TASK_REQUESTED)
|
|
12
12
|
async requested(event: WorkflowTaskRequested): Promise<void> {
|
|
13
|
+
await this.add(event, false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The task of a trial run: it goes to who started it, and is marked. */
|
|
17
|
+
@OnEvent(WORKFLOW_TRIAL_TASK_REQUESTED)
|
|
18
|
+
async trialRequested(event: WorkflowTaskRequested): Promise<void> {
|
|
19
|
+
await this.add(event, true);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private async add(event: WorkflowTaskRequested, test: boolean): Promise<void> {
|
|
13
23
|
const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
|
|
14
24
|
// Past the change tracker: the scope may go on to update tasks directly, which a context holding one would refuse.
|
|
15
25
|
await this.db.tasks.insertIfAbsent(Object.assign(new WfTask(), {
|
|
@@ -18,6 +28,7 @@ export class TaskEvents {
|
|
|
18
28
|
processKey: event.processKey,
|
|
19
29
|
nodeId: event.nodeId,
|
|
20
30
|
partition: event.partition,
|
|
31
|
+
test,
|
|
21
32
|
title: event.title,
|
|
22
33
|
assignee,
|
|
23
34
|
form: event.form,
|
|
@@ -32,6 +43,11 @@ export class TaskEvents {
|
|
|
32
43
|
await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(event.tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status: "cancelled" });
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
@OnEvent(WORKFLOW_TRIAL_TOKEN_CLOSED)
|
|
47
|
+
async trialClosed(event: WorkflowTokenClosed): Promise<void> {
|
|
48
|
+
await this.closed(event);
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
/** Retention deleted the instances: their tasks go too. */
|
|
36
52
|
@OnEvent(WORKFLOW_INSTANCES_DELETED)
|
|
37
53
|
async deleted(event: WorkflowInstancesDeleted): Promise<void> {
|
|
@@ -11,6 +11,8 @@ export interface TaskDetails {
|
|
|
11
11
|
readonly nodeId: string;
|
|
12
12
|
readonly partition: string | null;
|
|
13
13
|
readonly title: LocalizedText;
|
|
14
|
+
/** A task of a trial run. */
|
|
15
|
+
readonly test: boolean;
|
|
14
16
|
readonly assignee: string;
|
|
15
17
|
readonly form: FormSpec | null;
|
|
16
18
|
readonly dueAt: Date | null;
|
|
@@ -100,7 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
100
100
|
completedAt: this.clock.now(),
|
|
101
101
|
output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
|
|
102
102
|
});
|
|
103
|
-
await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
|
|
103
|
+
if (!task.test) await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
|
|
104
104
|
return await this.get(task.id);
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -157,6 +157,7 @@ function detailsOf(row: WfTask): TaskDetails {
|
|
|
157
157
|
nodeId: row.nodeId,
|
|
158
158
|
partition: row.partition,
|
|
159
159
|
title: row.title,
|
|
160
|
+
test: row.test,
|
|
160
161
|
assignee: row.assignee,
|
|
161
162
|
form: row.form,
|
|
162
163
|
dueAt: row.dueAt,
|
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
} from "./expressions";
|
|
43
43
|
export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
|
|
44
44
|
export { durationMs } from "./duration";
|
|
45
|
+
export { documentReferences, inputReferences, schemaReferenceKinds, type DocumentReference, type InputReference } from "./references";
|
|
45
46
|
export { isTimeZone, parseCron, type CronSchedule } from "./cron";
|
|
46
47
|
export { processToMermaid } from "./mermaid";
|
|
47
48
|
export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isExpressionNode } from "./expressions";
|
|
2
|
+
import { isPath } from "./paths";
|
|
3
|
+
import type { ProcessDocument } from "./ProcessDocument";
|
|
4
|
+
|
|
5
|
+
/** One reference in a Tool input or a document: what `x-ref` and `x-ref-keys` of a schema mark. */
|
|
6
|
+
export interface InputReference {
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
/** The area it is looked for in: `x-ref-scope`. */
|
|
10
|
+
readonly within?: string;
|
|
11
|
+
/** Where in the input: `/values/status`. */
|
|
12
|
+
readonly path: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A reference of a document, and whose it is. */
|
|
16
|
+
export interface DocumentReference extends InputReference {
|
|
17
|
+
readonly node?: string;
|
|
18
|
+
readonly variable?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
function isObject(value: unknown): value is Json {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a value is known only when the step runs. */
|
|
28
|
+
function isComputed(value: unknown): boolean {
|
|
29
|
+
return isPath(value) || isExpressionNode(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isId(value: unknown): value is string | number {
|
|
33
|
+
return (typeof value === "string" && value !== "") || typeof value === "number";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The references in a Tool input by the JSON Schema of the input. A value computed
|
|
38
|
+
* when the step runs is left out: it is checked then. `x-ref-scope` beginning
|
|
39
|
+
* with `/` is a JSON Pointer to another field of the input; otherwise it is the area
|
|
40
|
+
* itself. A reference whose area is computed is left out too.
|
|
41
|
+
*/
|
|
42
|
+
export function inputReferences(schema: unknown, input: unknown): InputReference[] {
|
|
43
|
+
const out: InputReference[] = [];
|
|
44
|
+
const within = (source: Json): { readonly known: boolean; readonly value?: string } => {
|
|
45
|
+
const scope = source["x-ref-scope"];
|
|
46
|
+
if (typeof scope !== "string") return { known: true };
|
|
47
|
+
if (!scope.startsWith("/")) return { known: true, value: scope };
|
|
48
|
+
let current: unknown = input;
|
|
49
|
+
for (const key of scope.slice(1).split("/")) {
|
|
50
|
+
current = isObject(current) && Object.hasOwn(current, key) ? current[key] : undefined;
|
|
51
|
+
}
|
|
52
|
+
return isId(current) && !isComputed(current) ? { known: true, value: String(current) } : { known: false };
|
|
53
|
+
};
|
|
54
|
+
const walk = (source: unknown, value: unknown, path: string): void => {
|
|
55
|
+
if (!isObject(source) || value === undefined || value === null || isComputed(value)) return;
|
|
56
|
+
const area = within(source);
|
|
57
|
+
const add = (kind: string, id: string, at: string) => out.push({ kind, id, ...(area.value === undefined ? {} : { within: area.value }), path: at });
|
|
58
|
+
if (typeof source["x-ref"] === "string" && isId(value) && area.known) add(source["x-ref"], String(value), path);
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
value.forEach((item, index) => walk(source.items, item, `${path}/${index}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isObject(value)) return;
|
|
64
|
+
if (typeof source["x-ref-keys"] === "string" && area.known) for (const key of Object.keys(value)) add(source["x-ref-keys"], key, `${path}/${key}`);
|
|
65
|
+
if (isObject(source.properties)) {
|
|
66
|
+
for (const [key, item] of Object.entries(source.properties)) if (Object.hasOwn(value, key)) walk(item, value[key], `${path}/${key}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(schema, input, "");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The kinds a schema refers to, whatever the values: each must have an owner. */
|
|
74
|
+
export function schemaReferenceKinds(schema: unknown): string[] {
|
|
75
|
+
const kinds = new Set<string>();
|
|
76
|
+
const walk = (source: unknown): void => {
|
|
77
|
+
if (!isObject(source)) return;
|
|
78
|
+
for (const key of ["x-ref", "x-ref-keys"]) if (typeof source[key] === "string") kinds.add(source[key]);
|
|
79
|
+
if (isObject(source.properties)) Object.values(source.properties).forEach(walk);
|
|
80
|
+
walk(source.items);
|
|
81
|
+
};
|
|
82
|
+
walk(schema);
|
|
83
|
+
return [...kinds];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The references a document holds as literals (WORKFLOW-002 §6.3): in the inputs of
|
|
88
|
+
* its Tools, by their schemas; the defaults of variables of type `ref`; the processes
|
|
89
|
+
* it calls. `inputSchemaOf` gives the input schema of a Tool, if it is known.
|
|
90
|
+
*/
|
|
91
|
+
export function documentReferences(document: ProcessDocument, inputSchemaOf: (tool: string) => unknown): DocumentReference[] {
|
|
92
|
+
const out: DocumentReference[] = [];
|
|
93
|
+
for (const variable of document.variables ?? []) {
|
|
94
|
+
if (variable.type === "ref" && typeof variable.ref === "string" && isId(variable.default)) {
|
|
95
|
+
out.push({ kind: variable.ref, id: String(variable.default), path: "/default", variable: variable.name });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const handler = document.settings?.errorHandler;
|
|
99
|
+
if (handler !== undefined) out.push({ kind: "workflow.process", id: handler, path: "/settings/errorHandler" });
|
|
100
|
+
for (const [node, { kind, config }] of Object.entries(document.nodes)) {
|
|
101
|
+
if (kind === "callProcess" && typeof config?.process === "string") out.push({ kind: "workflow.process", id: config.process, path: "/process", node });
|
|
102
|
+
if (kind !== "serviceTask" || typeof config?.tool !== "string") continue;
|
|
103
|
+
for (const reference of inputReferences(inputSchemaOf(config.tool), config.input)) out.push({ ...reference, node });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.135.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|