@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/client.js +1161 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +706 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
- package/scheduler.js +225 -0
- package/snapshot.js +400 -0
- package/source.js +201 -0
- package/store.js +187 -0
- package/targets.js +533 -0
- package/zip.js +168 -0
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@summerscar/dsh-mnemon-sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"source.js",
|
|
16
|
+
"client.js",
|
|
17
|
+
"store.js",
|
|
18
|
+
"snapshot.js",
|
|
19
|
+
"scheduler.js",
|
|
20
|
+
"targets.js",
|
|
21
|
+
"zip.js",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"locale/*.json",
|
|
24
|
+
"icon.svg",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh-plugin",
|
|
30
|
+
"deepseek-harness",
|
|
31
|
+
"deepseek-harness-plugin",
|
|
32
|
+
"dsh-mnemon",
|
|
33
|
+
"mnemon",
|
|
34
|
+
"backup",
|
|
35
|
+
"restore",
|
|
36
|
+
"memory",
|
|
37
|
+
"webdav",
|
|
38
|
+
"s3"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.16",
|
|
43
|
+
"dsh": ">=0.2.1-rc.1 <0.3.0-0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"dsh-mnemon": ">=0.5.40"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"dsh-mnemon": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"platform": "web",
|
|
59
|
+
"immediately": true,
|
|
60
|
+
"inject": [
|
|
61
|
+
"@deepseek-ai/dsh-client-locale",
|
|
62
|
+
"dsh-mnemon/client"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/summerscar/dsh-mnemon-sync.git"
|
|
69
|
+
},
|
|
70
|
+
"bugs": {
|
|
71
|
+
"url": "https://github.com/summerscar/dsh-mnemon-sync/issues"
|
|
72
|
+
},
|
|
73
|
+
"homepage": "https://github.com/summerscar/dsh-mnemon-sync#readme",
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
}
|
|
77
|
+
}
|
package/scheduler.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnemon Sync — the schedule and the run bookkeeping.
|
|
3
|
+
*
|
|
4
|
+
* The cadence model is copied from dsh-mnemon's own Git auto-backup, because
|
|
5
|
+
* that design is already proven against this runtime and both surfaces should
|
|
6
|
+
* read the same to a user:
|
|
7
|
+
*
|
|
8
|
+
* - One interval, a whole number of minutes, read from settings. Zero means
|
|
9
|
+
* "only when the button is pressed".
|
|
10
|
+
* - One `setTimeout` for the whole Host, re-armed from `finally`, never an
|
|
11
|
+
* interval. Two timers can never overlap into a double upload.
|
|
12
|
+
* - The interval is re-read from disk on every tick, so saving settings
|
|
13
|
+
* changes the cadence without restarting the process.
|
|
14
|
+
* - A tick that fires while a run is in flight is dropped, not queued: a slow
|
|
15
|
+
* target must not build a backlog of pending uploads.
|
|
16
|
+
* - Failures are recorded and swallowed. A timer is the last place that should
|
|
17
|
+
* raise an exception nobody is watching.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
export const MINUTE_MS = 60_000
|
|
24
|
+
const HISTORY_LIMIT = 20
|
|
25
|
+
const STATE_DIRECTORY = 'state'
|
|
26
|
+
const HISTORY_FILE = 'mnemon-sync-history.json'
|
|
27
|
+
|
|
28
|
+
/** Present a run in a form the tab can render without extra shaping. */
|
|
29
|
+
function toRunRecord(entry) {
|
|
30
|
+
return {
|
|
31
|
+
id: entry.id,
|
|
32
|
+
startedAt: entry.startedAt,
|
|
33
|
+
finishedAt: entry.finishedAt,
|
|
34
|
+
ok: entry.ok === true,
|
|
35
|
+
trigger: entry.trigger,
|
|
36
|
+
durationMs: entry.durationMs,
|
|
37
|
+
...entry.spaces === undefined ? {} : { spaces: entry.spaces },
|
|
38
|
+
...entry.bytes === undefined ? {} : { bytes: entry.bytes },
|
|
39
|
+
...entry.key === undefined ? {} : { key: entry.key },
|
|
40
|
+
...entry.location === undefined ? {} : { location: entry.location },
|
|
41
|
+
...entry.error === undefined ? {} : { error: entry.error },
|
|
42
|
+
...entry.removed === undefined ? {} : { removed: entry.removed },
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SyncHistory {
|
|
47
|
+
constructor(home) {
|
|
48
|
+
this.root = home
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
path() {
|
|
52
|
+
return join(this.root, STATE_DIRECTORY, HISTORY_FILE)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
read() {
|
|
56
|
+
try {
|
|
57
|
+
const stored = JSON.parse(readFileSync(this.path(), 'utf8'))
|
|
58
|
+
const runs = Array.isArray(stored?.runs) ? stored.runs : []
|
|
59
|
+
return runs.map(toRunRecord)
|
|
60
|
+
} catch {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
append(record) {
|
|
66
|
+
const runs = [toRunRecord(record), ...this.read()].slice(0, HISTORY_LIMIT)
|
|
67
|
+
mkdirSync(join(this.root, STATE_DIRECTORY), { recursive: true, mode: 0o700 })
|
|
68
|
+
writeFileSync(this.path(), `${JSON.stringify({ version: 1, runs }, null, 2)}\n`, { mode: 0o600 })
|
|
69
|
+
return runs
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
latest() {
|
|
73
|
+
return this.read()[0]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The Host-wide timer. `run()` is injected so the timer itself has no opinion
|
|
79
|
+
* about what a backup does, which keeps it testable and keeps the wiring in
|
|
80
|
+
* index.js.
|
|
81
|
+
*/
|
|
82
|
+
export class SyncScheduler {
|
|
83
|
+
/**
|
|
84
|
+
* @param {object} options
|
|
85
|
+
* @param {() => object} options.readSettings current settings, read fresh each tick
|
|
86
|
+
* @param {(trigger: string, settings: object, signal: AbortSignal) => Promise<object>} options.run performs one backup
|
|
87
|
+
* @param {() => Date} [options.now]
|
|
88
|
+
* @param {(error: unknown) => void} [options.onError]
|
|
89
|
+
*/
|
|
90
|
+
constructor(options) {
|
|
91
|
+
this.readSettings = options.readSettings
|
|
92
|
+
this.run = options.run
|
|
93
|
+
this.now = options.now ?? (() => new Date())
|
|
94
|
+
this.onError = options.onError
|
|
95
|
+
this.timer = undefined
|
|
96
|
+
this.running = false
|
|
97
|
+
this.stopped = false
|
|
98
|
+
this.controller = undefined
|
|
99
|
+
this.lastAttemptAt = undefined
|
|
100
|
+
this.lastRunAt = undefined
|
|
101
|
+
this.lastAt = undefined
|
|
102
|
+
this.lastError = undefined
|
|
103
|
+
this.lastFailure = undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
start() {
|
|
107
|
+
this.schedule()
|
|
108
|
+
return () => {
|
|
109
|
+
this.stopped = true
|
|
110
|
+
this.cancel()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-arm without running; called after the settings page saves. */
|
|
115
|
+
refresh() {
|
|
116
|
+
if (this.stopped) return
|
|
117
|
+
this.cancel()
|
|
118
|
+
this.schedule()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
cancel() {
|
|
122
|
+
if (this.timer !== undefined) {
|
|
123
|
+
clearTimeout(this.timer)
|
|
124
|
+
this.timer = undefined
|
|
125
|
+
}
|
|
126
|
+
this.controller?.abort()
|
|
127
|
+
this.controller = undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What the settings block shows, mirroring the Git channel's shape so the two
|
|
132
|
+
* read alike: whether the timer is armed, and when it ran and will run again.
|
|
133
|
+
*/
|
|
134
|
+
snapshot(settings) {
|
|
135
|
+
const minutes = minutesOf(settings)
|
|
136
|
+
const available = minutes > 0 && !this.stopped && settings.enabled === true
|
|
137
|
+
return {
|
|
138
|
+
available,
|
|
139
|
+
autoSyncMinutes: minutes,
|
|
140
|
+
...this.lastAt === undefined ? {} : { lastAt: this.lastAt },
|
|
141
|
+
...this.lastAttemptAt === undefined || !available
|
|
142
|
+
? {}
|
|
143
|
+
: { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
|
|
144
|
+
...this.lastError === undefined ? {} : { lastError: this.lastError },
|
|
145
|
+
...this.lastError === undefined || this.lastFailure === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { lastFailure: this.lastFailure },
|
|
148
|
+
...!this.running ? {} : { running: true },
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
schedule() {
|
|
153
|
+
if (this.stopped || this.timer !== undefined) return
|
|
154
|
+
let settings
|
|
155
|
+
try {
|
|
156
|
+
settings = this.readSettings()
|
|
157
|
+
} catch {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const minutes = minutesOf(settings)
|
|
161
|
+
if (minutes <= 0 || settings.enabled !== true) return
|
|
162
|
+
this.lastAttemptAt = this.now().getTime()
|
|
163
|
+
this.timer = setTimeout(() => {
|
|
164
|
+
this.timer = undefined
|
|
165
|
+
void this.tick('schedule').catch(() => {
|
|
166
|
+
// `tick` already recorded the failure; the timer must not rethrow.
|
|
167
|
+
})
|
|
168
|
+
}, minutes * MINUTE_MS)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Run one backup now. Shared by the timer and the tab's button so both paths
|
|
173
|
+
* record history and both refuse to overlap.
|
|
174
|
+
*/
|
|
175
|
+
async runNow(trigger = 'manual') {
|
|
176
|
+
return this.tick(trigger)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async tick(trigger) {
|
|
180
|
+
if (this.stopped) return { skipped: 'stopped' }
|
|
181
|
+
if (this.running) return { skipped: 'busy' }
|
|
182
|
+
this.running = true
|
|
183
|
+
const controller = new AbortController()
|
|
184
|
+
this.controller = controller
|
|
185
|
+
const startedAt = this.now().toISOString()
|
|
186
|
+
const startedMs = this.now().getTime()
|
|
187
|
+
try {
|
|
188
|
+
const settings = this.readSettings()
|
|
189
|
+
if (trigger === 'schedule' && settings.enabled !== true) {
|
|
190
|
+
return { skipped: 'disabled' }
|
|
191
|
+
}
|
|
192
|
+
const result = await this.run(trigger, settings, controller.signal)
|
|
193
|
+
this.lastRunAt = this.now().toISOString()
|
|
194
|
+
this.lastAt = this.lastRunAt
|
|
195
|
+
this.lastError = undefined
|
|
196
|
+
this.lastFailure = undefined
|
|
197
|
+
return { ...result, startedAt, durationMs: this.now().getTime() - startedMs, trigger }
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
200
|
+
// An aborted run is the expected outcome of disable/refresh, not a fault.
|
|
201
|
+
if (!this.stopped && !controller.signal.aborted) {
|
|
202
|
+
this.lastError = message
|
|
203
|
+
this.lastFailure = error instanceof Error ? error.reason : undefined
|
|
204
|
+
try {
|
|
205
|
+
this.onError?.(error)
|
|
206
|
+
} catch {
|
|
207
|
+
// the error hook must not become a second failure
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw error
|
|
211
|
+
} finally {
|
|
212
|
+
this.running = false
|
|
213
|
+
if (this.controller === controller) this.controller = undefined
|
|
214
|
+
this.schedule()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The saved interval, or zero when it cannot be read at all. */
|
|
220
|
+
function minutesOf(settings) {
|
|
221
|
+
const minutes = settings?.autoSyncMinutes
|
|
222
|
+
return typeof minutes === 'number' && Number.isInteger(minutes) && minutes > 0 ? minutes : 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { HISTORY_LIMIT }
|