dsh-session-autarchive 0.1.3 → 0.1.5
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.en.md +1 -1
- package/README.md +1 -1
- package/index.js +83 -71
- package/lib/client.js +71 -1
- package/package.json +3 -5
- package/scripts/smoke.mjs +95 -5
- package/src/entry.jsx +75 -2
- package/invariant.js +0 -10
package/README.en.md
CHANGED
|
@@ -29,7 +29,7 @@ dsh plugin --profile desktop add github:taoziliyouchong/dsh-session-autarchive
|
|
|
29
29
|
|
|
30
30
|
Restart DSH afterwards. Replace `desktop` with your own profile name if you use another one.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Requires DSH >= 0.2.1-alpha.3; verified on 0.2.1-alpha.3 and 0.2.2-rc.1, later versions untested.
|
|
33
33
|
|
|
34
34
|
Once published to npm, this also works:
|
|
35
35
|
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ dsh plugin --profile desktop add github:taoziliyouchong/dsh-session-autarchive
|
|
|
29
29
|
|
|
30
30
|
装完重启 DSH。用别的 profile 就把 `desktop` 换成你的 profile 名。
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
要求 DSH >= 0.2.1-alpha.3;实测 0.2.1-alpha.3、0.2.2-rc.1,更高版本未验证。
|
|
33
33
|
|
|
34
34
|
发布到 npm 之后也可以:
|
|
35
35
|
|
package/index.js
CHANGED
|
@@ -549,87 +549,99 @@ function registerRoutes(ctx) {
|
|
|
549
549
|
|
|
550
550
|
/** 注册工具与路由。 */
|
|
551
551
|
export function apply(ctx) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
sessionId: { type: 'string', description: 'protect/unprotect 用:会话 id,形如 session-xxxxxxxx-…' },
|
|
560
|
-
dryRun: { type: 'boolean', description: 'run 用:true 时只列候选、不动手' },
|
|
561
|
-
days: { type: 'number', description: 'config 用:多少天没动就归档,默认 5' },
|
|
562
|
-
enabled: { type: 'boolean', description: 'config 用:是否启用自动归档' },
|
|
563
|
-
exemptPinned: { type: 'boolean', description: 'config 用:置顶会话是否也豁免(默认豁免)' },
|
|
564
|
-
},
|
|
565
|
-
required: [],
|
|
566
|
-
additionalProperties: false,
|
|
567
|
-
},
|
|
568
|
-
output: {
|
|
569
|
-
schema: {
|
|
552
|
+
// 工具注册失败(重名、DSH 升级后格式漂移)不该拖垮路由与兜底定时:
|
|
553
|
+
// 单独圈起来,失败只留一行警告,归档主功能照常。
|
|
554
|
+
try {
|
|
555
|
+
ctx.tools.register({
|
|
556
|
+
name: 'dsh-session-autarchive',
|
|
557
|
+
description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config 改阈值与开关。判定口径=会话内最后一条对话结束事件(turn/end)的时间——只是被打开/恢复过(会追写开场提示)不算活动;读不到事件时退回文件最后写入时间。已归档、保护名单内、置顶(可关)都跳过。浏览器半边每 5 分钟按同一份清单自动执行,侧栏右键菜单的「不自动归档」也写这份名单。',
|
|
558
|
+
parameters: {
|
|
570
559
|
type: 'object',
|
|
571
|
-
properties: {
|
|
572
|
-
|
|
560
|
+
properties: {
|
|
561
|
+
action: { type: 'string', description: '要做什么:status(默认,看状态)|run(立刻归档候选)|protect(标记不自动归档)|unprotect(取消标记)|config(改阈值/开关)' },
|
|
562
|
+
sessionId: { type: 'string', description: 'protect/unprotect 用:会话 id,形如 session-xxxxxxxx-…' },
|
|
563
|
+
dryRun: { type: 'boolean', description: 'run 用:true 时只列候选、不动手' },
|
|
564
|
+
days: { type: 'number', description: 'config 用:多少天没动就归档,默认 5' },
|
|
565
|
+
enabled: { type: 'boolean', description: 'config 用:是否启用自动归档' },
|
|
566
|
+
exemptPinned: { type: 'boolean', description: 'config 用:置顶会话是否也豁免(默认豁免)' },
|
|
567
|
+
},
|
|
568
|
+
required: [],
|
|
573
569
|
additionalProperties: false,
|
|
574
570
|
},
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const lines = [head]
|
|
591
|
-
for (const id of result.archived) lines.push(' ✓ ' + id.slice(8, 16))
|
|
592
|
-
for (const line of result.failed) lines.push(' ✗ ' + line)
|
|
593
|
-
if (args && args.dryRun === true) {
|
|
594
|
-
for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last))
|
|
571
|
+
output: {
|
|
572
|
+
schema: {
|
|
573
|
+
type: 'object',
|
|
574
|
+
properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
|
|
575
|
+
required: ['ok', 'text'],
|
|
576
|
+
additionalProperties: false,
|
|
577
|
+
},
|
|
578
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
579
|
+
},
|
|
580
|
+
async execute(args) {
|
|
581
|
+
const action = String((args && args.action) || 'status')
|
|
582
|
+
let state = readState()
|
|
583
|
+
|
|
584
|
+
if (action === 'status') {
|
|
585
|
+
return { ok: true, text: statusText(ctx, state) }
|
|
595
586
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
ok: true,
|
|
610
|
-
text: (action === 'protect' ? '已标记不自动归档:' : '已取消标记:') + sessionId
|
|
611
|
-
+ '\n保护名单现有 ' + state.protected.length + ' 个',
|
|
587
|
+
|
|
588
|
+
if (action === 'run') {
|
|
589
|
+
const result = await runArchive(ctx, state, args && args.dryRun === true)
|
|
590
|
+
const head = args && args.dryRun === true
|
|
591
|
+
? '干跑:候选 ' + result.candidates.length + ' 个,未动手'
|
|
592
|
+
: '已归档 ' + result.archived.length + ' 个' + (result.failed.length > 0 ? ',失败 ' + result.failed.length + ' 个' : '')
|
|
593
|
+
const lines = [head]
|
|
594
|
+
for (const id of result.archived) lines.push(' ✓ ' + id.slice(8, 16))
|
|
595
|
+
for (const line of result.failed) lines.push(' ✗ ' + line)
|
|
596
|
+
if (args && args.dryRun === true) {
|
|
597
|
+
for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last))
|
|
598
|
+
}
|
|
599
|
+
return { ok: true, text: lines.join('\n') }
|
|
612
600
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
601
|
+
|
|
602
|
+
if (action === 'protect' || action === 'unprotect') {
|
|
603
|
+
const sessionId = String((args && args.sessionId) || '')
|
|
604
|
+
if (!/^session-/.test(sessionId)) return { ok: false, text: 'protect/unprotect 需要 sessionId(形如 session-xxxxxxxx-…)' }
|
|
605
|
+
const keep = new Set(state.protected)
|
|
606
|
+
if (action === 'protect') keep.add(sessionId)
|
|
607
|
+
else keep.delete(sessionId)
|
|
608
|
+
state = { ...state, protected: [...keep] }
|
|
609
|
+
writeProtected(state.protected)
|
|
610
|
+
audit((action === 'protect' ? 'protect ' : 'unprotect ') + sessionId + ',名单现有 ' + state.protected.length + ' 个')
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
text: (action === 'protect' ? '已标记不自动归档:' : '已取消标记:') + sessionId
|
|
614
|
+
+ '\n保护名单现有 ' + state.protected.length + ' 个',
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (action === 'config') {
|
|
619
|
+
const next = { ...state }
|
|
620
|
+
if (args.days !== undefined) next.days = Number(args.days) > 0 ? Number(args.days) : next.days
|
|
621
|
+
if (args.enabled !== undefined) next.enabled = args.enabled === true
|
|
622
|
+
if (args.exemptPinned !== undefined) next.exemptPinned = args.exemptPinned === true
|
|
623
|
+
writeState(next)
|
|
624
|
+
return { ok: true, text: '已更新:' + describe(next) }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return { ok: false, text: '不认识的 action:' + action + '(可用 status/run/protect/unprotect/config)' }
|
|
628
|
+
},
|
|
629
|
+
})
|
|
630
|
+
} catch (error) {
|
|
631
|
+
console.warn('[dsh-session-autarchive] 工具未注册:' + (error && error.message ? error.message : error))
|
|
632
|
+
}
|
|
627
633
|
|
|
628
634
|
registerRoutes(ctx)
|
|
629
635
|
|
|
630
636
|
// 宿主侧兜底:浏览器窗口没开着也照常归档(客户端那条是让侧栏当场刷新的快通道)。
|
|
631
637
|
// 启动 60 秒后第一次(避开启动竞争),之后每 10 分钟。
|
|
632
|
-
|
|
638
|
+
// 开关每次现读:关掉自动归档后这条定时链要立刻停,不能等重启。
|
|
639
|
+
// 手动执行(工具/路由的 run)不在这里拦,那是用户主动点的,开关只管自动。
|
|
640
|
+
const tick = () => {
|
|
641
|
+
const state = readState()
|
|
642
|
+
if (!state.enabled) return
|
|
643
|
+
void runArchive(ctx, state, false).catch(() => { /* 这轮不行,下一轮再来 */ })
|
|
644
|
+
}
|
|
633
645
|
const first = setTimeout(tick, 60 * 1000)
|
|
634
646
|
const timer = setInterval(tick, 10 * 60 * 1000)
|
|
635
647
|
if (typeof ctx.effect === 'function') {
|
package/lib/client.js
CHANGED
|
@@ -26,6 +26,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
26
26
|
var entry_exports = {};
|
|
27
27
|
__export(entry_exports, {
|
|
28
28
|
AutoArchiveRow: () => AutoArchiveRow,
|
|
29
|
+
AutoArchiveToggleRow: () => AutoArchiveToggleRow,
|
|
29
30
|
ProtectHover: () => ProtectHover,
|
|
30
31
|
ProtectLeading: () => ProtectLeading,
|
|
31
32
|
ProtectMenuItem: () => ProtectMenuItem,
|
|
@@ -223,6 +224,70 @@ var ROW_CSS = `
|
|
|
223
224
|
.zsa-unit { font-size:12px; color:var(--dsw-alias-label-secondary); }
|
|
224
225
|
.zsa-error { font-size:12px; line-height:18px; color:var(--dsw-alias-state-error-primary); }
|
|
225
226
|
`;
|
|
227
|
+
function Switch({ on, disabled, onChange }) {
|
|
228
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
229
|
+
"button",
|
|
230
|
+
{
|
|
231
|
+
type: "button",
|
|
232
|
+
role: "switch",
|
|
233
|
+
"aria-checked": on,
|
|
234
|
+
"aria-label": on ? "\u5173\u95ED\u81EA\u52A8\u5F52\u6863" : "\u5F00\u542F\u81EA\u52A8\u5F52\u6863",
|
|
235
|
+
disabled,
|
|
236
|
+
onClick: () => onChange(!on),
|
|
237
|
+
style: {
|
|
238
|
+
width: 36,
|
|
239
|
+
height: 20,
|
|
240
|
+
flex: "none",
|
|
241
|
+
border: 0,
|
|
242
|
+
borderRadius: 10,
|
|
243
|
+
padding: 0,
|
|
244
|
+
cursor: disabled ? "default" : "pointer",
|
|
245
|
+
position: "relative",
|
|
246
|
+
background: on ? "var(--dsw-alias-brand-primary)" : "var(--dsw-alias-border-l1)",
|
|
247
|
+
opacity: disabled ? 0.5 : 1,
|
|
248
|
+
transition: "background .15s"
|
|
249
|
+
},
|
|
250
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: {
|
|
251
|
+
position: "absolute",
|
|
252
|
+
top: 2,
|
|
253
|
+
left: on ? 18 : 2,
|
|
254
|
+
width: 16,
|
|
255
|
+
height: 16,
|
|
256
|
+
borderRadius: 8,
|
|
257
|
+
background: "#fff",
|
|
258
|
+
transition: "left .15s"
|
|
259
|
+
} })
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
function AutoArchiveToggleRow() {
|
|
264
|
+
const state = useStore();
|
|
265
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
266
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
267
|
+
const on = state.enabled !== false;
|
|
268
|
+
const toggle = async (next) => {
|
|
269
|
+
if (busy) return;
|
|
270
|
+
setBusy(true);
|
|
271
|
+
setError(null);
|
|
272
|
+
try {
|
|
273
|
+
const data = await call("POST", ROUTE + "/config", { enabled: next });
|
|
274
|
+
patchStore({ enabled: data.enabled !== false });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
setError("\u6CA1\u4FDD\u5B58\u4E0A\uFF1A" + String(err && err.message || err));
|
|
277
|
+
} finally {
|
|
278
|
+
setBusy(false);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "zsa-item", children: [
|
|
282
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: ROW_CSS }),
|
|
283
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "zsa-text", children: [
|
|
284
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-title", children: "\u81EA\u52A8\u5F52\u6863" }),
|
|
285
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-desc", children: "\u5173\u95ED\u540E\u540E\u53F0\u4E0D\u518D\u81EA\u52A8\u5F52\u6863\uFF1B\u624B\u52A8\u6267\u884C\u4ECD\u7136\u53EF\u7528\u3002\u5F53\u524D\u5019\u9009 " + state.candidates.length + " \u4E2A\u3002" }),
|
|
286
|
+
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-error", role: "alert", children: error }) : null
|
|
287
|
+
] }),
|
|
288
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-field", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Switch, { on, disabled: busy, onChange: (next) => void toggle(next) }) })
|
|
289
|
+
] });
|
|
290
|
+
}
|
|
226
291
|
function AutoArchiveRow() {
|
|
227
292
|
const state = useStore();
|
|
228
293
|
const [draft, setDraft] = (0, import_react.useState)(null);
|
|
@@ -277,7 +342,7 @@ function AutoArchiveRow() {
|
|
|
277
342
|
max: 3650,
|
|
278
343
|
step: 1,
|
|
279
344
|
value,
|
|
280
|
-
disabled: busy,
|
|
345
|
+
disabled: busy || state.enabled === false,
|
|
281
346
|
"aria-label": "\u81EA\u52A8\u5F52\u6863\u5929\u6570",
|
|
282
347
|
onChange: (event) => {
|
|
283
348
|
setDraft(event.target.value);
|
|
@@ -318,6 +383,11 @@ function apply(ctx) {
|
|
|
318
383
|
place(ctx, "sidebar.session.row.leading", 100, ProtectLeading);
|
|
319
384
|
place(ctx, "sidebar.session.row.hover", 100, ProtectHover);
|
|
320
385
|
try {
|
|
386
|
+
ctx.slots.inject("settings.general.item", () => ctx.slots.register({
|
|
387
|
+
name: "settings.general.item",
|
|
388
|
+
id: "session-autarchive-enabled",
|
|
389
|
+
order: 94
|
|
390
|
+
}, AutoArchiveToggleRow));
|
|
321
391
|
ctx.slots.inject("settings.general.item", () => ctx.slots.register({
|
|
322
392
|
name: "settings.general.item",
|
|
323
393
|
id: "session-autarchive-days",
|
package/package.json
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-autarchive",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "把长期没动过的会话自动归档(默认 5 天),单个会话可以标成不归档。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
-
"./invariant": "./invariant.js",
|
|
10
9
|
"./client": "./lib/client.js",
|
|
11
10
|
"./package.json": "./package.json"
|
|
12
11
|
},
|
|
13
12
|
"files": [
|
|
14
13
|
"index.js",
|
|
15
|
-
"invariant.js",
|
|
16
14
|
"cordis.patch.yml",
|
|
17
15
|
"src",
|
|
18
16
|
"lib/client.js",
|
|
@@ -24,7 +22,7 @@
|
|
|
24
22
|
],
|
|
25
23
|
"engines": {
|
|
26
24
|
"node": "^22.19.0 || >=24.0.0",
|
|
27
|
-
"dsh": ">=0.2.1-0
|
|
25
|
+
"dsh": ">=0.2.1-0"
|
|
28
26
|
},
|
|
29
27
|
"dsh": {
|
|
30
28
|
"client": {
|
|
@@ -48,7 +46,7 @@
|
|
|
48
46
|
"build:client": "node scripts/build-client.mjs",
|
|
49
47
|
"check:client": "node scripts/build-client.mjs --check",
|
|
50
48
|
"smoke": "node scripts/smoke.mjs",
|
|
51
|
-
"check": "node --check index.js
|
|
49
|
+
"check": "node --check index.js"
|
|
52
50
|
},
|
|
53
51
|
"devDependencies": {
|
|
54
52
|
"esbuild": "^0.28.1"
|
package/scripts/smoke.mjs
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* 本机冒烟:不起应用、不发网络请求、不碰真实会话数据。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 七关:
|
|
6
6
|
* 1. 包结构 —— files 声明的必需文件都在(含已提交的 lib/client.js)
|
|
7
7
|
* 2. 组合声明 —— dsh.bundle.patch 指向的文件在,且里面的 insert id 等于包名
|
|
8
8
|
* 3. 宿主半边 —— 桩 ctx 跑 apply:注册 1 个工具、4 条路由,路径与包名一致
|
|
9
9
|
* 4. 路由鉴权 —— 非回环 403、非 POST 405、坏会话 id 400
|
|
10
10
|
* 5. 状态读写 —— 指向临时 DSH_HOME:改天数、加名单后回读一致
|
|
11
|
-
* 6.
|
|
11
|
+
* 6. 开关生效 —— enabled=false 时兜底定时不归档,手动 run 仍归档
|
|
12
|
+
* 7. 客户端产物 —— 外壳 id == 包名,产物里带上了路由与四个 slot 名
|
|
12
13
|
*
|
|
13
14
|
* 用法:node scripts/smoke.mjs (任一条失败退出码 1)
|
|
14
15
|
*
|
|
15
16
|
* ⚠️ 全程使用临时 DSH_HOME,跑完删掉;不会读到或改到你自己的会话数据。
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
19
|
+
import { readFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
19
20
|
import { tmpdir } from 'node:os'
|
|
20
21
|
import { dirname, join, resolve } from 'node:path'
|
|
21
22
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
@@ -33,7 +34,6 @@ const fail = (label, why) => {
|
|
|
33
34
|
/** 1. 包结构 */
|
|
34
35
|
const REQUIRED = [
|
|
35
36
|
'index.js',
|
|
36
|
-
'invariant.js',
|
|
37
37
|
'cordis.patch.yml',
|
|
38
38
|
'src/entry.jsx',
|
|
39
39
|
'lib/client.js',
|
|
@@ -184,7 +184,74 @@ if (routes.length === wanted.length) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/** 6.
|
|
187
|
+
/** 6. 开关生效:enabled=false 时兜底定时不归档;手动 run 不受限 */
|
|
188
|
+
{
|
|
189
|
+
// 造一个 30 天前的会话目录(档里没有 turn/end,判定退回文件写入时间)
|
|
190
|
+
const wsDir = join(home, 'sessions', 'ws-smoke')
|
|
191
|
+
const sessDir = join(wsDir, 'session-smoke-old')
|
|
192
|
+
mkdirSync(sessDir, { recursive: true })
|
|
193
|
+
const oldFile = join(sessDir, 'session.v4.jsonl.zstd')
|
|
194
|
+
writeFileSync(oldFile, '')
|
|
195
|
+
const oldStamp = new Date(Date.now() - 30 * 86400000)
|
|
196
|
+
utimesSync(oldFile, oldStamp, oldStamp)
|
|
197
|
+
utimesSync(sessDir, oldStamp, oldStamp)
|
|
198
|
+
|
|
199
|
+
// 用一份新的桩 ctx 跑 apply,并把 60 秒的首次定时立即点一次
|
|
200
|
+
const tickOnce = async (enabled) => {
|
|
201
|
+
await call(routeOf('/config'), { method: 'POST', body: { enabled } })
|
|
202
|
+
const archived = []
|
|
203
|
+
const registry = {
|
|
204
|
+
archivedSessionIds: [],
|
|
205
|
+
pinnedSessionIds: [],
|
|
206
|
+
list: () => [{ sessionIds: ['session-smoke-old'] }],
|
|
207
|
+
archiveSession: async (id) => { archived.push(id) },
|
|
208
|
+
}
|
|
209
|
+
const hostTools = []
|
|
210
|
+
const hostCtx = {
|
|
211
|
+
tools: { register: (tool) => { hostTools.push(tool) } },
|
|
212
|
+
get: (name) => (name === 'webServer' ? webServer : name === 'workspaceRegistry' ? registry : undefined),
|
|
213
|
+
effect: (fn) => {
|
|
214
|
+
const cleanup = fn()
|
|
215
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
const realSetTimeout = globalThis.setTimeout
|
|
219
|
+
globalThis.setTimeout = (fn, ms) => {
|
|
220
|
+
if (ms === 60 * 1000) { fn(); return { unref() {} } }
|
|
221
|
+
return realSetTimeout(fn, ms)
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
mod.apply(hostCtx)
|
|
225
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
226
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
227
|
+
} finally {
|
|
228
|
+
globalThis.setTimeout = realSetTimeout
|
|
229
|
+
}
|
|
230
|
+
return { archived, tools: hostTools }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const off = await tickOnce(false)
|
|
234
|
+
// 先记下定时链这一轮的归档数:手动 run 会往同一个数组里记,不能等到 run 之后再判
|
|
235
|
+
const tickArchivedWhileOff = off.archived.length
|
|
236
|
+
const manualTool = off.tools[0]
|
|
237
|
+
let manualArchived = false
|
|
238
|
+
if (manualTool) {
|
|
239
|
+
const result = await manualTool.execute({ action: 'run' })
|
|
240
|
+
manualArchived = result && result.ok === true && /已归档 1 个/.test(result.text)
|
|
241
|
+
}
|
|
242
|
+
const on = await tickOnce(true)
|
|
243
|
+
if (tickArchivedWhileOff !== 0) {
|
|
244
|
+
fail('开关生效', 'enabled=false 时兜底定时仍归档了 ' + tickArchivedWhileOff + ' 个')
|
|
245
|
+
} else if (!manualArchived) {
|
|
246
|
+
fail('开关生效', 'enabled=false 时手动 run 没有归档候选')
|
|
247
|
+
} else if (on.archived.length !== 1) {
|
|
248
|
+
fail('开关生效', 'enabled=true 时兜底定时归档 ' + on.archived.length + ' 个,应为 1')
|
|
249
|
+
} else {
|
|
250
|
+
ok('开关生效', '关闭时定时不归档、手动照常、打开后恢复')
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 7. 客户端产物 */
|
|
188
255
|
const client = readFileSync(join(root, 'lib', 'client.js'), 'utf8')
|
|
189
256
|
const idMatch = /^\s*id:\s*"([^"]+)"/m.exec(client)
|
|
190
257
|
const SLOTS = [
|
|
@@ -199,9 +266,32 @@ else if (!client.includes(pkg.name + '/api')) fail('客户端产物', '产物里
|
|
|
199
266
|
else {
|
|
200
267
|
const absent = SLOTS.filter((slot) => !client.includes(slot))
|
|
201
268
|
if (absent.length > 0) fail('客户端产物', '产物里缺 slot:' + absent.join('、'))
|
|
269
|
+
else if (!client.includes('session-autarchive-enabled')) fail('客户端产物', '产物里没有设置开关行(源码改了但没重新构建)')
|
|
202
270
|
else ok('客户端产物', 'id=' + pkg.name + ',' + Buffer.byteLength(client, 'utf8') + ' 字节')
|
|
203
271
|
}
|
|
204
272
|
|
|
273
|
+
/** 8. 失败隔离:工具注册抛错时,路由与定时器照常 */
|
|
274
|
+
{
|
|
275
|
+
const routesAfter = []
|
|
276
|
+
let effects = 0
|
|
277
|
+
const hostWeb = { port: 0, register: (route) => { routesAfter.push(route); return () => {} } }
|
|
278
|
+
const throwingCtx = {
|
|
279
|
+
tools: { register: () => { throw new Error('simulated duplicate tool') } },
|
|
280
|
+
get: (name) => (name === 'webServer' ? hostWeb : undefined),
|
|
281
|
+
effect: (fn) => {
|
|
282
|
+
effects += 1
|
|
283
|
+
const cleanup = fn()
|
|
284
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
let threw = null
|
|
288
|
+
try { mod.apply(throwingCtx) } catch (error) { threw = error }
|
|
289
|
+
if (threw) fail('失败隔离', '工具注册抛错后 apply 整体抛出:' + threw.message)
|
|
290
|
+
else if (routesAfter.length !== 4) fail('失败隔离', '工具注册抛错后注册了 ' + routesAfter.length + ' 条路由,应为 4')
|
|
291
|
+
else if (effects !== 5) fail('失败隔离', '工具注册抛错后 effect 登记 ' + effects + ' 个,应为 5(4 条路由 + 1 个定时器)')
|
|
292
|
+
else ok('失败隔离', '工具注册抛错,路由 4 条与定时器照常')
|
|
293
|
+
}
|
|
294
|
+
|
|
205
295
|
/** 收尾:清掉宿主半边的定时器与临时目录 */
|
|
206
296
|
for (const cleanup of cleanups) {
|
|
207
297
|
try {
|
package/src/entry.jsx
CHANGED
|
@@ -242,6 +242,73 @@ const ROW_CSS = `
|
|
|
242
242
|
.zsa-error { font-size:12px; line-height:18px; color:var(--dsw-alias-state-error-primary); }
|
|
243
243
|
`
|
|
244
244
|
|
|
245
|
+
/** 开关:纯内联样式,不 import 宿主组件包。 */
|
|
246
|
+
function Switch({ on, disabled, onChange }) {
|
|
247
|
+
return (
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
role="switch"
|
|
251
|
+
aria-checked={on}
|
|
252
|
+
aria-label={on ? '关闭自动归档' : '开启自动归档'}
|
|
253
|
+
disabled={disabled}
|
|
254
|
+
onClick={() => onChange(!on)}
|
|
255
|
+
style={{
|
|
256
|
+
width: 36, height: 20, flex: 'none', border: 0, borderRadius: 10, padding: 0,
|
|
257
|
+
cursor: disabled ? 'default' : 'pointer', position: 'relative',
|
|
258
|
+
background: on ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l1)',
|
|
259
|
+
opacity: disabled ? 0.5 : 1, transition: 'background .15s',
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<span style={{
|
|
263
|
+
position: 'absolute', top: 2, left: on ? 18 : 2, width: 16, height: 16,
|
|
264
|
+
borderRadius: 8, background: '#fff', transition: 'left .15s',
|
|
265
|
+
}} />
|
|
266
|
+
</button>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 通用设置里的一行:自动归档开关。
|
|
272
|
+
* 关掉后宿主定时链与浏览器侧都不再自动归档;手动 run 不受影响(宿主那边也这么判)。
|
|
273
|
+
* @returns 一行设置。
|
|
274
|
+
*/
|
|
275
|
+
export function AutoArchiveToggleRow() {
|
|
276
|
+
const state = useStore()
|
|
277
|
+
const [busy, setBusy] = useState(false)
|
|
278
|
+
const [error, setError] = useState(null)
|
|
279
|
+
const on = state.enabled !== false
|
|
280
|
+
|
|
281
|
+
const toggle = async (next) => {
|
|
282
|
+
if (busy) return
|
|
283
|
+
setBusy(true)
|
|
284
|
+
setError(null)
|
|
285
|
+
try {
|
|
286
|
+
const data = await call('POST', ROUTE + '/config', { enabled: next })
|
|
287
|
+
patchStore({ enabled: data.enabled !== false })
|
|
288
|
+
} catch (err) {
|
|
289
|
+
setError('没保存上:' + String((err && err.message) || err))
|
|
290
|
+
} finally {
|
|
291
|
+
setBusy(false)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className="zsa-item">
|
|
297
|
+
<style>{ROW_CSS}</style>
|
|
298
|
+
<div className="zsa-text">
|
|
299
|
+
<div className="zsa-title">自动归档</div>
|
|
300
|
+
<div className="zsa-desc">
|
|
301
|
+
{'关闭后后台不再自动归档;手动执行仍然可用。当前候选 ' + state.candidates.length + ' 个。'}
|
|
302
|
+
</div>
|
|
303
|
+
{error ? <div className="zsa-error" role="alert">{error}</div> : null}
|
|
304
|
+
</div>
|
|
305
|
+
<div className="zsa-field">
|
|
306
|
+
<Switch on={on} disabled={busy} onChange={(next) => void toggle(next)} />
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
245
312
|
/**
|
|
246
313
|
* 通用设置里的一行:自动归档天数。
|
|
247
314
|
* 失焦或回车即落盘(与设置里其它行一样即时生效),写的是宿主同一个 days 字段。
|
|
@@ -301,7 +368,7 @@ export function AutoArchiveRow() {
|
|
|
301
368
|
max={3650}
|
|
302
369
|
step={1}
|
|
303
370
|
value={value}
|
|
304
|
-
disabled={busy}
|
|
371
|
+
disabled={busy || state.enabled === false}
|
|
305
372
|
aria-label="自动归档天数"
|
|
306
373
|
onChange={(event) => { setDraft(event.target.value); setError(null) }}
|
|
307
374
|
onBlur={() => { void commit() }}
|
|
@@ -339,8 +406,14 @@ export function apply(ctx) {
|
|
|
339
406
|
place(ctx, 'sidebar.session.row.leading', 100, ProtectLeading)
|
|
340
407
|
place(ctx, 'sidebar.session.row.hover', 100, ProtectHover)
|
|
341
408
|
|
|
342
|
-
//
|
|
409
|
+
// 通用设置里两行,排在「当前版本」上面(order 94/95,当前版本是 100):
|
|
410
|
+
// 「自动归档」开关与「自动归档天数」。先开关后天数,与页面从上到下的阅读顺序一致。
|
|
343
411
|
try {
|
|
412
|
+
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
413
|
+
name: 'settings.general.item',
|
|
414
|
+
id: 'session-autarchive-enabled',
|
|
415
|
+
order: 94,
|
|
416
|
+
}, AutoArchiveToggleRow))
|
|
344
417
|
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
345
418
|
name: 'settings.general.item',
|
|
346
419
|
id: 'session-autarchive-days',
|
package/invariant.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
const PACKAGE_NAME = 'dsh-session-autarchive'
|
|
2
|
-
|
|
3
|
-
/** Cordis companion plugin name. */
|
|
4
|
-
export const name = 'dsh-session-autarchive-invariant'
|
|
5
|
-
/** Service required before the companion can reserve package ownership. */
|
|
6
|
-
export const inject = ['invariants']
|
|
7
|
-
|
|
8
|
-
const install = () => {}
|
|
9
|
-
|
|
10
|
-
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|