ygc-dsh-sync 1.2.0 → 1.2.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.
Files changed (6) hide show
  1. package/client.js +28 -17
  2. package/index.js +43 -16
  3. package/lib/api.js +1815 -1781
  4. package/lib/core.js +484 -484
  5. package/lib/features.js +154 -154
  6. package/package.json +62 -62
package/lib/features.js CHANGED
@@ -1,23 +1,25 @@
1
1
  /**
2
- * 本机特征文件夹(`<工作区>/.dsh-sync/sessions/<本机标识>/`)的采纳、撤销与「谁参与」判定。
2
+ * 本机特征文件夹(`<工作区>/.dsh-sync/sessions/<本机标识>/`)的采纳、撤销与巡检判据。
3
3
  *
4
- * 用户 2026-11-14 定的规矩(这一层是这套规矩的唯一实现):
4
+ * 判据只有一条:**磁盘上有没有那三个东西** —— 别人的特征文件夹、我的特征文件夹、
5
+ * 对应的工作区(DSH 的 workspaceRegistry 注册)。
6
+ * 用户 2026-11-14 定的巡检矩阵(这一层就是它的唯一实现):
5
7
  *
6
- * 1. **检测到的匹配目录要人点**:巡检扫出「符合插件结构、但本机还没建特征文件夹」
7
- * 的目录时,只列出来问人,绝不自己建。人点了同意 → 建格子 + 注册成工作区
8
- * (进侧边栏,参与备份与拉取),一次做完。
9
- * 2. **新建的保持原样**:本机新建的共享工作区默认就带特征文件夹
10
- * (1.1.0 之前一直是这个行为,升级不能把它改成必须点一下)。
11
- * 3. **外部删掉会自愈**:曾经建过、现在磁盘上没有 → 巡检自动补建,不进人工队列。
12
- * 4. **删工作区就删格**:本机把某个工作区从侧边栏删掉后,它的特征文件夹跟着删。
13
- * 只删本机那一格,别人的格子一个字节都不动。
14
- * 5. **没格子的不干活**:备份、拉取、自动备份、自动拉取、补登记,
15
- * 一律只对「特征文件夹存在」的工作区生效。
8
+ * | 别人的格子 | 我的格子 | 工作区 | 动作 |
9
+ * |---|---|---|---|
10
+ * | 有 | 没有 | 没有 | **问人**:要不要在本机建特征文件夹(建了就顺带注册工作区) |
11
+ * | — | 有 | 有 | 正常,参与备份与拉取 |
12
+ * | — | 有 | 没有 | **补工作区**:把目录注册回侧边栏(不建也不删文件) |
13
+ * | — | 没有 | 有 | **补文件夹**:给这个工作区建本机特征文件夹 |
16
14
  *
17
- * 判据永远是磁盘:「特征文件夹存在」= 已参与。state 里只记三样辅助信息:
18
- * initialized 1.2.0 首次运行是否已经把「存量工作区」归过档
19
- * decisions 人对某个路径的表态(adopt / decline),防止反复骚扰
20
- * known 已经采纳过的工作区(id → 路径),用来判「被删除」与「被外部删除」
15
+ * 删除的语义(同样是用户定的):
16
+ *
17
+ * - 删工作区时,**先看有没有别人的格子**:
18
+ * - 只有本机这一格 → 连 `.dsh-sync/` 一起收掉,巡检**再也扫不到**它(不留痕、不追问)。
19
+ * - 还有别人的格子 → 只删我这一格(连同格里备份的对话记录),`.dsh-sync/` 留着,
20
+ * 巡检据此认为「本机没有这个共享工作区」,进人工队列问要不要重建。
21
+ * - 插件**不提供**「只删格子、留着工作区」的入口:那会让工作区变成
22
+ * 「已注册但没格子」,下一轮必然被补回来,看起来就是「删了又冒出来」。
21
23
  */
22
24
  import fs from 'node:fs';
23
25
  import path from 'node:path';
