@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/CHANGELOG.md +19 -0
- package/README.en.md +52 -12
- package/README.md +52 -12
- package/client.js +432 -0
- package/docs/update-settings.png +0 -0
- package/index.js +42 -3
- package/package.json +2 -2
- package/src/auto.js +68 -7
- package/src/config.js +37 -0
- package/src/history.js +27 -0
- package/src/schema.js +55 -0
- package/src/tools.js +24 -1
- package/src/update.js +406 -0
- package/src/vault.js +1 -1
- package/test/auto.mjs +68 -0
- package/test/client.mjs +15 -5
- package/test/update.mjs +401 -0
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
[
|
|
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')
|