@growthub/cli 0.14.9 → 0.14.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/callback/route.js +35 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/[productId]/resources/route.js +173 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +3 -20
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +29 -19
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +607 -63
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +21 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
  51. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
  52. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/provider.png +0 -0
  53. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
  54. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
  55. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
  56. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
  57. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -0
  58. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +6 -0
  59. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +3 -1
  60. package/dist/index.js +3024 -4191
  61. package/package.json +1 -1
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Serverless-schedule readiness scan — the causality gate that proves a workflow
3
+ * graph is SAFE to run in the selected serverless runtime BEFORE the schedule is
4
+ * allowed to bind / publish / remain active.
5
+ *
6
+ * Scheduler install succeeding (remote schedule exists, row owns scheduleId,
7
+ * trigger node is serverless-scheduler, signed destination/callback validate)
8
+ * proves the BINDING. It does NOT prove the downstream graph can actually run
9
+ * with no human at the keyboard and no local agent state. This module produces
10
+ * that missing COMPATIBILITY proof:
11
+ *
12
+ * - every downstream node can run in the selected serverless runtime
13
+ * - all API Registry dependencies resolve through server-side env refs
14
+ * (`server-secrets`), never browser/client/local-only state
15
+ * - no secret values are persisted into config / graph / receipts / payloads
16
+ * - required inputs are available through the trigger / runInputs / delta-tag
17
+ * contract (the same envelope the serverless destination consumes)
18
+ * - no local-only agent / process / browser / filesystem state is required
19
+ * unless explicitly upgraded
20
+ *
21
+ * It is PURE and dependency-injected (graph + workspace config + env in, a
22
+ * structured verdict out) so the same scan runs offline in `node --test`, in the
23
+ * install orchestration core (pre-bind gate), in the publish gate (bound phase),
24
+ * and on resume — one truth, four call sites.
25
+ *
26
+ * When the graph is NOT compatible the scan does not fail vaguely: it returns a
27
+ * thin, actionable delta layer (`blockingNodes` + `warnings`, each carrying
28
+ * canonical `deltaTags` and a `helperAction`) that callers surface to users and
29
+ * agents and persist as a draft delta / blocked receipt.
30
+ */
31
+
32
+ import { parseOrchestrationGraph } from "./orchestration-graph.js";
33
+ import { readServerSecret, envKeyCandidates } from "./server-secrets.js";
34
+
35
+ const READINESS_KIND = "serverless-schedule-readiness";
36
+
37
+ /** Canonical delta tags — one vocabulary across code / receipts / docs / UI. */
38
+ const READINESS_DELTA_TAGS = {
39
+ SERVERLESS_SCHEDULE: "serverless-schedule",
40
+ RUNTIME_LOCALITY: "runtime-locality",
41
+ INPUT_CONTRACT: "input-contract",
42
+ API_REGISTRY_ENV: "api-registry-env",
43
+ LOCAL_AGENT_UPGRADE_REQUIRED: "local-agent-upgrade-required",
44
+ DOWNSTREAM_NODE_INCOMPATIBLE: "downstream-node-incompatible",
45
+ MISSING_SERVER_SECRET: "missing-server-secret",
46
+ SCHEDULED_INPUT_UNMAPPED: "scheduled-input-unmapped",
47
+ PUBLISHED_GRAPH_REQUIRED: "published-graph-required",
48
+ };
49
+
50
+ // Adapters whose execution requires LOCAL agent/process state and therefore
51
+ // cannot be silently scheduled as serverless — they must be upgraded to an
52
+ // API-backed runtime first. Mirrors `SERVERLESS_LOCAL_ADAPTERS` in
53
+ // workspace-add-ons.js (the bind helper normalizes these; the scan refuses to
54
+ // schedule them silently and emits an explicit upgrade delta instead).
55
+ const LOCAL_ONLY_ADAPTERS = new Set(["local-agent-host", "local-intelligence"]);
56
+
57
+ function clean(value) {
58
+ return String(value == null ? "" : value).trim();
59
+ }
60
+
61
+ /** Runtime-live graph field precedence — must match the runner. */
62
+ function liveGraphFieldForRow(row) {
63
+ return parseOrchestrationGraph(row?.orchestrationGraph) ? "orchestrationGraph" : "orchestrationConfig";
64
+ }
65
+
66
+ /** Collect `{{input.X}}` keys referenced anywhere in a string/template. */
67
+ function collectInputRefs(text, out = new Set()) {
68
+ const str = String(text == null ? "" : text);
69
+ if (!str.includes("{{")) return out;
70
+ const re = /\{\{\s*input\.([a-zA-Z0-9_.]+)\s*\}\}/g;
71
+ let m;
72
+ while ((m = re.exec(str))) out.add(m[1]);
73
+ return out;
74
+ }
75
+
76
+ /** Recursively collect input refs from any nested config value. */
77
+ function collectInputRefsDeep(value, out = new Set()) {
78
+ if (value == null) return out;
79
+ if (typeof value === "string") return collectInputRefs(value, out);
80
+ if (Array.isArray(value)) {
81
+ for (const item of value) collectInputRefsDeep(item, out);
82
+ return out;
83
+ }
84
+ if (typeof value === "object") {
85
+ for (const v of Object.values(value)) collectInputRefsDeep(v, out);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Top-level keys available to a scheduled run (no human input). */
91
+ function collectAvailableInputKeys(inputNode, triggerInput) {
92
+ const keys = new Set();
93
+ const sample = inputNode?.config?.samplePayload;
94
+ const addObjectKeys = (obj) => {
95
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
96
+ for (const k of Object.keys(obj)) keys.add(k);
97
+ }
98
+ };
99
+ if (sample && typeof sample === "object") addObjectKeys(sample);
100
+ else if (typeof sample === "string" && sample.trim()) {
101
+ try { addObjectKeys(JSON.parse(sample)); } catch { /* ignore */ }
102
+ }
103
+ // The scheduled payload the serverless destination feeds into runInputs.
104
+ if (triggerInput && typeof triggerInput === "object") addObjectKeys(triggerInput);
105
+ else if (typeof triggerInput === "string" && triggerInput.trim()) {
106
+ try { addObjectKeys(JSON.parse(triggerInput)); } catch { /* ignore */ }
107
+ }
108
+ return keys;
109
+ }
110
+
111
+ /** A referenced `a.b.c` key is satisfied if its ROOT segment is available. */
112
+ function refRootSatisfied(ref, availableKeys) {
113
+ const root = String(ref || "").split(".")[0];
114
+ return availableKeys.has(root);
115
+ }
116
+
117
+ function findRegistryRow(workspaceConfig, registryId) {
118
+ const id = clean(registryId);
119
+ if (!id) return null;
120
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
121
+ for (const object of objects) {
122
+ const objectType = clean(object?.objectType);
123
+ const objectId = clean(object?.id || object?.objectId);
124
+ if (objectType !== "api-registry" && objectId !== "api-registry") continue;
125
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
126
+ if (clean(row?.integrationId) === id || clean(row?.id) === id || clean(row?.Name) === id) return row;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /** Upper-snake ref root — same normalization the serverless drivers use. */
133
+ function refRoot(ref) {
134
+ return clean(ref).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toUpperCase();
135
+ }
136
+
137
+ /**
138
+ * Is a logical authRef's credential available in the SELECTED runtime? This is
139
+ * the same secret-safe contract the existing serverless causation drivers use:
140
+ * the CLIENT proves presence through `configuredEnvRefs` (resolved ref slugs from
141
+ * env-status — never a value), and the SERVER may additionally resolve the value
142
+ * through `server-secrets` (`env`) so it can also detect a persisted-secret leak.
143
+ * Returns `{ configured, value }` — `value` is only ever set server-side.
144
+ */
145
+ function resolveCredentialRef(authRef, configuredRefSet, env) {
146
+ const root = refRoot(authRef);
147
+ if (!root) return { configured: false, value: "" };
148
+ if (configuredRefSet && configuredRefSet.has(root)) return { configured: true, value: "" };
149
+ if (configuredRefSet) {
150
+ for (const candidate of envKeyCandidates(authRef)) {
151
+ if (configuredRefSet.has(candidate)) return { configured: true, value: "" };
152
+ }
153
+ }
154
+ if (env) {
155
+ const hit = readServerSecret(authRef, env);
156
+ if (hit) return { configured: true, value: hit.value };
157
+ }
158
+ return { configured: false, value: "" };
159
+ }
160
+
161
+ /** Does a stored object literally contain a secret VALUE (leak)? */
162
+ function containsSecretValue(obj, secretValue) {
163
+ const secret = clean(secretValue);
164
+ if (!secret || secret.length < 6) return false;
165
+ try {
166
+ return JSON.stringify(obj || {}).includes(secret);
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Decide whether a node is LOCAL-ONLY (cannot run in the serverless runtime).
174
+ * Conservative: an ai-agent / sandbox-adapter node is incompatible unless it is
175
+ * provably API-backed (registry-backed or an API provider/model declared).
176
+ */
177
+ function classifyNodeLocality(node, { adapterLocality } = {}) {
178
+ const type = clean(node?.type);
179
+ const config = node?.config && typeof node.config === "object" ? node.config : {};
180
+ // Explicit local-state requirements on any node.
181
+ if (config.requiresLocalFilesystem === true || config.requiresBrowser === true || config.browserAccess === true || config.local === true) {
182
+ return { local: true, reason: "node declares local filesystem / browser / desktop state" };
183
+ }
184
+ const adapterId = clean(config.adapter || node?.adapter);
185
+ if (adapterId && LOCAL_ONLY_ADAPTERS.has(adapterId)) {
186
+ return { local: true, reason: `node adapter "${adapterId}" requires local agent state` };
187
+ }
188
+ if (adapterId && typeof adapterLocality === "function") {
189
+ const locality = clean(adapterLocality(adapterId));
190
+ if (locality === "local") return { local: true, reason: `node adapter "${adapterId}" is local-only` };
191
+ }
192
+ if (type === "ai-agent") {
193
+ const apiBacked =
194
+ config.apiBacked === true ||
195
+ Boolean(clean(config.registryId) || clean(config.integrationId)) ||
196
+ ["claude", "anthropic", "openai", "api", "api-registry"].includes(clean(config.provider).toLowerCase()) ||
197
+ clean(config.runtime).toLowerCase() === "api";
198
+ const host = clean(config.host || config.agentHost);
199
+ const localHost = /_local$|^local-/.test(host) || clean(config.runtime).toLowerCase() === "local";
200
+ if (!apiBacked || localHost) {
201
+ return { local: true, reason: `ai-agent node "${node?.id || ""}" is not API-backed (host=${host || "n/a"})` };
202
+ }
203
+ }
204
+ return { local: false, reason: "" };
205
+ }
206
+
207
+ /**
208
+ * Run the serverless-readiness scan against a workflow row's live graph.
209
+ *
210
+ * @param {object} args
211
+ * @param {object} args.row the owning sandbox-environment row
212
+ * @param {object} args.workspaceConfig full workspace config (to resolve API Registry rows)
213
+ * @param {string[]} [args.configuredEnvRefs] resolved credential ref slugs (env-status) — the
214
+ * secret-safe, CLIENT-usable credential signal (no values)
215
+ * @param {object} [args.env] server-only injectable env; enables persisted-secret-leak
216
+ * detection. Omit on the client (use configuredEnvRefs).
217
+ * @param {object} [args.expected] { scheduleId, schedulerRegistryId, providerId, productId }
218
+ * @param {"pre-bind"|"bound"} [args.phase] pre-bind: binding not yet written; bound: full trigger check
219
+ * @param {Function} [args.adapterLocality] optional (adapterId) => "local"|"serverless"|"remote"
220
+ * @returns {object} readiness verdict (kind/status/ok/blockingNodes/warnings/deltaTags/checks)
221
+ */
222
+ function scanServerlessReadiness({
223
+ row,
224
+ workspaceConfig,
225
+ configuredEnvRefs = [],
226
+ env = null,
227
+ expected = {},
228
+ phase = "pre-bind",
229
+ adapterLocality,
230
+ } = {}) {
231
+ const configuredRefSet = new Set((Array.isArray(configuredEnvRefs) ? configuredEnvRefs : []).map((s) => refRoot(s)).filter(Boolean));
232
+ const workflowRow = clean(row?.Name);
233
+ const liveField = liveGraphFieldForRow(row);
234
+ const checks = [];
235
+ const addCheck = (check) => {
236
+ checks.push({
237
+ status: "ok",
238
+ deltaTags: [],
239
+ ...check,
240
+ });
241
+ };
242
+
243
+ const graph = parseOrchestrationGraph(row?.[liveField] || row?.orchestrationGraph || row?.orchestrationConfig);
244
+
245
+ // ---- Graph-level gate: a published/live graph must exist to scan. --------
246
+ if (!graph || !Array.isArray(graph.nodes) || !graph.nodes.length) {
247
+ addCheck({
248
+ nodeId: null,
249
+ nodeType: "graph",
250
+ status: "blocked",
251
+ reason: "no published/live orchestration graph to scan for serverless readiness",
252
+ deltaTags: [READINESS_DELTA_TAGS.PUBLISHED_GRAPH_REQUIRED, READINESS_DELTA_TAGS.SERVERLESS_SCHEDULE],
253
+ helperAction: "Publish the workflow graph (orchestrationGraph/orchestrationConfig) before binding a serverless schedule.",
254
+ });
255
+ return finalize({ workflowRow, liveField, triggerNodeId: null, checks });
256
+ }
257
+
258
+ const nodes = graph.nodes.filter((n) => n && typeof n === "object");
259
+ const inputNode = nodes.find((n) => n.type === "input" || n.id === "input") || null;
260
+ const triggerNode = nodes.find((n) => n.type === "data-trigger") || inputNode;
261
+ const triggerNodeId = clean(triggerNode?.id) || null;
262
+
263
+ // Reachable-from-trigger set (linear graphs => all nodes). Falls back to all
264
+ // nodes when there are no edges, matching how the runner executes the chain.
265
+ const reachable = reachableNodeIds(graph, triggerNodeId);
266
+
267
+ // ---- 1. Input / trigger node -------------------------------------------
268
+ {
269
+ const expectedScheduleId = clean(expected.scheduleId);
270
+ const expectedRegistryId = clean(expected.schedulerRegistryId);
271
+ if (phase === "bound") {
272
+ const cfg = triggerNode?.config && typeof triggerNode.config === "object" ? triggerNode.config : {};
273
+ const schedule = cfg.schedule && typeof cfg.schedule === "object" ? cfg.schedule : {};
274
+ const rowScheduleId = clean(row?.scheduleId);
275
+ const rowRegistryId = clean(row?.schedulerRegistryId);
276
+ const triggerIsScheduler = clean(cfg.trigger) === "serverless-scheduler" && cfg.enabled !== false;
277
+ const scheduleIdAgrees =
278
+ clean(schedule.scheduleId) &&
279
+ clean(schedule.scheduleId) === rowScheduleId &&
280
+ (!expectedScheduleId || clean(schedule.scheduleId) === expectedScheduleId);
281
+ const registryAgrees =
282
+ clean(schedule.schedulerRegistryId) === rowRegistryId &&
283
+ (!expectedRegistryId || clean(schedule.schedulerRegistryId) === expectedRegistryId);
284
+ if (!triggerIsScheduler || !scheduleIdAgrees || !registryAgrees) {
285
+ addCheck({
286
+ nodeId: triggerNodeId,
287
+ nodeType: clean(triggerNode?.type) || "input",
288
+ status: "blocked",
289
+ reason: `trigger node binding does not agree with the owning row (trigger=${clean(cfg.trigger) || "manual"}, trigger.scheduleId=${clean(schedule.scheduleId) || "none"}, row.scheduleId=${rowScheduleId || "none"})`,
290
+ deltaTags: [READINESS_DELTA_TAGS.INPUT_CONTRACT, READINESS_DELTA_TAGS.SERVERLESS_SCHEDULE],
291
+ helperAction: "Re-install the serverless schedule so the trigger node, the owning row, and the remote schedule all carry the same scheduleId.",
292
+ });
293
+ } else {
294
+ addCheck({ nodeId: triggerNodeId, nodeType: clean(triggerNode?.type) || "input", status: "ok", reason: "trigger node bound to serverless-scheduler and agrees with the row" });
295
+ }
296
+ } else {
297
+ // pre-bind: the bind itself will sync the trigger node; just confirm an
298
+ // input/trigger entry point exists for the bind to attach to.
299
+ addCheck({
300
+ nodeId: triggerNodeId,
301
+ nodeType: clean(triggerNode?.type) || "input",
302
+ status: triggerNodeId ? "ok" : "warning",
303
+ reason: triggerNodeId ? "entry trigger/input node present for serverless bind" : "no input/data-trigger node — a canonical schedule-trigger node will be created on bind",
304
+ deltaTags: triggerNodeId ? [] : [READINESS_DELTA_TAGS.SERVERLESS_SCHEDULE],
305
+ });
306
+ }
307
+ }
308
+
309
+ // ---- Build the scheduled-input contract (no human at the keyboard) ------
310
+ const availableInputKeys = collectAvailableInputKeys(inputNode, expected.triggerInput ?? row?.schedulerTriggerInput);
311
+
312
+ // ---- Walk downstream nodes ---------------------------------------------
313
+ for (const node of nodes) {
314
+ const nodeId = clean(node.id);
315
+ if (triggerNodeId && nodeId === triggerNodeId) continue; // handled above
316
+ if (reachable && reachable.size && nodeId && !reachable.has(nodeId)) {
317
+ addCheck({ nodeId, nodeType: clean(node.type), status: "warning", reason: "node is not reachable from the trigger node", deltaTags: [READINESS_DELTA_TAGS.DOWNSTREAM_NODE_INCOMPATIBLE] });
318
+ continue;
319
+ }
320
+ const type = clean(node.type);
321
+
322
+ // 4. Agent / local-process nodes — local-only state cannot be scheduled.
323
+ const locality = classifyNodeLocality(node, { adapterLocality });
324
+ if (locality.local) {
325
+ addCheck({
326
+ nodeId,
327
+ nodeType: type,
328
+ status: "blocked",
329
+ reason: locality.reason,
330
+ deltaTags: [READINESS_DELTA_TAGS.RUNTIME_LOCALITY, READINESS_DELTA_TAGS.LOCAL_AGENT_UPGRADE_REQUIRED],
331
+ helperAction: "Upgrade this node to an API-backed agent/runtime (e.g. Claude/OpenAI/API Registry backed) before serverless scheduling.",
332
+ });
333
+ continue;
334
+ }
335
+
336
+ // 2. API Registry call nodes — must resolve through server-side env refs.
337
+ if (type === "api-registry-call") {
338
+ checkApiRegistryNode({ node, nodeId, workspaceConfig, configuredRefSet, env, availableInputKeys, addCheck });
339
+ continue;
340
+ }
341
+
342
+ // 3. Transform / filter / mapping nodes — every referenced input must exist.
343
+ if (type === "transform-filter" || type === "normalize-output") {
344
+ const refs = collectInputRefsDeep(node.config);
345
+ const unmapped = [...refs].filter((ref) => !refRootSatisfied(ref, availableInputKeys));
346
+ if (unmapped.length) {
347
+ addCheck({
348
+ nodeId,
349
+ nodeType: type,
350
+ status: "warning",
351
+ reason: `transform references input field(s) not available under scheduled execution: ${unmapped.join(", ")}`,
352
+ deltaTags: [READINESS_DELTA_TAGS.INPUT_CONTRACT, READINESS_DELTA_TAGS.SCHEDULED_INPUT_UNMAPPED],
353
+ helperAction: "Map these fields into the schedule's triggerInput (or the input node samplePayload) so the scheduled run feeds them downstream.",
354
+ });
355
+ } else {
356
+ addCheck({ nodeId, nodeType: type, status: "ok", reason: "transform inputs satisfied under scheduled execution" });
357
+ }
358
+ continue;
359
+ }
360
+
361
+ // 5. Tool-result / output node — must be able to write run proof back.
362
+ if (type === "tool-result") {
363
+ const writeOff = node?.config?.writeLastResponse === false;
364
+ addCheck({
365
+ nodeId,
366
+ nodeType: type,
367
+ status: writeOff ? "warning" : "ok",
368
+ reason: writeOff
369
+ ? "result node has writeLastResponse disabled — the serverless bind will enable it so scheduled-run proof syncs back to the row"
370
+ : "result node writes scheduled-run proof back to the owning row",
371
+ deltaTags: writeOff ? [READINESS_DELTA_TAGS.SERVERLESS_SCHEDULE] : [],
372
+ });
373
+ continue;
374
+ }
375
+
376
+ addCheck({ nodeId, nodeType: type || "unknown", status: "ok", reason: "node has no local-only runtime requirement" });
377
+ }
378
+
379
+ return finalize({ workflowRow, liveField, triggerNodeId, checks });
380
+ }
381
+
382
+ function checkApiRegistryNode({ node, nodeId, workspaceConfig, configuredRefSet, env, availableInputKeys, addCheck }) {
383
+ const config = node?.config && typeof node.config === "object" ? node.config : {};
384
+ const registryId = clean(config.registryId || config.integrationId);
385
+ if (!registryId) {
386
+ addCheck({
387
+ nodeId,
388
+ nodeType: "api-registry-call",
389
+ status: "blocked",
390
+ reason: "api-registry-call node has no registryId/integrationId",
391
+ deltaTags: [READINESS_DELTA_TAGS.DOWNSTREAM_NODE_INCOMPATIBLE, READINESS_DELTA_TAGS.API_REGISTRY_ENV],
392
+ helperAction: "Point this API call node at a concrete API Registry row (integrationId) before serverless scheduling.",
393
+ });
394
+ return;
395
+ }
396
+ const registryRow = findRegistryRow(workspaceConfig, registryId);
397
+ if (!registryRow) {
398
+ addCheck({
399
+ nodeId,
400
+ nodeType: "api-registry-call",
401
+ status: "blocked",
402
+ reason: `no API Registry row resolves for integrationId ${registryId}`,
403
+ deltaTags: [READINESS_DELTA_TAGS.DOWNSTREAM_NODE_INCOMPATIBLE, READINESS_DELTA_TAGS.API_REGISTRY_ENV],
404
+ helperAction: `Register/install the API Registry row "${registryId}" so its server-side identity resolves at scheduled run time.`,
405
+ });
406
+ return;
407
+ }
408
+ if (!clean(registryRow.integrationId)) {
409
+ addCheck({
410
+ nodeId,
411
+ nodeType: "api-registry-call",
412
+ status: "blocked",
413
+ reason: `API Registry row for ${registryId} has no concrete integrationId`,
414
+ deltaTags: [READINESS_DELTA_TAGS.API_REGISTRY_ENV, READINESS_DELTA_TAGS.DOWNSTREAM_NODE_INCOMPATIBLE],
415
+ helperAction: "Give the referenced API Registry row a concrete integrationId / registry identity.",
416
+ });
417
+ return;
418
+ }
419
+
420
+ // Server-side credential proof. authRef is a logical ref resolved through
421
+ // server-secrets — NEVER a browser/client/local value. When the row declares
422
+ // an authRef, the credential must resolve in the serverless runtime.
423
+ const authRef = clean(config.authRef || registryRow.authRef || registryId);
424
+ const declaresAuth = Boolean(clean(config.authRef || registryRow.authRef));
425
+ const credential = resolveCredentialRef(authRef, configuredRefSet, env);
426
+ if (declaresAuth && !credential.configured) {
427
+ addCheck({
428
+ nodeId,
429
+ nodeType: "api-registry-call",
430
+ status: "blocked",
431
+ reason: `missing server-side credential for API Registry row "${registryId}" (authRef ${authRef})`,
432
+ deltaTags: [READINESS_DELTA_TAGS.API_REGISTRY_ENV, READINESS_DELTA_TAGS.MISSING_SERVER_SECRET],
433
+ helperAction: "Connect the API Registry row to a server-side env ref via server-secrets before publishing the schedule.",
434
+ });
435
+ return;
436
+ }
437
+
438
+ // No secret VALUE may be persisted into the graph node config or the registry
439
+ // row (config/graph/receipts/client payloads stay value-free; only refs). This
440
+ // check only runs server-side, where the value is resolvable (credential.value).
441
+ const leaked =
442
+ containsSecretValue(config, credential.value) ||
443
+ containsSecretValue(registryRow, credential.value);
444
+ if (leaked) {
445
+ addCheck({
446
+ nodeId,
447
+ nodeType: "api-registry-call",
448
+ status: "blocked",
449
+ reason: `a credential VALUE is persisted in workspace/graph config for "${registryId}" — secrets must stay server-side as refs`,
450
+ deltaTags: [READINESS_DELTA_TAGS.MISSING_SERVER_SECRET, READINESS_DELTA_TAGS.API_REGISTRY_ENV],
451
+ helperAction: "Remove the inline secret value and reference it via a server-side env ref (authRef) instead.",
452
+ });
453
+ return;
454
+ }
455
+
456
+ // Endpoint/body templates must be bindable from the scheduled input contract.
457
+ const refs = collectInputRefs(config.endpoint, collectInputRefs(config.bodyTemplate));
458
+ const unmapped = [...refs].filter((ref) => !refRootSatisfied(ref, availableInputKeys));
459
+ if (unmapped.length) {
460
+ addCheck({
461
+ nodeId,
462
+ nodeType: "api-registry-call",
463
+ status: "warning",
464
+ reason: `endpoint/body template references input field(s) not available under scheduled execution: ${unmapped.join(", ")}`,
465
+ deltaTags: [READINESS_DELTA_TAGS.INPUT_CONTRACT, READINESS_DELTA_TAGS.SCHEDULED_INPUT_UNMAPPED],
466
+ helperAction: "Provide these fields through the schedule's triggerInput so the scheduled call can bind its template.",
467
+ });
468
+ return;
469
+ }
470
+
471
+ addCheck({ nodeId, nodeType: "api-registry-call", status: "ok", reason: `API Registry row "${registryId}" resolves with server-side credentials` });
472
+ }
473
+
474
+ /** BFS reachable node ids from the trigger; null when the graph has no edges. */
475
+ function reachableNodeIds(graph, triggerNodeId) {
476
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
477
+ if (!edges.length || !triggerNodeId) return null;
478
+ const adjacency = new Map();
479
+ for (const edge of edges) {
480
+ const from = clean(edge?.from);
481
+ const to = clean(edge?.to);
482
+ if (!from || !to) continue;
483
+ if (!adjacency.has(from)) adjacency.set(from, []);
484
+ adjacency.get(from).push(to);
485
+ }
486
+ const seen = new Set([triggerNodeId]);
487
+ const queue = [triggerNodeId];
488
+ while (queue.length) {
489
+ const current = queue.shift();
490
+ for (const next of adjacency.get(current) || []) {
491
+ if (!seen.has(next)) { seen.add(next); queue.push(next); }
492
+ }
493
+ }
494
+ return seen;
495
+ }
496
+
497
+ function finalize({ workflowRow, liveField, triggerNodeId, checks }) {
498
+ const blockingNodes = checks
499
+ .filter((c) => c.status === "blocked")
500
+ .map((c) => ({ nodeId: c.nodeId, nodeType: c.nodeType, reason: c.reason, deltaTags: c.deltaTags, helperAction: c.helperAction }));
501
+ const warnings = checks
502
+ .filter((c) => c.status === "warning")
503
+ .map((c) => ({ nodeId: c.nodeId, nodeType: c.nodeType, reason: c.reason, deltaTags: c.deltaTags, helperAction: c.helperAction }));
504
+ const deltaTags = normalizeTags(checks.flatMap((c) => c.deltaTags || []));
505
+ const status = blockingNodes.length ? "blocked" : warnings.length ? "warning" : "ready";
506
+ return {
507
+ kind: READINESS_KIND,
508
+ status,
509
+ ok: status !== "blocked",
510
+ workflowRow,
511
+ triggerNodeId,
512
+ liveField,
513
+ blockingNodes,
514
+ warnings,
515
+ deltaTags,
516
+ checks,
517
+ };
518
+ }
519
+
520
+ function normalizeTags(tags) {
521
+ return Array.from(new Set((Array.isArray(tags) ? tags : []).map((t) => clean(t)).filter(Boolean)));
522
+ }
523
+
524
+ /**
525
+ * Atomic delta-tag → config-field map. Each canonical readiness delta tag points
526
+ * at the EXACT orchestration-config / sandbox-row field(s) the operator must
527
+ * change to clear it. The canvas renders the node's border orange and fills ONLY
528
+ * these fields (and the matching delta-tag shields) light-orange — the color IS
529
+ * the guidance, no extra copy. `row:` prefixed keys are sandbox-row fields
530
+ * (e.g. the execution adapter); bare keys are node-config fields.
531
+ */
532
+ const READINESS_FIELD_HINTS = {
533
+ [READINESS_DELTA_TAGS.MISSING_SERVER_SECRET]: ["authRef"],
534
+ [READINESS_DELTA_TAGS.API_REGISTRY_ENV]: ["registryId", "integrationId", "authRef"],
535
+ [READINESS_DELTA_TAGS.RUNTIME_LOCALITY]: ["row:adapter"],
536
+ [READINESS_DELTA_TAGS.LOCAL_AGENT_UPGRADE_REQUIRED]: ["row:adapter", "row:agentHost"],
537
+ [READINESS_DELTA_TAGS.INPUT_CONTRACT]: ["endpoint", "bodyTemplate", "samplePayload", "triggerInput"],
538
+ [READINESS_DELTA_TAGS.SCHEDULED_INPUT_UNMAPPED]: ["endpoint", "bodyTemplate", "samplePayload", "triggerInput"],
539
+ [READINESS_DELTA_TAGS.DOWNSTREAM_NODE_INCOMPATIBLE]: ["registryId", "integrationId"],
540
+ [READINESS_DELTA_TAGS.SERVERLESS_SCHEDULE]: [],
541
+ [READINESS_DELTA_TAGS.PUBLISHED_GRAPH_REQUIRED]: [],
542
+ };
543
+
544
+ /**
545
+ * Flatten a readiness verdict into the per-node flag map the canvas/sidecar
546
+ * consume: `{ [nodeId]: { severity, deltaTags, fields, configFields, rowFields,
547
+ * reason, helperAction } }`. `severity` is "blocked" (orange) or "warning"
548
+ * (lighter). The color is keyed only off these fields — nothing else renders.
549
+ */
550
+ function readinessFieldFlags(readiness) {
551
+ const out = {};
552
+ const ingest = (entry, severity) => {
553
+ const nodeId = clean(entry?.nodeId);
554
+ if (!nodeId) return;
555
+ const tags = normalizeTags(entry?.deltaTags);
556
+ const fields = normalizeTags(tags.flatMap((t) => READINESS_FIELD_HINTS[t] || []));
557
+ const prev = out[nodeId] || { severity: "warning", deltaTags: [], fields: [], reasons: [], helperActions: [] };
558
+ out[nodeId] = {
559
+ severity: severity === "blocked" || prev.severity === "blocked" ? "blocked" : "warning",
560
+ deltaTags: normalizeTags([...prev.deltaTags, ...tags]),
561
+ fields: normalizeTags([...prev.fields, ...fields]),
562
+ configFields: normalizeTags([...prev.fields, ...fields].filter((f) => !f.startsWith("row:"))),
563
+ rowFields: normalizeTags([...prev.fields, ...fields].filter((f) => f.startsWith("row:")).map((f) => f.slice(4))),
564
+ reasons: [...prev.reasons, entry?.reason].filter(Boolean),
565
+ helperActions: [...prev.helperActions, entry?.helperAction].filter(Boolean),
566
+ };
567
+ };
568
+ for (const n of Array.isArray(readiness?.blockingNodes) ? readiness.blockingNodes : []) ingest(n, "blocked");
569
+ for (const n of Array.isArray(readiness?.warnings) ? readiness.warnings : []) ingest(n, "warning");
570
+ return out;
571
+ }
572
+
573
+ export {
574
+ scanServerlessReadiness,
575
+ readinessFieldFlags,
576
+ classifyNodeLocality,
577
+ collectInputRefs,
578
+ collectAvailableInputKeys,
579
+ READINESS_KIND,
580
+ READINESS_DELTA_TAGS,
581
+ READINESS_FIELD_HINTS,
582
+ LOCAL_ONLY_ADAPTERS,
583
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Scheduler callback bridge (route adapter).
3
+ *
4
+ * Thin wrapper: pulls the raw body + signature off the inbound request and
5
+ * delegates to the dependency-injected `runSchedulerCallback` core (which holds
6
+ * the verification + owning-row resolution + binding validation + persistence
7
+ * logic, and is testable offline). The success and failure callback routes both
8
+ * call this with a different `kind`.
9
+ */
10
+
11
+ import { readWorkspaceConfig, writeWorkspaceConfig } from "@/lib/workspace-config";
12
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
13
+ import { runSchedulerCallback } from "@/lib/scheduler-orchestration";
14
+
15
+ const CALLBACK_DEPS = {
16
+ readConfig: readWorkspaceConfig,
17
+ writeConfig: writeWorkspaceConfig,
18
+ appendReceipt: appendOutcomeReceipt,
19
+ env: process.env,
20
+ };
21
+
22
+ function requestOrigin(request) {
23
+ const forwardedHost = request.headers.get("x-forwarded-host");
24
+ const forwardedProto = request.headers.get("x-forwarded-proto") || "https";
25
+ if (forwardedHost) return `${forwardedProto}://${forwardedHost}`;
26
+ try {
27
+ return new URL(request.url).origin;
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+
33
+ function requestPublicUrl(request) {
34
+ const origin = requestOrigin(request);
35
+ try {
36
+ const url = new URL(request.url);
37
+ return `${origin}${url.pathname}${url.search}`;
38
+ } catch {
39
+ return origin;
40
+ }
41
+ }
42
+
43
+ async function handleSchedulerCallback({ request, providerId, kind }) {
44
+ const rawBody = await request.text();
45
+ const signature = request.headers.get("upstash-signature") || request.headers.get("Upstash-Signature") || "";
46
+ let scheduleId = "";
47
+ try {
48
+ scheduleId = new URL(request.url).searchParams.get("scheduleId") || "";
49
+ } catch {
50
+ scheduleId = "";
51
+ }
52
+ return runSchedulerCallback(CALLBACK_DEPS, {
53
+ providerId,
54
+ kind,
55
+ rawBody,
56
+ signature,
57
+ requestOrigin: requestOrigin(request),
58
+ requestUrl: requestPublicUrl(request),
59
+ scheduleId,
60
+ });
61
+ }
62
+
63
+ export { handleSchedulerCallback };