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