@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
@@ -1,3 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { createHash } from "node:crypto";
3
+ import { readWorkspaceConfig, readWorkspaceSourceRecords, writeWorkspaceConfig } from "@/lib/workspace-config";
4
+ import { sandboxRunSourceId } from "@/lib/workspace-data-model";
5
+ import { parseOrchestrationGraph, validateOrchestrationGraph } from "@/lib/orchestration-graph";
6
+ import { stableStringify } from "@/lib/workspace-patch-policy";
7
+ import { readTriggerScheduleBinding } from "@/lib/workspace-add-ons";
8
+ import { scanServerlessReadiness, READINESS_KIND } from "@/lib/serverless-readiness";
9
+ import { resolveWorkflowFieldNames, getNodeDeltaRecords, normalizeDeltaTags, patchSandboxRowInConfig } from "@/lib/orchestration-publish";
10
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
11
+ import { requireAppScope, checkScopedWorkflowAccess } from "@/lib/workspace-app-registry";
12
+
1
13
  /**
2
14
  * POST /api/workspace/workflow/publish
3
15
  *
@@ -31,308 +43,413 @@
31
43
  * or { ok: false, code, error, ... } with 4xx/5xx status.
32
44
  */
33
45
 
34
- import { NextResponse } from "next/server";
35
- import { createHash } from "node:crypto";
36
- import {
37
- readWorkspaceConfig,
38
- readWorkspaceSourceRecords,
39
- writeWorkspaceConfig
40
- } from "@/lib/workspace-config";
41
- import { sandboxRunSourceId } from "@/lib/workspace-data-model";
42
- import { parseOrchestrationGraph, validateOrchestrationGraph } from "@/lib/orchestration-graph";
43
- import { stableStringify } from "@/lib/workspace-patch-policy";
44
- import {
45
- getNodeDeltaRecords,
46
- normalizeDeltaTags,
47
- patchSandboxRowInConfig,
48
- resolveWorkflowFieldNames
49
- } from "@/lib/orchestration-publish";
50
- import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
51
- import { requireAppScope, checkScopedWorkflowAccess } from "@/lib/workspace-app-registry";
46
+
47
+
48
+
49
+
50
+
51
+
52
+
52
53
 
53
54
  function sha256(text) {
54
- return createHash("sha256").update(String(text), "utf8").digest("hex");
55
+ return createHash("sha256").update(String(text), "utf8").digest("hex");
55
56
  }
56
-
57
57
  function findSandboxRow(workspaceConfig, objectId, name) {
58
- const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
59
- const object = objects.find((entry) => entry?.id === objectId && entry?.objectType === "sandbox-environment");
60
- if (!object) return { object: null, row: null, rowIndex: -1 };
61
- const wantedName = String(name || "").trim();
62
- const rows = Array.isArray(object.rows) ? object.rows : [];
63
- const rowIndex = rows.findIndex((row) => String(row?.Name || "").trim() === wantedName);
64
- if (rowIndex === -1) return { object, row: null, rowIndex: -1 };
65
- return { object, row: rows[rowIndex], rowIndex };
58
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
59
+ const object = objects.find((entry)=>entry?.id === objectId && entry?.objectType === "sandbox-environment");
60
+ if (!object) return {
61
+ object: null,
62
+ row: null,
63
+ rowIndex: -1
64
+ };
65
+ const wantedName = String(name || "").trim();
66
+ const rows = Array.isArray(object.rows) ? object.rows : [];
67
+ const rowIndex = rows.findIndex((row)=>String(row?.Name || "").trim() === wantedName);
68
+ if (rowIndex === -1) return {
69
+ object,
70
+ row: null,
71
+ rowIndex: -1
72
+ };
73
+ return {
74
+ object,
75
+ row: rows[rowIndex],
76
+ rowIndex
77
+ };
78
+ }
79
+ function rowHasSuccessfulServerlessSchedulerProof(row, draft) {
80
+ const runLocality = String(row?.runLocality || "").trim().toLowerCase();
81
+ const schedulerRegistryId = String(row?.schedulerRegistryId || "").trim();
82
+ const scheduleId = String(row?.scheduleId || "").trim();
83
+ const draftGraph = String(draft || "").trim();
84
+ const testedConfig = String(row?.orchestrationDraftTestedConfig || "").trim();
85
+ const liveGraph = String(row?.orchestrationGraph || row?.orchestrationConfig || "").trim();
86
+ const binding = readTriggerScheduleBinding(row?.orchestrationGraph || row?.orchestrationConfig);
87
+ return runLocality === "serverless" && Boolean(schedulerRegistryId) && Boolean(scheduleId) && binding?.enabled === true && binding?.scheduleId === scheduleId && binding?.schedulerRegistryId === schedulerRegistryId && (testedConfig === draftGraph || liveGraph === draftGraph);
66
88
  }
