@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.
- 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-run/route.js +72 -1
- 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/WorkspaceLensPanel.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +22 -165
- 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/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 +23 -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/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
|
@@ -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:
|
|
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
|
|
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 }) {
|