@1e0zj/dsh-plugin-mall 0.4.15 → 0.4.17

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/client.js +171 -30
  3. package/src/index.js +64 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -128,10 +128,73 @@ window.__ModuleLoader__.load({
128
128
  return String(e && e.message ? e.message : e);
129
129
  }
130
130
 
131
+ // ── 更新按钮的防连点判据 ────────────────────────────────────────────────
132
+ //
133
+ // 纯函数,不碰 React;index.js 的 --self-test 把这一整段从本文件里原样抠
134
+ // 出来跑(同下面 session nonce 那段的做法),所以改坏了这里测试会红。
135
+ // 往段里加函数会自动被测到,把函数搬出去则当场 ReferenceError。
136
+
131
137
  function isJobActive(status) {
132
138
  return status === "running" || status === "stopping";
133
139
  }
134
140
 
141
+ // spec → npm 包名。**按包名、不按 spec** 是这段的关键:同一条更新链在不同
142
+ // 阶段的 spec 并不一样(点的是 foo@1.2.3,服务端防抢注解析后 track 存下的
143
+ // 可能是别的形态),按 spec 盯就会在解析之后丢失目标。
144
+ // 与服务端 npmNameOf(github.js)同一套形状;非 npm 形态返回 null。
145
+ function specPackageName(spec) {
146
+ if (typeof spec !== "string" || spec.length === 0) return null;
147
+ if (spec.charAt(0) === "@") {
148
+ var scoped = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(spec);
149
+ return scoped === null ? null : scoped[1];
150
+ }
151
+ var bare = /^([^@/\s:]+)(?:@[^/\s]+)?$/.exec(spec);
152
+ return bare === null ? null : bare[1];
153
+ }
154
+
155
+ // 这个包上有没有一条没了结的更新链。三段接力,中间不能断:
156
+ //
157
+ // updating 点击 → 预检 job 建立。这段 jobs 里还什么都没有,只有同步
158
+ // 的 ref 挡得住同一拍里的第二次点击。
159
+ // installing 预检落终态 → 安装 job 建立。预检那条已经不 active 了,
160
+ // 安装那条还没 track 上,中间这一拍靠它顶住。
161
+ // jobs job 全程。**RPC 返回只意味着 job 建好了,pnpm 还没开始跑**
162
+ // ——锁必须持续到 job 落终态,不是到 RPC 返回。
163
+ // needsApproval 的 status 是 failed,但那是等用户决定的暂停,
164
+ // 事务没结束,算未了结。
165
+ // cards 预检出了风险卡片,停在那里等用户读完再决定。这期间任务是
166
+ // 终态,但决定还没做,再起一条链就是两条链抢同一个事务。
167
+ // **按 jobId 存字典、不是单槽位**:同时可以停着好几张卡片
168
+ // (批量更新必然如此,手动连点两个包也够)。单槽位时后来的
169
+ // 会顶掉前一张,被顶掉那条从此没有任何入口能做决定,job 却
170
+ // 一直是未了结状态——那一行的更新按钮再也点不亮。
171
+ //
172
+ // sources 里递进来的必须是 ref.current 这类当下的值:判定发生在点击那一
173
+ // 刻,上一次渲染的快照没有意义。
174
+ function updateChainBusy(sources, name) {
175
+ if (name === null || name === undefined) return false;
176
+ if ((sources.updating || {})[name] === true) return true;
177
+ var installing = sources.installing || {};
178
+ for (var spec in installing) {
179
+ if (installing[spec] === true && specPackageName(spec) === name) return true;
180
+ }
181
+ var cards = sources.cards || {};
182
+ for (var cardId in cards) {
183
+ var card = cards[cardId];
184
+ if (card && specPackageName(card.spec) === name) return true;
185
+ }
186
+ var jobs = sources.jobs || {};
187
+ for (var id in jobs) {
188
+ var job = jobs[id];
189
+ if (!job || typeof job.spec !== "string") continue;
190
+ if (specPackageName(job.spec) !== name) continue;
191
+ if (isJobActive(job.status)) return true;
192
+ if (Array.isArray(job.needsApproval) && job.needsApproval.length > 0) return true;
193
+ }
194
+ return false;
195
+ }
196
+ // ── 更新按钮的防连点判据结束 ────────────────────────────────────────────
197
+
135
198
  function kindLabel(kind) {
136
199
  if (kind === "bundle") return "宿主插件层";
137
200
  if (kind === "client") return "浏览器UI插件";
@@ -344,7 +407,8 @@ window.__ModuleLoader__.load({
344
407
  }
345
408
  commit(next);
346
409
  }, [commit]);
347
- return { jobs: jobs, track: track, drop: drop, setStatus: setStatus, restore: restore };
410
+ // jobsRef 一并交出去:防连点的判定读的是当下的值,不是渲染快照。
411
+ return { jobs: jobs, jobsRef: jobsRef, track: track, drop: drop, setStatus: setStatus, restore: restore };
348
412
  }
