@mulmoclaude/core 3.0.1 → 3.1.0
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/assets/helps/collection-skills.md +72 -8
- package/assets/helps/custom-view.md +9 -1
- package/assets/helps/error-recovery.md +36 -0
- package/assets/helps/feeds.md +6 -4
- package/dist/calendarGrid-Csy2rpjp.js.map +1 -1
- package/dist/calendarGrid-p8K-7cuB.cjs.map +1 -1
- package/dist/collection/core/presentCollection.d.ts +49 -0
- package/dist/collection/core/schema.d.ts +16 -1
- package/dist/collection/index.cjs +56 -7
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +53 -8
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/importWriter.d.ts +2 -1
- package/dist/collection/registry/server/index.cjs +31 -10
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +30 -9
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/host.d.ts +34 -4
- package/dist/collection/server/index.cjs +4 -2
- package/dist/collection/server/index.d.ts +1 -1
- package/dist/collection/server/index.js +4 -3
- package/dist/collection/server/manageTool.d.ts +19 -0
- package/dist/collection/server/schemaDocs.d.ts +5 -1
- package/dist/collection-watchers/config.d.ts +15 -2
- package/dist/collection-watchers/index.cjs +310 -198
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +310 -198
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/collection-watchers/reconciler.d.ts +1 -5
- package/dist/collection-watchers/watcher.d.ts +31 -26
- package/dist/{discovery-4Z-ZWFam.js → discovery-CC7kuwHf.js} +21 -7
- package/dist/discovery-CC7kuwHf.js.map +1 -0
- package/dist/{discovery-CtiZMlsX.cjs → discovery-CDOjfctD.cjs} +33 -7
- package/dist/discovery-CDOjfctD.cjs.map +1 -0
- package/dist/feeds/server/index.cjs +26 -6
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.d.ts +1 -1
- package/dist/feeds/server/index.js +26 -7
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/feeds/server/scheduledRefresh.d.ts +16 -1
- package/dist/files/index.cjs +6 -5
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +2 -2
- package/dist/files/root.d.ts +15 -0
- package/dist/google/index.cjs +3 -3
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +2 -2
- package/dist/remote-host/index.cjs +3 -1
- package/dist/remote-host/index.d.ts +30 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/index.cjs +1 -1
- package/dist/remote-host/server/index.js +1 -1
- package/dist/{remote-host-Dba4lF3Z.js → remote-host-CFnl1I-L.js} +35 -2
- package/dist/{remote-host-Dba4lF3Z.js.map → remote-host-CFnl1I-L.js.map} +1 -1
- package/dist/{remote-host-CtQjagPt.cjs → remote-host-EQPlccB9.cjs} +46 -1
- package/dist/{remote-host-CtQjagPt.cjs.map → remote-host-EQPlccB9.cjs.map} +1 -1
- package/dist/{atomic-DPpdrJzO.js → root-BMroU_mB.js} +21 -2
- package/dist/root-BMroU_mB.js.map +1 -0
- package/dist/{atomic-DhUk8uiM.cjs → root-rPH6FGDT.cjs} +26 -1
- package/dist/root-rPH6FGDT.cjs.map +1 -0
- package/dist/{server-CedIocRS.js → server-AA2kfDfk.js} +137 -42
- package/dist/server-AA2kfDfk.js.map +1 -0
- package/dist/{server-DOZFzx5l.cjs → server-WU35UYlF.cjs} +138 -43
- package/dist/server-WU35UYlF.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/atomic-DPpdrJzO.js.map +0 -1
- package/dist/atomic-DhUk8uiM.cjs.map +0 -1
- package/dist/discovery-4Z-ZWFam.js.map +0 -1
- package/dist/discovery-CtiZMlsX.cjs.map +0 -1
- package/dist/server-CedIocRS.js.map +0 -1
- package/dist/server-DOZFzx5l.cjs.map +0 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_root = require("../root-rPH6FGDT.cjs");
|
|
2
3
|
const require_calendarGrid = require("../calendarGrid-p8K-7cuB.cjs");
|
|
3
4
|
const require_promptSafety = require("../promptSafety-CNEW3_qS.cjs");
|
|
4
5
|
require("../collection/index.cjs");
|
|
5
|
-
const require_discovery = require("../discovery-
|
|
6
|
-
const require_server = require("../server-
|
|
6
|
+
const require_discovery = require("../discovery-CDOjfctD.cjs");
|
|
7
|
+
const require_server = require("../server-WU35UYlF.cjs");
|
|
7
8
|
const require_notifier = require("../notifier-DZJj0sOh.cjs");
|
|
8
9
|
let node_fs_promises = require("node:fs/promises");
|
|
9
10
|
//#region src/collection-watchers/config.ts
|
|
@@ -48,24 +49,60 @@ function evalNow() {
|
|
|
48
49
|
* Used both to build new keys and to filter sweep candidates from the
|
|
49
50
|
* active bell. */
|
|
50
51
|
var LEGACY_ID_PREFIX = "collection-completion:";
|
|
51
|
-
/**
|
|
52
|
+
/** Marks a key that carries a root. Placed FIRST so a rooted key can never be
|
|
53
|
+
* mistaken for a slug — `<prefix>@<root>\0<slug>:<itemId>` vs the original
|
|
54
|
+
* `<prefix><slug>:<itemId>`. NUL cannot occur in a path, a slug or an itemId,
|
|
55
|
+
* so the split is unambiguous. */
|
|
56
|
+
var ROOT_MARK = "@";
|
|
57
|
+
var ROOT_SEP = "\0";
|
|
58
|
+
/** Stable key encoding (root, slug, item), round-tripped through the entry's
|
|
52
59
|
* `pluginData` so we can find it later without a side state file. Slug +
|
|
53
60
|
* itemId are upstream-validated via `safeSlugName`, which forbids the
|
|
54
|
-
* colon separator, so the two-segment parse below is unambiguous.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
61
|
+
* colon separator, so the two-segment parse below is unambiguous.
|
|
62
|
+
*
|
|
63
|
+
* COMPATIBILITY RULE — read this before changing the format. Both apps
|
|
64
|
+
* (MulmoClaude and MulmoTerminal) read ONE notifier file,
|
|
65
|
+
* `<ws>/data/notifier/active.json`; they never run simultaneously, but an id
|
|
66
|
+
* written by one must not produce a duplicate bell for the other. So:
|
|
67
|
+
*
|
|
68
|
+
* - with NO root, the id is byte-identical to the pre-multi-root format, and a
|
|
69
|
+
* single-workspace host's existing `active.json` entries keep matching;
|
|
70
|
+
* - a root is included ONLY when one was supplied, which is what lets two
|
|
71
|
+
* roots owning the same slug hold two distinct bells instead of deduping
|
|
72
|
+
* into each other;
|
|
73
|
+
* - `parseCompletionLegacyId` accepts BOTH forms, forever.
|
|
74
|
+
*
|
|
75
|
+
* The root is canonicalised here as well as at the watcher's claim, because
|
|
76
|
+
* this id is written to disk: a caller reaching `reconcileItem` directly with
|
|
77
|
+
* `/proj/` where the watcher used `/proj` would otherwise publish a SECOND
|
|
78
|
+
* bell for the same record, and neither pass would ever clear the other's. */
|
|
79
|
+
function completionLegacyId(slug, itemId, root) {
|
|
80
|
+
return `${LEGACY_ID_PREFIX}${root === void 0 ? "" : `${ROOT_MARK}${require_root.canonicalRoot(root)}${ROOT_SEP}`}${slug}:${itemId}`;
|
|
81
|
+
}
|
|
82
|
+
/** Decode a key back into its (root, slug, itemId), or null if the string
|
|
83
|
+
* didn't originate from this module. Accepts both the rooted and the
|
|
84
|
+
* original root-less form — see the compatibility rule above. Used by the
|
|
85
|
+
* sweep step. */
|
|
60
86
|
function parseCompletionLegacyId(legacyId) {
|
|
61
87
|
if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;
|
|
62
|
-
|
|
88
|
+
let body = legacyId.slice(22);
|
|
89
|
+
let root;
|
|
90
|
+
if (body.startsWith(ROOT_MARK)) {
|
|
91
|
+
const sep = body.indexOf(ROOT_SEP);
|
|
92
|
+
if (sep < 0) return null;
|
|
93
|
+
root = body.slice(1, sep);
|
|
94
|
+
body = body.slice(sep + 1);
|
|
95
|
+
}
|
|
63
96
|
const colon = body.indexOf(":");
|
|
64
97
|
if (colon < 0) return null;
|
|
65
|
-
|
|
98
|
+
const parsed = {
|
|
66
99
|
slug: body.slice(0, colon),
|
|
67
100
|
itemId: body.slice(colon + 1)
|
|
68
101
|
};
|
|
102
|
+
return root === void 0 ? parsed : {
|
|
103
|
+
root,
|
|
104
|
+
...parsed
|
|
105
|
+
};
|
|
69
106
|
}
|
|
70
107
|
/** The human-readable label shown in a completion notification's title.
|
|
71
108
|
* Uses the schema's `displayField` value when declared and non-empty;
|
|
@@ -79,13 +116,13 @@ function resolveDisplayLabel(schema, item, itemId) {
|
|
|
79
116
|
/** Every active bell entry whose key matches this (slug, itemId).
|
|
80
117
|
* Returns multiple when defensive cleanup is needed. Scans `listAll()`
|
|
81
118
|
* — cheap because the active set is bounded. */
|
|
82
|
-
async function findActiveEntries(slug, itemId) {
|
|
119
|
+
async function findActiveEntries(slug, itemId, root) {
|
|
83
120
|
const adapter = requireAdapter();
|
|
84
|
-
const legacyId = completionLegacyId(slug, itemId);
|
|
121
|
+
const legacyId = completionLegacyId(slug, itemId, root);
|
|
85
122
|
return (await require_notifier.listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
|
|
86
123
|
}
|
|
87
|
-
async function findActiveEntryIds(slug, itemId) {
|
|
88
|
-
return (await findActiveEntries(slug, itemId)).map((entry) => entry.id);
|
|
124
|
+
async function findActiveEntryIds(slug, itemId, root) {
|
|
125
|
+
return (await findActiveEntries(slug, itemId, root)).map((entry) => entry.id);
|
|
89
126
|
}
|
|
90
127
|
var ensureLocks = /* @__PURE__ */ new Map();
|
|
91
128
|
/** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
|
|
@@ -98,14 +135,22 @@ function notifyPriorityForItem(schema, item) {
|
|
|
98
135
|
const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
|
|
99
136
|
return spec.in.indexOf(value) === 0 ? "high" : "normal";
|
|
100
137
|
}
|
|
101
|
-
async function ensureItemNotification(slug, schema, itemId, displayLabel, priority) {
|
|
102
|
-
const legacyId = completionLegacyId(slug, itemId);
|
|
138
|
+
async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, root) {
|
|
139
|
+
const legacyId = completionLegacyId(slug, itemId, root);
|
|
103
140
|
while (true) {
|
|
104
141
|
const inflight = ensureLocks.get(legacyId);
|
|
105
142
|
if (!inflight) break;
|
|
106
143
|
await inflight.promise;
|
|
107
144
|
}
|
|
108
|
-
const lock = { promise: doEnsureItemNotification(
|
|
145
|
+
const lock = { promise: doEnsureItemNotification({
|
|
146
|
+
slug,
|
|
147
|
+
schema,
|
|
148
|
+
itemId,
|
|
149
|
+
legacyId,
|
|
150
|
+
displayLabel,
|
|
151
|
+
priority,
|
|
152
|
+
root
|
|
153
|
+
}) };
|
|
109
154
|
ensureLocks.set(legacyId, lock);
|
|
110
155
|
try {
|
|
111
156
|
await lock.promise;
|
|
@@ -117,7 +162,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
|
|
|
117
162
|
* place (preserving id / position / createdAt) so a record whose flagged
|
|
118
163
|
* value changed while it stayed pending re-colours the bell without a
|
|
119
164
|
* clear+republish flicker. No-op when the stored priority already matches. */
|
|
120
|
-
async function reconcileEntrySeverity(slug, itemId, entries, priority) {
|
|
165
|
+
async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
|
|
121
166
|
const adapter = requireAdapter();
|
|
122
167
|
for (const entry of entries) {
|
|
123
168
|
const parsed = adapter.readEntry(entry.pluginData);
|
|
@@ -129,20 +174,21 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority) {
|
|
|
129
174
|
slug,
|
|
130
175
|
itemId,
|
|
131
176
|
priority,
|
|
132
|
-
navigateTarget: adapter.buildNavigateTarget(slug, itemId)
|
|
177
|
+
navigateTarget: adapter.buildNavigateTarget(slug, itemId, root),
|
|
178
|
+
root
|
|
133
179
|
})
|
|
134
180
|
});
|
|
135
181
|
}
|
|
136
182
|
}
|
|
137
|
-
async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) {
|
|
183
|
+
async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, root }) {
|
|
138
184
|
const adapter = requireAdapter();
|
|
139
185
|
try {
|
|
140
|
-
const existing = await findActiveEntries(slug, itemId);
|
|
186
|
+
const existing = await findActiveEntries(slug, itemId, root);
|
|
141
187
|
if (existing.length > 0) {
|
|
142
|
-
await reconcileEntrySeverity(slug, itemId, existing, priority);
|
|
188
|
+
await reconcileEntrySeverity(slug, itemId, existing, priority, root);
|
|
143
189
|
return;
|
|
144
190
|
}
|
|
145
|
-
const navigateTarget = adapter.buildNavigateTarget(slug, itemId);
|
|
191
|
+
const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
|
|
146
192
|
await require_notifier.publish({
|
|
147
193
|
pluginPkg: adapter.pluginPkg,
|
|
148
194
|
severity: adapter.priorityToSeverity(priority),
|
|
@@ -154,7 +200,8 @@ async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayL
|
|
|
154
200
|
slug,
|
|
155
201
|
itemId,
|
|
156
202
|
priority,
|
|
157
|
-
navigateTarget
|
|
203
|
+
navigateTarget,
|
|
204
|
+
root
|
|
158
205
|
})
|
|
159
206
|
});
|
|
160
207
|
} catch (err) {
|
|
@@ -168,9 +215,9 @@ async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayL
|
|
|
168
215
|
/** Idempotently clear EVERY bell entry that matches this (slug, itemId).
|
|
169
216
|
* Silent no-op when nothing matches. The "every" is defensive: if a
|
|
170
217
|
* duplicate ever slips through, this drains the lot. */
|
|
171
|
-
async function clearItemNotification(slug, itemId) {
|
|
218
|
+
async function clearItemNotification(slug, itemId, root) {
|
|
172
219
|
try {
|
|
173
|
-
const ids = await findActiveEntryIds(slug, itemId);
|
|
220
|
+
const ids = await findActiveEntryIds(slug, itemId, root);
|
|
174
221
|
for (const entryId of ids) await require_notifier.clear(entryId);
|
|
175
222
|
} catch (err) {
|
|
176
223
|
log().warn("notify clear failed", {
|
|
@@ -190,18 +237,19 @@ async function clearItemNotification(slug, itemId) {
|
|
|
190
237
|
* `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
|
|
191
238
|
async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
|
|
192
239
|
const { slug, schema } = collection;
|
|
240
|
+
const root = ioOpts.workspaceRoot;
|
|
193
241
|
if (!schema.completionField) {
|
|
194
|
-
await clearItemNotification(slug, itemId);
|
|
242
|
+
await clearItemNotification(slug, itemId, root);
|
|
195
243
|
return;
|
|
196
244
|
}
|
|
197
245
|
const item = await require_discovery.storeFor(collection, ioOpts).read(itemId);
|
|
198
246
|
if (item === null) {
|
|
199
|
-
await clearItemNotification(slug, itemId);
|
|
247
|
+
await clearItemNotification(slug, itemId, root);
|
|
200
248
|
return;
|
|
201
249
|
}
|
|
202
250
|
await require_server.maybeSpawnSuccessor(collection, item, itemId, ioOpts);
|
|
203
251
|
if (require_promptSafety.itemIsDone(schema, item)) {
|
|
204
|
-
await clearItemNotification(slug, itemId);
|
|
252
|
+
await clearItemNotification(slug, itemId, root);
|
|
205
253
|
return;
|
|
206
254
|
}
|
|
207
255
|
if (schema.triggerField) {
|
|
@@ -213,15 +261,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
|
|
|
213
261
|
triggerField: schema.triggerField
|
|
214
262
|
});
|
|
215
263
|
if (due !== true) {
|
|
216
|
-
await clearItemNotification(slug, itemId);
|
|
264
|
+
await clearItemNotification(slug, itemId, root);
|
|
217
265
|
return;
|
|
218
266
|
}
|
|
219
267
|
}
|
|
220
268
|
if (!require_promptSafety.whenMatches(schema.notifyWhen, item)) {
|
|
221
|
-
await clearItemNotification(slug, itemId);
|
|
269
|
+
await clearItemNotification(slug, itemId, root);
|
|
222
270
|
return;
|
|
223
271
|
}
|
|
224
|
-
await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item));
|
|
272
|
+
await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), root);
|
|
225
273
|
}
|
|
226
274
|
/** Boot-time reconcile: walk every record of the collection once (through
|
|
227
275
|
* its store) and reconcile it. Catches up changes that happened while the
|
|
@@ -252,6 +300,36 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
|
|
|
252
300
|
* file is gone, whose collection was deleted, whose schema no longer
|
|
253
301
|
* tracks completion, or whose item is now done. Reverse-covers the cases
|
|
254
302
|
* `reconcileAllItems` misses (it only walks files that exist). */
|
|
303
|
+
/** What this sweep should do with an entry, given the root it belongs to and
|
|
304
|
+
* the root the sweep is running for.
|
|
305
|
+
*
|
|
306
|
+
* - `mine` — same root (or both root-less: the single-workspace case, which
|
|
307
|
+
* behaves exactly as it always did). Judge it against the records on disk.
|
|
308
|
+
* - `skip` — ANOTHER root's entry. Its collection and records live in a tree
|
|
309
|
+
* this sweep cannot see, so every check would read "gone" and clear a
|
|
310
|
+
* perfectly live bell. Two roots share one notifier file; this is the
|
|
311
|
+
* boundary between them.
|
|
312
|
+
* - `drop-legacy` — a root-less entry seen by a sweep that HAS a root: a
|
|
313
|
+
* leftover from before bell ids carried one. Nothing this host does from now
|
|
314
|
+
* on produces a root-less id, so skipping it would strand a bell no pass can
|
|
315
|
+
* ever clear, while the reconcile publishes a second rooted one beside it.
|
|
316
|
+
* Clearing converges — the record republishes rooted if still pending. */
|
|
317
|
+
function sweepVerdict(entryRoot, ownRoot) {
|
|
318
|
+
if (entryRoot === ownRoot) return "mine";
|
|
319
|
+
if (entryRoot === void 0) return "drop-legacy";
|
|
320
|
+
return "skip";
|
|
321
|
+
}
|
|
322
|
+
/** Should this entry's bell be dropped? True when the collection is gone, no
|
|
323
|
+
* longer tracks completion, or the record is missing / done / outside
|
|
324
|
+
* `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
|
|
325
|
+
* whose record the forward pass can never walk to (a delete leaves a bell that
|
|
326
|
+
* a walk over the SURVIVING records cannot clear). */
|
|
327
|
+
async function isStaleEntry(slug, itemId, opts) {
|
|
328
|
+
const collection = await require_discovery.loadCollection(slug, opts);
|
|
329
|
+
if (!collection || !collection.schema.completionField) return true;
|
|
330
|
+
const item = await require_discovery.storeFor(collection, opts).read(itemId);
|
|
331
|
+
return item === null || require_promptSafety.itemIsDone(collection.schema, item) || !require_promptSafety.whenMatches(collection.schema.notifyWhen, item);
|
|
332
|
+
}
|
|
255
333
|
async function sweepStaleActiveEntries(opts = {}) {
|
|
256
334
|
const adapter = requireAdapter();
|
|
257
335
|
let entries;
|
|
@@ -261,20 +339,21 @@ async function sweepStaleActiveEntries(opts = {}) {
|
|
|
261
339
|
log().warn("sweep list failed", { error: errMsg(err) });
|
|
262
340
|
return;
|
|
263
341
|
}
|
|
342
|
+
const ownRoot = opts.workspaceRoot === void 0 ? void 0 : require_root.canonicalRoot(opts.workspaceRoot);
|
|
264
343
|
for (const entry of entries) {
|
|
265
344
|
const own = adapter.readEntry(entry.pluginData);
|
|
266
345
|
if (!own) continue;
|
|
267
346
|
const parsed = parseCompletionLegacyId(own.legacyId);
|
|
268
347
|
if (!parsed) continue;
|
|
348
|
+
const verdict = sweepVerdict(parsed.root, ownRoot);
|
|
349
|
+
if (verdict === "skip") continue;
|
|
350
|
+
if (verdict === "drop-legacy") {
|
|
351
|
+
await require_notifier.clear(entry.id);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
269
354
|
const { slug, itemId } = parsed;
|
|
270
355
|
try {
|
|
271
|
-
|
|
272
|
-
if (!collection || !collection.schema.completionField) {
|
|
273
|
-
await require_notifier.clear(entry.id);
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
const item = await require_discovery.storeFor(collection, opts).read(itemId);
|
|
277
|
-
if (item === null || require_promptSafety.itemIsDone(collection.schema, item) || !require_promptSafety.whenMatches(collection.schema.notifyWhen, item)) await require_notifier.clear(entry.id);
|
|
356
|
+
if (await isStaleEntry(slug, itemId, opts)) await require_notifier.clear(entry.id);
|
|
278
357
|
} catch (err) {
|
|
279
358
|
log().warn("sweep entry failed", {
|
|
280
359
|
slug,
|
|
@@ -294,173 +373,205 @@ var ONE_SECOND_MS = 1e3;
|
|
|
294
373
|
var ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
|
|
295
374
|
var REDISCOVERY_INTERVAL_MS = 30 * ONE_SECOND_MS;
|
|
296
375
|
var TRIGGER_TICK_INTERVAL_MS = ONE_MINUTE_MS;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
*
|
|
312
|
-
* itself stop two concurrent starts from racing each other's state. Teardown
|
|
313
|
-
* awaits this too, so a boot in flight can't arm its intervals after the stop
|
|
314
|
-
* that was meant to disarm them. */
|
|
315
|
-
var bootInFlight = null;
|
|
316
|
-
/** The root the running (or booting) generation belongs to, NORMALIZED: an
|
|
317
|
-
* omitted `workspaceRoot` resolves to the host's configured default, so
|
|
318
|
-
* `start()` and `start({ workspaceRoot: <that same default> })` are the same
|
|
319
|
-
* generation rather than a spurious conflict. Distinct from
|
|
320
|
-
* `discoveryOpts.workspaceRoot`, which stays un-normalized because the change
|
|
321
|
-
* payload's `root` means "an explicit root was passed", not "this root". */
|
|
322
|
-
var activeRoot;
|
|
323
|
-
/** `err.code` on the conflict throw below: another ROOT's watcher generation is
|
|
324
|
-
* already up. Distinct from `COLLECTION_ROOT_REQUIRED` (this call forgot its
|
|
325
|
-
* `workspaceRoot`) because the fixes differ — stop the running generation
|
|
326
|
-
* versus pass the option — and a host that catches watcher startup in one
|
|
327
|
-
* fire-and-forget `.catch` should not have to match on message text. */
|
|
376
|
+
/** Live generations, keyed by NORMALIZED root: an omitted `workspaceRoot`
|
|
377
|
+
* resolves to the host's configured default, so `start()` and
|
|
378
|
+
* `start({ workspaceRoot: <that same default> })` join one generation rather
|
|
379
|
+
* than mounting two watchers over the same tree. */
|
|
380
|
+
var generations = /* @__PURE__ */ new Map();
|
|
381
|
+
/** Map key for a normalized root. `""` stands for "the host has no configured
|
|
382
|
+
* default and this call named no root" — a state in which discovery itself
|
|
383
|
+
* would throw, so at most one such generation can meaningfully exist. */
|
|
384
|
+
var generationKey = (root) => root ?? "";
|
|
385
|
+
/** Retained for hosts that catch it. NO LONGER THROWN: starting a second root
|
|
386
|
+
* now mounts a second generation instead of refusing. It stays exported
|
|
387
|
+
* because the refusal was a documented, catchable failure and a host's
|
|
388
|
+
* `.catch` branch for it must keep compiling — and because a future host-side
|
|
389
|
+
* constraint that reintroduces a conflict should reuse this code rather than
|
|
390
|
+
* invent a second one. */
|
|
328
391
|
var WATCHER_ROOT_CONFLICT = "WATCHER_ROOT_CONFLICT";
|
|
329
392
|
/** The root a start request resolves to: its explicit override, else the host's
|
|
330
393
|
* configured default. `peekWorkspaceRoot` rather than `getWorkspaceRoot` — this
|
|
331
394
|
* is a comparison, and under an explicit-root binding there is no default to
|
|
332
395
|
* read, which is not an error here. */
|
|
333
396
|
function effectiveRoot(opts) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
397
|
+
const root = opts?.workspaceRoot ?? require_discovery.peekWorkspaceRoot() ?? void 0;
|
|
398
|
+
return root === void 0 ? void 0 : require_root.canonicalRoot(root);
|
|
399
|
+
}
|
|
400
|
+
/** The discovery options a generation runs under, with an explicit root put in
|
|
401
|
+
* canonical form. Everything downstream — the change payload's `root`, the
|
|
402
|
+
* bell id the reconciler derives — reads it from here, so canonicalising once
|
|
403
|
+
* at the claim is what keeps those three consistent. An omitted root stays
|
|
404
|
+
* omitted: "no explicit root" is a distinct state, not a root to normalise. */
|
|
405
|
+
function canonicalDiscoveryOpts(opts) {
|
|
406
|
+
const base = opts ?? {};
|
|
407
|
+
return base.workspaceRoot === void 0 ? base : {
|
|
408
|
+
...base,
|
|
409
|
+
workspaceRoot: require_root.canonicalRoot(base.workspaceRoot)
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function newGeneration(discoveryOpts) {
|
|
413
|
+
return {
|
|
414
|
+
discoveryOpts,
|
|
415
|
+
watchers: /* @__PURE__ */ new Map(),
|
|
416
|
+
itemSlots: /* @__PURE__ */ new Map(),
|
|
417
|
+
collectionSlots: /* @__PURE__ */ new Map(),
|
|
418
|
+
rediscoveryTimer: null,
|
|
419
|
+
triggerTimer: null,
|
|
420
|
+
started: false,
|
|
421
|
+
triggerTickRunning: false,
|
|
422
|
+
triggerTickInFlight: null,
|
|
423
|
+
alive: true,
|
|
424
|
+
bootInFlight: null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** The generation used when NO watcher is running at all. Several tests (and a
|
|
428
|
+
* host that drives a reconcile directly) call the reconcile helpers without a
|
|
429
|
+
* boot; they still need ONE stable set of single-flight slots, or a burst of
|
|
430
|
+
* calls that should coalesce into a single publish would each get their own
|
|
431
|
+
* slot. Created lazily and dropped by a full `stopCollectionWatchers()`. */
|
|
432
|
+
var detachedGeneration = null;
|
|
433
|
+
function fallbackGeneration() {
|
|
434
|
+
detachedGeneration ??= newGeneration({});
|
|
435
|
+
return detachedGeneration;
|
|
436
|
+
}
|
|
437
|
+
/** The generation a test helper or a root-less call should act on: the one
|
|
438
|
+
* named by `workspaceRoot`, else the only one running, else the default
|
|
439
|
+
* root's. "The only one running" is what keeps the single-root test helpers
|
|
440
|
+
* (which name no root) working against a generation booted for a tmpdir. */
|
|
441
|
+
function resolveGeneration(workspaceRoot) {
|
|
442
|
+
if (workspaceRoot !== void 0) return generations.get(generationKey(workspaceRoot));
|
|
443
|
+
if (generations.size === 1) return [...generations.values()][0];
|
|
444
|
+
return generations.get(generationKey(effectiveRoot(void 0)));
|
|
445
|
+
}
|
|
347
446
|
/** Boot entry point: sweep stale active entries, then mount watchers for
|
|
348
447
|
* every discovered collection and arm the periodic re-discovery poll.
|
|
349
|
-
* Idempotent for the SAME root — a second call
|
|
448
|
+
* Idempotent for the SAME root — a second call joins the first.
|
|
350
449
|
*
|
|
351
|
-
* A
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* That is the same class of silent-wrong-root failure the explicit-root host
|
|
357
|
-
* binding exists to make loud, so it is loud here too. */
|
|
450
|
+
* A call for a DIFFERENT root mounts a SECOND generation beside the first, so
|
|
451
|
+
* a multi-root host can watch several projects at once. Each generation keeps
|
|
452
|
+
* its own watcher set, timers and single-flight slots, stamps its own root on
|
|
453
|
+
* its change payloads, and drives bells whose ids carry that root — which is
|
|
454
|
+
* what stops two projects' `tasks` collections from deduping into one bell. */
|
|
358
455
|
async function startCollectionWatchers(opts = {}) {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
await bootInFlight;
|
|
456
|
+
const key = generationKey(effectiveRoot(opts.discoveryOpts));
|
|
457
|
+
const existing = generations.get(key);
|
|
458
|
+
if (existing) {
|
|
459
|
+
await existing.bootInFlight;
|
|
363
460
|
return;
|
|
364
461
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
bootInFlight = bootWatchers(opts);
|
|
462
|
+
const gen = newGeneration(canonicalDiscoveryOpts(opts.discoveryOpts));
|
|
463
|
+
generations.set(key, gen);
|
|
464
|
+
gen.bootInFlight = bootWatchers(key, gen, opts);
|
|
368
465
|
try {
|
|
369
|
-
await bootInFlight;
|
|
466
|
+
await gen.bootInFlight;
|
|
370
467
|
} finally {
|
|
371
|
-
bootInFlight = null;
|
|
468
|
+
gen.bootInFlight = null;
|
|
372
469
|
}
|
|
373
470
|
}
|
|
374
471
|
/** The boot pass itself, owned by exactly one caller — `startCollectionWatchers`
|
|
375
472
|
* serialises entry into it. On failure it resets `discoveryOpts` so a
|
|
376
473
|
* supervisor / test harness can retry instead of being permanently latched. */
|
|
377
|
-
async function bootWatchers(opts) {
|
|
474
|
+
async function bootWatchers(key, gen, opts) {
|
|
378
475
|
try {
|
|
379
|
-
await sweepStaleActiveEntries(discoveryOpts);
|
|
380
|
-
await syncWatchers();
|
|
476
|
+
await sweepStaleActiveEntries(gen.discoveryOpts);
|
|
477
|
+
await syncWatchers(gen);
|
|
381
478
|
const intervalMs = opts.rediscoveryIntervalMs === void 0 ? REDISCOVERY_INTERVAL_MS : opts.rediscoveryIntervalMs;
|
|
382
479
|
if (intervalMs !== null) {
|
|
383
|
-
rediscoveryTimer = setInterval(() => {
|
|
384
|
-
syncWatchers().catch((err) => {
|
|
480
|
+
gen.rediscoveryTimer = setInterval(() => {
|
|
481
|
+
syncWatchers(gen).catch((err) => {
|
|
385
482
|
log().warn("watcher rediscovery failed", { error: errMsg(err) });
|
|
386
483
|
});
|
|
387
484
|
}, intervalMs);
|
|
388
|
-
rediscoveryTimer.unref();
|
|
485
|
+
gen.rediscoveryTimer.unref();
|
|
389
486
|
}
|
|
390
487
|
const triggerMs = opts.triggerTickIntervalMs === void 0 ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;
|
|
391
488
|
if (triggerMs !== null) {
|
|
392
|
-
triggerTimer = setInterval(() => {
|
|
393
|
-
if (triggerTickRunning) return;
|
|
394
|
-
triggerTickRunning = true;
|
|
395
|
-
|
|
396
|
-
triggerTickInFlight = tickTimeTriggers().catch((err) => {
|
|
489
|
+
gen.triggerTimer = setInterval(() => {
|
|
490
|
+
if (gen.triggerTickRunning) return;
|
|
491
|
+
gen.triggerTickRunning = true;
|
|
492
|
+
gen.triggerTickInFlight = tickTimeTriggers(gen).catch((err) => {
|
|
397
493
|
log().warn("watcher trigger tick failed", { error: errMsg(err) });
|
|
398
494
|
}).finally(() => {
|
|
399
|
-
if (
|
|
400
|
-
triggerTickRunning = false;
|
|
401
|
-
triggerTickInFlight = null;
|
|
495
|
+
if (!gen.alive) return;
|
|
496
|
+
gen.triggerTickRunning = false;
|
|
497
|
+
gen.triggerTickInFlight = null;
|
|
402
498
|
});
|
|
403
499
|
}, triggerMs);
|
|
404
|
-
triggerTimer.unref();
|
|
500
|
+
gen.triggerTimer.unref();
|
|
405
501
|
}
|
|
406
|
-
started = true;
|
|
502
|
+
gen.started = true;
|
|
407
503
|
} catch (err) {
|
|
408
|
-
|
|
409
|
-
|
|
504
|
+
gen.alive = false;
|
|
505
|
+
generations.delete(key);
|
|
410
506
|
throw err;
|
|
411
507
|
}
|
|
412
508
|
}
|
|
413
|
-
/** Tear down
|
|
414
|
-
*
|
|
509
|
+
/** Tear down watchers and stop their intervals, releasing the root claim so a
|
|
510
|
+
* subsequent `startCollectionWatchers` may re-mount.
|
|
511
|
+
*
|
|
512
|
+
* SCOPE: `{ workspaceRoot }` stops exactly that root's generation and leaves
|
|
513
|
+
* every other project running — what a multi-root host wants when it closes
|
|
514
|
+
* one project. Called with NO options it stops ALL generations, which is what
|
|
515
|
+
* it has always meant for a single-workspace host (and for a test teardown
|
|
516
|
+
* that must not leak a timer into the next file).
|
|
415
517
|
*
|
|
416
|
-
* This is a production API, not a test-only one.
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
518
|
+
* This is a production API, not a test-only one. Await it: it waits out a boot
|
|
519
|
+
* still in flight and a clock pass still running, so the next start does not
|
|
520
|
+
* race the one it replaced. */
|
|
521
|
+
async function stopCollectionWatchers(opts) {
|
|
522
|
+
if (opts?.workspaceRoot !== void 0) {
|
|
523
|
+
const key = generationKey(effectiveRoot(opts));
|
|
524
|
+
const gen = generations.get(key);
|
|
525
|
+
if (gen) await stopGeneration(key, gen);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
for (const [key, gen] of [...generations.entries()]) await stopGeneration(key, gen);
|
|
529
|
+
if (opts?.workspaceRoot === void 0) detachedGeneration = null;
|
|
530
|
+
}
|
|
531
|
+
async function stopGeneration(key, gen) {
|
|
532
|
+
await gen.bootInFlight?.catch(() => {});
|
|
533
|
+
if (gen.rediscoveryTimer) {
|
|
534
|
+
clearInterval(gen.rediscoveryTimer);
|
|
535
|
+
gen.rediscoveryTimer = null;
|
|
426
536
|
}
|
|
427
|
-
if (triggerTimer) {
|
|
428
|
-
clearInterval(triggerTimer);
|
|
429
|
-
triggerTimer = null;
|
|
537
|
+
if (gen.triggerTimer) {
|
|
538
|
+
clearInterval(gen.triggerTimer);
|
|
539
|
+
gen.triggerTimer = null;
|
|
430
540
|
}
|
|
431
|
-
await triggerTickInFlight;
|
|
432
|
-
triggerTickInFlight = null;
|
|
433
|
-
|
|
434
|
-
triggerTickRunning = false;
|
|
435
|
-
for (const watcher of watchers.values()) try {
|
|
541
|
+
await gen.triggerTickInFlight;
|
|
542
|
+
gen.triggerTickInFlight = null;
|
|
543
|
+
gen.alive = false;
|
|
544
|
+
gen.triggerTickRunning = false;
|
|
545
|
+
for (const watcher of gen.watchers.values()) try {
|
|
436
546
|
watcher.unsubscribe();
|
|
437
547
|
} catch {}
|
|
438
|
-
watchers.clear();
|
|
439
|
-
itemSlots.clear();
|
|
440
|
-
collectionSlots.clear();
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
started = false;
|
|
548
|
+
gen.watchers.clear();
|
|
549
|
+
gen.itemSlots.clear();
|
|
550
|
+
gen.collectionSlots.clear();
|
|
551
|
+
gen.started = false;
|
|
552
|
+
generations.delete(key);
|
|
444
553
|
}
|
|
445
554
|
/** Test-only: manually trigger one rediscovery + reconcile pass. */
|
|
446
|
-
async function _syncWatchersForTesting() {
|
|
447
|
-
|
|
555
|
+
async function _syncWatchersForTesting(workspaceRoot) {
|
|
556
|
+
const gen = resolveGeneration(workspaceRoot);
|
|
557
|
+
return gen ? syncWatchers(gen) : false;
|
|
448
558
|
}
|
|
449
559
|
/** Test-only: drive one wall-clock tick synchronously, with an optional
|
|
450
560
|
* injected clock. */
|
|
451
|
-
async function _tickTimeTriggersForTesting(now) {
|
|
452
|
-
|
|
561
|
+
async function _tickTimeTriggersForTesting(now, workspaceRoot) {
|
|
562
|
+
const gen = resolveGeneration(workspaceRoot);
|
|
563
|
+
if (gen) await tickTimeTriggers(gen, now);
|
|
453
564
|
}
|
|
454
565
|
/** Re-reconcile every watched collection that depends on the clock — i.e.
|
|
455
566
|
* declares `triggerField` (a bell that fires at a date) and/or `spawn`
|
|
456
567
|
* (recurrence whose successors come due over time). Collections with
|
|
457
568
|
* neither are skipped. Idempotent. Reads the watcher's cached
|
|
458
569
|
* `collection.schema` to avoid a per-tick disk read. */
|
|
459
|
-
async function tickTimeTriggers(now = evalNow()) {
|
|
460
|
-
for (const entry of watchers.values()) {
|
|
570
|
+
async function tickTimeTriggers(gen, now = evalNow()) {
|
|
571
|
+
for (const entry of gen.watchers.values()) {
|
|
461
572
|
const { schema } = entry.collection;
|
|
462
573
|
if (!schema.triggerField && !schema.spawn) continue;
|
|
463
|
-
await reconcileAllItems(entry.collection, discoveryOpts, now);
|
|
574
|
+
await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
|
|
464
575
|
}
|
|
465
576
|
}
|
|
466
577
|
/** Reconcile the watcher set against the currently-discovered
|
|
@@ -468,30 +579,30 @@ async function tickTimeTriggers(now = evalNow()) {
|
|
|
468
579
|
* their items), drops watchers for vanished slugs, and re-reconciles
|
|
469
580
|
* items for collections whose schema changed. Runs a final sweep when
|
|
470
581
|
* this tick changed the watcher set or any schema. */
|
|
471
|
-
async function syncWatchers() {
|
|
582
|
+
async function syncWatchers(gen) {
|
|
472
583
|
let collections;
|
|
473
584
|
try {
|
|
474
|
-
collections = await require_discovery.discoverCollections(discoveryOpts);
|
|
585
|
+
collections = await require_discovery.discoverCollections(gen.discoveryOpts);
|
|
475
586
|
} catch (err) {
|
|
476
587
|
log().warn("watcher discover failed", { error: errMsg(err) });
|
|
477
588
|
return false;
|
|
478
589
|
}
|
|
479
|
-
const vanishedMutated = stopVanishedWatchers(new Set(collections.map((collection) => collection.slug)));
|
|
480
|
-
const schemaMutated = await reconcileChangedSchemas(collections);
|
|
481
|
-
const addedMutated = await startNewWatchers(collections);
|
|
590
|
+
const vanishedMutated = stopVanishedWatchers(gen, new Set(collections.map((collection) => collection.slug)));
|
|
591
|
+
const schemaMutated = await reconcileChangedSchemas(gen, collections);
|
|
592
|
+
const addedMutated = await startNewWatchers(gen, collections);
|
|
482
593
|
if (!vanishedMutated && !schemaMutated && !addedMutated) return false;
|
|
483
|
-
await sweepStaleActiveEntries(discoveryOpts);
|
|
594
|
+
await sweepStaleActiveEntries(gen.discoveryOpts);
|
|
484
595
|
return true;
|
|
485
596
|
}
|
|
486
|
-
function stopVanishedWatchers(liveSlugs) {
|
|
597
|
+
function stopVanishedWatchers(gen, liveSlugs) {
|
|
487
598
|
let mutated = false;
|
|
488
|
-
for (const slug of [...watchers.keys()]) {
|
|
599
|
+
for (const slug of [...gen.watchers.keys()]) {
|
|
489
600
|
if (liveSlugs.has(slug)) continue;
|
|
490
|
-
const watcher = watchers.get(slug);
|
|
601
|
+
const watcher = gen.watchers.get(slug);
|
|
491
602
|
if (watcher) try {
|
|
492
603
|
watcher.unsubscribe();
|
|
493
604
|
} catch {}
|
|
494
|
-
watchers.delete(slug);
|
|
605
|
+
gen.watchers.delete(slug);
|
|
495
606
|
mutated = true;
|
|
496
607
|
log().info("watcher stopped", { slug });
|
|
497
608
|
}
|
|
@@ -506,10 +617,10 @@ function storagePathChanged(previous, next) {
|
|
|
506
617
|
}
|
|
507
618
|
/** Re-reconcile already-watched collections whose schema changed since
|
|
508
619
|
* the last tick. New collections fall through to `startNewWatchers`. */
|
|
509
|
-
async function reconcileChangedSchemas(collections) {
|
|
620
|
+
async function reconcileChangedSchemas(gen, collections) {
|
|
510
621
|
let mutated = false;
|
|
511
622
|
for (const collection of collections) {
|
|
512
|
-
const existing = watchers.get(collection.slug);
|
|
623
|
+
const existing = gen.watchers.get(collection.slug);
|
|
513
624
|
if (!existing) continue;
|
|
514
625
|
const nextJson = JSON.stringify(collection.schema);
|
|
515
626
|
if (existing.schemaJson === nextJson) continue;
|
|
@@ -518,8 +629,8 @@ async function reconcileChangedSchemas(collections) {
|
|
|
518
629
|
try {
|
|
519
630
|
existing.unsubscribe();
|
|
520
631
|
} catch {}
|
|
521
|
-
watchers.delete(collection.slug);
|
|
522
|
-
if (collection.schema.dataSource !== void 0) safePublish({
|
|
632
|
+
gen.watchers.delete(collection.slug);
|
|
633
|
+
if (collection.schema.dataSource !== void 0) safePublish(gen, {
|
|
523
634
|
slug: collection.slug,
|
|
524
635
|
op: "upsert"
|
|
525
636
|
});
|
|
@@ -529,8 +640,8 @@ async function reconcileChangedSchemas(collections) {
|
|
|
529
640
|
existing.schemaJson = nextJson;
|
|
530
641
|
existing.collection = collection;
|
|
531
642
|
log().info("watcher schema changed, re-reconciling", { slug: collection.slug });
|
|
532
|
-
await reconcileAllItems(collection, discoveryOpts);
|
|
533
|
-
if (collection.schema.dataSource !== void 0) safePublish({
|
|
643
|
+
await reconcileAllItems(collection, gen.discoveryOpts);
|
|
644
|
+
if (collection.schema.dataSource !== void 0) safePublish(gen, {
|
|
534
645
|
slug: collection.slug,
|
|
535
646
|
op: "upsert"
|
|
536
647
|
});
|
|
@@ -544,11 +655,11 @@ async function reconcileChangedSchemas(collections) {
|
|
|
544
655
|
* (logged and swallowed inside the starter) leaves `watchers` untouched, so
|
|
545
656
|
* the collection is retried next tick; counting that as a mutation made
|
|
546
657
|
* `syncWatchers` sweep on every tick for as long as the failure persisted. */
|
|
547
|
-
async function startNewWatchers(collections) {
|
|
658
|
+
async function startNewWatchers(gen, collections) {
|
|
548
659
|
let mutated = false;
|
|
549
660
|
for (const collection of collections) {
|
|
550
|
-
if (watchers.has(collection.slug)) continue;
|
|
551
|
-
if (await startWatcherFor(collection)) mutated = true;
|
|
661
|
+
if (gen.watchers.has(collection.slug)) continue;
|
|
662
|
+
if (await startWatcherFor(gen, collection)) mutated = true;
|
|
552
663
|
}
|
|
553
664
|
return mutated;
|
|
554
665
|
}
|
|
@@ -561,13 +672,13 @@ async function startNewWatchers(collections) {
|
|
|
561
672
|
* A store without `watch` cannot report external changes at all — it is
|
|
562
673
|
* still registered (so bells reconcile at boot and on the clock tick), just
|
|
563
674
|
* without live updates. */
|
|
564
|
-
async function startWatcherFor(collection) {
|
|
675
|
+
async function startWatcherFor(gen, collection) {
|
|
565
676
|
const { slug } = collection;
|
|
566
677
|
try {
|
|
567
|
-
await reconcileAllItems(collection, discoveryOpts);
|
|
568
|
-
const store = require_discovery.storeFor(collection, discoveryOpts);
|
|
678
|
+
await reconcileAllItems(collection, gen.discoveryOpts);
|
|
679
|
+
const store = require_discovery.storeFor(collection, gen.discoveryOpts);
|
|
569
680
|
const unsubscribe = store.watch ? await store.watch((change) => {
|
|
570
|
-
handleStoreChange(slug, change).catch((err) => {
|
|
681
|
+
handleStoreChange(gen, slug, change).catch((err) => {
|
|
571
682
|
log().warn("store change handling failed", {
|
|
572
683
|
slug,
|
|
573
684
|
error: errMsg(err)
|
|
@@ -578,7 +689,7 @@ async function startWatcherFor(collection) {
|
|
|
578
689
|
log().warn("collection watcher could not arm, retrying next sync", { slug });
|
|
579
690
|
return false;
|
|
580
691
|
}
|
|
581
|
-
watchers.set(slug, {
|
|
692
|
+
gen.watchers.set(slug, {
|
|
582
693
|
slug,
|
|
583
694
|
dataDir: collection.dataDir,
|
|
584
695
|
unsubscribe,
|
|
@@ -605,26 +716,26 @@ async function startWatcherFor(collection) {
|
|
|
605
716
|
* store couldn't say which record, so re-derive everything and pair it with
|
|
606
717
|
* a sweep — a record deleted remotely leaves a bell that a walk over the
|
|
607
718
|
* SURVIVING records can never clear. */
|
|
608
|
-
async function handleStoreChange(slug, change) {
|
|
719
|
+
async function handleStoreChange(gen, slug, change) {
|
|
609
720
|
if (change.kind === "collection") {
|
|
610
|
-
await scheduleCollectionReconcile(slug);
|
|
721
|
+
await scheduleCollectionReconcile(gen, slug);
|
|
611
722
|
return;
|
|
612
723
|
}
|
|
613
|
-
const current = watchers.get(slug)?.collection;
|
|
724
|
+
const current = gen.watchers.get(slug)?.collection;
|
|
614
725
|
if (!current) return;
|
|
615
|
-
await scheduleItemReconcile(current, change.itemId);
|
|
726
|
+
await scheduleItemReconcile(gen, current, change.itemId);
|
|
616
727
|
}
|
|
617
728
|
/** Full re-derivation for a collection-granularity change, single-flighted
|
|
618
729
|
* per slug so a burst of writes collapses into one pass plus one trailing
|
|
619
730
|
* re-run. */
|
|
620
|
-
function scheduleCollectionReconcile(slug) {
|
|
621
|
-
return runSingleFlight(collectionSlots, slug, async () => {
|
|
622
|
-
const collection = await require_discovery.loadCollection(slug, discoveryOpts);
|
|
731
|
+
function scheduleCollectionReconcile(gen, slug) {
|
|
732
|
+
return runSingleFlight(gen.collectionSlots, slug, async () => {
|
|
733
|
+
const collection = await require_discovery.loadCollection(slug, gen.discoveryOpts);
|
|
623
734
|
if (!collection) return;
|
|
624
|
-
await reconcileAllItems(collection, discoveryOpts);
|
|
625
|
-
await sweepStaleActiveEntries(discoveryOpts);
|
|
735
|
+
await reconcileAllItems(collection, gen.discoveryOpts);
|
|
736
|
+
await sweepStaleActiveEntries(gen.discoveryOpts);
|
|
626
737
|
}, () => {
|
|
627
|
-
safePublish({
|
|
738
|
+
safePublish(gen, {
|
|
628
739
|
slug,
|
|
629
740
|
op: "upsert"
|
|
630
741
|
});
|
|
@@ -634,18 +745,19 @@ function scheduleCollectionReconcile(slug) {
|
|
|
634
745
|
/** Test-only: feed one store-reported change through the same path a live
|
|
635
746
|
* subscription uses, so a test can pin how a change is reacted to without
|
|
636
747
|
* depending on fs.watch timing. */
|
|
637
|
-
function _handleStoreChangeForTesting(slug, change) {
|
|
638
|
-
|
|
748
|
+
function _handleStoreChangeForTesting(slug, change, workspaceRoot) {
|
|
749
|
+
const gen = resolveGeneration(workspaceRoot);
|
|
750
|
+
return gen ? handleStoreChange(gen, slug, change) : Promise.resolve();
|
|
639
751
|
}
|
|
640
752
|
/** Test-only: drive one collection-granularity reconcile directly. */
|
|
641
|
-
function _scheduleCollectionReconcileForTesting(slug) {
|
|
642
|
-
return scheduleCollectionReconcile(slug);
|
|
753
|
+
function _scheduleCollectionReconcileForTesting(slug, workspaceRoot) {
|
|
754
|
+
return scheduleCollectionReconcile(resolveGeneration(workspaceRoot) ?? fallbackGeneration(), slug);
|
|
643
755
|
}
|
|
644
|
-
function _scheduleItemReconcileForTesting(collection, itemId) {
|
|
645
|
-
return scheduleItemReconcile(collection, itemId);
|
|
756
|
+
function _scheduleItemReconcileForTesting(collection, itemId, workspaceRoot) {
|
|
757
|
+
return scheduleItemReconcile(resolveGeneration(workspaceRoot) ?? fallbackGeneration(), collection, itemId);
|
|
646
758
|
}
|
|
647
|
-
function scheduleItemReconcile(collection, itemId) {
|
|
648
|
-
return runSingleFlight(itemSlots, `${collection.slug}\x00${itemId}`, () => reconcileItem(collection, itemId, discoveryOpts), () => publishItemChange(collection, itemId));
|
|
759
|
+
function scheduleItemReconcile(gen, collection, itemId) {
|
|
760
|
+
return runSingleFlight(gen.itemSlots, `${collection.slug}\x00${itemId}`, () => reconcileItem(collection, itemId, gen.discoveryOpts), () => publishItemChange(gen, collection, itemId));
|
|
649
761
|
}
|
|
650
762
|
/** The shared single-flight loop behind both schedulers. Re-runs `pass`
|
|
651
763
|
* while events keep arriving — the trailing re-run captures any state
|
|
@@ -686,9 +798,9 @@ function runSingleFlight(slots, key, pass, onSettled) {
|
|
|
686
798
|
* of change nor, reliably, which of `rename`/`change` means what. Only
|
|
687
799
|
* file-backed collections reach here; a `storage` (db) collection has no
|
|
688
800
|
* per-record file and publishes wholesale in `scheduleStorageReconcile`. */
|
|
689
|
-
async function publishItemChange(collection, itemId) {
|
|
801
|
+
async function publishItemChange(gen, collection, itemId) {
|
|
690
802
|
const changeOp = await itemFileExists(collection.dataDir, itemId) ? "upsert" : "delete";
|
|
691
|
-
safePublish({
|
|
803
|
+
safePublish(gen, {
|
|
692
804
|
slug: collection.slug,
|
|
693
805
|
ids: [itemId],
|
|
694
806
|
op: changeOp
|
|
@@ -710,8 +822,8 @@ async function itemFileExists(dataDir, itemId) {
|
|
|
710
822
|
* carries only absolute paths, and a root reconstructed by string surgery
|
|
711
823
|
* would be a guess. Undefined in a single-workspace host, which is exactly
|
|
712
824
|
* what the payload contract means by "the host's configured root". */
|
|
713
|
-
function safePublish(payload) {
|
|
714
|
-
const enriched = require_discovery.collectionChangePayload(payload, discoveryOpts.workspaceRoot);
|
|
825
|
+
function safePublish(gen, payload) {
|
|
826
|
+
const enriched = require_discovery.collectionChangePayload(payload, gen.discoveryOpts.workspaceRoot);
|
|
715
827
|
try {
|
|
716
828
|
require_discovery.publishCollectionChange(enriched);
|
|
717
829
|
} catch (err) {
|