@oxygen-agent/cli 1.739.1 → 1.750.4

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.
@@ -0,0 +1,436 @@
1
+ export const DEFAULT_WORKFLOW_MAPPING_FIELD_LIMITS = {
2
+ maxDepth: 6,
3
+ maxKeysPerObject: 40,
4
+ maxFields: 240,
5
+ };
6
+ const BLOCKED_SEGMENTS = new Set([
7
+ "__proto__",
8
+ "constructor",
9
+ "prototype",
10
+ ]);
11
+ const SAFE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
+ const SOURCE_ORDER = [
13
+ "pinned_sample",
14
+ "observed",
15
+ "declared",
16
+ ];
17
+ const TYPE_ORDER = [
18
+ "object",
19
+ "array",
20
+ "string",
21
+ "integer",
22
+ "number",
23
+ "boolean",
24
+ "null",
25
+ "unknown",
26
+ ];
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function appendMappingProperty(prefix, value) {
31
+ if (BLOCKED_SEGMENTS.has(value))
32
+ return null;
33
+ if (SAFE_SEGMENT.test(value))
34
+ return prefix ? `${prefix}.${value}` : value;
35
+ return `${prefix}[${JSON.stringify(value)}]`;
36
+ }
37
+ /** Join one relative mapping path to a rooted executable scope path. */
38
+ export function workflowMappingScopePath(base, relative) {
39
+ if (!relative)
40
+ return base;
41
+ return relative.startsWith("[") ? `${base}${relative}` : `${base}.${relative}`;
42
+ }
43
+ /** Canonical relative mapping path from decoded property segments. */
44
+ export function workflowMappingPathFromSegments(segments) {
45
+ let path = "";
46
+ for (const segment of segments) {
47
+ const next = appendMappingProperty(path, segment);
48
+ if (next === null)
49
+ return null;
50
+ path = next;
51
+ }
52
+ return path;
53
+ }
54
+ /** True only for the display grammar's unquoted `[]` array marker. */
55
+ export function workflowMappingPathHasArrayBoundary(path) {
56
+ let quoted = false;
57
+ let escaped = false;
58
+ for (let index = 0; index < path.length; index += 1) {
59
+ const character = path[index] ?? "";
60
+ if (quoted) {
61
+ if (escaped)
62
+ escaped = false;
63
+ else if (character === "\\")
64
+ escaped = true;
65
+ else if (character === "\"")
66
+ quoted = false;
67
+ continue;
68
+ }
69
+ if (character === "\"") {
70
+ quoted = true;
71
+ continue;
72
+ }
73
+ if (character === "[" && path[index + 1] === "]")
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ function orderedTypes(types) {
79
+ const values = new Set(types);
80
+ return TYPE_ORDER.filter((type) => values.has(type));
81
+ }
82
+ function orderedSources(sources) {
83
+ const values = new Set(sources);
84
+ return SOURCE_ORDER.filter((source) => values.has(source));
85
+ }
86
+ function normalizedTypeEvidence(field) {
87
+ if (field.typesBySource) {
88
+ return Object.fromEntries(Object.entries(field.typesBySource).map(([source, types]) => [
89
+ source,
90
+ orderedTypes(types ?? []),
91
+ ]));
92
+ }
93
+ return Object.fromEntries(field.sources.map((source) => [source, [...field.types]]));
94
+ }
95
+ function mergeTypeEvidence(left, right) {
96
+ const merged = {};
97
+ const leftEvidence = normalizedTypeEvidence(left);
98
+ const rightEvidence = normalizedTypeEvidence(right);
99
+ for (const source of SOURCE_ORDER) {
100
+ const types = orderedTypes([
101
+ ...(leftEvidence[source] ?? []),
102
+ ...(rightEvidence[source] ?? []),
103
+ ]);
104
+ if (types.length > 0)
105
+ merged[source] = types;
106
+ }
107
+ return merged;
108
+ }
109
+ export function workflowMappingValueType(value) {
110
+ if (value === null)
111
+ return "null";
112
+ if (Array.isArray(value))
113
+ return "array";
114
+ if (isRecord(value))
115
+ return "object";
116
+ if (typeof value === "number")
117
+ return Number.isInteger(value) ? "integer" : "number";
118
+ if (typeof value === "string")
119
+ return "string";
120
+ if (typeof value === "boolean")
121
+ return "boolean";
122
+ return "unknown";
123
+ }
124
+ /** Human-sized preview; never used to resolve a mapping or build a payload. */
125
+ export function workflowMappingSample(value, maxLength = 72) {
126
+ if (value === undefined)
127
+ return undefined;
128
+ if (value === null)
129
+ return "null";
130
+ if (Array.isArray(value))
131
+ return `[${value.length} ${value.length === 1 ? "item" : "items"}]`;
132
+ if (isRecord(value)) {
133
+ const count = Object.keys(value).length;
134
+ return `{${count} ${count === 1 ? "field" : "fields"}}`;
135
+ }
136
+ const raw = typeof value === "string" ? value : String(value);
137
+ const oneLine = raw.replace(/\s+/g, " ").trim();
138
+ return oneLine.length <= maxLength ? oneLine : `${oneLine.slice(0, Math.max(0, maxLength - 1))}…`;
139
+ }
140
+ function schemaTypes(schema) {
141
+ if (!isRecord(schema))
142
+ return ["unknown"];
143
+ const types = new Set();
144
+ const declared = typeof schema.type === "string"
145
+ ? [schema.type]
146
+ : Array.isArray(schema.type)
147
+ ? schema.type
148
+ : [];
149
+ for (const type of declared) {
150
+ if (TYPE_ORDER.includes(type)) {
151
+ types.add(type);
152
+ }
153
+ }
154
+ if (isRecord(schema.properties) || Object.hasOwn(schema, "additionalProperties")) {
155
+ types.add("object");
156
+ }
157
+ if (isRecord(schema.items))
158
+ types.add("array");
159
+ if (Array.isArray(schema.anyOf)) {
160
+ for (const option of schema.anyOf) {
161
+ for (const type of schemaTypes(option))
162
+ types.add(type);
163
+ }
164
+ }
165
+ return orderedTypes(types.size > 0 ? types : ["unknown"]);
166
+ }
167
+ function addField(fields, next, maxFields) {
168
+ const existing = fields.get(next.path);
169
+ if (!existing && fields.size >= maxFields)
170
+ return;
171
+ fields.set(next.path, existing
172
+ ? {
173
+ path: next.path,
174
+ types: orderedTypes([...existing.types, ...next.types]),
175
+ typesBySource: mergeTypeEvidence(existing, next),
176
+ sources: orderedSources([...existing.sources, ...next.sources]),
177
+ ...(existing.sample !== undefined
178
+ ? {
179
+ sample: existing.sample,
180
+ ...(existing.sampleSource ? { sampleSource: existing.sampleSource } : {}),
181
+ }
182
+ : next.sample !== undefined
183
+ ? {
184
+ sample: next.sample,
185
+ ...(next.sampleSource ? { sampleSource: next.sampleSource } : {}),
186
+ }
187
+ : {}),
188
+ ...(existing.observedAt
189
+ ? { observedAt: existing.observedAt }
190
+ : next.observedAt
191
+ ? { observedAt: next.observedAt }
192
+ : {}),
193
+ }
194
+ : next);
195
+ }
196
+ /** Flatten a JSON Schema while retaining types, array boundaries, and pinned examples. */
197
+ export function workflowMappingFieldsFromSchema(schema, options = {}) {
198
+ const fields = new Map();
199
+ const limits = options.limits ?? DEFAULT_WORKFLOW_MAPPING_FIELD_LIMITS;
200
+ const source = options.source ?? "declared";
201
+ const explicitSample = options.sample !== undefined;
202
+ const schemaExample = isRecord(schema) && Array.isArray(schema.examples)
203
+ ? schema.examples[0]
204
+ : undefined;
205
+ const rootSample = explicitSample ? options.sample : schemaExample;
206
+ const rootSampleSource = rootSample === undefined
207
+ ? undefined
208
+ : explicitSample
209
+ ? options.sampleSource ?? "pinned_sample"
210
+ : "declared";
211
+ const walk = (node, prefix, sample, depth) => {
212
+ if (!isRecord(node) || depth > limits.maxDepth || fields.size >= limits.maxFields)
213
+ return;
214
+ if (Array.isArray(node.anyOf)) {
215
+ for (const option of node.anyOf)
216
+ walk(option, prefix, sample, depth + 1);
217
+ }
218
+ const types = schemaTypes(node);
219
+ if (types.includes("array")) {
220
+ walk(node.items, `${prefix}[]`, Array.isArray(sample) ? sample[0] : undefined, depth + 1);
221
+ return;
222
+ }
223
+ if (!isRecord(node.properties))
224
+ return;
225
+ let taken = 0;
226
+ for (const [name, child] of Object.entries(node.properties)) {
227
+ if (taken >= limits.maxKeysPerObject || fields.size >= limits.maxFields)
228
+ return;
229
+ const path = appendMappingProperty(prefix, name);
230
+ if (path === null)
231
+ continue;
232
+ if (!prefix && options.omitRootPaths?.has(name))
233
+ continue;
234
+ taken += 1;
235
+ const childSample = isRecord(sample) && Object.hasOwn(sample, name) ? sample[name] : undefined;
236
+ const samplePreview = childSample === undefined
237
+ ? undefined
238
+ : workflowMappingSample(childSample);
239
+ const declaredTypes = schemaTypes(child);
240
+ const sampleTypes = childSample === undefined
241
+ ? []
242
+ : [workflowMappingValueType(childSample)];
243
+ const typesBySource = {
244
+ [source]: declaredTypes,
245
+ };
246
+ if (rootSampleSource && sampleTypes.length > 0) {
247
+ typesBySource[rootSampleSource] = orderedTypes([
248
+ ...(typesBySource[rootSampleSource] ?? []),
249
+ ...sampleTypes,
250
+ ]);
251
+ }
252
+ addField(fields, {
253
+ path,
254
+ types: orderedTypes([...declaredTypes, ...sampleTypes]),
255
+ typesBySource,
256
+ sources: orderedSources([
257
+ source,
258
+ ...(childSample === undefined || !rootSampleSource ? [] : [rootSampleSource]),
259
+ ]),
260
+ ...(samplePreview === undefined
261
+ ? {}
262
+ : {
263
+ sample: samplePreview,
264
+ ...(rootSampleSource ? { sampleSource: rootSampleSource } : {}),
265
+ }),
266
+ }, limits.maxFields);
267
+ walk(child, path, childSample, depth + 1);
268
+ }
269
+ };
270
+ walk(schema, "", rootSample, 0);
271
+ return [...fields.values()];
272
+ }
273
+ /** Describe actual JSON output without retaining more than a bounded display preview. */
274
+ export function workflowMappingFieldsFromValue(value, options = {}) {
275
+ const fields = new Map();
276
+ const limits = options.limits ?? DEFAULT_WORKFLOW_MAPPING_FIELD_LIMITS;
277
+ const includeSamples = options.includeSamples ?? true;
278
+ const walk = (current, prefix, depth) => {
279
+ if (depth > limits.maxDepth || fields.size >= limits.maxFields)
280
+ return;
281
+ if (Array.isArray(current)) {
282
+ // One array can carry nullable or variant object rows. Inspecting only the
283
+ // first item made fields disappear whenever row zero happened to be null
284
+ // or a different variant. Twenty bounded samples are enough to learn the
285
+ // shape without turning a 50k-row provider response into editor work.
286
+ for (const item of current.slice(0, 20)) {
287
+ if (Array.isArray(item) || isRecord(item))
288
+ walk(item, `${prefix}[]`, depth + 1);
289
+ }
290
+ return;
291
+ }
292
+ if (!isRecord(current))
293
+ return;
294
+ let taken = 0;
295
+ for (const key of Object.keys(current)) {
296
+ if (taken >= limits.maxKeysPerObject || fields.size >= limits.maxFields)
297
+ return;
298
+ const path = appendMappingProperty(prefix, key);
299
+ if (path === null)
300
+ continue;
301
+ taken += 1;
302
+ const child = current[key];
303
+ const sample = includeSamples ? workflowMappingSample(child) : undefined;
304
+ addField(fields, {
305
+ path,
306
+ types: [workflowMappingValueType(child)],
307
+ typesBySource: { observed: [workflowMappingValueType(child)] },
308
+ sources: ["observed"],
309
+ ...(sample === undefined ? {} : { sample, sampleSource: "observed" }),
310
+ }, limits.maxFields);
311
+ walk(child, path, depth + 1);
312
+ }
313
+ };
314
+ walk(value, "", 0);
315
+ return [...fields.values()];
316
+ }
317
+ export function mergeWorkflowMappingFields(...groups) {
318
+ const merged = new Map();
319
+ const maxFields = Math.max(DEFAULT_WORKFLOW_MAPPING_FIELD_LIMITS.maxFields, groups.reduce((total, group) => total + group.length, 0));
320
+ for (const group of groups) {
321
+ for (const field of group)
322
+ addField(merged, field, maxFields);
323
+ }
324
+ return [...merged.values()];
325
+ }
326
+ /** Compatibility adapter for older path-only observations and small fixtures. */
327
+ export function workflowMappingFieldsFromPaths(paths, source = "observed") {
328
+ return [...new Set(paths)].map((path) => ({
329
+ path,
330
+ types: ["unknown"],
331
+ typesBySource: { [source]: ["unknown"] },
332
+ sources: [source],
333
+ }));
334
+ }
335
+ /** Attach immutable run lineage without changing the field's stable path/type contract. */
336
+ export function withWorkflowMappingObservation(fields, observedAt) {
337
+ return fields.map((field) => field.sources.includes("observed")
338
+ ? { ...field, observedAt }
339
+ : field);
340
+ }
341
+ export function workflowMappingSourceLabel(source) {
342
+ switch (source) {
343
+ case "pinned_sample": return "Pinned sample";
344
+ case "observed": return "Observed";
345
+ case "declared": return "Declared";
346
+ }
347
+ }
348
+ export function workflowMappingObservationLabel(observation) {
349
+ return observation.scope === "workflow"
350
+ ? `Run v${observation.revisionVersion}${observation.stale ? " · older revision" : ""}`
351
+ : `Workspace run v${observation.revisionVersion}`;
352
+ }
353
+ function targetTypes(value) {
354
+ const values = typeof value === "string" ? [value] : value ?? [];
355
+ const types = values.filter((entry) => (TYPE_ORDER.includes(entry)));
356
+ return orderedTypes(types.length > 0 ? types : ["unknown"]);
357
+ }
358
+ function typeCompatible(source, target) {
359
+ if (source === target)
360
+ return true;
361
+ return source === "integer" && target === "number";
362
+ }
363
+ /** True when real run evidence contradicts the endpoint's declared type contract. */
364
+ export function workflowMappingFieldHasTypeDrift(field) {
365
+ const evidence = normalizedTypeEvidence(field);
366
+ const declared = evidence.declared;
367
+ const observed = evidence.observed;
368
+ if (!declared?.length || !observed?.length)
369
+ return false;
370
+ return observed.some((actual) => !declared.some((expected) => typeCompatible(actual, expected)));
371
+ }
372
+ /** Compact, evidence-specific type copy for picker rows and diagnostics. */
373
+ export function workflowMappingTypeEvidenceLabel(field) {
374
+ const evidence = normalizedTypeEvidence(field);
375
+ const labels = SOURCE_ORDER.flatMap((source) => {
376
+ const types = evidence[source];
377
+ return types?.length
378
+ ? [`${workflowMappingSourceLabel(source)} ${types.join(" | ")}`]
379
+ : [];
380
+ });
381
+ return labels.length > 0 ? labels.join(" · ") : field.types.join(" | ");
382
+ }
383
+ function coercionForTarget(types, target) {
384
+ if (!["string", "number", "integer", "boolean"].includes(target))
385
+ return [];
386
+ if (types.some((type) => type === "null" || type === "array" || type === "object" || type === "unknown")) {
387
+ return [];
388
+ }
389
+ const safe = types.every((type) => {
390
+ if (target === "string")
391
+ return type === "string" || type === "number" || type === "integer" || type === "boolean";
392
+ if (target === "number")
393
+ return type === "string" || type === "number" || type === "integer";
394
+ if (target === "integer")
395
+ return type === "string" || type === "integer";
396
+ return type === "string" || type === "boolean";
397
+ });
398
+ return safe ? [target] : [];
399
+ }
400
+ /** Compare one source field with one destination schema field. */
401
+ export function workflowMappingCompatibility(sourceTypes, destinationType, coercion) {
402
+ const targets = targetTypes(destinationType);
403
+ const original = sourceTypes && sourceTypes.length > 0 ? [...sourceTypes] : ["unknown"];
404
+ const sourceLabel = original.join(" | ");
405
+ const targetLabel = targets.join(" | ");
406
+ if (targets.includes("unknown") || original.includes("unknown")) {
407
+ return { status: "unknown", sourceLabel, targetLabel, coercions: [] };
408
+ }
409
+ // A stored conversion still receives null and therefore fails. Without a
410
+ // conversion, nullable input is compatible only when the destination allows
411
+ // null too.
412
+ if (original.includes("null")
413
+ && (coercion !== undefined || !targets.includes("null"))
414
+ && !original.every((type) => type === "null")) {
415
+ return { status: "nullable", sourceLabel, targetLabel, coercions: [] };
416
+ }
417
+ const allowedCoercions = [...new Set(targets.flatMap((target) => coercionForTarget(original, target)))];
418
+ if (coercion !== undefined && !allowedCoercions.includes(coercion)) {
419
+ return {
420
+ status: "incompatible",
421
+ sourceLabel,
422
+ targetLabel,
423
+ coercions: allowedCoercions,
424
+ };
425
+ }
426
+ const effective = coercion ? [coercion] : original;
427
+ if (effective.every((type) => targets.some((target) => typeCompatible(type, target)))) {
428
+ return { status: "compatible", sourceLabel, targetLabel, coercions: [] };
429
+ }
430
+ return {
431
+ status: allowedCoercions.length > 0 ? "coercible" : "incompatible",
432
+ sourceLabel,
433
+ targetLabel,
434
+ coercions: allowedCoercions,
435
+ };
436
+ }
@@ -43,6 +43,15 @@ export declare function loopBodyNodeIds(graph: WorkflowGraphManifest, loopNodeId
43
43
  * runs and across machines.
44
44
  */
45
45
  export declare function topologicalOrder(graph: WorkflowGraphManifest): string[] | null;
46
+ /**
47
+ * Nodes that execute on every forward path reaching `targetNodeId`.
48
+ *
49
+ * A structural ancestor is not necessarily available: after a switch rejoins,
50
+ * each branch is an ancestor but only one branch ran. Dominators are the exact
51
+ * graph concept the mapper needs. Legal loop back-edges are removed, matching
52
+ * execution order and the existing topological analysis.
53
+ */
54
+ export declare function dominatingNodeIds(graph: WorkflowGraphManifest, targetNodeId: string): Set<string>;
46
55
  /**
47
56
  * Illegal cycles: one entry per strongly connected component that still contains
48
57
  * a cycle once legal loop back-edges are removed.
@@ -230,6 +230,58 @@ export function topologicalOrder(graph) {
230
230
  }
231
231
  return order.length === nodes.size ? order : null;
232
232
  }
233
+ /**
234
+ * Nodes that execute on every forward path reaching `targetNodeId`.
235
+ *
236
+ * A structural ancestor is not necessarily available: after a switch rejoins,
237
+ * each branch is an ancestor but only one branch ran. Dominators are the exact
238
+ * graph concept the mapper needs. Legal loop back-edges are removed, matching
239
+ * execution order and the existing topological analysis.
240
+ */
241
+ export function dominatingNodeIds(graph, targetNodeId) {
242
+ const forward = forwardOutgoing(graph);
243
+ const reachable = traverse(rootNodeIds(graph), forward);
244
+ if (!reachable.has(targetNodeId))
245
+ return new Set();
246
+ const order = topologicalOrder(graph);
247
+ if (!order)
248
+ return new Set();
249
+ const incoming = new Map();
250
+ for (const id of nodeIndex(graph).keys())
251
+ incoming.set(id, []);
252
+ for (const [source, edges] of forward) {
253
+ for (const edge of edges) {
254
+ if (!reachable.has(source) || !reachable.has(edge.target))
255
+ continue;
256
+ incoming.get(edge.target)?.push(source);
257
+ }
258
+ }
259
+ const roots = new Set(rootNodeIds(graph));
260
+ const dominators = new Map();
261
+ for (const id of order) {
262
+ if (!reachable.has(id))
263
+ continue;
264
+ if (roots.has(id)) {
265
+ dominators.set(id, new Set([id]));
266
+ continue;
267
+ }
268
+ const predecessors = (incoming.get(id) ?? [])
269
+ .map((predecessor) => dominators.get(predecessor))
270
+ .filter((entry) => entry !== undefined);
271
+ if (predecessors.length === 0) {
272
+ dominators.set(id, new Set([id]));
273
+ continue;
274
+ }
275
+ const intersection = new Set(predecessors[0]);
276
+ for (const candidate of [...intersection]) {
277
+ if (predecessors.some((set) => !set.has(candidate)))
278
+ intersection.delete(candidate);
279
+ }
280
+ intersection.add(id);
281
+ dominators.set(id, intersection);
282
+ }
283
+ return dominators.get(targetNodeId) ?? new Set();
284
+ }
233
285
  /**
234
286
  * Illegal cycles: one entry per strongly connected component that still contains
235
287
  * a cycle once legal loop back-edges are removed.
@@ -70,6 +70,7 @@ export type WorkflowValueRef =
70
70
  | {
71
71
  type: "ref";
72
72
  path: string;
73
+ coerce?: WorkflowValueCoercion;
73
74
  }
74
75
  /** An OXYGEN formula-language expression (packages/formula). */
75
76
  | {
@@ -88,6 +89,16 @@ export type WorkflowValueRef =
88
89
  path?: string;
89
90
  };
90
91
  export type WorkflowValueRefType = WorkflowValueRef["type"];
92
+ /**
93
+ * Explicit, fail-closed scalar conversions for a mapped value.
94
+ *
95
+ * A mapping never changes type merely because the destination field expects a
96
+ * different one. Authors may choose one of these conversions after the editor
97
+ * explains the mismatch; conversion failure stops the step instead of turning
98
+ * an invalid value into a provider request.
99
+ */
100
+ export declare const WORKFLOW_VALUE_COERCIONS: readonly ["string", "number", "integer", "boolean"];
101
+ export type WorkflowValueCoercion = typeof WORKFLOW_VALUE_COERCIONS[number];
91
102
  /** The only JSON value kinds the oxygen-js-v1 output validator enforces. */
92
103
  export type WorkflowCodeJsonType = "array" | "boolean" | "integer" | "null" | "number" | "object" | "string";
93
104
  /**
@@ -69,6 +69,20 @@ export const BILLABLE_WORKFLOW_GRAPH_NODE_KINDS = new Set([
69
69
  export function workflowGraphNodeKindBills(kind) {
70
70
  return BILLABLE_WORKFLOW_GRAPH_NODE_KINDS.has(kind);
71
71
  }
72
+ /**
73
+ * Explicit, fail-closed scalar conversions for a mapped value.
74
+ *
75
+ * A mapping never changes type merely because the destination field expects a
76
+ * different one. Authors may choose one of these conversions after the editor
77
+ * explains the mismatch; conversion failure stops the step instead of turning
78
+ * an invalid value into a provider request.
79
+ */
80
+ export const WORKFLOW_VALUE_COERCIONS = [
81
+ "string",
82
+ "number",
83
+ "integer",
84
+ "boolean",
85
+ ];
72
86
  export const WORKFLOW_CODE_V1_VERSION = 1;
73
87
  export const WORKFLOW_CODE_V1_LANGUAGE = "javascript";
74
88
  export const WORKFLOW_CODE_V1_RUNTIME = "oxygen-js-v1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.739.1",
3
+ "version": "1.750.4",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",