@letta-ai/dreams 0.0.1 → 0.0.3

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/dist/sync.js ADDED
@@ -0,0 +1,790 @@
1
+ "use strict";
2
+ /**
3
+ * Hook-safe `dreams sync` ([LET-10237] / [LET-10240]).
4
+ *
5
+ * Default invocation records a durable trigger and may spawn a detached worker.
6
+ * The hook parent always exits 0. The worker performs live scan + live-first
7
+ * upload, then — only when an active backfill policy already exists and upload
8
+ * budget remains — one backfill continuation slice + remaining-budget upload.
9
+ * Historical windows are never minted here; use `dreams backfill start`.
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.resolveSyncAdapter = resolveSyncAdapter;
46
+ exports.setDetachedWorkerSpawnerForTests = setDetachedWorkerSpawnerForTests;
47
+ exports.setBeforeWorkerLeaseReleaseForTests = setBeforeWorkerLeaseReleaseForTests;
48
+ exports.readSessionHintFromStdin = readSessionHintFromStdin;
49
+ exports.commandSyncSchedule = commandSyncSchedule;
50
+ exports.commandSyncPause = commandSyncPause;
51
+ exports.commandSyncResume = commandSyncResume;
52
+ exports.commandSyncStatus = commandSyncStatus;
53
+ exports.commandSyncWorker = commandSyncWorker;
54
+ exports.commandSync = commandSync;
55
+ const node_child_process_1 = require("node:child_process");
56
+ const path = __importStar(require("node:path"));
57
+ const upload_1 = require("./cloud/upload");
58
+ const backfill_1 = require("./local/backfill");
59
+ const db_1 = require("./local/db");
60
+ const leases_1 = require("./local/leases");
61
+ const scan_1 = require("./local/scan");
62
+ const state_lock_1 = require("./local/state-lock");
63
+ const sync_control_1 = require("./local/sync-control");
64
+ const source_adapters_1 = require("./local/source-adapters");
65
+ const store_1 = require("./store");
66
+ const STDIN_LIMIT_BYTES = 64 * 1024;
67
+ /** Resolve `--source <type>` at the sync command boundary (stdin never selects source). */
68
+ function resolveSyncAdapter(sourceType) {
69
+ if (sourceType === undefined)
70
+ return (0, source_adapters_1.defaultSourceAdapter)();
71
+ const trimmed = sourceType.trim();
72
+ if (!trimmed)
73
+ throw new Error("unknown source type: (empty)");
74
+ return (0, source_adapters_1.getSourceAdapterByType)(trimmed);
75
+ }
76
+ function defaultWorkerEntry() {
77
+ const candidate = process.argv[1] ? path.resolve(process.argv[1]) : "";
78
+ // Only trust argv when this process was started via the CLI entrypoints.
79
+ // Under `node --test`, argv[1] is the test file and must not be respawned.
80
+ if (candidate && /(?:^|[/\\])(?:cli\.(?:js|ts)|dreams\.cjs)$/.test(candidate))
81
+ return candidate;
82
+ return path.resolve(__dirname, "..", "bin", "dreams.cjs");
83
+ }
84
+ function defaultSpawnDetachedWorker(force, sourceType) {
85
+ try {
86
+ const args = [defaultWorkerEntry(), "sync", "--worker", "--json"];
87
+ if (force)
88
+ args.push("--force");
89
+ const type = sourceType ?? (0, source_adapters_1.defaultSourceAdapter)().sourceType;
90
+ args.push("--source", type);
91
+ const child = (0, node_child_process_1.spawn)(process.execPath, args, {
92
+ detached: true,
93
+ stdio: "ignore",
94
+ shell: false,
95
+ env: process.env,
96
+ });
97
+ // Asynchronous spawn failures must not reject the hook parent.
98
+ child.on("error", () => {
99
+ /* leave durable trigger pending for a later wake */
100
+ });
101
+ child.unref();
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ let detachedWorkerSpawner = defaultSpawnDetachedWorker;
109
+ /** Test-only: replace (or restore) the detached worker spawner. */
110
+ function setDetachedWorkerSpawnerForTests(spawner) {
111
+ detachedWorkerSpawner = spawner ?? defaultSpawnDetachedWorker;
112
+ }
113
+ /** Test-only: run after the in-loop successor decision, before lease release. */
114
+ let beforeWorkerLeaseReleaseForTests = null;
115
+ function setBeforeWorkerLeaseReleaseForTests(hook) {
116
+ beforeWorkerLeaseReleaseForTests = hook;
117
+ }
118
+ function spawnDetachedWorker(force, sourceType) {
119
+ return detachedWorkerSpawner(force, sourceType);
120
+ }
121
+ function emit(json, payload, human) {
122
+ if (json)
123
+ console.log(JSON.stringify(payload, null, 2));
124
+ else
125
+ console.log(human());
126
+ }
127
+ function openInstallationDb() {
128
+ const installation = (0, store_1.loadOrCreateInstallation)();
129
+ const db = (0, db_1.openDb)({
130
+ installationId: installation.installation_id,
131
+ installationCreatedAt: installation.created_at,
132
+ });
133
+ return { db, installationId: installation.installation_id };
134
+ }
135
+ /**
136
+ * Bound stdin read for hook payloads.
137
+ * Ignore interactive TTYs. In non-TTY environments without a real pipe (tests),
138
+ * time out quickly so we never hang waiting for EOF.
139
+ */
140
+ async function readSessionHintFromStdin(adapter, timeoutMs = 100) {
141
+ if (process.stdin.isTTY)
142
+ return null;
143
+ const chunks = [];
144
+ let total = 0;
145
+ const text = await new Promise((resolve) => {
146
+ let settled = false;
147
+ const finish = (value) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ process.stdin.removeListener("data", onData);
153
+ process.stdin.removeListener("end", onEnd);
154
+ process.stdin.removeListener("error", onError);
155
+ // Pause so a non-EOF stdin (test runners / sockets) does not keep the
156
+ // event loop alive after the bounded read completes.
157
+ try {
158
+ process.stdin.pause();
159
+ }
160
+ catch {
161
+ // ignore
162
+ }
163
+ resolve(value);
164
+ };
165
+ const onData = (chunk) => {
166
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
167
+ total += buf.length;
168
+ if (total <= STDIN_LIMIT_BYTES)
169
+ chunks.push(buf);
170
+ if (total >= STDIN_LIMIT_BYTES)
171
+ finish(Buffer.concat(chunks).toString("utf8"));
172
+ };
173
+ const onEnd = () => finish(chunks.length ? Buffer.concat(chunks).toString("utf8") : null);
174
+ const onError = () => finish(null);
175
+ const timer = setTimeout(() => finish(chunks.length ? Buffer.concat(chunks).toString("utf8") : null), timeoutMs);
176
+ process.stdin.on("data", onData);
177
+ process.stdin.on("end", onEnd);
178
+ process.stdin.on("error", onError);
179
+ if (process.stdin.readableEnded)
180
+ onEnd();
181
+ else
182
+ process.stdin.resume();
183
+ });
184
+ if (!text)
185
+ return null;
186
+ const trimmed = text.trim();
187
+ if (!trimmed)
188
+ return null;
189
+ try {
190
+ const parsed = JSON.parse(trimmed);
191
+ // Only session_id is trusted as a live hint. transcript_path / cwd from hooks
192
+ // are informational and must not become authoritative discovery paths.
193
+ const candidates = [parsed.session_id, parsed.sessionId, parsed.sessionID];
194
+ for (const value of candidates) {
195
+ if (typeof value === "string" && adapter.validateSessionId(value))
196
+ return value;
197
+ }
198
+ }
199
+ catch {
200
+ // Malformed hook stdin is ignored; sync still proceeds for tracked sessions.
201
+ }
202
+ return null;
203
+ }
204
+ function shouldSpawnWorker(options) {
205
+ if (options.forced)
206
+ return true;
207
+ if (options.retryDue)
208
+ return true;
209
+ if (options.runtimeDue && options.pending)
210
+ return true;
211
+ return false;
212
+ }
213
+ async function commandSyncSchedule(options) {
214
+ // Hook parent must always exit 0 — never surface local/SQLite failures as exit 1.
215
+ const adapter = options.adapter;
216
+ const sourceId = adapter.sourceId;
217
+ try {
218
+ const sessionHint = await readSessionHintFromStdin(adapter);
219
+ const { db } = openInstallationDb();
220
+ try {
221
+ const leaseRow = db.prepare("SELECT owner, expires_at FROM leases WHERE name = ?").get(leases_1.SYNC_WORKER_LEASE);
222
+ const workerActive = Boolean(leaseRow && leaseRow.expires_at > Date.now());
223
+ (0, sync_control_1.normalizeStaleRunning)(db, workerActive);
224
+ const recorded = (0, sync_control_1.recordSyncRequest)(db, { forced: options.force, sessionHint, sourceId });
225
+ if (recorded.paused) {
226
+ emit(options.json, { status: "paused", schema_version: 1 }, () => "Sync paused; hooks are no-ops until `dreams sync resume`.");
227
+ return 0;
228
+ }
229
+ const runtime = (0, sync_control_1.readRuntime)(db, sourceId);
230
+ const pending = (0, sync_control_1.triggerPending)(db, sourceId);
231
+ const due = options.force || recorded.forced || (0, sync_control_1.cadenceDue)(runtime) || (0, sync_control_1.retryDueFromError)(runtime);
232
+ if (!due && !workerActive) {
233
+ // Do not advance processed_gen here. Consuming on not_due would drop a
234
+ // durable trigger left pending after spawn failure / pass-budget handoff.
235
+ emit(options.json, {
236
+ status: "not_due",
237
+ schema_version: 1,
238
+ source: adapter.sourceType,
239
+ trigger_generation: recorded.generation,
240
+ pending,
241
+ next_due_at: runtime.next_due_at,
242
+ cadence_ms: sync_control_1.CADENCE_MS,
243
+ }, () => `Sync not due until ${runtime.next_due_at ?? "now"}.`);
244
+ return 0;
245
+ }
246
+ if (workerActive) {
247
+ emit(options.json, {
248
+ status: "coalesced",
249
+ schema_version: 1,
250
+ source: adapter.sourceType,
251
+ trigger_generation: recorded.generation,
252
+ forced: recorded.forced,
253
+ }, () => `Sync request coalesced into the running worker (generation ${recorded.generation}).`);
254
+ return 0;
255
+ }
256
+ if (!shouldSpawnWorker({
257
+ forced: options.force || recorded.forced,
258
+ runtimeDue: (0, sync_control_1.cadenceDue)(runtime),
259
+ retryDue: (0, sync_control_1.retryDueFromError)(runtime),
260
+ pending,
261
+ })) {
262
+ emit(options.json, {
263
+ status: "coalesced",
264
+ schema_version: 1,
265
+ source: adapter.sourceType,
266
+ trigger_generation: recorded.generation,
267
+ forced: recorded.forced,
268
+ }, () => `Sync request recorded (generation ${recorded.generation}).`);
269
+ return 0;
270
+ }
271
+ const spawned = spawnDetachedWorker(options.force || recorded.forced, adapter.sourceType);
272
+ // Spawn failure must leave the durable trigger pending for a later wake
273
+ // and record a degraded diagnosis visible via `sync status`.
274
+ if (!spawned) {
275
+ (0, sync_control_1.markRuntimeDegraded)(db, "spawn_failed", "worker could not be detached; trigger left pending for next wake", null, sourceId);
276
+ }
277
+ emit(options.json, {
278
+ status: spawned ? "scheduled" : "coalesced",
279
+ schema_version: 1,
280
+ source: adapter.sourceType,
281
+ trigger_generation: recorded.generation,
282
+ forced: recorded.forced,
283
+ worker: spawned ? "spawned" : "spawn_failed",
284
+ }, () => spawned
285
+ ? `Sync scheduled (generation ${recorded.generation}).`
286
+ : `Sync request recorded; worker spawn failed — will retry on next wake (generation ${recorded.generation}).`);
287
+ return 0;
288
+ }
289
+ finally {
290
+ (0, db_1.closeDb)();
291
+ }
292
+ }
293
+ catch (error) {
294
+ try {
295
+ emit(options.json, {
296
+ status: "coalesced",
297
+ schema_version: 1,
298
+ source: adapter.sourceType,
299
+ worker: "schedule_error",
300
+ message: error instanceof Error ? error.message : String(error),
301
+ }, () => "Sync request could not be fully scheduled; will retry on next wake.");
302
+ }
303
+ catch {
304
+ // ignore secondary emit failures
305
+ }
306
+ return 0;
307
+ }
308
+ }
309
+ function commandSyncPause(json) {
310
+ const { db } = openInstallationDb();
311
+ try {
312
+ const runtime = (0, sync_control_1.setPaused)(db, true);
313
+ emit(json, { status: "paused", schema_version: 1, sync: runtime }, () => "Sync paused. Existing hooks will no-op.");
314
+ return 0;
315
+ }
316
+ finally {
317
+ (0, db_1.closeDb)();
318
+ }
319
+ }
320
+ async function commandSyncResume(json) {
321
+ const { db } = openInstallationDb();
322
+ try {
323
+ const runtime = (0, sync_control_1.setPaused)(db, false);
324
+ // Resume force-wakes every source with a trigger row; spawn the first
325
+ // runnable source and let post-pass handoff chain the rest ([LET-10314]).
326
+ const nextId = (0, sync_control_1.nextRunnableSourceId)(db) ?? (0, source_adapters_1.defaultSourceAdapter)().sourceId;
327
+ const nextType = (0, source_adapters_1.getSourceAdapterById)(nextId).sourceType;
328
+ const spawned = spawnDetachedWorker(true, nextType);
329
+ emit(json, { status: "resumed", schema_version: 1, sync: runtime, worker: spawned ? "scheduled" : "pending", source: nextType }, () => "Sync resumed; an immediate sync was requested.");
330
+ return 0;
331
+ }
332
+ finally {
333
+ (0, db_1.closeDb)();
334
+ }
335
+ }
336
+ function commandSyncStatus(json) {
337
+ const { db } = openInstallationDb();
338
+ try {
339
+ const leaseRow = db.prepare("SELECT expires_at FROM leases WHERE name = ?").get(leases_1.SYNC_WORKER_LEASE);
340
+ const workerActive = Boolean(leaseRow && leaseRow.expires_at > Date.now());
341
+ (0, sync_control_1.normalizeStaleRunning)(db, workerActive);
342
+ const runtime = (0, sync_control_1.readRuntime)(db);
343
+ const trigger = (0, sync_control_1.observeTrigger)(db);
344
+ const pending = trigger.requested_gen > trigger.processed_gen || trigger.forced;
345
+ const effective = (0, sync_control_1.effectiveSyncState)({
346
+ runtime,
347
+ pending,
348
+ forced: trigger.forced,
349
+ workerActive,
350
+ });
351
+ const payload = {
352
+ schema_version: 1,
353
+ status: "ok",
354
+ sync: {
355
+ ...runtime,
356
+ effective_state: effective,
357
+ worker_active: workerActive,
358
+ pending,
359
+ requested_gen: trigger.requested_gen,
360
+ processed_gen: trigger.processed_gen,
361
+ forced: trigger.forced,
362
+ session_hints: trigger.session_hints,
363
+ cadence_ms: sync_control_1.CADENCE_MS,
364
+ },
365
+ };
366
+ emit(json, payload, () => [
367
+ "Dreams sync status",
368
+ "",
369
+ ` Effective: ${effective}`,
370
+ ` Runtime: ${runtime.state}`,
371
+ ` Paused: ${runtime.paused ? "yes" : "no"}`,
372
+ ` Pending: ${pending ? "yes" : "no"}`,
373
+ ` Worker: ${workerActive ? "active" : "idle"}`,
374
+ ` Next due: ${runtime.next_due_at ?? "(immediately)"}`,
375
+ ` Last success:${runtime.last_success_at ? ` ${runtime.last_success_at}` : " (none)"}`,
376
+ ` Last error: ${runtime.last_error_code ? `${runtime.last_error_code}: ${runtime.last_error_message}` : " (none)"}`,
377
+ ].join("\n"));
378
+ return 0;
379
+ }
380
+ finally {
381
+ (0, db_1.closeDb)();
382
+ }
383
+ }
384
+ function uploadFailureResult(db, uploadSummary, budgetExhausted, backfillContinuation, sourceId) {
385
+ if (uploadSummary.stopped_code) {
386
+ return {
387
+ ok: false,
388
+ code: uploadSummary.stopped_code,
389
+ message: uploadSummary.stopped_reason,
390
+ budgetExhausted,
391
+ backfillContinuation,
392
+ retryAt: (0, sync_control_1.earliestUploadRetryAt)(db, sourceId) ?? (0, sync_control_1.fallbackRetryAt)(uploadSummary.stopped_code),
393
+ paused: false,
394
+ };
395
+ }
396
+ if (uploadSummary.retry_scheduled > 0) {
397
+ return {
398
+ ok: false,
399
+ code: "network",
400
+ message: `retry_scheduled=${uploadSummary.retry_scheduled}`,
401
+ budgetExhausted,
402
+ backfillContinuation,
403
+ retryAt: (0, sync_control_1.earliestUploadRetryAt)(db, sourceId) ?? (0, sync_control_1.fallbackRetryAt)("network"),
404
+ paused: false,
405
+ };
406
+ }
407
+ if (uploadSummary.partial && uploadSummary.quarantined > 0 && uploadSummary.accepted === 0 && uploadSummary.duplicate === 0) {
408
+ return {
409
+ ok: false,
410
+ code: "local_error",
411
+ message: "upload pass quarantined rows without accepts",
412
+ budgetExhausted,
413
+ backfillContinuation,
414
+ retryAt: (0, sync_control_1.fallbackRetryAt)("local_error"),
415
+ paused: false,
416
+ };
417
+ }
418
+ return null;
419
+ }
420
+ async function runOneWorkerPass(db, installationId, sourceOwner, workerOwner, observed, adapter) {
421
+ if ((0, sync_control_1.isPaused)(db)) {
422
+ return {
423
+ ok: true,
424
+ code: null,
425
+ message: null,
426
+ budgetExhausted: false,
427
+ backfillContinuation: false,
428
+ retryAt: null,
429
+ paused: true,
430
+ };
431
+ }
432
+ const heartbeat = () => {
433
+ if (!(0, leases_1.renewLease)(db, leases_1.SYNC_WORKER_LEASE, workerOwner))
434
+ throw new Error("sync-worker lease lost");
435
+ };
436
+ const cancel = () => (0, sync_control_1.isPaused)(db);
437
+ const totalBudget = (0, sync_control_1.uploadRowBudget)();
438
+ const scanSummary = (0, scan_1.reconcile)(db, {
439
+ installationId,
440
+ leaseOwner: sourceOwner,
441
+ adapter,
442
+ mode: "live",
443
+ hintSessionIds: observed.session_hints,
444
+ priority: "live",
445
+ shouldCancel: cancel,
446
+ onProgress: heartbeat,
447
+ });
448
+ if ((0, sync_control_1.isPaused)(db)) {
449
+ return {
450
+ ok: true,
451
+ code: null,
452
+ message: null,
453
+ budgetExhausted: false,
454
+ backfillContinuation: false,
455
+ retryAt: null,
456
+ paused: true,
457
+ };
458
+ }
459
+ heartbeat();
460
+ const liveUpload = await (0, upload_1.runUpload)(db, {
461
+ installationId,
462
+ leaseOwner: sourceOwner,
463
+ adapter,
464
+ maxRows: totalBudget,
465
+ shouldCancel: cancel,
466
+ onProgress: heartbeat,
467
+ });
468
+ if ((0, sync_control_1.isPaused)(db)) {
469
+ return {
470
+ ok: true,
471
+ code: null,
472
+ message: null,
473
+ budgetExhausted: liveUpload.budget_exhausted,
474
+ backfillContinuation: false,
475
+ retryAt: null,
476
+ paused: true,
477
+ };
478
+ }
479
+ const liveFail = uploadFailureResult(db, liveUpload, liveUpload.budget_exhausted, false, adapter.sourceId);
480
+ if (liveFail)
481
+ return liveFail;
482
+ let rowsProcessed = liveUpload.rows_processed;
483
+ let budgetExhausted = liveUpload.budget_exhausted;
484
+ let accepted = liveUpload.accepted;
485
+ let duplicate = liveUpload.duplicate;
486
+ let backfillSessions = 0;
487
+ let backfillEnqueued = 0;
488
+ let backfillAccepted = 0;
489
+ let backfillContinuation = false;
490
+ // Live-first: only continue an existing active policy when shared budget remains.
491
+ const remainingBudget = Math.max(0, totalBudget - rowsProcessed);
492
+ if (!budgetExhausted && remainingBudget > 0) {
493
+ const backfill = (0, backfill_1.runBackfillContinuePass)(db, {
494
+ installationId,
495
+ leaseOwner: sourceOwner,
496
+ adapter,
497
+ shouldCancel: cancel,
498
+ onProgress: heartbeat,
499
+ });
500
+ if ((0, sync_control_1.isPaused)(db) || backfill.canceled) {
501
+ return {
502
+ ok: true,
503
+ code: null,
504
+ message: null,
505
+ budgetExhausted,
506
+ backfillContinuation: false,
507
+ retryAt: null,
508
+ paused: true,
509
+ };
510
+ }
511
+ backfillSessions = backfill.sessions_selected;
512
+ backfillEnqueued = backfill.scan.segments_enqueued;
513
+ if (backfill.status !== "skipped") {
514
+ heartbeat();
515
+ const backfillUpload = await (0, upload_1.runUpload)(db, {
516
+ installationId,
517
+ leaseOwner: sourceOwner,
518
+ adapter,
519
+ maxRows: remainingBudget,
520
+ shouldCancel: cancel,
521
+ onProgress: heartbeat,
522
+ });
523
+ if ((0, sync_control_1.isPaused)(db)) {
524
+ return {
525
+ ok: true,
526
+ code: null,
527
+ message: null,
528
+ budgetExhausted: budgetExhausted || backfillUpload.budget_exhausted,
529
+ backfillContinuation: false,
530
+ retryAt: null,
531
+ paused: true,
532
+ };
533
+ }
534
+ rowsProcessed += backfillUpload.rows_processed;
535
+ budgetExhausted = budgetExhausted || backfillUpload.budget_exhausted;
536
+ accepted += backfillUpload.accepted;
537
+ duplicate += backfillUpload.duplicate;
538
+ backfillAccepted = backfillUpload.accepted + backfillUpload.duplicate;
539
+ const backfillMadeProgress = backfill.made_progress || backfillAccepted > 0 || backfillEnqueued > 0;
540
+ backfillContinuation = backfillMadeProgress && backfill.actionable_remaining > 0;
541
+ const backfillFail = uploadFailureResult(db, backfillUpload, budgetExhausted,
542
+ // Keep continuation intent even on retryable upload failure when frontier remains.
543
+ backfillContinuation, adapter.sourceId);
544
+ if (backfillFail)
545
+ return backfillFail;
546
+ }
547
+ }
548
+ return {
549
+ ok: true,
550
+ code: null,
551
+ message: `live_sessions=${scanSummary.sessions_queued}; backfill_sessions=${backfillSessions}; accepted=${accepted}; duplicate=${duplicate}; rows_processed=${rowsProcessed}`,
552
+ budgetExhausted,
553
+ backfillContinuation,
554
+ retryAt: null,
555
+ paused: false,
556
+ };
557
+ }
558
+ async function commandSyncWorker(options) {
559
+ const adapter = options.adapter;
560
+ const sourceId = adapter.sourceId;
561
+ const lock = (0, state_lock_1.acquireLocalStateLock)();
562
+ if (lock === null) {
563
+ // Leave trigger pending for a later wake.
564
+ if (options.json) {
565
+ console.log(JSON.stringify({ status: "busy", schema_version: 1, code: "busy", source: adapter.sourceType }));
566
+ }
567
+ return 0;
568
+ }
569
+ const { db, installationId } = openInstallationDb();
570
+ let workerOwner = null;
571
+ let sourceOwner = null;
572
+ let handoffEligible = false;
573
+ let passes = 0;
574
+ let lastResult = {
575
+ ok: true,
576
+ code: null,
577
+ message: null,
578
+ budgetExhausted: false,
579
+ backfillContinuation: false,
580
+ retryAt: null,
581
+ paused: false,
582
+ };
583
+ try {
584
+ workerOwner = (0, leases_1.acquireLease)(db, leases_1.SYNC_WORKER_LEASE);
585
+ if (workerOwner === null) {
586
+ if (options.json) {
587
+ console.log(JSON.stringify({ status: "busy", schema_version: 1, code: "busy", source: adapter.sourceType }));
588
+ }
589
+ return 0;
590
+ }
591
+ (0, sync_control_1.normalizeStaleRunning)(db, true);
592
+ if ((0, sync_control_1.isPaused)(db)) {
593
+ if (options.json)
594
+ console.log(JSON.stringify({ status: "paused", schema_version: 1, source: adapter.sourceType }));
595
+ return 0;
596
+ }
597
+ (0, sync_control_1.markRuntimeRunning)(db);
598
+ while (passes < sync_control_1.MAX_TRAILING_PASSES) {
599
+ if ((0, sync_control_1.isPaused)(db))
600
+ break;
601
+ const observed = (0, sync_control_1.observeTrigger)(db, sourceId);
602
+ const runtime = (0, sync_control_1.readRuntime)(db, sourceId);
603
+ const due = options.force ||
604
+ observed.forced ||
605
+ observed.requested_gen > observed.processed_gen ||
606
+ (0, sync_control_1.cadenceDue)(runtime) ||
607
+ (0, sync_control_1.retryDueFromError)(runtime);
608
+ if (!due && passes > 0)
609
+ break;
610
+ if (!due && passes === 0 && !options.force && !observed.forced) {
611
+ break;
612
+ }
613
+ sourceOwner = (0, leases_1.acquireLease)(db, leases_1.SOURCE_LEASE);
614
+ if (sourceOwner === null) {
615
+ (0, sync_control_1.markRuntimeDegraded)(db, "busy", "source lease busy; trigger left pending", (0, sync_control_1.fallbackRetryAt)("busy"), sourceId);
616
+ lastResult = {
617
+ ok: false,
618
+ code: "busy",
619
+ message: "source lease busy",
620
+ budgetExhausted: false,
621
+ backfillContinuation: false,
622
+ retryAt: (0, sync_control_1.fallbackRetryAt)("busy"),
623
+ paused: false,
624
+ };
625
+ break;
626
+ }
627
+ try {
628
+ if (!(0, leases_1.renewLease)(db, leases_1.SYNC_WORKER_LEASE, workerOwner))
629
+ break;
630
+ lastResult = await runOneWorkerPass(db, installationId, sourceOwner, workerOwner, observed, adapter);
631
+ // Pause mid-pass must not consume the observed generation or its hints.
632
+ if (lastResult.paused || (0, sync_control_1.isPaused)(db)) {
633
+ lastResult = { ...lastResult, paused: true };
634
+ break;
635
+ }
636
+ const keepPending = lastResult.budgetExhausted || lastResult.backfillContinuation;
637
+ const nextDueAt = lastResult.ok ? undefined : (lastResult.retryAt ?? (0, sync_control_1.fallbackRetryAt)(lastResult.code));
638
+ (0, sync_control_1.completeSyncPass)(db, sourceId, observed.requested_gen, {
639
+ ok: lastResult.ok,
640
+ errorCode: lastResult.code,
641
+ errorMessage: lastResult.message,
642
+ advanceCadence: lastResult.ok && !keepPending,
643
+ clearHints: lastResult.ok,
644
+ forcePending: keepPending,
645
+ nextDueAt,
646
+ });
647
+ }
648
+ finally {
649
+ (0, leases_1.releaseLease)(db, leases_1.SOURCE_LEASE, sourceOwner);
650
+ sourceOwner = null;
651
+ }
652
+ passes++;
653
+ if (!(0, sync_control_1.triggerPending)(db, sourceId))
654
+ break;
655
+ // Trailing pass only if generation advanced during the pass.
656
+ if (!(0, sync_control_1.observeTrigger)(db, sourceId).requested_gen ||
657
+ (0, sync_control_1.observeTrigger)(db, sourceId).requested_gen <= observed.requested_gen) {
658
+ break;
659
+ }
660
+ }
661
+ // Immediate successors are only for successful (non-paused) passes. The
662
+ // actual pending check happens after lease release so a coalesced Stop wake
663
+ // that arrives during shutdown is not lost.
664
+ handoffEligible = lastResult.ok && !lastResult.paused;
665
+ if (options.json) {
666
+ console.log(JSON.stringify({
667
+ status: lastResult.paused ? "paused" : lastResult.ok ? "ok" : "degraded",
668
+ schema_version: 1,
669
+ source: adapter.sourceType,
670
+ passes,
671
+ code: lastResult.code,
672
+ message: lastResult.message,
673
+ sync: (0, sync_control_1.readRuntime)(db, sourceId),
674
+ }));
675
+ }
676
+ return 0;
677
+ }
678
+ catch (error) {
679
+ try {
680
+ // Do not advance processed_gen — leave in-flight and newer generations pending.
681
+ (0, sync_control_1.markRuntimeDegraded)(db, "local_error", error instanceof Error ? error.message : String(error), (0, sync_control_1.earliestUploadRetryAt)(db, sourceId) ?? (0, sync_control_1.fallbackRetryAt)("local_error"), sourceId);
682
+ }
683
+ catch {
684
+ // best effort
685
+ }
686
+ if (options.json) {
687
+ console.log(JSON.stringify({
688
+ status: "degraded",
689
+ schema_version: 1,
690
+ source: adapter.sourceType,
691
+ code: "local_error",
692
+ message: error instanceof Error ? error.message : String(error),
693
+ }));
694
+ }
695
+ handoffEligible = false;
696
+ return 0;
697
+ }
698
+ finally {
699
+ if (sourceOwner)
700
+ (0, leases_1.releaseLease)(db, leases_1.SOURCE_LEASE, sourceOwner);
701
+ try {
702
+ beforeWorkerLeaseReleaseForTests?.(db);
703
+ }
704
+ catch {
705
+ // test hooks must not break cleanup
706
+ }
707
+ if (workerOwner)
708
+ (0, leases_1.releaseLease)(db, leases_1.SYNC_WORKER_LEASE, workerOwner);
709
+ (0, db_1.closeDb)();
710
+ // Release the local-state lock before the final pending check so a hook that
711
+ // spawned during shutdown cannot observe busy-on-lock and exit while we also
712
+ // skip launching a successor.
713
+ (0, state_lock_1.releaseLocalStateLock)(lock);
714
+ let successorType = null;
715
+ if (handoffEligible) {
716
+ try {
717
+ const again = openInstallationDb();
718
+ try {
719
+ // Prefer this source when still pending; otherwise hand off to another
720
+ // registered source with an actionable wake (cross-source coalesce).
721
+ const nextId = (0, sync_control_1.nextRunnableSourceId)(again.db, sourceId);
722
+ if (nextId)
723
+ successorType = (0, source_adapters_1.getSourceAdapterById)(nextId).sourceType;
724
+ }
725
+ finally {
726
+ (0, db_1.closeDb)();
727
+ }
728
+ }
729
+ catch {
730
+ successorType = null;
731
+ }
732
+ }
733
+ if (successorType)
734
+ spawnDetachedWorker(true, successorType);
735
+ }
736
+ }
737
+ async function commandSync(subcommand, options) {
738
+ let adapter;
739
+ try {
740
+ adapter = resolveSyncAdapter(options.sourceType);
741
+ }
742
+ catch (error) {
743
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
744
+ return 1;
745
+ }
746
+ if (options.worker) {
747
+ if (subcommand) {
748
+ process.stderr.write(`--worker cannot be combined with sync ${subcommand}\n`);
749
+ return 1;
750
+ }
751
+ return commandSyncWorker({ json: options.json, force: options.force, adapter });
752
+ }
753
+ switch (subcommand) {
754
+ case undefined:
755
+ return commandSyncSchedule({ json: options.json, force: options.force, adapter });
756
+ case "pause":
757
+ if (options.force) {
758
+ process.stderr.write(`--force is not valid with sync pause\n`);
759
+ return 1;
760
+ }
761
+ if (options.sourceType) {
762
+ process.stderr.write(`--source is not valid with sync pause\n`);
763
+ return 1;
764
+ }
765
+ return commandSyncPause(options.json);
766
+ case "resume":
767
+ if (options.force) {
768
+ process.stderr.write(`--force is not valid with sync resume\n`);
769
+ return 1;
770
+ }
771
+ if (options.sourceType) {
772
+ process.stderr.write(`--source is not valid with sync resume\n`);
773
+ return 1;
774
+ }
775
+ return commandSyncResume(options.json);
776
+ case "status":
777
+ if (options.force) {
778
+ process.stderr.write(`--force is not valid with sync status\n`);
779
+ return 1;
780
+ }
781
+ if (options.sourceType) {
782
+ process.stderr.write(`--source is not valid with sync status\n`);
783
+ return 1;
784
+ }
785
+ return commandSyncStatus(options.json);
786
+ default:
787
+ process.stderr.write(`Unknown sync subcommand: ${subcommand} — expected pause, resume, or status\n`);
788
+ return 1;
789
+ }
790
+ }