@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,276 @@
1
+ // refresh-service.mjs — 预览服务与账号证据(T8)。
2
+ // 职责:内存预览快照(10 分钟、选择从原快照重算、operationId 绑定选择)、
3
+ // 账号证据门禁(重建需 live;补充允许 ≤24h 同 intentVersion 缓存;stale 只参考不可应用)、
4
+ // apply 幂等(同 operationId 返回已知结果;activeOperation 未终结时新 apply 423)、
5
+ // boot/apply/retire 同一队列串行。
6
+ // 事务内核(T9)经 transaction 注入:{runApply({snapshot}), bootRecover(), retire({operationId})}。
7
+ // 目录与配置取数经 deps 注入(host.mjs 在 T10 组装):{resolveCatalog(catalogSource), describeConfigView()}。
8
+ import { randomUUID } from "node:crypto";
9
+ import { mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
12
+ import { CREDENTIAL_KEY } from "./shared.mjs";
13
+ import { buildModelChange } from "./model-update.mjs";
14
+ import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
15
+
16
+ const PREVIEW_TTL_MS = 10 * 60 * 1000;
17
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
18
+
19
+ function flowError(code, message, extra = {}) {
20
+ const err = new Error(message ?? code);
21
+ err.code = code;
22
+ Object.assign(err, extra);
23
+ return err;
24
+ }
25
+
26
+ const iso = (ts) => new Date(ts).toISOString();
27
+
28
+ // 账号证据:live GET 成功才写缓存(fetchedAt+intentVersion);缓存仅 ≤24h 且同
29
+ // intentVersion 时可作补充的应用依据;否则 stale——预览可展示参考,apply 被拒(Q7/Q16)。
30
+ // 重建(rebuild)永不使用缓存:live 失败即 evidence-unavailable,不退化为缓存重建。
31
+ async function resolveAccountEvidence(ctx, { scope, readIntentVersion, clock, fetchImpl }) {
32
+ const iv = readIntentVersion();
33
+ const cacheFile = join(scope.dataDir, "account-model-cache.json");
34
+ const readCache = () => {
35
+ try {
36
+ return readJson(cacheFile);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ };
41
+ const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
42
+ const payload = record?.payload ?? {};
43
+ if (payload.access) {
44
+ try {
45
+ const ids = await fetchLiveAvailableModelIds({ credential: payload, fetchImpl });
46
+ const fetchedAt = iso(clock.now()); // 单次取时:缓存值与返回值同源(避免毫秒漂移)
47
+ mkdirSync(scope.dataDir, { recursive: true });
48
+ writeJsonAtomic(cacheFile, { ids, fetchedAt, intentVersion: iv });
49
+ return { source: "live", ids, fetchedAt, stale: false };
50
+ } catch { /* 回退缓存判定 */ }
51
+ }
52
+ const cache = readCache();
53
+ if (cache && Array.isArray(cache.ids) && typeof cache.fetchedAt === "string"
54
+ && Number.isFinite(Date.parse(cache.fetchedAt))
55
+ && clock.now() - Date.parse(cache.fetchedAt) <= CACHE_TTL_MS
56
+ && cache.intentVersion === iv) {
57
+ return { source: "cache", ids: cache.ids, fetchedAt: cache.fetchedAt, stale: false };
58
+ }
59
+ return { source: "stale", ids: Array.isArray(cache?.ids) ? cache.ids : [], fetchedAt: cache?.fetchedAt ?? null, stale: true };
60
+ }
61
+
62
+ export function createRefreshService(ctx, {
63
+ scope,
64
+ stateIO,
65
+ readIntentVersion,
66
+ clock = { now: () => Date.now() },
67
+ deps = {},
68
+ transaction = null,
69
+ fetchImpl,
70
+ }) {
71
+ const requireScope = () => {
72
+ if (!scope?.known) throw flowError("SCOPE_UNAVAILABLE", "scope-unavailable");
73
+ };
74
+ const queue = createMutex();
75
+ const snapshots = new Map(); // previewId -> snapshot(内存态;重启即失效,幂等走持久状态)
76
+ const getFetch = () => fetchImpl ?? globalThis.fetch?.bind(globalThis);
77
+ const resolveCatalog = deps.resolveCatalog;
78
+ const describeConfigView = deps.describeConfigView;
79
+
80
+ function requireDeps() {
81
+ if (typeof resolveCatalog !== "function" || typeof describeConfigView !== "function") {
82
+ throw flowError("DEPS_MISSING", "refresh service deps not wired");
83
+ }
84
+ }
85
+
86
+ function diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty }) {
87
+ const diff = buildModelChange({
88
+ operation,
89
+ rawView: config.view,
90
+ effectiveView: config.effectiveView,
91
+ accountIds: evidence.ids,
92
+ resolvableIds: catalog.resolvableIds,
93
+ selectedIds: selectedIds ?? [],
94
+ confirmEmpty: confirmEmpty === true,
95
+ catalogNewEntryCount: catalog.newEntryCount ?? 0,
96
+ });
97
+ if (!diff.allowed) throw flowError(diff.reason, diff.reason, { policy: diff.reason, warnings: diff.warnings });
98
+ return diff;
99
+ }
100
+
101
+ async function preview(body = {}) {
102
+ requireScope();
103
+ requireDeps();
104
+ // 过期快照淘汰(长驻进程防累积;幂等查询走持久状态,不受影响)
105
+ const nowTs = clock.now();
106
+ for (const [id, snap] of snapshots) {
107
+ if (nowTs > snap.expiresAt) snapshots.delete(id);
108
+ }
109
+ const { operation, catalogSource, basePreviewId, selectedIds, confirmEmpty } = body;
110
+ if (operation !== "supplement" && operation !== "rebuild") {
111
+ throw flowError("INVALID_OPERATION", "invalid-operation");
112
+ }
113
+ // 选择变化:从原快照重算(不重新下载、不重新取数),沿用原 expiresAt
114
+ if (basePreviewId !== undefined) {
115
+ const base = snapshots.get(basePreviewId);
116
+ if (!base || base.consumed) throw flowError("PREVIEW_INVALID", "preview-invalid");
117
+ if (clock.now() > base.expiresAt) throw flowError("PREVIEW_INVALID", "preview-invalid", { reason: "expired" });
118
+ const diff = diffFromInputs({
119
+ operation: base.operation, evidence: base.evidence, catalog: base.catalog,
120
+ config: base.config, selectedIds, confirmEmpty,
121
+ });
122
+ const snap = {
123
+ ...base,
124
+ previewId: randomUUID(),
125
+ selectedIds: [...(selectedIds ?? [])],
126
+ confirmEmpty: confirmEmpty === true,
127
+ diff,
128
+ operationId: randomUUID(),
129
+ basePreviewId,
130
+ consumed: false,
131
+ };
132
+ snapshots.set(snap.previewId, snap);
133
+ return projectPreview(snap);
134
+ }
135
+ 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
+ const catalog = await resolveCatalog(catalogSource);
140
+ const config = await describeConfigView();
141
+ const intentVersion = readIntentVersion();
142
+ const diff = diffFromInputs({ operation, evidence, catalog, config, selectedIds, confirmEmpty });
143
+ const now = clock.now();
144
+ const snap = {
145
+ previewId: randomUUID(),
146
+ createdAt: now,
147
+ expiresAt: now + PREVIEW_TTL_MS,
148
+ operation,
149
+ selectedIds: [...(selectedIds ?? [])],
150
+ confirmEmpty: confirmEmpty === true,
151
+ evidence,
152
+ catalog,
153
+ config,
154
+ intentVersion,
155
+ diff,
156
+ operationId: randomUUID(),
157
+ consumed: false,
158
+ };
159
+ snapshots.set(snap.previewId, snap);
160
+ return projectPreview(snap);
161
+ }
162
+
163
+ function projectPreview(snap) {
164
+ return {
165
+ ok: true,
166
+ previewId: snap.previewId,
167
+ operationId: snap.operationId,
168
+ operation: snap.operation,
169
+ expiresAt: iso(snap.expiresAt),
170
+ evidence: { source: snap.evidence.source, fetchedAt: snap.evidence.fetchedAt, stale: snap.evidence.stale },
171
+ catalogSource: snap.catalog.catalogSource,
172
+ catalogError: snap.catalog.catalogError ?? null,
173
+ skipped: snap.catalog.skipped ?? [],
174
+ normalized: snap.catalog.normalized ?? null,
175
+ diff: {
176
+ targetView: snap.diff.targetView,
177
+ candidates: snap.diff.candidates ?? null,
178
+ added: snap.diff.added,
179
+ removed: snap.diff.removed,
180
+ kept: snap.diff.kept,
181
+ warnings: snap.diff.warnings,
182
+ writeSet: snap.diff.writeSet,
183
+ },
184
+ };
185
+ }
186
+
187
+ async function apply(body = {}) {
188
+ requireScope();
189
+ const { previewId, operationId } = body;
190
+ if (typeof operationId !== "string" || typeof previewId !== "string") {
191
+ throw flowError("INVALID_REQUEST", "previewId and operationId are required");
192
+ }
193
+ return queue(async () => {
194
+ // ① 幂等:同 operationId 先查持久状态(active → 进行中结果;lastResult → 终态结果)
195
+ const state = stateIO.load();
196
+ if (state?.unknownVersion) throw flowError("STATE_UNKNOWN", "state unknown-version; manual review required");
197
+ if (state?.activeOperation?.operationId === operationId) {
198
+ return { ok: true, operationId, result: projectActive(state.activeOperation), idempotent: true };
199
+ }
200
+ if (state?.lastResult?.operationId === operationId) {
201
+ return { ok: true, operationId, result: state.lastResult, idempotent: true };
202
+ }
203
+ // ② activeOperation 未终结(不同 operationId)→ 423
204
+ if (state?.activeOperation) {
205
+ throw flowError("RESOURCE_BUSY", "an operation is still active; query status or retire it first", { activeOperationId: state.activeOperation.operationId });
206
+ }
207
+ // ③ live preview 校验
208
+ const snap = snapshots.get(previewId);
209
+ if (!snap || snap.consumed) throw flowError("PREVIEW_INVALID", "preview-invalid");
210
+ if (snap.operationId !== operationId) throw flowError("PREVIEW_INVALID", "operationId does not bind to this preview", { reason: "operation-mismatch" });
211
+ if (clock.now() > snap.expiresAt) throw flowError("PREVIEW_INVALID", "preview-invalid", { reason: "expired" });
212
+ // ④ 漂移复核:intentVersion / 配置 revision
213
+ if (readIntentVersion() !== snap.intentVersion) {
214
+ throw flowError("AUTH_CHANGED", "auth-changed: intent version moved since preview; preview again");
215
+ }
216
+ const current = await describeConfigView();
217
+ if (current.revision !== snap.config.revision) {
218
+ throw flowError("PREVIEW_STALE", "preview-stale: configuration changed since preview; preview again");
219
+ }
220
+ if (snap.evidence.stale) {
221
+ throw flowError("EVIDENCE_STALE", "account evidence is stale (no trusted timestamp, expired, or auth changed); live fetch required");
222
+ }
223
+ if (!transaction) throw flowError("DEPS_MISSING", "transaction kernel not wired");
224
+ snap.consumed = true;
225
+ return transaction.runApply({ snapshot: snap });
226
+ });
227
+ }
228
+
229
+ function projectActive(active) {
230
+ return {
231
+ operationId: active.operationId,
232
+ status: active.phaseResult?.status ?? "pending-restart",
233
+ phase: active.phase,
234
+ changes: active.changes ?? null,
235
+ error: active.lastError ?? null,
236
+ };
237
+ }
238
+
239
+ async function status(body = {}) {
240
+ requireScope();
241
+ const state = stateIO.load();
242
+ if (typeof body?.operationId === "string") {
243
+ if (state?.activeOperation?.operationId === body.operationId) {
244
+ return { query: "active", active: projectActive(state.activeOperation), lastResult: state.lastResult ?? null };
245
+ }
246
+ if (state?.lastResult?.operationId === body.operationId) {
247
+ return { query: "last", lastResult: state.lastResult, active: null };
248
+ }
249
+ return { query: "unknown", active: state?.activeOperation ?? null, lastResult: state?.lastResult ?? null };
250
+ }
251
+ return { query: "none", active: state?.activeOperation ?? null, lastResult: state?.lastResult ?? null };
252
+ }
253
+
254
+ async function retire(body = {}) {
255
+ requireScope();
256
+ if (!transaction) throw flowError("DEPS_MISSING", "transaction kernel not wired");
257
+ return queue(() => transaction.retire({ operationId: body?.operationId }));
258
+ }
259
+
260
+ async function bootReady() {
261
+ requireScope();
262
+ if (!transaction) return { ok: true, skipped: true };
263
+ return queue(() => transaction.bootRecover());
264
+ }
265
+
266
+ function dispose() {
267
+ snapshots.clear();
268
+ }
269
+
270
+ // 诊断/测试观察面:当前内存快照数(含未过期;过期项在下次 preview 入口清扫)
271
+ function snapshotCount() {
272
+ return snapshots.size;
273
+ }
274
+
275
+ return { preview, apply, status, retire, bootReady, dispose, snapshotCount };
276
+ }