@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/README.md +74 -20
- package/dist/auth/commands.js +46 -4
- package/dist/auth/credentials.js +28 -8
- package/dist/cli.js +198 -8
- package/dist/cloud/client.js +89 -5
- package/dist/cloud/upload.js +150 -70
- package/dist/development-reset.js +359 -0
- package/dist/launcher.js +311 -0
- package/dist/local/backfill.js +608 -0
- package/dist/local/bindings.js +30 -0
- package/dist/local/claude.js +61 -163
- package/dist/local/codex.js +313 -0
- package/dist/local/commands.js +129 -10
- package/dist/local/db.js +64 -0
- package/dist/local/detect.js +144 -0
- package/dist/local/discovered.js +6 -0
- package/dist/local/leases.js +3 -1
- package/dist/local/scan.js +99 -17
- package/dist/local/source-adapter.js +8 -0
- package/dist/local/source-adapters.js +58 -0
- package/dist/local/state-lock.js +88 -0
- package/dist/local/sync-control.js +381 -0
- package/dist/local/transcript-bytes.js +204 -0
- package/dist/managed-install.js +74 -86
- package/dist/onboard.js +26 -72
- package/dist/skill.js +281 -16
- package/dist/snapshots.js +210 -0
- package/dist/sync.js +790 -0
- package/package.json +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Sync control plane ([LET-10237] / [LET-10314]): durable triggers, pause, cadence.
|
|
4
|
+
*
|
|
5
|
+
* Global pause + singleton worker lease live on `sync_runtime`. Per-source
|
|
6
|
+
* cadence, retry, and degraded diagnostics live on `sync_triggers` so Claude
|
|
7
|
+
* and Codex cannot strand each other's wakes.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.DEFAULT_UPLOAD_ROW_BUDGET = exports.MAX_TRAILING_PASSES = exports.RETRY_BACKOFF_MS = exports.CADENCE_MS = exports.DEFAULT_SOURCE_ID = void 0;
|
|
11
|
+
exports.uploadRowBudget = uploadRowBudget;
|
|
12
|
+
exports.readTrigger = readTrigger;
|
|
13
|
+
exports.readRuntime = readRuntime;
|
|
14
|
+
exports.recordSyncRequest = recordSyncRequest;
|
|
15
|
+
exports.setPaused = setPaused;
|
|
16
|
+
exports.isPaused = isPaused;
|
|
17
|
+
exports.cadenceDue = cadenceDue;
|
|
18
|
+
exports.observeTrigger = observeTrigger;
|
|
19
|
+
exports.completeSyncPass = completeSyncPass;
|
|
20
|
+
exports.markRuntimeDegraded = markRuntimeDegraded;
|
|
21
|
+
exports.earliestUploadRetryAt = earliestUploadRetryAt;
|
|
22
|
+
exports.markRuntimeRunning = markRuntimeRunning;
|
|
23
|
+
exports.normalizeStaleRunning = normalizeStaleRunning;
|
|
24
|
+
exports.triggerPending = triggerPending;
|
|
25
|
+
exports.sourceIsRunnable = sourceIsRunnable;
|
|
26
|
+
exports.nextRunnableSourceId = nextRunnableSourceId;
|
|
27
|
+
exports.effectiveSyncState = effectiveSyncState;
|
|
28
|
+
exports.retryDueFromError = retryDueFromError;
|
|
29
|
+
exports.fallbackRetryAt = fallbackRetryAt;
|
|
30
|
+
const db_1 = require("./db");
|
|
31
|
+
const source_adapters_1 = require("./source-adapters");
|
|
32
|
+
/** CLI/worker default source id — Claude via deterministic registry ([LET-10313]). */
|
|
33
|
+
exports.DEFAULT_SOURCE_ID = (0, source_adapters_1.defaultSourceAdapter)().sourceId;
|
|
34
|
+
exports.CADENCE_MS = 3 * 60 * 60 * 1000;
|
|
35
|
+
exports.RETRY_BACKOFF_MS = 60_000;
|
|
36
|
+
exports.MAX_TRAILING_PASSES = 2;
|
|
37
|
+
exports.DEFAULT_UPLOAD_ROW_BUDGET = 32;
|
|
38
|
+
/** Soft upload row budget for one worker pass (overridable in tests). */
|
|
39
|
+
function uploadRowBudget() {
|
|
40
|
+
const raw = process.env.DREAMS_SYNC_UPLOAD_ROW_BUDGET;
|
|
41
|
+
if (raw) {
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
if (Number.isSafeInteger(n) && n > 0)
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
return exports.DEFAULT_UPLOAD_ROW_BUDGET;
|
|
47
|
+
}
|
|
48
|
+
function ensureLocalSource(db, sourceId) {
|
|
49
|
+
const adapter = (0, source_adapters_1.getSourceAdapterById)(sourceId);
|
|
50
|
+
const now = (0, db_1.nowIso)();
|
|
51
|
+
db.prepare(`INSERT INTO sources (id, source_type, source_key, display_name, adapter_version, status, created_at, updated_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, 'detected', ?, ?)
|
|
53
|
+
ON CONFLICT(id) DO NOTHING`).run(sourceId, adapter.sourceType, adapter.sourceKey, adapter.displayName, adapter.adapterVersion, now, now);
|
|
54
|
+
}
|
|
55
|
+
function ensureTrigger(db, sourceId) {
|
|
56
|
+
ensureLocalSource(db, sourceId);
|
|
57
|
+
db.prepare("INSERT INTO sync_triggers (source_id, updated_at) VALUES (?, ?) ON CONFLICT(source_id) DO NOTHING").run(sourceId, (0, db_1.nowIso)());
|
|
58
|
+
}
|
|
59
|
+
function ensureRuntime(db) {
|
|
60
|
+
db.prepare(`INSERT INTO sync_runtime (id, paused, state, updated_at)
|
|
61
|
+
VALUES (1, 0, 'idle', ?)
|
|
62
|
+
ON CONFLICT(id) DO NOTHING`).run((0, db_1.nowIso)());
|
|
63
|
+
}
|
|
64
|
+
function parseHints(raw) {
|
|
65
|
+
if (!raw)
|
|
66
|
+
return [];
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!Array.isArray(parsed))
|
|
70
|
+
return [];
|
|
71
|
+
return [...new Set(parsed.filter((v) => typeof v === "string" && v.length > 0))];
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function readTrigger(db, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
78
|
+
const row = db
|
|
79
|
+
.prepare("SELECT requested_gen, processed_gen, forced, session_hints FROM sync_triggers WHERE source_id = ?")
|
|
80
|
+
.get(sourceId);
|
|
81
|
+
if (!row)
|
|
82
|
+
return { requested_gen: 0, processed_gen: 0, forced: false, session_hints: [] };
|
|
83
|
+
return {
|
|
84
|
+
requested_gen: row.requested_gen,
|
|
85
|
+
processed_gen: row.processed_gen,
|
|
86
|
+
forced: row.forced === 1,
|
|
87
|
+
session_hints: parseHints(row.session_hints),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function globalPaused(db) {
|
|
91
|
+
ensureRuntime(db);
|
|
92
|
+
return db.prepare("SELECT paused FROM sync_runtime WHERE id = 1").get().paused === 1;
|
|
93
|
+
}
|
|
94
|
+
function globalWorkerState(db) {
|
|
95
|
+
ensureRuntime(db);
|
|
96
|
+
const row = db.prepare("SELECT state, paused FROM sync_runtime WHERE id = 1").get();
|
|
97
|
+
if (row.paused === 1)
|
|
98
|
+
return "paused";
|
|
99
|
+
return row.state || "idle";
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Per-source schedule view + global pause. Cadence/retry fields come from
|
|
103
|
+
* `sync_triggers`; `paused` and global `running` come from `sync_runtime`.
|
|
104
|
+
*/
|
|
105
|
+
function readRuntime(db, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
106
|
+
ensureRuntime(db);
|
|
107
|
+
ensureTrigger(db, sourceId);
|
|
108
|
+
const paused = globalPaused(db);
|
|
109
|
+
const globalState = globalWorkerState(db);
|
|
110
|
+
const row = db
|
|
111
|
+
.prepare(`SELECT state, last_attempt_at, last_success_at, last_error_at, last_error_code, last_error_message, next_due_at
|
|
112
|
+
FROM sync_triggers WHERE source_id = ?`)
|
|
113
|
+
.get(sourceId);
|
|
114
|
+
let state = row.state || "idle";
|
|
115
|
+
if (paused)
|
|
116
|
+
state = "paused";
|
|
117
|
+
else if (globalState === "running")
|
|
118
|
+
state = "running";
|
|
119
|
+
return {
|
|
120
|
+
paused,
|
|
121
|
+
state,
|
|
122
|
+
last_attempt_at: row.last_attempt_at,
|
|
123
|
+
last_success_at: row.last_success_at,
|
|
124
|
+
last_error_at: row.last_error_at,
|
|
125
|
+
last_error_code: row.last_error_code ?? null,
|
|
126
|
+
last_error_message: row.last_error_message,
|
|
127
|
+
next_due_at: row.next_due_at,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Atomic pause check + trigger record. Returns null when paused. */
|
|
131
|
+
function recordSyncRequest(db, options = {}) {
|
|
132
|
+
const sourceId = options.sourceId ?? exports.DEFAULT_SOURCE_ID;
|
|
133
|
+
return (0, db_1.tx)(db, () => {
|
|
134
|
+
ensureRuntime(db);
|
|
135
|
+
ensureTrigger(db, sourceId);
|
|
136
|
+
if (globalPaused(db))
|
|
137
|
+
return { paused: true };
|
|
138
|
+
const before = readTrigger(db, sourceId);
|
|
139
|
+
const hints = new Set(before.session_hints);
|
|
140
|
+
if (options.sessionHint)
|
|
141
|
+
hints.add(options.sessionHint);
|
|
142
|
+
const forced = Boolean(options.forced) || before.forced;
|
|
143
|
+
db.prepare(`UPDATE sync_triggers
|
|
144
|
+
SET requested_gen = requested_gen + 1,
|
|
145
|
+
forced = ?,
|
|
146
|
+
session_hints = ?,
|
|
147
|
+
updated_at = ?
|
|
148
|
+
WHERE source_id = ?`).run(forced ? 1 : 0, JSON.stringify([...hints]), (0, db_1.nowIso)(), sourceId);
|
|
149
|
+
const generation = db.prepare("SELECT requested_gen FROM sync_triggers WHERE source_id = ?").get(sourceId).requested_gen;
|
|
150
|
+
return { paused: false, generation, forced };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function setPaused(db, paused) {
|
|
154
|
+
return (0, db_1.tx)(db, () => {
|
|
155
|
+
ensureRuntime(db);
|
|
156
|
+
const now = (0, db_1.nowIso)();
|
|
157
|
+
if (paused) {
|
|
158
|
+
db.prepare(`UPDATE sync_runtime SET paused = 1, state = 'paused', updated_at = ? WHERE id = 1`).run(now);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Resume clears global pause and force-wakes every source with a trigger row
|
|
162
|
+
// (or at least the default) so pending Codex work is not left asleep.
|
|
163
|
+
db.prepare(`UPDATE sync_runtime SET paused = 0, state = CASE WHEN state = 'paused' THEN 'idle' ELSE state END, updated_at = ? WHERE id = 1`).run(now);
|
|
164
|
+
const rows = db.prepare("SELECT source_id FROM sync_triggers").all();
|
|
165
|
+
const targets = rows.length > 0 ? rows.map((r) => r.source_id) : [exports.DEFAULT_SOURCE_ID];
|
|
166
|
+
for (const sourceId of targets) {
|
|
167
|
+
ensureTrigger(db, sourceId);
|
|
168
|
+
db.prepare(`UPDATE sync_triggers
|
|
169
|
+
SET requested_gen = requested_gen + 1, forced = 1, updated_at = ?
|
|
170
|
+
WHERE source_id = ?`).run(now, sourceId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return readRuntime(db, exports.DEFAULT_SOURCE_ID);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function isPaused(db) {
|
|
177
|
+
return globalPaused(db);
|
|
178
|
+
}
|
|
179
|
+
function cadenceDue(runtime, nowMs = Date.now()) {
|
|
180
|
+
if (!runtime.next_due_at)
|
|
181
|
+
return true;
|
|
182
|
+
const due = Date.parse(runtime.next_due_at);
|
|
183
|
+
return !Number.isFinite(due) || due <= nowMs;
|
|
184
|
+
}
|
|
185
|
+
function observeTrigger(db, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
186
|
+
return readTrigger(db, sourceId);
|
|
187
|
+
}
|
|
188
|
+
function completeSyncPass(db, sourceId, observedGen, options) {
|
|
189
|
+
(0, db_1.tx)(db, () => {
|
|
190
|
+
ensureRuntime(db);
|
|
191
|
+
ensureTrigger(db, sourceId);
|
|
192
|
+
const now = (0, db_1.nowIso)();
|
|
193
|
+
let setNextDue = false;
|
|
194
|
+
let nextDue = null;
|
|
195
|
+
if (options.nextDueAt !== undefined) {
|
|
196
|
+
setNextDue = true;
|
|
197
|
+
// null means due immediately (retry ASAP on next wake).
|
|
198
|
+
nextDue = options.nextDueAt ?? now;
|
|
199
|
+
}
|
|
200
|
+
else if (options.advanceCadence) {
|
|
201
|
+
setNextDue = true;
|
|
202
|
+
nextDue = new Date(Date.now() + exports.CADENCE_MS).toISOString();
|
|
203
|
+
}
|
|
204
|
+
// Only clear hints when no newer generation recorded hints during this pass.
|
|
205
|
+
db.prepare(`UPDATE sync_triggers
|
|
206
|
+
SET processed_gen = MAX(processed_gen, ?),
|
|
207
|
+
forced = CASE WHEN requested_gen > ? THEN forced ELSE 0 END,
|
|
208
|
+
session_hints = CASE
|
|
209
|
+
WHEN ? = 1 AND requested_gen <= ? THEN NULL
|
|
210
|
+
ELSE session_hints
|
|
211
|
+
END,
|
|
212
|
+
updated_at = ?
|
|
213
|
+
WHERE source_id = ?`).run(observedGen, observedGen, options.clearHints ? 1 : 0, observedGen, now, sourceId);
|
|
214
|
+
if (options.forcePending) {
|
|
215
|
+
db.prepare(`UPDATE sync_triggers
|
|
216
|
+
SET requested_gen = CASE WHEN requested_gen <= ? THEN ? + 1 ELSE requested_gen END,
|
|
217
|
+
forced = 1,
|
|
218
|
+
updated_at = ?
|
|
219
|
+
WHERE source_id = ?`).run(observedGen, observedGen, now, sourceId);
|
|
220
|
+
}
|
|
221
|
+
if (options.ok) {
|
|
222
|
+
db.prepare(`UPDATE sync_triggers
|
|
223
|
+
SET state = 'idle',
|
|
224
|
+
last_attempt_at = ?,
|
|
225
|
+
last_success_at = ?,
|
|
226
|
+
next_due_at = CASE WHEN ? = 1 THEN ? ELSE next_due_at END,
|
|
227
|
+
updated_at = ?
|
|
228
|
+
WHERE source_id = ?`).run(now, now, setNextDue ? 1 : 0, nextDue, now, sourceId);
|
|
229
|
+
db.prepare(`UPDATE sync_runtime
|
|
230
|
+
SET state = CASE WHEN paused = 1 THEN 'paused' ELSE 'idle' END, updated_at = ?
|
|
231
|
+
WHERE id = 1`).run(now);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
db.prepare(`UPDATE sync_triggers
|
|
235
|
+
SET state = 'degraded',
|
|
236
|
+
last_attempt_at = ?,
|
|
237
|
+
last_error_at = ?,
|
|
238
|
+
last_error_code = ?,
|
|
239
|
+
last_error_message = ?,
|
|
240
|
+
next_due_at = CASE WHEN ? = 1 THEN ? ELSE next_due_at END,
|
|
241
|
+
updated_at = ?
|
|
242
|
+
WHERE source_id = ?`).run(now, now, options.errorCode ?? "local_error", (options.errorMessage ?? "sync failed").slice(0, 500), setNextDue ? 1 : 0, nextDue, now, sourceId);
|
|
243
|
+
db.prepare(`UPDATE sync_runtime
|
|
244
|
+
SET state = CASE WHEN paused = 1 THEN 'paused' ELSE 'idle' END, updated_at = ?
|
|
245
|
+
WHERE id = 1`).run(now);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Update per-source failure diagnostics without advancing trigger processed_gen. */
|
|
250
|
+
function markRuntimeDegraded(db, errorCode, errorMessage, nextDueAt = null, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
251
|
+
(0, db_1.tx)(db, () => {
|
|
252
|
+
ensureRuntime(db);
|
|
253
|
+
ensureTrigger(db, sourceId);
|
|
254
|
+
const now = (0, db_1.nowIso)();
|
|
255
|
+
db.prepare(`UPDATE sync_triggers
|
|
256
|
+
SET state = 'degraded',
|
|
257
|
+
last_attempt_at = ?,
|
|
258
|
+
last_error_at = ?,
|
|
259
|
+
last_error_code = ?,
|
|
260
|
+
last_error_message = ?,
|
|
261
|
+
next_due_at = COALESCE(?, next_due_at),
|
|
262
|
+
updated_at = ?
|
|
263
|
+
WHERE source_id = ?`).run(now, now, errorCode ?? "local_error", (errorMessage ?? "sync failed").slice(0, 500), nextDueAt, now, sourceId);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Earliest pending upload retry timestamp for retry-due scheduling. */
|
|
267
|
+
function earliestUploadRetryAt(db, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
268
|
+
const row = db
|
|
269
|
+
.prepare(`SELECT MIN(next_attempt_at) AS next_at
|
|
270
|
+
FROM upload_queue
|
|
271
|
+
WHERE source_id = ? AND state = 'pending' AND next_attempt_at IS NOT NULL`)
|
|
272
|
+
.get(sourceId);
|
|
273
|
+
return row?.next_at ?? null;
|
|
274
|
+
}
|
|
275
|
+
function markRuntimeRunning(db) {
|
|
276
|
+
(0, db_1.tx)(db, () => {
|
|
277
|
+
ensureRuntime(db);
|
|
278
|
+
db.prepare(`UPDATE sync_runtime SET state = CASE WHEN paused = 1 THEN 'paused' ELSE 'running' END, last_attempt_at = ?, updated_at = ? WHERE id = 1`).run((0, db_1.nowIso)(), (0, db_1.nowIso)());
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** Normalize stale running state when no worker lease is held. */
|
|
282
|
+
function normalizeStaleRunning(db, workerLeaseHeld) {
|
|
283
|
+
if (workerLeaseHeld)
|
|
284
|
+
return;
|
|
285
|
+
(0, db_1.tx)(db, () => {
|
|
286
|
+
ensureRuntime(db);
|
|
287
|
+
const row = db.prepare("SELECT state, paused FROM sync_runtime WHERE id = 1").get();
|
|
288
|
+
if (row.state !== "running")
|
|
289
|
+
return;
|
|
290
|
+
db.prepare(`UPDATE sync_runtime
|
|
291
|
+
SET state = CASE WHEN paused = 1 THEN 'paused' ELSE 'idle' END, updated_at = ?
|
|
292
|
+
WHERE id = 1`).run((0, db_1.nowIso)());
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function triggerPending(db, sourceId = exports.DEFAULT_SOURCE_ID) {
|
|
296
|
+
const t = readTrigger(db, sourceId);
|
|
297
|
+
return t.requested_gen > t.processed_gen || t.forced;
|
|
298
|
+
}
|
|
299
|
+
function sourceIsRunnable(db, sourceId, nowMs = Date.now()) {
|
|
300
|
+
const runtime = readRuntime(db, sourceId);
|
|
301
|
+
if (runtime.paused)
|
|
302
|
+
return false;
|
|
303
|
+
const trigger = readTrigger(db, sourceId);
|
|
304
|
+
const pending = trigger.requested_gen > trigger.processed_gen || trigger.forced;
|
|
305
|
+
if (!pending)
|
|
306
|
+
return false;
|
|
307
|
+
return trigger.forced || cadenceDue(runtime, nowMs) || retryDueFromError(runtime, nowMs);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Pick the next source with an actionable pending trigger.
|
|
311
|
+
* Prefers `preferredSourceId` when runnable; otherwise scans registered adapters.
|
|
312
|
+
*/
|
|
313
|
+
function nextRunnableSourceId(db, preferredSourceId = null, nowMs = Date.now()) {
|
|
314
|
+
if (preferredSourceId && sourceIsRunnable(db, preferredSourceId, nowMs))
|
|
315
|
+
return preferredSourceId;
|
|
316
|
+
const rows = db.prepare("SELECT source_id FROM sync_triggers ORDER BY updated_at ASC").all();
|
|
317
|
+
const known = new Set((0, source_adapters_1.listSourceAdapters)().map((adapter) => adapter.sourceId));
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
if (!known.has(row.source_id))
|
|
320
|
+
continue;
|
|
321
|
+
if (preferredSourceId && row.source_id === preferredSourceId)
|
|
322
|
+
continue;
|
|
323
|
+
if (sourceIsRunnable(db, row.source_id, nowMs))
|
|
324
|
+
return row.source_id;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Unambiguous status for humans/agents. Precedence:
|
|
330
|
+
* paused → running (worker lease) → degraded → pending (runnable now) →
|
|
331
|
+
* not_due (including retained triggers waiting on cadence/backoff) → idle.
|
|
332
|
+
*
|
|
333
|
+
* "Runnable" matches the scheduler: forced | cadenceDue | retryDueFromError.
|
|
334
|
+
*/
|
|
335
|
+
function effectiveSyncState(options) {
|
|
336
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
337
|
+
if (options.runtime.paused)
|
|
338
|
+
return "paused";
|
|
339
|
+
if (options.workerActive)
|
|
340
|
+
return "running";
|
|
341
|
+
if (options.runtime.state === "degraded")
|
|
342
|
+
return "degraded";
|
|
343
|
+
const runnable = Boolean(options.forced) || cadenceDue(options.runtime, nowMs) || retryDueFromError(options.runtime, nowMs);
|
|
344
|
+
if (options.pending && runnable)
|
|
345
|
+
return "pending";
|
|
346
|
+
if (options.pending && !runnable)
|
|
347
|
+
return "not_due";
|
|
348
|
+
if (options.runtime.next_due_at) {
|
|
349
|
+
const due = Date.parse(options.runtime.next_due_at);
|
|
350
|
+
if (Number.isFinite(due) && due > nowMs)
|
|
351
|
+
return "not_due";
|
|
352
|
+
}
|
|
353
|
+
return "idle";
|
|
354
|
+
}
|
|
355
|
+
function retryDueFromError(runtime, nowMs = Date.now()) {
|
|
356
|
+
if (runtime.state !== "degraded" || !runtime.last_error_at)
|
|
357
|
+
return false;
|
|
358
|
+
if (runtime.last_error_code !== "network" && runtime.last_error_code !== "needs_auth" && runtime.last_error_code !== "busy") {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
// Prefer explicit next_due_at when present (earliest upload retry / backoff).
|
|
362
|
+
if (runtime.next_due_at) {
|
|
363
|
+
const due = Date.parse(runtime.next_due_at);
|
|
364
|
+
return !Number.isFinite(due) || due <= nowMs;
|
|
365
|
+
}
|
|
366
|
+
const last = Date.parse(runtime.last_error_at);
|
|
367
|
+
if (!Number.isFinite(last))
|
|
368
|
+
return true;
|
|
369
|
+
// Bounded backoff: 1 minute after retryable failure.
|
|
370
|
+
return nowMs - last >= exports.RETRY_BACKOFF_MS;
|
|
371
|
+
}
|
|
372
|
+
/** Fallback next_due_at when a failure has no queued upload retry timestamp. */
|
|
373
|
+
function fallbackRetryAt(errorCode, nowMs = Date.now()) {
|
|
374
|
+
if (errorCode === "network" || errorCode === "needs_auth" || errorCode === "busy") {
|
|
375
|
+
return new Date(nowMs + exports.RETRY_BACKOFF_MS).toISOString();
|
|
376
|
+
}
|
|
377
|
+
if (errorCode === "cloud_rejected") {
|
|
378
|
+
return new Date(nowMs + exports.CADENCE_MS).toISOString();
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Harness-neutral transcript byte primitives ([LET-10313]).
|
|
4
|
+
*
|
|
5
|
+
* Identity checks, prefix hashing, and verified JSONL line scans used by the
|
|
6
|
+
* generic scanner. Harness-specific discovery stays in each source adapter.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.OversizedLineError = exports.IdentityChangedError = void 0;
|
|
43
|
+
exports.firstChunkFingerprint = firstChunkFingerprint;
|
|
44
|
+
exports.prefixFingerprint = prefixFingerprint;
|
|
45
|
+
exports.scanCompleteLinesVerified = scanCompleteLinesVerified;
|
|
46
|
+
const crypto = __importStar(require("node:crypto"));
|
|
47
|
+
const fs = __importStar(require("node:fs"));
|
|
48
|
+
const IO_CHUNK_BYTES = 64 * 1024;
|
|
49
|
+
const FIRST_CHUNK_BYTES = 4096;
|
|
50
|
+
/** Thrown when a file's identity changes between discovery and read (TOCTOU / swap). */
|
|
51
|
+
class IdentityChangedError extends Error {
|
|
52
|
+
constructor(filePath) {
|
|
53
|
+
super(`file identity changed under read: ${filePath}`);
|
|
54
|
+
this.name = "IdentityChangedError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.IdentityChangedError = IdentityChangedError;
|
|
58
|
+
class OversizedLineError extends Error {
|
|
59
|
+
filePath;
|
|
60
|
+
startOffset;
|
|
61
|
+
byteLength;
|
|
62
|
+
maxBytes;
|
|
63
|
+
constructor(filePath, startOffset, byteLength, maxBytes) {
|
|
64
|
+
super(`JSONL record at byte ${startOffset} in ${filePath} is ${byteLength} bytes; maximum is ${maxBytes}`);
|
|
65
|
+
this.filePath = filePath;
|
|
66
|
+
this.startOffset = startOffset;
|
|
67
|
+
this.byteLength = byteLength;
|
|
68
|
+
this.maxBytes = maxBytes;
|
|
69
|
+
this.name = "OversizedLineError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.OversizedLineError = OversizedLineError;
|
|
73
|
+
function fingerprint(bytes) {
|
|
74
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
function normalizedMtime(stat) {
|
|
77
|
+
return Number.isFinite(stat.mtimeMs) ? Math.floor(stat.mtimeMs) : null;
|
|
78
|
+
}
|
|
79
|
+
function inodeOf(stat) {
|
|
80
|
+
return stat.ino ? String(stat.ino) : null;
|
|
81
|
+
}
|
|
82
|
+
function hashRange(fd, end, digest, onProgress) {
|
|
83
|
+
let position = 0;
|
|
84
|
+
while (position < end) {
|
|
85
|
+
const buf = Buffer.allocUnsafe(Math.min(IO_CHUNK_BYTES, end - position));
|
|
86
|
+
const read = fs.readSync(fd, buf, 0, buf.length, position);
|
|
87
|
+
if (read === 0)
|
|
88
|
+
throw new IdentityChangedError("opened transcript");
|
|
89
|
+
digest.update(buf.subarray(0, read));
|
|
90
|
+
position += read;
|
|
91
|
+
onProgress?.();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fingerprint the transcript's FIRST LINE (which carries the stable sessionId).
|
|
96
|
+
* This is invariant under appends — new bytes go after the first line — but
|
|
97
|
+
* changes when the file is replaced by a different session, so it is a reliable
|
|
98
|
+
* replacement signal. (Hashing a fixed-size prefix would wrongly change on every
|
|
99
|
+
* append for files shorter than that prefix.)
|
|
100
|
+
*/
|
|
101
|
+
function firstChunkFingerprint(filePath) {
|
|
102
|
+
let fd;
|
|
103
|
+
try {
|
|
104
|
+
fd = fs.openSync(filePath, "r");
|
|
105
|
+
const buf = Buffer.alloc(FIRST_CHUNK_BYTES);
|
|
106
|
+
const read = fs.readSync(fd, buf, 0, FIRST_CHUNK_BYTES, 0);
|
|
107
|
+
const head = buf.subarray(0, read);
|
|
108
|
+
const nl = head.indexOf(0x0a);
|
|
109
|
+
const firstLine = nl === -1 ? head : head.subarray(0, nl + 1);
|
|
110
|
+
return fingerprint(firstLine);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (fd !== undefined)
|
|
117
|
+
fs.closeSync(fd);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** SHA-256 of [0, length), read with bounded memory. */
|
|
121
|
+
function prefixFingerprint(filePath, length, onProgress) {
|
|
122
|
+
if (length <= 0)
|
|
123
|
+
return "";
|
|
124
|
+
const fd = fs.openSync(filePath, "r");
|
|
125
|
+
try {
|
|
126
|
+
const digest = crypto.createHash("sha256");
|
|
127
|
+
hashRange(fd, length, digest, onProgress);
|
|
128
|
+
return digest.digest("hex");
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
fs.closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Read complete JSONL records from one opened handle using bounded memory.
|
|
136
|
+
* The covered-prefix hash is computed from that same handle, and a final fstat
|
|
137
|
+
* rejects files that changed while being scanned.
|
|
138
|
+
*/
|
|
139
|
+
function scanCompleteLinesVerified(realPath, start, expectedInode, maxLineBytes, onLine, onProgress) {
|
|
140
|
+
let fd;
|
|
141
|
+
try {
|
|
142
|
+
fd = fs.openSync(realPath, "r");
|
|
143
|
+
const initial = fs.fstatSync(fd);
|
|
144
|
+
const inode = inodeOf(initial);
|
|
145
|
+
if (expectedInode !== null && inode !== null && inode !== expectedInode)
|
|
146
|
+
throw new IdentityChangedError(realPath);
|
|
147
|
+
if (start > initial.size)
|
|
148
|
+
throw new IdentityChangedError(realPath);
|
|
149
|
+
const digest = crypto.createHash("sha256");
|
|
150
|
+
hashRange(fd, start, digest, onProgress);
|
|
151
|
+
let readPosition = start;
|
|
152
|
+
let carry = Buffer.alloc(0);
|
|
153
|
+
let carryStart = start;
|
|
154
|
+
let completeThrough = start;
|
|
155
|
+
while (readPosition < initial.size) {
|
|
156
|
+
const chunk = Buffer.allocUnsafe(Math.min(IO_CHUNK_BYTES, initial.size - readPosition));
|
|
157
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, readPosition);
|
|
158
|
+
if (read === 0)
|
|
159
|
+
break;
|
|
160
|
+
readPosition += read;
|
|
161
|
+
onProgress?.();
|
|
162
|
+
const combined = carry.length === 0 ? chunk.subarray(0, read) : Buffer.concat([carry, chunk.subarray(0, read)]);
|
|
163
|
+
const combinedStart = carryStart;
|
|
164
|
+
let consumed = 0;
|
|
165
|
+
while (consumed < combined.length) {
|
|
166
|
+
const newline = combined.indexOf(0x0a, consumed);
|
|
167
|
+
if (newline === -1)
|
|
168
|
+
break;
|
|
169
|
+
const end = newline + 1;
|
|
170
|
+
const byteLength = end - consumed;
|
|
171
|
+
const lineStart = combinedStart + consumed;
|
|
172
|
+
if (byteLength > maxLineBytes)
|
|
173
|
+
throw new OversizedLineError(realPath, lineStart, byteLength, maxLineBytes);
|
|
174
|
+
const bytes = combined.subarray(consumed, end);
|
|
175
|
+
digest.update(bytes);
|
|
176
|
+
completeThrough = combinedStart + end;
|
|
177
|
+
onLine({ bytes, start_offset: lineStart, end_offset: completeThrough });
|
|
178
|
+
consumed = end;
|
|
179
|
+
}
|
|
180
|
+
carry = Buffer.from(combined.subarray(consumed));
|
|
181
|
+
carryStart = combinedStart + consumed;
|
|
182
|
+
if (carry.length > maxLineBytes) {
|
|
183
|
+
throw new OversizedLineError(realPath, carryStart, carry.length, maxLineBytes);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const final = fs.fstatSync(fd);
|
|
187
|
+
if (inodeOf(final) !== inode ||
|
|
188
|
+
final.size !== initial.size ||
|
|
189
|
+
normalizedMtime(final) !== normalizedMtime(initial)) {
|
|
190
|
+
throw new IdentityChangedError(realPath);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
size: initial.size,
|
|
194
|
+
inode,
|
|
195
|
+
mtime_ms: normalizedMtime(initial),
|
|
196
|
+
complete_through_offset: completeThrough,
|
|
197
|
+
prefix_fp: completeThrough > 0 ? digest.digest("hex") : "",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
if (fd !== undefined)
|
|
202
|
+
fs.closeSync(fd);
|
|
203
|
+
}
|
|
204
|
+
}
|