@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
@@ -29,7 +29,9 @@ helpers:
29
29
  - path: helpers/check-self-improving-health.sh
30
30
  verb: check-self-improving-health
31
31
  description: Validate self-improving workspace primitives and proposal dirs.
32
- subSkills: []
32
+ subSkills:
33
+ - name: governed-workspace-mutation
34
+ path: skills/governed-workspace-mutation/SKILL.md
33
35
  mcpTools: []
34
36
  ---
35
37
 
@@ -44,7 +46,7 @@ Every Growthub governed Workspace is materialised from this kit. The kit ships t
44
46
  3. **`templates/project.md`** — session-memory template. On `growthub starter init` and `growthub starter import-*`, the CLI copies this to `.growthub-fork/project.md` so every fork starts with an append-only editing history alongside the machine-readable `trace.jsonl`.
45
47
  4. **`templates/self-eval.md`** — self-evaluation pattern. Describes the generate → render → evaluate → retry (≤3) loop that mirrors the Fork Sync Agent's preview → apply → trace lifecycle.
46
48
  5. **`helpers/`** — safe shell tool layer. Scripts an agent calls via one shell invocation instead of reconstructing raw commands. Populated per fork; the baseline ships conventions only.
47
- 6. **`skills/`** — nested sub-skill convention. Each sub-directory is a full `SKILL.md`-addressable sub-skill that a parent agent can spawn in parallel for heavy or narrow tasks.
49
+ 6. **`skills/`** — nested sub-skill convention. Each sub-directory is a full `SKILL.md`-addressable sub-skill that a parent agent can spawn in parallel for heavy or narrow tasks. The baseline ships [`skills/governed-workspace-mutation/SKILL.md`](./skills/governed-workspace-mutation/SKILL.md) — the runtime-verified contract card for the two canonical workspace calls (`PATCH /api/workspace`, `POST /api/workspace/sandbox-run`). **Read it before any workspace-configuration mutation or sandbox execution.**
48
50
 
49
51
  ## When to use this skill
