@inventec/dsh-copilot-auth 1.2.6 → 1.2.8

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.
@@ -0,0 +1,118 @@
1
+ // model-update.mjs — 纯模型变更策略(T6):补充/重建的目标视图、增删保留与原因、writeSet。
2
+ // 纯函数,不触碰网络/磁盘/settings;视图形状与 host.mjs viewFromUserLayer 同构:
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,不暗删
9
+ export function buildModelChange({
10
+ operation,
11
+ rawView,
12
+ effectiveView,
13
+ accountIds,
14
+ resolvableIds,
15
+ selectedIds,
16
+ confirmEmpty = false,
17
+ catalogNewEntryCount = 0,
18
+ }) {
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 } };
21
+ }
22
+ const accounts = [...new Set((Array.isArray(accountIds) ? accountIds : []).filter((x) => typeof x === "string"))];
23
+ const resolvable = new Set((Array.isArray(resolvableIds) ? resolvableIds : []).filter((x) => typeof x === "string"));
24
+ const raw = rawView ?? { modelsPresent: false, models: null, modelOverridesPresent: false, modelOverrides: null };
25
+ const eff = effectiveView ?? raw;
26
+ const rawModels = raw.modelsPresent && Array.isArray(raw.models) ? raw.models : [];
27
+ 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");
30
+ const warnings = [];
31
+ for (const id of rawIds) {
32
+ if (!resolvable.has(id)) warnings.push({ id, reason: "unresolvable" });
33
+ }
34
+ const catalogEntries = catalogNewEntryCount > 0;
35
+
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
+ }
75
+
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) {
80
+ 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 },
85
+ };
86
+ }
87
+ const intersection = accounts.filter((id) => resolvable.has(id));
88
+ if (intersection.length === 0 && !confirmEmpty) {
89
+ 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 },
94
+ };
95
+ }
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;
102
+ return {
103
+ allowed: true,
104
+ 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)),
113
+ removed,
114
+ kept: intersection.filter((id) => new Set(effIds).has(id)),
115
+ warnings,
116
+ writeSet: { catalogEntries, models: true, modelOverrides: rawOverridesPresent, overridesUnclearable: false },
117
+ };
118
+ }
@@ -1,143 +1,257 @@
1
- // refresh-flow.mjs — 「刷新可用模型目录」client 侧状态机。
2
- // reduce 为纯函数(不执行 I/O);runEffect/advance 只使用注入的 fetchImpl——
3
- // 本模块不直接引用全局 fetch,所有网络能力经注入获得(node:test 可测)。
4
- // 状态:idle → previewing → confirming → applying → restartNeeded / failed;
5
- // apply 409 → previewing 自动重拉(Y2-1:409 响应不携带新 preview,client 重新
6
- // POST preview)→ 成功进 confirming(staleNotice);restartNeeded 可由 /status 水合。
7
- import { routes } from "./shared.mjs";
1
+ // refresh-flow.mjs — 「补充模型/重建模型列表」client 侧状态机(协议 v2,T11)。
2
+ // reduce 为纯函数;runEffect/advance 只使用注入的 fetchImpl(node:test 可测)。
3
+ // 状态:idle → previewing → confirming(勾选→materialize 循环)→ applying →
4
+ // result / pendingRestart / checking / busy / resultUnknown;recovery-needed 经
5
+ // retireConfirm → retiring → 重新预览闭环。
6
+ // 关键规则:
7
+ // - 候选默认全不勾选;选择变化经 basePreviewId 从原快照 materialize(不重新取数)
8
+ // - 重建需二次确认;空目标需单独确认(reducer 强制,UI 负责走完流程)
9
+ // - 423 转 status 查询;apply 网络不明先查同 operationId,绝不换 ID 重发
10
+ // - 预览过期/漂移(409 preview-stale/auth-changed/preview-invalid)→ 自动重新预览
11
+ import { routes, PROTOCOL_VERSION } from "./shared.mjs";
12
+ import { requestJson } from "./client-http.mjs";
8
13
 
