bosun 0.41.8 → 0.41.10

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 (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
@@ -1,6 +1,6 @@
1
1
  import { execSync, spawn, spawnSync } from "node:child_process";
2
2
  import * as nodeCrypto from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, chmodSync, createWriteStream, createReadStream, writeFileSync, unlinkSync, watchFile, unwatchFile, readdirSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, chmodSync, createWriteStream, createReadStream, writeFileSync, unlinkSync, watchFile, unwatchFile, readdirSync, statSync } from "node:fs";
4
4
  import { open, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
5
  import { createServer } from "node:http";
6
6
  import { get as httpsGet } from "node:https";
@@ -80,6 +80,7 @@ import {
80
80
  scaffoldAgentProfiles,
81
81
  getBosunHomeDir,
82
82
  syncAutoDiscoveredLibraryEntries,
83
+ resolveAgentProfileLibraryMetadata,
83
84
  } from "../infra/library-manager.mjs";
84
85
  import {
85
86
  listCatalog,
@@ -319,6 +320,17 @@ function unblockInternalTask(taskId, options = {}) {
319
320
  }
320
321
  }
321
322
 
323
+ function resetExecutorTaskThrottleState(taskId, options = {}) {
324
+ const executor = uiDeps.getInternalExecutor?.() || null;
325
+ const fn = executor?.resetTaskThrottleState;
326
+ if (typeof fn !== "function") return false;
327
+ try {
328
+ return fn.call(executor, taskId, options) === true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
322
334
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
323
335
  const repoRoot = resolveRepoRoot();
324
336
  const uiRootPreferred = resolve(__dirname, "..", "ui");
@@ -729,10 +741,44 @@ function isVoiceAgentProfileEntry(entry, profile) {
729
741
  }
730
742
 
731
743
  function resolveAgentProfileType(entry, profile) {
732
- const explicit = String(profile?.agentType || "").trim().toLowerCase();
733
- if (explicit === "voice" || explicit === "task" || explicit === "chat") return explicit;
734
- if (isVoiceAgentProfileEntry(entry, profile)) return "voice";
735
- return "task";
744
+ return resolveAgentProfileLibraryMetadata(entry, profile).agentType;
745
+ }
746
+
747
+ function resolveAgentProfileLibraryView(entry, profile, storageScope) {
748
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
749
+ return {
750
+ ...entry,
751
+ storageScope,
752
+ ...metadata,
753
+ };
754
+ }
755
+
756
+ function listManualAgentProfiles(workspaceContext) {
757
+ const resolved = listLibraryEntriesAcrossRoots(workspaceContext, { type: "agent" });
758
+ const profiles = [];
759
+ for (const { entry, rootInfo } of resolved.entries) {
760
+ const profile = getEntryContent(rootInfo.rootDir, entry);
761
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
762
+ if (metadata.agentCategory !== "interactive" || metadata.showInChatDropdown !== true) continue;
763
+ const sectionLabel = metadata.interactiveLabel
764
+ || (metadata.interactiveMode ? metadata.interactiveMode.charAt(0).toUpperCase() + metadata.interactiveMode.slice(1) : "Manual");
765
+ profiles.push({
766
+ id: entry.id,
767
+ name: entry.name || entry.id,
768
+ description: entry.description || "",
769
+ storageScope: rootInfo.scope,
770
+ model: String(profile?.model || "").trim() || null,
771
+ sdk: String(profile?.sdk || "").trim() || null,
772
+ sectionLabel,
773
+ ...metadata,
774
+ });
775
+ }
776
+ profiles.sort((a, b) => {
777
+ const sectionCmp = String(a.sectionLabel || "").localeCompare(String(b.sectionLabel || ""));
778
+ if (sectionCmp !== 0) return sectionCmp;
779
+ return String(a.name || a.id).localeCompare(String(b.name || b.id));
780
+ });
781
+ return profiles;
736
782
  }
737
783
 
738
784
  function resolveVoiceLibraryRoot(callContext = {}) {
@@ -3741,6 +3787,418 @@ function shouldHideGeneratedWorkflowFromList(workflow = {}) {
3741
3787
  return false;
3742
3788
  }
3743
3789
 
3790
+ const WORKFLOW_COPILOT_PROMPT_MAX_CHARS = 6000;
3791
+
3792
+ function formatWorkflowCopilotBlock(value, maxChars = WORKFLOW_COPILOT_PROMPT_MAX_CHARS) {
3793
+ try {
3794
+ const json = JSON.stringify(value, null, 2);
3795
+ if (json.length <= maxChars) return json;
3796
+ const omitted = json.length - maxChars;
3797
+ return `${json.slice(0, maxChars)}\n\n[truncated ${omitted} chars]`;
3798
+ } catch {
3799
+ const text = String(value ?? "");
3800
+ if (text.length <= maxChars) return text;
3801
+ const omitted = text.length - maxChars;
3802
+ return `${text.slice(0, maxChars)}\n\n[truncated ${omitted} chars]`;
3803
+ }
3804
+ }
3805
+
3806
+ function formatWorkflowCopilotTimestamp(value) {
3807
+ const numeric = Number(value);
3808
+ const date =
3809
+ Number.isFinite(numeric) && numeric > 0
3810
+ ? new Date(numeric)
3811
+ : new Date(String(value || ""));
3812
+ return Number.isFinite(date.getTime()) ? date.toISOString() : "—";
3813
+ }
3814
+
3815
+ function buildWorkflowNodeTypeMap(wfMod) {
3816
+ const map = new Map();
3817
+ try {
3818
+ const list = typeof wfMod?.listNodeTypes === "function" ? wfMod.listNodeTypes() : [];
3819
+ for (const entry of Array.isArray(list) ? list : []) {
3820
+ const type = String(entry?.type || "").trim();
3821
+ if (!type) continue;
3822
+ map.set(type, entry);
3823
+ }
3824
+ } catch {}
3825
+ return map;
3826
+ }
3827
+
3828
+ function buildWorkflowNodeGraphIndex(workflow = {}) {
3829
+ const incoming = new Map();
3830
+ const outgoing = new Map();
3831
+ const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
3832
+ const edges = Array.isArray(workflow?.edges) ? workflow.edges : [];
3833
+ for (const node of nodes) {
3834
+ const nodeId = String(node?.id || "").trim();
3835
+ if (!nodeId) continue;
3836
+ incoming.set(nodeId, []);
3837
+ outgoing.set(nodeId, []);
3838
+ }
3839
+ for (const edge of edges) {
3840
+ const sourceId = String(edge?.source || edge?.from || "").trim();
3841
+ const targetId = String(edge?.target || edge?.to || "").trim();
3842
+ const link = {
3843
+ source: sourceId,
3844
+ target: targetId,
3845
+ sourcePort: String(edge?.sourcePort || edge?.fromPort || "").trim() || "default",
3846
+ targetPort: String(edge?.targetPort || edge?.toPort || "").trim() || "default",
3847
+ };
3848
+ if (sourceId) {
3849
+ if (!outgoing.has(sourceId)) outgoing.set(sourceId, []);
3850
+ outgoing.get(sourceId).push(link);
3851
+ }
3852
+ if (targetId) {
3853
+ if (!incoming.has(targetId)) incoming.set(targetId, []);
3854
+ incoming.get(targetId).push(link);
3855
+ }
3856
+ }
3857
+ return { incoming, outgoing };
3858
+ }
3859
+
3860
+ function summarizeWorkflowNodesForCopilot(workflow = {}, nodeTypeMap = new Map(), limit = 20) {
3861
+ const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
3862
+ if (!nodes.length) return "No nodes defined.";
3863
+ const lines = nodes.slice(0, limit).map((node, index) => {
3864
+ const nodeId = String(node?.id || `node-${index + 1}`).trim();
3865
+ const nodeType = String(node?.type || "unknown").trim() || "unknown";
3866
+ const nodeName =
3867
+ String(node?.label || node?.name || node?.title || "").trim() || null;
3868
+ const typeInfo = nodeTypeMap.get(nodeType) || null;
3869
+ const configKeys = Object.keys(node?.config || {}).slice(0, 6);
3870
+ const schemaKeys = Object.keys(typeInfo?.schema?.properties || {}).slice(0, 6);
3871
+ const extras = [];
3872
+ if (configKeys.length) extras.push(`config keys: ${configKeys.join(", ")}`);
3873
+ if (schemaKeys.length) extras.push(`schema keys: ${schemaKeys.join(", ")}`);
3874
+ return `${index + 1}. ${nodeId} [${nodeType}]${nodeName ? ` - ${nodeName}` : ""}${extras.length ? ` (${extras.join(" | ")})` : ""}`;
3875
+ });
3876
+ if (nodes.length > limit) {
3877
+ lines.push(`... ${nodes.length - limit} more node(s) omitted`);
3878
+ }
3879
+ return lines.join("\n");
3880
+ }
3881
+
3882
+ function summarizeWorkflowEdgesForCopilot(workflow = {}, limit = 24) {
3883
+ const edges = Array.isArray(workflow?.edges) ? workflow.edges : [];
3884
+ if (!edges.length) return "No edges defined.";
3885
+ const lines = edges.slice(0, limit).map((edge, index) => {
3886
+ const from = String(edge?.source || edge?.from || "?").trim() || "?";
3887
+ const to = String(edge?.target || edge?.to || "?").trim() || "?";
3888
+ const fromPort = String(edge?.sourcePort || edge?.fromPort || "").trim();
3889
+ const toPort = String(edge?.targetPort || edge?.toPort || "").trim();
3890
+ const portSummary = fromPort || toPort ? ` (${fromPort || "default"} -> ${toPort || "default"})` : "";
3891
+ return `${index + 1}. ${from} -> ${to}${portSummary}`;
3892
+ });
3893
+ if (edges.length > limit) {
3894
+ lines.push(`... ${edges.length - limit} more edge(s) omitted`);
3895
+ }
3896
+ return lines.join("\n");
3897
+ }
3898
+
3899
+ function summarizeAdjacentWorkflowLinks(nodeId, graphIndex, direction = "incoming", limit = 10) {
3900
+ const collection = direction === "outgoing" ? graphIndex?.outgoing : graphIndex?.incoming;
3901
+ const links = Array.isArray(collection?.get(nodeId)) ? collection.get(nodeId) : [];
3902
+ if (!links.length) return direction === "outgoing" ? "No downstream edges." : "No upstream edges.";
3903
+ const lines = links.slice(0, limit).map((link, index) => {
3904
+ const from = String(link?.source || "?").trim() || "?";
3905
+ const to = String(link?.target || "?").trim() || "?";
3906
+ const fromPort = String(link?.sourcePort || "").trim() || "default";
3907
+ const toPort = String(link?.targetPort || "").trim() || "default";
3908
+ return `${index + 1}. ${from}:${fromPort} -> ${to}:${toPort}`;
3909
+ });
3910
+ if (links.length > limit) {
3911
+ lines.push(`... ${links.length - limit} more ${direction} edge(s) omitted`);
3912
+ }
3913
+ return lines.join("\n");
3914
+ }
3915
+
3916
+ function buildWorkflowNodeCopilotPrompt(workflow = {}, node = null, wfMod = null) {
3917
+ if (!node) return "";
3918
+ const nodeTypeMap = buildWorkflowNodeTypeMap(wfMod);
3919
+ const graphIndex = buildWorkflowNodeGraphIndex(workflow);
3920
+ const nodeType = String(node?.type || "unknown").trim() || "unknown";
3921
+ const typeInfo = nodeTypeMap.get(nodeType) || null;
3922
+ const schemaKeys = Object.keys(typeInfo?.schema?.properties || {});
3923
+ const workflowName = String(workflow?.name || workflow?.id || "Unknown Workflow").trim();
3924
+ return [
3925
+ "You are helping inside Bosun with workflow node authoring.",
3926
+ "Explain what this node does, how it interacts with adjacent nodes, what is risky or underspecified, and which exact config edits Bosun should make next.",
3927
+ "",
3928
+ "Return:",
3929
+ "1. Node purpose",
3930
+ "2. Upstream/downstream interaction notes",
3931
+ "3. Risks, missing validation, or bad defaults",
3932
+ "4. Concrete config or graph edits",
3933
+ "",
3934
+ "Workflow Context",
3935
+ `- Workflow: ${workflowName}`,
3936
+ `- Workflow ID: ${String(workflow?.id || "").trim() || "(unknown)"}`,
3937
+ `- Node count: ${Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0}`,
3938
+ `- Edge count: ${Array.isArray(workflow?.edges) ? workflow.edges.length : 0}`,
3939
+ "",
3940
+ "Node Context",
3941
+ `- Node ID: ${String(node?.id || "").trim() || "(unknown)"}`,
3942
+ `- Label: ${String(node?.label || node?.name || "").trim() || "(none)"}`,
3943
+ `- Type: ${nodeType}`,
3944
+ `- Category: ${nodeType.split(".")[0] || "unknown"}`,
3945
+ `- Description: ${String(typeInfo?.description || "").trim() || "None provided."}`,
3946
+ `- Schema keys: ${schemaKeys.length ? schemaKeys.join(", ") : "None"}`,
3947
+ "",
3948
+ "Upstream Edges",
3949
+ summarizeAdjacentWorkflowLinks(String(node?.id || "").trim(), graphIndex, "incoming"),
3950
+ "",
3951
+ "Downstream Edges",
3952
+ summarizeAdjacentWorkflowLinks(String(node?.id || "").trim(), graphIndex, "outgoing"),
3953
+ "",
3954
+ "Node Config",
3955
+ formatWorkflowCopilotBlock(node?.config || {}, 3500),
3956
+ "",
3957
+ "Raw Node Snapshot",
3958
+ formatWorkflowCopilotBlock(node, 3500),
3959
+ ].join("\n");
3960
+ }
3961
+
3962
+ function buildWorkflowCopilotContextPayload(workflow = {}, opts = {}) {
3963
+ const workflowId = String(workflow?.id || "").trim() || "(unknown)";
3964
+ const workflowName = String(workflow?.name || workflowId).trim() || workflowId;
3965
+ const description = String(workflow?.description || "").trim() || "None provided.";
3966
+ const intent = String(opts?.intent || "explain").trim().toLowerCase();
3967
+ const nodeId = String(opts?.nodeId || "").trim();
3968
+ const node = nodeId
3969
+ ? (Array.isArray(workflow?.nodes) ? workflow.nodes.find((entry) => String(entry?.id || "").trim() === nodeId) : null)
3970
+ : null;
3971
+ if (nodeId && !node) {
3972
+ return null;
3973
+ }
3974
+ if (node) {
3975
+ return {
3976
+ prompt: buildWorkflowNodeCopilotPrompt(workflow, node, opts?.wfMod),
3977
+ context: {
3978
+ scope: "workflow-node",
3979
+ intent,
3980
+ workflowId,
3981
+ workflowName,
3982
+ nodeId,
3983
+ nodeType: String(node?.type || "").trim() || null,
3984
+ },
3985
+ };
3986
+ }
3987
+ const nodeTypeMap = buildWorkflowNodeTypeMap(opts?.wfMod);
3988
+ return {
3989
+ prompt: [
3990
+ "You are helping inside Bosun with a workflow authoring review.",
3991
+ "Explain this workflow in plain English, identify the riskiest nodes or missing guardrails, and suggest the smallest high-leverage improvements.",
3992
+ "",
3993
+ "Return:",
3994
+ "1. A concise summary of what the workflow is trying to do",
3995
+ "2. The critical nodes or transitions that matter most",
3996
+ "3. Failure risks, ambiguity, or missing validation/retry/observability",
3997
+ "4. Concrete next edits Bosun should make",
3998
+ "",
3999
+ "Workflow Context",
4000
+ `- Name: ${workflowName}`,
4001
+ `- ID: ${workflowId}`,
4002
+ `- Enabled: ${workflow?.enabled === false ? "no" : "yes"}`,
4003
+ `- Core workflow: ${workflow?.core === true ? "yes" : "no"}`,
4004
+ `- Description: ${description}`,
4005
+ `- Node count: ${Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0}`,
4006
+ `- Edge count: ${Array.isArray(workflow?.edges) ? workflow.edges.length : 0}`,
4007
+ "",
4008
+ "Variables",
4009
+ formatWorkflowCopilotBlock(workflow?.variables || {}, 2500),
4010
+ "",
4011
+ "Node Summary",
4012
+ summarizeWorkflowNodesForCopilot(workflow, nodeTypeMap),
4013
+ "",
4014
+ "Edge Summary",
4015
+ summarizeWorkflowEdgesForCopilot(workflow),
4016
+ "",
4017
+ "Raw Workflow Snapshot",
4018
+ formatWorkflowCopilotBlock({
4019
+ id: workflow?.id,
4020
+ name: workflow?.name,
4021
+ description: workflow?.description,
4022
+ enabled: workflow?.enabled,
4023
+ core: workflow?.core,
4024
+ metadata: workflow?.metadata || {},
4025
+ }, 2500),
4026
+ ].join("\n"),
4027
+ context: {
4028
+ scope: "workflow",
4029
+ intent,
4030
+ workflowId,
4031
+ workflowName,
4032
+ },
4033
+ };
4034
+ }
4035
+
4036
+ function summarizeRunNodeStatusesForCopilot(run = {}, limit = 25) {
4037
+ const statuses = run?.detail?.nodeStatuses && typeof run.detail.nodeStatuses === "object"
4038
+ ? run.detail.nodeStatuses
4039
+ : {};
4040
+ const entries = Object.entries(statuses);
4041
+ if (!entries.length) return "No node status data recorded.";
4042
+ const lines = entries.slice(0, limit).map(([nodeId, status], index) => (
4043
+ `${index + 1}. ${nodeId}: ${String(status || "unknown").trim() || "unknown"}`
4044
+ ));
4045
+ if (entries.length > limit) {
4046
+ lines.push(`... ${entries.length - limit} more node status entries omitted`);
4047
+ }
4048
+ return lines.join("\n");
4049
+ }
4050
+
4051
+ function summarizeRunNodeOutputsForCopilot(run = {}, limit = 12) {
4052
+ const outputs = run?.detail?.nodeOutputs && typeof run.detail.nodeOutputs === "object"
4053
+ ? run.detail.nodeOutputs
4054
+ : {};
4055
+ const entries = Object.entries(outputs);
4056
+ if (!entries.length) return "No node outputs recorded.";
4057
+ const lines = entries.slice(0, limit).map(([nodeId, output], index) => {
4058
+ const summary = String(output?.summary || "").trim();
4059
+ const narrative = String(output?.narrative || "").trim();
4060
+ if (summary || narrative) {
4061
+ const parts = [summary, narrative].filter(Boolean);
4062
+ return `${index + 1}. ${nodeId}: ${parts.join(" | ")}`;
4063
+ }
4064
+ return `${index + 1}. ${nodeId}: ${formatWorkflowCopilotBlock(output, 500)}`;
4065
+ });
4066
+ if (entries.length > limit) {
4067
+ lines.push(`... ${entries.length - limit} more node output entries omitted`);
4068
+ }
4069
+ return lines.join("\n");
4070
+ }
4071
+
4072
+ function buildRunNodeCopilotPrompt(run = {}, workflow = {}, nodeId = "", opts = {}) {
4073
+ const safeNodeId = String(nodeId || "").trim();
4074
+ if (!safeNodeId) return "";
4075
+ const workflowNode = Array.isArray(workflow?.nodes)
4076
+ ? workflow.nodes.find((node) => String(node?.id || "").trim() === safeNodeId) || null
4077
+ : null;
4078
+ const nodeStatuses = run?.detail?.nodeStatuses && typeof run.detail.nodeStatuses === "object"
4079
+ ? run.detail.nodeStatuses
4080
+ : {};
4081
+ const nodeOutputs = run?.detail?.nodeOutputs && typeof run.detail.nodeOutputs === "object"
4082
+ ? run.detail.nodeOutputs
4083
+ : {};
4084
+ const rawErrors = Array.isArray(run?.detail?.errors) ? run.detail.errors : [];
4085
+ const relatedErrors = rawErrors.filter((entry) => formatWorkflowCopilotBlock(entry, 500).includes(safeNodeId));
4086
+ const failed = String(opts?.intent || "").trim().toLowerCase() === "fix"
4087
+ || String(nodeStatuses[safeNodeId] || "").trim().toLowerCase() === "failed";
4088
+ return [
4089
+ "You are helping inside Bosun with workflow run node analysis.",
4090
+ failed
4091
+ ? "Diagnose why this node failed or behaved incorrectly, identify the root cause, and propose the smallest concrete fix Bosun should make."
4092
+ : "Explain what happened in this node during the run, what inputs or outputs matter, and what Bosun should inspect next.",
4093
+ "",
4094
+ "Return:",
4095
+ "1. Short diagnosis",
4096
+ "2. Evidence from this node",
4097
+ failed ? "3. Concrete fix plan" : "3. Recommended next checks",
4098
+ failed ? "4. Retry advice for this node or run" : "4. Risks or follow-up notes",
4099
+ "",
4100
+ "Run Context",
4101
+ `- Workflow ID: ${String(run?.workflowId || "").trim() || "(unknown)"}`,
4102
+ `- Run ID: ${String(run?.runId || "").trim() || "(unknown)"}`,
4103
+ `- Run status: ${String(run?.status || "unknown").trim() || "unknown"}`,
4104
+ `- Started: ${formatWorkflowCopilotTimestamp(run?.startedAt)}`,
4105
+ `- Finished: ${run?.endedAt ? formatWorkflowCopilotTimestamp(run.endedAt) : "Running"}`,
4106
+ "",
4107
+ "Node Context",
4108
+ `- Node ID: ${safeNodeId}`,
4109
+ `- Node label: ${String(workflowNode?.label || workflowNode?.name || "").trim() || "(none)"}`,
4110
+ `- Node type: ${String(workflowNode?.type || "").trim() || "(unknown)"}`,
4111
+ `- Node status: ${String(nodeStatuses[safeNodeId] || "unknown").trim() || "unknown"}`,
4112
+ "",
4113
+ "Node Config",
4114
+ formatWorkflowCopilotBlock(workflowNode?.config || {}, 2500),
4115
+ "",
4116
+ "Node Output",
4117
+ formatWorkflowCopilotBlock(nodeOutputs[safeNodeId] ?? null, 3500),
4118
+ "",
4119
+ "Node Errors",
4120
+ formatWorkflowCopilotBlock(relatedErrors.length ? relatedErrors : rawErrors.slice(0, 8), 3000),
4121
+ "",
4122
+ "Node Forensics",
4123
+ formatWorkflowCopilotBlock(opts?.forensics || null, 3500),
4124
+ ].join("\n");
4125
+ }
4126
+
4127
+ function buildRunCopilotContextPayload(run = {}, opts = {}) {
4128
+ const workflow = opts?.workflow || {};
4129
+ const workflowId = String(run?.workflowId || workflow?.id || "").trim() || "(unknown)";
4130
+ const workflowName = String(run?.workflowName || workflow?.name || workflowId).trim() || workflowId;
4131
+ const intent = String(opts?.intent || "ask").trim().toLowerCase();
4132
+ const nodeId = String(opts?.nodeId || "").trim();
4133
+ if (nodeId) {
4134
+ return {
4135
+ prompt: buildRunNodeCopilotPrompt(run, workflow, nodeId, {
4136
+ intent,
4137
+ forensics: opts?.nodeForensics || null,
4138
+ }),
4139
+ context: {
4140
+ scope: "run-node",
4141
+ intent,
4142
+ runId: String(run?.runId || "").trim() || "(unknown)",
4143
+ workflowId,
4144
+ workflowName,
4145
+ nodeId,
4146
+ },
4147
+ };
4148
+ }
4149
+ const errors = Array.isArray(run?.detail?.errors) ? run.detail.errors : [];
4150
+ const logs = Array.isArray(run?.detail?.logs) ? run.detail.logs : [];
4151
+ const failed = intent === "fix" || String(run?.status || "").trim().toLowerCase() === "failed";
4152
+ return {
4153
+ prompt: [
4154
+ "You are helping inside Bosun with workflow run analysis.",
4155
+ failed
4156
+ ? "Analyze why this workflow run failed. Identify the root cause, name the most likely failing node or nodes, propose the smallest concrete fix, and say whether Bosun should retry from failed state or rerun from the beginning."
4157
+ : "Explain what happened in this workflow run, call out unusual or risky behavior, and suggest the next debugging or hardening steps.",
4158
+ "",
4159
+ "Return:",
4160
+ "1. Short diagnosis",
4161
+ "2. Evidence from the run",
4162
+ failed ? "3. Concrete fix plan" : "3. Recommended next steps",
4163
+ failed ? "4. Retry advice: retry from failed, rerun from start, or do not retry yet" : "4. Risks or follow-up checks",
4164
+ "",
4165
+ "Run Context",
4166
+ `- Workflow: ${workflowName}`,
4167
+ `- Workflow ID: ${workflowId}`,
4168
+ `- Run ID: ${String(run?.runId || "").trim() || "(unknown)"}`,
4169
+ `- Status: ${String(run?.status || "unknown").trim() || "unknown"}`,
4170
+ `- Started: ${formatWorkflowCopilotTimestamp(run?.startedAt)}`,
4171
+ `- Finished: ${run?.endedAt ? formatWorkflowCopilotTimestamp(run.endedAt) : "Running"}`,
4172
+ `- Duration (ms): ${Number.isFinite(Number(run?.duration)) ? Number(run.duration) : 0}`,
4173
+ `- Active nodes: ${Number(run?.activeNodeCount || 0)}`,
4174
+ `- Error count: ${Number(run?.errorCount || errors.length)}`,
4175
+ `- Log count: ${Number(run?.logCount || logs.length)}`,
4176
+ "",
4177
+ "Node Statuses",
4178
+ summarizeRunNodeStatusesForCopilot(run),
4179
+ "",
4180
+ "Node Output Summaries",
4181
+ summarizeRunNodeOutputsForCopilot(run),
4182
+ "",
4183
+ "Errors",
4184
+ formatWorkflowCopilotBlock(errors.slice(0, 8), 3500),
4185
+ "",
4186
+ "Recent Logs",
4187
+ formatWorkflowCopilotBlock(logs.slice(-40), 4000),
4188
+ "",
4189
+ "Run Forensics",
4190
+ formatWorkflowCopilotBlock(opts?.runForensics || null, 3000),
4191
+ ].join("\n"),
4192
+ context: {
4193
+ scope: "run",
4194
+ intent,
4195
+ runId: String(run?.runId || "").trim() || "(unknown)",
4196
+ workflowId,
4197
+ workflowName,
4198
+ },
4199
+ };
4200
+ }
4201
+
3744
4202
  function normalizeWorktreePath(input) {
3745
4203
  if (!input) return "";
3746
4204
  try {
@@ -4072,6 +4530,13 @@ const workflowEngineListenerCleanup = new WeakMap();
4072
4530
  let workflowWsSeq = 0;
4073
4531
  let uiInstanceLockPath = "";
4074
4532
  let uiInstanceLockHeld = false;
4533
+
4534
+ // ── Unified setup state (entrypoint integration) ────────────────────────────
4535
+ // When running via entrypoint.mjs, `_setupMode` starts true and flips to false
4536
+ // once the wizard completes. In standalone ui-server mode, it's always false.
4537
+ let _setupMode = false;
4538
+ /** @type {(() => void)|null} */
4539
+ let _setupOnComplete = null;
4075
4540
  let _sessionTokenLastTouchedAt = 0;
4076
4541
  let _localRequestAddressCache = {
4077
4542
  loadedAt: 0,
@@ -4134,19 +4599,39 @@ async function resolveExecPrimaryPrompt() {
4134
4599
 
4135
4600
  /**
4136
4601
  * Resolve the bosun config directory. Falls back through:
4137
- * 1. uiDeps.configDir (injected at server start)
4138
- * 2. BOSUN_DIR env var
4139
- * 3. ~/bosun (standard default)
4602
+ * 1. uiDeps.configDir (explicitly injected at server start)
4603
+ * 2. BOSUN_CONFIG_PATH parent directory
4604
+ * 3. repo-local config when REPO_ROOT explicitly points at a managed repo
4605
+ * 4. BOSUN_HOME/BOSUN_DIR/test sandbox/default home dir
4140
4606
  * Ensures the directory exists.
4141
4607
  */
4142
4608
  function resolveUiConfigDir() {
4143
4609
  const sandbox = ensureTestRuntimeSandbox();
4610
+ if (uiDeps.configDir) {
4611
+ const injectedDir = resolve(String(uiDeps.configDir));
4612
+ try { mkdirSync(injectedDir, { recursive: true }); } catch { /* ok */ }
4613
+ return injectedDir;
4614
+ }
4144
4615
  if (process.env.BOSUN_CONFIG_PATH) {
4145
4616
  const fromConfigPath = dirname(resolve(process.env.BOSUN_CONFIG_PATH));
4146
4617
  try { mkdirSync(fromConfigPath, { recursive: true }); } catch { /* ok */ }
4147
- if (!uiDeps.configDir) uiDeps.configDir = fromConfigPath;
4148
4618
  return fromConfigPath;
4149
4619
  }
4620
+ if (String(process.env.REPO_ROOT || "").trim()) {
4621
+ const repoLocalConfigDirCandidates = [
4622
+ resolve(repoRoot, ".bosun"),
4623
+ repoRoot,
4624
+ ];
4625
+ for (const candidate of repoLocalConfigDirCandidates) {
4626
+ try {
4627
+ if (!existsSync(resolve(candidate, "bosun.config.json"))) continue;
4628
+ mkdirSync(candidate, { recursive: true });
4629
+ return candidate;
4630
+ } catch {
4631
+ // Fall through to the next candidate.
4632
+ }
4633
+ }
4634
+ }
4150
4635
  const isWslInteropRuntime = Boolean(
4151
4636
  process.env.WSL_DISTRO_NAME
4152
4637
  || process.env.WSL_INTEROP
@@ -4176,8 +4661,6 @@ function resolveUiConfigDir() {
4176
4661
  || resolve(baseDir, "bosun");
4177
4662
  if (dir) {
4178
4663
  try { mkdirSync(dir, { recursive: true }); } catch { /* ok */ }
4179
- // Cache it so subsequent calls don't re-resolve
4180
- if (!uiDeps.configDir) uiDeps.configDir = dir;
4181
4664
  }
4182
4665
  return dir;
4183
4666
  }
@@ -8379,10 +8862,49 @@ function getExpectedDesktopApiKey() {
8379
8862
  return fromEnv;
8380
8863
  }
8381
8864
 
8865
+ /**
8866
+ * Check whether the request carries a valid user-configured API key.
8867
+ * Set via BOSUN_API_KEY env var — intended for external clients (Electron app
8868
+ * connecting to a remote/Docker instance, CLI tools, third-party integrations).
8869
+ * Unlike the desktop API key (auto-generated per install), this is a
8870
+ * user-chosen secret that can be set in .env, docker-compose.yml, etc.
8871
+ */
8872
+ function checkApiKey(req) {
8873
+ const expected = String(process.env.BOSUN_API_KEY || "").trim();
8874
+ if (!expected || expected.length < 8) return false;
8875
+ const authHeader = req.headers.authorization || "";
8876
+ // Accept as Bearer token
8877
+ if (authHeader.startsWith("Bearer ")) {
8878
+ const provided = authHeader.slice(7).trim();
8879
+ if (!provided) return false;
8880
+ try {
8881
+ const a = Buffer.from(provided);
8882
+ const b = Buffer.from(expected);
8883
+ if (a.length !== b.length) return false;
8884
+ return timingSafeEqual(a, b);
8885
+ } catch { return false; }
8886
+ }
8887
+ // Accept as X-API-Key header
8888
+ const apiKeyHeader = String(req.headers["x-api-key"] || "").trim();
8889
+ if (apiKeyHeader) {
8890
+ try {
8891
+ const a = Buffer.from(apiKeyHeader);
8892
+ const b = Buffer.from(expected);
8893
+ if (a.length !== b.length) return false;
8894
+ return timingSafeEqual(a, b);
8895
+ } catch { return false; }
8896
+ }
8897
+ return false;
8898
+ }
8899
+
8382
8900
  async function requireAuth(req) {
8383
8901
  if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
8902
+ // User-configured API key (BOSUN_API_KEY env) — external clients, Docker
8903
+ // Issue a session cookie so the browser can authenticate WebSocket upgrades
8904
+ // (the WS constructor doesn't support custom headers).
8905
+ if (checkApiKey(req)) return { ok: true, source: "api-key", issueSessionCookie: true };
8384
8906
  // Desktop Electron API key — non-expiring, set via BOSUN_DESKTOP_API_KEY env
8385
- if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: false };
8907
+ if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: true };
8386
8908
  // Session token (browser access)
8387
8909
  if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
8388
8910
  // Telegram initData HMAC
@@ -8406,6 +8928,19 @@ async function requireAuth(req) {
8406
8928
 
8407
8929
  function resolveWsAuthSource(req, url) {
8408
8930
  if (isAllowUnsafe()) return "unsafe";
8931
+ // User-configured API key (BOSUN_API_KEY) — check Bearer header and query param
8932
+ if (checkApiKey(req)) return "api-key";
8933
+ const qApiKey = url.searchParams.get("apiKey") || "";
8934
+ if (qApiKey) {
8935
+ const expected = String(process.env.BOSUN_API_KEY || "").trim();
8936
+ if (expected && expected.length >= 8) {
8937
+ try {
8938
+ const a = Buffer.from(qApiKey);
8939
+ const b = Buffer.from(expected);
8940
+ if (a.length === b.length && timingSafeEqual(a, b)) return "api-key";
8941
+ } catch { /* ignore */ }
8942
+ }
8943
+ }
8409
8944
  // Desktop Electron API key (query param: desktopKey=...)
8410
8945
  const desktopKey = getExpectedDesktopApiKey();
8411
8946
  if (desktopKey) {
@@ -10337,7 +10872,21 @@ function withinDays(entry, days) {
10337
10872
  }
10338
10873
 
10339
10874
  async function readCompletedSessionEntries(maxLines = 100_000) {
10340
- const sessionLogPath = resolve(repoRoot, ".cache", "session-accumulator.jsonl");
10875
+ // Check multiple candidate paths — repoRoot may be the monorepo root
10876
+ // while data lives under the bosun subdirectory.
10877
+ const candidates = [
10878
+ resolve(repoRoot, ".cache", "session-accumulator.jsonl"),
10879
+ resolve(repoRoot, "bosun", ".cache", "session-accumulator.jsonl"),
10880
+ ];
10881
+ let sessionLogPath = candidates[0];
10882
+ for (const candidate of candidates) {
10883
+ try {
10884
+ if (existsSync(candidate) && statSync(candidate).size > 0) {
10885
+ sessionLogPath = candidate;
10886
+ break;
10887
+ }
10888
+ } catch { /* stat failed, skip */ }
10889
+ }
10341
10890
  const entries = await readJsonlTail(sessionLogPath, maxLines);
10342
10891
  return {
10343
10892
  sessionLogPath,
@@ -10629,10 +11178,21 @@ async function buildUsageAnalytics(days) {
10629
11178
  function resolveAgentWorkLogDir() {
10630
11179
  const candidates = [
10631
11180
  resolve(repoRoot, ".cache", "agent-work-logs"),
11181
+ // When repoRoot is the monorepo root, data lives under bosun/.cache
11182
+ resolve(repoRoot, "bosun", ".cache", "agent-work-logs"),
10632
11183
  // Legacy path used by older task-executor builds.
10633
11184
  resolve(repoRoot, "..", "..", ".cache", "agent-work-logs"),
10634
11185
  resolve(repoRoot, "..", ".cache", "agent-work-logs"),
10635
11186
  ];
11187
+ // Prefer directories that actually contain data (non-empty stream file).
11188
+ for (const dir of candidates) {
11189
+ if (!existsSync(dir)) continue;
11190
+ const streamFile = resolve(dir, "agent-work-stream.jsonl");
11191
+ try {
11192
+ if (existsSync(streamFile) && statSync(streamFile).size > 0) return dir;
11193
+ } catch { /* stat failed, skip */ }
11194
+ }
11195
+ // Fall back to first existing directory, then first candidate.
10636
11196
  for (const dir of candidates) {
10637
11197
  if (existsSync(dir)) return dir;
10638
11198
  }
@@ -12898,7 +13458,14 @@ async function handleApi(req, res, url) {
12898
13458
  : undefined;
12899
13459
  const metadataPatch = buildTaskMetadataPatch(body || {});
12900
13460
  const requestedStatus = normalizeTaskStatusKey(body?.status);
12901
- const clearsBlockedState = requestedStatus === "todo";
13461
+ const currentLooksBlocked =
13462
+ normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
13463
+ Boolean(previousTask?.blockedReason) ||
13464
+ Boolean(previousTask?.cooldownUntil) ||
13465
+ Boolean(previousTask?.meta?.autoRecovery) ||
13466
+ Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
13467
+ const clearsBlockedState =
13468
+ currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
12902
13469
  const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
12903
13470
  ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
12904
13471
  : null;
@@ -12932,6 +13499,9 @@ async function handleApi(req, res, url) {
12932
13499
  ? await adapter.updateTask(taskId, patch)
12933
13500
  : await adapter.updateTaskStatus(taskId, patch.status);
12934
13501
  const updated = withTaskMetadataTopLevel(updatedRaw);
13502
+ if (clearsBlockedState) {
13503
+ resetExecutorTaskThrottleState(taskId);
13504
+ }
12935
13505
  const nextStatus = updated?.status || patch.status || null;
12936
13506
  const lifecycleAction = inferLifecycleAction(
12937
13507
  previousTask?.status || null,
@@ -13039,7 +13609,14 @@ async function handleApi(req, res, url) {
13039
13609
  : undefined;
13040
13610
  const metadataPatch = buildTaskMetadataPatch(body || {});
13041
13611
  const requestedStatus = normalizeTaskStatusKey(body?.status);
13042
- const clearsBlockedState = requestedStatus === "todo";
13612
+ const currentLooksBlocked =
13613
+ normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
13614
+ Boolean(previousTask?.blockedReason) ||
13615
+ Boolean(previousTask?.cooldownUntil) ||
13616
+ Boolean(previousTask?.meta?.autoRecovery) ||
13617
+ Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
13618
+ const clearsBlockedState =
13619
+ currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
13043
13620
  const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
13044
13621
  ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
13045
13622
  : null;
@@ -13073,6 +13650,9 @@ async function handleApi(req, res, url) {
13073
13650
  ? await adapter.updateTask(taskId, patch)
13074
13651
  : await adapter.updateTaskStatus(taskId, patch.status);
13075
13652
  const updated = withTaskMetadataTopLevel(updatedRaw);
13653
+ if (clearsBlockedState) {
13654
+ resetExecutorTaskThrottleState(taskId);
13655
+ }
13076
13656
  const nextStatus = updated?.status || patch.status || null;
13077
13657
  const lifecycleAction = inferLifecycleAction(
13078
13658
  previousTask?.status || null,
@@ -13491,16 +14071,14 @@ async function handleApi(req, res, url) {
13491
14071
  search: search || undefined,
13492
14072
  });
13493
14073
  let data = resolved.entries.map(({ entry, rootInfo }) => {
13494
- const base = {
13495
- ...entry,
13496
- storageScope: rootInfo.scope,
13497
- };
13498
- if (entry?.type !== "agent") return base;
14074
+ if (entry?.type !== "agent") {
14075
+ return {
14076
+ ...entry,
14077
+ storageScope: rootInfo.scope,
14078
+ };
14079
+ }
13499
14080
  const profile = getEntryContent(rootInfo.rootDir, entry);
13500
- return {
13501
- ...base,
13502
- agentType: resolveAgentProfileType(entry, profile),
13503
- };
14081
+ return resolveAgentProfileLibraryView(entry, profile, rootInfo.scope);
13504
14082
  });
13505
14083
  if (type === "agent" && (agentTypeRaw === "voice" || agentTypeRaw === "task" || agentTypeRaw === "chat")) {
13506
14084
  data = data.filter((entry) => {
@@ -15986,6 +16564,47 @@ async function handleApi(req, res, url) {
15986
16564
  return;
15987
16565
  }
15988
16566
 
16567
+ if (action === "copilot-context" && (req.method === "GET" || req.method === "POST")) {
16568
+ const run = typeof engine.getRunDetail === "function" ? engine.getRunDetail(runId) : null;
16569
+ if (!run) {
16570
+ jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
16571
+ return;
16572
+ }
16573
+ const requestBody = req.method === "POST" ? await readJsonBody(req).catch(() => ({})) : {};
16574
+ const intent = String(
16575
+ requestBody?.intent || url.searchParams.get("intent") || "ask",
16576
+ ).trim().toLowerCase();
16577
+ const nodeId = String(
16578
+ requestBody?.nodeId || url.searchParams.get("nodeId") || "",
16579
+ ).trim();
16580
+ const workflow = typeof engine.get === "function"
16581
+ ? engine.get(String(run?.workflowId || "").trim())
16582
+ : null;
16583
+ const nodeForensics =
16584
+ nodeId && typeof engine.getNodeForensics === "function"
16585
+ ? engine.getNodeForensics(runId, nodeId)
16586
+ : null;
16587
+ const runForensics = typeof engine.getRunForensics === "function"
16588
+ ? engine.getRunForensics(runId)
16589
+ : null;
16590
+ const payload = buildRunCopilotContextPayload(run, {
16591
+ intent,
16592
+ nodeId,
16593
+ workflow,
16594
+ nodeForensics,
16595
+ runForensics,
16596
+ });
16597
+ if (!payload?.prompt) {
16598
+ jsonResponse(res, 404, {
16599
+ ok: false,
16600
+ error: nodeId ? "Node not found in workflow run" : "Workflow run copilot context unavailable",
16601
+ });
16602
+ return;
16603
+ }
16604
+ jsonResponse(res, 200, { ok: true, ...payload });
16605
+ return;
16606
+ }
16607
+
15989
16608
  if (action === "stop" && req.method === "POST") {
15990
16609
  if (typeof engine.cancelRun !== "function") {
15991
16610
  jsonResponse(res, 501, { ok: false, error: "Workflow run cancellation is not supported by this engine." });
@@ -16065,6 +16684,125 @@ async function handleApi(req, res, url) {
16065
16684
  return;
16066
16685
  }
16067
16686
 
16687
+ // ── GET /api/workflows/runs/:id/nodes/:nodeId — node forensics ──
16688
+ if (action === "nodes" && req.method === "GET") {
16689
+ const nodeId = (segments[2] || "").trim();
16690
+ if (!nodeId) {
16691
+ jsonResponse(res, 400, { ok: false, error: "nodeId is required" });
16692
+ return;
16693
+ }
16694
+ const forensics = typeof engine.getNodeForensics === "function"
16695
+ ? engine.getNodeForensics(runId, nodeId)
16696
+ : null;
16697
+ if (!forensics) {
16698
+ jsonResponse(res, 404, { ok: false, error: "Node not found in run" });
16699
+ return;
16700
+ }
16701
+ jsonResponse(res, 200, { ok: true, forensics });
16702
+ return;
16703
+ }
16704
+
16705
+ // ── GET /api/workflows/runs/:id/forensics — full run forensics ──
16706
+ if (action === "forensics" && req.method === "GET") {
16707
+ const forensics = typeof engine.getRunForensics === "function"
16708
+ ? engine.getRunForensics(runId)
16709
+ : null;
16710
+ if (!forensics) {
16711
+ jsonResponse(res, 404, { ok: false, error: "Run not found" });
16712
+ return;
16713
+ }
16714
+ jsonResponse(res, 200, { ok: true, forensics });
16715
+ return;
16716
+ }
16717
+
16718
+ // ── GET /api/workflows/runs/:id/evaluate — run evaluation ───────
16719
+ if (action === "evaluate" && req.method === "GET") {
16720
+ const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
16721
+ if (!run) {
16722
+ jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
16723
+ return;
16724
+ }
16725
+ const { RunEvaluator } = await import("../workflow/run-evaluator.mjs");
16726
+ const evaluator = new RunEvaluator();
16727
+ const evaluation = evaluator.evaluate(run);
16728
+ jsonResponse(res, 200, { ok: true, runId, evaluation });
16729
+ return;
16730
+ }
16731
+
16732
+ // ── POST /api/workflows/runs/:id/snapshot — create snapshot ─────
16733
+ if (action === "snapshot" && req.method === "POST") {
16734
+ if (typeof engine.createRunSnapshot !== "function") {
16735
+ jsonResponse(res, 501, { ok: false, error: "Snapshots not supported" });
16736
+ return;
16737
+ }
16738
+ const result = engine.createRunSnapshot(runId);
16739
+ if (!result) {
16740
+ jsonResponse(res, 404, { ok: false, error: "Run not found" });
16741
+ return;
16742
+ }
16743
+ jsonResponse(res, 200, { ok: true, ...result });
16744
+ return;
16745
+ }
16746
+
16747
+ // ── GET /api/workflows/runs/:id/snapshots — list snapshots ──────
16748
+ if (action === "snapshots" && req.method === "GET") {
16749
+ const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
16750
+ const workflowId = run?.workflowId || run?.detail?.data?._workflowId || null;
16751
+ const snapshots = typeof engine.listSnapshots === "function"
16752
+ ? engine.listSnapshots(workflowId)
16753
+ : [];
16754
+ jsonResponse(res, 200, { ok: true, snapshots });
16755
+ return;
16756
+ }
16757
+
16758
+ // ── POST /api/workflows/runs/:id/restore — restore from snapshot ─
16759
+ if (action === "restore" && req.method === "POST") {
16760
+ if (typeof engine.restoreFromSnapshot !== "function") {
16761
+ jsonResponse(res, 501, { ok: false, error: "Restore not supported" });
16762
+ return;
16763
+ }
16764
+ const body = await readJsonBody(req);
16765
+ const variables = body?.variables || {};
16766
+ const result = await engine.restoreFromSnapshot(runId, { variables });
16767
+ jsonResponse(res, 200, {
16768
+ ok: true,
16769
+ runId: result.runId,
16770
+ snapshotId: result.snapshotId,
16771
+ workflowId: result.workflowId,
16772
+ status: result.status,
16773
+ });
16774
+ return;
16775
+ }
16776
+
16777
+ // ── POST /api/workflows/runs/:id/remediate — apply fix actions ──
16778
+ if (action === "remediate" && req.method === "POST") {
16779
+ const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
16780
+ if (!run) {
16781
+ jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
16782
+ return;
16783
+ }
16784
+ const body = await readJsonBody(req);
16785
+ const actions = Array.isArray(body?.actions) ? body.actions : [];
16786
+ const autoRetry = body?.autoRetry === true;
16787
+ const applied = [];
16788
+ for (const action of actions) {
16789
+ applied.push({ type: action.type, nodeId: action.nodeId, status: "noted" });
16790
+ }
16791
+ let retryResult = null;
16792
+ if (autoRetry && run.status === "failed") {
16793
+ const mode = actions.length <= 1 ? "from_failed" : "from_scratch";
16794
+ retryResult = await engine.retryRun(runId, { mode });
16795
+ }
16796
+ jsonResponse(res, 200, {
16797
+ ok: true,
16798
+ runId,
16799
+ applied,
16800
+ retryTriggered: !!retryResult,
16801
+ retryRunId: retryResult?.retryRunId || null,
16802
+ });
16803
+ return;
16804
+ }
16805
+
16068
16806
  // ── GET /api/workflows/runs/:id ─────────────────────────────────
16069
16807
  const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
16070
16808
  if (!run) {
@@ -16079,7 +16817,7 @@ async function handleApi(req, res, url) {
16079
16817
  }
16080
16818
 
16081
16819
  // Dynamic routes: /api/workflows/:id, /api/workflows/:id/execute, /api/workflows/:id/runs
16082
- if (path.startsWith("/api/workflows/") && !path.startsWith("/api/workflows/save") && !path.startsWith("/api/workflows/templates") && !path.startsWith("/api/workflows/install") && !path.startsWith("/api/workflows/node") && !path.startsWith("/api/workflows/runs")) {
16820
+ if (path.startsWith("/api/workflows/") && !path.startsWith("/api/workflows/save") && !path.startsWith("/api/workflows/templates") && !path.startsWith("/api/workflows/install") && !path.startsWith("/api/workflows/node") && !path.startsWith("/api/workflows/runs") && !path.match(/^\/api\/workflows\/[^/]+\/webhook/) && !path.match(/^\/api\/workflows\/[^/]+\/schedule$/)) {
16083
16821
  const segments = path.replace("/api/workflows/", "").split("/");
16084
16822
  const workflowId = segments[0];
16085
16823
  const action = segments[1] || "";
@@ -16183,6 +16921,223 @@ async function handleApi(req, res, url) {
16183
16921
  return;
16184
16922
  }
16185
16923
 
16924
+ if (action === "copilot-context" && (req.method === "GET" || req.method === "POST")) {
16925
+ const requestBody = req.method === "POST" ? await readJsonBody(req).catch(() => ({})) : {};
16926
+ const persistedWorkflow = engine.get(workflowId);
16927
+ if (!persistedWorkflow && !requestBody?.workflow) {
16928
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found" });
16929
+ return;
16930
+ }
16931
+ const draftWorkflow =
16932
+ requestBody?.workflow && typeof requestBody.workflow === "object"
16933
+ ? requestBody.workflow
16934
+ : null;
16935
+ const workflow = draftWorkflow || persistedWorkflow;
16936
+ const intent = String(
16937
+ requestBody?.intent || url.searchParams.get("intent") || "explain",
16938
+ ).trim().toLowerCase();
16939
+ const nodeId = String(
16940
+ requestBody?.nodeId || url.searchParams.get("nodeId") || "",
16941
+ ).trim();
16942
+ const payload = buildWorkflowCopilotContextPayload(workflow, {
16943
+ intent,
16944
+ nodeId,
16945
+ wfMod: wfCtx.wfMod,
16946
+ });
16947
+ if (!payload?.prompt) {
16948
+ jsonResponse(res, 404, {
16949
+ ok: false,
16950
+ error: nodeId ? "Workflow node not found" : "Workflow copilot context unavailable",
16951
+ });
16952
+ return;
16953
+ }
16954
+ jsonResponse(res, 200, { ok: true, ...payload });
16955
+ return;
16956
+ }
16957
+
16958
+ // ── Workflow Code View ─────────────────────────────────────────
16959
+ if (action === "code" && req.method === "GET") {
16960
+ const wf = engine.get(workflowId);
16961
+ if (!wf) { jsonResponse(res, 404, { ok: false, error: "Workflow not found" }); return; }
16962
+ try {
16963
+ const { serializeWorkflowToCode } = await import("../workflow/workflow-serializer.mjs");
16964
+ const result = serializeWorkflowToCode(wf);
16965
+ jsonResponse(res, 200, result);
16966
+ } catch (err) {
16967
+ jsonResponse(res, 500, { ok: false, error: err.message });
16968
+ }
16969
+ return;
16970
+ }
16971
+
16972
+ if (action === "code" && req.method === "PUT") {
16973
+ const wf = engine.get(workflowId);
16974
+ if (!wf) { jsonResponse(res, 404, { ok: false, error: "Workflow not found" }); return; }
16975
+ try {
16976
+ const body = await readJsonBody(req);
16977
+ const { deserializeCodeToWorkflow } = await import("../workflow/workflow-serializer.mjs");
16978
+ const result = deserializeCodeToWorkflow(body?.code);
16979
+ if (result.errors.length > 0) {
16980
+ jsonResponse(res, 400, { ok: false, error: "Validation failed", errors: result.errors });
16981
+ return;
16982
+ }
16983
+ const merged = { ...wf, ...result.workflow, id: wf.id };
16984
+ engine.save(merged);
16985
+ jsonResponse(res, 200, { ok: true, workflow: merged });
16986
+ } catch (err) {
16987
+ jsonResponse(res, 500, { ok: false, error: err.message });
16988
+ }
16989
+ return;
16990
+ }
16991
+
16992
+ // ── Workflow Code Validation (POST /api/workflows/:id/code/validate) ──
16993
+ {
16994
+ const subAction = segments[2] || "";
16995
+ if (action === "code" && subAction === "validate" && req.method === "POST") {
16996
+ try {
16997
+ const body = await readJsonBody(req);
16998
+ const { validateWorkflowCode } = await import("../workflow/workflow-serializer.mjs");
16999
+ const result = validateWorkflowCode(body?.code);
17000
+ jsonResponse(res, 200, result);
17001
+ } catch (err) {
17002
+ jsonResponse(res, 500, { ok: false, error: err.message });
17003
+ }
17004
+ return;
17005
+ }
17006
+ }
17007
+
17008
+ // ── Workflow Export ──────────────────────────────────────────────
17009
+ if (action === "export" && req.method === "GET") {
17010
+ const wf = engine.get(workflowId);
17011
+ if (!wf) { jsonResponse(res, 404, { ok: false, error: "Workflow not found" }); return; }
17012
+ try {
17013
+ const { generateExportBundle } = await import("../workflow/workflow-exporter.mjs");
17014
+ const baseUrl = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host || "localhost:3077"}`;
17015
+ const bundle = generateExportBundle(wf, { baseUrl });
17016
+ jsonResponse(res, 200, bundle);
17017
+ } catch (err) {
17018
+ jsonResponse(res, 500, { ok: false, error: err.message });
17019
+ }
17020
+ return;
17021
+ }
17022
+
17023
+ // ── Cancel a running workflow run ─────────────────────────────
17024
+ if (action === "cancel" && req.method === "POST") {
17025
+ try {
17026
+ const runId = segments[2] || workflowId; // /api/workflows/:wfId/cancel/:runId or /api/workflows/:wfId/cancel
17027
+ const result = engine.cancelRun?.(runId);
17028
+ if (result === false || result === undefined) {
17029
+ jsonResponse(res, 404, { ok: false, error: "Run not found or not cancellable" });
17030
+ } else {
17031
+ jsonResponse(res, 200, { ok: true, cancelled: true, runId });
17032
+ }
17033
+ } catch (err) {
17034
+ jsonResponse(res, 500, { ok: false, error: err.message });
17035
+ }
17036
+ return;
17037
+ }
17038
+
17039
+ // ── Credential management ─────────────────────────────────────
17040
+ if (action === "credentials") {
17041
+ try {
17042
+ const { CredentialStore } = await import("../workflow/credential-store.mjs");
17043
+ const ctx = resolveActiveWorkspaceExecutionContext();
17044
+ const store = new CredentialStore({ configDir: ctx.workspaceDir });
17045
+ const credName = segments[2] || "";
17046
+
17047
+ if (req.method === "GET" && !credName) {
17048
+ // List all credentials (metadata only, no values)
17049
+ jsonResponse(res, 200, { ok: true, credentials: store.list() });
17050
+ return;
17051
+ }
17052
+
17053
+ if (req.method === "GET" && credName) {
17054
+ // Get single credential metadata
17055
+ const cred = store.get(credName);
17056
+ if (!cred) { jsonResponse(res, 404, { ok: false, error: "Credential not found" }); return; }
17057
+ jsonResponse(res, 200, { ok: true, credential: cred });
17058
+ return;
17059
+ }
17060
+
17061
+ if (req.method === "POST" || req.method === "PUT") {
17062
+ const body = await readJsonBody(req);
17063
+ const parsed = typeof body === "string" ? JSON.parse(body) : body;
17064
+ const name = credName || parsed.name;
17065
+ if (!name) { jsonResponse(res, 400, { ok: false, error: "Credential name is required" }); return; }
17066
+ const result = store.set(name, {
17067
+ type: parsed.type || "static",
17068
+ value: parsed.value,
17069
+ label: parsed.label,
17070
+ provider: parsed.provider,
17071
+ scopes: parsed.scopes,
17072
+ });
17073
+ jsonResponse(res, 200, { ok: true, ...result });
17074
+ return;
17075
+ }
17076
+
17077
+ if (req.method === "DELETE" && credName) {
17078
+ const deleted = store.delete(credName);
17079
+ jsonResponse(res, deleted ? 200 : 404, { ok: deleted, deleted: credName });
17080
+ return;
17081
+ }
17082
+
17083
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
17084
+ } catch (err) {
17085
+ jsonResponse(res, 500, { ok: false, error: err.message });
17086
+ }
17087
+ return;
17088
+ }
17089
+
17090
+ // ── Evaluation history + trends ───────────────────────────────
17091
+ if (action === "evaluations" && req.method === "GET") {
17092
+ try {
17093
+ const { RunEvaluator } = await import("../workflow/run-evaluator.mjs");
17094
+ const ctx = resolveActiveWorkspaceExecutionContext();
17095
+ const evaluator = new RunEvaluator({ configDir: ctx.workspaceDir });
17096
+ const history = evaluator.getHistory(workflowId);
17097
+ const trend = evaluator.getTrend(workflowId);
17098
+ jsonResponse(res, 200, { ok: true, workflowId, history, trend });
17099
+ } catch (err) {
17100
+ jsonResponse(res, 500, { ok: false, error: err.message });
17101
+ }
17102
+ return;
17103
+ }
17104
+
17105
+ // ── Cron schedule preview ─────────────────────────────────────
17106
+ if (action === "cron-preview" && req.method === "GET") {
17107
+ try {
17108
+ const { parseCronExpression } = await import("../workflow/cron-scheduler.mjs");
17109
+ const urlObj = new URL(path, "http://localhost");
17110
+ const expr = urlObj.searchParams?.get("expr") || segments[2] || "* * * * *";
17111
+ const tz = urlObj.searchParams?.get("tz") || null;
17112
+ const count = Math.min(20, Math.max(1, Number(urlObj.searchParams?.get("n")) || 5));
17113
+ const parsed = parseCronExpression(expr);
17114
+ const nextOccurrences = parsed.nextN(count, new Date(), tz);
17115
+ jsonResponse(res, 200, {
17116
+ ok: true,
17117
+ expression: expr,
17118
+ timezone: tz || "UTC",
17119
+ next: nextOccurrences.map((d) => d.toISOString()),
17120
+ });
17121
+ } catch (err) {
17122
+ jsonResponse(res, 400, { ok: false, error: err.message });
17123
+ }
17124
+ return;
17125
+ }
17126
+
17127
+ // ── Webhook delivery log ──────────────────────────────────────
17128
+ if (action === "webhook-log" && req.method === "GET") {
17129
+ try {
17130
+ const { WebhookGateway } = await import("../workflow/webhook-gateway.mjs");
17131
+ const ctx = resolveActiveWorkspaceExecutionContext();
17132
+ const gateway = new WebhookGateway({ configDir: ctx.workspaceDir });
17133
+ const log = gateway.getDeliveryLog(workflowId);
17134
+ jsonResponse(res, 200, { ok: true, workflowId, deliveries: log });
17135
+ } catch (err) {
17136
+ jsonResponse(res, 500, { ok: false, error: err.message });
17137
+ }
17138
+ return;
17139
+ }
17140
+
16186
17141
  if (req.method === "DELETE") {
16187
17142
  await engine.delete(workflowId);
16188
17143
  jsonResponse(res, 200, { ok: true });
@@ -16199,6 +17154,294 @@ async function handleApi(req, res, url) {
16199
17154
  return;
16200
17155
  }
16201
17156
 
17157
+ /* ═══════════════════════════════════════════════════════════
17158
+ * Public Webhook Receiver (no auth — token-validated)
17159
+ * ═══════════════════════════════════════════════════════════ */
17160
+
17161
+ if (path.startsWith("/api/webhooks/") && (req.method === "POST" || req.method === "GET")) {
17162
+ const webhookSegments = path.replace("/api/webhooks/", "").split("/");
17163
+ const webhookWorkflowId = webhookSegments[0] || "";
17164
+ const webhookToken = webhookSegments[1] || "";
17165
+
17166
+ if (!webhookWorkflowId || !webhookToken) {
17167
+ jsonResponse(res, 400, { ok: false, error: "Invalid webhook URL" });
17168
+ return;
17169
+ }
17170
+
17171
+ try {
17172
+ const { WebhookGateway } = await import("../workflow/webhook-gateway.mjs");
17173
+ const ctx = resolveActiveWorkspaceExecutionContext();
17174
+ const gateway = new WebhookGateway({ configDir: ctx.workspaceDir });
17175
+
17176
+ // Validate token (constant-time)
17177
+ if (!gateway.validateToken(webhookWorkflowId, webhookToken)) {
17178
+ jsonResponse(res, 401, { ok: false, error: "Invalid webhook token" });
17179
+ return;
17180
+ }
17181
+
17182
+ // Check active state
17183
+ if (!gateway.isActive(webhookWorkflowId)) {
17184
+ jsonResponse(res, 403, { ok: false, error: "Webhook is inactive" });
17185
+ return;
17186
+ }
17187
+
17188
+ // Rate limit
17189
+ const rateResult = gateway.checkRateLimit(webhookWorkflowId);
17190
+ if (!rateResult.allowed) {
17191
+ res.writeHead(429, {
17192
+ "Content-Type": "application/json; charset=utf-8",
17193
+ "Retry-After": String(Math.ceil((rateResult.resetAt - Date.now()) / 1000)),
17194
+ "Access-Control-Allow-Origin": "*",
17195
+ });
17196
+ res.end(JSON.stringify({ ok: false, error: "Rate limit exceeded" }));
17197
+ return;
17198
+ }
17199
+
17200
+ // Read payload
17201
+ let webhookPayload = {};
17202
+ if (req.method === "POST") {
17203
+ try {
17204
+ webhookPayload = (await readJsonBody(req)) || {};
17205
+ } catch {
17206
+ webhookPayload = {};
17207
+ }
17208
+ } else {
17209
+ // GET: use query params as payload
17210
+ const qp = {};
17211
+ for (const [k, v] of url.searchParams.entries()) qp[k] = v;
17212
+ webhookPayload = qp;
17213
+ }
17214
+
17215
+ // Check workflow exists and is enabled
17216
+ const wfCtx = await getWorkflowRequestContext(url);
17217
+ if (!wfCtx?.ok) {
17218
+ jsonResponse(res, 503, { ok: false, error: "Workflow engine unavailable" });
17219
+ return;
17220
+ }
17221
+ const wf = wfCtx.engine.get(webhookWorkflowId);
17222
+ if (!wf || wf.enabled === false) {
17223
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found or disabled" });
17224
+ return;
17225
+ }
17226
+
17227
+ // Check method against trigger node config
17228
+ const triggerNode = (wf.nodes || []).find((n) => n.type === "trigger.webhook");
17229
+ if (triggerNode?.config?.method && req.method !== triggerNode.config.method) {
17230
+ jsonResponse(res, 405, { ok: false, error: `Method ${req.method} not allowed for this webhook` });
17231
+ return;
17232
+ }
17233
+
17234
+ // Dispatch
17235
+ const runId = `webhook-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
17236
+ const eventPayload = {
17237
+ eventType: "webhook",
17238
+ webhookPayload,
17239
+ workflowId: webhookWorkflowId,
17240
+ method: req.method,
17241
+ headers: { ...req.headers },
17242
+ receivedAt: new Date().toISOString(),
17243
+ };
17244
+
17245
+ Promise.resolve()
17246
+ .then(() => wfCtx.engine.execute(webhookWorkflowId, eventPayload))
17247
+ .then((result) => {
17248
+ const status = Array.isArray(result?.errors) && result.errors.length > 0 ? "failed" : "completed";
17249
+ console.log(`[webhooks] run ${status} workflow=${webhookWorkflowId} runId=${result?.id || runId}`);
17250
+ })
17251
+ .catch((err) => {
17252
+ console.warn(`[webhooks] run failed workflow=${webhookWorkflowId}: ${err?.message || err}`);
17253
+ });
17254
+
17255
+ jsonResponse(res, 200, { ok: true, accepted: true, runId });
17256
+ } catch (err) {
17257
+ jsonResponse(res, 500, { ok: false, error: err.message });
17258
+ }
17259
+ return;
17260
+ }
17261
+
17262
+ /* ═══════════════════════════════════════════════════════════
17263
+ * Webhook Management API (authenticated, under /api/workflows/:id/webhook)
17264
+ * ═══════════════════════════════════════════════════════════ */
17265
+
17266
+ const webhookMgmtMatch = path.match(/^\/api\/workflows\/([^/]+)\/webhook(?:\/([^/]+))?$/);
17267
+ if (webhookMgmtMatch) {
17268
+ const wfId = webhookMgmtMatch[1];
17269
+ const subAction = webhookMgmtMatch[2] || "";
17270
+
17271
+ try {
17272
+ const wfCtx = await getWorkflowRequestContext(url);
17273
+ if (!wfCtx?.ok) {
17274
+ jsonResponse(res, wfCtx.status || 503, { ok: false, error: wfCtx.error });
17275
+ return;
17276
+ }
17277
+ const wf = wfCtx.engine.get(wfId);
17278
+ if (!wf) {
17279
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found" });
17280
+ return;
17281
+ }
17282
+
17283
+ const { WebhookGateway } = await import("../workflow/webhook-gateway.mjs");
17284
+ const ctx = resolveActiveWorkspaceExecutionContext();
17285
+ const gateway = new WebhookGateway({ configDir: ctx.workspaceDir });
17286
+
17287
+ // POST /api/workflows/:id/webhook/rotate
17288
+ if (subAction === "rotate" && req.method === "POST") {
17289
+ const newToken = gateway.rotateToken(wfId);
17290
+ jsonResponse(res, 200, {
17291
+ ok: true,
17292
+ workflowId: wfId,
17293
+ token: newToken,
17294
+ webhookUrl: `/api/webhooks/${wfId}/${newToken}`,
17295
+ });
17296
+ return;
17297
+ }
17298
+
17299
+ // GET /api/workflows/:id/webhook
17300
+ if (req.method === "GET") {
17301
+ const info = gateway.getWebhookInfo(wfId);
17302
+ jsonResponse(res, 200, {
17303
+ ok: true,
17304
+ workflowId: wfId,
17305
+ webhook: info
17306
+ ? { active: info.active, token: info.token, createdAt: info.createdAt, webhookUrl: `/api/webhooks/${wfId}/${info.token}` }
17307
+ : null,
17308
+ });
17309
+ return;
17310
+ }
17311
+
17312
+ // POST /api/workflows/:id/webhook — activate + generate token
17313
+ if (req.method === "POST") {
17314
+ const token = gateway.generateToken(wfId);
17315
+ jsonResponse(res, 201, {
17316
+ ok: true,
17317
+ workflowId: wfId,
17318
+ token,
17319
+ webhookUrl: `/api/webhooks/${wfId}/${token}`,
17320
+ active: true,
17321
+ });
17322
+ return;
17323
+ }
17324
+
17325
+ // DELETE /api/workflows/:id/webhook — deactivate + revoke
17326
+ if (req.method === "DELETE") {
17327
+ gateway.revokeToken(wfId);
17328
+ jsonResponse(res, 200, { ok: true, workflowId: wfId, deactivated: true });
17329
+ return;
17330
+ }
17331
+
17332
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
17333
+ } catch (err) {
17334
+ jsonResponse(res, 500, { ok: false, error: err.message });
17335
+ }
17336
+ return;
17337
+ }
17338
+
17339
+ /* ═══════════════════════════════════════════════════════════
17340
+ * Cron / Schedule Management API
17341
+ * ═══════════════════════════════════════════════════════════ */
17342
+
17343
+ const scheduleMgmtMatch = path.match(/^\/api\/workflows\/([^/]+)\/schedule$/);
17344
+ if (scheduleMgmtMatch) {
17345
+ const wfId = scheduleMgmtMatch[1];
17346
+
17347
+ try {
17348
+ const wfCtx = await getWorkflowRequestContext(url);
17349
+ if (!wfCtx?.ok) {
17350
+ jsonResponse(res, wfCtx.status || 503, { ok: false, error: wfCtx.error });
17351
+ return;
17352
+ }
17353
+ const engine = wfCtx.engine;
17354
+ const wf = engine.get(wfId);
17355
+ if (!wf) {
17356
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found" });
17357
+ return;
17358
+ }
17359
+
17360
+ const triggerNode = (wf.nodes || []).find(
17361
+ (n) => n.type === "trigger.schedule" || n.type === "trigger.scheduled_once",
17362
+ );
17363
+
17364
+ // GET /api/workflows/:id/schedule
17365
+ if (req.method === "GET") {
17366
+ const config = triggerNode?.config || {};
17367
+ jsonResponse(res, 200, {
17368
+ ok: true,
17369
+ workflowId: wfId,
17370
+ schedule: {
17371
+ cron: config.cron || null,
17372
+ intervalMs: config.intervalMs || null,
17373
+ timezone: config.timezone || "UTC",
17374
+ hasTrigger: Boolean(triggerNode),
17375
+ },
17376
+ });
17377
+ return;
17378
+ }
17379
+
17380
+ // POST /api/workflows/:id/schedule — update cron expression
17381
+ if (req.method === "POST") {
17382
+ const body = await readJsonBody(req);
17383
+ const cronExpr = String(body?.cron || "").trim();
17384
+ const intervalMs = body?.intervalMs != null ? Number(body.intervalMs) : undefined;
17385
+
17386
+ // Validate cron if provided
17387
+ if (cronExpr) {
17388
+ try {
17389
+ const { parseCronExpression } = await import("../workflow/cron-scheduler.mjs");
17390
+ parseCronExpression(cronExpr);
17391
+ } catch (err) {
17392
+ jsonResponse(res, 400, { ok: false, error: `Invalid cron expression: ${err?.message || err}` });
17393
+ return;
17394
+ }
17395
+ }
17396
+
17397
+ if (!triggerNode) {
17398
+ jsonResponse(res, 400, { ok: false, error: "Workflow has no schedule trigger node" });
17399
+ return;
17400
+ }
17401
+
17402
+ // Update the trigger node config
17403
+ if (!triggerNode.config) triggerNode.config = {};
17404
+ if (cronExpr) {
17405
+ triggerNode.config.cron = cronExpr;
17406
+ }
17407
+ if (intervalMs !== undefined && Number.isFinite(intervalMs) && intervalMs > 0) {
17408
+ triggerNode.config.intervalMs = intervalMs;
17409
+ }
17410
+ if (body?.timezone) {
17411
+ triggerNode.config.timezone = String(body.timezone);
17412
+ }
17413
+
17414
+ // Save the updated workflow
17415
+ engine.save(wf);
17416
+ jsonResponse(res, 200, {
17417
+ ok: true,
17418
+ workflowId: wfId,
17419
+ schedule: {
17420
+ cron: triggerNode.config.cron || null,
17421
+ intervalMs: triggerNode.config.intervalMs || null,
17422
+ timezone: triggerNode.config.timezone || "UTC",
17423
+ },
17424
+ });
17425
+ return;
17426
+ }
17427
+
17428
+ // DELETE /api/workflows/:id/schedule — disable schedule
17429
+ if (req.method === "DELETE") {
17430
+ if (triggerNode?.config) {
17431
+ delete triggerNode.config.cron;
17432
+ engine.save(wf);
17433
+ }
17434
+ jsonResponse(res, 200, { ok: true, workflowId: wfId, scheduleDisabled: true });
17435
+ return;
17436
+ }
17437
+
17438
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
17439
+ } catch (err) {
17440
+ jsonResponse(res, 500, { ok: false, error: err.message });
17441
+ }
17442
+ return;
17443
+ }
17444
+
16202
17445
  /* ═══════════════════════════════════════════════════════════
16203
17446
  * Manual Flows API endpoints
16204
17447
  * ═══════════════════════════════════════════════════════════ */
@@ -16836,6 +18079,7 @@ async function handleApi(req, res, url) {
16836
18079
  status: "todo",
16837
18080
  source: "manual-retry",
16838
18081
  });
18082
+ resetExecutorTaskThrottleState(taskId);
16839
18083
  if (!nextTask) {
16840
18084
  if (typeof adapter.updateTask === "function") {
16841
18085
  await adapter.updateTask(taskId, {
@@ -16900,6 +18144,7 @@ async function handleApi(req, res, url) {
16900
18144
  status: targetStatus,
16901
18145
  source: "api.tasks.unblock",
16902
18146
  });
18147
+ resetExecutorTaskThrottleState(taskId);
16903
18148
  if (!updatedTask) {
16904
18149
  const nextMeta = task?.meta && typeof task.meta === "object"
16905
18150
  ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
@@ -17079,10 +18324,13 @@ async function handleApi(req, res, url) {
17079
18324
 
17080
18325
  if (path === "/api/agents/available" && req.method === "GET") {
17081
18326
  try {
18327
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false })
18328
+ || { workspaceDir: repoRoot, workspaceRoot: repoRoot };
17082
18329
  const agents = getAvailableAgents();
17083
18330
  const active = getPrimaryAgentSelection();
17084
18331
  const mode = getAgentMode();
17085
- jsonResponse(res, 200, { ok: true, agents, active, mode });
18332
+ const manualAgents = listManualAgentProfiles(workspaceContext);
18333
+ jsonResponse(res, 200, { ok: true, agents, active, mode, manualAgents });
17086
18334
  } catch (err) {
17087
18335
  jsonResponse(res, 500, { ok: false, error: err.message });
17088
18336
  }
@@ -19255,6 +20503,21 @@ export async function startTelegramUiServer(options = {}) {
19255
20503
  const taskStoreModule = await ensureTaskStoreApi();
19256
20504
  const sandbox = ensureTestRuntimeSandbox();
19257
20505
 
20506
+ // ── Setup mode integration (entrypoint.mjs) ───────────────────────────
20507
+ if (options.setupMode) {
20508
+ _setupMode = true;
20509
+ _setupOnComplete = () => {
20510
+ _setupMode = false;
20511
+ console.log("[telegram-ui] setup complete — portal mode active");
20512
+ // Notify entrypoint to start monitor
20513
+ try {
20514
+ import("../entrypoint.mjs").then((m) => {
20515
+ if (typeof m.markSetupComplete === "function") m.markSetupComplete();
20516
+ }).catch(() => {});
20517
+ } catch { /* not running via entrypoint */ }
20518
+ };
20519
+ }
20520
+
19258
20521
  const rawPort = options.port ?? getDefaultPort();
19259
20522
  const configuredPort = Number(rawPort);
19260
20523
  const isTestRun =
@@ -19427,6 +20690,18 @@ export async function startTelegramUiServer(options = {}) {
19427
20690
  return;
19428
20691
  }
19429
20692
 
20693
+ // Docker / load-balancer health check — no auth required
20694
+ if (url.pathname === "/healthz") {
20695
+ try {
20696
+ const { getHealthStatus } = await import("../infra/health-status.mjs");
20697
+ jsonResponse(res, 200, getHealthStatus());
20698
+ } catch {
20699
+ // Fallback if health module unavailable
20700
+ jsonResponse(res, 200, { status: "ok", server: "bosun" });
20701
+ }
20702
+ return;
20703
+ }
20704
+
19430
20705
  // GitHub OAuth callback — public (no session auth required)
19431
20706
  // Accept both /github/callback (registered in GitHub App settings) and
19432
20707
  // /api/github/callback (documented API path) so either works.
@@ -19445,6 +20720,16 @@ export async function startTelegramUiServer(options = {}) {
19445
20720
  return;
19446
20721
  }
19447
20722
 
20723
+ // Setup wizard API routes — handled before the general /api/ catch-all
20724
+ // so the setup wizard works whether running standalone or unified with portal.
20725
+ if (url.pathname.startsWith("/api/setup/")) {
20726
+ const { handleSetupApi } = await import("./setup-web-server.mjs");
20727
+ const handled = await handleSetupApi(req, res, url, {
20728
+ onComplete: _setupOnComplete || undefined,
20729
+ });
20730
+ if (handled) return;
20731
+ }
20732
+
19448
20733
  if (url.pathname.startsWith("/api/")) {
19449
20734
  await handleApi(req, res, url);
19450
20735
  return;
@@ -19463,6 +20748,15 @@ export async function startTelegramUiServer(options = {}) {
19463
20748
  return;
19464
20749
  }
19465
20750
 
20751
+ // ── Setup wizard page ──────────────────────────────────────────────────
20752
+ // When in setup mode, redirect / → /setup so the user lands on the wizard.
20753
+ // When setup is complete, /setup still works for re-configuration access.
20754
+ if (_setupMode && url.pathname === "/") {
20755
+ res.writeHead(302, { Location: "/setup" });
20756
+ res.end();
20757
+ return;
20758
+ }
20759
+
19466
20760
  // /demo and /ui/demo are convenience aliases for /demo.html (the self-contained mock UI demo)
19467
20761
  if (url.pathname === "/demo" || url.pathname === "/ui/demo") {
19468
20762
  const qs = url.search || "";
@@ -20111,6 +21405,9 @@ export function stopTelegramUiServer() {
20111
21405
  if (!uiServer) return;
20112
21406
  stopTunnel();
20113
21407
  stopWsHeartbeat();
21408
+ // Clear injected configDir so it does not leak between server lifecycles
21409
+ // (tests start/stop servers repeatedly with different config directories).
21410
+ delete uiDeps.configDir;
20114
21411
  for (const socket of wsClients) {
20115
21412
  try {
20116
21413
  stopLogStream(socket);