@yandy0725/memory-core 2.0.0 → 2.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/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +28 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -5
- package/dist/index.js.map +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +1 -1
- package/dist/injection.js.map +1 -1
- package/dist/orchestration/conversation.d.ts.map +1 -1
- package/dist/orchestration/conversation.js +1 -2
- package/dist/orchestration/conversation.js.map +1 -1
- package/dist/orchestration/ports.d.ts.map +1 -1
- package/dist/orchestration/task.d.ts.map +1 -1
- package/dist/orchestration/task.js +1 -1
- package/dist/orchestration/task.js.map +1 -1
- package/dist/store/filename.d.ts +43 -0
- package/dist/store/filename.d.ts.map +1 -1
- package/dist/store/filename.js +72 -0
- package/dist/store/filename.js.map +1 -1
- package/dist/store/fs-lock.d.ts.map +1 -1
- package/dist/store/fs-lock.js +1 -3
- package/dist/store/fs-lock.js.map +1 -1
- package/dist/store/memory-store.d.ts +37 -0
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +169 -17
- package/dist/store/memory-store.js.map +1 -1
- package/dist/store/paths.d.ts.map +1 -1
- package/dist/store/paths.js +8 -2
- package/dist/store/paths.js.map +1 -1
- package/dist/store/process-lock.d.ts.map +1 -1
- package/dist/store/process-lock.js.map +1 -1
- package/dist/sync/backend-git.d.ts +27 -0
- package/dist/sync/backend-git.d.ts.map +1 -0
- package/dist/sync/backend-git.js +815 -0
- package/dist/sync/backend-git.js.map +1 -0
- package/dist/sync/backend.d.ts +213 -0
- package/dist/sync/backend.d.ts.map +1 -0
- package/dist/sync/backend.js +31 -0
- package/dist/sync/backend.js.map +1 -0
- package/dist/sync/conflict.d.ts +74 -0
- package/dist/sync/conflict.d.ts.map +1 -0
- package/dist/sync/conflict.js +164 -0
- package/dist/sync/conflict.js.map +1 -0
- package/dist/sync/device.d.ts +45 -0
- package/dist/sync/device.d.ts.map +1 -0
- package/dist/sync/device.js +227 -0
- package/dist/sync/device.js.map +1 -0
- package/dist/sync/diff.d.ts +78 -0
- package/dist/sync/diff.d.ts.map +1 -0
- package/dist/sync/diff.js +115 -0
- package/dist/sync/diff.js.map +1 -0
- package/dist/sync/engine.d.ts +142 -0
- package/dist/sync/engine.d.ts.map +1 -0
- package/dist/sync/engine.js +960 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hash.d.ts +11 -0
- package/dist/sync/hash.d.ts.map +1 -0
- package/dist/sync/hash.js +14 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/manifest.d.ts +63 -0
- package/dist/sync/manifest.d.ts.map +1 -0
- package/dist/sync/manifest.js +117 -0
- package/dist/sync/manifest.js.map +1 -0
- package/dist/sync/paths.d.ts +70 -0
- package/dist/sync/paths.d.ts.map +1 -0
- package/dist/sync/paths.js +111 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/scan.d.ts +44 -0
- package/dist/sync/scan.d.ts.map +1 -0
- package/dist/sync/scan.js +102 -0
- package/dist/sync/scan.js.map +1 -0
- package/dist/sync/scheduler.d.ts +81 -0
- package/dist/sync/scheduler.d.ts.map +1 -0
- package/dist/sync/scheduler.js +183 -0
- package/dist/sync/scheduler.js.map +1 -0
- package/dist/sync/types.d.ts +21 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +8 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/tool/handler.d.ts.map +1 -1
- package/dist/tool/handler.js +1 -3
- package/dist/tool/handler.js.map +1 -1
- package/package.json +15 -3
- package/src/config.ts +121 -0
- package/src/index.ts +101 -5
- package/src/injection.ts +1 -3
- package/src/orchestration/conversation.ts +1 -2
- package/src/orchestration/dream.ts +0 -1
- package/src/orchestration/extract.ts +0 -1
- package/src/orchestration/ports.ts +1 -5
- package/src/orchestration/prompts.ts +0 -1
- package/src/orchestration/surfacing.ts +0 -1
- package/src/orchestration/task.ts +2 -10
- package/src/store/filename.ts +71 -0
- package/src/store/fs-lock.ts +2 -6
- package/src/store/memory-store.ts +311 -134
- package/src/store/paths.ts +12 -3
- package/src/store/process-lock.ts +4 -1
- package/src/sync/backend-git.ts +902 -0
- package/src/sync/backend.ts +220 -0
- package/src/sync/conflict.ts +247 -0
- package/src/sync/device.ts +266 -0
- package/src/sync/diff.ts +152 -0
- package/src/sync/engine.ts +1258 -0
- package/src/sync/hash.ts +14 -0
- package/src/sync/manifest.ts +148 -0
- package/src/sync/paths.ts +117 -0
- package/src/sync/scan.ts +115 -0
- package/src/sync/scheduler.ts +241 -0
- package/src/sync/types.ts +22 -0
- package/src/tool/handler.ts +1 -3
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEFAULT_CONFIG, type SyncConfig } from "../config.js";
|
|
4
|
+
import { type ParsedEntryFile, parseEntryFile } from "../store/entry-file.js";
|
|
5
|
+
import { isEntryFileNameForWrite } from "../store/filename.js";
|
|
6
|
+
import { tryWithLock } from "../store/fs-lock.js";
|
|
7
|
+
import { type ApplyChange, type ApplyResult, BACKUP_DIR, INDEX_FILE, type MemoryStore } from "../store/memory-store.js";
|
|
8
|
+
import { createSnapshot } from "../store/snapshot.js";
|
|
9
|
+
import {
|
|
10
|
+
NonFastForwardError,
|
|
11
|
+
type RemoteView,
|
|
12
|
+
type SyncBackend,
|
|
13
|
+
type SyncPublishChanges,
|
|
14
|
+
type SyncScope,
|
|
15
|
+
} from "./backend.js";
|
|
16
|
+
import { type ConflictOutcome, type ConflictSide, resolveConflict } from "./conflict.js";
|
|
17
|
+
import type { DeviceIdentity } from "./device.js";
|
|
18
|
+
import { type SyncConflict, diffThreeWay, type SyncDiff } from "./diff.js";
|
|
19
|
+
import { sha256Hex } from "./hash.js";
|
|
20
|
+
import {
|
|
21
|
+
type SyncTransport,
|
|
22
|
+
emptyManifest,
|
|
23
|
+
MANIFEST_VERSION,
|
|
24
|
+
manifestMatchesTransport,
|
|
25
|
+
manifestPath,
|
|
26
|
+
readManifest,
|
|
27
|
+
syncTransport,
|
|
28
|
+
writeManifest,
|
|
29
|
+
} from "./manifest.js";
|
|
30
|
+
import { remotePrefixFromKey, syncLockPath } from "./paths.js";
|
|
31
|
+
import { type LocalScan, scanLocalEntries } from "./scan.js";
|
|
32
|
+
import { createSyncScheduler, type SyncScheduler } from "./scheduler.js";
|
|
33
|
+
import type { SyncFileInfo, SyncFileSet } from "./types.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 同步引擎:把「远端快照 + 本地扫描 + 上一次的基线」收敛成一次可解释的动作,并把它报出来。
|
|
37
|
+
*
|
|
38
|
+
* 一轮严格分三段,两把锁都是 **try 语义**(忙即跳过,绝不等待),而且**永不嵌套**:
|
|
39
|
+
*
|
|
40
|
+
* ```
|
|
41
|
+
* ① 传输段 根级 sync 锁(<syncRoot>/sync.lock)→ backend.fetch() → 出段即释放
|
|
42
|
+
* ② 应用段 项目的进程内逻辑锁 → 扫描 / 三方差集 / 快照 / 落盘 / 重建索引 / 写基线 → 释放
|
|
43
|
+
* ③ 发布段 再取一次根级 sync 锁 → backend.publish(增量) → 释放
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* 根锁不跨网络持有是刻意的:整轮持一把根锁会把远端往返的时间算进去,同一台机器上开着两个项目
|
|
47
|
+
* 会话时后启动的那个会整轮被跳过,而一次会话里拉取只有一次机会。因为两把锁从不嵌套,
|
|
48
|
+
* 也就不存在锁序成环的问题。
|
|
49
|
+
*
|
|
50
|
+
* pull 轮 = ①②;push 轮 = ①②③(先收敛再发布,所以 push 轮也可能改本地、也要快照)。
|
|
51
|
+
*
|
|
52
|
+
* **铁律:任何同步失败都不得阻塞会话、不得损坏本地记忆,最坏结果永远是「退回手动」。**
|
|
53
|
+
* 因此这里没有一处会把异常抛给调用方:所有失败都变成 `ok: false` + 一句可读的原因。
|
|
54
|
+
* 五条数据安全线(基线只记「两侧达成一致的状态」、读不出的本地文件既不是删除也不是可覆盖的
|
|
55
|
+
* 旧版本、远端 `modified` 必须先验形状、冲突收敛抛错只跳过该条、根级 sync 锁真的存在)
|
|
56
|
+
* 各自在实现处写了「为什么不能删掉这段」。
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** 引擎的装配参数。全部由适配层算好交进来:core 不读配置文件,也不猜项目身份。 */
|
|
60
|
+
export interface SyncEngineOptions {
|
|
61
|
+
/** 本功能自己的状态目录(记忆根下的那个隐藏目录):基线、设备身份、根级锁都在里面。 */
|
|
62
|
+
syncRoot: string;
|
|
63
|
+
/** 这个项目同步的目录(记忆根下的 `git/<project>`)。报告用;落盘一律以 `store.cfg.memoryDir` 为准。 */
|
|
64
|
+
projectDir: string;
|
|
65
|
+
/** 远端布局里这个项目的 key(`remoteProjectKey` 的输出)。 */
|
|
66
|
+
projectKey: string;
|
|
67
|
+
store: MemoryStore;
|
|
68
|
+
backend: SyncBackend;
|
|
69
|
+
device: DeviceIdentity;
|
|
70
|
+
config: SyncConfig;
|
|
71
|
+
/** 只为测试可固定:基线的 `syncedAt` 与快照目录名都用它。 */
|
|
72
|
+
now?: () => Date;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 一轮同步的结果。`ok: false` 时 `skipped.reason` 一定有一句人话。 */
|
|
76
|
+
export interface SyncRoundResult {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
/** 本轮为什么没做完(配置未开、锁忙、远端不可达……)。只在 `ok: false` 时出现。 */
|
|
79
|
+
skipped?: { reason: string };
|
|
80
|
+
/** 本轮从远端落到本地的 entry 文件名。 */
|
|
81
|
+
pulled: string[];
|
|
82
|
+
/** 本轮发布到远端的 entry 文件名(pull 轮不发布,恒为空)。 */
|
|
83
|
+
pushed: string[];
|
|
84
|
+
/** 本轮在本地删掉的文件名(含「删除 vs 修改」里照办的那次删除)。 */
|
|
85
|
+
deletedLocal: string[];
|
|
86
|
+
/**
|
|
87
|
+
* 本轮从远端删掉的文件名。pull 轮不发布,那里报的是**本轮判定、随下一次发布生效**的删除 ——
|
|
88
|
+
* 判定了却没报出来,用户就无从知道自己的删除还没生效。
|
|
89
|
+
*/
|
|
90
|
+
deletedRemote: string[];
|
|
91
|
+
/** 收敛掉的冲突:`file` 是原路径,`keptAsNew` 是被挤出来那一份的新文件名。 */
|
|
92
|
+
conflicts: Array<{ file: string; keptAsNew: string }>;
|
|
93
|
+
/** 被拒收的内容(远端那份解析不出、`modified` 形状不对、文件名碰撞……),带原因。
|
|
94
|
+
* 原因词与 `applyEntryFiles` 同一口径:引擎自己判定的那几种是**稳定词汇**
|
|
95
|
+
* (`unparsable frontmatter` / `invalid modified` / `unpublishable file name`),异常路径(读不到字节、
|
|
96
|
+
* 冲突没收敛掉)则直接把底层错误拼进去 —— 那些不是词汇,是诊断信息。 */
|
|
97
|
+
rejected: Array<{ file: string; reason: string }>;
|
|
98
|
+
/** 本地解析不出/读不了的 `.md`:本轮不参与判定,但必须让用户知道它没同步。 */
|
|
99
|
+
unparsableLocal: string[];
|
|
100
|
+
/**
|
|
101
|
+
* 远端有、但本轮不会同步的路径(远端根相对):传输端口报上来的**异常**路径(手工推上来的
|
|
102
|
+
* 索引、本前缀底下的嵌套目录……),加上引擎自己判定的「这个名字在任何设备上都写不下去」。
|
|
103
|
+
* 别的项目的记忆**不在这里** —— 那是设计规定的常态,报出来就等于每轮上千条噪声。
|
|
104
|
+
*/
|
|
105
|
+
outOfScopeRemote: string[];
|
|
106
|
+
/**
|
|
107
|
+
* 本轮结束时基线指向的远端版本(发布成功则是发布后的那一个)。
|
|
108
|
+
*
|
|
109
|
+
* **不变量:只有基线真写进盘了才有值。** 写基线失败、或在写基线之前就失败的那些轮
|
|
110
|
+
* (锁忙、未初始化、未配置、传输失败、扫描抛错)一律是 `undefined` —— 基线是适配层
|
|
111
|
+
* 渲染「同步到哪了」的唯一依据,报一个盘上根本不存在的 revision,等于让用户对着一个
|
|
112
|
+
* 假状态去排查(而 `pulled` / `conflicts` 那一堆部分结果反而可能非空:失败轮也可能已经改过本地)。
|
|
113
|
+
*/
|
|
114
|
+
revision?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `status` 的报告。**dry-run**:算得出一切,但盘上一个字都不改。 */
|
|
118
|
+
export interface SyncStatusReport {
|
|
119
|
+
configured: boolean;
|
|
120
|
+
initialized: boolean;
|
|
121
|
+
remote: string;
|
|
122
|
+
branch: string;
|
|
123
|
+
device: DeviceIdentity;
|
|
124
|
+
/** 本地目录(可能与远端 key 不同名:报出两者,映射关系才看得见)。 */
|
|
125
|
+
projectDir: string;
|
|
126
|
+
projectKey: string;
|
|
127
|
+
base: { revision: string; syncedAt: string } | null;
|
|
128
|
+
/** 本地领先远端的那些文件名(待发布的新增/修改,以及待发布的本地删除)。 */
|
|
129
|
+
ahead: string[];
|
|
130
|
+
/** 远端领先本地的那些文件名(待拉取的内容,以及待执行的远端删除)。 */
|
|
131
|
+
behind: string[];
|
|
132
|
+
conflicts: string[];
|
|
133
|
+
rejected: Array<{ file: string; reason: string }>;
|
|
134
|
+
unparsableLocal: string[];
|
|
135
|
+
/** 同一轮结果里那个字段的口径(异常路径,不含别的项目的记忆)。 */
|
|
136
|
+
outOfScopeRemote: string[];
|
|
137
|
+
/** 每一步失败的可读原因(未配置、未初始化、锁忙、远端不可达、目录读不了……)。 */
|
|
138
|
+
errors: string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SyncEngine {
|
|
142
|
+
pull(timeoutMs?: number): Promise<SyncRoundResult>;
|
|
143
|
+
push(timeoutMs?: number): Promise<SyncRoundResult>;
|
|
144
|
+
status(timeoutMs?: number): Promise<SyncStatusReport>;
|
|
145
|
+
/** 记下「本地刚写过」,由写触发调度器接线(去抖之后调 `flush`)。 */
|
|
146
|
+
noteWrite(): void;
|
|
147
|
+
/**
|
|
148
|
+
* 立刻跑一次去抖中的推送;没有尚未发布的内容则返回 `null`。
|
|
149
|
+
* 「尚未发布」包括上一次自动推送被跳过/失败的那一份:撞上锁而白跑的一轮不算干净,
|
|
150
|
+
* 宿主退出前调它就能把那一推补上。
|
|
151
|
+
*/
|
|
152
|
+
flush(timeoutMs?: number): Promise<SyncRoundResult | null>;
|
|
153
|
+
/** 释放调度器持有的资源。绝不抛错。 */
|
|
154
|
+
dispose(): void;
|
|
155
|
+
/**
|
|
156
|
+
* 在途的那一次自动推送(没有则 `null`)。宿主把它挂进自己的「退出前等在途写」账本:
|
|
157
|
+
* 看不到它,退出时正好跑着的一次推送就会被静默截断。
|
|
158
|
+
*/
|
|
159
|
+
readonly inFlight: Promise<unknown> | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 关闭写触发推送时装上的空转调度器:同一个接口,`noteWrite` 静默空转。
|
|
164
|
+
*
|
|
165
|
+
* 这样调用点(每次写入之后那一行)不必到处判 `config.pushOnWrite` —— 分支只在这里出现一次。
|
|
166
|
+
*/
|
|
167
|
+
const NOOP_SCHEDULER: SyncScheduler<SyncRoundResult> = {
|
|
168
|
+
noteWrite(): void {},
|
|
169
|
+
flush(): Promise<null> {
|
|
170
|
+
return Promise.resolve(null);
|
|
171
|
+
},
|
|
172
|
+
dispose(): void {},
|
|
173
|
+
inFlight: null,
|
|
174
|
+
dirty: false,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** 「本轮跳过」内部控制流:与真异常走同一条出口(都变成 `ok: false` + 可读原因)。 */
|
|
178
|
+
class SyncSkip extends Error {
|
|
179
|
+
constructor(reason: string) {
|
|
180
|
+
super(reason);
|
|
181
|
+
this.name = "SyncSkip";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 发布段撞上非快进:要求整轮重跑(重新取远端、重算差集),不是只重试一次 publish。 */
|
|
186
|
+
interface RetryRound {
|
|
187
|
+
retry: string;
|
|
188
|
+
/**
|
|
189
|
+
* 这一次尝试**已经落到本地**的那部分结果。
|
|
190
|
+
*
|
|
191
|
+
* 非快进是在发布段抛的,而应用段早就跑完了:拉下来的内容、冲突副本、`-sync` 快照、写好的
|
|
192
|
+
* 基线都已经在盘上。重跑不会撤销它们,所以这份部分结果必须跟着带出去(见 `runRound`)。
|
|
193
|
+
*/
|
|
194
|
+
partial: SyncRoundResult;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type RoundOutcome = SyncRoundResult | RetryRound;
|
|
198
|
+
|
|
199
|
+
type Mode = "pull" | "push";
|
|
200
|
+
|
|
201
|
+
/** 非快进最多重跑一次:再失败就把本轮交回给下一次触发(基线不前进,脏状态原样留着)。 */
|
|
202
|
+
const MAX_ROUND_ATTEMPTS = 2;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `modified` 的 ISO-8601 形状:完整时刻(可无毫秒、可带时区偏移),或纯日期。
|
|
206
|
+
*
|
|
207
|
+
* 只认形状,不用 `Date.parse` 单独判:`Date.parse` 会接受一堆本地化写法(`"Nov 9 2026"`),
|
|
208
|
+
* 而 `modified` 是跨设备**唯一**的定序与冲突判据,两台设备必须对同一串字节得出同一个先后。
|
|
209
|
+
* 形状过了再用 `Date.parse` 复核一次,挡掉 `2026-13-45` 这种字段越界。
|
|
210
|
+
*/
|
|
211
|
+
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
212
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
213
|
+
|
|
214
|
+
function isIsoModified(value: string): boolean {
|
|
215
|
+
if (!ISO_INSTANT_RE.test(value) && !ISO_DATE_RE.test(value)) return false;
|
|
216
|
+
return !Number.isNaN(Date.parse(value));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function describeError(err: unknown): string {
|
|
220
|
+
return err instanceof Error ? err.message : String(err);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 时间预算的入口兜底:只认「有限的正数」,其余一律退回 `fallback`。
|
|
225
|
+
*
|
|
226
|
+
* 配置合并那一层不校验类型,`deepMerge` 还会把用户写的 `null` 原样带进来;而 `null` / `NaN` / `0`
|
|
227
|
+
* 一路走到子进程的 `timeout` 上等于**无限等** —— 一次远端挂死就把「任何同步失败都不得阻塞会话」
|
|
228
|
+
* 这条铁律捅穿(自动拉取是被 await 在会话启动路径上的)。所以预算只在入口这里认一次,
|
|
229
|
+
* 里面每一层拿到的都已经是个能用的数。
|
|
230
|
+
*/
|
|
231
|
+
function positiveMs(value: unknown, fallback: number): number {
|
|
232
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 一份预算还剩下多少毫秒。**下限是 1 而不是 0**:子进程的 `timeout: 0` 是「不限时」,
|
|
237
|
+
* 预算耗尽时给出去的必须是「立刻超时」,而不是把无限等当成兜底。
|
|
238
|
+
*/
|
|
239
|
+
function remainingMs(deadline: number): number {
|
|
240
|
+
return Math.max(1, deadline - Date.now());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 「这个名字在哪台设备上都写不下去」的稳定原因词(同 `unparsable frontmatter` / `invalid modified`)。
|
|
245
|
+
*
|
|
246
|
+
* 不把具体理由(哪个保留名、哪一类字符)拼进去:适配层要拿它分类,而拼进去的文案会把一个
|
|
247
|
+
* 本属于用户记忆名字的字串变成只能靠子串匹配的变量。
|
|
248
|
+
*/
|
|
249
|
+
const UNPUBLISHABLE_NAME = "unpublishable file name";
|
|
250
|
+
|
|
251
|
+
/** 两份清单按 key 去重合并(同一个 key 保留后出现的那一份),顺序 = 出现顺序。 */
|
|
252
|
+
function unionBy<T>(left: T[], right: T[], key: (item: T) => string): T[] {
|
|
253
|
+
const seen = new Map<string, T>();
|
|
254
|
+
for (const item of [...left, ...right]) seen.set(key(item), item);
|
|
255
|
+
return [...seen.values()];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 把两次尝试各自**已经落到本地**的那部分合成一份报告。
|
|
260
|
+
*
|
|
261
|
+
* 重跑不会撤销上一次的落盘,而上一次拉下来的内容在新基线里已经是「两侧一致」的了 ——
|
|
262
|
+
* 第二次尝试的报告里不会再提它。只报后一次,用户看到的仍然是「本轮什么都没拉」,
|
|
263
|
+
* 而盘上明明多了文件 —— 正是这一版失败报告要修掉的那句假话。
|
|
264
|
+
*/
|
|
265
|
+
function mergePartials(previous: SyncRoundResult | undefined, latest: SyncRoundResult): SyncRoundResult {
|
|
266
|
+
if (previous === undefined) return latest;
|
|
267
|
+
const byName = (file: string): string => file;
|
|
268
|
+
return {
|
|
269
|
+
...latest,
|
|
270
|
+
pulled: unionBy(previous.pulled, latest.pulled, byName),
|
|
271
|
+
pushed: unionBy(previous.pushed, latest.pushed, byName),
|
|
272
|
+
deletedLocal: unionBy(previous.deletedLocal, latest.deletedLocal, byName),
|
|
273
|
+
deletedRemote: unionBy(previous.deletedRemote, latest.deletedRemote, byName),
|
|
274
|
+
unparsableLocal: unionBy(previous.unparsableLocal, latest.unparsableLocal, byName),
|
|
275
|
+
outOfScopeRemote: unionBy(previous.outOfScopeRemote, latest.outOfScopeRemote, byName),
|
|
276
|
+
conflicts: unionBy(previous.conflicts, latest.conflicts, (item) => item.file),
|
|
277
|
+
// 同一个文件因为同一个原因被拒收两次只报一次;原因不同则两条都留着(那是两次不同的拒收)
|
|
278
|
+
rejected: unionBy(previous.rejected, latest.rejected, (item) => `${item.file}\n${item.reason}`),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asFileSet(files: Record<string, SyncFileInfo>): SyncFileSet {
|
|
283
|
+
return new Map(Object.entries(files));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function asFileRecord(files: SyncFileSet): Record<string, SyncFileInfo> {
|
|
287
|
+
return Object.fromEntries(files);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 算出这一轮之后该记进基线的文件集:**这一轮的 `theirs`**,再按两类例外修正(本轮没收下的
|
|
292
|
+
* 名字剔掉,本地读不出的名字冻结在上一次一致的那份)。
|
|
293
|
+
*
|
|
294
|
+
* 基线是三方合并里的「共同祖先」,它的语义是「两侧上一次真正达成一致的状态」,
|
|
295
|
+
* **不是**「我盘上现在有什么」。所以它的形状只能从远端这一侧长出来:远端此刻持有的就是
|
|
296
|
+
* 两侧都看得见的那份状态,而本地独有的内容要等**发布成功**之后才算达成一致(见 `publishPhase`,
|
|
297
|
+
* 那里用发布出去的增量覆盖/删除对应条目)。
|
|
298
|
+
*
|
|
299
|
+
* `unagreed` 是本轮**没能收下**远端那份内容的名字(解析不出、`modified` 形状不对、store 拒收、
|
|
300
|
+
* 冲突没收敛掉)。把它们记进基线等于宣布「我们已经接受了这份内容」,而下一轮的差集会照这句话办事:
|
|
301
|
+
* - 本地没有它 → 「base 有 / ours 无 / theirs 未动」= 用户删了 → 把对端那份删掉;
|
|
302
|
+
* - 本地有自己的版本 → 「base 有(= 对端那份)/ ours 改了 / theirs 未动」= 本地胜 →
|
|
303
|
+
* 拿本地那份盖掉对端那份,静默丢掉我们明确拒收过的内容。
|
|
304
|
+
* 两种都是替对端做决定。所以这类名字**不跟着 `theirs` 前进**,而是退回上一次真正达成一致的
|
|
305
|
+
* 那一份(`previousBase`,没有就不记),下一轮才会照旧判成「远端改了 / 我们收不下」→ 再报一次。
|
|
306
|
+
* 干脆从基线里剔掉也保得住数据,但报告会变脏:基线里没有它,下一轮就把这件事读成
|
|
307
|
+
* 「两侧各新增了一份」→ 冲突,于是 `status` 在拒收旁边永久多出一条并不存在的冲突。
|
|
308
|
+
*
|
|
309
|
+
* 注意本地读不出的文件(`unparsable`)**不在** `unagreed` 里,它们走另一条路:基线**冻结**在
|
|
310
|
+
* 上一次一致的那份(`untouched`)。因为本地这一份本轮根本不参与判定,也就无从达成一致,
|
|
311
|
+
* 此时跟着 `theirs` 前进会把「对端的新版本」误记成「我们已经接受过的共同祖先」:
|
|
312
|
+
* 用户把本地文件修好之后(修好的往往是自己那份旧内容),下一轮就成了「只本地改过」→
|
|
313
|
+
* 拿修好的那份盖掉对端的新版本,对端的内容两边一起丢。冻结在上一次一致的那份,
|
|
314
|
+
* 修好之后就是「两侧都改过」→ 冲突 → 两份都留;用户自己删掉它之后,删除也能正常传到对端。
|
|
315
|
+
*
|
|
316
|
+
* @param theirs 这一轮取到的远端文件集
|
|
317
|
+
* @param localAfter 应用之后重新扫出来的本地文件集
|
|
318
|
+
* @param previousBase 上一次的基线(只用于冻结 `untouched` 里的名字)
|
|
319
|
+
* @param untouched 本地读不出的名字:本轮一律不动,基线也不得前进
|
|
320
|
+
* @param unagreed 本轮没收下远端那份内容的名字
|
|
321
|
+
*/
|
|
322
|
+
function agreedBaseFiles(
|
|
323
|
+
theirs: SyncFileSet,
|
|
324
|
+
localAfter: SyncFileSet,
|
|
325
|
+
previousBase: SyncFileSet,
|
|
326
|
+
untouched: ReadonlySet<string>,
|
|
327
|
+
unagreed: ReadonlySet<string>,
|
|
328
|
+
): Map<string, SyncFileInfo> {
|
|
329
|
+
const base = new Map<string, SyncFileInfo>();
|
|
330
|
+
for (const file of new Set([...theirs.keys(), ...untouched])) {
|
|
331
|
+
if (unagreed.has(file)) {
|
|
332
|
+
// 退回上一次**真的一致过**的那份(没有就不记)。别干脆剔掉:基线里没有它,
|
|
333
|
+
// 下一轮就把「远端改了、我们收不下」读成「两侧各新增了一份」→ 凭空多出一条冲突。
|
|
334
|
+
const agreed = previousBase.get(file);
|
|
335
|
+
if (agreed !== undefined) base.set(file, agreed);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (untouched.has(file)) {
|
|
339
|
+
// 从没达成过一致(例如一上来就读不出、远端也从来没推成功过)→ 干脆不记:
|
|
340
|
+
// 记一个假祖先比不记更危险,它会让下一轮把两侧的内容当成「其中一侧改过」。
|
|
341
|
+
const frozen = previousBase.get(file);
|
|
342
|
+
if (frozen !== undefined) base.set(file, frozen);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const remote = theirs.get(file);
|
|
346
|
+
if (remote === undefined) continue;
|
|
347
|
+
const mine = localAfter.get(file);
|
|
348
|
+
// 本地此刻就是同一份字节时用本地扫出的指纹:远端清单只给得出 hash,`modified`/`bytes`
|
|
349
|
+
// 是占位值(见 `readRemoteSnapshot`),能给出真值就不要在基线里留占位。
|
|
350
|
+
base.set(file, mine !== undefined && mine.hash === remote.hash ? mine : remote);
|
|
351
|
+
}
|
|
352
|
+
return base;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 解析不出/读不了的本地文件本轮**一律不动**:从差集产出的每一张动作清单里剔除。
|
|
357
|
+
*
|
|
358
|
+
* 扫描把这种文件放进 `unparsable` 而不是 `files`,于是差集看到「`ours` 里没有它」,
|
|
359
|
+
* 按标准三方语义有**会动到这份读不出的文件**的三种结论(落在哪一种取决于它在 `base` 里有没有、
|
|
360
|
+
* 以及远端相对基线动没动;两侧都没动、或远端也没这个名字时,差集判的是无动作):
|
|
361
|
+
* - `base` 里有、远端未动 → 判成**用户删除** → 删除被推到远端:一个坏文件(手滑编辑、半截写入、
|
|
362
|
+
* 权限抖动)就能把两台设备上的同一条记忆一起删掉,而本地那份还躺在盘上;
|
|
363
|
+
* - `base` 里有、远端改过 → 判成**删除 vs 修改**冲突 → 删除那半边照办:这份读不出的文件从原路径被
|
|
364
|
+
* 删掉,远端那份字节只以一个派生的 `… (冲突 …)` 名字留下(原路径的删与留由冲突收敛一并决定,
|
|
365
|
+
* 见 `convergeConflict`);
|
|
366
|
+
* - `base` 里没有、远端有 → 判成**远端新增** → 用远端那份盖掉同名的本地文件。
|
|
367
|
+
* (「远端改过而本地按基线算没动 → 远端胜」这种组合在这里**不可达**:`ours` 缺失的名字在差集里
|
|
368
|
+
* 只能读成删除或冲突,不可能同时是「按基线算没动」—— 见 `diffThreeWay` 的 `!inOurs` 分支。)
|
|
369
|
+
*
|
|
370
|
+
* 第三种尤其拦不得:frontmatter 被手改坏的文件,正文往往正是用户最在乎的内容,用远端那份
|
|
371
|
+
* 盖掉它违反本设计的铁律「任何同步失败都不得损坏本地记忆」,而 `.backups/<ts>-sync/` 只保留
|
|
372
|
+
* 5 份快照,不足以当作覆盖的补偿。所以这里连 `conflicts` 一起剔:这份文件既不被覆盖、也不被删除、
|
|
373
|
+
* 更不触发对端删除,只出现在 `unparsableLocal` 报告里,每轮都报,直到用户自己修好或自己删掉它。
|
|
374
|
+
*
|
|
375
|
+
* 剔完之后**不需要**再把这些名字按基线补回 `ours`:补回只能压掉「判成本地删除 → 推到远端」那一种
|
|
376
|
+
* (`deleteRemote`);「删 vs 改」冲突压不住(冲突收敛照样把原路径删掉),而「远端新增」那一种更是
|
|
377
|
+
* 无从定义 —— 它在 `base` 里根本没有可补的那一份。两套机制并存还会让「差集的结论」与「实际执行的
|
|
378
|
+
* 动作」长期不一致,后来人再也看不出哪一套算数。
|
|
379
|
+
*/
|
|
380
|
+
function withoutUnparsable(diff: SyncDiff, untouched: ReadonlySet<string>): SyncDiff {
|
|
381
|
+
if (untouched.size === 0) return diff;
|
|
382
|
+
return {
|
|
383
|
+
pullFromRemote: diff.pullFromRemote.filter((file) => !untouched.has(file)),
|
|
384
|
+
pushLocal: diff.pushLocal.filter((file) => !untouched.has(file)),
|
|
385
|
+
deleteLocal: diff.deleteLocal.filter((file) => !untouched.has(file)),
|
|
386
|
+
deleteRemote: diff.deleteRemote.filter((file) => !untouched.has(file)),
|
|
387
|
+
conflicts: diff.conflicts.filter((conflict) => !untouched.has(conflict.file)),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 本轮**没收下**远端这份内容:既不发布(两个方向都不动),也绝不进基线(见 `agreedBaseFiles`)。 */
|
|
392
|
+
function markUnagreed(plan: ApplyPlan, file: string): void {
|
|
393
|
+
plan.excluded.add(file);
|
|
394
|
+
plan.unagreed.add(file);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 一次远端快照里引擎要用的部分:内容按名字取,`theirs` 只有 hash 是真的(见 `readRemoteSnapshot`)。 */
|
|
398
|
+
interface RemoteSnapshot {
|
|
399
|
+
view: RemoteView;
|
|
400
|
+
revision: string;
|
|
401
|
+
/** fetch 同一根锁内读到的真实 origin:只用于 stale-publish 校验与派生哈希,绝不写明文进清单。 */
|
|
402
|
+
remote: string;
|
|
403
|
+
transport: SyncTransport;
|
|
404
|
+
/** status 在 origin 缺失时仍可 dry-run,但此时任何已存基线都不得参与差集。 */
|
|
405
|
+
originVerified: boolean;
|
|
406
|
+
theirs: SyncFileSet;
|
|
407
|
+
outOfScope: string[];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 应用段的产物:交给发布段的增量,以及发布成功之后基线该记的那份文件集。 */
|
|
411
|
+
interface AppliedRound {
|
|
412
|
+
delta: SyncPublishChanges;
|
|
413
|
+
/** 发布成功之后远端应有的样子(= 本轮基线 + 发布出去的增量)。没发布成功就不许用它前进基线。 */
|
|
414
|
+
remoteAfterPublish: SyncFileSet;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 一轮里逐步攒出来的落盘计划。 */
|
|
418
|
+
interface ApplyPlan {
|
|
419
|
+
writes: ApplyChange[];
|
|
420
|
+
deletes: string[];
|
|
421
|
+
/** 已占用的文件名(能给 hash 就给,冲突副本的「同内容即复用」判断要靠它)。 */
|
|
422
|
+
taken: Array<{ file: string; hash?: string }>;
|
|
423
|
+
/**
|
|
424
|
+
* 本轮**不得发布**的名字:本地读不出的、远端那份被拒收的、冲突没收敛掉的。
|
|
425
|
+
*
|
|
426
|
+
* 两个方向都得跳:把它们算成写入等于把一份我们根本没接受的字节推给对端,
|
|
427
|
+
* 算成删除更糟 —— 那是「我们收不下对端那份,于是把对端那份删了」。
|
|
428
|
+
*/
|
|
429
|
+
excluded: Set<string>;
|
|
430
|
+
/**
|
|
431
|
+
* 本轮**没收下远端那份内容**的名字(`excluded` 的子集,见 `markUnagreed`):不得进基线。
|
|
432
|
+
*
|
|
433
|
+
* 与 `excluded` 分开是因为两者不重合:本地读不出的文件在 `excluded` 里(不发布),
|
|
434
|
+
* 但它不算「没收下」—— 本地这一份本轮压根没参与判定,基线走的是另一条路(冻结在上一次
|
|
435
|
+
* 一致的那份,见 `agreedBaseFiles` 的 `untouched`)。
|
|
436
|
+
*/
|
|
437
|
+
unagreed: Set<string>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function createSyncEngine(options: SyncEngineOptions): SyncEngine {
|
|
441
|
+
const { syncRoot, projectDir, projectKey, store, backend, device, config } = options;
|
|
442
|
+
const now = options.now ?? ((): Date => new Date());
|
|
443
|
+
const platform = store.cfg.platform ?? process.platform;
|
|
444
|
+
/**
|
|
445
|
+
* 本地的一切读写都以 store 配置的目录为准,而不是 `options.projectDir`。
|
|
446
|
+
*
|
|
447
|
+
* 两者本该是同一个目录;万一是两个,扫描读的是 A、`applyEntryFiles` 写的是 B,
|
|
448
|
+
* 差集就会把「A 里没有」读成本地删除并推到远端 —— 所以这里只认写入通道认的那个目录。
|
|
449
|
+
* `projectDir` 仅用于报告(`status` 里那条「本地目录 ↔ 远端 key」的映射)。
|
|
450
|
+
*/
|
|
451
|
+
const memoryDir = store.cfg.memoryDir;
|
|
452
|
+
const rootLockPath = syncLockPath(syncRoot);
|
|
453
|
+
const manifestFile = manifestPath(syncRoot, projectKey);
|
|
454
|
+
const scope: SyncScope = { prefix: remotePrefixFromKey(projectKey) };
|
|
455
|
+
|
|
456
|
+
const NOT_CONFIGURED = 'sync is not configured: set "sync.enabled" and "sync.remote" in memory.json';
|
|
457
|
+
const NOT_INITIALIZED =
|
|
458
|
+
'the sync backend is not initialized: set "sync.remote" in memory.json so the host can initialize it';
|
|
459
|
+
const DISPOSED = "the sync engine is disposed; no new backend I/O will be started";
|
|
460
|
+
const ROOT_LOCK_BUSY = "another sync round holds the root sync lock, so this round was skipped";
|
|
461
|
+
const MEMORY_LOCK_BUSY = "the memory lock for this project is busy, so this round was skipped";
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 三个时间参数只在装配时认一次(`positiveMs`)。
|
|
465
|
+
*
|
|
466
|
+
* 为什么要兜:`mergeConfig` 不校验类型,`deepMerge` 还会把用户写的 `null` 原样带进来,而 `null` /
|
|
467
|
+
* `NaN` / `0` 一路传到子进程的 `timeout` 上等于**无限等**,传到 `setTimeout` 上等于「每次写入都立刻推一次」。
|
|
468
|
+
* 两者都把「一次同步失败不得阻塞会话」这条铁律变成了靠配置正确才成立的东西。
|
|
469
|
+
* 退回值是包里的那套默认值,而不是就地造一个魔法数:默认值已经记在设计里(自动档 5s / 手动档 20s)。
|
|
470
|
+
*/
|
|
471
|
+
const autoBudget = positiveMs(config.autoTimeoutMs, DEFAULT_CONFIG.sync.autoTimeoutMs);
|
|
472
|
+
const manualBudget = positiveMs(config.timeoutMs, DEFAULT_CONFIG.sync.timeoutMs);
|
|
473
|
+
const debounce = positiveMs(config.pushDebounceMs, DEFAULT_CONFIG.sync.pushDebounceMs);
|
|
474
|
+
|
|
475
|
+
let disposed = false;
|
|
476
|
+
|
|
477
|
+
function hasConfiguredRemote(): boolean {
|
|
478
|
+
// 逐字判类型:配置合并那一层不校验类型,`remote` 可能是任何值。
|
|
479
|
+
return typeof config.remote === "string" && config.remote.trim() !== "";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isConfigured(initialized: boolean): boolean {
|
|
483
|
+
// `enabled` 用 `=== true` 而不是真值:这是一道**同意边界** —— 用户写的 `"enabled": "false"`
|
|
484
|
+
// 在 JS 里是真值,按真值判定就会把明写的「关」读成「开」,继续往远端推数据。
|
|
485
|
+
// 初始化之后 origin 是真相来源,配置里的 remote 留空合法;只有尚未初始化时才需要它。
|
|
486
|
+
return config.enabled === true && (initialized || hasConfiguredRemote());
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function emptyResult(): SyncRoundResult {
|
|
490
|
+
return {
|
|
491
|
+
ok: true,
|
|
492
|
+
pulled: [],
|
|
493
|
+
pushed: [],
|
|
494
|
+
deletedLocal: [],
|
|
495
|
+
deletedRemote: [],
|
|
496
|
+
conflicts: [],
|
|
497
|
+
rejected: [],
|
|
498
|
+
unparsableLocal: [],
|
|
499
|
+
outOfScopeRemote: [],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function failedRound(reason: string, partial?: Partial<SyncRoundResult>): SyncRoundResult {
|
|
504
|
+
return { ...emptyResult(), ...partial, ok: false, skipped: { reason } };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 根级 sync 锁(`<syncRoot>/sync.lock`),**try 语义**:拿不到就跳过本轮,绝不排队等。
|
|
509
|
+
*
|
|
510
|
+
* 这把锁兑现的是传输端口对 backend 的承诺:同一个 `syncRoot` 上的 `fetch`/`publish` 已经被
|
|
511
|
+
* 串行化,所以以独立包实现的 backend 不必自加内部锁。它保护的是那台机器上共享的传输状态
|
|
512
|
+
* (git backend 的暂存 clone),因此传输段与发布段各取一次,**中间放开** ——
|
|
513
|
+
* 跨网络持锁会让同一台机器上另一个项目的同步整轮被跳过。
|
|
514
|
+
*/
|
|
515
|
+
async function withRootLock<T>(fn: () => Promise<T>): Promise<{ acquired: true; value: T } | { acquired: false }> {
|
|
516
|
+
// 锁文件就在 syncRoot 里,而首次同步时这个目录可能还没人建过
|
|
517
|
+
await mkdir(syncRoot, { recursive: true });
|
|
518
|
+
const outcome = await tryWithLock(rootLockPath, "sync", { timeoutMs: 0 }, async () => ({ value: await fn() }));
|
|
519
|
+
return outcome === null ? { acquired: false } : { acquired: true, value: outcome.value };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 在途那些远端调用的中止控制器账本(一轮一个;`status` 也算一轮)。
|
|
524
|
+
*
|
|
525
|
+
* 端口的 `signal` 形参存在的全部理由就是「宿主退出时能把在途的那一次收尾」,而只有这一层知道
|
|
526
|
+
* 什么时候算收摊:`dispose()` 把它们全部 abort,于是挂在会话启动路径上的拉取、以及退出时正在跑的
|
|
527
|
+
* 推送都能立刻结束,而不是等子进程自己的超时跑完。用账本而不是单个字段:手动 `pull()` 与自动
|
|
528
|
+
* 推送可能重叠(两者都允许,根锁会把传输段串起来),一个字段会丢一个。
|
|
529
|
+
*/
|
|
530
|
+
const activeSignals = new Set<AbortController>();
|
|
531
|
+
|
|
532
|
+
/** 跑一段会碰远端的工作:给它一个 signal,结束时从账本里摘掉(无论成败)。 */
|
|
533
|
+
async function withAbortSignal<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
534
|
+
const controller = new AbortController();
|
|
535
|
+
activeSignals.add(controller);
|
|
536
|
+
try {
|
|
537
|
+
return await work(controller.signal);
|
|
538
|
+
} finally {
|
|
539
|
+
activeSignals.delete(controller);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* ① 传输段:取一份远端快照,出段即释放根锁。
|
|
545
|
+
*
|
|
546
|
+
* `list()` 的 hash 是差集唯一的判据,但 `modified` / `bytes` 不在端口里 —— 它们要从内容里解析,
|
|
547
|
+
* 而内容只在真要落到本地时才读(`view.read`)。所以 `theirs` 里只有 hash 是真的,
|
|
548
|
+
* `modified`/`bytes` 是占位值:**任何消费者都不得读它们**,需要时按读到的字节重算
|
|
549
|
+
* (见 `remoteSide`)。远端可能上千条记忆,为一轮里通常一两条变更把全部内容读一遍不值得。
|
|
550
|
+
*
|
|
551
|
+
* 也就是说 `view.read()` 会在根锁**释放之后**(应用段里)才被调用:`RemoteView` 必须是一份
|
|
552
|
+
* 自足的快照,读内容不能推迟到共享的传输状态上。同一句话也写在端口注释里(backend 实现方
|
|
553
|
+
* 看的是那一份):两边都得说,否则独立包实现的 backend 会把 `read()` 写成对着共享状态现取。
|
|
554
|
+
*/
|
|
555
|
+
async function readRemoteSnapshot(
|
|
556
|
+
timeoutMs: number,
|
|
557
|
+
signal: AbortSignal,
|
|
558
|
+
allowUnverifiedForStatus = false,
|
|
559
|
+
): Promise<RemoteSnapshot> {
|
|
560
|
+
const locked = await withRootLock(async () => {
|
|
561
|
+
const view = await backend.fetch(scope, { timeoutMs, signal });
|
|
562
|
+
// fetch 与 origin 自省必须同锁:setRemote 也用这把根锁,因此这份身份就是该快照的传输身份。
|
|
563
|
+
const remote = await backend.getRemote();
|
|
564
|
+
if (remote === null && !allowUnverifiedForStatus) {
|
|
565
|
+
throw new Error("the sync origin is unavailable after fetch; no baseline was trusted");
|
|
566
|
+
}
|
|
567
|
+
return { view, listed: await view.list(), remote, originVerified: remote !== null };
|
|
568
|
+
});
|
|
569
|
+
if (!locked.acquired) throw new SyncSkip(ROOT_LOCK_BUSY);
|
|
570
|
+
|
|
571
|
+
const theirs: SyncFileSet = new Map();
|
|
572
|
+
// 范围外的路径原样报出去(远端根相对):静默丢掉的话,用户永远不知道远端有内容没进同步
|
|
573
|
+
const outOfScope = [...locked.value.view.outOfScope];
|
|
574
|
+
for (const { path, hash } of locked.value.listed) {
|
|
575
|
+
// 从网络进来的名字一律走**写方向**谓词(全平台最严的那一套),不是本地扫描那一套读方向:
|
|
576
|
+
// 这个名字的归宿是本地文件系统,而落盘前的整批断言(`applyEntryFiles`)问的正是写方向。
|
|
577
|
+
// 两边口径不一致的后果不是「少同步一条」:一个在 Linux 上合法、在 Windows 上开不了的
|
|
578
|
+
// 名字(例如 `con.md`)会先通过这里的过滤、再在落盘前让**整批**抛错 —— 应用段一个文件都
|
|
579
|
+
// 没写就失败,`-sync` 快照却已经拍了一份,而且下一轮一模一样:同步彻底停摆、status 不报错、
|
|
580
|
+
// 崩溃恢复用的快照名额每轮被白拍一份挤掉。
|
|
581
|
+
// 报成范围外而不是交给 `applyEntryFiles`:远端前缀里被人手工推上来的索引文件、点开头的
|
|
582
|
+
// 文件、嵌套目录同样走这一条(它们都不是记忆,也不得让整轮失败)。
|
|
583
|
+
if (!isEntryFileNameForWrite(path)) {
|
|
584
|
+
outOfScope.push(`${scope.prefix}/${path}`);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
theirs.set(path, { hash, modified: "", bytes: 0 });
|
|
588
|
+
}
|
|
589
|
+
outOfScope.sort();
|
|
590
|
+
return {
|
|
591
|
+
view: locked.value.view,
|
|
592
|
+
revision: locked.value.view.revision,
|
|
593
|
+
// 仅 status 允许 origin=null 的降级快照;配置值只用于展示形状,trustedManifest 会强制为空。
|
|
594
|
+
remote: locked.value.remote ?? config.remote,
|
|
595
|
+
transport: syncTransport(locked.value.remote ?? config.remote, config.branch),
|
|
596
|
+
originVerified: locked.value.originVerified,
|
|
597
|
+
theirs,
|
|
598
|
+
outOfScope,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 远端这份字节能不能落到本地。返回 `null` = 拒收(原因已记进 `rejected`)。
|
|
604
|
+
*
|
|
605
|
+
* `modified` 的形状校验必须在**这里**做,不能指望 `applyEntryFiles`:那条原语校验
|
|
606
|
+
* name / created / type,唯独不校验 `modified`,而 `modified` 是跨设备定序与冲突判定的
|
|
607
|
+
* 唯一依据,并且按设计永远不会被重新盖章 —— 一份 `modified: 昨天下午` 落了盘就再也修不回来,
|
|
608
|
+
* 它会让这条记忆在之后每一轮排序里都站在错误的一侧。挡在门外、报出来,是唯一能修的时机。
|
|
609
|
+
*/
|
|
610
|
+
async function acceptRemote(
|
|
611
|
+
view: RemoteView,
|
|
612
|
+
file: string,
|
|
613
|
+
rejected: Array<{ file: string; reason: string }>,
|
|
614
|
+
): Promise<{ raw: string; parsed: ParsedEntryFile } | null> {
|
|
615
|
+
const raw = await view.read(file).catch((err: unknown) => {
|
|
616
|
+
rejected.push({ file, reason: describeError(err) });
|
|
617
|
+
return null;
|
|
618
|
+
});
|
|
619
|
+
if (raw === null) return null;
|
|
620
|
+
const parsed = parseEntryFile(raw);
|
|
621
|
+
if (parsed === null) {
|
|
622
|
+
rejected.push({ file, reason: "unparsable frontmatter" });
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
if (!isIsoModified(parsed.meta.modified)) {
|
|
626
|
+
// 原因词保持稳定(同 `applyEntryFiles` 的 rejected 口径),不把值拼进去:
|
|
627
|
+
// 适配层要拿它分类,而那个值可能包含用户不想在状态行里看到的任意字节
|
|
628
|
+
rejected.push({ file, reason: "invalid modified" });
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
return { raw, parsed };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 冲突里「本地这一侧」的字节与指纹(读不出或解析不出 → 拒收并跳过这一条)。 */
|
|
635
|
+
async function localSide(
|
|
636
|
+
file: string,
|
|
637
|
+
plan: ApplyPlan,
|
|
638
|
+
rejected: Array<{ file: string; reason: string }>,
|
|
639
|
+
): Promise<ConflictSide | null> {
|
|
640
|
+
const raw = await readFile(join(memoryDir, file), "utf8").catch((err: unknown) => {
|
|
641
|
+
rejected.push({ file, reason: describeError(err) });
|
|
642
|
+
return null;
|
|
643
|
+
});
|
|
644
|
+
if (raw === null) {
|
|
645
|
+
markUnagreed(plan, file);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
const parsed = parseEntryFile(raw);
|
|
649
|
+
if (parsed === null) {
|
|
650
|
+
// 正常走不到这里:解析不出的本地文件会被扫描归进 `unparsable`,而那一类名字本轮
|
|
651
|
+
// 已从差集的每一张清单里剔除(见 `withoutUnparsable`),根本不会被当成冲突送进来。
|
|
652
|
+
// 真撞上只可能是它在扫描之后才坏掉,处置与上面一致:这一条不动,两侧内容都原样留着。
|
|
653
|
+
rejected.push({ file, reason: "unparsable frontmatter" });
|
|
654
|
+
markUnagreed(plan, file);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
// 指纹按**这一刻读到的字节**重算:`ConflictSide` 的 raw 与 info 必须描述同一份内容
|
|
658
|
+
return { raw, info: { hash: sha256Hex(raw), modified: parsed.meta.modified, bytes: Buffer.byteLength(raw, "utf8") } };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** 冲突里「远端这一侧」的字节与指纹(过不了 `acceptRemote` → 拒收并跳过这一条)。 */
|
|
662
|
+
async function remoteSide(
|
|
663
|
+
view: RemoteView,
|
|
664
|
+
file: string,
|
|
665
|
+
plan: ApplyPlan,
|
|
666
|
+
rejected: Array<{ file: string; reason: string }>,
|
|
667
|
+
): Promise<ConflictSide | null> {
|
|
668
|
+
const accepted = await acceptRemote(view, file, rejected);
|
|
669
|
+
if (accepted === null) {
|
|
670
|
+
markUnagreed(plan, file);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
raw: accepted.raw,
|
|
675
|
+
info: {
|
|
676
|
+
hash: sha256Hex(accepted.raw),
|
|
677
|
+
modified: accepted.parsed.meta.modified,
|
|
678
|
+
bytes: Buffer.byteLength(accepted.raw, "utf8"),
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* 收敛一条冲突:原路径写胜者(或删掉),败者另存为一条新 entry。
|
|
685
|
+
*
|
|
686
|
+
* `resolveConflict` 在败者字节解析不出 frontmatter 时**抛错**(它不借用 `null` 返回值,
|
|
687
|
+
* 那已经表示「两侧相同、无事可做」)。这里必须接住:跳过这一条、记一条可读的 `rejected`,
|
|
688
|
+
* **不要让整轮失败,更不要静默丢掉那份内容** —— 两侧的文件因此都原样留在盘上,
|
|
689
|
+
* 下一轮还会再判成同一个冲突,直到有人处理。
|
|
690
|
+
*/
|
|
691
|
+
async function convergeConflict(
|
|
692
|
+
conflict: SyncConflict,
|
|
693
|
+
view: RemoteView,
|
|
694
|
+
plan: ApplyPlan,
|
|
695
|
+
result: SyncRoundResult,
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
const { file } = conflict;
|
|
698
|
+
// 为 null 的一侧是「已删除」:不去取内容(远端那份可能根本读不出来)
|
|
699
|
+
const ours = conflict.ours === null ? null : await localSide(file, plan, result.rejected);
|
|
700
|
+
if (conflict.ours !== null && ours === null) return;
|
|
701
|
+
const theirs = conflict.theirs === null ? null : await remoteSide(view, file, plan, result.rejected);
|
|
702
|
+
if (conflict.theirs !== null && theirs === null) return;
|
|
703
|
+
|
|
704
|
+
let outcome: ConflictOutcome | null;
|
|
705
|
+
try {
|
|
706
|
+
outcome = resolveConflict({ file, ours, theirs }, plan.taken);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
result.rejected.push({ file, reason: `conflict not resolved: ${describeError(err)}` });
|
|
709
|
+
markUnagreed(plan, file);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// null = 两侧字节其实相同:差集按 hash 判定,正常走不到这里
|
|
713
|
+
if (outcome === null) return;
|
|
714
|
+
|
|
715
|
+
if (outcome.winnerRaw === null) {
|
|
716
|
+
// 删除 vs 修改:删除照办,幸存的那份内容由 loserEntry 承接
|
|
717
|
+
plan.deletes.push(file);
|
|
718
|
+
} else {
|
|
719
|
+
plan.writes.push({ file, raw: outcome.winnerRaw });
|
|
720
|
+
plan.taken.push({ file, hash: sha256Hex(outcome.winnerRaw) });
|
|
721
|
+
}
|
|
722
|
+
const loser = outcome.loserEntry;
|
|
723
|
+
if (loser === null) {
|
|
724
|
+
result.conflicts.push({ file, keptAsNew: "" });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// 冲突副本走**同一次** `applyEntryFiles`:它的文件名已由内容派生好,
|
|
728
|
+
// 一起落盘才能保证「原路径的删/写」与「副本的写」在同一份快照与同一次索引重建之间完成
|
|
729
|
+
plan.writes.push({ file: loser.file, raw: loser.raw });
|
|
730
|
+
// hash 一并给出去:`resolveConflict` 靠它认出「这个名字已经被同一份内容占着」并复用,
|
|
731
|
+
// 只给名字会一律走确定性追加,同一条记忆每轮多出一份副本
|
|
732
|
+
plan.taken.push({ file: loser.file, hash: sha256Hex(loser.raw) });
|
|
733
|
+
result.conflicts.push({ file, keptAsNew: loser.file });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* ② 应用段:整轮持有项目的进程内逻辑锁,所以每一次 store 调用都传
|
|
738
|
+
* `{ skipLogicalLock: true, skipSnapshot: true }`,而整段只拍**一份** `-sync` 快照。
|
|
739
|
+
*
|
|
740
|
+
* **这把锁只到进程边界**:`process-lock` 是同进程内的一个队列,它保证的是「本进程里这一整段
|
|
741
|
+
* 不与同进程的另一次 store 写入交错」。跳进程的那一半由每个写原语自己的 `<memoryDir>/.lock`
|
|
742
|
+
* 承担(`tryWithLock` 逐原语串行物理写入),**不是**由这里这把逻辑锁承担。所以本段对
|
|
743
|
+
* 另一个进程里的写入者只逐原语互斥:它可能在两次写入之间插进来。这是可接受的 ——
|
|
744
|
+
* 基线只记「两侧达成一致的那一份字节」,而插入者改的是本地文件,下一轮重新扫描就能看见。
|
|
745
|
+
*/
|
|
746
|
+
async function applyPhase(snapshot: RemoteSnapshot, result: SyncRoundResult): Promise<AppliedRound> {
|
|
747
|
+
const applied = await store.tryWithLogicalLock((): Promise<AppliedRound> => converge(snapshot, result));
|
|
748
|
+
if (applied === null) throw new SyncSkip(MEMORY_LOCK_BUSY);
|
|
749
|
+
return applied;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function converge(snapshot: RemoteSnapshot, result: SyncRoundResult): Promise<AppliedRound> {
|
|
753
|
+
const { view, theirs } = snapshot;
|
|
754
|
+
// 扫描抛错(非 ENOENT 的 readdir 失败)= 整轮跳过:绝不带着「读不出来」的 ours 去算差集,
|
|
755
|
+
// 那等于宣布本机删光了全部记忆,并把删除传播到远端。
|
|
756
|
+
const scan: LocalScan = await scanLocalEntries(memoryDir, platform);
|
|
757
|
+
const stored = await readManifest(manifestFile);
|
|
758
|
+
// 任何 origin/branch 变化都让旧基线失效。旧进程晚写的 manifest 仍带旧 transport,
|
|
759
|
+
// 因此即使它落在换远端之后,也不能把新远端解释成「本地全删了」。
|
|
760
|
+
const manifest =
|
|
761
|
+
stored !== null && manifestMatchesTransport(stored, snapshot.transport) ? stored : emptyManifest(snapshot.transport);
|
|
762
|
+
const previousBase = asFileSet(manifest.files);
|
|
763
|
+
|
|
764
|
+
// 解析不出/读不了的本地文件不进 `ours`,也不按基线补回:它们本轮一律不动,
|
|
765
|
+
// 由下面的 `withoutUnparsable` 从每一张动作清单里剔除(理由写在那里)。
|
|
766
|
+
const ours: SyncFileSet = new Map(scan.files);
|
|
767
|
+
const untouched = new Set(scan.unparsable);
|
|
768
|
+
result.unparsableLocal = [...scan.unparsable];
|
|
769
|
+
|
|
770
|
+
const plan: ApplyPlan = {
|
|
771
|
+
writes: [],
|
|
772
|
+
deletes: [],
|
|
773
|
+
// 已占用的名字:盘上的条目 + 索引文件本身(它永不能被条目名写穿)+ 本轮即将写下去的
|
|
774
|
+
taken: [
|
|
775
|
+
{ file: INDEX_FILE },
|
|
776
|
+
...[...scan.files].map(([file, info]) => ({ file, hash: info.hash })),
|
|
777
|
+
...scan.unparsable.map((file) => ({ file })),
|
|
778
|
+
],
|
|
779
|
+
// 读不出的本地文件两个方向都不发布(发布删除 = 一个坏文件删掉两台设备上的同一条记忆),
|
|
780
|
+
// 但它不算 `unagreed`:本轮根本没读到它,谈不上「收不收得下远端那份」,
|
|
781
|
+
// 基线因此冻结在上一次一致的那份(见 `agreedBaseFiles`)。
|
|
782
|
+
excluded: new Set(scan.unparsable),
|
|
783
|
+
unagreed: new Set(),
|
|
784
|
+
};
|
|
785
|
+
const diff = withoutUnparsable(diffThreeWay(previousBase, ours, theirs), untouched);
|
|
786
|
+
|
|
787
|
+
for (const file of diff.pullFromRemote) {
|
|
788
|
+
const accepted = await acceptRemote(view, file, result.rejected);
|
|
789
|
+
if (accepted === null) {
|
|
790
|
+
// 拒收的内容既不落盘、也绝不发布出去(发布 = 替对端决定它那份该没了),
|
|
791
|
+
// 更不进基线(进了基线 = 下一轮拿它当「已达成一致」,见 `agreedBaseFiles`)
|
|
792
|
+
markUnagreed(plan, file);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
plan.writes.push({ file, raw: accepted.raw });
|
|
796
|
+
plan.taken.push({ file, hash: sha256Hex(accepted.raw) });
|
|
797
|
+
}
|
|
798
|
+
for (const file of diff.deleteLocal) plan.deletes.push(file);
|
|
799
|
+
for (const conflict of diff.conflicts) await convergeConflict(conflict, view, plan, result);
|
|
800
|
+
|
|
801
|
+
// 只在**真要改本地文件**时快照 + 落盘 + 重建索引:远端没动的一轮(最常见)必须零本地写入,
|
|
802
|
+
// 否则每次自动拉取都会往 `.backups` 里塞一份快照,把用户还想留着做崩溃恢复的名额挤光。
|
|
803
|
+
const changesLocal = plan.writes.length > 0 || plan.deletes.length > 0;
|
|
804
|
+
let applied: ApplyResult | null = null;
|
|
805
|
+
if (changesLocal) {
|
|
806
|
+
await createSnapshot(
|
|
807
|
+
join(memoryDir, BACKUP_DIR),
|
|
808
|
+
"sync",
|
|
809
|
+
[...scan.files.keys(), ...scan.unparsable, INDEX_FILE],
|
|
810
|
+
memoryDir,
|
|
811
|
+
{ keep: store.cfg.lock.snapshotKeep, now, platform },
|
|
812
|
+
);
|
|
813
|
+
applied = await store.applyEntryFiles(
|
|
814
|
+
{ writes: plan.writes, deletes: plan.deletes },
|
|
815
|
+
{ skipLogicalLock: true, skipSnapshot: true },
|
|
816
|
+
);
|
|
817
|
+
for (const item of applied.rejected) {
|
|
818
|
+
result.rejected.push(item);
|
|
819
|
+
// store 收不下的字节(名字会伪造索引行、碰撞、类型不认识)同样算「没收下」:
|
|
820
|
+
// 进基线就会在下一轮变成「本地删了它」或「本地要盖掉对端」
|
|
821
|
+
markUnagreed(plan, item.file);
|
|
822
|
+
}
|
|
823
|
+
await store.rebuildIndex({ skipLogicalLock: true, skipSnapshot: true });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const written = new Set(applied?.written ?? []);
|
|
827
|
+
result.pulled = diff.pullFromRemote.filter((file) => written.has(file));
|
|
828
|
+
result.deletedLocal = [...(applied?.deleted ?? [])];
|
|
829
|
+
|
|
830
|
+
// 基线里的本地一侧**重新扫一遍**而不是推算:推算会把「被拒收的」「碰撞跳过的」也算成已落盘
|
|
831
|
+
const localAfter: SyncFileSet = changesLocal ? (await scanLocalEntries(memoryDir, platform)).files : scan.files;
|
|
832
|
+
const delta = await publishDelta(localAfter, theirs, plan, result.rejected);
|
|
833
|
+
// pull 轮不发布,但「远端那份要被删掉」这个判定必须报出来,否则用户无从知道自己的删除还没生效。
|
|
834
|
+
// 发布增量里的删除要并进来:「删除 vs 修改」里照办的那次删除是冲突收敛判的,差集只把那个
|
|
835
|
+
// 名字报成冲突(不在 `deleteRemote` 里),漏掉它用户要等到下一轮才看见自己刚删掉的那条。
|
|
836
|
+
result.deletedRemote = [...new Set([...diff.deleteRemote, ...delta.deletions])].sort();
|
|
837
|
+
/**
|
|
838
|
+
* 基线 = 「两侧上一次真正达成一致的状态」,**不是**「我盘上现在有什么」。
|
|
839
|
+
*
|
|
840
|
+
* 所以 `base.revision` 是本轮合并所依据的那个远端版本(发布成功之后才前进到发布返回的新版本,
|
|
841
|
+
* 见 `publishPhase`),而 `base.files` 是**这一轮的 theirs**(去掉本轮没收下的那些名字)。
|
|
842
|
+
* 发布成功时 merged == theirs',此时它恰好等于应用后的扫描结果 —— 那只是推论,不是定义。
|
|
843
|
+
*
|
|
844
|
+
* 写成「应用后的扫描结果」会丢用户数据:pull 轮与发布失败都会留下本轮产生、却根本没发布
|
|
845
|
+
* 出去的本地内容(新写的记忆、冲突副本),一旦进了基线,下一轮的差集就是「base 有 / ours 有 /
|
|
846
|
+
* theirs 无」= 远端删了它 → `deleteLocal` → 用户自己还没推出去的记忆被从本地删掉。
|
|
847
|
+
*
|
|
848
|
+
* 写成「逐名字判一致,不一致就沿用**上一次**的基线条目」也不对:「删除 vs 修改」收敛之后,
|
|
849
|
+
* 原路径在本地已经没了、而远端那份是被改过的内容,沿用旧条目会让下一轮(只拉取时)再判成
|
|
850
|
+
* 同一场删改冲突 —— 每拉一次就多出一份副本,永不收敛;按 theirs 此刻的指纹记,下一轮就是
|
|
851
|
+
* 「base 有 / ours 无 / theirs 未动」→ 把删除推出去,一轮收敛。
|
|
852
|
+
*/
|
|
853
|
+
const baseFiles = agreedBaseFiles(theirs, localAfter, previousBase, untouched, plan.unagreed);
|
|
854
|
+
await writeManifest(manifestFile, {
|
|
855
|
+
version: MANIFEST_VERSION,
|
|
856
|
+
transport: snapshot.transport,
|
|
857
|
+
base: { revision: snapshot.revision, syncedAt: now().toISOString() },
|
|
858
|
+
files: asFileRecord(baseFiles),
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// 发布成功之后远端应有的样子 = 本轮基线 + 发布出去的增量。只在发布真成功之后用它前进基线:
|
|
862
|
+
// 没发布出去的本地内容一律不在里面,所以下一轮仍然判得出「本地领先」→ 接着推。
|
|
863
|
+
const remoteAfterPublish: SyncFileSet = new Map(baseFiles);
|
|
864
|
+
for (const { path } of delta.writes) {
|
|
865
|
+
const info = localAfter.get(path);
|
|
866
|
+
if (info !== undefined) remoteAfterPublish.set(path, info);
|
|
867
|
+
}
|
|
868
|
+
for (const path of delta.deletions) remoteAfterPublish.delete(path);
|
|
869
|
+
|
|
870
|
+
return { delta, remoteAfterPublish };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* 要发布的增量 = **合并结果相对 theirs 的差异**,不是全量文件集。
|
|
875
|
+
*
|
|
876
|
+
* 两边的 hash 都在手上,直接算:远端已经有同一份字节的不发,本地已经没有了的才发删除。
|
|
877
|
+
* `excluded` 里的名字(读不出的、被拒收的、冲突没收敛掉的)两个方向都跳过 ——
|
|
878
|
+
* 把它们算成删除等于「我们收不下对端那份,于是把对端那份删了」,那是替用户做决定。
|
|
879
|
+
*/
|
|
880
|
+
async function publishDelta(
|
|
881
|
+
local: SyncFileSet,
|
|
882
|
+
theirs: SyncFileSet,
|
|
883
|
+
plan: ApplyPlan,
|
|
884
|
+
rejected: Array<{ file: string; reason: string }>,
|
|
885
|
+
): Promise<SyncPublishChanges> {
|
|
886
|
+
const writes: Array<{ path: string; content: string }> = [];
|
|
887
|
+
const deletions: string[] = [];
|
|
888
|
+
for (const [file, info] of local) {
|
|
889
|
+
if (plan.excluded.has(file)) continue;
|
|
890
|
+
// 发布方向也要过一遍**写方向**谓词:本地扫描用的是读口径(Linux 上 `con.md` 是一条合法记忆),
|
|
891
|
+
// 而远端是全平台共享的。把一个在任何一台设备上都落不了地的名字推上去,等于往远端埋一条
|
|
892
|
+
// 永远同步不了的记录:它会在每一台的每一轮里被归成范围外,而新设备 onboarding 的第一轮就
|
|
893
|
+
// 撞上它。所以这里不发布、只报一行(原因词是稳定词汇,适配层可以据此分类)。
|
|
894
|
+
if (!isEntryFileNameForWrite(file)) {
|
|
895
|
+
rejected.push({ file, reason: UNPUBLISHABLE_NAME });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (theirs.get(file)?.hash === info.hash) continue;
|
|
899
|
+
const raw = await readFile(join(memoryDir, file), "utf8").catch((err: unknown) => {
|
|
900
|
+
rejected.push({ file, reason: describeError(err) });
|
|
901
|
+
return null;
|
|
902
|
+
});
|
|
903
|
+
if (raw === null) {
|
|
904
|
+
// 只是这一刻读不到字节(内容本身是收下了的):不算 `unagreed`,基线照 theirs 记,
|
|
905
|
+
// 下一轮仍然判得出「本地领先」→ 重试发布。算成 unagreed 反而会把这个名字剔出基线,
|
|
906
|
+
// 下一轮就成了「两侧各有一份新增」→ 凭空多出一份冲突副本。
|
|
907
|
+
plan.excluded.add(file);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
writes.push({ path: file, content: raw });
|
|
911
|
+
}
|
|
912
|
+
for (const file of theirs.keys()) {
|
|
913
|
+
if (plan.excluded.has(file) || local.has(file)) continue;
|
|
914
|
+
deletions.push(file);
|
|
915
|
+
}
|
|
916
|
+
writes.sort((left, right) => (left.path === right.path ? 0 : left.path < right.path ? -1 : 1));
|
|
917
|
+
deletions.sort();
|
|
918
|
+
return { writes, deletions };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* ③ 发布段(只有 push 轮):再取一次根锁,把增量推上去,成功之后才把基线推到新的 revision。
|
|
923
|
+
*
|
|
924
|
+
* 基线**只在成功之后**前进:发布失败时应用段那次写入记下的仍然是「本轮合并所依据的那个远端
|
|
925
|
+
* 版本 + 它的文件集」,本轮产生而没发布出去的内容一律没进基线,所以下一轮仍然判得出
|
|
926
|
+
* 「本地领先」→ 接着推,而不是读成「远端删了它」。
|
|
927
|
+
*/
|
|
928
|
+
async function publishPhase(
|
|
929
|
+
snapshot: RemoteSnapshot,
|
|
930
|
+
applied: AppliedRound,
|
|
931
|
+
result: SyncRoundResult,
|
|
932
|
+
deadline: number,
|
|
933
|
+
roundBudgetMs: number,
|
|
934
|
+
signal: AbortSignal,
|
|
935
|
+
): Promise<RoundOutcome> {
|
|
936
|
+
const { delta } = applied;
|
|
937
|
+
// 增量为空 = 远端已经有我们全部的字节:不取锁、不做一次注定「无变化」的远端往返。
|
|
938
|
+
// 这条出口先于预算检查:没有发布工作要做时,传输段花光预算也不该把成功改成失败。
|
|
939
|
+
if (delta.writes.length === 0 && delta.deletions.length === 0) return result;
|
|
940
|
+
const budgetExceeded = (): SyncRoundResult =>
|
|
941
|
+
failedRound(
|
|
942
|
+
`the round budget of ${roundBudgetMs}ms ran out before publishing; ` +
|
|
943
|
+
"local changes converged and will be published next time",
|
|
944
|
+
result,
|
|
945
|
+
);
|
|
946
|
+
// `remainingMs` 为了给子进程规避 timeout: 0 会把耗尽钳成 1ms;在发起发布前先拦住,
|
|
947
|
+
// 否则用户看到的是一次注定失败的 “timed out after 1ms”,而不是真正的预算耗尽原因。
|
|
948
|
+
if (deadline - Date.now() <= 0) return budgetExceeded();
|
|
949
|
+
|
|
950
|
+
const locked = await withRootLock(async () => {
|
|
951
|
+
// mkdir/open/写锁记录都是异步的,预算可能在取锁期间耗尽;真正调用 backend 前必须再查一次。
|
|
952
|
+
if (deadline - Date.now() <= 0) return budgetExceeded();
|
|
953
|
+
return backend.publish(
|
|
954
|
+
scope,
|
|
955
|
+
delta,
|
|
956
|
+
{ revision: snapshot.revision, remote: snapshot.remote },
|
|
957
|
+
{ timeoutMs: remainingMs(deadline), signal, message: publishMessage(delta), author: commitAuthor(device) },
|
|
958
|
+
);
|
|
959
|
+
});
|
|
960
|
+
if (!locked.acquired) {
|
|
961
|
+
// 本轮不发布:本地已经收敛好了,脏状态原样留着,下一次触发接着推
|
|
962
|
+
return failedRound(`${ROOT_LOCK_BUSY}; local changes converged and will be published next time`, result);
|
|
963
|
+
}
|
|
964
|
+
if (typeof locked.value !== "string") return locked.value;
|
|
965
|
+
|
|
966
|
+
const revision = locked.value;
|
|
967
|
+
result.pushed = delta.writes.map((item) => item.path);
|
|
968
|
+
result.deletedRemote = [...delta.deletions];
|
|
969
|
+
result.revision = revision;
|
|
970
|
+
// 发布成功:两侧真达成一致了,基线才前进到新的 revision,并把发布出去的增量记进去。
|
|
971
|
+
await writeManifest(manifestFile, {
|
|
972
|
+
version: MANIFEST_VERSION,
|
|
973
|
+
transport: snapshot.transport,
|
|
974
|
+
base: { revision, syncedAt: now().toISOString() },
|
|
975
|
+
files: asFileRecord(applied.remoteAfterPublish),
|
|
976
|
+
});
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** 提交作者 = 设备身份:`git log` 里看得出哪台机器写了什么(设备归属绝不进文件名)。 */
|
|
981
|
+
function commitAuthor(identity: DeviceIdentity): { name: string; email: string } {
|
|
982
|
+
// 邮箱里放设备 id:hostname 可能重名,id 才是唯一归属。`.invalid` 是保留的永不解析域名 ——
|
|
983
|
+
// 我们从不发信,只是需要一个稳定且唯一的标识。
|
|
984
|
+
return { name: identity.hostname, email: `${identity.id}@sync.invalid` };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function publishMessage(changes: SyncPublishChanges): string {
|
|
988
|
+
const parts: string[] = [];
|
|
989
|
+
if (changes.writes.length > 0) parts.push(`${changes.writes.length} written`);
|
|
990
|
+
if (changes.deletions.length > 0) parts.push(`${changes.deletions.length} deleted`);
|
|
991
|
+
return `memory sync: ${parts.length > 0 ? parts.join(", ") : "no changes"}`;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* 跑一次尝试。**收的是截止时刻而不是一个毫秒数**:预算要在每一次远端调用之前重新问一次,
|
|
996
|
+
* 否则传输段花掉的时间不会从发布段的预算里扣(那就又变回「每段各一份满额」)。
|
|
997
|
+
*/
|
|
998
|
+
async function runOnce(
|
|
999
|
+
mode: Mode,
|
|
1000
|
+
deadline: number,
|
|
1001
|
+
roundBudgetMs: number,
|
|
1002
|
+
signal: AbortSignal,
|
|
1003
|
+
): Promise<RoundOutcome> {
|
|
1004
|
+
if (config.enabled !== true) throw new SyncSkip(NOT_CONFIGURED);
|
|
1005
|
+
// 先问本地初始化状态,才能区分「已初始化、配置 remote 留空」与「没有地址可初始化」。
|
|
1006
|
+
// 自动路径绝不偷偷做首次初始化:那可能是一次几十秒的远端往返。
|
|
1007
|
+
const initialized = await backend.ready();
|
|
1008
|
+
if (!isConfigured(initialized)) throw new SyncSkip(NOT_CONFIGURED);
|
|
1009
|
+
if (!initialized) throw new SyncSkip(NOT_INITIALIZED);
|
|
1010
|
+
|
|
1011
|
+
const snapshot = await readRemoteSnapshot(remainingMs(deadline), signal); // ① 出段即释放根锁
|
|
1012
|
+
const result = emptyResult();
|
|
1013
|
+
result.outOfScopeRemote = [...snapshot.outOfScope];
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
const applied = await applyPhase(snapshot, result); // ② 逻辑锁之内
|
|
1017
|
+
// 基线是应用段写下的,所以 `revision` 也只能在那之后报:失败的那一份部分结果同样会交到
|
|
1018
|
+
// 用户手上,报一个根本没写进基线的 revision,等于让他们对着一个不存在的状态去排查
|
|
1019
|
+
result.revision = snapshot.revision;
|
|
1020
|
+
if (mode === "pull") return result;
|
|
1021
|
+
return await publishPhase(snapshot, applied, result, deadline, roundBudgetMs, signal); // ③ 再取一次根锁
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
// 远端在这轮之间又前进了:整轮重跑一次(重新取远端、重算差集),而不是原地重试一次 publish。
|
|
1024
|
+
// 这一次尝试已经落盘的改动不会被重跑撤销,所以连同部分结果一起交出去。
|
|
1025
|
+
if (err instanceof NonFastForwardError) return { retry: describeError(err), partial: result };
|
|
1026
|
+
// 到了这里,本地很可能**已经改过了**:拉下来的内容、冲突副本、`-sync` 快照、写好的基线,
|
|
1027
|
+
// 以及「盘上已落盘、只差记账」的写基线失败。只回一句原因的话,用户看到的 `pulled` /
|
|
1028
|
+
// `conflicts` 全是空的,而盘上明明多了文件 —— 所以失败原因必须与部分结果一起出去。
|
|
1029
|
+
return failedRound(describeError(err), result);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 一轮同步:一份**总预算** + 一个**中止信号**,两者都在这一层生成、往下传给每一次 backend 调用。
|
|
1035
|
+
*
|
|
1036
|
+
* 预算为什么是「一轮」而不是「一次调用」:配置里那两档时限的推理是按一轮算的(自动档被
|
|
1037
|
+
* await 在会话启动路径上,所以只给 5s),而一轮至少是 `fetch` + `publish` 两次调用、每次调用里
|
|
1038
|
+
* 又是好几个子进程(离线路径上 `fetch` 失败后还要再探一次远端)。每个子进程各拿一份满额预算,
|
|
1039
|
+
* 最坏情况就是配置值的好几倍 —— 正是那两档时限要避免的事。所以这里只传**剩下的毫秒**。
|
|
1040
|
+
*/
|
|
1041
|
+
function runRound(mode: Mode, timeoutMs: number): Promise<SyncRoundResult> {
|
|
1042
|
+
if (disposed) return Promise.resolve(failedRound(DISPOSED));
|
|
1043
|
+
return withAbortSignal((signal) => attemptRound(mode, timeoutMs, signal));
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async function attemptRound(mode: Mode, timeoutMs: number, signal: AbortSignal): Promise<SyncRoundResult> {
|
|
1047
|
+
const deadline = Date.now() + timeoutMs;
|
|
1048
|
+
let lastRetry = "";
|
|
1049
|
+
// 到目前为止本轮**已经落到本地**的那部分。失败报告必须带着它:失败可能发生在应用段之后
|
|
1050
|
+
// (非快进重跑、发布时的传输错误、写基线失败),那时本地早就改过了,而「同步到底动没动
|
|
1051
|
+
// 我的记忆」正是这个功能被评判的那个问题。
|
|
1052
|
+
let lastPartial: SyncRoundResult | undefined;
|
|
1053
|
+
// 重跑的是**整轮**(重新取远端 → 重算差集 → 重新应用 → 重新发布):非快进意味着远端变了,
|
|
1054
|
+
// 上一轮算出来的差集已经作废,原地重试 publish 只会再撞一次。落盘与副本命名都是幂等的
|
|
1055
|
+
// (同一份内容派生同一个名字),代价只是可能多拍一份 `-sync` 快照。
|
|
1056
|
+
for (let attempt = 1; attempt <= MAX_ROUND_ATTEMPTS; attempt += 1) {
|
|
1057
|
+
// 预算耗尽就不再起重跑,而是给一句说得清的原因。拿零头去跑第二次等于让重跑系统性地
|
|
1058
|
+
// 饿死(看起来像一次传输失败),而给每次尝试一份满额预算又把一轮变成 2×。
|
|
1059
|
+
// 基线不前进、脏标记留着,下一次触发接着来 —— 与「重试用尽」同一个出口。
|
|
1060
|
+
if (attempt > 1 && deadline - Date.now() <= 0) {
|
|
1061
|
+
return failedRound(
|
|
1062
|
+
`${lastRetry} — the round budget of ${timeoutMs}ms ran out before a retry could start; ` +
|
|
1063
|
+
"nothing was published this round, but it may already have changed local memory " +
|
|
1064
|
+
"(see pulled/conflicts in this result); the next round will retry",
|
|
1065
|
+
lastPartial,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
const outcome = await runOnce(mode, deadline, timeoutMs, signal);
|
|
1070
|
+
if (!("retry" in outcome)) return outcome;
|
|
1071
|
+
lastRetry = outcome.retry;
|
|
1072
|
+
// 合并而不是覆盖:重跑不撤销上一次的落盘(理由写在 `mergePartials`)
|
|
1073
|
+
lastPartial = mergePartials(lastPartial, outcome.partial);
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
// 一次同步失败绝不允许打断会话:所有异常都在这里变成一句可读的原因。
|
|
1076
|
+
// 走到这里的只剩下传输段(及它之前)的失败:本轮一个字都没动时 `lastPartial` 为空,
|
|
1077
|
+
// 重跑那一轮的 fetch 失败时它带着上一次尝试已经落盘的那部分。
|
|
1078
|
+
return failedRound(describeError(err), lastPartial);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return failedRound(
|
|
1082
|
+
`${lastRetry} — gave up after ${MAX_ROUND_ATTEMPTS} attempts; nothing was published this round, ` +
|
|
1083
|
+
"but it may already have changed local memory (see pulled/conflicts in this result); the next round will retry",
|
|
1084
|
+
lastPartial,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** `status` 自己出意外时的兜底报告:字段齐全、只有 `errors` 里有话。 */
|
|
1089
|
+
function emptyStatusReport(): SyncStatusReport {
|
|
1090
|
+
return {
|
|
1091
|
+
// 初值只表达同意开关;是否还需要配置 remote 要等 ready() 之后才能判定。
|
|
1092
|
+
configured: config.enabled === true,
|
|
1093
|
+
initialized: false,
|
|
1094
|
+
remote: typeof config.remote === "string" ? config.remote : "",
|
|
1095
|
+
branch: config.branch,
|
|
1096
|
+
device,
|
|
1097
|
+
projectDir,
|
|
1098
|
+
projectKey,
|
|
1099
|
+
base: null,
|
|
1100
|
+
ahead: [],
|
|
1101
|
+
behind: [],
|
|
1102
|
+
conflicts: [],
|
|
1103
|
+
rejected: [],
|
|
1104
|
+
unparsableLocal: [],
|
|
1105
|
+
outOfScopeRemote: [],
|
|
1106
|
+
errors: [],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** 算一份报告。**dry-run**:全程只读,盘上一个字都不改。 */
|
|
1111
|
+
function computeStatus(timeoutMs: number): Promise<SyncStatusReport> {
|
|
1112
|
+
if (disposed) {
|
|
1113
|
+
const report = emptyStatusReport();
|
|
1114
|
+
report.errors.push(DISPOSED);
|
|
1115
|
+
return Promise.resolve(report);
|
|
1116
|
+
}
|
|
1117
|
+
// status 也要挂上中止信号:它同样会碰远端(一次 fetch),而宿主退出时该收尾的不只是同步轮。
|
|
1118
|
+
return withAbortSignal((signal) => buildStatusReport(timeoutMs, signal));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function buildStatusReport(timeoutMs: number, signal: AbortSignal): Promise<SyncStatusReport> {
|
|
1122
|
+
const manifest = await readManifest(manifestFile);
|
|
1123
|
+
// 初值只有一份(`emptyStatusReport`):字段清单写两遍,加字段时必然漏掉一边
|
|
1124
|
+
const report = emptyStatusReport();
|
|
1125
|
+
report.base = manifest?.base ?? null;
|
|
1126
|
+
if (!report.configured) {
|
|
1127
|
+
report.errors.push(NOT_CONFIGURED);
|
|
1128
|
+
return report;
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
report.initialized = await backend.ready();
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
report.errors.push(describeError(err));
|
|
1134
|
+
return report;
|
|
1135
|
+
}
|
|
1136
|
+
report.configured = isConfigured(report.initialized);
|
|
1137
|
+
if (!report.configured) {
|
|
1138
|
+
report.errors.push(NOT_CONFIGURED);
|
|
1139
|
+
return report;
|
|
1140
|
+
}
|
|
1141
|
+
if (!report.initialized) {
|
|
1142
|
+
report.errors.push(NOT_INITIALIZED);
|
|
1143
|
+
return report;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
// 初始化之后 origin 才是真相来源;读不到时保留配置值作展示回退,但不放弃整份 dry-run 报告。
|
|
1147
|
+
const origin = await backend.getRemote();
|
|
1148
|
+
if (origin !== null) report.remote = origin;
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
report.errors.push(describeError(err));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// dry-run:下面全程只读 —— 不快照、不落盘、不重建索引、不写基线
|
|
1154
|
+
let snapshot: RemoteSnapshot;
|
|
1155
|
+
try {
|
|
1156
|
+
snapshot = await readRemoteSnapshot(timeoutMs, signal, true);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
report.errors.push(describeError(err));
|
|
1159
|
+
return report;
|
|
1160
|
+
}
|
|
1161
|
+
report.outOfScopeRemote = [...snapshot.outOfScope];
|
|
1162
|
+
const trustedManifest =
|
|
1163
|
+
snapshot.originVerified && manifest !== null && manifestMatchesTransport(manifest, snapshot.transport)
|
|
1164
|
+
? manifest
|
|
1165
|
+
: null;
|
|
1166
|
+
report.base = trustedManifest?.base ?? null;
|
|
1167
|
+
if (manifest !== null && trustedManifest === null) {
|
|
1168
|
+
report.errors.push(
|
|
1169
|
+
"the stored manifest transport does not match the fetched origin and branch; " +
|
|
1170
|
+
"it was ignored and reset to an empty base for this report",
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
let scan: LocalScan;
|
|
1175
|
+
try {
|
|
1176
|
+
scan = await scanLocalEntries(memoryDir, platform);
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
report.errors.push(describeError(err));
|
|
1179
|
+
return report;
|
|
1180
|
+
}
|
|
1181
|
+
report.unparsableLocal = [...scan.unparsable];
|
|
1182
|
+
|
|
1183
|
+
const previousBase = asFileSet(trustedManifest?.files ?? {});
|
|
1184
|
+
const ours: SyncFileSet = new Map(scan.files);
|
|
1185
|
+
// 与真跑的同步轮同一套口径:读不出的本地文件本轮一律不动,所以既不会出现在
|
|
1186
|
+
// ahead/behind,也不会出现在 conflicts 里 —— 否则报告会把一个永远不会执行的动作说给用户听。
|
|
1187
|
+
const diff = withoutUnparsable(diffThreeWay(previousBase, ours, snapshot.theirs), new Set(scan.unparsable));
|
|
1188
|
+
report.ahead = [...diff.pushLocal, ...diff.deleteRemote].sort();
|
|
1189
|
+
report.behind = [...diff.pullFromRemote, ...diff.deleteLocal].sort();
|
|
1190
|
+
report.conflicts = diff.conflicts.map((conflict) => conflict.file);
|
|
1191
|
+
|
|
1192
|
+
// 与真跑的发布段同一口径:本地有、但在任何一台设备上都写不下去的名字永远发不出去。
|
|
1193
|
+
// 不报的话 `ahead` 每轮都列着它、却永远推不上去,用户只看到「本地领先 1 条」而不知道原因。
|
|
1194
|
+
for (const file of diff.pushLocal) {
|
|
1195
|
+
if (!isEntryFileNameForWrite(file)) report.rejected.push({ file, reason: UNPUBLISHABLE_NAME });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// 会被拒收的远端内容也要报出来:用户看到的应该是「这条同步不了,因为……」,
|
|
1199
|
+
// 而不是每轮拉取都静默失败。只读这一轮真要动的那些名字,不为报告把远端全读一遍。
|
|
1200
|
+
const candidates = [
|
|
1201
|
+
...diff.pullFromRemote,
|
|
1202
|
+
...diff.conflicts.filter((conflict) => conflict.theirs !== null).map((conflict) => conflict.file),
|
|
1203
|
+
];
|
|
1204
|
+
for (const file of candidates) await acceptRemote(snapshot.view, file, report.rejected);
|
|
1205
|
+
return report;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* 写触发推送:去抖之后跑一次 push 轮。`pushOnWrite` 关闭时用空转版本。
|
|
1210
|
+
*
|
|
1211
|
+
* 推送与手动 `push()` 走同一条 `runRound`:锁、快照、冲突收敛、基线全都不变,
|
|
1212
|
+
* 唯一区别是它由写入触发而不是用户敲命令。
|
|
1213
|
+
*/
|
|
1214
|
+
// `=== true`:同 `isConfigured`,一个字符串 `"false"` 不得被读成「开着自动推送」
|
|
1215
|
+
const scheduler: SyncScheduler<SyncRoundResult> =
|
|
1216
|
+
config.pushOnWrite === true
|
|
1217
|
+
? createSyncScheduler<SyncRoundResult>({
|
|
1218
|
+
debounceMs: debounce,
|
|
1219
|
+
timeoutMs: autoBudget,
|
|
1220
|
+
/*
|
|
1221
|
+
* 「没发布成功的一轮」必须映射成 `null`。`runRound` 永远 resolve 出一个结果对象 ——
|
|
1222
|
+
* 锁忙是 `SyncSkip`、传输失败是 `failedRound`,都包在 `ok: false` 里,从不 reject ——
|
|
1223
|
+
* 原样交过去的话调度器看到的是「永远成功」,退出兜底就会谎报干净。
|
|
1224
|
+
*/
|
|
1225
|
+
run: async (timeoutMs) => {
|
|
1226
|
+
const result = await runRound("push", timeoutMs);
|
|
1227
|
+
return result.ok ? result : null;
|
|
1228
|
+
},
|
|
1229
|
+
})
|
|
1230
|
+
: NOOP_SCHEDULER;
|
|
1231
|
+
|
|
1232
|
+
return {
|
|
1233
|
+
pull: (timeoutMs?: number) => runRound("pull", positiveMs(timeoutMs, autoBudget)),
|
|
1234
|
+
push: (timeoutMs?: number) => runRound("push", positiveMs(timeoutMs, autoBudget)),
|
|
1235
|
+
status: (timeoutMs?: number) =>
|
|
1236
|
+
computeStatus(positiveMs(timeoutMs, manualBudget)).catch((err: unknown) => {
|
|
1237
|
+
// 报告本身也绝不抛:状态命令失败时至少给出一份带错误的报告
|
|
1238
|
+
const fallback = emptyStatusReport();
|
|
1239
|
+
fallback.errors.push(describeError(err));
|
|
1240
|
+
return fallback;
|
|
1241
|
+
}),
|
|
1242
|
+
// 写触发推送的三个出口:记下写入、退出/补推时立刻跑一次、收摊。全部转发给调度器。
|
|
1243
|
+
noteWrite: () => scheduler.noteWrite(),
|
|
1244
|
+
flush: (timeoutMs?: number) => scheduler.flush(positiveMs(timeoutMs, autoBudget)),
|
|
1245
|
+
dispose: () => {
|
|
1246
|
+
// 先封住所有新入口,再中止在途那一轮并收摊调度器:dispose 之后连只读的 status 也不碰 backend。
|
|
1247
|
+
disposed = true;
|
|
1248
|
+
// 先中止在途那一轮的远端调用,再收摊调度器:宿主的退出路径是「`dispose()` → `await inFlight`」,
|
|
1249
|
+
// 少了这一步,一次挂死的 fetch/publish 会把退出拖到子进程自己的超时跑完为止。
|
|
1250
|
+
for (const controller of activeSignals) controller.abort();
|
|
1251
|
+
activeSignals.clear();
|
|
1252
|
+
scheduler.dispose();
|
|
1253
|
+
},
|
|
1254
|
+
get inFlight(): Promise<unknown> | null {
|
|
1255
|
+
return scheduler.inFlight;
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
}
|