ygc-dsh-sync 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/client.js +69 -17
  2. package/index.js +43 -16
  3. package/lib/api.js +1818 -1781
  4. package/lib/core.js +484 -484
  5. package/lib/features.js +169 -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,13 @@ 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 declined = [];
207
+ const drop = [];
178
208
  const seed = [];
179
209
 
180
210
  const underFolders = (p) => {
@@ -184,6 +214,7 @@ export function planFeatures({
184
214
  return key === fk || key.startsWith(fk + path.sep) || key.startsWith(fk + '/');
185
215
  });
186
216
  };
217
+ const alreadyAsk = (p) => ask.some((x) => keyOf(x.path) === keyOf(p));
187
218
 
188
219
  for (const ws of workspaces || []) {
189
220
  if (!ws || !ws.path) continue;
@@ -192,49 +223,69 @@ export function planFeatures({
192
223
  const entry = { id, path: ws.path, title: ws.title || path.basename(ws.path) };
193
224
 
194
225
  if (hasFeature(ws.path, machineId)) {
226
+ // 有我的格子 + 有工作区 → 正常
195
227
  active.push(entry);
196
- // 已有格子的一律进基线:首次运行靠它建档,之后靠它判「被删除」
197
228
  seed.push(entry);
198
229
  continue;
199
230
  }
200
231
 
201
- // 缺格子:不在特定文件夹里的本来就不归本插件管,不建也不问
232
+ // 不在特定文件夹里的本来就不归本插件管
202
233
  if (!underFolders(ws.path)) continue;
203
234
 
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' });
235
+ if (decisionOf(state, ws.path) === 'decline') {
236
+ /**
237
+ * 人明确说过这个目录不要建 → **从待决定清单里移除**,也不自动补文件夹。
238
+ *
239
+ * 之前这里是继续 push 进 ask(why='declined'),结果人每轮巡检都被同一个目录
240
+ * 挡着,「跳过」等于没跳过。真正的跳过 = 不再出现在清单里;
241
+ * 想改主意到「未参与同步」那一栏点「建特征文件夹」(走 /features/adopt,会清掉这条记录)。
242
+ */
243
+ declined.push(entry);
244
+ continue;
221
245
  }
246
+
247
+ // 有工作区 + 没我的格子 → 补文件夹
248
+ createFolder.push({ ...entry, why: 'workspace-without-folder' });
222
249
  }
223
250
 
224
- // 匹配目录里还没注册成工作区的:必须人工选择(同意后建格子 + 注册一起做)
251
+ // 有 .dsh-sync 结构、但没注册成工作区的目录
252
+ const knownWorkspacePaths = new Set(Object.values(seenNow).map((p) => keyOf(p)));
225
253
  for (const d of discover || []) {
226
254
  if (!d || !d.path) continue;
227
- if (decisionOf(state, d.path) === 'decline') continue;
228
- if (hasFeature(d.path, machineId)) continue;
229
- pending.push({
255
+ if (alreadyAsk(d.path)) continue;
256
+ // 已经在「工作区清单」里的,前面那一轮已经按「有工作区没格子 → 补文件夹」处理过了,
257
+ // 这里必须跳过:同一个目录不能既补文件夹又问人(scanFolders 会把它同时扫进 discover)。
258
+ if (knownWorkspacePaths.has(keyOf(d.path))) continue;
259
+ const title = d.title || path.basename(d.path);
260
+ /**
261
+ * 人点过「跳过」的目录:**不再列进 pending**。
262
+ *
263
+ * 之前这里是把 why 改成 'declined' 继续留在列表里 —— 那不是跳过,是把
264
+ * 「要不要建」的问句换成「你说过不建」的标签,人每轮巡检还是被同一个目录挡着。
265
+ * 真正的跳过 = 从清单里消失;想改主意到「未参与同步」那一栏点「建特征文件夹」即可
266
+ *(那条路走 /features/adopt,会把 decline 记录清掉)。
267
+ */
268
+ if (decisionOf(state, d.path) === 'decline') {
269
+ declined.push({ id: null, path: d.path, title });
270
+ continue;
271
+ }
272
+ if (hasFeature(d.path, machineId)) {
273
+ // 有我的格子 + 没工作区 → 补工作区
274
+ restoreWorkspace.push({ id: null, path: d.path, title, why: 'folder-without-workspace' });
275
+ continue;
276
+ }
277
+ // 没我的格子 + 没工作区 + 目录有结构 → 问人
278
+ const others = otherFeatures(d.path, machineId);
279
+ ask.push({
230
280
  id: null,
231
281
  path: d.path,
232
- title: d.title || path.basename(d.path),
233
- why: 'unregistered',
282
+ title,
283
+ why: others.length ? 'others-only' : 'structure-only',
284
+ others,
234
285
  });
235
286
  }
236
287
 
237
- // 基线里、这一轮清单里没有的 = 本机删了工作区 → 跟着删格子
288
+ // 基线里有、这一轮清单里没有 = 工作区被删了 → 格子跟着删
238
289
  for (const [id, p] of Object.entries(known)) {
239
290
  if (Object.prototype.hasOwnProperty.call(seenNow, id)) continue;
240
291
  if (!p) continue;
@@ -242,53 +293,24 @@ export function planFeatures({
242
293
  drop.push({ id, path: p, title: path.basename(p) });
243
294
  }
244
295
 
245
- return { active, adopt, restore, drop, pending, seed, seenNow };
296
+ return { active, createFolder, restoreWorkspace, ask, declined, drop, seed, seenNow };
246
297
  }
247
298
 
248
299
  /**
249
- * 把「本轮列进 pending 问人的」记成 ask,避免下一轮被当成「本机新建」。
300
+ * 执行计划里「不需要人点头」的部分:补文件夹、删格子,并回写基线。
250
301
  *
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 才动。
302
+ * **不处理 `restoreWorkspace`**:注册工作区要走宿主接口(async),由调用方做。
303
+ * `ask` 更不能在这里动。
271
304
  */
272
305
  export function applyFeaturePlan({ plan, state, machineId, log = () => {} } = {}) {
273
- const adopted = [];
274
- const restored = [];
306
+ const created = [];
275
307
  const dropped = [];
276
308
  const errors = [];
277
309
 
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 || []) {
310
+ for (const item of plan.createFolder || []) {
287
311
  try {
288
312
  adoptFeature(item.path, machineId);
289
- restored.push(item);
290
- forgetDecision(state, item.path);
291
- log(`补建本机特征文件夹 ${item.path}`);
313
+ created.push(item);
292
314
  } catch (err) {
293
315
  errors.push({ path: item.path, message: String((err && err.message) || err) });
294
316
  }
@@ -296,62 +318,33 @@ export function applyFeaturePlan({ plan, state, machineId, log = () => {} } = {}
296
318
  for (const item of plan.drop || []) {
297
319
  try {
298
320
  const out = dropFeature(item.path, machineId, { log });
299
- if (out.removed) dropped.push(item);
321
+ if (out.removed) dropped.push({ ...item, others: out.others, keptStructure: out.keptStructure });
322
+ // 删干净之后不能再留任何记录,否则巡检会「记住」一个已经不该存在的目录
323
+ forgetDecision(state, item.path);
300
324
  } catch (err) {
301
325
  errors.push({ path: item.path, message: String((err && err.message) || err) });
302
326
  }
303
327
  }
304
328
 
305
- // 基线:只记「这一轮真的在清单里」且「现在有格子」的工作区
306
329
  if (!state.features || typeof state.features !== 'object') {
307
330
  state.features = { initialized: false, decisions: {}, known: {} };
308
331
  }
309
- // 这一轮问过人的,打上 ask 标记——否则下一轮 initialized 已是 true,
310
- // 会被当成「本机新建」直接建格子,等于替人做了决定。
311
- markAsked(state, plan);
312
332
  const keep = {};
313
- const live = new Set((plan.seenNow ? Object.keys(plan.seenNow) : []).map(String));
314
333
  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;
334
+ for (const item of created) keep[item.id] = item.path;
317
335
  // 已有但本轮没扫到的(前缀被改掉等)保留,避免误判成「被删除」
336
+ const live = new Set(Object.keys(plan.seenNow || {}).map(String));
318
337
  for (const [id, p] of Object.entries(state.features.known || {})) {
319
338
  if (keep[id]) continue;
320
339
  if (live.has(String(id))) continue;
321
- if (plan.drop && plan.drop.some((d) => String(d.id) === String(id))) continue;
340
+ if ((plan.drop || []).some((d) => String(d.id) === String(id))) continue;
322
341
  keep[id] = p;
323
342
  }
324
343
  state.features.known = keep;
325
344
  state.features.initialized = true;
326
345
  state.features.lastPlanAt = Date.now();
327
346
 
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 };
347
+ return { created, dropped, errors };
355
348
  }
356
349
 
357
350
  /**
@@ -362,10 +355,6 @@ export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {
362
355
  * 等于把这个工作区的同步历史一把抹掉。规矩:
363
356
  * - 清单为空 → 本轮什么都不确认(清除计时);
364
357
  * - 同一个 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
358
  */
370
359
  export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = Date.now(), minAgeMs = 30000 }) {
371
360
  const next = missingSince instanceof Map ? missingSince : new Map();
@@ -377,7 +366,6 @@ export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = D
377
366
  return { confirmed, missingSince: next };
378
367
  }
379
368
 
380
- // 又回来了 / 基线里已经没有它了 → 清掉计时
381
369
  for (const id of [...next.keys()]) {
382
370
  if (live.has(id) || !Object.prototype.hasOwnProperty.call(known, id)) next.delete(id);
383
371
  }
@@ -396,7 +384,34 @@ export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = D
396
384
  return { confirmed, missingSince: next };
397
385
  }
398
386
 
399
- /** 过滤:只留下「特征文件夹存在」的工作区。备份/拉取/展示都用它。 */
387
+ /**
388
+ * 删掉「被删除的工作区」对应的本机格子(连同格里备份的对话),并清掉基线。
389
+ *
390
+ * 只做这一件事,不碰别人的格子;`.dsh-sync/` 是否被一起收掉由 dropFeature 判断。
391
+ * 删完不再留任何记录 —— 只有本机格子时巡检必须再也看不到它。
392
+ */
393
+ export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {} } = {}) {
394
+ const dropped = [];
395
+ const errors = [];
396
+ if (!state.features || typeof state.features !== 'object') return { dropped, errors };
397
+ const known = state.features.known || {};
398
+ for (const id of confirmedIds || []) {
399
+ const key = String(id);
400
+ const p = known[key];
401
+ if (!p) continue;
402
+ try {
403
+ const out = dropFeature(p, machineId, { log });
404
+ dropped.push({ id: key, path: p, others: out.others, keptStructure: out.keptStructure });
405
+ delete known[key];
406
+ forgetDecision(state, p);
407
+ } catch (err) {
408
+ errors.push({ id: key, path: p, message: String((err && err.message) || err) });
409
+ }
410
+ }
411
+ return { dropped, errors };
412
+ }
413
+
414
+ /** 过滤:只留下「有本机特征文件夹」的工作区。备份/拉取/展示都用它。 */
400
415
  export function onlyActive(list, machineId) {
401
416
  return (list || []).filter((ws) => ws && ws.path && hasFeature(ws.path, machineId));
402
417
  }
@@ -405,5 +420,5 @@ export function onlyActive(list, machineId) {
405
420
  export function inactiveReason(workspacePath, machineId, state) {
406
421
  if (hasFeature(workspacePath, machineId)) return '';
407
422
  if (decisionOf(state, workspacePath) === 'decline') return '你选了不在这个文件夹建本机特征文件夹';
408
- return '还没有建本机特征文件夹(未参与备份与拉取)';
423
+ return '还没有建本机特征文件夹(下次巡检会自动补上)';
409
424
  }
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.2",
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
+ }