9
- export const initial = Object.freeze({ name: "idle", catalogWritable: null, catalogMode: null });
14
+ export const initial = Object.freeze({
15
+ name: "idle",
16
+ flags: { catalogMode: null, blockedReason: null, scopeAvailable: true, legacyStateDetected: false },
17
+ lastResult: null,
18
+ });
19
+
20
+ const fromRefresh = (state, refresh) => ({
21
+ ...state,
22
+ flags: {
23
+ catalogMode: refresh && "catalogMode" in refresh ? refresh.catalogMode : state.flags.catalogMode,
24
+ blockedReason: refresh && "blockedReason" in refresh ? refresh.blockedReason : state.flags.blockedReason,
25
+ scopeAvailable: refresh ? refresh.scopeAvailable !== false : state.flags.scopeAvailable,
26
+ legacyStateDetected: refresh?.legacyStateDetected === true,
27
+ },
28
+ lastResult: refresh?.lastResult ?? null,
29
+ });
30
+
31
+ const restartStates = new Set(["pending-restart"]);
32
+
33
+ // 通道门控(T10 status refresh 块 → T11 入口禁用):catalogMode ∈ file/registry/blocked/unknown。
34
+ // file/registry 可操作;blocked/unknown 或 scope 不可用 → 不承诺可应用,入口禁用(G02/设计§4.3)。
35
+ export function refreshBlocked(flags) {
36
+ const mode = flags?.catalogMode;
37
+ return flags?.scopeAvailable === false || mode === "blocked" || mode === "unknown";
38
+ }
10
39
 
