@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,1063 @@
1
+ /**
2
+ * sync-engine.mjs — Two-way sync between internal task store and external kanban backends
3
+ *
4
+ * The internal task store (.cache/kanban-state.json via task-store.mjs) is the
5
+ * **source of truth** for status and agent tracking. External kanbans (VK,
6
+ * GitHub Issues, Jira) are kept in sync:
7
+ *
8
+ * - Pull: new tasks added externally flow INTO the internal store.
9
+ * - Push: status changes from the orchestrator flow OUT to the external kanban.
10
+ *
11
+ * EXPORTS:
12
+ * SyncEngine — Main class
13
+ * createSyncEngine() — Factory helper
14
+ */
15
+
16
+ import {
17
+ getTask,
18
+ getAllTasks,
19
+ addTask,
20
+ updateTask,
21
+ getDirtyTasks,
22
+ markSynced,
23
+ upsertFromExternal,
24
+ setTaskStatus,
25
+ removeTask,
26
+ } from "./task-store.mjs";
27
+
28
+ import {
29
+ getKanbanAdapter,
30
+ getKanbanBackendName,
31
+ listTasks,
32
+ updateTaskStatus as updateExternalStatus,
33
+ } from "./kanban-adapter.mjs";
34
+
35
+ import { getSharedState } from "./shared-state-manager.mjs";
36
+
37
+ const TAG = "[sync-engine]";
38
+
39
+ const SYNC_POLICIES = new Set(["internal-primary", "bidirectional"]);
40
+
41
+ // Shared state configuration
42
+ const SHARED_STATE_ENABLED = process.env.SHARED_STATE_ENABLED !== "false"; // default true
43
+ const SHARED_STATE_STALE_THRESHOLD_MS =
44
+ Number(process.env.SHARED_STATE_STALE_THRESHOLD_MS) || 300_000;
45
+
46
+ /**
47
+ * Check if a heartbeat is stale (local implementation for sync-engine)
48
+ * @param {string} heartbeat - ISO timestamp
49
+ * @param {number} staleThresholdMs - Threshold in milliseconds
50
+ * @returns {boolean}
51
+ */
52
+ function isHeartbeatStale(heartbeat, staleThresholdMs) {
53
+ if (!heartbeat) return true;
54
+ const heartbeatTime = new Date(heartbeat).getTime();
55
+ if (!Number.isFinite(heartbeatTime)) return true;
56
+ const now = Date.now();
57
+ return now - heartbeatTime > staleThresholdMs;
58
+ }
59
+
60
+ function getSharedStatePayload(task) {
61
+ if (!task) return null;
62
+ return task.sharedState || task.meta?.sharedState || null;
63
+ }
64
+
65
+ function getSharedHeartbeat(state) {
66
+ if (!state) return null;
67
+ return state.ownerHeartbeat || state.heartbeat || state.owner_heartbeat || null;
68
+ }
69
+
70
+ function isSharedStateStaleForDecision(state, staleThresholdMs) {
71
+ const heartbeat = getSharedHeartbeat(state);
72
+ if (!heartbeat) return false;
73
+ return isHeartbeatStale(heartbeat, staleThresholdMs);
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Task ID validation — ensure ID format is compatible with the target backend
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const UUID_RE =
81
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
82
+
83
+ /**
84
+ * Check whether a task ID is valid for the given kanban backend.
85
+ *
86
+ * - GitHub Issues: expects a numeric issue number (e.g. "42")
87
+ * - VK (Vibe-Kanban): expects a UUID
88
+ * - Jira: expects a project-key string like "PROJ-123"
89
+ *
90
+ * @param {string} id The task / issue ID
91
+ * @param {string} backend Backend name ("github", "vk", "jira")
92
+ * @returns {boolean}
93
+ */
94
+ function isIdValidForBackend(id, backend) {
95
+ if (!id) return false;
96
+ switch (backend) {
97
+ case "github":
98
+ return /^\d+$/.test(String(id));
99
+ case "vk":
100
+ return true; // VK accepts any string, UUIDs or otherwise
101
+ case "jira":
102
+ return /^[A-Z]+-\d+$/i.test(String(id));
103
+ default:
104
+ return true;
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Status ordering — higher = more advanced
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const STATUS_ORDER = {
113
+ todo: 0,
114
+ blocked: 1,
115
+ inprogress: 1,
116
+ inreview: 2,
117
+ done: 3,
118
+ cancelled: 3,
119
+ };
120
+
121
+ function statusRank(s) {
122
+ return STATUS_ORDER[s] ?? -1;
123
+ }
124
+
125
+ const TERMINAL_STATUS_ALIASES = {
126
+ closed: "cancelled",
127
+ close: "cancelled",
128
+ archived: "cancelled",
129
+ rejected: "cancelled",
130
+ wontfix: "cancelled",
131
+ merged: "done",
132
+ merge: "done",
133
+ completed: "done",
134
+ complete: "done",
135
+ resolved: "done",
136
+ };
137
+
138
+ const CANONICAL_STATUS_BY_KEY = {
139
+ todo: "todo",
140
+ inprogress: "inprogress",
141
+ inreview: "inreview",
142
+ blocked: "blocked",
143
+ done: "done",
144
+ cancelled: "cancelled",
145
+ };
146
+
147
+ function normalizeStatusLabel(status) {
148
+ if (status == null) return status;
149
+ const raw = String(status).trim();
150
+ if (!raw) return raw;
151
+
152
+ const key = raw.toLowerCase().replace(/[\s_-]+/g, "");
153
+ if (TERMINAL_STATUS_ALIASES[key]) {
154
+ return TERMINAL_STATUS_ALIASES[key];
155
+ }
156
+ if (CANONICAL_STATUS_BY_KEY[key]) {
157
+ return CANONICAL_STATUS_BY_KEY[key];
158
+ }
159
+ return raw;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // SyncResult helper
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function emptySyncResult() {
167
+ return {
168
+ pulled: 0,
169
+ pushed: 0,
170
+ conflicts: 0,
171
+ errors: [],
172
+ timestamp: new Date().toISOString(),
173
+ };
174
+ }
175
+
176
+ function mergeSyncResults(a, b) {
177
+ return {
178
+ pulled: a.pulled + b.pulled,
179
+ pushed: a.pushed + b.pushed,
180
+ conflicts: a.conflicts + b.conflicts,
181
+ errors: [...a.errors, ...b.errors],
182
+ timestamp: new Date().toISOString(),
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // SyncEngine
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export class SyncEngine {
191
+ /** @type {string} */
192
+ #projectId;
193
+ /** @type {number} */
194
+ #syncIntervalMs;
195
+ /** @type {object|null} */
196
+ #kanbanAdapter;
197
+ /** @type {Function|null} */
198
+ #sendTelegram;
199
+ /** @type {Function|null} */
200
+ #onAlert;
201
+ /** @type {"internal-primary"|"bidirectional"} */
202
+ #syncPolicy;
203
+
204
+ /** @type {ReturnType<typeof setInterval>|null} */
205
+ #timer = null;
206
+ /** @type {boolean} */
207
+ #running = false;
208
+
209
+ // Stats
210
+ #lastSync = null;
211
+ #nextSync = null;
212
+ #syncsCompleted = 0;
213
+ #consecutiveFailures = 0;
214
+ #errors = [];
215
+ #metrics = {
216
+ syncSuccesses: 0,
217
+ syncFailures: 0,
218
+ rateLimitEvents: 0,
219
+ rateLimitRetrySuccesses: 0,
220
+ rateLimitRetryFailures: 0,
221
+ alertsTriggered: 0,
222
+ lastSuccessAt: null,
223
+ lastFailureAt: null,
224
+ lastRateLimitAt: null,
225
+ lastError: null,
226
+ };
227
+ #failureAlertThreshold;
228
+ #rateLimitAlertThreshold;
229
+
230
+ // Back-off
231
+ #baseIntervalMs;
232
+ #backoffActive = false;
233
+ static BACKOFF_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
234
+ static BACKOFF_THRESHOLD = 5;
235
+ static RATE_LIMIT_DELAY_MS = 60 * 1000; // 60 seconds
236
+
237
+ /**
238
+ * @param {object} options
239
+ * @param {string} options.projectId VK / GitHub project ID
240
+ * @param {number} [options.syncIntervalMs] Sync period (default 60 000)
241
+ * @param {object} [options.kanbanAdapter] Override adapter from kanban-adapter.mjs
242
+ * @param {Function} [options.sendTelegram] Optional notification callback
243
+ */
244
+ constructor(options = {}) {
245
+ if (!options.projectId) {
246
+ throw new Error(`${TAG} projectId is required`);
247
+ }
248
+ this.#projectId = options.projectId;
249
+ this.#syncIntervalMs = options.syncIntervalMs ?? 60_000;
250
+ this.#baseIntervalMs = this.#syncIntervalMs;
251
+ this.#kanbanAdapter = options.kanbanAdapter ?? null;
252
+ this.#sendTelegram = options.sendTelegram ?? null;
253
+ this.#onAlert = options.onAlert ?? null;
254
+ this.#failureAlertThreshold = Math.max(
255
+ 1,
256
+ Number(
257
+ options.failureAlertThreshold ??
258
+ process.env.GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD ??
259
+ 3,
260
+ ),
261
+ );
262
+ this.#rateLimitAlertThreshold = Math.max(
263
+ 1,
264
+ Number(
265
+ options.rateLimitAlertThreshold ??
266
+ process.env.GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD ??
267
+ 3,
268
+ ),
269
+ );
270
+ const requestedPolicy = String(
271
+ options.syncPolicy || process.env.KANBAN_SYNC_POLICY || "internal-primary",
272
+ )
273
+ .trim()
274
+ .toLowerCase();
275
+ this.#syncPolicy = SYNC_POLICIES.has(requestedPolicy)
276
+ ? requestedPolicy
277
+ : "internal-primary";
278
+ }
279
+
280
+ // -----------------------------------------------------------------------
281
+ // Lifecycle
282
+ // -----------------------------------------------------------------------
283
+
284
+ /** Start periodic sync. */
285
+ start() {
286
+ if (this.#running) {
287
+ console.log(TAG, "Already running — skipping start()");
288
+ return;
289
+ }
290
+ this.#running = true;
291
+ console.log(
292
+ TAG,
293
+ `Starting periodic sync every ${this.#syncIntervalMs}ms for project ${this.#projectId}`,
294
+ );
295
+ this.#scheduleNext();
296
+ }
297
+
298
+ /** Stop periodic sync. */
299
+ stop() {
300
+ this.#running = false;
301
+ if (this.#timer) {
302
+ clearTimeout(this.#timer);
303
+ this.#timer = null;
304
+ }
305
+ this.#nextSync = null;
306
+ console.log(TAG, "Stopped periodic sync");
307
+ }
308
+
309
+ // -----------------------------------------------------------------------
310
+ // Pull: External → Internal
311
+ // -----------------------------------------------------------------------
312
+
313
+ /**
314
+ * Fetch tasks from the external kanban and reconcile into the internal store.
315
+ * Also reads shared state from external sources (like GitHub comments).
316
+ * @returns {Promise<SyncResult>}
317
+ */
318
+ async pullFromExternal() {
319
+ const result = emptySyncResult();
320
+ const internalPrimary = this.#syncPolicy === "internal-primary";
321
+
322
+ /** @type {Array} */
323
+ let externalTasks;
324
+ try {
325
+ externalTasks = await this.#listExternal();
326
+ } catch (err) {
327
+ const msg = `Pull failed — could not list external tasks: ${err.message}`;
328
+ console.warn(TAG, msg);
329
+ result.errors.push(msg);
330
+ return result;
331
+ }
332
+
333
+ const internalTasks = getAllTasks();
334
+ const internalById = new Map(internalTasks.map((t) => [t.id, t]));
335
+ const externalIds = new Set();
336
+
337
+ for (const ext of externalTasks) {
338
+ if (!ext || !ext.id) continue;
339
+ externalIds.add(ext.id);
340
+
341
+ const normalizedExternalStatus = normalizeStatusLabel(ext.status);
342
+ const normalizedExt = {
343
+ ...ext,
344
+ status: normalizedExternalStatus,
345
+ };
346
+ const externalBaseBranch =
347
+ normalizedExt.baseBranch ??
348
+ normalizedExt.base_branch ??
349
+ normalizedExt.meta?.base_branch ??
350
+ normalizedExt.meta?.baseBranch ??
351
+ null;
352
+
353
+ try {
354
+ const internal = internalById.get(ext.id);
355
+
356
+ if (!internal) {
357
+ // ── New task from external ──
358
+ upsertFromExternal({
359
+ ...normalizedExt,
360
+ projectId: normalizedExt.projectId ?? this.#projectId,
361
+ externalBackend: normalizedExt.backend ?? null,
362
+ });
363
+ result.pulled++;
364
+ console.log(TAG, `Pulled new task ${ext.id}: ${ext.title}`);
365
+ continue;
366
+ }
367
+
368
+ if (internalPrimary) {
369
+ const mergedMeta = {
370
+ ...(internal.meta || {}),
371
+ ...(normalizedExt.meta || {}),
372
+ };
373
+ updateTask(ext.id, {
374
+ title: normalizedExt.title ?? internal.title,
375
+ description: normalizedExt.description ?? internal.description,
376
+ assignee: normalizedExt.assignee ?? internal.assignee,
377
+ priority: normalizedExt.priority ?? internal.priority,
378
+ projectId: normalizedExt.projectId ?? internal.projectId,
379
+ baseBranch: externalBaseBranch ?? internal.baseBranch,
380
+ branchName: normalizedExt.branchName ?? internal.branchName,
381
+ prNumber: normalizedExt.prNumber ?? internal.prNumber,
382
+ prUrl: normalizedExt.prUrl ?? internal.prUrl,
383
+ externalStatus: normalizedExternalStatus,
384
+ externalBackend:
385
+ normalizedExt.backend ?? normalizedExt.externalBackend ?? null,
386
+ meta: mergedMeta,
387
+ });
388
+ markSynced(ext.id);
389
+ continue;
390
+ }
391
+
392
+ // ── Existing task — check for external status change ──
393
+ const oldExternal = normalizeStatusLabel(internal.externalStatus);
394
+ const newExternal = normalizedExternalStatus;
395
+
396
+ if (
397
+ externalBaseBranch &&
398
+ externalBaseBranch !== internal.baseBranch
399
+ ) {
400
+ updateTask(ext.id, {
401
+ baseBranch: externalBaseBranch,
402
+ externalBackend:
403
+ normalizedExt.backend ?? normalizedExt.externalBackend ?? null,
404
+ });
405
+ }
406
+
407
+ // Read shared state metadata from external adapter (e.g., GitHub comments)
408
+ if (SHARED_STATE_ENABLED) {
409
+ const sharedStatePayload = getSharedStatePayload(normalizedExt);
410
+ if (sharedStatePayload) {
411
+ try {
412
+ const heartbeat = getSharedHeartbeat(sharedStatePayload);
413
+ // Merge shared state data into internal task meta
414
+ updateTask(ext.id, {
415
+ sharedStateOwnerId: sharedStatePayload.ownerId,
416
+ sharedStateHeartbeat: heartbeat,
417
+ sharedStateRetryCount: sharedStatePayload.retryCount,
418
+ });
419
+ } catch (err) {
420
+ console.warn(
421
+ TAG,
422
+ `Failed to merge shared state for ${ext.id}: ${err.message}`,
423
+ );
424
+ }
425
+ }
426
+ }
427
+
428
+ if (newExternal && newExternal !== oldExternal) {
429
+ // External status changed (human edited it)
430
+ const internalRank = statusRank(internal.status);
431
+ const newExternalRank = statusRank(newExternal);
432
+ const oldExternalRank = statusRank(oldExternal);
433
+
434
+ if (newExternalRank < oldExternalRank) {
435
+ // External moved BACKWARD — but only accept as human override
436
+ // if internal is ALSO behind (i.e., orchestrator didn't advance it).
437
+ // When internal is at or ahead of the old external, the backward
438
+ // move is most likely a VK restart / state loss — re-push instead.
439
+ const sharedStatePayload = getSharedStatePayload(normalizedExt) || {
440
+ ownerId: internal.sharedStateOwnerId,
441
+ ownerHeartbeat: internal.sharedStateHeartbeat,
442
+ };
443
+ const sharedStateStale = isSharedStateStaleForDecision(
444
+ sharedStatePayload,
445
+ SHARED_STATE_STALE_THRESHOLD_MS,
446
+ );
447
+ if (internalRank >= oldExternalRank && !sharedStateStale) {
448
+ // Internal was actively progressed by orchestrator — ignore
449
+ // the stale external value and mark dirty for re-push.
450
+ updateTask(ext.id, {
451
+ externalStatus: newExternal,
452
+ syncDirty: true,
453
+ });
454
+ console.log(
455
+ TAG,
456
+ `External reverted ${ext.id}: ${oldExternal} → ${newExternal} but internal=${internal.status} is ahead — will re-push`,
457
+ );
458
+ } else {
459
+ // Internal is truly behind old-external, so external moving
460
+ // backward is a genuine human override — accept it.
461
+ setTaskStatus(ext.id, newExternal, "external");
462
+ updateTask(ext.id, {
463
+ externalStatus: newExternal,
464
+ syncDirty: false,
465
+ });
466
+ result.pulled++;
467
+ console.log(
468
+ TAG,
469
+ `External moved backward ${ext.id}: ${oldExternal} → ${newExternal} (human override)`,
470
+ );
471
+ }
472
+ } else if (newExternalRank > internalRank) {
473
+ // External moved FORWARD past internal → respect external
474
+ setTaskStatus(ext.id, newExternal, "external");
475
+ updateTask(ext.id, {
476
+ externalStatus: newExternal,
477
+ syncDirty: false,
478
+ });
479
+ result.pulled++;
480
+ console.log(
481
+ TAG,
482
+ `External advanced past internal ${ext.id}: ${internal.status} → ${newExternal}`,
483
+ );
484
+ } else if (internalRank > newExternalRank) {
485
+ // Internal is more advanced → skip, push will handle it
486
+ updateTask(ext.id, { externalStatus: newExternal });
487
+ console.log(
488
+ TAG,
489
+ `Internal ahead of external for ${ext.id}: internal=${internal.status} external=${newExternal} — skipping pull`,
490
+ );
491
+ } else {
492
+ // Same rank, different status (e.g., blocked vs inprogress) or equal
493
+ upsertFromExternal({
494
+ ...normalizedExt,
495
+ projectId: normalizedExt.projectId ?? this.#projectId,
496
+ externalBackend: normalizedExt.backend ?? null,
497
+ });
498
+ result.pulled++;
499
+ }
500
+ } else {
501
+ // No status change — still update metadata (title, description, etc.)
502
+ upsertFromExternal({
503
+ ...normalizedExt,
504
+ projectId: normalizedExt.projectId ?? this.#projectId,
505
+ externalBackend: normalizedExt.backend ?? null,
506
+ });
507
+ }
508
+ } catch (err) {
509
+ const msg = `Pull error for task ${ext.id}: ${err.message}`;
510
+ console.warn(TAG, msg);
511
+ result.errors.push(msg);
512
+ }
513
+ }
514
+
515
+ // ── Tasks deleted externally ──
516
+ for (const internal of internalTasks) {
517
+ if (
518
+ internal.projectId === this.#projectId &&
519
+ !externalIds.has(internal.id) &&
520
+ internal.status !== "cancelled" &&
521
+ internal.status !== "done"
522
+ ) {
523
+ if (internalPrimary) {
524
+ console.log(
525
+ TAG,
526
+ `External task missing for ${internal.id} — preserving internal task (syncPolicy=internal-primary)`,
527
+ );
528
+ continue;
529
+ }
530
+ try {
531
+ setTaskStatus(internal.id, "cancelled", "external");
532
+ updateTask(internal.id, {
533
+ externalStatus: "cancelled",
534
+ syncDirty: false,
535
+ blockedReason: "Deleted from external kanban",
536
+ });
537
+ console.log(
538
+ TAG,
539
+ `Task ${internal.id} deleted externally — marked cancelled`,
540
+ );
541
+ } catch (err) {
542
+ const msg = `Failed to cancel externally-deleted task ${internal.id}: ${err.message}`;
543
+ console.warn(TAG, msg);
544
+ result.errors.push(msg);
545
+ }
546
+ }
547
+ }
548
+
549
+ return result;
550
+ }
551
+
552
+ // -----------------------------------------------------------------------
553
+ // Push: Internal → External
554
+ // -----------------------------------------------------------------------
555
+
556
+ /**
557
+ * Push dirty internal tasks to the external kanban.
558
+ * Before pushing, checks shared state to prevent conflicts with fresher claims.
559
+ * @returns {Promise<SyncResult>}
560
+ */
561
+ async pushToExternal() {
562
+ const result = emptySyncResult();
563
+ const dirtyTasks = getDirtyTasks();
564
+
565
+ if (dirtyTasks.length === 0) {
566
+ return result;
567
+ }
568
+
569
+ const backendName = getKanbanBackendName();
570
+ console.log(
571
+ TAG,
572
+ `Pushing ${dirtyTasks.length} dirty task(s) to external (backend=${backendName})`,
573
+ );
574
+
575
+ for (const task of dirtyTasks) {
576
+ const baseBranchCandidate =
577
+ task.baseBranch ??
578
+ task.base_branch ??
579
+ task.meta?.base_branch ??
580
+ task.meta?.baseBranch ??
581
+ null;
582
+ const metaBaseBranch =
583
+ task.meta?.base_branch ?? task.meta?.baseBranch ?? null;
584
+ const wantsBaseBranch =
585
+ baseBranchCandidate &&
586
+ String(baseBranchCandidate) !== String(metaBaseBranch);
587
+
588
+ // Check shared state for conflicts before pushing
589
+ if (SHARED_STATE_ENABLED) {
590
+ try {
591
+ const sharedState = await getSharedState(task.id);
592
+ const localOwner =
593
+ task.sharedStateOwnerId ||
594
+ task.meta?.sharedState?.ownerId ||
595
+ task.claimedBy ||
596
+ null;
597
+ if (sharedState) {
598
+ const heartbeat = getSharedHeartbeat(sharedState);
599
+ const stale = isHeartbeatStale(
600
+ heartbeat,
601
+ SHARED_STATE_STALE_THRESHOLD_MS,
602
+ );
603
+ if (!stale && sharedState.ownerId !== localOwner) {
604
+ // Active conflict - skip push and log
605
+ console.log(
606
+ TAG,
607
+ `Skipping push for ${task.id} — active claim by ${sharedState.ownerId} (heartbeat: ${heartbeat || "unknown"})`,
608
+ );
609
+ result.conflicts++;
610
+ continue;
611
+ }
612
+ }
613
+ } catch (err) {
614
+ console.warn(
615
+ TAG,
616
+ `Shared state check failed for ${task.id}: ${err.message}`,
617
+ );
618
+ // Continue with push on error (graceful degradation)
619
+ }
620
+ }
621
+ // Skip tasks whose IDs are incompatible with the active backend.
622
+ // e.g. VK UUID tasks cannot be pushed to GitHub Issues (needs numeric IDs).
623
+ const pushId = task.externalId || task.id;
624
+ if (!isIdValidForBackend(pushId, backendName)) {
625
+ // If the task originated from a different backend, silently clear dirty
626
+ // flag — it will be synced when that backend is active.
627
+ markSynced(task.id);
628
+ console.log(
629
+ TAG,
630
+ `Skipped ${task.id} — ID format incompatible with ${backendName} backend`,
631
+ );
632
+ continue;
633
+ }
634
+
635
+ try {
636
+ if (wantsBaseBranch) {
637
+ await this.#updateExternalPatch(pushId, {
638
+ status: task.status,
639
+ baseBranch: baseBranchCandidate,
640
+ });
641
+ } else {
642
+ await this.#updateExternal(pushId, task.status);
643
+ }
644
+ markSynced(task.id);
645
+ result.pushed++;
646
+ console.log(TAG, `Pushed ${task.id} → ${task.status}`);
647
+ } catch (err) {
648
+ if (this.#isRateLimited(err)) {
649
+ this.#metrics.rateLimitEvents++;
650
+ this.#metrics.lastRateLimitAt = new Date().toISOString();
651
+ const msg = `Rate limited — backing off for ${SyncEngine.RATE_LIMIT_DELAY_MS / 1000}s`;
652
+ console.warn(TAG, msg);
653
+ result.errors.push(msg);
654
+ if (this.#metrics.rateLimitEvents % this.#rateLimitAlertThreshold === 0) {
655
+ this.#emitAlert(
656
+ "rate_limit",
657
+ `Sync engine observed ${this.#metrics.rateLimitEvents} rate-limit event(s)`,
658
+ { taskId: task.id, backend: backendName },
659
+ );
660
+ }
661
+ await this.#sleep(SyncEngine.RATE_LIMIT_DELAY_MS);
662
+ // Retry once after back-off
663
+ try {
664
+ if (wantsBaseBranch) {
665
+ await this.#updateExternalPatch(pushId, {
666
+ status: task.status,
667
+ baseBranch: baseBranchCandidate,
668
+ });
669
+ } else {
670
+ await this.#updateExternal(pushId, task.status);
671
+ }
672
+ markSynced(task.id);
673
+ result.pushed++;
674
+ this.#metrics.rateLimitRetrySuccesses++;
675
+ console.log(
676
+ TAG,
677
+ `Pushed ${task.id} → ${task.status} (after rate-limit retry)`,
678
+ );
679
+ } catch (retryErr) {
680
+ const retryMsg = `Push retry failed for ${task.id}: ${retryErr.message}`;
681
+ console.warn(TAG, retryMsg);
682
+ result.errors.push(retryMsg);
683
+ this.#metrics.rateLimitRetryFailures++;
684
+ }
685
+ } else if (this.#isNotFound(err)) {
686
+ // Task was deleted from the external kanban — stop retrying
687
+ console.warn(
688
+ TAG,
689
+ `Task ${task.id} returned 404 — removing orphaned task from internal store`,
690
+ );
691
+ removeTask(task.id);
692
+ } else if (this.#isInvalidIdFormat(err)) {
693
+ // ID format mismatch (e.g. UUID pushed to GitHub) — skip silently
694
+ markSynced(task.id);
695
+ console.log(
696
+ TAG,
697
+ `Skipped ${task.id} — invalid ID format for current backend`,
698
+ );
699
+ } else {
700
+ const msg = `Push failed for ${task.id}: ${err.message}`;
701
+ console.warn(TAG, msg);
702
+ result.errors.push(msg);
703
+ }
704
+ }
705
+ }
706
+
707
+ return result;
708
+ }
709
+
710
+ // -----------------------------------------------------------------------
711
+ // Full sync
712
+ // -----------------------------------------------------------------------
713
+
714
+ /**
715
+ * Run a complete pull + push cycle.
716
+ * @returns {Promise<SyncResult>}
717
+ */
718
+ async fullSync() {
719
+ console.log(TAG, "Starting full sync…");
720
+ const t0 = Date.now();
721
+
722
+ let pullResult = emptySyncResult();
723
+ let pushResult = emptySyncResult();
724
+
725
+ try {
726
+ pullResult = await this.pullFromExternal();
727
+ } catch (err) {
728
+ pullResult.errors.push(`Pull phase crashed: ${err.message}`);
729
+ console.warn(TAG, `Pull phase error: ${err.message}`);
730
+ }
731
+
732
+ try {
733
+ pushResult = await this.pushToExternal();
734
+ } catch (err) {
735
+ pushResult.errors.push(`Push phase crashed: ${err.message}`);
736
+ console.warn(TAG, `Push phase error: ${err.message}`);
737
+ }
738
+
739
+ const combined = mergeSyncResults(pullResult, pushResult);
740
+ const elapsed = Date.now() - t0;
741
+
742
+ // Track consecutive failures for back-off
743
+ if (combined.errors.length > 0) {
744
+ this.#consecutiveFailures++;
745
+ this.#errors = combined.errors.slice(-20); // keep last 20
746
+ this.#metrics.syncFailures++;
747
+ this.#metrics.lastFailureAt = new Date().toISOString();
748
+ this.#metrics.lastError = combined.errors[combined.errors.length - 1] || null;
749
+
750
+ if (this.#consecutiveFailures % this.#failureAlertThreshold === 0) {
751
+ this.#emitAlert(
752
+ "sync_failure",
753
+ `Sync engine has ${this.#consecutiveFailures} consecutive failure(s)`,
754
+ { errors: combined.errors.slice(-3) },
755
+ );
756
+ }
757
+
758
+ if (
759
+ this.#consecutiveFailures >= SyncEngine.BACKOFF_THRESHOLD &&
760
+ !this.#backoffActive
761
+ ) {
762
+ this.#backoffActive = true;
763
+ this.#syncIntervalMs = SyncEngine.BACKOFF_INTERVAL_MS;
764
+ console.warn(
765
+ TAG,
766
+ `${this.#consecutiveFailures} consecutive failures — slowing sync to ${this.#syncIntervalMs}ms`,
767
+ );
768
+ if (this.#sendTelegram) {
769
+ this.#sendTelegram(
770
+ `⚠️ Sync engine: ${this.#consecutiveFailures} consecutive failures, backing off to 5 min interval`,
771
+ ).catch(() => {});
772
+ }
773
+ }
774
+ } else {
775
+ // Successful sync — reset failures and restore normal interval
776
+ if (this.#consecutiveFailures > 0) {
777
+ console.log(
778
+ TAG,
779
+ `Sync succeeded after ${this.#consecutiveFailures} failure(s) — resetting`,
780
+ );
781
+ }
782
+ this.#consecutiveFailures = 0;
783
+ this.#metrics.syncSuccesses++;
784
+ this.#metrics.lastSuccessAt = new Date().toISOString();
785
+ this.#metrics.lastError = null;
786
+ if (this.#backoffActive) {
787
+ this.#backoffActive = false;
788
+ this.#syncIntervalMs = this.#baseIntervalMs;
789
+ console.log(
790
+ TAG,
791
+ `Back-off cleared — restoring interval to ${this.#syncIntervalMs}ms`,
792
+ );
793
+ }
794
+ }
795
+
796
+ this.#syncsCompleted++;
797
+ this.#lastSync = combined.timestamp;
798
+
799
+ console.log(
800
+ TAG,
801
+ `Full sync complete in ${elapsed}ms — pulled=${combined.pulled} pushed=${combined.pushed} conflicts=${combined.conflicts} errors=${combined.errors.length}`,
802
+ );
803
+
804
+ return combined;
805
+ }
806
+
807
+ // -----------------------------------------------------------------------
808
+ // Single-task sync
809
+ // -----------------------------------------------------------------------
810
+
811
+ /**
812
+ * Force-sync a specific task: push internal state to external.
813
+ * Also syncs shared state to/from external adapter.
814
+ * @param {string} taskId
815
+ */
816
+ async syncTask(taskId) {
817
+ const task = getTask(taskId);
818
+ if (!task) {
819
+ console.warn(TAG, `syncTask: task ${taskId} not found in internal store`);
820
+ return;
821
+ }
822
+
823
+ const backendName = getKanbanBackendName();
824
+ const pushId = task.externalId || task.id;
825
+ if (!isIdValidForBackend(pushId, backendName)) {
826
+ markSynced(task.id);
827
+ console.log(
828
+ TAG,
829
+ `Skipped ${task.id} — ID format incompatible with ${backendName} backend`,
830
+ );
831
+ return;
832
+ }
833
+
834
+ const baseBranchCandidate =
835
+ task.baseBranch ??
836
+ task.base_branch ??
837
+ task.meta?.base_branch ??
838
+ task.meta?.baseBranch ??
839
+ null;
840
+ const metaBaseBranch =
841
+ task.meta?.base_branch ?? task.meta?.baseBranch ?? null;
842
+ const wantsBaseBranch =
843
+ baseBranchCandidate &&
844
+ String(baseBranchCandidate) !== String(metaBaseBranch);
845
+
846
+ try {
847
+ if (wantsBaseBranch) {
848
+ await this.#updateExternalPatch(pushId, {
849
+ status: task.status,
850
+ baseBranch: baseBranchCandidate,
851
+ });
852
+ } else {
853
+ await this.#updateExternal(pushId, task.status);
854
+ }
855
+ markSynced(taskId);
856
+ console.log(
857
+ TAG,
858
+ `Force-synced task ${taskId} (${pushId}) → ${task.status}`,
859
+ );
860
+ } catch (err) {
861
+ if (this.#isNotFound(err)) {
862
+ console.warn(
863
+ TAG,
864
+ `Task ${task.id} returned 404 during force-sync — removing orphaned task from internal store`,
865
+ );
866
+ removeTask(task.id);
867
+ return;
868
+ }
869
+ if (this.#isInvalidIdFormat(err)) {
870
+ markSynced(task.id);
871
+ console.log(
872
+ TAG,
873
+ `Skipped ${task.id} — invalid ID format for current backend`,
874
+ );
875
+ return;
876
+ }
877
+ console.warn(TAG, `syncTask failed for ${taskId}: ${err.message}`);
878
+ }
879
+ }
880
+
881
+ // -----------------------------------------------------------------------
882
+ // Status
883
+ // -----------------------------------------------------------------------
884
+
885
+ /**
886
+ * Explicitly sync shared state to/from external adapter.
887
+ * This method allows for manual synchronization of shared state data.
888
+ * @param {string} taskId - Task to sync (optional, syncs all if not provided)
889
+ * @returns {Promise<{success: boolean, synced: number, errors: string[]}>}
890
+ */
891
+ async syncSharedState(taskId = null) {
892
+ if (!SHARED_STATE_ENABLED) {
893
+ return { success: false, synced: 0, errors: ["Shared state disabled"] };
894
+ }
895
+
896
+ console.log(
897
+ TAG,
898
+ `Syncing shared state${taskId ? ` for ${taskId}` : " for all tasks"}...`,
899
+ );
900
+
901
+ // Implementation would depend on kanban adapter supporting shared state comments
902
+ // For now, return success as the main sync flows handle this
903
+ return { success: true, synced: 0, errors: [] };
904
+ }
905
+
906
+ /**
907
+ * Return current sync engine status.
908
+ */
909
+ getStatus() {
910
+ return {
911
+ lastSync: this.#lastSync,
912
+ nextSync: this.#nextSync,
913
+ syncsCompleted: this.#syncsCompleted,
914
+ errors: [...this.#errors],
915
+ running: this.#running,
916
+ consecutiveFailures: this.#consecutiveFailures,
917
+ backoffActive: this.#backoffActive,
918
+ currentIntervalMs: this.#syncIntervalMs,
919
+ sharedStateEnabled: SHARED_STATE_ENABLED,
920
+ syncPolicy: this.#syncPolicy,
921
+ metrics: { ...this.#metrics },
922
+ };
923
+ }
924
+
925
+ // -----------------------------------------------------------------------
926
+ // Private helpers
927
+ // -----------------------------------------------------------------------
928
+
929
+ /** Schedule the next sync tick. */
930
+ #scheduleNext() {
931
+ if (!this.#running) return;
932
+
933
+ this.#nextSync = new Date(Date.now() + this.#syncIntervalMs).toISOString();
934
+ this.#timer = setTimeout(async () => {
935
+ try {
936
+ await this.fullSync();
937
+ } catch (err) {
938
+ console.warn(TAG, `Periodic sync error: ${err.message}`);
939
+ }
940
+ this.#scheduleNext();
941
+ }, this.#syncIntervalMs);
942
+
943
+ // Prevent timer from blocking process exit
944
+ if (this.#timer && typeof this.#timer.unref === "function") {
945
+ this.#timer.unref();
946
+ }
947
+ }
948
+
949
+ /**
950
+ * List tasks from the external kanban, using adapter override or the
951
+ * module-level listTasks convenience function.
952
+ */
953
+ async #listExternal() {
954
+ if (
955
+ this.#kanbanAdapter &&
956
+ typeof this.#kanbanAdapter.listTasks === "function"
957
+ ) {
958
+ return await this.#kanbanAdapter.listTasks(this.#projectId);
959
+ }
960
+ return await listTasks(this.#projectId);
961
+ }
962
+
963
+ /**
964
+ * Update a single task's status in the external kanban.
965
+ */
966
+ async #updateExternal(taskId, status) {
967
+ if (
968
+ this.#kanbanAdapter &&
969
+ typeof this.#kanbanAdapter.updateTaskStatus === "function"
970
+ ) {
971
+ return await this.#kanbanAdapter.updateTaskStatus(taskId, status);
972
+ }
973
+ return await updateExternalStatus(taskId, status);
974
+ }
975
+
976
+ async #updateExternalPatch(taskId, patch) {
977
+ if (
978
+ this.#kanbanAdapter &&
979
+ typeof this.#kanbanAdapter.updateTask === "function"
980
+ ) {
981
+ return await this.#kanbanAdapter.updateTask(taskId, patch);
982
+ }
983
+ if (typeof patch?.status === "string") {
984
+ return await this.#updateExternal(taskId, patch.status);
985
+ }
986
+ return null;
987
+ }
988
+
989
+ /**
990
+ * Determine whether an error is a 429 rate-limit response.
991
+ */
992
+ #isRateLimited(err) {
993
+ if (!err) return false;
994
+ const msg = String(err.message || err).toLowerCase();
995
+ return (
996
+ msg.includes("429") ||
997
+ msg.includes("rate limit") ||
998
+ msg.includes("too many requests")
999
+ );
1000
+ }
1001
+
1002
+ /**
1003
+ * Determine whether an error is a 404 Not Found response.
1004
+ */
1005
+ #isNotFound(err) {
1006
+ if (!err) return false;
1007
+ const msg = String(err.message || err).toLowerCase();
1008
+ return msg.includes("404") || msg.includes("not found");
1009
+ }
1010
+
1011
+ /**
1012
+ * Determine whether an error indicates an invalid task ID format
1013
+ * (e.g. pushing a UUID to GitHub Issues).
1014
+ */
1015
+ #isInvalidIdFormat(err) {
1016
+ if (!err) return false;
1017
+ const msg = String(err.message || err).toLowerCase();
1018
+ return (
1019
+ msg.includes("invalid issue format") ||
1020
+ msg.includes("invalid issue number") ||
1021
+ msg.includes("expected a numeric id")
1022
+ );
1023
+ }
1024
+
1025
+ /** Simple async sleep. */
1026
+ #sleep(ms) {
1027
+ return new Promise((resolve) => setTimeout(resolve, ms));
1028
+ }
1029
+
1030
+ #emitAlert(kind, message, context = {}) {
1031
+ this.#metrics.alertsTriggered++;
1032
+ console.warn(TAG, `[alert:${kind}] ${message}`);
1033
+ const payload = {
1034
+ kind,
1035
+ message,
1036
+ context,
1037
+ timestamp: new Date().toISOString(),
1038
+ };
1039
+ if (this.#sendTelegram) {
1040
+ this.#sendTelegram(`⚠️ ${message}`).catch(() => {});
1041
+ }
1042
+ if (typeof this.#onAlert === "function") {
1043
+ try {
1044
+ this.#onAlert(payload);
1045
+ } catch {
1046
+ // best effort
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // Factory
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ /**
1057
+ * Create and return a SyncEngine instance.
1058
+ * @param {object} options Same as SyncEngine constructor
1059
+ * @returns {SyncEngine}
1060
+ */
1061
+ export function createSyncEngine(options) {
1062
+ return new SyncEngine(options);
1063
+ }