ygc-dsh-sync 1.1.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.
package/index.js CHANGED
@@ -13,10 +13,12 @@ import {
13
13
  backupWorkspace,
14
14
  pickWorkspaces,
15
15
  projectKeysFor,
16
+ pullWorkspace,
16
17
  scanLocalSessions,
17
18
  } from './lib/backup.js';
18
19
  import { attachMany, findPulledUnattached } from './lib/attach.js';
19
- import { repairAll, scanFolders } from './lib/patrol.js';
20
+ import { scanFolders } from './lib/patrol.js';
21
+ import { applyFeaturePlan, onlyActive, pickRemovedIds, planFeatures, reconcileRemoved } from './lib/features.js';
20
22
  import {
21
23
  LINEAGE_FILE,
22
24
  LOCAL_SKILLS_ROOT,
@@ -107,6 +109,26 @@ export function apply(ctx, config = {}) {
107
109
  return { id: ws?.id, created: true };
108
110
  };
109
111
 
112
+ /**
113
+ * 取消注册一个工作区(只删注册,不删目录与会话)。
114
+ *
115
+ * 走宿主自己的 workspaceRegistry.delete:user 在侧边栏删工作区是同一个入口,
116
+ * 这里只是让「删特征文件夹」能顺手把注册也清掉(/features/drop 的选项)。
117
+ */
118
+ const deleteWorkspace = async (id) => {
119
+ const registry = (() => {
120
+ try {
121
+ return ctx.get('workspaceRegistry') || ctx.workspaceRegistry;
122
+ } catch {
123
+ return ctx.workspaceRegistry;
124
+ }
125
+ })();
126
+ if (!registry || typeof registry.delete !== 'function') {
127
+ throw new Error('宿主没有提供 workspaceRegistry.delete');
128
+ }
129
+ return registry.delete(id);
130
+ };
131
+
110
132
  // ---- 插件清单同步(1.0.0 从自动更新器迁来)----
111
133
 
112
134
  /** 把本机清单写进自己那一格,再汇集他人清单。幂等:没变就不写文件。 */
@@ -195,6 +217,7 @@ export function apply(ctx, config = {}) {
195
217
  workspaces,
196
218
  machineId,
197
219
  registerWorkspace,
220
+ deleteWorkspace,
198
221
  wsEntity,
199
222
  entities,
200
223
  // 起草器:宿主的默认模型。没有 llm 服务时返回 null,流水线自动跳过起草。
@@ -229,7 +252,9 @@ export function apply(ctx, config = {}) {
229
252
  if (!state.folders.length) return;
230
253
  const mid = config.machineId || state.machineId;
231
254
 
232
- const matched = pickWorkspaces(workspaces(), state.folders);
255
+ // 只备份「本机特征文件夹存在」的工作区:没建格子 = 本机不参与,
256
+ // 自动备份也不许往里写(1.2.0 起的硬规矩)。
257
+ const matched = onlyActive(pickWorkspaces(workspaces(), state.folders), mid);
233
258
  if (!matched.length) return;
234
259
 
235
260
  const local = scanLocalSessions();
@@ -270,56 +295,97 @@ export function apply(ctx, config = {}) {
270
295
  });
271
296
 
272
297
  // ---- 定时巡检 ----
273
- // 行为:到点后执行「发现 + 补全本机特征文件夹 + 补登记会话归属」。
274
- // 发现的新文件夹只记录、不自动注册(需用户在面板确认)。
298
+ // 判据(用户 2026-11-14 定的矩阵,只认磁盘上有没有那三个东西):
299
+ // 别人的格子 有 / 我的格子 没有 / 工作区 没有 → 问人要不要在本机建
300
+ // 我的格子 有 / 工作区 有 → 正常,参与备份与拉取
301
+ // 我的格子 有 / 工作区 没有 → 补工作区(注册回侧边栏)
302
+ // 我的格子 没有 / 工作区 有 → 补文件夹
303
+ // 工作区被删(基线里有、清单里没有) → 删我的格子(含格里对话)
304
+ // 补工作区这一步要调宿主接口(async),所以放在这里做,不在纯函数里。
275
305
  const runPatrol = async (reason) => {
276
306
  try {
277
307
  const state = loadState();
278
308
  const mid = config.machineId || state.machineId;
279
- const result = scanFolders({
309
+ const all = workspaces();
310
+ const scan = scanFolders({
280
311
  folders: state.folders,
281
- workspaces: workspaces(),
312
+ workspaces: all,
282
313
  machineId: mid,
283
314
  });
284
315
 
285
- // 沿用用户既有习惯:已注册工作区缺特征文件夹时直接补齐
286
- // (补齐只是建一个空目录,不改变任何会话数据)
287
- const repaired = repairAll(result.repair, mid);
316
+ const plan = planFeatures({
317
+ folders: state.folders,
318
+ workspaces: all,
319
+ discover: scan.discover,
320
+ state: loadState(),
321
+ machineId: mid,
322
+ });
323
+ const next = loadState();
324
+ // 补文件夹 + 删格子(这两件是同步的、不涉及注册)
325
+ const applied = applyFeaturePlan({ plan, state: next, machineId: mid, log });
326
+ saveState(next);
288
327
 
289
- // 补登记:本机已有、cwd 落在这个工作区、却没进归属账的会话。
290
- // 旧版拉取只写文件不登记,这些会话会一直躺在「未分组」里;
291
- // 这里每次巡检顺手补上,是幂等的(已登记的不动)。
292
- const attachStats = await repairAttachments();
328
+ // 补工作区:有我的格子、但侧边栏里没有它
329
+ const restoredWs = [];
330
+ const restoreErrors = [];
331
+ if (typeof registerWorkspace === 'function') {
332
+ for (const item of plan.restoreWorkspace || []) {
333
+ try {
334
+ const out = await registerWorkspace(item.path, item.title);
335
+ restoredWs.push({ path: item.path, id: out?.id });
336
+ } catch (err) {
337
+ restoreErrors.push({ path: item.path, message: String((err && err.message) || err) });
338
+ }
339
+ }
340
+ if (restoredWs.length) {
341
+ const s2 = loadState();
342
+ for (const r of restoredWs) {
343
+ if (r.id !== undefined && r.id !== null) s2.features.known[String(r.id)] = String(r.path).replace(/[\\/]+/g, '\\');
344
+ }
345
+ saveState(s2);
346
+ }
347
+ }
293
348
 
294
- const next = loadState();
295
- next.patrol = {
296
- ...next.patrol,
349
+ const attachment = await repairAttachments();
350
+
351
+ const after = loadState();
352
+ after.patrol = {
353
+ ...after.patrol,
297
354
  lastRunAt: Date.now(),
298
355
  lastResult: {
299
- discover: result.discover.length,
300
- repair: result.repair.length,
301
- repaired: repaired.created.length,
302
- attached: attachStats.attached,
303
- attachFailed: attachStats.failed,
356
+ discover: scan.discover.length,
357
+ repair: scan.repair.length,
358
+ ask: plan.ask.length,
359
+ createdFolder: applied.created.length,
360
+ restoredWorkspace: restoredWs.length,
361
+ dropped: applied.dropped.length,
362
+ attached: attachment.attached,
363
+ attachFailed: attachment.failed,
304
364
  reason,
305
365
  },
306
366
  };
307
367
  pushHistory(
308
- next,
368
+ after,
309
369
  'patrol',
310
- `巡检(${reason}):发现 ${result.discover.length} 个待注册、补全 ${repaired.created.length} 个特征文件夹${
311
- attachStats.attached ? `、补登记 ${attachStats.attached} 个会话归属` : ''
312
- }`,
370
+ `巡检(${reason}):待人工确认 ${plan.ask.length} 个、补文件夹 ${applied.created.length} 个、` +
371
+ `补工作区 ${restoredWs.length} 个、删格子 ${applied.dropped.length} 个` +
372
+ `${attachment.attached ? `、补登记 ${attachment.attached} 个会话归属` : ''}`,
313
373
  );
314
- saveState(next);
315
-
316
- if (result.discover.length || repaired.created.length || attachStats.attached) {
374
+ saveState(after);
375
+
376
+ if (
377
+ plan.ask.length ||
378
+ applied.created.length ||
379
+ restoredWs.length ||
380
+ applied.dropped.length ||
381
+ attachment.attached
382
+ ) {
317
383
  log(
318
- `巡检:发现 ${result.discover.length} 个待注册,补全 ${repaired.created.length} 个特征文件夹,` +
319
- `补登记 ${attachStats.attached} 个会话归属`,
384
+ `巡检:待人工确认 ${plan.ask.length},补文件夹 ${applied.created.length},补工作区 ${restoredWs.length},` +
385
+ `删格子 ${applied.dropped.length},补登记 ${attachment.attached}`,
320
386
  );
321
387
  }
322
- return next.patrol;
388
+ return after.patrol;
323
389
  } catch (err) {
324
390
  log(`巡检失败:${err && err.message ? err.message : err}`);
325
391
  return null;
@@ -332,6 +398,9 @@ export function apply(ctx, config = {}) {
332
398
  * 只认拉取产物(别的机器备份目录里有它),不按 cwd 无差别补——
333
399
  * 自动化插件和子代理留下的会话也命中 cwd,一起补会让侧栏一次多出上百条。
334
400
  * 幂等;单条失败不打断。
401
+ *
402
+ * 1.2.0 起只处理「有本机特征文件夹」的工作区:没建格子的本机压根没在同步它,
403
+ * 往里补登记只会让侧栏凭空多出一堆会话。
335
404
  */
336
405
  const repairAttachments = async () => {
337
406
  let attached = 0;
@@ -339,7 +408,7 @@ export function apply(ctx, config = {}) {
339
408
  try {
340
409
  const state = loadState();
341
410
  const mid = config.machineId || state.machineId;
342
- const matched = pickWorkspaces(workspaces(), state.folders);
411
+ const matched = onlyActive(pickWorkspaces(workspaces(), state.folders), mid);
343
412
  if (!matched.length) return { attached, failed };
344
413
 
345
414
  const localIndex = scanLocalSessions();
@@ -385,10 +454,50 @@ export function apply(ctx, config = {}) {
385
454
  });
386
455
  };
387
456
 
457
+ /**
458
+ * 删除对账的二次确认记录:工作区 id → 第一次发现「清单里没有它」的时间。
459
+ * 判据与取整逻辑在 pickRemovedIds(纯函数,有测试)。
460
+ */
461
+ let missingSince = new Map();
462
+
388
463
  const tick = () => {
389
464
  try {
390
465
  const state = loadState();
391
466
  const patrol = state.patrol || {};
467
+
468
+ // 轻量对账(每 30 秒,与巡检间隔无关):本机删掉工作区后要尽快把
469
+ // 特征文件夹一起清掉,不能等满一个巡检周期。只比对清单与基线,
470
+ // 不扫盘、不建目录,成本接近零。
471
+ try {
472
+ const liveIds = workspaces().map((w) => String(w.id));
473
+ const nextState = loadState();
474
+ const known = (nextState.features && nextState.features.known) || {};
475
+ const picked = pickRemovedIds({
476
+ known,
477
+ liveIds,
478
+ missingSince,
479
+ now: Date.now(),
480
+ minAgeMs: PATROL_TICK_MS,
481
+ });
482
+ missingSince = picked.missingSince;
483
+ if (picked.confirmed.length) {
484
+ const rec = reconcileRemoved(nextState, config.machineId || nextState.machineId, picked.confirmed, {
485
+ log,
486
+ });
487
+ if (rec.dropped.length || rec.errors.length) {
488
+ pushHistory(
489
+ nextState,
490
+ 'features',
491
+ `工作区已删除,同步删掉 ${rec.dropped.length} 个本机特征文件夹`,
492
+ );
493
+ saveState(nextState);
494
+ log(`工作区删除:同步清掉 ${rec.dropped.length} 个本机特征文件夹`);
495
+ }
496
+ }
497
+ } catch (err) {
498
+ log(`特征文件夹对账失败:${err && err.message ? err.message : err}`);
499
+ }
500
+
392
501
  if (!patrol.enabled) {
393
502
  // 关闭期间不积累待办;重新开启时从下一次 tick 重新起算
394
503
  booted = false;