@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
@@ -6,7 +6,6 @@ import { useRouter, useSearchParams } from "next/navigation";
6
6
  import {
7
7
  ArrowDown,
8
8
  ArrowUp,
9
- ArrowUpCircle,
10
9
  Bot,
11
10
  Code,
12
11
  Filter,
@@ -62,6 +61,8 @@ import { deriveProvenance, hasConnectionId, readUiCacheFlag } from "@/lib/worksp
62
61
  import { ApiRegistryCreationCockpit } from "../data-model/components/ApiRegistryCreationCockpit.jsx";
63
62
  import { deriveSandboxServerlessState } from "@/lib/sandbox-serverless-flow";
64
63
  import { deriveServerlessUpgradeState, SERVERLESS_UPGRADE_DISMISS_FLAG } from "@/lib/serverless-upgrade";
64
+ import { UPSTASH_QSTASH_INTEGRATION_ID, deriveWorkspaceAddOnsState } from "@/lib/workspace-add-ons";
65
+ import { scanServerlessReadiness, readinessFieldFlags } from "@/lib/serverless-readiness";
65
66
 
66
67
  // Set a flag on the governed workspace-ui-cache "activation" row (pure helper,
67
68
  // same transform the rail/lens one-time dismisses use).
@@ -136,6 +137,20 @@ function resolveRegistryRowForSandbox(workspaceConfig, sandboxRow) {
136
137
  return resolveRegistryRefForSandbox(workspaceConfig, sandboxRow)?.row || null;
137
138
  }
138
139
 
140
+ function resolveSchedulerRegistryRows(workspaceConfig) {
141
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
142
+ const rows = [];
143
+ for (const objectItem of objects) {
144
+ if (objectItem?.objectType !== "api-registry") continue;
145
+ for (const row of Array.isArray(objectItem.rows) ? objectItem.rows : []) {
146
+ if (String(row?.executionLane || "").trim() !== "serverless-scheduler") continue;
147
+ const integrationId = String(row?.integrationId || "").trim();
148
+ if (integrationId) rows.push({ object: objectItem, row, integrationId });
149
+ }
150
+ }
151
+ return rows;
152
+ }
153
+
139
154
  function resolveRegistryRefForSandbox(workspaceConfig, sandboxRow) {
140
155
  const graph = parseOrchestrationGraph(sandboxRow?.orchestrationConfig || sandboxRow?.orchestrationGraph);
141
156
  const apiNode = graph?.nodes?.find((n) => n?.type === "api-registry-call");
@@ -162,6 +177,235 @@ function resolveRegistryRefForSandbox(workspaceConfig, sandboxRow) {
162
177
  return firstRegistryRow ? { object: firstRegistryObject, row: firstRegistryRow } : null;
163
178
  }
164
179
 
180
+ function WorkflowAddOnChooser({ addOn, disabled, onUseQstash, onSetupQstash, onSetupCustom }) {
181
+ return (
182
+ <div className="dm-workflow-addon-choice-list">
183
+ <section className="dm-api-action-card dm-workflow-installed-addon">
184
+ <div className="dm-api-action-card-body">
185
+ <p className="dm-api-action-card-eyebrow">{addOn ? "Verified workflow add-on" : "Workflow add-on"}</p>
186
+ <h3>Upstash QStash/Workflow</h3>
187
+ <p>
188
+ {addOn
189
+ ? "Bind the verified workspace QStash scheduler to this workflow and switch it to serverless."
190
+ : "Install and sync QStash in Workspace Add-ons first; the canvas only binds verified scheduler rows."}
191
+ </p>
192
+ <div className="dm-cockpit-fields">
193
+ <span className="dm-cockpit-field"><b>registry</b>{addOn?.integrationId || UPSTASH_QSTASH_INTEGRATION_ID}</span>
194
+ <span className="dm-cockpit-field"><b>status</b>{addOn?.syncStatus || "setup required"}</span>
195
+ <span className="dm-cockpit-field"><b>region</b>{addOn?.region || "pending"}</span>
196
+ </div>
197
+ </div>
198
+ <div className="dm-api-action-card-actions">
199
+ {addOn ? (
200
+ <button type="button" className="dm-btn-primary-sm dm-api-action-card-cta" disabled={disabled} onClick={onUseQstash}>
201
+ Use for this workflow
202
+ </button>
203
+ ) : (
204
+ <button type="button" className="dm-btn-outline dm-api-action-card-cta" disabled={disabled} onClick={onSetupQstash}>
205
+ Set up QStash
206
+ </button>
207
+ )}
208
+ </div>
209
+ </section>
210
+ <section className="dm-api-action-card dm-workflow-installed-addon">
211
+ <div className="dm-api-action-card-body">
212
+ <p className="dm-api-action-card-eyebrow">Custom</p>
213
+ <h3>Custom scheduler plugin</h3>
214
+ <p>Use a governed API Registry scheduler row and bind it through this sandbox row's schedulerRegistryId field.</p>
215
+ </div>
216
+ <div className="dm-api-action-card-actions">
217
+ <button type="button" className="dm-btn-outline dm-api-action-card-cta" disabled={disabled} onClick={onSetupCustom}>
218
+ Configure custom
219
+ </button>
220
+ </div>
221
+ </section>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ const SCHEDULE_CADENCE_OPTIONS = [
227
+ { id: "daily", label: "Daily" },
228
+ { id: "weekly", label: "Weekly" },
229
+ { id: "monthly", label: "Monthly" },
230
+ ];
231
+
232
+ const SCHEDULE_WEEKDAY_OPTIONS = [
233
+ { id: "1", label: "Monday" },
234
+ { id: "2", label: "Tuesday" },
235
+ { id: "3", label: "Wednesday" },
236
+ { id: "4", label: "Thursday" },
237
+ { id: "5", label: "Friday" },
238
+ { id: "6", label: "Saturday" },
239
+ { id: "0", label: "Sunday" },
240
+ ];
241
+
242
+ const SCHEDULE_HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => {
243
+ const id = String(hour).padStart(2, "0");
244
+ return { id, label: id };
245
+ });
246
+
247
+ const SCHEDULE_MINUTE_OPTIONS = Array.from({ length: 12 }, (_, index) => {
248
+ const minute = String(index * 5).padStart(2, "0");
249
+ return { id: minute, label: minute };
250
+ });
251
+
252
+ const SCHEDULE_MONTH_DAY_OPTIONS = Array.from({ length: 28 }, (_, index) => {
253
+ const day = String(index + 1);
254
+ return { id: day, label: day };
255
+ });
256
+
257
+ function splitScheduleTime(value) {
258
+ const [rawHour, rawMinute] = String(value || "09:00").split(":");
259
+ const hour = Math.min(23, Math.max(0, Number(rawHour) || 0));
260
+ const minute = Math.min(59, Math.max(0, Number(rawMinute) || 0));
261
+ return {
262
+ hour: String(hour).padStart(2, "0"),
263
+ minute: String(Math.round(minute / 5) * 5).padStart(2, "0"),
264
+ };
265
+ }
266
+
267
+ function joinScheduleTime(hour, minute) {
268
+ const safeHour = Math.min(23, Math.max(0, Number(hour) || 0));
269
+ const safeMinute = Math.min(59, Math.max(0, Number(minute) || 0));
270
+ return `${String(safeHour).padStart(2, "0")}:${String(safeMinute).padStart(2, "0")}`;
271
+ }
272
+
273
+ function ScheduleTimeControls({ value, onChange }) {
274
+ const { hour, minute } = splitScheduleTime(value);
275
+ return (
276
+ <div className="dm-workflow-schedule-time-grid">
277
+ <label className="dm-orchestration-config__field">
278
+ <span>Hour (UTC)</span>
279
+ <select value={hour} onChange={(event) => onChange(joinScheduleTime(event.target.value, minute))}>
280
+ {SCHEDULE_HOUR_OPTIONS.map((option) => (
281
+ <option key={option.id} value={option.id}>{option.label}</option>
282
+ ))}
283
+ </select>
284
+ </label>
285
+ <label className="dm-orchestration-config__field">
286
+ <span>Minute</span>
287
+ <select value={minute} onChange={(event) => onChange(joinScheduleTime(hour, event.target.value))}>
288
+ {SCHEDULE_MINUTE_OPTIONS.map((option) => (
289
+ <option key={option.id} value={option.id}>{option.label}</option>
290
+ ))}
291
+ </select>
292
+ </label>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ function deriveCronFromSchedule({ cadence, time, weekday, monthDay }) {
298
+ const [hourText, minuteText] = String(time || "09:00").split(":");
299
+ const hour = Math.min(23, Math.max(0, Number(hourText) || 0));
300
+ const minute = Math.min(59, Math.max(0, Number(minuteText) || 0));
301
+ if (cadence === "weekly") return `${minute} ${hour} * * ${String(weekday || "1")}`;
302
+ if (cadence === "monthly") return `${minute} ${hour} ${Math.min(28, Math.max(1, Number(monthDay) || 1))} * *`;
303
+ return `${minute} ${hour} * * *`;
304
+ }
305
+
306
+ function describeSchedule({ cadence, time, weekday, monthDay }) {
307
+ const selectedWeekday = SCHEDULE_WEEKDAY_OPTIONS.find((option) => option.id === String(weekday))?.label || "Monday";
308
+ if (cadence === "weekly") return `Runs every ${selectedWeekday} at ${time || "09:00"} UTC.`;
309
+ if (cadence === "monthly") return `Runs on day ${Math.min(28, Math.max(1, Number(monthDay) || 1))} of each month at ${time || "09:00"} UTC.`;
310
+ return `Runs every day at ${time || "09:00"} UTC.`;
311
+ }
312
+
313
+ function buildServerlessScheduleRunInputs({ workflow, cadence, time, weekday, monthDay }) {
314
+ return {
315
+ kind: "growthub-workflow-run-inputs-v1",
316
+ source: "serverless-scheduler",
317
+ values: {
318
+ trigger: "scheduled",
319
+ source: "serverless-scheduler",
320
+ workflow: String(workflow || ""),
321
+ cadence: String(cadence || ""),
322
+ runTimeUtc: String(time || ""),
323
+ weekday: String(weekday || ""),
324
+ monthDay: String(monthDay || "")
325
+ }
326
+ };
327
+ }
328
+
329
+ function WorkflowScheduleModal({
330
+ open,
331
+ addOn,
332
+ workflowName,
333
+ cadence,
334
+ scheduleTime,
335
+ scheduleWeekday,
336
+ scheduleMonthDay,
337
+ errorMessage,
338
+ disabled,
339
+ onCadenceChange,
340
+ onScheduleTimeChange,
341
+ onScheduleWeekdayChange,
342
+ onScheduleMonthDayChange,
343
+ onSubmit,
344
+ onClose,
345
+ }) {
346
+ if (!open) return null;
347
+ return (
348
+ <div className="dm-workflow-schedule-backdrop" role="presentation">
349
+ <section className="dm-workflow-schedule-modal" role="dialog" aria-modal="true" aria-label="Configure QStash schedule">
350
+ <header>
351
+ <div>
352
+ <p className="dm-api-action-card-eyebrow">Serverless schedule</p>
353
+ <h3>Use QStash for {workflowName || "this workflow"}</h3>
354
+ </div>
355
+ <button type="button" className="dm-workflow-icon-btn" aria-label="Close schedule modal" onClick={onClose}>
356
+ <X size={14} />
357
+ </button>
358
+ </header>
359
+ <div className="dm-workflow-schedule-body">
360
+ <div className="dm-marketplace-config-summary">
361
+ <div><span>Registry</span><code>{addOn?.integrationId || UPSTASH_QSTASH_INTEGRATION_ID}</code></div>
362
+ <div><span>Region</span><code>{addOn?.region || "pending"}</code></div>
363
+ <div><span>Status</span><code>{addOn?.syncStatus || "setup required"}</code></div>
364
+ </div>
365
+ <label className="dm-marketplace-field">
366
+ <span>Cadence</span>
367
+ <select value={cadence} onChange={(event) => onCadenceChange(event.target.value)}>
368
+ {SCHEDULE_CADENCE_OPTIONS.map((option) => (
369
+ <option key={option.id} value={option.id}>{option.label}</option>
370
+ ))}
371
+ </select>
372
+ </label>
373
+ <ScheduleTimeControls value={scheduleTime} onChange={onScheduleTimeChange} />
374
+ {cadence === "weekly" ? (
375
+ <label className="dm-marketplace-field">
376
+ <span>Run day</span>
377
+ <select value={scheduleWeekday} onChange={(event) => onScheduleWeekdayChange(event.target.value)}>
378
+ {SCHEDULE_WEEKDAY_OPTIONS.map((option) => (
379
+ <option key={option.id} value={option.id}>{option.label}</option>
380
+ ))}
381
+ </select>
382
+ </label>
383
+ ) : null}
384
+ {cadence === "monthly" ? (
385
+ <label className="dm-marketplace-field">
386
+ <span>Day of month</span>
387
+ <select value={scheduleMonthDay} onChange={(event) => onScheduleMonthDayChange(event.target.value)}>
388
+ {SCHEDULE_MONTH_DAY_OPTIONS.map((option) => (
389
+ <option key={option.id} value={option.id}>{option.label}</option>
390
+ ))}
391
+ </select>
392
+ </label>
393
+ ) : null}
394
+ <p className="dm-cockpit-step-hint">{describeSchedule({ cadence, time: scheduleTime, weekday: scheduleWeekday, monthDay: scheduleMonthDay })}</p>
395
+ {errorMessage ? <p className="dm-workflow-schedule-error" role="alert">{errorMessage}</p> : null}
396
+ <p className="dm-cockpit-step-hint">This writes the sandbox row to serverless, stores the cadence and trigger input on the workflow trigger, and submits the QStash schedule through the server-owned workspace origin.</p>
397
+ </div>
398
+ <footer>
399
+ <button type="button" className="dm-btn-outline" onClick={onClose}>Cancel</button>
400
+ <button type="button" className="dm-btn-primary-sm" disabled={disabled || !String(scheduleTime || "").trim()} onClick={onSubmit}>
401
+ {disabled ? "Scheduling..." : "Create schedule"}
402
+ </button>
403
+ </footer>
404
+ </section>
405
+ </div>
406
+ );
407
+ }
408
+
165
409
  const WORKFLOW_ACTION_GROUPS = [
166
410
  {
167
411
  label: "Data",
@@ -192,7 +436,6 @@ const WORKFLOW_ACTION_GROUPS = [
192
436
  { id: "http-request", label: "HTTP Request", type: "core-action", Icon: Globe2, destructive: false },
193
437
  ],
194
438
  },
195
- { label: "Human Input", items: [{ id: "form", label: "Form", type: "human-input", Icon: FormInput, destructive: false }] },
196
439
  ];
197
440
 
198
441
  function getWorkspaceObjectOptions(workspaceConfig) {
@@ -214,7 +457,7 @@ function makeWorkflowNode(action, workspaceConfig, graph) {
214
457
  id = `${baseId}-${index}`;
215
458
  index += 1;
216
459
  }
217
- const isData = action.type === "data-action" || action.type === "data-trigger";
460
+ const isData = action.type === "data-action";
218
461
  return {
219
462
  id,
220
463
  type: action.type,
@@ -333,6 +576,13 @@ export default function WorkflowSurface() {
333
576
  const [dirty, setDirty] = useState(false);
334
577
  const [runSetupOpen, setRunSetupOpen] = useState(false);
335
578
  const [upgradeOpen, setUpgradeOpen] = useState(false);
579
+ const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
580
+ const [scheduleCadence, setScheduleCadence] = useState("daily");
581
+ const [scheduleTime, setScheduleTime] = useState("09:00");
582
+ const [scheduleWeekday, setScheduleWeekday] = useState("1");
583
+ const [scheduleMonthDay, setScheduleMonthDay] = useState("1");
584
+ const [scheduleError, setScheduleError] = useState("");
585
+ const [remoteScheduleState, setRemoteScheduleState] = useState({ status: "idle", verified: false, scheduleId: "", proof: "" });
336
586
  const [serverlessSignals, setServerlessSignals] = useState({ configuredEnvRefs: [], persistenceAdapters: [] });
337
587
 
338
588
  useEffect(() => {
@@ -380,6 +630,43 @@ export default function WorkflowSurface() {
380
630
 
381
631
  const sandboxRow = resolved.row;
382
632
 
633
+ useEffect(() => {
634
+ const scheduleId = String(sandboxRow?.scheduleId || "").trim();
635
+ const rowScheduler = String(sandboxRow?.schedulerRegistryId || "").trim();
636
+ const rowLocality = String(sandboxRow?.runLocality || "").trim();
637
+ if (!objectId || !rowId || rowLocality !== "serverless" || !scheduleId || rowScheduler !== UPSTASH_QSTASH_INTEGRATION_ID) {
638
+ setRemoteScheduleState({ status: "idle", verified: false, scheduleId: "", proof: "" });
639
+ return;
640
+ }
641
+ let cancelled = false;
642
+ const params = new URLSearchParams({
643
+ productId: "upstash-qstash",
644
+ objectId,
645
+ rowId,
646
+ scheduleId,
647
+ region: String(sandboxRow?.schedulerRegion || "us-east-1").trim(),
648
+ });
649
+ setRemoteScheduleState({ status: "checking", verified: false, scheduleId, proof: "" });
650
+ fetch(`/api/workspace/add-ons/upstash/schedule?${params.toString()}`, { cache: "no-store" })
651
+ .then(async (res) => {
652
+ const payload = await res.json().catch(() => ({}));
653
+ if (cancelled) return;
654
+ setRemoteScheduleState({
655
+ status: res.ok && payload.verified ? "verified" : "missing",
656
+ verified: res.ok && payload.verified === true && String(payload.remoteScheduleId || payload.scheduleId || "").trim() === scheduleId,
657
+ scheduleId,
658
+ proof: payload.proof || payload.error || "",
659
+ });
660
+ })
661
+ .catch((error) => {
662
+ if (cancelled) return;
663
+ setRemoteScheduleState({ status: "error", verified: false, scheduleId, proof: error?.message || "remote schedule verification failed" });
664
+ });
665
+ return () => { cancelled = true; };
666
+ }, [objectId, rowId, sandboxRow?.runLocality, sandboxRow?.schedulerRegistryId, sandboxRow?.scheduleId, sandboxRow?.schedulerRegion]);
667
+
668
+ const remoteScheduleVerified = remoteScheduleState.verified === true;
669
+
383
670
  // Per-node Workflow Canvas pill status — GENERAL orchestration (not swarm).
384
671
  // Live from the streamed orchestration.node.* deltas while a run is in
385
672
  // flight; settled from the persisted run record's nodeTrace once complete.
@@ -461,10 +748,11 @@ export default function WorkflowSurface() {
461
748
  if (!orchestrationGraph?.nodes || !selectedNodeId) return null;
462
749
  const node = orchestrationGraph.nodes.find((n) => String(n.id) === selectedNodeId) || null;
463
750
  if (!node) return null;
751
+ const baseConfig = node.config || {};
464
752
  return {
465
753
  ...node,
466
754
  config: {
467
- ...(node.config || {}),
755
+ ...baseConfig,
468
756
  sandboxRecordRef: nodeSandboxRecordRef(objectId, rowId, node.id)
469
757
  }
470
758
  };
@@ -667,6 +955,82 @@ export default function WorkflowSurface() {
667
955
  }
668
956
  }
669
957
 
958
+ function findWorkflowRowInConfig(config) {
959
+ return findSandboxRowByWorkflowRef(config, objectId, rowId)?.row || null;
960
+ }
961
+
962
+ async function fetchWorkspaceConfigOnce() {
963
+ const res = await fetch("/api/workspace", { cache: "no-store" });
964
+ const payload = await res.json().catch(() => ({}));
965
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
966
+ if (payload.workspaceConfig) setWorkspaceConfig(payload.workspaceConfig);
967
+ return payload.workspaceConfig || null;
968
+ }
969
+
970
+ async function waitForScheduledRunProof(messageId) {
971
+ const wanted = String(messageId || "").trim();
972
+ const maxAttempts = 20;
973
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
974
+ const latestConfig = await fetchWorkspaceConfigOnce();
975
+ const latestRow = findWorkflowRowInConfig(latestConfig);
976
+ const latestMessageId = String(latestRow?.lastScheduledRunMessageId || "").trim();
977
+ const latestStatus = String(latestRow?.lastScheduledRunStatus || "").trim();
978
+ if ((wanted && latestMessageId === wanted) || (!wanted && latestStatus)) return latestRow;
979
+ await new Promise((resolve) => setTimeout(resolve, 1500));
980
+ }
981
+ return findWorkflowRowInConfig(workspaceConfig);
982
+ }
983
+
984
+ async function runInstalledSchedulerNow() {
985
+ if (!objectId || !rowId || !workspaceConfig || !sandboxRow?.scheduleId || !remoteScheduleVerified) return runSandbox();
986
+ setRunning(true);
987
+ setRunMessage("");
988
+ setLiveRunEvents([]);
989
+ try {
990
+ const triggerInput = JSON.stringify(buildServerlessScheduleRunInputs({
991
+ workflow: rowId,
992
+ cadence: scheduleCadence,
993
+ time: scheduleTime,
994
+ weekday: scheduleWeekday,
995
+ monthDay: scheduleMonthDay,
996
+ }));
997
+ const response = await fetch("/api/workspace/add-ons/upstash/schedule", {
998
+ method: "POST",
999
+ headers: { "content-type": "application/json" },
1000
+ body: JSON.stringify({
1001
+ action: "run",
1002
+ productId: "upstash-qstash",
1003
+ objectId,
1004
+ rowId,
1005
+ region: sandboxRow.schedulerRegion || addOnsState.qstashWorkflow?.region || "us-east-1",
1006
+ scheduleId: sandboxRow.scheduleId,
1007
+ version: String(sandboxRow?.version || "v1"),
1008
+ workspaceId: workspaceConfig?.id || "workspace",
1009
+ triggerInput,
1010
+ }),
1011
+ });
1012
+ const payload = await response.json().catch(() => ({}));
1013
+ if (!response.ok || !payload.ok) {
1014
+ throw new Error(payload.error || "Scheduler run could not be published.");
1015
+ }
1016
+ setRunMessage(payload.messageId ? `Scheduler run published (${payload.messageId}). Waiting for callback proof...` : "Scheduler run published. Waiting for callback proof...");
1017
+ const proofRow = await waitForScheduledRunProof(payload.messageId);
1018
+ const status = String(proofRow?.lastScheduledRunStatus || "").trim();
1019
+ const succeeded = status && Number(status) >= 200 && Number(status) < 300;
1020
+ if (succeeded) {
1021
+ setRunMessage(`Scheduler run succeeded with HTTP ${status}.`);
1022
+ } else if (status) {
1023
+ setRunMessage(redactSecretsFromText(`Scheduler run returned HTTP ${status}: ${proofRow?.lastScheduledRunFailureReason || proofRow?.lastScheduledRunBodyPreview || "see run proof"}`));
1024
+ } else {
1025
+ setRunMessage("Scheduler run was published, but callback proof did not land before the UI timeout.");
1026
+ }
1027
+ } catch (err) {
1028
+ setRunMessage(redactSecretsFromText(err.message || "Scheduler run failed"));
1029
+ } finally {
1030
+ setRunning(false);
1031
+ }
1032
+ }
1033
+
670
1034
  function openTraceMode() {
671
1035
  const params = new URLSearchParams(searchParams.toString());
672
1036
  params.delete("run");
@@ -807,7 +1171,29 @@ export default function WorkflowSurface() {
807
1171
  const orderedNodes = orchestrationGraph?.nodes || [];
808
1172
  const currentGraphSerialized = graphUnset ? "" : serializeOrchestrationGraph(orchestrationGraph);
809
1173
  const draftPassed = sandboxRow?.orchestrationDraftTestPassed === true || String(sandboxRow?.orchestrationDraftTestPassed || "") === "true";
810
- const publishReady = draftPassed && String(sandboxRow?.orchestrationDraftTestedConfig || "") === currentGraphSerialized && !dirty;
1174
+ const liveScheduleBinding = (() => {
1175
+ const graph = parseOrchestrationGraph(sandboxRow?.orchestrationGraph || sandboxRow?.orchestrationConfig);
1176
+ const node = (Array.isArray(graph?.nodes) ? graph.nodes : []).find((entry) => entry?.type === "input" || entry?.id === "input");
1177
+ const schedule = node?.config?.schedule && typeof node.config.schedule === "object" ? node.config.schedule : {};
1178
+ return {
1179
+ enabled: node?.config?.enabled !== false,
1180
+ scheduleId: String(schedule.scheduleId || "").trim(),
1181
+ schedulerRegistryId: String(schedule.schedulerRegistryId || "").trim()
1182
+ };
1183
+ })();
1184
+ const serverlessInstalledAndBound =
1185
+ String(sandboxRow?.runLocality || "").trim() === "serverless" &&
1186
+ Boolean(String(sandboxRow?.scheduleId || "").trim()) &&
1187
+ remoteScheduleVerified &&
1188
+ Boolean(String(sandboxRow?.schedulerRegistryId || "").trim()) &&
1189
+ liveScheduleBinding.enabled === true &&
1190
+ liveScheduleBinding.scheduleId === String(sandboxRow?.scheduleId || "").trim() &&
1191
+ liveScheduleBinding.schedulerRegistryId === String(sandboxRow?.schedulerRegistryId || "").trim() &&
1192
+ String(sandboxRow?.orchestrationDraftConfig || sandboxRow?.orchestrationDraftGraph || "").trim() === currentGraphSerialized;
1193
+ const publishReady = !dirty && (
1194
+ (draftPassed && String(sandboxRow?.orchestrationDraftTestedConfig || "") === currentGraphSerialized) ||
1195
+ serverlessInstalledAndBound
1196
+ );
811
1197
  const savedDraftValue = String(sandboxRow?.[draftFieldName] || "").trim();
812
1198
  const draftStatus = String(sandboxRow?.orchestrationDraftStatus || "").trim();
813
1199
  const hasSavedDraft = Boolean(savedDraftValue) && draftStatus !== "published" && graphHasNodes(parseOrchestrationGraph(savedDraftValue));
@@ -830,7 +1216,32 @@ export default function WorkflowSurface() {
830
1216
  persistenceAdapters: serverlessSignals.persistenceAdapters,
831
1217
  })
832
1218
  : null;
1219
+ const addOnsState = deriveWorkspaceAddOnsState(workspaceConfig || {});
1220
+ const schedulerRegistryRows = resolveSchedulerRegistryRows(workspaceConfig || {});
1221
+ const selectedSchedulerRegistryId = String(sandboxRow?.schedulerRegistryId || addOnsState.qstashWorkflow?.integrationId || schedulerRegistryRows[0]?.integrationId || "").trim();
1222
+ const selectedSchedulerRow = schedulerRegistryRows.find((entry) => entry.integrationId === selectedSchedulerRegistryId)?.row || addOnsState.qstashWorkflow || null;
833
1223
  const isServerlessWorkflow = Boolean(serverlessState?.isServerless);
1224
+
1225
+ // Serverless-readiness — a PURE causation driver, the same shape/inputs as
1226
+ // deriveSandboxServerlessState above (no fetch, no effect): the credential
1227
+ // signal is the already-resolved `serverlessSignals.configuredEnvRefs` (slugs,
1228
+ // never values). It runs once the input trigger is in (or moving into)
1229
+ // Serverless Schedule, and feeds the ultrathin orange node border + the
1230
+ // light-orange field/delta-tag fills (the color is the only guidance added).
1231
+ const inputServerlessSelected = String(
1232
+ (Array.isArray(orchestrationGraph?.nodes) ? orchestrationGraph.nodes : [])
1233
+ .find((n) => n?.type === "input" || n?.id === "input" || n?.type === "data-trigger")?.config?.inputMode || "",
1234
+ ).trim() === "serverless-schedule";
1235
+ const serverlessReadiness = sandboxRow && (isServerlessWorkflow || inputServerlessSelected)
1236
+ ? scanServerlessReadiness({
1237
+ row: sandboxRow,
1238
+ workspaceConfig: workspaceConfig || {},
1239
+ configuredEnvRefs: serverlessSignals.configuredEnvRefs,
1240
+ phase: isServerlessWorkflow && String(sandboxRow?.scheduleId || "").trim() ? "bound" : "pre-bind",
1241
+ expected: { schedulerRegistryId: selectedSchedulerRegistryId, scheduleId: String(sandboxRow?.scheduleId || "").trim() },
1242
+ })
1243
+ : null;
1244
+ const readinessFlags = serverlessReadiness ? readinessFieldFlags(serverlessReadiness) : {};
834
1245
  const showServerlessUpgrade = String(sandboxRow?.adapter || "").trim() !== "local-intelligence";
835
1246
 
836
1247
  async function patchSandboxAndPersist(fields) {
@@ -855,13 +1266,7 @@ export default function WorkflowSurface() {
855
1266
  await patchSandboxAndPersist({ runLocality: "local" });
856
1267
  return;
857
1268
  }
858
- const registryRow = resolveRegistryRowForSandbox(workspaceConfig, sandboxRow);
859
- const adapterId = String(sandboxRow?.adapter || "").trim();
860
- await patchSandboxAndPersist({
861
- runLocality: "serverless",
862
- schedulerRegistryId: String(registryRow?.integrationId || "").trim(),
863
- adapter: ["local-agent-host", "local-intelligence"].includes(adapterId) ? "local-process" : adapterId,
864
- });
1269
+ setUpgradeOpen(true);
865
1270
  } else if (action.id === "open-settings") {
866
1271
  router.push(action.href || "/settings");
867
1272
  } else if (action.id === "link-scheduler") {
@@ -874,6 +1279,175 @@ export default function WorkflowSurface() {
874
1279
  } else if (action.id === "edit-adapter") {
875
1280
  // Full scheduler/adapter config lives on the sandbox object's drawer.
876
1281
  router.push(`/data-model?object=${encodeURIComponent(objectId)}&row=${encodeURIComponent(rowId)}`);
1282
+ } else if (action.id === "run-sandbox") {
1283
+ if (
1284
+ isServerlessWorkflow &&
1285
+ sandboxRow?.schedulerRegistryId === UPSTASH_QSTASH_INTEGRATION_ID &&
1286
+ sandboxRow?.scheduleId &&
1287
+ remoteScheduleVerified
1288
+ ) {
1289
+ await runInstalledSchedulerNow();
1290
+ } else {
1291
+ await runSandbox();
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ async function useInstalledQstashWorkflowAddOn() {
1297
+ setScheduleModalOpen(true);
1298
+ }
1299
+
1300
+ function updateScheduleCadence(cadence) {
1301
+ const next = SCHEDULE_CADENCE_OPTIONS.find((option) => option.id === cadence) || SCHEDULE_CADENCE_OPTIONS[0];
1302
+ setScheduleCadence(next.id);
1303
+ }
1304
+
1305
+ async function submitQstashSchedule() {
1306
+ // Bind requires an installed+verified QStash product. The schedule route
1307
+ // installs THIS row's schedule AND flips it to serverless in ONE
1308
+ // server-authoritative write, then returns the persisted config — we adopt
1309
+ // it verbatim. No second PATCH over stale state (which could clobber the
1310
+ // just-written scheduleId), and serverless is never claimed unless the
1311
+ // server confirmed both the remote schedule and the local persist.
1312
+ if (resolved.rowIndex < 0 || !objectId || !rowId || !workspaceConfig || !addOnsState.qstashWorkflow) return false;
1313
+ if (!String(scheduleTime || "").trim()) {
1314
+ setScheduleError("Choose a run time.");
1315
+ return false;
1316
+ }
1317
+ const scheduleCron = deriveCronFromSchedule({
1318
+ cadence: scheduleCadence,
1319
+ time: scheduleTime,
1320
+ weekday: scheduleWeekday,
1321
+ monthDay: scheduleMonthDay,
1322
+ });
1323
+ const triggerInput = JSON.stringify(buildServerlessScheduleRunInputs({
1324
+ workflow: rowId,
1325
+ cadence: scheduleCadence,
1326
+ time: scheduleTime,
1327
+ weekday: scheduleWeekday,
1328
+ monthDay: scheduleMonthDay,
1329
+ }));
1330
+ setScheduleError("");
1331
+ setSaving(true);
1332
+ setSaveMessage("");
1333
+ try {
1334
+ const response = await fetch("/api/workspace/add-ons/upstash/schedule", {
1335
+ method: "POST",
1336
+ headers: { "content-type": "application/json" },
1337
+ body: JSON.stringify({
1338
+ productId: "upstash-qstash",
1339
+ objectId,
1340
+ rowId,
1341
+ region: addOnsState.qstashWorkflow.region || "us-east-1",
1342
+ cron: scheduleCron,
1343
+ cadence: scheduleCadence,
1344
+ triggerInput,
1345
+ version: String(sandboxRow?.version || "v1"),
1346
+ workspaceId: workspaceConfig?.id || "workspace",
1347
+ }),
1348
+ });
1349
+ const payload = await response.json().catch(() => ({}));
1350
+ if (!response.ok || !payload.bound || !payload.workspaceConfig) {
1351
+ // No schedule installed (missing token / read-only / provider / persist
1352
+ // failure). Keep the workflow local; route the operator to finish setup.
1353
+ setScheduleError(payload.error ? `Could not create schedule: ${payload.error}` : "Could not create the QStash schedule.");
1354
+ return false;
1355
+ }
1356
+ setWorkspaceConfig(payload.workspaceConfig);
1357
+ setScheduleModalOpen(false);
1358
+ setSaveMessage("Schedule updated.");
1359
+ return true;
1360
+ } catch (error) {
1361
+ console.warn(error);
1362
+ setSaveMessage(error?.message || "Could not create QStash schedule.");
1363
+ return false;
1364
+ } finally {
1365
+ setSaving(false);
1366
+ }
1367
+ }
1368
+
1369
+ async function revertServerlessScheduleToLocal() {
1370
+ if (!objectId || !rowId || !sandboxRow?.scheduleId) return false;
1371
+ setScheduleError("");
1372
+ setSaving(true);
1373
+ setSaveMessage("");
1374
+ try {
1375
+ const response = await fetch("/api/workspace/add-ons/upstash/schedule", {
1376
+ method: "DELETE",
1377
+ headers: { "content-type": "application/json" },
1378
+ body: JSON.stringify({
1379
+ productId: "upstash-qstash",
1380
+ objectId,
1381
+ rowId,
1382
+ scheduleId: sandboxRow.scheduleId,
1383
+ }),
1384
+ });
1385
+ const payload = await response.json().catch(() => ({}));
1386
+ if (!response.ok || !payload.ok || !payload.workspaceConfig) {
1387
+ setScheduleError(payload.error || "Could not remove the remote schedule.");
1388
+ return false;
1389
+ }
1390
+ setWorkspaceConfig(payload.workspaceConfig);
1391
+ setSaveMessage("Schedule removed.");
1392
+ return true;
1393
+ } catch (error) {
1394
+ setScheduleError(error?.message || "Could not remove the remote schedule.");
1395
+ return false;
1396
+ } finally {
1397
+ setSaving(false);
1398
+ }
1399
+ }
1400
+
1401
+ async function controlInstalledScheduler(action) {
1402
+ if (!objectId || !rowId || !sandboxRow?.scheduleId || !["pause", "resume"].includes(action)) return false;
1403
+ setScheduleError("");
1404
+ setSaving(true);
1405
+ setSaveMessage("");
1406
+ try {
1407
+ const response = await fetch("/api/workspace/add-ons/upstash/schedule", {
1408
+ method: "POST",
1409
+ headers: { "content-type": "application/json" },
1410
+ body: JSON.stringify({
1411
+ action,
1412
+ productId: "upstash-qstash",
1413
+ objectId,
1414
+ rowId,
1415
+ region: sandboxRow.schedulerRegion || addOnsState.qstashWorkflow?.region || "us-east-1",
1416
+ scheduleId: sandboxRow.scheduleId,
1417
+ }),
1418
+ });
1419
+ const payload = await response.json().catch(() => ({}));
1420
+ if (!response.ok || !payload.ok || !payload.workspaceConfig) {
1421
+ setScheduleError(payload.error || `Could not ${action} the remote schedule.`);
1422
+ return false;
1423
+ }
1424
+ setWorkspaceConfig(payload.workspaceConfig);
1425
+ setSaveMessage(action === "pause" ? "Schedule paused." : "Schedule resumed.");
1426
+ return true;
1427
+ } catch (error) {
1428
+ setScheduleError(error?.message || `Could not ${action} the remote schedule.`);
1429
+ return false;
1430
+ } finally {
1431
+ setSaving(false);
1432
+ }
1433
+ }
1434
+
1435
+ function openQstashSetup() {
1436
+ router.push("/settings/add-ons");
1437
+ }
1438
+
1439
+ function openCustomSchedulerSetup() {
1440
+ // Custom schedulers are provider-AGNOSTIC: any governed API Registry row can
1441
+ // be bound via this sandbox row's schedulerRegistryId and is executed by the
1442
+ // generic sandbox-run serverless delegation — no marketplace adapter or
1443
+ // Upstash coupling. Route to the row drawer (where schedulerRegistryId is
1444
+ // editable) when we have a workflow row; otherwise send the operator to
1445
+ // API/Webhooks to register the custom scheduler row first. Never navigate
1446
+ // with empty object/row params.
1447
+ if (objectId && rowId) {
1448
+ router.push(`/data-model?object=${encodeURIComponent(objectId)}&row=${encodeURIComponent(rowId)}&field=${encodeURIComponent("schedulerRegistryId")}`);
1449
+ } else {
1450
+ router.push("/settings/apis-webhooks");
877
1451
  }
878
1452
  }
879
1453
 
@@ -933,18 +1507,6 @@ export default function WorkflowSurface() {
933
1507
  >
934
1508
  <ArrowUp size={13} />
935
1509
  </button>
936
- {sandboxRow && showServerlessUpgrade && (
937
- <button
938
- type="button"
939
- className={"dm-workflow-icon-btn dm-workflow-upgrade-btn" + (isServerlessWorkflow ? " is-serverless" : (upgradeState.showOnboarding ? " is-pulse" : ""))}
940
- aria-label={isServerlessWorkflow ? "Serverless workflow — review persistence & scheduling" : "Upgrade to serverless environment to ensure persistence"}
941
- data-tooltip={isServerlessWorkflow ? "Serverless — review persistence & scheduling" : "Upgrade to serverless environment to ensure persistence"}
942
- aria-pressed={upgradeOpen}
943
- onClick={() => setUpgradeOpen((open) => !open)}
944
- >
945
- <ArrowUpCircle size={14} />
946
- </button>
947
- )}
948
1510
  {showDiscardDraft && (
949
1511
  <button
950
1512
  type="button"
@@ -1017,42 +1579,23 @@ export default function WorkflowSurface() {
1017
1579
  </div>
1018
1580
  ) : null}
1019
1581
 
1020
- {/* One-time serverless upgrade onboarding — shows only when the operator
1021
- has workflows but none are serverless, and hasn't dismissed it. */}
1022
- {sandboxRow && showServerlessUpgrade && !upgradeOpen && upgradeState.showOnboarding ? (
1023
- <div className="workspace-template-context-banner dm-workflow-upgrade-nudge" role="note">
1024
- <div>
1025
- <strong>{upgradeState.headline}</strong>
1026
- <span style={{ display: "block", marginTop: 2 }}>{upgradeState.subheadline}</span>
1027
- </div>
1028
- <div className="dm-workflow-upgrade-nudge-actions">
1029
- <button type="button" className="dm-btn-primary-sm" onClick={() => setUpgradeOpen(true)}>
1030
- <ArrowUpCircle size={13} /> Upgrade this workflow
1031
- </button>
1032
- <button type="button" className="dm-btn-ghost" onClick={dismissUpgradeOnboarding}>Not now</button>
1033
- </div>
1034
- </div>
1035
- ) : null}
1036
-
1037
- {/* Serverless cockpit — same derivation + cockpit interface as the API
1038
- Registry and sandbox lanes. Toggles patch the sandbox row; deep config
1039
- (scheduler/adapter) routes to the object's Data Model drawer. */}
1040
- {sandboxRow && showServerlessUpgrade && upgradeOpen && serverlessState ? (
1041
- <div className="dm-workflow-upgrade-panel">
1042
- <div className="dm-workflow-upgrade-panel-head">
1043
- <span className="dm-api-action-card-eyebrow">Persistence &amp; scheduling</span>
1044
- <button type="button" className="dm-workflow-icon-btn" aria-label="Close upgrade panel" onClick={() => { setUpgradeOpen(false); dismissUpgradeOnboarding(); }}>
1045
- <X size={14} />
1046
- </button>
1047
- </div>
1048
- <ApiRegistryCreationCockpit
1049
- state={serverlessState}
1050
- onAction={handleUpgradeAction}
1051
- disabled={saving || publishing || running}
1052
- eyebrow={isServerlessWorkflow ? "Serverless workflow" : "Upgrade to serverless"}
1053
- />
1054
- </div>
1055
- ) : null}
1582
+ <WorkflowScheduleModal
1583
+ open={scheduleModalOpen}
1584
+ addOn={addOnsState.qstashWorkflow}
1585
+ workflowName={rowId}
1586
+ cadence={scheduleCadence}
1587
+ scheduleTime={scheduleTime}
1588
+ scheduleWeekday={scheduleWeekday}
1589
+ scheduleMonthDay={scheduleMonthDay}
1590
+ errorMessage={scheduleError}
1591
+ disabled={saving || publishing || running}
1592
+ onCadenceChange={updateScheduleCadence}
1593
+ onScheduleTimeChange={setScheduleTime}
1594
+ onScheduleWeekdayChange={setScheduleWeekday}
1595
+ onScheduleMonthDayChange={setScheduleMonthDay}
1596
+ onSubmit={submitQstashSchedule}
1597
+ onClose={() => setScheduleModalOpen(false)}
1598
+ />
1056
1599
 
1057
1600
  {loading ? (
1058
1601
  <p className="dm-workflow-empty">Loading workflow…</p>
@@ -1107,6 +1650,7 @@ export default function WorkflowSurface() {
1107
1650
  nodeStatuses={runNodeStatuses}
1108
1651
  onNodeStatusClick={(node) => { setSelectedNodeId(String(node?.id || "")); openTraceMode(); }}
1109
1652
  statusLabel={isDraftMode ? "Draft" : "Live"}
1653
+ readinessFlags={readinessFlags}
1110
1654
  />
1111
1655
  {nextNodeId && (
1112
1656
  <button type="button" className="dm-btn-outline dm-orchestration-canvas__add-node" onClick={addNextNode}>
@@ -1194,6 +1738,135 @@ export default function WorkflowSurface() {
1194
1738
  activeTab={configTab}
1195
1739
  onTabChange={setConfigTab}
1196
1740
  onConfigChange={handleNodeConfigChange}
1741
+ readinessFlag={selectedNodeId ? readinessFlags[selectedNodeId] : null}
1742
+ serverlessScheduleOptionAvailable={Boolean(addOnsState.qstashWorkflow || selectedSchedulerRegistryId || schedulerRegistryRows.length)}
1743
+ serverlessScheduleAvailable={remoteScheduleVerified}
1744
+ inputScheduleControls={selectedNode?.type === "input" && selectedNode?.config?.inputMode === "serverless-schedule" ? (
1745
+ <div className="dm-trigger-schedule-config">
1746
+ <span className="dm-field-label">Serverless schedule</span>
1747
+ <dl className="dm-workflow-schedule-state">
1748
+ <div>
1749
+ <dt>Scheduler</dt>
1750
+ <dd>{selectedSchedulerRegistryId || "Install scheduler first"}</dd>
1751
+ </div>
1752
+ <div>
1753
+ <dt>Status</dt>
1754
+ <dd>{remoteScheduleVerified ? (sandboxRow?.schedulerPaused ? "paused" : "bound") : "not bound"}</dd>
1755
+ </div>
1756
+ <div>
1757
+ <dt>Region</dt>
1758
+ <dd>{sandboxRow?.schedulerRegion || selectedSchedulerRow?.region || "pending"}</dd>
1759
+ </div>
1760
+ {remoteScheduleVerified && sandboxRow?.scheduleId ? (
1761
+ <div>
1762
+ <dt>Schedule</dt>
1763
+ <dd>{sandboxRow.scheduleId}</dd>
1764
+ </div>
1765
+ ) : null}
1766
+ {remoteScheduleVerified && sandboxRow?.schedulerCron ? (
1767
+ <div>
1768
+ <dt>Cron</dt>
1769
+ <dd>{sandboxRow.schedulerCron}</dd>
1770
+ </div>
1771
+ ) : null}
1772
+ {remoteScheduleVerified && sandboxRow?.schedulerInstalledAt ? (
1773
+ <div>
1774
+ <dt>Last sync</dt>
1775
+ <dd>{sandboxRow.schedulerInstalledAt}</dd>
1776
+ </div>
1777
+ ) : null}
1778
+ {sandboxRow?.schedulerPausedAt ? (
1779
+ <div>
1780
+ <dt>Paused</dt>
1781
+ <dd>{sandboxRow.schedulerPausedAt}</dd>
1782
+ </div>
1783
+ ) : null}
1784
+ </dl>
1785
+ {schedulerRegistryRows.length > 1 ? (
1786
+ <label className="dm-orchestration-config__field">
1787
+ <span>Scheduler registry</span>
1788
+ <select
1789
+ value={selectedSchedulerRegistryId}
1790
+ disabled={Boolean(sandboxRow?.scheduleId) || saving}
1791
+ onChange={(e) => patchSandboxRuntimeFields({ schedulerRegistryId: e.target.value })}
1792
+ >
1793
+ {schedulerRegistryRows.map((entry) => (
1794
+ <option key={entry.integrationId} value={entry.integrationId}>
1795
+ {entry.integrationId}
1796
+ </option>
1797
+ ))}
1798
+ </select>
1799
+ </label>
1800
+ ) : null}
1801
+ <label className="dm-orchestration-config__field">
1802
+ <span>Cadence</span>
1803
+ <select value={scheduleCadence} onChange={(e) => updateScheduleCadence(e.target.value)}>
1804
+ {SCHEDULE_CADENCE_OPTIONS.map((option) => (
1805
+ <option key={option.id} value={option.id}>{option.label}</option>
1806
+ ))}
1807
+ </select>
1808
+ </label>
1809
+ <ScheduleTimeControls value={scheduleTime} onChange={setScheduleTime} />
1810
+ {scheduleCadence === "weekly" ? (
1811
+ <label className="dm-orchestration-config__field">
1812
+ <span>Run day</span>
1813
+ <select value={scheduleWeekday} onChange={(e) => setScheduleWeekday(e.target.value)}>
1814
+ {SCHEDULE_WEEKDAY_OPTIONS.map((option) => (
1815
+ <option key={option.id} value={option.id}>{option.label}</option>
1816
+ ))}
1817
+ </select>
1818
+ </label>
1819
+ ) : null}
1820
+ {scheduleCadence === "monthly" ? (
1821
+ <label className="dm-orchestration-config__field">
1822
+ <span>Day of month</span>
1823
+ <select value={scheduleMonthDay} onChange={(e) => setScheduleMonthDay(e.target.value)}>
1824
+ {SCHEDULE_MONTH_DAY_OPTIONS.map((option) => (
1825
+ <option key={option.id} value={option.id}>{option.label}</option>
1826
+ ))}
1827
+ </select>
1828
+ </label>
1829
+ ) : null}
1830
+ {sandboxRow?.lastScheduledRunResponse || sandboxRow?.lastScheduledRunBodyPreview || sandboxRow?.lastScheduledRunStatus ? (
1831
+ <div className="dm-workflow-schedule-last-run">
1832
+ <span>Last run</span>
1833
+ <strong>{sandboxRow?.lastScheduledRunStatus || "pending"}</strong>
1834
+ </div>
1835
+ ) : null}
1836
+ {scheduleError ? <p className="dm-workflow-schedule-error" role="alert">{scheduleError}</p> : null}
1837
+ <button
1838
+ type="button"
1839
+ className="dm-btn-outline dm-workflow-schedule-submit"
1840
+ disabled={saving || !addOnsState.qstashWorkflow || !String(scheduleTime || "").trim()}
1841
+ onClick={submitQstashSchedule}
1842
+ >
1843
+ {saving ? "Saving schedule..." : sandboxRow?.scheduleId ? "Update schedule" : "Save schedule"}
1844
+ </button>
1845
+ {!addOnsState.qstashWorkflow ? (
1846
+ <p className="dm-cockpit-step-hint">Install + sync QStash in Workspace Add-ons first, then save the schedule here.</p>
1847
+ ) : null}
1848
+ {sandboxRow?.scheduleId ? (
1849
+ <div className="dm-workflow-schedule-actions">
1850
+ <button
1851
+ type="button"
1852
+ className="dm-btn-outline"
1853
+ disabled={saving}
1854
+ onClick={() => controlInstalledScheduler(sandboxRow?.schedulerPaused ? "resume" : "pause")}
1855
+ >
1856
+ {sandboxRow?.schedulerPaused ? "Resume" : "Pause"}
1857
+ </button>
1858
+ <button
1859
+ type="button"
1860
+ className="dm-btn-outline is-danger"
1861
+ disabled={saving}
1862
+ onClick={revertServerlessScheduleToLocal}
1863
+ >
1864
+ Revert to local
1865
+ </button>
1866
+ </div>
1867
+ ) : null}
1868
+ </div>
1869
+ ) : null}
1197
1870
  />
1198
1871
  {graphError && <p className="dm-orchestration-config__error">{graphError}</p>}
1199
1872
  </div>
@@ -1202,7 +1875,7 @@ export default function WorkflowSurface() {
1202
1875
  </div>
1203
1876
  )}
1204
1877
 
1205
- {(saveMessage || runMessage) && (
1878
+ {runMessage && (
1206
1879
  <p className="dm-workflow-status-msg">{saveMessage || runMessage}</p>
1207
1880
  )}
1208
1881
  </section>