@inventec/dsh-copilot-auth 1.0.2 → 1.1.1
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 +20 -6
- package/lib/client.js +332 -4
- package/package.json +10 -3
- package/src/atomic-json.mjs +89 -0
- package/src/catalog-fetch.mjs +98 -0
- package/src/catalog-overlay.json +115 -0
- package/src/catalog.mjs +156 -0
- package/src/copilot-models.mjs +113 -0
- package/src/host.mjs +505 -16
- package/src/refresh-flow.mjs +127 -0
- package/src/shared.mjs +2 -0
- package/src/state.mjs +98 -0
|
@@ -0,0 +1,127 @@
|
|
|
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";
|
|
8
|
+
|
|
9
|
+
export const initial = Object.freeze({ name: "idle" });
|
|
10
|
+
|
|
11
|
+
// reduce(state, event) → [nextState, effect | null]
|
|
12
|
+
// effect 为数据化指令:{type:"preview",mode} | {type:"apply",mode,digests} | {type:"status"}
|
|
13
|
+
export function reduce(state, event) {
|
|
14
|
+
switch (state.name) {
|
|
15
|
+
case "idle":
|
|
16
|
+
case "failed":
|
|
17
|
+
case "restartNeeded": {
|
|
18
|
+
if (event.type === "start") {
|
|
19
|
+
return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
|
|
20
|
+
}
|
|
21
|
+
if (state.name === "failed" && event.type === "dismiss") return [initial, null];
|
|
22
|
+
if (state.name === "idle" && event.type === "init") return [state, { type: "status" }];
|
|
23
|
+
if (event.type === "hydrate") {
|
|
24
|
+
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) return [{ name: "restartNeeded" }, null];
|
|
28
|
+
return state.name === "idle" ? [state, null] : [state, null];
|
|
29
|
+
}
|
|
30
|
+
return [state, null];
|
|
31
|
+
}
|
|
32
|
+
case "previewing": {
|
|
33
|
+
if (event.type === "preview-ok") {
|
|
34
|
+
return [{ name: "confirming", preview: event.preview, mode: state.mode, staleNotice: state.stale === true }, null];
|
|
35
|
+
}
|
|
36
|
+
if (event.type === "preview-fail") return [{ name: "failed", error: event.error }, null];
|
|
37
|
+
return [state, null]; // previewing 期间忽略 start 等重复事件
|
|
38
|
+
}
|
|
39
|
+
case "confirming": {
|
|
40
|
+
if (event.type === "start") {
|
|
41
|
+
// catalogSource==="local" 时弹窗提供「改用内置覆盖层预览」:
|
|
42
|
+
// 发 mode:"overlay" 的新 preview(不直接 apply,R2-5),新 diff 需再次确认
|
|
43
|
+
return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
|
|
44
|
+
}
|
|
45
|
+
if (event.type === "confirm") {
|
|
46
|
+
return [
|
|
47
|
+
{ name: "applying", mode: state.mode, digests: state.preview.digests },
|
|
48
|
+
{ type: "apply", mode: state.mode, digests: state.preview.digests },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
if (event.type === "cancel") return [initial, null];
|
|
52
|
+
return [state, null];
|
|
53
|
+
}
|
|
54
|
+
case "applying": {
|
|
55
|
+
if (event.type === "apply-ok") return [{ name: "restartNeeded" }, null];
|
|
56
|
+
if (event.type === "apply-stale") {
|
|
57
|
+
// 409 preview-stale:以原 mode 重新 preview(保持 overlay 来源,R2-5)
|
|
58
|
+
return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
|
|
59
|
+
}
|
|
60
|
+
if (event.type === "apply-fail") return [{ name: "failed", error: event.error }, null];
|
|
61
|
+
return [state, null]; // applying 态忽略 confirm/start(双击只发一次)
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return [state, null];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 副作用执行器:effect → 结果事件。只经注入的 fetchImpl 发请求。
|
|
69
|
+
export async function runEffect(effect, fetchImpl) {
|
|
70
|
+
const r = routes();
|
|
71
|
+
const post = (url, body) =>
|
|
72
|
+
fetchImpl(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
});
|
|
77
|
+
if (effect.type === "preview") {
|
|
78
|
+
try {
|
|
79
|
+
const res = await post(r.refreshPreview, effect.mode ? { mode: effect.mode } : {});
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
return res.status === 200 && body?.ok
|
|
82
|
+
? { type: "preview-ok", preview: body }
|
|
83
|
+
: { type: "preview-fail", error: body?.error ?? `HTTP ${res.status}` };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { type: "preview-fail", error: String(err?.message ?? err) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (effect.type === "apply") {
|
|
89
|
+
try {
|
|
90
|
+
const res = await post(r.refreshApply, {
|
|
91
|
+
digests: effect.digests,
|
|
92
|
+
...(effect.mode ? { mode: effect.mode } : {}),
|
|
93
|
+
});
|
|
94
|
+
const body = await res.json().catch(() => null);
|
|
95
|
+
if (res.status === 409) return { type: "apply-stale" };
|
|
96
|
+
return res.status === 200 && body?.ok
|
|
97
|
+
? { type: "apply-ok" }
|
|
98
|
+
: { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { type: "apply-fail", error: String(err?.message ?? err) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (effect.type === "status") {
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetchImpl(r.status);
|
|
106
|
+
return { type: "hydrate", status: await res.json() };
|
|
107
|
+
} catch {
|
|
108
|
+
return { type: "hydrate", status: null };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`unknown effect: ${effect.type}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// controller:reduce → 执行 effect → 结果事件回送 reduce → 循环至无 effect。
|
|
115
|
+
// client.jsx 必须只经 advance 驱动状态机,不直接调 reduce/runEffect。
|
|
116
|
+
// onState 在每个 reduce 步同步回调(含 effect 执行前)——UI 借此即时落中间态
|
|
117
|
+
// (applying 期间重复 confirm 被 reducer 忽略,双击只发一次)。
|
|
118
|
+
export async function advance(state, event, fetchImpl, onState) {
|
|
119
|
+
let [s, fx] = reduce(state, event);
|
|
120
|
+
onState?.(s);
|
|
121
|
+
while (fx) {
|
|
122
|
+
const resultEvent = await runEffect(fx, fetchImpl);
|
|
123
|
+
[s, fx] = reduce(s, resultEvent);
|
|
124
|
+
onState?.(s);
|
|
125
|
+
}
|
|
126
|
+
return s;
|
|
127
|
+
}
|
package/src/shared.mjs
CHANGED
|
@@ -7,5 +7,7 @@ export const routes = () => ({
|
|
|
7
7
|
state: `${ROUTE_PREFIX}/state`,
|
|
8
8
|
status: `${ROUTE_PREFIX}/status`,
|
|
9
9
|
logout: `${ROUTE_PREFIX}/logout`,
|
|
10
|
+
refreshPreview: `${ROUTE_PREFIX}/refresh/preview`,
|
|
11
|
+
refreshApply: `${ROUTE_PREFIX}/refresh/apply`,
|
|
10
12
|
});
|
|
11
13
|
export const emptyState = () => ({ status: "idle", notices: [], error: undefined });
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// state.mjs — 持久状态的唯一读写层(薄层,落盘逻辑都在 atomic-json)。
|
|
2
|
+
// 状态文件:~/.dsh/copilot-auth-state.json(路径由 host 决定),原子写。
|
|
3
|
+
// lastError 规则(R3-6):顶层 lastError/lastErrorAt 是唯一诊断面;journal 相位内
|
|
4
|
+
// 错误同时投影顶层;任一相位成功推进/消费时清除;state 损坏改名留存 .corrupt-<ts>
|
|
5
|
+
// 不覆盖,返回带 state-corrupt 的安全态,且后续普通保存不得抹掉留存文件。
|
|
6
|
+
import { renameSync } from "node:fs";
|
|
7
|
+
import { readJson, writeJsonAtomic } from "./atomic-json.mjs";
|
|
8
|
+
|
|
9
|
+
export const STATE_VERSION = 1;
|
|
10
|
+
export const CATALOG_IDENTITY = { packageName: "@earendil-works/pi-ai", catalogSchemaVersion: 1 };
|
|
11
|
+
export const JOURNAL_PHASES = ["prepared", "catalog-committed-needs-restart"];
|
|
12
|
+
export const RESTART_REASONS = ["refresh", "self-heal"];
|
|
13
|
+
|
|
14
|
+
export function freshState() {
|
|
15
|
+
return {
|
|
16
|
+
version: STATE_VERSION,
|
|
17
|
+
activated: false,
|
|
18
|
+
restartState: null,
|
|
19
|
+
appliedOverlay: {}, // 与 catalog 同构,不夹带插件私有字段
|
|
20
|
+
appliedProvenance: null, // { sourcePiAiVersion, integrity, appliedAgainstPiAiVersion, catalogSchemaVersion: 1 }
|
|
21
|
+
journal: null,
|
|
22
|
+
lastError: null,
|
|
23
|
+
lastErrorAt: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadState(path) {
|
|
28
|
+
let raw;
|
|
29
|
+
let corrupt = false;
|
|
30
|
+
try {
|
|
31
|
+
raw = readJson(path);
|
|
32
|
+
} catch {
|
|
33
|
+
corrupt = true;
|
|
34
|
+
}
|
|
35
|
+
if (!corrupt && raw !== undefined && (typeof raw !== "object" || raw === null || Array.isArray(raw))) {
|
|
36
|
+
corrupt = true; // 解析成功但形状非法同样按损坏处理
|
|
37
|
+
}
|
|
38
|
+
if (corrupt) {
|
|
39
|
+
// 解析失败/形状非法 → 改名留存 .corrupt-<ts>,返回安全态
|
|
40
|
+
try {
|
|
41
|
+
renameSync(path, `${path}.corrupt-${Date.now()}`);
|
|
42
|
+
} catch { /* 改名失败不阻断安全态返回 */ }
|
|
43
|
+
const s = freshState();
|
|
44
|
+
s.lastError = "state-corrupt";
|
|
45
|
+
s.lastErrorAt = new Date().toISOString();
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
if (raw === undefined) return freshState();
|
|
49
|
+
// 迁移:未知/缺失字段回落默认(当前仅 version 1)
|
|
50
|
+
return { ...freshState(), ...raw, version: STATE_VERSION };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function saveState(path, state) {
|
|
54
|
+
writeJsonAtomic(path, state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setLastError(state, error) {
|
|
58
|
+
return { ...state, lastError: String(error?.message ?? error), lastErrorAt: new Date().toISOString() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearLastError(state) {
|
|
62
|
+
return { ...state, lastError: null, lastErrorAt: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// journal 相位转移辅助:仅在 prepared → catalog-committed-needs-restart 单向前进。
|
|
66
|
+
export function createJournal({
|
|
67
|
+
settingsBaseline, targetIds, pendingOverlay, appliedAgainstPiAiVersion,
|
|
68
|
+
catalogBaselineDigest, patchedCatalogDigest, source,
|
|
69
|
+
}) {
|
|
70
|
+
return {
|
|
71
|
+
phase: "prepared",
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
appliedAgainstPiAiVersion,
|
|
74
|
+
catalogIdentity: { ...CATALOG_IDENTITY },
|
|
75
|
+
settingsBaseline, // raw user 层完整配置视图(R3-2)
|
|
76
|
+
targetIds,
|
|
77
|
+
pendingOverlay, // catalog-shaped 增量(R3-4),provenance 不夹带在条目里
|
|
78
|
+
catalogBaselineDigest,
|
|
79
|
+
patchedCatalogDigest,
|
|
80
|
+
source, // { kind: "latest" | "overlay" | "local", piAiVersion?, integrity? }
|
|
81
|
+
lastError: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function advanceJournal(journal) {
|
|
86
|
+
if (journal?.phase !== "prepared") {
|
|
87
|
+
throw new Error(`cannot advance journal from phase ${journal?.phase}`);
|
|
88
|
+
}
|
|
89
|
+
return { ...journal, phase: "catalog-committed-needs-restart" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// restartState 只表达「目录写入需跨 boot 加载」(R3-5),不携带 settings 意图。
|
|
93
|
+
export function restartMarker(reason, expectedEntriesDigest) {
|
|
94
|
+
if (!RESTART_REASONS.includes(reason)) {
|
|
95
|
+
throw new Error(`unknown restart reason: ${reason}`);
|
|
96
|
+
}
|
|
97
|
+
return { reason, expectedEntriesDigest, since: new Date().toISOString() };
|
|
98
|
+
}
|