@growthub/cli 0.10.0 → 0.12.0
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/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
- package/dist/index.js +1416 -2627
- package/package.json +1 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Helper Apply — governed mutation layer.
|
|
3
|
+
*
|
|
4
|
+
* Takes accepted WorkspaceHelperProposal objects from the query step and
|
|
5
|
+
* applies them to the live workspace config via the existing PATCH allowlist.
|
|
6
|
+
*
|
|
7
|
+
* Every accepted proposal writes a durable receipt. The receipts feed the
|
|
8
|
+
* fine-tune loop: accepted workspace-building traces are the highest-weight
|
|
9
|
+
* training signal for future distillation.
|
|
10
|
+
*
|
|
11
|
+
* Public functions:
|
|
12
|
+
*
|
|
13
|
+
* applyProposalToConfig(currentConfig, proposal)
|
|
14
|
+
* Merge a single proposal payload into the relevant config section.
|
|
15
|
+
* Returns the updated config object (immutable — never mutates in place).
|
|
16
|
+
*
|
|
17
|
+
* validateProposalForApply(proposal, currentConfig)
|
|
18
|
+
* Pre-apply guard: validates the merged result with validateWorkspaceConfig
|
|
19
|
+
* before any write. Returns { ok: boolean, error?: string }.
|
|
20
|
+
*
|
|
21
|
+
* buildApplyReceipt(proposal, appliedAt, reviewedBy, sessionId)
|
|
22
|
+
* Builds the durable receipt object for trace.jsonl + source-records.
|
|
23
|
+
*
|
|
24
|
+
* Boundary:
|
|
25
|
+
* - Never writes to disk directly. Callers (the apply route) call
|
|
26
|
+
* writeWorkspaceConfig() after all proposals pass validation.
|
|
27
|
+
* - The PATCH allowlist (dashboards, widgetTypes, canvas, dataModel) is the
|
|
28
|
+
* hard ceiling. Any proposal with an unknown affectedField is rejected.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { validateWorkspaceConfig } from "@/lib/workspace-schema";
|
|
32
|
+
|
|
33
|
+
const ALLOWED_PATCH_FIELDS = new Set(["dashboards", "widgetTypes", "canvas", "dataModel"]);
|
|
34
|
+
|
|
35
|
+
// Re-declared locally so the apply layer stays leaf-agnostic and does not
|
|
36
|
+
// pull from workspace-helper.js's runtime exports. Must stay in sync with
|
|
37
|
+
// the same mapping in lib/workspace-helper.js and packages/api-contract.
|
|
38
|
+
const PROPOSAL_TYPE_TO_PATCH_FIELD = {
|
|
39
|
+
"dashboard.create": "dashboards",
|
|
40
|
+
"dashboard.update": "dashboards",
|
|
41
|
+
"widgetType.bind": "widgetTypes",
|
|
42
|
+
"canvas.widget.add": "canvas",
|
|
43
|
+
"canvas.tab.create": "canvas",
|
|
44
|
+
"dataModel.object.create": "dataModel",
|
|
45
|
+
"dataModel.object.update": "dataModel",
|
|
46
|
+
"dataModel.row.add": "dataModel",
|
|
47
|
+
"repair.binding": "dataModel",
|
|
48
|
+
"explain.object": "dataModel",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merge a proposal payload into the relevant section of currentConfig.
|
|
53
|
+
* Returns a new config object — does not mutate.
|
|
54
|
+
*/
|
|
55
|
+
function applyProposalToConfig(currentConfig, proposal) {
|
|
56
|
+
const field = proposal.affectedField;
|
|
57
|
+
if (!ALLOWED_PATCH_FIELDS.has(field)) {
|
|
58
|
+
throw new Error(`proposal affectedField "${field}" is not in the PATCH allowlist`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = { ...currentConfig };
|
|
62
|
+
|
|
63
|
+
switch (proposal.type) {
|
|
64
|
+
case "dashboard.create": {
|
|
65
|
+
const existing = Array.isArray(config.dashboards) ? config.dashboards : [];
|
|
66
|
+
const newDash = {
|
|
67
|
+
id: proposal.payload.id || `dash-${Date.now().toString(36)}`,
|
|
68
|
+
name: proposal.payload.name || "Untitled Dashboard",
|
|
69
|
+
status: proposal.payload.status || "draft",
|
|
70
|
+
createdBy: proposal.payload.createdBy || "workspace-helper",
|
|
71
|
+
updatedAt: new Date().toISOString(),
|
|
72
|
+
...(proposal.payload.tabs ? { tabs: proposal.payload.tabs } : {}),
|
|
73
|
+
...(proposal.payload.activeTabId ? { activeTabId: proposal.payload.activeTabId } : {}),
|
|
74
|
+
};
|
|
75
|
+
config.dashboards = [...existing, newDash];
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case "dashboard.update": {
|
|
80
|
+
const existing = Array.isArray(config.dashboards) ? config.dashboards : [];
|
|
81
|
+
const targetId = proposal.payload.id;
|
|
82
|
+
if (!targetId) throw new Error("dashboard.update requires payload.id");
|
|
83
|
+
if (!existing.some((d) => d.id === targetId)) {
|
|
84
|
+
throw new Error(`dashboard.update target dashboard "${targetId}" not found`);
|
|
85
|
+
}
|
|
86
|
+
config.dashboards = existing.map((d) =>
|
|
87
|
+
d.id === targetId
|
|
88
|
+
? { ...d, ...proposal.payload, updatedAt: new Date().toISOString() }
|
|
89
|
+
: d
|
|
90
|
+
);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "widgetType.bind": {
|
|
95
|
+
const existing = Array.isArray(config.widgetTypes) ? config.widgetTypes : [];
|
|
96
|
+
const newKind = proposal.payload.kind;
|
|
97
|
+
if (!newKind) throw new Error("widgetType.bind requires payload.kind");
|
|
98
|
+
const alreadyExists = existing.some((w) => w.kind === newKind);
|
|
99
|
+
if (alreadyExists) {
|
|
100
|
+
config.widgetTypes = existing.map((w) =>
|
|
101
|
+
w.kind === newKind ? { ...w, ...proposal.payload } : w
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
config.widgetTypes = [...existing, { kind: newKind, label: proposal.payload.label || newKind, icon: proposal.payload.icon || newKind[0].toUpperCase() }];
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case "canvas.widget.add": {
|
|
110
|
+
const canvas = config.canvas ? { ...config.canvas } : {};
|
|
111
|
+
const widgets = Array.isArray(canvas.widgets) ? [...canvas.widgets] : [];
|
|
112
|
+
// Normalize either grammar — canonical {x,y,w,h} OR helper-spoken
|
|
113
|
+
// {col,row,width,height}/{layout:{...}} — into the validator-expected
|
|
114
|
+
// `position: {x, y, w, h}`. Also auto-pack to the first non-colliding
|
|
115
|
+
// slot when the helper doesn't pick one so the widget actually lands
|
|
116
|
+
// instead of hard-colliding at 0:0 with whatever existed first.
|
|
117
|
+
const pl = proposal.payload || {};
|
|
118
|
+
const layoutSrc = pl.position || pl.layout || pl;
|
|
119
|
+
let pos = {
|
|
120
|
+
x: Number.isFinite(layoutSrc.x) ? layoutSrc.x
|
|
121
|
+
: Number.isFinite(layoutSrc.col) ? layoutSrc.col : undefined,
|
|
122
|
+
y: Number.isFinite(layoutSrc.y) ? layoutSrc.y
|
|
123
|
+
: Number.isFinite(layoutSrc.row) ? layoutSrc.row : undefined,
|
|
124
|
+
w: Number.isFinite(layoutSrc.w) ? layoutSrc.w
|
|
125
|
+
: Number.isFinite(layoutSrc.width) ? layoutSrc.width : 6,
|
|
126
|
+
h: Number.isFinite(layoutSrc.h) ? layoutSrc.h
|
|
127
|
+
: Number.isFinite(layoutSrc.height) ? layoutSrc.height : 4,
|
|
128
|
+
};
|
|
129
|
+
if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
|
|
130
|
+
// Auto-pack: scan grid (12 cols × 16 rows) row-by-row for the first
|
|
131
|
+
// empty rectangle of size w×h that doesn't overlap an existing widget.
|
|
132
|
+
const GRID_COLS = 12;
|
|
133
|
+
const GRID_ROWS = 16;
|
|
134
|
+
const occupied = new Set();
|
|
135
|
+
for (const w of widgets) {
|
|
136
|
+
const p = w?.position;
|
|
137
|
+
if (!p) continue;
|
|
138
|
+
for (let dx = 0; dx < (p.w || 0); dx += 1) {
|
|
139
|
+
for (let dy = 0; dy < (p.h || 0); dy += 1) {
|
|
140
|
+
occupied.add(`${p.x + dx}:${p.y + dy}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const fits = (x, y) => {
|
|
145
|
+
if (x + pos.w > GRID_COLS) return false;
|
|
146
|
+
if (y + pos.h > GRID_ROWS) return false;
|
|
147
|
+
for (let dx = 0; dx < pos.w; dx += 1) {
|
|
148
|
+
for (let dy = 0; dy < pos.h; dy += 1) {
|
|
149
|
+
if (occupied.has(`${x + dx}:${y + dy}`)) return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
outer: for (let y = 0; y < GRID_ROWS; y += 1) {
|
|
155
|
+
for (let x = 0; x <= GRID_COLS - pos.w; x += 1) {
|
|
156
|
+
if (fits(x, y)) { pos = { ...pos, x, y }; break outer; }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!Number.isFinite(pos.x)) { pos.x = 0; pos.y = 0; }
|
|
160
|
+
}
|
|
161
|
+
// Bind the widget to a Data Model object. The kit's view/chart
|
|
162
|
+
// widgets read their data INLINE from `config.source` (label),
|
|
163
|
+
// `config.columns`, and `config.rows` (see lib/workspace-data-model.js
|
|
164
|
+
// deriveWidgetTable). They do NOT dynamically resolve a runtime
|
|
165
|
+
// `sourceObjectId` reference. So to make a widget actually render
|
|
166
|
+
// real data the moment it lands, we SNAPSHOT the bound object's
|
|
167
|
+
// columns + rows into the widget config at apply time. The top-level
|
|
168
|
+
// `sourceObjectId` is preserved as provenance so the helper / future
|
|
169
|
+
// refresh action can re-pull, and so listWorkspaceDataModelTables
|
|
170
|
+
// can attribute widgetRefs back to the source object.
|
|
171
|
+
const sourceObjectId = pl.sourceObjectId || pl.objectId || null;
|
|
172
|
+
const incomingConfig = pl.config || {};
|
|
173
|
+
let injectedConfig = incomingConfig;
|
|
174
|
+
if (sourceObjectId) {
|
|
175
|
+
const dmObj = (config.dataModel?.objects || []).find((o) => o?.id === sourceObjectId);
|
|
176
|
+
if (dmObj) {
|
|
177
|
+
const requestedCols = Array.isArray(incomingConfig.columns) && incomingConfig.columns.length
|
|
178
|
+
? incomingConfig.columns
|
|
179
|
+
: (Array.isArray(dmObj.columns) ? dmObj.columns : []);
|
|
180
|
+
const objectRows = Array.isArray(dmObj.rows) ? dmObj.rows : [];
|
|
181
|
+
injectedConfig = {
|
|
182
|
+
...incomingConfig,
|
|
183
|
+
source: incomingConfig.source || dmObj.label || dmObj.source || sourceObjectId,
|
|
184
|
+
columns: requestedCols,
|
|
185
|
+
rows: objectRows,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const newWidget = {
|
|
190
|
+
id: pl.id || `widget-${Date.now().toString(36)}`,
|
|
191
|
+
kind: pl.kind || "view",
|
|
192
|
+
title: pl.title || "Untitled Widget",
|
|
193
|
+
position: pos,
|
|
194
|
+
config: injectedConfig,
|
|
195
|
+
...(sourceObjectId ? { sourceObjectId } : {}),
|
|
196
|
+
};
|
|
197
|
+
canvas.widgets = [...widgets, newWidget];
|
|
198
|
+
config.canvas = canvas;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "canvas.tab.create": {
|
|
203
|
+
const canvas = config.canvas ? { ...config.canvas } : {};
|
|
204
|
+
const tabs = Array.isArray(canvas.tabs) ? [...canvas.tabs] : [];
|
|
205
|
+
const newTab = {
|
|
206
|
+
id: proposal.payload.id || `tab-${Date.now().toString(36)}`,
|
|
207
|
+
name: proposal.payload.name || "New Tab",
|
|
208
|
+
widgets: Array.isArray(proposal.payload.widgets) ? proposal.payload.widgets : [],
|
|
209
|
+
};
|
|
210
|
+
canvas.tabs = [...tabs, newTab];
|
|
211
|
+
if (!canvas.activeTabId) canvas.activeTabId = newTab.id;
|
|
212
|
+
config.canvas = canvas;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "dataModel.object.create": {
|
|
217
|
+
const dm = config.dataModel ? { ...config.dataModel } : {};
|
|
218
|
+
const objects = Array.isArray(dm.objects) ? [...dm.objects] : [];
|
|
219
|
+
const newObj = {
|
|
220
|
+
id: proposal.payload.id || `obj-${Date.now().toString(36)}`,
|
|
221
|
+
label: proposal.payload.label || "Untitled Object",
|
|
222
|
+
objectType: proposal.payload.objectType || "custom",
|
|
223
|
+
columns: Array.isArray(proposal.payload.columns) ? proposal.payload.columns : [],
|
|
224
|
+
rows: [],
|
|
225
|
+
binding: proposal.payload.binding || { mode: "manual", source: "Data Model" },
|
|
226
|
+
...(proposal.payload.relations ? { relations: proposal.payload.relations } : {}),
|
|
227
|
+
...(proposal.payload.fieldSettings ? { fieldSettings: proposal.payload.fieldSettings } : {}),
|
|
228
|
+
};
|
|
229
|
+
dm.objects = [...objects, newObj];
|
|
230
|
+
config.dataModel = dm;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case "dataModel.object.update": {
|
|
235
|
+
const dm = config.dataModel ? { ...config.dataModel } : {};
|
|
236
|
+
const objects = Array.isArray(dm.objects) ? dm.objects : [];
|
|
237
|
+
const targetId = proposal.payload.id;
|
|
238
|
+
if (!targetId) throw new Error("dataModel.object.update requires payload.id");
|
|
239
|
+
if (!objects.some((obj) => obj.id === targetId)) {
|
|
240
|
+
throw new Error(`dataModel.object.update target object "${targetId}" not found`);
|
|
241
|
+
}
|
|
242
|
+
dm.objects = objects.map((obj) =>
|
|
243
|
+
obj.id === targetId
|
|
244
|
+
? {
|
|
245
|
+
...obj,
|
|
246
|
+
...proposal.payload,
|
|
247
|
+
rows: Array.isArray(obj.rows) ? obj.rows : [],
|
|
248
|
+
}
|
|
249
|
+
: obj
|
|
250
|
+
);
|
|
251
|
+
config.dataModel = dm;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case "dataModel.row.add": {
|
|
256
|
+
const dm = config.dataModel ? { ...config.dataModel } : {};
|
|
257
|
+
const objects = Array.isArray(dm.objects) ? dm.objects : [];
|
|
258
|
+
const targetId = proposal.payload.objectId;
|
|
259
|
+
if (!targetId) throw new Error("dataModel.row.add requires payload.objectId");
|
|
260
|
+
if (!objects.some((obj) => obj.id === targetId)) {
|
|
261
|
+
throw new Error(`dataModel.row.add target object "${targetId}" not found`);
|
|
262
|
+
}
|
|
263
|
+
const newRow = proposal.payload.row || {};
|
|
264
|
+
dm.objects = objects.map((obj) =>
|
|
265
|
+
obj.id === targetId
|
|
266
|
+
? { ...obj, rows: [...(Array.isArray(obj.rows) ? obj.rows : []), newRow] }
|
|
267
|
+
: obj
|
|
268
|
+
);
|
|
269
|
+
config.dataModel = dm;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "repair.binding": {
|
|
274
|
+
const dm = config.dataModel ? { ...config.dataModel } : {};
|
|
275
|
+
const objects = Array.isArray(dm.objects) ? dm.objects : [];
|
|
276
|
+
const targetId = proposal.payload.objectId;
|
|
277
|
+
if (!targetId) throw new Error("repair.binding requires payload.objectId");
|
|
278
|
+
if (!objects.some((obj) => obj.id === targetId)) {
|
|
279
|
+
throw new Error(`repair.binding target object "${targetId}" not found`);
|
|
280
|
+
}
|
|
281
|
+
dm.objects = objects.map((obj) =>
|
|
282
|
+
obj.id === targetId
|
|
283
|
+
? { ...obj, binding: { ...obj.binding, ...proposal.payload.binding } }
|
|
284
|
+
: obj
|
|
285
|
+
);
|
|
286
|
+
config.dataModel = dm;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "explain.object":
|
|
291
|
+
// explain proposals carry no mutation — they are informational only
|
|
292
|
+
break;
|
|
293
|
+
|
|
294
|
+
default:
|
|
295
|
+
throw new Error(`unknown proposal type: ${proposal.type}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return config;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validate a proposal before applying. Returns { ok, error? }.
|
|
303
|
+
* Merges the proposal into a copy of currentConfig and runs validateWorkspaceConfig.
|
|
304
|
+
*/
|
|
305
|
+
function validateProposalForApply(proposal, currentConfig) {
|
|
306
|
+
if (!ALLOWED_PATCH_FIELDS.has(proposal.affectedField)) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
error: `affectedField "${proposal.affectedField}" is not in the PATCH allowlist`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const expectedField = PROPOSAL_TYPE_TO_PATCH_FIELD[proposal.type];
|
|
314
|
+
if (expectedField && proposal.affectedField !== expectedField) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
error: `proposal type "${proposal.type}" requires affectedField "${expectedField}", got "${proposal.affectedField}"`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (proposal.type === "explain.object") {
|
|
322
|
+
return { ok: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let merged;
|
|
326
|
+
try {
|
|
327
|
+
merged = applyProposalToConfig(currentConfig, proposal);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return { ok: false, error: err.message || "failed to merge proposal" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const patchFragment = {};
|
|
333
|
+
patchFragment[proposal.affectedField] = merged[proposal.affectedField];
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
validateWorkspaceConfig(patchFragment);
|
|
337
|
+
return { ok: true };
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: Array.isArray(err.details) ? err.details.join("; ") : (err.message || "invalid proposal payload"),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Build a durable apply receipt for source-records and trace.jsonl.
|
|
348
|
+
*/
|
|
349
|
+
function buildApplyReceipt(proposal, appliedAt, reviewedBy, sessionId) {
|
|
350
|
+
const ts = appliedAt || new Date().toISOString();
|
|
351
|
+
return {
|
|
352
|
+
type: proposal.type,
|
|
353
|
+
affectedField: proposal.affectedField,
|
|
354
|
+
rationale: proposal.rationale,
|
|
355
|
+
confidence: proposal.confidence,
|
|
356
|
+
appliedAt: ts,
|
|
357
|
+
ranAt: ts,
|
|
358
|
+
reviewedBy: reviewedBy || "user",
|
|
359
|
+
sessionId: sessionId || null,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Threads — governed conversation history compounded onto an EXISTING
|
|
365
|
+
* custom-typed Data Model object.
|
|
366
|
+
*
|
|
367
|
+
* There is no new object type, no new schema namespace, no parallel chat
|
|
368
|
+
* store, and no separate AI memory layer. Each helper turn upserts one
|
|
369
|
+
* row inside a single custom-typed object identified by the well-known
|
|
370
|
+
* id "helper-threads". The row is structurally identical to any other
|
|
371
|
+
* row in dataModel.objects[]: it persists in growthub.config.json, is
|
|
372
|
+
* validated by validateWorkspaceConfig on write, ships into the exported
|
|
373
|
+
* runtime, and survives redeploy. The user can rename the label, add
|
|
374
|
+
* fields, or delete the object entirely — the helper will re-seed it on
|
|
375
|
+
* the next turn if missing.
|
|
376
|
+
*
|
|
377
|
+
* The legacy source-records audit trail (helper:<intent>:<runId>,
|
|
378
|
+
* helper:apply:receipts) is preserved as-is and remains the long-tail
|
|
379
|
+
* signal for the distillation pipeline.
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
const HELPER_THREADS_OBJECT_ID = "helper-threads";
|
|
383
|
+
const HELPER_THREADS_LABEL = "Helper Threads";
|
|
384
|
+
|
|
385
|
+
function ensureHelperThreadsObject(config) {
|
|
386
|
+
const dm = config?.dataModel && typeof config.dataModel === "object" ? config.dataModel : {};
|
|
387
|
+
const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
|
|
388
|
+
const idx = objects.findIndex((o) => o?.id === HELPER_THREADS_OBJECT_ID);
|
|
389
|
+
if (idx >= 0) {
|
|
390
|
+
// Ensure rows is an array; never overwrite an existing object's fields.
|
|
391
|
+
const existing = objects[idx];
|
|
392
|
+
if (!Array.isArray(existing.rows)) {
|
|
393
|
+
objects[idx] = { ...existing, rows: [] };
|
|
394
|
+
}
|
|
395
|
+
return { ...config, dataModel: { ...dm, objects } };
|
|
396
|
+
}
|
|
397
|
+
// Helper Threads is a normal custom-typed governed object. Identity
|
|
398
|
+
// stays stable through the well-known id "helper-threads" so the cell
|
|
399
|
+
// renderer in DataModelShell can opt the "open" column into the Reopen
|
|
400
|
+
// hyperlink without inventing a new object type.
|
|
401
|
+
const seeded = {
|
|
402
|
+
id: HELPER_THREADS_OBJECT_ID,
|
|
403
|
+
label: HELPER_THREADS_LABEL,
|
|
404
|
+
source: HELPER_THREADS_LABEL,
|
|
405
|
+
objectType: "custom",
|
|
406
|
+
icon: "MessageSquare",
|
|
407
|
+
columns: ["title", "intent", "model", "applied", "skipped", "updatedAt", "open"],
|
|
408
|
+
rows: [],
|
|
409
|
+
binding: { mode: "manual", source: HELPER_THREADS_LABEL },
|
|
410
|
+
};
|
|
411
|
+
return { ...config, dataModel: { ...dm, objects: [...objects, seeded] } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function truncateTitle(prompt, max = 72) {
|
|
415
|
+
const text = String(prompt || "").replace(/\s+/g, " ").trim();
|
|
416
|
+
if (text.length <= max) return text;
|
|
417
|
+
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Upsert a thread row into the Helper Threads governed object.
|
|
422
|
+
* If a row with matching id exists, merge fields onto it.
|
|
423
|
+
* If not, append a new row.
|
|
424
|
+
*
|
|
425
|
+
* Returns the updated config. Caller writes via writeWorkspaceConfig.
|
|
426
|
+
*/
|
|
427
|
+
function upsertHelperThreadRow(config, threadPatch) {
|
|
428
|
+
if (!threadPatch || typeof threadPatch !== "object" || !threadPatch.id) {
|
|
429
|
+
throw new Error("upsertHelperThreadRow requires a thread patch with an id");
|
|
430
|
+
}
|
|
431
|
+
const withObject = ensureHelperThreadsObject(config);
|
|
432
|
+
const dm = withObject.dataModel;
|
|
433
|
+
const objects = dm.objects.slice();
|
|
434
|
+
const idx = objects.findIndex((o) => o?.id === HELPER_THREADS_OBJECT_ID);
|
|
435
|
+
if (idx === -1) {
|
|
436
|
+
// Should be impossible after ensureHelperThreadsObject.
|
|
437
|
+
return withObject;
|
|
438
|
+
}
|
|
439
|
+
const obj = objects[idx];
|
|
440
|
+
const rows = Array.isArray(obj.rows) ? obj.rows.slice() : [];
|
|
441
|
+
const rowIdx = rows.findIndex((r) => r && r.id === threadPatch.id);
|
|
442
|
+
const nowIso = threadPatch.updatedAt || new Date().toISOString();
|
|
443
|
+
const merged = {
|
|
444
|
+
...(rowIdx >= 0 ? rows[rowIdx] : {}),
|
|
445
|
+
...threadPatch,
|
|
446
|
+
updatedAt: nowIso,
|
|
447
|
+
open: "Reopen", // display-only string; the cell renderer turns it into a link
|
|
448
|
+
};
|
|
449
|
+
if (rowIdx >= 0) {
|
|
450
|
+
rows[rowIdx] = merged;
|
|
451
|
+
} else {
|
|
452
|
+
rows.push(merged);
|
|
453
|
+
}
|
|
454
|
+
// Cap thread history so the config file does not grow unbounded.
|
|
455
|
+
const capped = rows.length > 100 ? rows.slice(-100) : rows;
|
|
456
|
+
objects[idx] = { ...obj, rows: capped };
|
|
457
|
+
return { ...withObject, dataModel: { ...dm, objects } };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function nextThreadId() {
|
|
461
|
+
return `thr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export {
|
|
465
|
+
applyProposalToConfig,
|
|
466
|
+
validateProposalForApply,
|
|
467
|
+
buildApplyReceipt,
|
|
468
|
+
ensureHelperThreadsObject,
|
|
469
|
+
upsertHelperThreadRow,
|
|
470
|
+
nextThreadId,
|
|
471
|
+
HELPER_THREADS_OBJECT_ID,
|
|
472
|
+
ALLOWED_PATCH_FIELDS,
|
|
473
|
+
};
|