@growthub/cli 0.14.2 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +4 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +36 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +72 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +22 -165
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
  23. package/package.json +2 -2
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import { NextResponse } from "next/server";
53
+ import { createHash } from "node:crypto";
53
54
  import { promises as fs } from "node:fs";
54
55
  import os from "node:os";
55
56
  import path from "node:path";
@@ -78,6 +79,9 @@ import {
78
79
  } from "@/lib/adapters/sandboxes";
79
80
  import { runOrchestrationGraphIfPresent } from "@/lib/orchestration-graph-runner";
80
81
  import { parseOrchestrationGraph } from "@/lib/orchestration-graph";
82
+ import { stableStringify } from "@/lib/workspace-patch-policy";
83
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
84
+ import { requireAppScope, checkScopedWorkflowAccess } from "@/lib/workspace-app-registry";
81
85
  import {
82
86
  discoverRunInputSchema,
83
87
  normalizeRunInputsEnvelope,
@@ -470,6 +474,11 @@ async function executeSandboxRun(body, { emit } = {}) {
470
474
  const draftGraph = useDraft
471
475
  ? parseOrchestrationGraph(body?.draftGraph || row.orchestrationDraftConfig || row.orchestrationDraftGraph)
472
476
  : null;
477
+ // Hash the draft BEFORE execution (the graph runner may annotate the object
478
+ // in place). This is the lineage anchor the workflow publish gate verifies.
479
+ const draftSha256 = draftGraph
480
+ ? createHash("sha256").update(stableStringify(draftGraph), "utf8").digest("hex")
481
+ : null;
473
482
  const rowForRun = draftGraph
474
483
  ? { ...row, orchestrationGraph: draftGraph, orchestrationConfig: draftGraph }
475
484
  : row;
@@ -699,6 +708,16 @@ async function executeSandboxRun(body, { emit } = {}) {
699
708
  runInputs: normalizedRunInputs
700
709
  });
701
710
 
711
+ // Draft-run lineage anchor: the sha256 of the exact graph that executed,
712
+ // persisted into the run record. The sidecar is only writable by server
713
+ // routes (PATCH is policy-blocked from it), so the workflow publish gate
714
+ // can verify "this saved draft is what actually ran" against this hash —
715
+ // the PATCH-writable draft attestation fields alone are never trusted.
716
+ if (useDraft && draftSha256) {
717
+ response.useDraft = true;
718
+ response.draftSha256 = draftSha256;
719
+ }
720
+
702
721
  const sourceId = sandboxRunSourceId(objectId, row.Name || name);
703
722
  const persistence = describePersistenceMode();
704
723
  const status = response.exitCode === 0 && !response.error ? "connected" : "failed";
@@ -754,8 +773,29 @@ async function executeSandboxRun(body, { emit } = {}) {
754
773
  }
755
774
  }
756
775
 
