dsh-memory-sync 0.0.0-stage → 0.1.1

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/lib/git.mjs ADDED
@@ -0,0 +1,286 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { hostname } from 'node:os'
3
+
4
+ function pad2(value) {
5
+ return String(value).padStart(2, '0')
6
+ }
7
+
8
+ /** Local ISO timestamp with a numeric offset, e.g. 2026-11-05T15:53:12+0800. */
9
+ export function localIso(date = new Date()) {
10
+ const offset = -date.getTimezoneOffset()
11
+ const sign = offset >= 0 ? '+' : '-'
12
+ const abs = Math.abs(offset)
13
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
14
+ + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
15
+ + `${sign}${pad2(Math.floor(abs / 60))}${pad2(abs % 60)}`
16
+ }
17
+
18
+ export function tail(text, limit = 500) {
19
+ const clean = String(text || '').replace(/\s+/g, ' ').trim()
20
+ if (clean.length <= limit) return clean
21
+ return `…${clean.slice(-limit)}`
22
+ }
23
+
24
+ /** Kill the whole tree on Windows (cmd/git may spawn children). */
25
+ function killTree(child) {
26
+ if (!child || child.exitCode !== null) return
27
+ if (process.platform === 'win32') {
28
+ try {
29
+ spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
30
+ return
31
+ } catch {
32
+ // fall through
33
+ }
34
+ }
35
+ try {
36
+ child.kill('SIGTERM')
37
+ } catch {
38
+ // already gone
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Run a process without a shell, with a timeout and a bounded output buffer.
44
+ */
45
+ export function runProcess(command, args, {
46
+ cwd,
47
+ env,
48
+ timeoutMs = 60000,
49
+ maxBytes = 4 * 1024 * 1024,
50
+ } = {}) {
51
+ return new Promise((resolve) => {
52
+ const started = Date.now()
53
+ let stdout = ''
54
+ let stderr = ''
55
+ let truncated = false
56
+ let timedOut = false
57
+ let settled = false
58
+ let timer = null
59
+
60
+ const finish = (code, signal, errorMessage = '') => {
61
+ if (settled) return
62
+ settled = true
63
+ if (timer) clearTimeout(timer)
64
+ resolve({
65
+ code: Number.isInteger(code) ? code : -1,
66
+ signal: signal || null,
67
+ stdout,
68
+ stderr: errorMessage && !stderr ? errorMessage : stderr,
69
+ timedOut,
70
+ truncated,
71
+ durationMs: Date.now() - started,
72
+ })
73
+ }
74
+
75
+ let child
76
+ try {
77
+ child = spawn(command, args, {
78
+ cwd,
79
+ env: { ...process.env, ...(env || {}) },
80
+ windowsHide: true,
81
+ stdio: ['ignore', 'pipe', 'pipe'],
82
+ })
83
+ } catch (error) {
84
+ finish(-1, null, String(error?.message || error))
85
+ return
86
+ }
87
+
88
+ const take = (chunk, which) => {
89
+ const text = chunk.toString('utf8')
90
+ if (which === 'out') {
91
+ const room = maxBytes - stdout.length
92
+ if (room <= 0) {
93
+ truncated = true
94
+ return
95
+ }
96
+ stdout += room >= text.length ? text : text.slice(0, room)
97
+ if (room < text.length) truncated = true
98
+ return
99
+ }
100
+ const room = maxBytes - stderr.length
101
+ if (room > 0) stderr += room >= text.length ? text : text.slice(0, room)
102
+ }
103
+
104
+ child.stdout?.on('data', (chunk) => take(chunk, 'out'))
105
+ child.stderr?.on('data', (chunk) => take(chunk, 'err'))
106
+ child.on('error', (error) => finish(-1, null, String(error?.message || error)))
107
+ child.on('close', (code, signal) => finish(code, signal))
108
+
109
+ if (timeoutMs > 0) {
110
+ timer = setTimeout(() => {
111
+ timedOut = true
112
+ killTree(child)
113
+ }, Math.max(250, timeoutMs))
114
+ timer.unref?.()
115
+ }
116
+ })
117
+ }
118
+
119
+ export async function runGit(cfg, args, { timeoutMs = cfg.localTimeoutMs, cwd = cfg.repoPath } = {}) {
120
+ return runProcess(cfg.gitPath, ['-C', cwd, ...args], { cwd, timeoutMs })
121
+ }
122
+
123
+ export async function resolveRemote(cfg) {
124
+ for (const name of [cfg.remote, 'origin', 'cloud']) {
125
+ if (!name) continue
126
+ const result = await runGit(cfg, ['remote', 'get-url', name], { timeoutMs: 15000 })
127
+ if (result.code === 0 && result.stdout.trim()) return name
128
+ }
129
+ return ''
130
+ }
131
+
132
+ export async function inspectRepo(cfg) {
133
+ const dir = await runGit(cfg, ['rev-parse', '--git-dir'], { timeoutMs: 15000 })
134
+ if (dir.code !== 0) {
135
+ return { ok: false, detail: tail(dir.stderr || dir.stdout) || 'not a git repo' }
136
+ }
137
+
138
+ const branchResult = await runGit(cfg, ['branch', '--show-current'], { timeoutMs: 15000 })
139
+ const branch = branchResult.stdout.trim() || cfg.branch
140
+ const headResult = await runGit(cfg, ['rev-parse', '--short', 'HEAD'], { timeoutMs: 15000 })
141
+ const status = await runGit(cfg, ['status', '--porcelain'], { timeoutMs: 20000 })
142
+ const remote = await resolveRemote(cfg)
143
+
144
+ let ahead = 0
145
+ let behind = 0
146
+ if (remote) {
147
+ const counts = await runGit(
148
+ cfg,
149
+ ['rev-list', '--left-right', '--count', `HEAD...${remote}/${cfg.branch}`],
150
+ { timeoutMs: 20000 },
151
+ )
152
+ if (counts.code === 0) {
153
+ const [a, b] = counts.stdout.trim().split(/\s+/).map(Number)
154
+ ahead = Number.isFinite(a) ? a : 0
155
+ behind = Number.isFinite(b) ? b : 0
156
+ }
157
+ }
158
+
159
+ return {
160
+ ok: true,
161
+ branch,
162
+ head: headResult.code === 0 ? headResult.stdout.trim() : '',
163
+ dirty: status.stdout.trim().length > 0,
164
+ ahead,
165
+ behind,
166
+ remote,
167
+ }
168
+ }
169
+
170
+ function failure(phase, started, detail, extra = {}) {
171
+ return {
172
+ name: phase,
173
+ ok: false,
174
+ durationMs: Date.now() - started,
175
+ detail: tail(detail),
176
+ ...extra,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * One local round trip: pull --rebase --autostash, commit dirty files, push.
182
+ * A rejected push is retried once after fetch + rebase (the non-fast-forward case).
183
+ */
184
+ export async function syncLocal(cfg) {
185
+ const started = Date.now()
186
+ const repo = await inspectRepo(cfg)
187
+ if (!repo.ok) return failure('local', started, repo.detail)
188
+ if (!repo.remote) return failure('local', started, `no git remote named ${cfg.remote} (or origin)`)
189
+
190
+ const pullArgs = ['pull', '--rebase', '--autostash', repo.remote, cfg.branch]
191
+ let pull = await runGit(cfg, pullArgs)
192
+ if (pull.code !== 0) {
193
+ // A conflicted rebase leaves the work tree in a rebase state; abort so the
194
+ // user's files stay usable, then report instead of guessing a resolution.
195
+ await runGit(cfg, ['rebase', '--abort'], { timeoutMs: 15000 })
196
+ pull = await runGit(cfg, pullArgs)
197
+ if (pull.code !== 0) {
198
+ const detail = pull.stderr || pull.stdout
199
+ return failure('local', started, detail, { phase: 'pull', conflict: /conflict/i.test(detail) })
200
+ }
201
+ }
202
+
203
+ await runGit(cfg, ['add', '-A'])
204
+ const status = await runGit(cfg, ['status', '--porcelain'])
205
+ let committed = false
206
+ if (status.stdout.trim()) {
207
+ const nameResult = await runGit(cfg, ['config', 'user.name'], { timeoutMs: 10000 })
208
+ const emailResult = await runGit(cfg, ['config', 'user.email'], { timeoutMs: 10000 })
209
+ const commitArgs = ['commit', '-m', `${cfg.commitPrefix}: ${hostname()} ${localIso()}`]
210
+ if (!nameResult.stdout.trim()) commitArgs.unshift('-c', 'user.name=DSH Memory Sync')
211
+ if (!emailResult.stdout.trim()) commitArgs.unshift('-c', 'user.email=memory-sync@localhost')
212
+ const commit = await runGit(cfg, commitArgs)
213
+ if (commit.code !== 0) return failure('local', started, commit.stderr || commit.stdout, { phase: 'commit' })
214
+ committed = true
215
+ }
216
+
217
+ let push = await runGit(cfg, ['push', repo.remote, `HEAD:${cfg.branch}`])
218
+ let retried = false
219
+ if (push.code !== 0) {
220
+ retried = true
221
+ await runGit(cfg, ['fetch', repo.remote], { timeoutMs: 30000 })
222
+ const rebase = await runGit(cfg, ['rebase', `${repo.remote}/${cfg.branch}`], { timeoutMs: 30000 })
223
+ if (rebase.code === 0) {
224
+ push = await runGit(cfg, ['push', repo.remote, `HEAD:${cfg.branch}`])
225
+ } else {
226
+ await runGit(cfg, ['rebase', '--abort'], { timeoutMs: 15000 })
227
+ }
228
+ }
229
+ if (push.code !== 0) {
230
+ const detail = push.stderr || push.stdout
231
+ return failure('local', started, detail, { phase: 'push', retried, conflict: /conflict/i.test(detail) })
232
+ }
233
+
234
+ const after = await inspectRepo(cfg)
235
+ return {
236
+ name: 'local',
237
+ ok: true,
238
+ durationMs: Date.now() - started,
239
+ committed,
240
+ retried,
241
+ head: after.ok ? after.head : '',
242
+ dirty: after.ok ? after.dirty : false,
243
+ }
244
+ }
245
+
246
+ /** Phase two: ask the peer to commit and push its side. */
247
+ export async function syncPeer(cfg) {
248
+ const started = Date.now()
249
+ if (cfg.mode !== 'peer') {
250
+ return { name: 'peer', ok: true, skipped: true, durationMs: 0, detail: 'local 模式' }
251
+ }
252
+ if (!cfg.peerHost || !cfg.peerUser) {
253
+ return failure('peer', started, 'peerHost / peerUser 未配置,已跳过云端段')
254
+ }
255
+
256
+ const args = [
257
+ '-o', 'BatchMode=yes',
258
+ '-o', 'ConnectTimeout=10',
259
+ '-o', 'ServerAliveInterval=5',
260
+ '-o', 'ServerAliveCountMax=2',
261
+ '-o', 'StrictHostKeyChecking=accept-new',
262
+ ]
263
+ if (cfg.peerKeyPath) args.push('-i', cfg.peerKeyPath)
264
+ args.push(`${cfg.peerUser}@${cfg.peerHost}`, `DSH_SYNC_NAME=${cfg.peerEnvName} ${cfg.peerCommand}`)
265
+
266
+ const result = await runProcess('ssh', args, { timeoutMs: cfg.peerTimeoutMs })
267
+ return {
268
+ name: 'peer',
269
+ ok: result.code === 0,
270
+ exitCode: result.code,
271
+ durationMs: Date.now() - started,
272
+ timedOut: result.timedOut,
273
+ detail: result.code === 0 ? '' : tail(result.stderr || result.stdout),
274
+ }
275
+ }
276
+
277
+ /** The three-phase startup handshake: local push -> peer commit/push -> local pull. */
278
+ export async function runStartupChain(cfg) {
279
+ const phases = []
280
+ phases.push(await syncLocal(cfg))
281
+ phases.push(await syncPeer(cfg))
282
+ phases.push(await syncLocal(cfg))
283
+ const checked = phases.filter((phase) => !phase.skipped)
284
+ const ok = checked.every((phase) => phase.ok)
285
+ return { ok, phases, head: phases[phases.length - 1]?.head || '' }
286
+ }
package/lib/lock.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { hostname } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ const STALE_MS = 15 * 60 * 1000
6
+
7
+ /**
8
+ * Cross-process lock built from a directory (atomic on Windows and POSIX).
9
+ * Returns null when another process holds a fresh lock.
10
+ */
11
+ export async function acquireLock(lockDir, { staleMs = STALE_MS, now = Date.now() } = {}) {
12
+ await mkdir(lockDir, { recursive: true }).catch(() => {})
13
+ const lockPath = join(lockDir, 'sync.lock')
14
+
15
+ for (let attempt = 0; attempt < 2; attempt += 1) {
16
+ try {
17
+ await mkdir(lockPath)
18
+ await writeFile(
19
+ join(lockPath, 'owner.json'),
20
+ JSON.stringify({ pid: process.pid, host: hostname(), at: now }),
21
+ 'utf8',
22
+ ).catch(() => {})
23
+ return {
24
+ path: lockPath,
25
+ release: async () => {
26
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {})
27
+ },
28
+ }
29
+ } catch (error) {
30
+ if (error?.code !== 'EEXIST') throw error
31
+ let stale = false
32
+ try {
33
+ const owner = JSON.parse(await readFile(join(lockPath, 'owner.json'), 'utf8'))
34
+ stale = !Number.isFinite(Number(owner?.at)) || now - Number(owner.at) > staleMs
35
+ } catch {
36
+ stale = true
37
+ }
38
+ if (!stale) return null
39
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {})
40
+ }
41
+ }
42
+ return null
43
+ }
package/lib/notify.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ function runDetached(command, args) {
4
+ try {
5
+ const child = spawn(command, args, {
6
+ detached: true,
7
+ windowsHide: true,
8
+ stdio: 'ignore',
9
+ })
10
+ child.unref?.()
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Best-effort failure popup. There is no host notification service, so this
19
+ * talks to the OS directly. Never throws: a missing notifier must not turn a
20
+ * sync failure into a crash.
21
+ */
22
+ export async function notifyFailure({ title, message, logger } = {}) {
23
+ const safeTitle = String(title || 'DSH memory sync failed')
24
+ const safeMessage = String(message || 'The startup memory sync did not complete.')
25
+ try {
26
+ if (process.platform === 'win32') {
27
+ const script = [
28
+ '$w = New-Object -ComObject WScript.Shell;',
29
+ `$null = $w.Popup('${escapePowerShell(safeMessage)}', 300, '${escapePowerShell(safeTitle)}', 48)`,
30
+ ].join(' ')
31
+ runDetached('powershell.exe', [
32
+ '-NoProfile',
33
+ '-NonInteractive',
34
+ '-WindowStyle', 'Hidden',
35
+ '-Command', script,
36
+ ])
37
+ return
38
+ }
39
+ if (process.platform === 'darwin') {
40
+ const escaped = safeMessage.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
41
+ const escapedTitle = safeTitle.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
42
+ runDetached('osascript', ['-e', `display dialog "${escaped}" with title "${escapedTitle}" buttons {"OK"} default button 1 with icon caution`])
43
+ return
44
+ }
45
+ runDetached('notify-send', ['--urgency=critical', safeTitle, safeMessage])
46
+ } catch (error) {
47
+ logger?.warn?.(`notify failed: ${error?.message || error}`)
48
+ }
49
+ }
50
+
51
+ function escapePowerShell(text) {
52
+ return String(text).replace(/\r?\n/g, ' ').replace(/'/g, "''")
53
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,59 @@
1
+ import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ const MAX_LOG_BYTES = 1024 * 1024
5
+
6
+ export async function ensureDir(dir) {
7
+ await mkdir(dir, { recursive: true })
8
+ }
9
+
10
+ export async function readJson(file, fallback = null) {
11
+ try {
12
+ return JSON.parse(await readFile(file, 'utf8'))
13
+ } catch {
14
+ return fallback
15
+ }
16
+ }
17
+
18
+ /** Write JSON via a temp file + rename so a crash never leaves half a file. */
19
+ export async function writeJsonAtomic(file, value) {
20
+ const dir = dirname(file)
21
+ await mkdir(dir, { recursive: true })
22
+ const tmp = join(dir, `.${Date.now()}.${process.pid}.tmp`)
23
+ await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8')
24
+ await rename(tmp, file)
25
+ }
26
+
27
+ /** Keep the log bounded: one previous generation is enough. */
28
+ export async function rotateLog(file, maxBytes = MAX_LOG_BYTES) {
29
+ try {
30
+ const info = await stat(file)
31
+ if (info.size > maxBytes) await rename(file, `${file}.1`)
32
+ } catch {
33
+ // no log yet
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Tiny logger: console + best-effort file. Never throws, never blocks a turn.
39
+ */
40
+ export function createLogger({ dir, tag = 'memory-sync' }) {
41
+ const file = join(dir, 'sync.log')
42
+ const write = (level, message) => {
43
+ const line = `${new Date().toISOString()} [${tag}] ${level} ${message}\n`
44
+ try {
45
+ if (level === 'error') console.error(line.trimEnd())
46
+ else if (level === 'warn') console.warn(line.trimEnd())
47
+ else console.log(line.trimEnd())
48
+ } catch {
49
+ // console can be closed in a packaged app
50
+ }
51
+ void appendFile(file, line).catch(() => {})
52
+ }
53
+ return {
54
+ file,
55
+ info: (message) => write('info', message),
56
+ warn: (message) => write('warn', message),
57
+ error: (message) => write('error', message),
58
+ }
59
+ }
package/lib/status.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
2
+ import { hostname, platform, release, userInfo } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ function pad2(n) {
6
+ return String(n).padStart(2, '0')
7
+ }
8
+
9
+ /** Local wall-clock stamp: `YYYY-MM-DD HH:mm:ss`. */
10
+ export function stampLocal(date = new Date()) {
11
+ const d = date instanceof Date ? date : new Date(date)
12
+ if (Number.isNaN(d.getTime())) return '-'
13
+ return (
14
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
15
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
16
+ )
17
+ }
18
+
19
+ /** The process start time, derived from Node's uptime (no extra state needed). */
20
+ export function processStartedAt(now = Date.now()) {
21
+ return new Date(now - process.uptime() * 1000)
22
+ }
23
+
24
+ function currentUser() {
25
+ try {
26
+ return userInfo().username || process.env.USERNAME || process.env.USER || 'unknown'
27
+ } catch {
28
+ return process.env.USERNAME || process.env.USER || 'unknown'
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Render the human-readable PC snapshot. Kept to the same shape the old
34
+ * watcher wrote, so the cloud-side AGENTS.md can keep reading `状态刷新`.
35
+ */
36
+ export function renderStatus({ now = new Date(), startedAt } = {}) {
37
+ const started = startedAt instanceof Date ? startedAt : processStartedAt(now.getTime())
38
+ return [
39
+ '# 电脑侧状态(自动生成,勿手改)',
40
+ '',
41
+ `- 主机名:${hostname()}`,
42
+ `- 用户:${currentUser()}`,
43
+ `- 系统:${platform()} ${release()}`,
44
+ `- DSH 本次启动:${stampLocal(started)}`,
45
+ `- 状态刷新:${stampLocal(now)}`,
46
+ '',
47
+ ].join('\n')
48
+ }
49
+
50
+ /**
51
+ * Write the snapshot atomically. Never throws: a failed snapshot must not
52
+ * block the memory sync itself.
53
+ */
54
+ export async function writeStatusFile({ statusFile, logger, now = new Date(), startedAt } = {}) {
55
+ const file = String(statusFile || '').trim()
56
+ if (!file) return false
57
+ try {
58
+ const dir = dirname(file)
59
+ await mkdir(dir, { recursive: true })
60
+ const text = renderStatus({ now, startedAt })
61
+ const tmp = join(dir, `.${Date.now()}.${process.pid}.status.tmp`)
62
+ await writeFile(tmp, text, 'utf8')
63
+ await rename(tmp, file)
64
+ logger?.info?.(`status snapshot written: ${file}`)
65
+ return true
66
+ } catch (error) {
67
+ logger?.warn?.(`status snapshot failed: ${error?.message || error}`)
68
+ return false
69
+ }
70
+ }
package/lib/sync.mjs ADDED
@@ -0,0 +1,193 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { acquireLock } from './lock.mjs'
4
+ import { inspectRepo, runStartupChain } from './git.mjs'
5
+ import { notifyFailure } from './notify.mjs'
6
+ import { readJson, writeJsonAtomic } from './state.mjs'
7
+ import { writeStatusFile } from './status.mjs'
8
+
9
+ /**
10
+ * Owns the startup timer, the one-at-a-time lock, retry/self-heal timers and
11
+ * the last-run record. Pure Node; no host services besides the injected logger.
12
+ */
13
+ export function createSyncRuntime({ cfg, logger }) {
14
+ const lastRunFile = join(cfg.stateDir, 'last-run.json')
15
+ const lockDir = join(cfg.stateDir, 'lock')
16
+
17
+ let startupTimer = null
18
+ let retryTimer = null
19
+ let healTimer = null
20
+ let running = null
21
+ let lastRun = null
22
+ let notified = false
23
+ let disposed = false
24
+
25
+ const log = (level, message) => logger?.[level]?.(message)
26
+
27
+ function clearTimers() {
28
+ for (const timer of [startupTimer, retryTimer, healTimer]) {
29
+ if (timer) clearTimeout(timer)
30
+ }
31
+ startupTimer = null
32
+ retryTimer = null
33
+ healTimer = null
34
+ }
35
+
36
+ async function execute(trigger) {
37
+ if (disposed) return { ok: false, skipped: true, trigger, reason: 'disposed' }
38
+ if (running) return running
39
+
40
+ running = (async () => {
41
+ const lock = await acquireLock(lockDir)
42
+ if (!lock) return { ok: false, locked: true, trigger, at: new Date().toISOString() }
43
+ try {
44
+ log('info', `sync start (${trigger}) mode=${cfg.mode}`)
45
+ if (cfg.statusFile) {
46
+ await writeStatusFile({ statusFile: cfg.statusFile, logger })
47
+ }
48
+ const result = await runStartupChain(cfg)
49
+ const run = { ...result, trigger, at: new Date().toISOString() }
50
+ lastRun = run
51
+ await writeJsonAtomic(lastRunFile, run).catch(() => {})
52
+ log(
53
+ result.ok ? 'info' : 'warn',
54
+ `sync ${result.ok ? 'ok' : 'FAILED'} (${trigger}) head=${result.head || '-'}`,
55
+ )
56
+ return run
57
+ } catch (error) {
58
+ const run = {
59
+ ok: false,
60
+ phases: [],
61
+ trigger,
62
+ at: new Date().toISOString(),
63
+ error: String(error?.message || error),
64
+ }
65
+ lastRun = run
66
+ log('error', `sync crashed (${trigger}): ${run.error}`)
67
+ return run
68
+ } finally {
69
+ await lock.release().catch(() => {})
70
+ running = null
71
+ }
72
+ })()
73
+
74
+ return running
75
+ }
76
+
77
+ function finalFailure(run) {
78
+ if (cfg.notifyOnFailure && !notified) {
79
+ notified = true
80
+ const failed = (run.phases || [])
81
+ .filter((phase) => !phase.ok && !phase.skipped)
82
+ .map((phase) => phase.name)
83
+ .join('/')
84
+ const detail = failed || run.error || 'unknown'
85
+ void notifyFailure({
86
+ title: 'DSH 记忆同步失败',
87
+ message: `三段同步没有完成(${detail})。详见 ${logger?.file || cfg.stateDir}。`,
88
+ logger,
89
+ })
90
+ }
91
+ armHeal()
92
+ }
93
+
94
+ function armRetry(run) {
95
+ if (disposed || cfg.retryDelaySec <= 0) {
96
+ finalFailure(run)
97
+ return
98
+ }
99
+ if (retryTimer) clearTimeout(retryTimer)
100
+ retryTimer = setTimeout(() => {
101
+ retryTimer = null
102
+ void execute('retry').then((next) => afterRun(next, 'retry'))
103
+ }, cfg.retryDelaySec * 1000)
104
+ retryTimer.unref?.()
105
+ }
106
+
107
+ function armHeal() {
108
+ if (disposed || cfg.healIntervalSec <= 0) return
109
+ if (healTimer) clearTimeout(healTimer)
110
+ healTimer = setTimeout(() => {
111
+ healTimer = null
112
+ void execute('heal').then((next) => {
113
+ if (next.ok) {
114
+ notified = false
115
+ return
116
+ }
117
+ armHeal()
118
+ })
119
+ }, cfg.healIntervalSec * 1000)
120
+ healTimer.unref?.()
121
+ }
122
+
123
+ function afterRun(run, trigger) {
124
+ if (!run || run.skipped) return
125
+ if (run.locked) {
126
+ log('info', 'another sync holds the lock; will retry later')
127
+ armHeal()
128
+ return
129
+ }
130
+ if (run.ok) {
131
+ notified = false
132
+ return
133
+ }
134
+ if (trigger === 'startup') {
135
+ armRetry(run)
136
+ return
137
+ }
138
+ finalFailure(run)
139
+ }
140
+
141
+ function scheduleStartup() {
142
+ if (!cfg.enabled || !cfg.runOnStartup) {
143
+ log('info', 'startup sync disabled by config')
144
+ return
145
+ }
146
+ if (process.uptime() > cfg.startupGraceSec) {
147
+ log('info', `plugin mounted after ${Math.round(process.uptime())}s; treating as a reload, not a boot`)
148
+ return
149
+ }
150
+ // Let the app finish booting before touching git or ssh.
151
+ startupTimer = setTimeout(() => {
152
+ startupTimer = null
153
+ void execute('startup').then((run) => afterRun(run, 'startup'))
154
+ }, 2500)
155
+ startupTimer.unref?.()
156
+ }
157
+
158
+ async function run(trigger = 'manual') {
159
+ return execute(trigger)
160
+ }
161
+
162
+ async function status() {
163
+ const stored = lastRun || (await readJson(lastRunFile, null))
164
+ const repo = await inspectRepo(cfg)
165
+ return {
166
+ ok: Boolean(repo.ok),
167
+ repo: repo.ok
168
+ ? {
169
+ path: cfg.repoPath,
170
+ branch: repo.branch,
171
+ head: repo.head,
172
+ dirty: repo.dirty,
173
+ ahead: repo.ahead,
174
+ behind: repo.behind,
175
+ remote: repo.remote,
176
+ }
177
+ : { path: cfg.repoPath, error: repo.detail || 'not a git repo' },
178
+ lastRun: stored,
179
+ running: Boolean(running),
180
+ enabled: cfg.enabled,
181
+ mode: cfg.mode,
182
+ peerConfigured: Boolean(cfg.peerHost && cfg.peerUser),
183
+ logFile: logger?.file || '',
184
+ }
185
+ }
186
+
187
+ function dispose() {
188
+ disposed = true
189
+ clearTimers()
190
+ }
191
+
192
+ return { scheduleStartup, run, status, dispose }
193
+ }