@inventec/dsh-copilot-auth 1.2.17 → 1.2.19

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.17",
4
- "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.17:保留模型逐项列出(弱化色),预览终态可审计。v1.2.16:移除物化指示浮层。v1.2.13:修复勾选闪烁(弹窗保持挂载+固定高度)。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
3
+ "version": "1.2.19",
4
+ "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.19:管理模型列表 v3 目标状态编辑(分组全表/挽留/对齐/清空语义告知)+单次确认+移除请求撤回+退出残影修复。v1.2.18:授权失败附网络失败码+人性化提示。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "iasi",
package/src/auth-flow.mjs CHANGED
@@ -1,9 +1,9 @@
1
- // auth-flow.mjs — 浏览器授权状态机(T5):串行请求、代次保护、轮询退避、卸载停止。
2
- // 契约:createAuthFlow({fetchImpl, clock, onState}) -> {init, start, cancel, refresh, dispose}
1
+ // auth-flow.mjs — 浏览器授权状态机(T5;Q38 起「请求撤回」已移除):串行请求、代次保护、轮询退避、卸载停止。
2
+ // 契约:createAuthFlow({fetchImpl, clock, onState}) -> {init, start, logout, refresh, dispose}
3
3
  // - 单一 init 聚合 /status;start 202 → 轮询;409 → attempt.shared 并转入对现有 attempt 的轮询
4
4
  // - 请求代次(generation)保护:慢响应不得覆盖新操作;dispose 仅停止本页请求
5
5
  // - 轮询 1 秒;连续读取失败退避 1/2/4/8/15 秒封顶;累计失败 60 秒转手动(connectivity=manual)
6
- // - 写操作(start/cancel)绝不自动重发
6
+ // - 写操作(start)绝不自动重发
7
7
  import { requestJson } from "./client-http.mjs";
8
8
 
9
9
  const POLL_INTERVAL_MS = 1000;
@@ -61,7 +61,7 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
61
61
  emit({ phase: "failed", error: s?.error ?? "authorization-failed" });
62
62
  return;
63
63
  }
64
- if (s?.status === "withdrawal-pending-unverified" || s?.status === "timed-out-unverified") {
64
+ if (s?.status === "timed-out-unverified") {
65
65
  emit({ phase: "risk", riskKind: s.status });
66
66
  return;
67
67
  }
@@ -149,6 +149,12 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
149
149
  emit({ phase: "idle", error: r.error?.messageKey, connectivity: "manual" });
150
150
  return;
151
151
  }
152
+ // Q38 追加修复(真机验证发现):凭据已不在(configured:false)时,attempt 记录里的
153
+ // 「authorized」是退出前的历史残影而非登录态——如实回到未登录,不得翻转已登录视图
154
+ if (r.body?.status === "authorized" && body.configured !== true) {
155
+ emit({ phase: "idle" });
156
+ return;
157
+ }
152
158
  applySnapshot(gen, r.body);
153
159
  }
154
160
 
@@ -189,16 +195,6 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
189
195
  emit({ phase: "failed", error: errorCode ?? r.error?.messageKey });
190
196
  }
191
197
 
192
- async function cancel() {
193
- // 写操作:单次调用,绝不自动重发
194
- const r = await requestJson(fetchImpl, "/copilot-auth/cancel", { method: "POST" });
195
- if (r.ok) {
196
- emit({ withdrawalDelivery: r.body?.withdrawalDelivery ?? "unavailable" });
197
- return;
198
- }
199
- emit({ withdrawalDelivery: "failed", error: r.error?.details?.errorCode ?? r.error?.messageKey });
200
- }
201
-
202
198
  async function logout() {
203
199
  // 退出登录(写操作,单次调用):pending 态 → 成功后完整重跑 init 恢复真实
204
200
  // 状态(若晚到写把凭据写了回来,init 会如实显示 authorized,绝不伪报已退出);
@@ -230,5 +226,5 @@ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = ()
230
226
  clearTimer();
231
227
  }
