@virtengine/openfleet 0.25.0

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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,679 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Reactive State Layer
3
+ * All signals, data loaders, toast system, and tab refresh logic
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ import { signal } from "@preact/signals";
7
+ import { apiFetch, onWsMessage } from "./api.js";
8
+ import { cloneValue } from "./utils.js";
9
+ import { generateId } from "./utils.js";
10
+
11
+ /* ═══════════════════════════════════════════════════════════════
12
+ * CLOUD STORAGE HELPER — mirrors settings.js pattern
13
+ * ═══════════════════════════════════════════════════════════════ */
14
+
15
+ /** @param {string} key @returns {Promise<any>} */
16
+ function _cloudGet(key) {
17
+ return new Promise((resolve) => {
18
+ const tg = globalThis.Telegram?.WebApp;
19
+ if (tg?.CloudStorage) {
20
+ tg.CloudStorage.getItem(key, (err, val) => {
21
+ if (err || val == null) resolve(null);
22
+ else {
23
+ try { resolve(JSON.parse(val)); }
24
+ catch { resolve(val); }
25
+ }
26
+ });
27
+ } else {
28
+ try {
29
+ const v = localStorage.getItem("ve_settings_" + key);
30
+ resolve(v != null ? JSON.parse(v) : null);
31
+ } catch { resolve(null); }
32
+ }
33
+ });
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════════════════════
37
+ * API RESPONSE CACHE — stale-while-revalidate
38
+ * ═══════════════════════════════════════════════════════════════ */
39
+
40
+ const _apiCache = new Map();
41
+ const CACHE_TTL = {
42
+ status: 5000, executor: 5000, tasks: 10000, agents: 5000,
43
+ threads: 5000, logs: 15000, worktrees: 30000, workspaces: 30000,
44
+ presence: 30000, config: 60000, projects: 60000, git: 20000,
45
+ infra: 30000,
46
+ };
47
+
48
+ function _cacheKey(url) { return url; }
49
+ function _cacheGet(url) { return _apiCache.get(url) || null; }
50
+ function _cacheSet(url, data) { _apiCache.set(url, { data, fetchedAt: Date.now() }); }
51
+ function _cacheFresh(url, group) {
52
+ const e = _apiCache.get(url);
53
+ return e ? (Date.now() - e.fetchedAt) < (CACHE_TTL[group] || 10000) : false;
54
+ }
55
+ function _cacheClearGroup(group) {
56
+ for (const k of _apiCache.keys()) {
57
+ if (k.includes(group) || group === '*') _apiCache.delete(k);
58
+ }
59
+ }
60
+
61
+ /** Tracks last-fetch timestamps per data group for "Updated Xs ago" UI. */
62
+ export const dataFreshness = signal({});
63
+ function _markFresh(group) {
64
+ dataFreshness.value = { ...dataFreshness.value, [group]: Date.now() };
65
+ }
66
+
67
+ /* ═══════════════════════════════════════════════════════════════
68
+ * SIGNALS — Single source of truth for UI state
69
+ * ═══════════════════════════════════════════════════════════════ */
70
+
71
+ // ── Overall connectivity
72
+ export const connected = signal(false);
73
+
74
+ // ── Dashboard
75
+ export const statusData = signal(null);
76
+ export const executorData = signal(null);
77
+ export const projectSummary = signal(null);
78
+
79
+ // ── Tasks
80
+ export const tasksLoaded = signal(false);
81
+ export const tasksData = signal([]);
82
+ export const tasksPage = signal(0);
83
+ export const tasksPageSize = signal(20);
84
+ export const tasksFilter = signal("all");
85
+ export const tasksPriority = signal("all");
86
+ export const tasksSearch = signal("");
87
+ export const tasksSort = signal("updated");
88
+ export const tasksTotalPages = signal(1);
89
+
90
+ // ── Agents
91
+ export const agentsData = signal([]);
92
+ export const agentWorkspaceTarget = signal(null);
93
+
94
+ // ── Infra
95
+ export const worktreeData = signal([]);
96
+ export const sharedWorkspaces = signal([]);
97
+ export const presenceInstances = signal([]);
98
+ export const coordinatorInfo = signal(null);
99
+ export const infraData = signal(null);
100
+
101
+ // ── Logs
102
+ export const logsData = signal(null);
103
+ export const logsLines = signal(100);
104
+ export const gitDiff = signal(null);
105
+ export const gitBranches = signal([]);
106
+ export const agentLogFiles = signal([]);
107
+ export const agentLogFile = signal("");
108
+ export const agentLogTail = signal(null);
109
+ export const agentLogLines = signal(200);
110
+ export const agentLogQuery = signal("");
111
+ export const agentContext = signal(null);
112
+
113
+ // ── Config (routing, regions, etc.)
114
+ export const configData = signal(null);
115
+
116
+ // ── Toasts
117
+ export const toasts = signal([]);
118
+
119
+ // ── Notification Preferences (loaded from CloudStorage)
120
+ export const notificationPrefs = signal({
121
+ notifyUpdates: true,
122
+ notifyErrors: true,
123
+ notifyCompletion: true,
124
+ });
125
+
126
+ /* ═══════════════════════════════════════════════════════════════
127
+ * NOTIFICATION PREFERENCES
128
+ * ═══════════════════════════════════════════════════════════════ */
129
+
130
+ /** Load notification preferences from CloudStorage into signal */
131
+ export async function loadNotificationPrefs() {
132
+ const [nu, ne, nc] = await Promise.all([
133
+ _cloudGet("notifyUpdates"),
134
+ _cloudGet("notifyErrors"),
135
+ _cloudGet("notifyCompletion"),
136
+ ]);
137
+ notificationPrefs.value = {
138
+ notifyUpdates: nu != null ? nu : true,
139
+ notifyErrors: ne != null ? ne : true,
140
+ notifyCompletion: nc != null ? nc : true,
141
+ };
142
+ }
143
+
144
+ /** Critical message patterns that always show regardless of prefs */
145
+ const CRITICAL_PATTERNS = /\b(connection\s*lost|auth\s*(fail|error)|authentication\s*(fail|error)|unauthorized)\b/i;
146
+
147
+ /**
148
+ * Determine if a toast should be rendered based on notification prefs.
149
+ * Filtering happens at render time so toasts are still logged even if hidden.
150
+ * @param {{ id: string, message: string, type: string }} toast
151
+ * @returns {boolean}
152
+ */
153
+ export function shouldShowToast(toast) {
154
+ if (CRITICAL_PATTERNS.test(toast.message)) return true;
155
+
156
+ const prefs = notificationPrefs.value;
157
+
158
+ if (!prefs.notifyUpdates && (toast.type === "info" || toast.type === "success")) {
159
+ return false;
160
+ }
161
+ if (!prefs.notifyErrors && toast.type === "error") {
162
+ return false;
163
+ }
164
+ if (!prefs.notifyCompletion && /\b(complete[ds]?|done)\b/i.test(toast.message)) {
165
+ return false;
166
+ }
167
+
168
+ return true;
169
+ }
170
+
171
+ /* ═══════════════════════════════════════════════════════════════
172
+ * EXECUTOR DEFAULTS — apply stored settings on first load
173
+ * ═══════════════════════════════════════════════════════════════ */
174
+
175
+ let _defaultsApplied = false;
176
+
177
+ /**
178
+ * Read stored executor defaults from CloudStorage and POST them to
179
+ * the server if they differ from the current config.
180
+ * Only runs once per app lifecycle (not on tab switches).
181
+ */
182
+ export async function applyStoredDefaults() {
183
+ if (_defaultsApplied) return;
184
+ _defaultsApplied = true;
185
+
186
+ const [maxP, sdk, region] = await Promise.all([
187
+ _cloudGet("defaultMaxParallel"),
188
+ _cloudGet("defaultSdk"),
189
+ _cloudGet("defaultRegion"),
190
+ ]);
191
+
192
+ const promises = [];
193
+
194
+ if (maxP != null) {
195
+ const current = executorData.value;
196
+ const currentMax =
197
+ current?.data?.maxParallel ??
198
+ current?.maxParallel ??
199
+ null;
200
+ const isPaused = Boolean(current?.paused || current?.data?.paused);
201
+ if (!isPaused && currentMax !== maxP) {
202
+ promises.push(
203
+ apiFetch("/api/executor/maxparallel", {
204
+ method: "POST",
205
+ body: JSON.stringify({ maxParallel: maxP }),
206
+ _silent: true,
207
+ }).catch(() => {}),
208
+ );
209
+ }
210
+ }
211
+
212
+ const configUpdates = {};
213
+ if (sdk && sdk !== "auto") configUpdates.sdk = sdk;
214
+ if (region && region !== "auto") configUpdates.region = region;
215
+
216
+ if (Object.keys(configUpdates).length) {
217
+ promises.push(
218
+ apiFetch("/api/config/update", {
219
+ method: "POST",
220
+ body: JSON.stringify(configUpdates),
221
+ _silent: true,
222
+ }).catch(() => {}),
223
+ );
224
+ }
225
+
226
+ if (promises.length) await Promise.all(promises);
227
+ }
228
+
229
+ /* ═══════════════════════════════════════════════════════════════
230
+ * TOAST SYSTEM
231
+ * ═══════════════════════════════════════════════════════════════ */
232
+
233
+ /**
234
+ * Show a toast notification that auto-dismisses after 3 s.
235
+ * @param {string} message
236
+ * @param {'info'|'success'|'error'|'warning'} type
237
+ */
238
+ export function showToast(message, type = "info") {
239
+ const id = generateId();
240
+ toasts.value = [...toasts.value, { id, message, type }];
241
+ setTimeout(() => {
242
+ toasts.value = toasts.value.filter((t) => t.id !== id);
243
+ }, 3000);
244
+ }
245
+
246
+ // Listen for api-error events dispatched by the api module
247
+ if (typeof globalThis !== "undefined") {
248
+ try {
249
+ globalThis.addEventListener("ve:api-error", (e) => {
250
+ showToast(e.detail?.message || "Request failed", "error");
251
+ });
252
+ } catch {
253
+ /* SSR guard */
254
+ }
255
+ }
256
+
257
+ /* ═══════════════════════════════════════════════════════════════
258
+ * DATA LOADERS — each calls apiFetch and updates its signal(s)
259
+ * ═══════════════════════════════════════════════════════════════ */
260
+
261
+ /* ═══════════════════════════════════════════════════════════════
262
+ * DASHBOARD HISTORY — localStorage-backed trend tracking
263
+ * ═══════════════════════════════════════════════════════════════ */
264
+
265
+ const HISTORY_KEY = "ve-dashboard-history";
266
+ const HISTORY_MAX = 50;
267
+ const HISTORY_MIN_INTERVAL_MS = 30_000;
268
+
269
+ /** Read stored history from localStorage. */
270
+ export function getDashboardHistory() {
271
+ try {
272
+ const raw = localStorage.getItem(HISTORY_KEY);
273
+ return raw ? JSON.parse(raw) : [];
274
+ } catch {
275
+ return [];
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Compute trend delta between latest and previous snapshot for a metric.
281
+ * Returns 0 when insufficient history.
282
+ * @param {string} metric - one of: total, running, done, errors, successRate
283
+ * @returns {number}
284
+ */
285
+ export function getTrend(metric) {
286
+ const hist = getDashboardHistory();
287
+ if (hist.length < 2) return 0;
288
+ const latest = hist[hist.length - 1];
289
+ const prev = hist[hist.length - 2];
290
+ return (latest[metric] ?? 0) - (prev[metric] ?? 0);
291
+ }
292
+
293
+ /** Save a dashboard snapshot after a successful status load. */
294
+ function _saveDashboardSnapshot() {
295
+ try {
296
+ const s = statusData.value;
297
+ if (!s) return;
298
+ const counts = s.counts || {};
299
+ const running = Number(counts.running || counts.inprogress || 0);
300
+ const review = Number(counts.review || counts.inreview || 0);
301
+ const blocked = Number(counts.error || 0);
302
+ const done = Number(counts.done || 0);
303
+ const backlog = Number(s.backlog_remaining || counts.todo || 0);
304
+ const total = running + review + blocked + backlog + done;
305
+ const successRate = total > 0 ? +((done / total) * 100).toFixed(1) : 0;
306
+
307
+ const snap = { ts: Date.now(), total, running, done, errors: blocked, successRate };
308
+
309
+ const hist = getDashboardHistory();
310
+
311
+ // Deduplicate: skip if too recent and values unchanged
312
+ if (hist.length > 0) {
313
+ const last = hist[hist.length - 1];
314
+ const elapsed = snap.ts - (last.ts || 0);
315
+ const same =
316
+ last.total === snap.total &&
317
+ last.running === snap.running &&
318
+ last.done === snap.done &&
319
+ last.errors === snap.errors;
320
+ if (elapsed < HISTORY_MIN_INTERVAL_MS && same) return;
321
+ }
322
+
323
+ hist.push(snap);
324
+ // Trim to max length
325
+ while (hist.length > HISTORY_MAX) hist.shift();
326
+
327
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(hist));
328
+ } catch {
329
+ /* localStorage may be unavailable */
330
+ }
331
+ }
332
+
333
+ /** Load system status → statusData */
334
+ export async function loadStatus() {
335
+ const url = "/api/status";
336
+ const cached = _cacheGet(url);
337
+ if (_cacheFresh(url, "status")) return;
338
+ if (cached) { statusData.value = cached.data; connected.value = true; }
339
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
340
+ data: null,
341
+ }));
342
+ statusData.value = res.data ?? res ?? null;
343
+ connected.value = true;
344
+ _cacheSet(url, statusData.value);
345
+ _markFresh("status");
346
+ _saveDashboardSnapshot();
347
+ }
348
+
349
+ /** Load executor state → executorData */
350
+ export async function loadExecutor() {
351
+ const url = "/api/executor";
352
+ const cached = _cacheGet(url);
353
+ if (_cacheFresh(url, "executor")) return;
354
+ if (cached) executorData.value = cached.data;
355
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
356
+ data: null,
357
+ }));
358
+ executorData.value = res ?? null;
359
+ _cacheSet(url, executorData.value);
360
+ _markFresh("executor");
361
+ }
362
+
363
+ /** Load tasks with current filter/page/sort → tasksData + tasksTotalPages */
364
+ export async function loadTasks() {
365
+ const params = new URLSearchParams({
366
+ page: String(tasksPage.value),
367
+ pageSize: String(tasksPageSize.value),
368
+ });
369
+ if (tasksFilter.value && tasksFilter.value !== "all")
370
+ params.set("status", tasksFilter.value);
371
+ if (tasksPriority.value && tasksPriority.value !== "all")
372
+ params.set("priority", tasksPriority.value);
373
+ if (tasksSearch.value) params.set("search", tasksSearch.value);
374
+ if (tasksSort.value) params.set("sort", tasksSort.value);
375
+
376
+ const res = await apiFetch(`/api/tasks?${params}`, { _silent: true }).catch(
377
+ () => ({
378
+ data: [],
379
+ total: 0,
380
+ totalPages: 1,
381
+ }),
382
+ );
383
+ tasksData.value = res.data || [];
384
+ tasksTotalPages.value =
385
+ res.totalPages ||
386
+ Math.max(1, Math.ceil((res.total || 0) / tasksPageSize.value));
387
+ tasksLoaded.value = true;
388
+ _cacheSet(`/api/tasks?${params}`, { data: tasksData.value, totalPages: tasksTotalPages.value });
389
+ _markFresh("tasks");
390
+ }
391
+
392
+ /** Load active agents → agentsData */
393
+ export async function loadAgents() {
394
+ const url = "/api/agents";
395
+ const cached = _cacheGet(url);
396
+ if (_cacheFresh(url, "agents")) return;
397
+ if (cached) agentsData.value = cached.data;
398
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
399
+ data: [],
400
+ }));
401
+ agentsData.value = res.data || [];
402
+ _cacheSet(url, agentsData.value);
403
+ _markFresh("agents");
404
+ }
405
+
406
+ /** Load worktrees → worktreeData */
407
+ export async function loadWorktrees() {
408
+ const url = "/api/worktrees";
409
+ const cached = _cacheGet(url);
410
+ if (_cacheFresh(url, "worktrees")) return;
411
+ if (cached) worktreeData.value = cached.data;
412
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
413
+ data: [],
414
+ stats: null,
415
+ }));
416
+ worktreeData.value = res.data || [];
417
+ _cacheSet(url, worktreeData.value);
418
+ _markFresh("worktrees");
419
+ }
420
+
421
+ /** Load infrastructure overview → infraData */
422
+ export async function loadInfra() {
423
+ const url = "/api/infra";
424
+ const cached = _cacheGet(url);
425
+ if (_cacheFresh(url, "infra")) return;
426
+ if (cached) infraData.value = cached.data;
427
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
428
+ data: null,
429
+ }));
430
+ infraData.value = res.data ?? res ?? null;
431
+ _cacheSet(url, infraData.value);
432
+ _markFresh("infra");
433
+ }
434
+
435
+ /** Load system logs → logsData */
436
+ export async function loadLogs() {
437
+ const url = `/api/logs?lines=${logsLines.value}`;
438
+ if (_cacheFresh(url, "logs")) return;
439
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({ data: null }));
440
+ logsData.value = res.data ?? res ?? null;
441
+ _cacheSet(url, logsData.value);
442
+ _markFresh("logs");
443
+ }
444
+
445
+ /** Load git branches + diff → gitBranches, gitDiff */
446
+ export async function loadGit() {
447
+ const [branches, diff] = await Promise.all([
448
+ apiFetch("/api/git/branches", { _silent: true }).catch(() => ({
449
+ data: [],
450
+ })),
451
+ apiFetch("/api/git/diff", { _silent: true }).catch(() => ({ data: "" })),
452
+ ]);
453
+ gitBranches.value = branches.data || [];
454
+ gitDiff.value = diff.data || "";
455
+ }
456
+
457
+ /** Load agent log file list → agentLogFiles */
458
+ export async function loadAgentLogFileList() {
459
+ const params = new URLSearchParams();
460
+ if (agentLogQuery.value) params.set("query", agentLogQuery.value);
461
+ const path = params.toString()
462
+ ? `/api/agent-logs?${params}`
463
+ : "/api/agent-logs";
464
+ const res = await apiFetch(path, { _silent: true }).catch(() => ({
465
+ data: [],
466
+ }));
467
+ agentLogFiles.value = res.data || [];
468
+ }
469
+
470
+ /** Load tail of the currently selected agent log → agentLogTail */
471
+ export async function loadAgentLogTailData() {
472
+ if (!agentLogFile.value) {
473
+ agentLogTail.value = null;
474
+ return;
475
+ }
476
+ const params = new URLSearchParams({
477
+ file: agentLogFile.value,
478
+ lines: String(agentLogLines.value),
479
+ });
480
+ const res = await apiFetch(`/api/agent-logs/tail?${params}`, {
481
+ _silent: true,
482
+ }).catch(() => ({ data: null }));
483
+ agentLogTail.value = res.data ?? res ?? null;
484
+ }
485
+
486
+ /**
487
+ * Load worktree context for a branch/query → agentContext
488
+ * @param {string} query
489
+ */
490
+ export async function loadAgentContextData(query) {
491
+ if (!query) {
492
+ agentContext.value = null;
493
+ return;
494
+ }
495
+ const res = await apiFetch(
496
+ `/api/agent-context?query=${encodeURIComponent(query)}`,
497
+ { _silent: true },
498
+ ).catch(() => ({ data: null }));
499
+ agentContext.value = res.data ?? res ?? null;
500
+ }
501
+
502
+ /** Load shared workspaces → sharedWorkspaces */
503
+ export async function loadSharedWorkspaces() {
504
+ const url = "/api/shared-workspaces";
505
+ if (_cacheFresh(url, "workspaces")) return;
506
+ const res = await apiFetch(url, { _silent: true }).catch(
507
+ () => ({
508
+ data: [],
509
+ }),
510
+ );
511
+ sharedWorkspaces.value = res.data || res.workspaces || [];
512
+ _cacheSet(url, sharedWorkspaces.value);
513
+ _markFresh("workspaces");
514
+ }
515
+
516
+ /** Load presence / coordinator → presenceInstances, coordinatorInfo */
517
+ export async function loadPresence() {
518
+ const url = "/api/presence";
519
+ if (_cacheFresh(url, "presence")) return;
520
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
521
+ data: null,
522
+ }));
523
+ const data = res.data || res || {};
524
+ presenceInstances.value = data.instances || [];
525
+ coordinatorInfo.value = data.coordinator || null;
526
+ _cacheSet(url, data);
527
+ _markFresh("presence");
528
+ }
529
+
530
+ /** Load project summary → projectSummary */
531
+ export async function loadProjectSummary() {
532
+ const url = "/api/project-summary";
533
+ if (_cacheFresh(url, "projects")) return;
534
+ const res = await apiFetch(url, { _silent: true }).catch(
535
+ () => ({
536
+ data: null,
537
+ }),
538
+ );
539
+ projectSummary.value = res.data ?? res ?? null;
540
+ _cacheSet(url, projectSummary.value);
541
+ _markFresh("projects");
542
+ }
543
+
544
+ /** Load config (routing, regions, etc.) → configData */
545
+ export async function loadConfig() {
546
+ const url = "/api/config";
547
+ if (_cacheFresh(url, "config")) return;
548
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
549
+ ok: false,
550
+ }));
551
+ configData.value = res?.ok ? res : null;
552
+ _cacheSet(url, configData.value);
553
+ _markFresh("config");
554
+ }
555
+
556
+ /* ═══════════════════════════════════════════════════════════════
557
+ * TAB REFRESH — map tab names to their required loaders
558
+ * ═══════════════════════════════════════════════════════════════ */
559
+
560
+ const TAB_LOADERS = {
561
+ dashboard: () =>
562
+ Promise.all([loadStatus(), loadExecutor(), loadProjectSummary()]),
563
+ tasks: () => loadTasks(),
564
+ agents: () => Promise.all([loadAgents(), loadExecutor(), import("../components/session-list.js").then((m) => m.loadSessions()).catch(() => {})]),
565
+ infra: () =>
566
+ Promise.all([
567
+ loadWorktrees(),
568
+ loadInfra(),
569
+ loadSharedWorkspaces(),
570
+ loadPresence(),
571
+ ]),
572
+ control: () => Promise.all([loadExecutor(), loadConfig()]),
573
+ logs: () =>
574
+ Promise.all([loadLogs(), loadAgentLogFileList(), loadAgentLogTailData()]),
575
+ settings: () => Promise.all([loadStatus(), loadConfig()]),
576
+ };
577
+
578
+ /**
579
+ * Refresh all data for a given tab.
580
+ * @param {string} tabName
581
+ * @param {{ force?: boolean }} [opts]
582
+ */
583
+ export async function refreshTab(tabName, opts = {}) {
584
+ if (opts.force) _apiCache.clear();
585
+ const loader = TAB_LOADERS[tabName];
586
+ if (loader) {
587
+ try {
588
+ await loader();
589
+ } catch {
590
+ /* errors handled by individual loaders */
591
+ }
592
+ }
593
+ }
594
+
595
+ /* ═══════════════════════════════════════════════════════════════
596
+ * HELPERS
597
+ * ═══════════════════════════════════════════════════════════════ */
598
+
599
+ /**
600
+ * Optimistic update pattern:
601
+ * 1. Apply the optimistic change immediately
602
+ * 2. Run the async fetch
603
+ * 3. On error, revert via rollback
604
+ *
605
+ * @param {() => void} applyFn – mutate signals optimistically
606
+ * @param {() => Promise<any>} fetchFn – the actual API call
607
+ * @param {() => void} revertFn – undo the optimistic change on error
608
+ * @returns {Promise<any>}
609
+ */
610
+ export async function runOptimistic(applyFn, fetchFn, revertFn) {
611
+ try {
612
+ applyFn();
613
+ return await fetchFn();
614
+ } catch (err) {
615
+ if (typeof revertFn === "function") revertFn();
616
+ throw err;
617
+ }
618
+ }
619
+
620
+ /** @type {ReturnType<typeof setTimeout>|null} */
621
+ let _scheduleTimer = null;
622
+
623
+ /**
624
+ * Schedule a tab refresh after a short delay (debounced).
625
+ * Uses the the current activeTab from the router layer via import.
626
+ * Falls back to refreshing 'dashboard'.
627
+ *
628
+ * @param {number} ms
629
+ */
630
+ export function scheduleRefresh(ms = 5000) {
631
+ if (_scheduleTimer) clearTimeout(_scheduleTimer);
632
+ _scheduleTimer = setTimeout(async () => {
633
+ _scheduleTimer = null;
634
+ // Dynamic import to avoid circular dependency at module load time
635
+ try {
636
+ const { activeTab } = await import("./router.js");
637
+ await refreshTab(activeTab.value);
638
+ } catch {
639
+ await refreshTab("dashboard");
640
+ }
641
+ }, ms);
642
+ }
643
+
644
+ /* ─── WebSocket invalidation listener ─── */
645
+
646
+ const WS_CHANNEL_MAP = {
647
+ dashboard: ["overview", "executor", "tasks", "agents"],
648
+ tasks: ["tasks"],
649
+ agents: ["agents", "executor"],
650
+ infra: ["worktrees", "workspaces", "presence"],
651
+ control: ["executor", "overview"],
652
+ logs: ["*"],
653
+ settings: ["overview"],
654
+ };
655
+
656
+ /** Start listening for WS invalidation messages and auto-refreshing. */
657
+ export function initWsInvalidationListener() {
658
+ onWsMessage((msg) => {
659
+ if (msg?.type !== "invalidate") return;
660
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
661
+ // Clear cache for invalidated channels so next fetch is fresh
662
+ channels.forEach((ch) => _cacheClearGroup(ch));
663
+
664
+ // Determine interested channels based on active tab
665
+ import("./router.js")
666
+ .then(({ activeTab }) => {
667
+ const interested = WS_CHANNEL_MAP[activeTab.value] || ["*"];
668
+ if (
669
+ channels.includes("*") ||
670
+ channels.some((c) => interested.includes(c))
671
+ ) {
672
+ scheduleRefresh(150);
673
+ }
674
+ })
675
+ .catch(() => {
676
+ /* noop */
677
+ });
678
+ });
679
+ }