@@ -30,12 +32,34 @@ export function featurePath(workspacePath, machineId) {
30
32
  return ownRoot(workspacePath, machineId);
31
33
  }
32
34
 
33
- /** 特征文件夹是否已存在(必须真的是目录)。 */
35
+ /** 本机特征文件夹是否已存在(必须真的是目录)。 */
34
36
  export function hasFeature(workspacePath, machineId) {
35
37
  const st = safeStat(featurePath(workspacePath, machineId));
36
38
  return !!st && st.isDirectory();
37
39
  }
38
40
 
41
+ /** 这个工作区里,除了本机之外还有哪些机器的特征文件夹。 */
42
+ export function otherFeatures(workspacePath, machineId) {
43
+ const sessions = path.join(normSlash(workspacePath), TARGET_DIR_NAME);
44
+ let names = [];
45
+ try {
46
+ names = fs
47
+ .readdirSync(sessions, { withFileTypes: true })
48
+ .filter((d) => d.isDirectory())
49
+ .map((d) => d.name);
50
+ } catch {
51
+ return [];
52
+ }
53
+ const me = String(machineId || '');
54
+ return names.filter((n) => n !== me);
55
+ }
56
+
57
+ /** 目录里是否还有 `.dsh-sync/` 这套结构(含 sessions)。 */
58
+ export function hasSyncStructure(workspacePath) {
59
+ const st = safeStat(path.join(normSlash(workspacePath), TARGET_DIR_NAME.split(path.sep)[0]));
60
+ return !!st && st.isDirectory();
61
+ }
62
+
39
63
  /**
40
64
  * 建本机特征文件夹。
41
65
  *
@@ -54,12 +78,15 @@ export function adoptFeature(workspacePath, machineId) {
54
78
  }
55
79
 
56
80
  /**
57
- * 删本机特征文件夹(删工作区时调用)。
81
+ * 删本机特征文件夹(**连同格里备份的对话记录**)。
58
82
  *
59
83
  * 三条约束:
60
- * - 只删本机那一格;
61
- * - `sessions/` 里还有别人的备份时,`sessions/` 与 `.dsh-sync/` 一概不动;
84
+ * - 只删本机那一格;别人的格子一个字节都不动;
85
+ * - `sessions/` 空了才把 `sessions/` 与 `.dsh-sync/` 一起收掉 ——
86
+ * 全收掉之后巡检就再也扫不到这个目录,符合「只有本机格子时删干净」;
62
87
  * - 目标不存在算成功(幂等)。
88
+ *
89
+ * @returns {{removed:boolean, target?:string, keptStructure?:boolean, others?:string[], reason?:string}}
63
90
  */
64
91
  export function dropFeature(workspacePath, machineId, { log = () => {} } = {}) {
65
92
  if (!machineId) return { removed: false, reason: '缺少本机标识' };
@@ -67,11 +94,14 @@ export function dropFeature(workspacePath, machineId, { log = () => {} } = {}) {
67
94
  if (path.basename(target) !== String(machineId)) {
68
95
  return { removed: false, reason: '目标不是本机特征文件夹' };
69
96
  }
97
+ const others = otherFeatures(workspacePath, machineId);
70
98
  const st = safeStat(target);
71
- if (!st) return { removed: false, reason: '本来就没有' };
72
- if (!st.isDirectory()) return { removed: false, reason: '不是目录' };
99
+ if (!st) {
100
+ return { removed: false, reason: '本来就没有', others, keptStructure: hasSyncStructure(workspacePath) };
101
+ }
102
+ if (!st.isDirectory()) return { removed: false, reason: '不是目录', others };
73
103
  fs.rmSync(target, { recursive: true, force: true });
74
- log(`删除本机特征文件夹 ${target}`);
104
+ log(`删除本机特征文件夹(含其中备份的对话):${target}`);
75
105
 
76
106
  // 收尾:空了才收,避免把别人的备份目录一起删掉
77
107
  const sessionsDir = path.join(normSlash(workspacePath), TARGET_DIR_NAME);
@@ -83,7 +113,12 @@ export function dropFeature(workspacePath, machineId, { log = () => {} } = {}) {
83
113
  /* 收尾失败不影响主结果 */
84
114
  }
85
115
  }
86
- return { removed: true, target };
116
+ return {
117
+ removed: true,
118
+ target,
119
+ others,
120
+ keptStructure: hasSyncStructure(workspacePath),
121
+ };
87
122
  }