67
-
68
89
  /**
69
90
  * Gate failures are governance signal: emit a blocked outcome receipt
70
91
  * (non-fatal) and return the structured failure envelope.
71
- */
72
- async function publishBlocked(httpStatus, body, refs) {
73
- await appendOutcomeReceipt({
74
- kind: "workflow-publish",
75
- lane: "server-authoritative",
76
- outcomeStatus: "blocked",
77
- ...(refs ? { objectRefs: [refs] } : {}),
78
- summary: `publish blocked (${body.code}): ${body.error}`,
79
- nextActions: body.code === "no_draft" || body.code === "draft_not_tested" || body.code === "draft_run_not_verified" || body.code === "draft_changed_after_test"
80
- ? ["Save the draft, run POST /api/workspace/sandbox-run {useDraft:true} to a passing result, attest, then publish"]
81
- : []
82
- });
83
- return NextResponse.json(body, { status: httpStatus });
92
+ */ async function publishBlocked(httpStatus, body, refs) {
93
+ await appendOutcomeReceipt({
94
+ kind: "workflow-publish",
95
+ lane: "server-authoritative",
96
+ outcomeStatus: "blocked",
97
+ ...refs ? {
98
+ objectRefs: [
99
+ refs
100
+ ]
101
+ } : {},
102
+ summary: `publish blocked (${body.code}): ${body.error}`,
103
+ nextActions: body.code === "no_draft" || body.code === "draft_not_tested" || body.code === "draft_run_not_verified" || body.code === "draft_changed_after_test" ? [
104
+ "Save the draft, run POST /api/workspace/sandbox-run {useDraft:true} to a passing result, attest, then publish"
105
+ ] : []
106
+ });
107
+ return NextResponse.json(body, {
108
+ status: httpStatus
109
+ });
84
110
  }
85
-
86
111
  async function POST(request) {
87
- let body;
88
- try {
89
- body = await request.json();
90
- } catch {
91
- return NextResponse.json({ ok: false, code: "invalid_body", error: "invalid json body" }, { status: 400 });
92
- }
93
- const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
94
- const name = typeof body?.name === "string" ? body.name.trim() : "";
95
- const requestedField = typeof body?.field === "string" ? body.field.trim() : "";
96
- if (!objectId || !name) {
97
- return NextResponse.json(
98
- { ok: false, code: "invalid_body", error: "objectId and name are required" },
99
- { status: 400 }
100
- );
101
- }
102
- if (requestedField && requestedField !== "orchestrationConfig" && requestedField !== "orchestrationGraph") {
103
- return NextResponse.json(
104
- { ok: false, code: "invalid_body", error: 'field must be "orchestrationConfig" or "orchestrationGraph" when provided' },
105
- { status: 400 }
106
- );
107
- }
108
-
109
- const workspaceConfig = await readWorkspaceConfig();
110
-
111
- // Unified app-scope gate (route-shopping closed): with x-growthub-app-scope,
112
- // publish may only promote a workflow inside the app's governed scope.
113
- // NB: publish is deliberately NOT blocked when the app's health is
114
- // "blocked" — publishing is how the "workflow not live" blocker is cleared.
115
- const scope = requireAppScope(request, workspaceConfig);
116
- if (scope.scoped) {
117
- const violation = scope.violation || checkScopedWorkflowAccess(scope.context, objectId, name);
118
- if (violation) {
119
- await appendOutcomeReceipt({
120
- kind: "workflow-publish", lane: "server-authoritative", outcomeStatus: "blocked",
121
- appId: violation.appScope || scope.appId,
122
- summary: `publish rejected (422 app scope): ${violation.violationType}`,
123
- nextActions: violation.repairPlan
124
- });
125
- return NextResponse.json(violation, { status: 422 });
112
+ let body;
113
+ try {
114
+ body = await request.json();
115
+ } catch {
116
+ return NextResponse.json({
117
+ ok: false,
118
+ code: "invalid_body",
119
+ error: "invalid json body"
120
+ }, {
121
+ status: 400
122
+ });
126
123
  }
127
- }
128
-
129
- const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
130
- if (!object) {
131
- return NextResponse.json(
132
- { ok: false, code: "object_not_found", error: `no sandbox-environment object with id ${objectId}` },
133
- { status: 404 }
134
- );
135
- }
136
- if (!row) {
137
- return NextResponse.json(
138
- { ok: false, code: "row_not_found", error: `no sandbox row named ${name} in object ${objectId}` },
139
- { status: 404 }
140
- );
141
- }
142
-
143
- const { liveField, draftField } = resolveWorkflowFieldNames(row, requestedField || undefined);
144
- const draft = String(row[draftField] ?? "").trim();
145
- if (!draft) {
146
- return publishBlocked(409, {
147
- ok: false,
148
- code: "no_draft",
149
- error: `no saved draft in ${draftField} — save the draft, test it with sandbox-run useDraft:true, then publish`
150
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
151
- }
152
-
153
- const draftPassed = row.orchestrationDraftTestPassed === true
154
- || String(row.orchestrationDraftTestPassed ?? "") === "true";
155
- if (!draftPassed) {
156
- return publishBlocked(409, {
157
- ok: false,
158
- code: "draft_not_tested",
159
- error: "publish blocked — the saved draft has no successful test run; " +
160
- "run POST /api/workspace/sandbox-run with useDraft:true and a passing result first"
161
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
162
- }
163
-
164
- const testedConfig = String(row.orchestrationDraftTestedConfig ?? "");
165
- if (testedConfig !== draft) {
166
- return publishBlocked(409, {
167
- ok: false,
168
- code: "draft_changed_after_test",
169
- error: "publish blocked — the draft changed after its successful test; re-test this exact draft",
170
- // Diagnostic raw-STRING hashes (the equality above is byte-level);
171
- // the canonical graph hash everywhere else is sha256(stableStringify(parsedGraph)).
172
- draftStringSha256: sha256(draft),
173
- testedStringSha256: sha256(testedConfig)
174
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
175
- }
176
-
177
- // Lineage gate — the draft-field attestation (`orchestrationDraftTestPassed`,
178
- // `orchestrationDraftTestedConfig`) is PATCH-writable, so it is not trusted
179
- // alone. The claimed draft run must exist in the source-record run history
180
- // (which only sandbox-run writes; PATCH is policy-blocked from sidecar
181
- // writes), must have passed (exitCode 0, no error), and the graph it
182
- // actually executed must equal this draft.
183
- const draftRunId = String(row.orchestrationDraftLastRunId ?? "").trim();
184
- if (!draftRunId) {
185
- return publishBlocked(409, {
186
- ok: false,
187
- code: "draft_run_not_verified",
188
- error: "publish blocked — no server-recorded draft run on this row; " +
189
- "run POST /api/workspace/sandbox-run with useDraft:true first"
190
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
191
- }
192
- const sourceId = sandboxRunSourceId(objectId, row.Name || name);
193
- const history = sourceId ? await readWorkspaceSourceRecords(sourceId) : null;
194
- const records = Array.isArray(history?.records) ? history.records : [];
195
- const runRecord = records.find((record) => String(record?.runId ?? "") === draftRunId);
196
- if (!runRecord) {
197
- return publishBlocked(409, {
198
- ok: false,
199
- code: "draft_run_not_verified",
200
- error: `publish blocked — draft run ${draftRunId} has no record in the sandbox run history (${sourceId})`
201
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
202
- }
203
- if (runRecord.exitCode !== 0 || runRecord.error) {
204
- return publishBlocked(409, {
205
- ok: false,
206
- code: "draft_run_not_verified",
207
- error: `publish blocked draft run ${draftRunId} did not pass (exitCode ${runRecord.exitCode})`
208
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
209
- }
210
- // The record's draftSha256 is stamped by sandbox-run from the exact graph
211
- // it executed, before execution. It must match this saved draft.
212
- const draftGraphParsed = parseOrchestrationGraph(draft);
213
- const expectedSha256 = createHash("sha256")
214
- .update(stableStringify(draftGraphParsed), "utf8")
215
- .digest("hex");
216
- if (runRecord.useDraft !== true || runRecord.draftSha256 !== expectedSha256) {
217
- return publishBlocked(409, {
218
- ok: false,
219
- code: "draft_run_not_verified",
220
- error: `publish blocked — draft run ${draftRunId} executed a different graph than the saved draft ` +
221
- "(or was not a draft run); re-test this exact draft with sandbox-run useDraft:true"
222
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
223
- }
224
-
225
- const parsedDraft = draftGraphParsed;
226
- const validation = validateOrchestrationGraph(parsedDraft);
227
- if (!validation?.ok) {
228
- return publishBlocked(400, {
229
- ok: false,
230
- code: "invalid_graph",
231
- error: "publish blocked — the draft does not parse as a valid orchestration graph",
232
- details: validation?.errors ?? []
233
- }, { objectId, rowName: name, objectType: "sandbox-environment" });
234
- }
235
-
236
- const publishedAt = new Date().toISOString();
237
- const currentVersion = Number(row.version || 1);
238
- const nextVersion = Number.isFinite(currentVersion) ? String(currentVersion + 1) : "1";
239
- const previousDeltas = Array.isArray(row.orchestrationDeltas) ? row.orchestrationDeltas : [];
240
- const previousPublishedGraph = parseOrchestrationGraph(row[liveField]);
241
- const nodeDeltas = getNodeDeltaRecords(previousPublishedGraph, parsedDraft);
242
- const deltaTags = normalizeDeltaTags(nodeDeltas.flatMap((delta) => delta.deltaTags));
243
- const changeReason = nodeDeltas.map((delta) => delta.changeReason).filter(Boolean).join("\n");
244
- // One canonical draft/graph hash everywhere: sha256(stableStringify(parsedGraph)).
245
- // This is the same value sandbox-run stamped as the record's draftSha256,
246
- // so the lineage record and the publish delta are directly comparable.
247
- const publishedSha256 = expectedSha256;
248
-
249
- const next = patchSandboxRowInConfig(workspaceConfig, objectId, rowIndex, {
250
- [liveField]: draft,
251
- [draftField]: "",
252
- version: nextVersion,
253
- lifecycleStatus: "live",
254
- orchestrationDraftStatus: "published",
255
- orchestrationDraftTestPassed: false,
256
- orchestrationDraftTestedConfig: "",
257
- orchestrationPublishedAt: publishedAt,
258
- orchestrationDeltas: [
259
- ...previousDeltas,
260
- {
261
- at: publishedAt,
124
+ const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
125
+ const name = typeof body?.name === "string" ? body.name.trim() : "";
126
+ const requestedField = typeof body?.field === "string" ? body.field.trim() : "";
127
+ if (!objectId || !name) {
128
+ return NextResponse.json({
129
+ ok: false,
130
+ code: "invalid_body",
131
+ error: "objectId and name are required"
132
+ }, {
133
+ status: 400
134
+ });
135
+ }
136
+ if (requestedField && requestedField !== "orchestrationConfig" && requestedField !== "orchestrationGraph") {
137
+ return NextResponse.json({
138
+ ok: false,
139
+ code: "invalid_body",
140
+ error: 'field must be "orchestrationConfig" or "orchestrationGraph" when provided'
141
+ }, {
142
+ status: 400
143
+ });
144
+ }
145
+ const workspaceConfig = await readWorkspaceConfig();
146
+ // Unified app-scope gate (route-shopping closed): with x-growthub-app-scope,
147
+ // publish may only promote a workflow inside the app's governed scope.
148
+ // NB: publish is deliberately NOT blocked when the app's health is
149
+ // "blocked" — publishing is how the "workflow not live" blocker is cleared.
150
+ const scope = requireAppScope(request, workspaceConfig);
151
+ if (scope.scoped) {
152
+ const violation = scope.violation || checkScopedWorkflowAccess(scope.context, objectId, name);
153
+ if (violation) {
154
+ await appendOutcomeReceipt({
155
+ kind: "workflow-publish",
156
+ lane: "server-authoritative",
157
+ outcomeStatus: "blocked",
158
+ appId: violation.appScope || scope.appId,
159
+ summary: `publish rejected (422 app scope): ${violation.violationType}`,
160
+ nextActions: violation.repairPlan
161
+ });
162
+ return NextResponse.json(violation, {
163
+ status: 422
164
+ });
165
+ }
166
+ }
167
+ const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
168
+ if (!object) {
169
+ return NextResponse.json({
170
+ ok: false,
171
+ code: "object_not_found",
172
+ error: `no sandbox-environment object with id ${objectId}`
173
+ }, {
174
+ status: 404
175
+ });
176
+ }
177
+ if (!row) {
178
+ return NextResponse.json({
179
+ ok: false,
180
+ code: "row_not_found",
181
+ error: `no sandbox row named ${name} in object ${objectId}`
182
+ }, {
183
+ status: 404
184
+ });
185
+ }
186
+ const { liveField, draftField } = resolveWorkflowFieldNames(row, requestedField || undefined);
187
+ const draft = String(row[draftField] ?? "").trim();
188
+ if (!draft) {
189
+ return publishBlocked(409, {
190
+ ok: false,
191
+ code: "no_draft",
192
+ error: `no saved draft in ${draftField} save the draft, test it with sandbox-run useDraft:true, then publish`
193
+ }, {
194
+ objectId,
195
+ rowName: name,
196
+ objectType: "sandbox-environment"
197
+ });
198
+ }
199
+ const draftPassed = row.orchestrationDraftTestPassed === true || String(row.orchestrationDraftTestPassed ?? "") === "true";
200
+ const serverlessSchedulerProofPassed = rowHasSuccessfulServerlessSchedulerProof(row, draft);
201
+ // Causality gate: a serverless-bound row may only publish when its WHOLE
202
+ // downstream graph is still serverless-ready (binding valid ≠ graph runnable).
203
+ // The graph can drift after install — a downstream node, API Registry row,
204
+ // credential ref, or input template may have changed. Block publish until the
205
+ // compatibility proof is clean; keep the published graph unchanged.
206
+ if (serverlessSchedulerProofPassed) {
207
+ const readiness = scanServerlessReadiness({
208
+ row,
209
+ workspaceConfig,
210
+ env: process.env,
211
+ phase: "bound",
212
+ expected: {
213
+ scheduleId: String(row?.scheduleId || "").trim(),
214
+ schedulerRegistryId: String(row?.schedulerRegistryId || "").trim(),
215
+ providerId: String(row?.schedulerProviderId || "").trim(),
216
+ productId: String(row?.schedulerProductId || "").trim(),
217
+ },
218
+ });
219
+ if (!readiness.ok) {
220
+ await appendOutcomeReceipt({
221
+ kind: READINESS_KIND,
222
+ lane: "server-authoritative",
223
+ outcomeStatus: "blocked",
224
+ objectRefs: [{ objectId, rowName: name, objectType: "sandbox-environment" }],
225
+ policyVerdict: { ok: false, violationCodes: readiness.deltaTags },
226
+ summary: `publish blocked: ${name} is no longer serverless-ready (${readiness.blockingNodes.length} blocking node(s)).`,
227
+ nextActions: readiness.blockingNodes.map((nbl) => nbl.helperAction).filter(Boolean),
228
+ });
229
+ return NextResponse.json({
230
+ ok: false,
231
+ code: "serverless_not_ready",
232
+ error: "publish blocked — the serverless-bound graph is not compatible; resolve the flagged nodes before publishing",
233
+ readiness,
234
+ }, { status: 409 });
235
+ }
236
+ }
237
+ if (!draftPassed && !serverlessSchedulerProofPassed) {
238
+ return publishBlocked(409, {
239
+ ok: false,
240
+ code: "draft_not_tested",
241
+ error: "publish blocked — the saved draft has no successful test run; " + "run POST /api/workspace/sandbox-run with useDraft:true or the installed serverless scheduler with a passing result first"
242
+ }, {
243
+ objectId,
244
+ rowName: name,
245
+ objectType: "sandbox-environment"
246
+ });
247
+ }
248
+ const testedConfig = String(row.orchestrationDraftTestedConfig ?? "");
249
+ if (!serverlessSchedulerProofPassed && testedConfig !== draft) {
250
+ return publishBlocked(409, {
251
+ ok: false,
252
+ code: "draft_changed_after_test",
253
+ error: "publish blocked — the draft changed after its successful test; re-test this exact draft",
254
+ // Diagnostic raw-STRING hashes (the equality above is byte-level);
255
+ // the canonical graph hash everywhere else is sha256(stableStringify(parsedGraph)).
256
+ draftStringSha256: sha256(draft),
257
+ testedStringSha256: sha256(testedConfig)
258
+ }, {
259
+ objectId,
260
+ rowName: name,
261
+ objectType: "sandbox-environment"
262
+ });
263
+ }
264
+ // Lineage gate — the draft-field attestation (`orchestrationDraftTestPassed`,
265
+ // `orchestrationDraftTestedConfig`) is PATCH-writable, so it is not trusted
266
+ // alone. The claimed draft run must exist in the source-record run history
267
+ // (which only sandbox-run writes; PATCH is policy-blocked from sidecar
268
+ // writes), must have passed (exitCode 0, no error), and the graph it
269
+ // actually executed must equal this draft.
270
+ const draftRunId = String(row.orchestrationDraftLastRunId ?? "").trim();
271
+ if (!serverlessSchedulerProofPassed && !draftRunId) {
272
+ return publishBlocked(409, {
273
+ ok: false,
274
+ code: "draft_run_not_verified",
275
+ error: "publish blocked — no server-recorded draft run on this row; " + "run POST /api/workspace/sandbox-run with useDraft:true first"
276
+ }, {
277
+ objectId,
278
+ rowName: name,
279
+ objectType: "sandbox-environment"
280
+ });
281
+ }
282
+ const sourceId = sandboxRunSourceId(objectId, row.Name || name);
283
+ const history = !serverlessSchedulerProofPassed && sourceId ? await readWorkspaceSourceRecords(sourceId) : null;
284
+ const records = Array.isArray(history?.records) ? history.records : [];
285
+ const runRecord = serverlessSchedulerProofPassed ? null : records.find((record)=>String(record?.runId ?? "") === draftRunId);
286
+ if (!serverlessSchedulerProofPassed) {
287
+ if (!runRecord) {
288
+ return publishBlocked(409, {
289
+ ok: false,
290
+ code: "draft_run_not_verified",
291
+ error: `publish blocked — draft run ${draftRunId} has no record in the sandbox run history (${sourceId})`
292
+ }, {
293
+ objectId,
294
+ rowName: name,
295
+ objectType: "sandbox-environment"
296
+ });
297
+ }
298
+ if (runRecord.exitCode !== 0 || runRecord.error) {
299
+ return publishBlocked(409, {
300
+ ok: false,
301
+ code: "draft_run_not_verified",
302
+ error: `publish blocked — draft run ${draftRunId} did not pass (exitCode ${runRecord.exitCode})`
303
+ }, {
304
+ objectId,
305
+ rowName: name,
306
+ objectType: "sandbox-environment"
307
+ });
308
+ }
309
+ }
310
+ // The record's draftSha256 is stamped by sandbox-run from the exact graph
311
+ // it executed, before execution. It must match this saved draft.
312
+ const draftGraphParsed = parseOrchestrationGraph(draft);
313
+ const expectedSha256 = createHash("sha256").update(stableStringify(draftGraphParsed), "utf8").digest("hex");
314
+ if (!serverlessSchedulerProofPassed && (runRecord.useDraft !== true || runRecord.draftSha256 !== expectedSha256)) {
315
+ return publishBlocked(409, {
316
+ ok: false,
317
+ code: "draft_run_not_verified",
318
+ error: `publish blocked — draft run ${draftRunId} executed a different graph than the saved draft ` + "(or was not a draft run); re-test this exact draft with sandbox-run useDraft:true"
319
+ }, {
320
+ objectId,
321
+ rowName: name,
322
+ objectType: "sandbox-environment"
323
+ });
324
+ }
325
+ const parsedDraft = draftGraphParsed;
326
+ const validation = validateOrchestrationGraph(parsedDraft);
327
+ if (!validation?.ok) {
328
+ return publishBlocked(400, {
329
+ ok: false,
330
+ code: "invalid_graph",
331
+ error: "publish blocked — the draft does not parse as a valid orchestration graph",
332
+ details: validation?.errors ?? []
333
+ }, {
334
+ objectId,
335
+ rowName: name,
336
+ objectType: "sandbox-environment"
337
+ });
338
+ }
339
+ const publishedAt = new Date().toISOString();
340
+ const currentVersion = Number(row.version || 1);
341
+ const nextVersion = Number.isFinite(currentVersion) ? String(currentVersion + 1) : "1";
342
+ const previousDeltas = Array.isArray(row.orchestrationDeltas) ? row.orchestrationDeltas : [];
343
+ const previousPublishedGraph = parseOrchestrationGraph(row[liveField]);
344
+ const nodeDeltas = getNodeDeltaRecords(previousPublishedGraph, parsedDraft);
345
+ const deltaTags = normalizeDeltaTags(nodeDeltas.flatMap((delta)=>delta.deltaTags));
346
+ const changeReason = nodeDeltas.map((delta)=>delta.changeReason).filter(Boolean).join("\n");
347
+ // One canonical draft/graph hash everywhere: sha256(stableStringify(parsedGraph)).
348
+ // This is the same value sandbox-run stamped as the record's draftSha256,
349
+ // so the lineage record and the publish delta are directly comparable.
350
+ const publishedSha256 = expectedSha256;
351
+ const next = patchSandboxRowInConfig(workspaceConfig, objectId, rowIndex, {
352
+ [liveField]: draft,
353
+ [draftField]: "",
262
354
  version: nextVersion,
263
- field: liveField,
264
- action: "publish",
265
- previousVersion: String(row.version || "1"),
266
- draftTestedAt: row.orchestrationDraftLastTested || "",
267
- draftRunId: row.orchestrationDraftLastRunId || "",
268
- publishedSha256,
269
- changeReason,
270
- deltaTags,
271
- nodeDeltas,
272
- nodeCount: Array.isArray(parsedDraft?.nodes) ? parsedDraft.nodes.length : 0,
273
- edgeCount: Array.isArray(parsedDraft?.edges) ? parsedDraft.edges.length : 0
274
- }
275
- ]
276
- });
277
-
278
- try {
279
- const persisted = await writeWorkspaceConfig({ dataModel: next.dataModel });
280
- const { receipt } = await appendOutcomeReceipt({
281
- kind: "workflow-publish",
282
- lane: "server-authoritative",
283
- outcomeStatus: "published",
284
- ...(scope.scoped ? { appId: scope.appId } : {}),
285
- objectRefs: [{ objectId, rowName: name, objectType: "sandbox-environment" }],
286
- changedFields: ["dataModel"],
287
- runId: draftRunId,
288
- sourceId,
289
- draftSha256: expectedSha256,
290
- publishedSha256,
291
- version: nextVersion,
292
- summary: `published ${liveField} v${nextVersion} for ${objectId}/${name} (${nodeDeltas.length} node delta(s), verified draft run ${draftRunId})`,
293
- rollbackRef: {
294
- objectId,
295
- rowName: name,
296
- liveField,
297
- previousVersion: String(row.version || "1"),
298
- deltaIndex: previousDeltas.length,
299
- sourceId
300
- }
355
+ lifecycleStatus: "live",
356
+ orchestrationDraftStatus: "published",
357
+ orchestrationDraftTestPassed: false,
358
+ orchestrationDraftTestedConfig: "",
359
+ orchestrationPublishedAt: publishedAt,
360
+ orchestrationDeltas: [
361
+ ...previousDeltas,
362
+ {
363
+ at: publishedAt,
364
+ version: nextVersion,
365
+ field: liveField,
366
+ action: "publish",
367
+ previousVersion: String(row.version || "1"),
368
+ draftTestedAt: row.orchestrationDraftLastTested || "",
369
+ draftRunId: row.orchestrationDraftLastRunId || "",
370
+ publishedSha256,
371
+ changeReason,
372
+ deltaTags,
373
+ nodeDeltas,
374
+ nodeCount: Array.isArray(parsedDraft?.nodes) ? parsedDraft.nodes.length : 0,
375
+ edgeCount: Array.isArray(parsedDraft?.edges) ? parsedDraft.edges.length : 0
376
+ }
377
+ ]
301
378
  });
302
- return NextResponse.json({
303
- ok: true,
304
- objectId,
305
- name,
306
- version: nextVersion,
307
- publishedAt,
308
- liveField,
309
- publishedSha256,
310
- receiptId: receipt.receiptId,
311
- workspaceConfig: persisted
312
- });
313
- } catch (error) {
314
- if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
315
- return NextResponse.json(
316
- {
317
- ok: false,
318
- code: "read_only",
319
- error: "workspace config is read-only in this runtime",
320
- guidance: error.guidance || null
321
- },
322
- { status: 409 }
323
- );
324
- }
325
- if (error.code === "INVALID_WORKSPACE_CONFIG") {
326
- return NextResponse.json(
327
- { ok: false, code: "invalid_config", error: error.message, details: error.details },
328
- { status: 400 }
329
- );
379
+ try {
380
+ const persisted = await writeWorkspaceConfig({
381
+ dataModel: next.dataModel
382
+ });
383
+ const { receipt } = await appendOutcomeReceipt({
384
+ kind: "workflow-publish",
385
+ lane: "server-authoritative",
386
+ outcomeStatus: "published",
387
+ ...scope.scoped ? {
388
+ appId: scope.appId
389
+ } : {},
390
+ objectRefs: [
391
+ {
392
+ objectId,
393
+ rowName: name,
394
+ objectType: "sandbox-environment"
395
+ }
396
+ ],
397
+ changedFields: [
398
+ "dataModel"
399
+ ],
400
+ runId: draftRunId,
401
+ sourceId,
402
+ draftSha256: expectedSha256,
403
+ publishedSha256,
404
+ version: nextVersion,
405
+ summary: `published ${liveField} v${nextVersion} for ${objectId}/${name} (${nodeDeltas.length} node delta(s), verified draft run ${draftRunId})`,
406
+ rollbackRef: {
407
+ objectId,
408
+ rowName: name,
409
+ liveField,
410
+ previousVersion: String(row.version || "1"),
411
+ deltaIndex: previousDeltas.length,
412
+ sourceId
413
+ }
414
+ });
415
+ return NextResponse.json({
416
+ ok: true,
417
+ objectId,
418
+ name,
419
+ version: nextVersion,
420
+ publishedAt,
421
+ liveField,
422
+ publishedSha256,
423
+ receiptId: receipt.receiptId,
424
+ workspaceConfig: persisted
425
+ });
426
+ } catch (error) {
427
+ if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
428
+ return NextResponse.json({
429
+ ok: false,
430
+ code: "read_only",
431
+ error: "workspace config is read-only in this runtime",
432
+ guidance: error.guidance || null
433
+ }, {
434
+ status: 409
435
+ });
436
+ }
437
+ if (error.code === "INVALID_WORKSPACE_CONFIG") {
438
+ return NextResponse.json({
439
+ ok: false,
440
+ code: "invalid_config",
441
+ error: error.message,
442
+ details: error.details
443
+ }, {
444
+ status: 400
445
+ });
446
+ }
447
+ return NextResponse.json({
448
+ ok: false,
449
+ code: "write_failed",
450
+ error: error?.message || "failed to write workspace config"
451
+ }, {
452
+ status: 500
453
+ });
330
454
  }
331
- return NextResponse.json(
332
- { ok: false, code: "write_failed", error: error?.message || "failed to write workspace config" },
333
- { status: 500 }
334
- );
335
- }
336
455
  }
337
-
338
- export { POST };