50
52
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * GET /api/workspace/agent-outcomes
3
+ *
4
+ * The governance cockpit data model (Agent Outcome Loop V1 — contract:
5
+ * `@growthub/api-contract/workspace-outcome::AgentOutcomesResponse`).
6
+ *
7
+ * Returns the unified receipt stream (newest first, bounded) that every
8
+ * mutation lane emits — direct PATCH, preflight rejections, sandbox runs,
9
+ * workflow publishes, helper applies — plus an always-recomputed governance
10
+ * summary derived from the live config:
11
+ *
12
+ * - blocked policy/gate attempts
13
+ * - successful publishes
14
+ * - drafts waiting for a test
15
+ * - drafts tested but not published
16
+ * - live workflow rows whose last run failed
17
+ * - live workflow rows with no recorded run at all
18
+ * - helper applies
19
+ *
20
+ * Read-only. This is how an operator manages a workspace full of agents
21
+ * without reading logs, and how the next agent inherits context: read the
22
+ * stream, cite receiptIds, continue from `nextActions` / `rollbackRef`.
23
+ */
24
+
25
+ import { NextResponse } from "next/server";
26
+ import { readWorkspaceConfig } from "@/lib/workspace-config";
27
+ import { AGENT_OUTCOMES_SOURCE_ID, readOutcomeReceipts } from "@/lib/workspace-outcome-receipts";
28
+
29
+ function hasValue(v) {
30
+ return Boolean(String(v ?? "").trim());
31
+ }
32
+
33
+ function deriveRowCounters(workspaceConfig) {
34
+ const counters = {
35
+ draftsAwaitingTest: 0,
36
+ draftsTestedNotPublished: 0,
37
+ liveRowsWithFailedLastRun: 0,
38
+ liveRowsWithoutProof: 0
39
+ };
40
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
41
+ for (const object of objects) {
42
+ if (object?.objectType !== "sandbox-environment") continue;
43
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
44
+ const hasDraft = hasValue(row?.orchestrationDraftConfig) || hasValue(row?.orchestrationDraftGraph);
45
+ const attested = row?.orchestrationDraftTestPassed === true
46
+ || String(row?.orchestrationDraftTestPassed ?? "") === "true";
47
+ if (hasDraft && !attested) counters.draftsAwaitingTest += 1;
48
+ if (hasDraft && attested) counters.draftsTestedNotPublished += 1;
49
+ const isLive = String(row?.lifecycleStatus ?? "").trim().toLowerCase() === "live";
50
+ if (isLive && String(row?.status ?? "") === "failed") counters.liveRowsWithFailedLastRun += 1;
51
+ if (isLive && !hasValue(row?.lastRunId)) counters.liveRowsWithoutProof += 1;
52
+ }
53
+ }
54
+ return counters;
55
+ }
56
+
57
+ async function GET(request) {
58
+ const { searchParams } = new URL(request.url);
59
+ const limitRaw = Number(searchParams.get("limit") || 100);
60
+ const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(1, limitRaw), 200) : 100;
61
+
62
+ const receipts = await readOutcomeReceipts(limit);
63
+ let workspaceConfig = null;
64
+ try {
65
+ workspaceConfig = await readWorkspaceConfig();
66
+ } catch {
67
+ workspaceConfig = null;
68
+ }
69
+
70
+ const summary = {
71
+ blockedAttempts: receipts.filter((r) => r?.outcomeStatus === "blocked").length,
72
+ publishes: receipts.filter((r) => r?.kind === "workflow-publish" && r?.outcomeStatus === "published").length,
73
+ helperApplies: receipts.filter((r) => r?.kind === "helper-apply").length,
74
+ ...deriveRowCounters(workspaceConfig)
75
+ };
76
+
77
+ return NextResponse.json({
78
+ ok: true,
79
+ sourceId: AGENT_OUTCOMES_SOURCE_ID,
80
+ receipts,
81
+ summary
82
+ });
83
+ }
84
+
85
+ export { GET };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * GET /api/workspace/apps
3
+ *
4
+ * Governed Application Control Plane V1 — the fleet read surface (contract:
5
+ * `@growthub/api-contract/workspace-apps::WorkspaceAppsResponse`).
6
+ *
7
+ * Returns, read-only and secret-free:
8
+ *
9
+ * - `apps[]` — every application registered as a governed row of the
10
+ * `workspace-app-registry` Data Model object: resolved
11
+ * links (dashboards / workflows / data sources / APIs),
12
+ * health rollup with computed blockers, the single next
13
+ * action (with an href into the real surface), and the
14
+ * machine-readable agent assignment packet.
15
+ * - `detected[]` — app surfaces detected on the artifact's own filesystem
16
+ * (same probe heuristics as the CLI's `workspace surface
17
+ * list`, bridged into the runtime so registration never
18
+ * requires a separate tool). Detection is advisory: an
19
+ * app becomes GOVERNED only when a human/agent registers
20
+ * it as a row via the normal PATCH lane.
21
+ * - `lens` — the Fleet lens state (same deriver the Workspace Lens
22
+ * panel renders), so humans and agents read one truth.
23
+ * - `summary` — fleet counters.
24
+ *
25
+ * Authority invariants: GET only; mutations flow through the existing
26
+ * governed routes; this route never throws on partial/absent config.
27
+ */
28
+
29
+ import { NextResponse } from "next/server";
30
+ import fsSync from "node:fs";
31
+ import path from "node:path";
32
+ import {
33
+ describePersistenceMode,
34
+ readWorkspaceConfig,
35
+ readWorkspaceSourceRecords
36
+ } from "@/lib/workspace-config";
37
+ import { readAdapterConfig } from "@/lib/adapters/env";
38
+ import {
39
+ APP_REGISTRY_OBJECT_ID,
40
+ buildAppAssignmentPacket,
41
+ deriveAppHealth,
42
+ deriveAppNextAction,
43
+ listAppSurfaceRows,
44
+ summarizeFleet
45
+ } from "@/lib/workspace-app-registry";
46
+ import {
47
+ deriveDeployLensState,
48
+ deriveFleetLensState,
49
+ deriveRuntimeDurability
50
+ } from "@/lib/workspace-activation";
51
+
52
+ /** Same safe runtime descriptor the swarm-condition route assembles. */
53
+ function safeRuntime(warnings) {
54
+ const runtime = { persistenceMode: "", persistenceAdapter: null, allowFsWrite: false, nangoConfigured: false, deploy: {} };
55
+ try {
56
+ const persistence = describePersistenceMode();
57
+ runtime.persistenceMode = persistence.mode;
58
+ runtime.allowFsWrite = persistence.mode === "filesystem" && persistence.canSave === true;
59
+ const adapter = readAdapterConfig();
60
+ runtime.persistenceAdapter = persistence.mode === "database" ? (adapter.dataAdapter || null) : null;
61
+ runtime.nangoConfigured = Boolean(adapter?.nango?.hasSecretKey);
62
+ runtime.deploy = { target: adapter.deployTarget || "" };
63
+ } catch (error) {
64
+ warnings.push(`Failed to read runtime descriptor: ${error?.message || "unknown error"}`);
65
+ }
66
+ return runtime;
67
+ }
68
+
69
+ // Mirrors the CLI probe (cli/src/commands/workspace-surface.ts) so detection
70
+ // lives inside the artifact too — the bridge roadmap Item 4 called for.
71
+ const KNOWN_APP_DIRS = ["apps/workspace", "apps/agency-portal", "apps/portal", "studio", "app", "src"];
72
+
73
+ function detectFramework(absPath) {
74
+ try {
75
+ const entries = fsSync.readdirSync(absPath);
76
+ if (entries.some((e) => e.startsWith("next.config."))) return "nextjs";
77
+ if (entries.some((e) => e.startsWith("vite.config."))) return "vite";
78
+ } catch {
79
+ /* unreadable */
80
+ }
81
+ return "unknown";
82
+ }
83
+
84
+ function looksLikeAppSurface(absPath) {
85
+ try {
86
+ const has = (rel) => fsSync.existsSync(path.join(absPath, rel));
87
+ return has("package.json") || has("index.html") || has("app") || has("pages") || has("src");
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /** Read-only filesystem probe of the artifact root. Never throws. */
94
+ function detectAppSurfaces(warnings) {
95
+ const detected = [];
96
+ try {
97
+ // The workspace app runs from <artifact>/apps/workspace.
98
+ const artifactRoot = path.resolve(process.cwd(), "..", "..");
99
+ const candidates = new Set(KNOWN_APP_DIRS);
100
+ const appsDir = path.join(artifactRoot, "apps");
101
+ if (fsSync.existsSync(appsDir)) {
102
+ for (const entry of fsSync.readdirSync(appsDir, { withFileTypes: true })) {
103
+ if (entry.isDirectory()) candidates.add(`apps/${entry.name}`);
104
+ }
105
+ }
106
+ for (const rel of Array.from(candidates).sort()) {
107
+ const abs = path.join(artifactRoot, rel);
108
+ if (!fsSync.existsSync(abs) || !fsSync.statSync(abs).isDirectory()) continue;
109
+ if (!looksLikeAppSurface(abs)) continue;
110
+ let packageName;
111
+ try {
112
+ packageName = JSON.parse(fsSync.readFileSync(path.join(abs, "package.json"), "utf8")).name;
113
+ } catch {
114
+ packageName = undefined;
115
+ }
116
+ detected.push({
117
+ name: rel.split("/").pop(),
118
+ relPath: rel,
119
+ framework: detectFramework(abs),
120
+ hasEnvExample: fsSync.existsSync(path.join(abs, ".env.example")),
121
+ hasVercelJson: fsSync.existsSync(path.join(abs, "vercel.json")),
122
+ hasGrowthubConfig: fsSync.existsSync(path.join(abs, "growthub.config.json")),
123
+ ...(packageName ? { packageName } : {})
124
+ });
125
+ }
126
+ } catch (error) {
127
+ warnings.push(`Surface detection failed: ${error?.message || "unknown error"}`);
128
+ }
129
+ return detected;
130
+ }
131
+
132
+ async function GET() {
133
+ const warnings = [];
134
+
135
+ let workspaceConfig = {};
136
+ try {
137
+ workspaceConfig = (await readWorkspaceConfig()) || {};
138
+ } catch (error) {
139
+ warnings.push(`Failed to read workspace config: ${error?.message || "unknown error"}`);
140
+ }
141
+ let workspaceSourceRecords = {};
142
+ try {
143
+ workspaceSourceRecords = (await readWorkspaceSourceRecords()) || {};
144
+ } catch {
145
+ workspaceSourceRecords = {};
146
+ }
147
+
148
+ const runtime = safeRuntime(warnings);
149
+ const metadataGraph = { runtime };
150
+ const lensInput = { workspaceConfig, workspaceSourceRecords, metadataGraph };
151
+ const dur = deriveRuntimeDurability(metadataGraph);
152
+ const runtimeFlags = {
153
+ durable: dur.durable,
154
+ readOnly: dur.readOnly,
155
+ deployReady: deriveDeployLensState(lensInput).complete
156
+ };
157
+
158
+ const apps = listAppSurfaceRows(workspaceConfig).map((row) => {
159
+ const health = deriveAppHealth(workspaceConfig, workspaceSourceRecords, row, runtimeFlags);
160
+ return {
161
+ appId: String(row.appId || row.Name || "").trim(),
162
+ name: String(row.Name || "").trim(),
163
+ surfacePath: String(row.surfacePath || "").trim() || null,
164
+ framework: String(row.framework || "").trim() || null,
165
+ owner: String(row.owner || "").trim() || null,
166
+ environment: String(row.environment || "").trim() || null,
167
+ deployTarget: String(row.deployTarget || "").trim() || null,
168
+ registryHref: `/data-model?object=${APP_REGISTRY_OBJECT_ID}`,
169
+ health: { status: health.status, blockers: health.blockers, linkedCount: health.linkedCount },
170
+ links: health.links,
171
+ nextAction: deriveAppNextAction(row, health),
172
+ assignment: buildAppAssignmentPacket(workspaceConfig, workspaceSourceRecords, row, runtimeFlags)
173
+ };
174
+ });
175
+
176
+ return NextResponse.json({
177
+ ok: true,
178
+ registryObjectId: APP_REGISTRY_OBJECT_ID,
179
+ apps,
180
+ detected: detectAppSurfaces(warnings),
181
+ lens: deriveFleetLensState(lensInput),
182
+ summary: summarizeFleet(apps),
183
+ ...(warnings.length ? { warnings } : {})
184
+ });
185
+ }
186
+
187
+ export { GET };
@@ -35,6 +35,8 @@ import {
35
35
  writeWorkspaceSourceRecords,
36
36
  describePersistenceMode,
37
37
  } from "@/lib/workspace-config";
38
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
39
+ import { buildAppScopeViolation } from "@/lib/workspace-app-registry";
38
40
  import {
39
41
  applyProposalToConfig,
40
42
  validateProposalForApply,
@@ -204,6 +206,23 @@ function normalizeSwarmRunProposal(proposal, workspaceConfig) {
204
206
  }
205
207
 
206
208
  async function POST(request) {
209
+ // helper/apply is the OPERATOR lane (human-reviewed proposals). It is not
210
+ // available under an app scope — app-scoped agents use preflight + scoped
211
+ // PATCH + sandbox-run + workflow/publish. Advertised truthfully in
212
+ // AppAssignmentPacket.operatorOnlyRoutes.
213
+ const scopedHeader = request.headers.get("x-growthub-app-scope");
214
+ if (scopedHeader && scopedHeader.trim()) {
215
+ const violation = buildAppScopeViolation(scopedHeader.trim(), "route_operator_only",
216
+ ["POST /api/workspace/helper/apply"],
217
+ "helper/apply is the human-reviewed operator lane; app-scoped agents mutate via preflight + scoped PATCH", null);
218
+ await appendOutcomeReceipt({
219
+ kind: "helper-apply", lane: "governed-proposal", outcomeStatus: "blocked",
220
+ appId: scopedHeader.trim(),
221
+ summary: "helper/apply rejected (422 app scope): operator-only lane",
222
+ nextActions: violation.repairPlan
223
+ });
224
+ return NextResponse.json(violation, { status: 422 });
225
+ }
207
226
  let body;
208
227
  try {
209
228
  body = await request.json();
@@ -516,6 +535,23 @@ async function POST(request) {
516
535
  if (row && Array.isArray(row.messages)) messagesAfterApply = row.messages;
517
536
  }
518
537
 
538
+ // Agent Outcome Loop V1: the helper lane is GOVERNED-PROPOSAL — privileged
539
+ // (human-reviewed, server-built payloads), never an unlabelled bypass. It
540
+ // emits the same canonical receipt class as every other mutation lane, so
541
+ // the cockpit sees one stream.
542
+ await appendOutcomeReceipt({
543
+ kind: "helper-apply",
544
+ lane: "governed-proposal",
545
+ outcomeStatus: applied.length > 0 ? "published" : "failed",
546
+ actor: reviewedBy || "operator",
547
+ changedFields: Array.from(new Set(mutatingApplied.map((r) => r.affectedField))),
548
+ objectRefs: threadId ? [{ objectId: "helper-threads", rowName: threadId }] : undefined,
549
+ summary: `helper apply: ${applied.length} applied (${Array.from(new Set(applied.map((r) => r.type))).join(", ") || "none"}), ${skipped.length} skipped`,
550
+ ...(skipped.length > 0
551
+ ? { nextActions: skipped.slice(0, 3).map((s) => `skipped ${s.proposal?.type || "proposal"}: ${s.reason}`) }
552
+ : {})
553
+ });
554
+
519
555
  return NextResponse.json({
520
556
  ok: true,
521
557
  threadId,
@@ -0,0 +1,152 @@
1
+ /**
2
+ * POST /api/workspace/patch/preflight
3
+ *
4
+ * Dry-run for `PATCH /api/workspace`. Takes the exact body you intend to
5
+ * PATCH and returns the structured result of every gate the real PATCH will
6
+ * apply — allowlist + mutation policy (workspace-patch-policy.js) + full
7
+ * schema validation of the merged config (workspace-schema.js) — without
8
+ * ever writing.
9
+ *
10
+ * Always responds 200; `ok` is the verdict. Agents should preflight any
11
+ * non-trivial patch (especially dataModel mutations) and fix every reason
12
+ * before issuing the real PATCH.
13
+ *
14
+ * Response:
15
+ * {
16
+ * ok: boolean,
17
+ * allowed: string[], // the permanent PATCH allowlist
18
+ * policy: { ok, violations: [{ code, path, message }] },
19
+ * schema: { ok, errors: string[] },
20
+ * persistence: { mode, canSave, guidance } // would the write even land?
21
+ * }
22
+ */
23
+
24
+ import { NextResponse } from "next/server";
25
+ import {
26
+ applyWorkspaceConfigPatch,
27
+ describePersistenceMode,
28
+ readWorkspaceConfig,
29
+ validateWorkspaceConfig
30
+ } from "@/lib/workspace-config";
31
+ import {
32
+ WORKSPACE_PATCH_ALLOWED_FIELDS,
33
+ evaluateWorkspacePatchPolicy,
34
+ repairPlanForViolations
35
+ } from "@/lib/workspace-patch-policy";
36
+ import { evaluateAppScope, requireAppScope } from "@/lib/workspace-app-registry";
37
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
38
+
39
+ async function POST(request) {
40
+ let patch;
41
+ try {
42
+ patch = await request.json();
43
+ } catch {
44
+ return NextResponse.json({
45
+ ok: false,
46
+ allowed: WORKSPACE_PATCH_ALLOWED_FIELDS,
47
+ policy: { ok: false, violations: [{ code: "invalid_body", path: "", message: "invalid json body" }] },
48
+ schema: { ok: false, errors: [] },
49
+ persistence: null
50
+ });
51
+ }
52
+
53
+ let currentConfig = null;
54
+ try {
55
+ currentConfig = await readWorkspaceConfig();
56
+ } catch {
57
+ currentConfig = null;
58
+ }
59
+
60
+ const policy = evaluateWorkspacePatchPolicy(currentConfig, patch);
61
+
62
+ // Schema check uses writeWorkspaceConfig's EXACT merge step
63
+ // (applyWorkspaceConfigPatch) — canvas patches merge over the current
64
+ // canvas with layout/bindings preservation and null-deletes, never a
65
+ // top-level replacement — so preflight can never disagree with the real
66
+ // PATCH about the merged result. Skipped when the body is not a plain
67
+ // object (policy already reports that).
68
+ let schema = { ok: true, errors: [] };
69
+ if (patch && typeof patch === "object" && !Array.isArray(patch)) {
70
+ const sanitized = {};
71
+ for (const key of WORKSPACE_PATCH_ALLOWED_FIELDS) {
72
+ if (Object.prototype.hasOwnProperty.call(patch, key)) sanitized[key] = patch[key];
73
+ }
74
+ const merged = applyWorkspaceConfigPatch(currentConfig || {}, sanitized);
75
+ try {
76
+ validateWorkspaceConfig({
77
+ dashboards: merged.dashboards,
78
+ widgetTypes: merged.widgetTypes,
79
+ canvas: merged.canvas,
80
+ dataModel: merged.dataModel
81
+ });
82
+ } catch (error) {
83
+ schema = {
84
+ ok: false,
85
+ errors: Array.isArray(error?.details) ? error.details : [error?.message || "invalid workspace config"]
86
+ };
87
+ }
88
+ } else {
89
+ schema = { ok: false, errors: ["patch must be a plain object"] };
90
+ }
91
+
92
+ // Preflight mirrors PATCH exactly, INCLUDING the app-scope verdict
93
+ // (OpenClaw: preflight is the single source of truth — if appScopeVerdict
94
+ // is not allowed, the real PATCH with the same header will 422 the same way).
95
+ const scope = requireAppScope(request, currentConfig || {});
96
+ let appScopeVerdict = null;
97
+ if (scope.scoped) {
98
+ if (scope.violation) {
99
+ appScopeVerdict = { allowed: false, appScope: scope.violation.appScope, violations: [scope.violation] };
100
+ } else {
101
+ const verdict = evaluateAppScope(currentConfig || {}, patch, scope.appId);
102
+ appScopeVerdict = { allowed: verdict.ok, appScope: scope.appId, violations: verdict.violations };
103
+ }
104
+ }
105
+
106
+ const persistence = describePersistenceMode();
107
+ const ok = policy.ok && schema.ok && (appScopeVerdict ? appScopeVerdict.allowed : true);
108
+ const repairPlan = repairPlanForViolations(policy.violations);
109
+ // The single next governed call, when derivable from the verdicts.
110
+ let safeNextStep;
111
+ if (ok) {
112
+ safeNextStep = "PATCH /api/workspace with this exact body";
113
+ } else if (policy.violations.some((v) => v.code === "live_workflow_field" || v.code === "live_publish_via_patch")) {
114
+ safeNextStep =
115
+ "Save the graph as a draft field, prove it with POST /api/workspace/sandbox-run {useDraft:true}, then POST /api/workspace/workflow/publish";
116
+ } else if (repairPlan.length > 0) {
117
+ safeNextStep = repairPlan[0];
118
+ } else if (!schema.ok) {
119
+ safeNextStep = "Fix the schema errors against apps/workspace/lib/workspace-schema.js, then preflight again";
120
+ }
121
+
122
+ if (!ok) {
123
+ // Failed attempts are governance signal — visible in the cockpit stream.
124
+ await appendOutcomeReceipt({
125
+ kind: "patch-preflight",
126
+ lane: "untrusted-direct",
127
+ outcomeStatus: "blocked",
128
+ changedFields: patch && typeof patch === "object" && !Array.isArray(patch) ? Object.keys(patch) : [],
129
+ policyVerdict: { ok: policy.ok, violationCodes: policy.violations.map((v) => v.code) },
130
+ schemaVerdict: { ok: schema.ok, errorCount: schema.errors.length },
131
+ summary: `preflight blocked: ${[...policy.violations.map((v) => v.code), ...(schema.ok ? [] : ["schema"])].join(", ")}`,
132
+ nextActions: repairPlan.length ? repairPlan : (safeNextStep ? [safeNextStep] : [])
133
+ });
134
+ }
135
+
136
+ return NextResponse.json({
137
+ ok,
138
+ allowed: WORKSPACE_PATCH_ALLOWED_FIELDS,
139
+ policy,
140
+ schema,
141
+ repairPlan,
142
+ ...(appScopeVerdict ? { appScopeVerdict } : {}),
143
+ ...(safeNextStep ? { safeNextStep } : {}),
144
+ persistence: {
145
+ mode: persistence.mode,
146
+ canSave: persistence.canSave,
147
+ guidance: persistence.guidance
148
+ }
149
+ });
150
+ }
151
+
152
+ export { POST };
@@ -31,6 +31,8 @@
31
31
  */
32
32
 
33
33
  import { NextResponse } from "next/server";
34
+ import { requireAppScope, checkScopedSourceAccess } from "@/lib/workspace-app-registry";
35
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
34
36
  import { readAdapterConfig } from "@/lib/adapters/env";
35
37
  import { listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
36
38
  import { readWorkspaceConfig, writeWorkspaceSourceRecords } from "@/lib/workspace-config";
@@ -50,6 +52,25 @@ async function POST(request) {
50
52
  }
51
53
 
52
54
  const { sourceIds } = body;
55
+ // App-scope gate: a scoped agent may only refresh sources referenced on
56
+ // its registry row (dataSourceIds / derived sourceIds).
57
+ {
58
+ let cfgForScope = {};
59
+ try { cfgForScope = await readWorkspaceConfig(); } catch { cfgForScope = {}; }
60
+ const scope = requireAppScope(request, cfgForScope);
61
+ if (scope.scoped) {
62
+ const violation = scope.violation || checkScopedSourceAccess(scope.context, sourceIds);
63
+ if (violation) {
64
+ await appendOutcomeReceipt({
65
+ kind: "agent-outcome", lane: "untrusted-direct", outcomeStatus: "blocked",
66
+ appId: violation.appScope || scope.appId,
67
+ summary: `refresh-sources rejected (422 app scope): ${violation.violationType}`,
68
+ nextActions: violation.repairPlan
69
+ });
70
+ return NextResponse.json(violation, { status: 422 });
71
+ }
72
+ }
73
+ }
53
74
  if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
54
75
  return NextResponse.json({ error: "sourceIds must be a non-empty array" }, { status: 400 });
55
76
  }
@@ -12,12 +12,24 @@ import {
12
12
  readWorkspaceSourceRecords,
13
13
  writeWorkspaceConfig
14
14
  } from "@/lib/workspace-config";
15
+ import {
16
+ WORKSPACE_PATCH_ALLOWED_FIELDS,
17
+ evaluateWorkspacePatchPolicy,
18
+ repairPlanForViolations
19
+ } from "@/lib/workspace-patch-policy";
20
+ import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
21
+ import { evaluateAppScope } from "@/lib/workspace-app-registry";
15
22
 
16
23
  // Workspace Config Contract V1 — PATCH is permanently restricted to these
17
24
  // four fields. Sidecar source records (`workspaceSourceRecords`) are exposed
18
25
  // on GET for runtime hydration only; they are deliberately NOT in this set.
19
26
  // Sidecar writes flow through POST /api/workspace/refresh-sources.
20
- const ALLOWED_PATCH_FIELDS = new Set(["dashboards", "widgetTypes", "canvas", "dataModel"]);
27
+ //
28
+ // Mutation policy (workspace-patch-policy.js) runs before any write: live
29
+ // workflow fields are publish-owned (POST /api/workspace/workflow/publish),
30
+ // size ceilings apply, and history blobs never enter dataModel. Dry-run the
31
+ // same checks via POST /api/workspace/patch/preflight.
32
+ const ALLOWED_PATCH_FIELDS = new Set(WORKSPACE_PATCH_ALLOWED_FIELDS);
21
33
 
22
34
  async function GET() {
23
35
  const integrations = await listGovernedWorkspaceIntegrations();
@@ -65,6 +77,15 @@ async function PATCH(request) {
65
77
  }
66
78
  const unknown = Object.keys(patch).filter((key) => !ALLOWED_PATCH_FIELDS.has(key));
67
79
  if (unknown.length) {
80
+ await appendOutcomeReceipt({
81
+ kind: "direct-patch",
82
+ lane: "untrusted-direct",
83
+ outcomeStatus: "blocked",
84
+ changedFields: unknown,
85
+ policyVerdict: { ok: false, violationCodes: ["unknown_field"] },
86
+ summary: `PATCH rejected (400): unknown top-level fields ${unknown.join(", ")}`,
87
+ nextActions: ["Send only changed allowlisted keys; dry-run with POST /api/workspace/patch/preflight"]
88
+ });
68
89
  return NextResponse.json(
69
90
  { error: "patch contains unknown fields", details: unknown, allowed: Array.from(ALLOWED_PATCH_FIELDS) },
70
91
  { status: 400 }
@@ -76,8 +97,74 @@ async function PATCH(request) {
76
97
  sanitized[key] = patch[key];
77
98
  }
78
99
  }
100
+ let currentConfig = null;
101
+ try {
102
+ currentConfig = await readWorkspaceConfig();
103
+ } catch {
104
+ currentConfig = null;
105
+ }
106
+ const policy = evaluateWorkspacePatchPolicy(currentConfig, patch);
107
+ if (!policy.ok) {
108
+ const repairPlan = repairPlanForViolations(policy.violations);
109
+ await appendOutcomeReceipt({
110
+ kind: "direct-patch",
111
+ lane: "untrusted-direct",
112
+ outcomeStatus: "blocked",
113
+ changedFields: Object.keys(sanitized),
114
+ policyVerdict: { ok: false, violationCodes: policy.violations.map((v) => v.code) },
115
+ summary: `PATCH rejected (422): ${policy.violations.map((v) => v.code).join(", ")}`,
116
+ nextActions: repairPlan
117
+ });
118
+ return NextResponse.json(
119
+ {
120
+ error: "patch rejected by workspace mutation policy",
121
+ violations: policy.violations,
122
+ repairPlan,
123
+ preflight: "POST /api/workspace/patch/preflight dry-runs these checks without writing"
124
+ },
125
+ { status: 422 }
126
+ );
127
+ }
128
+ // App-scope enforcement (opt-in tightening): a harness working from an
129
+ // AppAssignmentPacket sets `x-growthub-app-scope: <appId>` and this PATCH
130
+ // may then only mutate that app's governed objects/dashboards. Unscoped
131
+ // PATCHes are unaffected.
132
+ const appScope = request.headers.get("x-growthub-app-scope");
133
+ if (appScope && appScope.trim()) {
134
+ const scopeVerdict = evaluateAppScope(currentConfig, patch, appScope.trim());
135
+ if (!scopeVerdict.ok) {
136
+ await appendOutcomeReceipt({
137
+ kind: "direct-patch",
138
+ lane: "untrusted-direct",
139
+ outcomeStatus: "blocked",
140
+ actor: `app-scope:${appScope.trim()}`,
141
+ changedFields: Object.keys(sanitized),
142
+ policyVerdict: { ok: false, violationCodes: scopeVerdict.violations.map((v) => v.code) },
143
+ summary: `PATCH rejected (422): app-scope "${appScope.trim()}" violation — ${scopeVerdict.violations[0]?.message || ""}`,
144
+ nextActions: ["Mutate only the objects referenced by this app's registry row, or drop the x-growthub-app-scope header for unscoped operator work"]
145
+ });
146
+ return NextResponse.json(
147
+ {
148
+ error: "patch rejected by app scope",
149
+ appScope: appScope.trim(),
150
+ violations: scopeVerdict.violations,
151
+ repairPlan: ["Work inside the app's AppAssignmentPacket objectRefs; register additional objects on the app's registry row first if the scope must grow"]
152
+ },
153
+ { status: 422 }
154
+ );
155
+ }
156
+ }
79
157
  try {
80
158
  const next = await writeWorkspaceConfig(sanitized);
159
+ await appendOutcomeReceipt({
160
+ kind: "direct-patch",
161
+ lane: "untrusted-direct",
162
+ outcomeStatus: "published",
163
+ changedFields: Object.keys(sanitized),
164
+ policyVerdict: { ok: true },
165
+ schemaVerdict: { ok: true },
166
+ summary: `PATCH applied: ${Object.keys(sanitized).join(", ")} updated`
167
+ });
81
168
  return NextResponse.json({ workspaceConfig: next });
82
169
  } catch (error) {
83
170
  if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
@@ -15,7 +15,7 @@
15
15
  * NEVER written to `growthub.config.json`.
16
16
  *
17
17
  * Request body:
18
- * { objectId: string, name: string }
18
+ * { objectId: string, name: string, agentHost?: string }
19
19
  *
20
20
  * Response:
21
21
  * {
@@ -49,6 +49,7 @@ async function POST(request) {
49
49
 
50
50
  const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
51
51
  const name = typeof body?.name === "string" ? body.name.trim() : "";
52
+ const agentHost = typeof body?.agentHost === "string" ? body.agentHost.trim() : "";
52
53
  if (!objectId || !name) {
53
54
  return NextResponse.json(
54
55
  { ok: false, error: "objectId and name are required" },
@@ -57,7 +58,7 @@ async function POST(request) {
57
58
  }
58
59
 
59
60
  try {
60
- const result = await runAgentLogin({ objectId, name });
61
+ const result = await runAgentLogin({ objectId, name, agentHost });
61
62
  return NextResponse.json(result);
62
63
  } catch (error) {
63
64
  return NextResponse.json(