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.
- package/.env.example +1 -1
- package/README.md +23 -1
- package/agent/agent-event-bus.mjs +31 -2
- package/agent/agent-pool.mjs +275 -40
- package/agent/agent-prompts.mjs +9 -1
- package/agent/agent-supervisor.mjs +22 -0
- package/agent/autofix.mjs +1 -1
- package/agent/primary-agent.mjs +115 -5
- package/cli.mjs +3 -2
- package/config/config.mjs +47 -33
- package/config/context-shredding-config.mjs +1 -1
- package/config/repo-root.mjs +41 -33
- package/desktop/main.mjs +350 -25
- package/desktop/preload.cjs +8 -0
- package/desktop/preload.mjs +19 -0
- package/entrypoint.mjs +332 -0
- package/git/sdk-conflict-resolver.mjs +1 -1
- package/infra/health-status.mjs +72 -0
- package/infra/library-manager.mjs +58 -1
- package/infra/maintenance.mjs +1 -2
- package/infra/monitor.mjs +26 -8
- package/infra/session-tracker.mjs +30 -3
- package/package.json +12 -4
- package/server/bosun-mcp-server.mjs +1004 -0
- package/server/setup-web-server.mjs +288 -259
- package/server/ui-server.mjs +1323 -26
- package/shell/claude-shell.mjs +14 -1
- package/shell/codex-config.mjs +1 -1
- package/shell/codex-model-profiles.mjs +170 -30
- package/shell/codex-shell.mjs +63 -18
- package/shell/opencode-providers.mjs +20 -8
- package/task/task-executor.mjs +28 -0
- package/task/task-store.mjs +13 -4
- package/telegram/telegram-sentinel.mjs +54 -3
- package/tools/list-todos.mjs +7 -1
- package/ui/app.js +3 -2
- package/ui/components/agent-selector.js +127 -0
- package/ui/components/session-list.js +15 -10
- package/ui/demo-defaults.js +334 -336
- package/ui/modules/router.js +2 -0
- package/ui/modules/state.js +13 -5
- package/ui/tabs/chat.js +3 -0
- package/ui/tabs/library.js +284 -52
- package/ui/tabs/tasks.js +5 -13
- package/ui/tabs/workflows.js +766 -3
- package/workflow/workflow-engine.mjs +246 -5
- package/workflow/workflow-nodes/definitions.mjs +37 -0
- package/workflow/workflow-nodes.mjs +1014 -184
- package/workflow/workflow-templates.mjs +0 -5
- package/workflow-templates/_helpers.mjs +253 -0
- package/workflow-templates/agents.mjs +199 -226
- package/workflow-templates/github.mjs +106 -16
- package/workflow-templates/sub-workflows.mjs +233 -0
- package/workflow-templates/task-execution.mjs +125 -471
- package/workflow-templates/task-lifecycle.mjs +11 -48
- package/workspace/command-diagnostics.mjs +460 -0
- package/workspace/context-cache.mjs +396 -28
- package/workspace/worktree-manager.mjs +1 -1
package/server/ui-server.mjs
CHANGED
|
@@ -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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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.
|
|
4139
|
-
* 3.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
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
|
-
|
|
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);
|