@growthub/cli 0.14.1 → 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 (49) 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-agent-auth/login/route.js +3 -2
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +86 -2
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +49 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +54 -11
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +113 -36
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +7 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +35 -169
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +85 -7
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +8 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +4 -2
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +1 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +24 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +3 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
  49. package/package.json +2 -2
@@ -10,7 +10,7 @@
10
10
  * host's `notes` string.
11
11
  *
12
12
  * Request body:
13
- * { objectId: string, name: string }
13
+ * { objectId: string, name: string, agentHost?: string }
14
14
  *
15
15
  * Response:
16
16
  * {
@@ -42,6 +42,7 @@ async function POST(request) {
42
42
 
43
43
  const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
44
44
  const name = typeof body?.name === "string" ? body.name.trim() : "";
45
+ const agentHost = typeof body?.agentHost === "string" ? body.agentHost.trim() : "";
45
46
  if (!objectId || !name) {
46
47
  return NextResponse.json(
47
48
  { ok: false, error: "objectId and name are required" },
@@ -50,7 +51,7 @@ async function POST(request) {
50
51
  }
51
52
 
52
53
  try {
53
- const result = await runAgentLogout({ objectId, name });
54
+ const result = await runAgentLogout({ objectId, name, agentHost });
54
55
  return NextResponse.json(result);
55
56
  } catch (error) {
56
57
  return NextResponse.json(
@@ -20,7 +20,7 @@
20
20
  * load.
21
21
  *
22
22
  * Request body:
23
- * { objectId: string, name: string }
23
+ * { objectId: string, name: string, agentHost?: string }
24
24
  *
25
25
  * Response (success):
26
26
  * {
@@ -52,6 +52,7 @@ async function POST(request) {
52
52
 
53
53
  const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
54
54
  const name = typeof body?.name === "string" ? body.name.trim() : "";
55
+ const agentHost = typeof body?.agentHost === "string" ? body.agentHost.trim() : "";
55
56
  if (!objectId || !name) {
56
57
  return NextResponse.json(
57
58
  { ok: false, error: "objectId and name are required" },
@@ -60,7 +61,7 @@ async function POST(request) {
60
61
  }
61
62
 
62
63
  try {
63
- const result = await checkAgentStatus({ objectId, name });
64
+ const result = await checkAgentStatus({ objectId, name, agentHost });
64
65
  return NextResponse.json(result);
65
66
  } catch (error) {
66
67
  return NextResponse.json(
@@ -43,12 +43,14 @@
43
43
  * envRefsMissing: string[],
44
44
  * networkAllow: boolean,
45
45
  * allowList: string[],
46
+ * browserAccess: boolean, // first-class browser capability (implies networkAllow)
46
47
  * adapterMeta?: Record<string, unknown>
47
48
  * }
48
49
  * }
49
50
  */
50
51
 
51
52
  import { NextResponse } from "next/server";
53
+ import { createHash } from "node:crypto";
52
54
  import { promises as fs } from "node:fs";
53
55
  import os from "node:os";
54
56
  import path from "node:path";
@@ -77,6 +79,9 @@ import {
77
79
  } from "@/lib/adapters/sandboxes";
78
80
  import { runOrchestrationGraphIfPresent } from "@/lib/orchestration-graph-runner";
79
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";
80
85
  import {
81
86
  discoverRunInputSchema,
82
87
  normalizeRunInputsEnvelope,
@@ -173,6 +178,7 @@ async function runServerlessScheduler({
173
178
  timeoutMs,
174
179
  networkAllow,
175
180
  allowList,
181
+ browserAccess,
176
182
  envRefSlugs,
177
183
  envRefsResolved,
178
184
  envRefsMissing
@@ -251,6 +257,7 @@ async function runServerlessScheduler({
251
257
  timeoutMs,
252
258
  networkAllow,
253
259
  allowList,
260
+ browserAccess,
254
261
  envRefSlugs,
255
262
  envRefsResolved,
256
263
  envRefsMissing
@@ -353,6 +360,7 @@ function buildRunResponse({
353
360
  envRefsMissing,
354
361
  networkAllow,
355
362
  allowList,
363
+ browserAccess,
356
364
  result,
357
365
  timeoutMs,
358
366
  row,
@@ -384,6 +392,7 @@ function buildRunResponse({
384
392
  envRefsMissing,
385
393
  networkAllow,
386
394
  allowList,
395
+ browserAccess,
387
396
  adapterMeta: result.adapterMeta || null
388
397
  };
389
398
  if (row && (row.resolverTemplateId || row.connectorKind || row.executionLane)) {
@@ -465,6 +474,11 @@ async function executeSandboxRun(body, { emit } = {}) {
465
474
  const draftGraph = useDraft
466
475
  ? parseOrchestrationGraph(body?.draftGraph || row.orchestrationDraftConfig || row.orchestrationDraftGraph)
467
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;
468
482
  const rowForRun = draftGraph
469
483
  ? { ...row, orchestrationGraph: draftGraph, orchestrationConfig: draftGraph }
470
484
  : row;
@@ -497,7 +511,11 @@ async function executeSandboxRun(body, { emit } = {}) {
497
511
  let adapterId = (typeof rowForRun.adapter === "string" && rowForRun.adapter.trim()) ? rowForRun.adapter.trim() : DEFAULT_SANDBOX_ADAPTER;
498
512
  const agentHost = typeof rowForRun.agentHost === "string" ? rowForRun.agentHost.trim() : "";
499
513
  const schedulerRegistryId = typeof rowForRun.schedulerRegistryId === "string" ? rowForRun.schedulerRegistryId.trim() : "";
500
- const networkAllow = coerceBoolean(rowForRun.networkAllow);
514
+ const browserAccess = coerceBoolean(rowForRun.browserAccess);
515
+ // Browser access implies outbound network — the same deterministic
516
+ // normalization the sidecar toggle applies, enforced server-side so
517
+ // rows patched via the API behave identically to rows saved in the UI.
518
+ const networkAllow = coerceBoolean(rowForRun.networkAllow) || browserAccess;
501
519
  const allowList = parseSandboxAllowList(rowForRun.allowList);
502
520
  const envRefSlugs = parseSandboxEnvRefs(rowForRun.envRefs);
503
521
  const command = typeof rowForRun.command === "string" ? rowForRun.command : "";
@@ -576,6 +594,7 @@ async function executeSandboxRun(body, { emit } = {}) {
576
594
  envRefsResolved,
577
595
  networkAllow,
578
596
  allowList,
597
+ browserAccess,
579
598
  instructions,
580
599
  command,
581
600
  timeoutMs,
@@ -607,6 +626,7 @@ async function executeSandboxRun(body, { emit } = {}) {
607
626
  timeoutMs,
608
627
  networkAllow,
609
628
  allowList,
629
+ browserAccess,
610
630
  envRefSlugs,
611
631
  envRefsResolved,
612
632
  envRefsMissing
@@ -640,6 +660,7 @@ async function executeSandboxRun(body, { emit } = {}) {
640
660
  timeoutMs,
641
661
  networkAllow,
642
662
  allowList,
663
+ browserAccess,
643
664
  env,
644
665
  envRefSlugs,
645
666
  envRefsMissing,
@@ -680,12 +701,23 @@ async function executeSandboxRun(body, { emit } = {}) {
680
701
  envRefsMissing,
681
702
  networkAllow,
682
703
  allowList,
704
+ browserAccess,
683
705
  result,
684
706
  timeoutMs,
685
707
  row: rowForRun,
686
708
  runInputs: normalizedRunInputs
687
709
  });
688
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
+
689
721
  const sourceId = sandboxRunSourceId(objectId, row.Name || name);
690
722
  const persistence = describePersistenceMode();
691
723
  const status = response.exitCode === 0 && !response.error ? "connected" : "failed";
@@ -741,8 +773,29 @@ async function executeSandboxRun(body, { emit } = {}) {
741
773
  }
742
774
  }
743
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
+
744
797
  return NextResponse.json({
745
- ok: response.exitCode === 0 && !response.error,
798
+ ok: runOk,
746
799
  status,
747
800
  runId,
748
801
  adapter: effectiveAdapterId,
@@ -758,6 +811,37 @@ async function executeSandboxRun(body, { emit } = {}) {
758
811
 
759
812
  async function POST(request) {
760
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
+ }
761
845
  let body;
762
846
  try {
763
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 };
@@ -255,7 +255,7 @@ function WorkspaceHelperSetupModal({ workspaceConfig, open, onClose, onSaved })
255
255
  />
256
256
  serverless
257
257
  </label>
258
- <small>Local uses process sandbox or Paperclip agent host on this machine. Serverless delegates to an API Registry URL.</small>
258
+ <small>Choose local execution or a scheduled serverless run.</small>
259
259
  </div>
260
260
  <div className="workspace-helper-setup-field-stack">
261
261
  <label>
@@ -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 }) {