232
228
 
233
- return { init, start, cancel, logout, refresh, dispose };
229
+ return { init, start, logout, refresh, dispose };
234
230
  }
package/src/auth-host.mjs CHANGED
@@ -1,12 +1,13 @@
1
- // auth-host.mjs — 授权控制器(T3):单实例尝试、软撤回+风险锁存、禁用退出/重授权、
2
- // intentVersion 与首次填充 handoff。
1
+ // auth-host.mjs — 授权控制器(T3;Q38 起软撤回已移除):单实例尝试、等待超时风险锁存、
2
+ // 禁用退出、intentVersion 与首次填充 handoff。
3
3
  // 契约见 docs/plans/2026-10-04-copilot-auth-models-unified-implementation-plan.md:
4
4
  // - start:先 intentIO.bump() 持久 intentVersion+1(失败不发 begin),再 begin;202 先行语义保留
5
- // - cancel:先 bump,再调用宿主撤销(invoked/unavailable/failed),设进程内 riskLatch
6
5
  // - logout:静止态(无 LIVE attempt+无 riskLatch)deleteRecord+删后核实;
7
6
  // 尝试进行中/风险锁存期拒绝。「重新授权」概念随 v1.2.11 移除(退出+登录分步即可)
8
- // - onAuthorized(handoff) 仅在 outcome=authorized 且 originIntentVersion 仍当前且无撤回/超时标记时触发
7
+ // - onAuthorized(handoff) 仅在 outcome=authorized 且 originIntentVersion 仍当前且无超时标记时触发
9
8
  // - riskLatch 进程级:重挂/dispose 不清;15 分钟等待超时由 snapshot 以注入 clock 惰性判定
9
+ // - Q38(2026-10-05):「请求撤回」按钮与 /cancel 路由移除——撤销本地轮询的价值自限
10
+ // (15 分钟超时自然收敛),而其进程级锁存会把用户锁进「登录成功却不能退出」的死局
10
11
  import { randomUUID } from "node:crypto";
11
12
  import { CREDENTIAL_KEY } from "./shared.mjs";
12
13
 
@@ -17,10 +18,18 @@ export const LIVE_STATUSES = ["starting", "waiting", "finishing"];
17
18
  // 授权错误脱敏:仅保留安全字符集(字母/数字/空格/点/冒号/逗号/连字符),
18
19
  // 阻断上游异常文本或疑似 token(含 = ; / + _ 等的串)进入状态面。
19
20
  const SAFE_AUTH_ERROR = /^[A-Za-z0-9 .,:-]{1,140}$/;
21
+ // 网络层失败码(undici cause.code):严格大写/数字/下划线白名单,
22
+ // 如 ETIMEDOUT/ECONNRESET/ENOTFOUND/UNABLE_TO_VERIFY_LEAF_SIGNATURE
23
+ const SAFE_CAUSE_CODE = /^[A-Z0-9_]{3,60}$/;
20
24
 
21
25
  export function sanitizeAuthError(err) {
22
26
  const msg = String(err?.message ?? err);
23
- return SAFE_AUTH_ERROR.test(msg) ? msg : "authorization-failed";
27
+ const base = SAFE_AUTH_ERROR.test(msg) ? msg : "authorization-failed";
28
+ // v1.2.18:网络层失败的原话(如 Node 的 "fetch failed")不携带任何定位信息,
29
+ // 追加 cause.code(严格白名单)让桌面/代理类环境问题一眼可辨(主人 2026-10-05 裁决)
30
+ const causeCode = String(err?.cause?.code ?? "");
31
+ if (SAFE_CAUSE_CODE.test(causeCode)) return `${base} (${causeCode})`;
32
+ return base;
24
33
  }
25
34
 
26
35
  function authError(code, message, extra = {}) {
@@ -66,9 +75,7 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
66
75
  startedAt: clock.now(),
67
76
  notices: [],
68
77
  originIntentVersion,
69
- withdrawalRequested: false,
70
78
  timedOut: false,
71
- withdrawalDelivery: undefined,
72
79
  error: undefined,
73
80
  staleIntent: false,
74
81
  };
