@inventec/dsh-copilot-auth 1.2.18 → 1.2.20

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.
@@ -1,11 +1,14 @@
1
- // refresh-flow.mjs — 「补充模型/重建模型列表」client 侧状态机(协议 v2,T11)。
1
+ // refresh-flow.mjs — 「管理模型列表」client 侧状态机(协议 v3,ADR 0005 / Q32–Q36)。
2
2
  // reduce 为纯函数;runEffect/advance 只使用注入的 fetchImpl(node:test 可测)。
3
- // 状态:idle → previewing → confirming(勾选→materialize 循环)→ applying →
4
- // result / pendingRestart / checking / busy / resultUnknown;recovery-needed 经
5
- // retireConfirm → retiring → 重新预览闭环。
3
+ // 状态:idle → previewing → confirming(勾选→materialize 循环;缓存证据+删除提案时
4
+ // 自动 live 升级:闩锁、硬失败回退 materialize、软降级置 staleNotice)→ applying →
5
+ // result / pendingRestart / checking / busy / resultUnknown;recovery-needed 经
6
+ // retireConfirm → retiring → 重新预览闭环。
6
7
  // 关键规则:
7
- // - 候选默认全不勾选;选择变化经 basePreviewId 从原快照 materialize(不重新取数)
8
- // - 重建需二次确认;空目标需单独确认(reducer 强制,UI 负责走完流程)
8
+ // - 勾选集合即终态;选择变化经 basePreviewId 从原快照 materialize(不重新取数)
9
+ // - 确认门按实际 diff:removed>0 ∨ clearOverrides ∨ 空目标 → 二次确认;空目标另需 empty
10
+ // - 升级判定覆盖两条 preview-ok 迁移路径(初始进入与 confirming 内原位替换);
11
+ // 升级在途置 materializing(确认门拦截);硬失败回退保留勾选,软降级置 staleNotice
9
12
  // - 423 转 status 查询;apply 网络不明先查同 operationId,绝不换 ID 重发
10
13
  // - 预览过期/漂移(409 preview-stale/auth-changed/preview-invalid)→ 自动重新预览
11
14
  import { routes, PROTOCOL_VERSION } from "./shared.mjs";
@@ -37,6 +40,49 @@ export function refreshBlocked(flags) {
37
40
  return flags?.scopeAvailable === false || mode === "blocked" || mode === "unknown";
38
41
  }
39
42
 
