@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +36 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +86 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +49 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +54 -11
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +113 -36
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +35 -169
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +85 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +8 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +24 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +3 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
- 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
|
|
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:
|
|
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
|
|
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>
|
|
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 }) {
|