@@ -99,7 +106,7 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
99
106
  if (attempt !== a) return;
100
107
  if (outcome && outcome.status === "authorized") {
101
108
  const versionStillCurrent = a.originIntentVersion === intentIO.read();
102
- const intact = versionStillCurrent && !a.withdrawalRequested && !a.timedOut;
109
+ const intact = versionStillCurrent && !a.timedOut;
103
110
  a.status = "authorized";
104
111
  a.staleIntent = !intact;
105
112
  if (intact && typeof onAuthorized === "function") {
@@ -111,9 +118,9 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
111
118
  });
112
119
  }
113
120
  } else {
114
- // 宿主 cancelled 只是源结果:晚写不可排除,转待核实并锁存风险(D-01 软撤回)
115
- a.status = "withdrawal-pending-unverified";
116
- setRiskLatch("withdrawal-unverified");
121
+ // Q38:撤回通道已移除,cancelled 结局防御性映射为 failed(无晚写风险源,不设锁存)
122
+ a.status = "failed";
123
+ a.error = outcome?.status === "cancelled" ? "authorization-cancelled" : "authorization-failed";
117
124
  }
118
125
  })
119
126
  .catch((err) => {
@@ -124,34 +131,9 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
124
131
  return { attemptId: a.attemptId };
125
132
  }
126
133
 
127
- async function cancel() {
128
- requireScope();
129
- // 先持久失效本地旧意图,再发撤回调用
130
- intentIO.bump();
131
- let withdrawalDelivery = "unavailable";
132
- if (attempt) {
133
- attempt.withdrawalRequested = true;
134
- if (LIVE_STATUSES.includes(attempt.status)) attempt.status = "withdrawal-pending-unverified";
135
- }
136
- const cancelFn = ctx.authorization?.cancel;
137
- if (typeof cancelFn === "function") {
138
- try {
139
- await cancelFn(CREDENTIAL_KEY);
140
- withdrawalDelivery = "invoked";
141
- } catch {
142
- withdrawalDelivery = "failed";
143
- }
144
- }
145
- // delivery 写入 attempt:刷新页面后 /state 恢复仍如实展示「已请求撤回」事实
146
- if (attempt) attempt.withdrawalDelivery = withdrawalDelivery;
147
- // 任何响应都不承诺晚写停止:风险锁存待核实
148
- setRiskLatch("withdrawal-requested");
149
- return { withdrawalDelivery };
150
- }
151
-
152
134
  async function logout() {
153
135
  // 退出安全条件(v1.2.10 起,经主人裁决替代 D-01 全禁策略):
154
- // ① 无进行中的授权尝试(begin 晚写窗口)② 无风险锁存(撤回/超时未核实)。
136
+ // ① 无进行中的授权尝试(begin 晚写窗口)② 无风险锁存(Q38 起仅剩等待超时未核实)。
155
137
  // 满足后凭据库视为静止:deleteRecord+删除后 describe 复核;晚到写无法绝对
156
138
  // 排除,以复核与状态页如实展示兜底——退出未核实成功绝不伪报成功。
157
139
  requireScope();
@@ -175,6 +157,9 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
175
157
  if (err?.code === "LOGOUT_FAILED") throw err;
176
158
  throw authError("LOGOUT_FAILED", "logout-failed");
177
159
  }
160
+ // Q38 追加修复(真机验证发现):退出成功后清掉 attempt 残影——否则 /state 继续报告
161
+ // 历史「authorized」,客户端会把残影误当登录态,UI 翻回已登录,表现为「退出没作用」
162
+ attempt = null;
178
163
  return { ok: true };
179
164
  }
180
165
 
@@ -196,7 +181,6 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
196
181
  url: attempt.url,
197
182
  expiresAt: attempt.expiresAt,
198
183
  waitDeadline: attempt.startedAt + WAIT_LIMIT_MS,
