@hyperdrive.bot/fleet-server 0.3.164 → 0.3.165
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/server/extensions/daemon-backend.js +25 -0
- package/dist/server/server/agent/agent-storage.d.ts +67 -12
- package/dist/server/server/agent/agent-storage.js +208 -132
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
- package/dist/server/server/bootstrap.d.ts +5 -0
- package/dist/server/server/bootstrap.js +26 -3
- package/dist/server/server/chat/chat-service.d.ts +22 -0
- package/dist/server/server/chat/chat-service.js +46 -3
- package/dist/server/server/daemon-worker.js +1 -0
- package/dist/server/server/fleet/decision-service.d.ts +5 -0
- package/dist/server/server/fleet/decision-service.js +30 -0
- package/dist/server/server/loop-service.d.ts +95 -0
- package/dist/server/server/loop-service.js +64 -3
- package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
- package/dist/server/server/push/token-store.d.ts +5 -1
- package/dist/server/server/push/token-store.js +26 -5
- package/dist/server/server/schedule/service.js +4 -1
- package/dist/server/server/schedule/store.d.ts +21 -1
- package/dist/server/server/schedule/store.js +283 -2
- package/dist/server/server/search/indexer.d.ts +10 -0
- package/dist/server/server/search/indexer.js +51 -2
- package/dist/server/server/session.js +12 -13
- package/dist/server/server/state/agent-state.d.ts +39 -0
- package/dist/server/server/state/agent-state.js +94 -0
- package/dist/server/server/state/daemon-state.d.ts +30 -0
- package/dist/server/server/state/daemon-state.js +99 -0
- package/dist/server/server/state/legacy-import.d.ts +124 -0
- package/dist/server/server/state/legacy-import.js +682 -0
- package/dist/server/server/state/legacy-manifest.d.ts +73 -0
- package/dist/server/server/state/legacy-manifest.js +112 -0
- package/dist/server/server/state/legacy-mirror.d.ts +57 -0
- package/dist/server/server/state/legacy-mirror.js +114 -0
- package/dist/server/server/state/legacy-sources.d.ts +9 -0
- package/dist/server/server/state/legacy-sources.js +163 -0
- package/dist/server/server/state/state-db.d.ts +263 -0
- package/dist/server/server/state/state-db.js +772 -0
- package/dist/server/server/state/state-schema.d.ts +39 -0
- package/dist/server/server/state/state-schema.js +125 -0
- package/dist/server/server/state/state-worker.d.ts +48 -0
- package/dist/server/server/state/state-worker.js +165 -0
- package/dist/server/server/websocket-server.js +2 -1
- package/dist/server/server/workspace/session-openable.js +7 -0
- package/dist/server/server/workspace-reconciliation-service.js +14 -3
- package/dist/server/server/workspace-registry.d.ts +45 -2
- package/dist/server/server/workspace-registry.js +75 -4
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-38580ed8926a5ddc569d744b8299d339.js} +4 -4
- package/dist/server/web-ui/_expo/static/js/web/index-38580ed8926a5ddc569d744b8299d339.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.gz → index-38580ed8926a5ddc569d744b8299d339.js.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br → index-38580ed8926a5ddc569d744b8299d339.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-38580ed8926a5ddc569d744b8299d339.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listAgentRecordFiles, parseStoredAgentRecord, } from "../agent/agent-storage.js";
|
|
4
|
+
import { TOMBSTONED_TABLES } from "./state-schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* First-boot copy of the legacy JSON stores into `state.sqlite`.
|
|
7
|
+
*
|
|
8
|
+
* - COPY, never move: no legacy file is deleted or renamed, so a rollback to an
|
|
9
|
+
* older daemon still finds everything (and the mirror keeps it current).
|
|
10
|
+
* - Batched and yielding: each batch is one transaction and then a
|
|
11
|
+
* `setImmediate`, so the event loop is never held for long.
|
|
12
|
+
* - Resumable: each store keeps a marker whose cursor commits in the same
|
|
13
|
+
* transaction as the rows it covers. A crash mid-import resumes after the
|
|
14
|
+
* last committed batch; re-running a finished store is a no-op.
|
|
15
|
+
* - Re-sync by content, not by clock: every file read here or written by the
|
|
16
|
+
* mirror has a row in the legacy manifest (`legacy-manifest.ts`). At the next
|
|
17
|
+
* boot a file is foreign only when its content no longer matches that row; a
|
|
18
|
+
* foreign file is reconciled row by row (the file wins, unless the database
|
|
19
|
+
* row changed after the file was written), and a file that is gone deletes
|
|
20
|
+
* what it held. Files this daemon wrote and nobody touched are never read.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Files per import batch. Parsing and validating 50 agent records is about 5ms
|
|
24
|
+
* of main-thread work; the commit itself runs on the state worker.
|
|
25
|
+
*/
|
|
26
|
+
export const AGENT_IMPORT_BATCH = 50;
|
|
27
|
+
/**
|
|
28
|
+
* Runs one batch and records how long it held the event loop: the synchronous
|
|
29
|
+
* part of `fn` (parsing, building statements, and the commit itself when there
|
|
30
|
+
* is no worker). A commit on the worker is awaited but not counted, because the
|
|
31
|
+
* loop is free while it runs.
|
|
32
|
+
*/
|
|
33
|
+
export class BatchClock {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.maxBatchMs = 0;
|
|
36
|
+
/** Every batch's loop-holding time, for percentiles in the boot log. */
|
|
37
|
+
this.batchMs = [];
|
|
38
|
+
}
|
|
39
|
+
async batch(fn) {
|
|
40
|
+
const started = performance.now();
|
|
41
|
+
const pending = fn();
|
|
42
|
+
const held = performance.now() - started;
|
|
43
|
+
this.batchMs.push(held);
|
|
44
|
+
this.maxBatchMs = Math.max(this.maxBatchMs, held);
|
|
45
|
+
const result = await pending;
|
|
46
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeDocRows(state, rows) {
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
state.doc(row.table).putNow(row.scope, row.id, JSON.stringify(row.value), row.ord ?? 0);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function docRowStatements(state, rows) {
|
|
56
|
+
return rows.map((row) => state.doc(row.table).putStatement(row.scope, row.id, JSON.stringify(row.value), row.ord ?? 0));
|
|
57
|
+
}
|
|
58
|
+
async function mapLimit(items, limit, fn) {
|
|
59
|
+
const out = [];
|
|
60
|
+
let next = 0;
|
|
61
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
62
|
+
while (next < items.length) {
|
|
63
|
+
const index = next++;
|
|
64
|
+
out[index] = await fn(items[index]);
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
async function exists(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
await fs.stat(filePath);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const rowKey = (table, scope, id) => `${table}\u0000${scope}\u0000${id}`;
|
|
79
|
+
/**
|
|
80
|
+
* Reconcile the rows one foreign file describes, inside the caller's
|
|
81
|
+
* transaction. `fileRows` null means the file was deleted. For each row: when
|
|
82
|
+
* the database row's rev is above `mirroredRev` it changed after the file was
|
|
83
|
+
* written, so it wins (counted as a conflict when the two disagree); otherwise
|
|
84
|
+
* the file's content replaces it. Rows the file no longer has are removed
|
|
85
|
+
* (tombstoned where the table keeps tombstones) under the same rule.
|
|
86
|
+
*/
|
|
87
|
+
function ownedRows(state, owned, tombstoned) {
|
|
88
|
+
const existing = new Map();
|
|
89
|
+
for (const pattern of owned) {
|
|
90
|
+
const table = state.doc(pattern.table, { tombstones: tombstoned?.has(pattern.table) ?? false });
|
|
91
|
+
for (const row of table.storedRows({ scope: pattern.scope, id: pattern.id })) {
|
|
92
|
+
existing.set(rowKey(pattern.table, row.scope, row.id), { table: pattern.table, row });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return existing;
|
|
96
|
+
}
|
|
97
|
+
function reconcileRows(state, owned, fileRows, mirroredRev, options = {}) {
|
|
98
|
+
const outcome = { applied: 0, removed: 0, conflicts: 0 };
|
|
99
|
+
const existing = ownedRows(state, owned, options.tombstoned);
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const fileRow of fileRows ?? []) {
|
|
102
|
+
const key = rowKey(fileRow.table, fileRow.scope, fileRow.id);
|
|
103
|
+
seen.add(key);
|
|
104
|
+
const body = JSON.stringify(fileRow.value);
|
|
105
|
+
const current = existing.get(key)?.row;
|
|
106
|
+
if (current && current.deletedAt === null && current.body === body)
|
|
107
|
+
continue;
|
|
108
|
+
if (current && current.rev > mirroredRev) {
|
|
109
|
+
outcome.conflicts += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
state.doc(fileRow.table).putNow(fileRow.scope, fileRow.id, body, fileRow.ord ?? 0);
|
|
113
|
+
outcome.applied += 1;
|
|
114
|
+
}
|
|
115
|
+
const deleted = fileRows === null;
|
|
116
|
+
for (const [key, { table, row }] of existing) {
|
|
117
|
+
if (seen.has(key) || row.deletedAt !== null)
|
|
118
|
+
continue;
|
|
119
|
+
if (row.rev > mirroredRev && !(deleted && options.deleteWins)) {
|
|
120
|
+
outcome.conflicts += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
state.doc(table).removeNow(row.scope, row.id);
|
|
124
|
+
outcome.removed += 1;
|
|
125
|
+
}
|
|
126
|
+
return outcome;
|
|
127
|
+
}
|
|
128
|
+
function logConflicts(logger, store, filePath, conflicts) {
|
|
129
|
+
if (conflicts > 0) {
|
|
130
|
+
logger.info({ store, filePath, conflicts }, "Legacy file changed by someone else; rows the daemon changed after writing it keep the daemon's version");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function importWholeFile(state, source, clock, logger) {
|
|
134
|
+
const started = performance.now();
|
|
135
|
+
const marker = state.getImportMarker(source.store);
|
|
136
|
+
const resync = marker?.status === "done";
|
|
137
|
+
const file = await state.manifest.classify(source.filePath);
|
|
138
|
+
if (resync && (file.status === "ours" || file.status === "absent")) {
|
|
139
|
+
return { store: source.store, imported: 0, skipped: 0, ms: 0, mode: "skip" };
|
|
140
|
+
}
|
|
141
|
+
let rows = null;
|
|
142
|
+
let skipped = 0;
|
|
143
|
+
if (file.status === "changed" || file.status === "created") {
|
|
144
|
+
try {
|
|
145
|
+
rows = source.toRows(JSON.parse(file.content));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
skipped = 1;
|
|
149
|
+
logger.warn({ err: error, filePath: source.filePath }, "Legacy store unreadable; not imported");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (file.status === "ours") {
|
|
153
|
+
// First import of a file the manifest already knows: read it plainly.
|
|
154
|
+
try {
|
|
155
|
+
rows = source.toRows(JSON.parse(await fs.readFile(source.filePath, "utf8")));
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
skipped = 1;
|
|
159
|
+
logger.warn({ err: error, filePath: source.filePath }, "Legacy store unreadable; not imported");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (resync && skipped > 0) {
|
|
163
|
+
if (file.status === "changed" || file.status === "created") {
|
|
164
|
+
const seen = state.manifest.statement(source.store, source.filePath, file.content, file.stat, file.entry?.rev ?? 0);
|
|
165
|
+
state.transaction(() => state.manifest.runNow(seen));
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
store: source.store,
|
|
169
|
+
imported: 0,
|
|
170
|
+
skipped,
|
|
171
|
+
ms: performance.now() - started,
|
|
172
|
+
mode: "resync",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
let imported = 0;
|
|
176
|
+
await clock.batch(() => state.transaction(() => {
|
|
177
|
+
if (resync) {
|
|
178
|
+
const outcome = reconcileRows(state, source.tables.map((table) => ({ table })), rows, file.status === "deleted" || file.status === "changed" ? (file.entry?.rev ?? 0) : 0, { tombstoned: TOMBSTONED_TABLES });
|
|
179
|
+
imported = outcome.applied + outcome.removed;
|
|
180
|
+
logConflicts(logger, source.store, source.filePath, outcome.conflicts);
|
|
181
|
+
if (file.status === "deleted") {
|
|
182
|
+
state.manifest.runNow(state.manifest.forgetStatement(source.filePath));
|
|
183
|
+
}
|
|
184
|
+
else if (outcome.conflicts === 0 &&
|
|
185
|
+
(file.status === "changed" || file.status === "created")) {
|
|
186
|
+
state.manifest.runNow(state.manifest.statement(source.store, source.filePath, file.content, file.stat, state.currentRev));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
writeDocRows(state, rows ?? []);
|
|
191
|
+
imported = rows?.length ?? 0;
|
|
192
|
+
if (file.status === "changed" || file.status === "created") {
|
|
193
|
+
state.manifest.runNow(state.manifest.statement(source.store, source.filePath, file.content, file.stat, state.currentRev));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
state.putImportMarker(source.store, { status: "done", cursor: null, imported, skipped });
|
|
197
|
+
}));
|
|
198
|
+
return {
|
|
199
|
+
store: source.store,
|
|
200
|
+
imported,
|
|
201
|
+
skipped,
|
|
202
|
+
ms: performance.now() - started,
|
|
203
|
+
mode: resync ? "resync" : "import",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Parse one batch of a directory store on its first import. */
|
|
207
|
+
async function readDirectoryBatch(state, source, names, logger) {
|
|
208
|
+
const rows = [];
|
|
209
|
+
const files = [];
|
|
210
|
+
let imported = 0;
|
|
211
|
+
let skipped = 0;
|
|
212
|
+
for (const name of names) {
|
|
213
|
+
const filePath = path.join(source.dir, name);
|
|
214
|
+
try {
|
|
215
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
216
|
+
const stat = await fs.stat(filePath);
|
|
217
|
+
rows.push(...source.toRows(JSON.parse(content), name));
|
|
218
|
+
files.push({ name, content, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
219
|
+
imported += 1;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
skipped += 1;
|
|
223
|
+
logger.warn({ err: error, filePath }, "Legacy record unreadable; not imported");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
void state;
|
|
227
|
+
return { rows, files, imported, skipped };
|
|
228
|
+
}
|
|
229
|
+
async function listJson(dir) {
|
|
230
|
+
try {
|
|
231
|
+
return (await fs.readdir(dir))
|
|
232
|
+
.filter((name) => name.endsWith(".json") && !name.startsWith("."))
|
|
233
|
+
.sort();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function importDirectory(state, source, clock, logger) {
|
|
240
|
+
const started = performance.now();
|
|
241
|
+
const marker = state.getImportMarker(source.store);
|
|
242
|
+
if (marker?.status === "done") {
|
|
243
|
+
return resyncDirectory(state, source, clock, logger, started);
|
|
244
|
+
}
|
|
245
|
+
const names = (await listJson(source.dir)) ?? [];
|
|
246
|
+
const cursor = marker?.cursor ?? null;
|
|
247
|
+
let imported = marker?.imported ?? 0;
|
|
248
|
+
let skipped = marker?.skipped ?? 0;
|
|
249
|
+
const todo = cursor === null ? names : names.filter((name) => name > cursor);
|
|
250
|
+
for (let index = 0; index < todo.length; index += AGENT_IMPORT_BATCH) {
|
|
251
|
+
const batch = todo.slice(index, index + AGENT_IMPORT_BATCH);
|
|
252
|
+
const read = await readDirectoryBatch(state, source, batch, logger);
|
|
253
|
+
imported += read.imported;
|
|
254
|
+
skipped += read.skipped;
|
|
255
|
+
const last = batch[batch.length - 1];
|
|
256
|
+
await clock.batch(() => {
|
|
257
|
+
const statements = docRowStatements(state, read.rows);
|
|
258
|
+
const rev = state.currentRev;
|
|
259
|
+
return state.commitBatch([
|
|
260
|
+
...statements,
|
|
261
|
+
...read.files.map((file) => state.manifest.statement(source.store, path.join(source.dir, file.name), file.content, file, rev)),
|
|
262
|
+
state.importMarkerStatement(source.store, {
|
|
263
|
+
status: "running",
|
|
264
|
+
cursor: last,
|
|
265
|
+
imported,
|
|
266
|
+
skipped,
|
|
267
|
+
}),
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
state.transaction(() => state.putImportMarker(source.store, { status: "done", cursor: null, imported, skipped }));
|
|
272
|
+
return {
|
|
273
|
+
store: source.store,
|
|
274
|
+
imported,
|
|
275
|
+
skipped,
|
|
276
|
+
ms: performance.now() - started,
|
|
277
|
+
mode: "import",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Directory store after the first import: classify every file on disk and
|
|
282
|
+
* every manifest entry, reconcile the foreign ones. A missing directory is
|
|
283
|
+
* treated as "nothing to say" rather than "everything deleted".
|
|
284
|
+
*/
|
|
285
|
+
async function resyncDirectory(state, source, clock, logger, started) {
|
|
286
|
+
const names = await listJson(source.dir);
|
|
287
|
+
const entries = state.manifest.entries(source.store);
|
|
288
|
+
const paths = new Set((names ?? []).map((name) => path.join(source.dir, name)));
|
|
289
|
+
if (names !== null) {
|
|
290
|
+
for (const entryPath of entries.keys())
|
|
291
|
+
paths.add(entryPath);
|
|
292
|
+
}
|
|
293
|
+
const states = await mapLimit([...paths].sort(), 32, async (filePath) => ({
|
|
294
|
+
filePath,
|
|
295
|
+
state: await state.manifest.classify(filePath, entries.get(filePath) ?? null),
|
|
296
|
+
}));
|
|
297
|
+
let imported = 0;
|
|
298
|
+
let skipped = 0;
|
|
299
|
+
const foreign = states.filter((item) => item.state.status !== "ours" && item.state.status !== "absent");
|
|
300
|
+
// Catch-up: a file we wrote whose rows the database has since deleted (the
|
|
301
|
+
// process died before the mirror's unlink ran). Remove it, so a rollback
|
|
302
|
+
// does not bring the deleted entry back.
|
|
303
|
+
if (state.mirror.enabled) {
|
|
304
|
+
for (const { filePath, state: file } of states) {
|
|
305
|
+
if (file.status !== "ours")
|
|
306
|
+
continue;
|
|
307
|
+
const owned = ownedRows(state, source.owns(path.basename(filePath)), undefined);
|
|
308
|
+
const live = [...owned.values()].some(({ row }) => row.deletedAt === null);
|
|
309
|
+
if (!live) {
|
|
310
|
+
await fs.rm(filePath, { force: true });
|
|
311
|
+
await state.manifest.forget(filePath);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (let index = 0; index < foreign.length; index += AGENT_IMPORT_BATCH) {
|
|
316
|
+
const batch = foreign.slice(index, index + AGENT_IMPORT_BATCH);
|
|
317
|
+
await clock.batch(() => state.transaction(() => {
|
|
318
|
+
for (const { filePath, state: file } of batch) {
|
|
319
|
+
const name = path.basename(filePath);
|
|
320
|
+
let rows = null;
|
|
321
|
+
if (file.status === "changed" || file.status === "created") {
|
|
322
|
+
try {
|
|
323
|
+
rows = source.toRows(JSON.parse(file.content), name);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
skipped += 1;
|
|
327
|
+
logger.warn({ err: error, filePath }, "Legacy record unreadable; not imported");
|
|
328
|
+
state.manifest.runNow(state.manifest.statement(source.store, filePath, file.content, file.stat, file.entry?.rev ?? 0));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const mirroredRev = "entry" in file ? (file.entry?.rev ?? 0) : 0;
|
|
333
|
+
const outcome = reconcileRows(state, source.owns(name), rows, mirroredRev, {
|
|
334
|
+
deleteWins: source.deleteWins,
|
|
335
|
+
});
|
|
336
|
+
imported += outcome.applied + outcome.removed > 0 ? 1 : 0;
|
|
337
|
+
logConflicts(logger, source.store, filePath, outcome.conflicts);
|
|
338
|
+
if (file.status === "deleted") {
|
|
339
|
+
state.manifest.runNow(state.manifest.forgetStatement(filePath));
|
|
340
|
+
}
|
|
341
|
+
else if (outcome.conflicts === 0 &&
|
|
342
|
+
(file.status === "changed" || file.status === "created")) {
|
|
343
|
+
state.manifest.runNow(state.manifest.statement(source.store, filePath, file.content, file.stat, state.currentRev));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
store: source.store,
|
|
350
|
+
imported,
|
|
351
|
+
skipped,
|
|
352
|
+
ms: performance.now() - started,
|
|
353
|
+
mode: foreign.length > 0 ? "resync" : "skip",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Validate one batch of a first import and build its upserts; newer
|
|
358
|
+
* `updatedAt` wins within the batch and against what an earlier batch stored
|
|
359
|
+
* (a cwd move that left the old file behind). Every file read is recorded in
|
|
360
|
+
* the manifest.
|
|
361
|
+
*/
|
|
362
|
+
function agentBatchStatements(state, table, contents, logger) {
|
|
363
|
+
let imported = 0;
|
|
364
|
+
let skipped = 0;
|
|
365
|
+
const chosen = new Map();
|
|
366
|
+
const read = [];
|
|
367
|
+
for (const item of contents) {
|
|
368
|
+
let record;
|
|
369
|
+
try {
|
|
370
|
+
if (item.content === undefined)
|
|
371
|
+
throw item.error;
|
|
372
|
+
record = parseStoredAgentRecord(JSON.parse(item.content));
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
skipped += 1;
|
|
376
|
+
logger.warn({ err: error, filePath: item.filePath }, "Skipping invalid agent record");
|
|
377
|
+
// Remembered as seen, so it is only read again once someone changes it.
|
|
378
|
+
if (item.content !== undefined) {
|
|
379
|
+
read.push({
|
|
380
|
+
filePath: item.filePath,
|
|
381
|
+
content: item.content,
|
|
382
|
+
size: item.size,
|
|
383
|
+
mtimeMs: item.mtimeMs,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
read.push({
|
|
389
|
+
filePath: item.filePath,
|
|
390
|
+
content: item.content,
|
|
391
|
+
size: item.size,
|
|
392
|
+
mtimeMs: item.mtimeMs,
|
|
393
|
+
});
|
|
394
|
+
const inBatch = chosen.get(record.id);
|
|
395
|
+
const stored = inBatch ? null : table.getInTransaction("", record.id);
|
|
396
|
+
const current = inBatch ?? (stored === null ? null : JSON.parse(stored));
|
|
397
|
+
if (current && current.updatedAt >= record.updatedAt) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
chosen.set(record.id, record);
|
|
401
|
+
imported += 1;
|
|
402
|
+
}
|
|
403
|
+
const statements = [...chosen.values()].map((record) => table.putStatement("", record.id, JSON.stringify(record)));
|
|
404
|
+
const rev = state.currentRev;
|
|
405
|
+
for (const file of read) {
|
|
406
|
+
statements.push(state.manifest.statement("agents", file.filePath, file.content, file, rev));
|
|
407
|
+
}
|
|
408
|
+
return { statements, imported, skipped };
|
|
409
|
+
}
|
|
410
|
+
function agentIdOf(filePath) {
|
|
411
|
+
return path.basename(filePath, ".json");
|
|
412
|
+
}
|
|
413
|
+
/** Classify every agent file and manifest entry, grouped by agent id. */
|
|
414
|
+
async function classifyAgentFiles(state, baseDir, logger) {
|
|
415
|
+
const entries = state.manifest.entries("agents");
|
|
416
|
+
const dirPresent = await exists(baseDir);
|
|
417
|
+
const paths = new Set(dirPresent ? await listAgentRecordFiles(baseDir) : []);
|
|
418
|
+
if (dirPresent) {
|
|
419
|
+
for (const entryPath of entries.keys())
|
|
420
|
+
paths.add(entryPath);
|
|
421
|
+
}
|
|
422
|
+
else if (entries.size > 0) {
|
|
423
|
+
logger.warn({ baseDir }, "Agent directory missing; not treating its files as deleted");
|
|
424
|
+
}
|
|
425
|
+
const classified = await mapLimit([...paths], 64, async (filePath) => ({
|
|
426
|
+
filePath,
|
|
427
|
+
file: await state.manifest.classify(filePath, entries.get(filePath) ?? null),
|
|
428
|
+
}));
|
|
429
|
+
const byId = new Map();
|
|
430
|
+
const invalid = [];
|
|
431
|
+
let skipped = 0;
|
|
432
|
+
for (const { filePath, file } of classified) {
|
|
433
|
+
const id = agentIdOf(filePath);
|
|
434
|
+
const current = byId.get(id) ?? { foreign: [], deleted: [], live: 0, mirroredRev: 0 };
|
|
435
|
+
byId.set(id, current);
|
|
436
|
+
const entry = "entry" in file ? file.entry : null;
|
|
437
|
+
if (entry)
|
|
438
|
+
current.mirroredRev = Math.max(current.mirroredRev, entry.rev);
|
|
439
|
+
if (file.status === "ours")
|
|
440
|
+
current.live += 1;
|
|
441
|
+
if (file.status === "deleted")
|
|
442
|
+
current.deleted.push(filePath);
|
|
443
|
+
if (file.status !== "changed" && file.status !== "created")
|
|
444
|
+
continue;
|
|
445
|
+
try {
|
|
446
|
+
const record = parseStoredAgentRecord(JSON.parse(file.content));
|
|
447
|
+
current.foreign.push({ filePath, record, file });
|
|
448
|
+
current.live += 1;
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
skipped += 1;
|
|
452
|
+
logger.warn({ err: error, filePath }, "Skipping invalid agent record");
|
|
453
|
+
invalid.push(state.manifest.statement("agents", filePath, file.content, file.stat, file.entry?.rev ?? 0));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { byId, invalid, skipped };
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The statements that reconcile one agent id. Returns whether a row changed
|
|
460
|
+
* and whether the database won a conflict.
|
|
461
|
+
*/
|
|
462
|
+
function reconcileAgent(state, table, id, current, statements) {
|
|
463
|
+
const row = table.storedRows({ scope: "", id })[0] ?? null;
|
|
464
|
+
const sqliteWins = row !== null && row.rev > current.mirroredRev;
|
|
465
|
+
let changed = false;
|
|
466
|
+
let conflict = false;
|
|
467
|
+
if (current.foreign.length > 0) {
|
|
468
|
+
const best = current.foreign.reduce((left, right) => right.record.updatedAt > left.record.updatedAt ||
|
|
469
|
+
(right.record.updatedAt === left.record.updatedAt &&
|
|
470
|
+
right.file.stat.mtimeMs > left.file.stat.mtimeMs)
|
|
471
|
+
? right
|
|
472
|
+
: left);
|
|
473
|
+
const body = JSON.stringify(best.record);
|
|
474
|
+
const same = row !== null && row.deletedAt === null && row.body === body;
|
|
475
|
+
if (sqliteWins && !same) {
|
|
476
|
+
conflict = true;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
if (!same) {
|
|
480
|
+
statements.push(table.putStatement("", id, body));
|
|
481
|
+
changed = true;
|
|
482
|
+
}
|
|
483
|
+
const rev = state.currentRev;
|
|
484
|
+
for (const item of current.foreign) {
|
|
485
|
+
statements.push(state.manifest.statement("agents", item.filePath, item.file.content, item.file.stat, rev));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else if (current.live === 0 && row !== null && row.deletedAt === null) {
|
|
490
|
+
if (sqliteWins) {
|
|
491
|
+
conflict = true;
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
statements.push(table.removeStatement("", id));
|
|
495
|
+
changed = true;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const filePath of current.deleted) {
|
|
499
|
+
statements.push(state.manifest.forgetStatement(filePath));
|
|
500
|
+
}
|
|
501
|
+
return { changed, conflict };
|
|
502
|
+
}
|
|
503
|
+
async function resyncAgents(state, baseDir, clock, logger, started, catchUp) {
|
|
504
|
+
const store = "agents";
|
|
505
|
+
const table = state.doc("agents", { tombstones: true });
|
|
506
|
+
const { byId, invalid, skipped } = await classifyAgentFiles(state, baseDir, logger);
|
|
507
|
+
const statements = [...invalid];
|
|
508
|
+
let imported = 0;
|
|
509
|
+
let conflicts = 0;
|
|
510
|
+
const flush = async () => {
|
|
511
|
+
if (statements.length === 0)
|
|
512
|
+
return;
|
|
513
|
+
const batch = statements.splice(0);
|
|
514
|
+
await clock.batch(() => state.commitBatch(batch));
|
|
515
|
+
};
|
|
516
|
+
for (const [id, current] of byId) {
|
|
517
|
+
if (current.foreign.length === 0 && current.deleted.length === 0)
|
|
518
|
+
continue;
|
|
519
|
+
const outcome = reconcileAgent(state, table, id, current, statements);
|
|
520
|
+
if (outcome.changed)
|
|
521
|
+
imported += 1;
|
|
522
|
+
if (outcome.conflict)
|
|
523
|
+
conflicts += 1;
|
|
524
|
+
if (statements.length >= AGENT_IMPORT_BATCH)
|
|
525
|
+
await flush();
|
|
526
|
+
}
|
|
527
|
+
await flush();
|
|
528
|
+
if (conflicts > 0) {
|
|
529
|
+
logger.info({ store, conflicts }, "Agent files changed by someone else; agents the daemon changed after writing them keep the daemon's version");
|
|
530
|
+
}
|
|
531
|
+
collectAgentCatchUp(state, table, catchUp);
|
|
532
|
+
return {
|
|
533
|
+
store,
|
|
534
|
+
imported,
|
|
535
|
+
skipped,
|
|
536
|
+
ms: performance.now() - started,
|
|
537
|
+
mode: imported > 0 || skipped > 0 || conflicts > 0 ? "resync" : "skip",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Rows whose legacy file lags the database: the process died inside the
|
|
542
|
+
* mirror's debounce window. Rewritten (or unlinked) at boot, so a later
|
|
543
|
+
* rollback does not land on files from before the crash.
|
|
544
|
+
*/
|
|
545
|
+
function collectAgentCatchUp(state, table, catchUp) {
|
|
546
|
+
const byId = new Map();
|
|
547
|
+
for (const entry of state.manifest.entries("agents").values()) {
|
|
548
|
+
const id = agentIdOf(entry.path);
|
|
549
|
+
const current = byId.get(id) ?? { rev: 0, paths: [] };
|
|
550
|
+
current.rev = Math.max(current.rev, entry.rev);
|
|
551
|
+
current.paths.push(entry.path);
|
|
552
|
+
byId.set(id, current);
|
|
553
|
+
}
|
|
554
|
+
for (const row of table.storedRows({ scope: "" })) {
|
|
555
|
+
const mirrored = byId.get(row.id);
|
|
556
|
+
if (row.deletedAt !== null) {
|
|
557
|
+
if (mirrored && row.rev > mirrored.rev)
|
|
558
|
+
catchUp.unlink.push(...mirrored.paths);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!mirrored || row.rev > mirrored.rev)
|
|
562
|
+
catchUp.rewrite.push(row.id);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Agents: a directory of project directories. The first import is resumable
|
|
567
|
+
* by sorted path; when the same agent id shows up twice (a cwd move that left
|
|
568
|
+
* the old file behind), the newer `updatedAt` wins. Afterwards, see `resyncAgents`.
|
|
569
|
+
*/
|
|
570
|
+
export async function importLegacyAgents(state, baseDir, clock, logger, catchUp = { rewrite: [], unlink: [] }) {
|
|
571
|
+
const store = "agents";
|
|
572
|
+
const started = performance.now();
|
|
573
|
+
const marker = state.getImportMarker(store);
|
|
574
|
+
if (marker?.status === "done") {
|
|
575
|
+
return resyncAgents(state, baseDir, clock, logger, started, catchUp);
|
|
576
|
+
}
|
|
577
|
+
const files = (await listAgentRecordFiles(baseDir)).sort();
|
|
578
|
+
const cursor = marker?.cursor ?? null;
|
|
579
|
+
const todo = cursor === null ? files : files.filter((file) => file > cursor);
|
|
580
|
+
let imported = marker?.imported ?? 0;
|
|
581
|
+
let skipped = marker?.skipped ?? 0;
|
|
582
|
+
const table = state.doc("agents", { tombstones: true });
|
|
583
|
+
for (let index = 0; index < todo.length; index += AGENT_IMPORT_BATCH) {
|
|
584
|
+
const batch = todo.slice(index, index + AGENT_IMPORT_BATCH);
|
|
585
|
+
const contents = await Promise.all(batch.map(async (filePath) => {
|
|
586
|
+
try {
|
|
587
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
588
|
+
const stat = await fs.stat(filePath);
|
|
589
|
+
return { filePath, content, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
return { filePath, error };
|
|
593
|
+
}
|
|
594
|
+
}));
|
|
595
|
+
await clock.batch(() => {
|
|
596
|
+
const built = agentBatchStatements(state, table, contents, logger);
|
|
597
|
+
imported += built.imported;
|
|
598
|
+
skipped += built.skipped;
|
|
599
|
+
return state.commitBatch([
|
|
600
|
+
...built.statements,
|
|
601
|
+
state.importMarkerStatement(store, {
|
|
602
|
+
status: "running",
|
|
603
|
+
cursor: batch[batch.length - 1],
|
|
604
|
+
imported,
|
|
605
|
+
skipped,
|
|
606
|
+
}),
|
|
607
|
+
]);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
state.transaction(() => state.putImportMarker(store, { status: "done", cursor: null, imported, skipped }));
|
|
611
|
+
return { store, imported, skipped, ms: performance.now() - started, mode: "import" };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* How long a deleted row keeps its tombstone. A tombstone matters for as long
|
|
615
|
+
* as a stale legacy file could still come back to contradict it: a rollback to
|
|
616
|
+
* the file-based release and a re-upgrade, or a crash before the mirror's
|
|
617
|
+
* unlink. A week covers the updater's rollback and any realistic
|
|
618
|
+
* downgrade-and-return; past it the tombstone is only boot-time cost (every
|
|
619
|
+
* create/delete cycle of a short-lived agent leaves one). Revisit when the
|
|
620
|
+
* delta protocol exposes `rev` to clients: then keep them past the oldest
|
|
621
|
+
* client cursor too.
|
|
622
|
+
*/
|
|
623
|
+
export const TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
624
|
+
/**
|
|
625
|
+
* Purge tombstones past `TOMBSTONE_RETENTION_MS`. An agent whose legacy file
|
|
626
|
+
* the manifest still tracks keeps its tombstone: that file is the one thing
|
|
627
|
+
* that could resurrect it, and the boot catch-up still owes its unlink.
|
|
628
|
+
*/
|
|
629
|
+
function purgeOldTombstones(state, logger) {
|
|
630
|
+
const before = Date.now() - TOMBSTONE_RETENTION_MS;
|
|
631
|
+
const tracked = new Set([...state.manifest.entries("agents").keys()].map((filePath) => agentIdOf(filePath)));
|
|
632
|
+
let purged = 0;
|
|
633
|
+
for (const table of TOMBSTONED_TABLES) {
|
|
634
|
+
purged += state.doc(table).purgeTombstones(before, table === "agents" ? tracked : new Set());
|
|
635
|
+
}
|
|
636
|
+
if (purged > 0)
|
|
637
|
+
logger.info({ purged }, "Purged old tombstones from state.sqlite");
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Import (or re-sync) every legacy store. Never throws for a single store: a
|
|
641
|
+
* store that fails is logged and left for the next boot, its marker still
|
|
642
|
+
* "running" so it resumes where it stopped.
|
|
643
|
+
*/
|
|
644
|
+
export async function importLegacyState(state, options) {
|
|
645
|
+
const started = performance.now();
|
|
646
|
+
const clock = new BatchClock();
|
|
647
|
+
const results = [];
|
|
648
|
+
const logger = options.logger;
|
|
649
|
+
state.pauseCheckpoints();
|
|
650
|
+
const run = async (store, fn) => {
|
|
651
|
+
try {
|
|
652
|
+
results.push(await fn());
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
logger.error({ err: error, store }, "Legacy import failed; will resume on next boot");
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const agentCatchUp = { rewrite: [], unlink: [] };
|
|
659
|
+
await run("agents", () => importLegacyAgents(state, options.agentStoragePath, clock, logger, agentCatchUp));
|
|
660
|
+
for (const source of options.sources) {
|
|
661
|
+
await run(source.store, () => {
|
|
662
|
+
switch (source.kind) {
|
|
663
|
+
case "whole-file":
|
|
664
|
+
return importWholeFile(state, source, clock, logger);
|
|
665
|
+
case "directory":
|
|
666
|
+
return importDirectory(state, source, clock, logger);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
state.resumeCheckpoints();
|
|
671
|
+
purgeOldTombstones(state, logger);
|
|
672
|
+
const sorted = [...clock.batchMs].sort((left, right) => left - right);
|
|
673
|
+
return {
|
|
674
|
+
totalMs: performance.now() - started,
|
|
675
|
+
maxBatchMs: clock.maxBatchMs,
|
|
676
|
+
p99BatchMs: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
|
|
677
|
+
batches: sorted.length,
|
|
678
|
+
stores: results,
|
|
679
|
+
agentCatchUp,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=legacy-import.js.map
|