@1e0zj/dsh-plugin-mall 0.3.1 → 0.3.3

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/src/index.js CHANGED
@@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
25
25
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
26
26
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
27
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
28
- import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
28
+ import { preflightInstall, inspectRemoteCandidate, recoverProfile, describeRollbackRebuild } from "./guard.js";
29
29
 
30
30
  export const name = "@1e0zj/dsh-plugin-mall";
31
31
  // `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
@@ -144,6 +144,13 @@ export function computeProfileFingerprint(profileDir) {
144
144
  const compatCache = new Map();
145
145
  const COMPAT_TTL = 600000;
146
146
 
147
+ // When this plugin instance loaded — for a host process this is effectively the
148
+ // host's start time (the loader mounts plugins at boot). Sent with `jobs` so a
149
+ // remounted client can tell "completed, restart still pending" from "completed
150
+ // and the restart already happened": a finishedAt older than this value means
151
+ // the task's Restart-dsh button has already done its job.
152
+ const pluginLoadedAt = Date.now();
153
+
147
154
  function compatCacheGet(key) {
148
155
  const cached = compatCache.get(key);
149
156
  if (cached === undefined) return undefined;
@@ -822,6 +829,9 @@ export function createJobTracker({ producerFactory } = {}) {
822
829
  record.status = status;
823
830
  record.detail = outcome?.detail;
824
831
  record.needsApproval = outcome?.needsApproval;
832
+ // 原因活不过一次重启的失败(被别的未了结事务挡住):浏览器据此在
833
+ // 重启后撤掉记录,而不是把一段现在时的描述留在面板上当现状读。
834
+ record.staleOnRestart = outcome?.staleOnRestart === true;
825
835
  record.finishedAt = Date.now();
826
836
 
827
837
  if (status === "completed") {
@@ -918,6 +928,13 @@ export function createJobTracker({ producerFactory } = {}) {
918
928
  const record = records.get(String(jobId));
919
929
  if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
920
930
  const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
931
+ const delta = typeof record.readOutput === "function"
932
+ ? record.readOutput()
933
+ : typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "";
934
+ // Accumulate the drained deltas so `list` can restore the full log after a
935
+ // remount: the polling client drains destructively, so without this the
936
+ // backend would hold no history at all.
937
+ record.log = String(record.log ?? "") + delta;
921
938
  return {
922
939
  snapshot: {
923
940
  id: record.id,
@@ -926,6 +943,7 @@ export function createJobTracker({ producerFactory } = {}) {
926
943
  status: record.status,
927
944
  detail: record.detail,
928
945
  needsApproval: record.needsApproval,
946
+ staleOnRestart: record.staleOnRestart,
929
947
  approvalToken: isSameSession ? record.approvalToken : undefined,
930
948
  // extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
931
949
  extras: isSameSession ? record.extras : undefined,
@@ -933,12 +951,49 @@ export function createJobTracker({ producerFactory } = {}) {
933
951
  startedAt: record.startedAt,
934
952
  finishedAt: record.finishedAt,
935
953
  },
936
- output: typeof record.readOutput === "function"
937
- ? record.readOutput()
938
- : typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
954
+ output: delta,
939
955
  };
940
956
  },
941
957
 
958
+ /**
959
+ * Every live record as {id, snapshot, output}, oldest first — for a freshly
960
+ * mounted client to restore its task panel. The install of a plugin whose
961
+ * bundle patch rewrites cordis.patch.yml replays the assembly tree and
962
+ * remounts this very UI mid-flight, dropping every React state; the backend
963
+ * records survive (1h/20-entry prune), so the remounted panel can show the
964
+ * finished task, its log, and the restart button instead of going blank
965
+ * with no signal at all (real incident: an update finished, the panel
966
+ * vanished, and the user learned it worked only by checking versions after
967
+ * a manual restart). Session visibility mirrors get(); dismissed records
968
+ * are skipped — "清空" must survive a remount too.
969
+ */
970
+ list(session) {
971
+ const out = [];
972
+ for (const record of records.values()) {
973
+ if (record.dismissed === true) continue;
974
+ const isSameSession = record.surface !== "browser" || (record.session !== "" && record.session === session);
975
+ out.push({
976
+ id: record.id,
977
+ snapshot: {
978
+ id: record.id,
979
+ kind: record.kind,
980
+ label: record.label,
981
+ status: record.status,
982
+ detail: record.detail,
983
+ needsApproval: record.needsApproval,
984
+ staleOnRestart: record.staleOnRestart,
985
+ approvalToken: isSameSession ? record.approvalToken : undefined,
986
+ extras: isSameSession ? record.extras : undefined,
987
+ spec: record.spec,
988
+ startedAt: record.startedAt,
989
+ finishedAt: record.finishedAt,
990
+ },
991
+ output: String(record.log ?? ""),
992
+ });
993
+ }
994
+ return out;
995
+ },
996
+
942
997
  cancel(jobId, session) {
943
998
  const record = records.get(String(jobId));
944
999
  if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
@@ -969,6 +1024,9 @@ export function createJobTracker({ producerFactory } = {}) {
969
1024
  if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
970
1025
  return false;
971
1026
  }
1027
+ // Marked, not deleted: `list` (panel restore after a remount) skips these,
1028
+ // so a cleared panel stays cleared across remounts.
1029
+ record.dismissed = true;
972
1030
  if (record.approvalToken) {
973
1031
  invalidateApprovalToken(record.approvalToken, record.session || undefined, record.surface);
974
1032
  record.approvalToken = undefined;
@@ -1482,6 +1540,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1482
1540
  return rpcFail(error);
1483
1541
  }
1484
1542
  }
1543
+ case "jobs": {
1544
+ // Panel restore for a freshly mounted client: an install that rewrites
1545
+ // cordis.patch.yml replays the assembly tree and remounts this UI,
1546
+ // dropping all React state. The task records live here.
1547
+ try {
1548
+ const session = requireBrowserSession(payload?.session);
1549
+ return rpcOk({ jobs: tracker.list(session), hostStartedAt: pluginLoadedAt });
1550
+ } catch (error) {
1551
+ return rpcFail(error);
1552
+ }
1553
+ }
1485
1554
  case "restart": {
1486
1555
  const profile = String(payload?.profile ?? defaultProfile).trim();
1487
1556
  try {
@@ -1572,6 +1641,11 @@ export function apply(ctx, config = {}) {
1572
1641
  console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
1573
1642
  } else if (result.action === "rolled-back") {
1574
1643
  console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
1644
+ // What the rebuild did, when it did anything. A rollback that relinked a
1645
+ // package used to be silent, so a reconcile that silently no-opped and a
1646
+ // fallback add that saved the profile looked exactly alike afterwards.
1647
+ const rebuild = describeRollbackRebuild(result.rebuild);
1648
+ if (rebuild !== undefined) console.warn(`[dsh-plugin-mall] startup recovery: node_modules rebuild — ${rebuild}`);
1575
1649
  }
1576
1650
  } catch (error) {
1577
1651
  // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
@@ -2291,6 +2365,52 @@ export async function runSelfTests() {
2291
2365
  const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
2292
2366
  check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
2293
2367
 
2368
+ // ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
2369
+ // 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
2370
+ // 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
2371
+ // 日志增量必须已在后端累积成全量,「清空」过的条目不得被拉回。
2372
+ {
2373
+ const pendingLines = ["line-a\n", "line-b\n"];
2374
+ const lineProducer = {
2375
+ cancel: () => {},
2376
+ done: Promise.resolve({ status: "completed", detail: "done" }),
2377
+ readOutput: () => pendingLines.shift() ?? "",
2378
+ };
2379
+ const listTracker = createJobTracker({ producerFactory: () => lineProducer });
2380
+ const listJobId = listTracker.start({
2381
+ profile: "web",
2382
+ spec: "log-pkg",
2383
+ profileDir,
2384
+ surface: "browser",
2385
+ session: "session-alpha",
2386
+ });
2387
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
2388
+ const drain1 = listTracker.get(listJobId, "session-alpha").output;
2389
+ const drain2 = listTracker.get(listJobId, "session-alpha").output;
2390
+ const restoredJob = listTracker.list("session-alpha").find((entry) => entry.id === listJobId);
2391
+ check(
2392
+ "list 恢复全量日志(drain 过的增量在后端累积成完整历史)",
2393
+ drain1 === "line-a\n" && drain2 === "line-b\n"
2394
+ && restoredJob !== undefined
2395
+ && restoredJob.output === "line-a\nline-b\n"
2396
+ && restoredJob.snapshot.status === "completed"
2397
+ && restoredJob.snapshot.spec === "log-pkg",
2398
+ JSON.stringify({ drain1, drain2, restored: restoredJob?.output }),
2399
+ );
2400
+ const crossSessionJob = listTracker.list("session-beta").find((entry) => entry.id === listJobId);
2401
+ check(
2402
+ "list 对异 session 不暴露 approvalToken/extras(与 get 同规格)",
2403
+ crossSessionJob !== undefined
2404
+ && crossSessionJob.snapshot.approvalToken === undefined
2405
+ && crossSessionJob.snapshot.extras === undefined,
2406
+ );
2407
+ listTracker.dismiss(listJobId, "session-alpha");
2408
+ check(
2409
+ "dismiss 后 list 不再返回该条(「清空」跨重挂载存活)",
2410
+ listTracker.list("session-alpha").every((entry) => entry.id !== listJobId),
2411
+ );
2412
+ }
2413
+
2294
2414
  // ── 6. Windows profile names & restart plan ──────────────────────────────
2295
2415
  check("合法 profile 名称识别", isSafeProfileName("web", true) && isSafeProfileName("profile_1", true) && isSafeProfileName("dev-test", true));
2296
2416
  check("Windows 尾随点拒绝", !isSafeProfileName("web.", true) && !isSafeProfileName("test..", true));
package/src/installer.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { dump, load } from "js-yaml";
17
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
18
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
- import { commitPendingSnapshot, createProfileSnapshot, markPendingSnapshot, pnpmGuardEnv, pnpmSpawnPlan, rollbackPendingSnapshot } from "./guard.js";
19
+ import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -188,7 +188,20 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
188
188
  return result;
189
189
  }
190
190
 
191
- /** List a profile's installed plugins (dependencies with classification + version). */
191
+ /**
192
+ * List a profile's installed plugins (dependencies with classification +
193
+ * version).
194
+ *
195
+ * An install paused at the build-script approval gate is reported as the
196
+ * snapshot has it, not as the half-written profile has it: the candidate's
197
+ * scripts were never approved, dsh has not loaded it, and the next startup
198
+ * rolls it back, so calling it "installed" tells the user the opposite of what
199
+ * is true — an update would show the NEW version while the old one is what is
200
+ * actually running and what a restart restores. A paused fresh install drops
201
+ * out of the list entirely. Every consumer goes through here (the browser's
202
+ * installed panel, the `updates` check that decides whether an update button
203
+ * appears, and the `market_installed` agent tool), so they all agree.
204
+ */
192
205
  export function listInstalled(profile) {
193
206
  const dir = resolveProfileDir(profile);
194
207
  const manifestPath = join(dir, "package.json");
@@ -209,7 +222,13 @@ export function listInstalled(profile) {
209
222
  }
210
223
  return { name, version, kind };
211
224
  });
212
- return { dir, deps };
225
+ const before = pausedCandidateBeforeState(dir);
226
+ if (before === undefined) return { dir, deps };
227
+ if (!before.present) return { dir, deps: deps.filter((dep) => dep.name !== before.name) };
228
+ return {
229
+ dir,
230
+ deps: deps.map((dep) => (dep.name === before.name ? { ...dep, version: before.version ?? "?" } : dep)),
231
+ };
213
232
  }
214
233
 
215
234
  // ── npm registry resolution ─────────────────────────────────────────────────
@@ -1094,6 +1113,9 @@ export function createJobTracker() {
1094
1113
  // 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
1095
1114
  // 一段文本,用户看不出要批准的到底是什么。
1096
1115
  record.needsApproval = outcome.needsApproval;
1116
+ // 这次失败的原因活不过一次重启(见 failedNow),浏览器据此在重启后
1117
+ // 撤掉记录,而不是把一段现在时的描述当成当前状态留在面板上。
1118
+ record.staleOnRestart = outcome.staleOnRestart === true;
1097
1119
  record.finishedAt = Date.now();
1098
1120
  onSettled?.(outcome);
1099
1121
  });
@@ -1112,6 +1134,7 @@ export function createJobTracker() {
1112
1134
  status: record.status,
1113
1135
  detail: record.detail,
1114
1136
  needsApproval: record.needsApproval,
1137
+ staleOnRestart: record.staleOnRestart,
1115
1138
  spec: record.spec,
1116
1139
  startedAt: record.startedAt,
1117
1140
  finishedAt: record.finishedAt,
@@ -1444,17 +1467,52 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1444
1467
  // first live pnpm add runs. These are the files that decide what pnpm
1445
1468
  // installs and what dsh loads; the marker is what lets startup/CLI recovery
1446
1469
  // roll the profile back if the plugin proves unloadable.
1447
- let snapshot;
1448
- try {
1449
- snapshot = createProfileSnapshot(profileDir, { spec });
1450
- } catch (error) {
1451
- return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} refusing to touch the profile`);
1452
- }
1470
+ //
1471
+ // An existing marker means one of two things. A needsApproval pause from a
1472
+ // previous attempt of THIS SAME install resumes: its snapshot keeps the
1473
+ // rollback target at "before this install first began", and re-snapshotting
1474
+ // now would capture the paused half-installed state as the rollback target.
1475
+ // Anything else (different spec, remove transaction, corrupt marker) is
1476
+ // refused — the recovery path owns it.
1477
+ let existingMarker;
1453
1478
  try {
1454
- markPendingSnapshot(snapshot, { spec, preflight });
1479
+ existingMarker = readValidatedPendingSnapshot(profileDir);
1455
1480
  } catch (error) {
1456
- rmSync(snapshot.dir, { recursive: true, force: true });
1457
- return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
1481
+ return failedNow(`profile 里有一个读不出来的安装记录,无法判断它是什么,因此拒绝安装 ${spec}(${error.message})。请先运行 \`dsh-plugin-guard guard recover\` 处理它。`);
1482
+ }
1483
+ if (existingMarker !== undefined) {
1484
+ const previous = existingMarker.metadata?.spec ?? existingMarker.metadata?.packageName ?? "unknown";
1485
+ if (existingMarker.operation !== "install" || existingMarker.metadata?.spec !== spec) {
1486
+ // 拒绝是对的(marker 是一次性事务,不能被覆盖),但用户看不见 marker,
1487
+ // 所以要说清楚挡路的是什么、以及怎么让它让开。暂停在批准闸的那种最
1488
+ // 常见——它正是用户刚点过取消的那次安装。
1489
+ const paused = pendingApprovalPaused(existingMarker) !== undefined;
1490
+ const what = existingMarker.operation === "remove" ? "卸载" : "安装";
1491
+ return failedNow(paused
1492
+ ? `${previous} 的${what}还没做完——它停在「允许安装依赖」那一步等你决定,没有批准就不会真正装上。现在无法安装 ${spec}。重启 dsh 会撤回那次未批准的${what},之后就能重新操作;也可以运行 \`dsh-plugin-guard guard recover\` 立即撤回。`
1493
+ : `${previous} 的${what}还没了结,现在无法安装 ${spec}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理。`,
1494
+ { staleOnRestart: true });
1495
+ }
1496
+ push(`[dsh-plugin-mall] resuming the paused install transaction for ${spec} — its original snapshot stays the rollback target\n`);
1497
+ // 事务复活:清掉暂停标记,否则重试成功后的启动提交会被它拦下错误回滚。
1498
+ try {
1499
+ clearPendingApprovalPause(profileDir);
1500
+ } catch (pauseError) {
1501
+ push(`[dsh-plugin-mall] WARNING: could not clear the approval-pause mark: ${pauseError.message}\n`);
1502
+ }
1503
+ } else {
1504
+ let snapshot;
1505
+ try {
1506
+ snapshot = createProfileSnapshot(profileDir, { spec });
1507
+ } catch (error) {
1508
+ return failedNow(`cannot snapshot profile before installing ${spec}: ${error.message} — refusing to touch the profile`);
1509
+ }
1510
+ try {
1511
+ markPendingSnapshot(snapshot, { spec, preflight });
1512
+ } catch (error) {
1513
+ rmSync(snapshot.dir, { recursive: true, force: true });
1514
+ return failedNow(`cannot register the install pending marker for ${spec}: ${error.message} — refusing to touch the profile`);
1515
+ }
1458
1516
  }
1459
1517
 
1460
1518
  // Neutralize existing allowBuilds before every first pnpm add so strict-dep-builds
@@ -1687,6 +1745,27 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1687
1745
  }
1688
1746
  if (result.status === "completed") {
1689
1747
  // Keep the marker as-is
1748
+ } else if (Array.isArray(result.needsApproval) && result.needsApproval.length > 0) {
1749
+ // needsApproval is a PAUSE awaiting the user's decision, not a terminal
1750
+ // failure — do not roll back. Rolling back would tear out the candidate
1751
+ // pnpm just installed and rewrite the manifest to the old version, so
1752
+ // the retry's profile fingerprint and preflight report drift with the
1753
+ // changed on-disk state and the approval token's digest check can never
1754
+ // pass (real incident: dsh-better-sidebar 0.12.3 → 0.13.0 update died
1755
+ // exactly here, "invalid approval token: preflight report changed").
1756
+ // The retry's rebuild branch also needs this tree in place. Leave the
1757
+ // on-disk state and the marker for the token retry; an abandoned pause
1758
+ // is settled by startup recovery / `guard recover`, whose rollback
1759
+ // target is still the pre-first-attempt snapshot. The pause is also
1760
+ // marked ON the marker: without the mark a restart that passes the
1761
+ // static validation would commit the never-approved version and drop
1762
+ // the snapshot (both recovery commit points check it).
1763
+ try {
1764
+ markPendingApprovalPause(profileDir);
1765
+ } catch (pauseError) {
1766
+ push(`[dsh-plugin-mall] WARNING: could not mark the pause on the pending marker: ${pauseError.message}\n`);
1767
+ }
1768
+ push("\n[dsh-plugin-mall] install paused for build-script approval — the candidate stays installed with its scripts blocked; approve in the UI to finish, or restart dsh / run `dsh-plugin-guard guard recover` to roll back\n");
1690
1769
  } else {
1691
1770
  try {
1692
1771
  rollbackPendingSnapshot(profileDir);
@@ -1713,10 +1792,21 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1713
1792
  // ── the background uninstall job ────────────────────────────────────────────
1714
1793
 
1715
1794
  /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
1716
- function failedNow(detail) {
1795
+ /**
1796
+ * @param staleOnRestart - true when this failure's CAUSE cannot outlive a
1797
+ * restart, so the browser should drop the record instead of keeping it as
1798
+ * history. Only the "another transaction owns this profile" refusals qualify:
1799
+ * startup recovery settles that transaction on the way up, so the message
1800
+ * ("X is still waiting at the approval gate, so Y cannot install") describes a
1801
+ * situation that is guaranteed gone — and it is written in the present tense,
1802
+ * so a reader after the restart takes it for the current state. Ordinary
1803
+ * failures (network, preflight blockers, pnpm errors) may well still apply
1804
+ * after a restart and keep their diagnostic value, so they stay.
1805
+ */
1806
+ function failedNow(detail, { staleOnRestart = false } = {}) {
1717
1807
  return {
1718
1808
  cancel: () => {},
1719
- done: Promise.resolve({ status: "failed", detail }),
1809
+ done: Promise.resolve({ status: "failed", detail, staleOnRestart }),
1720
1810
  readOutput: () => "",
1721
1811
  };
1722
1812
  }
@@ -1752,7 +1842,11 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn }, selfHeale
1752
1842
  // hard as a valid one, and is left untouched for the recovery path.
1753
1843
  const markerPath = pendingMarkerPath(profileDir);
1754
1844
  if (existsSync(markerPath)) {
1755
- return failedNow(`profile "${profile}" has a pending install transaction (${markerPath}) — refusing to remove ${packageName} until it is resolved; restart dsh (startup recovery) or run \`dsh-plugin-guard guard recover\` first`);
1845
+ // 存在性判断(同 markPendingSnapshot):坏 marker 也照样挡路。所以这里
1846
+ // 只能拿到「有」而拿不到「是什么」,文案相应保持笼统,但仍要给出路。
1847
+ return failedNow(
1848
+ `profile "${profile}" 里有一个还没了结的安装事务,在它了结之前无法卸载 ${packageName}。重启 dsh 会自动了结它(装好的提交、没批准的撤回),也可以运行 \`dsh-plugin-guard guard recover\` 手动处理(事务记录:${markerPath})。`,
1849
+ { staleOnRestart: true });
1756
1850
  }
1757
1851
  const manifestPath = join(profileDir, "package.json");
1758
1852
  if (!existsSync(manifestPath)) {
@@ -2093,7 +2187,9 @@ async function runTransactionFixtures() {
2093
2187
  }
2094
2188
 
2095
2189
  // 1a. exit 0 + "Ignored build scripts" 且未批准:必须停在批准闸(failed +
2096
- // needsApproval),携带 proof,绝不 finalize,回滚收掉 marker,且只 spawn 一次。
2190
+ // needsApproval),携带 proof,绝不 finalize,且是暂停不是失败——现场与
2191
+ // marker 原样保留给带 token 的重试(回滚会让重试的 approval token 必死,
2192
+ // 见收尾处的真实事故注释),只 spawn 一次。
2097
2193
  {
2098
2194
  const { profileDir, cleanup } = makeTempProfile("ignored-gate");
2099
2195
  try {
@@ -2118,8 +2214,10 @@ async function runTransactionFixtures() {
2118
2214
  }],
2119
2215
  });
2120
2216
  const outcome = await producer.done;
2217
+ const output = producer.readOutput();
2218
+ const markerBefore = pendingMarkerPath(profileDir);
2121
2219
  check(
2122
- "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize",
2220
+ "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2123
2221
  outcome.status === "failed"
2124
2222
  && Array.isArray(outcome.needsApproval)
2125
2223
  && outcome.needsApproval.some((entry) => entry.name === "node-pty")
@@ -2133,9 +2231,101 @@ async function runTransactionFixtures() {
2133
2231
  && entry.contentHash !== "0".repeat(64)
2134
2232
  && entry.weeklyDownloads === 123)
2135
2233
  && calls.length === 1
2136
- && !existsSync(pendingMarkerPath(profileDir)),
2137
- `status=${outcome.status} calls=${calls.length} marker=${existsSync(pendingMarkerPath(profileDir))}`,
2234
+ && existsSync(markerBefore)
2235
+ && /paused for build-script approval/.test(output),
2236
+ `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2138
2237
  );
2238
+ check(
2239
+ "暂停必须落盘到 marker(metadata.paused)——重启后的恢复靠它区分「装完待验证」与「停在批准闸被放弃」",
2240
+ (() => {
2241
+ try {
2242
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2243
+ return marker?.metadata?.paused?.reason === "paused for build-script approval";
2244
+ } catch { return false; }
2245
+ })(),
2246
+ );
2247
+
2248
+ // 1a-ter. 暂停期间装别的包:拒绝是对的(marker 是一次性事务),但用户
2249
+ // 看不见 marker,所以报错必须点名挡路的是那次「停在允许安装依赖」的
2250
+ // 安装,并给出让它让开的办法。这里 marker 仍带 paused。
2251
+ {
2252
+ const during = scriptedSpawn([{ code: 0, out: "Done\n" }]);
2253
+ const duringOutcome = await runInstall({
2254
+ profile: "p",
2255
+ spec: "other-during-pause",
2256
+ preflight: preflightStub("other-during-pause"),
2257
+ _profileDir: profileDir,
2258
+ _spawn: during.spawnFn,
2259
+ _describe: async () => [],
2260
+ }).done;
2261
+ check(
2262
+ // staleOnRestart:这条报错是现在时写的,而挡路的事务必然被启动恢复
2263
+ // 了结——留到重启之后会被当成当前状态读,所以面板要撤掉它。
2264
+ "暂停期间装别的包 → 拒绝,报错点名「停在允许安装依赖」+ 撤回办法 + 标记重启后失效",
2265
+ duringOutcome.status === "failed"
2266
+ && /停在「允许安装依赖」/.test(duringOutcome.detail ?? "")
2267
+ && /重启 dsh/.test(duringOutcome.detail ?? "")
2268
+ && duringOutcome.staleOnRestart === true
2269
+ && during.calls.length === 0,
2270
+ `status=${duringOutcome.status} detail=${(duringOutcome.detail ?? "").slice(0, 120)}`,
2271
+ );
2272
+ }
2273
+
2274
+ // 1a-bis. 同 spec 重试接管暂停的 marker:不再新建快照(回滚目标仍是
2275
+ // 第一次安装前的现场),继续 spawn pnpm;这次给批准后的成功路径——
2276
+ // completed 后 marker 保留给启动提交,且暂停标记必须已被接管清掉
2277
+ // (否则启动提交会被它拦下错误回滚)。异 spec 则拒绝且不 spawn。
2278
+ {
2279
+ const snapshotRootDir = join(dirname(dirname(profileDir)), "guard", "snapshots");
2280
+ const snapshotsBefore = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2281
+ const retry = scriptedSpawn([{ code: 0, out: "Done in 1s\n" }]);
2282
+ const retryProducer = runInstall({
2283
+ profile: "p",
2284
+ spec: "some-plugin",
2285
+ preflight: preflightStub("some-plugin"),
2286
+ _profileDir: profileDir,
2287
+ _spawn: retry.spawnFn,
2288
+ _describe: async () => [],
2289
+ });
2290
+ const retryOutcome = await retryProducer.done;
2291
+ const retryOutput = retryProducer.readOutput();
2292
+ const snapshotsAfter = readdirSync(snapshotRootDir, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
2293
+ check(
2294
+ "同 spec 重试接管暂停的 marker → 复用原快照、继续安装并清掉暂停标记",
2295
+ retryOutcome.status === "completed"
2296
+ && retry.calls.length === 1
2297
+ && /resuming the paused install transaction/.test(retryOutput)
2298
+ && snapshotsAfter === snapshotsBefore
2299
+ && existsSync(markerBefore)
2300
+ && (() => {
2301
+ try {
2302
+ const marker = JSON.parse(readFileSync(markerBefore, "utf8"));
2303
+ return marker?.metadata?.paused === undefined;
2304
+ } catch { return false; }
2305
+ })(),
2306
+ `status=${retryOutcome.status} calls=${retry.calls.length} snapshots=${snapshotsBefore}->${snapshotsAfter}`,
2307
+ );
2308
+ const other = scriptedSpawn([{ code: 0, out: "Done\n" }]);
2309
+ const otherOutcome = await runInstall({
2310
+ profile: "p",
2311
+ spec: "another-plugin",
2312
+ preflight: preflightStub("another-plugin"),
2313
+ _profileDir: profileDir,
2314
+ _spawn: other.spawnFn,
2315
+ _describe: async () => [],
2316
+ }).done;
2317
+ check(
2318
+ // 此刻 paused 已被上面的接管清掉,所以报错走的是「未了结」那一支,
2319
+ // 而不是「停在允许安装依赖」那一支。
2320
+ "异 spec 遇既有(已非暂停)marker → 拒绝且不 spawn",
2321
+ otherOutcome.status === "failed"
2322
+ && /还没了结/.test(otherOutcome.detail ?? "")
2323
+ && !/停在「允许安装依赖」/.test(otherOutcome.detail ?? "")
2324
+ && otherOutcome.staleOnRestart === true
2325
+ && other.calls.length === 0,
2326
+ `status=${otherOutcome.status} calls=${other.calls.length}`,
2327
+ );
2328
+ }
2139
2329
  const call = calls[0] ?? { args: [], options: {} };
2140
2330
  check(
2141
2331
  "实装 argv/env:strict-dep-builds + peer 关闭 + cwd/shell 正确",
@@ -2468,6 +2658,8 @@ async function runTransactionFixtures() {
2468
2658
  && installOutcome.status === "failed"
2469
2659
  && removeOutcome.status === "failed"
2470
2660
  && /not a dependency/.test(removeOutcome.detail ?? "")
2661
+ // 普通失败不标记:重启治不好「这个包本来就不在依赖里」。
2662
+ && removeOutcome.staleOnRestart !== true
2471
2663
  && procs.length === 1,
2472
2664
  `procs=${procs.length} install=${installOutcome.status} remove=${removeOutcome.status} ${JSON.stringify(removeOutcome.detail)}`,
2473
2665
  );
@@ -2496,9 +2688,12 @@ async function runTransactionFixtures() {
2496
2688
  const snapshotsDir = join(dirname(marker), "snapshots");
2497
2689
  const leftoverSnapshots = existsSync(snapshotsDir) ? readdirSync(snapshotsDir) : [];
2498
2690
  check(
2499
- "损坏/既有 pending marker install 不 spawn、不覆盖证据、不遗留新 snapshot",
2691
+ // 不标 staleOnRestart:损坏的 marker 重启后仍然 fail-closed 留给人工
2692
+ // 检查,不会被启动恢复了结——这条失败的原因活得过重启,面板要留着。
2693
+ "损坏/既有 pending marker → install 不 spawn、不覆盖证据、不遗留新 snapshot,且不标记重启后失效",
2500
2694
  outcome.status === "failed"
2501
- && /already has a pending install marker/.test(outcome.detail ?? "")
2695
+ && /读不出来的安装记录/.test(outcome.detail ?? "")
2696
+ && outcome.staleOnRestart !== true
2502
2697
  && calls.length === 0
2503
2698
  && readFileSync(marker, "utf8") === corruptBytes
2504
2699
  && leftoverSnapshots.length === 0,
@@ -2521,7 +2716,8 @@ async function runTransactionFixtures() {
2521
2716
  check(
2522
2717
  "pending marker 存在 → remove 拒绝执行且不 spawn pnpm,marker 保留",
2523
2718
  outcome.status === "failed"
2524
- && /pending install transaction/.test(outcome.detail ?? "")
2719
+ && /还没了结的安装事务/.test(outcome.detail ?? "")
2720
+ && outcome.staleOnRestart === true
2525
2721
  && procs.length === 0
2526
2722
  && existsSync(pendingMarkerPath(profileDir)),
2527
2723
  `status=${outcome.status} procs=${procs.length} ${JSON.stringify(outcome.detail)}`,