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 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
- Compatible with the DSH 0.2.1 line (alpha / rc / stable); 0.3 and later are not guaranteed.
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
- 兼容:DSH 0.2.1 系列(alpha / rc / 正式版均可);0.3 起不保证。
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
- ctx.tools.register({
553
- name: 'dsh-session-autarchive',
554
- description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config 改阈值与开关。判定口径=会话内最后一条对话结束事件(turn/end)的时间——只是被打开/恢复过(会追写开场提示)不算活动;读不到事件时退回文件最后写入时间。已归档、保护名单内、置顶(可关)都跳过。浏览器半边每 5 分钟按同一份清单自动执行,侧栏右键菜单的「不自动归档」也写这份名单。',
555
- parameters: {
556
- type: 'object',
557
- properties: {
558
- action: { type: 'string', description: '要做什么:status(默认,看状态)|run(立刻归档候选)|protect(标记不自动归档)|unprotect(取消标记)|config(改阈值/开关)' },
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: { ok: { type: 'boolean' }, text: { type: 'string' } },
572
- required: ['ok', 'text'],
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
- render: (_args, value) => [{ type: 'text', text: value.text }],
576
- },
577
- async execute(args) {
578
- const action = String((args && args.action) || 'status')
579
- let state = readState()
580
-
581
- if (action === 'status') {
582
- return { ok: true, text: statusText(ctx, state) }
583
- }
584
-
585
- if (action === 'run') {
586
- const result = await runArchive(ctx, state, args && args.dryRun === true)
587
- const head = args && args.dryRun === true
588
- ? '干跑:候选 ' + result.candidates.length + ' 个,未动手'
589
- : '已归档 ' + result.archived.length + ' 个' + (result.failed.length > 0 ? ',失败 ' + result.failed.length + ' 个' : '')
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
- return { ok: true, text: lines.join('\n') }
597
- }
598
-
599
- if (action === 'protect' || action === 'unprotect') {
600
- const sessionId = String((args && args.sessionId) || '')
601
- if (!/^session-/.test(sessionId)) return { ok: false, text: 'protect/unprotect 需要 sessionId(形如 session-xxxxxxxx-…)' }
602
- const keep = new Set(state.protected)
603
- if (action === 'protect') keep.add(sessionId)
604
- else keep.delete(sessionId)
605
- state = { ...state, protected: [...keep] }
606
- writeProtected(state.protected)
607
- audit((action === 'protect' ? 'protect ' : 'unprotect ') + sessionId + ',名单现有 ' + state.protected.length + ' 个')
608
- return {
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
- if (action === 'config') {
616
- const next = { ...state }
617
- if (args.days !== undefined) next.days = Number(args.days) > 0 ? Number(args.days) : next.days
618
- if (args.enabled !== undefined) next.enabled = args.enabled === true
619
- if (args.exemptPinned !== undefined) next.exemptPinned = args.exemptPinned === true
620
- writeState(next)
621
- return { ok: true, text: '已更新:' + describe(next) }
622
- }
623
-
624
- return { ok: false, text: '不认识的 action:' + action + '(可用 status/run/protect/unprotect/config)' }
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
- const tick = () => { void runArchive(ctx, readState(), false).catch(() => { /* 这轮不行,下一轮再来 */ }) }
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",
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 <0.3.0-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 && node --check invariant.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. 客户端产物 —— 外壳 id == 包名,产物里带上了路由与四个 slot 名
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
- // 通用设置里一行,排在「当前版本」上面(order 95,当前版本是 100):自动归档天数。
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))