@growthub/cli 0.13.6 → 0.13.8

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 (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +98 -34
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +1 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +106 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +189 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceContributionGraph.jsx +119 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +357 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +488 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensWalkthrough.jsx +69 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +37 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/NangoConnectionPanel.jsx +37 -2
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +437 -26
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +44 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +592 -41
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-lens/page.jsx +76 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +148 -4
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +1559 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +3 -3
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +24 -8
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +82 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +8 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/project-management.config.json +4 -4
  22. package/dist/index.js +5224 -5225
  23. package/package.json +1 -1
@@ -0,0 +1,1559 @@
1
+ /**
2
+ * Growthub Workspace Customer Activation Layer V1 — derivation helpers.
3
+ *
4
+ * Reads the existing governed artifacts (workspaceConfig + sidecar
5
+ * source records + workspace metadata graph store) and projects a typed,
6
+ * read-only activation checklist:
7
+ *
8
+ * - what template the workspace was scaffolded from (provenance)
9
+ * - which setup steps are still pending
10
+ * - what the next obvious action is
11
+ *
12
+ * Authority order:
13
+ *
14
+ * 1. growthub.config.json (governed workspace artifact)
15
+ * 2. growthub.source-records.json (sidecar source/run state)
16
+ * 3. derived metadata store (workspace-metadata-store.js)
17
+ *
18
+ * Invariants:
19
+ *
20
+ * - Pure derivation. No React. No fetch. No mutation of inputs.
21
+ * - Never includes secrets, OAuth tokens, provider credentials, or
22
+ * access/refresh tokens in the output. Connection IDs are surfaced
23
+ * as booleans only (`connectionConfigured: true/false`).
24
+ * - Activation state is NOT persisted — every checklist field is
25
+ * recomputable from inputs on every page load. Don't add hidden
26
+ * storage. Don't add localStorage. Don't add a separate sidecar.
27
+ * - Backwards-compatible: a workspace with no `provenance` block still
28
+ * produces a valid (generic) activation state.
29
+ *
30
+ * The output shape:
31
+ *
32
+ * {
33
+ * kind: "growthub-workspace-activation-state-v1"
34
+ * version: 1
35
+ * template: "blank" | "project-management" | <slug>
36
+ * templateName:string
37
+ * headline: string // user-facing "what is this workspace for"
38
+ * subheadline: string // user-facing "what's next"
39
+ * complete: boolean
40
+ * completedCount: number
41
+ * totalCount: number
42
+ * nextStepId: string | null
43
+ * steps: [
44
+ * {
45
+ * id: string // stable; safe to use as key
46
+ * label: string // short title
47
+ * description:string // one-line user-facing copy
48
+ * status: "complete"|"pending"|"blocked"|"optional"
49
+ * href: string // deep link into existing surface
50
+ * hint?: string // why it's blocked (no secrets)
51
+ * cta?: string // button label override
52
+ * }
53
+ * ]
54
+ * }
55
+ */
56
+
57
+ const ACTIVATION_KIND = "growthub-workspace-activation-state-v1";
58
+ const ACTIVATION_VERSION = 1;
59
+
60
+ const TEMPLATE_PROJECT_MANAGEMENT = "project-management";
61
+
62
+ function isPlainObject(value) {
63
+ return value !== null && typeof value === "object" && !Array.isArray(value);
64
+ }
65
+
66
+ function safeString(value) {
67
+ if (value == null) return "";
68
+ return typeof value === "string" ? value : String(value);
69
+ }
70
+
71
+ function findDataModelObject(workspaceConfig, predicate) {
72
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
73
+ ? workspaceConfig.dataModel.objects
74
+ : [];
75
+ for (const object of objects) {
76
+ if (!isPlainObject(object)) continue;
77
+ if (predicate(object)) return object;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function listObjectRows(object) {
83
+ return Array.isArray(object?.rows) ? object.rows : [];
84
+ }
85
+
86
+ function safeBoolean(value) {
87
+ return Boolean(value);
88
+ }
89
+
90
+ function deriveProvenance(workspaceConfig) {
91
+ const provenance = isPlainObject(workspaceConfig?.provenance) ? workspaceConfig.provenance : null;
92
+ const template = safeString(provenance?.template).trim().toLowerCase();
93
+ const templateKind = safeString(provenance?.templateKind).trim();
94
+ const privacy = safeString(provenance?.privacy).trim();
95
+ const note = safeString(provenance?.note).trim();
96
+ const templateName = safeString(workspaceConfig?.name || provenance?.label || "").trim();
97
+ return {
98
+ hasProvenance: Boolean(provenance),
99
+ template: template || "blank",
100
+ templateKind,
101
+ privacy,
102
+ note,
103
+ templateName,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Detect whether a string-or-list-of-strings field on an api-registry row
109
+ * contains at least one configured connection ID. We surface this as a
110
+ * boolean only — the connection identifier itself is never echoed into the
111
+ * activation state (it can be PII-adjacent).
112
+ */
113
+ function hasConnectionId(row) {
114
+ if (!isPlainObject(row)) return false;
115
+ const raw = row.connectionIds ?? row.connectionId;
116
+ if (Array.isArray(raw)) {
117
+ return raw.some((entry) => safeString(entry).trim().length > 0);
118
+ }
119
+ return safeString(raw).trim().length > 0;
120
+ }
121
+
122
+ /**
123
+ * Detect whether the workspace source-records sidecar has any rows for
124
+ * the given source id. Reads `recordCount` first (cheap) and falls back to
125
+ * `records.length`. Never inspects the underlying records.
126
+ */
127
+ function hasSourceRecords(workspaceSourceRecords, sourceId) {
128
+ if (!isPlainObject(workspaceSourceRecords)) return false;
129
+ const key = safeString(sourceId).trim();
130
+ if (!key) return false;
131
+ const sidecar = workspaceSourceRecords[key];
132
+ if (!isPlainObject(sidecar)) return false;
133
+ if (Number.isFinite(sidecar.recordCount) && sidecar.recordCount > 0) return true;
134
+ if (Array.isArray(sidecar.records) && sidecar.records.length > 0) return true;
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * Pull the latest workflow + sandbox row status from the typed metadata
140
+ * store when it's available, falling back to inspecting workspaceConfig
141
+ * directly. The store path is preferred because it dedupes runs and tags
142
+ * pipeline health, but neither path is required.
143
+ */
144
+ function findWorkflowRow(workspaceConfig, predicate) {
145
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
146
+ ? workspaceConfig.dataModel.objects
147
+ : [];
148
+ for (const object of objects) {
149
+ if (!isPlainObject(object) || object.objectType !== "sandbox-environment") continue;
150
+ const rows = listObjectRows(object);
151
+ for (const row of rows) {
152
+ if (!isPlainObject(row)) continue;
153
+ if (predicate(row, object)) return { object, row };
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function parseSafe(value) {
160
+ if (isPlainObject(value)) return value;
161
+ if (typeof value !== "string") return null;
162
+ const trimmed = value.trim();
163
+ if (!trimmed) return null;
164
+ try {
165
+ const parsed = JSON.parse(trimmed);
166
+ return isPlainObject(parsed) ? parsed : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function deriveLatestRunStatus(workflowRow) {
173
+ if (!isPlainObject(workflowRow)) return { status: "never", ok: false };
174
+ const status = safeString(workflowRow.status).trim().toLowerCase();
175
+ const lastResponse = parseSafe(workflowRow.lastResponse);
176
+ if (!lastResponse) {
177
+ if (status === "tested" || status === "ok" || status === "success") {
178
+ return { status: "ok", ok: true };
179
+ }
180
+ if (status === "failed" || status === "error") {
181
+ return { status: "failed", ok: false };
182
+ }
183
+ return { status: "never", ok: false };
184
+ }
185
+ const exitCode = Number.isFinite(lastResponse.exitCode) ? Number(lastResponse.exitCode) : null;
186
+ const ok = exitCode === 0 && !safeString(lastResponse.error).trim();
187
+ return { status: ok ? "ok" : "failed", ok };
188
+ }
189
+
190
+ function findDashboardByIdOrName(workspaceConfig, predicate) {
191
+ const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
192
+ for (const dashboard of dashboards) {
193
+ if (!isPlainObject(dashboard)) continue;
194
+ if (predicate(dashboard)) return dashboard;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // ───────────────────────────────────────────────────────────────────────────
200
+ // Project Management Workspace Template — adapter
201
+ // ───────────────────────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Derive the Project Management activation checklist.
205
+ *
206
+ * The seed config (`templates/seeded-configs/project-management.config.json`)
207
+ * ships a Nango-backed api-registry row, a Project Task Source, a sandbox
208
+ * workflow row, and a dashboard. This helper checks each one and builds
209
+ * the deep-link checklist.
210
+ */
211
+ function deriveProjectManagementActivationState({ workspaceConfig, workspaceSourceRecords, metadataGraph }) {
212
+ const provenance = deriveProvenance(workspaceConfig);
213
+ const registry = findDataModelObject(workspaceConfig, (o) => o.objectType === "api-registry");
214
+ const registryRow = registry
215
+ ? listObjectRows(registry).find((row) => safeString(row?.connectorKind).trim().toLowerCase() === "nango")
216
+ || listObjectRows(registry)[0]
217
+ : null;
218
+ const providerConfigKey = safeString(registryRow?.providerConfigKey).trim()
219
+ || safeString(registryRow?.integrationId).trim();
220
+ const connectionConfigured = hasConnectionId(registryRow);
221
+
222
+ const sourceObject = findDataModelObject(workspaceConfig, (o) => o.id === "project-task-source"
223
+ || o.objectType === "data-source");
224
+ const sourceRow = listObjectRows(sourceObject)[0] || null;
225
+ const sourceId = safeString(sourceRow?.sourceId).trim();
226
+ const sourceHasRecords = hasSourceRecords(workspaceSourceRecords, sourceId);
227
+
228
+ const workflowMatch = findWorkflowRow(workspaceConfig, (row) => {
229
+ const name = safeString(row?.Name || row?.name).trim();
230
+ return name === "project-active-tasks-workflow"
231
+ || name.toLowerCase().includes("project-active-tasks");
232
+ });
233
+ const workflowRow = workflowMatch?.row || null;
234
+ const workflowObjectId = workflowMatch?.object?.id || "sandbox-environments";
235
+ const workflowRowName = safeString(workflowRow?.Name || workflowRow?.name).trim();
236
+ const workflowRun = deriveLatestRunStatus(workflowRow);
237
+
238
+ const dashboard = findDashboardByIdOrName(workspaceConfig,
239
+ (d) => d.id === "project-management-template"
240
+ || safeString(d.name).trim().toLowerCase() === "project management");
241
+
242
+ // NANGO_SECRET_KEY presence is impossible to detect in pure derivation
243
+ // (it's an env var the browser can't read). The metadata graph exposes a
244
+ // safe boolean via authority/runtime status if the server has surfaced it;
245
+ // otherwise the step always asks the user to confirm.
246
+ const integrationsRuntime = isPlainObject(metadataGraph?.runtime) ? metadataGraph.runtime : null;
247
+ const nangoConfiguredHint = integrationsRuntime?.nangoConfigured;
248
+ // If the runtime explicitly says configured, mark the env step as
249
+ // complete. If it explicitly says not-configured OR we have no signal at
250
+ // all, we don't claim it's complete — the user still needs to confirm.
251
+ const nangoEnvComplete = nangoConfiguredHint === true;
252
+
253
+ const steps = [];
254
+
255
+ steps.push({
256
+ id: "provider-env",
257
+ label: "Configure provider auth",
258
+ description: "Set NANGO_SECRET_KEY in your runtime environment so the workspace can talk to Nango.",
259
+ status: nangoEnvComplete ? "complete" : "pending",
260
+ href: "/settings/integrations",
261
+ hint: nangoEnvComplete
262
+ ? ""
263
+ : "Set NANGO_SECRET_KEY in .env.local (or your hosted runtime), then restart the workspace server.",
264
+ cta: nangoEnvComplete ? "Review integrations" : "Open integration settings",
265
+ });
266
+
267
+ const nangoStepHref = registry && registryRow
268
+ ? `/data-model?object=${encodeURIComponent(registry.id)}&row=${encodeURIComponent(safeString(registryRow.integrationId).trim() || "asana-active-tasks")}`
269
+ : "/data-model";
270
+
271
+ steps.push({
272
+ id: "nango-connection",
273
+ label: "Connect provider through Nango",
274
+ description: connectionConfigured
275
+ ? `Connection configured for providerConfigKey "${providerConfigKey || "asana"}".`
276
+ : "Open the API Registry row and run Create Connect Session, then paste the connectionId Nango returns.",
277
+ status: connectionConfigured ? "complete" : (nangoEnvComplete ? "pending" : "blocked"),
278
+ href: nangoStepHref,
279
+ hint: connectionConfigured
280
+ ? ""
281
+ : nangoEnvComplete
282
+ ? "Use the Nango panel in the API Registry row — your workspace never sees provider tokens."
283
+ : "Finish the previous step first — Nango needs NANGO_SECRET_KEY to mint a Connect Session.",
284
+ cta: connectionConfigured ? "Manage connection" : "Open Nango panel",
285
+ });
286
+
287
+ const workflowHref = workflowRowName
288
+ ? `/workflows?object=${encodeURIComponent(workflowObjectId)}&row=${encodeURIComponent(workflowRowName)}&field=orchestrationConfig`
289
+ : "/workflows";
290
+
291
+ const workflowComplete = workflowRun.ok && sourceHasRecords;
292
+
293
+ steps.push({
294
+ id: "workflow-run",
295
+ label: "Run the Active Tasks workflow",
296
+ description: workflowComplete
297
+ ? "Workflow has executed successfully and refreshed the Project Task Source."
298
+ : workflowRun.status === "failed"
299
+ ? "The last run failed — open the run trace to see what went wrong."
300
+ : "Open the seeded workflow and click Test to pull active tasks from your project.",
301
+ status: workflowComplete
302
+ ? "complete"
303
+ : connectionConfigured
304
+ ? (workflowRun.status === "failed" ? "blocked" : "pending")
305
+ : "blocked",
306
+ href: workflowHref,
307
+ hint: workflowComplete
308
+ ? ""
309
+ : connectionConfigured
310
+ ? workflowRun.status === "failed"
311
+ ? "Open the workflow's See Runs trace and check the failing node's response."
312
+ : "Fill projectGid + workspaceGid in the input node, then click Test."
313
+ : "Finish the Nango connection step first.",
314
+ cta: workflowComplete ? "Open workflow" : workflowRun.status === "failed" ? "Open run trace" : "Open workflow",
315
+ });
316
+
317
+ const dashboardHref = dashboard
318
+ ? `/?dashboard=${encodeURIComponent(dashboard.id)}`
319
+ : "/";
320
+
321
+ steps.push({
322
+ id: "dashboard-view",
323
+ label: "View the Active Tasks dashboard",
324
+ description: workflowComplete
325
+ ? "Open the dashboard to see the latest active project tasks."
326
+ : "After the workflow runs successfully, hydrated tasks will appear in this dashboard.",
327
+ status: workflowComplete ? "complete" : "pending",
328
+ href: dashboardHref,
329
+ cta: "Open dashboard",
330
+ });
331
+
332
+ steps.push({
333
+ id: "customize",
334
+ label: "Customize this workspace",
335
+ description: "Duplicate the dashboard, add objects, schedule the workflow, or wire another provider through Nango.",
336
+ status: "optional",
337
+ href: "/data-model",
338
+ cta: "Customize",
339
+ });
340
+
341
+ const completedCount = steps.filter((step) => step.status === "complete").length;
342
+ const requiredCount = steps.filter((step) => step.status !== "optional").length;
343
+ const complete = completedCount >= requiredCount;
344
+ const nextStep = steps.find((step) => step.status === "pending" || step.status === "blocked");
345
+
346
+ return {
347
+ kind: ACTIVATION_KIND,
348
+ version: ACTIVATION_VERSION,
349
+ template: TEMPLATE_PROJECT_MANAGEMENT,
350
+ templateName: provenance.templateName || "Project Management Workspace",
351
+ headline: complete
352
+ ? "Your Project Management workspace is live."
353
+ : `You're ${requiredCount - completedCount} step${requiredCount - completedCount === 1 ? "" : "s"} from your first task dashboard.`,
354
+ subheadline: complete
355
+ ? "Inspect runs, customize objects, or wire another provider."
356
+ : nextStep
357
+ ? `Next: ${nextStep.label}.`
358
+ : "Open the dashboard to see your latest tasks.",
359
+ complete,
360
+ completedCount,
361
+ totalCount: requiredCount,
362
+ nextStepId: nextStep ? nextStep.id : null,
363
+ steps,
364
+ };
365
+ }
366
+
367
+ // ───────────────────────────────────────────────────────────────────────────
368
+ // Blank governed workspace — adapter
369
+ // ───────────────────────────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * Generic activation checklist for blank governed workspaces. Drives the
373
+ * user toward the first object → first dashboard → first widget → first
374
+ * workflow → first run, using only routes that already exist.
375
+ */
376
+ function deriveBlankWorkspaceActivationState({ workspaceConfig, workspaceSourceRecords, metadataGraph }) {
377
+ const provenance = deriveProvenance(workspaceConfig);
378
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
379
+ ? workspaceConfig.dataModel.objects
380
+ : [];
381
+
382
+ // Hidden helper objects don't count as "user-created".
383
+ const HIDDEN_OBJECT_IDS = new Set([
384
+ "workspace-helper-sandbox",
385
+ "nav-folders",
386
+ "helper-threads",
387
+ "sandbox-environments",
388
+ "workflow-api-registry",
389
+ "workspace-ui-cache",
390
+ ]);
391
+ const userObjects = objects.filter((o) => isPlainObject(o)
392
+ && safeString(o.id).trim()
393
+ && !HIDDEN_OBJECT_IDS.has(o.id));
394
+ const objectCreated = userObjects.length > 0;
395
+
396
+ const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
397
+ const dashboardCreated = dashboards.length > 0;
398
+ const widgetCount = dashboards.reduce((acc, dashboard) => {
399
+ const tabs = Array.isArray(dashboard?.tabs) ? dashboard.tabs : [];
400
+ for (const tab of tabs) {
401
+ const widgets = Array.isArray(tab?.widgets) ? tab.widgets : [];
402
+ acc += widgets.length;
403
+ }
404
+ return acc;
405
+ }, 0);
406
+ const widgetAdded = widgetCount > 0;
407
+
408
+ const workflowMatch = findWorkflowRow(workspaceConfig, () => true);
409
+ const workflowCreated = Boolean(workflowMatch?.row);
410
+ const workflowRun = deriveLatestRunStatus(workflowMatch?.row);
411
+
412
+ const steps = [
413
+ {
414
+ id: "create-object",
415
+ label: "Create your first object",
416
+ description: objectCreated
417
+ ? `${userObjects.length} object${userObjects.length === 1 ? "" : "s"} in your Data Model.`
418
+ : "Start by adding a custom object or connecting a data source in Management.",
419
+ status: objectCreated ? "complete" : "pending",
420
+ href: "/data-model",
421
+ cta: objectCreated ? "Open Data Model" : "Create object",
422
+ },
423
+ {
424
+ id: "create-dashboard",
425
+ label: "Create a dashboard",
426
+ description: dashboardCreated
427
+ ? `${dashboards.length} dashboard${dashboards.length === 1 ? "" : "s"} ready.`
428
+ : "Add a dashboard from the Builder to visualize your data.",
429
+ status: dashboardCreated ? "complete" : (objectCreated ? "pending" : "blocked"),
430
+ href: "/",
431
+ hint: dashboardCreated || objectCreated ? "" : "Add an object first so dashboards have data to bind to.",
432
+ cta: dashboardCreated ? "Open Builder" : "New dashboard",
433
+ },
434
+ {
435
+ id: "add-widget",
436
+ label: "Add a widget",
437
+ description: widgetAdded
438
+ ? `${widgetCount} widget${widgetCount === 1 ? "" : "s"} placed.`
439
+ : "Bind a chart or view widget to one of your objects.",
440
+ status: widgetAdded ? "complete" : (dashboardCreated ? "pending" : "blocked"),
441
+ href: "/",
442
+ hint: widgetAdded || dashboardCreated ? "" : "Add a dashboard first.",
443
+ cta: widgetAdded ? "Open Builder" : "Add widget",
444
+ },
445
+ {
446
+ id: "create-workflow",
447
+ label: "Create a workflow",
448
+ description: workflowCreated
449
+ ? "Sandbox workflow scaffolded."
450
+ : "Open Workflows to assemble your first automation.",
451
+ status: workflowCreated ? "complete" : "pending",
452
+ href: "/workflows",
453
+ cta: workflowCreated ? "Open Workflows" : "New workflow",
454
+ },
455
+ {
456
+ id: "run-workflow",
457
+ label: "Run your workflow",
458
+ description: workflowRun.ok
459
+ ? "Workflow has run successfully at least once."
460
+ : workflowRun.status === "failed"
461
+ ? "Last run failed — open the trace and fix the failing node."
462
+ : "Click Test inside the workflow to do a first run.",
463
+ status: workflowRun.ok ? "complete" : (workflowCreated ? "pending" : "blocked"),
464
+ href: "/workflows",
465
+ hint: workflowRun.ok || workflowCreated ? "" : "Create a workflow first.",
466
+ cta: workflowRun.ok ? "View runs" : "Open workflow",
467
+ },
468
+ ];
469
+
470
+ const completedCount = steps.filter((step) => step.status === "complete").length;
471
+ const requiredCount = steps.length;
472
+ const complete = completedCount >= requiredCount;
473
+ const nextStep = steps.find((step) => step.status === "pending" || step.status === "blocked");
474
+
475
+ return {
476
+ kind: ACTIVATION_KIND,
477
+ version: ACTIVATION_VERSION,
478
+ template: "blank",
479
+ templateName: provenance.templateName || "Governed Workspace",
480
+ headline: complete
481
+ ? "Your workspace is set up."
482
+ : "Get started with your governed workspace.",
483
+ subheadline: complete
484
+ ? "Add more objects, widgets, or workflows as your needs grow."
485
+ : nextStep
486
+ ? `Next: ${nextStep.label}.`
487
+ : "Pick the next step that fits what you want to build.",
488
+ complete,
489
+ completedCount,
490
+ totalCount: requiredCount,
491
+ nextStepId: nextStep ? nextStep.id : null,
492
+ steps,
493
+ // Silence unused-arg warnings — sidecar inputs are accepted by every
494
+ // adapter so callers can pass the same envelope through.
495
+ _sourceRecords: workspaceSourceRecords ? true : false,
496
+ _metadata: metadataGraph ? true : false,
497
+ };
498
+ }
499
+
500
+ // ───────────────────────────────────────────────────────────────────────────
501
+ // Public entry — template router
502
+ // ───────────────────────────────────────────────────────────────────────────
503
+
504
+ /**
505
+ * Resolve the activation state for the current workspace.
506
+ *
507
+ * Templates are routed by `workspaceConfig.provenance.template`. Unknown
508
+ * templates and workspaces without provenance fall back to the blank
509
+ * adapter so existing workspaces never lose their UX.
510
+ */
511
+ function deriveWorkspaceActivationState(input = {}) {
512
+ const safeInput = {
513
+ workspaceConfig: isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {},
514
+ workspaceSourceRecords: isPlainObject(input.workspaceSourceRecords) ? input.workspaceSourceRecords : {},
515
+ metadataGraph: isPlainObject(input.metadataGraph) ? input.metadataGraph : null,
516
+ };
517
+ const provenance = deriveProvenance(safeInput.workspaceConfig);
518
+ if (provenance.template === TEMPLATE_PROJECT_MANAGEMENT) {
519
+ return deriveProjectManagementActivationState(safeInput);
520
+ }
521
+ return deriveBlankWorkspaceActivationState(safeInput);
522
+ }
523
+
524
+ // ───────────────────────────────────────────────────────────────────────────
525
+ // Workspace State Lenses — generalize the activation derivation primitive
526
+ // ───────────────────────────────────────────────────────────────────────────
527
+ //
528
+ // The activation layer proved one idea: a delta in the workspace artifact is
529
+ // causal — it re-derives a typed, self-describing "what's next" state. A *lens*
530
+ // is the same primitive aimed at a different slice of the artifact. Every lens
531
+ // is a pure function over the same envelope and emits the same step shape the
532
+ // WorkspaceActivationPanel already renders, so new lenses cost no new UI.
533
+ //
534
+ // Lens output shape (sibling to the activation state):
535
+ //
536
+ // {
537
+ // kind: "growthub-workspace-lens-state-v1"
538
+ // lensId: string
539
+ // title: string
540
+ // headline: string
541
+ // subheadline: string
542
+ // complete: boolean
543
+ // completedCount / totalCount / nextStepId
544
+ // steps: [{ id, label, description, status, href, hint?, cta? }]
545
+ // }
546
+ //
547
+ // Invariants are inherited verbatim from the activation layer: pure derivation,
548
+ // no secrets (booleans/counts only), never throws on partial input, and every
549
+ // `href` routes into an existing workspace surface.
550
+
551
+ const LENS_STATE_KIND = "growthub-workspace-lens-state-v1";
552
+ const WORKSPACE_STATE_KIND = "growthub-workspace-state-v1";
553
+ const SWARM_PACKET_KIND = "growthub-swarm-condition-packet-v1";
554
+
555
+ /** Shared step scoring convention used by every lens. */
556
+ function scoreLensSteps(steps) {
557
+ const required = steps.filter((step) => step.status !== "optional");
558
+ const totalCount = required.length;
559
+ const completedCount = required.filter((step) => step.status === "complete").length;
560
+ const complete = completedCount >= totalCount;
561
+ const nextStep = steps.find((step) => step.status === "pending" || step.status === "blocked");
562
+ return { totalCount, completedCount, complete, nextStepId: nextStep ? nextStep.id : null };
563
+ }
564
+
565
+ /** Collect every sandbox-environment workflow row across the data model. */
566
+ function collectSandboxRows(workspaceConfig) {
567
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
568
+ ? workspaceConfig.dataModel.objects
569
+ : [];
570
+ const rows = [];
571
+ for (const object of objects) {
572
+ if (!isPlainObject(object) || object.objectType !== "sandbox-environment") continue;
573
+ for (const row of listObjectRows(object)) {
574
+ if (isPlainObject(row)) rows.push(row);
575
+ }
576
+ }
577
+ return rows;
578
+ }
579
+
580
+ /**
581
+ * Persistence & runtime-durability lens (roadmap Item 2 — derivation).
582
+ *
583
+ * Reads the resolved persistence mode/adapter (surfaced via
584
+ * metadataGraph.runtime when the server provides it) and whether durable run
585
+ * evidence exists, then nudges the workspace toward a store where workflow
586
+ * runs, source records, and agent-swarm evidence survive restart/redeploy.
587
+ *
588
+ * The persistence adapters themselves already ship
589
+ * (lib/adapters/persistence/{postgres,qstash-kv,provider-managed}.js); this
590
+ * lens is the self-describing activation pathway over them.
591
+ */
592
+ function derivePersistenceLensState(input = {}) {
593
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
594
+ const graph = isPlainObject(input?.metadataGraph) ? input.metadataGraph : {};
595
+ const rt = isPlainObject(graph?.runtime) ? graph.runtime : {};
596
+
597
+ const persistenceMode = safeString(rt.persistenceMode).trim() || null; // filesystem|read-only|database
598
+ const persistenceAdapter = safeString(rt.persistenceAdapter).trim() || null;
599
+ const allowFsWrite = rt.allowFsWrite === true;
600
+
601
+ // Run evidence lives in sandbox-environment object ROWS (canonical shape —
602
+ // see findWorkflowRow / the project-management seed), not on the object.
603
+ const sandboxRows = collectSandboxRows(cfg);
604
+ const hasRunEvidence = sandboxRows.some(
605
+ (row) => safeString(row.lastResponse).trim() !== "" || safeString(row.lastRunId).trim() !== "",
606
+ );
607
+
608
+ const isDurableDatabase = persistenceMode === "database" && persistenceAdapter !== null;
609
+ const isDurableFilesystem = persistenceMode === "filesystem" && allowFsWrite;
610
+ const isDurable = isDurableDatabase || isDurableFilesystem;
611
+ const isReadOnly = persistenceMode === "read-only"
612
+ || (persistenceMode === "filesystem" && !allowFsWrite);
613
+ const modeResolved = persistenceMode !== null;
614
+
615
+ const steps = [];
616
+
617
+ steps.push({
618
+ id: "choose-persistence",
619
+ label: "Choose a persistence mode",
620
+ description: "Resolve where the workspace stores run state, source records, and agent-swarm evidence.",
621
+ status: modeResolved ? "complete" : "pending",
622
+ href: "/settings",
623
+ cta: modeResolved ? "Review persistence" : "Open persistence settings",
624
+ });
625
+
626
+ if (isDurable) {
627
+ steps.push({
628
+ id: "enable-durable-store",
629
+ label: "Enable a durable store",
630
+ description: "Runs and agent-swarm evidence are written to a persistent backing store and survive redeploy.",
631
+ status: "complete",
632
+ href: "/settings",
633
+ hint: isDurableDatabase ? `Durable database adapter active (${persistenceAdapter}).` : "Filesystem writes enabled.",
634
+ cta: "Review store",
635
+ });
636
+ } else if (!modeResolved) {
637
+ steps.push({
638
+ id: "enable-durable-store",
639
+ label: "Enable a durable store",
640
+ description: "Configure a database adapter or enable filesystem writes so run data persists across restarts.",
641
+ status: "blocked",
642
+ href: "/settings",
643
+ hint: "Resolve the persistence mode first — the workspace can't persist runs until a store is chosen.",
644
+ cta: "Configure persistence",
645
+ });
646
+ } else {
647
+ steps.push({
648
+ id: "enable-durable-store",
649
+ label: "Enable a durable store",
650
+ description: "Persistence is read-only: PATCH returns 409 and run data is held only in-process — it won't survive redeploy.",
651
+ status: "blocked",
652
+ href: "/settings",
653
+ hint: `Mode "${persistenceMode}" is read-only. Switch to "database" or set WORKSPACE_CONFIG_ALLOW_FS_WRITE for filesystem.`,
654
+ cta: "Switch to a durable store",
655
+ });
656
+ }
657
+
658
+ if (hasRunEvidence && isDurable) {
659
+ steps.push({
660
+ id: "verify-run-durability",
661
+ label: "Verify run durability",
662
+ description: "Workflow runs are recorded and the store is durable — evidence will survive redeploy.",
663
+ status: "complete",
664
+ href: "/workflows",
665
+ cta: "Review runs",
666
+ });
667
+ } else if (hasRunEvidence && !isDurable) {
668
+ steps.push({
669
+ id: "verify-run-durability",
670
+ label: "Verify run durability",
671
+ description: "Workflow runs exist but the store is read-only. This evidence is ephemeral and will be lost on redeploy.",
672
+ status: "blocked",
673
+ href: "/workflows",
674
+ hint: "Enable a durable store to preserve the run records you've already produced.",
675
+ cta: "Review affected runs",
676
+ });
677
+ } else {
678
+ steps.push({
679
+ id: "verify-run-durability",
680
+ label: "Verify run durability",
681
+ description: "After workflows run, this confirms run evidence is persisted to the durable store.",
682
+ status: "optional",
683
+ href: "/workflows",
684
+ cta: "Open workflows",
685
+ });
686
+ }
687
+
688
+ const { totalCount, completedCount, complete, nextStepId } = scoreLensSteps(steps);
689
+
690
+ let headline;
691
+ let subheadline;
692
+ if (complete && steps.every((step) => step.status !== "blocked")) {
693
+ headline = "Workspace persistence is durable.";
694
+ subheadline = "Run evidence and source records will survive redeploy.";
695
+ } else if (!modeResolved) {
696
+ headline = "Persistence mode is not configured.";
697
+ subheadline = "Next: choose a persistence mode in settings.";
698
+ } else if (isReadOnly && hasRunEvidence) {
699
+ headline = "Store is read-only — run evidence is ephemeral.";
700
+ subheadline = "Next: switch to a durable store to preserve existing runs.";
701
+ } else if (isReadOnly) {
702
+ headline = "Store is read-only — runs won't survive redeploy.";
703
+ subheadline = "Next: enable a durable adapter or allow filesystem writes.";
704
+ } else {
705
+ headline = "Durable store active.";
706
+ subheadline = "Run a workflow to confirm end-to-end durability.";
707
+ }
708
+
709
+ return {
710
+ kind: LENS_STATE_KIND,
711
+ lensId: "persistence",
712
+ title: "Runtime persistence",
713
+ headline,
714
+ subheadline,
715
+ complete,
716
+ completedCount,
717
+ totalCount,
718
+ nextStepId,
719
+ steps,
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Orchestration-health / observability lens (roadmap Item 3 — derivation).
725
+ *
726
+ * Rolls up run-state deltas across every sandbox-environment workflow row into
727
+ * legible counts (healthy / failing / never-run) and points at the next action
728
+ * — launch an idle workflow or fix a failing one. This is the surface that
729
+ * makes an agent swarm's work steerable rather than opaque.
730
+ */
731
+ function deriveObservabilityLensState(input = {}) {
732
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
733
+ const graph = isPlainObject(input?.metadataGraph) ? input.metadataGraph : {};
734
+
735
+ const rows = collectSandboxRows(cfg);
736
+ let healthy = 0;
737
+ let failing = 0;
738
+ let neverRun = 0;
739
+ for (const row of rows) {
740
+ const { status } = deriveLatestRunStatus(row);
741
+ if (status === "ok") healthy += 1;
742
+ else if (status === "failed") failing += 1;
743
+ else neverRun += 1;
744
+ }
745
+ const workflowsTotal = rows.length;
746
+ const agents = Array.isArray(graph?.runtime?.agents) ? graph.runtime.agents.length : 0;
747
+ const rollup = { workflowsTotal, healthy, failing, neverRun, agents };
748
+
749
+ const steps = [
750
+ {
751
+ id: "have-workflow",
752
+ label: "Register a workflow",
753
+ description: "Add at least one sandbox-environment workflow to begin orchestration.",
754
+ status: workflowsTotal > 0 ? "complete" : "pending",
755
+ href: "/workflows",
756
+ cta: workflowsTotal > 0 ? "Open Workflows" : "New workflow",
757
+ },
758
+ {
759
+ id: "first-healthy-run",
760
+ label: "Land a healthy run",
761
+ description: "At least one workflow must complete successfully.",
762
+ status: healthy > 0 ? "complete" : (workflowsTotal === 0 ? "blocked" : "pending"),
763
+ href: "/workflows",
764
+ hint: workflowsTotal === 0 ? "Register a workflow first." : "",
765
+ cta: healthy > 0 ? "View runs" : "Run a workflow",
766
+ },
767
+ {
768
+ id: "resolve-failures",
769
+ label: "Resolve failing runs",
770
+ description: "Every failing workflow should be fixed or disabled.",
771
+ status: workflowsTotal === 0 ? "pending" : (failing > 0 ? "blocked" : "complete"),
772
+ href: "/workflows",
773
+ hint: failing > 0 ? `${failing} workflow${failing === 1 ? " is" : "s are"} failing — open the run trace.` : "",
774
+ cta: failing > 0 ? "Open failing runs" : "Review",
775
+ },
776
+ {
777
+ id: "launch-next",
778
+ label: "Launch idle workflows",
779
+ description: "Kick off any workflow that has never run.",
780
+ status: neverRun > 0 ? "pending" : (workflowsTotal > 0 ? "complete" : "optional"),
781
+ href: "/workflows",
782
+ hint: neverRun > 0 ? `${neverRun} workflow${neverRun === 1 ? " has" : "s have"} never run.` : "",
783
+ cta: neverRun > 0 ? "Launch workflow" : "Review",
784
+ },
785
+ ];
786
+
787
+ // Drop empty hints so the rendered panel stays clean.
788
+ for (const step of steps) {
789
+ if (!step.hint) delete step.hint;
790
+ }
791
+
792
+ const { totalCount, completedCount, complete, nextStepId } = scoreLensSteps(steps);
793
+
794
+ let headline;
795
+ let subheadline;
796
+ if (workflowsTotal === 0) {
797
+ headline = "No workflows registered yet.";
798
+ subheadline = "Add a workflow to start tracking orchestration health.";
799
+ } else {
800
+ const parts = [];
801
+ if (healthy > 0) parts.push(`${healthy} healthy`);
802
+ if (failing > 0) parts.push(`${failing} failing`);
803
+ if (neverRun > 0) parts.push(`${neverRun} never run`);
804
+ headline = `${workflowsTotal} workflow${workflowsTotal === 1 ? "" : "s"}: ${parts.join(", ")}.`;
805
+ if (failing > 0) {
806
+ subheadline = `Next: fix ${failing} failing workflow${failing === 1 ? "" : "s"}.`;
807
+ } else if (neverRun > 0) {
808
+ subheadline = `Next: launch ${neverRun} idle workflow${neverRun === 1 ? "" : "s"}.`;
809
+ } else {
810
+ subheadline = "All workflows are healthy.";
811
+ }
812
+ }
813
+
814
+ return {
815
+ kind: LENS_STATE_KIND,
816
+ lensId: "observability",
817
+ title: "Orchestration health",
818
+ headline,
819
+ subheadline,
820
+ complete,
821
+ completedCount,
822
+ totalCount,
823
+ nextStepId,
824
+ steps,
825
+ rollup,
826
+ };
827
+ }
828
+
829
+ // ───────────────────────────────────────────────────────────────────────────
830
+ // Lens registry + composed workspace state (roadmap Item 1 — the keystone)
831
+ // ───────────────────────────────────────────────────────────────────────────
832
+
833
+ /**
834
+ * Shared runtime-durability read used by the deploy + app-build lenses. Mirrors
835
+ * the persistence lens truth table but as a small reusable descriptor.
836
+ */
837
+ function deriveRuntimeDurability(metadataGraph) {
838
+ const rt = isPlainObject(metadataGraph?.runtime) ? metadataGraph.runtime : {};
839
+ const mode = safeString(rt.persistenceMode).trim();
840
+ const adapter = safeString(rt.persistenceAdapter).trim();
841
+ const allowFs = rt.allowFsWrite === true;
842
+ return {
843
+ mode,
844
+ adapter,
845
+ allowFs,
846
+ resolved: mode !== "",
847
+ durable: (mode === "database" && adapter !== "") || (mode === "filesystem" && allowFs),
848
+ readOnly: mode === "read-only" || (mode === "filesystem" && !allowFs),
849
+ };
850
+ }
851
+
852
+ /**
853
+ * Deploy-readiness lens (roadmap Item 5 — derivation).
854
+ *
855
+ * Pure derivation over deploy-check-shaped runtime signals
856
+ * (`metadataGraph.runtime.deploy`) + persistence durability + provenance. It
857
+ * never shells out and never fetches; it reads whatever safe deploy signal the
858
+ * runtime already exposes and otherwise emits a pending step into settings.
859
+ */
860
+ function deriveDeployLensState(input = {}) {
861
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
862
+ const graph = isPlainObject(input?.metadataGraph) ? input.metadataGraph : {};
863
+ const rt = isPlainObject(graph?.runtime) ? graph.runtime : {};
864
+ const deploy = isPlainObject(rt.deploy) ? rt.deploy : {};
865
+ const provenance = deriveProvenance(cfg);
866
+ const dur = deriveRuntimeDurability(graph);
867
+
868
+ const target = safeString(deploy.target || rt.deployTarget).trim();
869
+ const surfaceResolved = Boolean(target) || provenance.hasProvenance;
870
+ const hasEnvSignal = deploy.envReady !== undefined || Array.isArray(deploy.envVarsNeeded);
871
+ const envReady = deploy.envReady === true
872
+ || (Array.isArray(deploy.envVarsNeeded) && deploy.envVarsNeeded.length === 0);
873
+ const checkPassed = deploy.checkPassed === true;
874
+ const hasCheckSignal = deploy.checkPassed !== undefined;
875
+ const deployed = deploy.deployed === true;
876
+
877
+ const steps = [
878
+ {
879
+ id: "resolve-app-surface",
880
+ label: "Resolve the app surface",
881
+ description: surfaceResolved
882
+ ? `Deploy surface resolved${target ? ` (target: ${target})` : ""}.`
883
+ : "Identify the app surface and deploy target for this workspace.",
884
+ status: surfaceResolved ? "complete" : "pending",
885
+ href: "/settings",
886
+ cta: surfaceResolved ? "Review" : "Open settings",
887
+ },
888
+ {
889
+ id: "verify-env",
890
+ label: "Verify required env vars",
891
+ description: envReady
892
+ ? "All required environment variables are present."
893
+ : "Confirm the runtime has every required environment variable before deploy.",
894
+ status: envReady ? "complete" : "pending",
895
+ href: "/settings",
896
+ hint: hasEnvSignal && !envReady && Array.isArray(deploy.envVarsNeeded)
897
+ ? `Missing ${deploy.envVarsNeeded.length} required env var${deploy.envVarsNeeded.length === 1 ? "" : "s"}.`
898
+ : "",
899
+ cta: "Open settings",
900
+ },
901
+ {
902
+ id: "verify-persistence",
903
+ label: "Verify durable persistence",
904
+ description: dur.durable
905
+ ? "Persistence is durable — deployed runs will survive redeploy."
906
+ : "A deploy needs durable persistence so run state isn't lost on redeploy.",
907
+ status: dur.durable ? "complete" : (dur.readOnly ? "blocked" : "pending"),
908
+ href: "/settings",
909
+ hint: dur.durable
910
+ ? ""
911
+ : (dur.readOnly
912
+ ? "Persistence is read-only — switch to a durable store before deploying."
913
+ : "Resolve a persistence mode first."),
914
+ cta: "Open persistence",
915
+ },
916
+ {
917
+ id: "run-deploy-check",
918
+ label: "Run the deploy check",
919
+ description: checkPassed
920
+ ? "Deploy check passed."
921
+ : "Run the deploy check and resolve any missing steps before shipping.",
922
+ status: checkPassed ? "complete" : (dur.durable ? "pending" : "blocked"),
923
+ href: "/settings",
924
+ hint: checkPassed
925
+ ? ""
926
+ : (dur.durable
927
+ ? (hasCheckSignal ? "Deploy check reported missing steps." : "Run the deploy check to surface missing steps.")
928
+ : "Make persistence durable first."),
929
+ cta: "Open settings",
930
+ },
931
+ {
932
+ id: "deploy-or-review",
933
+ label: deployed ? "Review deployment" : "Deploy the app",
934
+ description: deployed
935
+ ? "The app is deployed — review the live deployment."
936
+ : "Once checks pass, deploy the app to your target runtime.",
937
+ status: deployed ? "complete" : "optional",
938
+ href: "/settings",
939
+ cta: deployed ? "Review" : "Deploy",
940
+ },
941
+ ];
942
+ for (const step of steps) {
943
+ if (!step.hint) delete step.hint;
944
+ }
945
+
946
+ const { totalCount, completedCount, complete, nextStepId } = scoreLensSteps(steps);
947
+ const headline = complete
948
+ ? "This workspace is deploy-ready."
949
+ : (dur.readOnly
950
+ ? "Deploy blocked — persistence is read-only."
951
+ : "Get this workspace deploy-ready.");
952
+ const nextStep = steps.find((s) => s.id === nextStepId);
953
+ const subheadline = complete
954
+ ? "Review the live deployment or ship an update."
955
+ : (nextStep ? `Next: ${nextStep.label.toLowerCase()}.` : "Resolve the remaining deploy steps.");
956
+
957
+ return {
958
+ kind: LENS_STATE_KIND,
959
+ lensId: "deploy",
960
+ title: "Deploy readiness",
961
+ headline,
962
+ subheadline,
963
+ complete,
964
+ completedCount,
965
+ totalCount,
966
+ nextStepId,
967
+ steps,
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Task-management lens (roadmap Item 6 — derivation).
973
+ *
974
+ * Pure derivation over governed Data Model rows. Detects a governed task object
975
+ * (objectType "task" or a task-named custom object) and/or source-backed task
976
+ * rows (a "task"-named data-source, e.g. the project-management Project Task
977
+ * Source). Never creates rows and never invents a schema.
978
+ */
979
+ function deriveTaskLensState(input = {}) {
980
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
981
+ const objects = Array.isArray(cfg?.dataModel?.objects) ? cfg.dataModel.objects : [];
982
+ const SYSTEM_TYPES = new Set(["api-registry", "sandbox-environment", "data-source"]);
983
+ const nameBlob = (o) => `${safeString(o.id)} ${safeString(o.name)} ${safeString(o.label)}`.toLowerCase();
984
+
985
+ const taskObject = objects.find((o) => isPlainObject(o) && safeString(o.objectType) === "task")
986
+ || objects.find((o) => isPlainObject(o)
987
+ && !SYSTEM_TYPES.has(safeString(o.objectType))
988
+ && /\btask/.test(nameBlob(o)));
989
+ const sourceTaskObject = objects.find((o) => isPlainObject(o)
990
+ && safeString(o.objectType) === "data-source"
991
+ && /\btask/.test(nameBlob(o)));
992
+
993
+ const hasGoverned = Boolean(taskObject);
994
+ const hasSourceBacked = Boolean(sourceTaskObject);
995
+ const taskRows = taskObject ? listObjectRows(taskObject) : [];
996
+ const sourceRows = sourceTaskObject ? listObjectRows(sourceTaskObject) : [];
997
+ const rowsPresent = taskRows.length > 0 || sourceRows.length > 0;
998
+ const ownersAssigned = taskRows.some((r) => isPlainObject(r)
999
+ && safeString(r.owner || r.assignee || r.Assignee || r.status || r.Status).trim() !== "");
1000
+ const blockedTasks = taskRows.some((r) => isPlainObject(r) && /block/i.test(safeString(r.status || r.Status)));
1001
+
1002
+ const taskBoundToView = (Array.isArray(cfg?.dashboards) ? cfg.dashboards : []).some((d) => {
1003
+ const tabs = Array.isArray(d?.tabs) ? d.tabs : [];
1004
+ return tabs.some((t) => (Array.isArray(t?.widgets) ? t.widgets : []).some((w) => {
1005
+ const binding = isPlainObject(w?.config?.binding) ? w.config.binding : {};
1006
+ return taskObject && safeString(binding.objectId).trim() === safeString(taskObject.id).trim();
1007
+ }));
1008
+ });
1009
+
1010
+ const steps = [
1011
+ {
1012
+ id: "create-task-object",
1013
+ label: "Create or connect a task object",
1014
+ description: hasGoverned
1015
+ ? "A governed task object exists in your Data Model."
1016
+ : (hasSourceBacked
1017
+ ? "Source-backed task rows are present — model a governed task object to manage them."
1018
+ : "Add a governed task object (or connect a task source) in the Data Model."),
1019
+ status: hasGoverned ? "complete" : "pending",
1020
+ href: "/data-model",
1021
+ cta: hasGoverned ? "Open Data Model" : "Create task object",
1022
+ },
1023
+ {
1024
+ id: "add-task-rows",
1025
+ label: "Add active tasks",
1026
+ description: rowsPresent
1027
+ ? "Task rows are present."
1028
+ : "Add task rows (or refresh the task source) so there's work to manage.",
1029
+ status: rowsPresent ? "complete" : ((hasGoverned || hasSourceBacked) ? "pending" : "blocked"),
1030
+ href: "/data-model",
1031
+ hint: rowsPresent || hasGoverned || hasSourceBacked ? "" : "Create a task object first.",
1032
+ cta: rowsPresent ? "Review tasks" : "Add tasks",
1033
+ },
1034
+ {
1035
+ id: "assign-owners-status",
1036
+ label: "Assign owners and status",
1037
+ description: ownersAssigned
1038
+ ? "Tasks carry owner/status values."
1039
+ : "Set an owner and status on tasks so the swarm and humans can coordinate.",
1040
+ status: ownersAssigned ? "complete" : (rowsPresent ? "pending" : "blocked"),
1041
+ href: "/data-model",
1042
+ hint: ownersAssigned || rowsPresent ? "" : "Add task rows first.",
1043
+ cta: "Open Data Model",
1044
+ },
1045
+ {
1046
+ id: "resolve-blocked-tasks",
1047
+ label: "Resolve blocked tasks",
1048
+ description: blockedTasks
1049
+ ? "Some tasks are marked blocked — clear them."
1050
+ : "No blocked tasks. This stays quiet until a task is blocked.",
1051
+ status: blockedTasks ? "pending" : "optional",
1052
+ href: "/data-model",
1053
+ cta: blockedTasks ? "Review blocked" : "Review",
1054
+ },
1055
+ {
1056
+ id: "bind-task-view",
1057
+ label: "Bind a task view",
1058
+ description: taskBoundToView
1059
+ ? "A dashboard view is bound to the task object."
1060
+ : "Bind the task object to a View widget so tasks are visible on a dashboard.",
1061
+ status: taskBoundToView ? "complete" : (hasGoverned ? "pending" : "blocked"),
1062
+ href: "/",
1063
+ hint: taskBoundToView || hasGoverned ? "" : "Create a governed task object first.",
1064
+ cta: taskBoundToView ? "Open dashboard" : "Bind view",
1065
+ },
1066
+ ];
1067
+ for (const step of steps) {
1068
+ if (!step.hint) delete step.hint;
1069
+ }
1070
+
1071
+ const { totalCount, completedCount, complete, nextStepId } = scoreLensSteps(steps);
1072
+ const headline = complete
1073
+ ? "Task management is set up."
1074
+ : (hasGoverned || hasSourceBacked ? "Finish wiring task management." : "Set up task management.");
1075
+ const nextStep = steps.find((s) => s.id === nextStepId);
1076
+ const subheadline = complete
1077
+ ? "Humans and the swarm manage tasks on the same surface."
1078
+ : (nextStep ? `Next: ${nextStep.label.toLowerCase()}.` : "Tasks are ready to manage.");
1079
+
1080
+ return {
1081
+ kind: LENS_STATE_KIND,
1082
+ lensId: "tasks",
1083
+ title: "Task management",
1084
+ headline,
1085
+ subheadline,
1086
+ complete,
1087
+ completedCount,
1088
+ totalCount,
1089
+ nextStepId,
1090
+ steps,
1091
+ };
1092
+ }
1093
+
1094
+ /**
1095
+ * Application-buildout lens (roadmap Item 7 — derivation).
1096
+ *
1097
+ * A readiness lens (it scaffolds nothing) that activates after the primary
1098
+ * activation loop has progress and points from "I have pieces" toward "I have
1099
+ * a deployable application": modeled object → dashboard → workflow → run
1100
+ * evidence → durable persistence → deploy readiness → packaged surface.
1101
+ */
1102
+ function deriveAppBuildLensState(input = {}) {
1103
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
1104
+ const objects = Array.isArray(cfg?.dataModel?.objects) ? cfg.dataModel.objects : [];
1105
+ const HIDDEN = new Set([
1106
+ "workspace-helper-sandbox", "nav-folders", "helper-threads",
1107
+ "sandbox-environments", "workflow-api-registry", "workspace-ui-cache",
1108
+ ]);
1109
+ const userObjects = objects.filter((o) => isPlainObject(o)
1110
+ && safeString(o.id).trim()
1111
+ && !HIDDEN.has(o.id)
1112
+ && o.objectType !== "api-registry"
1113
+ && o.objectType !== "sandbox-environment");
1114
+ const dashboards = Array.isArray(cfg?.dashboards) ? cfg.dashboards : [];
1115
+ const widgetCount = dashboards.reduce((acc, d) => acc
1116
+ + (Array.isArray(d?.tabs) ? d.tabs : []).reduce((a, t) => a + (Array.isArray(t?.widgets) ? t.widgets : []).length, 0), 0);
1117
+ const sandboxRows = collectSandboxRows(cfg);
1118
+ const workflowCreated = sandboxRows.length > 0;
1119
+ const healthyRun = sandboxRows.some((r) => deriveLatestRunStatus(r).ok);
1120
+
1121
+ const dur = deriveRuntimeDurability(input?.metadataGraph);
1122
+ const deployReady = deriveDeployLensState(input).complete;
1123
+
1124
+ const steps = [
1125
+ {
1126
+ id: "model-object",
1127
+ label: "Model a business object",
1128
+ description: userObjects.length > 0 ? `${userObjects.length} object${userObjects.length === 1 ? "" : "s"} modeled.` : "Model the core business object your app revolves around.",
1129
+ status: userObjects.length > 0 ? "complete" : "pending",
1130
+ href: "/data-model",
1131
+ cta: userObjects.length > 0 ? "Open Data Model" : "Model object",
1132
+ },
1133
+ {
1134
+ id: "build-dashboard",
1135
+ label: "Build a dashboard surface",
1136
+ description: (dashboards.length > 0 && widgetCount > 0) ? "A dashboard with widgets is in place." : "Build a dashboard with at least one bound widget.",
1137
+ status: (dashboards.length > 0 && widgetCount > 0) ? "complete" : (userObjects.length > 0 ? "pending" : "blocked"),
1138
+ href: "/",
1139
+ hint: (dashboards.length > 0 && widgetCount > 0) || userObjects.length > 0 ? "" : "Model an object first.",
1140
+ cta: "Open Builder",
1141
+ },
1142
+ {
1143
+ id: "add-workflow",
1144
+ label: "Add a workflow runtime",
1145
+ description: workflowCreated ? "A workflow runtime is registered." : "Add a workflow so the app can act, not just display.",
1146
+ status: workflowCreated ? "complete" : "pending",
1147
+ href: "/workflows",
1148
+ cta: workflowCreated ? "Open Workflows" : "New workflow",
1149
+ },
1150
+ {
1151
+ id: "land-run",
1152
+ label: "Land run evidence",
1153
+ description: healthyRun ? "A workflow has run successfully." : "Run the workflow at least once to produce evidence.",
1154
+ status: healthyRun ? "complete" : (workflowCreated ? "pending" : "blocked"),
1155
+ href: "/workflows",
1156
+ hint: healthyRun || workflowCreated ? "" : "Add a workflow first.",
1157
+ cta: healthyRun ? "View runs" : "Run workflow",
1158
+ },
1159
+ {
1160
+ id: "durable-persistence",
1161
+ label: "Verify durable persistence",
1162
+ description: dur.durable ? "Persistence is durable." : "Make persistence durable so the app keeps its state.",
1163
+ status: dur.durable ? "complete" : (dur.readOnly ? "blocked" : "pending"),
1164
+ href: "/settings",
1165
+ hint: dur.durable ? "" : (dur.readOnly ? "Persistence is read-only — switch to a durable store." : "Resolve a persistence mode."),
1166
+ cta: "Open persistence",
1167
+ },
1168
+ {
1169
+ id: "deploy-ready",
1170
+ label: "Verify deploy readiness",
1171
+ description: deployReady ? "The app is deploy-ready." : "Clear the deploy-readiness checks.",
1172
+ status: deployReady ? "complete" : "pending",
1173
+ href: "/settings",
1174
+ cta: "Open deploy",
1175
+ },
1176
+ {
1177
+ id: "package-surface",
1178
+ label: "Package the app surface",
1179
+ description: "Export or package the workspace as a distributable application surface.",
1180
+ status: (userObjects.length > 0 && dashboards.length > 0 && workflowCreated && healthyRun && dur.durable && deployReady) ? "pending" : "optional",
1181
+ href: "/settings",
1182
+ cta: "Package app",
1183
+ },
1184
+ ];
1185
+ for (const step of steps) {
1186
+ if (!step.hint) delete step.hint;
1187
+ }
1188
+
1189
+ const { totalCount, completedCount, complete, nextStepId } = scoreLensSteps(steps);
1190
+ const started = userObjects.length > 0 || dashboards.length > 0 || workflowCreated;
1191
+ const headline = complete
1192
+ ? "This workspace is a deployable application."
1193
+ : (started ? "Build this workspace into a full application." : "Start building a full application.");
1194
+ const nextStep = steps.find((s) => s.id === nextStepId);
1195
+ const subheadline = complete
1196
+ ? "Package or export the app surface."
1197
+ : (nextStep ? `Next: ${nextStep.label.toLowerCase()}.` : "Keep assembling the application.");
1198
+
1199
+ return {
1200
+ kind: LENS_STATE_KIND,
1201
+ lensId: "app-build",
1202
+ title: "Application buildout",
1203
+ headline,
1204
+ subheadline,
1205
+ complete,
1206
+ completedCount,
1207
+ totalCount,
1208
+ nextStepId,
1209
+ steps,
1210
+ };
1211
+ }
1212
+
1213
+ /**
1214
+ * The lens registry. The activation deriver is the `primary` lens (it keeps
1215
+ * its own v1 state kind for backwards compatibility); every other entry is a
1216
+ * secondary lens that plugs into the same panel and the same swarm packet.
1217
+ * Adding a roadmap item is "register a deriver" — no new surface.
1218
+ *
1219
+ * NB: a Fleet / multi-app lens (roadmap Item 4) is intentionally NOT registered
1220
+ * — the exported workspace runtime exposes no in-artifact multi-app surface
1221
+ * registry to derive from. See docs/ROADMAP_IMPACT_ITEMS_V1.md (it stays staged
1222
+ * until a runtime surface-metadata source exists).
1223
+ */
1224
+ const WORKSPACE_LENS_REGISTRY = [
1225
+ { id: "activation", title: "Activation", primary: true, derive: deriveWorkspaceActivationState },
1226
+ { id: "persistence", title: "Runtime persistence", primary: false, derive: derivePersistenceLensState },
1227
+ { id: "observability", title: "Orchestration health", primary: false, derive: deriveObservabilityLensState },
1228
+ { id: "deploy", title: "Deploy readiness", primary: false, derive: deriveDeployLensState },
1229
+ { id: "tasks", title: "Task management", primary: false, derive: deriveTaskLensState },
1230
+ { id: "app-build", title: "Application buildout", primary: false, derive: deriveAppBuildLensState },
1231
+ ];
1232
+
1233
+ function getLensEntry(lensId) {
1234
+ return WORKSPACE_LENS_REGISTRY.find((entry) => entry.id === lensId) || null;
1235
+ }
1236
+
1237
+ /**
1238
+ * Compose every registered lens into a single workspace state and resolve the
1239
+ * one highest-value next action across the whole workspace: prefer the primary
1240
+ * activation step, then fall back to the first incomplete secondary lens.
1241
+ */
1242
+ function deriveWorkspaceState(input = {}) {
1243
+ const safeInput = {
1244
+ workspaceConfig: isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {},
1245
+ workspaceSourceRecords: isPlainObject(input.workspaceSourceRecords) ? input.workspaceSourceRecords : {},
1246
+ metadataGraph: isPlainObject(input.metadataGraph) ? input.metadataGraph : null,
1247
+ };
1248
+
1249
+ const primaryEntry = WORKSPACE_LENS_REGISTRY.find((entry) => entry.primary);
1250
+ const primary = primaryEntry.derive(safeInput);
1251
+ const lenses = {};
1252
+ for (const entry of WORKSPACE_LENS_REGISTRY) {
1253
+ if (entry.primary) continue;
1254
+ lenses[entry.id] = entry.derive(safeInput);
1255
+ }
1256
+
1257
+ const stepFromState = (lensId, state) => {
1258
+ if (!state || !state.nextStepId) return null;
1259
+ const step = (state.steps || []).find((s) => s.id === state.nextStepId);
1260
+ if (!step) return null;
1261
+ return { lensId, stepId: step.id, label: step.label, status: step.status, href: step.href || "/" };
1262
+ };
1263
+
1264
+ let nextAction = null;
1265
+ if (!primary.complete) nextAction = stepFromState("activation", primary);
1266
+ if (!nextAction) {
1267
+ for (const entry of WORKSPACE_LENS_REGISTRY) {
1268
+ if (entry.primary) continue;
1269
+ const state = lenses[entry.id];
1270
+ if (state && !state.complete) {
1271
+ nextAction = stepFromState(entry.id, state);
1272
+ if (nextAction) break;
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ const complete = primary.complete
1278
+ && WORKSPACE_LENS_REGISTRY.every((entry) => entry.primary || lenses[entry.id].complete);
1279
+
1280
+ return {
1281
+ kind: WORKSPACE_STATE_KIND,
1282
+ version: 1,
1283
+ primary,
1284
+ lenses,
1285
+ nextAction,
1286
+ complete,
1287
+ };
1288
+ }
1289
+
1290
+ // ───────────────────────────────────────────────────────────────────────────
1291
+ // Swarm-assignable condition packet (roadmap Item 8)
1292
+ // ───────────────────────────────────────────────────────────────────────────
1293
+
1294
+ /** Derive the safe tool surface available to an agent operating this workspace. */
1295
+ function deriveAvailableTools(workspaceConfig) {
1296
+ const tools = [
1297
+ "workspace UI (same surfaces a human uses)",
1298
+ "PATCH /api/workspace (dashboards | widgetTypes | canvas | dataModel)",
1299
+ ];
1300
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
1301
+ ? workspaceConfig.dataModel.objects
1302
+ : [];
1303
+ const hasRegistry = objects.some((o) => isPlainObject(o) && o.objectType === "api-registry");
1304
+ const hasSandbox = objects.some((o) => isPlainObject(o) && o.objectType === "sandbox-environment");
1305
+ if (hasRegistry) tools.push("Nango proxy (/api/workspace/integrations/nango/proxy)");
1306
+ if (hasSandbox) tools.push("sandbox-run (POST /api/workspace/sandbox-run)");
1307
+ return tools;
1308
+ }
1309
+
1310
+ /**
1311
+ * Compose any registered lens into the swarm assignment shape: a single
1312
+ * read-only packet that hands an agent (or a swarm) a workspace *condition*
1313
+ * instead of a vague prompt — goal, current state, the blocked step, its
1314
+ * prerequisite, the tools available, and the evidence it must produce. The
1315
+ * human panel and this packet read the identical derived state.
1316
+ */
1317
+ function deriveSwarmConditionPacket(input = {}, options = {}) {
1318
+ const safeInput = {
1319
+ workspaceConfig: isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {},
1320
+ workspaceSourceRecords: isPlainObject(input.workspaceSourceRecords) ? input.workspaceSourceRecords : {},
1321
+ metadataGraph: isPlainObject(input.metadataGraph) ? input.metadataGraph : null,
1322
+ };
1323
+ const lensId = safeString(options.lensId).trim() || "activation";
1324
+ const entry = getLensEntry(lensId) || getLensEntry("activation");
1325
+ const state = entry.derive(safeInput);
1326
+ const steps = Array.isArray(state.steps) ? state.steps : [];
1327
+
1328
+ const blocked = steps.find((s) => s.status === "blocked") || null;
1329
+ const nextStep = steps.find((s) => s.id === state.nextStepId) || null;
1330
+ const blockedStep = blocked || nextStep;
1331
+ // The prerequisite is the last completed step before the blocker, surfaced
1332
+ // as guidance (the blocker's own hint already explains *why*).
1333
+ const prerequisite = blockedStep
1334
+ ? (safeString(blockedStep.hint).trim() || "Complete the prior step to unblock this one.")
1335
+ : null;
1336
+
1337
+ return {
1338
+ kind: SWARM_PACKET_KIND,
1339
+ version: 1,
1340
+ lensId: entry.id,
1341
+ goal: safeString(state.headline).trim() || `Activate the ${safeString(state.title || state.templateName).trim()} workspace.`,
1342
+ currentState: `${state.completedCount}/${state.totalCount}`,
1343
+ complete: Boolean(state.complete),
1344
+ nextAction: nextStep
1345
+ ? { stepId: nextStep.id, label: nextStep.label, href: nextStep.href || "/", status: nextStep.status }
1346
+ : null,
1347
+ blockedStep: blockedStep
1348
+ ? { stepId: blockedStep.id, label: blockedStep.label, status: blockedStep.status }
1349
+ : null,
1350
+ prerequisite,
1351
+ availableTools: deriveAvailableTools(safeInput.workspaceConfig),
1352
+ expectedEvidence: [
1353
+ "run record (sandbox-environment row lastResponse)",
1354
+ "hydrated source records",
1355
+ "dashboard rollup reflecting the new state",
1356
+ ],
1357
+ };
1358
+ }
1359
+
1360
+ // ───────────────────────────────────────────────────────────────────────────
1361
+ // Workspace contribution graph (daily-ritual visualization)
1362
+ // ───────────────────────────────────────────────────────────────────────────
1363
+ //
1364
+ // A GitHub-style contribution heatmap derived from the SAME artifact + data
1365
+ // flow the lenses read: every dated workspace activity event (workflow run
1366
+ // `ranAt`, source-record `fetchedAt`, sandbox `lastTested`) is bucketed by day
1367
+ // into a 53-week × 7-day grid with intensity levels 0–4. Pure derivation, no
1368
+ // secrets — counts and dates only. This closes the activation loop into an
1369
+ // ongoing daily behaviour: open Workspace Lens, see your activity, start work.
1370
+
1371
+ const CONTRIBUTIONS_KIND = "growthub-workspace-contributions-v1";
1372
+ const CONTRIBUTION_WEEKS = 53;
1373
+
1374
+ function toDayKey(value) {
1375
+ const s = safeString(value).trim();
1376
+ if (!s) return "";
1377
+ const d = new Date(s);
1378
+ if (Number.isNaN(d.getTime())) return "";
1379
+ return d.toISOString().slice(0, 10);
1380
+ }
1381
+
1382
+ function dayKeyFromDate(d) {
1383
+ return d.toISOString().slice(0, 10);
1384
+ }
1385
+
1386
+ function emptyContributionState() {
1387
+ return { kind: CONTRIBUTIONS_KIND, version: 1, total: 0, max: 0, start: "", end: "", weeks: [] };
1388
+ }
1389
+
1390
+ /**
1391
+ * Derive the workspace contribution grid. Reads dated evidence from the same
1392
+ * inputs as the lenses and never throws on partial input.
1393
+ */
1394
+ function deriveWorkspaceContributions(input = {}, options = {}) {
1395
+ const cfg = isPlainObject(input?.workspaceConfig) ? input.workspaceConfig : {};
1396
+ const sources = isPlainObject(input?.workspaceSourceRecords) ? input.workspaceSourceRecords : {};
1397
+
1398
+ const rawDays = [];
1399
+ const add = (value) => {
1400
+ const key = toDayKey(value);
1401
+ if (key) rawDays.push(key);
1402
+ };
1403
+ for (const row of collectSandboxRows(cfg)) {
1404
+ const resp = parseSafe(row.lastResponse);
1405
+ add(resp?.ranAt);
1406
+ add(row.lastRunAt);
1407
+ add(row.ranAt);
1408
+ add(row.lastTested);
1409
+ }
1410
+ for (const key of Object.keys(sources)) {
1411
+ const sidecar = sources[key];
1412
+ if (!isPlainObject(sidecar)) continue;
1413
+ add(sidecar.fetchedAt);
1414
+ if (Array.isArray(sidecar.records)) {
1415
+ for (const record of sidecar.records) {
1416
+ if (!isPlainObject(record)) continue;
1417
+ add(record.fetchedAt);
1418
+ add(record.ranAt);
1419
+ add(record.createdAt);
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ const endInput = options.endDate ? new Date(options.endDate) : new Date();
1425
+ if (Number.isNaN(endInput.getTime())) return emptyContributionState();
1426
+ const endUTC = new Date(Date.UTC(endInput.getUTCFullYear(), endInput.getUTCMonth(), endInput.getUTCDate()));
1427
+ // Pad the final column out to Saturday so the grid is week-aligned.
1428
+ const lastCell = new Date(endUTC);
1429
+ lastCell.setUTCDate(endUTC.getUTCDate() + (6 - endUTC.getUTCDay()));
1430
+ const firstCell = new Date(lastCell);
1431
+ firstCell.setUTCDate(lastCell.getUTCDate() - (CONTRIBUTION_WEEKS * 7 - 1));
1432
+
1433
+ // Browser and runtime clocks can disagree around UTC day boundaries. Keep the
1434
+ // GitHub-style mental model: activity that is real but slightly ahead of the
1435
+ // local day still paints the latest visible cell instead of disappearing.
1436
+ const endKey = dayKeyFromDate(endUTC);
1437
+ const byDay = Object.create(null);
1438
+ for (const rawDay of rawDays) {
1439
+ const key = rawDay > endKey ? endKey : rawDay;
1440
+ byDay[key] = (byDay[key] || 0) + 1;
1441
+ }
1442
+
1443
+ const counts = Object.values(byDay);
1444
+ const max = counts.length ? Math.max(...counts) : 0;
1445
+ const levelFor = (count) => {
1446
+ if (count <= 0) return 0;
1447
+ if (max <= 1) return 2;
1448
+ const q = count / max;
1449
+ if (q > 0.75) return 4;
1450
+ if (q > 0.5) return 3;
1451
+ if (q > 0.25) return 2;
1452
+ return 1;
1453
+ };
1454
+
1455
+ let total = 0;
1456
+ const weeks = [];
1457
+ const cursor = new Date(firstCell);
1458
+ for (let w = 0; w < CONTRIBUTION_WEEKS; w += 1) {
1459
+ const days = [];
1460
+ for (let d = 0; d < 7; d += 1) {
1461
+ const date = dayKeyFromDate(cursor);
1462
+ const future = cursor.getTime() > endUTC.getTime();
1463
+ const count = future ? 0 : (byDay[date] || 0);
1464
+ if (!future) total += count;
1465
+ days.push({ date, count, level: future ? 0 : levelFor(count), future });
1466
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
1467
+ }
1468
+ weeks.push({ days });
1469
+ }
1470
+
1471
+ return {
1472
+ kind: CONTRIBUTIONS_KIND,
1473
+ version: 1,
1474
+ total,
1475
+ max,
1476
+ start: dayKeyFromDate(firstCell),
1477
+ end: dayKeyFromDate(endUTC),
1478
+ weeks,
1479
+ };
1480
+ }
1481
+
1482
+ // ───────────────────────────────────────────────────────────────────────────
1483
+ // Workspace Lens first-time walkthrough (one-time guided reveal)
1484
+ // ───────────────────────────────────────────────────────────────────────────
1485
+ //
1486
+ // The walkthrough is the dopamine handoff: it appears ONLY in the in-between
1487
+ // state — onboarding complete, Workspace Lens unlocked, but no activity yet
1488
+ // (not a power user). Once the workspace shows real activity, or once the user
1489
+ // dismisses it (persisted in the same workspace-ui-cache row the onboarding
1490
+ // dismiss uses), it never shows again.
1491
+
1492
+ const LENS_WALKTHROUGH_DISMISS_FLAG = "lensWalkthroughDismissed";
1493
+
1494
+ /** Read a flag from the governed workspace-ui-cache "activation" row. */
1495
+ function readUiCacheFlag(workspaceConfig, key) {
1496
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
1497
+ const cache = objects.find((o) => isPlainObject(o) && o.id === "workspace-ui-cache");
1498
+ const row = cache && Array.isArray(cache.rows)
1499
+ ? cache.rows.find((r) => isPlainObject(r) && r.id === "activation")
1500
+ : null;
1501
+ return row ? row[key] : undefined;
1502
+ }
1503
+
1504
+ /**
1505
+ * Derive whether the one-time Workspace Lens walkthrough should show.
1506
+ * `show` is true iff: activation complete AND no workspace activity yet AND
1507
+ * not previously dismissed. Pure; never throws.
1508
+ */
1509
+ function deriveLensWalkthroughState(input = {}) {
1510
+ const safe = {
1511
+ workspaceConfig: isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {},
1512
+ workspaceSourceRecords: isPlainObject(input.workspaceSourceRecords) ? input.workspaceSourceRecords : {},
1513
+ metadataGraph: isPlainObject(input.metadataGraph) ? input.metadataGraph : null,
1514
+ };
1515
+ const activationComplete = deriveWorkspaceActivationState(safe).complete;
1516
+ const hasActivity = deriveWorkspaceContributions(safe).total > 0;
1517
+ const flag = readUiCacheFlag(safe.workspaceConfig, LENS_WALKTHROUGH_DISMISS_FLAG);
1518
+ const dismissed = flag === true || String(flag || "") === "true";
1519
+ return {
1520
+ kind: "growthub-lens-walkthrough-state-v1",
1521
+ activationComplete,
1522
+ hasActivity,
1523
+ dismissed,
1524
+ show: activationComplete && !hasActivity && !dismissed,
1525
+ };
1526
+ }
1527
+
1528
+ export {
1529
+ ACTIVATION_KIND,
1530
+ ACTIVATION_VERSION,
1531
+ TEMPLATE_PROJECT_MANAGEMENT,
1532
+ CONTRIBUTIONS_KIND,
1533
+ LENS_WALKTHROUGH_DISMISS_FLAG,
1534
+ readUiCacheFlag,
1535
+ deriveLensWalkthroughState,
1536
+ LENS_STATE_KIND,
1537
+ WORKSPACE_STATE_KIND,
1538
+ SWARM_PACKET_KIND,
1539
+ deriveWorkspaceActivationState,
1540
+ deriveProjectManagementActivationState,
1541
+ deriveBlankWorkspaceActivationState,
1542
+ deriveProvenance,
1543
+ hasConnectionId,
1544
+ hasSourceRecords,
1545
+ // Workspace State Lens registry (roadmap Item 1) + lenses (Items 2, 3, 5, 6, 7)
1546
+ WORKSPACE_LENS_REGISTRY,
1547
+ getLensEntry,
1548
+ deriveWorkspaceState,
1549
+ deriveRuntimeDurability,
1550
+ derivePersistenceLensState,
1551
+ deriveObservabilityLensState,
1552
+ deriveDeployLensState,
1553
+ deriveTaskLensState,
1554
+ deriveAppBuildLensState,
1555
+ // Swarm-assignable condition packet (roadmap Item 8)
1556
+ deriveSwarmConditionPacket,
1557
+ // Workspace contribution graph (daily-ritual visualization)
1558
+ deriveWorkspaceContributions,
1559
+ };