@xrn1997/dsh-session-delete 0.6.0 → 0.6.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/README.md +5 -4
- package/lib/client.js +296 -101
- package/lib/index.js +269 -173
- package/lib/types/client/api-base.d.ts +19 -0
- package/lib/types/client/context-menu-actions.d.ts +47 -0
- package/lib/types/client/copy.d.ts +8 -0
- package/lib/types/client/delete-confirm.d.ts +2 -2
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/maintenance-settings.d.ts +3 -1
- package/lib/types/client/remote.d.ts +11 -7
- package/lib/types/client/trash-nav.d.ts +5 -1
- package/lib/types/client/trash-view.d.ts +3 -0
- package/lib/types/client/ui/MenuItemButton.d.ts +1 -1
- package/lib/types/client/ui/Toast.d.ts +4 -1
- package/lib/types/index.d.ts +5 -4
- package/lib/types/ports/fs-port.d.ts +10 -0
- package/lib/types/ports/host-port.d.ts +4 -2
- package/lib/types/trash/attachments.d.ts +15 -2
- package/lib/types/verbs/errors.d.ts +3 -1
- package/lib/types/verbs/permanent-removal.d.ts +10 -5
- package/package.json +13 -8
package/lib/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path, { join, resolve } from "node:path";
|
|
2
|
-
import zlib from "node:zlib";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
2
|
import { mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import zlib from "node:zlib";
|
|
5
4
|
import { homedir } from "node:os";
|
|
6
5
|
//#region src/shared/wire.ts
|
|
7
6
|
/**
|
|
@@ -192,14 +191,21 @@ function carriedRollbackFailures(error) {
|
|
|
192
191
|
}
|
|
193
192
|
/** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
|
|
194
193
|
* 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
|
|
195
|
-
* 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。
|
|
194
|
+
* 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。
|
|
195
|
+
* **原错误 details 的其余键原样保留**:孤儿清理的部分回执(`removed` / `freedBytes` /
|
|
196
|
+
* `failedSessionId`)就挂在它上面,整份替换会把已经发生的删除报成 0。 */
|
|
196
197
|
function withRollbackFailures(error, failures) {
|
|
197
198
|
const code = verbCodeOf(error) ?? "io";
|
|
198
199
|
const carried = carriedRollbackFailures(error);
|
|
199
200
|
const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
|
|
200
201
|
const base = error instanceof Error ? error.message : String(error);
|
|
201
202
|
const extra = failures.filter((failure) => !carried.includes(failure));
|
|
202
|
-
|
|
203
|
+
const message = base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`;
|
|
204
|
+
const priorDetails = error?.details;
|
|
205
|
+
return verbError(code, message, error, {
|
|
206
|
+
...priorDetails,
|
|
207
|
+
rollbackFailures: merged
|
|
208
|
+
});
|
|
203
209
|
}
|
|
204
210
|
//#endregion
|
|
205
211
|
//#region src/remote/map-error.ts
|
|
@@ -922,6 +928,92 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
|
922
928
|
};
|
|
923
929
|
}
|
|
924
930
|
//#endregion
|
|
931
|
+
//#region src/ports/fs-port.ts
|
|
932
|
+
/**
|
|
933
|
+
* **缺席**只认 ENOENT 与 ENOTDIR("路径中间有一截不是目录"与"不存在"是同一种回答)。
|
|
934
|
+
*
|
|
935
|
+
* EACCES / EIO / ELOOP / 断挂载**不是缺席**:这些读数说明"我们看不见",不是"那里没有"。
|
|
936
|
+
* 破坏性动作(删目录、删附件对象、按"没有引用"清共享图)据此做决定,把读不出折成没有
|
|
937
|
+
* 就是数据消失;ADR-0039 是这条判据的裁决。
|
|
938
|
+
*/
|
|
939
|
+
function isAbsentPath(error) {
|
|
940
|
+
const code = error?.code;
|
|
941
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
942
|
+
}
|
|
943
|
+
/** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
|
|
944
|
+
* `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
|
|
945
|
+
async function sizeOfDir(p) {
|
|
946
|
+
let total = 0;
|
|
947
|
+
for (const e of await readdir(p, { withFileTypes: true })) {
|
|
948
|
+
const child = join(p, e.name);
|
|
949
|
+
total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
|
|
950
|
+
}
|
|
951
|
+
return total;
|
|
952
|
+
}
|
|
953
|
+
/** 路径在不在:只有缺席两种错误回 `false`,其余读错误照抛。`exists` 与 `rename` 的目标检查共用。 */
|
|
954
|
+
async function pathPresent(p) {
|
|
955
|
+
try {
|
|
956
|
+
await stat(p);
|
|
957
|
+
return true;
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (isAbsentPath(error)) return false;
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function createFsPort() {
|
|
964
|
+
return {
|
|
965
|
+
exists: pathPresent,
|
|
966
|
+
async ensureDir(p) {
|
|
967
|
+
await mkdir(p, { recursive: true });
|
|
968
|
+
},
|
|
969
|
+
async rename(from, to) {
|
|
970
|
+
if (await pathPresent(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
|
|
971
|
+
try {
|
|
972
|
+
await rename(from, to);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
|
|
975
|
+
throw error;
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
async removeDir(p) {
|
|
979
|
+
await rm(p, {
|
|
980
|
+
recursive: true,
|
|
981
|
+
force: true
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
sizeOfDir: (p) => sizeOfDir(p),
|
|
985
|
+
async listFiles(p) {
|
|
986
|
+
return readdir(p);
|
|
987
|
+
},
|
|
988
|
+
async listDirs(p) {
|
|
989
|
+
const dirs = [];
|
|
990
|
+
for (const entry of await readdir(p, { withFileTypes: true })) {
|
|
991
|
+
if (entry.isDirectory()) {
|
|
992
|
+
dirs.push(entry.name);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (!entry.isSymbolicLink()) continue;
|
|
996
|
+
try {
|
|
997
|
+
if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
|
|
998
|
+
} catch {}
|
|
999
|
+
}
|
|
1000
|
+
return dirs;
|
|
1001
|
+
},
|
|
1002
|
+
async realpath(p) {
|
|
1003
|
+
return realpath(p);
|
|
1004
|
+
},
|
|
1005
|
+
async readFile(p) {
|
|
1006
|
+
return readFile(p);
|
|
1007
|
+
},
|
|
1008
|
+
async statFile(p) {
|
|
1009
|
+
return { size: (await stat(p)).size };
|
|
1010
|
+
},
|
|
1011
|
+
async removeFile(p) {
|
|
1012
|
+
await rm(p, { force: true });
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
925
1017
|
//#region src/log/zstd-frames.ts
|
|
926
1018
|
const MAGIC = [
|
|
927
1019
|
40,
|
|
@@ -1166,10 +1258,10 @@ function isRecord(value) {
|
|
|
1166
1258
|
function numberOf(value) {
|
|
1167
1259
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
1168
1260
|
}
|
|
1169
|
-
/**
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1261
|
+
/** 路径缺席的判据只有一处:`src/ports/fs-port.ts` 的 `isAbsentPath`(ENOENT / ENOTDIR)。
|
|
1262
|
+
* `statFile` / `readFile` / `sizeOfDir` 的读错误里,"不在"只认这两枚;权限、盘挂、I/O 错误
|
|
1263
|
+
* 都必须露成读不出(ADR-0039),别在这里另写一份只有 ENOENT 的抄本。 */
|
|
1264
|
+
const isMissing = isAbsentPath;
|
|
1173
1265
|
/** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
|
|
1174
1266
|
function attachmentPathOf(home, kind, id) {
|
|
1175
1267
|
if (!isAttachmentId(id)) throw new Error(`附件 id 形状不对:${id}`);
|
|
@@ -1403,6 +1495,32 @@ async function collectRemainingImageRefs(deps, input) {
|
|
|
1403
1495
|
};
|
|
1404
1496
|
}
|
|
1405
1497
|
/**
|
|
1498
|
+
* 剩余引用扫描的 fail-closed 收口(独占计划 / 执行期复核 / 孤儿扫描三处共用同一形状):扫描抛错或
|
|
1499
|
+
* 任一引用源读不出都回 `reason`,读得出才交引用集——「读不出」绝不折成「没有引用」(ADR-0020;
|
|
1500
|
+
* 缺席判据见 ADR-0039)。`reasonLabel` 只补"这次是为什么扫"的前半句,后半句是读数原文。
|
|
1501
|
+
*/
|
|
1502
|
+
async function remainingRefsFailClosed(deps, excludeDirs, reasonLabel) {
|
|
1503
|
+
try {
|
|
1504
|
+
const refs = await collectRemainingImageRefs(deps, {
|
|
1505
|
+
sessionRoot: `${deps.home}/sessions`,
|
|
1506
|
+
excludeDirs
|
|
1507
|
+
});
|
|
1508
|
+
if (refs.unreadable.length > 0) return {
|
|
1509
|
+
ok: false,
|
|
1510
|
+
reason: `${reasonLabel}:${refs.unreadable[0]}`
|
|
1511
|
+
};
|
|
1512
|
+
return {
|
|
1513
|
+
ok: true,
|
|
1514
|
+
refs
|
|
1515
|
+
};
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
return {
|
|
1518
|
+
ok: false,
|
|
1519
|
+
reason: `${reasonLabel}:${String(error)}`
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1406
1524
|
* 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
|
|
1407
1525
|
* 目录 + storages/cache。
|
|
1408
1526
|
* 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
|
|
@@ -1425,24 +1543,13 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
|
|
|
1425
1543
|
bytes: 0,
|
|
1426
1544
|
...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
|
|
1427
1545
|
};
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
remaining = await collectRemainingImageRefs(deps, {
|
|
1431
|
-
sessionRoot: `${deps.home}/sessions`,
|
|
1432
|
-
excludeDirs: removedDirs
|
|
1433
|
-
});
|
|
1434
|
-
} catch (error) {
|
|
1435
|
-
return {
|
|
1436
|
-
entries: [],
|
|
1437
|
-
bytes: 0,
|
|
1438
|
-
skipped: `读不到剩余引用源,无法证明独占:${String(error)}`
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
|
-
if (remaining.unreadable.length > 0) return {
|
|
1546
|
+
const scanned = await remainingRefsFailClosed(deps, removedDirs, "读不到剩余引用源,无法证明独占");
|
|
1547
|
+
if (!scanned.ok) return {
|
|
1442
1548
|
entries: [],
|
|
1443
1549
|
bytes: 0,
|
|
1444
|
-
skipped:
|
|
1550
|
+
skipped: scanned.reason
|
|
1445
1551
|
};
|
|
1552
|
+
const remaining = scanned.refs;
|
|
1446
1553
|
const entries = [];
|
|
1447
1554
|
let bytes = 0;
|
|
1448
1555
|
let unreadableObjects = 0;
|
|
@@ -1469,6 +1576,31 @@ async function planImageAttachmentRemoval(deps, removedDirs) {
|
|
|
1469
1576
|
};
|
|
1470
1577
|
}
|
|
1471
1578
|
/**
|
|
1579
|
+
* 执行期复核:计划与真删对象之间隔着一整趟目录删除(秒级),这期间别的会话可能把同一张图
|
|
1580
|
+
* (内容寻址 ⇒ 同一个 sha256 id)再引用一次,或派生存储新写一条引用。计划是数据、会过期;
|
|
1581
|
+
* 删对象之前用同一套剩余引用扫描再判一次,**只删这一刻仍无引用的那些**(ADR-0041)。
|
|
1582
|
+
*
|
|
1583
|
+
* 调用时机:会话 / 回收站目录已经删掉之后、`executeImageAttachmentRemoval` 之前——那时被删的
|
|
1584
|
+
* 引用源已经不在了,扫到的就是真正的"剩余"。读不出照旧 fail closed:回空计划 + `skipped`,
|
|
1585
|
+
* 宁可留下孤儿也不删共享。空计划直接返回(不为零删付一次全 home 扫描)。
|
|
1586
|
+
*/
|
|
1587
|
+
async function recheckImageRemovalExclusivity(deps, plan) {
|
|
1588
|
+
if (plan.entries.length === 0) return plan;
|
|
1589
|
+
const scanned = await remainingRefsFailClosed(deps, [], "读不到剩余引用源,无法复核独占");
|
|
1590
|
+
if (!scanned.ok) return {
|
|
1591
|
+
entries: [],
|
|
1592
|
+
bytes: 0,
|
|
1593
|
+
skipped: plan.skipped === void 0 ? scanned.reason : `${plan.skipped};${scanned.reason}`
|
|
1594
|
+
};
|
|
1595
|
+
const remaining = scanned.refs;
|
|
1596
|
+
const entries = plan.entries.filter((entry) => !remaining.ids.has(entry.id));
|
|
1597
|
+
return {
|
|
1598
|
+
entries,
|
|
1599
|
+
bytes: entries.reduce((total, entry) => total + entry.bytes, 0),
|
|
1600
|
+
...plan.skipped === void 0 ? {} : { skipped: plan.skipped }
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1472
1604
|
* 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
|
|
1473
1605
|
*
|
|
1474
1606
|
* 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
|
|
@@ -1521,24 +1653,13 @@ const OBJECT_NAME = /^[0-9a-f]{64}$/u;
|
|
|
1521
1653
|
* 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
|
|
1522
1654
|
*/
|
|
1523
1655
|
async function scanOrphanImageAttachments(deps) {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
remaining = await collectRemainingImageRefs(deps, {
|
|
1527
|
-
sessionRoot: `${deps.home}/sessions`,
|
|
1528
|
-
excludeDirs: []
|
|
1529
|
-
});
|
|
1530
|
-
} catch (error) {
|
|
1531
|
-
return {
|
|
1532
|
-
entries: [],
|
|
1533
|
-
bytes: 0,
|
|
1534
|
-
skipped: `读不到引用源,无法判定孤儿附件:${String(error)}`
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
if (remaining.unreadable.length > 0) return {
|
|
1656
|
+
const scanned = await remainingRefsFailClosed(deps, [], "读不到引用源,无法判定孤儿附件");
|
|
1657
|
+
if (!scanned.ok) return {
|
|
1538
1658
|
entries: [],
|
|
1539
1659
|
bytes: 0,
|
|
1540
|
-
skipped:
|
|
1660
|
+
skipped: scanned.reason
|
|
1541
1661
|
};
|
|
1662
|
+
const remaining = scanned.refs;
|
|
1542
1663
|
const objectsRoot = `${deps.home}/attachments/v1/objects`;
|
|
1543
1664
|
const entries = [];
|
|
1544
1665
|
let bytes = 0;
|
|
@@ -1597,9 +1718,9 @@ async function cleanOrphanImageAttachments(deps) {
|
|
|
1597
1718
|
* purge / orphan-clean 的删除计划({@link planMemberRemovals})才付——executor 摘账本要用它。
|
|
1598
1719
|
*/
|
|
1599
1720
|
/** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
|
|
1600
|
-
* ENOENT
|
|
1721
|
+
* 缺席(ENOENT / ENOTDIR)才是"不在",别的错误照旧响亮(ADR-0039)。别再在调用点各写一遍这段 try/catch。
|
|
1601
1722
|
*
|
|
1602
|
-
* **已知粒度**:`sizeOfDir`
|
|
1723
|
+
* **已知粒度**:`sizeOfDir` 递归里撞到的缺席与"目录本身没了"是同一个码,所以目录里某个文件
|
|
1603
1724
|
* 恰好在这一拍消失时,整条目录会被记成「不在」(`/list` 少算这条、删除计划里没有可删的字节,
|
|
1604
1725
|
* 而清册行照旧摘——盘上留下目录等下一次 `reconcile` 重建成行,自愈、不丢数据)。复核一次
|
|
1605
1726
|
* `exists(dir)` 能把两者分开,却会让「宿主正在写、文件刚被挪走」这类正常竞态把 `/list` 整个打红——
|
|
@@ -1811,15 +1932,20 @@ async function planOrphanRemoval(deps, members) {
|
|
|
1811
1932
|
*
|
|
1812
1933
|
* 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
|
|
1813
1934
|
* (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
|
|
1814
|
-
* 目录删掉就没有"退回去"
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* `
|
|
1935
|
+
* 目录删掉就没有"退回去"这回事,中途失败**不回滚目录**;已经删掉的由下面的计数器如实交回。
|
|
1936
|
+
* **归档态回滚**:`unarchive` 排在所有删除之前(免得留下"会话没了、归档集还挂着"的幽灵 id),
|
|
1937
|
+
* 所以中途失败时目录还在、归档位却已经摘了。失败出口把**目录还在**的那些(这一趟没删掉、也不是计划时
|
|
1938
|
+
* 就缺席)按删除前的读数重新归档(`isArchived` 先读过一次,不是按 `wasArchived` 猜);目录不在的跳过
|
|
1939
|
+
* ——那时恢复归档只会造幽灵 id。
|
|
1940
|
+
* 回滚自己的失败走 `details.rollbackFailures`,不让它把原错误吞掉(ADR-0040)。
|
|
1941
|
+
* **附件计划在执行期复核**:计划到删对象之间的窗口里新增的引用会让计划过期;删对象前用同一套
|
|
1942
|
+
* 剩余引用扫描重判一次,只删这一刻仍无引用的对象(ADR-0041)。
|
|
1818
1943
|
*
|
|
1819
1944
|
* 失败形状:
|
|
1820
1945
|
* - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
|
|
1821
1946
|
* - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
|
|
1822
|
-
* - orphan-clean
|
|
1947
|
+
* - orphan-clean 的归档实况预读 / unarchive / detach 原样抛出;
|
|
1948
|
+
* - 归档态回滚不干净时,原错误带 `details.rollbackFailures`(码仍按原错误的动词码)。
|
|
1823
1949
|
*
|
|
1824
1950
|
* 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
|
|
1825
1951
|
*/
|
|
@@ -1841,7 +1967,7 @@ async function executeRemoval(deps, plan) {
|
|
|
1841
1967
|
} : void 0;
|
|
1842
1968
|
throw verbError("io", `${label}:${String(error)}`, error, details);
|
|
1843
1969
|
};
|
|
1844
|
-
/** 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
|
|
1970
|
+
/** 归档实况预读 / 退归档集 / 摘账本这一步的失败形状:purge 折成带标签的 `io`;orphan-clean 原样抛。 */
|
|
1845
1971
|
const step = async (label, sessionId, run) => {
|
|
1846
1972
|
if (plan.kind === "orphan") {
|
|
1847
1973
|
await run();
|
|
@@ -1853,49 +1979,79 @@ async function executeRemoval(deps, plan) {
|
|
|
1853
1979
|
fail(label, error, sessionId);
|
|
1854
1980
|
}
|
|
1855
1981
|
};
|
|
1856
|
-
|
|
1857
|
-
for (const
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1982
|
+
const archivedBefore = /* @__PURE__ */ new Set();
|
|
1983
|
+
for (const sessionId of plan.unarchive) await step("读取归档状态失败", sessionId, async () => {
|
|
1984
|
+
if (await deps.host.isArchived(sessionId)) archivedBefore.add(sessionId);
|
|
1985
|
+
});
|
|
1986
|
+
/** 走到过 `unarchive` 这一步的 id(含失败那一条):失败时按它回滚。 */
|
|
1987
|
+
const unarchived = [];
|
|
1988
|
+
/** 计划时目录还在的 id:只有这些会话还有本体可挂归档。计划时就缺席的行 / 成员也在 `unarchive`
|
|
1989
|
+
* 名单里(那一步顺带清幽灵 id),但没有目录可回滚。 */
|
|
1990
|
+
const onDisk = /* @__PURE__ */ new Set([...plan.trash.filter((row) => row.present).map((row) => row.entry.sessionId), ...plan.members.map((member) => member.sessionId)]);
|
|
1991
|
+
/** 目录已经真删掉的 id:没有归档态可放回去,跳过——否则会在 registry 里留下幽灵 id。 */
|
|
1992
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
1993
|
+
try {
|
|
1994
|
+
for (const sessionId of plan.unarchive) {
|
|
1995
|
+
unarchived.push(sessionId);
|
|
1996
|
+
await step("取消归档失败", sessionId, () => deps.host.unarchive(sessionId));
|
|
1997
|
+
}
|
|
1998
|
+
for (const member of plan.members) {
|
|
1999
|
+
const workspaceId = member.workspaceId;
|
|
2000
|
+
if (workspaceId === void 0) continue;
|
|
2001
|
+
await step("摘账本失败", member.sessionId, () => deps.host.detach(member.sessionId, workspaceId));
|
|
2002
|
+
}
|
|
2003
|
+
for (const row of plan.trash) {
|
|
2004
|
+
if (row.present) {
|
|
2005
|
+
try {
|
|
2006
|
+
await deps.fs.removeDir(row.dir);
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
fail("删除回收站目录失败", error, row.entry.sessionId);
|
|
2009
|
+
}
|
|
2010
|
+
deleted.add(row.entry.sessionId);
|
|
2011
|
+
freedSoFar += row.bytes;
|
|
2012
|
+
}
|
|
1864
2013
|
try {
|
|
1865
|
-
await deps.
|
|
2014
|
+
await deps.ledger.remove(row.entry.id);
|
|
1866
2015
|
} catch (error) {
|
|
1867
|
-
fail("
|
|
2016
|
+
fail("摘除回收站条目失败", error, row.entry.sessionId);
|
|
1868
2017
|
}
|
|
1869
|
-
|
|
2018
|
+
removed += 1;
|
|
2019
|
+
await presence.memberGone(row.entry.sessionId);
|
|
1870
2020
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2021
|
+
const memberLabel = plan.kind === "trash" ? "删除盘上后代目录失败" : "删除会话目录失败";
|
|
2022
|
+
for (const member of plan.members) {
|
|
2023
|
+
try {
|
|
2024
|
+
await deps.fs.removeDir(member.dir);
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
fail(memberLabel, error, member.sessionId);
|
|
2027
|
+
}
|
|
2028
|
+
deleted.add(member.sessionId);
|
|
2029
|
+
removed += 1;
|
|
2030
|
+
freedSoFar += member.bytes;
|
|
2031
|
+
await presence.memberGone(member.sessionId);
|
|
1875
2032
|
}
|
|
1876
|
-
|
|
1877
|
-
await
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2033
|
+
const attachmentPlan = await recheckImageRemovalExclusivity(deps, plan.attachments);
|
|
2034
|
+
const attachments = await executeImageAttachmentRemoval(deps, attachmentPlan);
|
|
2035
|
+
return {
|
|
2036
|
+
removed,
|
|
2037
|
+
freedBytes: plan.bytes,
|
|
2038
|
+
attachments: {
|
|
2039
|
+
...attachments,
|
|
2040
|
+
...attachmentPlan.skipped === void 0 ? {} : { skipped: attachmentPlan.skipped }
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
const rollbackFailures = [];
|
|
2045
|
+
for (const sessionId of [...unarchived].reverse()) {
|
|
2046
|
+
if (!archivedBefore.has(sessionId) || deleted.has(sessionId) || !onDisk.has(sessionId)) continue;
|
|
2047
|
+
try {
|
|
2048
|
+
await deps.host.archive(sessionId);
|
|
2049
|
+
} catch (rollbackError) {
|
|
2050
|
+
rollbackFailures.push(rollbackReason("恢复归档失败", rollbackError));
|
|
2051
|
+
}
|
|
1885
2052
|
}
|
|
1886
|
-
|
|
1887
|
-
freedSoFar += member.bytes;
|
|
1888
|
-
await presence.memberGone(member.sessionId);
|
|
2053
|
+
throw rollbackFailures.length === 0 ? error : withRollbackFailures(error, rollbackFailures);
|
|
1889
2054
|
}
|
|
1890
|
-
const attachments = await executeImageAttachmentRemoval(deps, plan.attachments);
|
|
1891
|
-
return {
|
|
1892
|
-
removed,
|
|
1893
|
-
freedBytes: plan.bytes,
|
|
1894
|
-
attachments: {
|
|
1895
|
-
...attachments,
|
|
1896
|
-
...plan.attachments.skipped === void 0 ? {} : { skipped: plan.attachments.skipped }
|
|
1897
|
-
}
|
|
1898
|
-
};
|
|
1899
2055
|
}
|
|
1900
2056
|
//#endregion
|
|
1901
2057
|
//#region src/verbs/orphan-subagents.ts
|
|
@@ -2079,6 +2235,21 @@ function liveSessionsOf(ctx) {
|
|
|
2079
2235
|
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
2080
2236
|
return store;
|
|
2081
2237
|
}
|
|
2238
|
+
/**
|
|
2239
|
+
* 会话 header 的**公开**读面:live `sessions.get(id).header` 优先(保留宿主原来的优先级),
|
|
2240
|
+
* 否则 `sessionPersistence.stat(id).header`。未知会话回 `undefined`;需要落到持久化时服务缺席 /
|
|
2241
|
+
* 形状不符抛错,由调用方决定是响亮失败(projectDirOf)还是降级(titleOf 的空串)。
|
|
2242
|
+
*/
|
|
2243
|
+
async function headerOf(host, sessionId) {
|
|
2244
|
+
const store = liveSessionsOf(host);
|
|
2245
|
+
if (store !== void 0) try {
|
|
2246
|
+
const live = store.get(sessionId);
|
|
2247
|
+
if (live?.header !== void 0) return live.header;
|
|
2248
|
+
} catch {}
|
|
2249
|
+
const persistence = host.get("sessionPersistence");
|
|
2250
|
+
if (persistence === void 0 || persistence === null || typeof persistence.stat !== "function") throw new Error("session-delete: sessionPersistence 服务缺席");
|
|
2251
|
+
return (await persistence.stat(sessionId))?.header;
|
|
2252
|
+
}
|
|
2082
2253
|
/** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。
|
|
2083
2254
|
* 它只按 `parentSession` 原样匹配,**不是**子会话分类器:旧宿主(≤0.2.1-rc.5)的普通 fork 也写
|
|
2084
2255
|
* `parentSession`,0.2.2-alpha.2 起才不写。子会话的判据是 `origin` 的 subagent 标记(宿主归档闸、归档
|
|
@@ -2174,15 +2345,6 @@ function projectionCacheOf(ctx) {
|
|
|
2174
2345
|
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
2175
2346
|
return cache;
|
|
2176
2347
|
}
|
|
2177
|
-
/** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
|
|
2178
|
-
* 出处:asar `dsh-workspace/lib/types/index.js` 的
|
|
2179
|
-
* `cannot validate session '${id}': session persistence holds no such session`。
|
|
2180
|
-
* 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
|
|
2181
|
-
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
2182
|
-
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
2183
|
-
function isUnknownSession(error) {
|
|
2184
|
-
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
2185
|
-
}
|
|
2186
2348
|
/** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
|
|
2187
2349
|
* 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
|
|
2188
2350
|
* 不是"恢复失败"。 */
|
|
@@ -2223,23 +2385,20 @@ function createHostPort(ctx) {
|
|
|
2223
2385
|
},
|
|
2224
2386
|
/**
|
|
2225
2387
|
* cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
|
|
2226
|
-
*
|
|
2388
|
+
* header 走公开面(live `sessions` → `sessionPersistence.stat`);未知会话回 `undefined`
|
|
2389
|
+
* (未命中,不是故障);服务缺席/真存储故障照抛。
|
|
2227
2390
|
* 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
|
|
2228
2391
|
*/
|
|
2229
2392
|
async projectDirOf(sessionId) {
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
header = await registry.readSessionHeader(sessionId);
|
|
2233
|
-
} catch (error) {
|
|
2234
|
-
if (isUnknownSession(error)) return void 0;
|
|
2235
|
-
throw error;
|
|
2236
|
-
}
|
|
2393
|
+
const header = await headerOf(host, sessionId);
|
|
2394
|
+
if (header === void 0) return void 0;
|
|
2237
2395
|
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
2238
2396
|
},
|
|
2239
|
-
/**
|
|
2397
|
+
/** 标题走宿主投影缓存;header 来自公开的 live/持久化读面,尽力取,取不到返回空串。 */
|
|
2240
2398
|
async titleOf(sessionId) {
|
|
2241
2399
|
try {
|
|
2242
|
-
const header = await
|
|
2400
|
+
const header = await headerOf(host, sessionId);
|
|
2401
|
+
if (header === void 0) return "";
|
|
2243
2402
|
const cache = projectionCacheOf(host);
|
|
2244
2403
|
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
2245
2404
|
return typeof title === "string" ? title : "";
|
|
@@ -3797,7 +3956,10 @@ function toResponse(result) {
|
|
|
3797
3956
|
});
|
|
3798
3957
|
return new Response(JSON.stringify(result.body), {
|
|
3799
3958
|
status: result.status,
|
|
3800
|
-
headers: {
|
|
3959
|
+
headers: {
|
|
3960
|
+
"content-type": "application/json; charset=utf-8",
|
|
3961
|
+
"cache-control": "private, no-store"
|
|
3962
|
+
}
|
|
3801
3963
|
});
|
|
3802
3964
|
}
|
|
3803
3965
|
/**
|
|
@@ -3903,73 +4065,6 @@ const Config = { "~standard": {
|
|
|
3903
4065
|
}
|
|
3904
4066
|
} };
|
|
3905
4067
|
//#endregion
|
|
3906
|
-
//#region src/ports/fs-port.ts
|
|
3907
|
-
/** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
|
|
3908
|
-
* `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
|
|
3909
|
-
async function sizeOfDir(p) {
|
|
3910
|
-
let total = 0;
|
|
3911
|
-
for (const e of await readdir(p, { withFileTypes: true })) {
|
|
3912
|
-
const child = join(p, e.name);
|
|
3913
|
-
total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
|
|
3914
|
-
}
|
|
3915
|
-
return total;
|
|
3916
|
-
}
|
|
3917
|
-
function createFsPort() {
|
|
3918
|
-
return {
|
|
3919
|
-
async exists(p) {
|
|
3920
|
-
return existsSync(p);
|
|
3921
|
-
},
|
|
3922
|
-
async ensureDir(p) {
|
|
3923
|
-
await mkdir(p, { recursive: true });
|
|
3924
|
-
},
|
|
3925
|
-
async rename(from, to) {
|
|
3926
|
-
if (existsSync(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
|
|
3927
|
-
try {
|
|
3928
|
-
await rename(from, to);
|
|
3929
|
-
} catch (error) {
|
|
3930
|
-
if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
|
|
3931
|
-
throw error;
|
|
3932
|
-
}
|
|
3933
|
-
},
|
|
3934
|
-
async removeDir(p) {
|
|
3935
|
-
await rm(p, {
|
|
3936
|
-
recursive: true,
|
|
3937
|
-
force: true
|
|
3938
|
-
});
|
|
3939
|
-
},
|
|
3940
|
-
sizeOfDir: (p) => sizeOfDir(p),
|
|
3941
|
-
async listFiles(p) {
|
|
3942
|
-
return readdir(p);
|
|
3943
|
-
},
|
|
3944
|
-
async listDirs(p) {
|
|
3945
|
-
const dirs = [];
|
|
3946
|
-
for (const entry of await readdir(p, { withFileTypes: true })) {
|
|
3947
|
-
if (entry.isDirectory()) {
|
|
3948
|
-
dirs.push(entry.name);
|
|
3949
|
-
continue;
|
|
3950
|
-
}
|
|
3951
|
-
if (!entry.isSymbolicLink()) continue;
|
|
3952
|
-
try {
|
|
3953
|
-
if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
|
|
3954
|
-
} catch {}
|
|
3955
|
-
}
|
|
3956
|
-
return dirs;
|
|
3957
|
-
},
|
|
3958
|
-
async realpath(p) {
|
|
3959
|
-
return realpath(p);
|
|
3960
|
-
},
|
|
3961
|
-
async readFile(p) {
|
|
3962
|
-
return readFile(p);
|
|
3963
|
-
},
|
|
3964
|
-
async statFile(p) {
|
|
3965
|
-
return { size: (await stat(p)).size };
|
|
3966
|
-
},
|
|
3967
|
-
async removeFile(p) {
|
|
3968
|
-
await rm(p, { force: true });
|
|
3969
|
-
}
|
|
3970
|
-
};
|
|
3971
|
-
}
|
|
3972
|
-
//#endregion
|
|
3973
4068
|
//#region src/ports/home.ts
|
|
3974
4069
|
/**
|
|
3975
4070
|
* 宿主数据根。官方解析器在 asar `dsh/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js`:
|
|
@@ -4227,10 +4322,11 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
4227
4322
|
* 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。**不进 inject 的**:
|
|
4228
4323
|
* - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
|
|
4229
4324
|
* Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
|
|
4230
|
-
* - `sessions` / `sessionProjectionCache
|
|
4231
|
-
* 用(活跃判据走 waterfall,见 src/ports/host-port.ts
|
|
4232
|
-
*
|
|
4233
|
-
*
|
|
4325
|
+
* - `sessions` / `sessionProjectionCache` / `sessionPersistence`:都按 `ctx.get(...)` **可选读**——
|
|
4326
|
+
* `sessions` 给 `evictLive` 和 live header 用(活跃判据走 waterfall,见 src/ports/host-port.ts;
|
|
4327
|
+
* 服务缺席回 `unknown`),`sessionProjectionCache` 是标题的投影读面,`sessionPersistence` 是
|
|
4328
|
+
* `projectDirOf` / `titleOf` 的公开 header 读面(workspaceRegistry 硬注入它,标准组合必有)。
|
|
4329
|
+
* inject 一个可能没挂的服务 = 整个插件 pending;标题取不到时 `titleOf` 返回空串,正是契约允许的降级。
|
|
4234
4330
|
* - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
|
|
4235
4331
|
* 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
|
|
4236
4332
|
*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 取数路径的挂载点:官方 `/api` 前缀 + **当前文档所在的目录**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能写死根绝对路径:宿主自己的客户端模块按「app-directory-relative form」下发 bundle 与
|
|
5
|
+
* chunk 描述符,浏览器在**自己的挂载点**下解析(`dsh-client-modules` README 原文:*"which a browser
|
|
6
|
+
* document resolves under its own mount"*);同一个部署可以被反代到 `/dsh/`,此时 `/api/...` 会打到
|
|
7
|
+
* 反代根、而不是宿主。路由注册侧仍然必须写 `/api/...`(宿主 `connection.fetch.register` 用
|
|
8
|
+
* `endpointFromPath('/api', path)` 校验),所以本文件只给**浏览器半**投影一层挂载点前缀。
|
|
9
|
+
*
|
|
10
|
+
* 根部署(`http://host/`)下 `mountPathOf` 返回空串,拼出来的 URL 与旧版逐字相同;只有挂载部署才
|
|
11
|
+
* 多出 `/dsh` 这一段。目录取自 `new URL('.', baseURI)`:页面是 `/dsh/`、`/dsh/index.html` 都归到
|
|
12
|
+
* `/dsh/`;`about:blank` 这类不透明 base 退回根路径(此时相对地址本来也无法成立)。
|
|
13
|
+
*/
|
|
14
|
+
/** `baseURI` 所在目录的路径,去掉尾斜杠;根路径回空串。 */
|
|
15
|
+
export declare function mountPathOf(baseURI: string): string;
|
|
16
|
+
/** 一段相对段名 → 宿主 `/api` 前缀 + 挂载点。`path` 以 `/` 开头,与 `ROUTES` 的形状一致。 */
|
|
17
|
+
export declare function apiPathOf(path: string, baseURI: string): string;
|
|
18
|
+
/** 当前文档的取数路径;Node 侧(无 `document`)退回根路径。 */
|
|
19
|
+
export declare function apiPath(path: string): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Translate } from './copy.js';
|
|
2
|
+
interface MenuTarget {
|
|
3
|
+
readonly sessionId: string;
|
|
4
|
+
readonly displayTitle: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MenuActionLike {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
icon?: {
|
|
11
|
+
key: string;
|
|
12
|
+
};
|
|
13
|
+
danger?: boolean;
|
|
14
|
+
separatorBefore?: boolean;
|
|
15
|
+
afterContent?: boolean;
|
|
16
|
+
run: () => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export interface ObservableSnapshotLike<T> {
|
|
19
|
+
getSnapshot(): T;
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
}
|
|
22
|
+
interface SessionsStateLike {
|
|
23
|
+
byId?: Record<string, {
|
|
24
|
+
running?: boolean;
|
|
25
|
+
} | undefined>;
|
|
26
|
+
}
|
|
27
|
+
export interface SessionsServiceLike {
|
|
28
|
+
list?: ObservableSnapshotLike<SessionsStateLike>;
|
|
29
|
+
}
|
|
30
|
+
export interface MenuContributionLike {
|
|
31
|
+
id: string;
|
|
32
|
+
target: 'session';
|
|
33
|
+
order: number;
|
|
34
|
+
observe?: (target: MenuTarget) => readonly ObservableSnapshotLike<unknown>[];
|
|
35
|
+
resolve: (target: MenuTarget) => readonly MenuActionLike[] | Promise<readonly MenuActionLike[]>;
|
|
36
|
+
}
|
|
37
|
+
export interface ContextMenusLike {
|
|
38
|
+
register(contribution: MenuContributionLike): () => void;
|
|
39
|
+
}
|
|
40
|
+
export declare const ICON_BATCH = "xrn1997.session-delete.batch-select";
|
|
41
|
+
export declare const ICON_DELETE = "xrn1997.session-delete.delete";
|
|
42
|
+
/**
|
|
43
|
+
* 造 0.2.2+ 的会话菜单贡献。order 700 落在内置 Archive(600) 之后;两行都是 `afterContent`,
|
|
44
|
+
* 所以它们与 Archive 一起排掉 legacy 组件行,恢复「归档在多选/删除之前」。
|
|
45
|
+
*/
|
|
46
|
+
export declare function createSessionMenuContribution(t: Translate, sessionList?: ObservableSnapshotLike<SessionsStateLike>): MenuContributionLike;
|
|
47
|
+
export {};
|