199
- withdrawalDelivery: attempt.withdrawalDelivery,
200
184
  riskLatch,
201
185
  staleIntent: attempt.staleIntent,
202
186
  notices: [...attempt.notices],
@@ -204,15 +188,14 @@ export function createAuthorizationController(ctx, { scope, intentIO, clock = {
204
188
  };
205
189
  }
206
190
 
207
- // T4 消费:handoff 是否仍完好(同 attempt、版本仍当前、无撤回/超时标记)
191
+ // T4 消费:handoff 是否仍完好(同 attempt、版本仍当前、无超时标记)
208
192
  function handoffIntact(handoff) {
209
193
  if (!attempt || !handoff) return false;
210
194
  return attempt.attemptId === handoff.attemptId
211
195
  && attempt.originIntentVersion === handoff.originIntentVersion
212
196
  && attempt.originIntentVersion === intentIO.read()
213
- && !attempt.withdrawalRequested
214
197
  && !attempt.timedOut;
215
198
  }
216
199
 
217
- return { start, cancel, logout, snapshot, handoffIntact };
200
+ return { start, logout, snapshot, handoffIntact };
218
201
  }
package/src/host.mjs CHANGED
@@ -528,24 +528,6 @@ export function apply(ctx, opts = {}) {
528
528
  },
529
529
  });
530
530
 
