bosun 0.40.0 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/primary-agent.mjs +194 -2
- package/config/config-doctor.mjs +9 -2
- package/config/config.mjs +23 -12
- package/infra/session-tracker.mjs +26 -0
- package/kanban/kanban-adapter.mjs +117 -2
- package/lib/session-insights.mjs +378 -0
- package/package.json +6 -2
- package/postinstall.mjs +24 -0
- package/server/ui-server.mjs +728 -62
- package/shell/copilot-shell.mjs +54 -3
- package/ui/app.js +25 -2
- package/ui/components/session-list.js +36 -3
- package/ui/demo.html +135 -1
- package/ui/modules/session-insights.js +4 -353
- package/ui/styles/components.css +132 -0
- package/ui/tabs/chat.js +86 -3
- package/ui/tabs/manual-flows.js +60 -2
- package/ui/tabs/tasks.js +807 -81
- package/ui/tabs/workflows.js +12 -4
- package/workflow/manual-flows.mjs +95 -0
- package/workflow/workflow-engine.mjs +101 -3
- package/workspace/workspace-manager.mjs +1 -0
package/agent/primary-agent.mjs
CHANGED
|
@@ -741,6 +741,114 @@ const FALLBACK_ORDER = [
|
|
|
741
741
|
"opencode-sdk",
|
|
742
742
|
];
|
|
743
743
|
|
|
744
|
+
const FAILOVER_CONSECUTIVE_INFRA_ERRORS = Math.max(
|
|
745
|
+
1,
|
|
746
|
+
Number(process.env.PRIMARY_AGENT_FAILOVER_CONSECUTIVE_INFRA_ERRORS) || 3,
|
|
747
|
+
);
|
|
748
|
+
const FAILOVER_ERROR_WINDOW_MS = Math.max(
|
|
749
|
+
10_000,
|
|
750
|
+
Number(process.env.PRIMARY_AGENT_FAILOVER_ERROR_WINDOW_MS) ||
|
|
751
|
+
10 * 60 * 1000,
|
|
752
|
+
);
|
|
753
|
+
const _primaryRecoveryRetryEnv = Number(
|
|
754
|
+
process.env.PRIMARY_AGENT_RECOVERY_RETRY_ATTEMPTS,
|
|
755
|
+
);
|
|
756
|
+
const PRIMARY_RECOVERY_RETRY_ATTEMPTS = Number.isFinite(
|
|
757
|
+
_primaryRecoveryRetryEnv,
|
|
758
|
+
)
|
|
759
|
+
? Math.max(0, _primaryRecoveryRetryEnv)
|
|
760
|
+
: 1;
|
|
761
|
+
|
|
762
|
+
const _adapterFailureState = new Map();
|
|
763
|
+
|
|
764
|
+
function adapterErrorText(err) {
|
|
765
|
+
const message = String(err?.message || err || "");
|
|
766
|
+
const code = String(err?.code || "");
|
|
767
|
+
return `${code} ${message}`.trim();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function isSessionScopedAdapterError(err) {
|
|
771
|
+
const text = adapterErrorText(err).toLowerCase();
|
|
772
|
+
if (!text) return false;
|
|
773
|
+
return (
|
|
774
|
+
/\b(session|thread|conversation|context)\b.*\b(not found|expired|invalid|closed|corrupt)\b/.test(
|
|
775
|
+
text,
|
|
776
|
+
) ||
|
|
777
|
+
/\bfailed to resume session\b/.test(text) ||
|
|
778
|
+
/\bsession does not exist\b/.test(text)
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function isInfrastructureAdapterError(err) {
|
|
783
|
+
const text = adapterErrorText(err).toLowerCase();
|
|
784
|
+
if (!text) return false;
|
|
785
|
+
return (
|
|
786
|
+
/\bagent_timeout\b/.test(text) ||
|
|
787
|
+
/\bcodex exec exited with code\b/.test(text) ||
|
|
788
|
+
/\btransport channel closed\b/.test(text) ||
|
|
789
|
+
/\bstream disconnected\b/.test(text) ||
|
|
790
|
+
/\brate limit|too many requests|429\b/.test(text) ||
|
|
791
|
+
/\bservice unavailable|temporarily unavailable|overloaded\b/.test(text) ||
|
|
792
|
+
/\bcannot find module\b/.test(text) ||
|
|
793
|
+
/\bsdk not available|failed to load sdk\b/.test(text) ||
|
|
794
|
+
/\beconnreset|econnrefused|etimedout|network error\b/.test(text) ||
|
|
795
|
+
/\bsegfault|crash|killed\b/.test(text)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function clearAdapterFailureState(adapterName) {
|
|
800
|
+
if (!adapterName) return;
|
|
801
|
+
_adapterFailureState.delete(adapterName);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function noteAdapterFailure(adapterName, err) {
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
const infrastructure = isInfrastructureAdapterError(err);
|
|
807
|
+
const previous = _adapterFailureState.get(adapterName) || {
|
|
808
|
+
streak: 0,
|
|
809
|
+
lastAt: 0,
|
|
810
|
+
lastError: "",
|
|
811
|
+
infrastructure: false,
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const next = {
|
|
815
|
+
streak: 0,
|
|
816
|
+
lastAt: now,
|
|
817
|
+
lastError: adapterErrorText(err),
|
|
818
|
+
infrastructure,
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
if (infrastructure) {
|
|
822
|
+
const withinWindow =
|
|
823
|
+
now - Number(previous.lastAt || 0) <= FAILOVER_ERROR_WINDOW_MS;
|
|
824
|
+
next.streak =
|
|
825
|
+
withinWindow && previous.infrastructure ? previous.streak + 1 : 1;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
_adapterFailureState.set(adapterName, next);
|
|
829
|
+
return {
|
|
830
|
+
...next,
|
|
831
|
+
allowFailover:
|
|
832
|
+
infrastructure && next.streak >= FAILOVER_CONSECUTIVE_INFRA_ERRORS,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function recoverAdapterSession(adapter, adapterName) {
|
|
837
|
+
if (!adapter) return;
|
|
838
|
+
if (typeof adapter.reset === "function") {
|
|
839
|
+
try {
|
|
840
|
+
await adapter.reset();
|
|
841
|
+
} catch (err) {
|
|
842
|
+
console.warn(
|
|
843
|
+
`[primary-agent] recovery reset failed for ${adapterName}: ${err?.message || err}`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (typeof adapter.init === "function") {
|
|
848
|
+
await adapter.init();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
744
852
|
function mapAdapterToPoolSdk(adapterName) {
|
|
745
853
|
const normalized = String(adapterName || "").trim().toLowerCase();
|
|
746
854
|
if (normalized === "copilot-sdk") return "copilot";
|
|
@@ -875,8 +983,12 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
|
|
|
875
983
|
}
|
|
876
984
|
|
|
877
985
|
let lastError = null;
|
|
986
|
+
const maxAdaptersToTry = Math.min(
|
|
987
|
+
adaptersToTry.length,
|
|
988
|
+
maxFailoverAttempts + 1,
|
|
989
|
+
);
|
|
878
990
|
|
|
879
|
-
for (let attempt = 0; attempt <
|
|
991
|
+
for (let attempt = 0; attempt < maxAdaptersToTry; attempt++) {
|
|
880
992
|
const adapterName = adaptersToTry[attempt];
|
|
881
993
|
const adapter = ADAPTERS[adapterName];
|
|
882
994
|
if (!adapter) continue;
|
|
@@ -950,16 +1062,96 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
|
|
|
950
1062
|
});
|
|
951
1063
|
}
|
|
952
1064
|
}
|
|
1065
|
+
clearAdapterFailureState(adapterName);
|
|
953
1066
|
return result;
|
|
954
1067
|
} catch (err) {
|
|
955
1068
|
lastError = err;
|
|
956
1069
|
const isTimeout = err.message?.startsWith("AGENT_TIMEOUT");
|
|
1070
|
+
const isPrimaryAttempt = attempt === 0;
|
|
957
1071
|
console.error(
|
|
958
1072
|
`[primary-agent] ${isTimeout ? ":clock: Timeout" : ":close: Error"} with ${adapterName}: ${err.message}`,
|
|
959
1073
|
);
|
|
960
1074
|
|
|
1075
|
+
if (
|
|
1076
|
+
isPrimaryAttempt &&
|
|
1077
|
+
PRIMARY_RECOVERY_RETRY_ATTEMPTS > 0 &&
|
|
1078
|
+
(isSessionScopedAdapterError(err) || isInfrastructureAdapterError(err))
|
|
1079
|
+
) {
|
|
1080
|
+
for (let retry = 1; retry <= PRIMARY_RECOVERY_RETRY_ATTEMPTS; retry++) {
|
|
1081
|
+
try {
|
|
1082
|
+
console.warn(
|
|
1083
|
+
`[primary-agent] :arrows_counterclockwise: recovering ${adapterName} session (${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS})`,
|
|
1084
|
+
);
|
|
1085
|
+
tracker.recordEvent(sessionId, {
|
|
1086
|
+
role: "system",
|
|
1087
|
+
type: "recovery",
|
|
1088
|
+
content: `:arrows_counterclockwise: Recovering ${adapterName} session (${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS}) before any failover.`,
|
|
1089
|
+
timestamp: new Date().toISOString(),
|
|
1090
|
+
});
|
|
1091
|
+
await recoverAdapterSession(adapter, adapterName);
|
|
1092
|
+
const timeoutAbort = new AbortController();
|
|
1093
|
+
if (options.abortController?.signal) {
|
|
1094
|
+
const callerSignal = options.abortController.signal;
|
|
1095
|
+
if (callerSignal.aborted) {
|
|
1096
|
+
timeoutAbort.abort(callerSignal.reason);
|
|
1097
|
+
} else {
|
|
1098
|
+
callerSignal.addEventListener("abort", () => {
|
|
1099
|
+
timeoutAbort.abort(callerSignal.reason || "user_stop");
|
|
1100
|
+
}, { once: true });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
const retryResult = await withTimeout(
|
|
1104
|
+
adapter.exec(framedMessage, { ...options, sessionId, abortController: timeoutAbort }),
|
|
1105
|
+
timeoutMs,
|
|
1106
|
+
`${adapterName}.exec.retry`,
|
|
1107
|
+
timeoutAbort,
|
|
1108
|
+
);
|
|
1109
|
+
const retryText = typeof retryResult === "string"
|
|
1110
|
+
? retryResult
|
|
1111
|
+
: retryResult.finalResponse || retryResult.text || retryResult.message || JSON.stringify(retryResult);
|
|
1112
|
+
tracker.recordEvent(sessionId, {
|
|
1113
|
+
role: "assistant",
|
|
1114
|
+
content: retryText,
|
|
1115
|
+
timestamp: new Date().toISOString(),
|
|
1116
|
+
_sessionType: sessionType,
|
|
1117
|
+
});
|
|
1118
|
+
clearAdapterFailureState(adapterName);
|
|
1119
|
+
return retryResult;
|
|
1120
|
+
} catch (retryErr) {
|
|
1121
|
+
lastError = retryErr;
|
|
1122
|
+
console.error(
|
|
1123
|
+
`[primary-agent] :close: recovery attempt ${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS} failed for ${adapterName}: ${retryErr?.message || retryErr}`,
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const failureState = noteAdapterFailure(adapterName, lastError);
|
|
1130
|
+
const shouldBlockPrimaryFailover =
|
|
1131
|
+
isPrimaryAttempt && !failureState.allowFailover;
|
|
1132
|
+
|
|
1133
|
+
if (shouldBlockPrimaryFailover) {
|
|
1134
|
+
const waitReason = failureState.infrastructure
|
|
1135
|
+
? `holding failover until ${FAILOVER_CONSECUTIVE_INFRA_ERRORS} consecutive infrastructure failures (${failureState.streak}/${FAILOVER_CONSECUTIVE_INFRA_ERRORS})`
|
|
1136
|
+
: "error classified as session-scoped/non-infrastructure";
|
|
1137
|
+
console.warn(
|
|
1138
|
+
`[primary-agent] failover suppressed for ${adapterName}: ${waitReason}`,
|
|
1139
|
+
);
|
|
1140
|
+
tracker.recordEvent(sessionId, {
|
|
1141
|
+
role: "system",
|
|
1142
|
+
type: "error",
|
|
1143
|
+
content: `:warning: ${adapterName} error: ${lastError?.message || "unknown error"}. Failover suppressed (${waitReason}).`,
|
|
1144
|
+
timestamp: new Date().toISOString(),
|
|
1145
|
+
});
|
|
1146
|
+
return {
|
|
1147
|
+
finalResponse: `:warning: ${adapterName} error: ${lastError?.message || "unknown error"}. Failover suppressed (${waitReason}).`,
|
|
1148
|
+
items: [],
|
|
1149
|
+
usage: null,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
961
1153
|
// If this is the last adapter, report to user
|
|
962
|
-
if (attempt >=
|
|
1154
|
+
if (attempt >= maxAdaptersToTry - 1) {
|
|
963
1155
|
tracker.recordEvent(sessionId, {
|
|
964
1156
|
role: "system",
|
|
965
1157
|
type: "error",
|
package/config/config-doctor.mjs
CHANGED
|
@@ -39,6 +39,7 @@ function detectRepoRoot() {
|
|
|
39
39
|
return execSync("git rev-parse --show-toplevel", {
|
|
40
40
|
encoding: "utf8",
|
|
41
41
|
stdio: ["pipe", "pipe", "ignore"],
|
|
42
|
+
timeout: 1500,
|
|
42
43
|
}).trim();
|
|
43
44
|
} catch {
|
|
44
45
|
return process.cwd();
|
|
@@ -144,8 +145,12 @@ function mergeNoOverride(base, extra) {
|
|
|
144
145
|
function commandExists(command) {
|
|
145
146
|
try {
|
|
146
147
|
const checker = process.platform === "win32" ? "where" : "which";
|
|
147
|
-
spawnSync(checker, [command], {
|
|
148
|
-
|
|
148
|
+
const result = spawnSync(checker, [command], {
|
|
149
|
+
stdio: "ignore",
|
|
150
|
+
timeout: 1500,
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
});
|
|
153
|
+
return result.status === 0;
|
|
149
154
|
} catch {
|
|
150
155
|
return false;
|
|
151
156
|
}
|
|
@@ -982,3 +987,5 @@ export function formatWorkspaceHealthReport(result) {
|
|
|
982
987
|
|
|
983
988
|
return lines.join("\n");
|
|
984
989
|
}
|
|
990
|
+
|
|
991
|
+
|
package/config/config.mjs
CHANGED
|
@@ -658,6 +658,12 @@ function detectRepoSlug(repoRoot = "") {
|
|
|
658
658
|
}
|
|
659
659
|
|
|
660
660
|
function detectRepoRoot() {
|
|
661
|
+
const gitExecOptions = {
|
|
662
|
+
encoding: "utf8",
|
|
663
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
664
|
+
timeout: 3000,
|
|
665
|
+
};
|
|
666
|
+
|
|
661
667
|
// 1. Explicit env var
|
|
662
668
|
if (process.env.REPO_ROOT) {
|
|
663
669
|
const envRoot = resolve(process.env.REPO_ROOT);
|
|
@@ -667,8 +673,7 @@ function detectRepoRoot() {
|
|
|
667
673
|
// 2. Try git from cwd
|
|
668
674
|
try {
|
|
669
675
|
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
670
|
-
|
|
671
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
676
|
+
...gitExecOptions,
|
|
672
677
|
}).trim();
|
|
673
678
|
if (gitRoot) return gitRoot;
|
|
674
679
|
} catch {
|
|
@@ -678,9 +683,8 @@ function detectRepoRoot() {
|
|
|
678
683
|
// 3. Bosun package directory may be inside a repo (common: scripts/bosun/ within a project)
|
|
679
684
|
try {
|
|
680
685
|
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
681
|
-
encoding: "utf8",
|
|
682
686
|
cwd: __dirname,
|
|
683
|
-
|
|
687
|
+
...gitExecOptions,
|
|
684
688
|
}).trim();
|
|
685
689
|
if (gitRoot) return gitRoot;
|
|
686
690
|
} catch {
|
|
@@ -693,9 +697,8 @@ function detectRepoRoot() {
|
|
|
693
697
|
if (moduleRoot && moduleRoot !== process.cwd()) {
|
|
694
698
|
try {
|
|
695
699
|
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
696
|
-
encoding: "utf8",
|
|
697
700
|
cwd: moduleRoot,
|
|
698
|
-
|
|
701
|
+
...gitExecOptions,
|
|
699
702
|
}).trim();
|
|
700
703
|
if (gitRoot) return gitRoot;
|
|
701
704
|
} catch {
|
|
@@ -1285,20 +1288,27 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1285
1288
|
|
|
1286
1289
|
const { reloadEnv = false } = options;
|
|
1287
1290
|
const cli = parseArgs(argv);
|
|
1291
|
+
const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
|
|
1292
|
+
const normalizedRepoRootOverride = repoRootOverride
|
|
1293
|
+
? resolve(repoRootOverride)
|
|
1294
|
+
: "";
|
|
1295
|
+
let detectedRepoRoot = "";
|
|
1296
|
+
const getFallbackRepoRoot = () => {
|
|
1297
|
+
if (normalizedRepoRootOverride) return normalizedRepoRootOverride;
|
|
1298
|
+
if (!detectedRepoRoot) detectedRepoRoot = detectRepoRoot();
|
|
1299
|
+
return detectedRepoRoot;
|
|
1300
|
+
};
|
|
1288
1301
|
|
|
1289
|
-
const repoRootForConfig = detectRepoRoot();
|
|
1290
1302
|
// Determine config directory (where bosun stores its config)
|
|
1291
1303
|
const configDir =
|
|
1292
1304
|
cli["config-dir"] ||
|
|
1293
1305
|
process.env.BOSUN_DIR ||
|
|
1294
|
-
resolveConfigDir(
|
|
1306
|
+
resolveConfigDir(normalizedRepoRootOverride);
|
|
1295
1307
|
|
|
1296
1308
|
const configFile = loadConfigFile(configDir);
|
|
1297
1309
|
let configData = configFile.data || {};
|
|
1298
1310
|
const configFileHadInvalidJson = configFile.error === "invalid-json";
|
|
1299
1311
|
|
|
1300
|
-
const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
|
|
1301
|
-
|
|
1302
1312
|
// Load workspace configuration
|
|
1303
1313
|
const workspacesDir = resolve(configDir, "workspaces");
|
|
1304
1314
|
const activeWorkspace = cli["workspace"] ||
|
|
@@ -1340,7 +1350,8 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1340
1350
|
// over REPO_ROOT (env); REPO_ROOT becomes "developer root" for config only.
|
|
1341
1351
|
const selectedRepoPath = selectedRepository?.path || "";
|
|
1342
1352
|
const selectedRepoHasGit = selectedRepoPath && existsSync(resolve(selectedRepoPath, ".git"));
|
|
1343
|
-
let repoRoot =
|
|
1353
|
+
let repoRoot =
|
|
1354
|
+
(selectedRepoHasGit ? selectedRepoPath : null) || getFallbackRepoRoot();
|
|
1344
1355
|
|
|
1345
1356
|
// Resolve agent execution root (workspace-aware, separate from developer root)
|
|
1346
1357
|
const agentRepoRoot = resolveAgentRepoRoot();
|
|
@@ -1405,7 +1416,7 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1405
1416
|
{
|
|
1406
1417
|
const selPath = selectedRepository?.path || "";
|
|
1407
1418
|
const selHasGit = selPath && existsSync(resolve(selPath, ".git"));
|
|
1408
|
-
repoRoot = (selHasGit ? selPath : null) ||
|
|
1419
|
+
repoRoot = (selHasGit ? selPath : null) || getFallbackRepoRoot();
|
|
1409
1420
|
}
|
|
1410
1421
|
|
|
1411
1422
|
if (resolve(repoRoot) !== resolve(initialRepoRoot)) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
15
15
|
import { resolve, dirname } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { buildSessionInsights } from "../lib/session-insights.mjs";
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const SESSIONS_DIR = resolve(__dirname, "..", "logs", "sessions");
|
|
@@ -184,6 +185,7 @@ export class SessionTracker {
|
|
|
184
185
|
status: "active",
|
|
185
186
|
lastActivityAt: Date.now(),
|
|
186
187
|
metadata: {},
|
|
188
|
+
insights: buildSessionInsights({ messages: [] }),
|
|
187
189
|
});
|
|
188
190
|
this.#markDirty(taskId);
|
|
189
191
|
}
|
|
@@ -235,6 +237,7 @@ export class SessionTracker {
|
|
|
235
237
|
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
236
238
|
while (session.messages.length > maxMessages) session.messages.shift();
|
|
237
239
|
}
|
|
240
|
+
this.#refreshDerivedState(session);
|
|
238
241
|
this.#markDirty(taskId);
|
|
239
242
|
emitSessionEvent(session, msg);
|
|
240
243
|
return;
|
|
@@ -266,6 +269,7 @@ export class SessionTracker {
|
|
|
266
269
|
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
267
270
|
while (session.messages.length > maxMessages) session.messages.shift();
|
|
268
271
|
}
|
|
272
|
+
this.#refreshDerivedState(session);
|
|
269
273
|
this.#markDirty(taskId);
|
|
270
274
|
emitSessionEvent(session, msg);
|
|
271
275
|
return;
|
|
@@ -282,6 +286,7 @@ export class SessionTracker {
|
|
|
282
286
|
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
283
287
|
while (session.messages.length > maxMessages) session.messages.shift();
|
|
284
288
|
}
|
|
289
|
+
this.#refreshDerivedState(session);
|
|
285
290
|
this.#markDirty(taskId);
|
|
286
291
|
emitSessionEvent(session, msg);
|
|
287
292
|
}
|
|
@@ -297,6 +302,7 @@ export class SessionTracker {
|
|
|
297
302
|
|
|
298
303
|
session.endedAt = Date.now();
|
|
299
304
|
session.status = status;
|
|
305
|
+
this.#refreshDerivedState(session);
|
|
300
306
|
this.#markDirty(taskId);
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -521,6 +527,7 @@ export class SessionTracker {
|
|
|
521
527
|
lastActivityAt: Date.now(),
|
|
522
528
|
metadata,
|
|
523
529
|
maxMessages: resolvedMax,
|
|
530
|
+
insights: buildSessionInsights({ messages: [] }),
|
|
524
531
|
};
|
|
525
532
|
this.#sessions.set(id, session);
|
|
526
533
|
this.#markDirty(id);
|
|
@@ -549,6 +556,7 @@ export class SessionTracker {
|
|
|
549
556
|
lastActiveAt: s.lastActiveAt || new Date(s.lastActivityAt).toISOString(),
|
|
550
557
|
preview: this.#lastMessagePreview(s),
|
|
551
558
|
lastMessage: this.#lastMessagePreview(s),
|
|
559
|
+
insights: s.insights || null,
|
|
552
560
|
});
|
|
553
561
|
}
|
|
554
562
|
list.sort((a, b) => (b.lastActiveAt || "").localeCompare(a.lastActiveAt || ""));
|
|
@@ -587,6 +595,7 @@ export class SessionTracker {
|
|
|
587
595
|
if (status === "completed" || status === "archived") {
|
|
588
596
|
session.endedAt = Date.now();
|
|
589
597
|
}
|
|
598
|
+
this.#refreshDerivedState(session);
|
|
590
599
|
this.#markDirty(sessionId);
|
|
591
600
|
}
|
|
592
601
|
|
|
@@ -665,6 +674,7 @@ export class SessionTracker {
|
|
|
665
674
|
target.editedAt = new Date().toISOString();
|
|
666
675
|
session.lastActivityAt = Date.now();
|
|
667
676
|
session.lastActiveAt = new Date().toISOString();
|
|
677
|
+
this.#refreshDerivedState(session);
|
|
668
678
|
this.#markDirty(sessionId);
|
|
669
679
|
|
|
670
680
|
return { ok: true, message: { ...target }, index: idx };
|
|
@@ -766,6 +776,7 @@ export class SessionTracker {
|
|
|
766
776
|
status,
|
|
767
777
|
lastActivityAt: lastActive || Date.now(),
|
|
768
778
|
metadata: data.metadata || {},
|
|
779
|
+
insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
|
|
769
780
|
});
|
|
770
781
|
}
|
|
771
782
|
}
|
|
@@ -817,6 +828,7 @@ export class SessionTracker {
|
|
|
817
828
|
if (idleMs > this.#idleThresholdMs) {
|
|
818
829
|
session.status = "completed";
|
|
819
830
|
session.endedAt = now;
|
|
831
|
+
this.#refreshDerivedState(session);
|
|
820
832
|
this.#markDirty(id);
|
|
821
833
|
reaped++;
|
|
822
834
|
}
|
|
@@ -840,6 +852,18 @@ export class SessionTracker {
|
|
|
840
852
|
}
|
|
841
853
|
}
|
|
842
854
|
|
|
855
|
+
#refreshDerivedState(session) {
|
|
856
|
+
if (!session) return;
|
|
857
|
+
try {
|
|
858
|
+
session.insights = buildSessionInsights({
|
|
859
|
+
...session,
|
|
860
|
+
insights: null,
|
|
861
|
+
});
|
|
862
|
+
} catch {
|
|
863
|
+
// Inspector insights are best-effort only.
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
843
867
|
#ensureDir() {
|
|
844
868
|
if (this.#persistDir && !existsSync(this.#persistDir)) {
|
|
845
869
|
mkdirSync(this.#persistDir, { recursive: true });
|
|
@@ -872,6 +896,7 @@ export class SessionTracker {
|
|
|
872
896
|
turnCount: session.turnCount || 0,
|
|
873
897
|
messages: session.messages || [],
|
|
874
898
|
metadata: session.metadata || {},
|
|
899
|
+
insights: session.insights || null,
|
|
875
900
|
};
|
|
876
901
|
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
877
902
|
} catch (err) {
|
|
@@ -946,6 +971,7 @@ export class SessionTracker {
|
|
|
946
971
|
turnCount: data.turnCount || 0,
|
|
947
972
|
lastActivityAt: lastActive || Date.now(),
|
|
948
973
|
metadata: data.metadata || {},
|
|
974
|
+
insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
|
|
949
975
|
});
|
|
950
976
|
}
|
|
951
977
|
} catch {
|
|
@@ -744,6 +744,16 @@ class InternalAdapter {
|
|
|
744
744
|
? task.meta.comments
|
|
745
745
|
: [];
|
|
746
746
|
const normalizedComments = normalizeCommentList(rawComments);
|
|
747
|
+
const assignee = normalizeTaskStringField(resolveTaskField(task, "assignee"));
|
|
748
|
+
const assignees = normalizeTaskStringList(
|
|
749
|
+
resolveTaskField(task, "assignees", assignee ? [assignee] : []),
|
|
750
|
+
);
|
|
751
|
+
const epicId = normalizeTaskStringField(resolveTaskField(task, "epicId"));
|
|
752
|
+
const storyPoints = normalizeTaskNumericField(resolveTaskField(task, "storyPoints"));
|
|
753
|
+
const parentTaskId = normalizeTaskStringField(
|
|
754
|
+
resolveTaskField(task, "parentTaskId"),
|
|
755
|
+
);
|
|
756
|
+
const dueDate = normalizeTaskStringField(resolveTaskField(task, "dueDate"));
|
|
747
757
|
const existingAttachments = []
|
|
748
758
|
.concat(Array.isArray(task.attachments) ? task.attachments : [])
|
|
749
759
|
.concat(Array.isArray(task.meta?.attachments) ? task.meta.attachments : []);
|
|
@@ -757,7 +767,12 @@ class InternalAdapter {
|
|
|
757
767
|
title: recoveredTitle || "",
|
|
758
768
|
description,
|
|
759
769
|
status: normaliseStatus(task.status || recoveredStatus),
|
|
760
|
-
assignee:
|
|
770
|
+
assignee: assignee || assignees[0] || null,
|
|
771
|
+
assignees,
|
|
772
|
+
epicId,
|
|
773
|
+
storyPoints,
|
|
774
|
+
parentTaskId,
|
|
775
|
+
dueDate,
|
|
761
776
|
priority: task.priority || null,
|
|
762
777
|
tags,
|
|
763
778
|
draft,
|
|
@@ -862,6 +877,20 @@ class InternalAdapter {
|
|
|
862
877
|
if (typeof patch.workspace === "string") updates.workspace = patch.workspace;
|
|
863
878
|
if (typeof patch.repository === "string") updates.repository = patch.repository;
|
|
864
879
|
if (Array.isArray(patch.repositories)) updates.repositories = patch.repositories;
|
|
880
|
+
const assigneeProvided = hasOwnField(patch, "assignee");
|
|
881
|
+
const assigneesProvided = hasOwnField(patch, "assignees");
|
|
882
|
+
const assignee = normalizeTaskStringField(patch.assignee ?? patch.meta?.assignee);
|
|
883
|
+
const assignees = normalizeTaskStringList(
|
|
884
|
+
assigneesProvided
|
|
885
|
+
? patch.assignees
|
|
886
|
+
: patch.meta?.assignees ?? (assignee ? [assignee] : []),
|
|
887
|
+
);
|
|
888
|
+
const epicId = normalizeTaskStringField(patch.epicId ?? patch.meta?.epicId);
|
|
889
|
+
const storyPoints = normalizeTaskNumericField(patch.storyPoints ?? patch.meta?.storyPoints);
|
|
890
|
+
const parentTaskId = normalizeTaskStringField(
|
|
891
|
+
patch.parentTaskId ?? patch.meta?.parentTaskId,
|
|
892
|
+
);
|
|
893
|
+
const dueDate = normalizeTaskStringField(patch.dueDate ?? patch.meta?.dueDate);
|
|
865
894
|
if (Array.isArray(patch.tags) || Array.isArray(patch.labels) || typeof patch.tags === "string") {
|
|
866
895
|
updates.tags = normalizeTags(patch.tags ?? patch.labels);
|
|
867
896
|
}
|
|
@@ -875,10 +904,32 @@ class InternalAdapter {
|
|
|
875
904
|
if (baseBranch) {
|
|
876
905
|
updates.baseBranch = baseBranch;
|
|
877
906
|
}
|
|
907
|
+
if (assigneeProvided || assignee || assignees.length > 0) {
|
|
908
|
+
updates.assignee = assignee || assignees[0] || null;
|
|
909
|
+
updates.assignees = assignees.length > 0 ? assignees : assignee ? [assignee] : [];
|
|
910
|
+
}
|
|
911
|
+
if (hasOwnField(patch, "epicId") || epicId) updates.epicId = epicId;
|
|
912
|
+
if (hasOwnField(patch, "storyPoints") || storyPoints != null) {
|
|
913
|
+
updates.storyPoints = storyPoints;
|
|
914
|
+
}
|
|
915
|
+
if (hasOwnField(patch, "parentTaskId") || parentTaskId) {
|
|
916
|
+
updates.parentTaskId = parentTaskId;
|
|
917
|
+
}
|
|
918
|
+
if (hasOwnField(patch, "dueDate") || dueDate) updates.dueDate = dueDate;
|
|
878
919
|
if (patch.meta && typeof patch.meta === "object") {
|
|
879
920
|
updates.meta = {
|
|
880
921
|
...(current?.meta || {}),
|
|
881
922
|
...patch.meta,
|
|
923
|
+
...((assigneeProvided || assignee || assignees.length > 0)
|
|
924
|
+
? {
|
|
925
|
+
assignee: assignee || assignees[0] || null,
|
|
926
|
+
assignees: assignees.length > 0 ? assignees : assignee ? [assignee] : [],
|
|
927
|
+
}
|
|
928
|
+
: {}),
|
|
929
|
+
...((hasOwnField(patch, "epicId") || epicId) ? { epicId } : {}),
|
|
930
|
+
...((hasOwnField(patch, "storyPoints") || storyPoints != null) ? { storyPoints } : {}),
|
|
931
|
+
...((hasOwnField(patch, "parentTaskId") || parentTaskId) ? { parentTaskId } : {}),
|
|
932
|
+
...((hasOwnField(patch, "dueDate") || dueDate) ? { dueDate } : {}),
|
|
882
933
|
...(typeof patch.workspace === "string" ? { workspace: patch.workspace } : {}),
|
|
883
934
|
...(typeof patch.repository === "string" ? { repository: patch.repository } : {}),
|
|
884
935
|
...(Array.isArray(patch.repositories) ? { repositories: patch.repositories } : {}),
|
|
@@ -903,12 +954,29 @@ class InternalAdapter {
|
|
|
903
954
|
const tags = normalizeTags(taskData.tags || taskData.labels || []);
|
|
904
955
|
const draft = Boolean(taskData.draft || taskData.status === "draft");
|
|
905
956
|
const baseBranch = resolveBaseBranchInput(taskData);
|
|
957
|
+
const assignee = normalizeTaskStringField(taskData.assignee ?? taskData.meta?.assignee);
|
|
958
|
+
const assignees = normalizeTaskStringList(
|
|
959
|
+
taskData.assignees ?? taskData.meta?.assignees ?? (assignee ? [assignee] : []),
|
|
960
|
+
);
|
|
961
|
+
const epicId = normalizeTaskStringField(taskData.epicId ?? taskData.meta?.epicId);
|
|
962
|
+
const storyPoints = normalizeTaskNumericField(
|
|
963
|
+
taskData.storyPoints ?? taskData.meta?.storyPoints,
|
|
964
|
+
);
|
|
965
|
+
const parentTaskId = normalizeTaskStringField(
|
|
966
|
+
taskData.parentTaskId ?? taskData.meta?.parentTaskId,
|
|
967
|
+
);
|
|
968
|
+
const dueDate = normalizeTaskStringField(taskData.dueDate ?? taskData.meta?.dueDate);
|
|
906
969
|
const created = addInternalTask({
|
|
907
970
|
id,
|
|
908
971
|
title: taskData.title || "Untitled task",
|
|
909
972
|
description: taskData.description || "",
|
|
910
973
|
status: draft ? "draft" : normaliseStatus(taskData.status || "todo"),
|
|
911
|
-
assignee:
|
|
974
|
+
assignee: assignee || assignees[0] || null,
|
|
975
|
+
assignees,
|
|
976
|
+
epicId,
|
|
977
|
+
storyPoints,
|
|
978
|
+
parentTaskId,
|
|
979
|
+
dueDate,
|
|
912
980
|
priority: taskData.priority || null,
|
|
913
981
|
tags,
|
|
914
982
|
draft,
|
|
@@ -932,6 +1000,16 @@ class InternalAdapter {
|
|
|
932
1000
|
baseBranch,
|
|
933
1001
|
meta: {
|
|
934
1002
|
...(taskData.meta || {}),
|
|
1003
|
+
...((assignee || assignees.length > 0)
|
|
1004
|
+
? {
|
|
1005
|
+
assignee: assignee || assignees[0] || null,
|
|
1006
|
+
assignees: assignees.length > 0 ? assignees : assignee ? [assignee] : [],
|
|
1007
|
+
}
|
|
1008
|
+
: {}),
|
|
1009
|
+
...(epicId ? { epicId } : {}),
|
|
1010
|
+
...(storyPoints != null ? { storyPoints } : {}),
|
|
1011
|
+
...(parentTaskId ? { parentTaskId } : {}),
|
|
1012
|
+
...(dueDate ? { dueDate } : {}),
|
|
935
1013
|
...(taskData.workspace ? { workspace: taskData.workspace } : {}),
|
|
936
1014
|
...(taskData.repository || taskData.repo
|
|
937
1015
|
? { repository: taskData.repository || taskData.repo }
|
|
@@ -1004,6 +1082,43 @@ function normalizeTags(raw) {
|
|
|
1004
1082
|
return normalizeLabels(raw);
|
|
1005
1083
|
}
|
|
1006
1084
|
|
|
1085
|
+
function hasOwnField(value, key) {
|
|
1086
|
+
return Object.prototype.hasOwnProperty.call(value || {}, key);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function normalizeTaskStringField(value) {
|
|
1090
|
+
const normalized = String(value || "").trim();
|
|
1091
|
+
return normalized || null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function normalizeTaskStringList(value) {
|
|
1095
|
+
const values = Array.isArray(value)
|
|
1096
|
+
? value
|
|
1097
|
+
: typeof value === "string"
|
|
1098
|
+
? value.split(",")
|
|
1099
|
+
: [];
|
|
1100
|
+
const seen = new Set();
|
|
1101
|
+
const normalized = [];
|
|
1102
|
+
for (const entry of values) {
|
|
1103
|
+
const next = normalizeTaskStringField(entry?.login || entry?.name || entry);
|
|
1104
|
+
if (!next || seen.has(next)) continue;
|
|
1105
|
+
seen.add(next);
|
|
1106
|
+
normalized.push(next);
|
|
1107
|
+
}
|
|
1108
|
+
return normalized;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function normalizeTaskNumericField(value) {
|
|
1112
|
+
const numeric = Number(value);
|
|
1113
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function resolveTaskField(task, key, fallback = null) {
|
|
1117
|
+
if (task?.[key] != null) return task[key];
|
|
1118
|
+
if (task?.meta?.[key] != null) return task.meta[key];
|
|
1119
|
+
return fallback;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1007
1122
|
function looksLikeKanbanEntity(value) {
|
|
1008
1123
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1009
1124
|
return (
|