@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,35 @@
1
+ /**
2
+ * POST /api/workspace/add-ons/[providerId]/callback
3
+ *
4
+ * Signed success callback for a scheduled serverless run. Verifies the provider
5
+ * signature and synchronizes the last response into workspace config. Thin
6
+ * wrapper over the shared, provider-agnostic handler.
7
+ */
8
+
9
+ import { NextResponse } from "next/server";
10
+ import { handleSchedulerCallback } from "@/lib/workspace-add-on-callback";
11
+
12
+ async function POST(request, context) {
13
+ const params = await context?.params;
14
+ const { status, body } = await handleSchedulerCallback({
15
+ request,
16
+ providerId: params?.providerId,
17
+ kind: "callback",
18
+ });
19
+ return NextResponse.json(body, { status });
20
+ }
21
+
22
+ function HEAD() {
23
+ return new Response(null, { status: 200 });
24
+ }
25
+
26
+ function OPTIONS() {
27
+ return new Response(null, {
28
+ status: 204,
29
+ headers: {
30
+ allow: "HEAD, OPTIONS, POST",
31
+ },
32
+ });
33
+ }
34
+
35
+ export { HEAD, OPTIONS, POST };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * POST /api/workspace/add-ons/[providerId]/failure
3
+ *
4
+ * Signed failure callback for a scheduled serverless run. Same verification +
5
+ * synchronization path as the success callback, recording the failure reason
6
+ * into workspace config so a broken scheduled run is visible, not silent.
7
+ */
8
+
9
+ import { NextResponse } from "next/server";
10
+ import { handleSchedulerCallback } from "@/lib/workspace-add-on-callback";
11
+
12
+ async function POST(request, context) {
13
+ const params = await context?.params;
14
+ const { status, body } = await handleSchedulerCallback({
15
+ request,
16
+ providerId: params?.providerId,
17
+ kind: "failure",
18
+ });
19
+ return NextResponse.json(body, { status });
20
+ }
21
+
22
+ function HEAD() {
23
+ return new Response(null, { status: 200 });
24
+ }
25
+
26
+ function OPTIONS() {
27
+ return new Response(null, {
28
+ status: 204,
29
+ headers: {
30
+ allow: "HEAD, OPTIONS, POST",
31
+ },
32
+ });
33
+ }
34
+
35
+ export { HEAD, OPTIONS, POST };
@@ -0,0 +1,423 @@
1
+ /**
2
+ * GET /api/workspace/add-ons/[providerId]/schedule
3
+ * POST /api/workspace/add-ons/[providerId]/schedule
4
+ * DELETE /api/workspace/add-ons/[providerId]/schedule
5
+ *
6
+ * Install (upsert) or remove a serverless scheduler capability for an installed
7
+ * marketplace product. Provider-agnostic: the provider's scheduler product
8
+ * (executionLane = serverless-scheduler) supplies a SchedulerAdapter that builds
9
+ * the provider-specific request; this route only orchestrates the governed flow.
10
+ *
11
+ * Closes the gap "QStash install must be a schedule capability, not just a read
12
+ * probe": on success it stamps `scheduleId` + scheduler metadata onto the
13
+ * product's API Registry row, which is what the canvas requires before it will
14
+ * bind a workflow to serverless.
15
+ *
16
+ * Secrets stay server-side: the token is resolved through the canonical env
17
+ * entry and used only to sign the outbound request — never written to config,
18
+ * receipts, or the response.
19
+ */
20
+
21
+ import { NextResponse } from "next/server";
22
+ import { readWorkspaceConfig, writeWorkspaceConfig } from "@/lib/workspace-config";
23
+ import {
24
+ getMarketplaceProvider,
25
+ getMarketplaceProduct,
26
+ findRegistryRowByIntegrationId,
27
+ findEligibleSandboxRow,
28
+ findSandboxRowByScheduleId,
29
+ withSandboxSchedulerControlState,
30
+ withWorkflowServerlessBind,
31
+ liveGraphField,
32
+ } from "@/lib/workspace-add-ons";
33
+ import {
34
+ getSchedulerAdapter,
35
+ isSchedulerProduct,
36
+ deriveScheduleId,
37
+ resolveWorkspacePublicUrl,
38
+ buildSchedulerCallbackUrls,
39
+ } from "@/lib/workspace-add-on-scheduler";
40
+ import { readEnvVar, resolveRequiredEnv } from "@/lib/server-secrets";
41
+ import { requireWorkspaceOperator } from "@/lib/workspace-operator-auth";
42
+ import { runScheduleInstall, runScheduleNow, runReadinessScan } from "@/lib/scheduler-orchestration";
43
+ import { scanServerlessReadiness, READINESS_KIND } from "@/lib/serverless-readiness";
44
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
45
+
46
+ const SCHEDULE_TIMEOUT_MS = 10000;
47
+
48
+ const SCHEDULER_DEPS = {
49
+ fetchImpl: fetch,
50
+ readConfig: readWorkspaceConfig,
51
+ writeConfig: writeWorkspaceConfig,
52
+ appendReceipt: appendOutcomeReceipt,
53
+ env: process.env,
54
+ };
55
+
56
+ function clean(value) {
57
+ return String(value == null ? "" : value).trim();
58
+ }
59
+
60
+ function jsonError(message, status = 400, extra = {}) {
61
+ return NextResponse.json({ error: message, ...extra }, { status });
62
+ }
63
+
64
+ function requestOrigin(request) {
65
+ try {
66
+ return new URL(request.url).origin;
67
+ } catch {
68
+ return "";
69
+ }
70
+ }
71
+
72
+ function resolveSchedulerProduct(provider, productId) {
73
+ if (productId) return getMarketplaceProduct(provider.providerId, productId);
74
+ return (provider.products || []).find((product) => isSchedulerProduct(product)) || null;
75
+ }
76
+
77
+ async function fetchWithTimeout(url, init = {}) {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), SCHEDULE_TIMEOUT_MS);
80
+ try {
81
+ return await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+
87
+ async function POST(request, context) {
88
+ const params = await context?.params;
89
+ const auth = requireWorkspaceOperator(request);
90
+ if (!auth.ok) return jsonError(auth.error, auth.status);
91
+ let body = {};
92
+ try {
93
+ body = await request.json();
94
+ } catch {
95
+ return jsonError("invalid json body", 400);
96
+ }
97
+ if (clean(body.action) === "readiness") {
98
+ // Read-only causality scan — no remote call, no mutation. The canvas calls
99
+ // this when the input trigger flips to Serverless Schedule.
100
+ const { status, body: out } = await runReadinessScan(SCHEDULER_DEPS, {
101
+ providerId: params?.providerId,
102
+ body,
103
+ });
104
+ return NextResponse.json(out, { status });
105
+ }
106
+ if (clean(body.action) === "run") {
107
+ const { status, body: out } = await runScheduleNow(SCHEDULER_DEPS, {
108
+ providerId: params?.providerId,
109
+ body,
110
+ requestOrigin: requestOrigin(request),
111
+ });
112
+ return NextResponse.json(out, { status });
113
+ }
114
+ if (["pause", "resume"].includes(clean(body.action))) {
115
+ return controlSchedule(request, params?.providerId, body);
116
+ }
117
+ // Thin wrapper over the dependency-injected install core (testable offline).
118
+ const { status, body: out } = await runScheduleInstall(SCHEDULER_DEPS, {
119
+ providerId: params?.providerId,
120
+ body,
121
+ requestOrigin: requestOrigin(request),
122
+ });
123
+ return NextResponse.json(out, { status });
124
+ }
125
+
126
+ async function controlSchedule(request, providerIdParam, body = {}) {
127
+ const providerId = clean(providerIdParam);
128
+ const provider = getMarketplaceProvider(providerId);
129
+ if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
130
+ const product = resolveSchedulerProduct(provider, clean(body.productId));
131
+ if (!product || !isSchedulerProduct(product)) {
132
+ return jsonError("provider has no serverless scheduler product", 400, { providerId: provider.providerId });
133
+ }
134
+ const adapter = getSchedulerAdapter(product);
135
+ const action = clean(body.action);
136
+ const config = await readWorkspaceConfig();
137
+ const objectId = clean(body.objectId);
138
+ const rowId = clean(body.rowId || body.name);
139
+ const eligible = findEligibleSandboxRow(config, objectId, rowId);
140
+ if (!eligible.ok) return jsonError(eligible.error, eligible.status, { providerId: provider.providerId, productId: product.productId });
141
+ const row = eligible.row;
142
+ const scheduleId = clean(body.scheduleId || row.scheduleId);
143
+ if (!scheduleId) return jsonError("workflow row has no installed schedule", 409, { providerId: provider.providerId, productId: product.productId, objectId, rowId });
144
+ if (clean(row.runLocality) !== "serverless" || clean(row.schedulerRegistryId) !== product.integrationId) {
145
+ return jsonError("workflow row is not bound to this scheduler", 409, { providerId: provider.providerId, productId: product.productId, objectId, rowId, scheduleId });
146
+ }
147
+ const token = readEnvVar(product.probe?.tokenEnv || (product.requiredEnv || [])[0], process.env)?.value || "";
148
+ if (!token) return jsonError(`${product.label} runtime credentials are not connected`, 422, { productId: product.productId });
149
+
150
+ // Resume is a re-activation of a continuing runtime contract — re-run the
151
+ // readiness scan before re-enabling. A workflow compatible at install time can
152
+ // drift (a downstream node, API Registry row, credential ref, or template
153
+ // changed). Pause needs no scan; uninstall is the explicit downgrade path.
154
+ if (action === "resume") {
155
+ const readiness = scanServerlessReadiness({
156
+ row,
157
+ workspaceConfig: config,
158
+ env: process.env,
159
+ phase: "bound",
160
+ expected: { schedulerRegistryId: product.integrationId, providerId: provider.providerId, productId: product.productId, scheduleId },
161
+ });
162
+ if (!readiness.ok) {
163
+ await appendOutcomeReceipt({
164
+ kind: READINESS_KIND,
165
+ lane: "server-authoritative",
166
+ outcomeStatus: "blocked",
167
+ actor: "workspace-marketplace",
168
+ objectRefs: [{ objectId, objectType: "sandbox-environment", rowName: rowId }],
169
+ policyVerdict: { ok: false, violationCodes: readiness.deltaTags },
170
+ summary: `${product.label} schedule ${scheduleId} resume blocked: graph drifted out of serverless readiness (${readiness.blockingNodes.length} blocking node(s)).`,
171
+ nextActions: readiness.blockingNodes.map((n) => n.helperAction).filter(Boolean),
172
+ });
173
+ return jsonError("workflow graph is not serverless-ready; resume blocked", 422, { providerId: provider.providerId, productId: product.productId, scheduleId, readiness });
174
+ }
175
+ }
176
+
177
+ const region = clean(body.region || row.schedulerRegion || "us-east-1");
178
+ let controlResult;
179
+ try {
180
+ const req = adapter.buildControlRequest({ product, region, token, scheduleId, action, env: process.env });
181
+ const response = await fetchWithTimeout(req.url, { method: req.method, headers: req.headers });
182
+ controlResult = adapter.parseControlResponse({ status: response.status, body: await response.text(), action, scheduleId });
183
+ } catch (error) {
184
+ controlResult = { ok: false, proof: error?.message || "remote scheduler control failed", scheduleId };
185
+ }
186
+ if (!controlResult.ok) {
187
+ await appendOutcomeReceipt({
188
+ kind: "workspace-add-on-schedule-control",
189
+ lane: "server-authoritative",
190
+ outcomeStatus: "blocked",
191
+ actor: "workspace-marketplace",
192
+ objectRefs: [{ objectId, objectType: "sandbox-environment", rowName: rowId }],
193
+ policyVerdict: { ok: false, violationCodes: [`scheduler_${action}_failed`] },
194
+ summary: controlResult.proof || `${product.label} schedule ${action} failed`,
195
+ });
196
+ return jsonError(controlResult.proof || `remote schedule ${action} failed`, 502, { providerId: provider.providerId, productId: product.productId, scheduleId });
197
+ }
198
+
199
+ const now = new Date().toISOString();
200
+ const patch = action === "pause"
201
+ ? { schedulerPaused: true, schedulerPausedAt: now, schedulerResumedAt: "" }
202
+ : { schedulerPaused: false, schedulerResumedAt: now };
203
+ const { config: nextConfig, found } = withSandboxSchedulerControlState(config, { objectId, rowId, patch });
204
+ let persisted = found;
205
+ if (found) {
206
+ try { await writeWorkspaceConfig({ dataModel: nextConfig.dataModel }); } catch { persisted = false; }
207
+ }
208
+ const { receipt } = await appendOutcomeReceipt({
209
+ kind: "workspace-add-on-schedule-control",
210
+ lane: "server-authoritative",
211
+ outcomeStatus: persisted ? "published" : "failed",
212
+ actor: "workspace-marketplace",
213
+ objectRefs: [{ objectId, objectType: "sandbox-environment", rowName: rowId }],
214
+ changedFields: [`dataModel.${objectId}.${rowId}.schedulerPaused`],
215
+ policyVerdict: { ok: persisted },
216
+ summary: persisted
217
+ ? `${product.label} schedule ${scheduleId} ${action}d and row state synced.`
218
+ : `${product.label} schedule ${scheduleId} ${action}d remotely but row state did not persist.`,
219
+ });
220
+ if (!persisted) {
221
+ return jsonError(`remote schedule ${action}d but workspace state did not persist`, 424, { providerId: provider.providerId, productId: product.productId, scheduleId, receiptId: receipt?.receiptId });
222
+ }
223
+ return NextResponse.json({ ok: true, providerId: provider.providerId, productId: product.productId, scheduleId, action, workspaceConfig: nextConfig, receiptId: receipt?.receiptId });
224
+ }
225
+
226
+ async function GET(request, context) {
227
+ const params = await context?.params;
228
+ const providerId = clean(params?.providerId);
229
+ const provider = getMarketplaceProvider(providerId);
230
+ if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
231
+
232
+ const auth = requireWorkspaceOperator(request);
233
+ if (!auth.ok) return jsonError(auth.error, auth.status);
234
+
235
+ const product = resolveSchedulerProduct(provider, clean(request.nextUrl?.searchParams?.get("productId")));
236
+ if (!product || !isSchedulerProduct(product)) {
237
+ return jsonError("provider has no serverless scheduler product", 400, { providerId: provider.providerId });
238
+ }
239
+ const adapter = getSchedulerAdapter(product);
240
+ const objectId = clean(request.nextUrl?.searchParams?.get("objectId"));
241
+ const rowId = clean(request.nextUrl?.searchParams?.get("rowId") || request.nextUrl?.searchParams?.get("name"));
242
+ const config = await readWorkspaceConfig();
243
+ let owner = null;
244
+ if (objectId && rowId) {
245
+ const eligible = findEligibleSandboxRow(config, objectId, rowId);
246
+ if (eligible.ok) owner = { objectId: eligible.object.id, row: eligible.row };
247
+ }
248
+ const scheduleId = clean(request.nextUrl?.searchParams?.get("scheduleId") || owner?.row?.scheduleId);
249
+ if (!scheduleId) {
250
+ return jsonError("workflow row has no installed schedule", 404, {
251
+ providerId: provider.providerId,
252
+ productId: product.productId,
253
+ exists: false,
254
+ verified: false,
255
+ });
256
+ }
257
+
258
+ const token = readEnvVar(product.probe?.tokenEnv || (product.requiredEnv || [])[0], process.env)?.value || "";
259
+ if (!token) {
260
+ return jsonError(`${product.label} runtime credentials are not connected`, 422, {
261
+ providerId: provider.providerId,
262
+ productId: product.productId,
263
+ scheduleId,
264
+ exists: false,
265
+ verified: false,
266
+ });
267
+ }
268
+
269
+ const region = clean(request.nextUrl?.searchParams?.get("region") || owner?.row?.schedulerRegion || "us-east-1");
270
+ let readResult;
271
+ try {
272
+ const read = adapter.buildReadRequest({ product, region, token, scheduleId, env: process.env });
273
+ const response = await fetchWithTimeout(read.url, { method: read.method, headers: read.headers });
274
+ readResult = adapter.parseReadResponse({ status: response.status, body: await response.text(), scheduleId });
275
+ } catch (error) {
276
+ readResult = {
277
+ ok: false,
278
+ exists: false,
279
+ scheduleId,
280
+ proof: error?.message || "remote schedule verification failed",
281
+ };
282
+ }
283
+
284
+ const status = readResult.ok ? 200 : 404;
285
+ return NextResponse.json({
286
+ ok: readResult.ok,
287
+ verified: readResult.ok,
288
+ exists: readResult.exists === true,
289
+ providerId: provider.providerId,
290
+ productId: product.productId,
291
+ scheduleId,
292
+ remoteScheduleId: readResult.scheduleId || "",
293
+ cron: readResult.cron || "",
294
+ region,
295
+ proof: readResult.proof || "",
296
+ }, { status });
297
+ }
298
+
299
+ async function DELETE(request, context) {
300
+ const params = await context?.params;
301
+ const providerId = clean(params?.providerId);
302
+ const provider = getMarketplaceProvider(providerId);
303
+ if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
304
+
305
+ const auth = requireWorkspaceOperator(request);
306
+ if (!auth.ok) return jsonError(auth.error, auth.status);
307
+
308
+ let body = {};
309
+ try {
310
+ body = await request.json();
311
+ } catch {
312
+ body = {};
313
+ }
314
+ const product = resolveSchedulerProduct(provider, clean(body.productId));
315
+ if (!product || !isSchedulerProduct(product)) {
316
+ return jsonError("provider has no serverless scheduler product", 400, { providerId: provider.providerId });
317
+ }
318
+ const adapter = getSchedulerAdapter(product);
319
+
320
+ // Uninstall targets the OWNING workflow row — by (objectId,rowId) or by
321
+ // scheduleId. Schedule state lives on the row, not the provider capability row.
322
+ const config = await readWorkspaceConfig();
323
+ let owner = null;
324
+ if (clean(body.objectId) && clean(body.rowId || body.name)) {
325
+ const eligible = findEligibleSandboxRow(config, clean(body.objectId), clean(body.rowId || body.name));
326
+ if (eligible.ok) owner = { objectId: eligible.object.id, row: eligible.row };
327
+ } else if (clean(body.scheduleId)) {
328
+ owner = findSandboxRowByScheduleId(config, clean(body.scheduleId));
329
+ }
330
+ if (!owner) return jsonError("no installed workflow schedule to remove", 404, { providerId: provider.providerId });
331
+ const scheduleId = clean(body.scheduleId || owner.row?.scheduleId);
332
+ if (!scheduleId) return jsonError("owning row has no installed schedule", 404, { providerId: provider.providerId });
333
+
334
+ // Never clear local schedule proof unless the remote schedule is actually
335
+ // gone — otherwise a real schedule keeps firing while the workspace claims
336
+ // it is removed (cost/noise, and callbacks rejected with no live row).
337
+ const token = readEnvVar(product.probe?.tokenEnv || (product.requiredEnv || [])[0], process.env)?.value || "";
338
+ if (!token) {
339
+ await appendOutcomeReceipt({
340
+ kind: "workspace-add-on-schedule",
341
+ lane: "server-authoritative",
342
+ outcomeStatus: "blocked",
343
+ actor: "workspace-marketplace",
344
+ objectRefs: [{ objectId: owner.objectId, objectType: "sandbox-environment", rowName: owner.row.Name }],
345
+ summary: `${product.label} schedule ${scheduleId} NOT removed: no runtime token to delete it remotely.`,
346
+ policyVerdict: { ok: false, violationCodes: ["scheduler_delete_no_token"] },
347
+ nextActions: [`Provide ${product.probe?.tokenEnv || "the runtime token"} so the remote schedule can be deleted, then retry.`],
348
+ });
349
+ return jsonError(`cannot delete ${product.label} schedule without a runtime token`, 422, {
350
+ providerId: provider.providerId, productId: product.productId, scheduleId,
351
+ });
352
+ }
353
+
354
+ let deleted = false;
355
+ let deleteDetail = "";
356
+ try {
357
+ const del = adapter.buildDeleteRequest({ product, region: clean(owner.row?.schedulerRegion) || "us-east-1", token, scheduleId, env: process.env });
358
+ const delResp = await fetchWithTimeout(del.url, { method: del.method, headers: del.headers });
359
+ // QStash returns 404 if the schedule is already gone — treat as deleted.
360
+ deleted = delResp.ok || delResp.status === 404;
361
+ deleteDetail = `HTTP ${delResp.status}`;
362
+ } catch (err) {
363
+ deleteDetail = err?.message || "network error";
364
+ }
365
+
366
+ if (!deleted) {
367
+ await appendOutcomeReceipt({
368
+ kind: "workspace-add-on-schedule",
369
+ lane: "server-authoritative",
370
+ outcomeStatus: "failed",
371
+ actor: "workspace-marketplace",
372
+ objectRefs: [{ objectId: owner.objectId, objectType: "sandbox-environment", rowName: owner.row.Name }],
373
+ policyVerdict: { ok: false, violationCodes: ["scheduler_delete_failed"] },
374
+ summary: `${product.label} remote schedule ${scheduleId} delete failed (${deleteDetail}); local proof kept to avoid a stale-but-firing schedule.`,
375
+ nextActions: [`Confirm schedule ${scheduleId} in the ${product.label} console, then retry uninstall.`],
376
+ });
377
+ return jsonError(`remote schedule delete failed (${deleteDetail})`, 502, {
378
+ providerId: provider.providerId, productId: product.productId, scheduleId, deleted: false,
379
+ });
380
+ }
381
+
382
+ // Revert the row to local + manual trigger (clears row schedule fields AND
383
+ // resets the orchestration trigger node) in one write.
384
+ const { config: nextConfig } = withWorkflowServerlessBind(config, {
385
+ objectId: owner.objectId,
386
+ rowId: owner.row.Name,
387
+ clear: true,
388
+ });
389
+ let persisted = true;
390
+ try {
391
+ await writeWorkspaceConfig({ dataModel: nextConfig.dataModel });
392
+ } catch {
393
+ persisted = false;
394
+ }
395
+ const liveField = liveGraphField(owner.row);
396
+ const { receipt } = await appendOutcomeReceipt({
397
+ kind: "workspace-add-on-schedule",
398
+ lane: "server-authoritative",
399
+ // Remote schedule is gone. If the local revert did NOT persist, this is a
400
+ // divergence (workspace still shows the row bound) — record it as failed.
401
+ outcomeStatus: persisted ? "published" : "failed",
402
+ actor: "workspace-marketplace",
403
+ objectRefs: [{ objectId: owner.objectId, objectType: "sandbox-environment", rowName: owner.row.Name }],
404
+ changedFields: [`dataModel.${owner.objectId}.${owner.row.Name}.scheduleId`, `dataModel.${owner.objectId}.${owner.row.Name}.${liveField}.trigger`],
405
+ policyVerdict: { ok: persisted, ...(persisted ? {} : { violationCodes: ["scheduler_delete_persist_failed"] }) },
406
+ summary: persisted
407
+ ? `${product.label} scheduler ${scheduleId} uninstalled from ${owner.row.Name} (${deleteDetail}); row reverted to local + manual trigger.`
408
+ : `${product.label} remote schedule ${scheduleId} deleted (${deleteDetail}) but workspace revert did NOT persist — row still shows bound. Re-run uninstall on a writable runtime.`,
409
+ nextActions: persisted ? [] : ["Persistence is read-only here. Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or use a writable runtime, then re-run uninstall to clear the row."],
410
+ });
411
+
412
+ // Remote delete succeeded but local persistence failed → do NOT report clean
413
+ // success; the workspace row is now stale relative to provider reality.
414
+ if (!persisted) {
415
+ return jsonError(`remote schedule ${scheduleId} deleted but workspace revert did not persist`, 424, {
416
+ providerId: provider.providerId, productId: product.productId, scheduleId, deleted: true, persisted: false, receiptId: receipt.receiptId,
417
+ });
418
+ }
419
+
420
+ return NextResponse.json({ ok: true, providerId: provider.providerId, productId: product.productId, scheduleId, deleted: true, persisted, workspaceConfig: nextConfig, receiptId: receipt.receiptId });
421
+ }
422
+
423
+ export { GET, POST, DELETE };
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readWorkspaceConfig, writeWorkspaceConfig } from "@/lib/workspace-config";
3
+ import {
4
+ getMarketplaceProvider,
5
+ withMarketplaceProviderRegistry,
6
+ } from "@/lib/workspace-add-ons";
7
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
8
+ import { readEnvVar } from "@/lib/server-secrets";
9
+ import { requireWorkspaceOperator } from "@/lib/workspace-operator-auth";
10
+
11
+ function clean(value) {
12
+ return String(value == null ? "" : value).trim();
13
+ }
14
+
15
+ function jsonError(message, status = 400, extra = {}) {
16
+ return NextResponse.json({ error: message, ...extra }, { status });
17
+ }
18
+
19
+ function resolvedEnvKeys(keys) {
20
+ return (Array.isArray(keys) ? keys : []).filter((key) => Boolean(readEnvVar(key, process.env)));
21
+ }
22
+
23
+ async function POST(request, context) {
24
+ const params = await context?.params;
25
+ const providerId = clean(params?.providerId);
26
+ const provider = getMarketplaceProvider(providerId);
27
+ if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
28
+
29
+ const auth = requireWorkspaceOperator(request);
30
+ if (!auth.ok) return jsonError(auth.error, auth.status);
31
+
32
+ const connectUrl = provider.accountSetupUrl || provider.consoleUrl;
33
+ const requiredEnv = [provider.accountProbe?.emailEnv, provider.accountProbe?.keyEnv].filter(Boolean);
34
+ const resolvedEnv = resolvedEnvKeys(requiredEnv);
35
+ const now = new Date().toISOString();
36
+ const syncResult = {
37
+ ok: false,
38
+ syncStatus: "setup-opened",
39
+ status: "setup-opened",
40
+ testedAt: now,
41
+ missingEnv: requiredEnv.filter((key) => !resolvedEnv.includes(key)),
42
+ resolvedEnv,
43
+ proof: "",
44
+ summary: `${provider.label} provider setup opened. Save account credentials to verify this provider before installing products.`,
45
+ };
46
+ const currentConfig = await readWorkspaceConfig();
47
+ const nextConfig = withMarketplaceProviderRegistry(currentConfig, { providerId: provider.providerId, syncResult });
48
+ const persisted = await writeWorkspaceConfig({ dataModel: nextConfig.dataModel });
49
+ const { receipt } = await appendOutcomeReceipt({
50
+ kind: "workspace-add-on-provider-connect",
51
+ lane: "server-authoritative",
52
+ outcomeStatus: "published",
53
+ actor: "workspace-marketplace",
54
+ objectRefs: [{ objectId: "api-registry", objectType: "api-registry", rowName: provider.label }],
55
+ changedFields: ["dataModel.api-registry"],
56
+ policyVerdict: { ok: true },
57
+ schemaVerdict: { ok: true },
58
+ summary: syncResult.summary,
59
+ nextActions: [`Save ${provider.label} account credentials, then sync the provider.`],
60
+ });
61
+
62
+ return NextResponse.json({
63
+ ok: true,
64
+ providerId: provider.providerId,
65
+ connectUrl,
66
+ accountState: "setup-opened",
67
+ workspaceConfig: persisted,
68
+ receiptId: receipt?.receiptId,
69
+ setup: {
70
+ account: provider.providerId,
71
+ requiredEnv,
72
+ missingEnv: requiredEnv.filter((key) => !resolvedEnv.includes(key)),
73
+ resolvedEnv,
74
+ },
75
+ });
76
+ }
77
+
78
+ export { POST };