bosun 0.42.0 → 0.42.2
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 +12 -0
- package/README.md +2 -0
- package/agent/agent-pool.mjs +34 -1
- package/agent/agent-work-report.mjs +89 -3
- package/agent/analyze-agent-work-helpers.mjs +14 -0
- package/agent/analyze-agent-work.mjs +23 -3
- package/agent/primary-agent.mjs +23 -1
- package/bosun-tui.mjs +4 -3
- package/bosun.schema.json +1 -1
- package/config/config.mjs +58 -0
- package/config/workspace-health.mjs +36 -6
- package/git/diff-stats.mjs +550 -124
- package/github/github-app-auth.mjs +9 -5
- package/infra/maintenance.mjs +13 -6
- package/infra/monitor.mjs +398 -10
- package/infra/runtime-accumulator.mjs +9 -1
- package/infra/session-tracker.mjs +163 -1
- package/infra/tui-bridge.mjs +415 -0
- package/infra/worktree-recovery-state.mjs +159 -0
- package/kanban/kanban-adapter.mjs +41 -8
- package/lib/repo-map.mjs +411 -0
- package/package.json +140 -137
- package/server/ui-server.mjs +953 -59
- package/shell/codex-config.mjs +34 -8
- package/task/task-cli.mjs +93 -19
- package/task/task-executor.mjs +397 -8
- package/task/task-store.mjs +194 -1
- package/telegram/telegram-bot.mjs +267 -18
- package/tools/vitest-runner.mjs +108 -0
- package/tui/app.mjs +252 -148
- package/tui/components/status-header.mjs +88 -131
- package/tui/lib/ws-bridge.mjs +125 -35
- package/tui/screens/agents-screen-helpers.mjs +219 -0
- package/tui/screens/agents.mjs +287 -270
- package/tui/screens/status.mjs +51 -189
- package/tui/screens/tasks.mjs +41 -253
- package/ui/app.js +52 -23
- package/ui/components/chat-view.js +263 -84
- package/ui/components/diff-viewer.js +324 -140
- package/ui/components/kanban-board.js +13 -9
- package/ui/components/session-list.js +111 -41
- package/ui/demo-defaults.js +481 -59
- package/ui/demo.html +32 -0
- package/ui/modules/session-api.js +320 -5
- package/ui/modules/stream-timeline.js +356 -0
- package/ui/modules/telegram.js +5 -2
- package/ui/modules/worktree-recovery.js +85 -0
- package/ui/styles.css +44 -0
- package/ui/tabs/chat.js +19 -4
- package/ui/tabs/dashboard.js +22 -0
- package/ui/tabs/infra.js +25 -0
- package/ui/tabs/tasks.js +119 -11
- package/voice/voice-auth-manager.mjs +10 -5
- package/workflow/workflow-engine.mjs +179 -1
- package/workflow/workflow-nodes.mjs +872 -16
- package/workflow/workflow-templates.mjs +4 -0
- package/workflow-templates/github.mjs +2 -1
- package/workflow-templates/planning.mjs +2 -1
- package/workflow-templates/sub-workflows.mjs +10 -0
- package/workflow-templates/task-batch.mjs +9 -8
- package/workflow-templates/task-execution.mjs +30 -12
- package/workflow-templates/task-lifecycle.mjs +59 -4
- package/workspace/shared-knowledge.mjs +409 -155
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TUI_EVENT_TYPES = Object.freeze([
|
|
5
|
+
"monitor:stats",
|
|
6
|
+
"sessions:update",
|
|
7
|
+
"session:event",
|
|
8
|
+
"logs:stream",
|
|
9
|
+
"workflow:status",
|
|
10
|
+
"tasks:update",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const RATE_LIMIT_BUCKET_SCHEMA = {
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["primary", "secondary", "credits", "unit"],
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
primary: { type: ["number", "null"] },
|
|
19
|
+
secondary: { type: ["number", "null"] },
|
|
20
|
+
credits: { type: ["number", "null"] },
|
|
21
|
+
unit: { type: "string", minLength: 1 },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SESSION_SUMMARY_SCHEMA = {
|
|
26
|
+
type: "object",
|
|
27
|
+
required: [
|
|
28
|
+
"id",
|
|
29
|
+
"taskId",
|
|
30
|
+
"title",
|
|
31
|
+
"type",
|
|
32
|
+
"status",
|
|
33
|
+
"workspaceId",
|
|
34
|
+
"workspaceDir",
|
|
35
|
+
"branch",
|
|
36
|
+
"turnCount",
|
|
37
|
+
"createdAt",
|
|
38
|
+
"lastActiveAt",
|
|
39
|
+
"idleMs",
|
|
40
|
+
"elapsedMs",
|
|
41
|
+
"recommendation",
|
|
42
|
+
"preview",
|
|
43
|
+
"lastMessage",
|
|
44
|
+
"insights",
|
|
45
|
+
],
|
|
46
|
+
additionalProperties: true,
|
|
47
|
+
properties: {
|
|
48
|
+
id: { type: "string", minLength: 1 },
|
|
49
|
+
taskId: { type: "string", minLength: 1 },
|
|
50
|
+
title: { type: ["string", "null"] },
|
|
51
|
+
type: { type: "string", minLength: 1 },
|
|
52
|
+
status: { type: "string", minLength: 1 },
|
|
53
|
+
workspaceId: { type: ["string", "null"] },
|
|
54
|
+
workspaceDir: { type: ["string", "null"] },
|
|
55
|
+
branch: { type: ["string", "null"] },
|
|
56
|
+
turnCount: { type: "number", minimum: 0 },
|
|
57
|
+
createdAt: { type: "string", minLength: 1 },
|
|
58
|
+
lastActiveAt: { type: "string", minLength: 1 },
|
|
59
|
+
idleMs: { type: "number", minimum: 0 },
|
|
60
|
+
elapsedMs: { type: "number", minimum: 0 },
|
|
61
|
+
recommendation: { type: "string" },
|
|
62
|
+
preview: { type: ["string", "null"] },
|
|
63
|
+
lastMessage: { type: ["string", "null"] },
|
|
64
|
+
insights: {},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const TUI_EVENT_SCHEMAS = Object.freeze({
|
|
69
|
+
"monitor:stats": {
|
|
70
|
+
type: "object",
|
|
71
|
+
required: [
|
|
72
|
+
"activeAgents",
|
|
73
|
+
"maxAgents",
|
|
74
|
+
"tokensIn",
|
|
75
|
+
"tokensOut",
|
|
76
|
+
"tokensTotal",
|
|
77
|
+
"throughputTps",
|
|
78
|
+
"uptimeMs",
|
|
79
|
+
"rateLimits",
|
|
80
|
+
],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
properties: {
|
|
83
|
+
activeAgents: { type: "number", minimum: 0 },
|
|
84
|
+
maxAgents: { type: "number", minimum: 0 },
|
|
85
|
+
tokensIn: { type: "number", minimum: 0 },
|
|
86
|
+
tokensOut: { type: "number", minimum: 0 },
|
|
87
|
+
tokensTotal: { type: "number", minimum: 0 },
|
|
88
|
+
throughputTps: { type: "number", minimum: 0 },
|
|
89
|
+
uptimeMs: { type: "number", minimum: 0 },
|
|
90
|
+
rateLimits: {
|
|
91
|
+
type: "object",
|
|
92
|
+
additionalProperties: RATE_LIMIT_BUCKET_SCHEMA,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"sessions:update": {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: SESSION_SUMMARY_SCHEMA,
|
|
99
|
+
},
|
|
100
|
+
"session:event": {
|
|
101
|
+
type: "object",
|
|
102
|
+
required: ["sessionId", "taskId", "session", "event"],
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
properties: {
|
|
105
|
+
sessionId: { type: "string", minLength: 1 },
|
|
106
|
+
taskId: { type: "string", minLength: 1 },
|
|
107
|
+
session: {
|
|
108
|
+
type: "object",
|
|
109
|
+
required: ["id", "taskId", "type", "status", "lastActiveAt", "turnCount"],
|
|
110
|
+
additionalProperties: true,
|
|
111
|
+
properties: {
|
|
112
|
+
id: { type: "string", minLength: 1 },
|
|
113
|
+
taskId: { type: "string", minLength: 1 },
|
|
114
|
+
type: { type: "string", minLength: 1 },
|
|
115
|
+
status: { type: "string", minLength: 1 },
|
|
116
|
+
lastActiveAt: { type: "string", minLength: 1 },
|
|
117
|
+
turnCount: { type: "number", minimum: 0 },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
event: {
|
|
121
|
+
type: "object",
|
|
122
|
+
required: ["kind"],
|
|
123
|
+
additionalProperties: true,
|
|
124
|
+
properties: {
|
|
125
|
+
kind: { type: "string", enum: ["message", "state"] },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"logs:stream": {
|
|
131
|
+
type: "object",
|
|
132
|
+
required: ["logType", "raw", "line", "level", "timestamp", "filePath"],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
properties: {
|
|
135
|
+
logType: { type: "string", minLength: 1 },
|
|
136
|
+
query: { type: ["string", "null"] },
|
|
137
|
+
filePath: { type: ["string", "null"] },
|
|
138
|
+
line: { type: "string" },
|
|
139
|
+
raw: { type: "string" },
|
|
140
|
+
level: { type: "string", minLength: 1 },
|
|
141
|
+
timestamp: { type: ["string", "null"] },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
"workflow:status": {
|
|
145
|
+
type: "object",
|
|
146
|
+
required: ["runId", "workflowId", "eventType", "status", "timestamp"],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
properties: {
|
|
149
|
+
runId: { type: "string", minLength: 1 },
|
|
150
|
+
workflowId: { type: "string", minLength: 1 },
|
|
151
|
+
workflowName: { type: ["string", "null"] },
|
|
152
|
+
eventType: { type: "string", minLength: 1 },
|
|
153
|
+
status: { type: "string", minLength: 1 },
|
|
154
|
+
nodeId: { type: ["string", "null"] },
|
|
155
|
+
nodeType: { type: ["string", "null"] },
|
|
156
|
+
nodeLabel: { type: ["string", "null"] },
|
|
157
|
+
error: { type: ["string", "null"] },
|
|
158
|
+
durationMs: { type: ["number", "null"], minimum: 0 },
|
|
159
|
+
timestamp: { type: "number", minimum: 0 },
|
|
160
|
+
meta: { type: ["object", "null"] },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
"tasks:update": {
|
|
164
|
+
type: "object",
|
|
165
|
+
required: ["reason", "sourceEvent", "patch"],
|
|
166
|
+
additionalProperties: false,
|
|
167
|
+
properties: {
|
|
168
|
+
reason: { type: "string", minLength: 1 },
|
|
169
|
+
sourceEvent: { type: "string", minLength: 1 },
|
|
170
|
+
taskId: { type: ["string", "null"] },
|
|
171
|
+
taskIds: {
|
|
172
|
+
type: ["array", "null"],
|
|
173
|
+
items: { type: "string", minLength: 1 },
|
|
174
|
+
},
|
|
175
|
+
status: { type: ["string", "null"] },
|
|
176
|
+
workspaceId: { type: ["string", "null"] },
|
|
177
|
+
projectId: { type: ["string", "null"] },
|
|
178
|
+
patch: { type: "object", additionalProperties: true },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function numberOr(value, fallback = 0) {
|
|
184
|
+
const numeric = Number(value);
|
|
185
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function nonNegativeNumber(value, fallback = 0) {
|
|
189
|
+
return Math.max(0, numberOr(value, fallback));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function roundMetric(value, precision = 4) {
|
|
193
|
+
const numeric = nonNegativeNumber(value, 0);
|
|
194
|
+
return Number(numeric.toFixed(precision));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveCacheDir({ cacheDir, configDir } = {}) {
|
|
198
|
+
if (cacheDir) return resolve(String(cacheDir));
|
|
199
|
+
return resolve(String(configDir || process.cwd()), ".cache");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeRateLimits(rateLimits = {}) {
|
|
203
|
+
const normalized = {};
|
|
204
|
+
for (const [provider, bucket] of Object.entries(rateLimits || {})) {
|
|
205
|
+
const key = String(provider || "").trim();
|
|
206
|
+
if (!key || !bucket || typeof bucket !== "object") continue;
|
|
207
|
+
normalized[key] = {
|
|
208
|
+
primary: Number.isFinite(Number(bucket.primary)) ? Number(bucket.primary) : null,
|
|
209
|
+
secondary: Number.isFinite(Number(bucket.secondary)) ? Number(bucket.secondary) : null,
|
|
210
|
+
credits: bucket.credits == null ? null : (Number.isFinite(Number(bucket.credits)) ? Number(bucket.credits) : null),
|
|
211
|
+
unit: String(bucket.unit || "count").trim() || "count",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return normalized;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sumRuntimeTokens(runtimeStats = {}) {
|
|
218
|
+
const sessions = Array.isArray(runtimeStats?.sessions) ? runtimeStats.sessions : [];
|
|
219
|
+
return sessions.reduce((acc, session) => {
|
|
220
|
+
acc.tokensIn += nonNegativeNumber(session?.inputTokens, 0);
|
|
221
|
+
acc.tokensOut += nonNegativeNumber(session?.outputTokens, 0);
|
|
222
|
+
return acc;
|
|
223
|
+
}, { tokensIn: 0, tokensOut: 0 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function buildMonitorStatsPayload({ agentPool, runtimeStats = {}, uptimeMs = 0 } = {}) {
|
|
227
|
+
const agentPoolStats = typeof agentPool?.getTuiStats === "function"
|
|
228
|
+
? (agentPool.getTuiStats() || {})
|
|
229
|
+
: (agentPool && typeof agentPool === "object" ? agentPool : {});
|
|
230
|
+
|
|
231
|
+
const runtimeTokenTotals = sumRuntimeTokens(runtimeStats);
|
|
232
|
+
const tokensIn = nonNegativeNumber(
|
|
233
|
+
agentPoolStats.tokensIn,
|
|
234
|
+
runtimeStats.totalInputTokens ?? runtimeTokenTotals.tokensIn,
|
|
235
|
+
);
|
|
236
|
+
const tokensOut = nonNegativeNumber(
|
|
237
|
+
agentPoolStats.tokensOut,
|
|
238
|
+
runtimeStats.totalOutputTokens ?? runtimeTokenTotals.tokensOut,
|
|
239
|
+
);
|
|
240
|
+
const tokensTotal = nonNegativeNumber(agentPoolStats.tokensTotal, tokensIn + tokensOut);
|
|
241
|
+
const resolvedUptimeMs = nonNegativeNumber(
|
|
242
|
+
uptimeMs,
|
|
243
|
+
runtimeStats.startedAt ? Date.now() - Number(runtimeStats.startedAt) : 0,
|
|
244
|
+
);
|
|
245
|
+
const throughputTps = Number.isFinite(Number(agentPoolStats.throughputTps))
|
|
246
|
+
? roundMetric(agentPoolStats.throughputTps)
|
|
247
|
+
: (resolvedUptimeMs > 0 ? roundMetric(tokensTotal / (resolvedUptimeMs / 1000)) : 0);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
activeAgents: nonNegativeNumber(agentPoolStats.activeAgents, 0),
|
|
251
|
+
maxAgents: nonNegativeNumber(agentPoolStats.maxAgents, 0),
|
|
252
|
+
tokensIn,
|
|
253
|
+
tokensOut,
|
|
254
|
+
tokensTotal,
|
|
255
|
+
throughputTps,
|
|
256
|
+
uptimeMs: resolvedUptimeMs,
|
|
257
|
+
rateLimits: normalizeRateLimits(agentPoolStats.rateLimits),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function buildSessionsUpdatePayload(sessions = []) {
|
|
262
|
+
return Array.isArray(sessions) ? sessions.map((session) => ({ ...session })) : [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildSessionEventPayload(payload = {}) {
|
|
266
|
+
const event = payload?.event && typeof payload.event === "object"
|
|
267
|
+
? { ...payload.event }
|
|
268
|
+
: { kind: "message", ...(payload?.message ? { message: payload.message } : {}) };
|
|
269
|
+
return {
|
|
270
|
+
sessionId: String(payload?.sessionId || payload?.session?.id || "").trim(),
|
|
271
|
+
taskId: String(payload?.taskId || payload?.session?.taskId || "").trim(),
|
|
272
|
+
session: payload?.session && typeof payload.session === "object" ? { ...payload.session } : {},
|
|
273
|
+
event,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function buildWorkflowStatusPayload(payload = {}) {
|
|
278
|
+
return {
|
|
279
|
+
runId: String(payload?.runId || "").trim(),
|
|
280
|
+
workflowId: String(payload?.workflowId || "").trim(),
|
|
281
|
+
workflowName: String(payload?.workflowName || payload?.name || "").trim() || null,
|
|
282
|
+
eventType: String(payload?.eventType || payload?.event || "").trim(),
|
|
283
|
+
status: String(payload?.status || "unknown").trim() || "unknown",
|
|
284
|
+
nodeId: String(payload?.nodeId || "").trim() || null,
|
|
285
|
+
nodeType: String(payload?.nodeType || "").trim() || null,
|
|
286
|
+
nodeLabel: String(payload?.nodeLabel || "").trim() || null,
|
|
287
|
+
error: String(payload?.error || "").trim() || null,
|
|
288
|
+
durationMs: Number.isFinite(Number(payload?.durationMs)) ? Number(payload.durationMs) : null,
|
|
289
|
+
timestamp: nonNegativeNumber(payload?.timestamp, Date.now()),
|
|
290
|
+
meta: payload?.meta && typeof payload.meta === "object" ? { ...payload.meta } : null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function buildLogStreamPayload({ logType, query = null, filePath = null, line = "" } = {}) {
|
|
295
|
+
const raw = String(line || "");
|
|
296
|
+
const levelMatch = raw.match(/\b(trace|debug|info|warn|warning|error|fatal)\b/i);
|
|
297
|
+
const timestampMatch = raw.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/);
|
|
298
|
+
return {
|
|
299
|
+
logType: String(logType || "system").trim() || "system",
|
|
300
|
+
query: query == null ? null : String(query),
|
|
301
|
+
filePath: filePath == null ? null : String(filePath),
|
|
302
|
+
line: raw,
|
|
303
|
+
raw,
|
|
304
|
+
level: levelMatch ? levelMatch[1].toLowerCase().replace("warning", "warn") : "info",
|
|
305
|
+
timestamp: timestampMatch ? timestampMatch[0] : null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function buildTasksUpdatePayload(payload = {}, { sourceEvent = "invalidate" } = {}) {
|
|
310
|
+
const normalized = payload && typeof payload === "object" ? payload : {};
|
|
311
|
+
const patch = normalized.patch && typeof normalized.patch === "object"
|
|
312
|
+
? { ...normalized.patch }
|
|
313
|
+
: { ...normalized };
|
|
314
|
+
const reason = String(normalized.reason || sourceEvent || "tasks-changed").trim() || "tasks-changed";
|
|
315
|
+
const taskIds = Array.isArray(normalized.taskIds)
|
|
316
|
+
? normalized.taskIds.map((taskId) => String(taskId || "").trim()).filter(Boolean)
|
|
317
|
+
: null;
|
|
318
|
+
return {
|
|
319
|
+
reason,
|
|
320
|
+
sourceEvent: String(sourceEvent || "invalidate").trim() || "invalidate",
|
|
321
|
+
taskId: String(normalized.taskId || normalized.id || "").trim() || null,
|
|
322
|
+
taskIds: taskIds && taskIds.length ? taskIds : null,
|
|
323
|
+
status: String(normalized.status || "").trim() || null,
|
|
324
|
+
workspaceId: String(normalized.workspaceId || "").trim() || null,
|
|
325
|
+
projectId: String(normalized.projectId || "").trim() || null,
|
|
326
|
+
patch,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function resolveTuiAuthToken({ env = process.env, cacheDir, configDir = process.cwd() } = {}) {
|
|
331
|
+
const envCandidates = [
|
|
332
|
+
env?.BOSUN_TUI_AUTH_TOKEN,
|
|
333
|
+
env?.BOSUN_TUI_WS_TOKEN,
|
|
334
|
+
env?.BOSUN_UI_TOKEN,
|
|
335
|
+
env?.BOSUN_WS_TOKEN,
|
|
336
|
+
];
|
|
337
|
+
for (const candidate of envCandidates) {
|
|
338
|
+
const token = String(candidate || "").trim();
|
|
339
|
+
if (token) return token;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const resolvedCacheDir = resolveCacheDir({ cacheDir, configDir });
|
|
343
|
+
const candidateFiles = [
|
|
344
|
+
resolve(resolvedCacheDir, "ui-token"),
|
|
345
|
+
resolve(resolvedCacheDir, "ui-session-token.json"),
|
|
346
|
+
];
|
|
347
|
+
for (const filePath of candidateFiles) {
|
|
348
|
+
try {
|
|
349
|
+
if (!existsSync(filePath)) continue;
|
|
350
|
+
const raw = readFileSync(filePath, "utf8").trim();
|
|
351
|
+
if (!raw) continue;
|
|
352
|
+
if (raw.startsWith("{")) {
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
const token = String(parsed?.token || "").trim();
|
|
355
|
+
if (token) return token;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
return raw;
|
|
359
|
+
} catch {
|
|
360
|
+
// ignore invalid cache files
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function persistCompatibleTuiAuthToken(token, { cacheDir, configDir = process.cwd() } = {}) {
|
|
367
|
+
const normalized = String(token || "").trim();
|
|
368
|
+
if (!normalized) return "";
|
|
369
|
+
const resolvedCacheDir = resolveCacheDir({ cacheDir, configDir });
|
|
370
|
+
mkdirSync(resolvedCacheDir, { recursive: true });
|
|
371
|
+
writeFileSync(resolve(resolvedCacheDir, "ui-token"), `${normalized}\n`, "utf8");
|
|
372
|
+
return normalized;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function createTuiStatsEmitter({ intervalMs = 2000, getPayload, emit } = {}) {
|
|
376
|
+
let timer = null;
|
|
377
|
+
let inFlight = null;
|
|
378
|
+
|
|
379
|
+
const tick = async () => {
|
|
380
|
+
if (inFlight) return inFlight;
|
|
381
|
+
inFlight = Promise.resolve()
|
|
382
|
+
.then(() => (typeof getPayload === "function" ? getPayload() : null))
|
|
383
|
+
.then(async (payload) => {
|
|
384
|
+
if (payload && typeof emit === "function") {
|
|
385
|
+
await emit(payload);
|
|
386
|
+
}
|
|
387
|
+
return payload;
|
|
388
|
+
})
|
|
389
|
+
.finally(() => {
|
|
390
|
+
inFlight = null;
|
|
391
|
+
});
|
|
392
|
+
return inFlight;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
async tick() {
|
|
397
|
+
return tick();
|
|
398
|
+
},
|
|
399
|
+
start() {
|
|
400
|
+
if (timer) return;
|
|
401
|
+
timer = setInterval(() => {
|
|
402
|
+
void tick();
|
|
403
|
+
}, Math.max(250, Number(intervalMs) || 2000));
|
|
404
|
+
timer.unref?.();
|
|
405
|
+
},
|
|
406
|
+
stop() {
|
|
407
|
+
if (!timer) return;
|
|
408
|
+
clearInterval(timer);
|
|
409
|
+
timer = null;
|
|
410
|
+
},
|
|
411
|
+
get isRunning() {
|
|
412
|
+
return Boolean(timer);
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RECOVERY_STATE = Object.freeze({
|
|
5
|
+
health: "healthy",
|
|
6
|
+
failureStreak: 0,
|
|
7
|
+
failureCount: 0,
|
|
8
|
+
successCount: 0,
|
|
9
|
+
lastUpdatedAt: null,
|
|
10
|
+
lastFailureAt: null,
|
|
11
|
+
lastRecoveredAt: null,
|
|
12
|
+
lastHealthyAt: null,
|
|
13
|
+
recentEvents: Object.freeze([]),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const MAX_RECOVERY_EVENTS = 12;
|
|
17
|
+
const VALID_HEALTH = new Set(["healthy", "recovered", "failing", "degraded"]);
|
|
18
|
+
const VALID_OUTCOMES = new Set(["healthy_noop", "recreated", "recreation_failed"]);
|
|
19
|
+
|
|
20
|
+
function getStatusPath(repoRoot) {
|
|
21
|
+
const override = String(process.env.STATUS_FILE || "").trim();
|
|
22
|
+
if (override) {
|
|
23
|
+
// If override is an absolute path, resolve it directly;
|
|
24
|
+
// if it's relative, resolve it against the repo root.
|
|
25
|
+
const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(override) || override.startsWith("\\\\");
|
|
26
|
+
const isPosixAbsolute = override.startsWith("/");
|
|
27
|
+
if (isWindowsAbsolute || isPosixAbsolute) {
|
|
28
|
+
return resolve(override);
|
|
29
|
+
}
|
|
30
|
+
return resolve(repoRoot, override);
|
|
31
|
+
}
|
|
32
|
+
return resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toIsoTimestamp(value) {
|
|
36
|
+
const text = String(value || "").trim();
|
|
37
|
+
if (!text) return new Date().toISOString();
|
|
38
|
+
const parsed = new Date(text);
|
|
39
|
+
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeDetectedIssues(input) {
|
|
43
|
+
const values = Array.isArray(input) ? input : [input];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const issues = [];
|
|
46
|
+
for (const value of values) {
|
|
47
|
+
const normalized = String(value || "").trim();
|
|
48
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
49
|
+
seen.add(normalized);
|
|
50
|
+
issues.push(normalized);
|
|
51
|
+
}
|
|
52
|
+
return issues;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRecoveryEvent(event = {}) {
|
|
56
|
+
const outcome = String(event?.outcome || "").trim().toLowerCase();
|
|
57
|
+
return {
|
|
58
|
+
outcome: VALID_OUTCOMES.has(outcome) ? outcome : "healthy_noop",
|
|
59
|
+
reason: String(event?.reason || "").trim() || null,
|
|
60
|
+
branch: String(event?.branch || "").trim() || null,
|
|
61
|
+
taskId: String(event?.taskId || "").trim() || null,
|
|
62
|
+
worktreePath: String(event?.worktreePath || "").trim() || null,
|
|
63
|
+
phase: String(event?.phase || "").trim() || null,
|
|
64
|
+
error: String(event?.error || "").trim() || null,
|
|
65
|
+
detectedIssues: normalizeDetectedIssues(event?.detectedIssues),
|
|
66
|
+
timestamp: toIsoTimestamp(event?.timestamp),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeWorktreeRecoveryState(input = null) {
|
|
71
|
+
const source = input && typeof input === "object" ? input : {};
|
|
72
|
+
const health = String(source.health || "").trim().toLowerCase();
|
|
73
|
+
return {
|
|
74
|
+
health: VALID_HEALTH.has(health) ? health : "healthy",
|
|
75
|
+
failureStreak: Math.max(0, Number.parseInt(String(source.failureStreak || 0), 10) || 0),
|
|
76
|
+
failureCount: Math.max(0, Number.parseInt(String(source.failureCount || 0), 10) || 0),
|
|
77
|
+
successCount: Math.max(0, Number.parseInt(String(source.successCount || 0), 10) || 0),
|
|
78
|
+
lastUpdatedAt: source.lastUpdatedAt ? toIsoTimestamp(source.lastUpdatedAt) : null,
|
|
79
|
+
lastFailureAt: source.lastFailureAt ? toIsoTimestamp(source.lastFailureAt) : null,
|
|
80
|
+
lastRecoveredAt: source.lastRecoveredAt ? toIsoTimestamp(source.lastRecoveredAt) : null,
|
|
81
|
+
lastHealthyAt: source.lastHealthyAt ? toIsoTimestamp(source.lastHealthyAt) : null,
|
|
82
|
+
recentEvents: Array.isArray(source.recentEvents)
|
|
83
|
+
? source.recentEvents.map((event) => normalizeRecoveryEvent(event)).slice(0, MAX_RECOVERY_EVENTS)
|
|
84
|
+
: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildNextWorktreeRecoveryState(currentState, event) {
|
|
89
|
+
const state = normalizeWorktreeRecoveryState(currentState);
|
|
90
|
+
const normalizedEvent = normalizeRecoveryEvent(event);
|
|
91
|
+
const nextState = {
|
|
92
|
+
...state,
|
|
93
|
+
lastUpdatedAt: normalizedEvent.timestamp,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (normalizedEvent.outcome === "healthy_noop") {
|
|
97
|
+
return {
|
|
98
|
+
...nextState,
|
|
99
|
+
health: state.failureStreak > 0 ? state.health : "healthy",
|
|
100
|
+
lastHealthyAt: normalizedEvent.timestamp,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const recentEvents = [normalizedEvent, ...state.recentEvents].slice(0, MAX_RECOVERY_EVENTS);
|
|
105
|
+
if (normalizedEvent.outcome === "recreated") {
|
|
106
|
+
return {
|
|
107
|
+
...nextState,
|
|
108
|
+
health: "recovered",
|
|
109
|
+
failureStreak: 0,
|
|
110
|
+
successCount: state.successCount + 1,
|
|
111
|
+
lastRecoveredAt: normalizedEvent.timestamp,
|
|
112
|
+
lastHealthyAt: normalizedEvent.timestamp,
|
|
113
|
+
recentEvents,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const failureStreak = state.failureStreak + 1;
|
|
118
|
+
return {
|
|
119
|
+
...nextState,
|
|
120
|
+
health: failureStreak > 1 ? "degraded" : "failing",
|
|
121
|
+
failureStreak,
|
|
122
|
+
failureCount: state.failureCount + 1,
|
|
123
|
+
lastFailureAt: normalizedEvent.timestamp,
|
|
124
|
+
recentEvents,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readStatusDocument(repoRoot) {
|
|
129
|
+
const statusPath = getStatusPath(repoRoot);
|
|
130
|
+
try {
|
|
131
|
+
const raw = await readFile(statusPath, "utf8");
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readWorktreeRecoveryState(repoRoot) {
|
|
140
|
+
const document = await readStatusDocument(repoRoot);
|
|
141
|
+
return normalizeWorktreeRecoveryState(document.worktreeRecovery);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function recordWorktreeRecoveryEvent(repoRoot, event) {
|
|
145
|
+
const statusPath = getStatusPath(repoRoot);
|
|
146
|
+
const document = await readStatusDocument(repoRoot);
|
|
147
|
+
document.worktreeRecovery = buildNextWorktreeRecoveryState(document.worktreeRecovery, event);
|
|
148
|
+
await mkdir(dirname(statusPath), { recursive: true });
|
|
149
|
+
await writeFile(statusPath, JSON.stringify(document, null, 2), "utf8");
|
|
150
|
+
return document.worktreeRecovery;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export {
|
|
154
|
+
buildNextWorktreeRecoveryState,
|
|
155
|
+
normalizeRecoveryEvent,
|
|
156
|
+
normalizeWorktreeRecoveryState,
|
|
157
|
+
readWorktreeRecoveryState,
|
|
158
|
+
recordWorktreeRecoveryEvent,
|
|
159
|
+
};
|
|
@@ -137,6 +137,23 @@ function normaliseStatus(raw) {
|
|
|
137
137
|
return STATUS_MAP[key] || "todo";
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
function resolveCreateTaskInput(projectIdOrTaskData, taskDataArg = {}) {
|
|
141
|
+
const payloadOnlyCall =
|
|
142
|
+
projectIdOrTaskData &&
|
|
143
|
+
typeof projectIdOrTaskData === "object" &&
|
|
144
|
+
!Array.isArray(projectIdOrTaskData);
|
|
145
|
+
const rawTaskData = payloadOnlyCall ? projectIdOrTaskData : (taskDataArg || {});
|
|
146
|
+
const projectId = payloadOnlyCall
|
|
147
|
+
? rawTaskData?.projectId ?? rawTaskData?.project_id
|
|
148
|
+
: projectIdOrTaskData;
|
|
149
|
+
const {
|
|
150
|
+
projectId: _ignoredProjectId,
|
|
151
|
+
project_id: _ignoredProjectIdSnake,
|
|
152
|
+
...taskData
|
|
153
|
+
} = rawTaskData || {};
|
|
154
|
+
return { projectId, taskData };
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
const STATUS_LABEL_KEYS = new Set([
|
|
141
158
|
"draft",
|
|
142
159
|
"todo",
|
|
@@ -1008,7 +1025,11 @@ class InternalAdapter {
|
|
|
1008
1025
|
return this._normalizeTask(updated);
|
|
1009
1026
|
}
|
|
1010
1027
|
|
|
1011
|
-
async createTask(
|
|
1028
|
+
async createTask(projectIdOrTaskData, taskDataArg = {}) {
|
|
1029
|
+
const { projectId, taskData } = resolveCreateTaskInput(
|
|
1030
|
+
projectIdOrTaskData,
|
|
1031
|
+
taskDataArg,
|
|
1032
|
+
);
|
|
1012
1033
|
const id = String(taskData.id || randomUUID());
|
|
1013
1034
|
const tags = normalizeTags(taskData.tags || taskData.labels || []);
|
|
1014
1035
|
const draft = Boolean(taskData.draft || taskData.status === "draft");
|
|
@@ -1551,7 +1572,11 @@ class VKAdapter {
|
|
|
1551
1572
|
return this._normaliseTask(task);
|
|
1552
1573
|
}
|
|
1553
1574
|
|
|
1554
|
-
async createTask(
|
|
1575
|
+
async createTask(projectIdOrTaskData, taskDataArg = {}) {
|
|
1576
|
+
const { projectId, taskData } = resolveCreateTaskInput(
|
|
1577
|
+
projectIdOrTaskData,
|
|
1578
|
+
taskDataArg,
|
|
1579
|
+
);
|
|
1555
1580
|
const fetchVk = await this._getFetchVk();
|
|
1556
1581
|
const tags = normalizeTags(taskData?.tags || taskData?.labels || []);
|
|
1557
1582
|
const draft = Boolean(taskData?.draft || taskData?.status === "draft");
|
|
@@ -3617,7 +3642,8 @@ class GitHubIssuesAdapter {
|
|
|
3617
3642
|
return result?.data?.convertProjectV2DraftIssueItemToIssue?.issue || null;
|
|
3618
3643
|
}
|
|
3619
3644
|
|
|
3620
|
-
async createTask(
|
|
3645
|
+
async createTask(projectIdOrTaskData, taskDataArg = {}) {
|
|
3646
|
+
const { taskData } = resolveCreateTaskInput(projectIdOrTaskData, taskDataArg);
|
|
3621
3647
|
const normalizedTitle = String(taskData?.title || "").trim();
|
|
3622
3648
|
if (!normalizedTitle) {
|
|
3623
3649
|
throw new Error("[kanban] github createTask requires non-empty title");
|
|
@@ -5417,7 +5443,11 @@ class JiraAdapter {
|
|
|
5417
5443
|
return this.getTask(issueKey);
|
|
5418
5444
|
}
|
|
5419
5445
|
|
|
5420
|
-
async createTask(
|
|
5446
|
+
async createTask(projectIdOrTaskData, taskDataArg = {}) {
|
|
5447
|
+
const { projectId, taskData } = resolveCreateTaskInput(
|
|
5448
|
+
projectIdOrTaskData,
|
|
5449
|
+
taskDataArg,
|
|
5450
|
+
);
|
|
5421
5451
|
const projectKey = this._normalizeProjectKey(projectId);
|
|
5422
5452
|
if (!projectKey) {
|
|
5423
5453
|
throw new Error(
|
|
@@ -6082,12 +6112,16 @@ export async function updateTask(taskId, patch) {
|
|
|
6082
6112
|
return adapter.getTask(taskId);
|
|
6083
6113
|
}
|
|
6084
6114
|
|
|
6085
|
-
export async function createTask(
|
|
6115
|
+
export async function createTask(projectIdOrTaskData, taskDataArg = {}) {
|
|
6116
|
+
const { projectId, taskData } = resolveCreateTaskInput(
|
|
6117
|
+
projectIdOrTaskData,
|
|
6118
|
+
taskDataArg,
|
|
6119
|
+
);
|
|
6086
6120
|
const result = await getKanbanAdapter().createTask(projectId, taskData);
|
|
6087
6121
|
emitKanbanEvent("task.created", {
|
|
6088
|
-
projectId,
|
|
6122
|
+
projectId: projectId ?? result?.projectId ?? result?.project_id ?? null,
|
|
6089
6123
|
taskId: result?.id || null,
|
|
6090
|
-
title: taskData?.title
|
|
6124
|
+
title: taskData?.title ?? result?.title ?? null,
|
|
6091
6125
|
});
|
|
6092
6126
|
return result;
|
|
6093
6127
|
}
|
|
@@ -6164,4 +6198,3 @@ export async function unmarkTaskIgnored(taskId) {
|
|
|
6164
6198
|
);
|
|
6165
6199
|
return false;
|
|
6166
6200
|
}
|
|
6167
|
-
|