bosun 0.41.2 → 0.41.4
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/agent/agent-pool.mjs +9 -2
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +119 -6
- package/agent/autofix-git.mjs +33 -0
- package/agent/autofix-prompts.mjs +151 -0
- package/agent/autofix.mjs +11 -175
- package/agent/bosun-skills.mjs +3 -2
- package/bosun.config.example.json +17 -0
- package/bosun.schema.json +87 -188
- package/cli.mjs +34 -1
- package/config/config-doctor.mjs +5 -250
- package/config/config-file-names.mjs +5 -0
- package/config/config.mjs +89 -493
- package/config/executor-config.mjs +493 -0
- package/config/repo-root.mjs +1 -2
- package/config/workspace-health.mjs +242 -0
- package/git/git-safety.mjs +15 -0
- package/github/github-oauth-portal.mjs +46 -0
- package/infra/library-manager-utils.mjs +22 -0
- package/infra/library-manager-well-known-sources.mjs +578 -0
- package/infra/library-manager.mjs +512 -1030
- package/infra/monitor.mjs +35 -9
- package/infra/session-tracker.mjs +10 -7
- package/kanban/kanban-adapter.mjs +17 -1
- package/lib/codebase-audit-manifests.mjs +117 -0
- package/lib/codebase-audit.mjs +18 -115
- package/package.json +18 -3
- package/server/setup-web-server.mjs +58 -5
- package/server/ui-server.mjs +1394 -79
- package/shell/codex-config-file.mjs +178 -0
- package/shell/codex-config.mjs +538 -575
- package/task/task-cli.mjs +54 -3
- package/task/task-executor.mjs +143 -13
- package/task/task-store.mjs +409 -1
- package/telegram/telegram-bot.mjs +127 -0
- package/tools/apply-pr-suggestions.mjs +401 -0
- package/tools/syntax-check.mjs +28 -9
- package/ui/app.js +3 -14
- package/ui/components/kanban-board.js +227 -4
- package/ui/components/session-list.js +85 -5
- package/ui/demo-defaults.js +338 -84
- package/ui/demo.html +155 -0
- package/ui/modules/session-api.js +96 -0
- package/ui/modules/settings-schema.js +1 -2
- package/ui/modules/state.js +43 -3
- package/ui/setup.html +4 -5
- package/ui/styles/components.css +58 -4
- package/ui/tabs/agents.js +12 -15
- package/ui/tabs/control.js +1 -0
- package/ui/tabs/library.js +484 -22
- package/ui/tabs/manual-flows.js +105 -29
- package/ui/tabs/tasks.js +848 -141
- package/ui/tabs/telemetry.js +129 -11
- package/ui/tabs/workflow-canvas-utils.mjs +130 -0
- package/ui/tabs/workflows.js +293 -23
- package/voice/voice-tool-definitions.mjs +757 -0
- package/voice/voice-tools.mjs +34 -778
- package/workflow/manual-flow-audit.mjs +165 -0
- package/workflow/manual-flows.mjs +164 -259
- package/workflow/workflow-engine.mjs +147 -58
- package/workflow/workflow-nodes/definitions.mjs +1207 -0
- package/workflow/workflow-nodes/transforms.mjs +612 -0
- package/workflow/workflow-nodes.mjs +358 -63
- package/workflow/workflow-templates.mjs +313 -191
- package/workflow-templates/_helpers.mjs +154 -0
- package/workflow-templates/agents.mjs +61 -4
- package/workflow-templates/code-quality.mjs +7 -7
- package/workflow-templates/github.mjs +20 -10
- package/workflow-templates/task-batch.mjs +44 -11
- package/workflow-templates/task-lifecycle.mjs +31 -6
- package/workspace/worktree-manager.mjs +277 -3
package/task/task-store.mjs
CHANGED
|
@@ -652,6 +652,138 @@ function compareTaskDagOrder(taskA, taskB) {
|
|
|
652
652
|
return String(taskA?.id || "").localeCompare(String(taskB?.id || ""));
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
+
function compareSprintDagOrder(sprintA, sprintB) {
|
|
656
|
+
const orderA = normalizeSprintOrder(sprintA?.order);
|
|
657
|
+
const orderB = normalizeSprintOrder(sprintB?.order);
|
|
658
|
+
if (orderA != null && orderB != null && orderA !== orderB) return orderA - orderB;
|
|
659
|
+
if (orderA != null && orderB == null) return -1;
|
|
660
|
+
if (orderA == null && orderB != null) return 1;
|
|
661
|
+
return String(sprintA?.name || sprintA?.id || "").localeCompare(String(sprintB?.name || sprintB?.id || ""));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function topoSortIds(seedIds, incomingMap, outgoingMap, compareEntries) {
|
|
665
|
+
const remaining = new Map();
|
|
666
|
+
for (const id of seedIds) remaining.set(id, incomingMap.get(id) || 0);
|
|
667
|
+
const ordered = [];
|
|
668
|
+
const ready = [...seedIds].filter((id) => (remaining.get(id) || 0) === 0);
|
|
669
|
+
|
|
670
|
+
while (ready.length > 0) {
|
|
671
|
+
ready.sort(compareEntries);
|
|
672
|
+
const current = ready.shift();
|
|
673
|
+
ordered.push(current);
|
|
674
|
+
for (const nextId of outgoingMap.get(current) || []) {
|
|
675
|
+
if (!remaining.has(nextId)) continue;
|
|
676
|
+
const nextCount = (remaining.get(nextId) || 0) - 1;
|
|
677
|
+
remaining.set(nextId, nextCount);
|
|
678
|
+
if (nextCount === 0) ready.push(nextId);
|
|
679
|
+
}
|
|
680
|
+
remaining.delete(current);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (remaining.size > 0) {
|
|
684
|
+
ordered.push(...[...remaining.keys()].sort(compareEntries));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return ordered;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function hasDependencyPath(taskMap, fromTaskId, targetTaskId, visited = new Set()) {
|
|
691
|
+
const startId = String(fromTaskId || "").trim();
|
|
692
|
+
const targetId = String(targetTaskId || "").trim();
|
|
693
|
+
if (!startId || !targetId) return false;
|
|
694
|
+
if (startId === targetId) return true;
|
|
695
|
+
if (visited.has(startId)) return false;
|
|
696
|
+
visited.add(startId);
|
|
697
|
+
const task = taskMap.get(startId);
|
|
698
|
+
if (!task) return false;
|
|
699
|
+
for (const dependencyId of listTaskDependencyIds(task)) {
|
|
700
|
+
if (dependencyId === targetId) return true;
|
|
701
|
+
if (hasDependencyPath(taskMap, dependencyId, targetId, visited)) return true;
|
|
702
|
+
}
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function collectDagRewriteSuggestions(taskMap, orderedTaskIds, sprint) {
|
|
707
|
+
const suggestions = [];
|
|
708
|
+
const sprintId = normalizeSprintId(sprint?.id);
|
|
709
|
+
const sprintMode = resolveSprintOrderMode(sprint?.executionMode || sprint?.taskOrderMode || "parallel");
|
|
710
|
+
|
|
711
|
+
// Cache for hasDependencyPath results to avoid repeated expensive traversals.
|
|
712
|
+
const pathCache = new Map();
|
|
713
|
+
function memoizedHasDependencyPath(fromTaskId, targetTaskId, initialVisited) {
|
|
714
|
+
const fromId = String(fromTaskId || "").trim();
|
|
715
|
+
const toId = String(targetTaskId || "").trim();
|
|
716
|
+
if (!fromId || !toId) return false;
|
|
717
|
+
const cacheKey = `${fromId}::${toId}`;
|
|
718
|
+
if (pathCache.has(cacheKey)) {
|
|
719
|
+
return pathCache.get(cacheKey);
|
|
720
|
+
}
|
|
721
|
+
const visited = initialVisited ? new Set(initialVisited) : new Set();
|
|
722
|
+
const result = hasDependencyPath(taskMap, fromId, toId, visited);
|
|
723
|
+
pathCache.set(cacheKey, result);
|
|
724
|
+
return result;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (sprintMode === "sequential") {
|
|
728
|
+
for (let index = 1; index < orderedTaskIds.length; index += 1) {
|
|
729
|
+
const previousTaskId = orderedTaskIds[index - 1];
|
|
730
|
+
const currentTaskId = orderedTaskIds[index];
|
|
731
|
+
const currentTask = taskMap.get(currentTaskId);
|
|
732
|
+
if (!currentTask) continue;
|
|
733
|
+
if (new Set(listTaskDependencyIds(currentTask)).has(previousTaskId)) continue;
|
|
734
|
+
suggestions.push({
|
|
735
|
+
type: "missing_sequential_dependency",
|
|
736
|
+
sprintId,
|
|
737
|
+
taskId: currentTaskId,
|
|
738
|
+
dependencyTaskId: previousTaskId,
|
|
739
|
+
message: `Add dependency ${previousTaskId} -> ${currentTaskId} to encode sequential sprint order.`,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
for (const taskId of orderedTaskIds) {
|
|
745
|
+
const task = taskMap.get(taskId);
|
|
746
|
+
if (!task) continue;
|
|
747
|
+
const directDependencies = listTaskDependencyIds(task);
|
|
748
|
+
for (const dependencyId of directDependencies) {
|
|
749
|
+
const redundant = directDependencies.some((otherDependencyId) => {
|
|
750
|
+
if (otherDependencyId === dependencyId) return false;
|
|
751
|
+
return memoizedHasDependencyPath(otherDependencyId, dependencyId, new Set([taskId]));
|
|
752
|
+
});
|
|
753
|
+
if (!redundant) continue;
|
|
754
|
+
suggestions.push({
|
|
755
|
+
type: "redundant_transitive_dependency",
|
|
756
|
+
sprintId,
|
|
757
|
+
taskId,
|
|
758
|
+
dependencyTaskId: dependencyId,
|
|
759
|
+
message: `Dependency ${dependencyId} -> ${taskId} is already implied transitively by another dependency.`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return suggestions;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function getTaskEpicId(task) {
|
|
768
|
+
return String(task?.epicId ?? task?.meta?.epicId ?? "").trim();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function normalizeRecoveredTaskMeta(task, recoveredAt) {
|
|
772
|
+
const currentMeta = task?.meta && typeof task.meta === "object" ? task.meta : {};
|
|
773
|
+
const currentRecovery = currentMeta.autoRecovery && typeof currentMeta.autoRecovery === "object"
|
|
774
|
+
? currentMeta.autoRecovery
|
|
775
|
+
: {};
|
|
776
|
+
return {
|
|
777
|
+
...currentMeta,
|
|
778
|
+
autoRecovery: {
|
|
779
|
+
...currentRecovery,
|
|
780
|
+
active: false,
|
|
781
|
+
recoveredAt,
|
|
782
|
+
recoveredStatus: "todo",
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
655
787
|
function recalcStats() {
|
|
656
788
|
const stats = {
|
|
657
789
|
draft: 0,
|
|
@@ -1320,6 +1452,56 @@ export function setTaskStatus(taskId, status, source) {
|
|
|
1320
1452
|
return { ...task };
|
|
1321
1453
|
}
|
|
1322
1454
|
|
|
1455
|
+
export function unblockTask(taskId, options = {}) {
|
|
1456
|
+
ensureLoaded();
|
|
1457
|
+
const task = _store.tasks[taskId];
|
|
1458
|
+
if (!task) {
|
|
1459
|
+
console.warn(TAG, `unblockTask: task ${taskId} not found`);
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const previousStatus = normalizeTaskStatus(task.status);
|
|
1464
|
+
const nextStatus = normalizeTaskStatus(
|
|
1465
|
+
options.status || options.targetStatus || "todo",
|
|
1466
|
+
);
|
|
1467
|
+
const timestamp = now();
|
|
1468
|
+
task.status = nextStatus;
|
|
1469
|
+
task.cooldownUntil = null;
|
|
1470
|
+
task.blockedReason = null;
|
|
1471
|
+
if (task.meta && typeof task.meta === "object") {
|
|
1472
|
+
const nextMeta = { ...task.meta };
|
|
1473
|
+
delete nextMeta.autoRecovery;
|
|
1474
|
+
task.meta = nextMeta;
|
|
1475
|
+
}
|
|
1476
|
+
task.updatedAt = timestamp;
|
|
1477
|
+
task.lastActivityAt = timestamp;
|
|
1478
|
+
task.syncDirty = options.source !== "external";
|
|
1479
|
+
|
|
1480
|
+
if (previousStatus !== nextStatus) {
|
|
1481
|
+
task.statusHistory.push({
|
|
1482
|
+
status: nextStatus,
|
|
1483
|
+
timestamp,
|
|
1484
|
+
source: options.source || "manual-unblock",
|
|
1485
|
+
});
|
|
1486
|
+
if (task.statusHistory.length > MAX_STATUS_HISTORY) {
|
|
1487
|
+
task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
pushTaskTimeline(task, {
|
|
1492
|
+
type: "task.unblocked",
|
|
1493
|
+
source: options.source || "manual-unblock",
|
|
1494
|
+
fromStatus: previousStatus,
|
|
1495
|
+
toStatus: nextStatus,
|
|
1496
|
+
status: nextStatus,
|
|
1497
|
+
action: "unblock_task",
|
|
1498
|
+
message: `Cleared blocked state and moved task to ${nextStatus}`,
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
saveStore();
|
|
1502
|
+
return { ...task };
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1323
1505
|
export function validateTaskStatusTransition(currentStatus, nextStatus, options = {}) {
|
|
1324
1506
|
return validateTaskTransition(currentStatus, nextStatus, options);
|
|
1325
1507
|
}
|
|
@@ -2076,6 +2258,232 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
2076
2258
|
};
|
|
2077
2259
|
}
|
|
2078
2260
|
|
|
2261
|
+
export function recoverAutoBlockedTasks(options = {}) {
|
|
2262
|
+
ensureLoaded();
|
|
2263
|
+
const recoveredAtMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
2264
|
+
const recoveredAt = new Date(recoveredAtMs).toISOString();
|
|
2265
|
+
const recoveredTaskIds = [];
|
|
2266
|
+
|
|
2267
|
+
for (const task of Object.values(_store.tasks)) {
|
|
2268
|
+
if (!task || normalizeTaskStatus(task.status) !== "blocked") continue;
|
|
2269
|
+
const autoRecovery = task.meta?.autoRecovery;
|
|
2270
|
+
if (!autoRecovery || typeof autoRecovery !== "object") continue;
|
|
2271
|
+
if (autoRecovery.active === false) continue;
|
|
2272
|
+
if (String(autoRecovery.reason || "").trim() !== "worktree_failure") continue;
|
|
2273
|
+
const retryAtMs = Date.parse(String(autoRecovery.retryAt || task.cooldownUntil || ""));
|
|
2274
|
+
if (!Number.isFinite(retryAtMs) || retryAtMs > recoveredAtMs) continue;
|
|
2275
|
+
|
|
2276
|
+
const previousStatus = normalizeTaskStatus(task.status);
|
|
2277
|
+
task.status = "todo";
|
|
2278
|
+
task.cooldownUntil = null;
|
|
2279
|
+
task.blockedReason = null;
|
|
2280
|
+
task.meta = normalizeRecoveredTaskMeta(task, recoveredAt);
|
|
2281
|
+
task.updatedAt = recoveredAt;
|
|
2282
|
+
task.lastActivityAt = recoveredAt;
|
|
2283
|
+
task.syncDirty = true;
|
|
2284
|
+
task.statusHistory.push({
|
|
2285
|
+
status: "todo",
|
|
2286
|
+
timestamp: recoveredAt,
|
|
2287
|
+
source: "auto-recovery",
|
|
2288
|
+
});
|
|
2289
|
+
if (task.statusHistory.length > MAX_STATUS_HISTORY) {
|
|
2290
|
+
task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
|
|
2291
|
+
}
|
|
2292
|
+
pushTaskTimeline(task, {
|
|
2293
|
+
type: "status.transition",
|
|
2294
|
+
source: "auto-recovery",
|
|
2295
|
+
fromStatus: previousStatus,
|
|
2296
|
+
toStatus: "todo",
|
|
2297
|
+
status: "todo",
|
|
2298
|
+
action: "recover_blocked_task",
|
|
2299
|
+
message: "Recovered timed blocked task back to todo",
|
|
2300
|
+
});
|
|
2301
|
+
markTaskTouched(task, "auto-recovery");
|
|
2302
|
+
recoveredTaskIds.push(task.id);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (recoveredTaskIds.length > 0) saveStore();
|
|
2306
|
+
|
|
2307
|
+
return {
|
|
2308
|
+
recoveredTaskIds,
|
|
2309
|
+
recoveredCount: recoveredTaskIds.length,
|
|
2310
|
+
recoveredAt,
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
export function organizeTaskDag(options = {}) {
|
|
2315
|
+
ensureLoaded();
|
|
2316
|
+
const sprintFilter = normalizeSprintId(options.sprintId);
|
|
2317
|
+
const applyDependencySuggestions = options.applyDependencySuggestions !== false;
|
|
2318
|
+
const syncEpicDependencies = options.syncEpicDependencies !== false;
|
|
2319
|
+
const sprintMap = ensureSprintsMap();
|
|
2320
|
+
const allTasks = Object.values(_store.tasks);
|
|
2321
|
+
const taskMap = new Map(allTasks.map((task) => [task.id, task]));
|
|
2322
|
+
const targetSprintIds = sprintFilter
|
|
2323
|
+
? (sprintMap[sprintFilter] ? [sprintFilter] : [])
|
|
2324
|
+
: listSprints().map((sprint) => sprint.id);
|
|
2325
|
+
|
|
2326
|
+
const suggestions = [];
|
|
2327
|
+
const orderedTaskIdsBySprint = {};
|
|
2328
|
+
let updatedTaskCount = 0;
|
|
2329
|
+
let appliedDependencySuggestionCount = 0;
|
|
2330
|
+
let syncedEpicDependencyCount = 0;
|
|
2331
|
+
|
|
2332
|
+
for (const sprintId of targetSprintIds) {
|
|
2333
|
+
const sprint = sprintMap[sprintId];
|
|
2334
|
+
if (!sprint) continue;
|
|
2335
|
+
const sprintTasks = allTasks
|
|
2336
|
+
.filter((task) => normalizeSprintId(task?.sprintId) === sprintId)
|
|
2337
|
+
.sort(compareTaskDagOrder);
|
|
2338
|
+
const incomingCounts = new Map();
|
|
2339
|
+
const outgoingMap = new Map();
|
|
2340
|
+
for (const task of sprintTasks) {
|
|
2341
|
+
incomingCounts.set(task.id, 0);
|
|
2342
|
+
outgoingMap.set(task.id, new Set());
|
|
2343
|
+
}
|
|
2344
|
+
for (const task of sprintTasks) {
|
|
2345
|
+
for (const dependencyId of listTaskDependencyIds(task)) {
|
|
2346
|
+
const dependencyTask = taskMap.get(dependencyId);
|
|
2347
|
+
if (!dependencyTask || normalizeSprintId(dependencyTask.sprintId) !== sprintId) continue;
|
|
2348
|
+
outgoingMap.get(dependencyId).add(task.id);
|
|
2349
|
+
incomingCounts.set(task.id, (incomingCounts.get(task.id) || 0) + 1);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const orderedTaskIds = topoSortIds(
|
|
2354
|
+
sprintTasks.map((task) => task.id),
|
|
2355
|
+
incomingCounts,
|
|
2356
|
+
outgoingMap,
|
|
2357
|
+
(leftId, rightId) => compareTaskDagOrder(taskMap.get(leftId), taskMap.get(rightId)),
|
|
2358
|
+
);
|
|
2359
|
+
orderedTaskIdsBySprint[sprintId] = orderedTaskIds;
|
|
2360
|
+
const sprintSuggestions = collectDagRewriteSuggestions(taskMap, orderedTaskIds, sprint);
|
|
2361
|
+
for (const suggestion of sprintSuggestions) {
|
|
2362
|
+
if (applyDependencySuggestions !== true || suggestion?.type !== "missing_sequential_dependency") {
|
|
2363
|
+
suggestions.push(suggestion);
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
const task = taskMap.get(suggestion.taskId);
|
|
2367
|
+
const dependencyTask = taskMap.get(suggestion.dependencyTaskId);
|
|
2368
|
+
if (!task || !dependencyTask) continue;
|
|
2369
|
+
const currentDependencies = listTaskDependencyIds(task);
|
|
2370
|
+
if (currentDependencies.includes(suggestion.dependencyTaskId)) continue;
|
|
2371
|
+
task.dependencyTaskIds = uniqueStringList([...(task.dependencyTaskIds || []), suggestion.dependencyTaskId]);
|
|
2372
|
+
task.dependsOn = uniqueStringList([...(task.dependsOn || []), suggestion.dependencyTaskId]);
|
|
2373
|
+
dependencyTask.blockedByTaskIds = uniqueStringList([...(dependencyTask.blockedByTaskIds || []), task.id]);
|
|
2374
|
+
markTaskTouched(task, "dag-organize");
|
|
2375
|
+
markTaskTouched(dependencyTask, "dag-organize");
|
|
2376
|
+
appliedDependencySuggestionCount += 1;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
orderedTaskIds.forEach((taskId, index) => {
|
|
2380
|
+
const task = taskMap.get(taskId);
|
|
2381
|
+
if (!task) return;
|
|
2382
|
+
const nextOrder = index + 1;
|
|
2383
|
+
if (normalizeSprintOrder(task.sprintOrder) === nextOrder) return;
|
|
2384
|
+
task.sprintOrder = nextOrder;
|
|
2385
|
+
markTaskTouched(task, "dag-organize");
|
|
2386
|
+
updatedTaskCount += 1;
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
const allSprintIds = listSprints().map((sprint) => sprint.id);
|
|
2391
|
+
const sprintIncoming = new Map();
|
|
2392
|
+
const sprintOutgoing = new Map();
|
|
2393
|
+
for (const sprintId of allSprintIds) {
|
|
2394
|
+
sprintIncoming.set(sprintId, 0);
|
|
2395
|
+
sprintOutgoing.set(sprintId, new Set());
|
|
2396
|
+
}
|
|
2397
|
+
for (const task of allTasks) {
|
|
2398
|
+
const taskSprintId = normalizeSprintId(task?.sprintId);
|
|
2399
|
+
if (!taskSprintId) continue;
|
|
2400
|
+
for (const dependencyId of listTaskDependencyIds(task)) {
|
|
2401
|
+
const dependencyTask = taskMap.get(dependencyId);
|
|
2402
|
+
const dependencySprintId = normalizeSprintId(dependencyTask?.sprintId);
|
|
2403
|
+
if (!dependencySprintId || dependencySprintId === taskSprintId) continue;
|
|
2404
|
+
if (!sprintOutgoing.get(dependencySprintId)?.has(taskSprintId)) {
|
|
2405
|
+
sprintOutgoing.get(dependencySprintId).add(taskSprintId);
|
|
2406
|
+
sprintIncoming.set(taskSprintId, (sprintIncoming.get(taskSprintId) || 0) + 1);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const orderedSprintIds = topoSortIds(
|
|
2412
|
+
allSprintIds,
|
|
2413
|
+
sprintIncoming,
|
|
2414
|
+
sprintOutgoing,
|
|
2415
|
+
(leftId, rightId) => compareSprintDagOrder(sprintMap[leftId], sprintMap[rightId]),
|
|
2416
|
+
);
|
|
2417
|
+
let updatedSprintCount = 0;
|
|
2418
|
+
if (!sprintFilter) {
|
|
2419
|
+
orderedSprintIds.forEach((sprintId, index) => {
|
|
2420
|
+
const sprint = sprintMap[sprintId];
|
|
2421
|
+
if (!sprint) return;
|
|
2422
|
+
const nextOrder = index + 1;
|
|
2423
|
+
if (normalizeSprintOrder(sprint.order) === nextOrder) return;
|
|
2424
|
+
sprint.order = nextOrder;
|
|
2425
|
+
sprint.updatedAt = now();
|
|
2426
|
+
updatedSprintCount += 1;
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
if (syncEpicDependencies) {
|
|
2431
|
+
const epicDependencyMap = ensureEpicDependenciesMap();
|
|
2432
|
+
const relevantTasks = sprintFilter
|
|
2433
|
+
? allTasks.filter((task) => targetSprintIds.includes(normalizeSprintId(task?.sprintId)))
|
|
2434
|
+
: allTasks;
|
|
2435
|
+
const nextDependenciesByEpic = new Map(
|
|
2436
|
+
Object.entries(epicDependencyMap).map(([epicId, dependencyIds]) => [epicId, uniqueStringList(dependencyIds)]),
|
|
2437
|
+
);
|
|
2438
|
+
|
|
2439
|
+
for (const task of relevantTasks) {
|
|
2440
|
+
const taskEpicId = getTaskEpicId(task);
|
|
2441
|
+
if (!taskEpicId) continue;
|
|
2442
|
+
const currentDependencies = nextDependenciesByEpic.get(taskEpicId) || [];
|
|
2443
|
+
let nextDependencies = currentDependencies;
|
|
2444
|
+
for (const dependencyTaskId of listTaskDependencyIds(task)) {
|
|
2445
|
+
const dependencyTask = taskMap.get(dependencyTaskId);
|
|
2446
|
+
const dependencyEpicId = getTaskEpicId(dependencyTask);
|
|
2447
|
+
if (!dependencyEpicId || dependencyEpicId === taskEpicId) continue;
|
|
2448
|
+
if (nextDependencies.includes(dependencyEpicId)) continue;
|
|
2449
|
+
nextDependencies = uniqueStringList([...nextDependencies, dependencyEpicId]);
|
|
2450
|
+
}
|
|
2451
|
+
nextDependenciesByEpic.set(taskEpicId, nextDependencies);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
for (const [epicId, nextDependencies] of nextDependenciesByEpic.entries()) {
|
|
2455
|
+
const currentDependencies = uniqueStringList(epicDependencyMap[epicId] || []);
|
|
2456
|
+
if (
|
|
2457
|
+
currentDependencies.length === nextDependencies.length &&
|
|
2458
|
+
currentDependencies.every((dependencyId, index) => dependencyId === nextDependencies[index])
|
|
2459
|
+
) {
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
if (nextDependencies.length > 0) {
|
|
2463
|
+
epicDependencyMap[epicId] = nextDependencies;
|
|
2464
|
+
} else {
|
|
2465
|
+
delete epicDependencyMap[epicId];
|
|
2466
|
+
}
|
|
2467
|
+
syncedEpicDependencyCount += 1;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (updatedTaskCount > 0 || updatedSprintCount > 0 || appliedDependencySuggestionCount > 0 || syncedEpicDependencyCount > 0) {
|
|
2472
|
+
saveStore();
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
return {
|
|
2476
|
+
sprintId: sprintFilter || null,
|
|
2477
|
+
orderedSprintIds,
|
|
2478
|
+
orderedTaskIdsBySprint,
|
|
2479
|
+
updatedSprintCount,
|
|
2480
|
+
updatedTaskCount,
|
|
2481
|
+
appliedDependencySuggestionCount,
|
|
2482
|
+
syncedEpicDependencyCount,
|
|
2483
|
+
suggestions,
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2079
2487
|
// ---------------------------------------------------------------------------
|
|
2080
2488
|
// Agent tracking
|
|
2081
2489
|
// ---------------------------------------------------------------------------
|
|
@@ -2423,4 +2831,4 @@ export function getStaleInReviewTasks(maxAgeMs) {
|
|
|
2423
2831
|
(t) => t.status === "inreview" && t.lastActivityAt < cutoff,
|
|
2424
2832
|
);
|
|
2425
2833
|
}
|
|
2426
|
-
|
|
2834
|
+
|
|
@@ -1126,6 +1126,11 @@ let agentChatId = null; // latest chat where an agent is running
|
|
|
1126
1126
|
const stickyMenuState = new Map();
|
|
1127
1127
|
const stickyMenuTimers = new Map();
|
|
1128
1128
|
const STICKY_MENU_BUMP_MS = 600;
|
|
1129
|
+
const callbackActionDeduper = new Map();
|
|
1130
|
+
const CALLBACK_ACTION_DEDUPE_MS = Math.max(
|
|
1131
|
+
150,
|
|
1132
|
+
Number(process.env.TELEGRAM_CALLBACK_ACTION_DEDUPE_MS || "1200") || 1200,
|
|
1133
|
+
);
|
|
1129
1134
|
|
|
1130
1135
|
// ── Queues ──────────────────────────────────────────────────────────────────
|
|
1131
1136
|
|
|
@@ -1383,6 +1388,18 @@ function setStickyMenuState(chatId, patch) {
|
|
|
1383
1388
|
stickyMenuState.set(chatId, { ...current, ...patch });
|
|
1384
1389
|
}
|
|
1385
1390
|
|
|
1391
|
+
function logTelegramStructured(event, payload = {}) {
|
|
1392
|
+
try {
|
|
1393
|
+
console.log(`[telegram-bot] ${JSON.stringify({
|
|
1394
|
+
event,
|
|
1395
|
+
at: new Date().toISOString(),
|
|
1396
|
+
...payload,
|
|
1397
|
+
})}`);
|
|
1398
|
+
} catch {
|
|
1399
|
+
console.log(`[telegram-bot] ${event}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1386
1403
|
function clearStickyMenuTimer(chatId) {
|
|
1387
1404
|
const timer = stickyMenuTimers.get(chatId);
|
|
1388
1405
|
if (timer) {
|
|
@@ -1396,6 +1413,94 @@ function isStickyMenuInteractive(chatId) {
|
|
|
1396
1413
|
return stickyMenuState.get(chatId)?.mode === "interactive";
|
|
1397
1414
|
}
|
|
1398
1415
|
|
|
1416
|
+
function isMenuCallbackData(data) {
|
|
1417
|
+
if (typeof data !== "string") return false;
|
|
1418
|
+
return data.startsWith("ui:") || data.startsWith("cb:");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function shouldRecoverStickyFromCallback(query) {
|
|
1422
|
+
const data = String(query?.data || "");
|
|
1423
|
+
if (!isMenuCallbackData(data)) return false;
|
|
1424
|
+
const messageId = query?.message?.message_id;
|
|
1425
|
+
const chatId = String(query?.message?.chat?.id || "");
|
|
1426
|
+
if (!chatId || !messageId) return false;
|
|
1427
|
+
const current = stickyMenuState.get(chatId);
|
|
1428
|
+
if (current?.enabled && current?.messageId) return false;
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function recoverStickyMenuContextFromCallback(query, reason = "callback") {
|
|
1433
|
+
if (!shouldRecoverStickyFromCallback(query)) return false;
|
|
1434
|
+
const chatId = String(query.message.chat.id || "");
|
|
1435
|
+
const messageId = query.message.message_id;
|
|
1436
|
+
const data = String(query.data || "");
|
|
1437
|
+
const prev = stickyMenuState.get(chatId) || {};
|
|
1438
|
+
const screenId = prev.screenId || "home";
|
|
1439
|
+
const params = prev.params || {};
|
|
1440
|
+
const mode = data === "cb:dismiss" || data === "ui:cancel"
|
|
1441
|
+
? "interactive"
|
|
1442
|
+
: "menu";
|
|
1443
|
+
setStickyMenuState(chatId, {
|
|
1444
|
+
enabled: true,
|
|
1445
|
+
messageId,
|
|
1446
|
+
screenId,
|
|
1447
|
+
params,
|
|
1448
|
+
mode,
|
|
1449
|
+
restoreScreenId: prev.restoreScreenId || screenId,
|
|
1450
|
+
restoreParams: prev.restoreParams || params,
|
|
1451
|
+
});
|
|
1452
|
+
logTelegramStructured("sticky_menu.context_recovered", {
|
|
1453
|
+
reason,
|
|
1454
|
+
chatId,
|
|
1455
|
+
messageId,
|
|
1456
|
+
mode,
|
|
1457
|
+
data,
|
|
1458
|
+
});
|
|
1459
|
+
return true;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function pruneCallbackActionDeduper(now = Date.now()) {
|
|
1463
|
+
for (const [key, entry] of callbackActionDeduper.entries()) {
|
|
1464
|
+
if (!entry || now - entry.atMs > CALLBACK_ACTION_DEDUPE_MS) {
|
|
1465
|
+
callbackActionDeduper.delete(key);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function dedupeMenuCallbackAction({
|
|
1471
|
+
chatId,
|
|
1472
|
+
fromId,
|
|
1473
|
+
messageId,
|
|
1474
|
+
data,
|
|
1475
|
+
callbackId,
|
|
1476
|
+
}) {
|
|
1477
|
+
if (!isMenuCallbackData(data)) return { duplicate: false };
|
|
1478
|
+
const now = Date.now();
|
|
1479
|
+
pruneCallbackActionDeduper(now);
|
|
1480
|
+
const key = [
|
|
1481
|
+
String(chatId || ""),
|
|
1482
|
+
String(fromId || ""),
|
|
1483
|
+
String(messageId || ""),
|
|
1484
|
+
String(data || ""),
|
|
1485
|
+
].join("|");
|
|
1486
|
+
const prev = callbackActionDeduper.get(key);
|
|
1487
|
+
callbackActionDeduper.set(key, {
|
|
1488
|
+
atMs: now,
|
|
1489
|
+
callbackId: String(callbackId || ""),
|
|
1490
|
+
});
|
|
1491
|
+
if (!prev) return { duplicate: false };
|
|
1492
|
+
if (String(prev.callbackId || "") === String(callbackId || "")) {
|
|
1493
|
+
return { duplicate: false };
|
|
1494
|
+
}
|
|
1495
|
+
const ageMs = now - prev.atMs;
|
|
1496
|
+
if (ageMs > CALLBACK_ACTION_DEDUPE_MS) return { duplicate: false };
|
|
1497
|
+
return {
|
|
1498
|
+
duplicate: true,
|
|
1499
|
+
key,
|
|
1500
|
+
ageMs,
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1399
1504
|
function getStickyMenuRestoreTarget(chatId) {
|
|
1400
1505
|
const state = stickyMenuState.get(chatId) || {};
|
|
1401
1506
|
return {
|
|
@@ -2607,6 +2712,7 @@ async function handleCallbackQuery(query) {
|
|
|
2607
2712
|
const fromId = String(query.from?.id || "");
|
|
2608
2713
|
const data = query.data || "";
|
|
2609
2714
|
const callbackId = query.id;
|
|
2715
|
+
const messageId = query.message?.message_id || null;
|
|
2610
2716
|
|
|
2611
2717
|
// Security: only accept from configured chat/user allow-list
|
|
2612
2718
|
if (!isAuthorizedTelegramActor(chatId, fromId)) {
|
|
@@ -2614,6 +2720,27 @@ async function handleCallbackQuery(query) {
|
|
|
2614
2720
|
return;
|
|
2615
2721
|
}
|
|
2616
2722
|
|
|
2723
|
+
recoverStickyMenuContextFromCallback(query, "reconnect");
|
|
2724
|
+
|
|
2725
|
+
const dedupe = dedupeMenuCallbackAction({
|
|
2726
|
+
chatId,
|
|
2727
|
+
fromId,
|
|
2728
|
+
messageId,
|
|
2729
|
+
data,
|
|
2730
|
+
callbackId,
|
|
2731
|
+
});
|
|
2732
|
+
if (dedupe.duplicate) {
|
|
2733
|
+
logTelegramStructured("sticky_menu.callback_deduped", {
|
|
2734
|
+
chatId,
|
|
2735
|
+
fromId,
|
|
2736
|
+
messageId,
|
|
2737
|
+
data,
|
|
2738
|
+
ageMs: dedupe.ageMs,
|
|
2739
|
+
});
|
|
2740
|
+
await answerCallbackQuery(callbackId, "Already processing...");
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2617
2744
|
console.log(`[telegram-bot] callback: "${data}" from chat ${chatId}`);
|
|
2618
2745
|
|
|
2619
2746
|
// Always acknowledge the callback to dismiss loading indicator
|