@growthub/cli 0.13.2 → 0.13.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 (33) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +24 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +14 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +74 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +77 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +48 -3
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +123 -27
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +136 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +713 -92
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +224 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxRunPanel.jsx +32 -1
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +514 -9
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +8 -1
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +10 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/RunSetupPanel.jsx +261 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +72 -7
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +766 -138
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +91 -14
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +35 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +384 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +323 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +32 -3
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-eligibility.js +50 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-redaction.js +64 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +629 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-host-catalog.js +168 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-chart-values.js +542 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +164 -7
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +11 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +111 -1
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
  33. package/package.json +1 -1
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Pure chart value computation.
3
+ *
4
+ * Responsibility:
5
+ * Convert normalized rows (Data Model object rows or hydrated sidecar
6
+ * source records) into the `number[]` projection persisted on
7
+ * `widget.config.values`. The chart renderer remains dumb — it reads
8
+ * `widget.config.values` and renders. This module is the only path that
9
+ * produces those values from row data.
10
+ *
11
+ * Authority boundary:
12
+ * - No browser fetch.
13
+ * - No provider logic.
14
+ * - No schema mutation.
15
+ * - No secrets read or written.
16
+ * - Never throws on user-supplied axis/filter combinations — invalid
17
+ * inputs return `values: []` plus structured `warnings[]`.
18
+ *
19
+ * Contract:
20
+ * computeChartValuesFromRows({ rows, xAxis, yAxis, filter, chartType })
21
+ * → { values: number[], rowCount: number, usedRowCount: number, warnings: string[] }
22
+ */
23
+
24
+ /**
25
+ * Aggregation vocabulary — extends the V1 `sum | avg | count | min | max`
26
+ * set with Twenty-style row-presence operations. Every operation here is
27
+ * a pure function over filtered rows (or per-bucket subsets after group-by);
28
+ * none of them require the Y field to be numeric.
29
+ *
30
+ * countAll — total rows in the bucket
31
+ * countEmpty — rows where Y is null/undefined/""
32
+ * countNotEmpty — rows where Y is non-empty
33
+ * countUnique — distinct non-empty Y values
34
+ * percentEmpty — countEmpty / countAll × 100
35
+ * percentNotEmpty — countNotEmpty / countAll × 100
36
+ * sum, avg, min, max — numeric operations (require Y to coerce to number)
37
+ *
38
+ * `count` is kept as an alias for `countAll` for backward compatibility with
39
+ * V1 charts whose configs still set `aggregation: "count"`.
40
+ */
41
+ const KNOWN_AGGREGATIONS = [
42
+ "sum",
43
+ "avg",
44
+ "count",
45
+ "countAll",
46
+ "countEmpty",
47
+ "countNotEmpty",
48
+ "countUnique",
49
+ "percentEmpty",
50
+ "percentNotEmpty",
51
+ "min",
52
+ "max"
53
+ ];
54
+ const COUNT_AGGREGATIONS = new Set(["count", "countAll", "countEmpty", "countNotEmpty", "countUnique"]);
55
+ const PERCENT_AGGREGATIONS = new Set(["percentEmpty", "percentNotEmpty"]);
56
+ // Aggregations that DO NOT require Y values to be numeric — they operate on
57
+ // row presence or emptiness, not row magnitude.
58
+ const NON_NUMERIC_AGGREGATIONS = new Set([
59
+ "count",
60
+ "countAll",
61
+ "countEmpty",
62
+ "countNotEmpty",
63
+ "countUnique",
64
+ "percentEmpty",
65
+ "percentNotEmpty"
66
+ ]);
67
+ const DEFAULT_AGGREGATION = "sum";
68
+
69
+ function isEmptyValue(value) {
70
+ return value === undefined || value === null || value === "" || (typeof value === "string" && value.trim() === "");
71
+ }
72
+
73
+ function isPlainObject(value) {
74
+ return value !== null && typeof value === "object" && !Array.isArray(value);
75
+ }
76
+
77
+ function coerceNumber(value) {
78
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
79
+ if (typeof value === "boolean") return value ? 1 : 0;
80
+ if (typeof value === "string") {
81
+ const trimmed = value.trim();
82
+ if (!trimmed) return null;
83
+ const parsed = Number(trimmed.replace(/,/g, ""));
84
+ return Number.isFinite(parsed) ? parsed : null;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function fieldHasNumericValue(rows, field) {
90
+ if (!field) return false;
91
+ for (const row of rows) {
92
+ if (coerceNumber(row?.[field]) !== null) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function compareFilterValue(left, right, operator) {
98
+ const op = String(operator || "eq");
99
+ if (op === "isEmpty") return left === undefined || left === null || left === "";
100
+ if (op === "isNotEmpty") return !(left === undefined || left === null || left === "");
101
+ const ln = coerceNumber(left);
102
+ const rn = coerceNumber(right);
103
+ if (op === "gt") return ln !== null && rn !== null && ln > rn;
104
+ if (op === "lt") return ln !== null && rn !== null && ln < rn;
105
+ const ls = left === undefined || left === null ? "" : String(left);
106
+ const rs = right === undefined || right === null ? "" : String(right);
107
+ if (op === "eq") return ls === rs;
108
+ if (op === "ne") return ls !== rs;
109
+ if (op === "contains") return ls.toLowerCase().includes(rs.toLowerCase());
110
+ return false;
111
+ }
112
+
113
+ function applyFilter(rows, filter) {
114
+ if (!isPlainObject(filter) || !Array.isArray(filter.clauses) || filter.clauses.length === 0) {
115
+ return rows;
116
+ }
117
+ const op = filter.op === "or" ? "or" : "and";
118
+ return rows.filter((row) => {
119
+ const decisions = filter.clauses.map((clause) => {
120
+ if (!isPlainObject(clause)) return true;
121
+ const field = String(clause.fieldId || "").trim();
122
+ if (!field) return true;
123
+ const left = row?.[field];
124
+ return compareFilterValue(left, clause.value, clause.operator);
125
+ });
126
+ return op === "or" ? decisions.some(Boolean) : decisions.every(Boolean);
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Aggregate a list of numeric values. Kept for backward compatibility and
132
+ * the V1 public surface — callers that already have a `number[]` (e.g. count
133
+ * aggregations) can hand them in directly.
134
+ *
135
+ * For Twenty-style row-presence operations (countEmpty, countNotEmpty,
136
+ * countUnique, percentEmpty, percentNotEmpty), use `aggregateRows` instead —
137
+ * those operations need the raw row set, not just numeric values.
138
+ */
139
+ function aggregateValues(numbers, aggregation) {
140
+ if (!numbers.length) return null;
141
+ const agg = KNOWN_AGGREGATIONS.includes(aggregation) ? aggregation : DEFAULT_AGGREGATION;
142
+ if (agg === "count" || agg === "countAll") return numbers.length;
143
+ if (agg === "min") return numbers.reduce((acc, value) => (value < acc ? value : acc), numbers[0]);
144
+ if (agg === "max") return numbers.reduce((acc, value) => (value > acc ? value : acc), numbers[0]);
145
+ const sum = numbers.reduce((acc, value) => acc + value, 0);
146
+ if (agg === "avg") return sum / numbers.length;
147
+ return sum;
148
+ }
149
+
150
+ /**
151
+ * Aggregate rows under a Twenty-style operation. Returns a finite number or
152
+ * null (for empty buckets where the operation cannot be defined).
153
+ *
154
+ * - count* operations ignore the Y field entirely — they count row presence.
155
+ * - percent* operations return a 0-100 percentage of countEmpty/countNotEmpty
156
+ * over countAll for the bucket.
157
+ * - sum/avg/min/max coerce row[yField] to number and discard non-numeric.
158
+ */
159
+ function aggregateRows(rowsForBucket, yField, aggregation) {
160
+ if (!Array.isArray(rowsForBucket) || rowsForBucket.length === 0) return null;
161
+ const agg = KNOWN_AGGREGATIONS.includes(aggregation) ? aggregation : DEFAULT_AGGREGATION;
162
+ const total = rowsForBucket.length;
163
+
164
+ if (agg === "count" || agg === "countAll") return total;
165
+ if (agg === "countEmpty") return rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 1 : 0), 0);
166
+ if (agg === "countNotEmpty") return rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 0 : 1), 0);
167
+ if (agg === "countUnique") {
168
+ const set = new Set();
169
+ for (const row of rowsForBucket) {
170
+ const value = row?.[yField];
171
+ if (isEmptyValue(value)) continue;
172
+ set.add(String(value));
173
+ }
174
+ return set.size;
175
+ }
176
+ if (agg === "percentEmpty") {
177
+ if (!total) return null;
178
+ const empty = rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 1 : 0), 0);
179
+ return (empty / total) * 100;
180
+ }
181
+ if (agg === "percentNotEmpty") {
182
+ if (!total) return null;
183
+ const notEmpty = rowsForBucket.reduce((acc, row) => acc + (isEmptyValue(row?.[yField]) ? 0 : 1), 0);
184
+ return (notEmpty / total) * 100;
185
+ }
186
+
187
+ // Numeric operations: coerce row[yField] to number and discard nulls.
188
+ const numbers = [];
189
+ for (const row of rowsForBucket) {
190
+ const coerced = coerceNumber(row?.[yField]);
191
+ if (coerced !== null) numbers.push(coerced);
192
+ }
193
+ return aggregateValues(numbers, agg);
194
+ }
195
+
196
+ function groupRows(rows, field) {
197
+ if (!field) return null;
198
+ const groups = new Map();
199
+ const order = [];
200
+ for (const row of rows) {
201
+ const rawKey = row?.[field];
202
+ const key = rawKey === undefined || rawKey === null ? "" : String(rawKey);
203
+ if (!groups.has(key)) {
204
+ groups.set(key, []);
205
+ order.push(key);
206
+ }
207
+ groups.get(key).push(row);
208
+ }
209
+ return { order, groups };
210
+ }
211
+
212
+ function sortGroups(order, valuesByKey, direction) {
213
+ const dir = direction === "desc" ? "desc" : direction === "asc" ? "asc" : "position";
214
+ if (dir === "position") return order;
215
+ const sorted = [...order];
216
+ sorted.sort((a, b) => {
217
+ const va = valuesByKey.get(a);
218
+ const vb = valuesByKey.get(b);
219
+ if (va === null && vb === null) return 0;
220
+ if (va === null) return 1;
221
+ if (vb === null) return -1;
222
+ return dir === "desc" ? vb - va : va - vb;
223
+ });
224
+ return sorted;
225
+ }
226
+
227
+ function omitZeroIfRequested(pairs, omitZero) {
228
+ if (!omitZero) return pairs;
229
+ return pairs.filter(([, value]) => value !== 0);
230
+ }
231
+
232
+ /**
233
+ * Compute the chart `values: number[]` projection from rows.
234
+ *
235
+ * - When `chartType === "count"` or `yAxis.aggregation === "count"`, every row
236
+ * in a bucket contributes 1 — the Y field is ignored entirely because
237
+ * counting is about row presence, not row values. A non-numeric Y field is
238
+ * therefore valid under count.
239
+ * - When the Y field is non-numeric and the aggregation needs numbers,
240
+ * the result is `values: []` plus a warning — never a throw.
241
+ * - When `yAxis.groupBy` is set, rows group on that key first; otherwise
242
+ * `xAxis.field` (if present) acts as the bucket key; otherwise every
243
+ * row contributes a single bucket.
244
+ */
245
+ function computeChartValuesFromRows({
246
+ rows,
247
+ xAxis,
248
+ yAxis,
249
+ filter,
250
+ chartType
251
+ } = {}) {
252
+ const debug = computeChartProjectionDebug({ rows, xAxis, yAxis, filter, chartType });
253
+ return {
254
+ values: debug.values,
255
+ rowCount: debug.rowCount,
256
+ usedRowCount: debug.usedRowCount,
257
+ warnings: debug.warnings
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Same computation as `computeChartValuesFromRows`, but also returns the
263
+ * intermediate steps so the Chart Hydration Inspector can show why each
264
+ * value exists.
265
+ *
266
+ * The returned shape includes:
267
+ * - `samples`: first N source rows (preview only)
268
+ * - `filteredCount` and `droppedByFilter`
269
+ * - `buckets`: per-group breakdown ({ key, rowCount, numericCount, value, reason })
270
+ * - `droppedRows`: per-row drop reasons after filtering ("non-numeric-y",
271
+ * "missing-y", "filter-removed", "zero-omitted")
272
+ * - `values`: final number[] persisted on widget.config.values
273
+ *
274
+ * Pure: no fetch, no provider logic, no schema mutation.
275
+ */
276
+ function computeChartProjectionDebug({
277
+ rows,
278
+ xAxis,
279
+ yAxis,
280
+ filter,
281
+ chartType
282
+ } = {}) {
283
+ const warnings = [];
284
+ const safeRows = Array.isArray(rows) ? rows.filter(isPlainObject) : [];
285
+ const rowCount = safeRows.length;
286
+ const samples = safeRows.slice(0, 5);
287
+ if (!rowCount) {
288
+ if (rows !== undefined && rows !== null) {
289
+ warnings.push("No rows available from the selected source.");
290
+ }
291
+ return {
292
+ values: [],
293
+ rowCount: 0,
294
+ usedRowCount: 0,
295
+ filteredCount: 0,
296
+ droppedByFilter: 0,
297
+ buckets: [],
298
+ droppedRows: [],
299
+ samples,
300
+ warnings
301
+ };
302
+ }
303
+
304
+ const filtered = applyFilter(safeRows, filter);
305
+ const droppedByFilter = rowCount - filtered.length;
306
+ if (!filtered.length) {
307
+ warnings.push("Filter clauses removed every row.");
308
+ return {
309
+ values: [],
310
+ rowCount,
311
+ usedRowCount: 0,
312
+ filteredCount: 0,
313
+ droppedByFilter,
314
+ buckets: [],
315
+ droppedRows: [],
316
+ samples,
317
+ warnings
318
+ };
319
+ }
320
+
321
+ const xField = isPlainObject(xAxis) && typeof xAxis.field === "string" ? xAxis.field.trim() : "";
322
+ const yField = isPlainObject(yAxis) && typeof yAxis.field === "string" ? yAxis.field.trim() : "";
323
+ const groupField = isPlainObject(yAxis) && typeof yAxis.groupBy === "string" ? yAxis.groupBy.trim() : "";
324
+ const aggregationRaw = isPlainObject(yAxis) && typeof yAxis.operation === "string"
325
+ ? yAxis.operation.trim()
326
+ : (isPlainObject(yAxis) && typeof yAxis.aggregation === "string" ? yAxis.aggregation.trim() : DEFAULT_AGGREGATION);
327
+ const aggregation = KNOWN_AGGREGATIONS.includes(aggregationRaw) ? aggregationRaw : DEFAULT_AGGREGATION;
328
+ const omitZero = isPlainObject(xAxis) ? Boolean(xAxis.omitZero) : false;
329
+ const sortDirection = isPlainObject(xAxis) && typeof xAxis.sort === "string" ? xAxis.sort : "position";
330
+ const chartKind = typeof chartType === "string" ? chartType : "";
331
+ const isCountAggregation = COUNT_AGGREGATIONS.has(aggregation) || chartKind === "count";
332
+ const isPercentAggregation = PERCENT_AGGREGATIONS.has(aggregation);
333
+ // Aggregation tolerates non-numeric Y when it operates on row presence or
334
+ // emptiness (count*, percent*) — every other operation needs at least one
335
+ // numeric Y value somewhere in the filtered set.
336
+ const tolerantOfNonNumericY = NON_NUMERIC_AGGREGATIONS.has(aggregation) || chartKind === "count";
337
+
338
+ if (!yField && !tolerantOfNonNumericY) {
339
+ warnings.push("Choose a Y axis field or switch aggregation to count.");
340
+ return {
341
+ values: [],
342
+ rowCount,
343
+ usedRowCount: 0,
344
+ filteredCount: filtered.length,
345
+ droppedByFilter,
346
+ buckets: [],
347
+ droppedRows: [],
348
+ samples,
349
+ warnings
350
+ };
351
+ }
352
+
353
+ if (yField && !tolerantOfNonNumericY && !fieldHasNumericValue(filtered, yField)) {
354
+ warnings.push(`Y field "${yField}" has no numeric values.`);
355
+ return {
356
+ values: [],
357
+ rowCount,
358
+ usedRowCount: 0,
359
+ filteredCount: filtered.length,
360
+ droppedByFilter,
361
+ buckets: [],
362
+ droppedRows: filtered.slice(0, 5).map((row) => ({ row, reason: "non-numeric-y" })),
363
+ samples,
364
+ warnings
365
+ };
366
+ }
367
+
368
+ const bucketField = groupField || xField || "";
369
+ const grouped = bucketField ? groupRows(filtered, bucketField) : null;
370
+ const droppedRows = [];
371
+
372
+ // For numeric operations we still surface dropped-row diagnostics; for
373
+ // count/percent operations the Y field is ignored, so nothing is "dropped".
374
+ const trackNumericDrops = !tolerantOfNonNumericY;
375
+ const trackNumericDropsForBucket = (rowsForBucket) => {
376
+ if (!trackNumericDrops || !yField) return;
377
+ for (const row of rowsForBucket) {
378
+ const coerced = coerceNumber(row?.[yField]);
379
+ if (coerced === null) {
380
+ droppedRows.push({
381
+ row,
382
+ reason: isEmptyValue(row?.[yField]) ? "missing-y" : "non-numeric-y"
383
+ });
384
+ }
385
+ }
386
+ };
387
+
388
+ const numericCountFor = (rowsForBucket) => {
389
+ if (!yField) return 0;
390
+ let count = 0;
391
+ for (const row of rowsForBucket) {
392
+ if (coerceNumber(row?.[yField]) !== null) count += 1;
393
+ }
394
+ return count;
395
+ };
396
+
397
+ const buckets = [];
398
+ let pairs;
399
+ if (!grouped) {
400
+ trackNumericDropsForBucket(filtered);
401
+ const value = aggregateRows(filtered, yField, aggregation);
402
+ if (value === null && !tolerantOfNonNumericY) {
403
+ warnings.push("No numeric values found.");
404
+ return {
405
+ values: [],
406
+ rowCount,
407
+ usedRowCount: filtered.length,
408
+ filteredCount: filtered.length,
409
+ droppedByFilter,
410
+ buckets: [],
411
+ droppedRows,
412
+ samples,
413
+ warnings
414
+ };
415
+ }
416
+ buckets.push({
417
+ key: "",
418
+ rowCount: filtered.length,
419
+ numericCount: numericCountFor(filtered),
420
+ value,
421
+ operation: aggregation
422
+ });
423
+ pairs = value === null ? [] : [["", value]];
424
+ } else {
425
+ const computed = new Map();
426
+ for (const key of grouped.order) {
427
+ const bucketRows = grouped.groups.get(key) || [];
428
+ trackNumericDropsForBucket(bucketRows);
429
+ const value = aggregateRows(bucketRows, yField, aggregation);
430
+ computed.set(key, value);
431
+ buckets.push({
432
+ key,
433
+ rowCount: bucketRows.length,
434
+ numericCount: numericCountFor(bucketRows),
435
+ value,
436
+ operation: aggregation
437
+ });
438
+ }
439
+ const ordered = sortGroups(grouped.order, computed, sortDirection);
440
+ pairs = ordered
441
+ .map((key) => [key, computed.get(key)])
442
+ .filter(([, value]) => value !== null && Number.isFinite(value));
443
+ }
444
+
445
+ // Cumulative running-total transform (Twenty-style yAxis.cumulative). Applied
446
+ // after sort/group and before omitZero so the displayed curve matches the
447
+ // sort direction the user picked.
448
+ const cumulative = isPlainObject(yAxis) ? Boolean(yAxis.cumulative) : false;
449
+ if (cumulative && pairs.length) {
450
+ let running = 0;
451
+ pairs = pairs.map(([key, value]) => {
452
+ running += value;
453
+ return [key, running];
454
+ });
455
+ }
456
+
457
+ if (isPercentAggregation) {
458
+ // Percent operations are already 0-100; nothing to clamp here.
459
+ }
460
+
461
+ if (omitZero) {
462
+ for (const [key, value] of pairs) {
463
+ if (value === 0) droppedRows.push({ key, reason: "zero-omitted" });
464
+ }
465
+ }
466
+ pairs = omitZeroIfRequested(pairs, omitZero);
467
+ const values = pairs.map(([, value]) => value).filter((value) => Number.isFinite(value));
468
+
469
+ if (!values.length) {
470
+ warnings.push("Computation produced no finite values.");
471
+ }
472
+
473
+ return {
474
+ values,
475
+ rowCount,
476
+ usedRowCount: filtered.length,
477
+ filteredCount: filtered.length,
478
+ droppedByFilter,
479
+ buckets,
480
+ droppedRows,
481
+ samples,
482
+ warnings
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Derive a single status code the UI can render as a chip.
488
+ *
489
+ * Inputs:
490
+ * - widget: the chart widget
491
+ * - table: the bound Data Model table (or null)
492
+ * - computation: a `computeChartProjectionDebug` result
493
+ * - lastSavedValues: the values that were last persisted to disk
494
+ * - sourceFetchedAt: ISO timestamp of the last refresh, if any
495
+ * - readOnlySave: true when the runtime persistence is read-only
496
+ *
497
+ * Returns one of:
498
+ * "static" | "unbound" | "needs-source" | "needs-axis" | "warnings"
499
+ * "unsaved" | "stale-source" | "read-only-save-blocked" | "computed"
500
+ */
501
+ function deriveChartHydrationState({
502
+ widget,
503
+ table,
504
+ computation,
505
+ lastSavedValues,
506
+ sourceFetchedAt,
507
+ readOnlySave
508
+ } = {}) {
509
+ const binding = widget?.config?.binding;
510
+ if (binding?.sourceType !== "workspace-data-model" || !binding.objectId) {
511
+ return Array.isArray(widget?.config?.values) && widget.config.values.length ? "static" : "unbound";
512
+ }
513
+ if (!table) return "needs-source";
514
+ const yField = isPlainObject(widget?.config?.yAxis) ? widget.config.yAxis.field : "";
515
+ const xField = isPlainObject(widget?.config?.xAxis) ? widget.config.xAxis.field : "";
516
+ const aggregation = isPlainObject(widget?.config?.yAxis) ? widget.config.yAxis.aggregation : "";
517
+ const isCount = aggregation === "count" || widget?.config?.chartType === "count";
518
+ if (!isCount && !yField) return "needs-axis";
519
+ if (!isCount && !xField) return "needs-axis";
520
+ if (computation?.warnings?.length) return "warnings";
521
+ const current = Array.isArray(widget?.config?.values) ? widget.config.values : [];
522
+ const saved = Array.isArray(lastSavedValues) ? lastSavedValues : current;
523
+ const valuesDiffer = current.length !== saved.length || current.some((value, index) => value !== saved[index]);
524
+ if (valuesDiffer && readOnlySave) return "read-only-save-blocked";
525
+ if (valuesDiffer) return "unsaved";
526
+ if (sourceFetchedAt && table.liveSource?.fetchedAt && sourceFetchedAt !== table.liveSource.fetchedAt) {
527
+ return "stale-source";
528
+ }
529
+ return "computed";
530
+ }
531
+
532
+ export {
533
+ KNOWN_AGGREGATIONS,
534
+ applyFilter,
535
+ aggregateRows,
536
+ aggregateValues,
537
+ coerceNumber,
538
+ computeChartProjectionDebug,
539
+ computeChartValuesFromRows,
540
+ deriveChartHydrationState,
541
+ groupRows
542
+ };