@letta-ai/dreams 0.0.1
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 +146 -0
- package/bin/dreams.cjs +19 -0
- package/dist/auth/browser.js +43 -0
- package/dist/auth/commands.js +141 -0
- package/dist/auth/credentials.js +222 -0
- package/dist/auth/index.js +65 -0
- package/dist/auth/lock.js +92 -0
- package/dist/auth/oauth-refresh.js +68 -0
- package/dist/auth/oauth.js +193 -0
- package/dist/cli.js +149 -0
- package/dist/cloud/client.js +223 -0
- package/dist/cloud/upload.js +384 -0
- package/dist/local/blobs.js +190 -0
- package/dist/local/claude.js +315 -0
- package/dist/local/commands.js +160 -0
- package/dist/local/config.js +126 -0
- package/dist/local/db.js +265 -0
- package/dist/local/dreamignore.js +124 -0
- package/dist/local/ids.js +78 -0
- package/dist/local/leases.js +117 -0
- package/dist/local/scan.js +475 -0
- package/dist/managed-install.js +200 -0
- package/dist/onboard.js +131 -0
- package/dist/skill.js +138 -0
- package/dist/store.js +133 -0
- package/dist/version.js +48 -0
- package/package.json +32 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Short-lived local process leases.
|
|
4
|
+
*
|
|
5
|
+
* Concurrent CLI invocations (a manual `dreams source scan` racing a future
|
|
6
|
+
* hook-triggered worker) must not both mutate the queue at once. A lease is a
|
|
7
|
+
* single row keyed by name; acquisition is atomic (one transaction), and a
|
|
8
|
+
* stale lease past its expiry is reclaimable so a crashed holder never wedges
|
|
9
|
+
* the CLI forever.
|
|
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.LeaseLostError = exports.SOURCE_LEASE = void 0;
|
|
46
|
+
exports.assertLeaseHeld = assertLeaseHeld;
|
|
47
|
+
exports.renewLease = renewLease;
|
|
48
|
+
exports.acquireLease = acquireLease;
|
|
49
|
+
exports.releaseLease = releaseLease;
|
|
50
|
+
exports.withLease = withLease;
|
|
51
|
+
const crypto = __importStar(require("node:crypto"));
|
|
52
|
+
const db_1 = require("./db");
|
|
53
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
54
|
+
/**
|
|
55
|
+
* The single per-source mutation lease. Scanning, privacy cancellation, upload
|
|
56
|
+
* state transitions, acknowledged-cursor collapse, and blob GC all serialize
|
|
57
|
+
* against this one lease so they never interleave writes.
|
|
58
|
+
*/
|
|
59
|
+
exports.SOURCE_LEASE = "source";
|
|
60
|
+
/** Thrown when a write is attempted after the lease was lost/reclaimed. */
|
|
61
|
+
class LeaseLostError extends Error {
|
|
62
|
+
constructor(name) {
|
|
63
|
+
super(`lease "${name}" was lost or reclaimed by another process`);
|
|
64
|
+
this.name = "LeaseLostError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.LeaseLostError = LeaseLostError;
|
|
68
|
+
/**
|
|
69
|
+
* Fence token check: throws unless we still hold `name`. Call this INSIDE every
|
|
70
|
+
* write transaction so a process that stalled past its lease cannot commit
|
|
71
|
+
* state after another process reclaimed the lease.
|
|
72
|
+
*/
|
|
73
|
+
function assertLeaseHeld(db, name, owner) {
|
|
74
|
+
const row = db.prepare("SELECT owner, expires_at FROM leases WHERE name = ?").get(name);
|
|
75
|
+
if (!row || row.owner !== owner || row.expires_at <= Date.now())
|
|
76
|
+
throw new LeaseLostError(name);
|
|
77
|
+
}
|
|
78
|
+
/** Extend our lease; returns false (without extending) if we no longer hold it. */
|
|
79
|
+
function renewLease(db, name, owner, ttlMs = DEFAULT_TTL_MS) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
return (0, db_1.tx)(db, () => {
|
|
82
|
+
const row = db.prepare("SELECT owner, expires_at FROM leases WHERE name = ?").get(name);
|
|
83
|
+
if (!row || row.owner !== owner || row.expires_at <= now)
|
|
84
|
+
return false;
|
|
85
|
+
db.prepare("UPDATE leases SET expires_at = ? WHERE name = ? AND owner = ?").run(now + ttlMs, name, owner);
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Atomically take `name` for `ttlMs`. Returns an owner token, or null if held. */
|
|
90
|
+
function acquireLease(db, name, ttlMs = DEFAULT_TTL_MS) {
|
|
91
|
+
const owner = crypto.randomUUID();
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
return (0, db_1.tx)(db, () => {
|
|
94
|
+
const existing = db.prepare("SELECT owner, expires_at FROM leases WHERE name = ?").get(name);
|
|
95
|
+
if (existing && existing.expires_at > now)
|
|
96
|
+
return null;
|
|
97
|
+
db.prepare("INSERT INTO leases (name, owner, acquired_at, expires_at) VALUES (?, ?, ?, ?) " +
|
|
98
|
+
"ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, acquired_at = excluded.acquired_at, expires_at = excluded.expires_at").run(name, owner, now, now + ttlMs);
|
|
99
|
+
return owner;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Release only if we still hold it (a reclaimed lease must not be released by the old owner). */
|
|
103
|
+
function releaseLease(db, name, owner) {
|
|
104
|
+
db.prepare("DELETE FROM leases WHERE name = ? AND owner = ?").run(name, owner);
|
|
105
|
+
}
|
|
106
|
+
/** Run `fn` under a lease; returns null without running if the lease is held elsewhere. */
|
|
107
|
+
function withLease(db, name, fn, ttlMs = DEFAULT_TTL_MS) {
|
|
108
|
+
const owner = acquireLease(db, name, ttlMs);
|
|
109
|
+
if (owner === null)
|
|
110
|
+
return { ran: false };
|
|
111
|
+
try {
|
|
112
|
+
return { ran: true, result: fn() };
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
releaseLease(db, name, owner);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reconcile discovered Claude Code sessions into a durable local upload queue.
|
|
4
|
+
* Queue rows, exclusions, and the covered cursor form one contiguous prefix;
|
|
5
|
+
* accepted bytes remain distinct from pending bytes canceled by later policy.
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.policyFingerprint = policyFingerprint;
|
|
42
|
+
exports.chunkCompleteLines = chunkCompleteLines;
|
|
43
|
+
exports.addExcludedRange = addExcludedRange;
|
|
44
|
+
exports.isEligible = isEligible;
|
|
45
|
+
exports.reconcile = reconcile;
|
|
46
|
+
exports.listPendingQueue = listPendingQueue;
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
48
|
+
const blobs_1 = require("./blobs");
|
|
49
|
+
const claude_1 = require("./claude");
|
|
50
|
+
const config_1 = require("./config");
|
|
51
|
+
const db_1 = require("./db");
|
|
52
|
+
const dreamignore_1 = require("./dreamignore");
|
|
53
|
+
const leases_1 = require("./leases");
|
|
54
|
+
const ids_1 = require("./ids");
|
|
55
|
+
/** Raised when privacy policy tightens mid-scan; the affected session is skipped, not committed. */
|
|
56
|
+
class PolicyChangedError extends Error {
|
|
57
|
+
constructor() {
|
|
58
|
+
super("privacy policy changed during scan");
|
|
59
|
+
this.name = "PolicyChangedError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Deterministic fingerprint of the policy that governs a session's root: the full
|
|
64
|
+
* approved-root set plus that root's `.dreamignore`. Stored on each queued row so
|
|
65
|
+
* the future uploader ([07]) can refuse to send rows enqueued under stale policy.
|
|
66
|
+
*/
|
|
67
|
+
function policyFingerprint(roots, approvedRoot, ignoreText) {
|
|
68
|
+
return (0, ids_1.sha256Hex)(Buffer.from(JSON.stringify({ roots: [...roots].sort(), approvedRoot, ignore: ignoreText }), "utf8"));
|
|
69
|
+
}
|
|
70
|
+
const DEFAULT_MAX_SEGMENT_BYTES = 1024 * 1024;
|
|
71
|
+
/** Pure helper retained for unit callers; oversized lines are a hard failure. */
|
|
72
|
+
function chunkCompleteLines(tail, maxSegmentBytes) {
|
|
73
|
+
const segments = [];
|
|
74
|
+
let segmentStart = 0;
|
|
75
|
+
let segmentEnd = 0;
|
|
76
|
+
let lineStart = 0;
|
|
77
|
+
for (let i = 0; i < tail.length; i++) {
|
|
78
|
+
if (tail[i] !== 0x0a)
|
|
79
|
+
continue;
|
|
80
|
+
const lineEnd = i + 1;
|
|
81
|
+
const lineLength = lineEnd - lineStart;
|
|
82
|
+
if (lineLength > maxSegmentBytes)
|
|
83
|
+
throw new RangeError(`JSONL record is ${lineLength} bytes; maximum is ${maxSegmentBytes}`);
|
|
84
|
+
if (segmentEnd > segmentStart && lineEnd - segmentStart > maxSegmentBytes) {
|
|
85
|
+
segments.push({ relStart: segmentStart, relEnd: segmentEnd });
|
|
86
|
+
segmentStart = lineStart;
|
|
87
|
+
}
|
|
88
|
+
segmentEnd = lineEnd;
|
|
89
|
+
lineStart = lineEnd;
|
|
90
|
+
}
|
|
91
|
+
if (segmentEnd > segmentStart)
|
|
92
|
+
segments.push({ relStart: segmentStart, relEnd: segmentEnd });
|
|
93
|
+
return segments;
|
|
94
|
+
}
|
|
95
|
+
function getOrCreateSource(db, owner) {
|
|
96
|
+
const id = `src_${claude_1.CLAUDE_SOURCE_TYPE}`;
|
|
97
|
+
return (0, db_1.tx)(db, () => {
|
|
98
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
99
|
+
const existing = db.prepare("SELECT id FROM sources WHERE id = ?").get(id);
|
|
100
|
+
if (!existing) {
|
|
101
|
+
const now = (0, db_1.nowIso)();
|
|
102
|
+
db.prepare(`INSERT INTO sources (id, source_type, source_key, display_name, adapter_version, status, created_at, updated_at)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, claude_1.CLAUDE_SOURCE_TYPE, claude_1.CLAUDE_SOURCE_TYPE, "Claude Code", claude_1.CLAUDE_ADAPTER_VERSION, now, now);
|
|
104
|
+
}
|
|
105
|
+
return id;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function currentFile(db, sourceId, sessionId) {
|
|
109
|
+
return db
|
|
110
|
+
.prepare("SELECT id, generation, size, mtime_ms, inode, first_chunk_fp, prefix_fp FROM source_files WHERE source_id = ? AND session_id = ? AND is_current = 1")
|
|
111
|
+
.get(sourceId, sessionId);
|
|
112
|
+
}
|
|
113
|
+
function createFileGeneration(db, sourceId, session, generation) {
|
|
114
|
+
const now = (0, db_1.nowIso)();
|
|
115
|
+
const info = db
|
|
116
|
+
.prepare(`INSERT INTO source_files
|
|
117
|
+
(source_id, session_id, generation, path, cwd, inode, birthtime_ms, size, mtime_ms, first_chunk_fp, prefix_fp, is_current, created_at, updated_at)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?)`)
|
|
119
|
+
.run(sourceId, session.session_id, generation, session.real_path, session.cwd, session.inode, session.birthtime_ms, session.size, session.mtime_ms, session.first_chunk_fp, now, now);
|
|
120
|
+
const fileId = Number(info.lastInsertRowid);
|
|
121
|
+
db.prepare("INSERT INTO source_cursors (source_file_id, queued_through_offset, acked_offset, updated_at) VALUES (?, 0, 0, ?)").run(fileId, now);
|
|
122
|
+
return fileId;
|
|
123
|
+
}
|
|
124
|
+
function readCursor(db, fileId) {
|
|
125
|
+
return db.prepare("SELECT queued_through_offset, acked_offset FROM source_cursors WHERE source_file_id = ?").get(fileId);
|
|
126
|
+
}
|
|
127
|
+
function isReplacement(stored, cursor, session, onProgress) {
|
|
128
|
+
if (session.size < cursor.queued_through_offset)
|
|
129
|
+
return true;
|
|
130
|
+
if (stored.inode && session.inode && stored.inode !== session.inode)
|
|
131
|
+
return true;
|
|
132
|
+
const statChanged = session.size !== stored.size || session.mtime_ms !== stored.mtime_ms;
|
|
133
|
+
if (statChanged && cursor.queued_through_offset > 0 && stored.prefix_fp) {
|
|
134
|
+
if ((0, claude_1.prefixFingerprint)(session.real_path, cursor.queued_through_offset, onProgress) !== stored.prefix_fp)
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (stored.first_chunk_fp && session.first_chunk_fp && stored.first_chunk_fp !== session.first_chunk_fp)
|
|
138
|
+
return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
function updateFileMeta(db, fileId, session, verified) {
|
|
142
|
+
db.prepare(`UPDATE source_files
|
|
143
|
+
SET path = ?, cwd = ?, size = ?, mtime_ms = ?, inode = ?, first_chunk_fp = ?, prefix_fp = ?, updated_at = ?
|
|
144
|
+
WHERE id = ?`).run(session.real_path, session.cwd, verified.size, verified.mtime_ms, verified.inode, session.first_chunk_fp, verified.prefix_fp, (0, db_1.nowIso)(), fileId);
|
|
145
|
+
}
|
|
146
|
+
function addExcludedRange(db, fileId, start, end, reason) {
|
|
147
|
+
if (end <= start)
|
|
148
|
+
return;
|
|
149
|
+
const overlaps = db
|
|
150
|
+
.prepare(`SELECT id, start_offset, end_offset, reason FROM excluded_ranges
|
|
151
|
+
WHERE source_file_id = ? AND end_offset >= ? AND start_offset <= ? ORDER BY start_offset`)
|
|
152
|
+
.all(fileId, start, end);
|
|
153
|
+
let mergedStart = start;
|
|
154
|
+
let mergedEnd = end;
|
|
155
|
+
let mergedReason = reason;
|
|
156
|
+
for (const row of overlaps) {
|
|
157
|
+
mergedStart = Math.min(mergedStart, row.start_offset);
|
|
158
|
+
mergedEnd = Math.max(mergedEnd, row.end_offset);
|
|
159
|
+
if (row.reason !== reason)
|
|
160
|
+
mergedReason = "privacy";
|
|
161
|
+
db.prepare("DELETE FROM excluded_ranges WHERE id = ?").run(row.id);
|
|
162
|
+
}
|
|
163
|
+
db.prepare("INSERT INTO excluded_ranges (source_file_id, start_offset, end_offset, reason, created_at) VALUES (?, ?, ?, ?, ?)").run(fileId, mergedStart, mergedEnd, mergedReason, (0, db_1.nowIso)());
|
|
164
|
+
}
|
|
165
|
+
function isEligible(roots, cwd, rulesByRoot) {
|
|
166
|
+
const approvedRoot = cwd ? (0, config_1.isUnderApprovedRoot)(roots, cwd) : null;
|
|
167
|
+
if (approvedRoot === null || cwd === null)
|
|
168
|
+
return { eligible: false, approvedRoot: null, ignored: false };
|
|
169
|
+
let rules = rulesByRoot.get(approvedRoot);
|
|
170
|
+
if (!rules) {
|
|
171
|
+
rules = (0, dreamignore_1.loadDreamignore)(approvedRoot);
|
|
172
|
+
rulesByRoot.set(approvedRoot, rules);
|
|
173
|
+
}
|
|
174
|
+
const ignored = (0, dreamignore_1.isIgnored)(rules, path.relative(approvedRoot, cwd), true);
|
|
175
|
+
return { eligible: !ignored, approvedRoot, ignored };
|
|
176
|
+
}
|
|
177
|
+
function deleteBlobIfUnreferenced(db, contentSha, owner) {
|
|
178
|
+
// Hold the SQLite writer lock while checking and deleting so a reclaimed
|
|
179
|
+
// process cannot commit a new reference between the query and unlink.
|
|
180
|
+
(0, db_1.tx)(db, () => {
|
|
181
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
182
|
+
const row = db
|
|
183
|
+
.prepare("SELECT count(*) c FROM upload_queue WHERE content_sha256 = ? AND state IN ('pending','quarantined')")
|
|
184
|
+
.get(contentSha);
|
|
185
|
+
if (row.c === 0)
|
|
186
|
+
(0, blobs_1.deleteBlob)(contentSha);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function cleanupUnreferencedBlobs(db, hashes, owner) {
|
|
190
|
+
for (const contentSha of new Set(hashes)) {
|
|
191
|
+
try {
|
|
192
|
+
deleteBlobIfUnreferenced(db, contentSha, owner);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof leases_1.LeaseLostError)
|
|
196
|
+
return; // The next holder will sweep it.
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function cancelPendingRows(db, rows, owner) {
|
|
202
|
+
if (rows.length === 0)
|
|
203
|
+
return 0;
|
|
204
|
+
const canceledHashes = [];
|
|
205
|
+
const count = (0, db_1.tx)(db, () => {
|
|
206
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
207
|
+
let changed = 0;
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
const info = db
|
|
210
|
+
.prepare("UPDATE upload_queue SET state = 'canceled', updated_at = ? WHERE id = ? AND state = 'pending'")
|
|
211
|
+
.run((0, db_1.nowIso)(), row.id);
|
|
212
|
+
if (info.changes === 0)
|
|
213
|
+
continue;
|
|
214
|
+
addExcludedRange(db, row.source_file_id, row.start_offset, row.end_offset, "privacy");
|
|
215
|
+
canceledHashes.push(row.content_sha256);
|
|
216
|
+
changed++;
|
|
217
|
+
}
|
|
218
|
+
return changed;
|
|
219
|
+
});
|
|
220
|
+
// Filesystem deletion is intentionally after commit. A crash before this point
|
|
221
|
+
// leaves an orphan that the next lease holder removes; it never loses live data.
|
|
222
|
+
cleanupUnreferencedBlobs(db, canceledHashes, owner);
|
|
223
|
+
return count;
|
|
224
|
+
}
|
|
225
|
+
function pendingRows(db, sourceId, sessionId) {
|
|
226
|
+
const suffix = sessionId === undefined ? "" : " AND q.session_id = ?";
|
|
227
|
+
const params = sessionId === undefined ? [sourceId] : [sourceId, sessionId];
|
|
228
|
+
return db
|
|
229
|
+
.prepare(`SELECT q.id, q.source_file_id, q.session_id, q.start_offset, q.end_offset, q.content_sha256, sf.cwd
|
|
230
|
+
FROM upload_queue q JOIN source_files sf ON sf.id = q.source_file_id
|
|
231
|
+
WHERE q.source_id = ? AND q.state = 'pending'${suffix}`)
|
|
232
|
+
.all(...params);
|
|
233
|
+
}
|
|
234
|
+
function reconcilePendingPrivacy(db, sourceId, roots, rulesByRoot, owner) {
|
|
235
|
+
const disallowed = pendingRows(db, sourceId).filter((row) => !isEligible(roots, row.cwd, rulesByRoot).eligible);
|
|
236
|
+
return cancelPendingRows(db, disallowed, owner);
|
|
237
|
+
}
|
|
238
|
+
function reconcileOrphanBlobs(db, owner) {
|
|
239
|
+
return (0, db_1.tx)(db, () => {
|
|
240
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
241
|
+
const rows = db
|
|
242
|
+
.prepare("SELECT DISTINCT content_sha256 FROM upload_queue WHERE state IN ('pending','quarantined')")
|
|
243
|
+
.all();
|
|
244
|
+
// The write lock fences lease reclamation until the filesystem sweep ends.
|
|
245
|
+
return (0, blobs_1.pruneOrphanBlobs)(new Set(rows.map((row) => row.content_sha256)));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function excludedRangesAfter(db, fileId, offset) {
|
|
249
|
+
return db
|
|
250
|
+
.prepare("SELECT start_offset, end_offset FROM excluded_ranges WHERE source_file_id = ? AND end_offset > ? ORDER BY start_offset, end_offset")
|
|
251
|
+
.all(fileId, offset);
|
|
252
|
+
}
|
|
253
|
+
function coalesceRange(ranges, start, end) {
|
|
254
|
+
const last = ranges[ranges.length - 1];
|
|
255
|
+
if (last && last.end_offset >= start)
|
|
256
|
+
last.end_offset = Math.max(last.end_offset, end);
|
|
257
|
+
else
|
|
258
|
+
ranges.push({ start_offset: start, end_offset: end });
|
|
259
|
+
}
|
|
260
|
+
function isFilesystemError(error) {
|
|
261
|
+
return error instanceof Error && typeof error.code === "string";
|
|
262
|
+
}
|
|
263
|
+
/** Record a per-file failure without advancing its cursor, so one poisoned session cannot abort the scan. */
|
|
264
|
+
function recordSessionFailure(db, fileId, code, message, owner) {
|
|
265
|
+
(0, db_1.tx)(db, () => {
|
|
266
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
267
|
+
db.prepare("UPDATE source_files SET last_error_code = ?, last_error_message = ?, last_error_at = ?, updated_at = ? WHERE id = ?").run(code, message.slice(0, 500), (0, db_1.nowIso)(), (0, db_1.nowIso)(), fileId);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function reconcile(db, options) {
|
|
271
|
+
const maxSegmentBytes = options.maxSegmentBytes ?? DEFAULT_MAX_SEGMENT_BYTES;
|
|
272
|
+
if (!Number.isSafeInteger(maxSegmentBytes) || maxSegmentBytes <= 0)
|
|
273
|
+
throw new Error("maxSegmentBytes must be a positive integer");
|
|
274
|
+
const priority = options.priority ?? "live";
|
|
275
|
+
const owner = options.leaseOwner;
|
|
276
|
+
const sourceId = getOrCreateSource(db, owner);
|
|
277
|
+
const roots = (0, config_1.getApprovedRoots)(db);
|
|
278
|
+
const rulesByRoot = new Map();
|
|
279
|
+
reconcileOrphanBlobs(db, owner);
|
|
280
|
+
const objectsCanceled = reconcilePendingPrivacy(db, sourceId, roots, rulesByRoot, owner);
|
|
281
|
+
const sessions = (0, claude_1.discoverSessions)(options.projectsDir);
|
|
282
|
+
const summary = {
|
|
283
|
+
source_id: sourceId,
|
|
284
|
+
sessions_discovered: sessions.length,
|
|
285
|
+
sessions_queued: 0,
|
|
286
|
+
sessions_excluded: 0,
|
|
287
|
+
sessions_skipped_unapproved: 0,
|
|
288
|
+
sessions_failed: 0,
|
|
289
|
+
generations_reset: 0,
|
|
290
|
+
objects_canceled: objectsCanceled,
|
|
291
|
+
segments_enqueued: 0,
|
|
292
|
+
bytes_enqueued: 0,
|
|
293
|
+
queued: [],
|
|
294
|
+
};
|
|
295
|
+
for (const session of sessions) {
|
|
296
|
+
if (!(0, leases_1.renewLease)(db, leases_1.SOURCE_LEASE, owner))
|
|
297
|
+
throw new leases_1.LeaseLostError(leases_1.SOURCE_LEASE);
|
|
298
|
+
let lastRenewedAt = Date.now();
|
|
299
|
+
const keepLeaseAlive = () => {
|
|
300
|
+
if (Date.now() - lastRenewedAt < 5_000)
|
|
301
|
+
return;
|
|
302
|
+
if (!(0, leases_1.renewLease)(db, leases_1.SOURCE_LEASE, owner))
|
|
303
|
+
throw new leases_1.LeaseLostError(leases_1.SOURCE_LEASE);
|
|
304
|
+
lastRenewedAt = Date.now();
|
|
305
|
+
};
|
|
306
|
+
let fileId;
|
|
307
|
+
let generation;
|
|
308
|
+
const stored = currentFile(db, sourceId, session.session_id);
|
|
309
|
+
if (!stored) {
|
|
310
|
+
({ fileId, generation } = (0, db_1.tx)(db, () => {
|
|
311
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
312
|
+
return { fileId: createFileGeneration(db, sourceId, session, 1), generation: 1 };
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const cursor = readCursor(db, stored.id);
|
|
317
|
+
if (isReplacement(stored, cursor, session, keepLeaseAlive)) {
|
|
318
|
+
({ fileId, generation } = (0, db_1.tx)(db, () => {
|
|
319
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
320
|
+
db.prepare("UPDATE source_files SET is_current = 0, updated_at = ? WHERE id = ?").run((0, db_1.nowIso)(), stored.id);
|
|
321
|
+
return {
|
|
322
|
+
fileId: createFileGeneration(db, sourceId, session, stored.generation + 1),
|
|
323
|
+
generation: stored.generation + 1,
|
|
324
|
+
};
|
|
325
|
+
}));
|
|
326
|
+
summary.generations_reset++;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
fileId = stored.id;
|
|
330
|
+
generation = stored.generation;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const policy = isEligible(roots, session.cwd, rulesByRoot);
|
|
334
|
+
// Pre-existing pending rows that are no longer eligible were already canceled
|
|
335
|
+
// by reconcilePendingPrivacy using each row's OWN persisted cwd. Do NOT cancel
|
|
336
|
+
// by session id here: a still-eligible older generation must not be dropped
|
|
337
|
+
// because a newer generation's cwd is disallowed.
|
|
338
|
+
if (policy.approvedRoot === null) {
|
|
339
|
+
summary.sessions_skipped_unapproved++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const prepared = [];
|
|
343
|
+
try {
|
|
344
|
+
const cursor = readCursor(db, fileId);
|
|
345
|
+
const scanStart = cursor.queued_through_offset;
|
|
346
|
+
const explicitExclusions = excludedRangesAfter(db, fileId, scanStart);
|
|
347
|
+
let exclusionIndex = 0;
|
|
348
|
+
const excludedLines = [];
|
|
349
|
+
let segmentLines = [];
|
|
350
|
+
let segmentLength = 0;
|
|
351
|
+
let segmentStart = 0;
|
|
352
|
+
let segmentEnd = 0;
|
|
353
|
+
const flushSegment = () => {
|
|
354
|
+
if (segmentLength === 0)
|
|
355
|
+
return;
|
|
356
|
+
const bytes = Buffer.concat(segmentLines, segmentLength);
|
|
357
|
+
const contentSha = (0, blobs_1.writeBlob)(bytes);
|
|
358
|
+
prepared.push({
|
|
359
|
+
upload_id: (0, ids_1.segmentId)({
|
|
360
|
+
installation_id: options.installationId,
|
|
361
|
+
source_key: claude_1.CLAUDE_SOURCE_TYPE,
|
|
362
|
+
file_identity: session.session_id,
|
|
363
|
+
generation,
|
|
364
|
+
start_offset: segmentStart,
|
|
365
|
+
end_offset: segmentEnd,
|
|
366
|
+
}),
|
|
367
|
+
source_file_id: fileId,
|
|
368
|
+
session_id: session.session_id,
|
|
369
|
+
generation,
|
|
370
|
+
start_offset: segmentStart,
|
|
371
|
+
end_offset: segmentEnd,
|
|
372
|
+
byte_length: segmentLength,
|
|
373
|
+
content_sha256: contentSha,
|
|
374
|
+
});
|
|
375
|
+
segmentLines = [];
|
|
376
|
+
segmentLength = 0;
|
|
377
|
+
};
|
|
378
|
+
const verified = (0, claude_1.scanCompleteLinesVerified)(session.real_path, scanStart, session.inode, maxSegmentBytes, (line) => {
|
|
379
|
+
while (exclusionIndex < explicitExclusions.length && explicitExclusions[exclusionIndex].end_offset <= line.start_offset) {
|
|
380
|
+
exclusionIndex++;
|
|
381
|
+
}
|
|
382
|
+
const explicit = exclusionIndex < explicitExclusions.length &&
|
|
383
|
+
explicitExclusions[exclusionIndex].start_offset < line.end_offset &&
|
|
384
|
+
explicitExclusions[exclusionIndex].end_offset > line.start_offset;
|
|
385
|
+
if (policy.ignored || explicit) {
|
|
386
|
+
flushSegment();
|
|
387
|
+
coalesceRange(excludedLines, line.start_offset, line.end_offset);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (segmentLength > 0 && segmentLength + line.bytes.length > maxSegmentBytes)
|
|
391
|
+
flushSegment();
|
|
392
|
+
if (segmentLength === 0)
|
|
393
|
+
segmentStart = line.start_offset;
|
|
394
|
+
segmentLines.push(line.bytes);
|
|
395
|
+
segmentLength += line.bytes.length;
|
|
396
|
+
segmentEnd = line.end_offset;
|
|
397
|
+
}, keepLeaseAlive);
|
|
398
|
+
flushSegment();
|
|
399
|
+
const newOffset = verified.complete_through_offset;
|
|
400
|
+
if (newOffset === scanStart) {
|
|
401
|
+
(0, db_1.tx)(db, () => {
|
|
402
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
403
|
+
updateFileMeta(db, fileId, session, verified);
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const enqueued = [];
|
|
408
|
+
(0, db_1.tx)(db, () => {
|
|
409
|
+
(0, leases_1.assertLeaseHeld)(db, leases_1.SOURCE_LEASE, owner);
|
|
410
|
+
// Re-read policy at the durable commit boundary. Approved roots are
|
|
411
|
+
// transactional (kv); .dreamignore is reloaded from disk. If a tightening
|
|
412
|
+
// raced this scan, abort rather than commit now-disallowed pending rows;
|
|
413
|
+
// the fingerprint stored on each row lets the uploader ([07]) re-gate.
|
|
414
|
+
let policyFp = null;
|
|
415
|
+
if (prepared.length > 0) {
|
|
416
|
+
const freshRoots = (0, config_1.getApprovedRoots)(db);
|
|
417
|
+
const fresh = isEligible(freshRoots, session.cwd, new Map());
|
|
418
|
+
if (!fresh.eligible || fresh.approvedRoot === null)
|
|
419
|
+
throw new PolicyChangedError();
|
|
420
|
+
policyFp = policyFingerprint(freshRoots, fresh.approvedRoot, (0, dreamignore_1.loadDreamignoreText)(fresh.approvedRoot));
|
|
421
|
+
}
|
|
422
|
+
for (const range of excludedLines)
|
|
423
|
+
addExcludedRange(db, fileId, range.start_offset, range.end_offset, policy.ignored ? "dreamignore" : "privacy");
|
|
424
|
+
for (const entry of prepared) {
|
|
425
|
+
const now = (0, db_1.nowIso)();
|
|
426
|
+
const info = db
|
|
427
|
+
.prepare(`INSERT OR IGNORE INTO upload_queue
|
|
428
|
+
(upload_id, source_id, source_file_id, session_id, generation, start_offset, end_offset, byte_length, content_sha256, priority, state, policy_fingerprint, created_at, updated_at)
|
|
429
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`)
|
|
430
|
+
.run(entry.upload_id, sourceId, fileId, session.session_id, generation, entry.start_offset, entry.end_offset, entry.byte_length, entry.content_sha256, priority, policyFp, now, now);
|
|
431
|
+
if (info.changes > 0) {
|
|
432
|
+
const { source_file_id: _sourceFileId, ...queued } = entry;
|
|
433
|
+
enqueued.push(queued);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
db.prepare("UPDATE source_cursors SET queued_through_offset = ?, updated_at = ? WHERE source_file_id = ?").run(newOffset, (0, db_1.nowIso)(), fileId);
|
|
437
|
+
updateFileMeta(db, fileId, session, verified);
|
|
438
|
+
});
|
|
439
|
+
cleanupUnreferencedBlobs(db, prepared.map((entry) => entry.content_sha256), owner);
|
|
440
|
+
if (excludedLines.length > 0)
|
|
441
|
+
summary.sessions_excluded++;
|
|
442
|
+
if (enqueued.length > 0) {
|
|
443
|
+
summary.sessions_queued++;
|
|
444
|
+
summary.segments_enqueued += enqueued.length;
|
|
445
|
+
for (const segment of enqueued) {
|
|
446
|
+
summary.bytes_enqueued += segment.byte_length;
|
|
447
|
+
summary.queued.push(segment);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
// Blobs published before an aborted commit become orphans; remove the ones
|
|
453
|
+
// no committed row references (safe even on partial failure).
|
|
454
|
+
cleanupUnreferencedBlobs(db, prepared.map((entry) => entry.content_sha256), owner);
|
|
455
|
+
if (error instanceof leases_1.LeaseLostError)
|
|
456
|
+
throw error;
|
|
457
|
+
if (error instanceof claude_1.IdentityChangedError || error instanceof PolicyChangedError)
|
|
458
|
+
continue;
|
|
459
|
+
if (error instanceof claude_1.OversizedLineError || isFilesystemError(error)) {
|
|
460
|
+
const code = error instanceof claude_1.OversizedLineError ? "oversized_record" : error.code ?? "fs_error";
|
|
461
|
+
recordSessionFailure(db, fileId, code, error.message, owner);
|
|
462
|
+
summary.sessions_failed++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return summary;
|
|
469
|
+
}
|
|
470
|
+
function listPendingQueue(db) {
|
|
471
|
+
return db
|
|
472
|
+
.prepare(`SELECT upload_id, source_id, session_id, generation, start_offset, end_offset, byte_length, content_sha256, priority, state
|
|
473
|
+
FROM upload_queue WHERE state = 'pending' ORDER BY session_id, generation, start_offset`)
|
|
474
|
+
.all();
|
|
475
|
+
}
|