88
123
 
89
124
  /* ----------------------------------------------------------- 决策与基线 */
@@ -97,23 +132,19 @@ const keyOf = (p) => {
97
132
  }
98
133
  };
99
134
 
100
- /** 人为某个路径表过态吗?返回 'adopt' | 'decline' | 'ask' | null。 */
135
+ /** 人为某个路径表过态吗?返回 'adopt' | 'decline' | null。 */
101
136
  export function decisionOf(state, workspacePath) {
102
137
  const bag = (state && state.features && state.features.decisions) || {};
103
138
  const hit = bag[keyOf(workspacePath)];
104
139
  if (!hit) return null;
105
- if (hit.action === 'decline') return 'decline';
106
- if (hit.action === 'ask') return 'ask';
107
- return 'adopt';
140
+ return hit.action === 'decline' ? 'decline' : 'adopt';
108
141
  }
109
142
 
110
143
  /**
111
- * 记一次表态。
144
+ * 记一次人为表态。
112
145
  *
113
146
  * adopt 人点了「建格子」(之后缺了就自动补)
114
147
  * decline 人点了「不要」(之后只列出来,不自动建)
115
- * ask 已经在面板上问过人、还等回话 —— 不写这一条,下一轮巡检
116
- * 会把「等待表态」误当成「本机新建」,直接替人建了格子。
117
148
  */