11
- // reduce(state, event) → [nextState, effect | null]
12
- // effect 为数据化指令:{type:"preview",mode} | {type:"apply",mode,digests} | {type:"status"}
13
40
  export function reduce(state, event) {
14
41
  switch (state.name) {
15
42
  case "idle":
16
43
  case "failed":
17
- case "restartNeeded": {
44
+ case "result":
45
+ case "pendingRestart":
46
+ case "busy":
47
+ case "resultUnknown": {
18
48
  if (event.type === "start") {
19
- return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
49
+ return [{
50
+ name: "previewing",
51
+ flags: state.flags ?? initial.flags,
52
+ operation: event.operation === "rebuild" ? "rebuild" : "supplement",
53
+ catalogSource: event.catalogSource,
54
+ stale: false,
55
+ }, {
56
+ type: "preview",
57
+ operation: event.operation === "rebuild" ? "rebuild" : "supplement",
58
+ catalogSource: event.catalogSource,
59
+ }];
60
+ }
61
+ if (event.type === "init" && state.name === "idle") return [state, { type: "status" }];
62
+ if (event.type === "check" && state.name === "resultUnknown") {
63
+ return [{ ...state, name: "checking", operationId: state.operationId }, { type: "statusQuery", operationId: state.operationId }];
64
+ }
65
+ if (event.type === "retire-request" && state.name === "result") {
66
+ return [{ name: "retireConfirm", result: state.result }, null];
67
+ }
68
+ if (event.type === "dismiss" && state.name === "failed") {
69
+ return [{ ...initial, flags: state.flags }, null];
20
70
  }
21
- if (state.name === "failed" && event.type === "dismiss") return [initial, null];
22
- if (state.name === "idle" && event.type === "init") return [state, { type: "status" }];
23
71
  if (event.type === "hydrate") {
24
72
  const refresh = event.status?.refresh;
25
- if (!event.status) return [state, null]; // status 拉取失败不硬失败
26
- if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
27
- if (refresh?.pendingRestart === true) {
28
- return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null, catalogMode: refresh?.catalogMode ?? null }, null];
73
+ if (!event.status) return [state, null];
74
+ if (refresh?.lastError === "state-corrupt") return [{ ...state, name: "failed", error: "state-corrupt" }, null];
75
+ const base = fromRefresh({ ...initial, name: "idle" }, refresh);
76
+ if (restartStates.has(refresh?.lastResult?.status ?? "") || refresh?.pendingRestart === true) {
77
+ return [{ ...base, name: "pendingRestart", result: refresh.lastResult }, null];
78
+ }
79
+ const st = refresh?.lastResult?.status;
80
+ if (st && ["recovery-needed", "rollback-conflict", "conflict", "partial", "rolled-back", "intent-retired", "applied"].includes(st)) {
81
+ return [{ ...base, name: "result", result: refresh.lastResult }, null];
29
82
  }
30
- // 干净水合:catalogWritable / catalogMode 随 /status 落态。desktop asar 只读
31
- // 形态下 catalogWritable=false 且 catalogMode="registry"(ADR 0003:刷新走
32
- // 进程内目录注册表注入,入口保持可用且无需重启);旧宿主不携带这两个字段时
33
- // 保持现值不回退(不回退为"可写",避免误导)。
34
- const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
35
- const mode = refresh && "catalogMode" in refresh ? refresh.catalogMode : state.catalogMode ?? null;
36
- return [{ ...state, catalogWritable: writable, catalogMode: mode }, null];
83
+ if (refresh?.activeOperation) return [{ ...base, name: "busy", active: refresh.activeOperation }, null];
84
+ return [base, null];
37
85
  }
38
86
  return [state, null];
39
87
  }
40
88
  case "previewing": {
41
89
  if (event.type === "preview-ok") {
42
- return [{ name: "confirming", preview: event.preview, mode: state.mode, staleNotice: state.stale === true }, null];
90
+ return [{ name: "confirming", flags: state.flags ?? initial.flags, preview: event.preview, staleNotice: state.stale === true }, null];
43
91
  }
44
- if (event.type === "preview-fail") return [{ name: "failed", error: event.error }, null];
45
- return [state, null]; // previewing 期间忽略 start 等重复事件
92
+ if (event.type === "preview-fail") return [{ name: "failed", flags: state.flags ?? initial.flags, error: event.error }, null];
93
+ return [state, null];
46
94
  }
47
95
  case "confirming": {
96
+ const p = state.preview;
97
+ if (event.type === "select") {
98
+ // 选择变化:从原快照 materialize(服务端重算,沿用原有效期,不重新取数)
99
+ return [{
100
+ name: "previewing",
101
+ flags: state.flags ?? initial.flags,
102
+ operation: p.operation,
103
+ catalogSource: p.catalogSource,
104
+ stale: false,
105
+ }, {
106
+ type: "preview",
107
+ operation: p.operation,
108
+ catalogSource: p.catalogSource,
109
+ basePreviewId: p.previewId,
110
+ selectedIds: event.selectedIds ?? [],
111
+ confirmEmpty: event.confirmEmpty === true,
112
+ }];
113
+ }
48
114
  if (event.type === "start") {
49
- // catalogSource==="local" 时弹窗提供「改用内置覆盖层预览」:
50
- // 发 mode:"overlay" 的新 preview(不直接 apply,R2-5),新 diff 需再次确认
51
- return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
115
+ // 显式切源(overlay/local)或切换操作:生成全新预览(不隐式降级,Q7)
116
+ return [{
117
+ name: "previewing",
118
+ operation: event.operation === "rebuild" ? "rebuild" : p.operation,
119
+ catalogSource: event.catalogSource,
120
+ stale: false,
121
+ }, {
122
+ type: "preview",
123
+ operation: event.operation === "rebuild" ? "rebuild" : p.operation,
124
+ catalogSource: event.catalogSource,
125
+ }];
52
126
  }
53
127
  if (event.type === "confirm") {
54
- return [
55
- { name: "applying", mode: state.mode, digests: state.preview.digests },
56
- { type: "apply", mode: state.mode, digests: state.preview.digests },
57
- ];
128
+ const isRebuild = p.operation === "rebuild";
129
+ const emptyTarget = isRebuild && (p.diff?.targetView?.models ?? []).length === 0;
130
+ if (isRebuild && event.second !== true) return [state, null]; // 重建需二次确认
131
+ if (emptyTarget && event.empty !== true) return [state, null]; // 清空需单独确认
132
+ if (p.evidence?.stale === true) return [state, null]; // stale 证据不可应用(Q7)
133
+ return [{ name: "applying", flags: state.flags ?? initial.flags, preview: p }, { type: "apply", previewId: p.previewId, operationId: p.operationId }];
58
134
  }
59
- if (event.type === "cancel") return [initial, null];
135
+ if (event.type === "cancel") return [{ ...initial, flags: state.flags ?? initial.flags, lastResult: state.lastResult ?? null }, null];
60
136
  return [state, null];
61
137
  }
62
138
  case "applying": {
63
- // apply-ok 携带 restartRequired / mode(ADR 0003):registry 通道当次生效,
64
- // 不要求重启;file 通道仍是两阶段(下个 boot 同步 settings)。
65
139
  if (event.type === "apply-ok") {
66
- return event.restartRequired === false
67
- ? [{ name: "applied", mode: event.mode ?? null, injected: event.injected ?? 0 }, null]
68
- : [{ name: "restartNeeded", catalogMode: state.catalogMode ?? null }, null];
140
+ return restartStates.has(event.result?.status ?? "")
141
+ ? [{ ...state, name: "pendingRestart", result: event.result }, null]
142
+ : [{ ...state, name: "result", result: event.result }, null];
69
143
  }
70
144
  if (event.type === "apply-stale") {
71
- // 409 preview-stale:以原 mode 重新 preview(保持 overlay 来源,R2-5)
72
- return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
145
+ // 预览过期/配置漂移/授权变化 → 自动重新预览(保持操作与来源)
146
+ return [{
147
+ name: "previewing",
148
+ flags: state.flags ?? initial.flags,
149
+ operation: state.preview.operation,
150
+ catalogSource: state.preview.catalogSource,
151
+ stale: true,
152
+ }, { type: "preview", operation: state.preview.operation, catalogSource: state.preview.catalogSource }];
153
+ }
154
+ if (event.type === "apply-busy") {
155
+ return [{ ...state, name: "checking" }, { type: "status" }];
156
+ }
157
+ if (event.type === "apply-unknown") {
158
+ return [{ ...state, name: "checking", operationId: state.preview.operationId }, { type: "statusQuery", operationId: state.preview.operationId }];
159
+ }
160
+ if (event.type === "apply-fail") return [{ ...state, name: "failed", error: event.error }, null];
161
+ return [state, null];
162
+ }
163
+ case "checking": {
164
+ if (event.type === "query-result") {
165
+ if (event.query === "active") return [{ ...state, name: "busy", active: event.active }, null];
166
+ if (event.query === "last") {
167
+ return restartStates.has(event.lastResult?.status ?? "")
168
+ ? [{ ...state, name: "pendingRestart", result: event.lastResult }, null]
169
+ : [{ ...state, name: "result", result: event.lastResult }, null];
170
+ }
171
+ return [{ ...state, name: "resultUnknown", operationId: state.operationId }, null];
172
+ }
173
+ if (event.type === "hydrate") {
174
+ const refresh = event.status?.refresh;
175
+ if (refresh?.activeOperation) return [{ ...state, name: "busy", active: refresh.activeOperation }, null];
176
+ if (refresh?.lastResult) return [{ ...state, name: "result", result: refresh.lastResult }, null];
177
+ return [{ ...state, name: "resultUnknown", operationId: state.operationId }, null];
73
178
  }
74
- if (event.type === "apply-fail") return [{ name: "failed", error: event.error }, null];
75
- return [state, null]; // applying 态忽略 confirm/start(双击只发一次)
179
+ return [state, null];
180
+ }
181
+ case "retireConfirm": {
182
+ if (event.type === "retire-confirm") {
183
+ return [{ name: "retiring", operationId: state.result?.operationId, result: state.result }, { type: "retire", operationId: state.result?.operationId }];
184
+ }
185
+ if (event.type === "cancel") return [{ name: "result", result: state.result }, null];
186
+ return [state, null];
187
+ }
188
+ case "retiring": {
189
+ if (event.type === "retire-ok") return [{ ...state, name: "checking" }, { type: "status" }];
190
+ if (event.type === "retire-fail") return [{ name: "result", result: state.result, retireError: event.error }, null];
191
+ return [state, null];
76
192
  }
77
193
  default:
78
194
  return [state, null];
79
195
  }
80
196
  }