349
413
 
350
414
  // ── plugin verification badge ───────────────────────────────────────────
@@ -607,7 +671,7 @@ window.__ModuleLoader__.load({
607
671
  if (ids.length === 0 && !props.error) return null;
608
672
  var hasSettled = ids.some(function (id) {
609
673
  var job = props.jobs[id];
610
- var hasDecision = (props.preflight && props.preflight.jobId === id)
674
+ var hasDecision = props.preflights[id] !== undefined
611
675
  || (job.needsApproval && job.needsApproval.length > 0);
612
676
  return !isJobActive(job.status) && !hasDecision;
613
677
  });
@@ -620,7 +684,7 @@ window.__ModuleLoader__.load({
620
684
  ids.map(function (id) {
621
685
  var job = props.jobs[id];
622
686
  var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
623
- var preflightDecision = props.preflight && props.preflight.jobId === id ? props.preflight : null;
687
+ var preflightDecision = props.preflights[id] || null;
624
688
  var awaitingDecision = preflightDecision !== null || (job.needsApproval && job.needsApproval.length > 0);
625
689
  return h("div", { key: id, className: "mkt_job" },
626
690
  // 不再显示 market-N 这种内部 id——对用户没有意义,反而让人以为
@@ -640,12 +704,12 @@ window.__ModuleLoader__.load({
640
704
  ? h(ApprovalRequest, {
641
705
  spec: job.spec,
642
706
  needsApproval: job.needsApproval,
643
- busy: props.approving === job.spec,
707
+ busy: props.specBusy(job.spec),
644
708
  onApprove: function (names) {
645
709
  // 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
646
710
  // (撤条目 + 日志接续一拍完成)。install RPC 现在只做本地校验、
647
711
  // 立刻返回新 job id,但保留这个次序仍然是它最稳的形态——
648
- // 万一 RPC 失败,旧条目和日志还在。等待期间按钮由 approving
712
+ // 万一 RPC 失败,旧条目和日志还在。等待期间按钮由 specBusy
649
713
  // 态显示「继续中…」。
650
714
  props.onApprove(job.spec, names, job.approvalToken, id);
651
715
  },
@@ -655,9 +719,9 @@ window.__ModuleLoader__.load({
655
719
  ? h(PreflightCard, {
656
720
  spec: preflightDecision.spec,
657
721
  report: preflightDecision.report,
658
- busy: props.preflightBusy === true,
659
- onConfirm: props.onConfirmPreflight,
660
- onClose: props.onClosePreflight,
722
+ busy: props.specBusy(preflightDecision.spec),
723
+ onConfirm: function () { props.onConfirmPreflight(id); },
724
+ onClose: function () { props.onClosePreflight(id); },
661
725
  })
662
726
  : job.detail ? h("div", { className: job.status === "failed" ? "mkt_error" : "mkt_desc" }, job.detail) : null,
663
727
  // 预检完成不提示重启——它没有改动任何东西,接下来才是安装。
@@ -715,6 +779,7 @@ window.__ModuleLoader__.load({
715
779
  var togglable = entry !== undefined && dep.name !== MARKET_PACKAGE;
716
780
  var enabled = entry === undefined ? true : entry.enabled !== false;
717
781
  var toggling = (props.toggling || {})[dep.name] === true;
782
+ var updating = props.isUpdating(dep.name);
718
783
  return h("div", { key: dep.name, className: "mkt_depRow" },
719
784
  h("span", { className: "mkt_desc" + (enabled ? "" : " mkt_depOff") }, dep.name + "@" + dep.version),
720
785
  h("span", { className: "mkt_depActions" },
@@ -728,8 +793,11 @@ window.__ModuleLoader__.load({
728
793
  }, h("span", { className: "mkt_switchKnob" })) : null,
729
794
  upd && upd.hasUpdate ? h("button", {
730
795
  className: "mkt_btn mkt_btnSm",
731
- onClick: function () { props.onInstallSpec(dep.name + "@" + upd.latest); },
732
- }, "更新至 " + upd.latest) : null,
796
+ // doUpdate 里的守卫读同一个判据,两边不会漂移——不一致
797
+ // 就会出现「按钮看着能点,点了什么也不发生」。
798
+ disabled: updating,
799
+ onClick: function () { props.onUpdate(dep.name, dep.name + "@" + upd.latest); },
800
+ }, updating ? "更新中…" : "更新至 " + upd.latest) : null,
733
801
  h("button", {
734
802
  className: "mkt_btn mkt_btnDanger mkt_btnSm",
735
803
  disabled: busy,
@@ -812,9 +880,32 @@ window.__ModuleLoader__.load({
812
880
  var _hostStartedAt = useState(0);
813
881
  var hostStartedAt = _hostStartedAt[0];
814
882
  var setHostStartedAt = _hostStartedAt[1];
815
- var _preflight = useState(null);
816
- var preflight = _preflight[0];
817
- var setPreflight = _preflight[1];
883
+ // jobId -> { spec, report, jobId }。字典而不是单个:见 updateChainBusy
884
+ // cards 一节——同时可以有好几张待决策的风险卡片。
885
+ var _preflights = useState({});
886
+ var preflights = _preflights[0];
887
+ var setPreflights = _preflights[1];
888
+ var dropPreflight = useCallback(function (jobId) {
889
+ setPreflights(function (prev) {
890
+ if (!(jobId in prev)) return prev;
891
+ var next = Object.assign({}, prev);
892
+ delete next[jobId];
893
+ return next;
894
+ });
895
+ }, []);
896
+
897
+ // 更新按钮防连点用的三份当下值(见 updateChainBusy)。ref 而不是 state:
898
+ // 判定发生在点击那一刻,而 setState 到重渲染之间还有一拍能点。ref 不触发
899
+ // 渲染,所以每次改动配一次 bumpBusy,让按钮当场变样。
900
+ var updatingRef = useRef({}); // 包名 -> true,点击到预检 job 建立之间
901
+ var installingRef = useRef({}); // spec -> true,安装 RPC 在途
902
+ var preflightsRef = useRef({});
903
+ preflightsRef.current = preflights; // 渲染期同步,同 resultsRef 的写法
904
+ var _busyTick = useState(0);
905
+ var setBusyTick = _busyTick[1];
906
+ var bumpBusy = useCallback(function () {
907
+ setBusyTick(function (n) { return n + 1; });
908
+ }, []);
818
909
 
819
910
  // Opaque one-shot approval tokens issued from needsApproval outcomes (spec -> token).
820
911
  // Cleared on success, unrelated failure, cancel, modal close, or spec change.
@@ -983,6 +1074,17 @@ window.__ModuleLoader__.load({
983
1074
  var polling = useJobPolling(call, onJobSettled, onApprovalToken, onPreflightSettled);
984
1075
  var track = polling.track;
985
1076
  var jobs = polling.jobs;
1077
+ var jobsRef = polling.jobsRef;
1078
+
1079
+ // 按钮的 disabled 和 handler 里的守卫都读这一个函数,两边不会漂移。
1080
+ var updateBusy = useCallback(function (packageName) {
1081
+ return updateChainBusy({
1082
+ updating: updatingRef.current,
1083
+ installing: installingRef.current,
1084
+ cards: preflightsRef.current,
1085
+ jobs: jobsRef.current,
1086
+ }, packageName);
1087
+ }, [jobsRef]);
986
1088
  var dropJob = polling.drop;
987
1089
  var setJobStatus = polling.setStatus;
988
1090
 
@@ -1000,6 +1102,10 @@ window.__ModuleLoader__.load({
1000
1102
  }, []);
1001
1103
 
1002
1104
  var doRawInstall = useCallback(function (spec, extra, carryFromId) {
1105
+ // ref 与 state 同时置位:state 负责触发渲染,ref 才是判定读的那一份
1106
+ // (见 updateChainBusy)。这一段是预检 job 已落终态、安装 job 还没
1107
+ // track 上的空档,中间断一拍更新按钮就会复活。
1108
+ installingRef.current[spec] = true;
1003
1109
  setInstalling(function (prev) {
1004
1110
  var next = Object.assign({}, prev, { [spec]: true });
1005
1111
  return next;
@@ -1012,6 +1118,7 @@ window.__ModuleLoader__.load({
1012
1118
  delete approvalTokensRef.current[spec];
1013
1119
  reportError("tasks", e);
1014
1120
  }).finally(function () {
1121
+ delete installingRef.current[spec];
1015
1122
  setInstalling(function (prev) {
1016
1123
  var next = Object.assign({}, prev);
1017
1124
  delete next[spec];
@@ -1041,20 +1148,29 @@ window.__ModuleLoader__.load({
1041
1148
  // 安全:接过预检的日志、撤掉预检条目,面板上只剩这一个任务。
1042
1149
  doRawInstall(spec, {}, jobId);
1043
1150
  } else {
1044
- setPreflight({ spec: spec, report: report, jobId: jobId });
1151
+ // jobId 落格,不覆盖别的包停着的卡片。
1152
+ setPreflights(function (prev) {
1153
+ var next = Object.assign({}, prev);
1154
+ next[jobId] = { spec: spec, report: report, jobId: jobId };
1155
+ return next;
1156
+ });
1045
1157
  }
1046
1158
  };
1047
1159
  }, [doRawInstall, reportError]);
1048
1160
 
1049
1161
  // 点安装 = 立刻起一个预检 job,面板马上有东西看、日志实时流。
1050
1162
  // 结论由轮询经 onPreflightSettled 交回上面的 handler,这里不等待。
1051
- var preflightAndInstall = useCallback(function (spec) {
1163
+ // onStarted:RPC 落定(不论成败)时回调一次。更新按钮用它把「点击
1164
+ // job 建立」这一段的锁交给 job,市场卡片不传,行为一字未变。
1165
+ var preflightAndInstall = useCallback(function (spec, onStarted) {
1052
1166
  delete approvalTokensRef.current[spec];
1053
1167
  setError(null);
1054
1168
  call("preflight", { spec: spec }).then(function (value) {
1055
1169
  track(value.jobId, value.spec || spec);
1056
1170
  }).catch(function (e) {
1057
1171
  reportError("tasks", e);
1172
+ }).finally(function () {
1173
+ if (typeof onStarted === "function") onStarted();
1058
1174
  });
1059
1175
  }, [call, reportError, track]);
1060
1176
 
@@ -1062,6 +1178,26 @@ window.__ModuleLoader__.load({
1062
1178
  preflightAndInstall("github:" + repo);
1063
1179
  }, [preflightAndInstall]);
1064
1180
 
1181
+ // 已装列表的「更新至 X」。此前它是那一行里唯一没有 disabled 的按钮,
1182
+ // 而起链要走联网的 registry 解析,那几秒里 jobs 还是空的、按钮还是可点
1183
+ // 的——连点几下就是几条预检+安装链,profile 事务队列会把它们排队串行
1184
+ // 跑完,同一个版本被反复装好几遍。
1185
+ //
1186
+ // 守卫按包名(updateChainBusy 的注释说明了为什么不能按 spec),同步的
1187
+ // ref 挡住同一拍里的第二次点击;RPC 返回只解开 updating 那一段,之后由
1188
+ // 对应的 job 接管,锁一直持续到 job 落终态。
1189
+ var doUpdate = useCallback(function (packageName, spec) {
1190
+ if (updateBusy(packageName)) return;
1191
+ updatingRef.current[packageName] = true;
1192
+ bumpBusy();
1193
+ preflightAndInstall(spec, function () {
1194
+ // preflight RPC 已返回:jobs 里已经有条目接手,这里才允许松手。
1195
+ // 顺序反过来会闪出一拍「谁都不认为它在忙」的可点窗口。
1196
+ delete updatingRef.current[packageName];
1197
+ bumpBusy();
1198
+ });
1199
+ }, [preflightAndInstall, updateBusy, bumpBusy]);
1200
+
1065
1201
  var doRestart = useCallback(function () {
1066
1202
  clearRestartPing();
1067
1203
  setRestarting(true);
@@ -1185,18 +1321,22 @@ window.__ModuleLoader__.load({
1185
1321
  toggling: toggling,
1186
1322
  onToggle: doToggle,
1187
1323
  onUninstall: doUninstall,
1188
- onInstallSpec: preflightAndInstall,
1324
+ onUpdate: doUpdate,
1325
+ isUpdating: updateBusy,
1189
1326
  error: error && error.scope === "installed" ? error.message : null,
1190
1327
  }),
1191
1328
  h(JobsPanel, {
1192
1329
  jobs: jobs,
1193
1330
  error: error && error.scope === "tasks" ? error.message : null,
1194
- preflight: preflight,
1195
- preflightBusy: preflight ? installing[preflight.spec] === true : false,
1331
+ preflights: preflights,
1332
+ // 这张卡片/这条批准请求对应的 install RPC 在不在途。原来是「取
1333
+ // installing 里的第一个 spec」的单值,同时两条链在途时只有一条显示
1334
+ // 得对;按 spec 问就没有这个假设了。
1335
+ specBusy: function (spec) { return installing[spec] === true; },
1196
1336
  onClear: function () {
1197
1337
  Object.keys(jobs).forEach(function (id) {
1198
1338
  var job = jobs[id];
1199
- var hasDecision = job && ((preflight && preflight.jobId === id)
1339
+ var hasDecision = job && (preflights[id] !== undefined
1200
1340
  || (job.needsApproval && job.needsApproval.length > 0));
1201
1341
  if (!job || isJobActive(job.status) || hasDecision) return;
1202
1342
  var token = job && job.approvalToken ? job.approvalToken : (job && job.spec ? approvalTokensRef.current[job.spec] : undefined);
@@ -1215,33 +1355,34 @@ window.__ModuleLoader__.load({
1215
1355
  });
1216
1356
  },
1217
1357
  onApprove: doApprove,
1218
- onConfirmPreflight: function () {
1219
- if (!preflight) return;
1220
- var spec = preflight.spec;
1221
- var carry = preflight.jobId;
1222
- var report = preflight.report;
1223
- setPreflight(null);
1358
+ onConfirmPreflight: function (jobId) {
1359
+ var decision = preflights[jobId];
1360
+ if (!decision) return;
1361
+ dropPreflight(jobId);
1224
1362
  // consentDigest 来自预检 job 的 extras,装的时候与当前报告比对:
1225
1363
  // 用户点「继续」到安装真正开跑之间候选包或 profile 变了,
1226
1364
  // 布尔同意不得沿用,要重新看新的警告。
1227
- doRawInstall(spec, { acceptWarnings: true, acceptedReportDigest: report.consentDigest }, carry);
1365
+ doRawInstall(decision.spec, { acceptWarnings: true, acceptedReportDigest: decision.report.consentDigest }, jobId);
1228
1366
  },
1229
- onClosePreflight: function () {
1230
- if (preflight && preflight.spec) delete approvalTokensRef.current[preflight.spec];
1231
- setPreflight(null);
1367
+ onClosePreflight: function (jobId) {
1368
+ var decision = preflights[jobId];
1369
+ if (decision && decision.spec) delete approvalTokensRef.current[decision.spec];
1370
+ dropPreflight(jobId);
1232
1371
  },
1233
1372
  onDismiss: function (id) {
1234
1373
  var job = jobs[id];
1235
1374
  var token = job && job.approvalToken ? job.approvalToken : (job && job.spec ? approvalTokensRef.current[job.spec] : undefined);
1236
1375
  if (job && job.spec) delete approvalTokensRef.current[job.spec];
1237
1376
  call("jobDismiss", { jobId: id, token: token }).catch(function () {});
1377
+ // job 条目撤了,挂在它上面的卡片没有宿主了。不一并撤的话
1378
+ // updateChainBusy 的 cards 分支会一直锁着那个包名。
1379
+ dropPreflight(id);
1238
1380
  dropJob(id);
1239
1381
  },
1240
1382
  onDrop: dropJob,
1241
1383
  onRestart: function () { setRestartConfirm(true); },
1242
1384
  restarting: restarting,
1243
1385
  hostStartedAt: hostStartedAt,
1244
- approving: Object.keys(installing).filter(function (s) { return installing[s]; })[0],
1245
1386
  }),
1246
1387
  h("div", { className: "mkt_list" },
1247
1388
  results == null
package/src/index.js CHANGED
@@ -3309,6 +3309,70 @@ export async function runSelfTests() {
3309
3309
  storageTouches === 0, `touches=${storageTouches}`);
3310
3310
  }
3311
3311
 
3312
+ // 已装列表「更新至 X」的防连点判据。同样从 src/client.js 原样抠出来跑
3313
+ // ——它在浏览器里是 React 闭包,测不到;提成纯函数就是为了能在这里钉住。
3314
+ //
3315
+ // 钉住的两件事,各对应一个真实的洞:
3316
+ // 1. 按包名判定 —— 按 spec 判定会在服务端解析之后丢失目标;
3317
+ // 2. 锁到 job 终态 —— 只锁 RPC 往返的话,jobId 一返回按钮就复活,而那
3318
+ // 时 pnpm 还一步都没跑。
3319
+ {
3320
+ const clientSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "client.js"), "utf8");
3321
+ const begin = clientSource.indexOf("// ── 更新按钮的防连点判据 ─");
3322
+ const end = clientSource.indexOf("// ── 更新按钮的防连点判据结束 ─");
3323
+ if (begin === -1 || end === -1 || end < begin) throw new Error("client.js 里找不到「更新按钮的防连点判据」整段");
3324
+ // 整段抠,不是逐个函数挑:依赖(isJobActive、specPackageName)就在段内,
3325
+ // 往段里加函数会自动被测到,搬出段外则当场 ReferenceError。
3326
+ const front = new Function([
3327
+ clientSource.slice(begin, end),
3328
+ "return { specPackageName, updateChainBusy };",
3329
+ ].join("\n"))();
3330
+
3331
+ const busy = (patch, name) => front.updateChainBusy(
3332
+ Object.assign({ updating: {}, installing: {}, cards: {}, jobs: {} }, patch), name);
3333
+ const running = (spec) => ({ spec, status: "running" });
3334
+
3335
+ check("更新防连点:裸包名折算", front.specPackageName("foo") === "foo");
3336
+ check("更新防连点:带版本折算", front.specPackageName("foo@1.2.3") === "foo");
3337
+ check("更新防连点:scoped 折算", front.specPackageName("@a/b@9.9.9") === "@a/b");
3338
+ check("更新防连点:非 npm 形态返回 null", front.specPackageName("github:o/r") === null);
3339
+
3340
+ check("更新防连点:点击到 job 建立之间靠同步 ref 挡住(第二次点击)",
3341
+ busy({ updating: { foo: true } }, "foo") === true);
3342
+ check("更新防连点:预检 job 在跑时锁着",
3343
+ busy({ jobs: { a: running("foo@1.2.3") } }, "foo") === true);
3344
+ check("更新防连点:预检落终态、安装 RPC 在途的空档也锁着",
3345
+ busy({ installing: { "foo@1.2.3": true }, jobs: { a: { spec: "foo@1.2.3", status: "completed" } } }, "foo") === true);
3346
+ check("更新防连点:安装 job 在跑时锁着(RPC 早返回了,pnpm 才刚开始)",
3347
+ busy({ jobs: { a: running("foo@1.2.3") } }, "foo") === true);
3348
+ check("更新防连点:stopping 也算在跑",
3349
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "stopping" } } }, "foo") === true);
3350
+ check("更新防连点:停在批准卡片上(failed + needsApproval)算未了结",
3351
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "failed", needsApproval: [{ name: "x" }] } } }, "foo") === true);
3352
+ check("更新防连点:停在风险卡片上时锁着(决定还没做,再起一条就是两条链)",
3353
+ busy({ cards: { j1: { spec: "foo@1.2.3" } } }, "foo") === true);
3354
+ // 单槽位时代的真实卡死:第二张卡片顶掉第一张,被顶掉那条再没有入口能
3355
+ // 做决定,job 却一直未了结——那一行的更新按钮永远点不亮。
3356
+ check("更新防连点:多张卡片同时停着,各锁各的包(批量更新的必然形状)",
3357
+ busy({ cards: { j1: { spec: "foo@1.2.3" }, j2: { spec: "bar@2.0.0" } } }, "foo") === true
3358
+ && busy({ cards: { j1: { spec: "foo@1.2.3" }, j2: { spec: "bar@2.0.0" } } }, "bar") === true);
3359
+ check("更新防连点:撤掉其中一张不影响另一张的锁",
3360
+ busy({ cards: { j2: { spec: "bar@2.0.0" } } }, "foo") === false
3361
+ && busy({ cards: { j2: { spec: "bar@2.0.0" } } }, "bar") === true);
3362
+
3363
+ check("更新防连点:job 落终态后解锁,用户可以重试",
3364
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "failed" } } }, "foo") === false);
3365
+ check("更新防连点:completed 后解锁",
3366
+ busy({ jobs: { a: { spec: "foo@1.2.3", status: "completed" } } }, "foo") === false);
3367
+ check("更新防连点:别的包在跑不锁本行",
3368
+ busy({ jobs: { a: running("bar@1.0.0") } }, "foo") === false);
3369
+ check("更新防连点:风险卡片停在别的包上不锁本行",
3370
+ busy({ cards: { j1: { spec: "bar@1.0.0" } } }, "foo") === false);
3371
+ check("更新防连点:什么都没在途 → 不锁", busy({}, "foo") === false);
3372
+ check("更新防连点:包名为 null 时不误判",
3373
+ busy({ jobs: { a: running("foo@1.2.3") } }, null) === false);
3374
+ }
3375
+
3312
3376
  let cancelRefused = false;
3313
3377
  try {
3314
3378
  sessionTracker.cancel(sessionJobId, "session-beta");