@xrn1997/dsh-session-delete 0.3.0 → 0.3.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 +3 -3
- package/lib/client.js +1496 -985
- package/lib/index.js +821 -710
- package/lib/types/client/batch-bar.d.ts +2 -1
- package/lib/types/client/batch-confirm.d.ts +1 -1
- package/lib/types/client/busy-gate.d.ts +6 -0
- package/lib/types/client/copy.d.ts +2 -2
- package/lib/types/client/host-rows.d.ts +18 -3
- package/lib/types/client/main-view.d.ts +7 -2
- package/lib/types/client/orphan-maintenance.d.ts +27 -0
- package/lib/types/client/receipt.d.ts +33 -3
- package/lib/types/client/remote.d.ts +18 -22
- package/lib/types/client/store.d.ts +31 -0
- package/lib/types/client/trash-nav.d.ts +30 -0
- package/lib/types/client/undo-toast.d.ts +11 -3
- package/lib/types/client/use-trash-panel-controller.d.ts +7 -20
- package/lib/types/client/use-trash-query.d.ts +20 -9
- package/lib/types/client/write-seats.d.ts +52 -0
- package/lib/types/client/write-settlement.d.ts +72 -0
- package/lib/types/client/write.d.ts +51 -0
- package/lib/types/deps.d.ts +2 -2
- package/lib/types/index.d.ts +2 -1
- package/lib/types/ports/host-port.d.ts +4 -18
- package/lib/types/shared/wire.d.ts +7 -0
- package/lib/types/trash/detail.d.ts +5 -2
- package/lib/types/trash/groups.d.ts +17 -6
- package/lib/types/trash/ledger.d.ts +20 -0
- package/lib/types/trash/manifest.d.ts +2 -1
- package/lib/types/trash/reconcile.d.ts +4 -3
- package/lib/types/verbs/batch-carried.d.ts +20 -0
- package/lib/types/verbs/delete-session.d.ts +4 -3
- package/lib/types/verbs/errors.d.ts +2 -10
- package/lib/types/verbs/live-admission.d.ts +13 -0
- package/lib/types/verbs/member-journal.d.ts +22 -3
- package/lib/types/verbs/member-presence.d.ts +38 -6
- package/lib/types/verbs/move-group.d.ts +20 -0
- package/lib/types/verbs/orphan-subagents.d.ts +8 -3
- package/lib/types/verbs/permanent-removal.d.ts +7 -6
- package/lib/types/verbs/purge-trash.d.ts +14 -6
- package/lib/types/verbs/restore-session.d.ts +8 -6
- package/package.json +3 -2
- package/lib/types/client/write-commit.d.ts +0 -10
package/lib/index.js
CHANGED
|
@@ -72,7 +72,7 @@ function isDSHRemoteError(value) {
|
|
|
72
72
|
/** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见本文件。 */
|
|
73
73
|
const SESSION_DELETE_ERROR_DOMAIN = "sessiondelete";
|
|
74
74
|
/** 五个**动词码**(本文件那张表)。`io` 是唯一兜底。 */
|
|
75
|
-
const VERB_CODES
|
|
75
|
+
const VERB_CODES = [
|
|
76
76
|
"live",
|
|
77
77
|
"not-found",
|
|
78
78
|
"trash-empty",
|
|
@@ -89,6 +89,23 @@ const SESSION_DELETE_CODES = {
|
|
|
89
89
|
"not-found-route": "sessiondelete/not-found-route",
|
|
90
90
|
"bad-request": "sessiondelete/bad-request"
|
|
91
91
|
};
|
|
92
|
+
/** 失败码的前缀。读码只走下面两条,别在调用点各写一份字面量。 */
|
|
93
|
+
const CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
|
|
94
|
+
/** `error.code` 是不是本仓的失败形状(`sessiondelete/<后缀>`)。**只认前缀**:后缀在不在册是
|
|
95
|
+
* {@link verbCodeOf} 的问题。任何要「已经是本仓的码就原样对待」的调用点都问这一句,不在调用点
|
|
96
|
+
* 再写一份前缀字面量。 */
|
|
97
|
+
function isSessionDeleteCodeError(error) {
|
|
98
|
+
const raw = error?.code;
|
|
99
|
+
return typeof raw === "string" && raw.startsWith(CODE_PREFIX);
|
|
100
|
+
}
|
|
101
|
+
/** `error.code` 折回那个动词词:前缀与后缀两件都成立才交回(后缀要在 {@link VERB_CODES} 册上)。
|
|
102
|
+
* 路由自检码、别域的码、非字符串 code 一律 `undefined`。 */
|
|
103
|
+
function verbCodeOf(error) {
|
|
104
|
+
const raw = error?.code;
|
|
105
|
+
if (typeof raw !== "string" || !raw.startsWith(CODE_PREFIX)) return void 0;
|
|
106
|
+
const suffix = raw.slice(CODE_PREFIX.length);
|
|
107
|
+
return VERB_CODES.includes(suffix) ? suffix : void 0;
|
|
108
|
+
}
|
|
92
109
|
/** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
|
|
93
110
|
const TRASH_DIR_NAME = "session-trash";
|
|
94
111
|
//#endregion
|
|
@@ -185,7 +202,7 @@ function toRemoteFailure(error) {
|
|
|
185
202
|
};
|
|
186
203
|
}
|
|
187
204
|
const raw = error?.code;
|
|
188
|
-
if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !
|
|
205
|
+
if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !isSessionDeleteCodeError(error)) {
|
|
189
206
|
const details = detailsOf(error);
|
|
190
207
|
return {
|
|
191
208
|
code: raw,
|
|
@@ -193,8 +210,7 @@ function toRemoteFailure(error) {
|
|
|
193
210
|
...details === void 0 ? {} : { details }
|
|
194
211
|
};
|
|
195
212
|
}
|
|
196
|
-
const
|
|
197
|
-
const fromVerb = reason !== void 0 && VERB_CODES$1.includes(reason) ? reason : void 0;
|
|
213
|
+
const fromVerb = verbCodeOf(error);
|
|
198
214
|
const host = hostErrorOf(error);
|
|
199
215
|
const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
|
|
200
216
|
if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
|
|
@@ -210,19 +226,8 @@ function toRemoteFailure(error) {
|
|
|
210
226
|
}
|
|
211
227
|
//#endregion
|
|
212
228
|
//#region src/verbs/errors.ts
|
|
213
|
-
/**
|
|
214
|
-
*
|
|
215
|
-
* 这里只转出旧名,免得调用点全改一遍。 */
|
|
216
|
-
const VERB_CODES = VERB_CODES$1;
|
|
217
|
-
/** `sessiondelete/` 这个前缀——拆码用它,别在下面几处各写一遍字面量。 */
|
|
218
|
-
const VERB_CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
|
|
219
|
-
/** `error.code` 是不是本仓的动词码(`sessiondelete/<码>`)。**唯一判据**:`journalFailure` 与永久删除
|
|
220
|
-
* 执行器要"已经是动词码就原样抛"时都问这一句。同文件的 `withRollbackFailures` 还要码的后缀,所以它
|
|
221
|
-
* 就地 `slice` 一次——前缀仍只住 `VERB_CODE_PREFIX` 这一处,别在调用点另写一份字面量。 */
|
|
222
|
-
function isVerbCodeError(error) {
|
|
223
|
-
const raw = error?.code;
|
|
224
|
-
return typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX);
|
|
225
|
-
}
|
|
229
|
+
/** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
|
|
230
|
+
* 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
|
|
226
231
|
function verbError(code, message, cause, details) {
|
|
227
232
|
const e = new Error(message);
|
|
228
233
|
e.code = SESSION_DELETE_CODES[code];
|
|
@@ -252,9 +257,7 @@ function carriedRollbackFailures(error) {
|
|
|
252
257
|
* 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
|
|
253
258
|
* 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
|
|
254
259
|
function withRollbackFailures(error, failures) {
|
|
255
|
-
const
|
|
256
|
-
const reason = typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX) ? raw.slice(VERB_CODE_PREFIX.length) : void 0;
|
|
257
|
-
const code = reason !== void 0 && VERB_CODES.includes(reason) ? reason : "io";
|
|
260
|
+
const code = verbCodeOf(error) ?? "io";
|
|
258
261
|
const carried = carriedRollbackFailures(error);
|
|
259
262
|
const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
|
|
260
263
|
const base = error instanceof Error ? error.message : String(error);
|
|
@@ -655,41 +658,26 @@ function createHostPort(ctx) {
|
|
|
655
658
|
} catch {}
|
|
656
659
|
},
|
|
657
660
|
/**
|
|
658
|
-
* 通告客户端"
|
|
661
|
+
* 通告客户端"这组会话回来了":**拉一次**宿主自己的会话列表,按 id 逐条发 `api-session/added`。
|
|
659
662
|
*
|
|
660
663
|
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
661
664
|
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
662
665
|
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**。
|
|
663
666
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
* 100 条的批量用单条路就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
667
|
+
* **取数从 interface 收进了 implementation**:单条就是长度为 1 的批,批量恢复与删除组回滚走同一条路。
|
|
668
|
+
* 一次预取由这里保证——100 条一组的批量若按条拉表就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
667
669
|
*/
|
|
668
|
-
async announceRestored(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
try {
|
|
672
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
673
|
-
} catch {}
|
|
674
|
-
},
|
|
675
|
-
/** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
|
|
676
|
-
async restoredSummariesOf() {
|
|
677
|
-
const out = [];
|
|
670
|
+
async announceRestored(sessionIds) {
|
|
671
|
+
if (sessionIds.length === 0) return;
|
|
672
|
+
const summaries = [];
|
|
678
673
|
for (const item of await controllerItems(host)) {
|
|
679
674
|
const sessionId = item?.sessionId;
|
|
680
|
-
if (typeof sessionId === "string")
|
|
675
|
+
if (typeof sessionId === "string") summaries.push({
|
|
681
676
|
sessionId,
|
|
682
677
|
summary: item
|
|
683
678
|
});
|
|
684
679
|
}
|
|
685
|
-
|
|
686
|
-
},
|
|
687
|
-
/** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
|
|
688
|
-
* 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
|
|
689
|
-
async announceRestoredBatch(ids, known) {
|
|
690
|
-
if (ids.length === 0) return;
|
|
691
|
-
const summaries = known ?? await this.restoredSummariesOf();
|
|
692
|
-
for (const id of ids) {
|
|
680
|
+
for (const id of sessionIds) {
|
|
693
681
|
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
694
682
|
if (summary === void 0) continue;
|
|
695
683
|
try {
|
|
@@ -820,14 +808,63 @@ function groupOf(rows, id) {
|
|
|
820
808
|
return kidsOf(rows, id);
|
|
821
809
|
}
|
|
822
810
|
/**
|
|
823
|
-
*
|
|
811
|
+
* 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
|
|
824
812
|
*
|
|
825
|
-
*
|
|
813
|
+
* 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
|
|
826
814
|
* `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
|
|
827
|
-
*
|
|
815
|
+
* 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
|
|
816
|
+
*/
|
|
817
|
+
function claimOf(original, current, id) {
|
|
818
|
+
if (groupOf(original, id).length === 0) return { verdict: "absent" };
|
|
819
|
+
const group = groupOf(current, id);
|
|
820
|
+
return group.length === 0 ? { verdict: "carried" } : {
|
|
821
|
+
verdict: "fresh",
|
|
822
|
+
group
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/verbs/batch-carried.ts
|
|
827
|
+
/**
|
|
828
|
+
* **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
|
|
829
|
+
*
|
|
830
|
+
* 三个动作曾各有一套机制:delete 用动词回吐的 `movedSessionIds`,restore/purge 用
|
|
831
|
+
* `original / current` 两份快照 + 旧 boolean 谓词 + `shrink`。这里把判据收成 `claim(id)` + `take(id)`;
|
|
832
|
+
* 两种 id 域各一个 adapter:
|
|
833
|
+
* - entry 适配:restore / purge 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
|
|
834
|
+
* `trash/groups.ts`;
|
|
835
|
+
* - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
|
|
836
|
+
*
|
|
837
|
+
* entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id
|
|
838
|
+
* (因此只有 fresh / carried 两态)。这里只做 id 域适配,不另写组语义。
|
|
839
|
+
* 两域的 `claim` 形状不同:entry 交 `GroupClaim`(只有 fresh 带组),session 只交 verdict;
|
|
840
|
+
* 当前只读视图(`rows`)只挂在 entry 域上:只有 purge 要它——盘上后代要按清册里还在的 id 排除。
|
|
828
841
|
*/
|
|
829
|
-
|
|
830
|
-
|
|
842
|
+
/** 恢复 / 彻底删除:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
|
|
843
|
+
function createEntryCarried(original) {
|
|
844
|
+
let current = [...original];
|
|
845
|
+
return {
|
|
846
|
+
claim(id) {
|
|
847
|
+
return claimOf(original, current, id);
|
|
848
|
+
},
|
|
849
|
+
take(id) {
|
|
850
|
+
const gone = new Set(groupOf(current, id).map((row) => row.id));
|
|
851
|
+
if (gone.size === 0) return;
|
|
852
|
+
current = current.filter((row) => !gone.has(row.id));
|
|
853
|
+
},
|
|
854
|
+
rows: () => current
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
/** 删除:会话 id 域;`take` 只收这次真的搬走的成员(父 + 盘上的孩子)。 */
|
|
858
|
+
function createSessionCarried() {
|
|
859
|
+
const carried = /* @__PURE__ */ new Set();
|
|
860
|
+
return {
|
|
861
|
+
claim(id) {
|
|
862
|
+
return carried.has(bareSessionId(id)) ? { verdict: "carried" } : { verdict: "fresh" };
|
|
863
|
+
},
|
|
864
|
+
take(id) {
|
|
865
|
+
carried.add(bareSessionId(id));
|
|
866
|
+
}
|
|
867
|
+
};
|
|
831
868
|
}
|
|
832
869
|
//#endregion
|
|
833
870
|
//#region src/trash/entry.ts
|
|
@@ -890,420 +927,190 @@ function trashDirOf(entryId, home) {
|
|
|
890
927
|
return `${home}/${TRASH_DIR_NAME}/${entryId}`;
|
|
891
928
|
}
|
|
892
929
|
//#endregion
|
|
893
|
-
//#region src/
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
];
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
930
|
+
//#region src/verbs/member-journal.ts
|
|
931
|
+
/**
|
|
932
|
+
* 一次成员移动 = 一串**自带反向动作的步骤**(ADR-0002)。
|
|
933
|
+
*
|
|
934
|
+
* 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
|
|
935
|
+
* (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:迁移前全仓 `rollbackReason(` **22 处**、
|
|
936
|
+
* `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
|
|
937
|
+
*
|
|
938
|
+
* 这里独占四件事,三个动词各自只声明"这次要做哪几步、每步怎么退":
|
|
939
|
+
* ① **相反顺序**退化(最后做成的先退);
|
|
940
|
+
* ② 一步失败时把**已经做成的**那些退回去;
|
|
941
|
+
* ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
|
|
942
|
+
* ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
|
|
943
|
+
* 得先把会话重新藏回去)。
|
|
944
|
+
*
|
|
945
|
+
* 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
|
|
946
|
+
* 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
|
|
947
|
+
*/
|
|
948
|
+
/** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
|
|
949
|
+
function createJournal(steps) {
|
|
950
|
+
const done = [];
|
|
951
|
+
const undoAll = async (alsoFailing) => {
|
|
952
|
+
const failures = [];
|
|
953
|
+
const restored = /* @__PURE__ */ new Map();
|
|
954
|
+
const runUndo = async (step) => {
|
|
955
|
+
if (step.undo === void 0) return;
|
|
956
|
+
const result = await step.undo();
|
|
957
|
+
failures.push(...result.failures);
|
|
958
|
+
restored.set(step.key, result.restored);
|
|
959
|
+
};
|
|
960
|
+
if (alsoFailing?.undo !== void 0) await runUndo(alsoFailing);
|
|
961
|
+
for (const step of [...done].reverse()) await runUndo(step);
|
|
962
|
+
return {
|
|
963
|
+
failures,
|
|
964
|
+
restored: (key) => restored.get(key) ?? false
|
|
965
|
+
};
|
|
966
|
+
};
|
|
967
|
+
return {
|
|
968
|
+
async run() {
|
|
969
|
+
for (const step of steps) {
|
|
970
|
+
try {
|
|
971
|
+
await step.run();
|
|
972
|
+
} catch (error) {
|
|
973
|
+
const undo = await undoAll(step.undoOnFailure === true ? step : void 0);
|
|
974
|
+
const base = journalFailure(step, error);
|
|
975
|
+
throw undo.failures.length > 0 ? withRollbackFailures(base, undo.failures) : base;
|
|
976
|
+
}
|
|
977
|
+
done.push(step);
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
undo() {
|
|
981
|
+
return undoAll();
|
|
982
|
+
}
|
|
983
|
+
};
|
|
935
984
|
}
|
|
936
|
-
|
|
937
|
-
|
|
985
|
+
/** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
|
|
986
|
+
async function attempt(label, run) {
|
|
987
|
+
try {
|
|
988
|
+
await run();
|
|
989
|
+
return [];
|
|
990
|
+
} catch (error) {
|
|
991
|
+
return [rollbackReason(label, error)];
|
|
992
|
+
}
|
|
938
993
|
}
|
|
939
|
-
/**
|
|
940
|
-
function
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
994
|
+
/** 一步反向动作的完整结果:主写是否回到原样 + 失败串。附属写失败只进 `failures`,不推翻 `restored`。 */
|
|
995
|
+
async function stepUndo(label, run) {
|
|
996
|
+
const failures = await attempt(label, run);
|
|
997
|
+
return {
|
|
998
|
+
restored: failures.length === 0,
|
|
999
|
+
failures
|
|
1000
|
+
};
|
|
944
1001
|
}
|
|
945
1002
|
/**
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
1003
|
+
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
1004
|
+
* `details.rollbackFailures`,绝不让原错误独走(2026-10-19 `f2a7757`;那些失败句子的合并与措辞住
|
|
1005
|
+
* `src/verbs/errors.ts`,这里只是唯一的调用方)。删除与恢复两个按组走的动词共用这一处,
|
|
1006
|
+
* "相反顺序 + 失败合并"不再各写一遍。
|
|
949
1007
|
*/
|
|
950
|
-
function
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const descriptor = raw[at + 4];
|
|
956
|
-
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
957
|
-
const fcsFlag = descriptor & 3;
|
|
958
|
-
const singleSegment = descriptor >> 2 & 1;
|
|
959
|
-
const didFlag = descriptor >> 6 & 3;
|
|
960
|
-
let cursor = at + AFTER_DESCRIPTOR;
|
|
961
|
-
if (!singleSegment) cursor += 1;
|
|
962
|
-
cursor += DID_SIZES[didFlag];
|
|
963
|
-
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
964
|
-
for (;;) {
|
|
965
|
-
const blockHeader = readUint24LE(raw, cursor);
|
|
966
|
-
cursor += BLOCK_HEADER_BYTES;
|
|
967
|
-
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
968
|
-
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
969
|
-
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
970
|
-
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
971
|
-
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
972
|
-
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
973
|
-
if (blockHeader & BLOCK_LAST) break;
|
|
974
|
-
}
|
|
975
|
-
cursor = withFrameTail(raw, cursor);
|
|
976
|
-
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
977
|
-
ranges.push({
|
|
978
|
-
start: at,
|
|
979
|
-
end: cursor
|
|
980
|
-
});
|
|
981
|
-
at = cursor;
|
|
1008
|
+
async function rollbackJournals(journals, error) {
|
|
1009
|
+
const failures = [];
|
|
1010
|
+
for (const journal of [...journals].reverse()) {
|
|
1011
|
+
const undo = await journal.undo();
|
|
1012
|
+
failures.push(...undo.failures);
|
|
982
1013
|
}
|
|
983
|
-
|
|
1014
|
+
if (failures.length > 0) throw withRollbackFailures(error, failures);
|
|
1015
|
+
throw error;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
|
|
1019
|
+
* 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
|
|
1020
|
+
* 换一种拼法会让上屏原文跟着变。
|
|
1021
|
+
*/
|
|
1022
|
+
function journalFailure(step, error) {
|
|
1023
|
+
if (isSessionDeleteCodeError(error)) return error;
|
|
1024
|
+
return verbError("io", `${step.label}:${String(error)}`, error);
|
|
984
1025
|
}
|
|
985
1026
|
//#endregion
|
|
986
|
-
//#region src/
|
|
1027
|
+
//#region src/verbs/live-admission.ts
|
|
1028
|
+
/** 逐个问,任一在跑就抛 `live`。名单的挑选(哪些成员算这一组)归调用方,这里只按传入顺序问完。 */
|
|
1029
|
+
async function assertNoLive(host, sessionIds) {
|
|
1030
|
+
for (const sessionId of sessionIds) if (await host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/verbs/member-presence.ts
|
|
1034
|
+
function createMemberPresence(host) {
|
|
1035
|
+
return {
|
|
1036
|
+
async memberGone(sessionId, facts = {}) {
|
|
1037
|
+
if (facts.backInTrash === false) return;
|
|
1038
|
+
if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
|
|
1039
|
+
},
|
|
1040
|
+
async memberBack(sessionId, facts) {
|
|
1041
|
+
if (!facts.diskBack || !facts.workspaceBack) return;
|
|
1042
|
+
await host.announceRestored([sessionId]);
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
987
1046
|
/**
|
|
988
|
-
*
|
|
989
|
-
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
1047
|
+
* 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场、undo 之后按事实归位或离场。
|
|
990
1048
|
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
1049
|
+
* 为什么是个工厂:删除与恢复此前各手拼一个匿名 journal,形状逐字相同(`journal.run` 加上
|
|
1050
|
+
* `presence.memberGone`/`journal.undo` 加上 `presence.memberBack`),只差键 → 事实的映射。
|
|
1051
|
+
* 两处各写一遍,就是「补在哪一拍」这条顺序又有第二个家。
|
|
993
1052
|
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
997
|
-
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
998
|
-
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
999
|
-
* 本机实测**没有一帧切在行中间**(连最后一帧都整行收尾;绝对帧数随盘上会话数摆动,那份读数
|
|
1000
|
-
* 不在这里钉,来历留在 git 历史),
|
|
1001
|
-
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
1002
|
-
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
1003
|
-
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
1004
|
-
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
1005
|
-
*/
|
|
1006
|
-
const NEWLINE = 10;
|
|
1007
|
-
/**
|
|
1008
|
-
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
1009
|
-
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
1010
|
-
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
1053
|
+
* 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表仍归 `createMemberPresence`;
|
|
1054
|
+
* 这里只把事实原样递过去,不替它判断。
|
|
1011
1055
|
*/
|
|
1012
|
-
|
|
1056
|
+
function withPresenceTail(presence, journal, tail) {
|
|
1057
|
+
const announce = async (undo) => {
|
|
1058
|
+
const fact = tail.onUndone(undo);
|
|
1059
|
+
if (fact.kind === "leave") {
|
|
1060
|
+
await presence.memberGone(tail.sessionId, { backInTrash: fact.backInTrash });
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
await presence.memberBack(tail.sessionId, {
|
|
1064
|
+
diskBack: fact.diskBack,
|
|
1065
|
+
workspaceBack: fact.workspaceBack
|
|
1066
|
+
});
|
|
1067
|
+
};
|
|
1068
|
+
return {
|
|
1069
|
+
async run() {
|
|
1070
|
+
await journal.run();
|
|
1071
|
+
if (tail.onDone === "leave") await presence.memberGone(tail.sessionId);
|
|
1072
|
+
},
|
|
1073
|
+
async undo() {
|
|
1074
|
+
const undo = await journal.undo();
|
|
1075
|
+
await announce(undo);
|
|
1076
|
+
return undo;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region src/verbs/move-group.ts
|
|
1013
1082
|
/**
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
1083
|
+
* 组级成员移动:组根先跑(拿到 `undefined`),跟随者按序跑并拿到根结果;任一失败就把**已经做成**的
|
|
1084
|
+
* 按相反顺序退回。
|
|
1017
1085
|
*
|
|
1018
|
-
*
|
|
1086
|
+
* 这里拥有顺序、整组回滚与**组根结果的交回**:根先跑,跟随者按序拿到根结果;调用方直接拿
|
|
1087
|
+
* 返回的根结果,不按位置索引结果数组。跟随者自己的结果不向上交回——当前调用方不需要。
|
|
1088
|
+
* 单成员的步骤与反向动作仍归 `member-journal.ts`;这条归属的裁决见 ADR-0002。
|
|
1019
1089
|
*/
|
|
1020
|
-
function
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1090
|
+
async function moveGroup({ root, followers, move }) {
|
|
1091
|
+
const journals = [];
|
|
1092
|
+
const run = async (member, rootResult) => {
|
|
1093
|
+
const prepared = await move(member, rootResult);
|
|
1094
|
+
await prepared.journal.run();
|
|
1095
|
+
journals.push(prepared.journal);
|
|
1096
|
+
return prepared.result;
|
|
1097
|
+
};
|
|
1098
|
+
try {
|
|
1099
|
+
const rootResult = await run(root, void 0);
|
|
1100
|
+
for (const follower of followers) await run(follower, rootResult);
|
|
1101
|
+
return rootResult;
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
return await rollbackJournals(journals, error);
|
|
1031
1104
|
}
|
|
1032
|
-
return best;
|
|
1033
1105
|
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/verbs/delete-session.ts
|
|
1034
1108
|
/**
|
|
1035
|
-
*
|
|
1109
|
+
* 删除 = 移入回收站,**按组走**:用户点掉一条父会话时,宿主的客户端把 `origin:"subagent"` 的孩子
|
|
1110
|
+
* 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
|
|
1111
|
+
* 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
|
|
1036
1112
|
*
|
|
1037
|
-
*
|
|
1038
|
-
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
1039
|
-
*/
|
|
1040
|
-
function walkSessionLog(raw, onRecord) {
|
|
1041
|
-
const stats = {
|
|
1042
|
-
frames: 0,
|
|
1043
|
-
records: 0,
|
|
1044
|
-
unparsedLines: 0,
|
|
1045
|
-
truncatedTailBytes: 0
|
|
1046
|
-
};
|
|
1047
|
-
let stopped = false;
|
|
1048
|
-
const feed = (line) => {
|
|
1049
|
-
if (line === "") return;
|
|
1050
|
-
let parsed;
|
|
1051
|
-
try {
|
|
1052
|
-
parsed = JSON.parse(line);
|
|
1053
|
-
} catch {
|
|
1054
|
-
stats.unparsedLines += 1;
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
stats.records += 1;
|
|
1058
|
-
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
1059
|
-
};
|
|
1060
|
-
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
1061
|
-
let carry = Buffer.alloc(0);
|
|
1062
|
-
for (const { start, end } of frameRanges(raw)) {
|
|
1063
|
-
if (stopped) break;
|
|
1064
|
-
stats.frames += 1;
|
|
1065
|
-
let plain;
|
|
1066
|
-
try {
|
|
1067
|
-
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
1070
|
-
}
|
|
1071
|
-
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
1072
|
-
const cut = merged.lastIndexOf(NEWLINE);
|
|
1073
|
-
if (cut === -1) {
|
|
1074
|
-
carry = merged;
|
|
1075
|
-
continue;
|
|
1076
|
-
}
|
|
1077
|
-
carry = merged.subarray(cut + 1);
|
|
1078
|
-
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
1079
|
-
feed(line);
|
|
1080
|
-
if (stopped) break;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
stats.truncatedTailBytes = carry.length;
|
|
1084
|
-
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
1085
|
-
return stats;
|
|
1086
|
-
}
|
|
1087
|
-
/**
|
|
1088
|
-
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
1089
|
-
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
1090
|
-
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
1091
|
-
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
1092
|
-
*/
|
|
1093
|
-
function readSessionHeader(raw) {
|
|
1094
|
-
let header;
|
|
1095
|
-
walkSessionLog(raw, (record) => {
|
|
1096
|
-
if (record.type !== "session") return;
|
|
1097
|
-
header = record;
|
|
1098
|
-
return false;
|
|
1099
|
-
});
|
|
1100
|
-
return header;
|
|
1101
|
-
}
|
|
1102
|
-
//#endregion
|
|
1103
|
-
//#region src/trash/reconcile.ts
|
|
1104
|
-
/**
|
|
1105
|
-
* 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(CONTEXT.md 的「对账」条)。
|
|
1106
|
-
*
|
|
1107
|
-
* 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
|
|
1108
|
-
* `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
|
|
1109
|
-
*
|
|
1110
|
-
* 五条口径(都不是口味):
|
|
1111
|
-
* ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
|
|
1112
|
-
* 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
|
|
1113
|
-
* (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
|
|
1114
|
-
* ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
|
|
1115
|
-
* (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
|
|
1116
|
-
* ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
|
|
1117
|
-
* `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
|
|
1118
|
-
* `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
|
|
1119
|
-
* 落在「未分组」)、`wasArchived: false`(文件侧没有归档事实;旧条目恢复走迁移支)、`title: ''`(详情有日志标题兜底)
|
|
1120
|
-
* ——这是重建的固有代价,不是可以"再读读看"的东西。
|
|
1121
|
-
* ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
|
|
1122
|
-
* 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
|
|
1123
|
-
* ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
|
|
1124
|
-
*
|
|
1125
|
-
* 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
|
|
1126
|
-
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
1127
|
-
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
1128
|
-
*/
|
|
1129
|
-
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
|
|
1130
|
-
* 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
|
|
1131
|
-
function isEntryId(name) {
|
|
1132
|
-
try {
|
|
1133
|
-
return isSinglePathSegment(parseEntryId(name).sessionDirName);
|
|
1134
|
-
} catch {
|
|
1135
|
-
return false;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
/**
|
|
1139
|
-
* 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
|
|
1140
|
-
* 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
|
|
1141
|
-
*
|
|
1142
|
-
* `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
|
|
1143
|
-
* header 缺席这个字段才回落到目录名里那段原目录名。
|
|
1144
|
-
*/
|
|
1145
|
-
async function buildEntry(deps, id) {
|
|
1146
|
-
const dir = trashDirOf(id, deps.home);
|
|
1147
|
-
let logName;
|
|
1148
|
-
try {
|
|
1149
|
-
logName = pickSessionLog(await deps.fs.listFiles(dir));
|
|
1150
|
-
} catch {
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
if (logName === void 0) return void 0;
|
|
1154
|
-
let raw;
|
|
1155
|
-
try {
|
|
1156
|
-
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
1157
|
-
} catch {
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
let header;
|
|
1161
|
-
try {
|
|
1162
|
-
header = readSessionHeader(raw);
|
|
1163
|
-
} catch {
|
|
1164
|
-
return;
|
|
1165
|
-
}
|
|
1166
|
-
if (header === void 0) return void 0;
|
|
1167
|
-
let sizeBytes;
|
|
1168
|
-
try {
|
|
1169
|
-
sizeBytes = await deps.fs.sizeOfDir(dir);
|
|
1170
|
-
} catch {
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
const { deletedAt, sessionDirName } = parseEntryId(id);
|
|
1174
|
-
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
1175
|
-
return {
|
|
1176
|
-
id,
|
|
1177
|
-
sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
|
|
1178
|
-
projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
1179
|
-
workspaceId: "",
|
|
1180
|
-
wasArchived: false,
|
|
1181
|
-
deletedAt,
|
|
1182
|
-
title: "",
|
|
1183
|
-
sizeBytes
|
|
1184
|
-
};
|
|
1185
|
-
}
|
|
1186
|
-
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
1187
|
-
async function reconcileTrash(deps) {
|
|
1188
|
-
const rows = await deps.manifest.list();
|
|
1189
|
-
const root = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
1190
|
-
if (!await deps.fs.exists(root)) return rows;
|
|
1191
|
-
const dirIds = /* @__PURE__ */ new Set();
|
|
1192
|
-
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
1193
|
-
const rowIds = new Set(rows.map((row) => row.id));
|
|
1194
|
-
const drops = rows.filter((row) => !dirIds.has(row.id));
|
|
1195
|
-
const rebuilds = [];
|
|
1196
|
-
for (const id of dirIds) {
|
|
1197
|
-
if (rowIds.has(id)) continue;
|
|
1198
|
-
const entry = await buildEntry(deps, id);
|
|
1199
|
-
if (entry !== void 0) rebuilds.push(entry);
|
|
1200
|
-
}
|
|
1201
|
-
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
1202
|
-
for (const row of drops) await deps.manifest.remove(row.id);
|
|
1203
|
-
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
1204
|
-
return await deps.manifest.list();
|
|
1205
|
-
}
|
|
1206
|
-
//#endregion
|
|
1207
|
-
//#region src/verbs/member-journal.ts
|
|
1208
|
-
/**
|
|
1209
|
-
* 一次成员移动 = 一串**自带反向动作的步骤**(ADR-0002)。
|
|
1210
|
-
*
|
|
1211
|
-
* 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
|
|
1212
|
-
* (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:迁移前全仓 `rollbackReason(` **22 处**、
|
|
1213
|
-
* `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
|
|
1214
|
-
*
|
|
1215
|
-
* 这里独占四件事,三个动词各自只声明"这次要做哪几步、每步怎么退":
|
|
1216
|
-
* ① **相反顺序**退化(最后做成的先退);
|
|
1217
|
-
* ② 一步失败时把**已经做成的**那些退回去;
|
|
1218
|
-
* ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
|
|
1219
|
-
* ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
|
|
1220
|
-
* 得先把会话重新藏回去)。
|
|
1221
|
-
*
|
|
1222
|
-
* 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
|
|
1223
|
-
* 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
|
|
1224
|
-
*/
|
|
1225
|
-
/** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
|
|
1226
|
-
function createJournal(steps) {
|
|
1227
|
-
const done = [];
|
|
1228
|
-
const undoAll = async (alsoFailing) => {
|
|
1229
|
-
const failures = [];
|
|
1230
|
-
if (alsoFailing?.undo !== void 0) failures.push(...await alsoFailing.undo());
|
|
1231
|
-
for (const step of [...done].reverse()) {
|
|
1232
|
-
if (step.undo === void 0) continue;
|
|
1233
|
-
failures.push(...await step.undo());
|
|
1234
|
-
}
|
|
1235
|
-
return failures;
|
|
1236
|
-
};
|
|
1237
|
-
return {
|
|
1238
|
-
async run() {
|
|
1239
|
-
for (const step of steps) {
|
|
1240
|
-
try {
|
|
1241
|
-
await step.run();
|
|
1242
|
-
} catch (error) {
|
|
1243
|
-
const failures = await undoAll(step.undoOnFailure === true ? step : void 0);
|
|
1244
|
-
const base = journalFailure(step, error);
|
|
1245
|
-
throw failures.length > 0 ? withRollbackFailures(base, failures) : base;
|
|
1246
|
-
}
|
|
1247
|
-
done.push(step);
|
|
1248
|
-
}
|
|
1249
|
-
},
|
|
1250
|
-
undo() {
|
|
1251
|
-
return undoAll();
|
|
1252
|
-
}
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
/** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
|
|
1256
|
-
async function attempt(label, run) {
|
|
1257
|
-
try {
|
|
1258
|
-
await run();
|
|
1259
|
-
return [];
|
|
1260
|
-
} catch (error) {
|
|
1261
|
-
return [rollbackReason(label, error)];
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
/**
|
|
1265
|
-
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
1266
|
-
* `details.rollbackFailures`,绝不让原错误独走(2026-10-19 `f2a7757`;那些失败句子的合并与措辞住
|
|
1267
|
-
* `src/verbs/errors.ts`,这里只是唯一的调用方)。删除与恢复两个按组走的动词共用这一处,
|
|
1268
|
-
* "相反顺序 + 失败合并"不再各写一遍。
|
|
1269
|
-
*/
|
|
1270
|
-
async function rollbackJournals(journals, error) {
|
|
1271
|
-
const failures = [];
|
|
1272
|
-
for (const journal of [...journals].reverse()) failures.push(...await journal.undo());
|
|
1273
|
-
if (failures.length > 0) throw withRollbackFailures(error, failures);
|
|
1274
|
-
throw error;
|
|
1275
|
-
}
|
|
1276
|
-
/**
|
|
1277
|
-
* 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
|
|
1278
|
-
* 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
|
|
1279
|
-
* 换一种拼法会让上屏原文跟着变。
|
|
1280
|
-
*/
|
|
1281
|
-
function journalFailure(step, error) {
|
|
1282
|
-
if (isVerbCodeError(error)) return error;
|
|
1283
|
-
return verbError("io", `${step.label}:${String(error)}`, error);
|
|
1284
|
-
}
|
|
1285
|
-
//#endregion
|
|
1286
|
-
//#region src/verbs/member-presence.ts
|
|
1287
|
-
function createMemberPresence(host) {
|
|
1288
|
-
return {
|
|
1289
|
-
async memberGone(sessionId, facts = {}) {
|
|
1290
|
-
if (facts.backInTrash === false) return;
|
|
1291
|
-
if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
|
|
1292
|
-
},
|
|
1293
|
-
async memberBack(sessionId, facts) {
|
|
1294
|
-
if (!facts.diskBack || !facts.workspaceBack) return;
|
|
1295
|
-
await host.announceRestored(sessionId);
|
|
1296
|
-
}
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
|
-
//#endregion
|
|
1300
|
-
//#region src/verbs/delete-session.ts
|
|
1301
|
-
/**
|
|
1302
|
-
* 删除 = 移入回收站,**按组走**:用户点掉一条父会话时,宿主的客户端把 `origin:"subagent"` 的孩子
|
|
1303
|
-
* 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
|
|
1304
|
-
* 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
|
|
1305
|
-
*
|
|
1306
|
-
* 三段,顺序承重:
|
|
1113
|
+
* 三段,顺序承重(成员循环与整组回滚的实现住 `verbs/move-group.ts`):
|
|
1307
1114
|
* ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
|
|
1308
1115
|
* 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
|
|
1309
1116
|
* ② **逐个搬**:每个成员是一份 `journal`(`verbs/member-journal.ts`),搬不动时 journal 自己把
|
|
@@ -1311,7 +1118,8 @@ function createMemberPresence(host) {
|
|
|
1311
1118
|
* ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
|
|
1312
1119
|
* 账本跟着挂),失败照抛——不留"半组在回收站、半组在原地"的世界。
|
|
1313
1120
|
*
|
|
1314
|
-
* interface 只有**一个 id
|
|
1121
|
+
* interface 只有**一个 id**:批量的性质不在这里(同批已带走集归 `verbs/batch-carried.ts`,
|
|
1122
|
+
* 组语义归 `trash/groups.ts`)。
|
|
1315
1123
|
* 结果带回这次真的搬走的整组(`movedSessionIds`)——它是动词交回的内部数据,不再靠回调借用动词的手。
|
|
1316
1124
|
*/
|
|
1317
1125
|
function deleteSession(deps) {
|
|
@@ -1321,40 +1129,32 @@ function deleteSession(deps) {
|
|
|
1321
1129
|
const kids = read.available ? read.value[sessionId] ?? [] : [];
|
|
1322
1130
|
const parentProjectDir = await deps.host.projectDirOf(sessionId);
|
|
1323
1131
|
if (parentProjectDir === void 0) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
|
|
1324
|
-
|
|
1325
|
-
sessionId,
|
|
1326
|
-
projectDir: parentProjectDir
|
|
1327
|
-
}, ...kids.map((k) => ({
|
|
1328
|
-
sessionId: k.sessionId,
|
|
1329
|
-
projectDir: k.projectDir
|
|
1330
|
-
}))];
|
|
1331
|
-
for (const member of candidates) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
|
|
1132
|
+
await assertNoLive(deps.host, [sessionId, ...kids.map((kid) => kid.sessionId)]);
|
|
1332
1133
|
const parentDir = sessionDirOf(parentProjectDir, sessionId, deps.home);
|
|
1333
1134
|
if (!await deps.fs.exists(parentDir)) throw verbError("not-found", `会话目录不存在:${parentDir}`);
|
|
1334
|
-
const
|
|
1135
|
+
const parent = {
|
|
1335
1136
|
sessionId,
|
|
1336
1137
|
projectDir: parentProjectDir
|
|
1337
|
-
}
|
|
1138
|
+
};
|
|
1139
|
+
const members = [parent];
|
|
1338
1140
|
for (const kid of kids) if (await deps.fs.exists(sessionDirOf(kid.projectDir, kid.sessionId, deps.home))) members.push({
|
|
1339
1141
|
sessionId: kid.sessionId,
|
|
1340
1142
|
projectDir: kid.projectDir
|
|
1341
1143
|
});
|
|
1342
1144
|
const deletedAt = deps.now();
|
|
1343
|
-
const done = [];
|
|
1344
|
-
let parentEntry;
|
|
1345
|
-
try {
|
|
1346
|
-
for (const member of members) {
|
|
1347
|
-
const isParent = member.sessionId === sessionId;
|
|
1348
|
-
const move = await memberMove(deps, presence, member, deletedAt, isParent ? void 0 : parentEntry.id);
|
|
1349
|
-
if (isParent) parentEntry = move.entry;
|
|
1350
|
-
await move.journal.run();
|
|
1351
|
-
done.push(move.journal);
|
|
1352
|
-
}
|
|
1353
|
-
} catch (error) {
|
|
1354
|
-
await rollbackJournals(done, error);
|
|
1355
|
-
}
|
|
1356
1145
|
return {
|
|
1357
|
-
entry:
|
|
1146
|
+
entry: await moveGroup({
|
|
1147
|
+
root: parent,
|
|
1148
|
+
followers: members.slice(1),
|
|
1149
|
+
move: async (member, rootResult) => {
|
|
1150
|
+
const parentEntryId = rootResult?.id;
|
|
1151
|
+
const move = await memberMove(deps, presence, member, deletedAt, parentEntryId);
|
|
1152
|
+
return {
|
|
1153
|
+
journal: move.journal,
|
|
1154
|
+
result: move.entry
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
}),
|
|
1358
1158
|
movedSessionIds: members.map((member) => member.sessionId)
|
|
1359
1159
|
};
|
|
1360
1160
|
};
|
|
@@ -1391,72 +1191,65 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
|
1391
1191
|
};
|
|
1392
1192
|
/** 目录真的搬进回收站了吗——决定那一步的反向动作有没有东西可退。 */
|
|
1393
1193
|
let moved = false;
|
|
1394
|
-
/** 回滚的两件写是否都真正退回原样:目录、账本。两个事实交给成员在场 module 判「归位」通告。
|
|
1395
|
-
* 删除不碰归档集 ⇒ 可见性不是这次删除改过的事实,不进真值表。 */
|
|
1396
|
-
let movedBack = false;
|
|
1397
|
-
let workspaceBack = workspaceId === void 0;
|
|
1398
|
-
const journal = createJournal([
|
|
1399
|
-
{
|
|
1400
|
-
label: "读取会话现场失败",
|
|
1401
|
-
run: async () => {
|
|
1402
|
-
entry.title = await deps.host.titleOf(sessionId);
|
|
1403
|
-
entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
1404
|
-
}
|
|
1405
|
-
},
|
|
1406
|
-
...workspaceId === void 0 ? [] : [{
|
|
1407
|
-
label: "摘账本失败",
|
|
1408
|
-
run: async () => {
|
|
1409
|
-
await deps.host.detach(sessionId, workspaceId);
|
|
1410
|
-
},
|
|
1411
|
-
undo: async () => {
|
|
1412
|
-
return await attempt("账本挂回失败", async () => {
|
|
1413
|
-
await deps.host.attach(sessionId, workspaceId);
|
|
1414
|
-
workspaceBack = true;
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1417
|
-
}],
|
|
1418
|
-
{
|
|
1419
|
-
label: "移动会话目录失败",
|
|
1420
|
-
undoOnFailure: true,
|
|
1421
|
-
run: async () => {
|
|
1422
|
-
await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
|
|
1423
|
-
await deps.fs.rename(sessionDir, trashDir);
|
|
1424
|
-
moved = true;
|
|
1425
|
-
try {
|
|
1426
|
-
await deps.manifest.add(entry);
|
|
1427
|
-
} catch (error) {
|
|
1428
|
-
throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
|
|
1429
|
-
}
|
|
1430
|
-
},
|
|
1431
|
-
undo: async () => {
|
|
1432
|
-
if (!moved) return [];
|
|
1433
|
-
const failures = [...await attempt("目录搬回失败", async () => {
|
|
1434
|
-
await deps.fs.rename(trashDir, sessionDir);
|
|
1435
|
-
movedBack = true;
|
|
1436
|
-
})];
|
|
1437
|
-
if (failures.length === 0) failures.push(...await attempt("清册行摘除失败", () => deps.manifest.remove(entry.id)));
|
|
1438
|
-
return failures;
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
]);
|
|
1442
1194
|
return {
|
|
1443
1195
|
entry,
|
|
1444
|
-
journal:
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1196
|
+
journal: withPresenceTail(presence, createJournal([
|
|
1197
|
+
{
|
|
1198
|
+
key: "read",
|
|
1199
|
+
label: "读取会话现场失败",
|
|
1200
|
+
run: async () => {
|
|
1201
|
+
entry.title = await deps.host.titleOf(sessionId);
|
|
1202
|
+
entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
1203
|
+
}
|
|
1448
1204
|
},
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1205
|
+
...workspaceId === void 0 ? [] : [{
|
|
1206
|
+
key: "workspace",
|
|
1207
|
+
label: "摘账本失败",
|
|
1208
|
+
run: async () => {
|
|
1209
|
+
await deps.host.detach(sessionId, workspaceId);
|
|
1210
|
+
},
|
|
1211
|
+
undo: () => stepUndo("账本挂回失败", () => deps.host.attach(sessionId, workspaceId))
|
|
1212
|
+
}],
|
|
1213
|
+
{
|
|
1214
|
+
key: "dir",
|
|
1215
|
+
label: "移动会话目录失败",
|
|
1216
|
+
undoOnFailure: true,
|
|
1217
|
+
run: async () => {
|
|
1218
|
+
await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
|
|
1219
|
+
await deps.fs.rename(sessionDir, trashDir);
|
|
1220
|
+
moved = true;
|
|
1221
|
+
try {
|
|
1222
|
+
await deps.ledger.add(entry);
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
undo: async () => {
|
|
1228
|
+
if (!moved) return {
|
|
1229
|
+
restored: true,
|
|
1230
|
+
failures: []
|
|
1231
|
+
};
|
|
1232
|
+
const directory = await stepUndo("目录搬回失败", () => deps.fs.rename(trashDir, sessionDir));
|
|
1233
|
+
const failures = [...directory.failures];
|
|
1234
|
+
if (directory.restored) failures.push(...await attempt("清册行摘除失败", () => deps.ledger.remove(entry.id)));
|
|
1235
|
+
return {
|
|
1236
|
+
restored: directory.restored,
|
|
1237
|
+
failures
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1458
1240
|
}
|
|
1459
|
-
|
|
1241
|
+
]), {
|
|
1242
|
+
sessionId,
|
|
1243
|
+
onDone: "leave",
|
|
1244
|
+
/** 组级回滚:先按相反顺序退步骤,再判「目录与账本两件写都回去了吗」。
|
|
1245
|
+
* 这一行此前已经收过 `api-session/removed`;不发「回来了」它就会在客户端消失到重连为止。
|
|
1246
|
+
* 「退没退成」由 journal 的逐步结果交出来,这里只读键,不自己累计布尔。 */
|
|
1247
|
+
onUndone: (undo) => ({
|
|
1248
|
+
kind: "return",
|
|
1249
|
+
diskBack: undo.restored("dir"),
|
|
1250
|
+
workspaceBack: workspaceId === void 0 ? true : undo.restored("workspace")
|
|
1251
|
+
})
|
|
1252
|
+
})
|
|
1460
1253
|
};
|
|
1461
1254
|
}
|
|
1462
1255
|
//#endregion
|
|
@@ -1593,28 +1386,24 @@ async function planVisibleTrash(deps, rows, groups) {
|
|
|
1593
1386
|
};
|
|
1594
1387
|
}));
|
|
1595
1388
|
}
|
|
1596
|
-
/** 活体准入:逐个问,任一在跑就抛 `live`。**计划函数就是执行前的闸**——调用方拿到计划才允许写,
|
|
1597
|
-
* 所以这里必须发生在任何写之前(`admission:'none'` 的调用方自己承担这个风险,不进来)。 */
|
|
1598
|
-
async function assertNoLive(deps, sessionIds) {
|
|
1599
|
-
for (const sessionId of sessionIds) if (await deps.host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
|
|
1600
|
-
}
|
|
1601
1389
|
function totalBytes(items) {
|
|
1602
1390
|
return items.reduce((total, item) => total + item.bytes, 0);
|
|
1603
1391
|
}
|
|
1604
1392
|
/**
|
|
1605
1393
|
* purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
|
|
1394
|
+
* **计划函数就是执行前的闸**——调用方拿到计划才允许写,所以准入必须发生在任何写之前。
|
|
1606
1395
|
*
|
|
1607
1396
|
* - `trash` 按 targets 原序逐个实测(目录缺席的行 `present:false` / 0 字节,但清册行仍要摘)。
|
|
1608
1397
|
* - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
|
|
1609
1398
|
* - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节);`unarchive` = targets + 这些成员:
|
|
1610
1399
|
* 只摘这一次真要碰的 id。
|
|
1611
|
-
* -
|
|
1612
|
-
*
|
|
1400
|
+
* - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
|
|
1401
|
+
* 零副作用。没有跳过它的开关——计划就是执行前的闸(ADR-0006)。
|
|
1613
1402
|
* - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
|
|
1614
1403
|
*/
|
|
1615
|
-
async function planTrashRemoval(deps, rows, targets
|
|
1404
|
+
async function planTrashRemoval(deps, rows, targets) {
|
|
1616
1405
|
const candidates = (await selectStrandedMembers(deps.host, rows, targets.map((entry) => entry.sessionId))).flat();
|
|
1617
|
-
|
|
1406
|
+
await assertNoLive(deps.host, [...targets.map((entry) => entry.sessionId), ...candidates.map((member) => member.sessionId)]);
|
|
1618
1407
|
const trash = await Promise.all(targets.map((entry) => measureTrashRow(deps, entry)));
|
|
1619
1408
|
const members = await planMemberRemovals(deps, candidates);
|
|
1620
1409
|
return {
|
|
@@ -1635,7 +1424,7 @@ async function planTrashRemoval(deps, rows, targets, options) {
|
|
|
1635
1424
|
* - 回执由 `kind:'orphan'` 派生:删目录阶段失败时执行器要交回 `{removed, freedBytes, failedSessionId}`。
|
|
1636
1425
|
*/
|
|
1637
1426
|
async function planOrphanRemoval(deps, members) {
|
|
1638
|
-
await assertNoLive(deps, members.map((member) => member.sessionId));
|
|
1427
|
+
await assertNoLive(deps.host, members.map((member) => member.sessionId));
|
|
1639
1428
|
const present = await planMemberRemovals(deps, members);
|
|
1640
1429
|
return {
|
|
1641
1430
|
kind: "orphan",
|
|
@@ -1646,7 +1435,8 @@ async function planOrphanRemoval(deps, members) {
|
|
|
1646
1435
|
};
|
|
1647
1436
|
}
|
|
1648
1437
|
/**
|
|
1649
|
-
*
|
|
1438
|
+
* 按计划执行一次永久删除:只写、不再量、不再判活体(计划就是执行前的闸)。回执的字节念计划
|
|
1439
|
+
* (`plan.bytes`),执行期不再累加一遍。
|
|
1650
1440
|
*
|
|
1651
1441
|
* 顺序(与迁移前的 purge / orphan-clean 一致):整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
|
|
1652
1442
|
* (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)。目录删掉就没有
|
|
@@ -1662,15 +1452,16 @@ async function planOrphanRemoval(deps, members) {
|
|
|
1662
1452
|
async function executeRemoval(deps, plan) {
|
|
1663
1453
|
const presence = createMemberPresence(deps.host);
|
|
1664
1454
|
let removed = 0;
|
|
1665
|
-
|
|
1455
|
+
/** 删到这一刻已经腾出的**进度**:只喂 `orphan` 的部分回执;成功回执念计划的合计(`plan.bytes`)。 */
|
|
1456
|
+
let freedSoFar = 0;
|
|
1666
1457
|
/** 删除途中失败的唯一出口:标签沿用旧措辞;部分回执只属于 `orphan`(由计划的 `kind` 派生)。
|
|
1667
1458
|
* 已经是本仓动词码的错误原样抛(与 `member-journal.ts` 的 `journalFailure` 同一条判据)——
|
|
1668
1459
|
* `not-found` / `live` 之类不许在这里被折成 `io`。 */
|
|
1669
1460
|
const fail = (label, error, failedSessionId) => {
|
|
1670
|
-
if (
|
|
1461
|
+
if (isSessionDeleteCodeError(error)) throw error;
|
|
1671
1462
|
const details = plan.kind === "orphan" ? {
|
|
1672
1463
|
removed,
|
|
1673
|
-
freedBytes,
|
|
1464
|
+
freedBytes: freedSoFar,
|
|
1674
1465
|
failedSessionId
|
|
1675
1466
|
} : void 0;
|
|
1676
1467
|
throw verbError("io", `${label}:${String(error)}`, error, details);
|
|
@@ -1700,10 +1491,10 @@ async function executeRemoval(deps, plan) {
|
|
|
1700
1491
|
} catch (error) {
|
|
1701
1492
|
fail("删除回收站目录失败", error, row.entry.sessionId);
|
|
1702
1493
|
}
|
|
1703
|
-
|
|
1494
|
+
freedSoFar += row.bytes;
|
|
1704
1495
|
}
|
|
1705
1496
|
try {
|
|
1706
|
-
await deps.
|
|
1497
|
+
await deps.ledger.remove(row.entry.id);
|
|
1707
1498
|
} catch (error) {
|
|
1708
1499
|
fail("删除回收站目录失败", error, row.entry.sessionId);
|
|
1709
1500
|
}
|
|
@@ -1718,12 +1509,12 @@ async function executeRemoval(deps, plan) {
|
|
|
1718
1509
|
fail(memberLabel, error, member.sessionId);
|
|
1719
1510
|
}
|
|
1720
1511
|
removed += 1;
|
|
1721
|
-
|
|
1512
|
+
freedSoFar += member.bytes;
|
|
1722
1513
|
await presence.memberGone(member.sessionId);
|
|
1723
1514
|
}
|
|
1724
1515
|
return {
|
|
1725
1516
|
removed,
|
|
1726
|
-
freedBytes
|
|
1517
|
+
freedBytes: plan.bytes
|
|
1727
1518
|
};
|
|
1728
1519
|
}
|
|
1729
1520
|
//#endregion
|
|
@@ -1777,17 +1568,19 @@ function orphanGroupsOf(corpus, trashSessionIds) {
|
|
|
1777
1568
|
}
|
|
1778
1569
|
return groups;
|
|
1779
1570
|
}
|
|
1780
|
-
/**
|
|
1781
|
-
*
|
|
1571
|
+
/** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。清册默认读
|
|
1572
|
+
* `ledger.raw()`;写路径(`orphan-clean`)可以在调它之前先 `ledger.list()` 对账,并把交回的行递进来,
|
|
1573
|
+
* 免得同一份索引再读一遍——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成"父没了"
|
|
1574
|
+
* 直接删盘。
|
|
1782
1575
|
*
|
|
1783
1576
|
* `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
|
|
1784
1577
|
* 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
|
|
1785
1578
|
* 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
|
|
1786
|
-
async function loadOrphanGroups(deps) {
|
|
1579
|
+
async function loadOrphanGroups(deps, trash) {
|
|
1787
1580
|
const read = await deps.host.listSessions();
|
|
1788
1581
|
if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
|
|
1789
1582
|
const corpus = read.value;
|
|
1790
|
-
const groups = orphanGroupsOf(corpus, (await deps.
|
|
1583
|
+
const groups = orphanGroupsOf(corpus, (trash ?? await deps.ledger.raw()).map((entry) => entry.sessionId));
|
|
1791
1584
|
const out = [];
|
|
1792
1585
|
for (const group of groups) {
|
|
1793
1586
|
const members = [];
|
|
@@ -1856,10 +1649,12 @@ async function cleanOrphanGroup(deps, group) {
|
|
|
1856
1649
|
/**
|
|
1857
1650
|
* 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
|
|
1858
1651
|
*
|
|
1859
|
-
* 与另外两个动词的关系(ADR-0002):它的 interface
|
|
1860
|
-
|
|
1652
|
+
* 与另外两个动词的关系(ADR-0002):它的 interface 是"这一组目标 + 一份只读清册快照
|
|
1653
|
+
*(targets 缺席 = 清空)";目标由 `verbs/batch-carried.ts` 按组语义算好,批量的"已被同批前一条
|
|
1654
|
+
* 带走"判据不在这里。`requestedId` 是唯一的例外参数,但它**不参与任何判据**:只是让
|
|
1655
|
+
* `trash-empty` 的原文点得出用户点的是哪一条(原文要上屏,点名承重),批量语义仍在批量层。
|
|
1861
1656
|
*
|
|
1862
|
-
*
|
|
1657
|
+
* **这条动词只做两件事:拿目标、把计划交给执行器**。量目录、筛盘上后代、活体准入与"先退归档集、
|
|
1863
1658
|
* 再摘账本、最后删目录"的顺序都住 `verbs/permanent-removal.ts`——计划只读,执行器只写,计划就是
|
|
1864
1659
|
* 执行前的闸(别在这里再长出一段手写步骤)。
|
|
1865
1660
|
*
|
|
@@ -1868,11 +1663,10 @@ async function cleanOrphanGroup(deps, group) {
|
|
|
1868
1663
|
* 对应地,`kind:'trash'` 的计划失败时执行器只抛那一步的 `io` 与原文、不带 details。
|
|
1869
1664
|
*/
|
|
1870
1665
|
function purgeTrash(deps) {
|
|
1871
|
-
return async function purgeTrash(
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
return await executeRemoval(deps, await planTrashRemoval(deps, all, targets, { admission: "live" }));
|
|
1666
|
+
return async function purgeTrash(targets, rows, requestedId) {
|
|
1667
|
+
const selected = targets ?? rows;
|
|
1668
|
+
if (targets !== void 0 && selected.length === 0) throw verbError("trash-empty", requestedId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${requestedId}`);
|
|
1669
|
+
return await executeRemoval(deps, await planTrashRemoval(deps, rows, selected));
|
|
1876
1670
|
};
|
|
1877
1671
|
}
|
|
1878
1672
|
//#endregion
|
|
@@ -1881,7 +1675,7 @@ function purgeTrash(deps) {
|
|
|
1881
1675
|
* 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
|
|
1882
1676
|
* 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
|
|
1883
1677
|
*
|
|
1884
|
-
*
|
|
1678
|
+
* 三段与删除对称(成员循环与整组回滚的实现住 `verbs/move-group.ts`):
|
|
1885
1679
|
* ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
|
|
1886
1680
|
* (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
|
|
1887
1681
|
* 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
|
|
@@ -1891,32 +1685,31 @@ function purgeTrash(deps) {
|
|
|
1891
1685
|
* ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
|
|
1892
1686
|
*
|
|
1893
1687
|
* **通告不在这里**:一次批量只该拉一遍宿主会话表、只发一次 `api-session/added`(2026-10-17 那次
|
|
1894
|
-
* "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface
|
|
1895
|
-
* "一个条目 id
|
|
1688
|
+
* "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 是
|
|
1689
|
+
* "一个条目 id + 这一组进、回收站那一行出",没有批量专用的开关。
|
|
1896
1690
|
*/
|
|
1897
1691
|
function restoreSession(deps) {
|
|
1898
1692
|
const presence = createMemberPresence(deps.host);
|
|
1899
|
-
return async function restoreSession(entryId,
|
|
1900
|
-
const
|
|
1901
|
-
const target =
|
|
1902
|
-
if (
|
|
1903
|
-
const group = groupOf(all, entryId);
|
|
1693
|
+
return async function restoreSession(entryId, group) {
|
|
1694
|
+
const [root, ...followers] = group;
|
|
1695
|
+
const target = group.find((e) => e.id === entryId);
|
|
1696
|
+
if (root === void 0 || target === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
1904
1697
|
for (const entry of group) {
|
|
1905
1698
|
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1906
1699
|
if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
|
|
1907
1700
|
const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
|
|
1908
1701
|
if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
|
|
1909
1702
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1703
|
+
await moveGroup({
|
|
1704
|
+
root,
|
|
1705
|
+
followers,
|
|
1706
|
+
move: async (entry) => {
|
|
1707
|
+
return {
|
|
1708
|
+
journal: (await memberRestore(deps, presence, entry)).journal,
|
|
1709
|
+
result: void 0
|
|
1710
|
+
};
|
|
1916
1711
|
}
|
|
1917
|
-
}
|
|
1918
|
-
await rollbackJournals(done, error);
|
|
1919
|
-
}
|
|
1712
|
+
});
|
|
1920
1713
|
return target;
|
|
1921
1714
|
};
|
|
1922
1715
|
}
|
|
@@ -1963,21 +1756,17 @@ async function memberRestore(deps, presence, entry) {
|
|
|
1963
1756
|
const legacyArchived = !entry.wasArchived && await deps.host.isArchived(entry.sessionId);
|
|
1964
1757
|
/** 真挂上了账本才记下那个 id——回滚里"摘账本"那一步只对挂过的成员存在。 */
|
|
1965
1758
|
let attached = false;
|
|
1966
|
-
|
|
1967
|
-
let movedBack = false;
|
|
1968
|
-
const journal = createJournal([
|
|
1759
|
+
return { journal: withPresenceTail(presence, createJournal([
|
|
1969
1760
|
{
|
|
1761
|
+
key: "dir",
|
|
1970
1762
|
label: "放回会话目录失败",
|
|
1971
1763
|
run: async () => {
|
|
1972
1764
|
await deps.fs.rename(trashDir, sessionDir);
|
|
1973
1765
|
},
|
|
1974
|
-
undo:
|
|
1975
|
-
const failures = await attempt("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir));
|
|
1976
|
-
if (failures.length === 0) movedBack = true;
|
|
1977
|
-
return failures;
|
|
1978
|
-
}
|
|
1766
|
+
undo: () => stepUndo("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir))
|
|
1979
1767
|
},
|
|
1980
1768
|
...workspaceId === void 0 ? [] : [{
|
|
1769
|
+
key: "workspace",
|
|
1981
1770
|
label: "挂回账本失败",
|
|
1982
1771
|
run: async () => {
|
|
1983
1772
|
try {
|
|
@@ -1987,32 +1776,38 @@ async function memberRestore(deps, presence, entry) {
|
|
|
1987
1776
|
if (!isWorkspaceMissingError(error) || await deps.host.workspaceExists(workspaceId)) throw error;
|
|
1988
1777
|
}
|
|
1989
1778
|
},
|
|
1990
|
-
undo: async () =>
|
|
1779
|
+
undo: async () => {
|
|
1780
|
+
if (!attached) return {
|
|
1781
|
+
restored: true,
|
|
1782
|
+
failures: []
|
|
1783
|
+
};
|
|
1784
|
+
return stepUndo("摘账本失败", () => deps.host.detach(entry.sessionId, workspaceId));
|
|
1785
|
+
}
|
|
1991
1786
|
}],
|
|
1992
1787
|
...!legacyArchived ? [] : [{
|
|
1788
|
+
key: "archive",
|
|
1993
1789
|
label: "取消归档失败",
|
|
1994
1790
|
undoOnFailure: true,
|
|
1995
1791
|
run: async () => {
|
|
1996
1792
|
await deps.host.unarchive(entry.sessionId);
|
|
1997
1793
|
},
|
|
1998
|
-
undo: () =>
|
|
1794
|
+
undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
|
|
1999
1795
|
}],
|
|
2000
1796
|
{
|
|
1797
|
+
key: "manifest",
|
|
2001
1798
|
label: "摘回收站条目失败",
|
|
2002
1799
|
run: async () => {
|
|
2003
|
-
await deps.
|
|
1800
|
+
await deps.ledger.remove(entry.id);
|
|
2004
1801
|
},
|
|
2005
|
-
undo: () =>
|
|
2006
|
-
}
|
|
2007
|
-
]);
|
|
2008
|
-
return { journal: {
|
|
2009
|
-
run: () => journal.run(),
|
|
2010
|
-
async undo() {
|
|
2011
|
-
const failures = await journal.undo();
|
|
2012
|
-
await presence.memberGone(entry.sessionId, { backInTrash: movedBack });
|
|
2013
|
-
return failures;
|
|
1802
|
+
undo: () => stepUndo("清册行写回失败", () => deps.ledger.add(entry))
|
|
2014
1803
|
}
|
|
2015
|
-
|
|
1804
|
+
]), {
|
|
1805
|
+
sessionId: entry.sessionId,
|
|
1806
|
+
onUndone: (undo) => ({
|
|
1807
|
+
kind: "leave",
|
|
1808
|
+
backInTrash: undo.restored("dir")
|
|
1809
|
+
})
|
|
1810
|
+
}) };
|
|
2016
1811
|
}
|
|
2017
1812
|
//#endregion
|
|
2018
1813
|
//#region src/verbs/batch.ts
|
|
@@ -2025,16 +1820,18 @@ async function memberRestore(deps, presence, entry) {
|
|
|
2025
1820
|
* interface 上(`onMembers` / `snapshot` / `suppressAnnounce`),于是每个动词都得知道"我是第几个、
|
|
2026
1821
|
* 前面的兄弟带走了谁",同一个事实还有三种机制(回调、调用方 `taken` 集合、动词就地收缩数组)。
|
|
2027
1822
|
*
|
|
2028
|
-
*
|
|
1823
|
+
* 这一层拥有三件(每条批量语义都在这里):
|
|
2029
1824
|
* ① **去重保序**:输入顺序就是界面"清单从上往下"的读感;
|
|
2030
|
-
* ②
|
|
2031
|
-
* ③
|
|
2032
|
-
*
|
|
2033
|
-
*
|
|
1825
|
+
* ② **逐条信封**:一条失败不传染下一条,失败按 `map-error` 那张表折成 `{code,message,details?}`;
|
|
1826
|
+
* ③ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、调一次 `announceRestored(ids)`(预取与逐条
|
|
1827
|
+
* 发送都在 adapter 里;逐条调 = 每条一次宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
|
|
1828
|
+
*
|
|
1829
|
+
* **同批已带走集**(整批只读一次"最初的样子")住 `src/verbs/batch-carried.ts`;这里消费它的
|
|
1830
|
+
* `claim` / `take`,purge 另取 entry 域的 `rows()` 作为当前只读清册。
|
|
2034
1831
|
*
|
|
2035
1832
|
* 「已被同批前一条带走」是**幂等成功**,不是失败:用户要的结果本来就已经达成了(`delete`/`restore`
|
|
2036
|
-
* 回 `null`;`purge` 回 `{removed:0,freedBytes:0}
|
|
2037
|
-
*
|
|
1833
|
+
* 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据仍归 `trash/groups.ts`(ADR-0001,
|
|
1834
|
+
* 由 `batch-carried.ts` 调用),同批已带走集只决定"这一条还该不该交给动词"。
|
|
2038
1835
|
*/
|
|
2039
1836
|
function failure(id, action, error) {
|
|
2040
1837
|
return {
|
|
@@ -2065,116 +1862,307 @@ function unique(ids) {
|
|
|
2065
1862
|
return out;
|
|
2066
1863
|
}
|
|
2067
1864
|
/**
|
|
2068
|
-
* 一条 id 一条结果:**这是批量唯一的形式**(单条就是长度为 1 的批量,仓里不留第二条执行路径)。
|
|
2069
|
-
* 动词只收一个 id、只吐一条结果;`try/catch` 在这一处,失败不传染下一条。
|
|
1865
|
+
* 一条 id 一条结果:**这是批量唯一的形式**(单条就是长度为 1 的批量,仓里不留第二条执行路径)。
|
|
1866
|
+
* 动词只收一个 id、只吐一条结果;`try/catch` 在这一处,失败不传染下一条。
|
|
1867
|
+
*/
|
|
1868
|
+
async function forEachId(action, ids, run) {
|
|
1869
|
+
const outcomes = [];
|
|
1870
|
+
for (const id of unique(ids)) try {
|
|
1871
|
+
outcomes.push(success(id, action, await run(id)));
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
outcomes.push(failure(id, action, error));
|
|
1874
|
+
}
|
|
1875
|
+
return outcomes;
|
|
1876
|
+
}
|
|
1877
|
+
async function applyDelete(deps, ids) {
|
|
1878
|
+
const run = deleteSession(deps);
|
|
1879
|
+
const carried = createSessionCarried();
|
|
1880
|
+
return forEachId("delete", ids, async (sessionId) => {
|
|
1881
|
+
if (carried.claim(sessionId).verdict === "carried") return null;
|
|
1882
|
+
const { entry, movedSessionIds } = await run(sessionId);
|
|
1883
|
+
for (const movedId of movedSessionIds) carried.take(movedId);
|
|
1884
|
+
return entry;
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
/** 「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`;`apply` 整批只读一次清册,
|
|
1888
|
+
* 动词只拿它交回的只读组与当前清册。 */
|
|
1889
|
+
async function applyRestore(deps, ids) {
|
|
1890
|
+
const carried = createEntryCarried(await deps.ledger.list());
|
|
1891
|
+
const run = restoreSession(deps);
|
|
1892
|
+
const restored = [];
|
|
1893
|
+
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
1894
|
+
const claim = carried.claim(entryId);
|
|
1895
|
+
if (claim.verdict === "carried") return null;
|
|
1896
|
+
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
1897
|
+
const entry = await run(entryId, group);
|
|
1898
|
+
carried.take(entryId);
|
|
1899
|
+
restored.push(...group.map((row) => row.sessionId));
|
|
1900
|
+
return entry;
|
|
1901
|
+
});
|
|
1902
|
+
if (restored.length > 0) await deps.host.announceRestored(restored);
|
|
1903
|
+
return outcomes;
|
|
1904
|
+
}
|
|
1905
|
+
async function applyPurge(deps, ids) {
|
|
1906
|
+
const carried = createEntryCarried(await deps.ledger.list());
|
|
1907
|
+
const run = purgeTrash(deps);
|
|
1908
|
+
return forEachId("purge", ids, async (entryId) => {
|
|
1909
|
+
const claim = carried.claim(entryId);
|
|
1910
|
+
if (claim.verdict === "carried") return {
|
|
1911
|
+
removed: 0,
|
|
1912
|
+
freedBytes: 0
|
|
1913
|
+
};
|
|
1914
|
+
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
1915
|
+
const stats = await run(group, carried.rows(), entryId);
|
|
1916
|
+
carried.take(entryId);
|
|
1917
|
+
return stats;
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
/** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
|
|
1921
|
+
* 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
|
|
1922
|
+
* 浏览器半按"读不到"把整句不说。 */
|
|
1923
|
+
async function applyCount(deps, ids) {
|
|
1924
|
+
const wanted = unique(ids);
|
|
1925
|
+
const read = await deps.host.subagentMembersOf(wanted);
|
|
1926
|
+
return wanted.map((id) => success(id, "count", read.available ? read.value[id]?.length ?? 0 : 0));
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* 清一组无主子会话(父已被彻底删掉、孩子还留在 `sessions/` 里的那些)。id 是**根会话 id**——
|
|
1930
|
+
* 界面先 `GET /orphans` 拿 `roots` 再原样带回来,所以这里的 `not-found` 说的是"这句话已经过期了"
|
|
1931
|
+
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
1932
|
+
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
1933
|
+
*
|
|
1934
|
+
* **这是写动作,排在到达序链上**,而且**先对账再扫**:`loadOrphanGroups` 本身只扫描、不写清册;
|
|
1935
|
+
* 这里先把 `ledger.list()` 对账后的行交给它——刚被人手工搬进 `session-trash/` 的那一格目录此刻可能还
|
|
1936
|
+
* 没有对应行,拿 raw 索引扫就会把"父还在回收站里等着"的一组当成孤儿**直接删盘**,替用户做了一个没人
|
|
1937
|
+
* 点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
1938
|
+
*/
|
|
1939
|
+
async function applyOrphanClean(deps, ids) {
|
|
1940
|
+
const groups = await loadOrphanGroups(deps, await deps.ledger.list());
|
|
1941
|
+
const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
|
|
1942
|
+
return forEachId("orphan-clean", ids, async (id) => {
|
|
1943
|
+
const group = byRoot.get(bareSessionId(id));
|
|
1944
|
+
if (group === void 0) throw verbError("not-found", `不再是可清理的无主子会话:${id}`);
|
|
1945
|
+
return await cleanOrphanGroup(deps, group);
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function applyActions(deps, action, ids) {
|
|
1949
|
+
switch (action) {
|
|
1950
|
+
case "delete": return applyDelete(deps, ids);
|
|
1951
|
+
case "restore": return applyRestore(deps, ids);
|
|
1952
|
+
case "purge": return applyPurge(deps, ids);
|
|
1953
|
+
case "count": return applyCount(deps, ids);
|
|
1954
|
+
case "orphan-clean": return applyOrphanClean(deps, ids);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
//#endregion
|
|
1958
|
+
//#region src/log/zstd-frames.ts
|
|
1959
|
+
const MAGIC = [
|
|
1960
|
+
40,
|
|
1961
|
+
181,
|
|
1962
|
+
47,
|
|
1963
|
+
253
|
|
1964
|
+
];
|
|
1965
|
+
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
1966
|
+
const AFTER_DESCRIPTOR = 5;
|
|
1967
|
+
const BLOCK_HEADER_BYTES = 3;
|
|
1968
|
+
const BLOCK_LAST = 1;
|
|
1969
|
+
const BLOCK_TYPE_SHIFT = 1;
|
|
1970
|
+
const BLOCK_SIZE_SHIFT = 3;
|
|
1971
|
+
const BLOCK_SIZE_MASK = 2097151;
|
|
1972
|
+
const BLOCK_RAW = 0;
|
|
1973
|
+
const BLOCK_RLE = 1;
|
|
1974
|
+
const BLOCK_COMRESSED = 2;
|
|
1975
|
+
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
1976
|
+
const BLOCK_RESERVED = 3;
|
|
1977
|
+
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
1978
|
+
const DID_SIZES = [
|
|
1979
|
+
0,
|
|
1980
|
+
1,
|
|
1981
|
+
2,
|
|
1982
|
+
4
|
|
1983
|
+
];
|
|
1984
|
+
const FCS_SIZES_SINGLE = [
|
|
1985
|
+
1,
|
|
1986
|
+
2,
|
|
1987
|
+
4,
|
|
1988
|
+
8
|
|
1989
|
+
];
|
|
1990
|
+
const FCS_SIZES_MULTI = [
|
|
1991
|
+
0,
|
|
1992
|
+
2,
|
|
1993
|
+
4,
|
|
1994
|
+
8
|
|
1995
|
+
];
|
|
1996
|
+
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
1997
|
+
const FRAME_TAIL_BYTES = 4;
|
|
1998
|
+
function isMagicAt(raw, at) {
|
|
1999
|
+
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
2000
|
+
}
|
|
2001
|
+
function readUint24LE(raw, at) {
|
|
2002
|
+
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
2003
|
+
}
|
|
2004
|
+
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
2005
|
+
function withFrameTail(raw, at) {
|
|
2006
|
+
const afterTail = at + FRAME_TAIL_BYTES;
|
|
2007
|
+
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
2008
|
+
return at;
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* 把 `raw` 切成帧区间。
|
|
2012
|
+
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
2013
|
+
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
2014
|
+
*/
|
|
2015
|
+
function frameRanges(raw) {
|
|
2016
|
+
const ranges = [];
|
|
2017
|
+
let at = 0;
|
|
2018
|
+
while (at < raw.length) {
|
|
2019
|
+
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
2020
|
+
const descriptor = raw[at + 4];
|
|
2021
|
+
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
2022
|
+
const fcsFlag = descriptor & 3;
|
|
2023
|
+
const singleSegment = descriptor >> 2 & 1;
|
|
2024
|
+
const didFlag = descriptor >> 6 & 3;
|
|
2025
|
+
let cursor = at + AFTER_DESCRIPTOR;
|
|
2026
|
+
if (!singleSegment) cursor += 1;
|
|
2027
|
+
cursor += DID_SIZES[didFlag];
|
|
2028
|
+
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
2029
|
+
for (;;) {
|
|
2030
|
+
const blockHeader = readUint24LE(raw, cursor);
|
|
2031
|
+
cursor += BLOCK_HEADER_BYTES;
|
|
2032
|
+
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
2033
|
+
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
2034
|
+
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
2035
|
+
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
2036
|
+
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
2037
|
+
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
2038
|
+
if (blockHeader & BLOCK_LAST) break;
|
|
2039
|
+
}
|
|
2040
|
+
cursor = withFrameTail(raw, cursor);
|
|
2041
|
+
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
2042
|
+
ranges.push({
|
|
2043
|
+
start: at,
|
|
2044
|
+
end: cursor
|
|
2045
|
+
});
|
|
2046
|
+
at = cursor;
|
|
2047
|
+
}
|
|
2048
|
+
return ranges;
|
|
2049
|
+
}
|
|
2050
|
+
//#endregion
|
|
2051
|
+
//#region src/log/session-log.ts
|
|
2052
|
+
/**
|
|
2053
|
+
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
2054
|
+
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
2055
|
+
*
|
|
2056
|
+
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
2057
|
+
* 这一层只懂编解码,所以它能被单独验穿。
|
|
2058
|
+
*
|
|
2059
|
+
* 两条实测决定的口径:
|
|
2060
|
+
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
2061
|
+
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
2062
|
+
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
2063
|
+
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
2064
|
+
* 本机实测**没有一帧切在行中间**(连最后一帧都整行收尾;绝对帧数随盘上会话数摆动,那份读数
|
|
2065
|
+
* 不在这里钉,来历留在 git 历史),
|
|
2066
|
+
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
2067
|
+
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
2068
|
+
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
2069
|
+
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
2070
|
+
*/
|
|
2071
|
+
const NEWLINE = 10;
|
|
2072
|
+
/**
|
|
2073
|
+
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
2074
|
+
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
2075
|
+
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
2070
2076
|
*/
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
for (const
|
|
2083
|
-
const
|
|
2084
|
-
if (
|
|
2077
|
+
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
2078
|
+
/**
|
|
2079
|
+
* 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
|
|
2080
|
+
* 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
|
|
2081
|
+
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
2082
|
+
*
|
|
2083
|
+
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
2084
|
+
*/
|
|
2085
|
+
function pickSessionLog(names) {
|
|
2086
|
+
let best;
|
|
2087
|
+
let bestVersion = -1;
|
|
2088
|
+
for (const name of names) {
|
|
2089
|
+
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
2090
|
+
if (match === null) continue;
|
|
2091
|
+
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
2092
|
+
if (version > bestVersion) {
|
|
2093
|
+
bestVersion = version;
|
|
2094
|
+
best = name;
|
|
2095
|
+
}
|
|
2085
2096
|
}
|
|
2086
|
-
|
|
2087
|
-
async function applyDelete(deps, ids) {
|
|
2088
|
-
const run = deleteSession(deps);
|
|
2089
|
-
const carried = /* @__PURE__ */ new Set();
|
|
2090
|
-
return forEachId("delete", ids, async (sessionId) => {
|
|
2091
|
-
const root = bareSessionId(sessionId);
|
|
2092
|
-
if (carried.has(root)) return null;
|
|
2093
|
-
const { entry, movedSessionIds } = await run(sessionId);
|
|
2094
|
-
for (const movedId of movedSessionIds) carried.add(bareSessionId(movedId));
|
|
2095
|
-
return entry;
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
/** 「这条是不是被同批前一条带走了」:原始快照里认得出、当前快照里已经空了。
|
|
2099
|
-
* **收缩归这一层**(动词只拿只读快照)——`apply` 整批只读一次清册,`takenByEarlierInBatch` 不比它多读。 */
|
|
2100
|
-
async function applyRestore(deps, ids) {
|
|
2101
|
-
const original = await deps.manifest.list();
|
|
2102
|
-
const current = [...original];
|
|
2103
|
-
const run = restoreSession(deps);
|
|
2104
|
-
const restored = [];
|
|
2105
|
-
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
2106
|
-
if (takenByEarlierInBatch(original, current, entryId)) return null;
|
|
2107
|
-
const group = groupOf(current, entryId);
|
|
2108
|
-
const entry = await run(entryId, current);
|
|
2109
|
-
restored.push(...group.map((row) => row.sessionId));
|
|
2110
|
-
shrink(current, group);
|
|
2111
|
-
return entry;
|
|
2112
|
-
});
|
|
2113
|
-
if (restored.length > 0) await deps.host.announceRestoredBatch(restored, await deps.host.restoredSummariesOf());
|
|
2114
|
-
return outcomes;
|
|
2115
|
-
}
|
|
2116
|
-
async function applyPurge(deps, ids) {
|
|
2117
|
-
const original = await deps.manifest.list();
|
|
2118
|
-
const current = [...original];
|
|
2119
|
-
const run = purgeTrash(deps);
|
|
2120
|
-
return forEachId("purge", ids, async (entryId) => {
|
|
2121
|
-
if (takenByEarlierInBatch(original, current, entryId)) return {
|
|
2122
|
-
removed: 0,
|
|
2123
|
-
freedBytes: 0
|
|
2124
|
-
};
|
|
2125
|
-
const group = groupOf(current, entryId);
|
|
2126
|
-
const stats = await run(entryId, current);
|
|
2127
|
-
shrink(current, group);
|
|
2128
|
-
return stats;
|
|
2129
|
-
});
|
|
2130
|
-
}
|
|
2131
|
-
/** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
|
|
2132
|
-
* 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
|
|
2133
|
-
* 浏览器半按"读不到"把整句不说。 */
|
|
2134
|
-
async function applyCount(deps, ids) {
|
|
2135
|
-
const wanted = unique(ids);
|
|
2136
|
-
const read = await deps.host.subagentMembersOf(wanted);
|
|
2137
|
-
return wanted.map((id) => success(id, "count", read.available ? read.value[id]?.length ?? 0 : 0));
|
|
2097
|
+
return best;
|
|
2138
2098
|
}
|
|
2139
2099
|
/**
|
|
2140
|
-
*
|
|
2141
|
-
* 界面先 `GET /orphans` 拿 `roots` 再原样带回来,所以这里的 `not-found` 说的是"这句话已经过期了"
|
|
2142
|
-
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
2143
|
-
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
2100
|
+
* 逐帧走一遍日志,每条记录交给回调。
|
|
2144
2101
|
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2147
|
-
* 一组当成孤儿**直接删盘**,替用户做了一个没人点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
2148
|
-
* 对账写在动词这一侧而不是只写在路由里,为的是 `apply` 的批量入口只有一个:链上的排队与这份快照
|
|
2149
|
-
* 谁也别想只占一半。
|
|
2102
|
+
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
2103
|
+
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
2150
2104
|
*/
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2105
|
+
function walkSessionLog(raw, onRecord) {
|
|
2106
|
+
const stats = {
|
|
2107
|
+
frames: 0,
|
|
2108
|
+
records: 0,
|
|
2109
|
+
unparsedLines: 0,
|
|
2110
|
+
truncatedTailBytes: 0
|
|
2111
|
+
};
|
|
2112
|
+
let stopped = false;
|
|
2113
|
+
const feed = (line) => {
|
|
2114
|
+
if (line === "") return;
|
|
2115
|
+
let parsed;
|
|
2116
|
+
try {
|
|
2117
|
+
parsed = JSON.parse(line);
|
|
2118
|
+
} catch {
|
|
2119
|
+
stats.unparsedLines += 1;
|
|
2120
|
+
return;
|
|
2161
2121
|
}
|
|
2122
|
+
stats.records += 1;
|
|
2123
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
2124
|
+
};
|
|
2125
|
+
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
2126
|
+
let carry = Buffer.alloc(0);
|
|
2127
|
+
for (const { start, end } of frameRanges(raw)) {
|
|
2128
|
+
if (stopped) break;
|
|
2129
|
+
stats.frames += 1;
|
|
2130
|
+
let plain;
|
|
2162
2131
|
try {
|
|
2163
|
-
|
|
2132
|
+
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
2164
2133
|
} catch (error) {
|
|
2165
|
-
|
|
2134
|
+
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
2135
|
+
}
|
|
2136
|
+
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
2137
|
+
const cut = merged.lastIndexOf(NEWLINE);
|
|
2138
|
+
if (cut === -1) {
|
|
2139
|
+
carry = merged;
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
carry = merged.subarray(cut + 1);
|
|
2143
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
2144
|
+
feed(line);
|
|
2145
|
+
if (stopped) break;
|
|
2166
2146
|
}
|
|
2167
2147
|
}
|
|
2168
|
-
|
|
2148
|
+
stats.truncatedTailBytes = carry.length;
|
|
2149
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
2150
|
+
return stats;
|
|
2169
2151
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2152
|
+
/**
|
|
2153
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
2154
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
2155
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
2156
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
2157
|
+
*/
|
|
2158
|
+
function readSessionHeader(raw) {
|
|
2159
|
+
let header;
|
|
2160
|
+
walkSessionLog(raw, (record) => {
|
|
2161
|
+
if (record.type !== "session") return;
|
|
2162
|
+
header = record;
|
|
2163
|
+
return false;
|
|
2164
|
+
});
|
|
2165
|
+
return header;
|
|
2178
2166
|
}
|
|
2179
2167
|
const DEFAULT_LIMITS = {
|
|
2180
2168
|
perMessageChars: 3e3,
|
|
@@ -2187,7 +2175,7 @@ function textOf(content) {
|
|
|
2187
2175
|
}
|
|
2188
2176
|
function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
2189
2177
|
return async function readTrashDetail(entryId) {
|
|
2190
|
-
const record = (await deps.
|
|
2178
|
+
const record = (await deps.ledger.raw()).find((e) => e.id === entryId);
|
|
2191
2179
|
if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
2192
2180
|
const dir = trashDirOf(record.id, deps.home);
|
|
2193
2181
|
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
@@ -2388,7 +2376,7 @@ const ROUTE_TABLE = {
|
|
|
2388
2376
|
method: "GET",
|
|
2389
2377
|
body: "none",
|
|
2390
2378
|
purity: "chained",
|
|
2391
|
-
run: async (deps) => assembleTrashRows(deps, await
|
|
2379
|
+
run: async (deps) => assembleTrashRows(deps, await deps.ledger.list())
|
|
2392
2380
|
},
|
|
2393
2381
|
[ROUTES.ping]: {
|
|
2394
2382
|
method: "GET",
|
|
@@ -2413,7 +2401,7 @@ const ROUTE_TABLE = {
|
|
|
2413
2401
|
body: "none",
|
|
2414
2402
|
purity: "chained",
|
|
2415
2403
|
run: async (deps) => {
|
|
2416
|
-
const rows = await
|
|
2404
|
+
const rows = await deps.ledger.list();
|
|
2417
2405
|
return await purgeTrash(deps)(void 0, rows);
|
|
2418
2406
|
}
|
|
2419
2407
|
},
|
|
@@ -2653,6 +2641,125 @@ function resolveHome(env = process.env, homeDir = homedir()) {
|
|
|
2653
2641
|
return resolve(raw);
|
|
2654
2642
|
}
|
|
2655
2643
|
//#endregion
|
|
2644
|
+
//#region src/trash/reconcile.ts
|
|
2645
|
+
/**
|
|
2646
|
+
* 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(CONTEXT.md 的「对账」条)。
|
|
2647
|
+
*
|
|
2648
|
+
* 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
|
|
2649
|
+
* `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
|
|
2650
|
+
*
|
|
2651
|
+
* 五条口径(都不是口味):
|
|
2652
|
+
* ① **每次真相读都对账**(`src/trash/ledger.ts` 的 `list()`;`GET /list` 的正文就是"对账 + 读"):挂载、
|
|
2653
|
+
* 刷新、别处动过后的自动重读、客户端挂载后的首次列表读全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。
|
|
2654
|
+
* pure 读面(`/orphans`、`/detail`)走 `raw()`,不对账——对账会写行,必须排在到达序链上。
|
|
2655
|
+
* 代价 = 每次真相读多一次目录枚举(本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
|
|
2656
|
+
* ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
|
|
2657
|
+
* (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
|
|
2658
|
+
* ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
|
|
2659
|
+
* `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
|
|
2660
|
+
* `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
|
|
2661
|
+
* 落在「未分组」)、`wasArchived: false`(文件侧没有归档事实;旧条目恢复走迁移支)、`title: ''`(详情有日志标题兜底)
|
|
2662
|
+
* ——这是重建的固有代价,不是可以"再读读看"的东西。
|
|
2663
|
+
* ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
|
|
2664
|
+
* 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
|
|
2665
|
+
* ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
|
|
2666
|
+
*
|
|
2667
|
+
* 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
|
|
2668
|
+
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
2669
|
+
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
2670
|
+
*/
|
|
2671
|
+
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
|
|
2672
|
+
* 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
|
|
2673
|
+
function isEntryId(name) {
|
|
2674
|
+
try {
|
|
2675
|
+
return isSinglePathSegment(parseEntryId(name).sessionDirName);
|
|
2676
|
+
} catch {
|
|
2677
|
+
return false;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
|
|
2682
|
+
* 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
|
|
2683
|
+
*
|
|
2684
|
+
* `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
|
|
2685
|
+
* header 缺席这个字段才回落到目录名里那段原目录名。
|
|
2686
|
+
*/
|
|
2687
|
+
async function buildEntry(deps, id) {
|
|
2688
|
+
const dir = trashDirOf(id, deps.home);
|
|
2689
|
+
let logName;
|
|
2690
|
+
try {
|
|
2691
|
+
logName = pickSessionLog(await deps.fs.listFiles(dir));
|
|
2692
|
+
} catch {
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
if (logName === void 0) return void 0;
|
|
2696
|
+
let raw;
|
|
2697
|
+
try {
|
|
2698
|
+
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
2699
|
+
} catch {
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
let header;
|
|
2703
|
+
try {
|
|
2704
|
+
header = readSessionHeader(raw);
|
|
2705
|
+
} catch {
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
if (header === void 0) return void 0;
|
|
2709
|
+
let sizeBytes;
|
|
2710
|
+
try {
|
|
2711
|
+
sizeBytes = await deps.fs.sizeOfDir(dir);
|
|
2712
|
+
} catch {
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const { deletedAt, sessionDirName } = parseEntryId(id);
|
|
2716
|
+
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
2717
|
+
return {
|
|
2718
|
+
id,
|
|
2719
|
+
sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
|
|
2720
|
+
projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
2721
|
+
workspaceId: "",
|
|
2722
|
+
wasArchived: false,
|
|
2723
|
+
deletedAt,
|
|
2724
|
+
title: "",
|
|
2725
|
+
sizeBytes
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
2729
|
+
async function reconcileTrash(deps) {
|
|
2730
|
+
const rows = await deps.manifest.list();
|
|
2731
|
+
const root = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
2732
|
+
if (!await deps.fs.exists(root)) return rows;
|
|
2733
|
+
const dirIds = /* @__PURE__ */ new Set();
|
|
2734
|
+
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
2735
|
+
const rowIds = new Set(rows.map((row) => row.id));
|
|
2736
|
+
const drops = rows.filter((row) => !dirIds.has(row.id));
|
|
2737
|
+
const rebuilds = [];
|
|
2738
|
+
for (const id of dirIds) {
|
|
2739
|
+
if (rowIds.has(id)) continue;
|
|
2740
|
+
const entry = await buildEntry(deps, id);
|
|
2741
|
+
if (entry !== void 0) rebuilds.push(entry);
|
|
2742
|
+
}
|
|
2743
|
+
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
2744
|
+
for (const row of drops) await deps.manifest.remove(row.id);
|
|
2745
|
+
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
2746
|
+
return await deps.manifest.list();
|
|
2747
|
+
}
|
|
2748
|
+
//#endregion
|
|
2749
|
+
//#region src/trash/ledger.ts
|
|
2750
|
+
function createTrashLedger(raw, fs, home) {
|
|
2751
|
+
return {
|
|
2752
|
+
list: () => reconcileTrash({
|
|
2753
|
+
fs,
|
|
2754
|
+
manifest: raw,
|
|
2755
|
+
home
|
|
2756
|
+
}),
|
|
2757
|
+
raw: () => raw.list(),
|
|
2758
|
+
add: (entry) => raw.add(entry),
|
|
2759
|
+
remove: (id) => raw.remove(id)
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
//#endregion
|
|
2656
2763
|
//#region src/trash/manifest.ts
|
|
2657
2764
|
function createStorageManifest(domain) {
|
|
2658
2765
|
return {
|
|
@@ -2774,7 +2881,8 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
2774
2881
|
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
2775
2882
|
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
2776
2883
|
*
|
|
2777
|
-
*
|
|
2884
|
+
* 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
|
|
2885
|
+
* 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
|
|
2778
2886
|
*/
|
|
2779
2887
|
const inject = [
|
|
2780
2888
|
"workspaceRegistry",
|
|
@@ -2789,11 +2897,14 @@ const inject = [
|
|
|
2789
2897
|
* 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
|
|
2790
2898
|
*/
|
|
2791
2899
|
function apply(ctx) {
|
|
2900
|
+
const fs = createFsPort();
|
|
2901
|
+
const home = resolveHome();
|
|
2902
|
+
const rawManifest = createHostStorageManifest(ctx);
|
|
2792
2903
|
const deps = {
|
|
2793
|
-
fs
|
|
2904
|
+
fs,
|
|
2794
2905
|
host: createHostPort(ctx),
|
|
2795
|
-
|
|
2796
|
-
home
|
|
2906
|
+
ledger: createTrashLedger(rawManifest, fs, home),
|
|
2907
|
+
home,
|
|
2797
2908
|
now: () => Date.now()
|
|
2798
2909
|
};
|
|
2799
2910
|
const webServer = ctx.webServer;
|