bosun 0.33.2 → 0.33.3
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 +5 -0
- package/README.md +1 -0
- package/merge-strategy.mjs +41 -2
- package/monitor.mjs +285 -5
- package/package.json +1 -1
- package/review-agent.mjs +62 -18
- package/setup-web-server.mjs +4 -2
- package/ui/components/chat-view.js +5 -4
- package/ui/components/diff-viewer.js +4 -2
- package/ui/components/kanban-board.js +17 -1
- package/ui/components/session-list.js +18 -4
- package/ui/modules/agent-display.js +79 -0
- package/ui/styles/components.css +15 -0
- package/ui/styles/kanban.css +5 -0
- package/ui/tabs/chat.js +11 -11
- package/ui/tabs/tasks.js +17 -10
- package/ui/tabs/workflows.js +20 -6
- package/ui-server.mjs +35 -12
package/.env.example
CHANGED
|
@@ -979,6 +979,11 @@ COPILOT_CLOUD_DISABLED=true
|
|
|
979
979
|
# ─── Merge Strategy / Conflict Resolution ────────────────────────────────────
|
|
980
980
|
# Merge strategy mode: "smart" or "smart+codexsdk" (enables Codex conflict resolution)
|
|
981
981
|
# MERGE_STRATEGY_MODE=smart
|
|
982
|
+
# Flow primary mode (default: true). When enabled, merge actions are gated by
|
|
983
|
+
# Flow sequencing rules instead of immediate merge-strategy execution.
|
|
984
|
+
# BOSUN_FLOW_PRIMARY=true
|
|
985
|
+
# Require review approval before any merge action can be enabled (default: true).
|
|
986
|
+
# BOSUN_FLOW_REQUIRE_REVIEW=true
|
|
982
987
|
# Codex conflict resolution timeout in ms
|
|
983
988
|
# MERGE_CONFLICT_RESOLUTION_TIMEOUT_MS=600000
|
|
984
989
|
|
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Key references:
|
|
|
69
69
|
- [GitHub Projects v2 monitoring](_docs/GITHUB_PROJECTS_V2_MONITORING.md)
|
|
70
70
|
- [GitHub Projects v2 checklist](_docs/GITHUB_PROJECTS_V2_IMPLEMENTATION_CHECKLIST.md)
|
|
71
71
|
- [Jira integration](_docs/JIRA_INTEGRATION.md)
|
|
72
|
+
- [Workflows](_docs/WORKFLOWS.md)
|
|
72
73
|
- [Agent logging quickstart](docs/agent-logging-quickstart.md)
|
|
73
74
|
- [Agent logging design](docs/agent-work-logging-design.md)
|
|
74
75
|
- [Agent logging summary](docs/AGENT_LOGGING_SUMMARY.md)
|
package/merge-strategy.mjs
CHANGED
|
@@ -607,6 +607,8 @@ export function resetMergeStrategyDedup() {
|
|
|
607
607
|
* @param {number} [opts.timeoutMs] Timeout for agent operations (default: 15 min)
|
|
608
608
|
* @param {number} [opts.maxRetries] Max retries for re_attempt (default: 2)
|
|
609
609
|
* @param {object} [opts.promptTemplates] Optional prompt template overrides
|
|
610
|
+
* @param {function} [opts.canEnableMerge] Optional async gate for merge action.
|
|
611
|
+
* Signature: ({ decision, ctx }) => Promise<{ allowed: boolean, reason?: string }>
|
|
610
612
|
* @returns {Promise<ExecutionResult>}
|
|
611
613
|
*/
|
|
612
614
|
export async function executeDecision(decision, ctx, opts = {}) {
|
|
@@ -616,6 +618,7 @@ export async function executeDecision(decision, ctx, opts = {}) {
|
|
|
616
618
|
timeoutMs = 15 * 60 * 1000,
|
|
617
619
|
maxRetries = 2,
|
|
618
620
|
promptTemplates = {},
|
|
621
|
+
canEnableMerge,
|
|
619
622
|
} = opts;
|
|
620
623
|
|
|
621
624
|
const tag = `merge-exec(${ctx.shortId})`;
|
|
@@ -648,7 +651,11 @@ export async function executeDecision(decision, ctx, opts = {}) {
|
|
|
648
651
|
});
|
|
649
652
|
|
|
650
653
|
case "merge_after_ci_pass":
|
|
651
|
-
return await executeMergeAction(decision, ctx, {
|
|
654
|
+
return await executeMergeAction(decision, ctx, {
|
|
655
|
+
tag,
|
|
656
|
+
onTelegram,
|
|
657
|
+
canEnableMerge,
|
|
658
|
+
});
|
|
652
659
|
|
|
653
660
|
case "close_pr":
|
|
654
661
|
return await executeCloseAction(decision, ctx, { tag, onTelegram });
|
|
@@ -988,7 +995,39 @@ function buildReAttemptPrompt(ctx, reason, promptTemplate = "") {
|
|
|
988
995
|
* Enable auto-merge for the PR via `gh pr merge --auto --squash`.
|
|
989
996
|
*/
|
|
990
997
|
async function executeMergeAction(decision, ctx, execOpts) {
|
|
991
|
-
const { tag, onTelegram } = execOpts;
|
|
998
|
+
const { tag, onTelegram, canEnableMerge } = execOpts;
|
|
999
|
+
|
|
1000
|
+
if (typeof canEnableMerge === "function") {
|
|
1001
|
+
try {
|
|
1002
|
+
const gate = await canEnableMerge({ decision, ctx });
|
|
1003
|
+
if (gate && gate.allowed === false) {
|
|
1004
|
+
const reason = String(gate.reason || "merge_gate_blocked");
|
|
1005
|
+
console.warn(
|
|
1006
|
+
`[${tag}] merge_after_ci_pass blocked by merge gate: ${reason}`,
|
|
1007
|
+
);
|
|
1008
|
+
if (onTelegram) {
|
|
1009
|
+
onTelegram(
|
|
1010
|
+
`⛔ Merge blocked for ${ctx.taskTitle || `PR #${ctx.prNumber || "unknown"}`}: ${reason}`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
executed: false,
|
|
1015
|
+
action: "merge_after_ci_pass",
|
|
1016
|
+
success: false,
|
|
1017
|
+
error: `FLOW_REVIEW_GATE:${reason}`,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
const msg = err?.message || String(err || "merge gate error");
|
|
1022
|
+
console.warn(`[${tag}] merge gate threw: ${msg}`);
|
|
1023
|
+
return {
|
|
1024
|
+
executed: false,
|
|
1025
|
+
action: "merge_after_ci_pass",
|
|
1026
|
+
success: false,
|
|
1027
|
+
error: `FLOW_REVIEW_GATE_ERROR:${msg}`,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
992
1031
|
|
|
993
1032
|
if (!ctx.prNumber) {
|
|
994
1033
|
console.warn(`[${tag}] merge_after_ci_pass but no PR number`);
|
package/monitor.mjs
CHANGED
|
@@ -730,6 +730,22 @@ function isReviewAgentEnabled() {
|
|
|
730
730
|
return true;
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
function isFlowPrimaryEnabled() {
|
|
734
|
+
const explicit = process.env.BOSUN_FLOW_PRIMARY;
|
|
735
|
+
if (explicit !== undefined && String(explicit).trim() !== "") {
|
|
736
|
+
return !isFalsyFlag(explicit);
|
|
737
|
+
}
|
|
738
|
+
return !isFalsyFlag(flowPrimaryDefault);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function isFlowReviewGateEnabled() {
|
|
742
|
+
const explicit = process.env.BOSUN_FLOW_REQUIRE_REVIEW;
|
|
743
|
+
if (explicit !== undefined && String(explicit).trim() !== "") {
|
|
744
|
+
return !isFalsyFlag(explicit);
|
|
745
|
+
}
|
|
746
|
+
return !isFalsyFlag(flowRequireReviewDefault);
|
|
747
|
+
}
|
|
748
|
+
|
|
733
749
|
function isMonitorMonitorEnabled() {
|
|
734
750
|
if (process.env.VITEST) return false;
|
|
735
751
|
if (!isDevMode()) return false;
|
|
@@ -968,6 +984,8 @@ const codexAnalyzeMergeStrategy =
|
|
|
968
984
|
const mergeStrategyMode = String(
|
|
969
985
|
process.env.MERGE_STRATEGY_MODE || "smart",
|
|
970
986
|
).toLowerCase();
|
|
987
|
+
const flowPrimaryDefault = "true";
|
|
988
|
+
const flowRequireReviewDefault = "true";
|
|
971
989
|
const codexResolveConflictsEnabled =
|
|
972
990
|
agentPoolEnabled &&
|
|
973
991
|
(process.env.CODEX_RESOLVE_CONFLICTS || "true").toLowerCase() !== "false";
|
|
@@ -4784,7 +4802,16 @@ async function checkMergedPRsAndUpdateTasks() {
|
|
|
4784
4802
|
(c) => c.branch && mergedBranchCache.has(c.branch),
|
|
4785
4803
|
);
|
|
4786
4804
|
if (knownBranch) {
|
|
4805
|
+
const canFinalize = await shouldFinalizeMergedTask(task, {
|
|
4806
|
+
branch: knownBranch.branch,
|
|
4807
|
+
prNumber: knownBranch.prNumber,
|
|
4808
|
+
reason: "known_merged_branch",
|
|
4809
|
+
});
|
|
4810
|
+
if (!canFinalize) {
|
|
4811
|
+
continue;
|
|
4812
|
+
}
|
|
4787
4813
|
mergedTaskCache.add(task.id);
|
|
4814
|
+
pendingMergeStrategyByTask.delete(String(task.id || "").trim());
|
|
4788
4815
|
// Cache all branches for this task
|
|
4789
4816
|
for (const c of candidates) {
|
|
4790
4817
|
if (c.branch) mergedBranchCache.add(c.branch);
|
|
@@ -4831,9 +4858,19 @@ async function checkMergedPRsAndUpdateTasks() {
|
|
|
4831
4858
|
console.log(
|
|
4832
4859
|
`[monitor] Task "${task.title}" (${task.id.substring(0, 8)}...) has merged PR #${cand.prNumber}, updating to done [confidence=${confidence.confidence}, ${confidence.reason}]`,
|
|
4833
4860
|
);
|
|
4861
|
+
const canFinalize = await shouldFinalizeMergedTask(task, {
|
|
4862
|
+
branch: cand.branch,
|
|
4863
|
+
prNumber: cand.prNumber,
|
|
4864
|
+
reason: "merged_pr_detected",
|
|
4865
|
+
});
|
|
4866
|
+
if (!canFinalize) {
|
|
4867
|
+
resolved = true;
|
|
4868
|
+
break;
|
|
4869
|
+
}
|
|
4834
4870
|
const success = await updateTaskStatus(task.id, "done");
|
|
4835
4871
|
movedCount++;
|
|
4836
4872
|
mergedTaskCache.add(task.id);
|
|
4873
|
+
pendingMergeStrategyByTask.delete(String(task.id || "").trim());
|
|
4837
4874
|
for (const c of candidates) {
|
|
4838
4875
|
if (c.branch) mergedBranchCache.add(c.branch);
|
|
4839
4876
|
}
|
|
@@ -4890,9 +4927,19 @@ async function checkMergedPRsAndUpdateTasks() {
|
|
|
4890
4927
|
console.log(
|
|
4891
4928
|
`[monitor] Task "${task.title}" (${task.id.substring(0, 8)}...) has merged branch ${cand.branch}, updating to done`,
|
|
4892
4929
|
);
|
|
4930
|
+
const canFinalize = await shouldFinalizeMergedTask(task, {
|
|
4931
|
+
branch: cand.branch,
|
|
4932
|
+
prNumber: cand.prNumber,
|
|
4933
|
+
reason: "merged_branch_detected",
|
|
4934
|
+
});
|
|
4935
|
+
if (!canFinalize) {
|
|
4936
|
+
resolved = true;
|
|
4937
|
+
break;
|
|
4938
|
+
}
|
|
4893
4939
|
const success = await updateTaskStatus(task.id, "done");
|
|
4894
4940
|
movedCount++;
|
|
4895
4941
|
mergedTaskCache.add(task.id);
|
|
4942
|
+
pendingMergeStrategyByTask.delete(String(task.id || "").trim());
|
|
4896
4943
|
for (const c of candidates) {
|
|
4897
4944
|
if (c.branch) mergedBranchCache.add(c.branch);
|
|
4898
4945
|
}
|
|
@@ -6064,6 +6111,137 @@ async function checkAndMergeDependabotPRs() {
|
|
|
6064
6111
|
|
|
6065
6112
|
// ── Merge Strategy Analysis ─────────────────────────────────────────────────
|
|
6066
6113
|
|
|
6114
|
+
function getReviewGateSnapshot(taskId) {
|
|
6115
|
+
const id = String(taskId || "").trim();
|
|
6116
|
+
if (!id) return null;
|
|
6117
|
+
|
|
6118
|
+
const runtime = reviewGateResults.get(id);
|
|
6119
|
+
if (runtime) {
|
|
6120
|
+
return {
|
|
6121
|
+
approved: runtime.approved === true,
|
|
6122
|
+
reviewedAt: runtime.reviewedAt || null,
|
|
6123
|
+
source: "runtime",
|
|
6124
|
+
};
|
|
6125
|
+
}
|
|
6126
|
+
|
|
6127
|
+
try {
|
|
6128
|
+
const task = getInternalTask(id);
|
|
6129
|
+
if (!task) return null;
|
|
6130
|
+
return {
|
|
6131
|
+
approved: task.reviewStatus === "approved",
|
|
6132
|
+
reviewedAt: task.reviewedAt || null,
|
|
6133
|
+
source: "task-store",
|
|
6134
|
+
};
|
|
6135
|
+
} catch {
|
|
6136
|
+
return null;
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6140
|
+
function isTaskReviewApprovedForFlow(taskId) {
|
|
6141
|
+
const snapshot = getReviewGateSnapshot(taskId);
|
|
6142
|
+
return snapshot?.approved === true;
|
|
6143
|
+
}
|
|
6144
|
+
|
|
6145
|
+
function rememberPendingMergeStrategy(ctx, reason = "review_pending") {
|
|
6146
|
+
const taskId = String(ctx?.taskId || "").trim();
|
|
6147
|
+
if (!taskId) return false;
|
|
6148
|
+
pendingMergeStrategyByTask.set(taskId, {
|
|
6149
|
+
...ctx,
|
|
6150
|
+
_flowDeferredAt: new Date().toISOString(),
|
|
6151
|
+
_flowDeferredReason: reason,
|
|
6152
|
+
});
|
|
6153
|
+
return true;
|
|
6154
|
+
}
|
|
6155
|
+
|
|
6156
|
+
function dequeuePendingMergeStrategy(taskId) {
|
|
6157
|
+
const id = String(taskId || "").trim();
|
|
6158
|
+
if (!id) return null;
|
|
6159
|
+
const ctx = pendingMergeStrategyByTask.get(id) || null;
|
|
6160
|
+
if (ctx) pendingMergeStrategyByTask.delete(id);
|
|
6161
|
+
return ctx;
|
|
6162
|
+
}
|
|
6163
|
+
|
|
6164
|
+
async function queueFlowReview(taskId, ctx, reason = "") {
|
|
6165
|
+
if (!reviewAgent) return false;
|
|
6166
|
+
const id = String(taskId || "").trim();
|
|
6167
|
+
if (!id) return false;
|
|
6168
|
+
try {
|
|
6169
|
+
const prUrl =
|
|
6170
|
+
ctx?.prUrl ||
|
|
6171
|
+
(ctx?.prNumber ? `${repoUrlBase}/pull/${ctx.prNumber}` : "");
|
|
6172
|
+
await reviewAgent.queueReview({
|
|
6173
|
+
id,
|
|
6174
|
+
title: ctx?.taskTitle || id,
|
|
6175
|
+
branchName: ctx?.branch || "",
|
|
6176
|
+
prUrl,
|
|
6177
|
+
description: ctx?.taskDescription || "",
|
|
6178
|
+
taskContext: reason ? `Flow gate reason: ${reason}` : "",
|
|
6179
|
+
worktreePath: ctx?.worktreeDir || null,
|
|
6180
|
+
sessionMessages: "",
|
|
6181
|
+
diffStats: "",
|
|
6182
|
+
});
|
|
6183
|
+
return true;
|
|
6184
|
+
} catch (err) {
|
|
6185
|
+
console.warn(
|
|
6186
|
+
`[flow-gate] failed to queue review for ${id}: ${err.message || err}`,
|
|
6187
|
+
);
|
|
6188
|
+
return false;
|
|
6189
|
+
}
|
|
6190
|
+
}
|
|
6191
|
+
|
|
6192
|
+
async function canEnableMergeForFlow(ctx) {
|
|
6193
|
+
if (!isFlowPrimaryEnabled() || !isFlowReviewGateEnabled()) {
|
|
6194
|
+
return { allowed: true };
|
|
6195
|
+
}
|
|
6196
|
+
|
|
6197
|
+
const taskId = String(ctx?.taskId || "").trim();
|
|
6198
|
+
if (!taskId) {
|
|
6199
|
+
return { allowed: false, reason: "missing_task_id_for_review_gate" };
|
|
6200
|
+
}
|
|
6201
|
+
|
|
6202
|
+
if (!isTaskReviewApprovedForFlow(taskId)) {
|
|
6203
|
+
return { allowed: false, reason: "task_review_not_approved" };
|
|
6204
|
+
}
|
|
6205
|
+
|
|
6206
|
+
return { allowed: true };
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
async function shouldFinalizeMergedTask(task, context = {}) {
|
|
6210
|
+
if (!isFlowPrimaryEnabled() || !isFlowReviewGateEnabled()) {
|
|
6211
|
+
return true;
|
|
6212
|
+
}
|
|
6213
|
+
|
|
6214
|
+
const taskId = String(task?.id || "").trim();
|
|
6215
|
+
if (!taskId) return false;
|
|
6216
|
+
if (isTaskReviewApprovedForFlow(taskId)) return true;
|
|
6217
|
+
|
|
6218
|
+
const branch = context.branch || task?.branch || task?.workspace_branch || "";
|
|
6219
|
+
const prNumber = context.prNumber || task?.pr_number || null;
|
|
6220
|
+
const reason = context.reason || "merged_before_review";
|
|
6221
|
+
console.log(
|
|
6222
|
+
`[flow-gate] Task "${task?.title || taskId}" merged but review is not approved yet — holding in inreview`,
|
|
6223
|
+
);
|
|
6224
|
+
const currentStatus = String(task?.status || "").toLowerCase();
|
|
6225
|
+
if (currentStatus && currentStatus !== "inreview") {
|
|
6226
|
+
try {
|
|
6227
|
+
await updateTaskStatus(taskId, "inreview");
|
|
6228
|
+
} catch {
|
|
6229
|
+
/* best effort */
|
|
6230
|
+
}
|
|
6231
|
+
}
|
|
6232
|
+
await queueFlowReview(taskId, {
|
|
6233
|
+
taskId,
|
|
6234
|
+
taskTitle: task?.title || taskId,
|
|
6235
|
+
taskDescription: task?.description || task?.body || "",
|
|
6236
|
+
branch,
|
|
6237
|
+
prNumber,
|
|
6238
|
+
prUrl:
|
|
6239
|
+
context.prUrl ||
|
|
6240
|
+
(prNumber ? `${repoUrlBase}/pull/${prNumber}` : task?.pr_url || ""),
|
|
6241
|
+
}, reason);
|
|
6242
|
+
return false;
|
|
6243
|
+
}
|
|
6244
|
+
|
|
6067
6245
|
/**
|
|
6068
6246
|
* Run the Codex-powered merge strategy analysis for a completed task.
|
|
6069
6247
|
* This is fire-and-forget (void) — it runs async in the background and
|
|
@@ -6071,9 +6249,29 @@ async function checkAndMergeDependabotPRs() {
|
|
|
6071
6249
|
*
|
|
6072
6250
|
* @param {import("./merge-strategy.mjs").MergeContext} ctx
|
|
6073
6251
|
*/
|
|
6074
|
-
async function runMergeStrategyAnalysis(ctx) {
|
|
6252
|
+
async function runMergeStrategyAnalysis(ctx, opts = {}) {
|
|
6075
6253
|
const tag = `merge-strategy(${ctx.shortId})`;
|
|
6076
6254
|
try {
|
|
6255
|
+
const skipFlowGate = opts?.skipFlowGate === true;
|
|
6256
|
+
const flowGateEnabled = isFlowPrimaryEnabled() && isFlowReviewGateEnabled();
|
|
6257
|
+
if (!skipFlowGate && flowGateEnabled) {
|
|
6258
|
+
const taskId = String(ctx?.taskId || "").trim();
|
|
6259
|
+
if (!taskId) {
|
|
6260
|
+
console.warn(
|
|
6261
|
+
`[${tag}] flow gate: missing taskId — deferring merge strategy until task mapping is available`,
|
|
6262
|
+
);
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
if (!isTaskReviewApprovedForFlow(taskId)) {
|
|
6266
|
+
rememberPendingMergeStrategy(ctx, "review_pending");
|
|
6267
|
+
await queueFlowReview(taskId, ctx, "merge_strategy_waiting_for_review");
|
|
6268
|
+
console.log(
|
|
6269
|
+
`[${tag}] flow gate: deferred until review is approved for task ${taskId}`,
|
|
6270
|
+
);
|
|
6271
|
+
return;
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
6274
|
+
|
|
6077
6275
|
const telegramFn =
|
|
6078
6276
|
telegramToken && telegramChatId
|
|
6079
6277
|
? (msg) => void sendTelegramMessage(msg)
|
|
@@ -6105,6 +6303,7 @@ async function runMergeStrategyAnalysis(ctx) {
|
|
|
6105
6303
|
onTelegram: telegramFn,
|
|
6106
6304
|
timeoutMs:
|
|
6107
6305
|
parseInt(process.env.MERGE_STRATEGY_TIMEOUT_MS, 10) || 15 * 60 * 1000,
|
|
6306
|
+
canEnableMerge: ({ ctx: mergeCtx }) => canEnableMergeForFlow(mergeCtx),
|
|
6108
6307
|
promptTemplates: {
|
|
6109
6308
|
mergeStrategyFix: agentPrompts?.mergeStrategyFix,
|
|
6110
6309
|
mergeStrategyReAttempt: agentPrompts?.mergeStrategyReAttempt,
|
|
@@ -6127,6 +6326,17 @@ async function runMergeStrategyAnalysis(ctx) {
|
|
|
6127
6326
|
|
|
6128
6327
|
if (!execResult.success && execResult.error) {
|
|
6129
6328
|
console.warn(`[${tag}] execution issue: ${execResult.error}`);
|
|
6329
|
+
if (
|
|
6330
|
+
String(execResult.error).startsWith("FLOW_REVIEW_GATE:") &&
|
|
6331
|
+
ctx?.taskId
|
|
6332
|
+
) {
|
|
6333
|
+
rememberPendingMergeStrategy(ctx, "merge_blocked_by_review_gate");
|
|
6334
|
+
await queueFlowReview(
|
|
6335
|
+
ctx.taskId,
|
|
6336
|
+
ctx,
|
|
6337
|
+
"merge_action_blocked_by_review_gate",
|
|
6338
|
+
);
|
|
6339
|
+
}
|
|
6130
6340
|
}
|
|
6131
6341
|
} catch (err) {
|
|
6132
6342
|
console.warn(
|
|
@@ -6753,12 +6963,32 @@ async function smartPRFlow(attemptId, shortId, status) {
|
|
|
6753
6963
|
}
|
|
6754
6964
|
const merged = await isBranchMerged(attemptInfo.branch);
|
|
6755
6965
|
if (merged) {
|
|
6966
|
+
let canFinalize = true;
|
|
6967
|
+
if (attemptInfo.task_id) {
|
|
6968
|
+
canFinalize = await shouldFinalizeMergedTask(
|
|
6969
|
+
{
|
|
6970
|
+
id: attemptInfo.task_id,
|
|
6971
|
+
title: attemptInfo.task_title || attemptInfo.task_id,
|
|
6972
|
+
description: attemptInfo.task_description || "",
|
|
6973
|
+
pr_number: attemptInfo.pr_number || null,
|
|
6974
|
+
pr_url: attemptInfo.pr_url || "",
|
|
6975
|
+
},
|
|
6976
|
+
{
|
|
6977
|
+
branch: attemptInfo.branch,
|
|
6978
|
+
prNumber: attemptInfo.pr_number || null,
|
|
6979
|
+
reason: "smartpr_initial_merged_branch",
|
|
6980
|
+
},
|
|
6981
|
+
);
|
|
6982
|
+
}
|
|
6756
6983
|
console.log(
|
|
6757
|
-
`[monitor] ${tag}: branch ${attemptInfo.branch} confirmed merged — completing task`,
|
|
6984
|
+
`[monitor] ${tag}: branch ${attemptInfo.branch} confirmed merged — ${canFinalize ? "completing task" : "awaiting review gate"}`,
|
|
6758
6985
|
);
|
|
6759
6986
|
mergedBranchCache.add(attemptInfo.branch);
|
|
6760
|
-
if (attemptInfo.task_id) {
|
|
6987
|
+
if (attemptInfo.task_id && canFinalize) {
|
|
6761
6988
|
mergedTaskCache.add(attemptInfo.task_id);
|
|
6989
|
+
pendingMergeStrategyByTask.delete(
|
|
6990
|
+
String(attemptInfo.task_id || "").trim(),
|
|
6991
|
+
);
|
|
6762
6992
|
void updateTaskStatus(attemptInfo.task_id, "done");
|
|
6763
6993
|
}
|
|
6764
6994
|
await archiveAttempt(attemptId);
|
|
@@ -7134,6 +7364,7 @@ Return a short summary of what you did and any files that needed manual resoluti
|
|
|
7134
7364
|
// ── Step 5b: Merge strategy analysis (Codex-powered) ─────
|
|
7135
7365
|
if (codexAnalyzeMergeStrategy) {
|
|
7136
7366
|
void runMergeStrategyAnalysis({
|
|
7367
|
+
taskId: attempt?.task_id || attemptInfo?.task_id || null,
|
|
7137
7368
|
attemptId,
|
|
7138
7369
|
shortId,
|
|
7139
7370
|
status,
|
|
@@ -7267,12 +7498,32 @@ async function resolveAndTriggerSmartPR(shortId, status) {
|
|
|
7267
7498
|
// Check GitHub for a merged PR with this head branch
|
|
7268
7499
|
const merged = await isBranchMerged(resolvedAttempt.branch);
|
|
7269
7500
|
if (merged) {
|
|
7501
|
+
let canFinalize = true;
|
|
7502
|
+
if (resolvedAttempt.task_id) {
|
|
7503
|
+
canFinalize = await shouldFinalizeMergedTask(
|
|
7504
|
+
{
|
|
7505
|
+
id: resolvedAttempt.task_id,
|
|
7506
|
+
title: resolvedAttempt.task_title || resolvedAttempt.task_id,
|
|
7507
|
+
description: resolvedAttempt.task_description || "",
|
|
7508
|
+
pr_number: resolvedAttempt.pr_number || null,
|
|
7509
|
+
pr_url: resolvedAttempt.pr_url || "",
|
|
7510
|
+
},
|
|
7511
|
+
{
|
|
7512
|
+
branch: resolvedAttempt.branch,
|
|
7513
|
+
prNumber: resolvedAttempt.pr_number || null,
|
|
7514
|
+
reason: "resolve_smartpr_merged_branch",
|
|
7515
|
+
},
|
|
7516
|
+
);
|
|
7517
|
+
}
|
|
7270
7518
|
console.log(
|
|
7271
|
-
`[monitor] smartPR(${shortId}): branch ${resolvedAttempt.branch} confirmed merged — completing task and skipping PR flow`,
|
|
7519
|
+
`[monitor] smartPR(${shortId}): branch ${resolvedAttempt.branch} confirmed merged — ${canFinalize ? "completing task and skipping PR flow" : "awaiting review gate"}`,
|
|
7272
7520
|
);
|
|
7273
7521
|
mergedBranchCache.add(resolvedAttempt.branch);
|
|
7274
|
-
if (resolvedAttempt.task_id) {
|
|
7522
|
+
if (resolvedAttempt.task_id && canFinalize) {
|
|
7275
7523
|
mergedTaskCache.add(resolvedAttempt.task_id);
|
|
7524
|
+
pendingMergeStrategyByTask.delete(
|
|
7525
|
+
String(resolvedAttempt.task_id || "").trim(),
|
|
7526
|
+
);
|
|
7276
7527
|
void updateTaskStatus(resolvedAttempt.task_id, "done");
|
|
7277
7528
|
}
|
|
7278
7529
|
await archiveAttempt(resolvedAttempt.id || shortId);
|
|
@@ -12356,6 +12607,10 @@ let agentEndpoint = null;
|
|
|
12356
12607
|
let agentEventBus = null;
|
|
12357
12608
|
/** @type {import("./review-agent.mjs").ReviewAgent|null} */
|
|
12358
12609
|
let reviewAgent = null;
|
|
12610
|
+
/** @type {Map<string, import("./merge-strategy.mjs").MergeContext>} */
|
|
12611
|
+
const pendingMergeStrategyByTask = new Map();
|
|
12612
|
+
/** @type {Map<string, { approved: boolean, reviewedAt: string }>} */
|
|
12613
|
+
const reviewGateResults = new Map();
|
|
12359
12614
|
/** @type {import("./sync-engine.mjs").SyncEngine|null} */
|
|
12360
12615
|
let syncEngine = null;
|
|
12361
12616
|
/** @type {import("./error-detector.mjs").ErrorDetector|null} */
|
|
@@ -12715,9 +12970,14 @@ if (isExecutorDisabled()) {
|
|
|
12715
12970
|
: null,
|
|
12716
12971
|
promptTemplate: agentPrompts?.reviewer,
|
|
12717
12972
|
onReviewComplete: (taskId, result) => {
|
|
12973
|
+
const normalizedTaskId = String(taskId || "").trim();
|
|
12718
12974
|
console.log(
|
|
12719
12975
|
`[monitor] review complete for ${taskId}: ${result?.approved ? "approved" : "changes_requested"} — prMerged: ${result?.prMerged}`,
|
|
12720
12976
|
);
|
|
12977
|
+
reviewGateResults.set(normalizedTaskId, {
|
|
12978
|
+
approved: result?.approved === true,
|
|
12979
|
+
reviewedAt: result?.reviewedAt || new Date().toISOString(),
|
|
12980
|
+
});
|
|
12721
12981
|
try {
|
|
12722
12982
|
setReviewResult(taskId, {
|
|
12723
12983
|
approved: result?.approved ?? false,
|
|
@@ -12726,6 +12986,9 @@ if (isExecutorDisabled()) {
|
|
|
12726
12986
|
} catch {
|
|
12727
12987
|
/* best-effort */
|
|
12728
12988
|
}
|
|
12989
|
+
const pendingCtx = result?.approved
|
|
12990
|
+
? dequeuePendingMergeStrategy(normalizedTaskId)
|
|
12991
|
+
: null;
|
|
12729
12992
|
|
|
12730
12993
|
if (result?.approved && result?.prMerged) {
|
|
12731
12994
|
// PR merged and reviewer happy — fully done
|
|
@@ -12747,10 +13010,27 @@ if (isExecutorDisabled()) {
|
|
|
12747
13010
|
console.log(
|
|
12748
13011
|
`[monitor] review approved but PR not merged — ${taskId} stays inreview`,
|
|
12749
13012
|
);
|
|
13013
|
+
if (pendingCtx) {
|
|
13014
|
+
console.log(
|
|
13015
|
+
`[monitor] flow gate released for ${taskId} — running deferred merge strategy`,
|
|
13016
|
+
);
|
|
13017
|
+
void runMergeStrategyAnalysis(
|
|
13018
|
+
{
|
|
13019
|
+
...pendingCtx,
|
|
13020
|
+
taskId: pendingCtx.taskId || taskId,
|
|
13021
|
+
},
|
|
13022
|
+
{ skipFlowGate: false },
|
|
13023
|
+
);
|
|
13024
|
+
}
|
|
12750
13025
|
} else {
|
|
12751
13026
|
console.log(
|
|
12752
13027
|
`[monitor] review found ${result?.issues?.length || 0} issue(s) for ${taskId} — task stays inreview`,
|
|
12753
13028
|
);
|
|
13029
|
+
if (pendingMergeStrategyByTask.has(normalizedTaskId)) {
|
|
13030
|
+
console.log(
|
|
13031
|
+
`[monitor] flow gate remains active for ${taskId} — merge strategy stays deferred`,
|
|
13032
|
+
);
|
|
13033
|
+
}
|
|
12754
13034
|
}
|
|
12755
13035
|
},
|
|
12756
13036
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.3",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/review-agent.mjs
CHANGED
|
@@ -185,9 +185,17 @@ function getPrDiff({ prUrl, branchName }) {
|
|
|
185
185
|
function parseReviewResult(raw) {
|
|
186
186
|
if (!raw || !raw.trim()) {
|
|
187
187
|
return {
|
|
188
|
-
approved:
|
|
189
|
-
issues: [
|
|
190
|
-
|
|
188
|
+
approved: false,
|
|
189
|
+
issues: [
|
|
190
|
+
{
|
|
191
|
+
severity: "major",
|
|
192
|
+
category: "broken",
|
|
193
|
+
file: "(review)",
|
|
194
|
+
line: null,
|
|
195
|
+
description: "Reviewer returned empty output; review is not complete.",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
summary: "Empty reviewer output",
|
|
191
199
|
};
|
|
192
200
|
}
|
|
193
201
|
|
|
@@ -218,11 +226,19 @@ function parseReviewResult(raw) {
|
|
|
218
226
|
}
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
// Couldn't parse — auto-
|
|
229
|
+
// Couldn't parse — treat as failed review to prevent unsafe auto-merge.
|
|
222
230
|
return {
|
|
223
|
-
approved:
|
|
224
|
-
issues: [
|
|
225
|
-
|
|
231
|
+
approved: false,
|
|
232
|
+
issues: [
|
|
233
|
+
{
|
|
234
|
+
severity: "major",
|
|
235
|
+
category: "broken",
|
|
236
|
+
file: "(review)",
|
|
237
|
+
line: null,
|
|
238
|
+
description: "Could not parse reviewer output as JSON.",
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
summary: "Could not parse reviewer output",
|
|
226
242
|
};
|
|
227
243
|
}
|
|
228
244
|
|
|
@@ -232,8 +248,18 @@ function parseReviewResult(raw) {
|
|
|
232
248
|
* @returns {{ approved: boolean, issues: Array, summary: string }}
|
|
233
249
|
*/
|
|
234
250
|
function normalizeResult(obj) {
|
|
235
|
-
const
|
|
251
|
+
const verdict = String(obj?.verdict || "").trim().toLowerCase();
|
|
252
|
+
const approved = verdict === "approved";
|
|
236
253
|
const issues = Array.isArray(obj.issues) ? obj.issues : [];
|
|
254
|
+
if (!approved && verdict !== "changes_requested" && issues.length === 0) {
|
|
255
|
+
issues.push({
|
|
256
|
+
severity: "major",
|
|
257
|
+
category: "broken",
|
|
258
|
+
file: "(review)",
|
|
259
|
+
line: null,
|
|
260
|
+
description: `Invalid reviewer verdict "${verdict || "missing"}".`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
237
263
|
const summary = typeof obj.summary === "string" ? obj.summary : "";
|
|
238
264
|
return { approved, issues, summary };
|
|
239
265
|
}
|
|
@@ -249,7 +275,7 @@ export class ReviewAgent {
|
|
|
249
275
|
/** @type {Array<{ id: string, title: string, branchName: string, prUrl: string, description: string, taskContext?: string }>} */
|
|
250
276
|
#queue = [];
|
|
251
277
|
|
|
252
|
-
/** @type {Set<string>} - task IDs
|
|
278
|
+
/** @type {Set<string>} - task IDs queued or currently in-flight */
|
|
253
279
|
#seen = new Set();
|
|
254
280
|
|
|
255
281
|
#completedCount = 0;
|
|
@@ -309,7 +335,7 @@ export class ReviewAgent {
|
|
|
309
335
|
|
|
310
336
|
/**
|
|
311
337
|
* Queue a task for review.
|
|
312
|
-
* Deduplicates by task ID
|
|
338
|
+
* Deduplicates by task ID while queued/in-flight.
|
|
313
339
|
* @param {{ id: string, title: string, branchName: string, prUrl: string, description: string, taskContext?: string }} task
|
|
314
340
|
*/
|
|
315
341
|
async queueReview(task) {
|
|
@@ -319,7 +345,7 @@ export class ReviewAgent {
|
|
|
319
345
|
}
|
|
320
346
|
|
|
321
347
|
if (this.#seen.has(task.id)) {
|
|
322
|
-
console.log(`${TAG} task ${task.id} already
|
|
348
|
+
console.log(`${TAG} task ${task.id} already queued/in-flight — skipping`);
|
|
323
349
|
return;
|
|
324
350
|
}
|
|
325
351
|
|
|
@@ -412,6 +438,7 @@ export class ReviewAgent {
|
|
|
412
438
|
})
|
|
413
439
|
.finally(() => {
|
|
414
440
|
this.#activeReviews.delete(task.id);
|
|
441
|
+
this.#seen.delete(task.id);
|
|
415
442
|
this.#completedCount++;
|
|
416
443
|
// Continue processing after slot freed
|
|
417
444
|
if (this.#running && this.#queue.length > 0) {
|
|
@@ -441,12 +468,21 @@ export class ReviewAgent {
|
|
|
441
468
|
|
|
442
469
|
if (!diff) {
|
|
443
470
|
console.log(
|
|
444
|
-
`${TAG} no diff available for task ${task.id} —
|
|
471
|
+
`${TAG} no diff available for task ${task.id} — marking review as changes requested`,
|
|
445
472
|
);
|
|
446
473
|
const result = {
|
|
447
|
-
approved:
|
|
448
|
-
issues: [
|
|
449
|
-
|
|
474
|
+
approved: false,
|
|
475
|
+
issues: [
|
|
476
|
+
{
|
|
477
|
+
severity: "major",
|
|
478
|
+
category: "broken",
|
|
479
|
+
file: "(diff)",
|
|
480
|
+
line: null,
|
|
481
|
+
description:
|
|
482
|
+
"No diff could be loaded for this PR; cannot complete review safely.",
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
summary: "No diff available for review",
|
|
450
486
|
reviewedAt: new Date().toISOString(),
|
|
451
487
|
agentOutput: "",
|
|
452
488
|
};
|
|
@@ -474,7 +510,7 @@ export class ReviewAgent {
|
|
|
474
510
|
const sdkResult = await execWithRetry(prompt, {
|
|
475
511
|
taskKey: `review-${task.id}`,
|
|
476
512
|
timeoutMs: this.#reviewTimeoutMs,
|
|
477
|
-
maxRetries: 0, // Reviews don't retry —
|
|
513
|
+
maxRetries: 0, // Reviews don't retry — reject on failure
|
|
478
514
|
sdk: this.#sdk,
|
|
479
515
|
});
|
|
480
516
|
|
|
@@ -482,8 +518,16 @@ export class ReviewAgent {
|
|
|
482
518
|
} catch (err) {
|
|
483
519
|
console.error(`${TAG} SDK call failed for task ${task.id}:`, err.message);
|
|
484
520
|
const result = {
|
|
485
|
-
approved:
|
|
486
|
-
issues: [
|
|
521
|
+
approved: false,
|
|
522
|
+
issues: [
|
|
523
|
+
{
|
|
524
|
+
severity: "major",
|
|
525
|
+
category: "broken",
|
|
526
|
+
file: "(review)",
|
|
527
|
+
line: null,
|
|
528
|
+
description: `Reviewer execution failed: ${err.message}`,
|
|
529
|
+
},
|
|
530
|
+
],
|
|
487
531
|
summary: `Review failed: ${err.message}`,
|
|
488
532
|
reviewedAt: new Date().toISOString(),
|
|
489
533
|
agentOutput: "",
|
package/setup-web-server.mjs
CHANGED
|
@@ -30,7 +30,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
// 2. Node module resolution via createRequire — handles global install hoisting
|
|
31
31
|
// 3. CDN redirect — last resort
|
|
32
32
|
const _require = createRequire(import.meta.url);
|
|
33
|
-
const
|
|
33
|
+
const uiRootPreferred = resolve(__dirname, "site", "ui");
|
|
34
|
+
const uiRootFallback = resolve(__dirname, "ui");
|
|
35
|
+
const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
|
|
36
|
+
const BUNDLED_VENDOR_DIR = resolve(uiRoot, "vendor");
|
|
34
37
|
|
|
35
38
|
const VENDOR_FILES = {
|
|
36
39
|
"preact.js": { specifier: "preact/dist/preact.module.js", cdn: "https://esm.sh/preact@10.25.4" },
|
|
@@ -997,7 +1000,6 @@ async function handleRequest(req, res) {
|
|
|
997
1000
|
}
|
|
998
1001
|
|
|
999
1002
|
// Static file serving from ui/
|
|
1000
|
-
const uiRoot = resolve(__dirname, "ui");
|
|
1001
1003
|
let pathname = url.pathname === "/" || url.pathname === "/setup" ? "/setup.html" : url.pathname;
|
|
1002
1004
|
const filePath = resolve(uiRoot, `.${pathname}`);
|
|
1003
1005
|
|
|
@@ -397,6 +397,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
397
397
|
session?.status === "active" || session?.status === "running";
|
|
398
398
|
const resumeLabel =
|
|
399
399
|
session?.status === "archived" ? "Unarchive" : "Resume Session";
|
|
400
|
+
const safeSessionId = sessionId ? encodeURIComponent(sessionId) : "";
|
|
400
401
|
|
|
401
402
|
/* Memoize the filter key list so filteredMessages memoization works properly.
|
|
402
403
|
Previously a new array was created every render, breaking useMemo deps. */
|
|
@@ -630,7 +631,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
630
631
|
for (const file of list) {
|
|
631
632
|
form.append("file", file, file.name || "attachment");
|
|
632
633
|
}
|
|
633
|
-
const res = await apiFetch(`/api/sessions/${
|
|
634
|
+
const res = await apiFetch(`/api/sessions/${safeSessionId}/attachments`, {
|
|
634
635
|
method: "POST",
|
|
635
636
|
body: form,
|
|
636
637
|
});
|
|
@@ -684,7 +685,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
684
685
|
setSending(true);
|
|
685
686
|
|
|
686
687
|
try {
|
|
687
|
-
await apiFetch(`/api/sessions/${
|
|
688
|
+
await apiFetch(`/api/sessions/${safeSessionId}/message`, {
|
|
688
689
|
method: "POST",
|
|
689
690
|
body: JSON.stringify({
|
|
690
691
|
content: text,
|
|
@@ -702,7 +703,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
702
703
|
|
|
703
704
|
const handleResume = useCallback(async () => {
|
|
704
705
|
try {
|
|
705
|
-
await apiFetch(`/api/sessions/${
|
|
706
|
+
await apiFetch(`/api/sessions/${safeSessionId}/resume`, { method: "POST" });
|
|
706
707
|
showToast(
|
|
707
708
|
session?.status === "archived" ? "Session unarchived" : "Session resumed",
|
|
708
709
|
"success",
|
|
@@ -717,7 +718,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
717
718
|
|
|
718
719
|
const handleArchive = useCallback(async () => {
|
|
719
720
|
try {
|
|
720
|
-
await apiFetch(`/api/sessions/${
|
|
721
|
+
await apiFetch(`/api/sessions/${safeSessionId}/archive`, { method: "POST" });
|
|
721
722
|
showToast("Session archived", "success");
|
|
722
723
|
await loadSessions();
|
|
723
724
|
const res = await loadSessionMessages(sessionId);
|
|
@@ -99,8 +99,9 @@ export function DiffViewer({ sessionId }) {
|
|
|
99
99
|
let active = true;
|
|
100
100
|
setLoading(true);
|
|
101
101
|
setError(null);
|
|
102
|
+
const safeSessionId = encodeURIComponent(sessionId);
|
|
102
103
|
|
|
103
|
-
apiFetch(`/api/sessions/${
|
|
104
|
+
apiFetch(`/api/sessions/${safeSessionId}/diff`, { _silent: true })
|
|
104
105
|
.then((res) => {
|
|
105
106
|
if (!active) return;
|
|
106
107
|
setDiffData(res?.diff || null);
|
|
@@ -118,7 +119,8 @@ export function DiffViewer({ sessionId }) {
|
|
|
118
119
|
const handleRetry = useCallback(() => {
|
|
119
120
|
setError(null);
|
|
120
121
|
setLoading(true);
|
|
121
|
-
|
|
122
|
+
const safeSessionId = encodeURIComponent(sessionId);
|
|
123
|
+
apiFetch(`/api/sessions/${safeSessionId}/diff`, { _silent: true })
|
|
122
124
|
.then((res) => setDiffData(res?.diff || null))
|
|
123
125
|
.catch(() => setError("unavailable"))
|
|
124
126
|
.finally(() => setLoading(false));
|
|
@@ -10,6 +10,7 @@ import { apiFetch } from "../modules/api.js";
|
|
|
10
10
|
import { haptic } from "../modules/telegram.js";
|
|
11
11
|
import { formatRelative, truncate, cloneValue } from "../modules/utils.js";
|
|
12
12
|
import { iconText, resolveIcon } from "../modules/icon-utils.js";
|
|
13
|
+
import { getAgentDisplay } from "../modules/agent-display.js";
|
|
13
14
|
|
|
14
15
|
const html = htm.bind(h);
|
|
15
16
|
|
|
@@ -340,6 +341,16 @@ function KanbanCard({ task, onOpen }) {
|
|
|
340
341
|
const baseBranch = getTaskBaseBranch(task);
|
|
341
342
|
const repoName = task.repo || task.repository || "";
|
|
342
343
|
const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
|
|
344
|
+
const hasAgent = Boolean(
|
|
345
|
+
task?.assignee ||
|
|
346
|
+
task?.meta?.execution?.sdk ||
|
|
347
|
+
task?.meta?.execution?.executor ||
|
|
348
|
+
task?.sdk ||
|
|
349
|
+
task?.executor ||
|
|
350
|
+
task?.agent ||
|
|
351
|
+
task?.agentName,
|
|
352
|
+
);
|
|
353
|
+
const agentDisplay = hasAgent ? getAgentDisplay(task) : null;
|
|
343
354
|
|
|
344
355
|
return html`
|
|
345
356
|
<div
|
|
@@ -380,7 +391,12 @@ function KanbanCard({ task, onOpen }) {
|
|
|
380
391
|
</div>
|
|
381
392
|
`}
|
|
382
393
|
<div class="kanban-card-meta">
|
|
383
|
-
${
|
|
394
|
+
${agentDisplay && html`
|
|
395
|
+
<span
|
|
396
|
+
class="kanban-card-assignee"
|
|
397
|
+
title=${agentDisplay.label}
|
|
398
|
+
>${agentDisplay.icon}</span>
|
|
399
|
+
`}
|
|
384
400
|
<span class="kanban-card-id">${typeof task.id === "string" ? truncate(task.id, 12) : task.id}</span>
|
|
385
401
|
${task.created_at && html`<span>${formatRelative(task.created_at)}</span>`}
|
|
386
402
|
</div>
|
|
@@ -23,6 +23,12 @@ let _wsListenerReady = false;
|
|
|
23
23
|
/** Track the last filter used so createSession can reload with the same filter */
|
|
24
24
|
let _lastLoadFilter = {};
|
|
25
25
|
|
|
26
|
+
function sessionPath(id, action = "") {
|
|
27
|
+
const safeId = encodeURIComponent(String(id || "").trim());
|
|
28
|
+
if (!safeId) return "";
|
|
29
|
+
return action ? `/api/sessions/${safeId}/${action}` : `/api/sessions/${safeId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/* ─── Data loaders ─── */
|
|
27
33
|
export async function loadSessions(filter = {}) {
|
|
28
34
|
_lastLoadFilter = filter;
|
|
@@ -38,7 +44,9 @@ export async function loadSessions(filter = {}) {
|
|
|
38
44
|
|
|
39
45
|
export async function loadSessionMessages(id) {
|
|
40
46
|
try {
|
|
41
|
-
const
|
|
47
|
+
const url = sessionPath(id);
|
|
48
|
+
if (!url) return { ok: false, error: "invalid" };
|
|
49
|
+
const res = await apiFetch(url, { _silent: true });
|
|
42
50
|
if (res?.session) {
|
|
43
51
|
sessionMessages.value = res.session.messages || [];
|
|
44
52
|
return { ok: true, messages: sessionMessages.value };
|
|
@@ -195,7 +203,9 @@ export async function createSession(options = {}) {
|
|
|
195
203
|
/* ─── Session actions ─── */
|
|
196
204
|
export async function archiveSession(id) {
|
|
197
205
|
try {
|
|
198
|
-
|
|
206
|
+
const url = sessionPath(id, "archive");
|
|
207
|
+
if (!url) return false;
|
|
208
|
+
await apiFetch(url, { method: "POST" });
|
|
199
209
|
if (selectedSessionId.value === id) selectedSessionId.value = null;
|
|
200
210
|
await loadSessions(_lastLoadFilter);
|
|
201
211
|
return true;
|
|
@@ -206,7 +216,9 @@ export async function archiveSession(id) {
|
|
|
206
216
|
|
|
207
217
|
export async function deleteSession(id) {
|
|
208
218
|
try {
|
|
209
|
-
|
|
219
|
+
const url = sessionPath(id, "delete");
|
|
220
|
+
if (!url) return false;
|
|
221
|
+
await apiFetch(url, { method: "POST" });
|
|
210
222
|
if (selectedSessionId.value === id) selectedSessionId.value = null;
|
|
211
223
|
await loadSessions(_lastLoadFilter);
|
|
212
224
|
return true;
|
|
@@ -217,7 +229,9 @@ export async function deleteSession(id) {
|
|
|
217
229
|
|
|
218
230
|
export async function resumeSession(id) {
|
|
219
231
|
try {
|
|
220
|
-
|
|
232
|
+
const url = sessionPath(id, "resume");
|
|
233
|
+
if (!url) return false;
|
|
234
|
+
await apiFetch(url, { method: "POST" });
|
|
221
235
|
await loadSessions(_lastLoadFilter);
|
|
222
236
|
return true;
|
|
223
237
|
} catch {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* Agent display helpers
|
|
3
|
+
* - Normalizes SDK/executor metadata to icons + labels
|
|
4
|
+
* ───────────────────────────────────────────────────────────── */
|
|
5
|
+
import { resolveIcon } from "./icon-utils.js";
|
|
6
|
+
|
|
7
|
+
const AGENT_SDKS = [
|
|
8
|
+
{
|
|
9
|
+
key: "codex",
|
|
10
|
+
label: "Codex",
|
|
11
|
+
icon: "⚡",
|
|
12
|
+
aliases: ["codex", "openai", "gpt", "o3", "o4"],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
key: "copilot",
|
|
16
|
+
label: "Copilot",
|
|
17
|
+
icon: "🤖",
|
|
18
|
+
aliases: ["copilot", "github"],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: "claude",
|
|
22
|
+
label: "Claude",
|
|
23
|
+
icon: "🧠",
|
|
24
|
+
aliases: ["claude", "anthropic"],
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function normalize(value) {
|
|
29
|
+
return String(value || "").trim().toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveSdkKey(raw) {
|
|
33
|
+
const normalized = normalize(raw);
|
|
34
|
+
if (!normalized) return null;
|
|
35
|
+
for (const sdk of AGENT_SDKS) {
|
|
36
|
+
if (sdk.aliases.some((alias) => normalized.includes(alias))) {
|
|
37
|
+
return sdk.key;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findSdk(task = {}) {
|
|
44
|
+
const execution = task?.meta?.execution || task?.execution || {};
|
|
45
|
+
const candidates = [
|
|
46
|
+
execution.sdk,
|
|
47
|
+
execution.executor,
|
|
48
|
+
task.sdk,
|
|
49
|
+
task.executor,
|
|
50
|
+
task.meta?.sdk,
|
|
51
|
+
task.meta?.executor,
|
|
52
|
+
task.assignee,
|
|
53
|
+
task.agent,
|
|
54
|
+
task.agentId,
|
|
55
|
+
task.agentName,
|
|
56
|
+
];
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const key = resolveSdkKey(candidate);
|
|
59
|
+
if (key) return key;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getAgentDisplay(task = {}) {
|
|
65
|
+
const sdkKey = findSdk(task);
|
|
66
|
+
const sdk = sdkKey ? AGENT_SDKS.find((entry) => entry.key === sdkKey) : null;
|
|
67
|
+
if (sdk) {
|
|
68
|
+
return {
|
|
69
|
+
key: sdk.key,
|
|
70
|
+
label: sdk.label,
|
|
71
|
+
icon: resolveIcon(sdk.icon) || sdk.icon,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
key: "agent",
|
|
76
|
+
label: "Agent",
|
|
77
|
+
icon: resolveIcon("🤖") || "🤖",
|
|
78
|
+
};
|
|
79
|
+
}
|
package/ui/styles/components.css
CHANGED
|
@@ -867,6 +867,7 @@ select.input {
|
|
|
867
867
|
justify-content: center;
|
|
868
868
|
align-items: center;
|
|
869
869
|
padding: 8px 0;
|
|
870
|
+
position: relative;
|
|
870
871
|
}
|
|
871
872
|
|
|
872
873
|
.donut-legend {
|
|
@@ -5350,3 +5351,17 @@ select.input {
|
|
|
5350
5351
|
height: 16px;
|
|
5351
5352
|
stroke-width: 1.7;
|
|
5352
5353
|
}
|
|
5354
|
+
|
|
5355
|
+
.agent-inline-icon {
|
|
5356
|
+
display: inline-flex;
|
|
5357
|
+
align-items: center;
|
|
5358
|
+
justify-content: center;
|
|
5359
|
+
margin-right: 6px;
|
|
5360
|
+
line-height: 0;
|
|
5361
|
+
}
|
|
5362
|
+
|
|
5363
|
+
.agent-inline-icon svg {
|
|
5364
|
+
width: 14px;
|
|
5365
|
+
height: 14px;
|
|
5366
|
+
stroke-width: 1.7;
|
|
5367
|
+
}
|
package/ui/styles/kanban.css
CHANGED
package/ui/tabs/chat.js
CHANGED
|
@@ -545,12 +545,12 @@ export function ChatTab() {
|
|
|
545
545
|
markUserMessageSent(activeAgent.value);
|
|
546
546
|
|
|
547
547
|
// Use sendOrQueue for offline resilience
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
548
|
+
const sendFn = async (sid, msg) => {
|
|
549
|
+
await apiFetch(`/api/sessions/${encodeURIComponent(sid)}/message`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
body: JSON.stringify({ content: msg, mode: agentMode.value, yolo: yoloMode.peek() }),
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
554
|
|
|
555
555
|
try {
|
|
556
556
|
await sendOrQueue(sessionId, content, sendFn);
|
|
@@ -576,7 +576,7 @@ export function ChatTab() {
|
|
|
576
576
|
markUserMessageSent(activeAgent.value);
|
|
577
577
|
|
|
578
578
|
try {
|
|
579
|
-
await apiFetch(`/api/sessions/${newId}/message`, {
|
|
579
|
+
await apiFetch(`/api/sessions/${encodeURIComponent(newId)}/message`, {
|
|
580
580
|
method: "POST",
|
|
581
581
|
body: JSON.stringify({ content, mode: agentMode.value, yolo: yoloMode.peek() }),
|
|
582
582
|
});
|
|
@@ -638,10 +638,10 @@ export function ChatTab() {
|
|
|
638
638
|
/* ── Session rename ── */
|
|
639
639
|
async function saveRename(sid, newTitle) {
|
|
640
640
|
try {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
641
|
+
await apiFetch(`/api/sessions/${encodeURIComponent(sid)}/rename`, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
body: JSON.stringify({ title: newTitle }),
|
|
644
|
+
});
|
|
645
645
|
loadSessions();
|
|
646
646
|
showToast("Session renamed", "success");
|
|
647
647
|
} catch {
|
package/ui/tabs/tasks.js
CHANGED
|
@@ -16,6 +16,7 @@ const html = htm.bind(h);
|
|
|
16
16
|
import { haptic, showConfirm } from "../modules/telegram.js";
|
|
17
17
|
import { apiFetch, sendCommandToChat } from "../modules/api.js";
|
|
18
18
|
import { iconText, resolveIcon } from "../modules/icon-utils.js";
|
|
19
|
+
import { getAgentDisplay } from "../modules/agent-display.js";
|
|
19
20
|
import { signal } from "@preact/signals";
|
|
20
21
|
import {
|
|
21
22
|
tasksData,
|
|
@@ -786,7 +787,7 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
786
787
|
: "var(--color-error)";
|
|
787
788
|
|
|
788
789
|
const startedRelative = liveTask?.created ? formatRelative(liveTask.created) : "—";
|
|
789
|
-
const
|
|
790
|
+
const agentDisplay = getAgentDisplay(liveTask || task);
|
|
790
791
|
const branchLabel = liveTask?.branch || task.branch || "—";
|
|
791
792
|
|
|
792
793
|
const handleCancel = async () => {
|
|
@@ -842,10 +843,13 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
842
843
|
|
|
843
844
|
|
|
844
845
|
<div class="tp-meta-strip">
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
846
|
+
<div class="tp-meta-item">
|
|
847
|
+
<span class="tp-meta-label">Agent</span>
|
|
848
|
+
<span class="tp-meta-value">
|
|
849
|
+
<span class="agent-inline-icon">${agentDisplay.icon}</span>
|
|
850
|
+
${agentDisplay.label}
|
|
851
|
+
</span>
|
|
852
|
+
</div>
|
|
849
853
|
<div class="tp-meta-item">
|
|
850
854
|
<span class="tp-meta-label">Branch</span>
|
|
851
855
|
<span class="tp-meta-value mono">${branchLabel}</span>
|
|
@@ -964,7 +968,7 @@ export function TaskReviewModal({ task, onClose, onStart }) {
|
|
|
964
968
|
|
|
965
969
|
const prNumber = liveTask?.pr || task.pr;
|
|
966
970
|
const branchLabel = liveTask?.branch || task.branch || "—";
|
|
967
|
-
const
|
|
971
|
+
const agentDisplay = getAgentDisplay(liveTask || task);
|
|
968
972
|
const updatedRelative = liveTask?.updated ? formatRelative(liveTask.updated) : "—";
|
|
969
973
|
const reviewAttachments = normalizeTaskAttachments(liveTask || task);
|
|
970
974
|
|
|
@@ -1060,10 +1064,13 @@ export function TaskReviewModal({ task, onClose, onStart }) {
|
|
|
1060
1064
|
|
|
1061
1065
|
|
|
1062
1066
|
<div class="tr-meta-grid">
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
+
<div class="tr-meta-item">
|
|
1068
|
+
<span class="tr-meta-label">Agent</span>
|
|
1069
|
+
<span class="tr-meta-value">
|
|
1070
|
+
<span class="agent-inline-icon">${agentDisplay.icon}</span>
|
|
1071
|
+
${agentDisplay.label}
|
|
1072
|
+
</span>
|
|
1073
|
+
</div>
|
|
1067
1074
|
<div class="tr-meta-item">
|
|
1068
1075
|
<span class="tr-meta-label">Branch</span>
|
|
1069
1076
|
<span class="tr-meta-value mono">${branchLabel}</span>
|
package/ui/tabs/workflows.js
CHANGED
|
@@ -1288,6 +1288,15 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
|
|
|
1288
1288
|
function WorkflowListView() {
|
|
1289
1289
|
const wfs = workflows.value || [];
|
|
1290
1290
|
const tmpls = templates.value || [];
|
|
1291
|
+
const installedTemplateIds = new Set();
|
|
1292
|
+
wfs.forEach((wf) => {
|
|
1293
|
+
if (wf.metadata?.installedFrom) installedTemplateIds.add(wf.metadata.installedFrom);
|
|
1294
|
+
installedTemplateIds.add(wf.name);
|
|
1295
|
+
});
|
|
1296
|
+
const availableTemplates = tmpls.filter((t) => {
|
|
1297
|
+
if (installedTemplateIds.has(t.id) || installedTemplateIds.has(t.name)) return false;
|
|
1298
|
+
return true;
|
|
1299
|
+
});
|
|
1291
1300
|
|
|
1292
1301
|
return html`
|
|
1293
1302
|
<div style="padding: 0 4px;">
|
|
@@ -1384,25 +1393,30 @@ function WorkflowListView() {
|
|
|
1384
1393
|
const newWf = { name: "New Workflow", description: "", category: "custom", enabled: true, nodes: [], edges: [], variables: {} };
|
|
1385
1394
|
saveWorkflow(newWf).then(wf => { if (wf) { activeWorkflow.value = wf; viewMode.value = "canvas"; } });
|
|
1386
1395
|
}}>+ Create Blank</button>
|
|
1387
|
-
${
|
|
1388
|
-
<button class="wf-btn" style="border-color: #f59e0b60; color: #f59e0b;" onClick=${() => installTemplate(
|
|
1396
|
+
${availableTemplates.length > 0 && html`
|
|
1397
|
+
<button class="wf-btn" style="border-color: #f59e0b60; color: #f59e0b;" onClick=${() => installTemplate(availableTemplates[0]?.id)}>
|
|
1389
1398
|
<span class="btn-icon">${resolveIcon("zap")}</span>
|
|
1390
|
-
Quick Install: ${
|
|
1399
|
+
Quick Install: ${availableTemplates[0]?.name}
|
|
1391
1400
|
</button>
|
|
1392
1401
|
`}
|
|
1393
1402
|
</div>
|
|
1394
1403
|
</div>
|
|
1395
1404
|
`}
|
|
1396
1405
|
|
|
1397
|
-
<!-- Templates (grouped by category) -->
|
|
1406
|
+
<!-- Templates (grouped by category, deduped against installed) -->
|
|
1398
1407
|
<div>
|
|
1399
1408
|
<h3 style="font-size: 14px; font-weight: 600; color: var(--color-text-secondary, #8b95a5); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
|
|
1400
|
-
Templates (${tmpls.length}
|
|
1409
|
+
Available Templates (${availableTemplates.length})${tmpls.length !== availableTemplates.length ? html` <span style="font-size: 11px; font-weight: 400; opacity: 0.6;">· ${tmpls.length - availableTemplates.length} installed</span>` : ""}
|
|
1401
1410
|
</h3>
|
|
1411
|
+
${availableTemplates.length === 0 && html`
|
|
1412
|
+
<div style="text-align: center; padding: 24px; opacity: 0.5; font-size: 13px;">
|
|
1413
|
+
All templates are installed! 🎉
|
|
1414
|
+
</div>
|
|
1415
|
+
`}
|
|
1402
1416
|
${(() => {
|
|
1403
1417
|
// Group templates by category
|
|
1404
1418
|
const groups = {};
|
|
1405
|
-
|
|
1419
|
+
availableTemplates.forEach(t => {
|
|
1406
1420
|
const key = t.category || "custom";
|
|
1407
1421
|
if (!groups[key]) groups[key] = { label: t.categoryLabel || key, icon: t.categoryIcon || "settings", order: t.categoryOrder || 99, items: [] };
|
|
1408
1422
|
groups[key].items.push(t);
|
package/ui-server.mjs
CHANGED
|
@@ -125,7 +125,9 @@ import {
|
|
|
125
125
|
|
|
126
126
|
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
127
127
|
const repoRoot = resolveRepoRoot();
|
|
128
|
-
const
|
|
128
|
+
const uiRootPreferred = resolve(__dirname, "site", "ui");
|
|
129
|
+
const uiRootFallback = resolve(__dirname, "ui");
|
|
130
|
+
const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
|
|
129
131
|
let libraryInitAttempted = false;
|
|
130
132
|
|
|
131
133
|
function ensureLibraryInitialized() {
|
|
@@ -154,7 +156,7 @@ function ensureLibraryInitialized() {
|
|
|
154
156
|
// 2. node_modules — createRequire resolution (handles npm hoisting)
|
|
155
157
|
// 3. CDN redirect — last resort for airgap / first-run edge cases
|
|
156
158
|
const _require = createRequire(import.meta.url);
|
|
157
|
-
const BUNDLED_VENDOR_DIR = resolve(
|
|
159
|
+
const BUNDLED_VENDOR_DIR = resolve(uiRoot, "vendor");
|
|
158
160
|
|
|
159
161
|
function resolveVendorPath(specifier) {
|
|
160
162
|
// Direct resolution first (works when package exports allow the sub-path)
|
|
@@ -248,7 +250,10 @@ async function handleVendor(req, res, url) {
|
|
|
248
250
|
}
|
|
249
251
|
const statusPath = resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
|
|
250
252
|
const logsDir = resolve(__dirname, "logs");
|
|
251
|
-
const
|
|
253
|
+
const agentLogsDirCandidates = [
|
|
254
|
+
resolve(__dirname, "logs", "agents"),
|
|
255
|
+
resolve(repoRoot, ".cache", "agent-logs"),
|
|
256
|
+
];
|
|
252
257
|
const CONFIG_SCHEMA_PATH = resolve(__dirname, "bosun.schema.json");
|
|
253
258
|
const PLANNER_STATE_PATH = resolve(
|
|
254
259
|
repoRoot,
|
|
@@ -2235,6 +2240,21 @@ function broadcastSessionMessage(payload) {
|
|
|
2235
2240
|
|
|
2236
2241
|
/* ─── Log Streaming Helpers ─── */
|
|
2237
2242
|
|
|
2243
|
+
async function resolveAgentLogsDir() {
|
|
2244
|
+
for (const dir of agentLogsDirCandidates) {
|
|
2245
|
+
const files = await readdir(dir).catch(() => null);
|
|
2246
|
+
if (files?.some((f) => f.endsWith(".log"))) return dir;
|
|
2247
|
+
}
|
|
2248
|
+
for (const dir of agentLogsDirCandidates) {
|
|
2249
|
+
if (existsSync(dir)) return dir;
|
|
2250
|
+
}
|
|
2251
|
+
return agentLogsDirCandidates[0];
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function normalizeAgentLogName(name) {
|
|
2255
|
+
return basename(String(name || "")).trim();
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2238
2258
|
/**
|
|
2239
2259
|
* Resolve the log file path for a given logType and optional query.
|
|
2240
2260
|
* Returns null if no matching file found.
|
|
@@ -2246,6 +2266,7 @@ async function resolveLogPath(logType, query) {
|
|
|
2246
2266
|
return logFile ? resolve(logsDir, logFile) : null;
|
|
2247
2267
|
}
|
|
2248
2268
|
if (logType === "agent") {
|
|
2269
|
+
const agentLogsDir = await resolveAgentLogsDir();
|
|
2249
2270
|
const files = await readdir(agentLogsDir).catch(() => []);
|
|
2250
2271
|
let candidates = files.filter((f) => f.endsWith(".log")).sort().reverse();
|
|
2251
2272
|
if (query) {
|
|
@@ -3325,6 +3346,7 @@ function summarizeTelemetry(metrics, days) {
|
|
|
3325
3346
|
|
|
3326
3347
|
async function listAgentLogFiles(query = "", limit = 60) {
|
|
3327
3348
|
const entries = [];
|
|
3349
|
+
const agentLogsDir = await resolveAgentLogsDir();
|
|
3328
3350
|
const files = await readdir(agentLogsDir).catch(() => []);
|
|
3329
3351
|
for (const name of files) {
|
|
3330
3352
|
if (!name.endsWith(".log")) continue;
|
|
@@ -3333,6 +3355,7 @@ async function listAgentLogFiles(query = "", limit = 60) {
|
|
|
3333
3355
|
const info = await stat(resolve(agentLogsDir, name));
|
|
3334
3356
|
entries.push({
|
|
3335
3357
|
name,
|
|
3358
|
+
source: agentLogsDir,
|
|
3336
3359
|
size: info.size,
|
|
3337
3360
|
mtime:
|
|
3338
3361
|
info.mtime?.toISOString?.() || new Date(info.mtime).toISOString(),
|
|
@@ -4617,7 +4640,7 @@ async function handleApi(req, res, url) {
|
|
|
4617
4640
|
|
|
4618
4641
|
if (path === "/api/agent-logs") {
|
|
4619
4642
|
try {
|
|
4620
|
-
const file = url.searchParams.get("file");
|
|
4643
|
+
const file = normalizeAgentLogName(url.searchParams.get("file"));
|
|
4621
4644
|
const query = url.searchParams.get("query") || "";
|
|
4622
4645
|
const lines = Math.min(
|
|
4623
4646
|
1000,
|
|
@@ -4628,6 +4651,7 @@ async function handleApi(req, res, url) {
|
|
|
4628
4651
|
jsonResponse(res, 200, { ok: true, data: files });
|
|
4629
4652
|
return;
|
|
4630
4653
|
}
|
|
4654
|
+
const agentLogsDir = await resolveAgentLogsDir();
|
|
4631
4655
|
const filePath = resolve(agentLogsDir, file);
|
|
4632
4656
|
if (!filePath.startsWith(agentLogsDir)) {
|
|
4633
4657
|
jsonResponse(res, 403, { ok: false, error: "Forbidden" });
|
|
@@ -4851,7 +4875,8 @@ async function handleApi(req, res, url) {
|
|
|
4851
4875
|
return;
|
|
4852
4876
|
}
|
|
4853
4877
|
const latest = files[0];
|
|
4854
|
-
const
|
|
4878
|
+
const agentLogsDir = await resolveAgentLogsDir();
|
|
4879
|
+
const filePath = resolve(agentLogsDir, normalizeAgentLogName(latest.name || latest));
|
|
4855
4880
|
if (!filePath.startsWith(agentLogsDir) || !existsSync(filePath)) {
|
|
4856
4881
|
jsonResponse(res, 200, { ok: true, data: null });
|
|
4857
4882
|
return;
|
|
@@ -5417,8 +5442,9 @@ async function handleApi(req, res, url) {
|
|
|
5417
5442
|
const all = engine.list();
|
|
5418
5443
|
jsonResponse(res, 200, { ok: true, workflows: all.map(w => ({
|
|
5419
5444
|
id: w.id, name: w.name, description: w.description, category: w.category,
|
|
5420
|
-
enabled: w.enabled !== false,
|
|
5421
|
-
|
|
5445
|
+
enabled: w.enabled !== false,
|
|
5446
|
+
nodeCount: Number.isFinite(w.nodeCount) ? w.nodeCount : (w.nodes || []).length,
|
|
5447
|
+
trigger: w.trigger || (w.nodes || [])[0]?.type || "manual",
|
|
5422
5448
|
})) });
|
|
5423
5449
|
} catch (err) {
|
|
5424
5450
|
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
@@ -5530,8 +5556,7 @@ async function handleApi(req, res, url) {
|
|
|
5530
5556
|
}
|
|
5531
5557
|
|
|
5532
5558
|
// GET — return full workflow definition
|
|
5533
|
-
const
|
|
5534
|
-
const wf = all.find(w => w.id === workflowId);
|
|
5559
|
+
const wf = engine.get(workflowId);
|
|
5535
5560
|
if (!wf) { jsonResponse(res, 404, { ok: false, error: "Workflow not found" }); return; }
|
|
5536
5561
|
jsonResponse(res, 200, { ok: true, workflow: wf });
|
|
5537
5562
|
} catch (err) {
|
|
@@ -6876,8 +6901,7 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
6876
6901
|
}
|
|
6877
6902
|
}
|
|
6878
6903
|
|
|
6879
|
-
|
|
6880
|
-
if (uiServerTls) {
|
|
6904
|
+
// Start cloudflared tunnel for trusted TLS (Telegram Mini App requires valid cert)
|
|
6881
6905
|
const tUrl = await startTunnel(actualPort);
|
|
6882
6906
|
if (tUrl) {
|
|
6883
6907
|
console.log(`[telegram-ui] Telegram Mini App URL: ${tUrl}`);
|
|
@@ -6888,7 +6912,6 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
6888
6912
|
);
|
|
6889
6913
|
}
|
|
6890
6914
|
}
|
|
6891
|
-
}
|
|
6892
6915
|
|
|
6893
6916
|
return uiServer;
|
|
6894
6917
|
}
|