776
+ // Agent Outcome Loop V1: every execution emits the same canonical receipt
777
+ // (kind "sandbox-run", lane "execution-proof") into workspace:agent-outcomes,
778
+ // linking the run record so publish gates and future agents can cite it.
779
+ const runOk = response.exitCode === 0 && !response.error;
780
+ await appendOutcomeReceipt({
781
+ kind: "sandbox-run",
782
+ lane: "execution-proof",
783
+ outcomeStatus: runOk ? "tested" : "failed",
784
+ intent: typeof body?.intent === "string" ? body.intent : undefined,
785
+ actor: typeof body?.actor === "string" ? body.actor : undefined,
786
+ objectRefs: [{ objectId, rowName: rowForRun.Name || name, objectType: "sandbox-environment" }],
787
+ runId,
788
+ sourceId: sourceId || undefined,
789
+ ...(useDraft && draftSha256 ? { draftSha256 } : {}),
790
+ summary: `${useDraft ? "draft " : ""}run ${runOk ? "passed" : "failed"} (exit ${response.exitCode}, ${effectiveAdapterId}/${runtime}) — ${String(response.stdout || response.stderr || response.error || "").slice(0, 160)}`,
791
+ ...(runOk && useDraft
792
+ ? { nextActions: ["Attest the tested draft (orchestrationDraftTestPassed + orchestrationDraftTestedConfig), then POST /api/workspace/workflow/publish"] }
793
+ : {}),
794
+ rollbackRef: sourceId ? { objectId, rowName: rowForRun.Name || name, sourceId } : undefined
795
+ });
796
+
757
797
  return NextResponse.json({
758
- ok: response.exitCode === 0 && !response.error,
798
+ ok: runOk,
759
799
  status,
760
800
  runId,
761
801
  adapter: effectiveAdapterId,
@@ -771,6 +811,37 @@ async function executeSandboxRun(body, { emit } = {}) {
771
811
 
772
812
  async function POST(request) {
773
813
  const accept = request.headers.get("accept") || "";
814
+ // Unified app-scope gate: with x-growthub-app-scope, this run must target
815
+ // a workflow inside the app's governed scope (route-shopping closed).
816
+ let scopedAppId = null;
817
+ {
818
+ const cfgForScope = await readWorkspaceConfig().catch(() => ({}));
819
+ const scope = requireAppScope(request, cfgForScope);
820
+ if (scope.scoped && scope.violation) {
821
+ await appendOutcomeReceipt({
822
+ kind: "sandbox-run", lane: "execution-proof", outcomeStatus: "blocked",
823
+ appId: scope.violation.appScope,
824
+ summary: `sandbox-run rejected (422 app scope): ${scope.violation.violationType}`,
825
+ nextActions: [scope.violation.suggestedAction]
826
+ });
827
+ return NextResponse.json(scope.violation, { status: 422 });
828
+ }
829
+ if (scope.scoped) {
830
+ let peek = null;
831
+ try { peek = await request.clone().json(); } catch { peek = null; }
832
+ const violation = checkScopedWorkflowAccess(scope.context, peek?.objectId, peek?.name);
833
+ if (violation) {
834
+ await appendOutcomeReceipt({
835
+ kind: "sandbox-run", lane: "execution-proof", outcomeStatus: "blocked",
836
+ appId: scope.appId,
837
+ summary: `sandbox-run rejected (422 app scope): workflow ${peek?.objectId}:${peek?.name} outside app ${scope.appId}`,
838
+ nextActions: [violation.suggestedAction]
839
+ });
840
+ return NextResponse.json(violation, { status: 422 });
841
+ }
842
+ scopedAppId = scope.appId;
843
+ }
844
+ }
774
845
  let body;
775
846
  try {
776
847
  body = await request.json();
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * Optional query parameter:
13
13
  * - lensId: "activation" (default) | "persistence" | "observability" |
14
- * "deploy" | "tasks" | "app-build". Unknown ids fall back to
15
- * "activation".
14
+ * "deploy" | "tasks" | "app-build" | "fleet". Unknown ids fall
15
+ * back to "activation".
16
16
  *
17
17
  * Authority invariants:
18
18
  * - GET only. PATCH / POST / PUT / DELETE are not exposed. Writes still flow
@@ -35,6 +35,9 @@
35
35
  */
36
36
 
37
37
  import { NextResponse } from "next/server";
38
+ import { requireAppScope, checkScopedRegistryAccess } from "@/lib/workspace-app-registry";
39
+ import { readWorkspaceConfig as readCfgForScope } from "@/lib/workspace-config";
40
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
38
41
  import { readAdapterConfig } from "@/lib/adapters/env";
39
42
  import { listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
40
43
  import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
@@ -61,6 +64,24 @@ async function POST(request) {
61
64
  }
62
65
 
63
66
  const { integrationId, binding } = body || {};
67
+ // App-scope gate: a scoped agent may only test integrations on its
68
+ // registry-row registryIds (data-plane isolation, Attio-style object scope).
69
+ {
70
+ const cfgForScope = await readCfgForScope().catch(() => ({}));
71
+ const scope = requireAppScope(request, cfgForScope);
72
+ if (scope.scoped) {
73
+ const violation = scope.violation || checkScopedRegistryAccess(scope.context, integrationId);
74
+ if (violation) {
75
+ await appendOutcomeReceipt({
76
+ kind: "agent-outcome", lane: "untrusted-direct", outcomeStatus: "blocked",
77
+ appId: violation.appScope || scope.appId,
78
+ summary: `test-source rejected (422 app scope): ${violation.violationType}`,
79
+ nextActions: violation.repairPlan
80
+ });
81
+ return NextResponse.json(violation, { status: 422 });
82
+ }
83
+ }
84
+ }
64
85
  if (typeof integrationId !== "string" || !integrationId.trim()) {
65
86
  return NextResponse.json({ ok: false, reason: "bad-request", error: "integrationId must be a non-empty string" }, { status: 400 });
66
87
  }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * POST /api/workspace/workflow/publish
3
+ *
4
+ * Server-authoritative publish for sandbox-environment workflow rows.
5
+ * This route is the ONLY transition from draft to live: direct
6
+ * `PATCH /api/workspace` is policy-blocked (workspace-patch-policy.js) from
7
+ * changing `orchestrationGraph` / `orchestrationConfig` / `version` /
8
+ * `orchestrationPublishedAt` / `orchestrationDeltas` or setting
9
+ * `lifecycleStatus: "live"`.
10
+ *
11
+ * Publish gates (all server-verified against the persisted row — the client
12
+ * cannot vouch for itself):
13
+ * 1. The row exists (object id + objectType "sandbox-environment" +
14
+ * capital-N `Name`).
15
+ * 2. A saved draft exists (`orchestrationDraftConfig` / `orchestrationDraftGraph`).
16
+ * 3. The draft was test-run successfully: `orchestrationDraftTestPassed === true`
17
+ * (set by POST /api/workspace/sandbox-run with `useDraft: true`).
18
+ * 4. The tested config is byte-identical to the saved draft
19
+ * (`orchestrationDraftTestedConfig` === draft) — a draft edited after
20
+ * its successful test must be re-tested.
21
+ * 5. The draft parses as a structurally valid orchestration graph.
22
+ *
23
+ * On success: bumps `version`, moves the draft into the live field, clears
24
+ * draft state, stamps `orchestrationPublishedAt`, appends an
25
+ * `orchestrationDeltas` record (with the sha256 of the published config),
26
+ * sets `lifecycleStatus: "live"`, and persists via writeWorkspaceConfig.
27
+ *
28
+ * Request: { objectId: string, name: string }
29
+ * Response: { ok, objectId, name, version, publishedAt, liveField,
30
+ * publishedSha256, workspaceConfig }
31
+ * or { ok: false, code, error, ... } with 4xx/5xx status.
32
+ */
33
+
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";
52
+
53
+ function sha256(text) {
54
+ return createHash("sha256").update(String(text), "utf8").digest("hex");
55
+ }
56
+
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 };
66
+ }
67
+
68
+ /**
69
+ * Gate failures are governance signal: emit a blocked outcome receipt
70
+ * (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 });
84
+ }
85
+
86
+ 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 });
126
+ }
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,
262
+ 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
+ }
301
+ });
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
+ );
330
+ }
331
+ return NextResponse.json(
332
+ { ok: false, code: "write_failed", error: error?.message || "failed to write workspace config" },
333
+ { status: 500 }
334
+ );
335
+ }
336
+ }
337
+
338
+ export { POST };
@@ -92,6 +92,7 @@ const FILTERS = [
92
92
  { id: "deploy", label: "Deploy" },
93
93
  { id: "tasks", label: "Tasks" },
94
94
  { id: "app-build", label: "App build" },
95
+ { id: "fleet", label: "Fleet" },
95
96
  ];
96
97
 
97
98
  export function WorkspaceLensPanel({ workspaceConfig, workspaceSourceRecords, metadataGraph }) {