@growthub/cli 0.14.2 → 0.14.4

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 (31) 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 +69 -1
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +72 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +532 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +36 -5
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +9 -1
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +11 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +22 -165
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-agent-teams.js +211 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-bootstrap-console.js +325 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-cockpit-console.js +206 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +402 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +69 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
  31. 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,
@@ -54,6 +56,10 @@ import {
54
56
  findSwarmRunRows,
55
57
  summarizeSwarmRunProposal,
56
58
  } from "@/lib/workspace-swarm-proposal";
59
+ import {
60
+ CEO_BOOTSTRAP_COMPLETE_PROPOSAL_TYPE,
61
+ buildCeoBootstrapCompletion,
62
+ } from "@/lib/ceo-bootstrap-console";
57
63
 
58
64
  const HELPER_APPLY_SOURCE_KEY = "helper:apply:receipts";
59
65
 
@@ -204,6 +210,23 @@ function normalizeSwarmRunProposal(proposal, workspaceConfig) {
204
210
  }
205
211
 
206
212
  async function POST(request) {
213
+ // helper/apply is the OPERATOR lane (human-reviewed proposals). It is not
214
+ // available under an app scope — app-scoped agents use preflight + scoped
215
+ // PATCH + sandbox-run + workflow/publish. Advertised truthfully in
216
+ // AppAssignmentPacket.operatorOnlyRoutes.
217
+ const scopedHeader = request.headers.get("x-growthub-app-scope");
218
+ if (scopedHeader && scopedHeader.trim()) {
219
+ const violation = buildAppScopeViolation(scopedHeader.trim(), "route_operator_only",
220
+ ["POST /api/workspace/helper/apply"],
221
+ "helper/apply is the human-reviewed operator lane; app-scoped agents mutate via preflight + scoped PATCH", null);
222
+ await appendOutcomeReceipt({
223
+ kind: "helper-apply", lane: "governed-proposal", outcomeStatus: "blocked",
224
+ appId: scopedHeader.trim(),
225
+ summary: "helper/apply rejected (422 app scope): operator-only lane",
226
+ nextActions: violation.repairPlan
227
+ });
228
+ return NextResponse.json(violation, { status: 422 });
229
+ }
207
230
  let body;
208
231
  try {
209
232
  body = await request.json();
@@ -248,8 +271,14 @@ async function POST(request) {
248
271
  );
249
272
  const resolverProposals = normalizedProposals.filter((p) => p?.type === RESOLVER_PROPOSAL_TYPE);
250
273
  const swarmProposals = normalizedProposals.filter((p) => SWARM_PROPOSAL_TYPES.includes(p?.type));
274
+ const ceoBootstrapProposals = normalizedProposals.filter(
275
+ (p) => p?.type === CEO_BOOTSTRAP_COMPLETE_PROPOSAL_TYPE
276
+ );
251
277
  const configProposals = normalizedProposals.filter(
252
- (p) => p?.type !== RESOLVER_PROPOSAL_TYPE && !SWARM_PROPOSAL_TYPES.includes(p?.type)
278
+ (p) =>
279
+ p?.type !== RESOLVER_PROPOSAL_TYPE &&
280
+ !SWARM_PROPOSAL_TYPES.includes(p?.type) &&
281
+ p?.type !== CEO_BOOTSTRAP_COMPLETE_PROPOSAL_TYPE
253
282
  );
254
283
 
255
284
  for (const proposal of resolverProposals) {
@@ -291,6 +320,28 @@ async function POST(request) {
291
320
  });
292
321
  }
293
322
 
323
+ // CEO bootstrap lane — stamps the "CEO setup complete" marker onto the
324
+ // well-known workspace-helper sandbox row in the EXISTING dataModel patch
325
+ // field. The builder refuses unless the loop is config-provably done (a
326
+ // ready swarm with a completed run), so completion is evidence, not a
327
+ // client assertion. No new object, no execution.
328
+ for (const proposal of ceoBootstrapProposals) {
329
+ const result = buildCeoBootstrapCompletion({
330
+ workspaceConfig: workingConfig,
331
+ completedAt: appliedAt,
332
+ completedBy: reviewedBy || "user",
333
+ });
334
+ if (!result.ok) {
335
+ skipped.push({ proposal, reason: result.error || "CEO bootstrap not ready to complete" });
336
+ continue;
337
+ }
338
+ workingConfig = result.config;
339
+ applied.push({
340
+ ...buildApplyReceipt({ ...proposal, affectedField: "dataModel" }, appliedAt, reviewedBy, sessionId),
341
+ summary: "CEO setup marked complete",
342
+ });
343
+ }
344
+
294
345
  for (const proposal of configProposals) {
295
346
  if (
296
347
  !proposal ||
@@ -516,6 +567,23 @@ async function POST(request) {
516
567
  if (row && Array.isArray(row.messages)) messagesAfterApply = row.messages;
517
568
  }
518
569
 
570
+ // Agent Outcome Loop V1: the helper lane is GOVERNED-PROPOSAL — privileged
571
+ // (human-reviewed, server-built payloads), never an unlabelled bypass. It
572
+ // emits the same canonical receipt class as every other mutation lane, so
573
+ // the cockpit sees one stream.
574
+ await appendOutcomeReceipt({
575
+ kind: "helper-apply",
576
+ lane: "governed-proposal",
577
+ outcomeStatus: applied.length > 0 ? "published" : "failed",
578
+ actor: reviewedBy || "operator",
579
+ changedFields: Array.from(new Set(mutatingApplied.map((r) => r.affectedField))),
580
+ objectRefs: threadId ? [{ objectId: "helper-threads", rowName: threadId }] : undefined,
581
+ summary: `helper apply: ${applied.length} applied (${Array.from(new Set(applied.map((r) => r.type))).join(", ") || "none"}), ${skipped.length} skipped`,
582
+ ...(skipped.length > 0
583
+ ? { nextActions: skipped.slice(0, 3).map((s) => `skipped ${s.proposal?.type || "proposal"}: ${s.reason}`) }
584
+ : {})
585
+ });
586
+
519
587
  return NextResponse.json({
520
588
  ok: true,
521
589
  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
  }