@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/sync-engine.mjs
ADDED
|
@@ -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
|
+
}
|