@hyperdrive.bot/fleet-server 0.3.164 → 0.3.166

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.
Files changed (110) hide show
  1. package/dist/server/extensions/daemon-backend.js +25 -0
  2. package/dist/server/server/agent/agent-storage.d.ts +67 -12
  3. package/dist/server/server/agent/agent-storage.js +208 -132
  4. package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
  5. package/dist/server/server/bootstrap.d.ts +5 -0
  6. package/dist/server/server/bootstrap.js +26 -3
  7. package/dist/server/server/chat/chat-service.d.ts +22 -0
  8. package/dist/server/server/chat/chat-service.js +46 -3
  9. package/dist/server/server/daemon-worker.js +1 -0
  10. package/dist/server/server/fleet/decision-service.d.ts +5 -0
  11. package/dist/server/server/fleet/decision-service.js +30 -0
  12. package/dist/server/server/loop-service.d.ts +95 -0
  13. package/dist/server/server/loop-service.js +64 -3
  14. package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
  15. package/dist/server/server/push/token-store.d.ts +5 -1
  16. package/dist/server/server/push/token-store.js +26 -5
  17. package/dist/server/server/schedule/service.js +4 -1
  18. package/dist/server/server/schedule/store.d.ts +21 -1
  19. package/dist/server/server/schedule/store.js +283 -2
  20. package/dist/server/server/search/indexer.d.ts +10 -0
  21. package/dist/server/server/search/indexer.js +51 -2
  22. package/dist/server/server/session.js +12 -13
  23. package/dist/server/server/state/agent-state.d.ts +39 -0
  24. package/dist/server/server/state/agent-state.js +94 -0
  25. package/dist/server/server/state/daemon-state.d.ts +30 -0
  26. package/dist/server/server/state/daemon-state.js +99 -0
  27. package/dist/server/server/state/legacy-import.d.ts +124 -0
  28. package/dist/server/server/state/legacy-import.js +682 -0
  29. package/dist/server/server/state/legacy-manifest.d.ts +73 -0
  30. package/dist/server/server/state/legacy-manifest.js +112 -0
  31. package/dist/server/server/state/legacy-mirror.d.ts +57 -0
  32. package/dist/server/server/state/legacy-mirror.js +114 -0
  33. package/dist/server/server/state/legacy-sources.d.ts +9 -0
  34. package/dist/server/server/state/legacy-sources.js +163 -0
  35. package/dist/server/server/state/state-db.d.ts +263 -0
  36. package/dist/server/server/state/state-db.js +772 -0
  37. package/dist/server/server/state/state-schema.d.ts +39 -0
  38. package/dist/server/server/state/state-schema.js +125 -0
  39. package/dist/server/server/state/state-worker.d.ts +48 -0
  40. package/dist/server/server/state/state-worker.js +165 -0
  41. package/dist/server/server/websocket-server.js +2 -1
  42. package/dist/server/server/workspace/session-openable.js +7 -0
  43. package/dist/server/server/workspace-reconciliation-service.js +14 -3
  44. package/dist/server/server/workspace-registry.d.ts +45 -2
  45. package/dist/server/server/workspace-registry.js +75 -4
  46. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js → desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js} +5 -5
  47. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.gz +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.br +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-eafa6217277b83ec33d4967049da7697.js.map.gz +0 -0
  51. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js → desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js} +3 -3
  52. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.br +0 -0
  53. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.gz +0 -0
  54. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.br +0 -0
  55. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-bdcb16b0ac512f03771e9926602de68f.js.map.gz +0 -0
  56. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-1ff4c43fd657ba6473b02d56dae75a25.js} +1530 -1527
  57. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.br +0 -0
  58. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.gz +0 -0
  59. package/dist/server/web-ui/_expo/static/js/web/index-1ff4c43fd657ba6473b02d56dae75a25.js.map.br +0 -0
  60. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-1ff4c43fd657ba6473b02d56dae75a25.js.map.gz} +0 -0
  61. package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js → indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js} +3 -3
  62. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.br +0 -0
  63. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.gz +0 -0
  64. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.br +0 -0
  65. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-c83a6261ece654d4dfc3566e3b929e73.js.map.gz +0 -0
  66. package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js → native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js} +5 -5
  67. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.br +0 -0
  68. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.gz +0 -0
  69. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.br +0 -0
  70. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ff6e2b6baeb643378628c82f177b35c9.js.map.gz +0 -0
  71. package/dist/server/web-ui/_expo/static/js/web/{profiler-b509d7fffd6cf457a9c12588b80560cb.js → profiler-a28d140b5874532b9eef737c77fde610.js} +11 -11
  72. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.br +0 -0
  73. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.gz +0 -0
  74. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.br +0 -0
  75. package/dist/server/web-ui/_expo/static/js/web/profiler-a28d140b5874532b9eef737c77fde610.js.map.gz +0 -0
  76. package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js} +60 -60
  77. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.br +0 -0
  78. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.gz +0 -0
  79. package/dist/server/web-ui/_expo/static/js/web/startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.br +0 -0
  80. package/dist/server/web-ui/_expo/static/js/web/{startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.gz → startRecording-f05b37fde4fc248dba4cb646dda2fc3d.js.map.gz} +0 -0
  81. package/dist/server/web-ui/index.html +1 -1
  82. package/dist/server/web-ui/index.html.br +0 -0
  83. package/dist/server/web-ui/index.html.gz +0 -0
  84. package/package.json +6 -6
  85. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.br +0 -0
  86. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.gz +0 -0
  87. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.br +0 -0
  88. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-491bc812b0659658291d67bc77c9e697.js.map.gz +0 -0
  89. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.br +0 -0
  90. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.gz +0 -0
  91. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.br +0 -0
  92. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-6d63da0fd45a8d1001f62f227484f116.js.map.gz +0 -0
  93. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
  94. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.gz +0 -0
  95. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br +0 -0
  96. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.br +0 -0
  97. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.gz +0 -0
  98. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.br +0 -0
  99. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-040c97c7cac8a3ed86c75baa2ce39d61.js.map.gz +0 -0
  100. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.br +0 -0
  101. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.gz +0 -0
  102. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.br +0 -0
  103. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-ac3bbd454eba677c4fd092227d5b5b89.js.map.gz +0 -0
  104. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.br +0 -0
  105. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.gz +0 -0
  106. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.br +0 -0
  107. package/dist/server/web-ui/_expo/static/js/web/profiler-b509d7fffd6cf457a9c12588b80560cb.js.map.gz +0 -0
  108. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.br +0 -0
  109. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.gz +0 -0
  110. package/dist/server/web-ui/_expo/static/js/web/startRecording-9c90e555a5ecdc643e277f9a98913aaf.js.map.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