@ilaomianyang/dsh-memory-graph 0.11.0 → 0.12.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/src/update.js ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Self-update: notice that a newer version exists, then let one decision install it.
3
+ *
4
+ * Two halves, deliberately separated.
5
+ *
6
+ * *Checking* is a single `GET <registry>/<name>/latest` — no query string, no
7
+ * body, nothing about the vault or the user in it — compared against the version
8
+ * this module is running. The answer is cached in the vault (`.update-state.json`)
9
+ * for `update.checkEveryHours`, so a page load does not turn into a request storm
10
+ * and an offline machine just keeps the last answer.
11
+ *
12
+ * *Installing* goes through the host's own `pluginManager.installBundle`, the same
13
+ * pnpm path `dsh plugin` uses, so the plugin does not invent a second way to move
14
+ * packages around and the profile's registry configuration is respected.
15
+ *
16
+ * The one thing that must not happen is a development checkout updating itself:
17
+ * under a `link:` install "install the newest version" would replace the user's
18
+ * working tree with a registry tarball. That is refused, and the refusal is
19
+ * reported as a fact about the installation rather than as an error.
20
+ */
21
+ import fsp from 'node:fs/promises'
22
+
23
+ import { paths } from './config.js'
24
+
25
+ export const UPDATE_PATH = '/api/memory-graph.update'
26
+ export const PACKAGE_NAME = '@ilaomianyang/dsh-memory-graph'
27
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
28
+ const REQUEST_TIMEOUT_MS = 8000
29
+ /** A GET younger than this is answered from the cache even when the caller asks. */
30
+ const FRESH_MS = 60 * 1000
31
+
32
+ /**
33
+ * The version this code is, read from its own manifest.
34
+ *
35
+ * Not a constant: a release bump must not need a second place to be edited, and
36
+ * the file is right there next to `src/`. A missing or unreadable manifest is not
37
+ * fatal — the plugin keeps working and simply cannot compare versions.
38
+ */
39
+ export async function readOwnVersion(url = new URL('../package.json', import.meta.url)) {
40
+ try {
41
+ const manifest = JSON.parse(await fsp.readFile(url, 'utf8'))
42
+ return typeof manifest?.version === 'string' ? manifest.version : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Compare two dotted versions: -1, 0, 1.
50
+ *
51
+ * Deliberately small rather than a semver dependency: the only consumers are a
52
+ * published version and a registry dist-tag, both plain `x.y.z`. A prerelease
53
+ * (`1.0.0-beta.1`) sorts below its release, which is the one rule worth keeping.
54
+ */
55
+ export function compareVersions(left, right) {
56
+ const parse = (text) => {
57
+ const [core, pre = ''] = String(text ?? '').trim().replace(/^v/u, '').split('-', 2)
58
+ const nums = core.split('.').map((part) => {
59
+ const value = Number.parseInt(part, 10)
60
+ return Number.isFinite(value) ? value : 0
61
+ })
62
+ while (nums.length < 3) nums.push(0)
63
+ return { nums: nums.slice(0, 3), pre }
64
+ }
65
+ const a = parse(left)
66
+ const b = parse(right)
67
+ for (let i = 0; i < 3; i += 1) {
68
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] < b.nums[i] ? -1 : 1
69
+ }
70
+ if (a.pre === b.pre) return 0
71
+ // A prerelease is older than the release it precedes; two prereleases compare
72
+ // as text, which is wrong in theory and unreachable for this package.
73
+ if (a.pre === '') return 1
74
+ if (b.pre === '') return -1
75
+ return a.pre < b.pre ? -1 : 1
76
+ }
77
+
78
+ /**
79
+ * What the registry says the newest version is.
80
+ *
81
+ * `latest` as a dist-tag, not "the highest version ever published": a dist-tag is
82
+ * what `npm install` would give you, and it is what a person means by "the newest
83
+ * version".
84
+ */
85
+ export async function fetchLatestVersion({ registry, name = PACKAGE_NAME, timeoutMs = REQUEST_TIMEOUT_MS, fetchFn } = {}) {
86
+ const base = String(registry || DEFAULT_REGISTRY).replace(/\/+$/u, '')
87
+ const url = `${base}/${name.replace('/', '%2f')}/latest`
88
+ const doFetch = fetchFn ?? globalThis.fetch
89
+ if (typeof doFetch !== 'function') throw new Error('这个运行时没有 fetch,查不了新版本 · fetch is unavailable in this runtime')
90
+ const init = { headers: { accept: 'application/json' } }
91
+ if (typeof AbortSignal?.timeout === 'function') init.signal = AbortSignal.timeout(timeoutMs)
92
+ const response = await doFetch(url, init)
93
+ if (!response?.ok) throw new Error(`registry ${base} answered ${response?.status ?? 'nothing'} · 注册表 ${base} 返回 ${response?.status ?? '无响应'}`)
94
+ const body = await response.json()
95
+ const version = typeof body?.version === 'string' ? body.version : ''
96
+ if (version === '') throw new Error(`registry ${base} returned no version · 注册表 ${base} 没有返回版本号`)
97
+ return { version, registry: base, name: body?.name ?? name }
98
+ }
99
+
100
+ function isoAt(ms) {
101
+ const date = new Date(ms)
102
+ const pad = (value) => String(Math.abs(value)).padStart(2, '0')
103
+ const offset = -date.getTimezoneOffset()
104
+ const sign = offset >= 0 ? '+' : '-'
105
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`
106
+ }
107
+
108
+ /**
109
+ * How this plugin is installed, as far as the update decision is concerned.
110
+ *
111
+ * `link:` and `file:` are a working copy (this repository, in development): a
112
+ * registry install would overwrite it, so an update is refused rather than
113
+ * attempted. Anything else — including "cannot tell" — is treated the same way:
114
+ * not updatable until the host itself says the package came from a registry.
115
+ */
116
+ export function installKindOf(bundles, name = PACKAGE_NAME) {
117
+ if (!Array.isArray(bundles)) return 'unknown'
118
+ const mine = bundles.find((bundle) => bundle?.name === name)
119
+ if (!mine) return 'unknown'
120
+ const spec = String(mine?.installTarget?.spec ?? '')
121
+ const source = String(mine?.source ?? '')
122
+ if (/^(link|file|workspace):/u.test(spec) || /^(link|file|workspace):/u.test(source)) return 'link'
123
+ if (mine.availability === 'profile' && spec !== '') return 'registry'
124
+ return 'unknown'
125
+ }
126
+
127
+ /**
128
+ * The checker. One instance per activation, shared by the route, the startup
129
+ * check and `memory_stats`.
130
+ */
131
+ export function createUpdateChecker({ cfg, log = () => {}, getPluginManager, getName = () => PACKAGE_NAME, now = () => Date.now(), readVersion = readOwnVersion, fetchFn } = {}) {
132
+ let current = null
133
+ let cached = null
134
+ let busy = false
135
+ let lastError = null
136
+ let loading = null
137
+
138
+ const settings = () => cfg?.update ?? {}
139
+ const stateFile = () => (cfg?.vaultDir ? paths(String(cfg.vaultDir)).updateState : null)
140
+
141
+ async function version() {
142
+ if (current === null) current = await readVersion()
143
+ return current
144
+ }
145
+
146
+ async function loadCache() {
147
+ if (cached !== null) return cached
148
+ cached = { checkedAt: null, latest: null, registry: null, error: null }
149
+ const file = stateFile()
150
+ if (file === null) return cached
151
+ try {
152
+ const parsed = JSON.parse(await fsp.readFile(file, 'utf8'))
153
+ const at = Number(parsed?.checkedAtMs)
154
+ cached = {
155
+ checkedAt: Number.isFinite(at) && at > 0 ? at : null,
156
+ latest: typeof parsed?.latest === 'string' ? parsed.latest : null,
157
+ registry: typeof parsed?.registry === 'string' ? parsed.registry : null,
158
+ error: typeof parsed?.error === 'string' ? parsed.error : null,
159
+ }
160
+ } catch {
161
+ // A missing or broken cache is the normal first run, not a failure.
162
+ }
163
+ return cached
164
+ }
165
+
166
+ async function saveCache(next) {
167
+ cached = next
168
+ const file = stateFile()
169
+ if (file === null) return
170
+ try {
171
+ await fsp.mkdir(String(cfg.vaultDir), { recursive: true })
172
+ await fsp.writeFile(
173
+ file,
174
+ `${JSON.stringify({ checkedAtMs: next.checkedAt, latest: next.latest, registry: next.registry, error: next.error }, null, 2)}\n`,
175
+ 'utf8',
176
+ )
177
+ } catch (error) {
178
+ log('warn', `自动更新:没能记下检查结果 · update: could not write the check cache: ${error?.message ?? error}`)
179
+ }
180
+ }
181
+
182
+ async function registryOf() {
183
+ const configured = String(settings().registry || '')
184
+ if (configured !== '') return configured
185
+ try {
186
+ const manager = getPluginManager?.()
187
+ const resolved = typeof manager?.registries === 'function' ? await manager.registries() : null
188
+ if (typeof resolved?.registry === 'string' && resolved.registry !== '') return resolved.registry
189
+ } catch {
190
+ // No plugin manager, or a profile that will not say: npm's own registry.
191
+ }
192
+ return DEFAULT_REGISTRY
193
+ }
194
+
195
+ async function refresh() {
196
+ const at = now()
197
+ const own = await version()
198
+ try {
199
+ const found = await fetchLatestVersion({ registry: await registryOf(), fetchFn })
200
+ const next = { checkedAt: at, latest: found.version, registry: found.registry, error: null }
201
+ await saveCache(next)
202
+ lastError = null
203
+ log('info', `自动更新:最新版本 ${found.version},当前 ${own ?? '未知'}`)
204
+ return next
205
+ } catch (error) {
206
+ // Keep the previous answer and its timestamp: an offline machine should not
207
+ // forget what it knew, and a failed check must not look like "up to date".
208
+ const previous = await loadCache()
209
+ lastError = String(error?.message ?? error)
210
+ await saveCache({ ...previous, error: lastError })
211
+ log('warn', `自动更新:查不到新版本 · update: could not reach the registry: ${lastError}`)
212
+ return { ...previous, error: lastError }
213
+ }
214
+ }
215
+
216
+ async function ensureFresh(force = false) {
217
+ const cache = await loadCache()
218
+ const ttl = Math.max(1, Number(settings().checkEveryHours ?? 12)) * 3600 * 1000
219
+ const age = cache.checkedAt === null ? Number.POSITIVE_INFINITY : now() - cache.checkedAt
220
+ if (force && age >= FRESH_MS) return refresh()
221
+ if (age < ttl) return cache
222
+ if (loading !== null) return loading
223
+ loading = refresh().finally(() => {
224
+ loading = null
225
+ })
226
+ return loading
227
+ }
228
+
229
+ async function installKind() {
230
+ try {
231
+ const manager = getPluginManager?.()
232
+ if (typeof manager?.listBundles !== 'function') return 'unknown'
233
+ return installKindOf(await manager.listBundles(), getName())
234
+ } catch {
235
+ return 'unknown'
236
+ }
237
+ }
238
+
239
+ /** The shape the Settings page and the tests read. */
240
+ async function status({ force = false } = {}) {
241
+ const own = await version()
242
+ if (settings().enabled === false) {
243
+ return {
244
+ ok: true,
245
+ current: own,
246
+ latest: null,
247
+ behind: false,
248
+ canCheck: false,
249
+ canInstall: false,
250
+ installKind: 'unknown',
251
+ checkedAt: null,
252
+ registry: await registryOf(),
253
+ busy,
254
+ message: '自动检查更新已在设置里关掉 · update checks are off',
255
+ error: null,
256
+ }
257
+ }
258
+ const cache = await ensureFresh(force)
259
+ const kind = await installKind()
260
+ const behind = own !== null && cache.latest !== null ? compareVersions(cache.latest, own) > 0 : false
261
+ const canInstall = behind && kind === 'registry' && !busy
262
+ let message = null
263
+ if (cache.error !== null && cache.latest === null) message = `查不到新版本 · could not check: ${cache.error}`
264
+ else if (cache.latest === null) message = null
265
+ else if (behind) message = `有新版本 ${cache.latest} · ${own ?? '?'} → ${cache.latest}`
266
+ else message = `已是最新版本 ${own ?? cache.latest}`
267
+ if (behind && kind === 'link') message = `${message};当前是本地 link: 安装,不能自动更新`
268
+ return {
269
+ ok: true,
270
+ current: own,
271
+ latest: cache.latest,
272
+ behind,
273
+ canCheck: true,
274
+ canInstall,
275
+ installKind: kind,
276
+ checkedAt: cache.checkedAt === null ? null : isoAt(cache.checkedAt),
277
+ registry: cache.registry ?? (await registryOf()),
278
+ busy,
279
+ message,
280
+ error: cache.error,
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Install the newest version through the host.
286
+ *
287
+ * Refusals are returned, not thrown: every one of them is something a person
288
+ * needs to read (development install, nothing to update, no plugin manager),
289
+ * and the difference between "refused" and "failed" is worth keeping.
290
+ */
291
+ async function install() {
292
+ if (busy) return { ok: false, applied: false, application: 'failed', version: null, message: '已经有一次更新在跑 · an update is already running', error: null }
293
+ const state = await status({ force: true })
294
+ if (state.error !== null && state.latest === null) {
295
+ return { ok: false, applied: false, application: 'failed', version: null, message: `查不到新版本,装不了 · could not check: ${state.error}`, error: state.error }
296
+ }
297
+ if (!state.behind) {
298
+ return { ok: false, applied: false, application: 'failed', version: state.current, message: `已经是最新版本 ${state.current ?? ''} · already up to date`, error: null }
299
+ }
300
+ if (state.installKind === 'link') {
301
+ return {
302
+ ok: false,
303
+ applied: false,
304
+ application: 'failed',
305
+ version: state.latest,
306
+ message: '当前不是从 npm 安装的(link: 本地安装),自动更新会覆盖你的开发目录,已拒绝 · this is a link: install, so updating would overwrite the working copy',
307
+ error: null,
308
+ }
309
+ }
310
+ const manager = getPluginManager?.()
311
+ if (typeof manager?.installBundle !== 'function') {
312
+ return { ok: false, applied: false, application: 'failed', version: state.latest, message: '宿主没有提供 pluginManager,装不了 · the host exposes no pluginManager', error: null }
313
+ }
314
+ busy = true
315
+ try {
316
+ const spec = `${getName()}@${state.latest}`
317
+ log('info', `自动更新:正在安装 ${spec}`)
318
+ const result = await manager.installBundle(spec, { enabled: true })
319
+ const application = String(result?.application ?? 'failed')
320
+ const applied = application === 'applied' || application === 'restart-required'
321
+ if (applied) {
322
+ // The running code is now a version older than the one on disk; forget the
323
+ // cache so the next status is honest about what is installed.
324
+ cached = null
325
+ }
326
+ const message = applied
327
+ ? application === 'restart-required'
328
+ ? `已安装 ${state.latest},需要重启 Harness 生效 · installed, restart the harness to apply`
329
+ : `已更新到 ${state.latest} · updated to ${state.latest}`
330
+ : `更新没有成功:${result?.error?.diagnostic ?? result?.error?.code ?? application} · update did not apply`
331
+ log(applied ? 'info' : 'warn', `自动更新:${message}`)
332
+ return {
333
+ ok: applied,
334
+ applied,
335
+ application,
336
+ version: applied ? (result?.version ?? state.latest) : state.latest,
337
+ message,
338
+ error: applied ? null : String(result?.error?.diagnostic ?? result?.error?.code ?? ''),
339
+ }
340
+ } catch (error) {
341
+ const message = String(error?.message ?? error)
342
+ log('warn', `自动更新:安装失败 · update install failed: ${message}`)
343
+ return { ok: false, applied: false, application: 'failed', version: state.latest, message: `安装失败:${message}`, error: message }
344
+ } finally {
345
+ busy = false
346
+ }
347
+ }
348
+
349
+ /** Startup hook: check once, and install only when that was asked for. */
350
+ async function onStart() {
351
+ if (settings().enabled === false) return null
352
+ try {
353
+ const state = await status()
354
+ if (state.behind && settings().autoInstall === true && state.installKind === 'registry') {
355
+ return await install()
356
+ }
357
+ return state
358
+ } catch (error) {
359
+ log('warn', `自动更新:启动检查失败 · update: startup check failed: ${error?.message ?? error}`)
360
+ return null
361
+ }
362
+ }
363
+
364
+ return { status, install, onStart, version, peek: () => ({ current, latest: cached?.latest ?? null, checkedAt: cached?.checkedAt ?? null, busy }), lastError: () => lastError }
365
+ }
366
+
367
+ /**
368
+ * The route. One path, two methods: GET answers "am I behind?", POST answers
369
+ * "then update" — and POST is the only verb that can touch the installation, so a
370
+ * page load or a prefetch can never do it by accident.
371
+ */
372
+ export function createUpdateHandler({ getChecker }) {
373
+ return async function handle(request) {
374
+ const checker = getChecker()
375
+ if (!checker) {
376
+ return new Response(JSON.stringify({ ok: false, error: '更新检查还没有就绪 · the update checker is not ready' }), {
377
+ status: 503,
378
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
379
+ })
380
+ }
381
+ try {
382
+ if ((request?.method ?? 'GET').toUpperCase() === 'POST') {
383
+ const result = await checker.install()
384
+ // 200 when it landed, 409 when it was refused (a link: install, nothing to
385
+ // update, no plugin manager). "Refused" and "failed" are both non-2xx on
386
+ // purpose: neither should look like a version change to a caller.
387
+ return new Response(JSON.stringify(result), {
388
+ status: result.ok ? 200 : 409,
389
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
390
+ })
391
+ }
392
+ const url = new URL(request?.url ?? 'http://localhost/')
393
+ const force = url.searchParams.get('force') === '1'
394
+ const state = await checker.status({ force })
395
+ return new Response(JSON.stringify(state), {
396
+ status: 200,
397
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
398
+ })
399
+ } catch (error) {
400
+ return new Response(JSON.stringify({ ok: false, error: `更新检查失败:${error?.message ?? error}` }), {
401
+ status: 500,
402
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
403
+ })
404
+ }
405
+ }
406
+ }
package/src/vault.js CHANGED
@@ -260,7 +260,7 @@ export function createVault(cfg, redactor) {
260
260
  // The vault is plain text so the owner can version it themselves; keep the
261
261
  // OS junk and the per-machine distillation cursors out of that repo.
262
262
  if (!(await exists(P.gitignore))) {
263
- await writeAtomic(P.gitignore, '*.tmp\n.DS_Store\nThumbs.db\n.distill-state.json\n')
263
+ await writeAtomic(P.gitignore, '*.tmp\n.DS_Store\nThumbs.db\n.distill-state.json\n.auto-state.json\n.update-state.json\n')
264
264
  }
265
265
  })()
266
266
  return ready
package/test/auto.mjs CHANGED
@@ -125,6 +125,74 @@ await check('a threshold with no clock still fires at once: that rule is the pil
125
125
  assert.equal(calls.length, 1)
126
126
  })
127
127
 
128
+ /* -------------------------------------------------------------- catch-up */
129
+
130
+ const CATCH_UP = { ...AUTO, enabled: true, minEpisodes: 0, everyMinutes: 0, minGapMinutes: 30, catchUp: true, catchUpEveryMinutes: 10, maxBatchesPerDay: 3 }
131
+
132
+ await check('catch-up is off unless asked for: a full batch is not a queue', () => {
133
+ const base = { auto: { ...AUTO, minEpisodes: 0, everyMinutes: 0 }, now: 5_000_000, lastAttemptAt: null, pending: 500, batchSize: 40 }
134
+ assert.equal(autoDecision(base).due, false)
135
+ })
136
+
137
+ await check('catch-up runs on its own cadence even though minGapMinutes is longer', async () => {
138
+ const { distiller, calls, clock } = makeDistiller({ auto: CATCH_UP, pending: 500 })
139
+ const first = await distiller.tick()
140
+ assert.equal(first.rule, 'catch-up', 'a backlog is due on the first tick')
141
+ assert.equal(first.batchesToday, 1)
142
+ assert.equal(calls.length, 1)
143
+ clock.at += 5 * MINUTE
144
+ const tooSoon = await distiller.tick()
145
+ assert.equal(tooSoon.due, false, 'ten minutes is the catch-up cadence, five is not enough')
146
+ assert.match(tooSoon.reason, /catch-up gap/u)
147
+ clock.at += 6 * MINUTE
148
+ const again = await distiller.tick()
149
+ assert.equal(again.rule, 'catch-up')
150
+ assert.equal(again.batchesToday, 2)
151
+ assert.equal(calls.length, 2)
152
+ })
153
+
154
+ await check('catch-up stops at the daily cap and starts again the next day', async () => {
155
+ const { distiller, calls, clock, vault } = makeDistiller({ auto: CATCH_UP, pending: 500 })
156
+ for (let i = 0; i < 3; i += 1) {
157
+ assert.equal((await distiller.tick()).rule, 'catch-up')
158
+ clock.at += 11 * MINUTE
159
+ }
160
+ const capped = await distiller.tick()
161
+ assert.equal(capped.due, false)
162
+ assert.match(capped.reason, /上限 3/u)
163
+ assert.equal(calls.length, 3, 'three batches are the whole day')
164
+ assert.equal(vault.written.batchesToday, 3)
165
+
166
+ clock.at += 24 * 60 * MINUTE
167
+ const tomorrow = await distiller.tick()
168
+ assert.equal(tomorrow.rule, 'catch-up')
169
+ assert.equal(tomorrow.batchesToday, 1, 'a new day resets the counter')
170
+ })
171
+
172
+ await check('a pile smaller than one batch is not catch-up work', async () => {
173
+ const { distiller, calls } = makeDistiller({ auto: CATCH_UP, pending: 12 })
174
+ const decision = await distiller.tick()
175
+ assert.equal(decision.due, false, 'twelve episodes wait for a schedule or a threshold')
176
+ assert.equal(calls.length, 0)
177
+ })
178
+
179
+ await check('the daily counter is read back from the vault, not assumed to be zero', async () => {
180
+ const clock = { at: new Date(2026, 10, 3, 9, 0, 0).getTime() }
181
+ const state = { batchDay: '2026-11-03', batchesToday: 3, lastAttemptAt: clock.at - 20 * MINUTE }
182
+ const { distiller, calls } = makeDistiller({ auto: CATCH_UP, pending: 500, state, clock })
183
+ const decision = await distiller.tick()
184
+ assert.equal(decision.due, false, 'a restart must not hand out a fresh daily allowance')
185
+ assert.match(decision.reason, /上限 3/u)
186
+ assert.equal(calls.length, 0)
187
+ })
188
+
189
+ await check('quiet hours outrank catch-up', async () => {
190
+ const at = new Date(2026, 10, 3, 2, 0, 0).getTime()
191
+ const decision = autoDecision({ auto: { ...CATCH_UP, quietHours: '23:00-08:00' }, now: at, lastAttemptAt: null, pending: 500, batchSize: 40 })
192
+ assert.equal(decision.due, false)
193
+ assert.equal(decision.reason, 'quiet hours')
194
+ })
195
+
128
196
  await check('both rules can be on: whichever is satisfied first runs', () => {
129
197
  const auto = { ...AUTO, minEpisodes: 10, everyMinutes: 60 }
130
198
  const now = 100 * MINUTE
package/test/client.mjs CHANGED
@@ -321,7 +321,7 @@ await check('the page renders every knob the served value makes visible', () =>
321
321
  try {
322
322
  const expected = visibleFields(SERVED)
323
323
  // The harness route hides the seven http-only knobs and the provider field.
324
- assert.equal(expected.length, 27, `expected 27 visible fields, spec says ${expected.length}`)
324
+ assert.equal(expected.length, 34, `expected 34 visible fields, spec says ${expected.length}`)
325
325
  for (const field of expected) {
326
326
  const key = field.path.join('.')
327
327
  assert.ok(control(instance.tree, key), `${key} has no control on the page`)
@@ -335,18 +335,18 @@ await check('the page renders every knob the served value makes visible', () =>
335
335
  }
336
336
  })
337
337
 
338
- await check('the five sections are all there, each with a title', () => {
338
+ await check('the six sections are all there, each with a title', () => {
339
339
  const instance = render(makeForm(clone(SERVED)))
340
340
  try {
341
341
  const titles = byClass(instance.tree, 'mg-card-t').map(text)
342
- assert.equal(titles.length, 5)
342
+ assert.equal(titles.length, 6)
343
343
  for (const title of titles) assert.ok(title.length > 0)
344
344
  } finally {
345
345
  instance.unmount()
346
346
  }
347
347
  })
348
348
 
349
- await check('the automatic distiller has both triggers on the page, each with its own switch', async () => {
349
+ await check('the automatic distiller has its three rules on the page, each with its own switch', async () => {
350
350
  const form = makeForm(clone(SERVED))
351
351
  const instance = render(form)
352
352
  try {
@@ -354,7 +354,17 @@ await check('the automatic distiller has both triggers on the page, each with it
354
354
  assert.ok(section, 'automatic distillation needs a section of its own, not a corner of another one')
355
355
  assert.deepEqual(
356
356
  section.fields.map((field) => field.path.join('.')),
357
- ['auto.enabled', 'auto.minEpisodes', 'auto.everyMinutes', 'auto.minGapMinutes', 'auto.quietHours', 'auto.project'],
357
+ [
358
+ 'auto.enabled',
359
+ 'auto.minEpisodes',
360
+ 'auto.everyMinutes',
361
+ 'auto.minGapMinutes',
362
+ 'auto.quietHours',
363
+ 'auto.project',
364
+ 'auto.catchUp',
365
+ 'auto.catchUpEveryMinutes',
366
+ 'auto.maxBatchesPerDay',
367
+ ],
358
368
  )
359
369
  // Both rules show as 0 — "this rule is off" — rather than as a blank.
360
370
  assert.equal(control(instance.tree, 'auto.minEpisodes').props.value, '0')