@growthub/cli 0.13.9 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +31 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +227 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +17 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +6 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +200 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +414 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +339 -77
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +229 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +224 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +2 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +317 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-response-profile.js +207 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/creation-error-recovery.js +103 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +100 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +69 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +215 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-write.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-upgrade.js +89 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +11 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +8 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +30 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +200 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -1
- package/package.json +1 -1
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
* - lib/orchestration-run-trace.js (lower-level record parser)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
extractSwarmNodes,
|
|
21
|
+
isAgentSwarmGraph,
|
|
22
|
+
parseOrchestrationGraph,
|
|
23
|
+
redactSecretsFromText
|
|
24
|
+
} from "./orchestration-graph.js";
|
|
20
25
|
import { redactRunInputsEnvelope, summarizeRunInputs } from "./orchestration-run-inputs.js";
|
|
21
26
|
|
|
22
27
|
const RUN_LOG_BUNDLE_KIND = "growthub-sandbox-run-log-v1";
|
|
@@ -202,6 +207,388 @@ function buildExportsForRecord(record, stdoutText, stderrText, outputText) {
|
|
|
202
207
|
return { available, external: [] };
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Swarm cockpit projection (SWARM_RUN_CONTRACT_V1).
|
|
212
|
+
*
|
|
213
|
+
* Pure transformation of a sandbox run record carrying a `swarm` block
|
|
214
|
+
* (written by the agent-swarm-v1 runtime through sandbox-run) into the
|
|
215
|
+
* phase/agent tree the helper sidecar cockpit renders. Returns `null` for
|
|
216
|
+
* non-swarm records so existing runs are untouched.
|
|
217
|
+
*
|
|
218
|
+
* Telemetry is truthful: tokens/tools are null when the adapter did not
|
|
219
|
+
* report them — the UI renders "—", never an estimate. Totals are null when
|
|
220
|
+
* no agent reported a number.
|
|
221
|
+
*
|
|
222
|
+
* Same module rules as the rest of this file: no React, no fetch, no config
|
|
223
|
+
* writes, no localStorage, no CSS.
|
|
224
|
+
*/
|
|
225
|
+
// Truthful counts only: null/undefined (adapter reported nothing) stays
|
|
226
|
+
// null — clampNumber would coerce null to 0, which would be a fake metric.
|
|
227
|
+
function toTruthfulCount(value) {
|
|
228
|
+
if (value == null) return null;
|
|
229
|
+
const n = Number(value);
|
|
230
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toAdapterReportedCount(entry, field) {
|
|
234
|
+
const meta = entry?.adapterMeta && typeof entry.adapterMeta === "object" ? entry.adapterMeta : null;
|
|
235
|
+
if (meta?.telemetrySource === "unreported" && meta?.[field] == null) return null;
|
|
236
|
+
if (entry?.[field] === 0 && meta && meta[field] == null) return null;
|
|
237
|
+
if (entry?.[field] === 0 && !meta && safeString(entry?.adapter).trim() === "local-agent-host") return null;
|
|
238
|
+
return toTruthfulCount(entry?.[field]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parsePersistedAgentHostTelemetry(entry) {
|
|
242
|
+
const stderr = safeString(entry?.stderr);
|
|
243
|
+
const total = stderr.match(/\b(?:total\s+tokens|tokens\s+used)\s*(?:[:=]|\r?\n)\s*([0-9][0-9,]*)/i);
|
|
244
|
+
const input = stderr.match(/\b(?:input|prompt)\s+tokens\s*[:=]\s*([0-9][0-9,]*)/i);
|
|
245
|
+
const output = stderr.match(/\b(?:output|completion)\s+tokens\s*[:=]\s*([0-9][0-9,]*)/i);
|
|
246
|
+
const tools = stderr.match(/\b(?:tool\s+calls?|tools\s+used)\s*[:=]\s*([0-9][0-9,]*)/i);
|
|
247
|
+
const toInt = (value) => {
|
|
248
|
+
if (value == null || value === "") return null;
|
|
249
|
+
const n = Number(String(value).replace(/,/g, ""));
|
|
250
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
251
|
+
};
|
|
252
|
+
const inputTokens = toInt(input?.[1]);
|
|
253
|
+
const outputTokens = toInt(output?.[1]);
|
|
254
|
+
const summed = inputTokens != null || outputTokens != null
|
|
255
|
+
? (inputTokens || 0) + (outputTokens || 0)
|
|
256
|
+
: null;
|
|
257
|
+
const tokens = toInt(total?.[1]) ?? summed;
|
|
258
|
+
return {
|
|
259
|
+
tokens,
|
|
260
|
+
tools: toInt(tools?.[1]) ?? (tokens != null ? 0 : null)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function titleizePhaseId(id) {
|
|
265
|
+
const text = safeString(id).trim() || "dispatch";
|
|
266
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sumReportedOrNull(values) {
|
|
270
|
+
const reported = values.filter((n) => n != null);
|
|
271
|
+
return reported.length > 0 ? reported.reduce((sum, n) => sum + n, 0) : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function deriveSwarmRunProjection(record) {
|
|
275
|
+
if (!record || typeof record !== "object") return null;
|
|
276
|
+
const swarm = record.swarm;
|
|
277
|
+
if (!swarm || typeof swarm !== "object") return null;
|
|
278
|
+
|
|
279
|
+
const summary = deriveRunSummary(record);
|
|
280
|
+
const tasks = Array.isArray(swarm.tasks) ? swarm.tasks.filter((t) => t && typeof t === "object") : [];
|
|
281
|
+
|
|
282
|
+
// Agents projected from a persisted record are terminal: `pending: false`
|
|
283
|
+
// means a missing count is "ran but never reported" → the UI shows "—".
|
|
284
|
+
// (Pending agents — from the graph skeleton or a live stream — show blank.)
|
|
285
|
+
const toAgent = (entry, fallbackId, fallbackLabel, transcriptParts, logNodeId) => {
|
|
286
|
+
const persistedTelemetry = parsePersistedAgentHostTelemetry(entry);
|
|
287
|
+
const tokens = persistedTelemetry.tokens ?? toAdapterReportedCount(entry, "tokens");
|
|
288
|
+
const tools = persistedTelemetry.tools ?? toAdapterReportedCount(entry, "tools");
|
|
289
|
+
return {
|
|
290
|
+
id: safeString(entry?.taskId || entry?.nodeId || fallbackId).trim() || fallbackId,
|
|
291
|
+
label: safeString(entry?.role || entry?.label || fallbackLabel).trim() || fallbackLabel,
|
|
292
|
+
status: safeString(entry?.status || "unknown").trim() || "unknown",
|
|
293
|
+
pending: false,
|
|
294
|
+
tokens,
|
|
295
|
+
tools,
|
|
296
|
+
durationMs: clampNumber(entry?.durationMs) ?? 0,
|
|
297
|
+
transcript: redactSecretsFromText(
|
|
298
|
+
transcriptParts.map((part) => safeString(part).trim()).filter(Boolean).join("\n\n")
|
|
299
|
+
),
|
|
300
|
+
logNodeId
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const phases = [];
|
|
305
|
+
const orchestrator = swarm.orchestrator && typeof swarm.orchestrator === "object" ? swarm.orchestrator : null;
|
|
306
|
+
if (orchestrator) {
|
|
307
|
+
phases.push({
|
|
308
|
+
id: "plan",
|
|
309
|
+
label: "Plan",
|
|
310
|
+
status: safeString(orchestrator.status || "unknown").trim() || "unknown",
|
|
311
|
+
agents: [
|
|
312
|
+
toAgent(
|
|
313
|
+
orchestrator,
|
|
314
|
+
"orchestrator",
|
|
315
|
+
"Orchestrator",
|
|
316
|
+
[orchestrator.error, orchestrator.plan],
|
|
317
|
+
"phase-orchestrator"
|
|
318
|
+
)
|
|
319
|
+
]
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Group dispatch tasks by their declared phase id (author-named phases),
|
|
324
|
+
// falling back to the single "Dispatch" group for legacy records whose
|
|
325
|
+
// tasks carry no phaseId — those project identically to before.
|
|
326
|
+
const dispatchGroups = new Map();
|
|
327
|
+
tasks.forEach((task, index) => {
|
|
328
|
+
const phaseId = safeString(task.phaseId).trim() || "dispatch";
|
|
329
|
+
if (!dispatchGroups.has(phaseId)) dispatchGroups.set(phaseId, []);
|
|
330
|
+
dispatchGroups.get(phaseId).push(
|
|
331
|
+
toAgent(
|
|
332
|
+
task,
|
|
333
|
+
`task-${index + 1}`,
|
|
334
|
+
`Agent ${index + 1}`,
|
|
335
|
+
[task.error, task.stdout, task.stderr],
|
|
336
|
+
safeString(task.taskId || task.nodeId || `task-${index + 1}`).trim()
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
if (dispatchGroups.size === 0) dispatchGroups.set("dispatch", []);
|
|
341
|
+
for (const [phaseId, agents] of dispatchGroups) {
|
|
342
|
+
const status = agents.length === 0
|
|
343
|
+
? "failed"
|
|
344
|
+
: agents.every((a) => a.status === "completed")
|
|
345
|
+
? "completed"
|
|
346
|
+
: agents.some((a) => a.status === "failed")
|
|
347
|
+
? "failed"
|
|
348
|
+
: "info";
|
|
349
|
+
phases.push({ id: phaseId, label: titleizePhaseId(phaseId), status, agents });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const synthesis = swarm.synthesis && typeof swarm.synthesis === "object" ? swarm.synthesis : null;
|
|
353
|
+
if (synthesis) {
|
|
354
|
+
phases.push({
|
|
355
|
+
id: "synthesize",
|
|
356
|
+
label: "Synthesize",
|
|
357
|
+
status: safeString(synthesis.status || "unknown").trim() || "unknown",
|
|
358
|
+
agents: [
|
|
359
|
+
toAgent(
|
|
360
|
+
synthesis,
|
|
361
|
+
"synthesis",
|
|
362
|
+
synthesis.label || "Synthesizer",
|
|
363
|
+
[synthesis.error, synthesis.answer],
|
|
364
|
+
"phase-synthesis"
|
|
365
|
+
)
|
|
366
|
+
]
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const allAgents = phases.flatMap((phase) => phase.agents);
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
runId: safeString(record.runId).trim(),
|
|
374
|
+
title: safeString(record.name || record.sandboxName).trim() || "agent-swarm",
|
|
375
|
+
status: summary.status,
|
|
376
|
+
elapsedMs: clampNumber(record.durationMs) ?? 0,
|
|
377
|
+
agentCount: allAgents.length,
|
|
378
|
+
totalTokens: sumReportedOrNull(allAgents.map((a) => a.tokens)),
|
|
379
|
+
totalTools: sumReportedOrNull(allAgents.map((a) => a.tools)),
|
|
380
|
+
phases
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Declared-phase skeleton (SWARM_RUN_CONTRACT_V1, parity P1).
|
|
386
|
+
*
|
|
387
|
+
* Projects the SAME projection shape as deriveSwarmRunProjection from the
|
|
388
|
+
* governed row's agent-swarm-v1 graph alone — before any run exists. Every
|
|
389
|
+
* phase and agent renders upfront with `status: "pending"`, `pending: true`
|
|
390
|
+
* (UI shows hollow dots and BLANK cells, reserving "—" for terminal
|
|
391
|
+
* never-reported). Author-named phases come from each subagent node's
|
|
392
|
+
* `config.phase` / `config.phaseId`; graphs that declare none fall back to
|
|
393
|
+
* the Plan / Dispatch / Synthesize derivation, converging exactly with the
|
|
394
|
+
* record projection for the same workflow.
|
|
395
|
+
*
|
|
396
|
+
* Pure: no React, no fetch, no config writes, no localStorage, no CSS.
|
|
397
|
+
*/
|
|
398
|
+
function deriveSwarmGraphProjection(graphLike, { title = "", runId = "" } = {}) {
|
|
399
|
+
const graph = parseOrchestrationGraph(graphLike);
|
|
400
|
+
if (!graph || !isAgentSwarmGraph(graph)) return null;
|
|
401
|
+
const extracted = extractSwarmNodes(graph);
|
|
402
|
+
if (!extracted) return null;
|
|
403
|
+
const { orchestrator, subagents, synthesis } = extracted;
|
|
404
|
+
|
|
405
|
+
const pendingAgent = (id, label) => ({
|
|
406
|
+
id: safeString(id).trim() || "agent",
|
|
407
|
+
label: safeString(label).trim() || "Agent",
|
|
408
|
+
status: "pending",
|
|
409
|
+
pending: true,
|
|
410
|
+
tokens: null,
|
|
411
|
+
tools: null,
|
|
412
|
+
durationMs: 0,
|
|
413
|
+
transcript: "",
|
|
414
|
+
logNodeId: ""
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const phases = [];
|
|
418
|
+
if (orchestrator) {
|
|
419
|
+
phases.push({
|
|
420
|
+
id: "plan",
|
|
421
|
+
label: "Plan",
|
|
422
|
+
status: "pending",
|
|
423
|
+
agents: [pendingAgent(orchestrator.id || "orchestrator", orchestrator.config?.role || "Orchestrator")]
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const dispatchGroups = new Map();
|
|
428
|
+
subagents.forEach((node, index) => {
|
|
429
|
+
const phaseId = safeString(node?.config?.phase || node?.config?.phaseId).trim().toLowerCase() || "dispatch";
|
|
430
|
+
if (!dispatchGroups.has(phaseId)) dispatchGroups.set(phaseId, []);
|
|
431
|
+
dispatchGroups.get(phaseId).push(
|
|
432
|
+
pendingAgent(node?.id || `task-${index + 1}`, node?.config?.role || node?.label || `Agent ${index + 1}`)
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
if (dispatchGroups.size === 0) dispatchGroups.set("dispatch", []);
|
|
436
|
+
for (const [phaseId, agents] of dispatchGroups) {
|
|
437
|
+
phases.push({ id: phaseId, label: titleizePhaseId(phaseId), status: "pending", agents });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (synthesis) {
|
|
441
|
+
phases.push({
|
|
442
|
+
id: "synthesize",
|
|
443
|
+
label: "Synthesize",
|
|
444
|
+
status: "pending",
|
|
445
|
+
agents: [pendingAgent(synthesis.id || "synthesis", synthesis.label || "Synthesizer")]
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
runId: safeString(runId).trim(),
|
|
451
|
+
title: safeString(title).trim() || "agent-swarm",
|
|
452
|
+
status: "pending",
|
|
453
|
+
elapsedMs: 0,
|
|
454
|
+
agentCount: phases.reduce((sum, phase) => sum + phase.agents.length, 0),
|
|
455
|
+
totalTokens: null,
|
|
456
|
+
totalTools: null,
|
|
457
|
+
phases
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function cloneSwarmProjection(projection) {
|
|
462
|
+
if (!projection || typeof projection !== "object") return null;
|
|
463
|
+
return {
|
|
464
|
+
...projection,
|
|
465
|
+
phases: Array.isArray(projection.phases)
|
|
466
|
+
? projection.phases.map((phase) => ({
|
|
467
|
+
...phase,
|
|
468
|
+
agents: Array.isArray(phase.agents)
|
|
469
|
+
? phase.agents.map((agent) => ({ ...agent }))
|
|
470
|
+
: []
|
|
471
|
+
}))
|
|
472
|
+
: []
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function findOrCreateLivePhase(projection, phaseId, label) {
|
|
477
|
+
const id = safeString(phaseId).trim() || "dispatch";
|
|
478
|
+
let phase = projection.phases.find((entry) => entry.id === id);
|
|
479
|
+
if (!phase) {
|
|
480
|
+
phase = { id, label: safeString(label).trim() || titleizePhaseId(id), status: "pending", agents: [] };
|
|
481
|
+
projection.phases.push(phase);
|
|
482
|
+
}
|
|
483
|
+
return phase;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function upsertLiveAgent(phase, incoming) {
|
|
487
|
+
if (!phase) return;
|
|
488
|
+
const fallbackId = safeString(incoming?.taskId || incoming?.nodeId || incoming?.id).trim() || "agent";
|
|
489
|
+
const id = fallbackId;
|
|
490
|
+
const label = safeString(incoming?.role || incoming?.label).trim() || "Agent";
|
|
491
|
+
const index = phase.agents.findIndex((agent) => agent.id === id || agent.logNodeId === id);
|
|
492
|
+
const prior = index >= 0 ? phase.agents[index] : {};
|
|
493
|
+
const status = safeString(incoming?.status || prior.status || "running").trim() || "running";
|
|
494
|
+
const next = {
|
|
495
|
+
id,
|
|
496
|
+
label: label || prior.label || "Agent",
|
|
497
|
+
status,
|
|
498
|
+
pending: status === "pending" || status === "running" || status === "executing",
|
|
499
|
+
tokens: toTruthfulCount(incoming?.tokens ?? prior.tokens),
|
|
500
|
+
tools: toTruthfulCount(incoming?.tools ?? prior.tools),
|
|
501
|
+
durationMs: clampNumber(incoming?.durationMs) ?? prior.durationMs ?? 0,
|
|
502
|
+
transcript: prior.transcript || "",
|
|
503
|
+
logNodeId: safeString(incoming?.nodeId || prior.logNodeId || id).trim() || id
|
|
504
|
+
};
|
|
505
|
+
if (index >= 0) phase.agents[index] = next;
|
|
506
|
+
else phase.agents.push(next);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function derivePhaseStatusFromAgents(phase) {
|
|
510
|
+
const agents = Array.isArray(phase?.agents) ? phase.agents : [];
|
|
511
|
+
if (agents.some((agent) => agent.status === "running" || agent.status === "executing")) return "running";
|
|
512
|
+
if (agents.some((agent) => agent.status === "failed")) return "failed";
|
|
513
|
+
if (agents.length > 0 && agents.every((agent) => agent.status === "completed")) return "completed";
|
|
514
|
+
return phase?.status || "pending";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Live swarm delta projection.
|
|
519
|
+
*
|
|
520
|
+
* This consumes the optional NDJSON events from POST /api/workspace/sandbox-run
|
|
521
|
+
* and returns the exact same shape as persisted-record projection. The final
|
|
522
|
+
* source of truth remains deriveSwarmRunProjection(record); this only hydrates
|
|
523
|
+
* the cosmetic Background Tasks card while the POST is still open.
|
|
524
|
+
*/
|
|
525
|
+
function deriveSwarmDeltaProjection(graphLike, events, { title = "", runId = "", elapsedMs = 0 } = {}) {
|
|
526
|
+
const base = cloneSwarmProjection(deriveSwarmGraphProjection(graphLike, { title, runId }));
|
|
527
|
+
if (!base) return null;
|
|
528
|
+
const list = Array.isArray(events) ? events.filter((event) => event && typeof event === "object") : [];
|
|
529
|
+
let status = "running";
|
|
530
|
+
let latestRunId = safeString(runId).trim() || base.runId;
|
|
531
|
+
|
|
532
|
+
for (const event of list) {
|
|
533
|
+
if (event.runId) latestRunId = safeString(event.runId).trim() || latestRunId;
|
|
534
|
+
const type = safeString(event.type).trim();
|
|
535
|
+
if (type === "swarm.run.started") {
|
|
536
|
+
status = "running";
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (type === "swarm.run.completed" || type === "swarm.run.failed") {
|
|
540
|
+
status = safeString(event.status || (type.endsWith("completed") ? "completed" : "failed")).trim() || status;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (type === "swarm.phase.started") {
|
|
544
|
+
const phase = findOrCreateLivePhase(base, event.phaseId, event.label);
|
|
545
|
+
phase.status = "running";
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (type === "swarm.phase.completed" || type === "swarm.phase.failed") {
|
|
549
|
+
const phase = findOrCreateLivePhase(base, event.phaseId, event.label);
|
|
550
|
+
phase.status = safeString(event.status || (type.endsWith("completed") ? "completed" : "failed")).trim() || phase.status;
|
|
551
|
+
if (event.agent && typeof event.agent === "object") upsertLiveAgent(phase, event.agent);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (type === "swarm.task.started") {
|
|
555
|
+
const phase = findOrCreateLivePhase(base, event.phaseId, titleizePhaseId(event.phaseId));
|
|
556
|
+
phase.status = "running";
|
|
557
|
+
upsertLiveAgent(phase, {
|
|
558
|
+
taskId: event.taskId,
|
|
559
|
+
nodeId: event.nodeId,
|
|
560
|
+
role: event.role,
|
|
561
|
+
status: "running",
|
|
562
|
+
phaseId: event.phaseId
|
|
563
|
+
});
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (type === "swarm.task.completed" || type === "swarm.task.failed") {
|
|
567
|
+
const task = event.task && typeof event.task === "object" ? event.task : event;
|
|
568
|
+
const phase = findOrCreateLivePhase(base, task.phaseId || event.phaseId, titleizePhaseId(task.phaseId || event.phaseId));
|
|
569
|
+
upsertLiveAgent(phase, task);
|
|
570
|
+
phase.status = derivePhaseStatusFromAgents(phase);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (const phase of base.phases) {
|
|
575
|
+
if (phase.status === "running") continue;
|
|
576
|
+
phase.status = derivePhaseStatusFromAgents(phase);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const allAgents = base.phases.flatMap((phase) => phase.agents);
|
|
580
|
+
return {
|
|
581
|
+
...base,
|
|
582
|
+
runId: latestRunId,
|
|
583
|
+
status,
|
|
584
|
+
elapsedMs: clampNumber(elapsedMs) ?? base.elapsedMs ?? 0,
|
|
585
|
+
agentCount: allAgents.length,
|
|
586
|
+
totalTokens: sumReportedOrNull(allAgents.map((agent) => agent.tokens)),
|
|
587
|
+
totalTools: sumReportedOrNull(allAgents.map((agent) => agent.tools)),
|
|
588
|
+
phases: base.phases
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
205
592
|
function normalizeRunConsoleRecord(record) {
|
|
206
593
|
if (!record || typeof record !== "object") return null;
|
|
207
594
|
const summary = deriveRunSummary(record);
|
|
@@ -303,6 +690,7 @@ function normalizeRunConsoleRecord(record) {
|
|
|
303
690
|
},
|
|
304
691
|
lineage,
|
|
305
692
|
swarm: record.swarm && typeof record.swarm === "object" ? record.swarm : null,
|
|
693
|
+
swarmRun: deriveSwarmRunProjection(record),
|
|
306
694
|
logTree: buildRunLogTree(record)
|
|
307
695
|
};
|
|
308
696
|
}
|
|
@@ -387,6 +775,24 @@ function formatRunDuration(ms) {
|
|
|
387
775
|
return `${minutes}m ${seconds}s`;
|
|
388
776
|
}
|
|
389
777
|
|
|
778
|
+
/**
|
|
779
|
+
* Compact zero-padded duration for the swarm cockpit tables and cards —
|
|
780
|
+
* Claude Code Background-tasks format: "04s", "15s", "1m 04s". A separate
|
|
781
|
+
* formatter (not a change to formatRunDuration) so existing non-swarm
|
|
782
|
+
* run-console surfaces keep their ms-precision rendering untouched.
|
|
783
|
+
*/
|
|
784
|
+
function formatCompactRunDuration(ms) {
|
|
785
|
+
if (ms == null) return "—";
|
|
786
|
+
const n = clampNumber(ms);
|
|
787
|
+
if (n == null) return "—";
|
|
788
|
+
const totalSeconds = Math.max(0, Math.round(n / 1000));
|
|
789
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
790
|
+
const seconds = totalSeconds % 60;
|
|
791
|
+
const padded = String(seconds).padStart(2, "0");
|
|
792
|
+
if (minutes <= 0) return `${padded}s`;
|
|
793
|
+
return `${minutes}m ${padded}s`;
|
|
794
|
+
}
|
|
795
|
+
|
|
390
796
|
function downloadRunBundle({ record, runId, sourceId } = {}) {
|
|
391
797
|
const normalized = normalizeRunConsoleRecord(record || {});
|
|
392
798
|
return {
|
|
@@ -403,6 +809,10 @@ export {
|
|
|
403
809
|
DEFAULT_EXPORT_TARGETS,
|
|
404
810
|
normalizeRunConsoleRecord,
|
|
405
811
|
deriveRunSummary,
|
|
812
|
+
deriveSwarmRunProjection,
|
|
813
|
+
deriveSwarmGraphProjection,
|
|
814
|
+
deriveSwarmDeltaProjection,
|
|
815
|
+
formatCompactRunDuration,
|
|
406
816
|
deriveRunLifecycle,
|
|
407
817
|
buildRunLogTree,
|
|
408
818
|
buildRunTimeline,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox Serverless Flow V1 — the governed persistence + scheduling journey for
|
|
3
|
+
* one sandbox-environment workflow row, expressed in the EXACT same step shape
|
|
4
|
+
* as the API Registry creation cockpit (lib/api-registry-creation-flow.js) so it
|
|
5
|
+
* renders through the same cockpit interface and mental model.
|
|
6
|
+
*
|
|
7
|
+
* It connects the dots that already exist:
|
|
8
|
+
* - runLocality local|serverless toggle (sandbox row)
|
|
9
|
+
* - execution adapter (sandbox-adapter-registry)
|
|
10
|
+
* - schedulerRegistryId reference field → an API Registry row that delegates
|
|
11
|
+
* the serverless run (sandbox-run's registry-delegation mode)
|
|
12
|
+
* - the scheduler row's authRef → resolved via env-status configuredEnvRefs
|
|
13
|
+
* - durable persistence via the real thin adapters (postgres / qstash-kv /
|
|
14
|
+
* provider-managed) surfaced by env-status persistenceAdapters
|
|
15
|
+
*
|
|
16
|
+
* Pure + deterministic; never reads process.env, never throws. Secret-safe
|
|
17
|
+
* (slugs/ids/booleans only).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function isPlainObject(value) {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clean(value) {
|
|
25
|
+
return String(value == null ? "" : value).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Exact env keys a ref resolves through (runtime/.env.local), surfaced so the
|
|
29
|
+
* config loop is concrete — same model as the NANGO_SECRET_KEY activation step. */
|
|
30
|
+
function envCandidates(ref) {
|
|
31
|
+
const token = clean(ref).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toUpperCase();
|
|
32
|
+
if (!token) return [];
|
|
33
|
+
return Array.from(new Set([token, `${token}_API_KEY`, `${token}_TOKEN`]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SCHEDULER_OK_STATUSES = new Set(["connected", "approved", "ok", "success", "live", "tested"]);
|
|
37
|
+
const STATE_KIND = "growthub-sandbox-serverless-state-v1";
|
|
38
|
+
|
|
39
|
+
function findApiRegistryRow(workspaceConfig, integrationId) {
|
|
40
|
+
const id = clean(integrationId);
|
|
41
|
+
if (!id) return null;
|
|
42
|
+
const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
|
|
43
|
+
for (const object of objects) {
|
|
44
|
+
if (object?.objectType !== "api-registry") continue;
|
|
45
|
+
const match = (object.rows || []).find((r) => clean(r?.integrationId) === id);
|
|
46
|
+
if (match) return match;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive the serverless/scheduling/persistence journey for a sandbox row.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} input
|
|
55
|
+
* @param {object} input.sandboxRow the row being edited (drawer draft)
|
|
56
|
+
* @param {object} [input.workspaceConfig] for scheduler row lookup
|
|
57
|
+
* @param {string[]} [input.configuredEnvRefs] auth/env slugs that resolve (env-status)
|
|
58
|
+
* @param {object[]} [input.persistenceAdapters] [{id,label,mode,configured,missingEnv}]
|
|
59
|
+
*/
|
|
60
|
+
function deriveSandboxServerlessState(input = {}) {
|
|
61
|
+
const row = isPlainObject(input.sandboxRow) ? input.sandboxRow : {};
|
|
62
|
+
const workspaceConfig = isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {};
|
|
63
|
+
const configuredRefs = new Set((Array.isArray(input.configuredEnvRefs) ? input.configuredEnvRefs : []).map((s) => clean(s).toUpperCase()));
|
|
64
|
+
const adapters = Array.isArray(input.persistenceAdapters) ? input.persistenceAdapters : [];
|
|
65
|
+
// When the cockpit is rendered above the drawer's own editable fields
|
|
66
|
+
// (locality toggle, adapter picker, scheduler reference dropdown), those steps
|
|
67
|
+
// show status only — the inline field is the editor (no duplicate button).
|
|
68
|
+
const inlineEditing = input.inlineEditing === true;
|
|
69
|
+
const inline = (action) => (inlineEditing ? null : action);
|
|
70
|
+
|
|
71
|
+
const locality = clean(row.runLocality).toLowerCase() === "serverless" ? "serverless" : "local";
|
|
72
|
+
const isServerless = locality === "serverless";
|
|
73
|
+
const adapterId = clean(row.adapter);
|
|
74
|
+
const adapterChosen = Boolean(adapterId);
|
|
75
|
+
|
|
76
|
+
const schedulerId = clean(row.schedulerRegistryId);
|
|
77
|
+
const schedulerRow = isServerless ? findApiRegistryRow(workspaceConfig, schedulerId) : null;
|
|
78
|
+
const schedulerLinked = isServerless ? Boolean(schedulerId) : true;
|
|
79
|
+
const schedulerHealthy = Boolean(schedulerRow) && SCHEDULER_OK_STATUSES.has(clean(schedulerRow.status).toLowerCase());
|
|
80
|
+
const schedulerAuthRef = clean(schedulerRow?.authRef).toUpperCase();
|
|
81
|
+
const schedulerAuthConfigured = !schedulerAuthRef || configuredRefs.has(schedulerAuthRef);
|
|
82
|
+
|
|
83
|
+
// Durable persistence: any real adapter that is env-ready. provider-managed is
|
|
84
|
+
// always "ready" (the deploy provider owns persistence). qstash-kv/postgres
|
|
85
|
+
// require their env keys — surfaced honestly with the missing keys.
|
|
86
|
+
const durableAdapters = adapters.filter((a) => a && a.configured);
|
|
87
|
+
const durableReady = durableAdapters.length > 0;
|
|
88
|
+
const envBackedAdapter = durableAdapters.find((a) => Array.isArray(a.requiredEnv) && a.requiredEnv.length > 0) || durableAdapters[0] || null;
|
|
89
|
+
|
|
90
|
+
const steps = [];
|
|
91
|
+
|
|
92
|
+
steps.push({
|
|
93
|
+
id: "locality",
|
|
94
|
+
label: "Choose run locality",
|
|
95
|
+
status: isServerless ? "complete" : "active",
|
|
96
|
+
description: isServerless
|
|
97
|
+
? "Serverless — runs are delegated to a scheduler and persist across redeploy."
|
|
98
|
+
: "Local — runs execute in-process on this machine.",
|
|
99
|
+
action: inline({ id: "toggle-locality", label: isServerless ? "Switch to local" : "Switch to serverless" }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
steps.push({
|
|
103
|
+
id: "adapter",
|
|
104
|
+
label: "Pick an execution adapter",
|
|
105
|
+
status: adapterChosen ? "complete" : "active",
|
|
106
|
+
description: adapterChosen
|
|
107
|
+
? `Adapter "${adapterId}".`
|
|
108
|
+
: "Select the execution adapter for this workflow.",
|
|
109
|
+
action: adapterChosen ? null : inline({ id: "edit-adapter", label: "Choose adapter" }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (isServerless) {
|
|
113
|
+
steps.push({
|
|
114
|
+
id: "scheduler",
|
|
115
|
+
label: "Link a scheduler",
|
|
116
|
+
status: schedulerLinked ? (schedulerHealthy ? "complete" : "pending") : "active",
|
|
117
|
+
description: !schedulerLinked
|
|
118
|
+
? "Set schedulerRegistryId to an API Registry row that delegates the serverless run."
|
|
119
|
+
: schedulerHealthy
|
|
120
|
+
? `Scheduler "${schedulerId}" is connected.`
|
|
121
|
+
: `Scheduler "${schedulerId}" is linked but not connected yet — test that API Registry row.`,
|
|
122
|
+
hint: schedulerLinked && !schedulerRow ? "The referenced API Registry row was not found." : undefined,
|
|
123
|
+
action: inline({ id: "link-scheduler", label: schedulerLinked ? "Review scheduler" : "Link scheduler" }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
steps.push({
|
|
127
|
+
id: "scheduler-auth",
|
|
128
|
+
label: "Scheduler auth resolves",
|
|
129
|
+
status: !schedulerAuthRef
|
|
130
|
+
? "complete"
|
|
131
|
+
: schedulerAuthConfigured
|
|
132
|
+
? "complete"
|
|
133
|
+
: (schedulerLinked ? "pending" : "blocked"),
|
|
134
|
+
description: !schedulerAuthRef
|
|
135
|
+
? "The scheduler needs no secret."
|
|
136
|
+
: schedulerAuthConfigured
|
|
137
|
+
? `Scheduler secret ${schedulerAuthRef} resolves in this runtime.`
|
|
138
|
+
: `Set one of ${envCandidates(schedulerAuthRef).join(" / ")} in .env.local (or your hosted runtime), then reopen.`,
|
|
139
|
+
action: schedulerAuthRef && !schedulerAuthConfigured ? { id: "open-settings", label: "Manage in Settings", href: "/settings/apis-webhooks" } : null,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
steps.push({
|
|
143
|
+
id: "persistence",
|
|
144
|
+
label: "Enable durable persistence",
|
|
145
|
+
status: durableReady ? "complete" : "active",
|
|
146
|
+
description: durableReady
|
|
147
|
+
? `Durable store ready (${(envBackedAdapter || durableAdapters[0]).label}).`
|
|
148
|
+
: "Set a durable store's env keys in .env.local (or your hosted runtime) so serverless runs survive redeploy.",
|
|
149
|
+
// Fully surface every thin adapter + its exact env keys + readiness, so no
|
|
150
|
+
// adapter is assumed server-side without being shown to the operator.
|
|
151
|
+
hint: durableReady
|
|
152
|
+
? undefined
|
|
153
|
+
: adapters.length
|
|
154
|
+
? adapters.map((a) => `${a.label}: ${(a.requiredEnv || []).length ? a.requiredEnv.join(", ") : "no env"}${a.configured ? " ✓" : ""}`).join(" · ")
|
|
155
|
+
: "No persistence adapter signal yet — open env-status.",
|
|
156
|
+
action: durableReady ? null : { id: "open-settings", label: "Manage in Settings", href: "/settings" },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
steps.push({
|
|
161
|
+
id: "run",
|
|
162
|
+
label: isServerless ? "Run on the scheduler" : "Run locally",
|
|
163
|
+
status: "optional",
|
|
164
|
+
description: isServerless
|
|
165
|
+
? "Once the scheduler, auth, and store are ready, run delegates to the serverless scheduler."
|
|
166
|
+
: "Run this workflow in-process.",
|
|
167
|
+
action: inline({ id: "run-sandbox", label: "Run" }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const s of steps) { if (!s.hint) delete s.hint; }
|
|
171
|
+
|
|
172
|
+
const required = steps.filter((s) => s.status !== "optional");
|
|
173
|
+
const completedCount = required.filter((s) => s.status === "complete").length;
|
|
174
|
+
const totalCount = required.length;
|
|
175
|
+
const complete = completedCount >= totalCount;
|
|
176
|
+
const nextStep = steps.find((s) => s.status === "active")
|
|
177
|
+
|| steps.find((s) => s.status === "pending")
|
|
178
|
+
|| steps.find((s) => s.status === "blocked")
|
|
179
|
+
|| null;
|
|
180
|
+
|
|
181
|
+
// Milestone score tied to evidence.
|
|
182
|
+
let score = isServerless ? 10 : 40;
|
|
183
|
+
if (adapterChosen) score = Math.max(score, isServerless ? 25 : 70);
|
|
184
|
+
if (isServerless && schedulerLinked) score = Math.max(score, 45);
|
|
185
|
+
if (isServerless && schedulerHealthy) score = Math.max(score, 60);
|
|
186
|
+
if (isServerless && schedulerAuthConfigured && schedulerLinked) score = Math.max(score, 75);
|
|
187
|
+
if (isServerless && durableReady) score = Math.max(score, 90);
|
|
188
|
+
if (complete) score = 100;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
kind: STATE_KIND,
|
|
192
|
+
version: 1,
|
|
193
|
+
locality,
|
|
194
|
+
isServerless,
|
|
195
|
+
adapterChosen,
|
|
196
|
+
schedulerLinked,
|
|
197
|
+
schedulerHealthy,
|
|
198
|
+
schedulerAuthConfigured,
|
|
199
|
+
durableReady,
|
|
200
|
+
completedCount,
|
|
201
|
+
totalCount,
|
|
202
|
+
complete,
|
|
203
|
+
score,
|
|
204
|
+
nextStepId: nextStep ? nextStep.id : null,
|
|
205
|
+
nextAction: nextStep && nextStep.action ? { stepId: nextStep.id, ...nextStep.action } : null,
|
|
206
|
+
headline: !isServerless
|
|
207
|
+
? "This workflow runs locally."
|
|
208
|
+
: complete
|
|
209
|
+
? "This workflow is scheduled and durable."
|
|
210
|
+
: "Make this workflow persistent and scheduled.",
|
|
211
|
+
steps,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { STATE_KIND, deriveSandboxServerlessState };
|