531
- ctx.webServer.register({
532
- kind: "exact",
533
- path: r.cancel,
534
- handler: async (req, res) => {
535
- if (!guard(req, res, "POST")) return;
536
- if (!scope.known) {
537
- json(res, 503, { ok: false, error: "scope-unavailable" });
538
- return;
539
- }
540
- try {
541
- const { withdrawalDelivery } = await controller.cancel();
542
- json(res, 200, { ok: true, withdrawalDelivery });
543
- } catch (err) {
544
- json(res, 500, { ok: false, error: sanitizeAuthError(err) });
545
- }
546
- },
547
- });
548
-
549
531
  ctx.webServer.register({
550
532
  kind: "exact",
551
533
  path: r.state,
@@ -1,11 +1,16 @@
1
- // model-update.mjs — 纯模型变更策略(T6):补充/重建的目标视图、增删保留与原因、writeSet。
1
+ // model-update.mjs — 纯模型变更策略(v3):管理模型列表=目标状态编辑(ADR 0005)。
2
2
  // 纯函数,不触碰网络/磁盘/settings;视图形状与 host.mjs viewFromUserLayer 同构:
3
3
  // {modelsPresent, models, modelOverridesPresent, modelOverrides}
4
- // 语义(设计§4.1 / Q1/Q5/Q6/Q8/Q15):
5
- // - 补充:保留全部现有对象/顺序/modelOverrides,仅追加勾选且可解析去重后的候选;默认空选择
6
- // - 重建:目标=账号∩可解析 的纯 {id} 列表,清空本路由 modelOverrides;空交集需 confirmEmpty
7
- // - 继承限制:base 继承的 modelOverrides(raw 缺失而 effective 存在)无法经用户层 unset 清除 → 受限
8
- // - 不可解析现有项保留+warning,不暗删
4
+ // 语义(设计§4.1/§4.2 2026-10-05 修订 / Q32–Q36):
5
+ // - 单一操作 manage:勾选集合即期望终态;selectedIds 缺省 → 在列∩(账号∩可解析)(与 UI 初始态一致)
6
+ // - 被保留模型维持原对象/顺序/定制;被移除模型定制随模型消失(overrides 按 kept 过滤,
7
+ // 唯一定制模型被移除时 present:false → unset,防重加复活定制)
8
+ // - rows:addable(账号∩可解析−在列)/ listed(在列∩(账号∩可解析))/ removal-proposal
9
+ // (在列−(账号∩可解析),reason 优先级:不在账号→not-in-account,否则 unresolvable)
10
+ // - removed:raw 在列全表−勾选;reason:not-in-account → unresolvable → unchecked(用户主动取消健康行)
11
+ // - 空目标确认仅约束用户显式提交的空选择(selectedIds!==undefined);确认后落显式空列表
12
+ // (modelsPresent:true, models:[],防 unset 后首次填充静默回填)
13
+ // - clearOverrides:清除可清除定制(present:false);仅继承覆盖(raw 缺失而 effective 有)→ 阻止该项
9
14
  export function buildModelChange({
10
15
  operation,
11
16
  rawView,
@@ -13,11 +18,17 @@ export function buildModelChange({
13
18
  accountIds,
14
19
  resolvableIds,
15
20
  selectedIds,
21
+ clearOverrides = false,
16
22
  confirmEmpty = false,
17
23
  catalogNewEntryCount = 0,
18
24
  }) {
19
- if (operation !== "supplement" && operation !== "rebuild") {
20
- return { allowed: false, reason: "invalid-operation", targetView: null, added: [], removed: [], kept: [], warnings: [{ reason: "invalid-operation" }], writeSet: { catalogEntries: false, models: false, modelOverrides: false } };
25
+ if (operation !== "manage") {
26
+ return {
27
+ allowed: false, reason: "invalid-operation", targetView: null, rows: [], selectedIds: [],
28
+ added: [], removed: [], kept: [], warnings: [], removalClass: false,
29
+ overridesMeta: { rawPresent: false, clearable: false, inheritedOnly: false },
30
+ writeSet: { catalogEntries: false, models: false, modelOverrides: false, overridesUnclearable: false },
31
+ };
21
32
  }
22
33
  const accounts = [...new Set((Array.isArray(accountIds) ? accountIds : []).filter((x) => typeof x === "string"))];
23
34
  const resolvable = new Set((Array.isArray(resolvableIds) ? resolvableIds : []).filter((x) => typeof x === "string"));
@@ -25,94 +36,108 @@ export function buildModelChange({
25
36
  const eff = effectiveView ?? raw;
26
37
  const rawModels = raw.modelsPresent && Array.isArray(raw.models) ? raw.models : [];
27
38
  const rawIds = rawModels.map((m) => m?.id).filter((x) => typeof x === "string");
28
- const effModels = eff.modelsPresent && Array.isArray(eff.models) ? eff.models : [];
29
- const effIds = effModels.map((m) => m?.id).filter((x) => typeof x === "string");
39
+ const rawIdSet = new Set(rawIds);
40
+ const accountSet = new Set(accounts);
41
+
42
+ const overridesKeys = raw.modelOverridesPresent && raw.modelOverrides && typeof raw.modelOverrides === "object"
43
+ ? Object.keys(raw.modelOverrides) : [];
44
+ const rawPresent = overridesKeys.length > 0;
45
+ const effOverrides = eff.modelOverridesPresent && eff.modelOverrides && typeof eff.modelOverrides === "object"
46
+ ? Object.keys(eff.modelOverrides) : [];
47
+ const inheritedOnly = !rawPresent && effOverrides.length > 0;
48
+ const overridesMeta = { rawPresent, clearable: rawPresent, inheritedOnly };
49
+
50
+ const inUniverse = (id) => accountSet.has(id) && resolvable.has(id);
51
+ const addable = accounts.filter((id) => resolvable.has(id) && !rawIdSet.has(id));
52
+ const addableSet = new Set(addable);
53
+ const rows = [
54
+ ...addable.map((id) => ({ id, status: "addable", customized: false })),
55
+ ...rawIds.filter((id) => inUniverse(id)).map((id) => ({ id, status: "listed", customized: overridesKeys.includes(id) })),
56
+ ...rawIds.filter((id) => !inUniverse(id)).map((id) => ({
57
+ id,
58
+ status: "removal-proposal",
59
+ reason: accountSet.has(id) ? "unresolvable" : "not-in-account",
60
+ customized: overridesKeys.includes(id),
61
+ })),
62
+ ];
63
+
64
+ // 选择规范化:缺省 → listed(与 UI 初始勾选态一致);显式(含 [])→ 用户意图
65
+ const explicitSelection = selectedIds !== undefined;
66
+ const requested = explicitSelection
67
+ ? [...new Set((Array.isArray(selectedIds) ? selectedIds : []).filter((x) => typeof x === "string"))]
68
+ : rawIds.filter((id) => inUniverse(id));
69
+ const selectedSet = new Set(requested);
70
+ // warnings:manage 语义下只产 invalid-selection/unresolvable(already-configured 仅保留在契约枚举中,不可达)
30
71
  const warnings = [];
31
- for (const id of rawIds) {
32
- if (!resolvable.has(id)) warnings.push({ id, reason: "unresolvable" });
72
+ for (const id of requested) {
73
+ if (addableSet.has(id) || rawIdSet.has(id)) continue;
74
+ warnings.push({ id, reason: accountSet.has(id) && !resolvable.has(id) ? "unresolvable" : "invalid-selection" });
33
75
  }
34
- const catalogEntries = catalogNewEntryCount > 0;
35
76
 
36
- if (operation === "supplement") {
37
- const existing = new Set(rawIds);
38
- const candidates = accounts.filter((id) => resolvable.has(id) && !existing.has(id));
39
- const candidateSet = new Set(candidates);
40
- const seen = new Set();
41
- const added = [];
42
- for (const id of Array.isArray(selectedIds) ? selectedIds : []) {
43
- if (typeof id !== "string" || seen.has(id)) continue;
44
- seen.add(id);
45
- if (candidateSet.has(id)) {
46
- added.push(id);
47
- } else if (existing.has(id)) {
48
- warnings.push({ id, reason: "already-configured" });
49
- } else if (accounts.includes(id) && !resolvable.has(id)) {
50
- warnings.push({ id, reason: "unresolvable" });
51
- } else {
52
- warnings.push({ id, reason: "invalid-selection" });
53
- }
54
- }
55
- const models = added.length > 0 ? [...rawModels, ...added.map((id) => ({ id }))] : rawModels;
56
- const modelsPresent = raw.modelsPresent || added.length > 0;
57
- const overridesUnclearable = false;
58
- return {
59
- allowed: true,
60
- reason: null,
61
- candidates, // 候选=账号∩可解析−已有(Q5:默认全不勾选,勾选后 materialize 新预览)
62
- targetView: {
63
- modelsPresent,
64
- models: modelsPresent ? models : null,
65
- modelOverridesPresent: raw.modelOverridesPresent,
66
- modelOverrides: raw.modelOverrides,
67
- },
68
- added,
69
- removed: [],
70
- kept: effIds.filter((id) => existing.has(id)),
71
- warnings,
72
- writeSet: { catalogEntries, models: added.length > 0, modelOverrides: false, overridesUnclearable },
73
- };
74
- }
77
+ const kept = rawIds.filter((id) => selectedSet.has(id));
78
+ const keptSet = new Set(kept);
79
+ const added = addable.filter((id) => selectedSet.has(id));
80
+ const removed = rawIds
81
+ .filter((id) => !selectedSet.has(id))
82
+ .map((id) => ({
83
+ id,
84
+ reason: !accountSet.has(id) ? "not-in-account" : !resolvable.has(id) ? "unresolvable" : "unchecked",
85
+ }));
86
+ const removalClass = removed.length > 0;
75
87
 
76
- // rebuild
77
- const effOverrides = eff.modelOverridesPresent && eff.modelOverrides && typeof eff.modelOverrides === "object"
78
- ? Object.keys(eff.modelOverrides) : [];
79
- if (!raw.modelOverridesPresent && effOverrides.length > 0) {
88
+ if (clearOverrides && inheritedOnly) {
80
89
  return {
81
- allowed: false,
82
- reason: "inherited-overrides-unclearable",
83
- targetView: null, added: [], removed: [], kept: [], warnings,
84
- writeSet: { catalogEntries, models: false, modelOverrides: false, overridesUnclearable: true },
90
+ allowed: false, reason: "inherited-overrides-unclearable", targetView: null, rows, selectedIds: requested,
91
+ added, removed, kept, warnings, removalClass,
92
+ overridesMeta,
93
+ writeSet: { catalogEntries: catalogNewEntryCount > 0, models: false, modelOverrides: false, overridesUnclearable: true },
85
94
  };
86
95
  }
87
- const intersection = accounts.filter((id) => resolvable.has(id));
88
- if (intersection.length === 0 && !confirmEmpty) {
96
+ if (explicitSelection && rawIds.length > 0 && requested.length === 0 && !confirmEmpty) {
89
97
  return {
90
- allowed: false,
91
- reason: "empty-intersection-unconfirmed",
92
- targetView: null, added: [], removed: [], kept: [], warnings,
93
- writeSet: { catalogEntries, models: false, modelOverrides: false, overridesUnclearable: false },
98
+ allowed: false, reason: "empty-target-unconfirmed", targetView: null, rows, selectedIds: requested,
99
+ added, removed, kept, warnings, removalClass,
100
+ overridesMeta,
101
+ writeSet: { catalogEntries: catalogNewEntryCount > 0, models: false, modelOverrides: false, overridesUnclearable: false },
94
102
  };
95
103
  }
96
- const target = new Set(intersection);
97
- const accountSet = new Set(accounts);
98
- const removed = effIds
99
- .filter((id) => !target.has(id))
100
- .map((id) => ({ id, reason: accountSet.has(id) ? "unresolvable" : "not-in-account" }));
101
- const rawOverridesPresent = raw.modelOverridesPresent && raw.modelOverrides && Object.keys(raw.modelOverrides).length > 0;
104
+
105
+ const keptRawModels = rawModels.filter((m) => keptSet.has(m?.id));
106
+ const modelsPresent = raw.modelsPresent || requested.length > 0;
107
+ const models = modelsPresent ? [...keptRawModels, ...added.map((id) => ({ id }))] : null;
108
+ const clearable = rawPresent;
109
+ let modelOverridesPresent;
110
+ let modelOverrides;
111
+ if (clearOverrides && clearable) {
112
+ modelOverridesPresent = false;
113
+ modelOverrides = null;
114
+ } else if (rawPresent) {
115
+ const filtered = {};
116
+ for (const key of overridesKeys) if (keptSet.has(key)) filtered[key] = raw.modelOverrides[key];
117
+ modelOverridesPresent = Object.keys(filtered).length > 0;
118
+ modelOverrides = modelOverridesPresent ? filtered : null;
119
+ } else {
120
+ modelOverridesPresent = raw.modelOverridesPresent;
121
+ modelOverrides = raw.modelOverrides;
122
+ }
123
+
102
124
  return {
103
125
  allowed: true,
104
126
  reason: null,
105
- candidates: null, // 重建目标固定为账号∩可解析交集,无逐项勾选
106
- targetView: {
107
- modelsPresent: true,
108
- models: intersection.map((id) => ({ id })),
109
- modelOverridesPresent: false,
110
- modelOverrides: null,
111
- },
112
- added: intersection.filter((id) => !new Set(effIds).has(id)),
127
+ rows,
128
+ selectedIds: requested,
129
+ added,
113
130
  removed,
114
- kept: intersection.filter((id) => new Set(effIds).has(id)),
131
+ kept,
115
132
  warnings,
116
- writeSet: { catalogEntries, models: true, modelOverrides: rawOverridesPresent, overridesUnclearable: false },
133
+ removalClass,
134
+ overridesMeta,
135
+ targetView: { modelsPresent, models, modelOverridesPresent, modelOverrides },
136
+ writeSet: {
137
+ catalogEntries: catalogNewEntryCount > 0,
138
+ models: added.length > 0 || removalClass || (!raw.modelsPresent && requested.length > 0),
139
+ modelOverrides: clearOverrides && clearable,
140
+ overridesUnclearable: false,
141
+ },
117
142
  };
118
143
  }