@oxygen-agent/cli 1.177.1 → 1.184.3
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/index.js +531 -7
- package/node_modules/@oxygen/shared/dist/budget-scopes.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/budget-scopes.js +9 -0
- package/node_modules/@oxygen/shared/dist/cell-format.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cell-format.js +26 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +9 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +91 -1
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -15
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-webhook.js +78 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +15 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +4 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +22 -0
- package/node_modules/@oxygen/workflows/dist/index.js +165 -24
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
// Shared transport contract for the signup-lead webhook. Both the request-time
|
|
3
|
+
// direct emitter (apps/web/src/lib/signup-lead-webhook.ts) and the durable
|
|
4
|
+
// worker retry queue (apps/worker/src/signup-lead-delivery-queue.ts) build the
|
|
5
|
+
// same HMAC signature, Oxygen table/workflow secret headers, timeout, and
|
|
6
|
+
// target classification. This module is the single source of truth so the two
|
|
7
|
+
// paths cannot drift when the webhook auth/header contract changes (OXY-679).
|
|
8
|
+
/** Request timeout shared by the direct emit and the durable worker retry. */
|
|
9
|
+
export const SIGNUP_LEAD_WEBHOOK_TIMEOUT_MS = 10_000;
|
|
10
|
+
/** True for the canonical Oxygen apex domain and its subdomains only. */
|
|
11
|
+
export function isOxygenHostname(hostname) {
|
|
12
|
+
return (hostname === "oxygen-agent.com" || hostname.endsWith(".oxygen-agent.com"));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Classify a webhook target URL so the caller can decide whether to attach the
|
|
16
|
+
* internal table/workflow secret. Only genuine oxygen-agent.com hosts receive
|
|
17
|
+
* internal secrets; external hosts with Oxygen-looking paths are "external" and
|
|
18
|
+
* must never be trusted with the internal webhook secret.
|
|
19
|
+
*/
|
|
20
|
+
export function classifySignupLeadWebhookTarget(value) {
|
|
21
|
+
const target = typeof value === "string" ? value.trim() : "";
|
|
22
|
+
if (!target)
|
|
23
|
+
return "missing";
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(target);
|
|
26
|
+
if (!isOxygenHostname(url.hostname))
|
|
27
|
+
return "external";
|
|
28
|
+
if (url.pathname.startsWith("/api/webhooks/tables/")) {
|
|
29
|
+
return "oxygen_table_webhook";
|
|
30
|
+
}
|
|
31
|
+
if (url.pathname.startsWith("/api/webhooks/workflows/")) {
|
|
32
|
+
return "oxygen_workflow_webhook";
|
|
33
|
+
}
|
|
34
|
+
return "oxygen_internal";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "invalid_url";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** HMAC-SHA256 hex digest of the request body keyed by the webhook secret. */
|
|
41
|
+
export function signSignupLeadWebhookBody(secret, body) {
|
|
42
|
+
return createHmac("sha256", secret).update(body).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the complete header set for a signup-lead webhook request. Produces
|
|
46
|
+
* identical output for the request-time emit and the durable worker retry so
|
|
47
|
+
* the two transports stay in lockstep. Header values fall back to safe defaults
|
|
48
|
+
* for the worker's untyped (DB-sourced) payload; for the web emitter's typed
|
|
49
|
+
* payload the fallbacks are no-ops.
|
|
50
|
+
*/
|
|
51
|
+
export function buildSignupLeadWebhookHeaders(input) {
|
|
52
|
+
const { webhookUrl, payload, body, secret } = input;
|
|
53
|
+
const now = input.now ?? new Date();
|
|
54
|
+
const headers = {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
"x-event-id": headerString(payload.event_id) ?? "",
|
|
57
|
+
"x-event-type": headerString(payload.type) ?? "signup_lead.event",
|
|
58
|
+
"x-event-time": headerString(payload.occurred_at) ?? now.toISOString(),
|
|
59
|
+
"x-oxygen-signup-source": headerString(payload.source) ?? "clerk",
|
|
60
|
+
};
|
|
61
|
+
if (secret) {
|
|
62
|
+
headers["x-oxygen-webhook-signature"] =
|
|
63
|
+
`sha256=${signSignupLeadWebhookBody(secret, body)}`;
|
|
64
|
+
headers["x-oxygen-webhook-signature-algorithm"] = "hmac-sha256";
|
|
65
|
+
headers["x-oxygen-webhook-signature-timestamp"] = String(Math.floor(now.getTime() / 1000));
|
|
66
|
+
const target = classifySignupLeadWebhookTarget(webhookUrl);
|
|
67
|
+
if (target === "oxygen_table_webhook") {
|
|
68
|
+
headers["x-oxygen-table-webhook-secret"] = secret;
|
|
69
|
+
}
|
|
70
|
+
else if (target === "oxygen_workflow_webhook") {
|
|
71
|
+
headers["x-oxygen-workflow-secret"] = secret;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
function headerString(value) {
|
|
77
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
78
|
+
}
|
|
@@ -33,5 +33,17 @@ export declare function sqlErrorFields(error: unknown): Record<string, unknown>;
|
|
|
33
33
|
* the generic error serializers would otherwise emit those values verbatim.
|
|
34
34
|
*/
|
|
35
35
|
export declare function redactSqlParameters(text: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* True when the error is a Postgres serialization failure (40001) or deadlock
|
|
38
|
+
* (40P01) — the two "the transaction was aborted as a concurrency victim, retry
|
|
39
|
+
* the whole transaction" outcomes. Postgres aborts exactly one transaction in
|
|
40
|
+
* such a conflict and lets the other commit, so re-running the victim's
|
|
41
|
+
* transaction almost always succeeds once the winner has committed. Callers use
|
|
42
|
+
* this both to drive a bounded transaction retry and to surface a typed,
|
|
43
|
+
* retryable error (instead of an opaque unexpected_error) when retries are
|
|
44
|
+
* exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
|
|
45
|
+
* 40001/40P01 knowledge lives in one place. Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isRetryableConcurrencyError(error: unknown): boolean;
|
|
36
48
|
/** Telemetry-attribute shape (dotted keys) for span error attribution. */
|
|
37
49
|
export declare function sqlErrorTelemetryAttributes(error: unknown): Record<string, unknown>;
|
|
@@ -146,6 +146,21 @@ export function redactSqlParameters(text) {
|
|
|
146
146
|
return text;
|
|
147
147
|
return text.replace(/\n[ \t]*params[ \t]*:[\s\S]*?(?=\n[ \t]*at\s|$)/i, "");
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* True when the error is a Postgres serialization failure (40001) or deadlock
|
|
151
|
+
* (40P01) — the two "the transaction was aborted as a concurrency victim, retry
|
|
152
|
+
* the whole transaction" outcomes. Postgres aborts exactly one transaction in
|
|
153
|
+
* such a conflict and lets the other commit, so re-running the victim's
|
|
154
|
+
* transaction almost always succeeds once the winner has committed. Callers use
|
|
155
|
+
* this both to drive a bounded transaction retry and to surface a typed,
|
|
156
|
+
* retryable error (instead of an opaque unexpected_error) when retries are
|
|
157
|
+
* exhausted. Reuses the same SQLSTATE classifier as the telemetry helpers so the
|
|
158
|
+
* 40001/40P01 knowledge lives in one place. Never throws.
|
|
159
|
+
*/
|
|
160
|
+
export function isRetryableConcurrencyError(error) {
|
|
161
|
+
const cause = describeSqlError(error)?.cause;
|
|
162
|
+
return cause === "serialization" || cause === "deadlock";
|
|
163
|
+
}
|
|
149
164
|
/** Telemetry-attribute shape (dotted keys) for span error attribution. */
|
|
150
165
|
export function sqlErrorTelemetryAttributes(error) {
|
|
151
166
|
const attribution = describeSqlError(error);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
2
|
-
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.184.3";
|
|
2
|
+
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.184.3";
|
|
2
2
|
// Bump this only when deployed CLI/API contracts require a newer CLI.
|
|
3
|
+
// 1.181.0: paid table action runs and background columns run require
|
|
4
|
+
// approved=true in addition to max_credits; older CLIs cannot send the flag.
|
|
3
5
|
// 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
|
|
4
6
|
// (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
|
|
5
7
|
// removed the old /api/cli/linkedin/* routes — older CLIs would 404.
|
|
6
|
-
export const OXYGEN_MINIMUM_CLI_VERSION = "1.
|
|
8
|
+
export const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
@@ -67,6 +67,7 @@ export type WorkflowToolStepManifest = {
|
|
|
67
67
|
effect: WorkflowStepEffect;
|
|
68
68
|
mode?: WorkflowMode;
|
|
69
69
|
payload_source: string;
|
|
70
|
+
max_credits?: number;
|
|
70
71
|
};
|
|
71
72
|
export type WorkflowBranchStepManifest = {
|
|
72
73
|
kind: "branch";
|
|
@@ -121,6 +122,7 @@ export type WorkflowManifest = {
|
|
|
121
122
|
trigger?: WorkflowTriggerManifest;
|
|
122
123
|
input_schema?: JsonSchema;
|
|
123
124
|
steps: WorkflowStepManifest[];
|
|
125
|
+
max_credits?: number;
|
|
124
126
|
source_hash: string;
|
|
125
127
|
compiler_version: string;
|
|
126
128
|
created_at: string;
|
|
@@ -139,6 +141,7 @@ export type RecipeManifest = {
|
|
|
139
141
|
bundle_format: "esm";
|
|
140
142
|
tools_used: string[];
|
|
141
143
|
visual_plan?: RecipeVisualPlanManifest;
|
|
144
|
+
max_credits?: number;
|
|
142
145
|
source_hash: string;
|
|
143
146
|
compiler_version: typeof DURABLE_RECIPE_COMPILER_VERSION;
|
|
144
147
|
created_at: string;
|
|
@@ -219,6 +222,7 @@ export type WorkflowDefinition = {
|
|
|
219
222
|
specification?: string;
|
|
220
223
|
trigger?: WorkflowTriggerDefinition;
|
|
221
224
|
inputSchema?: JsonSchema;
|
|
225
|
+
maxCredits?: number;
|
|
222
226
|
steps: WorkflowStepDefinition[];
|
|
223
227
|
};
|
|
224
228
|
export type WorkflowTriggerDefinition = WorkflowTriggerManifest;
|
|
@@ -237,6 +241,7 @@ export type WorkflowToolStepDefinition = {
|
|
|
237
241
|
tool: string;
|
|
238
242
|
effect?: WorkflowStepEffect;
|
|
239
243
|
mode?: WorkflowMode;
|
|
244
|
+
maxCredits?: number;
|
|
240
245
|
payload: WorkflowFunction;
|
|
241
246
|
};
|
|
242
247
|
export type WorkflowBranchStepDefinition = {
|
|
@@ -270,6 +275,7 @@ export declare function defineWorkflow(input: {
|
|
|
270
275
|
specification?: string;
|
|
271
276
|
trigger?: WorkflowTriggerDefinition;
|
|
272
277
|
inputSchema?: JsonSchema;
|
|
278
|
+
maxCredits?: number;
|
|
273
279
|
steps: WorkflowStepDefinition[];
|
|
274
280
|
}): WorkflowDefinition;
|
|
275
281
|
export declare function apiTrigger(input?: {
|
|
@@ -310,8 +316,22 @@ export declare function toolStep(input: {
|
|
|
310
316
|
tool: string;
|
|
311
317
|
effect?: WorkflowStepEffect;
|
|
312
318
|
mode?: WorkflowMode;
|
|
319
|
+
maxCredits?: number;
|
|
313
320
|
payload: WorkflowFunction;
|
|
314
321
|
}): WorkflowToolStepDefinition;
|
|
322
|
+
/**
|
|
323
|
+
* Conditional step. `condition` is evaluated; control then flows by mode:
|
|
324
|
+
*
|
|
325
|
+
* - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
|
|
326
|
+
* between the branch and `then` are skipped); falsy skips straight to the end
|
|
327
|
+
* of the workflow (no step after the branch runs) and the run completes.
|
|
328
|
+
* - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
|
|
329
|
+
* chosen target, the remaining steps run sequentially through the end.
|
|
330
|
+
* - then + else + join — real if/else: the non-chosen arm is skipped and `join`
|
|
331
|
+
* is the convergence point where shared downstream work resumes.
|
|
332
|
+
*
|
|
333
|
+
* All targets must be ids of later steps (branches are forward-only).
|
|
334
|
+
*/
|
|
315
335
|
export declare function branchStep(input: {
|
|
316
336
|
id: string;
|
|
317
337
|
description?: string;
|
|
@@ -339,6 +359,7 @@ export declare function buildRecipeManifest(input: {
|
|
|
339
359
|
bundle: string;
|
|
340
360
|
toolsUsed: string[];
|
|
341
361
|
visualPlan?: RecipeVisualPlanManifest;
|
|
362
|
+
maxCredits?: number;
|
|
342
363
|
sourceHash?: string;
|
|
343
364
|
createdAt?: Date;
|
|
344
365
|
}): RecipeManifest;
|
|
@@ -804,6 +825,7 @@ export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" |
|
|
|
804
825
|
};
|
|
805
826
|
};
|
|
806
827
|
};
|
|
828
|
+
export declare function computeWorkflowPlanHash(input: Record<string, unknown>): string;
|
|
807
829
|
export declare const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
|
|
808
830
|
export declare function readWorkflowRunMaxCredits(metadata: Record<string, unknown> | null | undefined): number | null;
|
|
809
831
|
export declare function readManagedToolRunCredits(output: unknown): number;
|
|
@@ -8,6 +8,9 @@ export const BLUEPRINT_COMPILER_VERSION = "oxygen-blueprints-v1";
|
|
|
8
8
|
export const MAX_RECIPE_BUNDLE_BYTES = 2_000_000;
|
|
9
9
|
export const MAX_BLUEPRINT_BYTES = 4_000_000;
|
|
10
10
|
export const DEFAULT_WORKFLOW_CRON_TIMEZONE = "UTC";
|
|
11
|
+
// Step ids that must not be used: they collide with JavaScript object internals
|
|
12
|
+
// when the runtime assembles ctx.steps, so their outputs would be silently lost.
|
|
13
|
+
const RESERVED_STEP_IDS = new Set(["__proto__", "constructor", "prototype"]);
|
|
11
14
|
// Compatibility and determinism lint only. The Vercel sandbox process,
|
|
12
15
|
// denied network policy, and runtime global guards are the security boundary.
|
|
13
16
|
const UNSAFE_RECIPE_BUNDLE_PATTERNS = [
|
|
@@ -18,7 +21,11 @@ const UNSAFE_RECIPE_BUNDLE_PATTERNS = [
|
|
|
18
21
|
{ token: "XMLHttpRequest", pattern: /\bXMLHttpRequest\b/ }, // skipcq: SCT-A000
|
|
19
22
|
{ token: "WebSocket", pattern: /\bWebSocket\b/ },
|
|
20
23
|
{ token: "EventSource", pattern: /\bEventSource\b/ }, // skipcq: SCT-A000
|
|
21
|
-
|
|
24
|
+
// Block CommonJS require(...) but allow a `.require` method access (the
|
|
25
|
+
// ctx.approvals.require() durable-approval API). A real global require is a
|
|
26
|
+
// bare identifier; any X.require on a dangerous global (globalThis/process/…)
|
|
27
|
+
// is already caught by that global's own rule above.
|
|
28
|
+
{ token: "require", pattern: /(?<!\.)\brequire\b/ },
|
|
22
29
|
{ token: "dynamic import", pattern: /\bimport(?:\s|\/\*[\s\S]*?\*\/)*\(/ }, // skipcq: SCT-A000
|
|
23
30
|
{ token: "node module import", pattern: /\b(?:node:)?(?:fs|child_process|net|tls|http|https|dns|dgram|worker_threads)\b/ }, // skipcq: SCT-A000
|
|
24
31
|
{ token: "eval", pattern: /\beval\b/ },
|
|
@@ -44,6 +51,7 @@ export function defineWorkflow(input) {
|
|
|
44
51
|
...(input.specification ? { specification: input.specification } : {}),
|
|
45
52
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
46
53
|
...(input.inputSchema ? { inputSchema: input.inputSchema } : {}),
|
|
54
|
+
...(input.maxCredits != null ? { maxCredits: input.maxCredits } : {}),
|
|
47
55
|
steps: input.steps,
|
|
48
56
|
};
|
|
49
57
|
}
|
|
@@ -85,18 +93,37 @@ export function eventTrigger(input) {
|
|
|
85
93
|
...(input.status ? { status: input.status } : {}),
|
|
86
94
|
};
|
|
87
95
|
}
|
|
96
|
+
// Lookahead window for the next cron run. Must exceed the largest gap a *valid*
|
|
97
|
+
// schedule can have, or legitimate sparse crons get falsely rejected: the worst
|
|
98
|
+
// case is "Feb 29" across a century boundary (e.g. 2096 → 2104, an ~8-year gap,
|
|
99
|
+
// since 2100 is not a leap year). The day-skipping scan below keeps even this
|
|
100
|
+
// wide window cheap (~one zonedDateParts call per non-matching day).
|
|
101
|
+
const MAX_CRON_LOOKAHEAD_DAYS = 366 * 8 + 2;
|
|
88
102
|
export function nextCronRunAfter(input) {
|
|
89
103
|
const schedule = parseCronSchedule(input.cron);
|
|
90
104
|
const timezone = input.timezone?.trim() || DEFAULT_WORKFLOW_CRON_TIMEZONE;
|
|
91
105
|
let candidate = roundUpToNextMinute(input.after ?? new Date());
|
|
92
|
-
const deadline = candidate.getTime() +
|
|
106
|
+
const deadline = candidate.getTime() + MAX_CRON_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
|
|
93
107
|
while (candidate.getTime() <= deadline) {
|
|
94
|
-
|
|
108
|
+
const parts = zonedDateParts(candidate, timezone);
|
|
109
|
+
if (matchesCronSchedule(schedule, parts)) {
|
|
95
110
|
return candidate;
|
|
96
111
|
}
|
|
97
|
-
|
|
112
|
+
// If the calendar date itself can't match, no minute that day matches —
|
|
113
|
+
// jump to the next day instead of scanning all 1440 minutes. This keeps
|
|
114
|
+
// sparse schedules (e.g. "0 9 29 2 *") and unsatisfiable ones fast over the
|
|
115
|
+
// multi-year window. The big jump always lands at ~00:00 of the next day,
|
|
116
|
+
// so a subsequently matching date is scanned from its start (no missed
|
|
117
|
+
// earlier minutes).
|
|
118
|
+
if (!matchesCronDate(schedule, parts)) {
|
|
119
|
+
const minutesToNextDay = Math.max(1, 24 * 60 - (parts.hour * 60 + parts.minute));
|
|
120
|
+
candidate = new Date(candidate.getTime() + minutesToNextDay * 60_000);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
candidate = new Date(candidate.getTime() + 60_000);
|
|
124
|
+
}
|
|
98
125
|
}
|
|
99
|
-
throw new Error(
|
|
126
|
+
throw new Error(`Cron expression has no matching run time in the next ${MAX_CRON_LOOKAHEAD_DAYS} days.`);
|
|
100
127
|
}
|
|
101
128
|
export function transformStep(input) {
|
|
102
129
|
return {
|
|
@@ -116,9 +143,23 @@ export function toolStep(input) {
|
|
|
116
143
|
tool: input.tool,
|
|
117
144
|
...(input.effect ? { effect: input.effect } : {}),
|
|
118
145
|
...(input.mode ? { mode: input.mode } : {}),
|
|
146
|
+
...(input.maxCredits != null ? { maxCredits: input.maxCredits } : {}),
|
|
119
147
|
payload: input.payload,
|
|
120
148
|
};
|
|
121
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Conditional step. `condition` is evaluated; control then flows by mode:
|
|
152
|
+
*
|
|
153
|
+
* - then only (no `else`, no `join`) — GUARD: truthy continues to `then` (steps
|
|
154
|
+
* between the branch and `then` are skipped); falsy skips straight to the end
|
|
155
|
+
* of the workflow (no step after the branch runs) and the run completes.
|
|
156
|
+
* - then + else (no `join`) — skip-until-target jump to `then`/`else`; from the
|
|
157
|
+
* chosen target, the remaining steps run sequentially through the end.
|
|
158
|
+
* - then + else + join — real if/else: the non-chosen arm is skipped and `join`
|
|
159
|
+
* is the convergence point where shared downstream work resumes.
|
|
160
|
+
*
|
|
161
|
+
* All targets must be ids of later steps (branches are forward-only).
|
|
162
|
+
*/
|
|
122
163
|
export function branchStep(input) {
|
|
123
164
|
return {
|
|
124
165
|
__oxygen_workflow_step: true,
|
|
@@ -161,6 +202,7 @@ definition, options = {}) {
|
|
|
161
202
|
...(definition.specification ? { specification: definition.specification } : {}),
|
|
162
203
|
...(definition.trigger ? { trigger: definition.trigger } : {}),
|
|
163
204
|
...(definition.inputSchema ? { input_schema: definition.inputSchema } : {}),
|
|
205
|
+
...(definition.maxCredits != null ? { max_credits: definition.maxCredits } : {}),
|
|
164
206
|
steps: definition.steps.map((step) => {
|
|
165
207
|
if (step.kind === "transform") {
|
|
166
208
|
return {
|
|
@@ -188,6 +230,7 @@ definition, options = {}) {
|
|
|
188
230
|
tool: step.tool,
|
|
189
231
|
effect: step.effect ?? "external_read",
|
|
190
232
|
...(step.mode ? { mode: step.mode } : {}),
|
|
233
|
+
...(step.maxCredits != null ? { max_credits: step.maxCredits } : {}),
|
|
191
234
|
payload_source: serializeWorkflowFunction(step.payload, `steps.${step.id}.payload`),
|
|
192
235
|
};
|
|
193
236
|
}),
|
|
@@ -218,12 +261,23 @@ export function buildRecipeManifest(input) {
|
|
|
218
261
|
bundle_format: "esm",
|
|
219
262
|
tools_used: Array.from(new Set((input.toolsUsed ?? []).filter(Boolean))).sort(),
|
|
220
263
|
...(input.visualPlan ? { visual_plan: input.visualPlan } : {}),
|
|
264
|
+
...(input.maxCredits != null ? { max_credits: input.maxCredits } : {}),
|
|
221
265
|
source_hash: sourceHash,
|
|
222
266
|
compiler_version: DURABLE_RECIPE_COMPILER_VERSION,
|
|
223
267
|
created_at: (input.createdAt ?? new Date()).toISOString(),
|
|
224
268
|
};
|
|
225
269
|
return manifest;
|
|
226
270
|
}
|
|
271
|
+
// Shared positive-finite validator for the optional per-run / per-step
|
|
272
|
+
// max_credits caps (Gap 2). `undefined` is valid (no cap); anything else must be
|
|
273
|
+
// a positive finite number, or lint rejects the manifest.
|
|
274
|
+
function validateOptionalMaxCredits(value, path, add) {
|
|
275
|
+
if (value === undefined)
|
|
276
|
+
return;
|
|
277
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
278
|
+
add(path, "invalid_max_credits", "max_credits must be a positive number.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
227
281
|
export function lintWorkflowManifest(// skipcq: JS-R1005
|
|
228
282
|
value, options = {}) {
|
|
229
283
|
if (isRecord(value)
|
|
@@ -255,6 +309,7 @@ value, options = {}) {
|
|
|
255
309
|
}
|
|
256
310
|
if (value.trigger !== undefined)
|
|
257
311
|
validateTrigger(value.trigger, "$.trigger", add);
|
|
312
|
+
validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
|
|
258
313
|
if (!Array.isArray(value.steps) || value.steps.length === 0) {
|
|
259
314
|
add("$.steps", "missing_steps", "At least one workflow step is required.");
|
|
260
315
|
}
|
|
@@ -278,6 +333,15 @@ value, options = {}) {
|
|
|
278
333
|
if (!isNonEmptyString(step.id)) {
|
|
279
334
|
add(`${path}.id`, "invalid_step_id", "Step id is required.");
|
|
280
335
|
}
|
|
336
|
+
else if (RESERVED_STEP_IDS.has(step.id)) {
|
|
337
|
+
// Step ids become keys on the ctx.steps object the runtime assembles.
|
|
338
|
+
// "__proto__" / "constructor" / "prototype" collide with JavaScript
|
|
339
|
+
// object internals: assigning ctx.steps["__proto__"] = output hits the
|
|
340
|
+
// prototype setter, so the output is silently dropped from step outputs
|
|
341
|
+
// and unreadable downstream while the run still reports success. Reject
|
|
342
|
+
// at authoring time instead of letting that data loss happen at runtime.
|
|
343
|
+
add(`${path}.id`, "reserved_step_id", `Step id '${step.id}' is reserved (it collides with a JavaScript object key and would be dropped from step outputs). Choose another id.`);
|
|
344
|
+
}
|
|
281
345
|
else if (ids.has(step.id)) {
|
|
282
346
|
add(`${path}.id`, "duplicate_step_id", `Step id '${step.id}' is duplicated.`);
|
|
283
347
|
}
|
|
@@ -320,6 +384,7 @@ value, options = {}) {
|
|
|
320
384
|
&& step.mode !== "smoke_test") {
|
|
321
385
|
add(`${path}.mode`, "invalid_step_mode", "Step mode is invalid.");
|
|
322
386
|
}
|
|
387
|
+
validateOptionalMaxCredits(step.max_credits, `${path}.max_credits`, add);
|
|
323
388
|
return;
|
|
324
389
|
}
|
|
325
390
|
if (step.kind === "branch") {
|
|
@@ -390,6 +455,7 @@ value, options = {}) {
|
|
|
390
455
|
if (value.bundle_format !== "esm") {
|
|
391
456
|
add("$.bundle_format", "invalid_bundle_format", "Recipe bundle_format must be 'esm'.");
|
|
392
457
|
}
|
|
458
|
+
validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
|
|
393
459
|
if (value.runtime !== "durable") {
|
|
394
460
|
add("$.runtime", "invalid_recipe_runtime", "Durable recipe manifests must set runtime to durable.");
|
|
395
461
|
}
|
|
@@ -802,25 +868,52 @@ export async function runPureWorkflowFunction(input) {
|
|
|
802
868
|
sandbox.setImmediate = undefined;
|
|
803
869
|
sandbox.queueMicrotask = undefined;
|
|
804
870
|
sandbox.WebAssembly = undefined;
|
|
805
|
-
const script = new vm.Script(
|
|
806
|
-
+
|
|
871
|
+
const script = new vm.Script('"use strict";\n'
|
|
872
|
+
+ 'const __oxygen_context = JSON.parse(__oxygen_context_json);\n'
|
|
807
873
|
+ `const __oxygen_fn = (${input.source});\n`
|
|
808
|
-
+
|
|
874
|
+
+ '__oxygen_fn(__oxygen_context);');
|
|
809
875
|
// Disable dynamic code generation in the sandboxed context. Combined with
|
|
810
876
|
// the JSON re-parse above, this means every prototype-walk path to
|
|
811
877
|
// Function — direct, via context.constructor.constructor, or any other
|
|
812
878
|
// reachable Function instance — throws EvalError when called with source.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
879
|
+
try {
|
|
880
|
+
const result = script.runInNewContext(sandbox, {
|
|
881
|
+
timeout: timeoutMs,
|
|
882
|
+
contextCodeGeneration: {
|
|
883
|
+
strings: false,
|
|
884
|
+
wasm: false,
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
const resolved = isPromiseLike(result)
|
|
888
|
+
? await withTimeout(result, timeoutMs)
|
|
889
|
+
: result;
|
|
890
|
+
return enforceJsonOutput(resolved, maxOutputBytes);
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
throw normalizeSandboxThrow(error);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// A throw from inside the vm sandbox is constructed with the sandbox realm's
|
|
897
|
+
// Error, so `error instanceof Error` is false in the host worker — and
|
|
898
|
+
// serializeOxygenError then drops the message and reports a generic
|
|
899
|
+
// "Workflow run failed.", leaving a failing transform/branch undebuggable.
|
|
900
|
+
// Reconstruct a host-realm Error preserving the user's message/name. Host-realm
|
|
901
|
+
// throws (vm timeout, enforceJsonOutput's workflow_output_too_large) already
|
|
902
|
+
// pass instanceof and are returned unchanged.
|
|
903
|
+
function normalizeSandboxThrow(error) {
|
|
904
|
+
if (error instanceof Error)
|
|
905
|
+
return error;
|
|
906
|
+
if (error && typeof error === "object") {
|
|
907
|
+
const record = error;
|
|
908
|
+
const message = typeof record.message === "string" && record.message
|
|
909
|
+
? record.message
|
|
910
|
+
: "Workflow function threw.";
|
|
911
|
+
const normalized = new Error(message);
|
|
912
|
+
if (typeof record.name === "string" && record.name)
|
|
913
|
+
normalized.name = record.name;
|
|
914
|
+
return normalized;
|
|
915
|
+
}
|
|
916
|
+
return new Error(typeof error === "string" && error ? error : "Workflow function threw a non-error value.");
|
|
824
917
|
}
|
|
825
918
|
export const workflowApplySchema = {
|
|
826
919
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -1077,6 +1170,19 @@ value, path, add) {
|
|
|
1077
1170
|
add(`${path}.cron`, "invalid_cron", error instanceof Error ? error.message : "Cron expression is invalid.");
|
|
1078
1171
|
}
|
|
1079
1172
|
}
|
|
1173
|
+
// Validate the timezone here so an unknown zone fails lint with a typed
|
|
1174
|
+
// issue, rather than passing lint and blowing up at apply time (the
|
|
1175
|
+
// scheduler's Intl.DateTimeFormat throws on an unknown zone). null/empty
|
|
1176
|
+
// are allowed — the scheduler falls back to UTC.
|
|
1177
|
+
const { timezone } = value;
|
|
1178
|
+
if (timezone !== undefined && timezone !== null) {
|
|
1179
|
+
if (typeof timezone !== "string") {
|
|
1180
|
+
add(`${path}.timezone`, "invalid_timezone", "Cron trigger timezone must be a string IANA timezone.");
|
|
1181
|
+
}
|
|
1182
|
+
else if (timezone.trim() && !isValidTimeZone(timezone.trim())) {
|
|
1183
|
+
add(`${path}.timezone`, "invalid_timezone", `Unknown timezone '${timezone}'. Use an IANA timezone such as 'America/New_York' or 'UTC'.`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1080
1186
|
return;
|
|
1081
1187
|
}
|
|
1082
1188
|
if (value.type === "event") {
|
|
@@ -1272,11 +1378,9 @@ function normalizeDaysOfWeek(values) {
|
|
|
1272
1378
|
}
|
|
1273
1379
|
return normalized;
|
|
1274
1380
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (!schedule.hours.has(parts.hour))
|
|
1279
|
-
return false;
|
|
1381
|
+
// Date-only match (month + day-of-month/day-of-week), excluding hour/minute.
|
|
1382
|
+
// Lets nextCronRunAfter skip whole non-matching days in one jump.
|
|
1383
|
+
function matchesCronDate(schedule, parts) {
|
|
1280
1384
|
if (!schedule.months.has(parts.month))
|
|
1281
1385
|
return false;
|
|
1282
1386
|
const dayOfMonthMatches = schedule.daysOfMonth.has(parts.dayOfMonth);
|
|
@@ -1286,6 +1390,13 @@ function matchesCronSchedule(schedule, parts) {
|
|
|
1286
1390
|
}
|
|
1287
1391
|
return dayOfMonthMatches && dayOfWeekMatches;
|
|
1288
1392
|
}
|
|
1393
|
+
function matchesCronSchedule(schedule, parts) {
|
|
1394
|
+
if (!schedule.minutes.has(parts.minute))
|
|
1395
|
+
return false;
|
|
1396
|
+
if (!schedule.hours.has(parts.hour))
|
|
1397
|
+
return false;
|
|
1398
|
+
return matchesCronDate(schedule, parts);
|
|
1399
|
+
}
|
|
1289
1400
|
function roundUpToNextMinute(value) {
|
|
1290
1401
|
const result = new Date(value.getTime());
|
|
1291
1402
|
result.setUTCSeconds(0, 0);
|
|
@@ -1309,6 +1420,17 @@ function zonedDateParts(date, timezone) {
|
|
|
1309
1420
|
dayOfWeek: weekdayToNumber(parts.get("weekday") ?? ""),
|
|
1310
1421
|
};
|
|
1311
1422
|
}
|
|
1423
|
+
function isValidTimeZone(timezone) {
|
|
1424
|
+
try {
|
|
1425
|
+
// Throws RangeError for an unknown IANA zone; same mechanism the scheduler
|
|
1426
|
+
// uses, so lint accepts exactly the zones the runtime can resolve.
|
|
1427
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
return false;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1312
1434
|
function getZonedFormatter(timezone) {
|
|
1313
1435
|
const cached = zonedFormatters.get(timezone);
|
|
1314
1436
|
if (cached)
|
|
@@ -1349,6 +1471,25 @@ function weekdayToNumber(value) {
|
|
|
1349
1471
|
function hashWorkflowSource(source) {
|
|
1350
1472
|
return createHash("sha256").update(source).digest("hex");
|
|
1351
1473
|
}
|
|
1474
|
+
// Deterministic plan hash (Gap 4): binds an approval decision to the exact
|
|
1475
|
+
// reviewed plan + cost so it cannot be replayed against a changed plan. The
|
|
1476
|
+
// durable mid-run approval gate (recipe-context) hashes its relevant fields
|
|
1477
|
+
// through this; binding the imperative `workflows call` admission path the same
|
|
1478
|
+
// way is a flagged follow-up. Canonical (sorted-key) JSON keeps the hash stable
|
|
1479
|
+
// regardless of key order or undefined fields.
|
|
1480
|
+
export function computeWorkflowPlanHash(input) {
|
|
1481
|
+
return hashWorkflowSource(canonicalJsonStringify(input));
|
|
1482
|
+
}
|
|
1483
|
+
function canonicalJsonStringify(value) {
|
|
1484
|
+
if (value === null || typeof value !== "object")
|
|
1485
|
+
return JSON.stringify(value) ?? "null";
|
|
1486
|
+
if (Array.isArray(value))
|
|
1487
|
+
return `[${value.map(canonicalJsonStringify).join(",")}]`;
|
|
1488
|
+
const entries = Object.entries(value)
|
|
1489
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
1490
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1491
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJsonStringify(entryValue)}`).join(",")}}`;
|
|
1492
|
+
}
|
|
1352
1493
|
function workflowJsonReplacer(_key, value) {
|
|
1353
1494
|
return typeof value === "function" ? Function.prototype.toString.call(value) : value;
|
|
1354
1495
|
}
|