81
197
 
82
- // 副作用执行器:effect → 结果事件。只经注入的 fetchImpl 发请求。
198
+ // 副作用执行器:effect → 结果事件。只经注入的 fetchImpl;错误经 requestJson 脱敏。
83
199
  export async function runEffect(effect, fetchImpl) {
84
200
  const r = routes();
85
- const post = (url, body) =>
86
- fetchImpl(url, {
87
- method: "POST",
88
- headers: { "content-type": "application/json" },
89
- body: JSON.stringify(body),
90
- });
201
+ const post = (url, body) => requestJson(fetchImpl, url, {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({ protocolVersion: PROTOCOL_VERSION, ...body }),
205
+ });
91
206
  if (effect.type === "preview") {
92
- try {
93
- const res = await post(r.refreshPreview, effect.mode ? { mode: effect.mode } : {});
94
- const body = await res.json();
95
- return res.status === 200 && body?.ok
96
- ? { type: "preview-ok", preview: body }
97
- : { type: "preview-fail", error: body?.error ?? `HTTP ${res.status}` };
98
- } catch (err) {
99
- return { type: "preview-fail", error: String(err?.message ?? err) };
100
- }
207
+ const res = await post(r.refreshPreview, {
208
+ operation: effect.operation,
209
+ ...(effect.catalogSource ? { catalogSource: effect.catalogSource } : {}),
210
+ ...(effect.basePreviewId ? { basePreviewId: effect.basePreviewId, selectedIds: effect.selectedIds ?? [] } : {}),
211
+ ...(effect.confirmEmpty ? { confirmEmpty: true } : {}),
212
+ });
213
+ if (res.ok) return { type: "preview-ok", preview: res.body };
214
+ return { type: "preview-fail", error: res.error?.details?.errorCode ?? res.error?.messageKey };
101
215
  }
102
216
  if (effect.type === "apply") {
103
- try {
104
- const res = await post(r.refreshApply, {
105
- digests: effect.digests,
106
- ...(effect.mode ? { mode: effect.mode } : {}),
107
- });
108
- const body = await res.json().catch(() => null);
109
- if (res.status === 409) return { type: "apply-stale" };
110
- return res.status === 200 && body?.ok
111
- ? {
112
- type: "apply-ok",
113
- restartRequired: body.restartRequired !== false,
114
- mode: body.mode ?? null,
115
- injected: Array.isArray(body.injected) ? body.injected.length : 0,
116
- }
117
- : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
118
- } catch (err) {
119
- return { type: "apply-fail", error: String(err?.message ?? err) };
217
+ const res = await post(r.refreshApply, { previewId: effect.previewId, operationId: effect.operationId });
218
+ if (res.ok) return { type: "apply-ok", result: res.body?.result ?? { status: "unknown" } };
219
+ const code = res.error?.details?.errorCode ?? "";
220
+ const status = res.httpStatus;
221
+ if (status === null) return { type: "apply-unknown" }; // 网络不明:先查同 operationId,绝不换 ID 重发
222
+ if (status === 423 || code === "resource-busy") return { type: "apply-busy" };
223
+ if (status === 409 && ["preview-stale", "auth-changed", "preview-invalid"].includes(code)) {
224
+ return { type: "apply-stale", error: code };
120
225
  }
226
+ return { type: "apply-fail", error: code || res.error?.messageKey };
227
+ }
228
+ if (effect.type === "retire") {
229
+ const res = await post(r.refreshRetire, { operationId: effect.operationId });
230
+ if (res.ok) return { type: "retire-ok", result: res.body?.result ?? null };
231
+ return { type: "retire-fail", error: res.error?.details?.errorCode ?? res.error?.messageKey };
121
232
  }
122
233
  if (effect.type === "status") {
123
- try {
124
- const res = await fetchImpl(r.status);
125
- return { type: "hydrate", status: await res.json() };
126
- } catch {
127
- return { type: "hydrate", status: null };
234
+ const res = await requestJson(fetchImpl, r.status, { method: "GET" });
235
+ if (res.ok) return { type: "hydrate", status: res.body };
236
+ return { type: "hydrate", status: null };
237
+ }
238
+ if (effect.type === "statusQuery") {
239
+ const res = await requestJson(fetchImpl, `${r.status}?operationId=${encodeURIComponent(effect.operationId ?? "")}`, { method: "GET" });
240
+ if (res.ok && res.body?.operation) {
241
+ return { type: "query-result", query: res.body.operation.query, active: res.body.operation.active, lastResult: res.body.operation.lastResult };
128
242
  }
243
+ return { type: "query-result", query: "unknown" };
129
244
  }
130
245
  throw new Error(`unknown effect: ${effect.type}`);
131
246
  }
132
247
 
133
248
  // controller:reduce → 执行 effect → 结果事件回送 reduce → 循环至无 effect。
134
- // client.jsx 必须只经 advance 驱动状态机,不直接调 reduce/runEffect。
135
- // onState 在每个 reduce 步同步回调(含 effect 执行前)——UI 借此即时落中间态
136
- // (applying 期间重复 confirm 被 reducer 忽略,双击只发一次)。
137
249
  export async function advance(state, event, fetchImpl, onState) {
138
250
  let [s, fx] = reduce(state, event);
139
251
  onState?.(s);
252
+ let guard = 0;
140
253
  while (fx) {
254
+ if (guard++ > 8) throw new Error("effect loop guard");
141
255
  const resultEvent = await runEffect(fx, fetchImpl);
142
256
  [s, fx] = reduce(s, resultEvent);
143
257
  onState?.(s);