118
149
  export function rememberDecision(state, workspacePath, action) {
119
150
  if (!state.features || typeof state.features !== 'object') {
@@ -122,16 +153,15 @@ export function rememberDecision(state, workspacePath, action) {
122
153
  if (!state.features.decisions || typeof state.features.decisions !== 'object') {
123
154
  state.features.decisions = {};
124
155
  }
125
- const normalized = action === 'decline' ? 'decline' : action === 'ask' ? 'ask' : 'adopt';
126
156
  state.features.decisions[keyOf(workspacePath)] = {
127
- action: normalized,
157
+ action: action === 'decline' ? 'decline' : 'adopt',
128
158
  path: normSlash(workspacePath),
129
159
  at: Date.now(),
130
160
  };
131
161
  return state;
132
162
  }
133
163
 
134
- /** 清掉某路径的表态(人后来又把格子建回来时用)。 */
164
+ /** 清掉某路径的表态。 */
135
165
  export function forgetDecision(state, workspacePath) {
136
166
  const bag = (state.features && state.features.decisions) || null;
137
167
  if (!bag) return state;
@@ -142,22 +172,22 @@ export function forgetDecision(state, workspacePath) {
142
172
  /* --------------------------------------------------------------- 巡检计划 */
143
173
 
144
174
  /**
145
- * 算本轮该对特征文件夹做什么。**纯函数:不落盘、不改任何文件。**
175
+ * 算本轮该对特征文件夹做什么。**纯函数:不落盘、不改任何文件、不注册工作区。**
146
176
  *
147
177
  * 输入:
148
178
  * folders 特定文件夹前缀
149
179
  * workspaces 当前工作区清单(id/path/title)
150
- * discover 巡检扫出、但还没注册成工作区的目录({path,title,folder})
151
- * state 插件状态(读 features.initialized / known / decisions)
180
+ * discover 巡检扫出、「有 .dsh-sync 结构但没注册成工作区」的目录
181
+ * state 插件状态(读 features.known / decisions)
152
182
  * machineId 本机标识
153
183
  *
154
- * 输出:
155
- * active 已有格子、正常参与备份与拉取的工作区
156
- * adopt 本机新建的工作区 → **默认建格子**(保持 1.1.0 原有设置)
157
- * restore 曾经建过、现在磁盘上没有 → 自动补建(被外部删掉的自愈)
158
- * drop 本机已删除的工作区 → 跟着删格子
159
- * pending 必须人工表态的:未注册的匹配目录,以及升级时检出的存量工作区
160
- * seed 首次运行需要把「已有格子」的存量工作区记进基线(不改任何文件)
184
+ * 输出(正好对上那张矩阵):
185
+ * active 有我的格子 + 有工作区 → 正常
186
+ * createFolder 有工作区 + 没我的格子 → 补文件夹
187
+ * restoreWorkspace 有我的格子 + 没工作区 → 补工作区
188
+ * ask 没我的格子 + 没工作区 + 目录有 .dsh-sync → 问人
189
+ * drop 基线里有、现在清单里没有 → 工作区被删了,格子要跟着删
190
+ * seed 有格子的工作区(进基线,之后靠它判「被删除」)
161
191
  */
162
192
  export function planFeatures({
163
193
  folders = [],
@@ -168,13 +198,12 @@ export function planFeatures({
168
198
  } = {}) {
169
199
  const features = (state && state.features) || {};
170
200
  const known = features.known || {};
171
- const initialized = features.initialized === true;
172
201
  const seenNow = {};
173
- const adopt = [];
174
- const restore = [];
175
- const drop = [];
176
- const pending = [];
177
202
  const active = [];
203
+ const createFolder = [];
204
+ const restoreWorkspace = [];
205
+ const ask = [];
206
+ const drop = [];
178
207
  const seed = [];
179
208
 
180
209
  const underFolders = (p) => {
@@ -184,6 +213,7 @@ export function planFeatures({
184
213
  return key === fk || key.startsWith(fk + path.sep) || key.startsWith(fk + '/');
185
214
  });
186
215
  };
216
+ const alreadyAsk = (p) => ask.some((x) => keyOf(x.path) === keyOf(p));
187
217
 
188
218
  for (const ws of workspaces || []) {
189
219
  if (!ws || !ws.path) continue;
@@ -192,49 +222,55 @@ export function planFeatures({
192
222
  const entry = { id, path: ws.path, title: ws.title || path.basename(ws.path) };
193
223
 
194
224
  if (hasFeature(ws.path, machineId)) {
225
+ // 有我的格子 + 有工作区 → 正常
195
226
  active.push(entry);
196
- // 已有格子的一律进基线:首次运行靠它建档,之后靠它判「被删除」
197
227
  seed.push(entry);
198
228
  continue;
199
229
  }
200
230
 
201
- // 缺格子:不在特定文件夹里的本来就不归本插件管,不建也不问
231
+ // 不在特定文件夹里的本来就不归本插件管
202
232
  if (!underFolders(ws.path)) continue;
203
233
 
204
- const wasKnown = Object.prototype.hasOwnProperty.call(known, id);
205
- const said = decisionOf(state, ws.path);
206
- if (said === 'decline') {
207
- // 人说过不要 → 仍然列出来(可以改主意),但不自己建
208
- pending.push({ ...entry, why: 'declined' });
209
- } else if (said === 'ask') {
210
- // 已经在面板上问过、还没回话 → 继续等着,别替人决定
211
- pending.push({ ...entry, why: 'awaiting' });
212
- } else if (said === 'adopt' || wasKnown) {
213
- // 人点过同意 / 曾经建过又没了 → 自动补
214
- restore.push({ ...entry, why: wasKnown ? 'vanished' : 'adopted' });
215
- } else if (!initialized) {
216
- // 1.2.0 首次运行:存量工作区,交给人决定
217
- pending.push({ ...entry, why: 'existing' });
218
- } else {
219
- // 基线建档之后才出现 = 本机新建的,默认建格子
220
- adopt.push({ ...entry, why: 'new' });
234
+ if (decisionOf(state, ws.path) === 'decline') {
235
+ // 人明确说过这个目录不要建 → 只列出来,不自作主张
236
+ ask.push({ ...entry, why: 'declined' });
237
+ continue;
221
238
  }
239
+
240
+ // 有工作区 + 没我的格子 → 补文件夹
241
+ createFolder.push({ ...entry, why: 'workspace-without-folder' });
222
242
  }
223
243
 
224
- // 匹配目录里还没注册成工作区的:必须人工选择(同意后建格子 + 注册一起做)
244
+ // 有 .dsh-sync 结构、但没注册成工作区的目录
245
+ const knownWorkspacePaths = new Set(Object.values(seenNow).map((p) => keyOf(p)));
225
246
  for (const d of discover || []) {
226
247
  if (!d || !d.path) continue;
227
- if (decisionOf(state, d.path) === 'decline') continue;
228
- if (hasFeature(d.path, machineId)) continue;
229
- pending.push({
248
+ if (alreadyAsk(d.path)) continue;
249
+ // 已经在「工作区清单」里的,前面那一轮已经按「有工作区没格子 → 补文件夹」处理过了,
250
+ // 这里必须跳过:同一个目录不能既补文件夹又问人(scanFolders 会把它同时扫进 discover)。
251
+ if (knownWorkspacePaths.has(keyOf(d.path))) continue;
252
+ const title = d.title || path.basename(d.path);
253
+ if (decisionOf(state, d.path) === 'decline') {
254
+ ask.push({ id: null, path: d.path, title, why: 'declined' });
255
+ continue;
256
+ }
257
+ if (hasFeature(d.path, machineId)) {
258
+ // 有我的格子 + 没工作区 → 补工作区
259
+ restoreWorkspace.push({ id: null, path: d.path, title, why: 'folder-without-workspace' });
260
+ continue;
261
+ }
262
+ // 没我的格子 + 没工作区 + 目录有结构 → 问人
263
+ const others = otherFeatures(d.path, machineId);
264
+ ask.push({
230
265
  id: null,
231
266
  path: d.path,
232
- title: d.title || path.basename(d.path),
233
- why: 'unregistered',
267
+ title,
268
+ why: others.length ? 'others-only' : 'structure-only',
269
+ others,
234
270
  });
235
271
  }
236
272
 
237
- // 基线里、这一轮清单里没有的 = 本机删了工作区 → 跟着删格子
273
+ // 基线里有、这一轮清单里没有 = 工作区被删了 → 格子跟着删
238
274
  for (const [id, p] of Object.entries(known)) {
239
275
  if (Object.prototype.hasOwnProperty.call(seenNow, id)) continue;
240
276
  if (!p) continue;
@@ -242,53 +278,24 @@ export function planFeatures({
242
278
  drop.push({ id, path: p, title: path.basename(p) });
243
279
  }
244
280
 
245
- return { active, adopt, restore, drop, pending, seed, seenNow };
281
+ return { active, createFolder, restoreWorkspace, ask, drop, seed, seenNow };
246
282
  }
247
283
 
248
284
  /**
249
- * 把「本轮列进 pending 问人的」记成 ask,避免下一轮被当成「本机新建」。
285
+ * 执行计划里「不需要人点头」的部分:补文件夹、删格子,并回写基线。
250
286
  *
251
- * 只对「已注册工作区缺格子」这一类记(why = existing / awaiting),
252
- * 未注册的匹配目录本来就不会自动处理,不必额外打标。
253
- */
254
- export function markAsked(state, plan) {
255
- if (!state.features) state.features = { initialized: false, decisions: {}, known: {} };
256
- let n = 0;
257
- for (const item of (plan && plan.pending) || []) {
258
- if (item.why !== 'existing' && item.why !== 'awaiting') continue;
259
- if (decisionOf(state, item.path) === 'ask') continue;
260
- rememberDecision(state, item.path, 'ask');
261
- n++;
262
- }
263
- return n;
264
- }
265
-
266
- /**
267
- * 执行计划里「不需要人点头」的部分(adopt / restore / drop + 基线),
268
- * 并把首次运行标记打上。
269
- *
270
- * `pending` 不在这里处理:那一档必须由人显式调用 /features/adopt 才动。
287
+ * **不处理 `restoreWorkspace`**:注册工作区要走宿主接口(async),由调用方做。
288
+ * `ask` 更不能在这里动。
271
289
  */
272
290
  export function applyFeaturePlan({ plan, state, machineId, log = () => {} } = {}) {
273
- const adopted = [];
274
- const restored = [];
291
+ const created = [];
275
292
  const dropped = [];
276
293
  const errors = [];
277
294
 
278
- for (const item of plan.adopt || []) {
279
- try {
280
- adoptFeature(item.path, machineId);
281
- adopted.push(item);
282
- } catch (err) {
283
- errors.push({ path: item.path, message: String((err && err.message) || err) });
284
- }
285
- }
286
- for (const item of plan.restore || []) {
295
+ for (const item of plan.createFolder || []) {
287
296
  try {
288
297
  adoptFeature(item.path, machineId);
289
- restored.push(item);
290
- forgetDecision(state, item.path);
291
- log(`补建本机特征文件夹 ${item.path}`);
298
+ created.push(item);
292
299
  } catch (err) {
293
300
  errors.push({ path: item.path, message: String((err && err.message) || err) });
294
301
  }
@@ -296,62 +303,33 @@ export function applyFeaturePlan({ plan, state, machineId, log = () => {} } = {}
296
303
  for (const item of plan.drop || []) {
297
304
  try {
298
305
  const out = dropFeature(item.path, machineId, { log });
299
- if (out.removed) dropped.push(item);
306
+ if (out.removed) dropped.push({ ...item, others: out.others, keptStructure: out.keptStructure });
307
+ // 删干净之后不能再留任何记录,否则巡检会「记住」一个已经不该存在的目录
308
+ forgetDecision(state, item.path);
300
309
  } catch (err) {
301
310
  errors.push({ path: item.path, message: String((err && err.message) || err) });
302
311
  }
303
312
  }
304
313
 
305
- // 基线:只记「这一轮真的在清单里」且「现在有格子」的工作区
306
314
  if (!state.features || typeof state.features !== 'object') {
307
315
  state.features = { initialized: false, decisions: {}, known: {} };
308
316
  }
309
- // 这一轮问过人的,打上 ask 标记——否则下一轮 initialized 已是 true,
310
- // 会被当成「本机新建」直接建格子,等于替人做了决定。
311
- markAsked(state, plan);
312
317
  const keep = {};
313
- const live = new Set((plan.seenNow ? Object.keys(plan.seenNow) : []).map(String));
314
318
  for (const item of plan.seed || []) keep[item.id] = item.path;
315
- for (const item of adopted) keep[item.id] = item.path;
316
- for (const item of restored) keep[item.id] = item.path;
319
+ for (const item of created) keep[item.id] = item.path;
317
320
  // 已有但本轮没扫到的(前缀被改掉等)保留,避免误判成「被删除」
321
+ const live = new Set(Object.keys(plan.seenNow || {}).map(String));
318
322
  for (const [id, p] of Object.entries(state.features.known || {})) {
319
323
  if (keep[id]) continue;
320
324
  if (live.has(String(id))) continue;
321
- if (plan.drop && plan.drop.some((d) => String(d.id) === String(id))) continue;
325
+ if ((plan.drop || []).some((d) => String(d.id) === String(id))) continue;
322
326
  keep[id] = p;
323
327
  }
324
328
  state.features.known = keep;
325
329
  state.features.initialized = true;
326
330
  state.features.lastPlanAt = Date.now();
327
331
 
328
- return { adopted, restored, dropped, errors };
329
- }
330
-
331
- /**
332
- * 只做「删掉的工作区 → 删格子」这一件事。
333
- *
334
- * 供 30 秒一次的轻量巡检用:不扫盘、不建目录,只按给定 id 清单清格子。
335
- * `confirmedIds` 由 pickRemovedIds 选出来(已经过二次确认)。
336
- */
337
- export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {} } = {}) {
338
- const dropped = [];
339
- const errors = [];
340
- if (!state.features || typeof state.features !== 'object') return { dropped, errors };
341
- const known = state.features.known || {};
342
- for (const id of confirmedIds || []) {
343
- const key = String(id);
344
- const p = known[key];
345
- if (!p) continue;
346
- try {
347
- const out = dropFeature(p, machineId, { log });
348
- if (out.removed) dropped.push({ id: key, path: p });
349
- delete known[key];
350
- } catch (err) {
351
- errors.push({ id: key, path: p, message: String((err && err.message) || err) });
352
- }
353
- }
354
- return { dropped, errors };
332
+ return { created, dropped, errors };
355
333
  }
356
334
 
357
335
  /**
@@ -362,10 +340,6 @@ export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {
362
340
  * 等于把这个工作区的同步历史一把抹掉。规矩:
363
341
  * - 清单为空 → 本轮什么都不确认(清除计时);
364
342
  * - 同一个 id 连续两轮都不在,且间隔 ≥ minAgeMs,才确认删除。
365
- *
366
- * @param {{known:Object<string,string>, liveIds:string[], missingSince:Map<string,number>,
367
- * now:number, minAgeMs:number}} input
368
- * @returns {{confirmed:string[], missingSince:Map<string,number>}}
369
343
  */
370
344
  export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = Date.now(), minAgeMs = 30000 }) {
371
345
  const next = missingSince instanceof Map ? missingSince : new Map();
@@ -377,7 +351,6 @@ export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = D
377
351
  return { confirmed, missingSince: next };
378
352
  }
379
353
 
380
- // 又回来了 / 基线里已经没有它了 → 清掉计时
381
354
  for (const id of [...next.keys()]) {
382
355
  if (live.has(id) || !Object.prototype.hasOwnProperty.call(known, id)) next.delete(id);
383
356
  }
@@ -396,7 +369,34 @@ export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = D
396
369
  return { confirmed, missingSince: next };
397
370
  }
398
371
 
399
- /** 过滤:只留下「特征文件夹存在」的工作区。备份/拉取/展示都用它。 */
372
+ /**
373
+ * 删掉「被删除的工作区」对应的本机格子(连同格里备份的对话),并清掉基线。
374
+ *
375
+ * 只做这一件事,不碰别人的格子;`.dsh-sync/` 是否被一起收掉由 dropFeature 判断。
376
+ * 删完不再留任何记录 —— 只有本机格子时巡检必须再也看不到它。
377
+ */
378
+ export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {} } = {}) {
379
+ const dropped = [];
380
+ const errors = [];
381
+ if (!state.features || typeof state.features !== 'object') return { dropped, errors };
382
+ const known = state.features.known || {};
383
+ for (const id of confirmedIds || []) {
384
+ const key = String(id);
385
+ const p = known[key];
386
+ if (!p) continue;
387
+ try {
388
+ const out = dropFeature(p, machineId, { log });
389
+ dropped.push({ id: key, path: p, others: out.others, keptStructure: out.keptStructure });
390
+ delete known[key];
391
+ forgetDecision(state, p);
392
+ } catch (err) {
393
+ errors.push({ id: key, path: p, message: String((err && err.message) || err) });
394
+ }
395
+ }
396
+ return { dropped, errors };
397
+ }
398
+
399
+ /** 过滤:只留下「有本机特征文件夹」的工作区。备份/拉取/展示都用它。 */
400
400
  export function onlyActive(list, machineId) {
401
401
  return (list || []).filter((ws) => ws && ws.path && hasFeature(ws.path, machineId));
402
402
  }
@@ -405,5 +405,5 @@ export function onlyActive(list, machineId) {
405
405
  export function inactiveReason(workspacePath, machineId, state) {
406
406
  if (hasFeature(workspacePath, machineId)) return '';
407
407
  if (decisionOf(state, workspacePath) === 'decline') return '你选了不在这个文件夹建本机特征文件夹';
408
- return '还没有建本机特征文件夹(未参与备份与拉取)';
408
+ return '还没有建本机特征文件夹(下次巡检会自动补上)';
409
409
  }
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "ygc-dsh-sync",
3
- "version": "1.2.0",
4
- "type": "module",
5
- "description": "DSH 同步插件:会话按工作区落地、按机器隔离的跨机备份与拉取;技能本机↔母版双向同步(含记忆插件技能优化闭环);插件清单共享(只存清单与来源,不存源码,只能联网装)。",
6
- "icon": "icon.svg",
7
- "keywords": [
8
- "dsh",
9
- "deepseek-harness",
10
- "dsh-plugin",
11
- "session",
12
- "sync",
13
- "backup",
14
- "plugin-manifest"
15
- ],
16
- "license": "MIT",
17
- "exports": {
18
- ".": "./index.js",
19
- "./client": "./client.js",
20
- "./locale/*.json": "./locale/*.json",
21
- "./icon": "./icon.svg"
22
- },
23
- "files": [
24
- "index.js",
25
- "client.js",
26
- "lib/core.js",
27
- "lib/features.js",
28
- "lib/plugins.js",
29
- "lib/backup.js",
30
- "lib/attach.js",
31
- "lib/api.js",
32
- "lib/patrol.js",
33
- "lib/skills.js",
34
- "lib/mnemon.js",
35
- "lib/mnemon-optimize.js",
36
- "lib/changelog.js",
37
- "lib/zstd-frame.js",
38
- "cordis.patch.yml",
39
- "locale/*.json",
40
- "icon.svg",
41
- "README.md",
42
- "tools/*.mjs",
43
- "lib/mnemon-pipeline.js",
44
- "lib/mnemon-drafter.js"
45
- ],
46
- "publishConfig": {
47
- "registry": "https://registry.npmjs.org/",
48
- "access": "public"
49
- },
50
- "dsh": {
51
- "bundle": {
52
- "patch": "./cordis.patch.yml"
53
- },
54
- "client": {
55
- "platform": "web",
56
- "immediately": true,
57
- "inject": [
58
- "@deepseek-ai/dsh-client-ui-conversation"
59
- ]
60
- }
61
- }
62
- }
1
+ {
2
+ "name": "ygc-dsh-sync",
3
+ "version": "1.2.1",
4
+ "type": "module",
5
+ "description": "DSH 同步插件:会话按工作区落地、按机器隔离的跨机备份与拉取;技能本机↔母版双向同步(含记忆插件技能优化闭环);插件清单共享(只存清单与来源,不存源码,只能联网装)。",
6
+ "icon": "icon.svg",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "dsh-plugin",
11
+ "session",
12
+ "sync",
13
+ "backup",
14
+ "plugin-manifest"
15
+ ],
16
+ "license": "MIT",
17
+ "exports": {
18
+ ".": "./index.js",
19
+ "./client": "./client.js",
20
+ "./locale/*.json": "./locale/*.json",
21
+ "./icon": "./icon.svg"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "client.js",
26
+ "lib/core.js",
27
+ "lib/features.js",
28
+ "lib/plugins.js",
29
+ "lib/backup.js",
30
+ "lib/attach.js",
31
+ "lib/api.js",
32
+ "lib/patrol.js",
33
+ "lib/skills.js",
34
+ "lib/mnemon.js",
35
+ "lib/mnemon-optimize.js",
36
+ "lib/changelog.js",
37
+ "lib/zstd-frame.js",
38
+ "cordis.patch.yml",
39
+ "locale/*.json",
40
+ "icon.svg",
41
+ "README.md",
42
+ "tools/*.mjs",
43
+ "lib/mnemon-pipeline.js",
44
+ "lib/mnemon-drafter.js"
45
+ ],
46
+ "publishConfig": {
47
+ "registry": "https://registry.npmjs.org/",
48
+ "access": "public"
49
+ },
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "immediately": true,
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-ui-conversation"
59
+ ]
60
+ }
61
+ }
62
+ }