@inventec/dsh-copilot-auth 1.2.7 → 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.
- package/README.md +38 -25
- package/lib/client.js +806 -275
- package/package.json +9 -2
- package/src/auth-flow.mjs +190 -0
- package/src/auth-host.mjs +182 -0
- package/src/client-http.mjs +80 -0
- package/src/host.mjs +352 -635
- package/src/model-update.mjs +118 -0
- package/src/refresh-flow.mjs +200 -86
- package/src/refresh-service.mjs +276 -0
- package/src/refresh-transaction.mjs +573 -0
- package/src/runtime-scope.mjs +25 -0
- package/src/shared.mjs +3 -0
- package/src/state.mjs +61 -60
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// refresh-transaction.mjs — apply 事务内核、boot 恢复与 retire(T9)。
|
|
2
|
+
// 阶段机:prepared → catalog-landed → configuration-committed → verified,
|
|
3
|
+
// 每阶段先持久再行动(写前意图);两通道提交时点(接口契约「apply 提交时点」):
|
|
4
|
+
// - file 通道且 writeSet.catalogEntries:apply 只落目录并返回 pending-restart,
|
|
5
|
+
// 配置提交延迟到下个 boot(目录已加载+目标可解析+基线 view 未变,否则 conflict)
|
|
6
|
+
// - registry 通道/纯配置:apply 当次完成提交与验证(纯配置即时生效,Q29)
|
|
7
|
+
// 回滚只在「当前配置仍等于本操作写入值」时执行,否则 rollback-conflict 保留用户值。
|
|
8
|
+
// bootRecover 同时承担 v1 boot 的保守激活/自愈职责(等价迁移)与 restartState 清理。
|
|
9
|
+
// failpoint 仅测试注入(opts.failpoint(name) 在命名边界抛错模拟崩溃窗口)。
|
|
10
|
+
import { writeJsonAtomic } from "./atomic-json.mjs";
|
|
11
|
+
import { mergeCatalog, digest } from "./catalog.mjs";
|
|
12
|
+
import { catalogShapeKeys } from "./catalog.mjs";
|
|
13
|
+
import { restartMarker, setRefreshError, clearRefreshError } from "./state.mjs";
|
|
14
|
+
|
|
15
|
+
const MODELS_PATH = ["providers", "github-copilot", "models"];
|
|
16
|
+
const OVERRIDES_PATH = ["providers", "github-copilot", "modelOverrides"];
|
|
17
|
+
|
|
18
|
+
function canonical(value) {
|
|
19
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
21
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
22
|
+
}
|
|
23
|
+
const viewDigest = (view) => digest(canonical(view));
|
|
24
|
+
|
|
25
|
+
function overlayIds(overlay) {
|
|
26
|
+
return Object.values(overlay ?? {}).flatMap((s) => Object.keys(s ?? {}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildConfigOps(baseline, target) {
|
|
30
|
+
const ops = [];
|
|
31
|
+
if (target.modelsPresent) {
|
|
32
|
+
ops.push({ op: "set", path: MODELS_PATH, value: target.models });
|
|
33
|
+
} else if (baseline.modelsPresent) {
|
|
34
|
+
ops.push({ op: "unset", path: MODELS_PATH });
|
|
35
|
+
}
|
|
36
|
+
if (target.modelOverridesPresent) {
|
|
37
|
+
ops.push({ op: "set", path: OVERRIDES_PATH, value: target.modelOverrides });
|
|
38
|
+
} else if (baseline.modelOverridesPresent) {
|
|
39
|
+
ops.push({ op: "unset", path: OVERRIDES_PATH });
|
|
40
|
+
}
|
|
41
|
+
return ops;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// boot 终态的 models 计数:按 settingsBaseline ↔ targetView 的 id 差异计算
|
|
45
|
+
//(apply 当期的 pending-restart 结果 modelsAdded=0 是事实——配置尚未写;boot 提交后按此更正)。
|
|
46
|
+
function modelChangeCounts(active) {
|
|
47
|
+
const baseIds = new Set(((active.settingsBaseline?.view?.models ?? [])
|
|
48
|
+
.map((m) => m?.id).filter(Boolean)));
|
|
49
|
+
const targetIds = (active.targetView?.models ?? []).map((m) => m?.id).filter(Boolean);
|
|
50
|
+
return {
|
|
51
|
+
modelsAdded: targetIds.filter((id) => !baseIds.has(id)).length,
|
|
52
|
+
modelsRemoved: [...baseIds].filter((id) => !targetIds.includes(id)).length,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createTransaction(ctx, {
|
|
57
|
+
scope,
|
|
58
|
+
stateIO,
|
|
59
|
+
readIntentVersion,
|
|
60
|
+
clock = { now: () => Date.now() },
|
|
61
|
+
install,
|
|
62
|
+
deps,
|
|
63
|
+
log = () => {},
|
|
64
|
+
failpoint = null,
|
|
65
|
+
servedRetry = { attempts: 5, delayMs: 5 },
|
|
66
|
+
} = {}) {
|
|
67
|
+
const fp = (name) => {
|
|
68
|
+
if (failpoint && failpoint(name)) throw new Error(`failpoint:${name}`);
|
|
69
|
+
};
|
|
70
|
+
const diskIds = () => {
|
|
71
|
+
try {
|
|
72
|
+
const disk = deps.readDiskCatalog();
|
|
73
|
+
return new Set(Object.values(disk).flatMap((s) => Object.keys(s ?? {})));
|
|
74
|
+
} catch {
|
|
75
|
+
return new Set();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
// 运行时实际可解析集合:目录文件 ∪ 进程内注册表(只读安装树才并注册表面)
|
|
79
|
+
const servedIds = async () => {
|
|
80
|
+
const ids = diskIds();
|
|
81
|
+
if (install && install.writable !== true) {
|
|
82
|
+
try {
|
|
83
|
+
const reg = await deps.registryIds();
|
|
84
|
+
if (reg) for (const id of reg) ids.add(id);
|
|
85
|
+
} catch { /* 注册表不可用不阻断 */ }
|
|
86
|
+
}
|
|
87
|
+
return ids;
|
|
88
|
+
};
|
|
89
|
+
async function waitForServed(targetIds) {
|
|
90
|
+
const want = [...new Set(targetIds)];
|
|
91
|
+
for (let i = 0; i < servedRetry.attempts; i++) {
|
|
92
|
+
let served = null;
|
|
93
|
+
try {
|
|
94
|
+
served = await deps.listServedIds();
|
|
95
|
+
} catch {
|
|
96
|
+
served = null;
|
|
97
|
+
}
|
|
98
|
+
if (served && want.every((id) => served.has(id))) return { ok: true, missing: [] };
|
|
99
|
+
if (i < servedRetry.attempts - 1) await new Promise((r) => setTimeout(r, servedRetry.delayMs));
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, missing: want };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 落地一次目录增量:可写安装树走原子写盘(只增合并);只读树走注册表注入。
|
|
105
|
+
// 返回 {ok, wrote, mode, reason},绝不抛。
|
|
106
|
+
async function landIncrement(overlay) {
|
|
107
|
+
const merged = mergeCatalog(deps.readDiskCatalog(), overlay);
|
|
108
|
+
if (merged.added.length === 0) return { ok: true, wrote: false, mode: "none", reason: null, merged };
|
|
109
|
+
if (install.writable) {
|
|
110
|
+
writeJsonAtomic(install.catalogFile, merged.merged);
|
|
111
|
+
return { ok: true, wrote: true, mode: "file", reason: null, merged };
|
|
112
|
+
}
|
|
113
|
+
const injected = await deps.injectRegistry(overlay, { allowedKeys: catalogShapeKeys(deps.readDiskCatalog()) });
|
|
114
|
+
return { ok: injected.ok, wrote: false, mode: "registry", reason: injected.reason, merged };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function terminal(state, result) {
|
|
118
|
+
// 终态原子写:lastResult 与清 activeOperation 同一次保存(不留「丢事实开新写」窗口)
|
|
119
|
+
const next = { ...state, activeOperation: null, lastResult: result };
|
|
120
|
+
next.intentVersion = readIntentVersion();
|
|
121
|
+
stateIO.save(next);
|
|
122
|
+
return { ok: true, operationId: result.operationId, result };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runApply({ snapshot }) {
|
|
126
|
+
const state0 = stateIO.load();
|
|
127
|
+
if (!state0) throw new Error("state-missing");
|
|
128
|
+
const diff = snapshot.diff;
|
|
129
|
+
const writeSet = diff.writeSet;
|
|
130
|
+
const active = {
|
|
131
|
+
operationId: snapshot.operationId,
|
|
132
|
+
strategy: snapshot.operation,
|
|
133
|
+
selectedIds: [...(snapshot.selectedIds ?? [])],
|
|
134
|
+
confirmEmpty: snapshot.confirmEmpty === true,
|
|
135
|
+
intentVersion: snapshot.intentVersion,
|
|
136
|
+
settingsBaseline: { view: snapshot.config.view, revision: snapshot.config.revision },
|
|
137
|
+
targetView: diff.targetView,
|
|
138
|
+
writeSet,
|
|
139
|
+
pendingOverlay: snapshot.catalog.addedOverlay ?? {},
|
|
140
|
+
provenance: snapshot.catalog.provenance ?? null,
|
|
141
|
+
appliedAgainstPiAiVersion: install?.version ?? null,
|
|
142
|
+
phase: "prepared",
|
|
143
|
+
createdAt: new Date(clock.now()).toISOString(),
|
|
144
|
+
lastError: null,
|
|
145
|
+
phaseResult: null,
|
|
146
|
+
};
|
|
147
|
+
fp("before-prepared");
|
|
148
|
+
stateIO.save({ ...state0, activeOperation: active });
|
|
149
|
+
fp("after-prepared");
|
|
150
|
+
|
|
151
|
+
let state = stateIO.load();
|
|
152
|
+
|
|
153
|
+
// ---- 目录落地 ----
|
|
154
|
+
if (writeSet.catalogEntries && overlayIds(active.pendingOverlay).length > 0) {
|
|
155
|
+
const landed = await landIncrement(active.pendingOverlay);
|
|
156
|
+
if (!landed.ok) {
|
|
157
|
+
return terminal(state, {
|
|
158
|
+
operationId: active.operationId,
|
|
159
|
+
status: "conflict",
|
|
160
|
+
phase: "prepared",
|
|
161
|
+
error: landed.mode === "file" ? "catalog-not-writable" : `registry-inject-failed (${landed.reason})`,
|
|
162
|
+
changes: { catalogAdded: 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
state = stateIO.load();
|
|
166
|
+
state.appliedOverlay = mergeCatalog(state.appliedOverlay, active.pendingOverlay).merged;
|
|
167
|
+
state.appliedProvenance = {
|
|
168
|
+
sourcePiAiVersion: active.provenance?.piAiVersion ?? null,
|
|
169
|
+
integrity: active.provenance?.integrity ?? null,
|
|
170
|
+
appliedAgainstPiAiVersion: install.version,
|
|
171
|
+
catalogSchemaVersion: 1,
|
|
172
|
+
};
|
|
173
|
+
state.activated = true;
|
|
174
|
+
active.phase = "catalog-landed";
|
|
175
|
+
state.activeOperation = active;
|
|
176
|
+
state = clearRefreshError(state);
|
|
177
|
+
fp("after-catalog-landed");
|
|
178
|
+
stateIO.save(state);
|
|
179
|
+
state = stateIO.load();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const configWrite = writeSet.models || writeSet.modelOverrides;
|
|
183
|
+
const fileChannelDeferred = install.writable === true && writeSet.catalogEntries;
|
|
184
|
+
|
|
185
|
+
// ---- file 通道两阶段:配置提交延迟到 boot ----
|
|
186
|
+
if (fileChannelDeferred) {
|
|
187
|
+
if (configWrite) {
|
|
188
|
+
state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
189
|
+
state.activeOperation = { ...active, phaseResult: { status: "pending-restart" } };
|
|
190
|
+
stateIO.save(state);
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
operationId: active.operationId,
|
|
194
|
+
result: {
|
|
195
|
+
operationId: active.operationId,
|
|
196
|
+
status: "pending-restart",
|
|
197
|
+
phase: "catalog-landed",
|
|
198
|
+
changes: { catalogAdded: overlayIds(active.pendingOverlay).length, modelsAdded: 0, modelsRemoved: 0 },
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// 目录-only:无配置写,待重启加载即完成(结果非 no-change:目录确实落地了)
|
|
203
|
+
state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
204
|
+
return terminal(state, {
|
|
205
|
+
operationId: active.operationId,
|
|
206
|
+
status: "pending-restart",
|
|
207
|
+
phase: "catalog-landed",
|
|
208
|
+
changes: { catalogAdded: overlayIds(active.pendingOverlay).length, modelsAdded: 0, modelsRemoved: 0 },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- 当次配置提交(registry 通道 / 纯配置) ----
|
|
213
|
+
if (configWrite) {
|
|
214
|
+
const current = await deps.describeConfigView();
|
|
215
|
+
if (current.revision !== active.settingsBaseline.revision) {
|
|
216
|
+
return terminal(state, {
|
|
217
|
+
operationId: active.operationId,
|
|
218
|
+
status: "conflict",
|
|
219
|
+
phase: "catalog-landed",
|
|
220
|
+
error: "settings-conflict: configuration changed since preview",
|
|
221
|
+
changes: { catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const ops = buildConfigOps(active.settingsBaseline.view, active.targetView);
|
|
225
|
+
// 写前意图:先持久 configuration-committed(提交即将发生),再 mutate;
|
|
226
|
+
// 崩溃于两窗之间由 boot 按「当前值==target→applied/==baseline→重提交」收敛
|
|
227
|
+
state = stateIO.load();
|
|
228
|
+
state.activeOperation = { ...active, phase: "configuration-committed" };
|
|
229
|
+
stateIO.save(state);
|
|
230
|
+
fp("before-mutate");
|
|
231
|
+
try {
|
|
232
|
+
await deps.mutateSettings(ops, current.revision);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const conflict = err?.code === "SETTINGS_CONFLICT" || /conflict/i.test(String(err?.message ?? ""));
|
|
235
|
+
return terminal(stateIO.load(), {
|
|
236
|
+
operationId: active.operationId,
|
|
237
|
+
status: "conflict",
|
|
238
|
+
phase: "configuration-committed",
|
|
239
|
+
error: conflict ? "settings-conflict" : String(err?.message ?? err),
|
|
240
|
+
changes: { catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
fp("after-mutate");
|
|
244
|
+
state = stateIO.load();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- 验证 ----
|
|
248
|
+
const targetIds = (active.targetView.models ?? []).map((m) => m?.id).filter(Boolean);
|
|
249
|
+
if (!install.writable && (configWrite || writeSet.catalogEntries)) {
|
|
250
|
+
// registry:宿主 listModels 端到端复核(注入后宿主快照可能需要净零切换重建)
|
|
251
|
+
let served = await waitForServed(targetIds);
|
|
252
|
+
if (!served.ok && typeof deps.touchRouteIdentity === "function") {
|
|
253
|
+
let touched = false;
|
|
254
|
+
try {
|
|
255
|
+
touched = await deps.touchRouteIdentity();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
log(`copilot-auth: registry snapshot touch failed — ${String(err?.message ?? err)}`);
|
|
258
|
+
}
|
|
259
|
+
if (touched) served = await waitForServed(targetIds);
|
|
260
|
+
}
|
|
261
|
+
if (!served.ok) {
|
|
262
|
+
// 自证失败:仅当当前配置仍等于本操作写入值才回滚,否则 rollback-conflict 保留用户值
|
|
263
|
+
const now = await deps.describeConfigView();
|
|
264
|
+
const weOwn = canonical(now.view) === canonical({ ...active.targetView });
|
|
265
|
+
if (weOwn) {
|
|
266
|
+
const restore = buildConfigOps(active.targetView, active.settingsBaseline.view);
|
|
267
|
+
try {
|
|
268
|
+
const cur = await deps.describeConfigView();
|
|
269
|
+
await deps.mutateSettings(restore, cur.revision);
|
|
270
|
+
return terminal(stateIO.load(), {
|
|
271
|
+
operationId: active.operationId,
|
|
272
|
+
status: "rolled-back",
|
|
273
|
+
phase: "configuration-committed",
|
|
274
|
+
error: `registry-not-served: ${served.missing.join(",")}`,
|
|
275
|
+
changes: { catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
return terminal(stateIO.load(), {
|
|
279
|
+
operationId: active.operationId,
|
|
280
|
+
status: "rollback-conflict",
|
|
281
|
+
phase: "configuration-committed",
|
|
282
|
+
error: `registry-not-served: ${served.missing.join(",")}`,
|
|
283
|
+
changes: { catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return terminal(stateIO.load(), {
|
|
288
|
+
operationId: active.operationId,
|
|
289
|
+
status: "rollback-conflict",
|
|
290
|
+
phase: "configuration-committed",
|
|
291
|
+
error: `registry-not-served: ${served.missing.join(",")}`,
|
|
292
|
+
changes: { catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0, modelsAdded: 0, modelsRemoved: 0 },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
} else if (configWrite) {
|
|
296
|
+
// 纯配置/可写树:re-describe 相等即验证
|
|
297
|
+
const now = await deps.describeConfigView();
|
|
298
|
+
if (canonical(now.view.models) !== canonical(active.targetView.models)
|
|
299
|
+
|| canonical(now.view.modelOverrides) !== canonical(active.targetView.modelOverrides)) {
|
|
300
|
+
return terminal(stateIO.load(), {
|
|
301
|
+
operationId: active.operationId,
|
|
302
|
+
status: "partial",
|
|
303
|
+
phase: "configuration-committed",
|
|
304
|
+
error: "verification-mismatch",
|
|
305
|
+
changes: { catalogAdded: 0, modelsAdded: diff.added.length, modelsRemoved: diff.removed.length },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
state = stateIO.load();
|
|
311
|
+
state.activeOperation = { ...active, phase: "verified" };
|
|
312
|
+
stateIO.save(state);
|
|
313
|
+
return terminal(stateIO.load(), {
|
|
314
|
+
operationId: active.operationId,
|
|
315
|
+
status: "applied",
|
|
316
|
+
phase: "verified",
|
|
317
|
+
changes: {
|
|
318
|
+
catalogAdded: writeSet.catalogEntries ? overlayIds(active.pendingOverlay).length : 0,
|
|
319
|
+
modelsAdded: diff.added.length,
|
|
320
|
+
modelsRemoved: diff.removed.length,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- boot 恢复:activeOperation + restartState 清理 + 保守激活/自愈(等价迁移 v1 boot) ----
|
|
326
|
+
async function bootRecover() {
|
|
327
|
+
let state = stateIO.load();
|
|
328
|
+
if (!state) return { ok: true, skipped: true };
|
|
329
|
+
let wroteDiskThisBoot = false;
|
|
330
|
+
|
|
331
|
+
// 自愈(v1 boot 1):仅 activated 且 pi-ai 基线未变才重放;跨基线只上报不自动应用
|
|
332
|
+
if (state.activated && overlayIds(state.appliedOverlay).length > 0 && state.appliedProvenance?.appliedAgainstPiAiVersion) {
|
|
333
|
+
const baseline = state.appliedProvenance.appliedAgainstPiAiVersion;
|
|
334
|
+
if (install?.version === baseline) {
|
|
335
|
+
const landed = await landIncrement(state.appliedOverlay);
|
|
336
|
+
if (!landed.ok) {
|
|
337
|
+
state = setRefreshError(state, landed.mode === "file" ? "catalog-not-writable: self-heal deferred" : `registry-inject-failed (${landed.reason}): self-heal deferred`);
|
|
338
|
+
stateIO.save(state);
|
|
339
|
+
} else {
|
|
340
|
+
if (landed.wrote) {
|
|
341
|
+
state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
|
|
342
|
+
wroteDiskThisBoot = true; // 本进程尚未加载新目录:restartState 留待下个 boot 核实后清除
|
|
343
|
+
}
|
|
344
|
+
if (landed.mode === "registry" && landed.merged.added.length > 0) {
|
|
345
|
+
// 注入当次生效:宿主快照复核,缺失即净零切换重试,仍缺失记 lastError(不写盘、不阻断)
|
|
346
|
+
const ids = overlayIds(state.appliedOverlay);
|
|
347
|
+
const served = await waitForServed(ids);
|
|
348
|
+
if (!served.ok && ids.length > 0) {
|
|
349
|
+
let recovered = false;
|
|
350
|
+
if (typeof deps.touchRouteIdentity === "function") {
|
|
351
|
+
let touched = false;
|
|
352
|
+
try { touched = await deps.touchRouteIdentity(); } catch { /* 尽力而为 */ }
|
|
353
|
+
if (touched) recovered = (await waitForServed(ids)).ok;
|
|
354
|
+
}
|
|
355
|
+
if (!recovered) {
|
|
356
|
+
state = setRefreshError(state, `registry-not-served: ${served.missing.join(",")} — injected entries are in the registry but the route still does not serve them`);
|
|
357
|
+
stateIO.save(state);
|
|
358
|
+
return finish();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
state = clearRefreshError(state);
|
|
363
|
+
stateIO.save(state);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
const ids = await servedIds();
|
|
367
|
+
const missing = overlayIds(state.appliedOverlay).filter((id) => !ids.has(id));
|
|
368
|
+
if (missing.length > 0) {
|
|
369
|
+
state = setRefreshError(state, `self-heal-incompatible: pi-ai ${install?.version} != baseline ${baseline}, missing ${missing.join(",")}`);
|
|
370
|
+
stateIO.save(state);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return finish();
|
|
376
|
+
|
|
377
|
+
async function finish() {
|
|
378
|
+
let st = stateIO.load();
|
|
379
|
+
// restartState 清理(v1 boot 2b):期望条目已可解析即清除(不携带 settings 意图);
|
|
380
|
+
// 本 boot 刚写盘的除外(当前进程目录快照未加载新条目)
|
|
381
|
+
if (st.restartState && install && !wroteDiskThisBoot) {
|
|
382
|
+
const ids = await servedIds();
|
|
383
|
+
if (overlayIds(st.appliedOverlay).every((id) => ids.has(id))) {
|
|
384
|
+
st.restartState = null;
|
|
385
|
+
st = clearRefreshError(st);
|
|
386
|
+
stateIO.save(st);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
st = stateIO.load();
|
|
390
|
+
const active = st?.activeOperation;
|
|
391
|
+
if (!active) return { ok: true, active: false };
|
|
392
|
+
|
|
393
|
+
// intentVersion 漂移:不消费旧 target,标记 recovery-needed(用户经 retire 显式结束后重新预览)
|
|
394
|
+
if (active.intentVersion !== readIntentVersion()) {
|
|
395
|
+
const next = stateIO.load();
|
|
396
|
+
next.activeOperation = { ...active, phaseResult: { status: "recovery-needed", error: "intent-version-drift" } };
|
|
397
|
+
stateIO.save(next);
|
|
398
|
+
return { ok: true, active: true, status: "recovery-needed" };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (active.phase === "prepared") {
|
|
402
|
+
// 崩溃窗口:目录可能已落地也可能没有——兼容 gate 通过后幂等重放,进入 catalog-landed 语义
|
|
403
|
+
if (active.writeSet?.catalogEntries && overlayIds(active.pendingOverlay).length > 0) {
|
|
404
|
+
if (install?.version !== active.appliedAgainstPiAiVersion) {
|
|
405
|
+
const next = stateIO.load();
|
|
406
|
+
next.activeOperation = { ...active, phaseResult: { status: "recovery-needed", error: "prepared-incompatible" } };
|
|
407
|
+
stateIO.save(setRefreshError(next, "prepared-incompatible"));
|
|
408
|
+
return { ok: true, active: true, status: "recovery-needed" };
|
|
409
|
+
}
|
|
410
|
+
const landed = await landIncrement(active.pendingOverlay);
|
|
411
|
+
if (!landed.ok) {
|
|
412
|
+
const next = stateIO.load();
|
|
413
|
+
next.activeOperation = { ...active, phaseResult: { status: "recovery-needed", error: landed.mode === "file" ? "catalog-not-writable" : `registry-inject-failed (${landed.reason})` } };
|
|
414
|
+
next.activeOperation.lastError = next.activeOperation.phaseResult.error;
|
|
415
|
+
stateIO.save(setRefreshError(next, next.activeOperation.phaseResult.error));
|
|
416
|
+
return { ok: true, active: true, status: "recovery-needed" };
|
|
417
|
+
}
|
|
418
|
+
const next = stateIO.load();
|
|
419
|
+
next.appliedOverlay = mergeCatalog(next.appliedOverlay, active.pendingOverlay).merged;
|
|
420
|
+
next.appliedProvenance = next.appliedProvenance ?? {
|
|
421
|
+
sourcePiAiVersion: null, integrity: null, appliedAgainstPiAiVersion: install.version, catalogSchemaVersion: 1,
|
|
422
|
+
};
|
|
423
|
+
next.activated = true;
|
|
424
|
+
next.activeOperation = { ...active, phase: "catalog-landed" };
|
|
425
|
+
stateIO.save(clearRefreshError(next));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const a = stateIO.load()?.activeOperation;
|
|
430
|
+
if (!a) return { ok: true, active: false };
|
|
431
|
+
|
|
432
|
+
if (a.phase === "catalog-landed") {
|
|
433
|
+
const configWrite = a.writeSet?.models || a.writeSet?.modelOverrides;
|
|
434
|
+
if (install?.writable === true && a.writeSet?.catalogEntries && configWrite) {
|
|
435
|
+
// file 通道延迟提交:目录已加载(新进程)→可解析前置→基线 CAS→verified
|
|
436
|
+
const ids = await servedIds();
|
|
437
|
+
const targetIds = (a.targetView.models ?? []).map((m) => m?.id).filter(Boolean);
|
|
438
|
+
const unresolvable = targetIds.filter((id) => !ids.has(id));
|
|
439
|
+
if (unresolvable.length > 0) {
|
|
440
|
+
const next = stateIO.load();
|
|
441
|
+
next.activeOperation = { ...a, phaseResult: { status: "recovery-needed", error: `target-unresolvable: ${unresolvable.join(",")}` } };
|
|
442
|
+
stateIO.save(setRefreshError(next, `target-unresolvable: ${unresolvable.join(",")}`));
|
|
443
|
+
return { ok: true, active: true, status: "recovery-needed" };
|
|
444
|
+
}
|
|
445
|
+
const now = await deps.describeConfigView();
|
|
446
|
+
const baselineDigestNow = viewDigest(now.view);
|
|
447
|
+
const baselineDigestThen = viewDigest(a.settingsBaseline.view);
|
|
448
|
+
const targetDigest = viewDigest(a.targetView);
|
|
449
|
+
if (canonical(now.view.models) === canonical(a.targetView.models)) {
|
|
450
|
+
// 已等于 target(崩溃于提交后)→ 幂等消费
|
|
451
|
+
return terminalize("applied", "verified");
|
|
452
|
+
}
|
|
453
|
+
if (baselineDigestNow === baselineDigestThen) {
|
|
454
|
+
const ops = buildConfigOps(a.settingsBaseline.view, a.targetView);
|
|
455
|
+
try {
|
|
456
|
+
await deps.mutateSettings(ops, now.revision);
|
|
457
|
+
return terminalize("applied", "verified");
|
|
458
|
+
} catch {
|
|
459
|
+
return terminalize("conflict", "catalog-landed", "settings-conflict at boot commit");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// 基线漂移:用户值优先,不覆盖
|
|
463
|
+
return terminalize("conflict", "catalog-landed", "settings-conflict: configuration changed since preview");
|
|
464
|
+
function terminalize(status, phase, error) {
|
|
465
|
+
const st = stateIO.load();
|
|
466
|
+
st.activeOperation = null;
|
|
467
|
+
st.lastResult = { operationId: a.operationId, status, phase, error: error ?? null, changes: { catalogAdded: overlayIds(a.pendingOverlay ?? {}).length, ...(status === "applied" ? modelChangeCounts(a) : { modelsAdded: 0, modelsRemoved: 0 }) } };
|
|
468
|
+
if (status === "applied") st.restartState = null;
|
|
469
|
+
st.intentVersion = readIntentVersion();
|
|
470
|
+
stateIO.save(st);
|
|
471
|
+
return { ok: true, active: false, status };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// registry/目录-only 的 catalog-landed 残留:崩溃窗口两义(提交前/提交后/验证前),
|
|
475
|
+
// 先按当前配置归属收敛,再做 served 验证(R2-02)。
|
|
476
|
+
// changes 语义统一为「已发生的变更」(R2-01):applied/partial(配置确已落地)按
|
|
477
|
+
// baseline↔target 计数;conflict(配置未写/保留用户值)与目录-only 计 0。
|
|
478
|
+
const residueConfigWrite = a.writeSet?.models || a.writeSet?.modelOverrides;
|
|
479
|
+
const residueTargetIds = (a.targetView.models ?? []).map((m) => m?.id).filter(Boolean);
|
|
480
|
+
const finishResidue = (status, error) => {
|
|
481
|
+
const st = stateIO.load();
|
|
482
|
+
st.activeOperation = null;
|
|
483
|
+
st.lastResult = {
|
|
484
|
+
operationId: a.operationId,
|
|
485
|
+
status,
|
|
486
|
+
phase: status === "applied" ? "verified" : "catalog-landed",
|
|
487
|
+
error: error ?? null,
|
|
488
|
+
changes: {
|
|
489
|
+
catalogAdded: overlayIds(a.pendingOverlay ?? {}).length,
|
|
490
|
+
...(residueConfigWrite && (status === "applied" || status === "partial") ? modelChangeCounts(a) : { modelsAdded: 0, modelsRemoved: 0 }),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
st.intentVersion = readIntentVersion();
|
|
494
|
+
stateIO.save(st);
|
|
495
|
+
return { ok: true, active: false, status };
|
|
496
|
+
};
|
|
497
|
+
if (residueConfigWrite) {
|
|
498
|
+
const now = await deps.describeConfigView();
|
|
499
|
+
const atTarget = canonical(now.view.models) === canonical(a.targetView.models)
|
|
500
|
+
&& canonical(now.view.modelOverrides) === canonical(a.targetView.modelOverrides);
|
|
501
|
+
const atBaseline = canonical(now.view.models) === canonical(a.settingsBaseline.view.models)
|
|
502
|
+
&& canonical(now.view.modelOverrides) === canonical(a.settingsBaseline.view.modelOverrides);
|
|
503
|
+
if (!atTarget && !atBaseline) {
|
|
504
|
+
return finishResidue("conflict", "settings-conflict: configuration changed since preview; user value preserved");
|
|
505
|
+
}
|
|
506
|
+
if (atBaseline) {
|
|
507
|
+
// 崩溃于提交前:写前意图已持久,按当前 revision 重提交
|
|
508
|
+
try {
|
|
509
|
+
await deps.mutateSettings(buildConfigOps(a.settingsBaseline.view, a.targetView), now.revision);
|
|
510
|
+
} catch {
|
|
511
|
+
return finishResidue("conflict", "settings-conflict at boot recommit");
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const served = await waitForServed(residueTargetIds);
|
|
516
|
+
return served.ok
|
|
517
|
+
? finishResidue("applied")
|
|
518
|
+
: finishResidue("partial", `registry-not-served: ${served.missing.join(",")}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (a.phase === "configuration-committed") {
|
|
522
|
+
// 崩溃窗口收敛:当前值==target(提交已落地)→ applied;==baseline(提交未落地)→ 重提交;
|
|
523
|
+
// 其余(用户并发修改,归属不明)→ rollback-conflict 保留用户值,绝不覆盖
|
|
524
|
+
const now = await deps.describeConfigView();
|
|
525
|
+
if (canonical(now.view.models) === canonical(a.targetView.models)
|
|
526
|
+
&& canonical(now.view.modelOverrides) === canonical(a.targetView.modelOverrides)) {
|
|
527
|
+
return terminal(stateIO.load(), { operationId: a.operationId, status: "applied", phase: "verified", changes: { catalogAdded: 0, ...modelChangeCounts(a) } });
|
|
528
|
+
}
|
|
529
|
+
if (canonical(now.view.models) === canonical(a.settingsBaseline.view.models)
|
|
530
|
+
&& canonical(now.view.modelOverrides) === canonical(a.settingsBaseline.view.modelOverrides)) {
|
|
531
|
+
const ops = buildConfigOps(a.settingsBaseline.view, a.targetView);
|
|
532
|
+
try {
|
|
533
|
+
await deps.mutateSettings(ops, now.revision);
|
|
534
|
+
return terminal(stateIO.load(), { operationId: a.operationId, status: "applied", phase: "verified", changes: { catalogAdded: 0, ...modelChangeCounts(a) } });
|
|
535
|
+
} catch {
|
|
536
|
+
return terminal(stateIO.load(), { operationId: a.operationId, status: "conflict", phase: "configuration-committed", error: "settings-conflict at boot recommit", changes: { catalogAdded: 0, modelsAdded: 0, modelsRemoved: 0 } });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return terminal(stateIO.load(), { operationId: a.operationId, status: "rollback-conflict", phase: "configuration-committed", error: "commit-gap: configuration neither baseline nor target; user value preserved", changes: { catalogAdded: 0, modelsAdded: 0, modelsRemoved: 0 } });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { ok: true, active: true, status: a.phaseResult?.status ?? a.phase };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---- retire:静止核实后清 activeOperation(服务队列内串行即静止;绝不写配置) ----
|
|
547
|
+
async function retire({ operationId } = {}) {
|
|
548
|
+
const state = stateIO.load();
|
|
549
|
+
const active = state?.activeOperation;
|
|
550
|
+
if (!active) {
|
|
551
|
+
if (state?.lastResult?.operationId === operationId) return { ok: true, idempotent: true, result: state.lastResult };
|
|
552
|
+
return { ok: false, error: "not-active" };
|
|
553
|
+
}
|
|
554
|
+
if (operationId !== undefined && active.operationId !== operationId) {
|
|
555
|
+
return { ok: false, error: "operation-mismatch" };
|
|
556
|
+
}
|
|
557
|
+
const drift = active.intentVersion !== readIntentVersion();
|
|
558
|
+
const result = {
|
|
559
|
+
operationId: active.operationId,
|
|
560
|
+
status: "intent-retired",
|
|
561
|
+
phase: active.phase,
|
|
562
|
+
drift,
|
|
563
|
+
changes: null,
|
|
564
|
+
error: drift ? "intent-version-drift: old target not consumed" : null,
|
|
565
|
+
};
|
|
566
|
+
const next = { ...state, activeOperation: null, lastResult: result };
|
|
567
|
+
next.intentVersion = readIntentVersion();
|
|
568
|
+
stateIO.save(next);
|
|
569
|
+
return { ok: true, result };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { runApply, bootRecover, retire };
|
|
573
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// runtime-scope.mjs — 稳定 profile 身份与私有数据目录(T2)。
|
|
2
|
+
// 契约:resolveRuntimeScope(ctx) -> {known, profileId, dataDir, reason?}
|
|
3
|
+
// - dataDir = join(profileContext.dir, "copilot-auth")(dir = $DSH_HOME/profiles/<name>,
|
|
4
|
+
// profile 私有;禁止使用跨 profile 共享的 home)
|
|
5
|
+
// - profileId = sha256(name \0 dir) 前 16 hex
|
|
6
|
+
// - 缺 profileContext 或形状非法 → known=false + reason;调用方禁写新状态
|
|
7
|
+
// 本模块纯计算,不写盘;dataDir 由调用方按需创建。
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
function validString(v) {
|
|
12
|
+
return typeof v === "string" && v.length > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveRuntimeScope(ctx) {
|
|
16
|
+
const pc = ctx?.profileContext;
|
|
17
|
+
if (!pc || typeof pc !== "object" || Array.isArray(pc)) {
|
|
18
|
+
return { known: false, profileId: null, dataDir: null, reason: "profile-context-unavailable" };
|
|
19
|
+
}
|
|
20
|
+
if (!validString(pc.name) || !validString(pc.dir)) {
|
|
21
|
+
return { known: false, profileId: null, dataDir: null, reason: "profile-context-invalid" };
|
|
22
|
+
}
|
|
23
|
+
const profileId = createHash("sha256").update(`${pc.name}\0${pc.dir}`).digest("hex").slice(0, 16);
|
|
24
|
+
return { known: true, profileId, dataDir: join(pc.dir, "copilot-auth") };
|
|
25
|
+
}
|
package/src/shared.mjs
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
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
6
|
export const routes = () => ({
|
|
6
7
|
start: `${ROUTE_PREFIX}/start`,
|
|
7
8
|
state: `${ROUTE_PREFIX}/state`,
|
|
8
9
|
status: `${ROUTE_PREFIX}/status`,
|
|
9
10
|
logout: `${ROUTE_PREFIX}/logout`,
|
|
11
|
+
cancel: `${ROUTE_PREFIX}/cancel`,
|
|
10
12
|
refreshPreview: `${ROUTE_PREFIX}/refresh/preview`,
|
|
11
13
|
refreshApply: `${ROUTE_PREFIX}/refresh/apply`,
|
|
14
|
+
refreshRetire: `${ROUTE_PREFIX}/refresh/retire`,
|
|
12
15
|
});
|
|
13
16
|
export const emptyState = () => ({ status: "idle", notices: [], error: undefined });
|