@oxygen-agent/cli 1.696.2 → 1.717.1
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/README.md +1 -1
- package/dist/help.js +2 -0
- package/dist/http-client.js +11 -1
- package/dist/index.js +524 -86
- package/dist/transcript.js +2 -2
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/billing.js +7 -6
- package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +53 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.js +175 -0
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +36 -1
- package/node_modules/@oxygen/shared/dist/file-import.js +80 -1
- package/node_modules/@oxygen/shared/dist/image-sniff.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/image-sniff.js +75 -0
- package/node_modules/@oxygen/shared/dist/import-limits.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/import-limits.js +30 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -4
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +18 -10
- package/node_modules/@oxygen/shared/dist/member-columns.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/member-columns.js +111 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +1 -1
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +65 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +100 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/plan-limits.js +12 -2
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +79 -11
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +679 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +80 -1
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +8 -1
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +72 -3
- package/node_modules/@oxygen/workflows/dist/graph/types.js +12 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +16 -0
- package/node_modules/@oxygen/workflows/dist/index.js +45 -3
- package/node_modules/@oxygen/workflows/dist/portable.d.ts +43 -0
- package/node_modules/@oxygen/workflows/dist/portable.js +319 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +18 -4
- package/package.json +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PORTABLE_WORKFLOW_DEFINITION_COMPILER_VERSION, PORTABLE_WORKFLOW_DEFINITION_VERSION, isPortableWorkflowDefinitionV1, isWorkflowGraphManifest, } from "./graph/types.js";
|
|
3
|
+
import { lintWorkflowGraphManifest } from "./graph/lint.js";
|
|
4
|
+
export class PortableWorkflowDefinitionValidationError extends Error {
|
|
5
|
+
issues;
|
|
6
|
+
constructor(issues) {
|
|
7
|
+
const first = issues[0];
|
|
8
|
+
super(first ? `${first.code}: ${first.message}` : "Invalid portable workflow definition.");
|
|
9
|
+
this.name = "PortableWorkflowDefinitionValidationError";
|
|
10
|
+
this.issues = issues;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const TRIGGER_AUTHORITY_FIELDS = new Set([
|
|
14
|
+
"preapproved_labels",
|
|
15
|
+
"preApprovedLabels",
|
|
16
|
+
"preapproved_by_user_id",
|
|
17
|
+
"standing_authorization",
|
|
18
|
+
"standing_authorization_policy",
|
|
19
|
+
]);
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function cloneJson(value) {
|
|
24
|
+
return JSON.parse(JSON.stringify(value));
|
|
25
|
+
}
|
|
26
|
+
function isNonEmptyString(value) {
|
|
27
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
/** The graph editor and server use this same material rule. */
|
|
30
|
+
export function hashPortableWorkflowGraphManifest(manifest) {
|
|
31
|
+
const material = cloneJson(manifest);
|
|
32
|
+
delete material.source_hash;
|
|
33
|
+
delete material.created_at;
|
|
34
|
+
return createHash("sha256").update(JSON.stringify(material)).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
function stripTriggerAuthority(trigger) {
|
|
37
|
+
if (!trigger)
|
|
38
|
+
return undefined;
|
|
39
|
+
const safe = cloneJson(trigger);
|
|
40
|
+
safe.status = "disabled";
|
|
41
|
+
if (safe.type !== "cron" || !isRecord(safe.metadata))
|
|
42
|
+
return safe;
|
|
43
|
+
const metadata = {};
|
|
44
|
+
for (const [key, value] of Object.entries(safe.metadata)) {
|
|
45
|
+
if (!TRIGGER_AUTHORITY_FIELDS.has(key))
|
|
46
|
+
metadata[key] = value;
|
|
47
|
+
}
|
|
48
|
+
return { ...safe, metadata };
|
|
49
|
+
}
|
|
50
|
+
function portableNodeIssue(node, index) {
|
|
51
|
+
if (node.kind === "code" && node.recipe) {
|
|
52
|
+
return {
|
|
53
|
+
path: `$.graph.nodes.${index}.recipe`,
|
|
54
|
+
code: "recipe_bundle_not_portable",
|
|
55
|
+
message: "A durable recipe bundle is one opaque compatibility node, not an editable workflow definition. Rebuild it as explicit graph nodes before exporting.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (node.kind === "tool" && node.payload_source !== undefined) {
|
|
59
|
+
return {
|
|
60
|
+
path: `$.graph.nodes.${index}.payload_source`,
|
|
61
|
+
code: "legacy_tool_payload_not_portable",
|
|
62
|
+
message: "This tool still computes its payload with legacy JavaScript. Replace it with schema-mapped params in the editor before exporting.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if ("legacy_context" in node && node.legacy_context !== undefined) {
|
|
66
|
+
return {
|
|
67
|
+
path: `$.graph.nodes.${index}.legacy_context`,
|
|
68
|
+
code: "legacy_context_not_portable",
|
|
69
|
+
message: "Legacy execution context cannot be represented as a portable editable graph.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (node.kind === "workflow") {
|
|
73
|
+
return {
|
|
74
|
+
path: `$.graph.nodes.${index}`,
|
|
75
|
+
code: "workflow_node_not_portable",
|
|
76
|
+
message: "Child-workflow nodes are not executable yet and cannot be exported as a portable definition.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function valueRefUsesWorkspaceAsset(ref) {
|
|
82
|
+
return ref.type === "context_asset";
|
|
83
|
+
}
|
|
84
|
+
function conditionUsesWorkspaceAsset(condition) {
|
|
85
|
+
if (condition.type === "group") {
|
|
86
|
+
return condition.children.some(conditionUsesWorkspaceAsset);
|
|
87
|
+
}
|
|
88
|
+
return valueRefUsesWorkspaceAsset(condition.left)
|
|
89
|
+
|| (condition.right !== undefined && valueRefUsesWorkspaceAsset(condition.right));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Knowledge asset ids are workspace-local bindings, just like connection UUIDs.
|
|
93
|
+
* Keep this exhaustive over the graph union so a new binding-bearing node cannot
|
|
94
|
+
* accidentally become "portable" without an explicit rebinding contract.
|
|
95
|
+
*/
|
|
96
|
+
function nodeUsesWorkspaceAsset(node) {
|
|
97
|
+
switch (node.kind) {
|
|
98
|
+
case "tool":
|
|
99
|
+
return Object.values(node.params).some(valueRefUsesWorkspaceAsset);
|
|
100
|
+
case "filter":
|
|
101
|
+
return conditionUsesWorkspaceAsset(node.when);
|
|
102
|
+
case "switch":
|
|
103
|
+
return node.cases.some((entry) => conditionUsesWorkspaceAsset(entry.when));
|
|
104
|
+
case "loop":
|
|
105
|
+
return valueRefUsesWorkspaceAsset(node.over);
|
|
106
|
+
case "set":
|
|
107
|
+
return node.fields.some((field) => valueRefUsesWorkspaceAsset(field.value));
|
|
108
|
+
case "wait":
|
|
109
|
+
return node.until !== undefined && valueRefUsesWorkspaceAsset(node.until);
|
|
110
|
+
case "approval":
|
|
111
|
+
return (node.reason !== undefined && valueRefUsesWorkspaceAsset(node.reason))
|
|
112
|
+
|| Object.values(node.request ?? {}).some(valueRefUsesWorkspaceAsset);
|
|
113
|
+
case "code":
|
|
114
|
+
return Object.values(node.configuration ?? {}).some(valueRefUsesWorkspaceAsset);
|
|
115
|
+
case "workflow":
|
|
116
|
+
return Object.values(node.input).some(valueRefUsesWorkspaceAsset);
|
|
117
|
+
case "trigger":
|
|
118
|
+
case "merge":
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function lintPortableWorkflowGraph(manifest) {
|
|
123
|
+
const issues = [];
|
|
124
|
+
if (!isWorkflowGraphManifest(manifest)) {
|
|
125
|
+
issues.push({
|
|
126
|
+
path: "$.graph",
|
|
127
|
+
code: "workflow_graph_required",
|
|
128
|
+
message: "Portable workflow definitions require an oxygen-workflows-v2 graph. Legacy workflow and recipe revisions remain runnable but must be rebuilt as graph nodes before export.",
|
|
129
|
+
});
|
|
130
|
+
return { ok: false, issues };
|
|
131
|
+
}
|
|
132
|
+
manifest.nodes.forEach((node, index) => {
|
|
133
|
+
const issue = portableNodeIssue(node, index);
|
|
134
|
+
if (issue)
|
|
135
|
+
issues.push(issue);
|
|
136
|
+
if (nodeUsesWorkspaceAsset(node)) {
|
|
137
|
+
issues.push({
|
|
138
|
+
path: `$.graph.nodes.${index}`,
|
|
139
|
+
code: "workspace_asset_not_portable",
|
|
140
|
+
message: "This node references a workspace-local Knowledge asset. Replace it with portable input or profile context before exporting.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return { ok: issues.length === 0, issues };
|
|
145
|
+
}
|
|
146
|
+
export function assertPortableWorkflowGraph(manifest) {
|
|
147
|
+
const result = lintPortableWorkflowGraph(manifest);
|
|
148
|
+
if (!result.ok)
|
|
149
|
+
throw new PortableWorkflowDefinitionValidationError(result.issues);
|
|
150
|
+
}
|
|
151
|
+
export function lintPortableWorkflowDefinition(value) {
|
|
152
|
+
const issues = [];
|
|
153
|
+
if (!isPortableWorkflowDefinitionV1(value)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
issues: [{
|
|
157
|
+
path: "$",
|
|
158
|
+
code: "invalid_workflow_definition",
|
|
159
|
+
message: "Expected an oxygen-workflow-definition envelope with definition_version 1 and an oxygen-workflows-v2 manifest.",
|
|
160
|
+
}],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
issues.push(...lintWorkflowGraphManifest(value.graph).issues.map((issue) => ({
|
|
164
|
+
...issue,
|
|
165
|
+
path: issue.path === "$" ? "$.graph" : `$.graph${issue.path.slice(1)}`,
|
|
166
|
+
})));
|
|
167
|
+
issues.push(...lintPortableWorkflowGraph(value.graph).issues);
|
|
168
|
+
const toolNodes = new Map(value.graph.nodes
|
|
169
|
+
.filter((node) => node.kind === "tool")
|
|
170
|
+
.map((node) => [node.id, node]));
|
|
171
|
+
const seenBindings = new Set();
|
|
172
|
+
const seenNodes = new Set();
|
|
173
|
+
for (const [index, requirement] of value.requirements.connections.entries()) {
|
|
174
|
+
const path = `$.requirements.connections.${index}`;
|
|
175
|
+
if (!isRecord(requirement)) {
|
|
176
|
+
issues.push({ path, code: "invalid_connection_requirement", message: "Connection requirement must be an object." });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!isNonEmptyString(requirement.binding_id)
|
|
180
|
+
|| !isNonEmptyString(requirement.integration_id)
|
|
181
|
+
|| !Array.isArray(requirement.node_ids)
|
|
182
|
+
|| requirement.node_ids.length === 0
|
|
183
|
+
|| requirement.node_ids.some((nodeId) => !isNonEmptyString(nodeId))) {
|
|
184
|
+
issues.push({ path, code: "invalid_connection_requirement", message: "Connection requirement needs binding_id, integration_id, and at least one node_id." });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (seenBindings.has(requirement.binding_id)) {
|
|
188
|
+
issues.push({ path: `${path}.binding_id`, code: "duplicate_connection_requirement", message: `Binding '${requirement.binding_id}' is duplicated.` });
|
|
189
|
+
}
|
|
190
|
+
seenBindings.add(requirement.binding_id);
|
|
191
|
+
for (const nodeId of requirement.node_ids) {
|
|
192
|
+
if (seenNodes.has(nodeId)) {
|
|
193
|
+
issues.push({ path: `${path}.node_ids`, code: "duplicate_connection_requirement", message: `Node '${nodeId}' belongs to more than one connection requirement.` });
|
|
194
|
+
}
|
|
195
|
+
seenNodes.add(nodeId);
|
|
196
|
+
if (!toolNodes.has(nodeId)) {
|
|
197
|
+
issues.push({ path: `${path}.node_ids`, code: "connection_requirement_mismatch", message: `Connection requirement points to missing or non-tool node '${nodeId}'.` });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const node of toolNodes.values()) {
|
|
202
|
+
if (node.connection_id !== undefined) {
|
|
203
|
+
issues.push({
|
|
204
|
+
path: `$.graph.nodes.${value.graph.nodes.indexOf(node)}.connection_id`,
|
|
205
|
+
code: "source_connection_id_present",
|
|
206
|
+
message: "Portable definitions must not contain organization-local connection ids.",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: issues.length === 0, issues };
|
|
211
|
+
}
|
|
212
|
+
export function assertPortableWorkflowDefinition(value) {
|
|
213
|
+
const result = lintPortableWorkflowDefinition(value);
|
|
214
|
+
if (!result.ok)
|
|
215
|
+
throw new PortableWorkflowDefinitionValidationError(result.issues);
|
|
216
|
+
}
|
|
217
|
+
export function buildPortableWorkflowDefinition(input) {
|
|
218
|
+
assertPortableWorkflowGraph(input.manifest);
|
|
219
|
+
const manifest = cloneJson(input.manifest);
|
|
220
|
+
const grouped = new Map();
|
|
221
|
+
manifest.nodes = manifest.nodes.map((node) => {
|
|
222
|
+
if (node.kind !== "tool")
|
|
223
|
+
return node;
|
|
224
|
+
if (node.connection_id) {
|
|
225
|
+
const source = input.sourceConnections?.[node.connection_id];
|
|
226
|
+
if (!source || !isNonEmptyString(source.integrationId)) {
|
|
227
|
+
throw new PortableWorkflowDefinitionValidationError([{
|
|
228
|
+
path: `$.graph.nodes.${manifest.nodes.indexOf(node)}.connection_id`,
|
|
229
|
+
code: "source_connection_metadata_required",
|
|
230
|
+
message: `Tool node '${node.id}' is pinned. Export must resolve its source integration account before removing the organization-local connection id.`,
|
|
231
|
+
}]);
|
|
232
|
+
}
|
|
233
|
+
const current = grouped.get(node.connection_id);
|
|
234
|
+
if (current)
|
|
235
|
+
current.nodeIds.push(node.id);
|
|
236
|
+
else
|
|
237
|
+
grouped.set(node.connection_id, { source, nodeIds: [node.id] });
|
|
238
|
+
}
|
|
239
|
+
const { connection_id: _connectionId, ...portableNode } = node;
|
|
240
|
+
return portableNode;
|
|
241
|
+
});
|
|
242
|
+
const integrationCounts = new Map();
|
|
243
|
+
const connections = [...grouped.values()].map(({ source, nodeIds }) => {
|
|
244
|
+
const next = (integrationCounts.get(source.integrationId) ?? 0) + 1;
|
|
245
|
+
integrationCounts.set(source.integrationId, next);
|
|
246
|
+
const accountHint = {
|
|
247
|
+
...(isNonEmptyString(source.externalAccountId) ? { external_account_id: source.externalAccountId } : {}),
|
|
248
|
+
...(isNonEmptyString(source.displayName) ? { display_name: source.displayName } : {}),
|
|
249
|
+
...(isNonEmptyString(source.authScheme) ? { auth_scheme: source.authScheme } : {}),
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
binding_id: `connection.${source.integrationId}.${next}`,
|
|
253
|
+
integration_id: source.integrationId,
|
|
254
|
+
node_ids: nodeIds,
|
|
255
|
+
...(Object.keys(accountHint).length > 0 ? { account_hint: accountHint } : {}),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
manifest.workflow = { ...manifest.workflow, status: "disabled" };
|
|
259
|
+
manifest.trigger = stripTriggerAuthority(manifest.trigger ?? { type: "api" });
|
|
260
|
+
manifest.source_hash = hashPortableWorkflowGraphManifest(manifest);
|
|
261
|
+
return {
|
|
262
|
+
kind: "oxygen-workflow-definition",
|
|
263
|
+
definition_version: PORTABLE_WORKFLOW_DEFINITION_VERSION,
|
|
264
|
+
graph: manifest,
|
|
265
|
+
requirements: { connections },
|
|
266
|
+
exported_at: (input.exportedAt ?? new Date()).toISOString(),
|
|
267
|
+
...(input.exportedFrom ? { exported_from: cloneJson(input.exportedFrom) } : {}),
|
|
268
|
+
compiler_version: PORTABLE_WORKFLOW_DEFINITION_COMPILER_VERSION,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
export function materializePortableWorkflowDefinition(input) {
|
|
272
|
+
assertPortableWorkflowDefinition(input.definition);
|
|
273
|
+
const definition = input.definition;
|
|
274
|
+
const manifest = cloneJson(definition.graph);
|
|
275
|
+
const bindings = input.connectionBindings ?? {};
|
|
276
|
+
const knownBindingIds = new Set(definition.requirements.connections.map((item) => item.binding_id));
|
|
277
|
+
const unknownBindings = Object.keys(bindings).filter((bindingId) => !knownBindingIds.has(bindingId));
|
|
278
|
+
if (unknownBindings.length > 0) {
|
|
279
|
+
throw new PortableWorkflowDefinitionValidationError(unknownBindings.map((bindingId) => ({
|
|
280
|
+
path: `$.connection_bindings.${bindingId}`,
|
|
281
|
+
code: "unknown_connection_binding",
|
|
282
|
+
message: `No connection requirement exists for binding '${bindingId}'.`,
|
|
283
|
+
})));
|
|
284
|
+
}
|
|
285
|
+
const nodeConnections = new Map();
|
|
286
|
+
const missing = [];
|
|
287
|
+
for (const requirement of definition.requirements.connections) {
|
|
288
|
+
const connectionId = bindings[requirement.binding_id]?.trim();
|
|
289
|
+
if (!connectionId) {
|
|
290
|
+
missing.push({
|
|
291
|
+
path: `$.connection_bindings.${requirement.binding_id}`,
|
|
292
|
+
code: "connection_binding_required",
|
|
293
|
+
message: `Bind '${requirement.binding_id}' (${requirement.integration_id}) to one active destination connection before import.`,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
for (const nodeId of requirement.node_ids)
|
|
298
|
+
nodeConnections.set(nodeId, connectionId);
|
|
299
|
+
}
|
|
300
|
+
if (missing.length > 0)
|
|
301
|
+
throw new PortableWorkflowDefinitionValidationError(missing);
|
|
302
|
+
manifest.nodes = manifest.nodes.map((node) => {
|
|
303
|
+
if (node.kind !== "tool")
|
|
304
|
+
return node;
|
|
305
|
+
const connectionId = nodeConnections.get(node.id);
|
|
306
|
+
return connectionId ? { ...node, connection_id: connectionId } : node;
|
|
307
|
+
});
|
|
308
|
+
const workflowId = input.workflowId?.trim() || manifest.workflow.id;
|
|
309
|
+
const workflowName = input.workflowName?.trim() || manifest.workflow.name;
|
|
310
|
+
manifest.workflow = { id: workflowId, name: workflowName, status: "disabled" };
|
|
311
|
+
manifest.trigger = stripTriggerAuthority(manifest.trigger ?? { type: "api" });
|
|
312
|
+
if (manifest.trigger?.type === "webhook") {
|
|
313
|
+
const triggerId = input.webhookTriggerId?.trim() || `${workflowId}-webhook`;
|
|
314
|
+
manifest.trigger = { ...manifest.trigger, trigger_id: triggerId, status: "disabled" };
|
|
315
|
+
}
|
|
316
|
+
manifest.created_at = (input.importedAt ?? new Date()).toISOString();
|
|
317
|
+
manifest.source_hash = hashPortableWorkflowGraphManifest(manifest);
|
|
318
|
+
return manifest;
|
|
319
|
+
}
|
|
@@ -30,6 +30,11 @@ export function estimateWorkflowRunAutomationActionsFloor(manifest) {
|
|
|
30
30
|
const graph = readWorkflowGraph(manifest);
|
|
31
31
|
if (graph)
|
|
32
32
|
return estimateWorkflowGraphActionsFloor(graph);
|
|
33
|
+
return estimateRecipeActionsFloor(manifest);
|
|
34
|
+
}
|
|
35
|
+
/** One action for entering the durable recipe plus one per statically visible
|
|
36
|
+
* checkpoint/tool. Runtime code may emit more, so this remains a floor. */
|
|
37
|
+
function estimateRecipeActionsFloor(manifest) {
|
|
33
38
|
const visualPlan = isRecord(manifest.visual_plan) ? manifest.visual_plan : null;
|
|
34
39
|
const visualSteps = Array.isArray(visualPlan?.steps) ? visualPlan.steps : [];
|
|
35
40
|
const checkpointCount = visualSteps.filter((step) => isRecord(step) && (step.kind !== "branch" || typeof step.checkpoint_id === "string")).length;
|
|
@@ -56,7 +61,8 @@ function manifestHasDynamicRuntimeActions(manifest) {
|
|
|
56
61
|
const graph = readWorkflowGraph(manifest);
|
|
57
62
|
if (!graph)
|
|
58
63
|
return false;
|
|
59
|
-
return graph.nodes.some((node) => node.
|
|
64
|
+
return graph.nodes.some((node) => node.hasRecipe
|
|
65
|
+
|| (node.kind === "loop" && !node.disabled && declaredLoopIterations(node.maxIterations) === null));
|
|
60
66
|
}
|
|
61
67
|
function manifestWritesRowsPerRun(manifest) {
|
|
62
68
|
if (!isRecord(manifest))
|
|
@@ -67,7 +73,8 @@ function manifestWritesRowsPerRun(manifest) {
|
|
|
67
73
|
}
|
|
68
74
|
const graph = readWorkflowGraph(manifest);
|
|
69
75
|
if (graph) {
|
|
70
|
-
return graph.nodes.some((node) => node.kind === "tool" && typeof node.tool === "string" && ROW_FANOUT_TOOL_IDS.has(node.tool))
|
|
76
|
+
return graph.nodes.some((node) => (node.kind === "tool" && typeof node.tool === "string" && ROW_FANOUT_TOOL_IDS.has(node.tool))
|
|
77
|
+
|| node.recipeTools.some((tool) => ROW_FANOUT_TOOL_IDS.has(tool)));
|
|
71
78
|
}
|
|
72
79
|
const toolsUsed = Array.isArray(manifest.tools_used) ? manifest.tools_used : [];
|
|
73
80
|
return toolsUsed.some((tool) => typeof tool === "string" && ROW_FANOUT_TOOL_IDS.has(tool));
|
|
@@ -81,12 +88,18 @@ function readWorkflowGraph(manifest) {
|
|
|
81
88
|
continue;
|
|
82
89
|
if (typeof raw.id !== "string" || !raw.id || typeof raw.kind !== "string")
|
|
83
90
|
continue;
|
|
91
|
+
const recipe = isRecord(raw.recipe) ? raw.recipe : null;
|
|
84
92
|
nodes.push({
|
|
85
93
|
id: raw.id,
|
|
86
94
|
kind: raw.kind,
|
|
87
95
|
disabled: raw.disabled === true,
|
|
88
96
|
tool: raw.tool,
|
|
89
97
|
maxIterations: raw.max_iterations,
|
|
98
|
+
recipeTools: recipe && Array.isArray(recipe.tools_used)
|
|
99
|
+
? recipe.tools_used.filter((tool) => typeof tool === "string")
|
|
100
|
+
: [],
|
|
101
|
+
hasRecipe: recipe !== null,
|
|
102
|
+
recipeActionsFloor: recipe ? estimateRecipeActionsFloor(recipe) : 0,
|
|
90
103
|
});
|
|
91
104
|
}
|
|
92
105
|
const edges = manifest.edges.flatMap((raw) => {
|
|
@@ -162,9 +175,10 @@ function estimateWorkflowGraphActionsFloor(graph) {
|
|
|
162
175
|
for (const node of graph.nodes) {
|
|
163
176
|
if (node.disabled || skipped.has(node.id))
|
|
164
177
|
continue;
|
|
165
|
-
if (!workflowGraphNodeKindBills(node.kind))
|
|
178
|
+
if (!workflowGraphNodeKindBills(node.kind) && !node.hasRecipe)
|
|
166
179
|
continue;
|
|
167
|
-
|
|
180
|
+
const ownFloor = node.hasRecipe ? node.recipeActionsFloor : 1;
|
|
181
|
+
floor += ownFloor * (multipliers.get(node.id) ?? 1);
|
|
168
182
|
}
|
|
169
183
|
// Nested caps multiply, and two large ones overflow Number's exact range;
|
|
170
184
|
// clamping keeps every downstream product (runs x floor) a finite number.
|