ygc-dsh-sync 1.0.2 → 1.2.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/client.js +255 -119
- package/index.js +113 -31
- package/lib/api.js +306 -35
- package/lib/core.js +42 -1
- package/lib/features.js +409 -0
- package/package.json +62 -61
package/lib/api.js
CHANGED
|
@@ -23,7 +23,17 @@ import {
|
|
|
23
23
|
scanLocalSessions,
|
|
24
24
|
} from './backup.js';
|
|
25
25
|
import { attachMany, attachedIds, findPulledUnattached, findUnattached } from './attach.js';
|
|
26
|
-
import {
|
|
26
|
+
import { joinMachineId, repairAll, scanFolders, splitMachineId } from './patrol.js';
|
|
27
|
+
import {
|
|
28
|
+
adoptFeature,
|
|
29
|
+
decisionOf,
|
|
30
|
+
dropFeature,
|
|
31
|
+
featurePath,
|
|
32
|
+
hasFeature,
|
|
33
|
+
inactiveReason,
|
|
34
|
+
planFeatures,
|
|
35
|
+
rememberDecision,
|
|
36
|
+
} from './features.js';
|
|
27
37
|
import {
|
|
28
38
|
LINEAGE_FILE,
|
|
29
39
|
LOCAL_SKILLS_ROOT,
|
|
@@ -218,7 +228,21 @@ export function createApi(deps) {
|
|
|
218
228
|
const matched = pickWorkspaces(all, state.folders);
|
|
219
229
|
const localIndex = scanLocalSessions();
|
|
220
230
|
|
|
221
|
-
|
|
231
|
+
// 1.2.0:匹配的工作区里只把「有本机特征文件夹」的算作参与同步的;
|
|
232
|
+
// 其余的单独列一份「未参与」清单,面板上给原因和采纳按钮。
|
|
233
|
+
const active = matched.filter((ws) => hasFeature(ws.path, machineId));
|
|
234
|
+
const inactive = matched
|
|
235
|
+
.filter((ws) => !hasFeature(ws.path, machineId))
|
|
236
|
+
.map((ws) => ({
|
|
237
|
+
id: ws.id,
|
|
238
|
+
path: ws.path,
|
|
239
|
+
title: ws.title || ws.path,
|
|
240
|
+
featurePath: featurePath(ws.path, machineId),
|
|
241
|
+
declined: decisionOf(state, ws.path) === 'decline',
|
|
242
|
+
reason: inactiveReason(ws.path, machineId, state),
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const workspaces = active.map((ws) => {
|
|
222
246
|
const machines = listMachines(ws.path);
|
|
223
247
|
const own = machines.find((m) => m.machineId === machineId);
|
|
224
248
|
const others = machines.filter((m) => m.machineId !== machineId);
|
|
@@ -227,6 +251,7 @@ export function createApi(deps) {
|
|
|
227
251
|
path: ws.path,
|
|
228
252
|
title: ws.title || ws.path,
|
|
229
253
|
matchedPrefix: ws.matchedPrefix,
|
|
254
|
+
featurePath: featurePath(ws.path, machineId),
|
|
230
255
|
own: own ? { sessions: own.sessions, bytes: own.bytes, newest: own.newest } : { sessions: 0, bytes: 0, newest: 0 },
|
|
231
256
|
others: others.map((o) => ({
|
|
232
257
|
machineId: o.machineId,
|
|
@@ -237,6 +262,20 @@ export function createApi(deps) {
|
|
|
237
262
|
};
|
|
238
263
|
});
|
|
239
264
|
|
|
265
|
+
// 待人工确认的采纳候选:未注册的匹配目录 + 升级时检出的存量工作区
|
|
266
|
+
const scan = scanFolders({
|
|
267
|
+
folders: state.folders,
|
|
268
|
+
workspaces: all,
|
|
269
|
+
machineId,
|
|
270
|
+
});
|
|
271
|
+
const plan = planFeatures({
|
|
272
|
+
folders: state.folders,
|
|
273
|
+
workspaces: all,
|
|
274
|
+
discover: scan.discover,
|
|
275
|
+
state,
|
|
276
|
+
machineId,
|
|
277
|
+
});
|
|
278
|
+
|
|
240
279
|
json(res, 200, {
|
|
241
280
|
ok: true,
|
|
242
281
|
machineId,
|
|
@@ -247,6 +286,14 @@ export function createApi(deps) {
|
|
|
247
286
|
lastBackupAt: state.lastBackupAt,
|
|
248
287
|
lastPullAt: state.lastPullAt,
|
|
249
288
|
patrol: state.patrol,
|
|
289
|
+
// 特征文件夹采纳:待人工确认清单 + 已有格子的工作区数
|
|
290
|
+
features: {
|
|
291
|
+
initialized: state.features.initialized === true,
|
|
292
|
+
pending: plan.pending,
|
|
293
|
+
pendingCount: plan.pending.length,
|
|
294
|
+
activeCount: active.length,
|
|
295
|
+
inactiveCount: inactive.length,
|
|
296
|
+
},
|
|
250
297
|
skills: { ...state.skills, localRoot: LOCAL_SKILLS_ROOT },
|
|
251
298
|
// 插件清单共享(1.0.0 从自动更新器迁来):配置 + 运行态 + 口径声明
|
|
252
299
|
plugins: {
|
|
@@ -258,6 +305,8 @@ export function createApi(deps) {
|
|
|
258
305
|
history: state.history.slice(0, 20),
|
|
259
306
|
workspaceCount: all.length,
|
|
260
307
|
matchedCount: workspaces.length,
|
|
308
|
+
matchedTotal: matched.length,
|
|
309
|
+
inactive,
|
|
261
310
|
workspaces,
|
|
262
311
|
totals: {
|
|
263
312
|
ownSessions: workspaces.reduce((n, w) => n + w.own.sessions, 0),
|
|
@@ -309,13 +358,23 @@ export function createApi(deps) {
|
|
|
309
358
|
});
|
|
310
359
|
},
|
|
311
360
|
|
|
312
|
-
/**
|
|
361
|
+
/** 立即备份:只备份「有本机特征文件夹」的工作区。 */
|
|
313
362
|
'POST /backup': async (req, res) => {
|
|
314
363
|
const body = await readJson(req);
|
|
315
364
|
const state = loadState();
|
|
316
365
|
const machineId = deps.machineId() || state.machineId;
|
|
317
366
|
const all = listWorkspaces();
|
|
318
|
-
|
|
367
|
+
// 1.2.0:只对「特征文件夹存在」的工作区干活;没建格子的跳过并报原因
|
|
368
|
+
const matchedAll = pickWorkspaces(all, state.folders);
|
|
369
|
+
const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
|
|
370
|
+
const skipped = matchedAll
|
|
371
|
+
.filter((w) => !hasFeature(w.path, machineId))
|
|
372
|
+
.map((w) => ({
|
|
373
|
+
id: w.id,
|
|
374
|
+
path: w.path,
|
|
375
|
+
title: w.title || w.path,
|
|
376
|
+
reason: '没有本机特征文件夹(未参与备份)',
|
|
377
|
+
}));
|
|
319
378
|
const chosen =
|
|
320
379
|
Array.isArray(body.workspaceIds) && body.workspaceIds.length
|
|
321
380
|
? matched.filter((w) => body.workspaceIds.includes(w.id))
|
|
@@ -336,24 +395,41 @@ export function createApi(deps) {
|
|
|
336
395
|
pushHistory(
|
|
337
396
|
next,
|
|
338
397
|
'backup',
|
|
339
|
-
`备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}
|
|
398
|
+
`备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}` +
|
|
399
|
+
`${skipped.length ? `;跳过 ${skipped.length} 个没有本机特征文件夹的` : ''}`,
|
|
340
400
|
);
|
|
341
401
|
saveState(next);
|
|
342
402
|
|
|
343
403
|
json(res, 200, {
|
|
344
404
|
ok: true,
|
|
345
405
|
results,
|
|
346
|
-
|
|
406
|
+
skipped,
|
|
407
|
+
summary: { workspaces: chosen.length, created, updated, bytes, skipped: skipped.length },
|
|
347
408
|
});
|
|
348
409
|
},
|
|
349
410
|
|
|
350
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* 一键拉取:多选工作区。
|
|
413
|
+
*
|
|
414
|
+
* 1.2.0 起只拉「有本机特征文件夹」的工作区——没建格子的本机不参与同步,
|
|
415
|
+
* 往里拉会话等于替人做决定。拉完的会话归属登记是拉取的一部分,不再单列按钮:
|
|
416
|
+
* 只写文件不登记,DSH 侧栏只会把它显示成「未分组」。
|
|
417
|
+
*/
|
|
351
418
|
'POST /pull': async (req, res) => {
|
|
352
419
|
const body = await readJson(req);
|
|
353
420
|
const state = loadState();
|
|
354
421
|
const machineId = deps.machineId() || state.machineId;
|
|
355
422
|
const all = listWorkspaces();
|
|
356
|
-
const
|
|
423
|
+
const matchedAll = pickWorkspaces(all, state.folders);
|
|
424
|
+
const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
|
|
425
|
+
const skipped = matchedAll
|
|
426
|
+
.filter((w) => !hasFeature(w.path, machineId))
|
|
427
|
+
.map((w) => ({
|
|
428
|
+
id: w.id,
|
|
429
|
+
path: w.path,
|
|
430
|
+
title: w.title || w.path,
|
|
431
|
+
reason: '没有本机特征文件夹(未参与拉取)',
|
|
432
|
+
}));
|
|
357
433
|
const chosen =
|
|
358
434
|
Array.isArray(body.workspaceIds) && body.workspaceIds.length
|
|
359
435
|
? matched.filter((w) => body.workspaceIds.includes(w.id))
|
|
@@ -364,22 +440,33 @@ export function createApi(deps) {
|
|
|
364
440
|
*
|
|
365
441
|
* 这是「拉取后显示在未分组」最常见的原因之一:目录里有别的机器的
|
|
366
442
|
* 会话备份,可它压根不在 workspaceRegistry 里,/pull 的匹配范围就没有它。
|
|
367
|
-
*
|
|
443
|
+
*
|
|
444
|
+
* 但它同时是「本机还没建特征文件夹」的目录 —— 1.2.0 起**不再自动注册**:
|
|
445
|
+
* 注册 + 建格子必须由人在面板上确认(POST /features/adopt)。
|
|
446
|
+
* 这里只把它们列在 unregistered 里交回去。
|
|
368
447
|
*/
|
|
369
|
-
const autoRegister = body.autoRegister
|
|
448
|
+
const autoRegister = body.autoRegister === true;
|
|
370
449
|
const registered = [];
|
|
371
450
|
const registerErrors = [];
|
|
372
|
-
|
|
451
|
+
const unregistered = [];
|
|
452
|
+
if (typeof deps.registerWorkspace === 'function') {
|
|
373
453
|
const scan = scanFolders({
|
|
374
454
|
folders: state.folders,
|
|
375
455
|
workspaces: all,
|
|
376
456
|
machineId,
|
|
377
457
|
});
|
|
378
458
|
for (const item of scan.discover) {
|
|
379
|
-
//
|
|
459
|
+
// 只关心「确实有别的机器的备份」那些,空壳目录不提
|
|
380
460
|
const hasRemote = listMachines(item.path).some((m) => m.machineId !== machineId);
|
|
381
461
|
if (!hasRemote) continue;
|
|
462
|
+
if (hasFeature(item.path, machineId)) continue;
|
|
463
|
+
if (!autoRegister) {
|
|
464
|
+
unregistered.push({ path: item.path, title: item.title, why: '需要先建本机特征文件夹' });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
// 显式 autoRegister:true(供脚本/测试用),仍然要先建格子再注册
|
|
382
468
|
try {
|
|
469
|
+
adoptFeature(item.path, machineId);
|
|
383
470
|
const out = await deps.registerWorkspace(item.path, item.title);
|
|
384
471
|
registered.push({ path: item.path, id: out?.id });
|
|
385
472
|
all.push({ id: out?.id, path: item.path, title: item.title });
|
|
@@ -389,8 +476,8 @@ export function createApi(deps) {
|
|
|
389
476
|
}
|
|
390
477
|
}
|
|
391
478
|
|
|
392
|
-
//
|
|
393
|
-
const matched2 = pickWorkspaces(all, state.folders);
|
|
479
|
+
// 注册完重新匹配一次,把新注册的目录纳入拉取范围(仍只含有格子的)
|
|
480
|
+
const matched2 = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
|
|
394
481
|
const chosen2 =
|
|
395
482
|
Array.isArray(body.workspaceIds) && body.workspaceIds.length
|
|
396
483
|
? matched2.filter((w) =>
|
|
@@ -443,8 +530,10 @@ export function createApi(deps) {
|
|
|
443
530
|
json(res, 200, {
|
|
444
531
|
ok: true,
|
|
445
532
|
results,
|
|
533
|
+
skipped,
|
|
446
534
|
registered,
|
|
447
535
|
registerErrors,
|
|
536
|
+
unregistered,
|
|
448
537
|
summary: {
|
|
449
538
|
workspaces: chosen2.length,
|
|
450
539
|
imported,
|
|
@@ -453,27 +542,162 @@ export function createApi(deps) {
|
|
|
453
542
|
attached,
|
|
454
543
|
attachFailed,
|
|
455
544
|
registered: registered.length,
|
|
545
|
+
skipped: skipped.length,
|
|
456
546
|
},
|
|
457
547
|
// 全部登记成功就不需要重开会话索引:会话列表是实时读盘 + 归属表的
|
|
458
548
|
needsRestart: imported + forked > attached,
|
|
459
549
|
});
|
|
460
550
|
},
|
|
461
551
|
|
|
552
|
+
/**
|
|
553
|
+
* 采纳:给选中的目录建本机特征文件夹;未注册的顺带注册成工作区。
|
|
554
|
+
*
|
|
555
|
+
* 这是「检测到匹配的工作区文件夹 → 人工选择要不要在里面建本机特征文件夹」
|
|
556
|
+
* 的那个人工入口。同意之后立刻生效:进侧边栏、参与备份与拉取,
|
|
557
|
+
* 不需要再点一次注册。
|
|
558
|
+
*/
|
|
559
|
+
'POST /features/adopt': async (req, res) => {
|
|
560
|
+
const body = await readJson(req);
|
|
561
|
+
const state = loadState();
|
|
562
|
+
const machineId = deps.machineId() || state.machineId;
|
|
563
|
+
const wanted = Array.isArray(body.paths)
|
|
564
|
+
? body.paths.filter((p) => typeof p === 'string' && p.trim())
|
|
565
|
+
: [];
|
|
566
|
+
if (!wanted.length) {
|
|
567
|
+
json(res, 400, { ok: false, error: '未选择任何目录' });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const existing = new Set(listWorkspaces().map((w) => pathKey(w.path)));
|
|
572
|
+
const adopted = [];
|
|
573
|
+
const registeredList = [];
|
|
574
|
+
const errors = [];
|
|
575
|
+
|
|
576
|
+
for (const raw of wanted) {
|
|
577
|
+
const p = normSlash(raw);
|
|
578
|
+
try {
|
|
579
|
+
const target = adoptFeature(p, machineId);
|
|
580
|
+
rememberDecision(state, p, 'adopt');
|
|
581
|
+
let registeredId;
|
|
582
|
+
if (!existing.has(pathKey(p))) {
|
|
583
|
+
if (typeof deps.registerWorkspace === 'function') {
|
|
584
|
+
const out = await deps.registerWorkspace(p, path.basename(p));
|
|
585
|
+
registeredId = out?.id;
|
|
586
|
+
existing.add(pathKey(p));
|
|
587
|
+
registeredList.push({ path: p, id: registeredId });
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
registeredId = listWorkspaces().find((w) => pathKey(w.path) === pathKey(p))?.id;
|
|
591
|
+
}
|
|
592
|
+
adopted.push({ path: p, id: registeredId, feature: target });
|
|
593
|
+
} catch (err) {
|
|
594
|
+
errors.push({ path: p, message: String((err && err.message) || err) });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 基线补上:刚采纳的立刻算「已知」,否则下一轮会被当成新建/缺失再处理一遍
|
|
599
|
+
if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
|
|
600
|
+
for (const item of adopted) {
|
|
601
|
+
if (item.id !== undefined && item.id !== null) state.features.known[String(item.id)] = item.path;
|
|
602
|
+
}
|
|
603
|
+
pushHistory(
|
|
604
|
+
state,
|
|
605
|
+
'features',
|
|
606
|
+
`按人工选择建本机特征文件夹 ${adopted.length} 个${
|
|
607
|
+
registeredList.length ? `(其中注册工作区 ${registeredList.length} 个)` : ''
|
|
608
|
+
}`,
|
|
609
|
+
);
|
|
610
|
+
saveState(state);
|
|
611
|
+
|
|
612
|
+
json(res, 200, { ok: true, adopted, registered: registeredList, errors });
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
/** 拒绝:记录「这个目录本机不要建格子」,之后巡检不再反复建。 */
|
|
616
|
+
'POST /features/decline': async (req, res) => {
|
|
617
|
+
const body = await readJson(req);
|
|
618
|
+
const state = loadState();
|
|
619
|
+
const wanted = Array.isArray(body.paths)
|
|
620
|
+
? body.paths.filter((p) => typeof p === 'string' && p.trim())
|
|
621
|
+
: [];
|
|
622
|
+
if (!wanted.length) {
|
|
623
|
+
json(res, 400, { ok: false, error: '未选择任何目录' });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
for (const raw of wanted) rememberDecision(state, normSlash(raw), 'decline');
|
|
627
|
+
pushHistory(state, 'features', `跳过 ${wanted.length} 个目录(不建本机特征文件夹)`);
|
|
628
|
+
saveState(state);
|
|
629
|
+
json(res, 200, { ok: true, declined: wanted.map((p) => normSlash(p)) });
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 删除:删本机特征文件夹(等于本机不再参与这个工作区的同步)。
|
|
634
|
+
*
|
|
635
|
+
* body.removeWorkspace=true 时连工作区注册一起去掉(侧边栏不再显示);
|
|
636
|
+
* 默认只删格子,保留工作区,因为「不想同步」和「不想在这里干活」是两件事。
|
|
637
|
+
*/
|
|
638
|
+
'POST /features/drop': async (req, res) => {
|
|
639
|
+
const body = await readJson(req);
|
|
640
|
+
const state = loadState();
|
|
641
|
+
const machineId = deps.machineId() || state.machineId;
|
|
642
|
+
const wanted = Array.isArray(body.paths)
|
|
643
|
+
? body.paths.filter((p) => typeof p === 'string' && p.trim())
|
|
644
|
+
: [];
|
|
645
|
+
if (!wanted.length) {
|
|
646
|
+
json(res, 400, { ok: false, error: '未选择任何目录' });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const removeWorkspace = body.removeWorkspace === true;
|
|
650
|
+
const all = listWorkspaces();
|
|
651
|
+
const dropped = [];
|
|
652
|
+
const errors = [];
|
|
653
|
+
|
|
654
|
+
for (const raw of wanted) {
|
|
655
|
+
const p = normSlash(raw);
|
|
656
|
+
try {
|
|
657
|
+
const out = dropFeature(p, machineId, { log });
|
|
658
|
+
if (!out.removed && out.reason !== '本来就没有') {
|
|
659
|
+
errors.push({ path: p, message: out.reason || '删除失败' });
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
rememberDecision(state, p, 'decline');
|
|
663
|
+
let removedWorkspace = false;
|
|
664
|
+
if (removeWorkspace && typeof deps.deleteWorkspace === 'function') {
|
|
665
|
+
const hit = all.find((w) => pathKey(w.path) === pathKey(p));
|
|
666
|
+
if (hit) {
|
|
667
|
+
await deps.deleteWorkspace(hit.id);
|
|
668
|
+
removedWorkspace = true;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const [id, knownPath] of Object.entries(state.features.known || {})) {
|
|
672
|
+
if (pathKey(knownPath) === pathKey(p)) delete state.features.known[id];
|
|
673
|
+
}
|
|
674
|
+
dropped.push({ path: p, removed: true, removedWorkspace });
|
|
675
|
+
} catch (err) {
|
|
676
|
+
errors.push({ path: p, message: String((err && err.message) || err) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
pushHistory(state, 'features', `删除本机特征文件夹 ${dropped.length} 个`);
|
|
681
|
+
saveState(state);
|
|
682
|
+
json(res, 200, { ok: true, dropped, errors });
|
|
683
|
+
},
|
|
684
|
+
|
|
462
685
|
/**
|
|
463
686
|
* 补登记:把「本插件拉进来、但没进归属账」的会话补进 sessionIds。
|
|
464
687
|
*
|
|
465
688
|
* 这是历史遗留数据的清理入口——旧版拉取只写文件不登记,
|
|
466
|
-
*
|
|
689
|
+
* 那些会话至今仍散在「未分组」里。**日常不需要点**:/pull 拉完自己会登记。
|
|
467
690
|
*
|
|
468
691
|
* 默认只补拉取产物(别的机器备份目录里有它),不会把自动化插件和
|
|
469
692
|
* 子代理留下的会话一起刷进来。要全量补必须显式传 all:true。
|
|
693
|
+
* 1.2.0 起只处理有本机特征文件夹的工作区。
|
|
470
694
|
*/
|
|
471
695
|
'POST /attach/repair': async (req, res) => {
|
|
472
696
|
const body = await readJson(req);
|
|
473
697
|
const state = loadState();
|
|
474
698
|
const machineId = deps.machineId() || state.machineId;
|
|
475
699
|
const all = listWorkspaces();
|
|
476
|
-
const matched = pickWorkspaces(all, state.folders);
|
|
700
|
+
const matched = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
|
|
477
701
|
const chosen =
|
|
478
702
|
Array.isArray(body.workspaceIds) && body.workspaceIds.length
|
|
479
703
|
? matched.filter((w) => body.workspaceIds.includes(w.id))
|
|
@@ -527,27 +751,60 @@ export function createApi(deps) {
|
|
|
527
751
|
});
|
|
528
752
|
},
|
|
529
753
|
|
|
530
|
-
/**
|
|
754
|
+
/**
|
|
755
|
+
* 巡检:只扫描,不动任何文件。
|
|
756
|
+
*
|
|
757
|
+
* 返回四类:
|
|
758
|
+
* discover 符合插件结构、但本机还没注册的目录(要人点)
|
|
759
|
+
* pending 需要人工决定「要不要在本机建特征文件夹」的(含 discover 与存量工作区)
|
|
760
|
+
* restore/drop 下一轮巡检会自动做的(外部删除自愈 / 随工作区删除)
|
|
761
|
+
* repair 已注册但缺格子——1.2.0 起不再自动补,归到 pending 让人点头
|
|
762
|
+
*/
|
|
531
763
|
'POST /patrol/scan': async (req, res) => {
|
|
532
764
|
const state = loadState();
|
|
533
765
|
const machineId = deps.machineId() || state.machineId;
|
|
766
|
+
const all = listWorkspaces();
|
|
534
767
|
const result = scanFolders({
|
|
535
768
|
folders: state.folders,
|
|
536
|
-
workspaces:
|
|
769
|
+
workspaces: all,
|
|
770
|
+
machineId,
|
|
771
|
+
});
|
|
772
|
+
const plan = planFeatures({
|
|
773
|
+
folders: state.folders,
|
|
774
|
+
workspaces: all,
|
|
775
|
+
discover: result.discover,
|
|
776
|
+
state,
|
|
537
777
|
machineId,
|
|
538
778
|
});
|
|
539
779
|
|
|
540
780
|
const next = loadState();
|
|
541
|
-
next.patrol = {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
781
|
+
next.patrol = {
|
|
782
|
+
...next.patrol,
|
|
783
|
+
lastRunAt: Date.now(),
|
|
784
|
+
lastResult: {
|
|
785
|
+
discover: result.discover.length,
|
|
786
|
+
repair: result.repair.length,
|
|
787
|
+
pending: plan.pending.length,
|
|
788
|
+
},
|
|
789
|
+
};
|
|
545
790
|
saveState(next);
|
|
546
791
|
|
|
547
|
-
json(res, 200, {
|
|
792
|
+
json(res, 200, {
|
|
793
|
+
ok: true,
|
|
794
|
+
...result,
|
|
795
|
+
pending: plan.pending,
|
|
796
|
+
autoAdopt: plan.adopt,
|
|
797
|
+
autoRestore: plan.restore,
|
|
798
|
+
autoDrop: plan.drop,
|
|
799
|
+
machineId,
|
|
800
|
+
});
|
|
548
801
|
},
|
|
549
802
|
|
|
550
|
-
/**
|
|
803
|
+
/**
|
|
804
|
+
* 注册:把选中的目录注册为工作区,并建本机特征文件夹。
|
|
805
|
+
*
|
|
806
|
+
* 与 /features/adopt 等价(保留旧路由给已有面板/脚本用)。
|
|
807
|
+
*/
|
|
551
808
|
'POST /patrol/register': async (req, res) => {
|
|
552
809
|
const body = await readJson(req);
|
|
553
810
|
const state = loadState();
|
|
@@ -569,26 +826,35 @@ export function createApi(deps) {
|
|
|
569
826
|
for (const p of paths) {
|
|
570
827
|
try {
|
|
571
828
|
if (existing.has(pathKey(p))) {
|
|
572
|
-
|
|
829
|
+
// 已注册但可能缺格子:这次点了同意就等于确认建格子
|
|
830
|
+
const target = adoptFeature(p, machineId);
|
|
831
|
+
rememberDecision(state, p, 'adopt');
|
|
832
|
+
registered.push({ path: p, skipped: '已注册', own: target });
|
|
573
833
|
continue;
|
|
574
834
|
}
|
|
575
835
|
const out = await deps.registerWorkspace(p, path.basename(p));
|
|
576
|
-
//
|
|
577
|
-
const target =
|
|
836
|
+
// 注册 + 建格子一起做:人点了同意,就该立刻参与
|
|
837
|
+
const target = adoptFeature(p, machineId);
|
|
838
|
+
rememberDecision(state, p, 'adopt');
|
|
839
|
+
if (out?.id !== undefined) state.features.known[String(out.id)] = normSlash(p);
|
|
578
840
|
registered.push({ path: p, id: out?.id, created: !!out?.created, own: target });
|
|
579
841
|
} catch (err) {
|
|
580
842
|
errors.push({ path: p, message: String((err && err.message) || err) });
|
|
581
843
|
}
|
|
582
844
|
}
|
|
583
845
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
saveState(next);
|
|
846
|
+
pushHistory(state, 'register', `注册 ${registered.length} 个工作区(含建本机特征文件夹)`);
|
|
847
|
+
saveState(state);
|
|
587
848
|
|
|
588
849
|
json(res, 200, { ok: true, registered, errors });
|
|
589
850
|
},
|
|
590
851
|
|
|
591
|
-
/**
|
|
852
|
+
/**
|
|
853
|
+
* 补建:给选中的工作区建本机特征文件夹。
|
|
854
|
+
*
|
|
855
|
+
* 1.2.0 起**不再**「发现问题就自动补」:只补传进来的 paths,
|
|
856
|
+
* 没传就什么都不做(历史上这里会对全部匹配工作区动手)。
|
|
857
|
+
*/
|
|
592
858
|
'POST /patrol/repair': async (req, res) => {
|
|
593
859
|
const body = await readJson(req);
|
|
594
860
|
const state = loadState();
|
|
@@ -596,12 +862,17 @@ export function createApi(deps) {
|
|
|
596
862
|
const all = listWorkspaces();
|
|
597
863
|
const targets = Array.isArray(body.paths) && body.paths.length
|
|
598
864
|
? all.filter((w) => body.paths.some((p) => pathKey(p) === pathKey(w.path)))
|
|
599
|
-
:
|
|
865
|
+
: [];
|
|
600
866
|
|
|
601
867
|
const out = repairAll(targets, machineId);
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
868
|
+
for (const p of body.paths || []) rememberDecision(state, p, 'adopt');
|
|
869
|
+
if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
|
|
870
|
+
for (const ws of targets) {
|
|
871
|
+
if (!hasFeature(ws.path, machineId)) continue;
|
|
872
|
+
state.features.known[String(ws.id)] = normSlash(ws.path);
|
|
873
|
+
}
|
|
874
|
+
pushHistory(state, 'repair', `补建 ${out.created.length} 个本机特征文件夹(人工指定)`);
|
|
875
|
+
saveState(state);
|
|
605
876
|
|
|
606
877
|
json(res, 200, { ok: true, ...out });
|
|
607
878
|
},
|
package/lib/core.js
CHANGED
|
@@ -182,6 +182,23 @@ const DEFAULT_STATE = {
|
|
|
182
182
|
lastResult: null,
|
|
183
183
|
lastError: '',
|
|
184
184
|
},
|
|
185
|
+
/**
|
|
186
|
+
* 本机特征文件夹的采纳状态(1.2.0 新增)。
|
|
187
|
+
*
|
|
188
|
+
* 判据永远是磁盘:`<工作区>/.dsh-sync/sessions/<本机标识>/` 存在 = 已采纳。
|
|
189
|
+
* 这里只记两样辅助信息:
|
|
190
|
+
* decisions 人对某个路径的表态(adopt / decline),防止每轮重复问;
|
|
191
|
+
* known 上一轮看到的工作区清单,用来判「本机新建」与「本机已删除」。
|
|
192
|
+
*/
|
|
193
|
+
features: {
|
|
194
|
+
/** 1.2.0 首次运行是否已把「存量工作区」归过档:没有就一律问人,不自己建。 */
|
|
195
|
+
initialized: false,
|
|
196
|
+
/** 路径(小写归一)→ { action, path, at } */
|
|
197
|
+
decisions: {},
|
|
198
|
+
/** 工作区 id → 路径;只记已采纳过的 */
|
|
199
|
+
known: {},
|
|
200
|
+
lastPlanAt: 0,
|
|
201
|
+
},
|
|
185
202
|
};
|
|
186
203
|
|
|
187
204
|
export function loadState() {
|
|
@@ -196,6 +213,7 @@ export function loadState() {
|
|
|
196
213
|
patrol: false,
|
|
197
214
|
patrolInterval: false,
|
|
198
215
|
plugins: false,
|
|
216
|
+
features: false,
|
|
199
217
|
};
|
|
200
218
|
try {
|
|
201
219
|
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
|
@@ -209,6 +227,7 @@ export function loadState() {
|
|
|
209
227
|
patrol: parsed.patrol !== undefined,
|
|
210
228
|
patrolInterval: !!(parsed.patrol && parsed.patrol.intervalMinutes !== undefined),
|
|
211
229
|
plugins: parsed.plugins !== undefined,
|
|
230
|
+
features: parsed.features !== undefined,
|
|
212
231
|
};
|
|
213
232
|
state = { ...state, ...parsed };
|
|
214
233
|
// skills 与 patrol 需要深拷贝,否则后续就地修改会改到 parsed
|
|
@@ -220,6 +239,14 @@ export function loadState() {
|
|
|
220
239
|
}
|
|
221
240
|
if (parsed.patrol && typeof parsed.patrol === 'object') state.patrol = { ...parsed.patrol };
|
|
222
241
|
if (parsed.plugins && typeof parsed.plugins === 'object') state.plugins = { ...parsed.plugins };
|
|
242
|
+
if (parsed.features && typeof parsed.features === 'object') {
|
|
243
|
+
state.features = {
|
|
244
|
+
initialized: parsed.features.initialized === true,
|
|
245
|
+
decisions: { ...(parsed.features.decisions || {}) },
|
|
246
|
+
known: { ...(parsed.features.known || {}) },
|
|
247
|
+
lastPlanAt: Number(parsed.features.lastPlanAt) || 0,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
223
250
|
if (!Array.isArray(state.folders)) state.folders = [];
|
|
224
251
|
if (!Array.isArray(state.history)) state.history = [];
|
|
225
252
|
} catch {
|
|
@@ -285,6 +312,19 @@ export function loadState() {
|
|
|
285
312
|
state.plugins.lastResult && typeof state.plugins.lastResult === 'object' ? state.plugins.lastResult : null;
|
|
286
313
|
state.plugins.lastError = typeof state.plugins.lastError === 'string' ? state.plugins.lastError.slice(0, 800) : '';
|
|
287
314
|
|
|
315
|
+
// 本机特征文件夹的采纳状态(1.2.0 新增;老 state 没有这一段)
|
|
316
|
+
if (!state.features || typeof state.features !== 'object' || Array.isArray(state.features)) {
|
|
317
|
+
state.features = { initialized: false, decisions: {}, known: {}, lastPlanAt: 0 };
|
|
318
|
+
}
|
|
319
|
+
state.features.initialized = state.features.initialized === true;
|
|
320
|
+
if (!state.features.decisions || typeof state.features.decisions !== 'object' || Array.isArray(state.features.decisions)) {
|
|
321
|
+
state.features.decisions = {};
|
|
322
|
+
}
|
|
323
|
+
if (!state.features.known || typeof state.features.known !== 'object' || Array.isArray(state.features.known)) {
|
|
324
|
+
state.features.known = {};
|
|
325
|
+
}
|
|
326
|
+
state.features.lastPlanAt = Number(state.features.lastPlanAt) || 0;
|
|
327
|
+
|
|
288
328
|
if (!state.machineHashGeneratedAt) state.machineHashGeneratedAt = 0;
|
|
289
329
|
|
|
290
330
|
/**
|
|
@@ -323,7 +363,8 @@ export function loadState() {
|
|
|
323
363
|
!diskHas.modes ||
|
|
324
364
|
!diskHas.mnemonAuto ||
|
|
325
365
|
!diskHas.patrol ||
|
|
326
|
-
!diskHas.patrolInterval
|
|
366
|
+
!diskHas.patrolInterval ||
|
|
367
|
+
!diskHas.features;
|
|
327
368
|
|
|
328
369
|
if (needsWrite) saveState(state);
|
|
329
370
|
return state;
|