43
+ // lastSelection → effect 选择字段(逐字段在场性复制;空对象 → 不带任何选择字段)
44
+ function selectionFields(lastSelection = {}) {
45
+ const out = {};
46
+ if (lastSelection.selectedIds !== undefined) out.selectedIds = lastSelection.selectedIds;
47
+ if (lastSelection.clearOverrides !== undefined) out.clearOverrides = lastSelection.clearOverrides;
48
+ if (lastSelection.confirmEmpty !== undefined) out.confirmEmpty = lastSelection.confirmEmpty;
49
+ return out;
50
+ }
51
+
52
+ // 升级判定:删除提案 ∧ 有效缓存 ∧ latest 目录 ∧ 闩锁未用 → 发新鲜预览(不走 basePreviewId)。
53
+ // 初始默认预览的 lastSelection 为空对象 → 升级 effect 不带选择字段,服务端按 live 数据重算默认
54
+ // (全漂移 corner 因此不可能触发空目标确认,M01 弹窗必开)。
55
+ function upgradeCandidate(state) {
56
+ const p = state.preview;
57
+ if (!p || state.liveUpgradeUsed) return null;
58
+ if ((p.diff?.removed?.length ?? 0) === 0) return null;
59
+ if (p.evidence?.source !== "cache" || p.evidence?.stale === true) return null;
60
+ if (p.catalogSource !== "latest") return null;
61
+ return {
62
+ type: "preview",
63
+ operation: p.operation,
64
+ catalogSource: p.catalogSource,
65
+ ...selectionFields(state.lastSelection),
66
+ };
67
+ }
68
+
69
+ // 进入 confirming(两条 preview-ok 迁移路径共用):先落确认基态,再评估自动升级。
70
+ function enterConfirming({ flags, preview, staleNotice, lastSelection }) {
71
+ const state = {
72
+ name: "confirming",
73
+ flags: flags ?? initial.flags,
74
+ preview,
75
+ staleNotice: staleNotice === true,
76
+ materializing: false,
77
+ awaitingUpgrade: false,
78
+ liveUpgradeUsed: false,
79
+ lastSelection: lastSelection ?? {},
80
+ };
81
+ const effect = upgradeCandidate(state);
82
+ if (!effect) return [state, null];
83
+ return [{ ...state, liveUpgradeUsed: true, awaitingUpgrade: true, materializing: true }, effect];
84
+ }
85
+
40
86
  export function reduce(state, event) {
41
87
  switch (state.name) {
42
88
  case "idle":
@@ -46,17 +92,14 @@ export function reduce(state, event) {
46
92
  case "busy":
47
93
  case "resultUnknown": {
48
94
  if (event.type === "start") {
95
+ // v3 单一操作:恒为 manage(无 legacy 字面量分支)
49
96
  return [{
50
97
  name: "previewing",
51
98
  flags: state.flags ?? initial.flags,
52
- operation: event.operation === "rebuild" ? "rebuild" : "supplement",
99
+ operation: "manage",
53
100
  catalogSource: event.catalogSource,
54
101
  stale: false,
55
- }, {
56
- type: "preview",
57
- operation: event.operation === "rebuild" ? "rebuild" : "supplement",
58
- catalogSource: event.catalogSource,
59
- }];
102
+ }, { type: "preview", operation: "manage", catalogSource: event.catalogSource }];
60
103
  }
61
104
  if (event.type === "init" && state.name === "idle") return [state, { type: "status" }];
62
105
  if (event.type === "check" && state.name === "resultUnknown") {
@@ -87,7 +130,7 @@ export function reduce(state, event) {
87
130
  }
88
131
  case "previewing": {
89
132
  if (event.type === "preview-ok") {
90
- return [{ name: "confirming", flags: state.flags ?? initial.flags, preview: event.preview, staleNotice: state.stale === true }, null];
133
+ return enterConfirming({ flags: state.flags ?? initial.flags, preview: event.preview, staleNotice: state.stale === true });
91
134
  }
92
135
  if (event.type === "preview-fail") return [{ name: "failed", flags: state.flags ?? initial.flags, error: event.error }, null];
93
136
  return [state, null];
@@ -95,53 +138,89 @@ export function reduce(state, event) {
95
138
  case "confirming": {
96
139
  const p = state.preview;
97
140
  if (event.type === "select") {
141
+ const lastSelection = { ...selectionFields({
142
+ selectedIds: event.selectedIds,
143
+ clearOverrides: event.clearOverrides,
144
+ confirmEmpty: event.confirmEmpty,
145
+ }) };
98
146
  // 选择变化:从原快照 materialize(服务端重算,沿用原有效期,不重新取数)。
99
- // 留在 confirming+materializing 标记——弹窗保持挂载、不卸载重挂;
100
- // 历史实现切回 previewing 导致弹窗整体关闭再打开(勾选闪烁的根因,2026-10-05)。
147
+ // 留在 confirming+materializing——弹窗保持挂载、不卸载重挂。
101
148
  return [{
102
149
  name: "confirming",
103
150
  flags: state.flags ?? initial.flags,
104
151
  preview: p,
105
152
  materializing: true,
153
+ awaitingUpgrade: false,
154
+ liveUpgradeUsed: state.liveUpgradeUsed ?? false,
155
+ lastSelection,
106
156
  staleNotice: state.staleNotice,
107
157
  }, {
108
158
  type: "preview",
109
159
  operation: p.operation,
110
160
  catalogSource: p.catalogSource,
111
161
  basePreviewId: p.previewId,
112
- selectedIds: event.selectedIds ?? [],
113
- confirmEmpty: event.confirmEmpty === true,
162
+ ...selectionFields(lastSelection),
114
163
  }];
115
164
  }
116
165
  if (event.type === "preview-ok") {
117
- // materialize/切源完成:预览原位替换(新 previewId 绑定新选择)
118
- return [{ name: "confirming", flags: state.flags ?? initial.flags, preview: event.preview, staleNotice: state.stale === true, materializing: false }, null];
166
+ // materialize/切源/升级完成:预览原位替换(新 previewId 绑定新选择)。
167
+ // 升级软降级(R4-3):落地后证据仍非 live → staleNotice(UI 据此禁用 apply)。
168
+ const wasUpgrade = state.awaitingUpgrade === true;
169
+ const landed = {
170
+ name: "confirming",
171
+ flags: state.flags ?? initial.flags,
172
+ preview: event.preview,
173
+ materializing: false,
174
+ awaitingUpgrade: false,
175
+ liveUpgradeUsed: state.liveUpgradeUsed ?? false,
176
+ lastSelection: state.lastSelection ?? {},
177
+ staleNotice: (wasUpgrade && event.preview?.evidence?.source !== "live") || state.staleNotice === true,
178
+ };
179
+ const effect = upgradeCandidate(landed);
180
+ if (effect) return [{ ...landed, liveUpgradeUsed: true, awaitingUpgrade: true, materializing: true }, effect];
181
+ return [landed, null];
119
182
  }
120
183
  if (event.type === "preview-fail") {
184
+ if (state.awaitingUpgrade === true && p) {
185
+ // 升级硬失败回退:以升级前快照 materialize(逐字段复制 lastSelection,保留勾选);
186
+ // 弹窗保持挂载;回退后再失败才落 failed。
187
+ return [{
188
+ name: "confirming",
189
+ flags: state.flags ?? initial.flags,
190
+ preview: p,
191
+ materializing: true,
192
+ awaitingUpgrade: false,
193
+ liveUpgradeUsed: state.liveUpgradeUsed ?? true,
194
+ lastSelection: state.lastSelection ?? {},
195
+ staleNotice: true,
196
+ }, {
197
+ type: "preview",
198
+ operation: p.operation,
199
+ catalogSource: p.catalogSource,
200
+ basePreviewId: p.previewId,
201
+ ...selectionFields(state.lastSelection),
202
+ }];
203
+ }
121
204
  return [{ name: "failed", flags: state.flags ?? initial.flags, error: event.error }, null];
122
205
  }
123
206
  if (event.type === "start") {
124
- // 显式切源(overlay/local)或切换操作:生成全新预览(不隐式降级,Q7)。
125
- // 同 select:留在 confirming 保持弹窗挂载。
207
+ // 显式切源(overlay/local):生成全新预览(不隐式降级,Q7);留在 confirming 保持弹窗挂载
126
208
  return [{
127
209
  name: "confirming",
128
210
  flags: state.flags ?? initial.flags,
129
211
  preview: p,
130
212
  materializing: true,
213
+ awaitingUpgrade: false,
214
+ liveUpgradeUsed: state.liveUpgradeUsed ?? false,
215
+ lastSelection: state.lastSelection ?? {},
131
216
  staleNotice: state.staleNotice,
132
- }, {
133
- type: "preview",
134
- operation: event.operation === "rebuild" ? "rebuild" : p.operation,
135
- catalogSource: event.catalogSource,
136
- }];
217
+ }, { type: "preview", operation: p.operation, catalogSource: event.catalogSource }];
137
218
  }
138
219
  if (event.type === "confirm") {
139
- if (state.materializing) return [state, null]; // 物化中不得用旧 previewId 应用
140
- const isRebuild = p.operation === "rebuild";
141
- const emptyTarget = isRebuild && (p.diff?.targetView?.models ?? []).length === 0;
142
- if (isRebuild && event.second !== true) return [state, null]; // 重建需二次确认
143
- if (emptyTarget && event.empty !== true) return [state, null]; // 清空需单独确认
220
+ if (state.materializing) return [state, null]; // 物化/升级在途不得用旧 previewId 应用
144
221
  if (p.evidence?.stale === true) return [state, null]; // stale 证据不可应用(Q7)
222
+ // 单次确认(Q37 用户裁决):破坏性意图已由固定底栏常驻风险线在操作前完整告知,
223
+ // 不再要求二次点击;证据门(removals-need-live)由服务端独立兜底
145
224
  return [{ name: "applying", flags: state.flags ?? initial.flags, preview: p }, { type: "apply", previewId: p.previewId, operationId: p.operationId }];
146
225
  }
147
226
  if (event.type === "cancel") return [{ ...initial, flags: state.flags ?? initial.flags, lastResult: state.lastResult ?? null }, null];
@@ -216,11 +295,14 @@ export async function runEffect(effect, fetchImpl) {
216
295
  body: JSON.stringify({ protocolVersion: PROTOCOL_VERSION, ...body }),
217
296
  });
218
297
  if (effect.type === "preview") {
298
+ // 选择字段按在场性组装(R1-9):初始预览不带;materialize/升级/回退按 effect 实际字段携带
219
299
  const res = await post(r.refreshPreview, {
220
300
  operation: effect.operation,
221
301
  ...(effect.catalogSource ? { catalogSource: effect.catalogSource } : {}),
222
- ...(effect.basePreviewId ? { basePreviewId: effect.basePreviewId, selectedIds: effect.selectedIds ?? [] } : {}),
223
- ...(effect.confirmEmpty ? { confirmEmpty: true } : {}),
302
+ ...(effect.basePreviewId !== undefined ? { basePreviewId: effect.basePreviewId } : {}),
303
+ ...(effect.selectedIds !== undefined ? { selectedIds: effect.selectedIds } : {}),
304
+ ...(effect.clearOverrides !== undefined ? { clearOverrides: effect.clearOverrides } : {}),
305
+ ...(effect.confirmEmpty !== undefined ? { confirmEmpty: effect.confirmEmpty } : {}),
224
306
  });
225
307
  if (res.ok) return { type: "preview-ok", preview: res.body };
226
308
  return { type: "preview-fail", error: res.error?.details?.errorCode ?? res.error?.messageKey };
@@ -1,6 +1,6 @@
1
1
  // refresh-service.mjs — 预览服务与账号证据(T8)。
2
2
  // 职责:内存预览快照(10 分钟、选择从原快照重算、operationId 绑定选择)、
3
- // 账号证据门禁(重建需 live;补充允许 ≤24h 同 intentVersion 缓存;stale 只参考不可应用)、
3
+ // 账号证据门禁(移除类变更需 live+latest;纯新增允许 ≤24h 同 intentVersion 缓存;stale 只参考不可应用)、
4
4
  // apply 幂等(同 operationId 返回已知结果;activeOperation 未终结时新 apply 423)、
5
5
  // boot/apply/retire 同一队列串行。
6
6
  // 事务内核(T9)经 transaction 注入:{runApply({snapshot}), bootRecover(), retire({operationId})}。
@@ -26,8 +26,9 @@ function flowError(code, message, extra = {}) {
26
26
  const iso = (ts) => new Date(ts).toISOString();
27
27
 
28
28
  // 账号证据:live GET 成功才写缓存(fetchedAt+intentVersion);缓存仅 ≤24h 且同
29
- // intentVersion 时可作补充的应用依据;否则 stale——预览可展示参考,apply 被拒(Q7/Q16)。
30
- // 重建(rebuild)永不使用缓存:live 失败即 evidence-unavailable,不退化为缓存重建。
29
+ // intentVersion 时可作纯新增的应用依据;否则 stale——预览可展示参考,apply 被拒(Q7/Q16)。
30
+ // 移除类变更(removed>0)在 apply 时强制 live 证据+latest 目录(removals-need-live,Q35/R2);
31
+ // live 拉取失败回退有效缓存属软降级,由 client 端升级/禁用逻辑兜底(R4-3),服务端门最终兜底。
31
32
  async function resolveAccountEvidence(ctx, { scope, readIntentVersion, clock, fetchImpl }) {
32
33
  const iv = readIntentVersion();
33
34
  const cacheFile = join(scope.dataDir, "account-model-cache.json");
@@ -83,14 +84,15 @@ export function createRefreshService(ctx, {
83
84
  }
84
85
  }
85
86
 
86
- function diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty }) {
87
+ function diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty, clearOverrides }) {
87
88
  const diff = buildModelChange({
88
89
  operation,
89
90
  rawView: config.view,
90
91
  effectiveView: config.effectiveView,
91
92
  accountIds: evidence.ids,
92
93
  resolvableIds: catalog.resolvableIds,
93
- selectedIds: selectedIds ?? [],
94
+ selectedIds, // undefined → 服务端默认(在列∩(账号∩可解析));显式 [] → 用户空目标意图
95
+ clearOverrides: clearOverrides === true,
94
96
  confirmEmpty: confirmEmpty === true,
95
97
  catalogNewEntryCount: catalog.newEntryCount ?? 0,
96
98
  });
@@ -98,6 +100,11 @@ export function createRefreshService(ctx, {
98
100
  return diff;
99
101
  }
100
102
 
103
+ const hadModelsOf = (config) => {
104
+ const models = config?.view?.modelsPresent && Array.isArray(config.view.models) ? config.view.models : [];
105
+ return models.some((m) => typeof m?.id === "string");
106
+ };
107
+
101
108
  async function preview(body = {}) {
102
109
  requireScope();
103
110
  requireDeps();
@@ -106,8 +113,8 @@ export function createRefreshService(ctx, {
106
113
  for (const [id, snap] of snapshots) {
107
114
  if (nowTs > snap.expiresAt) snapshots.delete(id);
108
115
  }
109
- const { operation, catalogSource, basePreviewId, selectedIds, confirmEmpty } = body;
110
- if (operation !== "supplement" && operation !== "rebuild") {
116
+ const { operation, catalogSource, basePreviewId, selectedIds, confirmEmpty, clearOverrides } = body;
117
+ if (operation !== "manage") {
111
118
  throw flowError("INVALID_OPERATION", "invalid-operation");
112
119
  }
113
120
  // 选择变化:从原快照重算(不重新下载、不重新取数),沿用原 expiresAt
@@ -117,13 +124,14 @@ export function createRefreshService(ctx, {
117
124
  if (clock.now() > base.expiresAt) throw flowError("PREVIEW_INVALID", "preview-invalid", { reason: "expired" });
118
125
  const diff = diffFromInputs({
119
126
  operation: base.operation, evidence: base.evidence, catalog: base.catalog,
120
- config: base.config, selectedIds, confirmEmpty,
127
+ config: base.config, selectedIds, confirmEmpty, clearOverrides,
121
128
  });
122
129
  const snap = {
123
130
  ...base,
124
131
  previewId: randomUUID(),
125
132
  selectedIds: [...(selectedIds ?? [])],
126
133
  confirmEmpty: confirmEmpty === true,
134
+ clearOverrides: clearOverrides === true,
127
135
  diff,
128
136
  operationId: randomUUID(),
129
137
  basePreviewId,
@@ -133,13 +141,10 @@ export function createRefreshService(ctx, {
133
141
  return projectPreview(snap);
134
142
  }
135
143
  const evidence = await resolveAccountEvidence(ctx, { scope, readIntentVersion, clock, fetchImpl: getFetch() });
136
- if (operation === "rebuild" && evidence.source !== "live") {
137
- throw flowError("EVIDENCE_UNAVAILABLE", "rebuild requires a live account fetch; cache is never used for rebuild");
138
- }
139
144
  const catalog = await resolveCatalog(catalogSource);
140
145
  const config = await describeConfigView();
141
146
  const intentVersion = readIntentVersion();
142
- const diff = diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty });
147
+ const diff = diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty, clearOverrides });
143
148
  const now = clock.now();
144
149
  const snap = {
145
150
  previewId: randomUUID(),
@@ -148,6 +153,8 @@ export function createRefreshService(ctx, {
148
153
  operation,
149
154
  selectedIds: [...(selectedIds ?? [])],
150
155
  confirmEmpty: confirmEmpty === true,
156
+ clearOverrides: clearOverrides === true,
157
+ hadModels: hadModelsOf(config),
151
158
  evidence,
152
159
  catalog,
153
160
  config,
@@ -172,9 +179,14 @@ export function createRefreshService(ctx, {
172
179
  catalogError: snap.catalog.catalogError ?? null,
173
180
  skipped: snap.catalog.skipped ?? [],
174
181
  normalized: snap.catalog.normalized ?? null,
182
+ hadModels: snap.hadModels === true,
183
+ clearOverrides: snap.clearOverrides === true,
184
+ catalogNewEntries: snap.catalog.newEntryCount ?? 0,
185
+ overridesMeta: snap.diff.overridesMeta,
186
+ rows: snap.diff.rows,
175
187
  diff: {
176
188
  targetView: snap.diff.targetView,
177
- candidates: snap.diff.candidates ?? null,
189
+ selectedIds: snap.diff.selectedIds,
178
190
  added: snap.diff.added,
179
191
  removed: snap.diff.removed,
180
192
  kept: snap.diff.kept,
@@ -220,6 +232,9 @@ export function createRefreshService(ctx, {
220
232
  if (snap.evidence.stale) {
221
233
  throw flowError("EVIDENCE_STALE", "account evidence is stale (no trusted timestamp, expired, or auth changed); live fetch required");
222
234
  }
235
+ if ((snap.diff.removed ?? []).length > 0 && (snap.evidence.source !== "live" || snap.catalog.catalogSource !== "latest")) {
236
+ throw flowError("removals-need-live", "removals-need-live");
237
+ }
223
238
  if (!transaction) throw flowError("DEPS_MISSING", "transaction kernel not wired");
224
239
  snap.consumed = true;
225
240
  return transaction.runApply({ snapshot: snap });
package/src/shared.mjs CHANGED
@@ -2,13 +2,12 @@
2
2
  // client 侧 fetch 硬编码同一路径,配置一旦漂移 client 即 404(见计划附录 B)。
3
3
  export const CREDENTIAL_KEY = "llm-pi-ai/github-copilot";
4
4
  export const ROUTE_PREFIX = "/copilot-auth";
5
- export const PROTOCOL_VERSION = 2;
5
+ export const PROTOCOL_VERSION = 3; // v3:manage 目标状态编辑(ADR 0005);v2 为 supplement/rebuild 双入口
6
6
  export const routes = () => ({
7
7
  start: `${ROUTE_PREFIX}/start`,
8
8
  state: `${ROUTE_PREFIX}/state`,
9
9
  status: `${ROUTE_PREFIX}/status`,
10
10
  logout: `${ROUTE_PREFIX}/logout`,
11
- cancel: `${ROUTE_PREFIX}/cancel`,
12
11
  refreshPreview: `${ROUTE_PREFIX}/refresh/preview`,
13
12
  refreshApply: `${ROUTE_PREFIX}/refresh/apply`,
14
13
  refreshRetire: `${ROUTE_PREFIX}/refresh/retire`,