dsh-instruction-memory 1.4.0 → 1.4.2
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 +158 -0
- package/README.md +32 -11
- package/docs/screenshot.png +0 -0
- package/lib/client.js +391 -81
- package/lib/index.js +217 -72
- package/lib/mailer.js +141 -19
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { readFileSync, watch } from 'node:fs'
|
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import { isIP } from 'node:net'
|
|
32
32
|
import { homedir, hostname } from 'node:os'
|
|
33
|
-
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
33
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
35
|
import {
|
|
36
36
|
FEEDBACK_ENV,
|
|
@@ -93,6 +93,15 @@ const MAX_SETS = 20
|
|
|
93
93
|
const MAX_SET_NAME = 30
|
|
94
94
|
/** Rolling history snapshots (F14): how many are kept PER writing device. */
|
|
95
95
|
const MAX_HISTORY = 20
|
|
96
|
+
/**
|
|
97
|
+
* Byte ceiling for one device's snapshots (F14). The count alone bounded
|
|
98
|
+
* nothing in practice: at the 200 × 6,000-character cap a single snapshot is
|
|
99
|
+
* ~3.6 MB, so twenty of them were ~72 MB per device — and a custom backup
|
|
100
|
+
* directory inside a cloud-synced folder meant that much upload traffic for
|
|
101
|
+
* one checkbox toggle. Newest snapshots win; the newest is always kept even
|
|
102
|
+
* when it alone exceeds the budget.
|
|
103
|
+
*/
|
|
104
|
+
const MAX_HISTORY_BYTES = 32 * 1024 * 1024
|
|
96
105
|
/**
|
|
97
106
|
* F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
|
|
98
107
|
* far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
|
|
@@ -165,7 +174,14 @@ const PLUGIN_VERSION = (() => {
|
|
|
165
174
|
*/
|
|
166
175
|
export function resolveStorePath(env = process.env, home = homedir()) {
|
|
167
176
|
const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
168
|
-
|
|
177
|
+
// A relative override (`DSH_HOME=./data`) is resolved ONCE against the
|
|
178
|
+
// process cwd. Without this the store path stayed relative for the whole
|
|
179
|
+
// process: the settings page promised an absolute path (README), the
|
|
180
|
+
// watcher's dirname() became '.', and the "same file" identity depended on
|
|
181
|
+
// how the path was spelled. Resolving does not make a relative override
|
|
182
|
+
// cwd-independent — it cannot be — but it pins one absolute location for
|
|
183
|
+
// this process and keeps every displayed path absolute.
|
|
184
|
+
const root = configured !== '' ? resolve(configured) : join(home, '.dsh')
|
|
169
185
|
return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
|
|
170
186
|
}
|
|
171
187
|
|
|
@@ -512,6 +528,41 @@ export function exportFileName(epoch) {
|
|
|
512
528
|
return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
|
|
513
529
|
}
|
|
514
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Which snapshots survive retention, for ONE writing device.
|
|
533
|
+
*
|
|
534
|
+
* Two ceilings apply together: the count (MAX_HISTORY) and a byte budget
|
|
535
|
+
* (MAX_HISTORY_BYTES). Counting alone bounded nothing at the top end — see the
|
|
536
|
+
* constant's note — while a byte budget alone would let a flood of tiny saves
|
|
537
|
+
* grow the directory without limit. The newest snapshot is always kept, even
|
|
538
|
+
* when it alone exceeds the byte budget: a history that prunes its own latest
|
|
539
|
+
* version is worse than a large one.
|
|
540
|
+
*
|
|
541
|
+
* Pure and exported so the policy is unit-testable without writing 32 MB.
|
|
542
|
+
*
|
|
543
|
+
* @param {{ name: string, size: number }[]} items ascending by name (chronological)
|
|
544
|
+
* @returns {{ keep: string[], remove: string[] }} `remove` is oldest-first
|
|
545
|
+
*/
|
|
546
|
+
export function planHistoryPrune(items, maxCount = MAX_HISTORY, maxBytes = MAX_HISTORY_BYTES) {
|
|
547
|
+
const list = (Array.isArray(items) ? items : [])
|
|
548
|
+
.filter((item) => item !== null && typeof item === 'object' && typeof item.name === 'string' && item.name !== '')
|
|
549
|
+
const keep = []
|
|
550
|
+
let bytes = 0
|
|
551
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
552
|
+
const size = Number.isFinite(list[index].size) ? Math.max(0, list[index].size) : 0
|
|
553
|
+
// keep.length === 0 keeps the newest unconditionally (see above).
|
|
554
|
+
if (keep.length === 0 || (keep.length < maxCount && bytes + size <= maxBytes)) {
|
|
555
|
+
keep.push(list[index].name)
|
|
556
|
+
bytes += size
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const kept = new Set(keep)
|
|
560
|
+
return {
|
|
561
|
+
keep,
|
|
562
|
+
remove: list.filter((item) => !kept.has(item.name)).map((item) => item.name),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
515
566
|
/**
|
|
516
567
|
* Which fields of a raw entry had to be cut to fit the stored limits.
|
|
517
568
|
*
|
|
@@ -748,6 +799,28 @@ function normalizeData(raw) {
|
|
|
748
799
|
return loaded
|
|
749
800
|
}
|
|
750
801
|
|
|
802
|
+
/**
|
|
803
|
+
* The scalar half of the store shape, in ONE place.
|
|
804
|
+
*
|
|
805
|
+
* The boot state, normalizeData, persistableData, cloneData and snapshot() all
|
|
806
|
+
* spell these fields out. A new field added to four of them and forgotten in
|
|
807
|
+
* the fifth is dropped on save, lost on rollback, or invisible to the panel —
|
|
808
|
+
* the boot state's own comment already warns about exactly that drift. The
|
|
809
|
+
* array halves stay explicit because each caller transforms them differently
|
|
810
|
+
* (copy, filter to the active set, or map to {id,name}).
|
|
811
|
+
*/
|
|
812
|
+
const storeScalars = (data) => ({
|
|
813
|
+
version: FORMAT_VERSION,
|
|
814
|
+
rev: typeof data.rev === 'number' && Number.isFinite(data.rev) && data.rev > 0 ? data.rev : 0,
|
|
815
|
+
enabled: data.enabled === true,
|
|
816
|
+
disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
|
|
817
|
+
? data.disabledUntil
|
|
818
|
+
: 0,
|
|
819
|
+
budgetChars: clampBudget(data.budgetChars),
|
|
820
|
+
backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
|
|
821
|
+
activeSetId: data.activeSetId,
|
|
822
|
+
})
|
|
823
|
+
|
|
751
824
|
/**
|
|
752
825
|
* A structural copy of the store, shallow per entry.
|
|
753
826
|
*
|
|
@@ -759,15 +832,7 @@ function normalizeData(raw) {
|
|
|
759
832
|
*/
|
|
760
833
|
function cloneData(data) {
|
|
761
834
|
return {
|
|
762
|
-
|
|
763
|
-
rev: data.rev || 0,
|
|
764
|
-
enabled: data.enabled === true,
|
|
765
|
-
disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
|
|
766
|
-
? data.disabledUntil
|
|
767
|
-
: 0,
|
|
768
|
-
budgetChars: data.budgetChars,
|
|
769
|
-
backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
|
|
770
|
-
activeSetId: data.activeSetId,
|
|
835
|
+
...storeScalars(data),
|
|
771
836
|
sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
|
|
772
837
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
773
838
|
profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
|
|
@@ -1290,17 +1355,19 @@ export function apply(ctx) {
|
|
|
1290
1355
|
|
|
1291
1356
|
const state = {
|
|
1292
1357
|
data: {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1358
|
+
// Scalars come from the shared shape (see storeScalars) so this literal
|
|
1359
|
+
// cannot drift from normalizeData / persistableData / snapshot. The key
|
|
1360
|
+
// ORDER still matters: the watcher's self-write detection compares JSON
|
|
1361
|
+
// strings, and a missing or reordered key made every fresh process's
|
|
1362
|
+
// first save look like an external change (one redundant reload + frame).
|
|
1363
|
+
...storeScalars({
|
|
1364
|
+
rev: 0,
|
|
1365
|
+
enabled: true,
|
|
1366
|
+
disabledUntil: 0,
|
|
1367
|
+
budgetChars: DEFAULT_BUDGET,
|
|
1368
|
+
backupDir: '',
|
|
1369
|
+
activeSetId: DEFAULT_SET_ID,
|
|
1370
|
+
}),
|
|
1304
1371
|
sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
|
|
1305
1372
|
entries: [],
|
|
1306
1373
|
profiles: [],
|
|
@@ -1525,7 +1592,20 @@ export function apply(ctx) {
|
|
|
1525
1592
|
const wanted = sectionText() !== ''
|
|
1526
1593
|
if (wanted && sectionDispose === null) {
|
|
1527
1594
|
try {
|
|
1528
|
-
|
|
1595
|
+
// `interpolate: false` is load-bearing, not a hint. The host runs every
|
|
1596
|
+
// section through `{{variable}}` interpolation by DEFAULT and THROWS on
|
|
1597
|
+
// a reference it cannot resolve (`unknown prompt variable "{{x}}" in
|
|
1598
|
+
// section "instruction-memory"`). Instruction memory is user prose — an
|
|
1599
|
+
// nginx snippet (`proxy_pass {{upstream}};`), a Vue/Jinja/Handlebars
|
|
1600
|
+
// template or any `{{…}}` pair — so the default would abort the whole
|
|
1601
|
+
// agent turn at renderPrompt(). This section is literal text and never
|
|
1602
|
+
// references a host variable, so opt out explicitly.
|
|
1603
|
+
sectionDispose = systemPrompt.section({
|
|
1604
|
+
name: SECTION_NAME,
|
|
1605
|
+
order: SECTION_ORDER,
|
|
1606
|
+
text: assembledSectionText,
|
|
1607
|
+
interpolate: false,
|
|
1608
|
+
})
|
|
1529
1609
|
state.sectionRegistered = true
|
|
1530
1610
|
state.sectionError = null
|
|
1531
1611
|
logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
|
|
@@ -1576,10 +1656,12 @@ export function apply(ctx) {
|
|
|
1576
1656
|
/* ---------------- external-change watching (SSE) ---------------- */
|
|
1577
1657
|
|
|
1578
1658
|
/**
|
|
1579
|
-
* Live subscribers to the settings page's event stream. Each entry
|
|
1580
|
-
*
|
|
1581
|
-
* (the socket went away) can dispose THAT stream instead of just
|
|
1582
|
-
* it from the broadcast set while its response stays open.
|
|
1659
|
+
* Live subscribers to the settings page's event stream. Each entry is
|
|
1660
|
+
* `{ cleanup, lite }`: `cleanup` is that stream's own teardown, so a write
|
|
1661
|
+
* that fails (the socket went away) can dispose THAT stream instead of just
|
|
1662
|
+
* dropping it from the broadcast set while its response stays open. `lite`
|
|
1663
|
+
* records whether the subscriber asked for lightweight frames (see
|
|
1664
|
+
* pushSnapshot).
|
|
1583
1665
|
*/
|
|
1584
1666
|
const sseStreams = new Map()
|
|
1585
1667
|
let storeWatcher = null
|
|
@@ -1587,14 +1669,25 @@ export function apply(ctx) {
|
|
|
1587
1669
|
|
|
1588
1670
|
const pushSnapshot = () => {
|
|
1589
1671
|
if (disposed || sseStreams.size === 0) return
|
|
1590
|
-
|
|
1591
|
-
|
|
1672
|
+
// A lite stream only needs to know that the store moved: it fetches the
|
|
1673
|
+
// snapshot through POST `state` when the rev it sees differs from the one
|
|
1674
|
+
// on screen. The full frame is built LAZILY, so a page whose subscribers
|
|
1675
|
+
// are all lite (the current client) never pays for serialising the whole
|
|
1676
|
+
// store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
|
|
1677
|
+
const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
|
|
1678
|
+
let fullPayload = null
|
|
1679
|
+
for (const [send, entry] of Array.from(sseStreams)) {
|
|
1592
1680
|
try {
|
|
1593
|
-
|
|
1681
|
+
if (entry.lite === true) {
|
|
1682
|
+
send(litePayload)
|
|
1683
|
+
} else {
|
|
1684
|
+
if (fullPayload === null) fullPayload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
1685
|
+
send(fullPayload)
|
|
1686
|
+
}
|
|
1594
1687
|
} catch {
|
|
1595
1688
|
// The response is dead: run its full teardown (end + unsubscribe),
|
|
1596
1689
|
// not just a set delete that leaves the stream half-registered.
|
|
1597
|
-
cleanup()
|
|
1690
|
+
entry.cleanup()
|
|
1598
1691
|
}
|
|
1599
1692
|
}
|
|
1600
1693
|
}
|
|
@@ -1674,9 +1767,9 @@ export function apply(ctx) {
|
|
|
1674
1767
|
}
|
|
1675
1768
|
|
|
1676
1769
|
const disposeStreams = () => {
|
|
1677
|
-
for (const
|
|
1770
|
+
for (const entry of Array.from(sseStreams.values())) {
|
|
1678
1771
|
try {
|
|
1679
|
-
cleanup()
|
|
1772
|
+
entry.cleanup()
|
|
1680
1773
|
} catch {
|
|
1681
1774
|
// The response is already gone.
|
|
1682
1775
|
}
|
|
@@ -1698,15 +1791,7 @@ export function apply(ctx) {
|
|
|
1698
1791
|
*/
|
|
1699
1792
|
const persistableData = () => ({
|
|
1700
1793
|
$schema: SCHEMA_REF,
|
|
1701
|
-
|
|
1702
|
-
rev: state.data.rev || 0,
|
|
1703
|
-
enabled: state.data.enabled === true,
|
|
1704
|
-
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
1705
|
-
? state.data.disabledUntil
|
|
1706
|
-
: 0,
|
|
1707
|
-
budgetChars: clampBudget(state.data.budgetChars),
|
|
1708
|
-
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
1709
|
-
activeSetId: state.data.activeSetId,
|
|
1794
|
+
...storeScalars(state.data),
|
|
1710
1795
|
sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
|
|
1711
1796
|
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
1712
1797
|
profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
|
|
@@ -1766,6 +1851,14 @@ export function apply(ctx) {
|
|
|
1766
1851
|
const token = randomUUID()
|
|
1767
1852
|
const deadline = Date.now() + LOCK_WAIT_MS
|
|
1768
1853
|
for (;;) {
|
|
1854
|
+
// The deadline is checked at the TOP of every attempt, not only on the
|
|
1855
|
+
// "someone else holds it" branch. It used to sit below the stale-lock
|
|
1856
|
+
// branch, which `continue`d after a failed unlink — so a stale marker
|
|
1857
|
+
// that could not be deleted (a Windows handle without delete sharing, a
|
|
1858
|
+
// read-only volume) spun here forever, and every later disk operation
|
|
1859
|
+
// queued behind a save that would never return. Waiting is always
|
|
1860
|
+
// bounded; proceeding unlocked is the documented fallback.
|
|
1861
|
+
if (Date.now() >= deadline) return null
|
|
1769
1862
|
try {
|
|
1770
1863
|
await writeFile(lockPath, token, { flag: 'wx' })
|
|
1771
1864
|
return { path: lockPath, token }
|
|
@@ -1776,10 +1869,10 @@ export function apply(ctx) {
|
|
|
1776
1869
|
// Crashed-holder debris. It may be recreated by a live process
|
|
1777
1870
|
// between this unlink and the next wx attempt — that case simply
|
|
1778
1871
|
// loops and re-evaluates freshness instead of forcing its way in.
|
|
1872
|
+
// A failed unlink must not become a tight loop, so every attempt
|
|
1873
|
+
// still sleeps below.
|
|
1779
1874
|
await unlink(lockPath).catch(() => {})
|
|
1780
|
-
continue
|
|
1781
1875
|
}
|
|
1782
|
-
if (Date.now() >= deadline) return null
|
|
1783
1876
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
1784
1877
|
}
|
|
1785
1878
|
}
|
|
@@ -1845,11 +1938,14 @@ export function apply(ctx) {
|
|
|
1845
1938
|
let lastHistoryText = null
|
|
1846
1939
|
|
|
1847
1940
|
/**
|
|
1848
|
-
* Enforce the per-device retention
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
* our naming pattern are never
|
|
1941
|
+
* Enforce the per-device retention policy: the count and byte ceilings from
|
|
1942
|
+
* planHistoryPrune(). Grouping by the device tag inside the filename is
|
|
1943
|
+
* deliberate: two machines sharing one backup folder (the documented F18
|
|
1944
|
+
* setup) each keep their OWN budget, so one device can never prune the
|
|
1945
|
+
* other's history away. Files that do not match our naming pattern are never
|
|
1946
|
+
* touched. Sizes come from stat() rather than a maintained index because the
|
|
1947
|
+
* directory is shared with other processes and a stale index would prune the
|
|
1948
|
+
* wrong files.
|
|
1853
1949
|
*/
|
|
1854
1950
|
const pruneHistory = async (dir) => {
|
|
1855
1951
|
const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
@@ -1865,15 +1961,17 @@ export function apply(ctx) {
|
|
|
1865
1961
|
for (const bucket of byDevice.values()) {
|
|
1866
1962
|
// Fixed-width UTC stamp first => lexicographic order is chronological.
|
|
1867
1963
|
bucket.sort()
|
|
1868
|
-
const
|
|
1869
|
-
for (
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
}
|
|
1964
|
+
const sized = []
|
|
1965
|
+
for (const name of bucket) {
|
|
1966
|
+
const info = await stat(join(dir, name)).catch(() => null)
|
|
1967
|
+
sized.push({ name, size: info === null ? 0 : info.size })
|
|
1968
|
+
}
|
|
1969
|
+
const plan = planHistoryPrune(sized)
|
|
1970
|
+
for (const name of plan.remove) {
|
|
1971
|
+
await unlink(join(dir, name)).catch(() => {})
|
|
1972
|
+
removed += 1
|
|
1876
1973
|
}
|
|
1974
|
+
kept += plan.keep.length
|
|
1877
1975
|
}
|
|
1878
1976
|
return { removed, kept }
|
|
1879
1977
|
}
|
|
@@ -2346,15 +2444,7 @@ export function apply(ctx) {
|
|
|
2346
2444
|
const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
|
|
2347
2445
|
return {
|
|
2348
2446
|
data: {
|
|
2349
|
-
|
|
2350
|
-
rev: state.data.rev || 0,
|
|
2351
|
-
enabled: state.data.enabled === true,
|
|
2352
|
-
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
2353
|
-
? state.data.disabledUntil
|
|
2354
|
-
: 0,
|
|
2355
|
-
budgetChars: clampBudget(state.data.budgetChars),
|
|
2356
|
-
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
2357
|
-
activeSetId: state.data.activeSetId,
|
|
2447
|
+
...storeScalars(state.data),
|
|
2358
2448
|
sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
|
|
2359
2449
|
entries: activeEntries.map((entry) => ({ ...entry })),
|
|
2360
2450
|
profiles: activeProfiles.map((profile) => ({ ...profile })),
|
|
@@ -2466,6 +2556,16 @@ export function apply(ctx) {
|
|
|
2466
2556
|
minBudget: MIN_BUDGET,
|
|
2467
2557
|
maxBudget: MAX_BUDGET,
|
|
2468
2558
|
maxEntries: MAX_ENTRIES,
|
|
2559
|
+
// Per-field ceilings. The editor used to hard-code 120/6000/200 and
|
|
2560
|
+
// 8/24 for tags, so changing a limit here left the panel silently
|
|
2561
|
+
// disagreeing with the Host (it would accept text the loader then
|
|
2562
|
+
// truncated, with a note the user could not have predicted). Every
|
|
2563
|
+
// limit the panel enforces is published here instead.
|
|
2564
|
+
maxTitle: MAX_TITLE,
|
|
2565
|
+
maxContent: MAX_CONTENT,
|
|
2566
|
+
maxWhen: MAX_WHEN,
|
|
2567
|
+
maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
|
|
2568
|
+
maxTagLength: MAX_TAG_LENGTH,
|
|
2469
2569
|
maxProfiles: MAX_PROFILES,
|
|
2470
2570
|
maxProfileName: MAX_PROFILE_NAME,
|
|
2471
2571
|
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
@@ -3713,16 +3813,29 @@ export function apply(ctx) {
|
|
|
3713
3813
|
return { ok: false, message: '反馈发送过于频繁,请 ' + waitSeconds + ' 秒后再试' }
|
|
3714
3814
|
}
|
|
3715
3815
|
|
|
3816
|
+
// Reserve the window BEFORE the awaits below. Checking the timestamp here and
|
|
3817
|
+
// writing it only after a successful send straddled them: two concurrent
|
|
3818
|
+
// requests both passed the check and both delivered. A send that never
|
|
3819
|
+
// happens releases the reservation, so a genuine failure can be retried at
|
|
3820
|
+
// once.
|
|
3821
|
+
const previousSentAt = feedbackLastSentAt
|
|
3822
|
+
feedbackLastSentAt = now
|
|
3823
|
+
const releaseReservation = () => {
|
|
3824
|
+
if (feedbackLastSentAt === now) feedbackLastSentAt = previousSentAt
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3716
3827
|
const configPath = feedbackConfigPath()
|
|
3717
3828
|
let file = null
|
|
3718
3829
|
try {
|
|
3719
3830
|
file = await readFeedbackConfigFile(configPath)
|
|
3720
3831
|
} catch (err) {
|
|
3832
|
+
releaseReservation()
|
|
3721
3833
|
logInfo('feedback config unusable: ' + describeError(err))
|
|
3722
3834
|
return { ok: false, message: describeError(err) }
|
|
3723
3835
|
}
|
|
3724
3836
|
const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
|
|
3725
3837
|
if (!readiness.ok) {
|
|
3838
|
+
releaseReservation()
|
|
3726
3839
|
logDebug('feedback not configured: ' + readiness.message)
|
|
3727
3840
|
return { ok: false, message: readiness.message }
|
|
3728
3841
|
}
|
|
@@ -3741,8 +3854,10 @@ export function apply(ctx) {
|
|
|
3741
3854
|
// back, so both UI text and the log keep it masked.
|
|
3742
3855
|
const maskAddresses = (text) => {
|
|
3743
3856
|
let out = String(text)
|
|
3744
|
-
|
|
3745
|
-
|
|
3857
|
+
// The SMTP account is masked too: a relay that differs from From/To is
|
|
3858
|
+
// echoed by some servers inside their rejection text.
|
|
3859
|
+
for (const address of [readiness.config.to, readiness.config.from, readiness.config.smtp.user]) {
|
|
3860
|
+
if (typeof address === 'string' && address !== '') out = out.split(address).join('[邮箱已隐藏]')
|
|
3746
3861
|
}
|
|
3747
3862
|
return out
|
|
3748
3863
|
}
|
|
@@ -3764,6 +3879,7 @@ export function apply(ctx) {
|
|
|
3764
3879
|
// The failure reason is the only actionable thing a maintainer can act
|
|
3765
3880
|
// on; it never contains the password (see mailer.js), and the configured
|
|
3766
3881
|
// mailboxes are masked, so logging it is safe and makes reports debuggable.
|
|
3882
|
+
releaseReservation()
|
|
3767
3883
|
const reason = maskAddresses(describeError(err))
|
|
3768
3884
|
logInfo('feedback send failed: ' + reason)
|
|
3769
3885
|
return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
|
|
@@ -3871,6 +3987,30 @@ export function apply(ctx) {
|
|
|
3871
3987
|
|
|
3872
3988
|
let routeDispose = null
|
|
3873
3989
|
|
|
3990
|
+
/**
|
|
3991
|
+
* Does this stream subscriber want lightweight frames?
|
|
3992
|
+
*
|
|
3993
|
+
* `?lite=1` is additive on purpose: a client that does not ask (every build
|
|
3994
|
+
* before this one) keeps receiving the bare snapshot exactly as documented,
|
|
3995
|
+
* so an old client against a new Host is unchanged. A lite subscriber gets
|
|
3996
|
+
* `{ rev, lite: true }` and fetches the snapshot itself when the rev differs
|
|
3997
|
+
* — which is the difference between pushing ~3.4 MB per save (maximal store)
|
|
3998
|
+
* and pushing ~40 bytes.
|
|
3999
|
+
*
|
|
4000
|
+
* `req.url` is not part of the harness contract, so anything unparseable
|
|
4001
|
+
* degrades to the legacy full frame rather than throwing.
|
|
4002
|
+
*/
|
|
4003
|
+
const isLiteStreamRequest = (req) => {
|
|
4004
|
+
const url = req !== null && typeof req === 'object' && typeof req.url === 'string' ? req.url : ''
|
|
4005
|
+
const mark = url.indexOf('?')
|
|
4006
|
+
if (mark === -1) return false
|
|
4007
|
+
try {
|
|
4008
|
+
return new URLSearchParams(url.slice(mark + 1)).get('lite') === '1'
|
|
4009
|
+
} catch {
|
|
4010
|
+
return false
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
|
|
3874
4014
|
/**
|
|
3875
4015
|
* The live-update stream for the settings page (GET + `Accept:
|
|
3876
4016
|
* text/event-stream`, i.e. exactly what an EventSource sends).
|
|
@@ -3881,7 +4021,8 @@ export function apply(ctx) {
|
|
|
3881
4021
|
* (someone navigating to the URL) keeps the old 405.
|
|
3882
4022
|
*
|
|
3883
4023
|
* Frames carry the bare snapshot — the same shape `state` answers with —
|
|
3884
|
-
* which the Client's normalizer already accepts.
|
|
4024
|
+
* which the Client's normalizer already accepts. A subscriber that appends
|
|
4025
|
+
* `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
|
|
3885
4026
|
*/
|
|
3886
4027
|
const handleEventStream = (req, res) => {
|
|
3887
4028
|
const verdict = requestOriginVerdict(req.headers, guardHost)
|
|
@@ -3899,6 +4040,7 @@ export function apply(ctx) {
|
|
|
3899
4040
|
// Ask the browser to retry politely if the stream drops (e.g. the host was
|
|
3900
4041
|
// restarted) instead of hammering the route.
|
|
3901
4042
|
res.write('retry: 5000\n\n')
|
|
4043
|
+
const lite = isLiteStreamRequest(req)
|
|
3902
4044
|
const send = (payload) => { res.write(payload) }
|
|
3903
4045
|
let closed = false
|
|
3904
4046
|
const cleanup = () => {
|
|
@@ -3910,7 +4052,7 @@ export function apply(ctx) {
|
|
|
3910
4052
|
// The response is already gone.
|
|
3911
4053
|
}
|
|
3912
4054
|
}
|
|
3913
|
-
sseStreams.set(send, cleanup)
|
|
4055
|
+
sseStreams.set(send, { cleanup, lite })
|
|
3914
4056
|
req.on('close', cleanup)
|
|
3915
4057
|
req.on('error', cleanup)
|
|
3916
4058
|
res.on('error', cleanup)
|
|
@@ -3918,11 +4060,14 @@ export function apply(ctx) {
|
|
|
3918
4060
|
// instead of waiting for the next change. It must wait for boot: a stream
|
|
3919
4061
|
// opened while the initial disk read is still in flight would otherwise be
|
|
3920
4062
|
// handed the empty boot state as its first frame, flashing "no
|
|
3921
|
-
// instructions" before the loaded store arrives.
|
|
4063
|
+
// instructions" before the loaded store arrives. A lite subscriber gets
|
|
4064
|
+
// only the current rev and pulls the snapshot itself when it is behind.
|
|
3922
4065
|
const sendConnectFrame = () => {
|
|
3923
4066
|
if (closed) return
|
|
3924
4067
|
try {
|
|
3925
|
-
send(
|
|
4068
|
+
send(lite
|
|
4069
|
+
? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
|
|
4070
|
+
: 'data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
3926
4071
|
} catch {
|
|
3927
4072
|
cleanup()
|
|
3928
4073
|
}
|