mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a7a4aac49 | |||
| fff5d7ede9 | |||
| a2fa86bea1 | |||
| a8246da3d6 | |||
| 56acfd266e | |||
| 43dc18a2c5 | |||
| a221e086a3 | |||
| 0b609158d7 | |||
| c94983d5fa | |||
| 692ef41883 | |||
| 914fc99b4c | |||
| 2ba603fb77 | |||
| 40e087c072 | |||
| 1e9e59051a | |||
| 5360061bb6 | |||
| 969cdaed5b | |||
| 57ba6beb43 | |||
| 93eddd5521 | |||
| 9f910270ef | |||
| 9d0e75d56a | |||
| 26447effa2 | |||
| cf978c1346 | |||
| 6953f9fffd | |||
| 816a8b3928 | |||
| a88a58b516 | |||
| 7609ff799e | |||
| 26a95aeb05 | |||
| 9fffc8853d | |||
| d2ad7995a3 | |||
| bd3e2e54fc | |||
| 2a0a8125eb | |||
| 15c735f040 | |||
| 2f856b0ffb | |||
| 9b5a146323 | |||
| 2533db035a | |||
| 3fa6b1f158 | |||
| ce6edf68a0 | |||
| c036c6359e | |||
| 29cccd5bd3 | |||
| 28530ff641 | |||
| 89961dff28 | |||
| 9a87512224 | |||
| 2841811700 | |||
| 22c010079a | |||
| 37e78166c1 | |||
| 2bc92e0f79 | |||
| 10f2dacbe4 | |||
| 4f78451405 | |||
| 3dc188313e | |||
| 119d795121 | |||
| 4bfb3fc30d | |||
| c49cf96a16 | |||
| b884595a23 | |||
| 4317bd9981 |
@@ -0,0 +1,85 @@
|
||||
# Pi Extensions 一等支持设计附录
|
||||
|
||||
> 状态:仅设计,不含实现。主工程不实现 extensions;themes 明确不在范围内。
|
||||
> authority:任何上游路径、加载顺序、启用规则或 tool 组合语义,在实施前都必须
|
||||
> 由 pin `ab366ebe94cacd419d986be454f12b1b9913aaca` 的 oracle 或
|
||||
> `scripts/pi-transport-capture.mjs` 实际执行确认,本文不以源码阅读代替证据。
|
||||
|
||||
## 目标
|
||||
|
||||
未来让用户在 cc-switch 中观察、导入、启用和停用 Pi extensions,同时满足:
|
||||
|
||||
1. 原生文件/目录是真相,`exists = active`,不制造与 Pi 分叉的 enabled 影子状态;
|
||||
2. 只接管 cc-switch 明确拥有或用户显式、可验证采用的内容;
|
||||
3. extension 提供的 tools 与 pinned core tools 分开展示,不伪装为 MCP server;
|
||||
4. 所有写操作可并发检测、可补偿,portable import 不覆盖目标设备的未知资产;
|
||||
5. 复用前置 C inspection 与当前 Skill/Prompt 的共享文件、fingerprint、ownership
|
||||
和协调器原语。
|
||||
|
||||
## 模块边界
|
||||
|
||||
```text
|
||||
PiExtensionInspector (只读、oracle 驱动)
|
||||
│
|
||||
├── NativeExtensionObservation
|
||||
│ path / fingerprint / manifest / contributed tools
|
||||
│ validity / reasons / ownership
|
||||
│
|
||||
└── PiExtensionCoordinator (唯一写入口)
|
||||
├── exact-content adoption
|
||||
├── CAS + atomic replace
|
||||
├── ownership ledger transaction
|
||||
├── catalog epoch / UI invalidation
|
||||
└── compensation on partial failure
|
||||
```
|
||||
|
||||
- `PiExtensionInspector` 只负责原生观察和结构化诊断;不得写数据库或文件。
|
||||
- `PiExtensionCoordinator` 是唯一写入口;命令、deeplink、portable reconcile 与
|
||||
UI mutation 都调用它,禁止各自复制目录。
|
||||
- 通用 `shared_file`、Skill tree fingerprint 与 ownership ledger 可复用;
|
||||
extension-specific manifest/加载规则必须先新增捕获向量,不能借 Skill 规则猜测。
|
||||
- gateway 只消费 coordinator 发布后的 immutable runtime snapshot;extension
|
||||
不能在请求中途直接改 candidate/header 计划。
|
||||
|
||||
## 状态模型
|
||||
|
||||
建议公开三个正交维度:
|
||||
|
||||
- `discovery`: `absent | active | invalid | unknown`,只来自 native observation;
|
||||
- `ownership`: `external | adoptable_exact | managed | conflict`;
|
||||
- `capability`: `inspectable | manageable | unsupported | unknown`。
|
||||
|
||||
不得增加独立 `enabled` 布尔值。用户点击“停用”时,语义是对受管原生资产执行可逆
|
||||
移除;外部资产只能显式采用后再管理。内容变化导致 fingerprint 不匹配时进入
|
||||
`conflict`,不得覆盖。
|
||||
|
||||
## Tools 与 MCP
|
||||
|
||||
- capture 已确认 pinned core tool inventory 为
|
||||
`bash/edit/find/grep/ls/read/write`;未来 capture 应分别记录每个 extension
|
||||
注入前后的 tool inventory 与来源。
|
||||
- UI 将 tools 按 `core` / `extension:<id>` 分组,并展示冲突与覆盖次序的实测结果。
|
||||
- MCP 页面仍不为 Pi 建虚假 registry。即使某个 extension 通过自身机制连接外部
|
||||
tool,也属于 extension capability,除非未来 pinned Pi 真正提供 MCP registry
|
||||
且由新证据和契约明确升级。
|
||||
|
||||
## Portable 与冲突策略
|
||||
|
||||
- 备份只携带 cc-switch 拥有的 extension 描述、内容 hash 和期望状态,不携带绝对
|
||||
目录、设备 token 或未知外部目录。
|
||||
- 导入先观察目标设备;missing 可部署,exact 可采用,different 必须 conflict,
|
||||
绝不“最后写入者获胜”。
|
||||
- 多 extension 贡献同名 tool、command 或资源时 fail-closed;只有 oracle/capture
|
||||
证明 Pi 的确定 precedence 且产品明确展示该覆盖时,才允许自动解析。
|
||||
|
||||
## 实施前验收
|
||||
|
||||
1. 扩展 transport capture:发现路径、空/损坏 manifest、启停、重复 ID、资源覆盖、
|
||||
tool inventory、相对路径和 symlink 负例。
|
||||
2. 冻结 schema/oracle provenance,建立 lossless raw observation;未知形状为
|
||||
`unknown`,不得整目录连坐隐藏合法兄弟。
|
||||
3. 服务级测试覆盖显式采用、并发 CAS loser、写后补偿、portable reconcile、
|
||||
外部冲突、目录越界与 UI 的 `exists = active`。
|
||||
4. 中英文 UI 与可访问性完成后,再进入独立实现与盲审。
|
||||
|
||||
Themes 不与 extensions 共用该项目:其资源语义、预览与安全面另行立项。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Restore 加固工程:债务登记表(自 Pi 工程移交)
|
||||
|
||||
> 状态:立项挂起的独立未来工程。由用户裁决(2026-08-02)自 Pi 支持工程移交:
|
||||
> Pi 的 PR 聚焦业务点,restore 存量产品面的对抗加固不属于其范围。
|
||||
> 移交时点:前置 B 终止 checkpoint `3fa6b1f158b4e609b1588affb366bd90fb11878e`。
|
||||
> 表中 D1–D4 为 **Pi 工程之前即存在的产品面债务**(非 Pi 引入、非回归);
|
||||
> **D5 例外**:该死代码由 Pi 工程自身的 `4f784514`(typed provider writes /
|
||||
> canonical restore)引入,属本工程遗留物,未来工程处理时应优先直接删除。
|
||||
> Pi 耦合面(设备本地台账重建、弱化 schema 不可发布、token 不入可迁移产物)
|
||||
> 已实现并在两套认证套件中持续全绿。
|
||||
|
||||
## Pi 工程期间已完成并保留的加固(随 Pi PR 交付)
|
||||
|
||||
- Data-only canonical restore:`UntrustedScratch`/`CanonicalStage` 类型屏障,
|
||||
导入 schema 永不成为发布权威;
|
||||
- `MigrationSourceSpec`(v16/v17)源规格先行 + 全函数 typed 映射;
|
||||
- 不可信恢复版本门:仅接受 user_version N/N−1(16/17),含升级前备份回滚专测;
|
||||
- `MigrationRunContext { LocalUpgrade, UntrustedRestore }` 语义分离;
|
||||
- 值域强制(sort_index、circuit_error_rate_threshold ∈ [0,1] 等)、
|
||||
auto_vacuum 继承、设备本地台账 canonical 重建;
|
||||
- 恶意 schema 语料、预算、文件边界等既有测试全绿(Rust 2372 passed)。
|
||||
|
||||
## 未决 finding(终止时点,均已双 reviewer/主审确认)
|
||||
|
||||
| # | 严重度 | 描述 | 现有缓解 |
|
||||
|---|---|---|---|
|
||||
| D1 | High | `Database` 未持有不可变 live 路径:配置目录变更"稍后重启"期间,旧连接与新目录缓存并存,safety backup 与 canonical publish 可作用于不同库(mod.rs:90、backup.rs:2295、app_store.rs:104、useSettings.ts:363)。根治需 `DatabaseHome`/`BackupRepository` 不可变注入架构 | 无(建议未来工程首项;临时缓解可为"待重启状态禁用备份/恢复操作") |
|
||||
| D2 | High | restore 行校验未复用写面校验链:可发布空 ID/app_type(`ProviderKey::new` 会拒)、空白或尾斜杠 URL 的 endpoint(尾斜杠规范化在 `NewProviderAggregate::from_input` 而非 `NewEndpoint` 内,恢复路径两者都绕过),恢复后无法被 remove/touch 命中(backup.rs:549) | 写面自身拒绝此类值;仅恢复路径可引入 |
|
||||
| D3 | High | safety backup 直写可预测最终文件名;dangling symlink 绕过 `Path::exists()`,`Connection::open` 跟随链接;nofollow 检查晚于内容写出(backup.rs:2307) | 需本机文件系统攻击者前置 |
|
||||
| D4 | Medium | 全表 decoder sweep 不完整:`proxy_live_backup.original_config` 非法 JSON、`usage_daily_rollups.date` 非法日期可发布,生产读取时失败(backup.rs:633) | 读取侧失败即暴露,无静默数据错 |
|
||||
| D5 | Medium | 取消标志固定 false,无 setter/外部 token,cancellation check 不可触发(backup.rs:1248) | 纯死代码,建议未来删除或接真实 token |
|
||||
|
||||
## 一并挂起的既定立项
|
||||
|
||||
- v1–v15 历史备份导入(缩围裁决 2026-08-02;`MigrationSourceSpec` 架构为其地基);
|
||||
- 主审否决记录:两位 reviewer 建议"升级前备份失败应阻断启动",因违反
|
||||
"LocalUpgrade 既有行为不变"裁决未采信——未来工程重议时须重新评估。
|
||||
|
||||
## 机制锁定
|
||||
|
||||
Pi PR 内 restore 面已被前置 A 认证套件的 infra SHA-256 基线重冻
|
||||
(schema.rs/migration.rs/backup.rs @ 3fa6b1f1 状态):任何再触碰即认证红,
|
||||
防止范围回潮。未来工程启动时由其裁决方解冻。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,776 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pi transport request-capture harness.
|
||||
*
|
||||
* 现有的 native-oracle 只执行 pinned Pi 的 schema evaluator / composer /
|
||||
* config-value resolver,**不执行** adapter 与厂商 SDK 的头合并,因此
|
||||
* "Pi 实际发出什么认证头" 一直只能靠读源码推断。本脚本补上这一层:
|
||||
* 起一个本地 HTTP 抓包端点当 baseUrl,用 pinned Pi 的 adapter 真发一次
|
||||
* 请求,记录实际发出的 header。
|
||||
*
|
||||
* 用法:
|
||||
* PI_CHECKOUT=/path/to/pinned/pi node scripts/pi-transport-capture.mjs
|
||||
*
|
||||
* 不含任何密钥:测试用的 apiKey 是本地抓包用的假值;若要打真实端点,
|
||||
* 通过环境变量传入(PI_CAPTURE_BASE_URL / PI_CAPTURE_API_KEY),不要写进文件。
|
||||
*
|
||||
* 输出为 JSON,可作为 transport 断言的出处依据。若要升级为受冻结的
|
||||
* oracle 夹具,请比照 scripts/generate-pi-native-oracle.mjs 补 provenance
|
||||
* (pinned commit、源码哈希、bundler 版本)。
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const PI = process.env.PI_CHECKOUT;
|
||||
const EXPECTED_PI_COMMIT = "ab366ebe94cacd419d986be454f12b1b9913aaca";
|
||||
if (!PI) {
|
||||
console.error(
|
||||
"PI_CHECKOUT must point at a pinned Pi checkout (with node_modules).",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const piCommit = execFileSync("git", ["-C", PI, "rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (piCommit !== EXPECTED_PI_COMMIT) {
|
||||
throw new Error(
|
||||
`Pi checkout pin mismatch: expected ${EXPECTED_PI_COMMIT}, got ${piCommit}`,
|
||||
);
|
||||
}
|
||||
|
||||
const requireFromPi = createRequire(join(PI, "package.json"));
|
||||
const { buildSync, version: esbuildVersion } = requireFromPi("esbuild");
|
||||
const codingAgentPackagePath = join(PI, "packages/coding-agent/package.json");
|
||||
const codingAgentPackageBytes = readFileSync(codingAgentPackagePath);
|
||||
const codingAgentPackage = JSON.parse(codingAgentPackageBytes.toString("utf8"));
|
||||
const distributionMetadata = {
|
||||
source: "packages/coding-agent/package.json",
|
||||
sha256: createHash("sha256").update(codingAgentPackageBytes).digest("hex"),
|
||||
name: codingAgentPackage.name,
|
||||
version: codingAgentPackage.version,
|
||||
bin: codingAgentPackage.bin,
|
||||
piConfig: codingAgentPackage.piConfig,
|
||||
};
|
||||
|
||||
const ANTHROPIC_SSE =
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' +
|
||||
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}\n\n' +
|
||||
'event: message_stop\ndata: {"type":"message_stop"}\n\n';
|
||||
const OPENAI_SSE =
|
||||
'data: {"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' +
|
||||
"data: [DONE]\n\n";
|
||||
|
||||
const captured = [];
|
||||
const server = createServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
captured.push({ url: request.url, headers: { ...request.headers } });
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end(request.url.includes("messages") ? ANTHROPIC_SSE : OPENAI_SSE);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const baseUrl =
|
||||
process.env.PI_CAPTURE_BASE_URL ??
|
||||
`http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const harnessDirectory = mkdtempSync(join(PI, ".cc-switch-transport-capture-"));
|
||||
process.on("exit", () =>
|
||||
rmSync(harnessDirectory, { recursive: true, force: true }),
|
||||
);
|
||||
const aiShimPath = join(harnessDirectory, "pi-ai-shim.mjs");
|
||||
const compatShimPath = join(harnessDirectory, "pi-ai-compat-shim.mjs");
|
||||
writeFileSync(
|
||||
aiShimPath,
|
||||
[
|
||||
'export function lazyStream() { throw new Error("transport shim must not execute during compat capture"); }',
|
||||
"let uuidSequence = 0;",
|
||||
'export function uuidv7() { uuidSequence += 1; return `00000000-0000-7000-8000-${String(uuidSequence).padStart(12, "0")}`; }',
|
||||
"export class EventStream {}",
|
||||
"export class ModelsError extends Error {}",
|
||||
"export function validateToolArguments() { return undefined; }",
|
||||
'export function contentText(value) { return typeof value === "string" ? value : ""; }',
|
||||
'export function retryAssistantCall() { throw new Error("resource capture must not call AI"); }',
|
||||
"export function parseStreamingJson() { return undefined; }",
|
||||
"export function modelsAreEqual(left, right) { return left === right; }",
|
||||
"export function createModels() { return {}; }",
|
||||
"export function getBuiltinModelDataGeneratedAt() { return undefined; }",
|
||||
"export function builtinProviders() { return []; }",
|
||||
"export function radiusProvider() { return undefined; }",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
compatShimPath,
|
||||
[
|
||||
'export function getApiProvider() { throw new Error("transport shim must not execute during compat capture"); }',
|
||||
"export function clampThinkingLevel(value) { return value; }",
|
||||
"export async function cleanupSessionResources() {}",
|
||||
"export function getSupportedThinkingLevels() { return []; }",
|
||||
"export function isContextOverflow() { return false; }",
|
||||
"export function isRetryableAssistantError() { return false; }",
|
||||
"export function modelsAreEqual(left, right) { return left === right; }",
|
||||
"export function resetApiProviders() {}",
|
||||
'export function streamSimple() { throw new Error("resource capture must not call AI"); }',
|
||||
'export function stream() { throw new Error("resource capture must not call AI"); }',
|
||||
'export function completeSimple() { throw new Error("resource capture must not call AI"); }',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const entryPoint = join(harnessDirectory, "entry.mjs");
|
||||
writeFileSync(
|
||||
entryPoint,
|
||||
[
|
||||
`export { streamSimple as anthropicMessages } from "${PI}/packages/ai/src/api/anthropic-messages.ts";`,
|
||||
`export { streamSimple as openaiResponses } from "${PI}/packages/ai/src/api/openai-responses.ts";`,
|
||||
`export { streamSimple as openaiCompletions } from "${PI}/packages/ai/src/api/openai-completions.ts";`,
|
||||
`export { streamSimple as googleGenerativeAi } from "${PI}/packages/ai/src/api/google-generative-ai.ts";`,
|
||||
`export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`,
|
||||
`export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`,
|
||||
`export { loadSkills } from "${PI}/packages/coding-agent/src/core/skills.ts";`,
|
||||
`export { loadPromptTemplates } from "${PI}/packages/coding-agent/src/core/prompt-templates.ts";`,
|
||||
`export { SessionManager } from "${PI}/packages/coding-agent/src/core/session-manager.ts";`,
|
||||
`export { parseArgs } from "${PI}/packages/coding-agent/src/cli/args.ts";`,
|
||||
`export { createAllToolDefinitions } from "${PI}/packages/coding-agent/src/core/tools/index.ts";`,
|
||||
].join("\n"),
|
||||
);
|
||||
const bundlePath = join(harnessDirectory, "bundle.mjs");
|
||||
buildSync({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: bundlePath,
|
||||
external: ["node:*"],
|
||||
packages: "external",
|
||||
alias: {
|
||||
"@earendil-works/pi-ai": aiShimPath,
|
||||
"@earendil-works/pi-ai/compat": compatShimPath,
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
const adapters = await import(pathToFileURL(bundlePath).href);
|
||||
|
||||
// ResourceLoader pulls the complete coding-agent resource graph. Bundle the
|
||||
// real pinned ResourceLoader separately with broad AI stubs; the captured
|
||||
// instruction behavior remains real while unrelated generated model data is
|
||||
// kept outside this resource-only probe.
|
||||
const resourceEntryPoint = join(harnessDirectory, "resource-entry.mjs");
|
||||
const resourceBundlePath = join(harnessDirectory, "resource-bundle.mjs");
|
||||
writeFileSync(
|
||||
resourceEntryPoint,
|
||||
`export { DefaultResourceLoader } from "${PI}/packages/coding-agent/src/core/resource-loader.ts";\n`,
|
||||
);
|
||||
buildSync({
|
||||
entryPoints: [resourceEntryPoint],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: resourceBundlePath,
|
||||
external: ["node:*"],
|
||||
packages: "external",
|
||||
alias: {
|
||||
"@earendil-works/pi-ai/providers/all": aiShimPath,
|
||||
"@earendil-works/pi-ai/oauth": aiShimPath,
|
||||
"@earendil-works/pi-ai": aiShimPath,
|
||||
"@earendil-works/pi-ai/compat": compatShimPath,
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
const resourceAdapters = await import(pathToFileURL(resourceBundlePath).href);
|
||||
|
||||
const API_BY_ADAPTER = {
|
||||
anthropicMessages: "anthropic-messages",
|
||||
openaiResponses: "openai-responses",
|
||||
openaiCompletions: "openai-completions",
|
||||
googleGenerativeAi: "google-generative-ai",
|
||||
};
|
||||
|
||||
/** 每个用例只改变凭证与显式 header,其余保持最小合法模型。 */
|
||||
const CASES = [
|
||||
["anthropicMessages", "plain-key", "sk-ant-api03-plain", {}],
|
||||
["anthropicMessages", "oauth-token", "sk-ant-oat01-token", {}],
|
||||
[
|
||||
"anthropicMessages",
|
||||
"oauth-with-explicit-x-api-key",
|
||||
"sk-ant-oat01-token",
|
||||
{ "x-api-key": "explicit-secret" },
|
||||
],
|
||||
[
|
||||
"anthropicMessages",
|
||||
"oauth-with-explicit-authorization",
|
||||
"sk-ant-oat01-token",
|
||||
{ authorization: "Bearer configured" },
|
||||
],
|
||||
[
|
||||
"anthropicMessages",
|
||||
"explicit-x-api-key",
|
||||
"synthesized-secret",
|
||||
{ "x-api-key": "explicit-secret" },
|
||||
],
|
||||
[
|
||||
"anthropicMessages",
|
||||
"explicit-authorization",
|
||||
"synthesized-secret",
|
||||
{ authorization: "Bearer configured" },
|
||||
],
|
||||
["openaiResponses", "plain-key", "sk-plain", {}],
|
||||
["openaiResponses", "oauth-shaped-token", "sk-ant-oat01-not-anthropic", {}],
|
||||
[
|
||||
"openaiResponses",
|
||||
"explicit-authorization",
|
||||
"synthesized-secret",
|
||||
{ authorization: "Bearer configured" },
|
||||
],
|
||||
[
|
||||
"openaiCompletions",
|
||||
"explicit-authorization",
|
||||
"synthesized-secret",
|
||||
{ authorization: "Bearer configured" },
|
||||
],
|
||||
[
|
||||
"openaiCompletions",
|
||||
"explicit-x-api-key",
|
||||
"synthesized-secret",
|
||||
{ "x-api-key": "explicit-secret" },
|
||||
],
|
||||
["googleGenerativeAi", "plain-key", "google-plain", {}],
|
||||
[
|
||||
"googleGenerativeAi",
|
||||
"explicit-x-goog-api-key",
|
||||
"google-synthesized",
|
||||
{ "x-goog-api-key": "google-explicit" },
|
||||
],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
for (const [adapter, label, apiKey, headers] of CASES) {
|
||||
const model = {
|
||||
id: "m",
|
||||
name: "m",
|
||||
api: API_BY_ADAPTER[adapter],
|
||||
provider: "candidate",
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
const before = captured.length;
|
||||
let error;
|
||||
try {
|
||||
const stream = adapters[adapter](
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
{
|
||||
apiKey: process.env.PI_CAPTURE_API_KEY ?? apiKey,
|
||||
headers,
|
||||
maxTokens: 16,
|
||||
},
|
||||
);
|
||||
for await (const _event of stream) {
|
||||
// drain
|
||||
}
|
||||
} catch (caught) {
|
||||
error = String(caught);
|
||||
}
|
||||
const request =
|
||||
captured.length > before ? captured[captured.length - 1] : undefined;
|
||||
results.push({
|
||||
adapter: API_BY_ADAPTER[adapter],
|
||||
case: label,
|
||||
requestSent: Boolean(request),
|
||||
requestUrl: request?.url,
|
||||
error: request ? undefined : error,
|
||||
authHeaders: request
|
||||
? Object.fromEntries(
|
||||
Object.entries(request.headers).filter(([name]) =>
|
||||
[
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
"openai-beta",
|
||||
].includes(name),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const compatInput = {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://compat.example/v1",
|
||||
apiKey: "literal",
|
||||
compat: {
|
||||
openRouterRouting: ["first", "second"],
|
||||
chatTemplateKwargs: "ab",
|
||||
baseOnly: true,
|
||||
},
|
||||
models: [{ id: "m", compat: { supportsStore: true } }],
|
||||
modelOverrides: {
|
||||
m: {
|
||||
compat: {
|
||||
openRouterRouting: null,
|
||||
chatTemplateKwargs: { named: true },
|
||||
overlayOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const compatProvider = adapters.composeModelProvider(
|
||||
"compat-spread",
|
||||
undefined,
|
||||
{
|
||||
getProvider(providerId) {
|
||||
return providerId === "compat-spread" ? compatInput : undefined;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
const compatSpread = compatProvider.getModels()[0].compat;
|
||||
|
||||
const minimalProvider = adapters.composeModelProvider(
|
||||
"minimal-provider",
|
||||
undefined,
|
||||
{
|
||||
getProvider(providerId) {
|
||||
return providerId === "minimal-provider"
|
||||
? {
|
||||
name: "Minimal provider",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://minimal.example/v1",
|
||||
apiKey: "literal",
|
||||
models: [{ id: "minimal-model", name: "Minimal model" }],
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
const minimalModel = minimalProvider.getModels()[0];
|
||||
const minimalProviderComposition = {
|
||||
id: minimalModel.id,
|
||||
name: minimalModel.name,
|
||||
provider: minimalModel.provider,
|
||||
api: minimalModel.api,
|
||||
baseUrl: minimalModel.baseUrl,
|
||||
reasoning: minimalModel.reasoning,
|
||||
input: minimalModel.input,
|
||||
cost: minimalModel.cost,
|
||||
contextWindow: minimalModel.contextWindow,
|
||||
maxTokens: minimalModel.maxTokens,
|
||||
};
|
||||
|
||||
function jsonSafeJavaScriptValue(value) {
|
||||
if (typeof value === "string") {
|
||||
const codeUnits = Array.from({ length: value.length }, (_, index) =>
|
||||
value.charCodeAt(index),
|
||||
);
|
||||
const hasLoneSurrogate = codeUnits.some((unit, index) => {
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
return !(
|
||||
index + 1 < codeUnits.length &&
|
||||
codeUnits[index + 1] >= 0xdc00 &&
|
||||
codeUnits[index + 1] <= 0xdfff
|
||||
);
|
||||
}
|
||||
if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||
return !(
|
||||
index > 0 &&
|
||||
codeUnits[index - 1] >= 0xd800 &&
|
||||
codeUnits[index - 1] <= 0xdbff
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return hasLoneSurrogate
|
||||
? {
|
||||
$javascriptStringUtf16: codeUnits.map((unit) =>
|
||||
unit.toString(16).padStart(4, "0"),
|
||||
),
|
||||
}
|
||||
: value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(jsonSafeJavaScriptValue);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
jsonSafeJavaScriptValue(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function captureCompatSpread(label, baseValue, overlayValue) {
|
||||
const providerId = `compat-${label}`;
|
||||
const provider = adapters.composeModelProvider(
|
||||
providerId,
|
||||
undefined,
|
||||
{
|
||||
getProvider(candidate) {
|
||||
if (candidate !== providerId) return undefined;
|
||||
return {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://compat.example/v1",
|
||||
apiKey: "literal",
|
||||
compat: { chatTemplateKwargs: baseValue },
|
||||
models: [{ id: "m" }],
|
||||
modelOverrides: {
|
||||
m: { compat: { chatTemplateKwargs: overlayValue } },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
label,
|
||||
baseValue,
|
||||
overlayValue,
|
||||
result: jsonSafeJavaScriptValue(
|
||||
provider.getModels()[0].compat.chatTemplateKwargs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const compatEdgeCases = [
|
||||
captureCompatSpread("ascii-string-to-string", "ab", "cd"),
|
||||
captureCompatSpread("astral-string-to-object", "😀", { named: true }),
|
||||
captureCompatSpread("astral-string-fully-overridden", "😀", {
|
||||
0: "repaired-high",
|
||||
1: "repaired-low",
|
||||
named: true,
|
||||
}),
|
||||
captureCompatSpread("string-to-array", "ab", ["first", "second"]),
|
||||
];
|
||||
|
||||
const resolverInputs = [
|
||||
"literal-secret",
|
||||
"cash$money",
|
||||
"café$literal",
|
||||
"$$literal-$!bang",
|
||||
"prefix-${PI_CAPTURE_MISSING}-suffix",
|
||||
];
|
||||
const resolverCases = resolverInputs.map((input) => {
|
||||
try {
|
||||
return {
|
||||
input,
|
||||
status: "success",
|
||||
result: adapters.resolveConfigValueOrThrow(
|
||||
input,
|
||||
"transport capture",
|
||||
{},
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
input,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Execute Pi's real discovery entry point. The directories are deliberately
|
||||
// created in reverse order: a deterministic winner must come from Pi's
|
||||
// discovery ordering, not filesystem insertion order.
|
||||
const skillProbeRoot = join(harnessDirectory, "skill-discovery");
|
||||
const skillAgentDir = join(skillProbeRoot, "agent");
|
||||
const skillProjectDir = join(skillProbeRoot, "project");
|
||||
for (const directory of [
|
||||
skillProjectDir,
|
||||
join(skillAgentDir, "skills", "b-second"),
|
||||
join(skillAgentDir, "skills", "a-first"),
|
||||
]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
writeFileSync(
|
||||
join(skillAgentDir, "skills", "b-second", "SKILL.md"),
|
||||
"---\nname: duplicate\ndescription: second\n---\nsecond\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillAgentDir, "skills", "a-first", "SKILL.md"),
|
||||
"---\nname: duplicate\ndescription: first\n---\nfirst\n",
|
||||
);
|
||||
const skillDiscovery = jsonSafeJavaScriptValue(
|
||||
await adapters.loadSkills({
|
||||
cwd: skillProjectDir,
|
||||
agentDir: skillAgentDir,
|
||||
skillPaths: [],
|
||||
includeDefaults: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const promptAgentDir = join(harnessDirectory, "prompt-agent");
|
||||
const promptProjectDir = join(harnessDirectory, "prompt-project");
|
||||
mkdirSync(join(promptAgentDir, "prompts", "nested"), { recursive: true });
|
||||
mkdirSync(promptProjectDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(promptAgentDir, "prompts", "review.md"),
|
||||
"---\ndescription: Review captured changes\nargument-hint: <range>\n---\nReview $1\n",
|
||||
);
|
||||
writeFileSync(join(promptAgentDir, "prompts", "empty.md"), "");
|
||||
writeFileSync(
|
||||
join(promptAgentDir, "prompts", "nested", "ignored.md"),
|
||||
"nested",
|
||||
);
|
||||
const promptTemplateDiscovery = jsonSafeJavaScriptValue(
|
||||
adapters
|
||||
.loadPromptTemplates({
|
||||
cwd: promptProjectDir,
|
||||
agentDir: promptAgentDir,
|
||||
promptPaths: [],
|
||||
includeDefaults: true,
|
||||
})
|
||||
.map((template) => ({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
argumentHint: template.argumentHint,
|
||||
content: template.content,
|
||||
source: template.sourceInfo?.source,
|
||||
scope: template.sourceInfo?.scope,
|
||||
relativeFile:
|
||||
template.filePath ===
|
||||
join(promptAgentDir, "prompts", `${template.name}.md`)
|
||||
? `prompts/${template.name}.md`
|
||||
: template.filePath,
|
||||
})),
|
||||
);
|
||||
|
||||
// File presence, including a zero-byte file, is the native activation state
|
||||
// for Pi's global instruction resources. Execute the real resource loader so
|
||||
// cc-switch does not infer that rule from a parser implementation.
|
||||
const instructionAgentDir = join(harnessDirectory, "instruction-agent");
|
||||
const instructionProjectDir = join(harnessDirectory, "instruction-project");
|
||||
mkdirSync(instructionAgentDir, { recursive: true });
|
||||
mkdirSync(instructionProjectDir, { recursive: true });
|
||||
for (const filename of ["AGENTS.md", "SYSTEM.md", "APPEND_SYSTEM.md"]) {
|
||||
writeFileSync(join(instructionAgentDir, filename), "");
|
||||
}
|
||||
const instructionLoader = new resourceAdapters.DefaultResourceLoader({
|
||||
cwd: instructionProjectDir,
|
||||
agentDir: instructionAgentDir,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
});
|
||||
await instructionLoader.reload();
|
||||
const emptyInstructionFiles = {
|
||||
agentsFiles: instructionLoader.getAgentsFiles().agentsFiles.map((entry) => ({
|
||||
relativeFile: entry.path.startsWith(instructionAgentDir)
|
||||
? entry.path.slice(instructionAgentDir.length + 1)
|
||||
: entry.path,
|
||||
content: entry.content,
|
||||
})),
|
||||
systemPrompt: instructionLoader.getSystemPrompt(),
|
||||
systemPromptSource: instructionLoader.getSystemPromptSource()?.path,
|
||||
appendSystemPrompt: instructionLoader.getAppendSystemPrompt(),
|
||||
appendSystemPromptSources: instructionLoader
|
||||
.getAppendSystemPromptSources()
|
||||
.map((entry) => entry.path),
|
||||
};
|
||||
|
||||
// Exercise Pi's real SessionManager instead of inferring sessionDir or JSONL
|
||||
// shape from its TypeScript source. Relative sessionDir is resolved against the
|
||||
// launching process cwd, while the header keeps the explicit project cwd.
|
||||
const sessionProjectDir = join(harnessDirectory, "session project");
|
||||
mkdirSync(sessionProjectDir, { recursive: true });
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(sessionProjectDir);
|
||||
const capturedSession = adapters.SessionManager.create(
|
||||
sessionProjectDir,
|
||||
".pi/sessions",
|
||||
{ id: "cc-switch-capture-session" },
|
||||
);
|
||||
capturedSession.appendSessionInfo("Captured session");
|
||||
capturedSession.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "captured question" }],
|
||||
timestamp: 1_700_000_000_000,
|
||||
});
|
||||
capturedSession.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "captured answer" }],
|
||||
api: "openai-responses",
|
||||
provider: "capture",
|
||||
model: "capture-model",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1_700_000_001_000,
|
||||
});
|
||||
const capturedSessionFile = capturedSession.getSessionFile();
|
||||
if (!capturedSessionFile) {
|
||||
throw new Error("pinned SessionManager did not persist the capture session");
|
||||
}
|
||||
const capturedSessionPath = resolve(capturedSessionFile);
|
||||
const parsedSessionArgs = adapters.parseArgs([
|
||||
"--session",
|
||||
capturedSessionPath,
|
||||
]);
|
||||
const parsedVersionArgs = adapters.parseArgs(["--version"]);
|
||||
const sessionCliSemantics = {
|
||||
argv: ["--session", capturedSessionPath],
|
||||
parsedSession: parsedSessionArgs.session,
|
||||
diagnostics: parsedSessionArgs.diagnostics,
|
||||
versionArgv: ["--version"],
|
||||
parsedVersion: parsedVersionArgs.version,
|
||||
versionDiagnostics: parsedVersionArgs.diagnostics,
|
||||
};
|
||||
const capturedSessionLines = capturedSessionFile
|
||||
? readFileSync(capturedSessionFile, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line))
|
||||
: [];
|
||||
const sessionDirectorySemantics = {
|
||||
processCwd: sessionProjectDir,
|
||||
suppliedProjectCwd: sessionProjectDir,
|
||||
suppliedSessionDir: ".pi/sessions",
|
||||
resolvedSessionDir: capturedSession.getSessionDir(),
|
||||
headerKeys: Object.keys(capturedSession.getHeader() ?? {}).sort(),
|
||||
entryShapes: capturedSession.getEntries().map((entry) => ({
|
||||
type: entry.type,
|
||||
keys: Object.keys(entry).sort(),
|
||||
messageRole: entry.type === "message" ? entry.message.role : undefined,
|
||||
messageKeys:
|
||||
entry.type === "message" ? Object.keys(entry.message).sort() : undefined,
|
||||
})),
|
||||
persistedLineTypes: capturedSessionLines.map((entry) => entry.type),
|
||||
listAllCount: (
|
||||
await adapters.SessionManager.listAll(capturedSession.getSessionDir())
|
||||
).length,
|
||||
};
|
||||
|
||||
const branchedSession = adapters.SessionManager.create(
|
||||
sessionProjectDir,
|
||||
".pi/sessions",
|
||||
{ id: "cc-switch-capture-branch" },
|
||||
);
|
||||
const branchRootId = branchedSession.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "branch root" }],
|
||||
timestamp: 1_700_000_002_000,
|
||||
});
|
||||
branchedSession.appendSessionInfo("Abandoned branch name");
|
||||
branchedSession.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "abandoned answer" }],
|
||||
api: "openai-responses",
|
||||
provider: "capture",
|
||||
model: "capture-model",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1_700_000_003_000,
|
||||
});
|
||||
branchedSession.branch(branchRootId);
|
||||
branchedSession.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "active branch" }],
|
||||
timestamp: 1_700_000_004_000,
|
||||
});
|
||||
const sessionBranchSemantics = {
|
||||
sessionName: branchedSession.getSessionName(),
|
||||
activeEntryTypes: branchedSession.getBranch().map((entry) => entry.type),
|
||||
};
|
||||
|
||||
const malformedSessionFile = join(
|
||||
capturedSession.getSessionDir(),
|
||||
"cc-switch-capture-malformed.jsonl",
|
||||
);
|
||||
writeFileSync(
|
||||
malformedSessionFile,
|
||||
[
|
||||
JSON.stringify(capturedSession.getHeader()),
|
||||
"{not valid json",
|
||||
...capturedSession
|
||||
.getEntries()
|
||||
.map((entry) => JSON.stringify(entry)),
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
let malformedSessionSemantics;
|
||||
try {
|
||||
const malformedSession = adapters.SessionManager.open(malformedSessionFile);
|
||||
malformedSessionSemantics = {
|
||||
status: "accepted",
|
||||
entryTypes: malformedSession.getEntries().map((entry) => entry.type),
|
||||
};
|
||||
} catch (error) {
|
||||
malformedSessionSemantics = {
|
||||
status: "rejected",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
process.chdir(originalCwd);
|
||||
|
||||
// The executed built-in tool factory is the authoritative core inventory.
|
||||
// Extension-provided tools remain possible, but Pi exposes no native MCP
|
||||
// registry for cc-switch to mirror as an app toggle.
|
||||
const nativeToolInventory = Object.values(
|
||||
adapters.createAllToolDefinitions(sessionProjectDir),
|
||||
)
|
||||
.map((tool) => tool.name)
|
||||
.sort();
|
||||
|
||||
server.close();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
bundler: `esbuild@${esbuildVersion}`,
|
||||
piCheckout: PI,
|
||||
piCommit,
|
||||
distributionMetadata,
|
||||
baseUrl,
|
||||
results,
|
||||
minimalProviderComposition,
|
||||
compatSpread,
|
||||
compatEdgeCases,
|
||||
resolverCases,
|
||||
skillDiscovery,
|
||||
promptTemplateDiscovery,
|
||||
emptyInstructionFiles,
|
||||
sessionDirectorySemantics,
|
||||
sessionCliSemantics,
|
||||
sessionBranchSemantics,
|
||||
malformedSessionSemantics,
|
||||
nativeToolInventory,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Generated
+13
-1
@@ -783,6 +783,8 @@ dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
"jsonc-parser",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -799,6 +801,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"syn 2.0.117",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -2798,6 +2801,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonc-parser"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -4686,7 +4698,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+12
-2
@@ -23,7 +23,8 @@ test-hooks = []
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
|
||||
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -73,11 +74,12 @@ url = "2.5"
|
||||
auto-launch = "0.5"
|
||||
once_cell = "1.21.3"
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks", "limits"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
libc = "0.2"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
@@ -92,8 +94,15 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Wdk_Foundation",
|
||||
"Wdk_Storage_FileSystem",
|
||||
"Win32_Globalization",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
@@ -116,3 +125,4 @@ strip = "symbols"
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
tempfile = "3"
|
||||
syn = { version = "2", features = ["full", "visit"] }
|
||||
|
||||
+35
-30
@@ -32,6 +32,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::Pi => false, // Pi core has no native MCP registry.
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
@@ -46,6 +47,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::Pi => {} // Pi core has no native MCP registry.
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
|
||||
}
|
||||
}
|
||||
@@ -100,6 +102,8 @@ pub struct SkillApps {
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
#[serde(default)]
|
||||
pub pi: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
@@ -112,6 +116,7 @@ impl SkillApps {
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::Pi => self.pi,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
@@ -126,6 +131,7 @@ impl SkillApps {
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::Pi => self.pi = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
|
||||
}
|
||||
@@ -152,6 +158,9 @@ impl SkillApps {
|
||||
if self.hermes {
|
||||
apps.push(AppType::Hermes);
|
||||
}
|
||||
if self.pi {
|
||||
apps.push(AppType::Pi);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
@@ -163,6 +172,7 @@ impl SkillApps {
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
&& !self.pi
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -357,6 +367,8 @@ pub struct PromptRoot {
|
||||
pub openclaw: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub hermes: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub pi: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -381,6 +393,7 @@ pub enum AppType {
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
Pi,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -394,6 +407,7 @@ impl AppType {
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
AppType::Pi => "pi",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +433,7 @@ impl AppType {
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
AppType::Pi,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -438,10 +453,11 @@ impl FromStr for AppType {
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
"pi" => Ok(AppType::Pi),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -467,6 +483,9 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hermes: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pi: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
@@ -481,6 +500,7 @@ impl CommonConfigSnippets {
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
AppType::Hermes => self.hermes.as_ref(),
|
||||
AppType::Pi => self.pi.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +515,7 @@ impl CommonConfigSnippets {
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
AppType::Hermes => self.hermes = snippet,
|
||||
AppType::Pi => self.pi = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,6 +560,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
apps.insert("hermes".to_string(), ProviderManager::default());
|
||||
apps.insert("pi".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -626,6 +648,12 @@ impl MultiAppConfig {
|
||||
.insert("gemini".to_string(), ProviderManager::default());
|
||||
updated = true;
|
||||
}
|
||||
if !config.apps.contains_key("pi") {
|
||||
config
|
||||
.apps
|
||||
.insert("pi".to_string(), ProviderManager::default());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
||||
let migrated = config.migrate_mcp_to_unified()?;
|
||||
@@ -691,34 +719,6 @@ impl MultiAppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定客户端的 MCP 配置(不可变引用)
|
||||
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::GrokBuild => &self.mcp.grokbuild,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
AppType::Hermes => &self.mcp.hermes,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定客户端的 MCP 配置(可变引用)
|
||||
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::GrokBuild => &mut self.mcp.grokbuild,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
AppType::Hermes => &mut self.mcp.hermes,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建默认配置并自动导入已存在的提示词文件
|
||||
fn default_with_auto_import() -> Result<Self, AppError> {
|
||||
log::info!("首次启动,创建默认配置并检测提示词文件");
|
||||
@@ -733,6 +733,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Pi)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -757,6 +758,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
|| !self.prompts.hermes.prompts.is_empty()
|
||||
|| !self.prompts.pi.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -772,6 +774,7 @@ impl MultiAppConfig {
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
AppType::Pi,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
@@ -846,6 +849,7 @@ impl MultiAppConfig {
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
AppType::Hermes => &mut config.prompts.hermes.prompts,
|
||||
AppType::Pi => &mut config.prompts.pi.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -889,6 +893,7 @@ impl MultiAppConfig {
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
|
||||
AppType::Pi => continue, // Pi didn't exist in v3.6.x, skip
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ use crate::codex_state_db::codex_state_db_paths;
|
||||
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
||||
use crate::database::{is_official_seed_id, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::{
|
||||
provider_row_fingerprint, provider_to_mutation_input,
|
||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||
};
|
||||
use crate::settings::{
|
||||
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||
CodexThirdPartyHistoryProviderBucketMigration,
|
||||
@@ -663,7 +667,8 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
let providers = db.get_all_providers("codex")?;
|
||||
let mut migrated_provider_ids = Vec::new();
|
||||
|
||||
for (_, provider) in providers {
|
||||
for (_, mut provider) in providers {
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
if provider.category.as_deref() == Some("official")
|
||||
|| is_official_seed_id(&provider.id)
|
||||
|| provider.is_codex_oauth()
|
||||
@@ -694,8 +699,21 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
};
|
||||
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
||||
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
||||
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
||||
migrated_provider_ids.push(provider.id);
|
||||
let provider_id = provider.id.clone();
|
||||
provider.settings_config = settings;
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
reconcile_provider_record_with_precondition(
|
||||
db,
|
||||
"codex",
|
||||
input,
|
||||
ReconcilePrecondition::ExpectPresent {
|
||||
fingerprint: observed_fingerprint,
|
||||
},
|
||||
)?;
|
||||
migrated_provider_ids.push(provider_id);
|
||||
}
|
||||
|
||||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||||
@@ -1439,7 +1457,8 @@ base_url = "https://proxy.example/v1"
|
||||
),
|
||||
];
|
||||
for provider in providers {
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
}
|
||||
|
||||
let mut official = Provider::with_id(
|
||||
@@ -1449,7 +1468,8 @@ base_url = "https://proxy.example/v1"
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official");
|
||||
|
||||
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert_eq!(
|
||||
@@ -2171,9 +2191,10 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
|
||||
db.save_provider("codex", &third_party)
|
||||
db.reconcile_provider_fixture("codex", &third_party)
|
||||
.expect("save third-party");
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("rightcode"));
|
||||
@@ -2196,7 +2217,8 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2216,7 +2238,8 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2244,7 +2267,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2264,7 +2288,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("aihubmix"));
|
||||
@@ -2285,7 +2310,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("ccswitch"));
|
||||
@@ -2317,7 +2343,8 @@ model = "gpt-5.4"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
|
||||
@@ -2390,7 +2417,8 @@ base_url = "https://aihubmix.example/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(
|
||||
@@ -2446,7 +2474,8 @@ base_url = "http://localhost:8080/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert!(outcome.migrated_provider_ids.is_empty());
|
||||
@@ -2495,7 +2524,8 @@ base_url = "https://proxy.example/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert!(outcome.migrated_provider_ids.is_empty());
|
||||
@@ -2552,7 +2582,8 @@ model_provider = "aihubmix"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
|
||||
@@ -2601,7 +2632,8 @@ model_provider = "aihubmix"
|
||||
provider.category = Some("custom".to_string());
|
||||
provider.created_at = Some(1);
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2622,7 +2654,8 @@ model_provider = "aihubmix"
|
||||
);
|
||||
provider.category = Some("custom".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-local-relay"));
|
||||
|
||||
@@ -135,6 +135,18 @@ pub async fn get_config_status(
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::Pi => {
|
||||
let config_path =
|
||||
crate::pi_config::native::get_pi_models_path().map_err(|e| e.to_string())?;
|
||||
let path = crate::pi_config::native::get_pi_agent_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
Ok(ConfigStatus {
|
||||
exists: config_path.exists(),
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +168,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -174,6 +187,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::FailoverQueueItem;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
@@ -39,6 +40,50 @@ pub async fn add_to_failover_queue(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
if app_type == "pi" {
|
||||
let _guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(AppType::Pi.as_str())
|
||||
.await;
|
||||
if state
|
||||
.db
|
||||
.get_provider_aggregate("pi", &provider_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_none()
|
||||
{
|
||||
return Err(format!("Pi provider does not exist: {provider_id}"));
|
||||
}
|
||||
let was_member = state
|
||||
.db
|
||||
.is_in_failover_queue("pi", &provider_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
if let Err(error) = state.db.add_to_failover_queue("pi", &provider_id) {
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await;
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
{
|
||||
if !was_member {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(rollback_epoch)
|
||||
.await;
|
||||
return Err(format!(
|
||||
"Pi failover queue changed but runtime publication failed: {error}"
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
state
|
||||
.db
|
||||
.add_to_failover_queue(&app_type, &provider_id)
|
||||
@@ -52,6 +97,42 @@ pub async fn remove_from_failover_queue(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
if app_type == "pi" {
|
||||
let _guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(AppType::Pi.as_str())
|
||||
.await;
|
||||
let was_member = state
|
||||
.db
|
||||
.is_in_failover_queue("pi", &provider_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
if let Err(error) = state.db.remove_from_failover_queue("pi", &provider_id) {
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await;
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
{
|
||||
if was_member {
|
||||
let _ = state.db.add_to_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(rollback_epoch)
|
||||
.await;
|
||||
return Err(format!(
|
||||
"Pi failover queue changed but runtime publication failed: {error}"
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
state
|
||||
.db
|
||||
.remove_from_failover_queue(&app_type, &provider_id)
|
||||
@@ -64,6 +145,9 @@ pub async fn get_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<bool, String> {
|
||||
if app_type == "pi" {
|
||||
return Ok(crate::settings::get_pi_proxy_settings().auto_failover_enabled);
|
||||
}
|
||||
state
|
||||
.db
|
||||
.get_proxy_config_for_app(&app_type)
|
||||
@@ -86,6 +170,10 @@ pub async fn set_auto_failover_enabled(
|
||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||
);
|
||||
|
||||
if app_type == "pi" {
|
||||
return set_pi_auto_failover_enabled(&app, state.inner(), enabled).await;
|
||||
}
|
||||
|
||||
// 读取当前配置
|
||||
let mut config = state
|
||||
.db
|
||||
@@ -180,3 +268,88 @@ pub async fn set_auto_failover_enabled(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_pi_auto_failover_enabled(
|
||||
app: &tauri::AppHandle,
|
||||
state: &AppState,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let _guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(AppType::Pi.as_str())
|
||||
.await;
|
||||
let previous_config = crate::settings::get_pi_proxy_settings();
|
||||
if enabled && !crate::settings::pi_takeover_enabled() {
|
||||
return Err("Pi gateway takeover must be enabled before failover".to_string());
|
||||
}
|
||||
|
||||
let mut auto_added = None;
|
||||
if enabled
|
||||
&& state
|
||||
.db
|
||||
.get_failover_queue("pi")
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_empty()
|
||||
{
|
||||
let current =
|
||||
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| {
|
||||
"Pi failover queue is empty and no current provider is selected".to_string()
|
||||
})?;
|
||||
state
|
||||
.db
|
||||
.add_to_failover_queue("pi", ¤t)
|
||||
.map_err(|error| error.to_string())?;
|
||||
auto_added = Some(current);
|
||||
}
|
||||
|
||||
let mut next = previous_config.clone();
|
||||
next.auto_failover_enabled = enabled;
|
||||
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
|
||||
if let Some(provider_id) = auto_added {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await;
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
{
|
||||
let _ = crate::settings::update_pi_proxy_settings(previous_config);
|
||||
if let Some(provider_id) = auto_added {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(rollback_epoch)
|
||||
.await;
|
||||
return Err(format!(
|
||||
"Pi failover preference changed but runtime publication failed: {error}"
|
||||
));
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"provider-switched",
|
||||
serde_json::json!({
|
||||
"appType": "pi",
|
||||
"providerId":
|
||||
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
|
||||
.map_err(|error| error.to_string())?,
|
||||
"source": "failoverPreferenceChanged"
|
||||
}),
|
||||
);
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, state) {
|
||||
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
|
||||
let _ = tray.set_menu(Some(new_menu));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,26 +44,56 @@ pub async fn import_config_from_file(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path_buf = PathBuf::from(&filePath);
|
||||
let backup_id = db.import_sql(&path_buf)?;
|
||||
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[Import] post-import sync warning: {msg}");
|
||||
let app_state = state.inner().clone();
|
||||
let pi_guard = app_state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
app_state
|
||||
.proxy_service
|
||||
.prepare_pi_portable_import_under_lock(&pi_guard)
|
||||
.await
|
||||
.map_err(|error| format!("导入前恢复 Pi 直连投影失败: {error}"))?;
|
||||
|
||||
let import_path = filePath.clone();
|
||||
let import_result =
|
||||
tauri::async_runtime::spawn_blocking(move || db.import_sql(&PathBuf::from(import_path)))
|
||||
.await
|
||||
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
|
||||
.and_then(|result| result);
|
||||
let backup_id = match import_result {
|
||||
Ok(backup_id) => backup_id,
|
||||
Err(error) => {
|
||||
let recovery = app_state
|
||||
.proxy_service
|
||||
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
|
||||
.await;
|
||||
return Err(match recovery {
|
||||
Ok(()) => error.to_string(),
|
||||
Err(recovery) => {
|
||||
format!("{error}; Pi gateway recovery after aborted import failed: {recovery}")
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
};
|
||||
drop(pi_guard);
|
||||
|
||||
let sync_state = app_state.clone();
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
|
||||
.await
|
||||
.map_err(|error| error.to_string()),
|
||||
);
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[Import] post-import sync warning: {msg}");
|
||||
}
|
||||
Ok(success_payload_with_warning(backup_id, warning))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let app_state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let app_state = AppState::new(db);
|
||||
ProviderService::sync_current_to_live(&app_state)?;
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
@@ -154,10 +184,50 @@ pub async fn restore_db_backup(
|
||||
filename: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||
let app_state = state.inner().clone();
|
||||
let pi_guard = app_state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
app_state
|
||||
.proxy_service
|
||||
.prepare_pi_portable_import_under_lock(&pi_guard)
|
||||
.await
|
||||
.map_err(|e| format!("Restore failed: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
.map_err(|error| format!("Restore preparation failed: {error}"))?;
|
||||
|
||||
let restore_result =
|
||||
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||
.await
|
||||
.map_err(|error| AppError::Message(format!("Restore task failed: {error}")))
|
||||
.and_then(|result| result);
|
||||
let restored = match restore_result {
|
||||
Ok(restored) => restored,
|
||||
Err(error) => {
|
||||
let recovery = app_state
|
||||
.proxy_service
|
||||
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
|
||||
.await;
|
||||
return Err(match recovery {
|
||||
Ok(()) => error.to_string(),
|
||||
Err(recovery) => {
|
||||
format!("{error}; Pi gateway recovery after aborted restore failed: {recovery}")
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
drop(pi_guard);
|
||||
|
||||
let sync_state = app_state.clone();
|
||||
match tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
log::warn!("[Restore] database restored but post-restore sync failed: {error}");
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("[Restore] database restored but post-restore sync task failed: {error}");
|
||||
}
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// Rename a database backup file
|
||||
|
||||
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 7] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
|
||||
const VALID_TOOLS: [&str; 8] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", "pi",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -428,6 +428,7 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
"opencode" => "OpenCode",
|
||||
"openclaw" => "OpenClaw",
|
||||
"hermes" => "Hermes",
|
||||
"pi" => "Pi",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -508,6 +509,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
"grok" => Some("npm i -g @xai-official/grok@latest"),
|
||||
"opencode" => Some("npm i -g opencode-ai@latest"),
|
||||
"openclaw" => Some("npm i -g openclaw@latest"),
|
||||
"pi" => Some("npm i -g @earendil-works/pi-coding-agent@latest"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -802,6 +804,9 @@ async fn get_single_tool_version_impl(
|
||||
}
|
||||
"openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await,
|
||||
"hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await,
|
||||
"pi" => {
|
||||
fetch_npm_latest_for_tool(&client, "@earendil-works/pi-coding-agent", tool, local).await
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -1999,6 +2004,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
|
||||
"grok" => Some("@xai-official/grok"),
|
||||
"opencode" => Some("opencode-ai"),
|
||||
"openclaw" => Some("openclaw"),
|
||||
"pi" => Some("@earendil-works/pi-coding-agent"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2650,6 +2656,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||
"openclaw" => crate::settings::get_openclaw_override_dir(),
|
||||
"hermes" => crate::settings::get_hermes_override_dir(),
|
||||
"pi" => crate::settings::get_pi_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
@@ -3787,6 +3794,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_lifecycle_metadata_matches_pinned_distribution() {
|
||||
let requested = vec!["unsupported".to_string(), "pi".to_string()];
|
||||
assert_eq!(normalize_requested_tools(&requested), vec!["pi"]);
|
||||
assert_eq!(tool_display_name("pi"), "Pi");
|
||||
assert_eq!(
|
||||
npm_package_for("pi"),
|
||||
Some("@earendil-works/pi-coding-agent")
|
||||
);
|
||||
assert_eq!(
|
||||
npm_install_command_for("pi"),
|
||||
Some("npm i -g @earendil-works/pi-coding-agent@latest")
|
||||
);
|
||||
// The verified distribution exposes `pi --version`, but no updater
|
||||
// contract is assumed; upgrades stay on the package-manager path.
|
||||
assert_eq!(official_update_args("pi"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_semver() {
|
||||
use std::cmp::Ordering;
|
||||
@@ -5129,6 +5154,13 @@ mod tests {
|
||||
assert_eq!(cmd, "npm i -g openclaw@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_install_uses_the_verified_pinned_package() {
|
||||
let cmd = install_command_for("pi");
|
||||
assert_eq!(cmd, "npm i -g @earendil-works/pi-coding-agent@latest");
|
||||
assert!(!cmd.contains("||"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fallbacks_use_official_cli_only_when_supported() {
|
||||
assert_eq!(
|
||||
@@ -5158,6 +5190,11 @@ mod tests {
|
||||
static_fallback_command("openclaw"),
|
||||
"openclaw update --yes || npm i -g openclaw@latest"
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("pi"),
|
||||
"npm i -g @earendil-works/pi-coding-agent@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("pi").contains("pi update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,7 @@ mod misc;
|
||||
mod model_fetch;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod pi;
|
||||
mod plugin;
|
||||
mod profile;
|
||||
mod prompt;
|
||||
@@ -53,6 +54,7 @@ pub use misc::*;
|
||||
pub use model_fetch::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub(crate) use pi::*;
|
||||
pub use plugin::*;
|
||||
pub use profile::*;
|
||||
pub use prompt::*;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::pi_config::model::PiNativeDiagnostic;
|
||||
use crate::pi_config::native_settings::{read_pi_native_defaults, PiNativeDefaults};
|
||||
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
|
||||
use crate::session_manager::providers::pi::PiSessionDiscovery;
|
||||
use crate::store::AppState;
|
||||
use tauri::State;
|
||||
|
||||
/// Read-only diagnostics come exclusively from the Pre-C certified inspection
|
||||
/// service. This command does not infer manageability or gateway status.
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_pi_native_catalog(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PiNativeDiagnostic>, String> {
|
||||
PiCatalogCoordinator::inspect_native(state.inner()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_pi_native_provider(
|
||||
state: State<'_, AppState>,
|
||||
#[allow(non_snake_case)] providerKey: String,
|
||||
#[allow(non_snake_case)] expectedFingerprint: String,
|
||||
) -> Result<String, String> {
|
||||
let result = PiCatalogCoordinator::apply(
|
||||
state.inner(),
|
||||
PiCatalogMutation::ImportNative {
|
||||
provider_key: providerKey,
|
||||
expected_fingerprint: expectedFingerprint,
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
result
|
||||
.provider_id
|
||||
.ok_or_else(|| "Pi import did not return a provider id".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_pi_default_model(
|
||||
state: State<'_, AppState>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
#[allow(non_snake_case)] modelId: String,
|
||||
) -> Result<bool, String> {
|
||||
PiCatalogCoordinator::apply(
|
||||
state.inner(),
|
||||
PiCatalogMutation::SetDefault {
|
||||
provider_id: providerId,
|
||||
model_id: modelId,
|
||||
},
|
||||
)
|
||||
.map(|_| true)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_pi_native_defaults() -> Result<PiNativeDefaults, String> {
|
||||
read_pi_native_defaults().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_pi_session_discovery() -> PiSessionDiscovery {
|
||||
crate::session_manager::providers::pi::session_discovery()
|
||||
}
|
||||
|
||||
/// Explicitly rotate the device-local gateway bearer and republish every
|
||||
/// managed Pi projection. Existing Pi processes must restart because they
|
||||
/// retain the previous projected credential in memory.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn reset_pi_gateway_credential(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.proxy_service
|
||||
.rotate_pi_gateway_token()
|
||||
.await
|
||||
.map(|()| true)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -5,7 +5,11 @@ use tauri::State;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::prompt::Prompt;
|
||||
use crate::services::PromptService;
|
||||
use crate::services::pi_prompt_files::{
|
||||
PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate,
|
||||
PiPromptTemplateService,
|
||||
};
|
||||
use crate::services::prompt::{PiPromptLibraryStatus, PromptService};
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -62,3 +66,61 @@ pub async fn get_current_prompt_file_content(app: String) -> Result<Option<Strin
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pi_prompt_library_status(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PiPromptLibraryStatus, String> {
|
||||
PromptService::get_pi_library_status(&state).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_pi_prompt_library(state: State<'_, AppState>) -> Result<(), String> {
|
||||
PromptService::reconcile_pi_library(&state).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, String> {
|
||||
PiPromptFileService::read(kind).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn replace_pi_prompt_file(
|
||||
kind: PiPromptFileKind,
|
||||
#[allow(non_snake_case)] expectedRevision: String,
|
||||
content: String,
|
||||
) -> Result<PiPromptFileSnapshot, String> {
|
||||
PiPromptFileService::replace(kind, &expectedRevision, &content)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_pi_prompt_file(
|
||||
kind: PiPromptFileKind,
|
||||
#[allow(non_snake_case)] expectedRevision: String,
|
||||
) -> Result<bool, String> {
|
||||
PiPromptFileService::delete(kind, &expectedRevision).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_pi_prompt_templates() -> Result<Vec<PiPromptTemplate>, String> {
|
||||
PiPromptTemplateService::list().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upsert_pi_prompt_template(
|
||||
slug: String,
|
||||
#[allow(non_snake_case)] expectedRevision: String,
|
||||
content: String,
|
||||
) -> Result<PiPromptTemplate, String> {
|
||||
PiPromptTemplateService::upsert(&slug, &expectedRevision, &content)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_pi_prompt_template(
|
||||
slug: String,
|
||||
#[allow(non_snake_case)] expectedRevision: String,
|
||||
) -> Result<bool, String> {
|
||||
PiPromptTemplateService::delete(&slug, &expectedRevision).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ use tauri::{Emitter, Manager, State};
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::commands::xai_oauth::XaiOAuthState;
|
||||
use crate::database::NewProviderAggregate;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
@@ -39,7 +40,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
|
||||
pub fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
provider: ProviderMutationInput,
|
||||
#[allow(non_snake_case)] addToLive: Option<bool>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
@@ -51,7 +52,7 @@ pub fn add_provider(
|
||||
pub fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
provider: ProviderMutationInput,
|
||||
#[allow(non_snake_case)] originalId: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
@@ -250,7 +251,13 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
||||
.create_provider(
|
||||
NewProviderAggregate::from_input(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
crate::services::provider::provider_to_mutation_input(desktop_provider),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|
||||
|| takeover.grokbuild
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
|| takeover.pi
|
||||
{
|
||||
return Err(
|
||||
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
|
||||
@@ -120,6 +121,9 @@ pub async fn get_proxy_config_for_app(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<AppProxyConfig, String> {
|
||||
if app_type == "pi" {
|
||||
return Ok(crate::settings::get_pi_app_proxy_config());
|
||||
}
|
||||
let db = &state.db;
|
||||
db.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
@@ -138,6 +142,60 @@ pub async fn update_proxy_config_for_app(
|
||||
let app_type = config.app_type.clone();
|
||||
let circuit_config = CircuitBreakerConfig::from(&config);
|
||||
|
||||
if app_type == "pi" {
|
||||
let _guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
let previous = crate::settings::get_pi_proxy_settings();
|
||||
if config.enabled != crate::settings::pi_takeover_enabled() {
|
||||
return Err(
|
||||
"Pi enabled state is owned by set_proxy_takeover_for_app, not proxy config"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let next = crate::settings::PiProxySettings {
|
||||
auto_failover_enabled: config.auto_failover_enabled,
|
||||
max_retries: config.max_retries,
|
||||
streaming_first_byte_timeout: config.streaming_first_byte_timeout,
|
||||
streaming_idle_timeout: config.streaming_idle_timeout,
|
||||
non_streaming_timeout: config.non_streaming_timeout,
|
||||
circuit_failure_threshold: config.circuit_failure_threshold,
|
||||
circuit_success_threshold: config.circuit_success_threshold,
|
||||
circuit_timeout_seconds: config.circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold: config.circuit_error_rate_threshold,
|
||||
circuit_min_requests: config.circuit_min_requests,
|
||||
};
|
||||
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await;
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
{
|
||||
let _ = crate::settings::update_pi_proxy_settings(previous);
|
||||
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
|
||||
let _ = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(rollback_epoch)
|
||||
.await;
|
||||
return Err(format!(
|
||||
"Pi proxy config changed but runtime publication failed: {error}"
|
||||
));
|
||||
}
|
||||
state
|
||||
.proxy_service
|
||||
.update_circuit_breaker_config_for_app(&app_type, circuit_config)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -107,18 +107,44 @@ pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String>
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let app_state = state.inner().clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
let pi_guard = app_state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
app_state
|
||||
.proxy_service
|
||||
.prepare_pi_portable_import_under_lock(&pi_guard)
|
||||
.await
|
||||
.map_err(|error| format!("S3 下载前恢复 Pi 直连投影失败: {error}"))?;
|
||||
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})?;
|
||||
let mut result = match sync_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
persist_sync_error(&mut settings, &error, "manual");
|
||||
let recovery = app_state
|
||||
.proxy_service
|
||||
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
|
||||
.await;
|
||||
return Err(match recovery {
|
||||
Ok(()) => error.to_string(),
|
||||
Err(recovery) => {
|
||||
format!(
|
||||
"{error}; Pi gateway recovery after aborted S3 download failed: {recovery}"
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
drop(pi_guard);
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let sync_state = app_state.clone();
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
|
||||
@@ -48,6 +48,13 @@ fn merge_settings_for_save(
|
||||
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
|
||||
// 重新开启时被"复活"的标记挡住而漏迁。
|
||||
incoming.local_migrations = existing.local_migrations.clone();
|
||||
// Pi gateway credential is an installation secret. Settings IPC can
|
||||
// neither observe it (frontend projection clears it) nor mutate it.
|
||||
incoming.pi_gateway_token = existing.pi_gateway_token.clone();
|
||||
// Pi proxy behavior is committed through the proxy commands so a generic
|
||||
// settings round-trip cannot bypass the switch/epoch publication boundary.
|
||||
incoming.pi_takeover_enabled = existing.pi_takeover_enabled;
|
||||
incoming.pi_proxy = existing.pi_proxy.clone();
|
||||
incoming
|
||||
}
|
||||
|
||||
@@ -63,12 +70,29 @@ pub async fn save_settings(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
settings: crate::settings::AppSettings,
|
||||
) -> Result<bool, String> {
|
||||
// The frontend settings projection intentionally cannot mutate Pi's
|
||||
// takeover bit or gateway secret. Serialize the read/merge/write with Pi
|
||||
// catalog mutations so a concurrent toggle cannot be overwritten by a
|
||||
// stale full-settings payload.
|
||||
let pi_guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
let existing = crate::settings::get_settings();
|
||||
let merged = merge_settings_for_save(settings, &existing);
|
||||
let unify_codex_changed =
|
||||
merged.unify_codex_session_history != existing.unify_codex_session_history;
|
||||
let unify_codex_enabled = merged.unify_codex_session_history;
|
||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||
state
|
||||
.proxy_service
|
||||
.replace_settings_with_pi_directory_boundary_under_lock(
|
||||
&pi_guard,
|
||||
&existing,
|
||||
merged.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(pi_guard);
|
||||
|
||||
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
|
||||
// 不必等下一次切换才生效。
|
||||
@@ -82,7 +106,18 @@ pub async fn save_settings(
|
||||
crate::services::provider::reapply_current_codex_official_live(state.inner())
|
||||
{
|
||||
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
|
||||
if let Err(rollback_err) = crate::settings::update_settings(existing) {
|
||||
let pi_guard = state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
let current = crate::settings::get_settings();
|
||||
if let Err(rollback_err) = state
|
||||
.proxy_service
|
||||
.replace_settings_with_pi_directory_boundary_under_lock(
|
||||
&pi_guard, ¤t, existing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("回滚统一会话开关设置失败: {rollback_err}");
|
||||
}
|
||||
return Err(format!(
|
||||
@@ -618,6 +653,28 @@ mod tests {
|
||||
|
||||
assert!(merged.local_migrations.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_cannot_bypass_pi_gateway_publication_ownership() {
|
||||
let existing = AppSettings {
|
||||
pi_takeover_enabled: true,
|
||||
pi_proxy: crate::settings::PiProxySettings {
|
||||
max_retries: 7,
|
||||
..crate::settings::PiProxySettings::default()
|
||||
},
|
||||
..AppSettings::default()
|
||||
};
|
||||
let incoming = AppSettings {
|
||||
pi_takeover_enabled: false,
|
||||
pi_proxy: crate::settings::PiProxySettings::default(),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.pi_takeover_enabled);
|
||||
assert_eq!(merged.pi_proxy.max_retries, 7);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
|
||||
@@ -11,7 +11,9 @@ use crate::services::skill::{
|
||||
SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo,
|
||||
SkillsShSearchResult,
|
||||
};
|
||||
use crate::services::skill_deployment::{PiSkillDeploymentService, SkillAppStatus};
|
||||
use crate::store::AppState;
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -32,6 +34,13 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pi_skill_statuses(
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<BTreeMap<String, SkillAppStatus>, String> {
|
||||
PiSkillDeploymentService::inspect_all(&app_state.db).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
|
||||
SkillService::list_backups().map_err(|e| e.to_string())
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::services::PromptService;
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
|
||||
let app_state = AppState::new(db);
|
||||
ProviderService::sync_current_to_live(&app_state)?;
|
||||
pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> {
|
||||
PromptService::reconcile_pi_portable_import(app_state)?;
|
||||
ProviderService::sync_current_to_live(app_state)?;
|
||||
settings::reload_settings()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,18 +115,44 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let app_state = state.inner().clone();
|
||||
let mut settings = require_enabled_webdav_settings()?;
|
||||
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
let pi_guard = app_state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
|
||||
.await;
|
||||
app_state
|
||||
.proxy_service
|
||||
.prepare_pi_portable_import_under_lock(&pi_guard)
|
||||
.await
|
||||
.map_err(|error| format!("WebDAV 下载前恢复 Pi 直连投影失败: {error}"))?;
|
||||
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})?;
|
||||
let mut result = match sync_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
persist_sync_error(&mut settings, &error, "manual");
|
||||
let recovery = app_state
|
||||
.proxy_service
|
||||
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
|
||||
.await;
|
||||
return Err(match recovery {
|
||||
Ok(()) => error.to_string(),
|
||||
Err(recovery) => {
|
||||
format!(
|
||||
"{error}; Pi gateway recovery after aborted WebDAV download failed: {recovery}"
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
drop(pi_guard);
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let sync_state = app_state.clone();
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
|
||||
+95
-35
@@ -295,6 +295,22 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
|
||||
|
||||
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
atomic_write_durable(path, data, None)
|
||||
}
|
||||
|
||||
/// Durable same-directory atomic replacement.
|
||||
///
|
||||
/// Existing permissions are preserved. `new_file_mode` controls only a newly
|
||||
/// created Unix file (settings and other local secrets pass `0o600`). The
|
||||
/// temporary file is created exclusively, synced before replacement, and the
|
||||
/// containing directory is synced afterwards on Unix.
|
||||
pub(crate) fn atomic_write_durable(
|
||||
path: &Path,
|
||||
data: &[u8],
|
||||
new_file_mode: Option<u32>,
|
||||
) -> Result<(), AppError> {
|
||||
#[cfg(not(unix))]
|
||||
let _ = new_file_mode;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
@@ -302,51 +318,95 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
|
||||
let mut tmp = parent.to_path_buf();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
tmp.push(format!("{file_name}.tmp.{ts}"));
|
||||
let tmp = parent.join(format!(
|
||||
".{file_name}.{}.tmp",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
|
||||
{
|
||||
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
|
||||
f.flush().map_err(|e| AppError::io(&tmp, e))?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let perm = meta.permissions().mode();
|
||||
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
|
||||
let result = (|| -> Result<(), AppError> {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(new_file_mode.unwrap_or(0o666));
|
||||
}
|
||||
}
|
||||
let mut file = options
|
||||
.open(&tmp)
|
||||
.map_err(|error| AppError::io(&tmp, error))?;
|
||||
file.write_all(data)
|
||||
.map_err(|error| AppError::io(&tmp, error))?;
|
||||
file.flush().map_err(|error| AppError::io(&tmp, error))?;
|
||||
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
|
||||
drop(file);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(path)
|
||||
.map(|metadata| metadata.permissions().mode())
|
||||
.unwrap_or_else(|_| new_file_mode.unwrap_or(0o666));
|
||||
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| AppError::io(&tmp, error))?;
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
|
||||
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
|
||||
source: e,
|
||||
})?;
|
||||
replace_file_atomically(&tmp, path)?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(parent)
|
||||
.and_then(|directory| directory.sync_all())
|
||||
.map_err(|error| AppError::io(parent, error))?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
|
||||
fs::rename(temp_path, path).map_err(|source| AppError::IoContext {
|
||||
context: format!(
|
||||
"原子替换失败: {} -> {}",
|
||||
temp_path.display(),
|
||||
path.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
let source: Vec<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let destination: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
// SAFETY: both buffers are NUL-terminated and remain alive for the
|
||||
// duration of this synchronous Win32 call.
|
||||
let moved = unsafe {
|
||||
MoveFileExW(
|
||||
source.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
if moved == 0 {
|
||||
return Err(AppError::IoContext {
|
||||
context: format!(
|
||||
"原子替换失败: {} -> {}",
|
||||
temp_path.display(),
|
||||
path.display()
|
||||
),
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4719
-248
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,18 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub(crate) mod pi_catalog;
|
||||
pub mod pi_projections;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod provider_write;
|
||||
#[cfg(test)]
|
||||
mod provider_write_certification;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skill_deployments;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Transactional database half of Pi catalog coordination.
|
||||
//!
|
||||
//! Provider row/endpoint SQL remains owned by the certified provider-write
|
||||
//! primitives. This module only composes those primitives with Pi's exact-key
|
||||
//! ownership ledger in one SQLite transaction.
|
||||
|
||||
use super::pi_projections::PiProviderProjection;
|
||||
use super::provider_write::{
|
||||
insert_endpoint, insert_row, restore_provider_aggregate_on_tx, NewEndpoint,
|
||||
NewProviderAggregate, ProviderKey, ProviderRowUpdate,
|
||||
};
|
||||
use super::providers::delete_provider_on_tx;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ProviderAggregate, ProviderMutationInput};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn restore_pi_catalog_snapshot(
|
||||
&self,
|
||||
aggregates: &IndexMap<String, ProviderAggregate>,
|
||||
projections: &[PiProviderProjection],
|
||||
current_provider: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.execute("DELETE FROM pi_provider_projections", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current_ids = {
|
||||
let mut statement = tx
|
||||
.prepare("SELECT id FROM providers WHERE app_type = 'pi'")
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let ids = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
ids
|
||||
};
|
||||
for provider_id in current_ids
|
||||
.iter()
|
||||
.filter(|provider_id| !aggregates.contains_key(provider_id.as_str()))
|
||||
{
|
||||
// Only rows created after the snapshot are removed. Updating
|
||||
// providers which existed in the snapshot preserves dependent
|
||||
// provider_health history instead of triggering ON DELETE CASCADE.
|
||||
delete_provider_on_tx(&tx, "pi", provider_id)?;
|
||||
}
|
||||
|
||||
for aggregate in aggregates.values() {
|
||||
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
|
||||
let mut input = provider_mutation_input(aggregate);
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
let endpoints = aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.cloned()
|
||||
.map(NewEndpoint::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
restore_provider_aggregate_on_tx(
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
aggregate.provider.created_at,
|
||||
aggregate.provider.sort_index,
|
||||
current_provider == Some(key.id()),
|
||||
aggregate.provider.in_failover_queue,
|
||||
&endpoints,
|
||||
)?;
|
||||
}
|
||||
for projection in projections {
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
projection.provider_id,
|
||||
projection.provider_key,
|
||||
projection.created_at,
|
||||
projection.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn create_pi_catalog_provider(
|
||||
&self,
|
||||
input: NewProviderAggregate,
|
||||
provider_key: &str,
|
||||
) -> Result<PiProviderProjection, AppError> {
|
||||
if input.key.app_type() != "pi" || provider_key.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi catalog create requires app_type=pi and a non-empty native key".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&input.key,
|
||||
&input.row.content,
|
||||
input.row.created_at,
|
||||
input.sort_index,
|
||||
false,
|
||||
input.in_failover_queue,
|
||||
)?;
|
||||
for endpoint in &input.initial_endpoints {
|
||||
insert_endpoint(&tx, &input.key, endpoint)?;
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)",
|
||||
params![input.key.id(), provider_key, now],
|
||||
)
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"Pi native provider key '{provider_key}' is already claimed"
|
||||
))
|
||||
}
|
||||
_ => AppError::Database(error.to_string()),
|
||||
})?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: input.key.id().to_string(),
|
||||
provider_key: provider_key.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn update_pi_catalog_provider(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
if key.app_type() != "pi" {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi catalog update requires app_type=pi".to_string(),
|
||||
));
|
||||
}
|
||||
self.update_provider(key, row)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_catalog_provider(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let projection = tx
|
||||
.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections
|
||||
WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
|row| {
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: row.get(0)?,
|
||||
provider_key: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
delete_provider_on_tx(&tx, "pi", provider_id)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_pi_catalog_provider(
|
||||
&self,
|
||||
aggregate: &ProviderAggregate,
|
||||
was_current: bool,
|
||||
projection: Option<&PiProviderProjection>,
|
||||
) -> Result<(), AppError> {
|
||||
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
|
||||
let mut input = provider_mutation_input(aggregate);
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
let endpoints = aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.cloned()
|
||||
.map(NewEndpoint::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
restore_provider_aggregate_on_tx(
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
aggregate.provider.created_at,
|
||||
aggregate.provider.sort_index,
|
||||
was_current,
|
||||
aggregate.provider.in_failover_queue,
|
||||
&endpoints,
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[key.id()],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(projection) = projection {
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
projection.provider_id,
|
||||
projection.provider_key,
|
||||
projection.created_at,
|
||||
projection.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_mutation_input(aggregate: &ProviderAggregate) -> ProviderMutationInput {
|
||||
let provider = &aggregate.provider;
|
||||
ProviderMutationInput {
|
||||
id: provider.id.clone(),
|
||||
name: provider.name.clone(),
|
||||
settings_config: provider.settings_config.clone(),
|
||||
website_url: provider.website_url.clone(),
|
||||
category: provider.category.clone(),
|
||||
created_at: provider.created_at,
|
||||
sort_index: provider.sort_index,
|
||||
notes: provider.notes.clone(),
|
||||
meta: provider.meta.clone(),
|
||||
icon: provider.icon.clone(),
|
||||
icon_color: provider.icon_color.clone(),
|
||||
in_failover_queue: provider.in_failover_queue,
|
||||
}
|
||||
}
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Device-local ownership ledger for exact keys in Pi's shared models.json.
|
||||
|
||||
// The projection writer is introduced in a later contract-ordered commit.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PiProviderProjection {
|
||||
pub provider_id: String,
|
||||
pub provider_key: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn decode_projection(row: &rusqlite::Row<'_>) -> rusqlite::Result<PiProviderProjection> {
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: row.get(0)?,
|
||||
provider_key: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn get_pi_projection(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_projection_for_key(
|
||||
&self,
|
||||
provider_key: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_key = ?1",
|
||||
[provider_key],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_projection_manifest(
|
||||
&self,
|
||||
) -> Result<IndexMap<String, PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections ORDER BY provider_id",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], decode_projection)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let mut manifest = IndexMap::new();
|
||||
for row in rows {
|
||||
let projection = row.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
manifest.insert(projection.provider_id.clone(), projection);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Claim an exact key. Existing exact claims are idempotent; either-side
|
||||
/// collisions fail and are never rewritten.
|
||||
pub(crate) fn claim_pi_projection_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_key: &str,
|
||||
) -> Result<PiProviderProjection, AppError> {
|
||||
if provider_id.trim().is_empty() || provider_key.trim().is_empty() {
|
||||
return Err(AppError::Config(
|
||||
"Pi projection provider id and key must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let by_provider = tx
|
||||
.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(existing) = by_provider {
|
||||
if existing.provider_key != provider_key {
|
||||
return Err(AppError::Config(format!(
|
||||
"Pi provider '{provider_id}' already owns key '{}', not '{provider_key}'",
|
||||
existing.provider_key
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
return Ok(existing);
|
||||
}
|
||||
if let Some(existing_owner) = tx
|
||||
.query_row(
|
||||
"SELECT provider_id FROM pi_provider_projections WHERE provider_key = ?1",
|
||||
[provider_key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
{
|
||||
return Err(AppError::Config(format!(
|
||||
"Pi key '{provider_key}' is already owned by provider '{existing_owner}'"
|
||||
)));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)",
|
||||
params![provider_id, provider_key, now],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_key: provider_key.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_projection_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
expected_key: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let removed = conn
|
||||
.execute(
|
||||
"DELETE FROM pi_provider_projections
|
||||
WHERE provider_id = ?1 AND provider_key = ?2",
|
||||
params![provider_id, expected_key],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if removed == 0
|
||||
&& conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Config(format!(
|
||||
"refusing to delete Pi projection '{provider_id}': expected key changed"
|
||||
)));
|
||||
}
|
||||
Ok(removed == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_claims_are_exact_idempotent_and_collision_safe() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let first = db.claim_pi_projection_key("provider-a", "native-a")?;
|
||||
let repeated = db.claim_pi_projection_key("provider-a", "native-a")?;
|
||||
assert_eq!(first, repeated);
|
||||
assert!(db
|
||||
.claim_pi_projection_key("provider-a", "native-b")
|
||||
.is_err());
|
||||
assert!(db
|
||||
.claim_pi_projection_key("provider-b", "native-a")
|
||||
.is_err());
|
||||
assert_eq!(db.get_pi_projection_manifest()?.len(), 1);
|
||||
assert!(db.delete_pi_projection_key("provider-a", "wrong").is_err());
|
||||
assert!(db.get_pi_projection("provider-a")?.is_some());
|
||||
assert!(db.delete_pi_projection_key("provider-a", "native-a")?);
|
||||
assert!(db.get_pi_projection_for_key("native-a")?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,51 +6,106 @@ use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::prompt::Prompt;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
|
||||
fn query_prompts(conn: &Connection, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||
FROM prompts WHERE app_type = ?1
|
||||
ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let prompt_iter = stmt
|
||||
.query_map(params![app_type], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let content: String = row.get(2)?;
|
||||
let description: Option<String> = row.get(3)?;
|
||||
let enabled: bool = row.get(4)?;
|
||||
let created_at: Option<i64> = row.get(5)?;
|
||||
let updated_at: Option<i64> = row.get(6)?;
|
||||
|
||||
Ok((
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id,
|
||||
name,
|
||||
content,
|
||||
description,
|
||||
enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut prompts = IndexMap::new();
|
||||
for prompt_res in prompt_iter {
|
||||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
prompts.insert(id, prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
fn validate_prompt_selection(prompts: &IndexMap<String, Prompt>) -> Result<(), AppError> {
|
||||
if prompts.values().filter(|prompt| prompt.enabled).count() > 1 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"at most one prompt may be enabled for an app".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_prompt_rows(
|
||||
transaction: &Transaction<'_>,
|
||||
app_type: &str,
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
transaction
|
||||
.execute("DELETE FROM prompts WHERE app_type = ?1", [app_type])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let mut statement = transaction
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO prompts (
|
||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for prompt in prompts.values() {
|
||||
statement
|
||||
.execute(params![
|
||||
prompt.id,
|
||||
app_type,
|
||||
prompt.name,
|
||||
prompt.content,
|
||||
prompt.description,
|
||||
prompt.enabled,
|
||||
prompt.created_at,
|
||||
prompt.updated_at,
|
||||
])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_libraries_equal(
|
||||
left: &IndexMap<String, Prompt>,
|
||||
right: &IndexMap<String, Prompt>,
|
||||
) -> bool {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.all(|(id, prompt)| right.get(id) == Some(prompt))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取指定应用类型的所有提示词
|
||||
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||
FROM prompts WHERE app_type = ?1
|
||||
ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let prompt_iter = stmt
|
||||
.query_map(params![app_type], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let content: String = row.get(2)?;
|
||||
let description: Option<String> = row.get(3)?;
|
||||
let enabled: bool = row.get(4)?;
|
||||
let created_at: Option<i64> = row.get(5)?;
|
||||
let updated_at: Option<i64> = row.get(6)?;
|
||||
|
||||
Ok((
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id,
|
||||
name,
|
||||
content,
|
||||
description,
|
||||
enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut prompts = IndexMap::new();
|
||||
for prompt_res in prompt_iter {
|
||||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
prompts.insert(id, prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
query_prompts(&conn, app_type)
|
||||
}
|
||||
|
||||
/// 保存提示词
|
||||
@@ -75,6 +130,75 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a complete prompt-library selection atomically.
|
||||
///
|
||||
/// Pi projects the single enabled row into AGENTS.md. A sequence of
|
||||
/// individual `save_prompt` calls can expose two enabled rows (or none) to
|
||||
/// concurrent readers, so selection changes use one SQLite transaction.
|
||||
pub(crate) fn save_prompt_selection(
|
||||
&self,
|
||||
app_type: &str,
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
validate_prompt_selection(prompts)?;
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
replace_prompt_rows(&transaction, app_type, prompts)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
/// Atomically publish a complete prompt library only while its full
|
||||
/// before-image still matches. This is the database half of Pi's
|
||||
/// native-file/portable-library compare-and-swap boundary.
|
||||
pub(crate) fn compare_exchange_prompt_selection(
|
||||
&self,
|
||||
app_type: &str,
|
||||
expected: &IndexMap<String, Prompt>,
|
||||
replacement: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
validate_prompt_selection(replacement)?;
|
||||
self.compare_exchange_prompt_selection_unchecked(app_type, expected, replacement)
|
||||
}
|
||||
|
||||
/// Restore a captured before-image only if the database still contains the
|
||||
/// exact attempted projection. The before-image may predate the current
|
||||
/// single-selection invariant, so compensation must preserve it byte for
|
||||
/// byte instead of refusing to restore legacy rows.
|
||||
pub(crate) fn restore_prompt_selection_if_attempted(
|
||||
&self,
|
||||
app_type: &str,
|
||||
attempted: &IndexMap<String, Prompt>,
|
||||
before: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
self.compare_exchange_prompt_selection_unchecked(app_type, attempted, before)
|
||||
}
|
||||
|
||||
fn compare_exchange_prompt_selection_unchecked(
|
||||
&self,
|
||||
app_type: &str,
|
||||
expected: &IndexMap<String, Prompt>,
|
||||
replacement: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let observed = query_prompts(&transaction, app_type)?;
|
||||
if !prompt_libraries_equal(&observed, expected) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"{app_type} prompt library changed since it was read"
|
||||
)));
|
||||
}
|
||||
replace_prompt_rows(&transaction, app_type, replacement)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
/// 删除提示词
|
||||
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ProviderMeta, ProviderMutationInput};
|
||||
use crate::settings::CustomEndpoint;
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderKey {
|
||||
app_type: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ProviderKey {
|
||||
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
|
||||
let app_type = app_type.into();
|
||||
let id = id.into();
|
||||
if app_type.trim().is_empty() || id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider app type and id must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self { app_type, id })
|
||||
}
|
||||
|
||||
pub fn app_type(&self) -> &str {
|
||||
&self.app_type
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowUpdate {
|
||||
pub(super) name: String,
|
||||
pub(super) settings_config: Value,
|
||||
pub(super) website_url: Option<String>,
|
||||
pub(super) category: Option<String>,
|
||||
pub(super) notes: Option<String>,
|
||||
pub(super) meta: ProviderMeta,
|
||||
pub(super) icon: Option<String>,
|
||||
pub(super) icon_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRowUpdate {
|
||||
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
|
||||
let meta = input.meta.clone().unwrap_or_default();
|
||||
if !meta.custom_endpoints.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider update must not contain customEndpoints; use endpoint operations"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
name: input.name.clone(),
|
||||
settings_config: input.settings_config.clone(),
|
||||
website_url: input.website_url.clone(),
|
||||
category: input.category.clone(),
|
||||
notes: input.notes.clone(),
|
||||
meta,
|
||||
icon: input.icon.clone(),
|
||||
icon_color: input.icon_color.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
pub(super) content: ProviderRowUpdate,
|
||||
pub(super) created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewEndpoint {
|
||||
pub(super) url: String,
|
||||
pub(super) added_at: Option<i64>,
|
||||
pub(super) last_used: Option<i64>,
|
||||
}
|
||||
|
||||
impl NewEndpoint {
|
||||
pub fn new(
|
||||
url: impl Into<String>,
|
||||
added_at: Option<i64>,
|
||||
last_used: Option<i64>,
|
||||
) -> Result<Self, AppError> {
|
||||
let url = url.into();
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider endpoint URL cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
url,
|
||||
added_at,
|
||||
last_used,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
|
||||
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||
type Error = AppError;
|
||||
|
||||
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
|
||||
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProviderAggregate {
|
||||
pub(super) key: ProviderKey,
|
||||
pub(super) row: ProviderRowCreate,
|
||||
pub(super) sort_index: Option<usize>,
|
||||
pub(super) in_failover_queue: bool,
|
||||
pub(super) initial_endpoints: Vec<NewEndpoint>,
|
||||
}
|
||||
|
||||
impl NewProviderAggregate {
|
||||
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
|
||||
let endpoints = input
|
||||
.meta
|
||||
.as_mut()
|
||||
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
|
||||
.unwrap_or_default();
|
||||
let mut seen = HashSet::with_capacity(endpoints.len());
|
||||
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
|
||||
for (key, endpoint) in endpoints {
|
||||
let normalized_key = key.trim().trim_end_matches('/').to_string();
|
||||
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
|
||||
if normalized_key != normalized_url {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"provider endpoint key '{key}' must match endpoint URL '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
if !seen.insert(normalized_url.clone()) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"duplicate initial provider endpoint '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
initial_endpoints.push(NewEndpoint::new(
|
||||
normalized_url,
|
||||
endpoint.added_at,
|
||||
endpoint.last_used,
|
||||
)?);
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowCreate {
|
||||
content: ProviderRowUpdate::from_input(&input)?,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
Ok(Self {
|
||||
key,
|
||||
row,
|
||||
sort_index: input.sort_index,
|
||||
in_failover_queue: input.in_failover_queue,
|
||||
initial_endpoints,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenameProvider {
|
||||
source: ProviderKey,
|
||||
target_id: String,
|
||||
row: ProviderRowUpdate,
|
||||
}
|
||||
|
||||
impl RenameProvider {
|
||||
pub fn from_input(
|
||||
source: ProviderKey,
|
||||
input: &ProviderMutationInput,
|
||||
) -> Result<Self, AppError> {
|
||||
if !matches!(source.app_type(), "opencode" | "openclaw") {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if source.id() == input.id {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider rename requires a different target id".to_string(),
|
||||
));
|
||||
}
|
||||
if input.id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider target id must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut row = ProviderRowUpdate::from_input(input)?;
|
||||
// A successful key change always remains DB-only. The service owns
|
||||
// the corresponding live-file absence check, while the DAO persists
|
||||
// the durable half of that invariant.
|
||||
row.meta.live_config_managed = Some(false);
|
||||
Ok(Self {
|
||||
source,
|
||||
target_id: input.id.clone(),
|
||||
row,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
|
||||
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
|
||||
AppError::Database(format!("failed to serialize settings_config: {error}"))
|
||||
})?;
|
||||
let meta = serde_json::to_string(&row.meta).map_err(|error| {
|
||||
AppError::Database(format!("failed to serialize provider meta: {error}"))
|
||||
})?;
|
||||
Ok((settings_config, meta))
|
||||
}
|
||||
|
||||
pub(super) fn insert_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let (settings_config, meta) = encode_row(row)?;
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta,
|
||||
is_current, in_failover_queue
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
|
||||
)",
|
||||
params![
|
||||
key.id,
|
||||
key.app_type,
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
created_at,
|
||||
sort_index,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
meta,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"provider '{}/{}' already exists",
|
||||
key.app_type, key.id
|
||||
))
|
||||
}
|
||||
_ => AppError::Database(error.to_string()),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn insert_endpoint(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
endpoint: &NewEndpoint,
|
||||
) -> Result<(), AppError> {
|
||||
tx.execute(
|
||||
"INSERT INTO provider_endpoints
|
||||
(provider_id, app_type, url, added_at, last_used)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
key.id,
|
||||
key.app_type,
|
||||
endpoint.url,
|
||||
endpoint.added_at,
|
||||
endpoint.last_used
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exact aggregate replacement is sealed inside the DAO parent module. The
|
||||
/// catalog compensation coordinator introduced with the ordered mutation
|
||||
/// pipeline is the only intended caller.
|
||||
#[allow(dead_code)]
|
||||
// The certification contract keeps immutable creation time separate from the
|
||||
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn restore_provider_aggregate_on_tx(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
endpoints: &[NewEndpoint],
|
||||
) -> Result<(), AppError> {
|
||||
let updated = update_row(tx, key, row)?;
|
||||
if updated == 0 {
|
||||
insert_row(
|
||||
tx,
|
||||
key,
|
||||
row,
|
||||
created_at,
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
)?;
|
||||
} else {
|
||||
// Exact compensation is the only path allowed to restore immutable
|
||||
// creation time after a prior aggregate mutation.
|
||||
tx.execute(
|
||||
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![created_at, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
||||
params![key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for endpoint in endpoints {
|
||||
insert_endpoint(tx, key, endpoint)?;
|
||||
}
|
||||
// State and order are maintained by their dedicated authorities. Exact
|
||||
// compensation may restore their captured values without exposing them in
|
||||
// ProviderRowUpdate.
|
||||
tx.execute(
|
||||
"UPDATE providers
|
||||
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
|
||||
WHERE id = ?4 AND app_type = ?5",
|
||||
params![
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
key.id,
|
||||
key.app_type
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn update_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<usize, AppError> {
|
||||
let (settings_config, meta) = encode_row(row)?;
|
||||
tx.execute(
|
||||
"UPDATE providers SET
|
||||
name = ?1,
|
||||
settings_config = ?2,
|
||||
website_url = ?3,
|
||||
category = ?4,
|
||||
notes = ?5,
|
||||
icon = ?6,
|
||||
icon_color = ?7,
|
||||
meta = ?8
|
||||
WHERE id = ?9 AND app_type = ?10",
|
||||
params![
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
meta,
|
||||
key.id,
|
||||
key.app_type,
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&input.key,
|
||||
&input.row.content,
|
||||
input.row.created_at,
|
||||
input.sort_index,
|
||||
false,
|
||||
input.in_failover_queue,
|
||||
)?;
|
||||
for endpoint in &input.initial_endpoints {
|
||||
insert_endpoint(&tx, &input.key, endpoint)?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn update_provider(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if update_row(&tx, key, row)? != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn update_provider_if_content_fingerprint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
expected_fingerprint: &str,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
||||
params![key.id, key.app_type],
|
||||
StoredProviderRow::from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
||||
.decode(key.app_type())?;
|
||||
if current.row_content_fingerprint() != expected_fingerprint {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' changed since it was read",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
if update_row(&tx, key, row)? != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn rename_db_only_additive_provider(
|
||||
&self,
|
||||
input: RenameProvider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let source_state = tx
|
||||
.query_row(
|
||||
"SELECT sort_index, is_current, in_failover_queue, category, created_at, meta
|
||||
FROM providers
|
||||
WHERE id = ?1 AND app_type = ?2",
|
||||
params![input.source.id, input.source.app_type],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, Option<usize>>(0)?,
|
||||
row.get::<_, bool>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
input.source.app_type, input.source.id
|
||||
))
|
||||
})?;
|
||||
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"OMO/OMO Slim providers cannot be renamed".to_string(),
|
||||
));
|
||||
}
|
||||
let source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
|
||||
ProviderMeta::default()
|
||||
} else {
|
||||
serde_json::from_str(&source_state.5).map_err(|error| {
|
||||
AppError::Database(format!(
|
||||
"invalid meta for provider '{}/{}': {error}",
|
||||
input.source.app_type, input.source.id
|
||||
))
|
||||
})?
|
||||
};
|
||||
if source_meta.live_config_managed == Some(true) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' became live-managed before rename",
|
||||
input.source.app_type, input.source.id
|
||||
)));
|
||||
}
|
||||
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&target,
|
||||
&input.row,
|
||||
source_state.4,
|
||||
source_state.0,
|
||||
source_state.1,
|
||||
source_state.2,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO provider_endpoints
|
||||
(provider_id, app_type, url, added_at, last_used)
|
||||
SELECT ?1, app_type, url, added_at, last_used
|
||||
FROM provider_endpoints
|
||||
WHERE provider_id = ?2 AND app_type = ?3
|
||||
ORDER BY id",
|
||||
params![target.id, input.source.id, input.source.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if tx
|
||||
.execute(
|
||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![input.source.id, input.source.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
input.source.app_type, input.source.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn add_provider_endpoint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
endpoint: NewEndpoint,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_endpoint(&tx, key, &endpoint)?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"DELETE FROM provider_endpoints
|
||||
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||||
params![key.id, key.app_type, url],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider endpoint '{}/{}/{}'",
|
||||
key.app_type, key.id, url
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn touch_provider_endpoint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
url: &str,
|
||||
at: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"UPDATE provider_endpoints
|
||||
SET last_used = ?1
|
||||
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
|
||||
params![at, key.id, key.app_type, url],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider endpoint '{}/{}/{}'",
|
||||
key.app_type, key.id, url
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn update_provider_sort_index(
|
||||
&self,
|
||||
updates: &[(ProviderKey, usize)],
|
||||
) -> Result<(), AppError> {
|
||||
let mut seen = std::collections::HashSet::with_capacity(updates.len());
|
||||
for (key, _) in updates {
|
||||
if !seen.insert((key.app_type().to_string(), key.id().to_string())) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"duplicate provider sort update for '{}/{}'",
|
||||
key.app_type(),
|
||||
key.id()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for (key, sort_index) in updates {
|
||||
if tx
|
||||
.execute(
|
||||
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![sort_index, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,8 @@ impl Database {
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
Ok(Some(
|
||||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||||
))
|
||||
row.get::<_, Option<String>>(0)
|
||||
.map_err(|e| AppError::Database(e.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -325,3 +324,23 @@ impl Database {
|
||||
self.set_setting("log_config", &json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn null_setting_value_hydrates_as_absent() -> Result<(), crate::error::AppError> {
|
||||
let database = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(database.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('nullable-setting', NULL)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
assert_eq!(database.get_setting("nullable-setting")?, None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Device-local evidence for Pi Skill deployments.
|
||||
|
||||
// Pi skill reconciliation consumes this ledger in a later contract-ordered commit.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum SkillDeploymentMethod {
|
||||
Symlink,
|
||||
Copy,
|
||||
}
|
||||
|
||||
impl SkillDeploymentMethod {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Symlink => "symlink",
|
||||
Self::Copy => "copy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SkillDeploymentMethod {
|
||||
type Err = AppError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"symlink" => Ok(Self::Symlink),
|
||||
"copy" => Ok(Self::Copy),
|
||||
_ => Err(AppError::Database(format!(
|
||||
"unknown Pi Skill deployment method '{value}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillDeployment {
|
||||
pub skill_id: String,
|
||||
pub destination: String,
|
||||
pub destination_key: String,
|
||||
pub method: SkillDeploymentMethod,
|
||||
pub source_identity: String,
|
||||
pub deployed_digest: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn decode_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillDeployment> {
|
||||
let method: String = row.get(3)?;
|
||||
let method = method.parse().map_err(|error: AppError| {
|
||||
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error))
|
||||
})?;
|
||||
Ok(SkillDeployment {
|
||||
skill_id: row.get(0)?,
|
||||
destination: row.get(1)?,
|
||||
destination_key: row.get(2)?,
|
||||
method,
|
||||
source_identity: row.get(4)?,
|
||||
deployed_digest: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn set_pi_skill_desired(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
desired_enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let changed = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{skill_id}' disappeared before desired state was saved"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_skill_deployment(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
) -> Result<Option<SkillDeployment>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
|
||||
params![skill_id, destination_key],
|
||||
decode_deployment,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_skill_deployments(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
) -> Result<Vec<SkillDeployment>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1
|
||||
ORDER BY created_at, destination_key",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([skill_id], decode_deployment)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
rows.map(|row| row.map_err(|error| AppError::Database(error.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn save_pi_skill_deployment(
|
||||
&self,
|
||||
deployment: &SkillDeployment,
|
||||
) -> Result<(), AppError> {
|
||||
self.save_pi_skill_deployment_with_desired(deployment, None)
|
||||
}
|
||||
|
||||
/// Commit ledger evidence and, for a user toggle, the desired Pi bit in
|
||||
/// the same SQLite transaction. Filesystem publication happens before
|
||||
/// this point; a failed transaction is therefore safe to compensate by
|
||||
/// restoring the staged destination without exposing split DB authority.
|
||||
pub(crate) fn save_pi_skill_deployment_with_desired(
|
||||
&self,
|
||||
deployment: &SkillDeployment,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> Result<(), AppError> {
|
||||
if deployment.skill_id.trim().is_empty()
|
||||
|| deployment.destination.trim().is_empty()
|
||||
|| deployment.destination_key.trim().is_empty()
|
||||
|| deployment.source_identity.trim().is_empty()
|
||||
{
|
||||
return Err(AppError::Config(
|
||||
"Pi Skill deployment identity fields must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO skill_deployments (
|
||||
app_type, skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(app_type, skill_id, destination_key) DO UPDATE SET
|
||||
destination = excluded.destination,
|
||||
method = excluded.method,
|
||||
source_identity = excluded.source_identity,
|
||||
deployed_digest = excluded.deployed_digest,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
deployment.skill_id,
|
||||
deployment.destination,
|
||||
deployment.destination_key,
|
||||
deployment.method.as_str(),
|
||||
deployment.source_identity,
|
||||
deployment.deployed_digest,
|
||||
deployment.created_at,
|
||||
deployment.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(desired_enabled) = desired_enabled {
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, deployment.skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{}' disappeared before deployment commit",
|
||||
deployment.skill_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_skill_deployment(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
self.delete_pi_skill_deployment_with_desired(skill_id, destination_key, None)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_skill_deployment_with_desired(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> Result<bool, AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(desired_enabled) = desired_enabled {
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{skill_id}' disappeared before deployment cleanup"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let removed = transaction
|
||||
.execute(
|
||||
"DELETE FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
|
||||
params![skill_id, destination_key],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
== 1;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
|
||||
SkillDeployment {
|
||||
skill_id: skill_id.into(),
|
||||
destination: format!("/tmp/{destination_key}"),
|
||||
destination_key: destination_key.into(),
|
||||
method: SkillDeploymentMethod::Copy,
|
||||
source_identity: format!("source:{skill_id}"),
|
||||
deployed_digest: Some("sha256:initial".into()),
|
||||
created_at: 10,
|
||||
updated_at: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_ledger_preserves_created_at_and_rejects_destination_collision() -> Result<(), AppError>
|
||||
{
|
||||
let db = Database::memory()?;
|
||||
db.save_pi_skill_deployment(&deployment("one", "destination"))?;
|
||||
let mut updated = deployment("one", "destination");
|
||||
updated.updated_at = 20;
|
||||
updated.deployed_digest = Some("sha256:updated".into());
|
||||
db.save_pi_skill_deployment(&updated)?;
|
||||
let saved = db
|
||||
.get_pi_skill_deployment("one", "destination")?
|
||||
.expect("deployment");
|
||||
assert_eq!(saved.created_at, 10);
|
||||
assert_eq!(saved.updated_at, 20);
|
||||
assert_eq!(saved.deployed_digest.as_deref(), Some("sha256:updated"));
|
||||
|
||||
assert!(db
|
||||
.save_pi_skill_deployment(&deployment("two", "destination"))
|
||||
.is_err());
|
||||
assert_eq!(db.get_pi_skill_deployments("one")?.len(), 1);
|
||||
assert!(db.delete_pi_skill_deployment("one", "destination")?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
enabled_opencode, enabled_hermes, enabled_pi,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -46,10 +47,11 @@ impl Database {
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
pi: row.get(14)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(15)?,
|
||||
content_hash: row.get(16)?,
|
||||
updated_at: row.get::<_, i64>(17).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -69,7 +71,8 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
enabled_opencode, enabled_hermes, enabled_pi,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -91,10 +94,11 @@ impl Database {
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
pi: row.get(14)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(15)?,
|
||||
content_hash: row.get(16)?,
|
||||
updated_at: row.get::<_, i64>(17).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -109,11 +113,28 @@ impl Database {
|
||||
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
"INSERT INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
enabled_pi, installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
directory = excluded.directory,
|
||||
repo_owner = excluded.repo_owner,
|
||||
repo_name = excluded.repo_name,
|
||||
repo_branch = excluded.repo_branch,
|
||||
readme_url = excluded.readme_url,
|
||||
enabled_claude = excluded.enabled_claude,
|
||||
enabled_codex = excluded.enabled_codex,
|
||||
enabled_gemini = excluded.enabled_gemini,
|
||||
enabled_grokbuild = excluded.enabled_grokbuild,
|
||||
enabled_opencode = excluded.enabled_opencode,
|
||||
enabled_hermes = excluded.enabled_hermes,
|
||||
installed_at = excluded.installed_at,
|
||||
content_hash = excluded.content_hash,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -129,6 +150,7 @@ impl Database {
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.apps.pi,
|
||||
skill.installed_at,
|
||||
skill.content_hash,
|
||||
skill.updated_at,
|
||||
@@ -160,8 +182,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_pi = ?7 WHERE id = ?8",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, apps.pi, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
@@ -262,3 +284,52 @@ impl Database {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn installed_skill() -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: "owner/repo:skill".into(),
|
||||
name: "Skill".into(),
|
||||
description: Some("before".into()),
|
||||
directory: "skill".into(),
|
||||
repo_owner: Some("owner".into()),
|
||||
repo_name: Some("repo".into()),
|
||||
repo_branch: Some("main".into()),
|
||||
readme_url: None,
|
||||
apps: SkillApps::default(),
|
||||
installed_at: 10,
|
||||
content_hash: Some("sha256:before".into()),
|
||||
updated_at: 11,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_skill_save_preserves_pi_desired_state() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let mut skill = installed_skill();
|
||||
db.save_skill(&skill)?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"UPDATE skills SET enabled_pi = 1 WHERE id = ?1",
|
||||
[&skill.id],
|
||||
)?;
|
||||
}
|
||||
|
||||
skill.name = "Updated".into();
|
||||
skill.content_hash = Some("sha256:after".into());
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let saved: (String, String, bool) = conn.query_row(
|
||||
"SELECT name, content_hash, enabled_pi FROM skills WHERE id = ?1",
|
||||
[&skill.id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(saved, ("Updated".into(), "sha256:after".into(), true));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。
|
||||
|
||||
use super::schema::MigrationRunContext;
|
||||
use super::{lock_conn, to_json_string, Database};
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use crate::error::AppError;
|
||||
@@ -28,8 +29,8 @@ impl Database {
|
||||
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let mut conn =
|
||||
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Self::create_tables_on_conn(&conn)?;
|
||||
Self::apply_schema_migrations_on_conn(&conn)?;
|
||||
Self::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
Self::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
let tx = conn
|
||||
.transaction()
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
//! Declarative source schemas for N/N-1 untrusted database migration.
|
||||
//!
|
||||
//! The restore pipeline validates one of these versioned shapes before any
|
||||
//! migration DDL runs. This module is intentionally independent from the
|
||||
//! current-schema factory: source recognition must never become true because
|
||||
//! current tables were created into the untrusted database.
|
||||
//!
|
||||
//! Only the current and immediately previous schema versions are accepted.
|
||||
//! Importing v1..v15 backups is a separate future “historical backup import”
|
||||
//! project. Local in-place upgrades still use the complete migration chain in
|
||||
//! `schema.rs`; this module only gates the untrusted SQL/binary restore paths.
|
||||
//!
|
||||
//! SQLite storage classes belong to values rather than columns. Accordingly,
|
||||
//! the declaration spelling (`BOOLEAN` versus `INTEGER`, for example) is not
|
||||
//! an authority boundary; every populated value is checked with `typeof`, while
|
||||
//! the exact table and column-name sets are checked independently.
|
||||
|
||||
use super::{Database, SCHEMA_VERSION};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const EARLIEST_UNTRUSTED_RESTORE_VERSION: i32 = 16;
|
||||
const LATEST_DECLARED_SOURCE_VERSION: i32 = 17;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MigrationStorageClass {
|
||||
Integer,
|
||||
Real,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl MigrationStorageClass {
|
||||
const fn sqlite_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Integer => "integer",
|
||||
Self::Real => "real",
|
||||
Self::Text => "text",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct MigrationSourceColumnSpec {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) declared_type: &'static str,
|
||||
pub(crate) storage: MigrationStorageClass,
|
||||
pub(crate) nullable: bool,
|
||||
}
|
||||
|
||||
impl MigrationSourceColumnSpec {
|
||||
const fn new(
|
||||
name: &'static str,
|
||||
declared_type: &'static str,
|
||||
storage: MigrationStorageClass,
|
||||
nullable: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
declared_type,
|
||||
storage,
|
||||
nullable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct MigrationSourceTableSpec {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) columns: &'static [MigrationSourceColumnSpec],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MigrationSourceSpec {
|
||||
pub(crate) version: i32,
|
||||
pub(crate) tables: Vec<&'static MigrationSourceTableSpec>,
|
||||
}
|
||||
|
||||
macro_rules! text {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, false)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! nullable_text {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, true)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! integer {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, false)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! nullable_integer {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, true)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! boolean {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "BOOLEAN", MigrationStorageClass::Integer, false)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! real {
|
||||
($name:literal) => {
|
||||
MigrationSourceColumnSpec::new($name, "REAL", MigrationStorageClass::Real, false)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! table {
|
||||
($name:ident, $sql_name:literal, [$($column:expr),* $(,)?]) => {
|
||||
const $name: MigrationSourceTableSpec = MigrationSourceTableSpec {
|
||||
name: $sql_name,
|
||||
columns: &[$($column),*],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
table!(
|
||||
PROVIDERS_V2,
|
||||
"providers",
|
||||
[
|
||||
text!("id"),
|
||||
text!("app_type"),
|
||||
text!("name"),
|
||||
text!("settings_config"),
|
||||
nullable_text!("website_url"),
|
||||
nullable_text!("category"),
|
||||
nullable_integer!("created_at"),
|
||||
nullable_integer!("sort_index"),
|
||||
nullable_text!("notes"),
|
||||
nullable_text!("icon"),
|
||||
nullable_text!("icon_color"),
|
||||
text!("meta"),
|
||||
boolean!("is_current"),
|
||||
boolean!("in_failover_queue"),
|
||||
text!("cost_multiplier"),
|
||||
nullable_text!("limit_daily_usd"),
|
||||
nullable_text!("limit_monthly_usd"),
|
||||
nullable_text!("provider_type"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROVIDER_ENDPOINTS_V16,
|
||||
"provider_endpoints",
|
||||
[
|
||||
integer!("id"),
|
||||
text!("provider_id"),
|
||||
text!("app_type"),
|
||||
text!("url"),
|
||||
nullable_integer!("added_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROVIDER_ENDPOINTS_V17,
|
||||
"provider_endpoints",
|
||||
[
|
||||
integer!("id"),
|
||||
text!("provider_id"),
|
||||
text!("app_type"),
|
||||
text!("url"),
|
||||
nullable_integer!("added_at"),
|
||||
nullable_integer!("last_used"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
MCP_V15,
|
||||
"mcp_servers",
|
||||
[
|
||||
text!("id"),
|
||||
text!("name"),
|
||||
text!("server_config"),
|
||||
nullable_text!("description"),
|
||||
nullable_text!("homepage"),
|
||||
nullable_text!("docs"),
|
||||
text!("tags"),
|
||||
boolean!("enabled_claude"),
|
||||
boolean!("enabled_codex"),
|
||||
boolean!("enabled_gemini"),
|
||||
boolean!("enabled_grokbuild"),
|
||||
boolean!("enabled_opencode"),
|
||||
boolean!("enabled_hermes"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROMPTS,
|
||||
"prompts",
|
||||
[
|
||||
text!("id"),
|
||||
text!("app_type"),
|
||||
text!("name"),
|
||||
text!("content"),
|
||||
nullable_text!("description"),
|
||||
boolean!("enabled"),
|
||||
nullable_integer!("created_at"),
|
||||
nullable_integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SKILLS_V15,
|
||||
"skills",
|
||||
[
|
||||
text!("id"),
|
||||
text!("name"),
|
||||
nullable_text!("description"),
|
||||
text!("directory"),
|
||||
nullable_text!("repo_owner"),
|
||||
nullable_text!("repo_name"),
|
||||
nullable_text!("repo_branch"),
|
||||
nullable_text!("readme_url"),
|
||||
boolean!("enabled_claude"),
|
||||
boolean!("enabled_codex"),
|
||||
boolean!("enabled_gemini"),
|
||||
boolean!("enabled_grokbuild"),
|
||||
boolean!("enabled_opencode"),
|
||||
boolean!("enabled_hermes"),
|
||||
integer!("installed_at"),
|
||||
nullable_text!("content_hash"),
|
||||
integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SKILLS_V17,
|
||||
"skills",
|
||||
[
|
||||
text!("id"),
|
||||
text!("name"),
|
||||
nullable_text!("description"),
|
||||
text!("directory"),
|
||||
nullable_text!("repo_owner"),
|
||||
nullable_text!("repo_name"),
|
||||
nullable_text!("repo_branch"),
|
||||
nullable_text!("readme_url"),
|
||||
boolean!("enabled_claude"),
|
||||
boolean!("enabled_codex"),
|
||||
boolean!("enabled_gemini"),
|
||||
boolean!("enabled_grokbuild"),
|
||||
boolean!("enabled_opencode"),
|
||||
boolean!("enabled_hermes"),
|
||||
boolean!("enabled_pi"),
|
||||
integer!("installed_at"),
|
||||
nullable_text!("content_hash"),
|
||||
integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SKILL_REPOS,
|
||||
"skill_repos",
|
||||
[
|
||||
text!("owner"),
|
||||
text!("name"),
|
||||
text!("branch"),
|
||||
boolean!("enabled"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SETTINGS,
|
||||
"settings",
|
||||
[text!("key"), nullable_text!("value")]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROXY_CONFIG_V14,
|
||||
"proxy_config",
|
||||
[
|
||||
text!("app_type"),
|
||||
boolean!("proxy_enabled"),
|
||||
text!("listen_address"),
|
||||
integer!("listen_port"),
|
||||
boolean!("enable_logging"),
|
||||
boolean!("enabled"),
|
||||
boolean!("auto_failover_enabled"),
|
||||
integer!("max_retries"),
|
||||
integer!("streaming_first_byte_timeout"),
|
||||
integer!("streaming_idle_timeout"),
|
||||
integer!("non_streaming_timeout"),
|
||||
integer!("circuit_failure_threshold"),
|
||||
integer!("circuit_success_threshold"),
|
||||
integer!("circuit_timeout_seconds"),
|
||||
real!("circuit_error_rate_threshold"),
|
||||
integer!("circuit_min_requests"),
|
||||
text!("created_at"),
|
||||
text!("updated_at"),
|
||||
text!("default_cost_multiplier"),
|
||||
text!("pricing_model_source"),
|
||||
boolean!("live_takeover_active"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROVIDER_HEALTH,
|
||||
"provider_health",
|
||||
[
|
||||
text!("provider_id"),
|
||||
text!("app_type"),
|
||||
boolean!("is_healthy"),
|
||||
integer!("consecutive_failures"),
|
||||
nullable_text!("last_success_at"),
|
||||
nullable_text!("last_failure_at"),
|
||||
nullable_text!("last_error"),
|
||||
text!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROXY_LOG_V13,
|
||||
"proxy_request_logs",
|
||||
[
|
||||
text!("request_id"),
|
||||
text!("provider_id"),
|
||||
text!("app_type"),
|
||||
text!("model"),
|
||||
nullable_text!("request_model"),
|
||||
nullable_text!("pricing_model"),
|
||||
integer!("input_tokens"),
|
||||
integer!("output_tokens"),
|
||||
integer!("cache_read_tokens"),
|
||||
integer!("cache_creation_tokens"),
|
||||
integer!("input_token_semantics"),
|
||||
text!("input_cost_usd"),
|
||||
text!("output_cost_usd"),
|
||||
text!("cache_read_cost_usd"),
|
||||
text!("cache_creation_cost_usd"),
|
||||
text!("total_cost_usd"),
|
||||
integer!("latency_ms"),
|
||||
nullable_integer!("first_token_ms"),
|
||||
nullable_integer!("duration_ms"),
|
||||
integer!("status_code"),
|
||||
nullable_text!("error_message"),
|
||||
nullable_text!("session_id"),
|
||||
nullable_text!("provider_type"),
|
||||
boolean!("is_streaming"),
|
||||
text!("cost_multiplier"),
|
||||
integer!("created_at"),
|
||||
text!("data_source"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
MODEL_PRICING,
|
||||
"model_pricing",
|
||||
[
|
||||
text!("model_id"),
|
||||
text!("display_name"),
|
||||
text!("input_cost_per_million"),
|
||||
text!("output_cost_per_million"),
|
||||
text!("cache_read_cost_per_million"),
|
||||
text!("cache_creation_cost_per_million"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
STREAM_CHECK_LOGS,
|
||||
"stream_check_logs",
|
||||
[
|
||||
integer!("id"),
|
||||
text!("provider_id"),
|
||||
text!("provider_name"),
|
||||
text!("app_type"),
|
||||
text!("status"),
|
||||
boolean!("success"),
|
||||
text!("message"),
|
||||
nullable_integer!("response_time_ms"),
|
||||
nullable_integer!("http_status"),
|
||||
nullable_text!("model_used"),
|
||||
nullable_integer!("retry_count"),
|
||||
integer!("tested_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROXY_LIVE_BACKUP,
|
||||
"proxy_live_backup",
|
||||
[
|
||||
text!("app_type"),
|
||||
text!("original_config"),
|
||||
text!("backed_up_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
USAGE_ROLLUPS_V13,
|
||||
"usage_daily_rollups",
|
||||
[
|
||||
text!("date"),
|
||||
text!("app_type"),
|
||||
text!("provider_id"),
|
||||
text!("model"),
|
||||
text!("request_model"),
|
||||
text!("pricing_model"),
|
||||
integer!("request_count"),
|
||||
integer!("success_count"),
|
||||
integer!("input_tokens"),
|
||||
integer!("output_tokens"),
|
||||
integer!("cache_read_tokens"),
|
||||
integer!("cache_creation_tokens"),
|
||||
integer!("input_token_semantics"),
|
||||
text!("total_cost_usd"),
|
||||
integer!("avg_latency_ms"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SESSION_LOG_SYNC,
|
||||
"session_log_sync",
|
||||
[
|
||||
text!("file_path"),
|
||||
integer!("last_modified"),
|
||||
integer!("last_line_offset"),
|
||||
integer!("last_synced_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PROFILES,
|
||||
"profiles",
|
||||
[
|
||||
text!("id"),
|
||||
text!("name"),
|
||||
text!("payload"),
|
||||
nullable_integer!("sort_order"),
|
||||
nullable_integer!("created_at"),
|
||||
nullable_integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
PI_PROVIDER_PROJECTIONS,
|
||||
"pi_provider_projections",
|
||||
[
|
||||
text!("provider_id"),
|
||||
text!("provider_key"),
|
||||
integer!("created_at"),
|
||||
integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
table!(
|
||||
SKILL_DEPLOYMENTS,
|
||||
"skill_deployments",
|
||||
[
|
||||
text!("app_type"),
|
||||
text!("skill_id"),
|
||||
text!("destination"),
|
||||
text!("destination_key"),
|
||||
text!("method"),
|
||||
text!("source_identity"),
|
||||
nullable_text!("deployed_digest"),
|
||||
integer!("created_at"),
|
||||
integer!("updated_at"),
|
||||
]
|
||||
);
|
||||
|
||||
fn replace_table(
|
||||
tables: &mut Vec<&'static MigrationSourceTableSpec>,
|
||||
replacement: &'static MigrationSourceTableSpec,
|
||||
) {
|
||||
if let Some(existing) = tables
|
||||
.iter_mut()
|
||||
.find(|table| table.name == replacement.name)
|
||||
{
|
||||
*existing = replacement;
|
||||
} else {
|
||||
tables.push(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_source_specs_match_current_schema() -> Result<(), AppError> {
|
||||
if SCHEMA_VERSION != LATEST_DECLARED_SOURCE_VERSION {
|
||||
return Err(AppError::Config(format!(
|
||||
"schema version {SCHEMA_VERSION} has no reviewed MigrationSourceSpec; \
|
||||
latest declared source version is {LATEST_DECLARED_SOURCE_VERSION}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn require_supported_untrusted_restore_version(version: i32) -> Result<(), AppError> {
|
||||
ensure_source_specs_match_current_schema()?;
|
||||
if version < EARLIEST_UNTRUSTED_RESTORE_VERSION {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"备份版本过旧: source user_version={version}; untrusted restore accepts only \
|
||||
user_version {EARLIEST_UNTRUSTED_RESTORE_VERSION} or \
|
||||
{LATEST_DECLARED_SOURCE_VERSION}"
|
||||
)));
|
||||
}
|
||||
if version > LATEST_DECLARED_SOURCE_VERSION {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"restore source user_version={version} is newer than supported \
|
||||
user_version {LATEST_DECLARED_SOURCE_VERSION}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn migration_source_spec(version: i32) -> Result<MigrationSourceSpec, AppError> {
|
||||
require_supported_untrusted_restore_version(version)?;
|
||||
let mut tables = vec![
|
||||
&PROVIDERS_V2,
|
||||
&PROVIDER_ENDPOINTS_V16,
|
||||
&MCP_V15,
|
||||
&PROMPTS,
|
||||
&SKILLS_V15,
|
||||
&SKILL_REPOS,
|
||||
&SETTINGS,
|
||||
&PROXY_CONFIG_V14,
|
||||
&PROVIDER_HEALTH,
|
||||
&PROXY_LOG_V13,
|
||||
&MODEL_PRICING,
|
||||
&STREAM_CHECK_LOGS,
|
||||
&PROXY_LIVE_BACKUP,
|
||||
&USAGE_ROLLUPS_V13,
|
||||
&SESSION_LOG_SYNC,
|
||||
&PROFILES,
|
||||
];
|
||||
if version == LATEST_DECLARED_SOURCE_VERSION {
|
||||
replace_table(&mut tables, &PROVIDER_ENDPOINTS_V17);
|
||||
replace_table(&mut tables, &SKILLS_V17);
|
||||
tables.extend([&PI_PROVIDER_PROJECTIONS, &SKILL_DEPLOYMENTS]);
|
||||
}
|
||||
|
||||
tables.sort_by_key(|table| table.name);
|
||||
Ok(MigrationSourceSpec { version, tables })
|
||||
}
|
||||
|
||||
/// Materialize an exact declared source shape for migration certification.
|
||||
///
|
||||
/// This is deliberately a source-spec fixture factory, not an alias for the
|
||||
/// canonical current-schema factory. Keeping it here lets all migration tests
|
||||
/// exercise the same versioned declarations that guard production restores.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn exact_migration_source_for_test(version: i32) -> Result<Connection, AppError> {
|
||||
let spec = migration_source_spec(version)?;
|
||||
let connection = Connection::open_in_memory()?;
|
||||
for table in &spec.tables {
|
||||
let columns = table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
format!(
|
||||
"{} {}",
|
||||
quoted_identifier(column.name),
|
||||
column.declared_type
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
connection.execute(
|
||||
&format!("CREATE TABLE {} ({columns})", quoted_identifier(table.name)),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Database::set_user_version(&connection, version)?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Pi-managed provider input executed by the pinned upstream composer oracle,
|
||||
/// plus one unknown field that makes each migration-version sentinel distinct.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pinned_pi_provider_settings_for_test(version: i32) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"{{\"api\":\"google-generative-ai\",",
|
||||
"\"baseUrl\":\"https://fractional.example/v1\",",
|
||||
"\"apiKey\":\"literal-secret\",",
|
||||
"\"models\":[{{\"id\":\"fractional\",",
|
||||
"\"contextWindow\":128000.5,\"maxTokens\":16384.25,",
|
||||
"\"cost\":{{\"input\":0.125,\"output\":0.375,",
|
||||
"\"cacheRead\":0.0625,\"cacheWrite\":0.1875}}}}],",
|
||||
"\"migrationVersion\":{version}}}"
|
||||
),
|
||||
version = version
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct MigrationStructuralColumnAddition {
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
storage: MigrationStorageClass,
|
||||
nullable: bool,
|
||||
}
|
||||
|
||||
const V16_TO_V17_STRUCTURAL_COLUMNS: &[MigrationStructuralColumnAddition] = &[
|
||||
MigrationStructuralColumnAddition {
|
||||
table: "provider_endpoints",
|
||||
column: "last_used",
|
||||
storage: MigrationStorageClass::Integer,
|
||||
nullable: true,
|
||||
},
|
||||
MigrationStructuralColumnAddition {
|
||||
table: "skills",
|
||||
column: "enabled_pi",
|
||||
storage: MigrationStorageClass::Integer,
|
||||
nullable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const V16_TO_V17_EMPTY_STRUCTURAL_TABLES: &[&str] =
|
||||
&["pi_provider_projections", "skill_deployments"];
|
||||
|
||||
/// Mechanically prove that the only accepted migration consumes every v16
|
||||
/// source column and introduces no undeclared v17 shape.
|
||||
///
|
||||
/// Common columns are identity mappings: SQLite storage class must remain
|
||||
/// unchanged, and a nullable source may never map into a non-null target.
|
||||
/// Target-only columns and tables are version-owned structural additions listed
|
||||
/// above; the behavior suite separately proves their NULL/false/empty values.
|
||||
pub(crate) fn validate_migration_mapping_completeness(version: i32) -> Result<(), AppError> {
|
||||
require_supported_untrusted_restore_version(version)?;
|
||||
if version == SCHEMA_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
if version != EARLIEST_UNTRUSTED_RESTORE_VERSION {
|
||||
return Err(AppError::Config(format!(
|
||||
"untrusted migration completeness is defined only for v{}->v{}; got v{version}",
|
||||
EARLIEST_UNTRUSTED_RESTORE_VERSION, LATEST_DECLARED_SOURCE_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
let source = migration_source_spec(version)?;
|
||||
let target = migration_source_spec(version + 1)?;
|
||||
let source_tables: BTreeMap<_, _> = source
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| (table.name, *table))
|
||||
.collect();
|
||||
let target_tables: BTreeMap<_, _> = target
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| (table.name, *table))
|
||||
.collect();
|
||||
|
||||
for source_table in &source.tables {
|
||||
let target_table = target_tables.get(source_table.name).ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"v{version}->v{} drops source table {}",
|
||||
version + 1,
|
||||
source_table.name
|
||||
))
|
||||
})?;
|
||||
for source_column in source_table.columns {
|
||||
let target_column = target_table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.name == source_column.name)
|
||||
.ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"v{version}->v{} does not consume source column {}.{}",
|
||||
version + 1,
|
||||
source_table.name,
|
||||
source_column.name
|
||||
))
|
||||
})?;
|
||||
if target_column.storage != source_column.storage {
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} changes storage for identity column {}.{}",
|
||||
version + 1,
|
||||
source_table.name,
|
||||
source_column.name
|
||||
)));
|
||||
}
|
||||
if source_column.nullable && !target_column.nullable {
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} narrows NULL semantics for identity column {}.{}",
|
||||
version + 1,
|
||||
source_table.name,
|
||||
source_column.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for target_table in &target.tables {
|
||||
let Some(source_table) = source_tables.get(target_table.name) else {
|
||||
if !V16_TO_V17_EMPTY_STRUCTURAL_TABLES.contains(&target_table.name) {
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} introduces undeclared structural table {}",
|
||||
version + 1,
|
||||
target_table.name
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
for target_column in target_table.columns {
|
||||
if source_table
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == target_column.name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let addition = V16_TO_V17_STRUCTURAL_COLUMNS
|
||||
.iter()
|
||||
.find(|addition| {
|
||||
addition.table == target_table.name && addition.column == target_column.name
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"v{version}->v{} introduces undeclared structural column {}.{}",
|
||||
version + 1,
|
||||
target_table.name,
|
||||
target_column.name
|
||||
))
|
||||
})?;
|
||||
if addition.storage != target_column.storage
|
||||
|| addition.nullable != target_column.nullable
|
||||
{
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} structural column {}.{} does not match its declaration",
|
||||
version + 1,
|
||||
target_table.name,
|
||||
target_column.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for addition in V16_TO_V17_STRUCTURAL_COLUMNS {
|
||||
let source_has_column = source_tables.get(addition.table).is_some_and(|table| {
|
||||
table
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == addition.column)
|
||||
});
|
||||
let target_has_column = target_tables.get(addition.table).is_some_and(|table| {
|
||||
table.columns.iter().any(|column| {
|
||||
column.name == addition.column
|
||||
&& column.storage == addition.storage
|
||||
&& column.nullable == addition.nullable
|
||||
})
|
||||
});
|
||||
if source_has_column || !target_has_column {
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} structural column declaration is stale: {}.{}",
|
||||
version + 1,
|
||||
addition.table,
|
||||
addition.column
|
||||
)));
|
||||
}
|
||||
}
|
||||
for table in V16_TO_V17_EMPTY_STRUCTURAL_TABLES {
|
||||
if source_tables.contains_key(table) || !target_tables.contains_key(table) {
|
||||
return Err(AppError::Config(format!(
|
||||
"v{version}->v{} structural table declaration is stale: {table}",
|
||||
version + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quoted_identifier(identifier: &str) -> String {
|
||||
format!("\"{}\"", identifier.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn validate_untrusted_migration_source(conn: &Connection) -> Result<i32, AppError> {
|
||||
let version = Self::get_user_version(conn)?;
|
||||
require_supported_untrusted_restore_version(version)?;
|
||||
let spec = migration_source_spec(version)?;
|
||||
if version == EARLIEST_UNTRUSTED_RESTORE_VERSION {
|
||||
validate_migration_mapping_completeness(version)?;
|
||||
}
|
||||
Self::validate_migration_source_spec(conn, &spec)?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_migration_source_version(
|
||||
conn: &Connection,
|
||||
version: i32,
|
||||
) -> Result<(), AppError> {
|
||||
let spec = migration_source_spec(version)?;
|
||||
Self::validate_migration_source_spec(conn, &spec)
|
||||
}
|
||||
|
||||
fn validate_migration_source_spec(
|
||||
conn: &Connection,
|
||||
spec: &MigrationSourceSpec,
|
||||
) -> Result<(), AppError> {
|
||||
// Never express the internal-table boundary with SQL LIKE: `_` is a
|
||||
// wildcard there, so `sqliteX` would be mistaken for `sqlite_*`.
|
||||
// Enumerating the internal tables that this SQLite build may own keeps
|
||||
// every other table—including case variants and forged reserved names—
|
||||
// inside the exact source inventory.
|
||||
let observed_tables = conn
|
||||
.prepare(
|
||||
"SELECT name FROM main.sqlite_schema
|
||||
WHERE type = 'table'
|
||||
ORDER BY name",
|
||||
)
|
||||
.and_then(|mut statement| {
|
||||
statement
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"inspect untrusted v{} table set: {error}",
|
||||
spec.version
|
||||
))
|
||||
})?
|
||||
.into_iter()
|
||||
.filter(|name| !super::is_sqlite_internal_table_name(name))
|
||||
.collect::<Vec<_>>();
|
||||
let expected_tables: Vec<_> = spec
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| table.name.to_string())
|
||||
.collect();
|
||||
if observed_tables != expected_tables {
|
||||
let observed: BTreeSet<_> = observed_tables.iter().cloned().collect();
|
||||
let expected: BTreeSet<_> = expected_tables.iter().cloned().collect();
|
||||
let missing: Vec<_> = expected.difference(&observed).cloned().collect();
|
||||
let extra: Vec<_> = observed.difference(&expected).cloned().collect();
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"untrusted v{} source table set mismatch; missing={missing:?}, extra={extra:?}",
|
||||
spec.version
|
||||
)));
|
||||
}
|
||||
|
||||
for table in &spec.tables {
|
||||
// `table_info` omits generated and virtual-table hidden columns.
|
||||
// `table_xinfo` is required for an exact source column set; a
|
||||
// generated replacement for an expected column must not acquire
|
||||
// authority merely because its name and observed values match.
|
||||
let pragma = format!("PRAGMA main.table_xinfo({})", quoted_identifier(table.name));
|
||||
let observed_columns = conn
|
||||
.prepare(&pragma)
|
||||
.and_then(|mut statement| {
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(6)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"inspect untrusted v{} table {} columns: {error}",
|
||||
spec.version, table.name
|
||||
))
|
||||
})?;
|
||||
let observed_by_name: BTreeSet<_> = observed_columns
|
||||
.iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
let hidden_or_generated = observed_columns
|
||||
.iter()
|
||||
.filter(|(_, hidden)| *hidden != 0)
|
||||
.map(|(name, hidden)| format!("{name}:{hidden}"))
|
||||
.collect::<Vec<_>>();
|
||||
let expected_by_name: BTreeSet<_> = table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.name.to_string())
|
||||
.collect();
|
||||
if observed_by_name.len() != observed_columns.len()
|
||||
|| observed_by_name != expected_by_name
|
||||
|| !hidden_or_generated.is_empty()
|
||||
{
|
||||
let missing: Vec<_> = expected_by_name
|
||||
.difference(&observed_by_name)
|
||||
.cloned()
|
||||
.collect();
|
||||
let extra: Vec<_> = observed_by_name
|
||||
.difference(&expected_by_name)
|
||||
.cloned()
|
||||
.collect();
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"untrusted v{} source column set mismatch for {}; missing={missing:?}, \
|
||||
extra={extra:?}, generated_or_hidden={hidden_or_generated:?}",
|
||||
spec.version, table.name,
|
||||
)));
|
||||
}
|
||||
|
||||
for column in table.columns {
|
||||
let table_name = quoted_identifier(table.name);
|
||||
let column_name = quoted_identifier(column.name);
|
||||
let mismatch_predicate = if column.nullable {
|
||||
format!(
|
||||
"typeof({column_name}) NOT IN ('null', '{}')",
|
||||
column.storage.sqlite_name()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"typeof({column_name}) <> '{}'",
|
||||
column.storage.sqlite_name()
|
||||
)
|
||||
};
|
||||
let mismatch_sql = format!(
|
||||
"SELECT typeof({column_name}) FROM {table_name}
|
||||
WHERE {mismatch_predicate} LIMIT 1"
|
||||
);
|
||||
let mismatch = conn
|
||||
.query_row(&mismatch_sql, [], |row| row.get::<_, String>(0))
|
||||
.optional()
|
||||
.map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"validate untrusted v{} storage for {}.{}: {error}",
|
||||
spec.version, table.name, column.name
|
||||
))
|
||||
})?;
|
||||
if let Some(observed_storage) = mismatch {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"untrusted v{} source storage mismatch for {}.{}: expected {}{}, got {}",
|
||||
spec.version,
|
||||
table.name,
|
||||
column.name,
|
||||
column.storage.sqlite_name(),
|
||||
if column.nullable { " or null" } else { "" },
|
||||
observed_storage
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+157
-13
@@ -24,14 +24,23 @@
|
||||
//! ```
|
||||
|
||||
pub(crate) mod backup;
|
||||
#[cfg(test)]
|
||||
mod backup_restore_certification;
|
||||
#[cfg(test)]
|
||||
mod backup_restore_certification_ext;
|
||||
mod dao;
|
||||
mod migration;
|
||||
mod migration_source;
|
||||
mod schema;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::pi_projections::PiProviderProjection;
|
||||
pub use dao::provider_write::{
|
||||
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
|
||||
};
|
||||
pub(crate) use dao::providers_seed::{
|
||||
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
@@ -40,20 +49,23 @@ pub(crate) use dao::proxy::{
|
||||
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
};
|
||||
pub(crate) use dao::skill_deployments::{SkillDeployment, SkillDeploymentMethod};
|
||||
pub use dao::FailoverQueueItem;
|
||||
pub use dao::Profile;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{hooks::Action, Connection};
|
||||
use rusqlite::{hooks::Action, Connection, OpenFlags};
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 16;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 17;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -79,6 +91,85 @@ pub(crate) use lock_conn;
|
||||
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
|
||||
pub struct Database {
|
||||
pub(crate) conn: Mutex<Connection>,
|
||||
// Keep this field after `conn`: field drop order closes the writable
|
||||
// connection before releasing its process-local ownership lease.
|
||||
_live_write_lease: Option<LiveDatabaseWriteLease>,
|
||||
}
|
||||
|
||||
static LIVE_DATABASE_WRITERS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
const SQLITE_INTERNAL_TABLE_NAMES: &[&str] = &["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"];
|
||||
|
||||
pub(crate) fn is_sqlite_internal_table_name(name: &str) -> bool {
|
||||
SQLITE_INTERNAL_TABLE_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
struct LiveDatabaseWriteLease {
|
||||
identity: PathBuf,
|
||||
}
|
||||
|
||||
impl LiveDatabaseWriteLease {
|
||||
fn acquire(path: &Path) -> Result<Self, AppError> {
|
||||
let identity = live_database_identity(path)?;
|
||||
let mut writers = LIVE_DATABASE_WRITERS
|
||||
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !writers.insert(identity.clone()) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"live database already has a writable connection: {}",
|
||||
identity.display()
|
||||
)));
|
||||
}
|
||||
Ok(Self { identity })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LiveDatabaseWriteLease {
|
||||
fn drop(&mut self) {
|
||||
LIVE_DATABASE_WRITERS
|
||||
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&self.identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn live_database_identity(path: &Path) -> Result<PathBuf, AppError> {
|
||||
match std::fs::canonicalize(path) {
|
||||
Ok(identity) => Ok(identity),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(_) => {
|
||||
// Do not treat an existing broken symlink as a new
|
||||
// database path: SQLite could follow it to an alias
|
||||
// outside the lease identity selected below.
|
||||
return Err(AppError::io(path, error));
|
||||
}
|
||||
Err(metadata_error) if metadata_error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(metadata_error) => return Err(AppError::io(path, metadata_error)),
|
||||
}
|
||||
// A new database has no file identity yet. Canonicalizing the
|
||||
// already-created parent still collapses relative and symlinked
|
||||
// directory aliases before the single writable open occurs.
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"live database path has no parent: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let filename = path.file_name().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"live database path has no filename: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let parent =
|
||||
std::fs::canonicalize(parent).map_err(|error| AppError::io(parent, error))?;
|
||||
Ok(parent.join(filename))
|
||||
}
|
||||
Err(error) => Err(AppError::io(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_db_change_hook(conn: &Connection) {
|
||||
@@ -106,6 +197,7 @@ impl Database {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let live_write_lease = LiveDatabaseWriteLease::acquire(&db_path)?;
|
||||
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 启用外键约束
|
||||
@@ -121,10 +213,13 @@ impl Database {
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
_live_write_lease: Some(live_write_lease),
|
||||
};
|
||||
db.create_tables()?;
|
||||
|
||||
// Pre-migration backup: only when upgrading from an existing database
|
||||
// Pre-migration backup: only when upgrading from an existing database.
|
||||
// This must precede the current-schema factory. Otherwise a v16 file
|
||||
// would be backed up after v17-only tables had been synthesized, making
|
||||
// the nominal v16 rollback image fail its exact source specification.
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
@@ -139,6 +234,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
db.create_tables()?;
|
||||
db.apply_schema_migrations()?;
|
||||
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
@@ -177,7 +273,8 @@ impl Database {
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
Ok((version > SCHEMA_VERSION).then_some(version))
|
||||
}
|
||||
@@ -195,8 +292,14 @@ impl Database {
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
_live_write_lease: None,
|
||||
};
|
||||
db.create_tables()?;
|
||||
// Keep the test database structurally identical to a fresh production
|
||||
// database. Marking the base DDL as current without running the
|
||||
// migration chain creates a false-current schema and makes restore
|
||||
// tests certify columns that do not actually exist.
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
@@ -208,14 +311,19 @@ impl Database {
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let mut statement = conn
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'")
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
Ok(count > 0)
|
||||
let names = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
for name in names {
|
||||
let name = name.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
if !is_sqlite_internal_table_name(&name) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
||||
@@ -293,3 +401,39 @@ impl Database {
|
||||
Ok(count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Database {
|
||||
/// Test-fixture reconciliation helper. Production code cannot call this:
|
||||
/// provider writes there must choose a typed create or update operation.
|
||||
pub(crate) fn reconcile_provider_fixture(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &crate::provider::Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut input = crate::provider::ProviderMutationInput {
|
||||
id: provider.id.clone(),
|
||||
name: provider.name.clone(),
|
||||
settings_config: provider.settings_config.clone(),
|
||||
website_url: provider.website_url.clone(),
|
||||
category: provider.category.clone(),
|
||||
created_at: provider.created_at,
|
||||
sort_index: provider.sort_index,
|
||||
notes: provider.notes.clone(),
|
||||
meta: provider.meta.clone(),
|
||||
icon: provider.icon.clone(),
|
||||
icon_color: provider.icon_color.clone(),
|
||||
in_failover_queue: provider.in_failover_queue,
|
||||
};
|
||||
if self.get_provider_aggregate(app_type, &input.id)?.is_some() {
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
self.update_provider(&key, &row)
|
||||
} else {
|
||||
self.create_provider(NewProviderAggregate::from_input(app_type, input)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1868
-358
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! 数据库模块测试
|
||||
//!
|
||||
//! 包含 Schema 迁移和基本功能的测试。
|
||||
|
||||
use super::schema::MigrationRunContext;
|
||||
use super::*;
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use crate::provider::{Provider, ProviderManager};
|
||||
@@ -187,13 +190,15 @@ fn existing_skill_repo_selection_is_not_supplemented() {
|
||||
fn schema_migration_sets_user_version_when_missing() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables");
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("read version before"),
|
||||
0
|
||||
);
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migration");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("read version after"),
|
||||
@@ -204,11 +209,12 @@ fn schema_migration_sets_user_version_when_missing() {
|
||||
#[test]
|
||||
fn schema_migration_rejects_future_version() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables");
|
||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||
|
||||
let err =
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
|
||||
let err = Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect_err("should reject higher version");
|
||||
assert!(
|
||||
err.to_string().contains("数据库版本过新"),
|
||||
"unexpected error: {err}"
|
||||
@@ -223,7 +229,8 @@ fn schema_migration_adds_missing_columns_for_providers() {
|
||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||
.expect("seed old schema");
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migrations");
|
||||
|
||||
// 验证关键新增列已补齐
|
||||
for (table, column) in [
|
||||
@@ -262,7 +269,8 @@ fn schema_migration_aligns_column_defaults_and_types() {
|
||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||
.expect("seed old schema");
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migrations");
|
||||
|
||||
let is_current = get_column_info(&conn, "providers", "is_current");
|
||||
assert_eq!(is_current.r#type, "BOOLEAN");
|
||||
@@ -303,7 +311,8 @@ fn schema_migration_aligns_column_defaults_and_types() {
|
||||
#[test]
|
||||
fn schema_create_tables_include_pricing_model_columns() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables");
|
||||
|
||||
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||
assert_eq!(multiplier.r#type, "TEXT");
|
||||
@@ -352,7 +361,8 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
.expect("seed v4 schema");
|
||||
|
||||
Database::set_user_version(&conn, 4).expect("set user_version=4");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migrations");
|
||||
|
||||
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||
assert_eq!(multiplier.r#type, "TEXT");
|
||||
@@ -414,7 +424,8 @@ fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
|
||||
.expect("seed v10 rollup table");
|
||||
|
||||
Database::set_user_version(&conn, 10).expect("set user_version=10");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migrations");
|
||||
|
||||
// 新列存在且 NOT NULL DEFAULT ''
|
||||
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
|
||||
@@ -482,7 +493,8 @@ fn schema_create_tables_repairs_dev_global_profile_marker() {
|
||||
.expect("seed dev v12 shape");
|
||||
Database::set_user_version(&conn, 12).expect("set user_version=12");
|
||||
|
||||
Database::create_tables_on_conn(&conn).expect("create tables should repair marker");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables should repair marker");
|
||||
|
||||
// 全局 current 标记改名为 claude 组标记,旧 key 删除
|
||||
let claude_marker: String = conn
|
||||
@@ -503,7 +515,8 @@ fn schema_create_tables_repairs_dev_global_profile_marker() {
|
||||
assert_eq!(old_marker, 0);
|
||||
|
||||
// 修复必须幂等:再跑一遍不应破坏已迁移的标记
|
||||
Database::create_tables_on_conn(&conn).expect("repair is idempotent");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("repair is idempotent");
|
||||
let claude_marker: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'current_profile_id_claude'",
|
||||
@@ -539,7 +552,8 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
)
|
||||
.expect("seed legacy proxy_config");
|
||||
|
||||
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables should repair proxy_config");
|
||||
|
||||
assert!(
|
||||
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
|
||||
@@ -603,8 +617,10 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
.expect("seed legacy skill");
|
||||
|
||||
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables");
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("apply migrations");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("user_version after migration"),
|
||||
@@ -899,7 +915,8 @@ fn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {
|
||||
let conn = Connection::open(&path).expect("open temp db");
|
||||
conn.execute("PRAGMA auto_vacuum = NONE;", [])
|
||||
.expect("set none auto_vacuum");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
.expect("create tables");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&conn).expect("auto_vacuum before rebuild"),
|
||||
|
||||
@@ -66,6 +66,10 @@ pub struct DeepLinkImportRequest {
|
||||
/// Optional model name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Native API identifier. Pi provider links require this explicitly;
|
||||
/// cc-switch never infers a protocol from a URL or model name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<String>,
|
||||
/// Optional notes/description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ fn parse_provider_deeplink(
|
||||
|
||||
// Extract optional fields
|
||||
let model = params.get("model").cloned();
|
||||
let api = params.get("api").cloned();
|
||||
let notes = params.get("notes").cloned();
|
||||
let haiku_model = params.get("haikuModel").cloned();
|
||||
let sonnet_model = params.get("sonnetModel").cloned();
|
||||
@@ -127,6 +128,24 @@ fn parse_provider_deeplink(
|
||||
let config = params.get("config").cloned();
|
||||
let config_format = params.get("configFormat").cloned();
|
||||
let config_url = params.get("configUrl").cloned();
|
||||
if app == "pi" {
|
||||
if model.as_deref().is_none_or(|value| value.trim().is_empty()) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi provider deep links require a non-empty 'model' parameter".to_string(),
|
||||
));
|
||||
}
|
||||
if api.as_deref().is_none_or(|value| value.trim().is_empty()) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi provider deep links require an explicit non-empty 'api' parameter".to_string(),
|
||||
));
|
||||
}
|
||||
if config.is_some() || config_url.is_some() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi provider deep links use explicit endpoint/api/model fields; embedded or remote config payloads are not supported"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||
|
||||
// Extract usage script fields (v3.9+)
|
||||
@@ -153,6 +172,7 @@ fn parse_provider_deeplink(
|
||||
api_key,
|
||||
icon,
|
||||
model,
|
||||
api,
|
||||
notes,
|
||||
haiku_model,
|
||||
sonnet_model,
|
||||
@@ -190,10 +210,10 @@ fn parse_prompt_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -225,6 +245,7 @@ fn parse_prompt_deeplink(
|
||||
endpoint: None,
|
||||
api_key: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -298,6 +319,7 @@ fn parse_mcp_deeplink(
|
||||
endpoint: None,
|
||||
api_key: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -353,6 +375,7 @@ fn parse_skill_deeplink(
|
||||
endpoint: None,
|
||||
api_key: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
|
||||
@@ -109,27 +109,35 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let provider_id = provider.id.clone();
|
||||
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
// All endpoints supplied by one import request belong to the same create
|
||||
// intent. Put the non-primary endpoints into the initial aggregate so the
|
||||
// provider row and its complete endpoint set commit atomically.
|
||||
let initial_endpoints = &mut provider
|
||||
.meta
|
||||
.get_or_insert_with(ProviderMeta::default)
|
||||
.custom_endpoints;
|
||||
for endpoint in all_endpoints.iter().skip(1) {
|
||||
let normalized = endpoint.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
initial_endpoints.insert(
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to add custom endpoint '{}': {e}",
|
||||
crate::url_for_log(&normalized)
|
||||
);
|
||||
}
|
||||
crate::settings::CustomEndpoint {
|
||||
url: normalized,
|
||||
added_at: Some(timestamp),
|
||||
last_used: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderService owns the strict aggregate create.
|
||||
ProviderService::add(
|
||||
state,
|
||||
app_type.clone(),
|
||||
crate::services::provider::provider_to_mutation_input(provider),
|
||||
true,
|
||||
)?;
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -152,6 +160,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
AppType::Pi => build_pi_settings(request)?,
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -583,6 +592,45 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!(config)
|
||||
}
|
||||
|
||||
/// Pi deep links intentionally carry one explicit model, endpoint and native
|
||||
/// API identifier. Map only that closed subset; richer Pi catalogs use native
|
||||
/// inspection/import or the Pi editor. No URL/model heuristic may invent the
|
||||
/// protocol or model identity.
|
||||
fn build_pi_settings(request: &DeepLinkImportRequest) -> Result<serde_json::Value, AppError> {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
let model = request
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::InvalidInput(
|
||||
"Pi provider deep links require a non-empty model identifier".to_string(),
|
||||
)
|
||||
})?;
|
||||
let api = request
|
||||
.api
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::InvalidInput(
|
||||
"Pi provider deep links require an explicit API identifier".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json!({
|
||||
"name": request.name,
|
||||
"baseUrl": endpoint,
|
||||
"apiKey": request.api_key,
|
||||
"api": api,
|
||||
"models": [{
|
||||
"id": model,
|
||||
"name": model
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Deep link module tests
|
||||
|
||||
use super::mcp::parse_mcp_apps;
|
||||
use super::parser::parse_deeplink_url;
|
||||
use super::prompt::import_prompt_from_deeplink;
|
||||
use super::provider::parse_and_merge_config;
|
||||
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
|
||||
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||||
use super::DeepLinkImportRequest;
|
||||
use crate::AppType;
|
||||
@@ -87,6 +89,61 @@ fn test_parse_deeplink_with_notes() {
|
||||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_deeplink_requires_and_preserves_explicit_native_identity() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let request = parse_deeplink_url(
|
||||
"ccswitch://v1/import?resource=provider&app=pi&name=Pi%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=opaque-model&api=future-native-api",
|
||||
)
|
||||
.expect("parse explicit Pi provider link");
|
||||
assert_eq!(request.app.as_deref(), Some("pi"));
|
||||
assert_eq!(request.api.as_deref(), Some("future-native-api"));
|
||||
assert_eq!(request.model.as_deref(), Some("opaque-model"));
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Pi, &request).expect("build Pi provider");
|
||||
assert_eq!(
|
||||
provider.settings_config,
|
||||
serde_json::json!({
|
||||
"name": "Pi Provider",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "sk-test",
|
||||
"api": "future-native-api",
|
||||
"models": [{
|
||||
"id": "opaque-model",
|
||||
"name": "opaque-model"
|
||||
}]
|
||||
}),
|
||||
"deeplinks must not invent a model, protocol, capability, pricing, or limit field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_provider_deeplink_rejects_implicit_model_or_protocol() {
|
||||
let missing_api = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=opaque-model";
|
||||
assert!(parse_deeplink_url(missing_api)
|
||||
.expect_err("Pi api must be explicit")
|
||||
.to_string()
|
||||
.contains("'api'"));
|
||||
|
||||
let missing_model = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&api=openai-responses";
|
||||
assert!(parse_deeplink_url(missing_model)
|
||||
.expect_err("Pi model must be explicit")
|
||||
.to_string()
|
||||
.contains("'model'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_prompt_deeplink_is_accepted_by_the_shared_prompt_path() {
|
||||
let content = BASE64_STANDARD.encode("Pinned Pi AGENTS content");
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=pi&name=Pi%20AGENTS&content={content}&enabled=false"
|
||||
);
|
||||
let request = parse_deeplink_url(&url).expect("parse Pi prompt deeplink");
|
||||
assert_eq!(request.app.as_deref(), Some("pi"));
|
||||
assert_eq!(request.content.as_deref(), Some(content.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
@@ -208,6 +265,7 @@ fn test_build_gemini_provider_with_model() {
|
||||
api_key: Some("test-api-key".to_string()),
|
||||
icon: None,
|
||||
model: Some("gemini-2.0-flash".to_string()),
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -261,6 +319,7 @@ fn test_build_gemini_provider_without_model() {
|
||||
api_key: Some("test-api-key".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -307,6 +366,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -354,6 +414,7 @@ fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImpo
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -437,6 +498,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -485,6 +547,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -538,6 +601,7 @@ fn test_parse_and_merge_config_claude() {
|
||||
api_key: None,
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -661,6 +725,7 @@ fn test_parse_and_merge_config_url_override() {
|
||||
api_key: Some("sk-new".to_string()), // URL param should override
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -724,6 +789,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() {
|
||||
icon: None,
|
||||
// URL param: must win over the same key in config (haiku-from-config)
|
||||
model: Some("main-model".to_string()),
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: Some("haiku-from-url".to_string()),
|
||||
sonnet_model: None,
|
||||
@@ -779,6 +845,7 @@ fn test_build_claude_provider_without_config_unchanged() {
|
||||
api_key: Some("sk".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
api: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
@@ -952,6 +1019,39 @@ fn test_parse_multiple_endpoints_comma_separated() {
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
|
||||
let _test_home = TestHomeGuard::new();
|
||||
let request = parse_deeplink_url(
|
||||
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
|
||||
)
|
||||
.expect("parse provider deeplink");
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
|
||||
|
||||
let provider_id =
|
||||
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
|
||||
let aggregate = state
|
||||
.db
|
||||
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
|
||||
.expect("read provider aggregate")
|
||||
.expect("provider exists");
|
||||
|
||||
assert_eq!(aggregate.endpoints.len(), 2);
|
||||
assert_eq!(
|
||||
aggregate.endpoints["https://second.example.com"].url,
|
||||
"https://second.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate.endpoints["https://third.example.com"].url,
|
||||
"https://third.example.com"
|
||||
);
|
||||
assert!(aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
|
||||
@@ -9,6 +9,13 @@ pub enum AppError {
|
||||
Config(String),
|
||||
#[error("无效输入: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("未找到: {0}")]
|
||||
NotFound(String),
|
||||
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
|
||||
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
|
||||
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
|
||||
#[error("并发冲突: {0}")]
|
||||
Conflict(String),
|
||||
#[error("IO 错误: {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
|
||||
@@ -287,7 +287,8 @@ pub fn apply_proxy_takeover(
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
|
||||
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
@@ -512,8 +513,12 @@ context_window = 500000
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let direct_config = valid_env_key_config().replace(
|
||||
"api_backend = \"responses\"",
|
||||
"api_backend = \"chat_completions\"",
|
||||
);
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
&direct_config,
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
@@ -523,6 +528,7 @@ context_window = 500000
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+79
-12
@@ -25,6 +25,7 @@ mod model_capabilities;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod pi_config;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
@@ -38,12 +39,18 @@ mod tray;
|
||||
mod usage_events;
|
||||
mod usage_script;
|
||||
|
||||
#[cfg(test)]
|
||||
mod architecture_tests;
|
||||
|
||||
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::{Database, Profile};
|
||||
pub use database::{
|
||||
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
|
||||
RenameProvider,
|
||||
};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use grok_config::get_grok_config_path;
|
||||
@@ -55,7 +62,7 @@ pub use mcp::{
|
||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
|
||||
pub use services::{
|
||||
profile::{ProfilePayload, ProfileScope, ProfileService},
|
||||
provider::reapply_current_codex_official_live,
|
||||
@@ -944,6 +951,7 @@ pub fn run() {
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
crate::app_config::AppType::Hermes,
|
||||
crate::app_config::AppType::Pi,
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
@@ -1322,6 +1330,12 @@ pub fn run() {
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_pi_native_catalog,
|
||||
commands::import_pi_native_provider,
|
||||
commands::set_pi_default_model,
|
||||
commands::get_pi_native_defaults,
|
||||
commands::get_pi_session_discovery,
|
||||
commands::reset_pi_gateway_credential,
|
||||
commands::get_claude_desktop_status,
|
||||
commands::get_claude_desktop_default_routes,
|
||||
commands::import_claude_desktop_providers_from_claude,
|
||||
@@ -1406,6 +1420,14 @@ pub fn run() {
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
commands::get_pi_prompt_library_status,
|
||||
commands::reconcile_pi_prompt_library,
|
||||
commands::get_pi_prompt_file,
|
||||
commands::replace_pi_prompt_file,
|
||||
commands::delete_pi_prompt_file,
|
||||
commands::list_pi_prompt_templates,
|
||||
commands::upsert_pi_prompt_template,
|
||||
commands::delete_pi_prompt_template,
|
||||
// Profile management (项目配置方案)
|
||||
commands::list_profiles,
|
||||
commands::create_profile,
|
||||
@@ -1460,6 +1482,7 @@ pub fn run() {
|
||||
commands::restore_env_backup,
|
||||
// Skill management (v3.10.0+ unified)
|
||||
commands::get_installed_skills,
|
||||
commands::get_pi_skill_statuses,
|
||||
commands::get_skill_backups,
|
||||
commands::delete_skill_backup,
|
||||
commands::install_skill_unified,
|
||||
@@ -1825,7 +1848,11 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
}
|
||||
};
|
||||
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
|
||||
let needs_restore = has_backups || live_taken_over;
|
||||
let needs_restore = cleanup_before_exit_needed(
|
||||
has_backups,
|
||||
live_taken_over,
|
||||
crate::settings::pi_takeover_enabled(),
|
||||
);
|
||||
|
||||
if needs_restore {
|
||||
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||||
@@ -1849,6 +1876,14 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_before_exit_needed(
|
||||
has_live_backups: bool,
|
||||
legacy_live_taken_over: bool,
|
||||
pi_takeover_enabled: bool,
|
||||
) -> bool {
|
||||
has_live_backups || legacy_live_taken_over || pi_takeover_enabled
|
||||
}
|
||||
|
||||
/// 主动从系统托盘移除托盘图标。
|
||||
///
|
||||
/// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`,
|
||||
@@ -1879,7 +1914,10 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
|
||||
|
||||
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
|
||||
async fn enabled_proxy_apps_on_startup(
|
||||
db: &database::Database,
|
||||
pi_takeover_enabled: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut apps = Vec::new();
|
||||
for app_type in PROXY_STARTUP_APP_TYPES {
|
||||
if db
|
||||
@@ -1890,12 +1928,16 @@ async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static
|
||||
apps.push(app_type);
|
||||
}
|
||||
}
|
||||
if pi_takeover_enabled {
|
||||
apps.push("pi");
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
|
||||
let apps_to_restore =
|
||||
enabled_proxy_apps_on_startup(&state.db, crate::settings::pi_takeover_enabled()).await;
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -1916,7 +1958,15 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||
// Pi desired state is device-local user intent. Keep it
|
||||
// pending/degraded so a transient bind or projection failure
|
||||
// is retried on the next startup.
|
||||
if app_type == "pi" {
|
||||
continue;
|
||||
}
|
||||
// Legacy live-config apps retain their historical cleanup
|
||||
// behavior because their enabled bit also describes a live
|
||||
// file takeover, not an independent desired/operational pair.
|
||||
if let Err(clear_err) = state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(app_type, false)
|
||||
@@ -1986,6 +2036,7 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_run_legacy_migration {
|
||||
let mut legacy_migration_succeeded = true;
|
||||
for app_type in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
@@ -1999,11 +2050,14 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||
app_type.as_str()
|
||||
);
|
||||
legacy_migration_succeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
if legacy_migration_succeeded {
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2214,9 +2268,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
|
||||
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
|
||||
ExitRequestAction,
|
||||
classify_exit_request, cleanup_before_exit_needed, enabled_proxy_apps_on_startup,
|
||||
redact_url_for_log, redact_url_for_log_with_secrets, redact_url_origin_for_log,
|
||||
runtime_log_level_allows, ExitRequestAction,
|
||||
};
|
||||
use crate::database::Database;
|
||||
|
||||
@@ -2338,8 +2392,21 @@ mod tests {
|
||||
.await
|
||||
.expect("enable Grok Build proxy config");
|
||||
|
||||
let apps = enabled_proxy_apps_on_startup(&db).await;
|
||||
let apps = enabled_proxy_apps_on_startup(&db, false).await;
|
||||
|
||||
assert_eq!(apps, vec!["grokbuild"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_restore_republishes_persisted_pi_takeover() {
|
||||
let db = Database::memory().expect("initialize database");
|
||||
let apps = enabled_proxy_apps_on_startup(&db, true).await;
|
||||
assert_eq!(apps, vec!["pi"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_exit_cleanup_includes_pi_takeover_without_legacy_live_backups() {
|
||||
assert!(cleanup_before_exit_needed(false, false, true));
|
||||
assert!(!cleanup_before_exit_needed(false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
//! Credential-blind Pi native model composition.
|
||||
//!
|
||||
//! The only Pi-layer input is [`PiRawValidProvider`]. This module does not
|
||||
//! import managed DTOs or gateway families, and it never resolves credentials,
|
||||
//! environment variables, commands, files, or network resources.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{
|
||||
merge_pi_compat,
|
||||
raw_schema::{PiRawApiId, PiRawValidProvider},
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
const PROVIDER_FIELDS: &[&str] = &[
|
||||
"name",
|
||||
"baseUrl",
|
||||
"apiKey",
|
||||
"api",
|
||||
"oauth",
|
||||
"headers",
|
||||
"compat",
|
||||
"authHeader",
|
||||
"models",
|
||||
"modelOverrides",
|
||||
];
|
||||
const MODEL_FIELDS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"baseUrl",
|
||||
"api",
|
||||
"reasoning",
|
||||
"thinkingLevelMap",
|
||||
"input",
|
||||
"cost",
|
||||
"contextWindow",
|
||||
"maxTokens",
|
||||
"headers",
|
||||
"compat",
|
||||
];
|
||||
const OVERRIDE_FIELDS: &[&str] = &[
|
||||
"name",
|
||||
"reasoning",
|
||||
"thinkingLevelMap",
|
||||
"input",
|
||||
"cost",
|
||||
"contextWindow",
|
||||
"maxTokens",
|
||||
"headers",
|
||||
"compat",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PiComposerStatus {
|
||||
Composed,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PiComposerReasonCode {
|
||||
CatalogRequired,
|
||||
MissingExplicitModels,
|
||||
MissingEffectiveApi,
|
||||
MissingEffectiveEndpoint,
|
||||
NonPositiveModelLimit,
|
||||
UnrepresentableCompat,
|
||||
CompositionFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PiComposerReason {
|
||||
pub code: PiComposerReasonCode,
|
||||
pub json_pointer: String,
|
||||
}
|
||||
|
||||
/// One configured header together with the source pointer that Pi resolves.
|
||||
///
|
||||
/// `headers` remains the pinned composer's flattened observable result, while
|
||||
/// these entries retain the provider-vs-model boundary needed to reproduce
|
||||
/// the later `ModelRuntime` merge on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PiComposedHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub json_pointer: String,
|
||||
}
|
||||
|
||||
/// The lossless native result of pinned Pi composition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PiComposedNativeModel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub api: PiRawApiId,
|
||||
pub provider: String,
|
||||
pub base_url: String,
|
||||
pub reasoning: bool,
|
||||
pub thinking_level_map: Option<Value>,
|
||||
pub input: Value,
|
||||
pub cost: Value,
|
||||
pub context_window: Value,
|
||||
pub max_tokens: Value,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub provider_headers: Vec<PiComposedHeader>,
|
||||
pub model_headers: Vec<PiComposedHeader>,
|
||||
pub compat: Option<Value>,
|
||||
pub api_key: Option<String>,
|
||||
pub oauth: Option<Value>,
|
||||
pub auth_header: bool,
|
||||
pub provider_extra: BTreeMap<String, Value>,
|
||||
pub model_extra: BTreeMap<String, Value>,
|
||||
pub override_extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PiNativeComposition {
|
||||
pub status: PiComposerStatus,
|
||||
pub provider_id: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub models: Vec<PiComposedNativeModel>,
|
||||
pub ignored_override_keys: Vec<String>,
|
||||
pub reasons: Vec<PiComposerReason>,
|
||||
}
|
||||
|
||||
impl PiNativeComposition {
|
||||
pub(super) fn unavailable_without_valid_raw() -> Self {
|
||||
Self {
|
||||
status: PiComposerStatus::Unknown,
|
||||
provider_id: None,
|
||||
provider_name: None,
|
||||
provider_base_url: None,
|
||||
models: Vec::new(),
|
||||
ignored_override_keys: Vec::new(),
|
||||
reasons: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn catalog_required(pointer: &str) -> Self {
|
||||
Self {
|
||||
status: PiComposerStatus::Unknown,
|
||||
provider_id: None,
|
||||
provider_name: None,
|
||||
provider_base_url: None,
|
||||
models: Vec::new(),
|
||||
ignored_override_keys: Vec::new(),
|
||||
reasons: vec![PiComposerReason {
|
||||
code: PiComposerReasonCode::CatalogRequired,
|
||||
json_pointer: pointer.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: PiComposerStatus::Failed,
|
||||
provider_id: None,
|
||||
provider_name: None,
|
||||
provider_base_url: None,
|
||||
models: Vec::new(),
|
||||
ignored_override_keys: Vec::new(),
|
||||
reasons: vec![PiComposerReason {
|
||||
code,
|
||||
json_pointer: pointer.into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: PiComposerStatus::Unknown,
|
||||
provider_id: None,
|
||||
provider_name: None,
|
||||
provider_base_url: None,
|
||||
models: Vec::new(),
|
||||
ignored_override_keys: Vec::new(),
|
||||
reasons: vec![PiComposerReason {
|
||||
code,
|
||||
json_pointer: pointer.into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn compose_explicit_custom_catalog(
|
||||
provider_id: &str,
|
||||
provider: &PiRawValidProvider,
|
||||
) -> PiNativeComposition {
|
||||
let Some(provider_object) = provider.raw().as_object() else {
|
||||
return PiNativeComposition::failed(PiComposerReasonCode::CompositionFailed, "");
|
||||
};
|
||||
let Some(definitions) = provider_object
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|models| !models.is_empty())
|
||||
else {
|
||||
return PiNativeComposition::failed(PiComposerReasonCode::MissingExplicitModels, "/models");
|
||||
};
|
||||
|
||||
let provider_api = provider_object.get("api").and_then(Value::as_str);
|
||||
let provider_base_url = provider_object.get("baseUrl").and_then(Value::as_str);
|
||||
if provider_object.get("oauth").and_then(Value::as_str) == Some("radius")
|
||||
&& provider_base_url.is_none()
|
||||
{
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::MissingEffectiveEndpoint,
|
||||
"/baseUrl",
|
||||
);
|
||||
}
|
||||
let provider_compat = provider_object.get("compat").cloned();
|
||||
let provider_header_entries = header_entries(provider_object.get("headers"), "/headers");
|
||||
let provider_headers = provider_header_entries
|
||||
.iter()
|
||||
.map(|entry| (entry.name.clone(), entry.value.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let provider_extra = unknown_fields(provider_object, PROVIDER_FIELDS);
|
||||
let api_key = provider_object
|
||||
.get("apiKey")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let oauth = provider_object.get("oauth").cloned();
|
||||
let auth_header = provider_object
|
||||
.get("authHeader")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let overrides = provider_object
|
||||
.get("modelOverrides")
|
||||
.and_then(Value::as_object);
|
||||
|
||||
let mut models: Vec<PiComposedNativeModel> = Vec::with_capacity(definitions.len());
|
||||
for (index, definition_value) in definitions.iter().enumerate() {
|
||||
let Some(definition) = definition_value.as_object() else {
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::CompositionFailed,
|
||||
format!("/models/{index}"),
|
||||
);
|
||||
};
|
||||
let Some(id) = definition.get("id").and_then(Value::as_str) else {
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::CompositionFailed,
|
||||
format!("/models/{index}/id"),
|
||||
);
|
||||
};
|
||||
let existing_index = models.iter().position(|model| model.id == id);
|
||||
let defaults = existing_index
|
||||
.and_then(|position| models.get(position))
|
||||
.or_else(|| models.first());
|
||||
|
||||
let api_value = definition
|
||||
.get("api")
|
||||
.and_then(Value::as_str)
|
||||
.or(provider_api)
|
||||
.or_else(|| defaults.map(|model| model.api.as_str()));
|
||||
let Some(api_value) = api_value else {
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::MissingEffectiveApi,
|
||||
format!("/models/{index}/api"),
|
||||
);
|
||||
};
|
||||
let Some(api) = PiRawApiId::new(api_value) else {
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::MissingEffectiveApi,
|
||||
format!("/models/{index}/api"),
|
||||
);
|
||||
};
|
||||
|
||||
let base_url = definition
|
||||
.get("baseUrl")
|
||||
.and_then(Value::as_str)
|
||||
.or(provider_base_url)
|
||||
.or_else(|| defaults.map(|model| model.base_url.as_str()));
|
||||
let Some(base_url) = base_url.filter(|value| !value.is_empty()) else {
|
||||
return PiNativeComposition::failed(
|
||||
PiComposerReasonCode::MissingEffectiveEndpoint,
|
||||
format!("/models/{index}/baseUrl"),
|
||||
);
|
||||
};
|
||||
|
||||
for (field, code) in [
|
||||
("contextWindow", PiComposerReasonCode::NonPositiveModelLimit),
|
||||
("maxTokens", PiComposerReasonCode::NonPositiveModelLimit),
|
||||
] {
|
||||
if definition
|
||||
.get(field)
|
||||
.and_then(Value::as_f64)
|
||||
.is_some_and(|value| value <= 0.0)
|
||||
{
|
||||
return PiNativeComposition::failed(code, format!("/models/{index}/{field}"));
|
||||
}
|
||||
}
|
||||
|
||||
let compat =
|
||||
match merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()) {
|
||||
Ok(compat) => compat,
|
||||
Err(_) => {
|
||||
return PiNativeComposition::unknown(
|
||||
PiComposerReasonCode::UnrepresentableCompat,
|
||||
format!("/models/{index}/compat"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let model = PiComposedNativeModel {
|
||||
id: id.to_string(),
|
||||
name: definition
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(id)
|
||||
.to_string(),
|
||||
api,
|
||||
provider: provider_id.to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
reasoning: definition
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
thinking_level_map: definition.get("thinkingLevelMap").cloned(),
|
||||
input: definition
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(["text"])),
|
||||
cost: definition.get("cost").cloned().unwrap_or_else(default_cost),
|
||||
context_window: definition
|
||||
.get("contextWindow")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(128000)),
|
||||
max_tokens: definition
|
||||
.get("maxTokens")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(16384)),
|
||||
headers: BTreeMap::new(),
|
||||
provider_headers: provider_header_entries.clone(),
|
||||
model_headers: Vec::new(),
|
||||
compat,
|
||||
api_key: api_key.clone(),
|
||||
oauth: oauth.clone(),
|
||||
auth_header,
|
||||
provider_extra: provider_extra.clone(),
|
||||
model_extra: unknown_fields(definition, MODEL_FIELDS),
|
||||
override_extra: BTreeMap::new(),
|
||||
};
|
||||
if let Some(existing_index) = existing_index {
|
||||
models[existing_index] = model;
|
||||
} else {
|
||||
models.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
for model in &mut models {
|
||||
// Pinned Pi's rawModelHeaders uses Array.find, so duplicate model
|
||||
// definitions obtain request headers from the first definition even
|
||||
// though the later definition replaces the composed model slot.
|
||||
let (definition_index, definition) = definitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, definition)| {
|
||||
definition
|
||||
.as_object()
|
||||
.filter(|definition| {
|
||||
definition.get("id").and_then(Value::as_str) == Some(model.id.as_str())
|
||||
})
|
||||
.map(|definition| (index, definition))
|
||||
})
|
||||
.expect("raw-valid composed model has a source definition");
|
||||
let model_override =
|
||||
overrides.and_then(|overrides| overrides.get(&model.id).and_then(Value::as_object));
|
||||
|
||||
// rawModelHeaders constructs one case-sensitive JavaScript object from
|
||||
// override headers followed by the first matching model definition.
|
||||
// Exact-name replacement keeps its insertion slot; differently-cased
|
||||
// names remain distinct until ModelRuntime performs its later
|
||||
// case-insensitive HTTP merge.
|
||||
let mut model_headers = Vec::new();
|
||||
if let Some(model_override) = model_override {
|
||||
overlay_header_entries(
|
||||
&mut model_headers,
|
||||
header_entries(
|
||||
model_override.get("headers"),
|
||||
&format!("/modelOverrides/{}/headers", escape_json_pointer(&model.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
overlay_header_entries(
|
||||
&mut model_headers,
|
||||
header_entries(
|
||||
definition.get("headers"),
|
||||
&format!("/models/{definition_index}/headers"),
|
||||
),
|
||||
);
|
||||
|
||||
let mut headers = provider_headers.clone();
|
||||
for entry in &model_headers {
|
||||
headers.insert(entry.name.clone(), entry.value.clone());
|
||||
}
|
||||
model.headers = headers;
|
||||
model.model_headers = model_headers;
|
||||
|
||||
if let Some(model_override) = model_override {
|
||||
if let Some(name) = model_override.get("name").and_then(Value::as_str) {
|
||||
model.name = name.to_string();
|
||||
}
|
||||
if let Some(reasoning) = model_override.get("reasoning").and_then(Value::as_bool) {
|
||||
model.reasoning = reasoning;
|
||||
}
|
||||
if let Some(override_map) = model_override
|
||||
.get("thinkingLevelMap")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let mut merged = model
|
||||
.thinking_level_map
|
||||
.take()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
merged.extend(override_map.clone());
|
||||
model.thinking_level_map = Some(Value::Object(merged));
|
||||
}
|
||||
if let Some(input) = model_override.get("input") {
|
||||
model.input = input.clone();
|
||||
}
|
||||
if let Some(cost) = model_override.get("cost").and_then(Value::as_object) {
|
||||
model.cost = merge_cost(&model.cost, cost);
|
||||
}
|
||||
if let Some(context_window) = model_override.get("contextWindow") {
|
||||
model.context_window = context_window.clone();
|
||||
}
|
||||
if let Some(max_tokens) = model_override.get("maxTokens") {
|
||||
model.max_tokens = max_tokens.clone();
|
||||
}
|
||||
model.compat = match merge_pi_compat(
|
||||
model.compat.clone(),
|
||||
model_override.get("compat").cloned(),
|
||||
) {
|
||||
Ok(compat) => compat,
|
||||
Err(_) => {
|
||||
return PiNativeComposition::unknown(
|
||||
PiComposerReasonCode::UnrepresentableCompat,
|
||||
format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)),
|
||||
)
|
||||
}
|
||||
};
|
||||
model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS);
|
||||
}
|
||||
}
|
||||
|
||||
let model_ids = models
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let ignored_override_keys = overrides
|
||||
.into_iter()
|
||||
.flat_map(|overrides| overrides.keys())
|
||||
.filter(|model_id| !model_ids.contains(model_id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
PiNativeComposition {
|
||||
status: PiComposerStatus::Composed,
|
||||
provider_id: Some(provider_id.to_string()),
|
||||
provider_name: Some(
|
||||
provider_object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(provider_id)
|
||||
.to_string(),
|
||||
),
|
||||
provider_base_url: provider_base_url.map(ToOwned::to_owned),
|
||||
models,
|
||||
ignored_override_keys,
|
||||
reasons: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_cost() -> Value {
|
||||
json!({
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_cost(base: &Value, overlay: &Map<String, Value>) -> Value {
|
||||
let base = base.as_object();
|
||||
let mut merged = Map::new();
|
||||
for key in ["input", "output", "cacheRead", "cacheWrite", "tiers"] {
|
||||
if let Some(value) = overlay
|
||||
.get(key)
|
||||
.or_else(|| base.and_then(|base| base.get(key)))
|
||||
{
|
||||
merged.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec<PiComposedHeader> {
|
||||
value
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flat_map(|object| object.iter())
|
||||
.filter_map(|(name, value)| {
|
||||
value.as_str().map(|value| PiComposedHeader {
|
||||
name: name.clone(),
|
||||
value: value.to_string(),
|
||||
json_pointer: format!("{base_pointer}/{}", escape_json_pointer(name)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn overlay_header_entries(
|
||||
base: &mut Vec<PiComposedHeader>,
|
||||
overlay: impl IntoIterator<Item = PiComposedHeader>,
|
||||
) {
|
||||
for entry in overlay {
|
||||
if let Some(existing) = base.iter_mut().find(|existing| existing.name == entry.name) {
|
||||
*existing = entry;
|
||||
} else {
|
||||
base.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_json_pointer(segment: &str) -> String {
|
||||
segment.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
fn unknown_fields(object: &Map<String, Value>, recognized: &[&str]) -> BTreeMap<String, Value> {
|
||||
object
|
||||
.iter()
|
||||
.filter(|(key, _)| !recognized.contains(&key.as_str()))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pi_config::raw_schema::{evaluate_provider_value, PiRawValidity};
|
||||
use serde::Deserialize;
|
||||
|
||||
const COMPOSER_ORACLE_SOURCE: &str =
|
||||
include_str!("../../../tests/fixtures/pi/native-oracle/composer-oracle-v1.json");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComposerOracle {
|
||||
cases: Vec<ComposerOracleCase>,
|
||||
fail_closed_cases: Vec<FailClosedCase>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComposerOracleCase {
|
||||
id: String,
|
||||
provider_id: String,
|
||||
input: Value,
|
||||
execution: Execution,
|
||||
#[serde(default)]
|
||||
auth_execution: Option<Value>,
|
||||
#[serde(default)]
|
||||
expected: Option<Value>,
|
||||
#[serde(default)]
|
||||
expected_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Execution {
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FailClosedCase {
|
||||
id: String,
|
||||
rust_expected_status: String,
|
||||
reason_code: String,
|
||||
}
|
||||
|
||||
fn model_as_oracle_value(model: &PiComposedNativeModel) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(model.id));
|
||||
object.insert("name".into(), json!(model.name));
|
||||
object.insert("api".into(), json!(model.api.as_str()));
|
||||
object.insert("provider".into(), json!(model.provider));
|
||||
object.insert("baseUrl".into(), json!(model.base_url));
|
||||
object.insert("reasoning".into(), json!(model.reasoning));
|
||||
if let Some(thinking) = &model.thinking_level_map {
|
||||
object.insert("thinkingLevelMap".into(), thinking.clone());
|
||||
}
|
||||
object.insert("input".into(), model.input.clone());
|
||||
object.insert("cost".into(), model.cost.clone());
|
||||
object.insert("contextWindow".into(), model.context_window.clone());
|
||||
object.insert("maxTokens".into(), model.max_tokens.clone());
|
||||
object.insert("authHeader".into(), json!(model.auth_header));
|
||||
if let Some(compat) = &model.compat {
|
||||
object.insert("compat".into(), compat.clone());
|
||||
}
|
||||
if !model.headers.is_empty() {
|
||||
object.insert("headers".into(), json!(model.headers));
|
||||
}
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn provider_as_oracle_value(composition: &PiNativeComposition) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert(
|
||||
"id".into(),
|
||||
json!(composition
|
||||
.provider_id
|
||||
.as_ref()
|
||||
.expect("composed provider id")),
|
||||
);
|
||||
object.insert(
|
||||
"name".into(),
|
||||
json!(composition
|
||||
.provider_name
|
||||
.as_ref()
|
||||
.expect("composed provider name")),
|
||||
);
|
||||
if let Some(base_url) = &composition.provider_base_url {
|
||||
object.insert("baseUrl".into(), json!(base_url));
|
||||
}
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn json_numbers_equal(left: &Value, right: &Value) -> bool {
|
||||
match (left, right) {
|
||||
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
|
||||
(Value::Array(left), Value::Array(right)) => {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.zip(right)
|
||||
.all(|(left, right)| json_numbers_equal(left, right))
|
||||
}
|
||||
(Value::Object(left), Value::Object(right)) => {
|
||||
left.len() == right.len()
|
||||
&& left.iter().all(|(key, left)| {
|
||||
right
|
||||
.get(key)
|
||||
.is_some_and(|right| json_numbers_equal(left, right))
|
||||
})
|
||||
}
|
||||
_ => left == right,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_composer_matches_actual_pinned_upstream_execution() {
|
||||
let oracle: ComposerOracle =
|
||||
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
|
||||
for case in oracle.cases {
|
||||
let raw = evaluate_provider_value(&case.input);
|
||||
if case.execution.status == "error" {
|
||||
assert!(
|
||||
case.expected_error.is_some(),
|
||||
"upstream error vector '{}' records its actual error",
|
||||
case.id
|
||||
);
|
||||
match raw.validity {
|
||||
PiRawValidity::Invalid => {}
|
||||
PiRawValidity::Valid => {
|
||||
let result = compose_explicit_custom_catalog(
|
||||
&case.provider_id,
|
||||
raw.valid_provider.as_ref().expect("raw-valid provider"),
|
||||
);
|
||||
assert_eq!(
|
||||
result.status,
|
||||
PiComposerStatus::Failed,
|
||||
"raw-valid upstream error case '{}'",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
PiRawValidity::Unknown => {
|
||||
panic!("oracle case '{}' unexpectedly became Unknown", case.id)
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
assert_eq!(raw.validity, PiRawValidity::Valid, "case '{}'", case.id);
|
||||
let result = compose_explicit_custom_catalog(
|
||||
&case.provider_id,
|
||||
raw.valid_provider.as_ref().expect("raw-valid provider"),
|
||||
);
|
||||
assert_eq!(
|
||||
result.status,
|
||||
PiComposerStatus::Composed,
|
||||
"case '{}'",
|
||||
case.id
|
||||
);
|
||||
let auth_execution = case
|
||||
.auth_execution
|
||||
.as_ref()
|
||||
.expect("successful composer case records actual auth execution");
|
||||
assert_eq!(
|
||||
auth_execution.pointer("/status").and_then(Value::as_str),
|
||||
Some("success"),
|
||||
"case '{}'",
|
||||
case.id
|
||||
);
|
||||
let actual_resolved_key = auth_execution
|
||||
.pointer("/result/auth/apiKey")
|
||||
.and_then(Value::as_str)
|
||||
.expect("successful literal composer vector resolves an API key");
|
||||
assert!(
|
||||
result
|
||||
.models
|
||||
.iter()
|
||||
.all(|model| { model.api_key.as_deref() == Some(actual_resolved_key) }),
|
||||
"case '{}' preserves the same literal key that pinned Pi resolved",
|
||||
case.id
|
||||
);
|
||||
if case
|
||||
.input
|
||||
.get("authHeader")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let expected_bearer = format!("Bearer {actual_resolved_key}");
|
||||
assert_eq!(
|
||||
auth_execution
|
||||
.pointer("/result/auth/headers/Authorization")
|
||||
.and_then(Value::as_str),
|
||||
Some(expected_bearer.as_str()),
|
||||
"case '{}' uses pinned Pi authHeader behavior",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
let actual = json!({
|
||||
"provider": provider_as_oracle_value(&result),
|
||||
"models": result
|
||||
.models
|
||||
.iter()
|
||||
.map(model_as_oracle_value)
|
||||
.collect::<Vec<_>>(),
|
||||
"ignoredOverrideKeys": result.ignored_override_keys,
|
||||
});
|
||||
let expected = case.expected.expect("successful upstream expected output");
|
||||
assert!(
|
||||
json_numbers_equal(&actual, &expected),
|
||||
"oracle case '{}'\nactual: {actual:#}\nexpected: {expected:#}",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_upstream_catalog_semantics_are_explicitly_unknown() {
|
||||
let oracle: ComposerOracle =
|
||||
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
|
||||
assert_eq!(oracle.fail_closed_cases.len(), 2);
|
||||
for case in oracle.fail_closed_cases {
|
||||
assert_eq!(case.rust_expected_status, "unknown", "case '{}'", case.id);
|
||||
assert_eq!(case.reason_code, "catalog_required", "case '{}'", case.id);
|
||||
let result = PiNativeComposition::catalog_required("/models");
|
||||
assert_eq!(result.status, PiComposerStatus::Unknown);
|
||||
assert_eq!(
|
||||
result.reasons[0].code,
|
||||
PiComposerReasonCode::CatalogRequired
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_expressions_are_preserved_without_execution() {
|
||||
let value = json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://example.test/v1",
|
||||
"apiKey": "!read-secret",
|
||||
"oauth": "radius",
|
||||
"authHeader": true,
|
||||
"headers": {"x-tenant": "${TENANT}"},
|
||||
"models": [{"id": "m"}]
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"deferred",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
assert_eq!(composed.status, PiComposerStatus::Composed);
|
||||
assert_eq!(composed.models[0].api_key.as_deref(), Some("!read-secret"));
|
||||
assert_eq!(composed.models[0].oauth, Some(json!("radius")));
|
||||
assert!(composed.models[0].auth_header);
|
||||
assert_eq!(composed.models[0].headers["x-tenant"], "${TENANT}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_cost_override_reconstructs_only_known_cost_members() {
|
||||
let value = json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://cost.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 2,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0.2,
|
||||
"futureRate": 9
|
||||
}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {"cost": {"output": 3}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"cost-shape",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
assert_eq!(
|
||||
composed.models[0].cost,
|
||||
json!({
|
||||
"input": 1,
|
||||
"output": 3,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0.2
|
||||
}),
|
||||
"pinned applyModelOverride drops unknown base cost keys when an override exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_matches_pinned_composer_request_capture() {
|
||||
let value = json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://compat.example/v1",
|
||||
"apiKey": "literal",
|
||||
"compat": {
|
||||
"openRouterRouting": ["first", "second"],
|
||||
"chatTemplateKwargs": "ab",
|
||||
"baseOnly": true
|
||||
},
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"compat": {"supportsStore": true}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {
|
||||
"compat": {
|
||||
"openRouterRouting": null,
|
||||
"chatTemplateKwargs": {"named": true},
|
||||
"overlayOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"compat-spread",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
composed.models[0].compat,
|
||||
Some(json!({
|
||||
"openRouterRouting": {"0": "first", "1": "second"},
|
||||
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
|
||||
"baseOnly": true,
|
||||
"supportsStore": true,
|
||||
"overlayOnly": true
|
||||
})),
|
||||
"captured by scripts/pi-transport-capture.mjs at the pinned Pi commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_fails_closed_when_pinned_output_requires_lone_surrogates() {
|
||||
let value = json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://compat.example/v1",
|
||||
"apiKey": "literal",
|
||||
"compat": {"chatTemplateKwargs": "😀"},
|
||||
"models": [{"id": "m"}],
|
||||
"modelOverrides": {
|
||||
"m": {"compat": {"chatTemplateKwargs": {"named": true}}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composition = compose_explicit_custom_catalog(
|
||||
"compat-surrogate",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
|
||||
assert_eq!(composition.status, PiComposerStatus::Unknown);
|
||||
assert_eq!(
|
||||
composition.reasons,
|
||||
vec![PiComposerReason {
|
||||
code: PiComposerReasonCode::UnrepresentableCompat,
|
||||
json_pointer: "/modelOverrides/m/compat".to_string(),
|
||||
}],
|
||||
"capture records UTF-16 d83d/de00 as two lone-surrogate values, which \
|
||||
serde_json::Value cannot represent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_layers_retain_runtime_precedence_and_source_pointers() {
|
||||
let value = json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://headers.example",
|
||||
"apiKey": "literal",
|
||||
"headers": {"authorization": "Bearer provider"},
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"headers": {"Authorization": "Bearer model"}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {"headers": {"x-layer": "override"}}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"header-layers",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
let model = &composed.models[0];
|
||||
assert_eq!(
|
||||
model.provider_headers[0].json_pointer,
|
||||
"/headers/authorization"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.model_headers
|
||||
.iter()
|
||||
.map(|entry| (entry.name.as_str(), entry.value.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("x-layer", "override"), ("Authorization", "Bearer model")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_model_and_override_fields_are_retained_losslessly() {
|
||||
let value = json!({
|
||||
"api": "future-wire-v9",
|
||||
"baseUrl": "https://example.test/v9",
|
||||
"apiKey": "literal",
|
||||
"futureProviderShape": {
|
||||
"nested": [1, {"flag": true}]
|
||||
},
|
||||
"models": [{
|
||||
"id": "m",
|
||||
"futureModelShape": {
|
||||
"mode": "novel",
|
||||
"threshold": 0.125
|
||||
}
|
||||
}],
|
||||
"modelOverrides": {
|
||||
"m": {
|
||||
"futureOverrideShape": [
|
||||
null,
|
||||
{"preserve": "exactly"}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let raw = evaluate_provider_value(&value);
|
||||
let composed = compose_explicit_custom_catalog(
|
||||
"lossless",
|
||||
raw.valid_provider.as_ref().expect("raw-valid"),
|
||||
);
|
||||
assert_eq!(composed.status, PiComposerStatus::Composed);
|
||||
let model = &composed.models[0];
|
||||
assert_eq!(
|
||||
model.provider_extra["futureProviderShape"],
|
||||
json!({"nested": [1, {"flag": true}]})
|
||||
);
|
||||
assert_eq!(
|
||||
model.model_extra["futureModelShape"],
|
||||
json!({"mode": "novel", "threshold": 0.125})
|
||||
);
|
||||
assert_eq!(
|
||||
model.override_extra["futureOverrideShape"],
|
||||
json!([null, {"preserve": "exactly"}])
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
//! Pi Coding Agent integration boundaries.
|
||||
//!
|
||||
//! This module deliberately separates the managed control-plane model from
|
||||
//! Pi's shared files and from the proxy data plane. Callers must use the
|
||||
//! typed model resolver rather than reimplementing provider/model inheritance.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(crate) mod composer;
|
||||
pub(crate) mod document;
|
||||
pub(crate) mod gateway;
|
||||
pub(crate) mod model;
|
||||
pub(crate) mod native;
|
||||
#[cfg(test)]
|
||||
mod native_inspection_certification;
|
||||
pub(crate) mod native_settings;
|
||||
pub(crate) mod raw_schema;
|
||||
pub(crate) mod shared_file;
|
||||
|
||||
const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [
|
||||
"openRouterRouting",
|
||||
"vercelGatewayRouting",
|
||||
"chatTemplateKwargs",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PiCompatMergeError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum JavaScriptSpreadValue {
|
||||
Json(Value),
|
||||
LoneSurrogate,
|
||||
}
|
||||
|
||||
type JavaScriptSpreadMap = IndexMap<String, JavaScriptSpreadValue>;
|
||||
|
||||
/// Mirror pinned Pi's `mergeCompat` JavaScript object-spread semantics.
|
||||
///
|
||||
/// Arrays expose numeric enumerable properties, strings expose character
|
||||
/// properties, objects expose their own fields, and the remaining JSON
|
||||
/// primitives expose none. Existing key positions are retained when an
|
||||
/// overlay replaces their values, matching object spread.
|
||||
///
|
||||
/// A JavaScript string is indexed by UTF-16 code unit. Spreading an astral
|
||||
/// character therefore creates lone-surrogate string values, which cannot be
|
||||
/// represented by Rust `String` or `serde_json::Value`. That shape is rejected
|
||||
/// explicitly so callers can fail closed instead of emitting a different
|
||||
/// composed model.
|
||||
fn merge_pi_compat(
|
||||
base: Option<Value>,
|
||||
overlay: Option<Value>,
|
||||
) -> Result<Option<Value>, PiCompatMergeError> {
|
||||
let Some(overlay) = overlay else {
|
||||
return Ok(base);
|
||||
};
|
||||
if !javascript_truthy(&overlay) {
|
||||
return Ok(base);
|
||||
}
|
||||
|
||||
let mut merged = javascript_object_spread(base.as_ref());
|
||||
merged.extend(javascript_object_spread(Some(&overlay)));
|
||||
|
||||
for key in PI_COMPAT_NESTED_SPREAD_KEYS {
|
||||
let base_value = javascript_property(base.as_ref(), key);
|
||||
let overlay_value = javascript_property(Some(&overlay), key);
|
||||
if base_value.is_some_and(javascript_is_object)
|
||||
|| overlay_value.is_some_and(javascript_is_object)
|
||||
{
|
||||
let mut nested = javascript_object_spread(base_value);
|
||||
nested.extend(javascript_object_spread(overlay_value));
|
||||
merged.insert(
|
||||
key.to_string(),
|
||||
JavaScriptSpreadValue::Json(Value::Object(finish_javascript_object_spread(
|
||||
nested,
|
||||
)?)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Some(Value::Object(finish_javascript_object_spread(
|
||||
merged,
|
||||
)?)))
|
||||
}
|
||||
|
||||
fn javascript_truthy(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null | Value::Bool(false) => false,
|
||||
Value::Number(value) => value.as_f64().is_none_or(|value| value != 0.0),
|
||||
Value::String(value) => !value.is_empty(),
|
||||
Value::Bool(true) | Value::Array(_) | Value::Object(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn javascript_is_object(value: &Value) -> bool {
|
||||
matches!(value, Value::Array(_) | Value::Object(_))
|
||||
}
|
||||
|
||||
fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Value> {
|
||||
value.and_then(Value::as_object)?.get(key)
|
||||
}
|
||||
|
||||
fn javascript_object_spread(value: Option<&Value>) -> JavaScriptSpreadMap {
|
||||
match value {
|
||||
Some(Value::Object(object)) => object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), JavaScriptSpreadValue::Json(value.clone())))
|
||||
.collect(),
|
||||
Some(Value::Array(values)) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
(
|
||||
index.to_string(),
|
||||
JavaScriptSpreadValue::Json(value.clone()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::String(value)) => value
|
||||
.encode_utf16()
|
||||
.enumerate()
|
||||
.map(|(index, unit)| {
|
||||
let value = char::from_u32(u32::from(unit))
|
||||
.map(|character| {
|
||||
JavaScriptSpreadValue::Json(Value::String(character.to_string()))
|
||||
})
|
||||
.unwrap_or(JavaScriptSpreadValue::LoneSurrogate);
|
||||
(index.to_string(), value)
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::Null | Value::Bool(_) | Value::Number(_)) | None => IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_javascript_object_spread(
|
||||
spread: JavaScriptSpreadMap,
|
||||
) -> Result<Map<String, Value>, PiCompatMergeError> {
|
||||
spread
|
||||
.into_iter()
|
||||
.map(|(key, value)| match value {
|
||||
JavaScriptSpreadValue::Json(value) => Ok((key, value)),
|
||||
JavaScriptSpreadValue::LoneSurrogate => Err(PiCompatMergeError),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compat_spread_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn compat_nested_values_follow_javascript_object_spread() {
|
||||
let merged = merge_pi_compat(
|
||||
Some(json!({
|
||||
"openRouterRouting": ["first", "second"],
|
||||
"chatTemplateKwargs": "ab",
|
||||
"baseOnly": true
|
||||
})),
|
||||
Some(json!({
|
||||
"openRouterRouting": null,
|
||||
"chatTemplateKwargs": {"named": true},
|
||||
"overlayOnly": true
|
||||
})),
|
||||
)
|
||||
.expect("representable compat spread")
|
||||
.expect("truthy overlay produces an object");
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
json!({
|
||||
"openRouterRouting": {"0": "first", "1": "second"},
|
||||
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
|
||||
"baseOnly": true,
|
||||
"overlayOnly": true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_falsy_overlay_returns_base_without_spreading() {
|
||||
let base = Some(json!({"openRouterRouting": ["kept"]}));
|
||||
assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), Ok(base));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_rejects_unrepresentable_javascript_surrogates() {
|
||||
assert_eq!(
|
||||
merge_pi_compat(
|
||||
Some(json!({"chatTemplateKwargs": "😀"})),
|
||||
Some(json!({"chatTemplateKwargs": {"named": true}})),
|
||||
),
|
||||
Err(PiCompatMergeError)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_spread_checks_surrogates_after_later_properties_override_them() {
|
||||
assert_eq!(
|
||||
merge_pi_compat(
|
||||
Some(json!({"chatTemplateKwargs": "😀"})),
|
||||
Some(json!({
|
||||
"chatTemplateKwargs": {
|
||||
"0": "repaired-high",
|
||||
"1": "repaired-low",
|
||||
"named": true
|
||||
}
|
||||
})),
|
||||
),
|
||||
Ok(Some(json!({
|
||||
"chatTemplateKwargs": {
|
||||
"0": "repaired-high",
|
||||
"1": "repaired-low",
|
||||
"named": true
|
||||
}
|
||||
})))
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,834 @@
|
||||
#![cfg(test)]
|
||||
//! 只读 native inspection 契约测试。
|
||||
//!
|
||||
//! ## 目标
|
||||
//! **pinned Pi 决定什么是合法**。本仓库的 DTO 形状、网关支持范围、头部策略
|
||||
//! 都不得成为"合法性"的来源:schema 接受的,managed 不得拒绝也不得丢值;
|
||||
//! Pi 会发出的,网关不得降级;Pi 不接受的形态,我们也不假装支持。
|
||||
//!
|
||||
//! ## 六条裁决及其上游证据
|
||||
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
|
||||
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
|
||||
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
|
||||
//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover
|
||||
//! 的共同入口)都必须无损,**空容器与缺席必须保持可区分**(`{}` 之于
|
||||
//! thinkingLevelMap、`[]` 之于 cost.tiers 同理)。
|
||||
//! 据此取代两个既有测试中把收窄固化为断言的部分:
|
||||
//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与
|
||||
//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately`
|
||||
//! (授权改写、可改名;DuplicateModelId 与 composer 无损两个语义由本套件
|
||||
//! 直接接管)。若 `InvalidThinkingLevel` 变体因此不再可构造,授权移除。
|
||||
//! C2【认证头】authorization / x-api-key / x-goog-api-key 是候选认证头,
|
||||
//! 不是 protected。取值次序据 pinned SDK 与 composer 源码:authHeader 未
|
||||
//! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth →
|
||||
//! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来
|
||||
//! 优先(pinned provider-composer 在自定义头之后写入,且只写
|
||||
//! Authorization、不动 x-api-key)。**header-only 凭证对四族都不是 Pi 原生
|
||||
//! 可请求形态**:pinned `ModelRuntime.prepareRequest()` 先解析 auth,得不到
|
||||
//! AuthResult 即抛 "Provider is not configured",在合并 headers 之前返回,
|
||||
//! 而 headers 本身永不产生 AuthResult(Google adapter 更是无条件要 apiKey)。
|
||||
//! 故无 apiKey 时维持 MissingCredential 降级,但认证头本身仍不得被报为
|
||||
//! ProtectedHeader。
|
||||
//! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-`
|
||||
//! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace /
|
||||
//! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件
|
||||
//! 剥离的集合对齐。
|
||||
//! C4【deferred 值的校验时机】pinned `resolveConfigValueOrThrow()` 先执行
|
||||
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
|
||||
//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。
|
||||
//! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性
|
||||
//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。**字面量值仍在
|
||||
//! 判定期校验,且该规则对 credential 与 header 一视同仁**——判定期说
|
||||
//! "可代理"而每次物化必然失败,是判定层与执行层自相矛盾。
|
||||
//!
|
||||
//! C5【凭证种类,2026-08-02 新增,**已 request-capture 实证**】pinned
|
||||
//! Anthropic 传输层以 `apiKey.includes("sk-ant-oat")`(子串,非前缀)判定
|
||||
//! OAuth,命中则以 `Authorization: Bearer` 发送、**不发 x-api-key**,并附
|
||||
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`;**models.json
|
||||
//! 里的字面量 apiKey 同样会走该分支**;该判定**只在 Anthropic 族**,同形
|
||||
//! token 在 OpenAI 族仍按普通 Bearer 发送。因此网关不得把这类凭证当普通
|
||||
//! x-api-key 代理:字面量命中即判定期 DirectOnly 并给结构化理由(不得是
|
||||
//! MissingCredential);deferred 凭证判定期不可知,则**物化期解析出命中值
|
||||
//! 时必须失败**,绝不发出错误的认证形态。
|
||||
//! **完整 OAuth 传输(Bearer + oauth beta 值)不在前置 C 范围**——按
|
||||
//! 项目范围划分,gateway 数据面属主工程,且需要
|
||||
//! 先补 request-capture oracle。本工程只保证判定诚实、不发错凭证。
|
||||
//! C6【entry 隔离,2026-08-02 新增】pinned Pi 逐 entry 做 TypeBox 判定,
|
||||
//! 单个 entry 的取值错误(如 `contextWindow: 1e400`)只令该 entry 非法;
|
||||
//! 整文件解析失败会让合法的兄弟 entry 被连坐隐藏,违反四层判定"每个
|
||||
//! entry 独立"的核心设计。
|
||||
//!
|
||||
//! ## 实现方义务(不在本文件断言,交盲审核查)
|
||||
//! O1 `compat` 需复现 JavaScript object-spread 对嵌套值(尤其数组)的语义;
|
||||
//! O2 架构扫描器:cfg 布尔语义(`cfg(not(test))` 的生产代码必须被扫描)、
|
||||
//! 不得按 `tests/` 路径整体跳过文件、嵌套模块须继承父层归属。
|
||||
//!
|
||||
//! ## 上游实证(request-capture,2026-08-02)
|
||||
//! `scripts/pi-transport-capture.mjs` 以本地抓包端点作 baseUrl,用 pinned Pi
|
||||
//! 的 adapter 真发请求,实测矩阵(据此 C2/C5 不再是"读源码推断"):
|
||||
//! - anthropic 普通 key → `x-api-key: <key>`;
|
||||
//! - anthropic `sk-ant-oat...` → `authorization: Bearer <token>` +
|
||||
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`,**无 x-api-key**;
|
||||
//! - anthropic apiKey + 显式 `x-api-key` → 发**显式值**(显式覆盖合成);
|
||||
//! - anthropic apiKey + 显式 `authorization` → 两者**并存**
|
||||
//! (`authorization` 取显式值,`x-api-key` 取合成值);
|
||||
//! - openai responses/completions + 显式 `authorization` → 发**显式值**;
|
||||
//! - openai + `sk-ant-oat` 形状 token → 仍是普通 `Bearer`,无 OAuth 特殊处理;
|
||||
//! - openai completions + 显式 `x-api-key` → 与合成 `authorization` **并存**。
|
||||
//!
|
||||
//! ## 残余
|
||||
//! Google 族两值并存的优先级、头名大小写变体未实测;命令输出 trim 与环境模板
|
||||
//! 不 trim 的差异属数据面语义,本只读面不断言;完整 OAuth 传输实现按范围表
|
||||
//! 归主工程(harness 已就位,可直接扩为受冻结的 transport oracle);
|
||||
//! 其余按盲审 finding 处理。
|
||||
|
||||
use super::composer::compose_explicit_custom_catalog;
|
||||
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
|
||||
use super::model::{
|
||||
effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment,
|
||||
PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity,
|
||||
};
|
||||
use super::native::{inspect_pi_native_catalog, inspect_pi_native_entry};
|
||||
use super::raw_schema::evaluate_provider_value;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("workspace root")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn write_catalog(value: &Value) -> (tempfile::TempDir, PathBuf) {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("models.json");
|
||||
fs::write(&path, serde_json::to_string_pretty(value).expect("encode")).expect("write");
|
||||
(temp, path)
|
||||
}
|
||||
|
||||
fn composed_catalog(value: Value) -> super::composer::PiNativeComposition {
|
||||
let raw = evaluate_provider_value(&value);
|
||||
compose_explicit_custom_catalog(
|
||||
"candidate",
|
||||
raw.valid_provider.as_ref().expect("raw-valid input"),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_gateway_reason(
|
||||
gateway: &super::gateway::PiGatewayAssessment,
|
||||
code: PiGatewayReasonCode,
|
||||
) -> bool {
|
||||
gateway.reasons.iter().any(|reason| reason.code == code)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pinned 夹具冻结——oracle 是上游出处工件,不得为过测试再生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PINNED_FIXTURES: &[(&str, &str)] = &[
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/composer-oracle-v1.json",
|
||||
"f7e54bb84e5fd6d50e5762dc304834410fa73ef608c2f9c42475c5983f8e0cf5",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/field-coverage-v1.json",
|
||||
"b8b85e611cf1dbef86c611df185ba8ac2d64160087d0c6e47747f838a0fafe42",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/provenance-v1.json",
|
||||
"6b2f9570ecc58d54ebe3da094530fee1c8c0d4a8265fa9c3199218582cb8dbcb",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/provider-schema.snapshot.json",
|
||||
"e498c9f1b344eee1bd3c3ba74d1b648dcb835378cfad92800ec80078b825745c",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/raw-oracle-v1.json",
|
||||
"5aaa37160f96a0fe50867d900ca38c73f13aba769e156a883324368d9dbeeb9a",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/native-oracle/transport-oracle-v1.json",
|
||||
"b2c816e53b60da5cd6352d2c23934939e9f6dd0077971488fe9dd36fa723e855",
|
||||
),
|
||||
(
|
||||
"tests/fixtures/pi/module-boundaries-v1.json",
|
||||
"a69ab84fc0db323d5eb8ddc63555a9c69613dda865962f077cd8691639951b4d",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn certify_pinned_fixtures_are_frozen() {
|
||||
for (relative, expected) in PINNED_FIXTURES {
|
||||
let bytes = fs::read(repo_root().join(relative))
|
||||
.unwrap_or_else(|e| panic!("read fixture {relative}: {e}"));
|
||||
assert_eq!(
|
||||
&format!("{:x}", Sha256::digest(bytes)),
|
||||
expected,
|
||||
"pinned fixture '{relative}' drifted; fixtures are upstream provenance \
|
||||
artifacts and may only change under adjudication"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 被取代测试中必须保留的语义,由本套件直接接管
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_duplicate_model_id_rejection_is_preserved() {
|
||||
let config: PiManagedProviderConfig = serde_json::from_value(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://dup.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{"id": "same"}, {"id": "same"}]
|
||||
}))
|
||||
.expect("deserialize managed provider");
|
||||
assert_eq!(
|
||||
validate_pi_managed_provider(&config),
|
||||
Err(PiConfigError::DuplicateModelId("same".into())),
|
||||
"duplicate model ids must keep being rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certify_composer_thinking_losslessness_guard() {
|
||||
let odd_map = json!({"high": "h", "future": {"opaque": true}});
|
||||
let composition = composed_catalog(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://thinking.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{"id": "m", "thinkingLevelMap": odd_map}]
|
||||
}));
|
||||
assert_eq!(
|
||||
composition.models[0].thinking_level_map.as_ref(),
|
||||
Some(&odd_map),
|
||||
"composer keeps the raw thinkingLevelMap value verbatim"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C1:schema 合法值必须无损直到 effective 边界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_managed_losslessness_through_effective_boundary() {
|
||||
// 标准键只取 schema 允许的 string|null;额外键覆盖全部 JSON 类型。
|
||||
let model_map = json!({
|
||||
"high": "native-high",
|
||||
"medium": null,
|
||||
"future-level": "textual",
|
||||
"vendor": {"opaque": {"nested": true}},
|
||||
"budget": 42,
|
||||
"enabled": true
|
||||
});
|
||||
// 与 model_map 共有 "high",用于绑定 override 的覆盖方向。
|
||||
let override_map = json!({
|
||||
"high": "override-high",
|
||||
"low": "override-low",
|
||||
"another-future": [1, "two", null]
|
||||
});
|
||||
let cost = json!({
|
||||
"input": 1.5,
|
||||
"output": 2.5,
|
||||
"cacheRead": 0.5,
|
||||
"cacheWrite": 0.25,
|
||||
"futureRate": 9.0,
|
||||
"tiers": [{
|
||||
"inputTokensAbove": 100.0,
|
||||
"input": 1.0,
|
||||
"output": 2.0,
|
||||
"cacheRead": 0.5,
|
||||
"cacheWrite": 0.25,
|
||||
"futureTierField": "opaque"
|
||||
}]
|
||||
});
|
||||
let catalog = json!({
|
||||
"providers": {
|
||||
"thinking": {
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://thinking.example",
|
||||
"apiKey": "literal",
|
||||
"models": [
|
||||
{"id": "m", "thinkingLevelMap": model_map.clone(), "cost": cost.clone()},
|
||||
{"id": "empty-map", "thinkingLevelMap": {}},
|
||||
{"id": "absent-map"}
|
||||
],
|
||||
"modelOverrides": {"m": {"thinkingLevelMap": override_map.clone()}}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (_temp, path) = write_catalog(&catalog);
|
||||
let inspection = inspect_pi_native_entry(&path, "thinking", &BTreeMap::new())
|
||||
.expect("inspect")
|
||||
.expect("entry present");
|
||||
|
||||
assert_eq!(
|
||||
inspection.diagnostic.raw_validity,
|
||||
PiRawNativeValidity::Valid,
|
||||
"the pinned schema accepts additional thinkingLevelMap and cost members"
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.diagnostic.managed_assessment,
|
||||
PiManagedAssessment::Manageable,
|
||||
"managed must not reject what the executed pin accepts"
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.diagnostic.management_status,
|
||||
PiManagementStatus::Importable
|
||||
);
|
||||
// 以序列化后的字符串码断言,便于 InvalidThinkingLevel 变体被整体移除。
|
||||
let reasons = serde_json::to_value(&inspection.diagnostic.reasons).expect("serialize reasons");
|
||||
assert!(
|
||||
!reasons
|
||||
.as_array()
|
||||
.expect("reasons array")
|
||||
.iter()
|
||||
.any(|reason| reason["code"] == "invalid_thinking_level"),
|
||||
"no invalid_thinking_level reason may fire for schema-valid input"
|
||||
);
|
||||
|
||||
let managed = inspection.managed_config.expect("managed config");
|
||||
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/0/thinkingLevelMap"),
|
||||
Some(&model_map),
|
||||
"model thinkingLevelMap must round-trip losslessly"
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip.pointer("/modelOverrides/m/thinkingLevelMap"),
|
||||
Some(&override_map),
|
||||
"override thinkingLevelMap must round-trip losslessly"
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/0/cost"),
|
||||
Some(&cost),
|
||||
"cost and tier members must round-trip losslessly, including future keys"
|
||||
);
|
||||
// 空对象与缺席是两种原生形态,序列化必须保持可区分。
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/1/thinkingLevelMap"),
|
||||
Some(&json!({})),
|
||||
"an explicitly empty thinkingLevelMap must survive as an empty object"
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/2/thinkingLevelMap"),
|
||||
None,
|
||||
"an absent thinkingLevelMap must stay absent"
|
||||
);
|
||||
|
||||
// effective 是 projection / runtime / routing / failover 的共同入口:
|
||||
// DTO 修好后在这里二次收窄同样是丢值。
|
||||
let effective = effective_pi_model(&managed, "m").expect("effective model");
|
||||
let effective_value = serde_json::to_value(&effective).expect("serialize effective model");
|
||||
let mut merged = model_map.as_object().expect("model map").clone();
|
||||
for (key, value) in override_map.as_object().expect("override map") {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
assert_eq!(
|
||||
effective_value.pointer("/thinkingLevelMap"),
|
||||
Some(&Value::Object(merged)),
|
||||
"the effective model must carry the merged map losslessly, with override \
|
||||
entries winning on shared keys"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_value.pointer("/cost"),
|
||||
Some(&cost),
|
||||
"the effective model must not drop cost members either"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C2:候选认证头不是 protected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_auth_candidate_headers_are_not_protected() {
|
||||
// (a) Anthropic:显式 x-api-key 不得被拒,取值优先于 apiKey 合成值。
|
||||
let explicit = composed_catalog(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://anthropic.example",
|
||||
"apiKey": "synthesized-secret",
|
||||
"headers": {"x-api-key": "explicit-secret"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&explicit);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"x-api-key is candidate-auth, not protected"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
let materialized = gateway.plans[0]
|
||||
.materialize(&|_: &str| None)
|
||||
.expect("materialize literal candidate");
|
||||
assert_eq!(
|
||||
materialized.headers[&http::HeaderName::from_static("x-api-key")],
|
||||
http::HeaderValue::from_static("explicit-secret"),
|
||||
"explicit config header value takes precedence over synthesized family auth"
|
||||
);
|
||||
// 认证头永远不进 failover 协议身份。
|
||||
if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() {
|
||||
assert!(
|
||||
!protocol_headers.contains_key(http::HeaderName::from_static("x-api-key")),
|
||||
"auth headers must stay out of the failover protocol identity"
|
||||
);
|
||||
}
|
||||
|
||||
// (b) OpenAI-Responses:显式 authorization 同理。
|
||||
let bearer = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "synthesized-secret",
|
||||
"headers": {"authorization": "Bearer configured-token"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&bearer);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"authorization is candidate-auth, not protected"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
assert_eq!(
|
||||
gateway.plans[0]
|
||||
.materialize(&|_: &str| None)
|
||||
.expect("materialize")
|
||||
.headers[&http::HeaderName::from_static("authorization")],
|
||||
http::HeaderValue::from_static("Bearer configured-token")
|
||||
);
|
||||
|
||||
// (c) Google:显式认证头与 apiKey 并存,不得拒绝、不得降级
|
||||
// (取值优先级不断言——Google SDK 顺序无上游证据)。
|
||||
let google = composed_catalog(json!({
|
||||
"api": "google-generative-ai",
|
||||
"baseUrl": "https://gemini.example",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-goog-api-key": "explicit-secret"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&google);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"x-goog-api-key is candidate-auth, not protected"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C2:authHeader:true 时合成 Bearer 覆盖显式 Authorization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_auth_header_bearer_overrides_explicit_authorization() {
|
||||
let composition = composed_catalog(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://anthropic.example",
|
||||
"apiKey": "synthesized-secret",
|
||||
"authHeader": true,
|
||||
"headers": {"authorization": "Bearer explicit-token"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&composition);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::Proxyable,
|
||||
"an explicit authorization header must not downgrade an authHeader model"
|
||||
);
|
||||
let materialized = gateway.plans[0]
|
||||
.materialize(&|_: &str| None)
|
||||
.expect("materialize literal candidate");
|
||||
assert_eq!(
|
||||
materialized.headers[&http::HeaderName::from_static("authorization")],
|
||||
http::HeaderValue::from_static("Bearer synthesized-secret"),
|
||||
"with authHeader:true the synthesized Bearer wins (pinned composer writes it \
|
||||
after the explicit headers)"
|
||||
);
|
||||
assert_eq!(
|
||||
materialized.headers[&http::HeaderName::from_static("x-api-key")],
|
||||
http::HeaderValue::from_static("synthesized-secret"),
|
||||
"the Bearer step only rewrites Authorization; family auth stays synthesized"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C3:传输层与网关自有身份头
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 逐跳/传输头。末四项是合成名字:精确枚举无法覆盖,必须按 `proxy-` 前缀拒绝。
|
||||
const HOP_BY_HOP_HEADERS: &[&str] = &[
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"proxy-future-extension",
|
||||
"proxy-tenant-routing",
|
||||
"proxy-x9",
|
||||
];
|
||||
|
||||
/// Gateway/HTTP owned:proxy trace / CDN 客户端身份 / 分布式追踪。
|
||||
/// 与生产 forwarder 无条件剥离的集合对齐,两侧同进退。
|
||||
const GATEWAY_OWNED_HEADERS: &[&str] = &[
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
"x-real-ip",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray",
|
||||
"cf-visitor",
|
||||
"true-client-ip",
|
||||
"fastly-client-ip",
|
||||
"x-azure-clientip",
|
||||
"x-azure-fdid",
|
||||
"x-azure-ref",
|
||||
"akamai-origin-hop",
|
||||
"x-akamai-config-log-detail",
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
"x-trace-id",
|
||||
"x-amzn-trace-id",
|
||||
"x-b3-traceid",
|
||||
"x-b3-spanid",
|
||||
"x-b3-parentspanid",
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn certify_transport_owned_headers_stay_protected() {
|
||||
let cases = HOP_BY_HOP_HEADERS
|
||||
.iter()
|
||||
.map(|header| ("hop-by-hop", *header))
|
||||
.chain(
|
||||
GATEWAY_OWNED_HEADERS
|
||||
.iter()
|
||||
.map(|header| ("gateway-owned", *header)),
|
||||
);
|
||||
for (class, header) in cases {
|
||||
let composition = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {header: "value"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&composition);
|
||||
assert!(
|
||||
has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"{class} header '{header}' must be reported as ProtectedHeader"
|
||||
);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"{class} header '{header}' must keep the model DirectOnly"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C2:header-only 凭证四族皆非 Pi 原生可请求形态
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_header_only_credentials_stay_direct_only() {
|
||||
// pinned ModelRuntime.prepareRequest() 先解析 auth,得不到 AuthResult 即抛
|
||||
// "Provider is not configured",在合并 headers 之前返回;headers 永不产生
|
||||
// AuthResult。因此"只有认证头、无 apiKey"必须降级——但认证头本身依然是
|
||||
// candidate-auth,不得被报为 ProtectedHeader。
|
||||
for (api, header) in [
|
||||
("anthropic-messages", "x-api-key"),
|
||||
("openai-completions", "authorization"),
|
||||
("openai-responses", "authorization"),
|
||||
("google-generative-ai", "x-goog-api-key"),
|
||||
] {
|
||||
let composition = composed_catalog(json!({
|
||||
"api": api,
|
||||
"baseUrl": "https://example.test/v1",
|
||||
"headers": {header: "header-secret"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&composition);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"{api}: header-only credentials are not a requestable pinned Pi form"
|
||||
);
|
||||
assert!(
|
||||
has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
|
||||
"{api}: a missing apiKey must be reported as MissingCredential"
|
||||
);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"{api}: the auth header itself must not be reported as protected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C4:deferred 值只能在物化之后校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_deferred_header_values_are_validated_after_resolution() {
|
||||
// 原始表达式含头非法字符(非可见 ASCII),解析结果合法。pinned Pi 先执行
|
||||
// 再用结果,从不校验原始表达式,故这类配置必须被接受。
|
||||
let expression = "!echo café";
|
||||
let deferred = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": expression},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&deferred);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
|
||||
"a deferred expression must not be validated as an HTTP header value before \
|
||||
it is resolved"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
let materialized = gateway.plans[0]
|
||||
.materialize(&|value: &str| (value == expression).then(|| "resolved-secret".to_string()))
|
||||
.expect("materialize resolved candidate");
|
||||
assert_eq!(
|
||||
materialized.headers[&http::HeaderName::from_static("x-tenant")],
|
||||
http::HeaderValue::from_static("resolved-secret"),
|
||||
"the resolved value is what reaches the candidate"
|
||||
);
|
||||
|
||||
// 防过度放宽:字面量(非 deferred)含头非法字符仍必须当场拒绝。
|
||||
let literal = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": "café"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&literal);
|
||||
assert!(
|
||||
has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
|
||||
"a literal header value outside visible ASCII must still be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C5:OAuth 凭证绝不能按 x-api-key 代理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_oauth_credentials_are_never_proxied_as_api_key() {
|
||||
// 字面量命中:判定期即可知,必须 DirectOnly 并给出结构化理由——
|
||||
// 而不是宣称可代理再发出错误的认证形态。
|
||||
let literal = composed_catalog(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://anthropic.example",
|
||||
"apiKey": "sk-ant-oat01-example-token",
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&literal);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"pinned Pi sends an sk-ant-oat credential as an OAuth Bearer with oauth beta \
|
||||
headers; proxying it as x-api-key would send the wrong auth form"
|
||||
);
|
||||
assert!(
|
||||
!gateway.reasons.is_empty(),
|
||||
"the downgrade must carry a structured reason"
|
||||
);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
|
||||
"the credential is present; MissingCredential would misreport the cause"
|
||||
);
|
||||
|
||||
// deferred 凭证:判定期不可知,允许 Proxyable;但物化解析出命中值时必须
|
||||
// 失败,绝不发出错误的认证形态。
|
||||
let deferred = composed_catalog(json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://anthropic.example",
|
||||
"apiKey": "!load-token",
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&deferred);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::Proxyable,
|
||||
"a deferred credential's kind is unknowable at plan time"
|
||||
);
|
||||
assert!(
|
||||
gateway.plans[0]
|
||||
.materialize(&|_: &str| Some("sk-ant-oat01-resolved".to_string()))
|
||||
.is_err(),
|
||||
"materialising a resolved OAuth credential must fail rather than send it as \
|
||||
a plain api key"
|
||||
);
|
||||
|
||||
// 防过度收窄:普通 Anthropic key 不受影响;非 Anthropic 族不适用该判定
|
||||
// (pinned 的 includes 检查只在 Anthropic 传输层)。
|
||||
for (api, key) in [
|
||||
("anthropic-messages", "sk-ant-api03-plain"),
|
||||
("openai-responses", "sk-ant-oat01-not-anthropic"),
|
||||
] {
|
||||
let plain = composed_catalog(json!({
|
||||
"api": api,
|
||||
"baseUrl": "https://plain.example/v1",
|
||||
"apiKey": key,
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
assert_eq!(
|
||||
assess_composition(&plain).capability,
|
||||
PiGatewayCapability::Proxyable,
|
||||
"{api}: the OAuth rule must not over-reach"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C4 扩展:字面量凭证必须在判定期校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_literal_credentials_are_validated_at_plan_time() {
|
||||
// 判定期宣称"可代理"、而每次物化必然失败,是判定层与执行层自相矛盾。
|
||||
let illegal = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "café",
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&illegal);
|
||||
assert!(
|
||||
gateway.plans.is_empty() || gateway.plans[0].materialize(&|_: &str| None).is_err(),
|
||||
"sanity: this literal credential can never materialise"
|
||||
);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"a literal credential that can never materialise must not be judged proxyable"
|
||||
);
|
||||
assert!(
|
||||
!gateway.reasons.is_empty(),
|
||||
"the downgrade must carry a structured reason"
|
||||
);
|
||||
|
||||
// 对称约束:deferred 凭证仍不得因原始表达式在判定期被拒(C4)。
|
||||
let deferred = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "!echo café",
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
assert_eq!(
|
||||
assess_composition(&deferred).capability,
|
||||
PiGatewayCapability::Proxyable,
|
||||
"a deferred credential must not be validated as a header value before it is \
|
||||
resolved"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C1 扩展:空容器与缺席必须可区分
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_empty_containers_stay_distinct_from_absent() {
|
||||
let base_rates = json!({
|
||||
"input": 1.0, "output": 2.0, "cacheRead": 0.5, "cacheWrite": 0.25
|
||||
});
|
||||
let mut with_empty = base_rates.as_object().expect("rates").clone();
|
||||
with_empty.insert("tiers".into(), json!([]));
|
||||
let catalog = json!({
|
||||
"providers": {
|
||||
"tiers": {
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://tiers.example",
|
||||
"apiKey": "literal",
|
||||
"models": [
|
||||
{"id": "empty-tiers", "cost": Value::Object(with_empty)},
|
||||
{"id": "absent-tiers", "cost": base_rates.clone()}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let (_temp, path) = write_catalog(&catalog);
|
||||
let managed = inspect_pi_native_entry(&path, "tiers", &BTreeMap::new())
|
||||
.expect("inspect")
|
||||
.expect("entry present")
|
||||
.managed_config
|
||||
.expect("managed config");
|
||||
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/0/cost/tiers"),
|
||||
Some(&json!([])),
|
||||
"an explicitly empty tiers list must survive as an empty list"
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip.pointer("/models/1/cost/tiers"),
|
||||
None,
|
||||
"an absent tiers list must stay absent"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C6:单个 entry 的错误不得连坐兄弟 entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_one_bad_entry_does_not_hide_its_siblings() {
|
||||
// pinned Pi 逐 entry 判定:`contextWindow: 1e400` 只令该 entry 非法。
|
||||
// 整文件解析失败会让合法条目一并消失,破坏"每个 entry 独立"的判定设计。
|
||||
let source = r#"{
|
||||
"providers": {
|
||||
"healthy": {
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://healthy.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{"id": "m"}]
|
||||
},
|
||||
"overflow": {
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://overflow.example",
|
||||
"apiKey": "literal",
|
||||
"models": [{"id": "m", "contextWindow": 1e400}]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("models.json");
|
||||
fs::write(&path, source).expect("write");
|
||||
|
||||
let diagnostics = inspect_pi_native_catalog(&path, &BTreeMap::new())
|
||||
.expect("one malformed entry must not fail the whole catalog");
|
||||
assert_eq!(diagnostics.len(), 2, "both entries must still be reported");
|
||||
let healthy = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.provider_key == "healthy")
|
||||
.expect("healthy entry present");
|
||||
assert_eq!(
|
||||
healthy.raw_validity,
|
||||
PiRawNativeValidity::Valid,
|
||||
"a legal sibling must not be hidden by a malformed entry"
|
||||
);
|
||||
assert_eq!(healthy.management_status, PiManagementStatus::Importable);
|
||||
let overflow = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.provider_key == "overflow")
|
||||
.expect("overflow entry present");
|
||||
assert_ne!(
|
||||
overflow.raw_validity,
|
||||
PiRawNativeValidity::Valid,
|
||||
"the out-of-range contextWindow entry itself must not be judged valid"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Exact-field access to Pi's shared `settings.json`.
|
||||
//!
|
||||
//! cc-switch owns only `defaultProvider` and `defaultModel`. Every other field
|
||||
//! remains Pi/user-owned and survives each mutation unchanged.
|
||||
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use super::shared_file::{
|
||||
compare_exchange_shared_file_bytes, delete_shared_file, read_shared_file, replace_shared_file,
|
||||
SharedFileSnapshot,
|
||||
};
|
||||
|
||||
const MAX_PI_SETTINGS_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_WRITE_ATTEMPTS: usize = 3;
|
||||
static SETTINGS_WRITE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PiNativeDefaults {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PiNativeDefaultsReceipt {
|
||||
path: PathBuf,
|
||||
before: SharedFileSnapshot,
|
||||
after: SharedFileSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PiNativeDefaultsRollback {
|
||||
Restored,
|
||||
Superseded,
|
||||
}
|
||||
|
||||
impl PiNativeDefaultsReceipt {
|
||||
/// Restore the exact file revision replaced by this write. A newer Pi/user
|
||||
/// edit wins and is reported as Superseded rather than being overwritten.
|
||||
pub(crate) fn rollback(&self) -> Result<PiNativeDefaultsRollback, AppError> {
|
||||
let result = match self.before.bytes.as_deref() {
|
||||
Some(bytes) => replace_shared_file(
|
||||
&self.path,
|
||||
&self.after.revision,
|
||||
bytes,
|
||||
MAX_PI_SETTINGS_BYTES,
|
||||
None,
|
||||
"Pi settings rollback",
|
||||
)
|
||||
.map(|_| ()),
|
||||
None => delete_shared_file(
|
||||
&self.path,
|
||||
&self.after.revision,
|
||||
MAX_PI_SETTINGS_BYTES,
|
||||
"Pi settings rollback",
|
||||
)
|
||||
.map(|_| ()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(PiNativeDefaultsRollback::Restored),
|
||||
Err(AppError::Conflict(_)) => Ok(PiNativeDefaultsRollback::Superseded),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_settings_path() -> Result<PathBuf, AppError> {
|
||||
Ok(super::native::get_pi_agent_dir()?.join("settings.json"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_pi_native_defaults() -> Result<PiNativeDefaults, AppError> {
|
||||
read_pi_native_defaults_at(&get_pi_settings_path()?)
|
||||
}
|
||||
|
||||
pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result<PiNativeDefaults, AppError> {
|
||||
let document = read_settings_document(path)?;
|
||||
let root = document.as_object().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"Pi settings root must be an object: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
Ok(PiNativeDefaults {
|
||||
default_provider: optional_string(root, "defaultProvider", path)?,
|
||||
default_model: optional_string(root, "defaultModel", path)?,
|
||||
session_dir: optional_string(root, "sessionDir", path)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_pi_native_default_with_receipt(
|
||||
provider_key: &str,
|
||||
model_id: &str,
|
||||
) -> Result<PiNativeDefaultsReceipt, AppError> {
|
||||
if provider_key.trim().is_empty() || model_id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi default provider and model must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
mutate_settings_document(&get_pi_settings_path()?, |root| {
|
||||
root.insert(
|
||||
"defaultProvider".to_string(),
|
||||
Value::String(provider_key.to_string()),
|
||||
);
|
||||
root.insert(
|
||||
"defaultModel".to_string(),
|
||||
Value::String(model_id.to_string()),
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_string(
|
||||
root: &Map<String, Value>,
|
||||
key: &str,
|
||||
path: &Path,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
match root.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(value)) => Ok(Some(value.clone())),
|
||||
Some(_) => Err(AppError::Config(format!(
|
||||
"Pi settings field '{key}' must be a string: {}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_settings_document(
|
||||
path: &Path,
|
||||
mut mutator: impl FnMut(&mut Map<String, Value>) -> Result<(), AppError>,
|
||||
) -> Result<PiNativeDefaultsReceipt, AppError> {
|
||||
let _guard = SETTINGS_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("Pi settings lock is poisoned: {error}")))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?;
|
||||
}
|
||||
|
||||
for _ in 0..MAX_WRITE_ATTEMPTS {
|
||||
let before = read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?;
|
||||
let mut document = match before.bytes.as_deref() {
|
||||
Some(bytes) => {
|
||||
serde_json::from_slice(bytes).map_err(|error| AppError::json(path, error))?
|
||||
}
|
||||
None => Value::Object(Map::new()),
|
||||
};
|
||||
let root = document.as_object_mut().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"Pi settings root must be an object: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
mutator(root)?;
|
||||
let mut serialized = serde_json::to_vec_pretty(&document)
|
||||
.map_err(|source| AppError::JsonSerialize { source })?;
|
||||
serialized.push(b'\n');
|
||||
|
||||
match compare_exchange_shared_file_bytes(
|
||||
path,
|
||||
before.bytes.as_deref(),
|
||||
&serialized,
|
||||
MAX_PI_SETTINGS_BYTES,
|
||||
None,
|
||||
"Pi settings",
|
||||
) {
|
||||
Ok(after) => {
|
||||
return Ok(PiNativeDefaultsReceipt {
|
||||
path: path.to_path_buf(),
|
||||
before,
|
||||
after,
|
||||
})
|
||||
}
|
||||
Err(AppError::Conflict(_)) => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::Conflict(format!(
|
||||
"Pi settings changed concurrently too many times: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn read_settings_document(path: &Path) -> Result<Value, AppError> {
|
||||
match read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?.bytes {
|
||||
Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| AppError::json(path, error)),
|
||||
None => Ok(Value::Object(Map::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_patch_preserves_every_unowned_field() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("settings.json");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"theme": "custom",
|
||||
"packages": ["npm:foreign"],
|
||||
"sessionDir": "/tmp/pi-sessions",
|
||||
"defaultProvider": "old",
|
||||
"defaultModel": "old-model"
|
||||
}))
|
||||
.expect("serialize"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
mutate_settings_document(&path, |root| {
|
||||
root.insert("defaultProvider".into(), json!("managed"));
|
||||
root.insert("defaultModel".into(), json!("model"));
|
||||
Ok(())
|
||||
})
|
||||
.expect("mutate");
|
||||
|
||||
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
|
||||
assert_eq!(saved["theme"], "custom");
|
||||
assert_eq!(saved["packages"], json!(["npm:foreign"]));
|
||||
assert_eq!(saved["sessionDir"], "/tmp/pi-sessions");
|
||||
assert_eq!(saved["defaultProvider"], "managed");
|
||||
assert_eq!(saved["defaultModel"], "model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_rename_during_settings_patch_is_reparsed_before_retry() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("settings.json");
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{"theme":"before","defaultProvider":"old","defaultModel":"old"}"#,
|
||||
)
|
||||
.expect("seed");
|
||||
crate::pi_config::shared_file::replace_before_next_compare_exchange(
|
||||
&path,
|
||||
br#"{"theme":"external","packages":["foreign"],"defaultProvider":"old","defaultModel":"old"}"#,
|
||||
);
|
||||
|
||||
mutate_settings_document(&path, |root| {
|
||||
root.insert("defaultProvider".into(), json!("managed"));
|
||||
root.insert("defaultModel".into(), json!("model"));
|
||||
Ok(())
|
||||
})
|
||||
.expect("retry mutation");
|
||||
|
||||
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
|
||||
assert_eq!(saved["theme"], "external");
|
||||
assert_eq!(saved["packages"], json!(["foreign"]));
|
||||
assert_eq!(saved["defaultProvider"], "managed");
|
||||
assert_eq!(saved["defaultModel"], "model");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn settings_symlink_is_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let target = temp.path().join("target.json");
|
||||
let path = temp.path().join("settings.json");
|
||||
fs::write(&target, "{}").expect("target");
|
||||
symlink(&target, &path).expect("symlink");
|
||||
assert!(read_pi_native_defaults_at(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_receipt_never_overwrites_a_newer_external_default() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("settings.json");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"theme": "before",
|
||||
"defaultProvider": "old",
|
||||
"defaultModel": "old-model"
|
||||
}))
|
||||
.expect("serialize"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let receipt = mutate_settings_document(&path, |root| {
|
||||
root.insert("defaultProvider".into(), json!("attempted"));
|
||||
root.insert("defaultModel".into(), json!("attempted-model"));
|
||||
Ok(())
|
||||
})
|
||||
.expect("write attempted defaults");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"theme": "external",
|
||||
"defaultProvider": "external",
|
||||
"defaultModel": "external-model"
|
||||
}))
|
||||
.expect("serialize external"),
|
||||
)
|
||||
.expect("external write");
|
||||
|
||||
assert_eq!(
|
||||
receipt.rollback().expect("rollback decision"),
|
||||
PiNativeDefaultsRollback::Superseded
|
||||
);
|
||||
assert_eq!(
|
||||
read_pi_native_defaults_at(&path)
|
||||
.expect("live defaults")
|
||||
.default_provider
|
||||
.as_deref(),
|
||||
Some("external")
|
||||
);
|
||||
assert_eq!(
|
||||
read_settings_document(&path).expect("live document")["theme"],
|
||||
"external"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Prompt {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?,
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
@@ -33,7 +34,11 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::Pi => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,84 @@ pub struct Provider {
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
/// IPC/service input for creating or editing a provider.
|
||||
///
|
||||
/// This deliberately is not the hydrated [`Provider`] read projection. In
|
||||
/// particular, callers cannot pass a DAO aggregate back into the provider-row
|
||||
/// writer without first crossing the service boundary, where endpoint
|
||||
/// ownership is checked.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderMutationInput {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "settingsConfig")]
|
||||
pub settings_config: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "inFailoverQueue")]
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
impl From<ProviderMutationInput> for Provider {
|
||||
fn from(input: ProviderMutationInput) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
settings_config: input.settings_config,
|
||||
website_url: input.website_url,
|
||||
category: input.category,
|
||||
created_at: input.created_at,
|
||||
sort_index: input.sort_index,
|
||||
notes: input.notes,
|
||||
meta: input.meta,
|
||||
icon: input.icon,
|
||||
icon_color: input.icon_color,
|
||||
in_failover_queue: input.in_failover_queue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider row and every endpoint owned by that row.
|
||||
///
|
||||
/// SQLite stores endpoints separately from provider metadata. This aggregate
|
||||
/// is the only lossless DAO boundary; legacy `Provider` reads are projections
|
||||
/// of it for API compatibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderAggregate {
|
||||
pub provider: Provider,
|
||||
#[serde(default)]
|
||||
pub endpoints: IndexMap<String, crate::settings::CustomEndpoint>,
|
||||
}
|
||||
|
||||
impl ProviderAggregate {
|
||||
pub(crate) fn into_provider(mut self) -> Provider {
|
||||
self.provider
|
||||
.meta
|
||||
.get_or_insert_with(ProviderMeta::default)
|
||||
.custom_endpoints = self.endpoints.into_iter().collect();
|
||||
self.provider
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// 从现有ID创建供应商
|
||||
pub fn with_id(
|
||||
@@ -67,6 +145,72 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_content_fingerprint(&self) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
|
||||
match value {
|
||||
serde_json::Value::Null => hasher.update(b"n"),
|
||||
serde_json::Value::Bool(value) => {
|
||||
hasher.update(b"b");
|
||||
hasher.update([*value as u8]);
|
||||
}
|
||||
serde_json::Value::Number(value) => {
|
||||
let text = value.to_string();
|
||||
hasher.update(b"#");
|
||||
hasher.update((text.len() as u64).to_le_bytes());
|
||||
hasher.update(text.as_bytes());
|
||||
}
|
||||
serde_json::Value::String(value) => {
|
||||
hasher.update(b"s");
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
hasher.update(b"[");
|
||||
hasher.update((items.len() as u64).to_le_bytes());
|
||||
for item in items {
|
||||
hash_canonical(item, hasher);
|
||||
}
|
||||
hasher.update(b"]");
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
hasher.update(b"{");
|
||||
hasher.update((map.len() as u64).to_le_bytes());
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
hasher.update((key.len() as u64).to_le_bytes());
|
||||
hasher.update(key.as_bytes());
|
||||
hash_canonical(&map[key.as_str()], hasher);
|
||||
}
|
||||
hasher.update(b"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
|
||||
if let serde_json::Value::Object(map) = &mut meta {
|
||||
map.remove("custom_endpoints");
|
||||
map.remove("customEndpoints");
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
for part in [
|
||||
serde_json::Value::String(self.name.clone()),
|
||||
self.settings_config.clone(),
|
||||
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
|
||||
meta,
|
||||
] {
|
||||
hash_canonical(&part, &mut hasher);
|
||||
hasher.update([0u8]);
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn is_codex_oauth(&self) -> bool {
|
||||
self.provider_type() == Some("codex_oauth")
|
||||
}
|
||||
@@ -191,6 +335,10 @@ impl Provider {
|
||||
str_at(settings.get("base_url")),
|
||||
str_at(settings.get("api_key")),
|
||||
),
|
||||
AppType::Pi => (
|
||||
str_at(settings.get("baseUrl")),
|
||||
str_at(settings.get("apiKey")),
|
||||
),
|
||||
// OpenClaw (openclaw.json) flattens credentials at the top level, camelCase.
|
||||
AppType::OpenClaw => (
|
||||
str_at(settings.get("baseUrl")),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::proxy::usage::InputTokenSemantics;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 使用量解析器类型别名
|
||||
@@ -31,6 +32,8 @@ pub struct UsageParserConfig {
|
||||
pub model_extractor: StreamModelExtractor,
|
||||
/// 流式 usage 事件预过滤器
|
||||
pub stream_event_filter: Option<StreamUsageEventFilter>,
|
||||
/// Semantics of `TokenUsage::input_tokens` produced by these parsers.
|
||||
pub input_token_semantics: InputTokenSemantics,
|
||||
/// 应用类型字符串(用于日志记录)
|
||||
pub app_type_str: &'static str,
|
||||
}
|
||||
@@ -141,6 +144,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
response_parser: TokenUsage::from_claude_response,
|
||||
model_extractor: claude_model_extractor,
|
||||
stream_event_filter: Some(claude_stream_usage_event_filter),
|
||||
input_token_semantics: InputTokenSemantics::FreshExcludesCache,
|
||||
app_type_str: "claude",
|
||||
};
|
||||
|
||||
@@ -150,6 +154,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
response_parser: TokenUsage::from_openai_response,
|
||||
model_extractor: openai_model_extractor,
|
||||
stream_event_filter: Some(openai_stream_usage_event_filter),
|
||||
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
@@ -159,6 +164,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
response_parser: TokenUsage::from_codex_response_auto,
|
||||
model_extractor: codex_auto_model_extractor,
|
||||
stream_event_filter: Some(codex_stream_usage_event_filter),
|
||||
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
@@ -168,6 +174,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
response_parser: TokenUsage::from_gemini_response,
|
||||
model_extractor: gemini_model_extractor,
|
||||
stream_event_filter: Some(gemini_stream_usage_event_filter),
|
||||
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
app_type_str: "gemini",
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ use super::{
|
||||
server::ProxyState,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
usage::{parser::TokenUsage, InputTokenSemantics},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
@@ -338,6 +338,7 @@ async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) {
|
||||
&log.model,
|
||||
&log.request_model,
|
||||
&log.outbound_model,
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
log.usage,
|
||||
log.latency_ms,
|
||||
None,
|
||||
@@ -465,6 +466,7 @@ async fn handle_claude_transform(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -1133,6 +1135,7 @@ async fn handle_codex_responses_namespace_restore(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -1245,6 +1248,7 @@ async fn handle_codex_chat_to_responses_transform(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -1366,6 +1370,7 @@ async fn handle_codex_chat_to_responses_transform(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -1531,6 +1536,7 @@ async fn handle_codex_anthropic_to_responses_transform(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -1618,6 +1624,7 @@ fn build_codex_anthropic_sse_response(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -2590,6 +2597,7 @@ fn log_forward_error(
|
||||
is_streaming,
|
||||
Some(ctx.session_id.clone()),
|
||||
None,
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
) {
|
||||
log::warn!("记录失败请求日志失败: {e}");
|
||||
}
|
||||
@@ -2607,6 +2615,7 @@ async fn log_usage(
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
outbound_model: &str,
|
||||
input_token_semantics: InputTokenSemantics,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -2630,7 +2639,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
@@ -2640,6 +2649,7 @@ async fn log_usage(
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
input_token_semantics,
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
|
||||
@@ -21,6 +21,8 @@ pub(crate) mod json_canonical;
|
||||
pub mod log_codes;
|
||||
pub mod media_sanitizer;
|
||||
pub mod model_mapper;
|
||||
pub(crate) mod pi_handler;
|
||||
pub(crate) mod pi_runtime;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
pub mod response_processor;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,16 @@ impl ProviderRouter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn app_proxy_config(
|
||||
&self,
|
||||
app_type: &str,
|
||||
) -> Result<crate::proxy::types::AppProxyConfig, AppError> {
|
||||
if app_type == AppType::Pi.as_str() {
|
||||
return Ok(crate::settings::get_pi_app_proxy_config());
|
||||
}
|
||||
self.db.get_proxy_config_for_app(app_type).await
|
||||
}
|
||||
|
||||
/// 选择可用的供应商(支持故障转移)
|
||||
///
|
||||
/// 返回按优先级排序的可用供应商列表:
|
||||
@@ -40,7 +50,7 @@ impl ProviderRouter {
|
||||
let mut circuit_open_count = 0usize;
|
||||
|
||||
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
let auto_failover_enabled = match self.app_proxy_config(app_type).await {
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
Err(e) => {
|
||||
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
|
||||
@@ -132,7 +142,7 @@ impl ProviderRouter {
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. 按应用独立获取熔断器配置
|
||||
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
let failure_threshold = match self.app_proxy_config(app_type).await {
|
||||
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||
Err(_) => 5, // 默认值
|
||||
};
|
||||
@@ -251,7 +261,7 @@ impl ProviderRouter {
|
||||
let app_type = key.split(':').next().unwrap_or("claude");
|
||||
|
||||
// 按应用独立读取熔断器配置
|
||||
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
let config = match self.app_proxy_config(app_type).await {
|
||||
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
failure_threshold: app_config.circuit_failure_threshold,
|
||||
success_threshold: app_config.circuit_success_threshold,
|
||||
@@ -348,8 +358,10 @@ mod tests {
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
|
||||
@@ -374,8 +386,10 @@ mod tests {
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
@@ -407,8 +421,10 @@ mod tests {
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||
@@ -444,8 +460,10 @@ mod tests {
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
@@ -485,7 +503,8 @@ mod tests {
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
|
||||
// 启用自动故障转移
|
||||
|
||||
@@ -205,7 +205,11 @@ impl ProviderType {
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
|
||||
// Generic callers cannot infer Pi's wire family from AppType;
|
||||
// the dedicated Pi runtime routes by effective model API.
|
||||
ProviderType::Codex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +263,11 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
|
||||
// Pi requests use the dedicated per-model adapter path. Keep the
|
||||
// generic fallback deterministic for non-routing utilities.
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,11 +254,11 @@ pub async fn handle_non_streaming(
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
parser_config.input_token_semantics,
|
||||
usage,
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let model = json_value
|
||||
@@ -271,11 +271,11 @@ pub async fn handle_non_streaming(
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
parser_config.input_token_semantics,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
log::debug!(
|
||||
"[{}] 未能解析 usage 信息,跳过记录",
|
||||
@@ -291,11 +291,11 @@ pub async fn handle_non_streaming(
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
parser_config.input_token_semantics,
|
||||
TokenUsage::default(),
|
||||
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -488,6 +488,7 @@ pub(crate) fn create_usage_collector(
|
||||
let start_time = ctx.start_time;
|
||||
let stream_parser = parser_config.stream_parser;
|
||||
let model_extractor = parser_config.model_extractor;
|
||||
let input_token_semantics = parser_config.input_token_semantics;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
Some(SseUsageCollector::new(
|
||||
@@ -512,6 +513,7 @@ pub(crate) fn create_usage_collector(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
input_token_semantics,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -538,6 +540,7 @@ pub(crate) fn create_usage_collector(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
input_token_semantics,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -557,11 +560,11 @@ pub(crate) fn create_usage_collector(
|
||||
fn spawn_log_usage(
|
||||
state: &ProxyState,
|
||||
ctx: &RequestContext,
|
||||
input_token_semantics: super::usage::InputTokenSemantics,
|
||||
usage: TokenUsage,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
// Check enable_logging before spawning the log task
|
||||
if let Ok(config) = state.config.try_read() {
|
||||
@@ -591,10 +594,11 @@ fn spawn_log_usage(
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
input_token_semantics,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
is_streaming,
|
||||
false,
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
@@ -624,6 +628,7 @@ async fn log_usage_internal(
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
outbound_model: &str,
|
||||
input_token_semantics: super::usage::InputTokenSemantics,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -642,7 +647,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
log::debug!(
|
||||
@@ -661,6 +666,7 @@ async fn log_usage_internal(
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
input_token_semantics,
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
@@ -1001,6 +1007,8 @@ mod tests {
|
||||
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
|
||||
app_handle: None,
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
pi_runtime: Arc::new(crate::proxy::pi_runtime::PiRuntimeStore::default()),
|
||||
pi_server_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1072,6 +1080,7 @@ mod tests {
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"req-model",
|
||||
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
@@ -1142,6 +1151,7 @@ mod tests {
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"outbound-model",
|
||||
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
@@ -1222,6 +1232,7 @@ mod tests {
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"req-model",
|
||||
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
handlers,
|
||||
log_codes::srv as log_srv,
|
||||
pi_runtime::PiRuntimeStore,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore},
|
||||
types::*,
|
||||
@@ -48,6 +49,11 @@ pub struct ProxyState {
|
||||
pub app_handle: Option<tauri::AppHandle>,
|
||||
/// 故障转移切换管理器
|
||||
pub failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// Immutable Pi catalog publication point shared with `ProxyService`.
|
||||
pub pi_runtime: Arc<PiRuntimeStore>,
|
||||
/// Listener instance identity. A runtime built for an older listener can
|
||||
/// never admit requests through this state.
|
||||
pub pi_server_generation: u64,
|
||||
}
|
||||
|
||||
/// 代理HTTP服务器
|
||||
@@ -57,6 +63,7 @@ pub struct ProxyServer {
|
||||
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
/// 服务器任务句柄,用于等待服务器实际关闭
|
||||
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
pi_server_generation: u64,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
@@ -64,6 +71,8 @@ impl ProxyServer {
|
||||
config: ProxyConfig,
|
||||
db: Arc<Database>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
pi_runtime: Arc<PiRuntimeStore>,
|
||||
pi_server_generation: u64,
|
||||
) -> Self {
|
||||
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
|
||||
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
|
||||
@@ -81,6 +90,8 @@ impl ProxyServer {
|
||||
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
|
||||
app_handle,
|
||||
failover_manager,
|
||||
pi_runtime,
|
||||
pi_server_generation,
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -88,9 +99,14 @@ impl ProxyServer {
|
||||
state,
|
||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||
server_handle: Arc::new(RwLock::new(None)),
|
||||
pi_server_generation,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pi_server_generation(&self) -> u64 {
|
||||
self.pi_server_generation
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
|
||||
// 检查是否已在运行
|
||||
if self.shutdown_tx.read().await.is_some() {
|
||||
@@ -364,6 +380,12 @@ impl ProxyServer {
|
||||
.route("/gemini/v1beta/*path", any(handlers::handle_gemini))
|
||||
// Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口
|
||||
.route("/gemini/v1/*path", any(handlers::handle_gemini))
|
||||
// Pi native SDK requests retain their family-specific path below
|
||||
// the opaque provider route token.
|
||||
.route(
|
||||
"/pi/:route_token/*path",
|
||||
any(super::pi_handler::handle_pi_native),
|
||||
)
|
||||
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||
.with_state(self.state.clone())
|
||||
|
||||
+130
-10
@@ -7,6 +7,7 @@
|
||||
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
|
||||
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
|
||||
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取
|
||||
//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取
|
||||
//! - 其他: 生成新的 UUID
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
@@ -23,7 +24,7 @@ pub enum SessionIdSource {
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取 (Codex)
|
||||
/// 从 headers 提取
|
||||
Header,
|
||||
/// 新生成
|
||||
Generated,
|
||||
@@ -56,6 +57,11 @@ pub struct SessionIdResult {
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Grok Build 请求
|
||||
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
@@ -73,9 +79,15 @@ pub fn extract_session_id(
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
// Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
|
||||
// 但保留独立前缀,避免统计和缓存键跨应用碰撞。
|
||||
if matches!(client_format, "codex" | "openai" | "grokbuild") {
|
||||
let prefix = if client_format == "grokbuild" {
|
||||
"grokbuild"
|
||||
} else {
|
||||
"codex"
|
||||
};
|
||||
if let Some(result) = extract_responses_session(headers, body, prefix) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -111,16 +123,28 @@ fn extract_claude_session(
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 提取 Responses 客户端的 Session ID
|
||||
fn extract_responses_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
prefix: &str,
|
||||
) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
let header_names: &[&str] = if prefix == "grokbuild" {
|
||||
// Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少
|
||||
// conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。
|
||||
&["x-grok-conv-id", "x-grok-session-id"]
|
||||
} else {
|
||||
&["session_id", "x-session-id"]
|
||||
};
|
||||
for header_name in header_names {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
let session_id = session_id.trim();
|
||||
// Responses 客户端的 Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -135,9 +159,10 @@ fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Optio
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let session_id = session_id.trim();
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -302,6 +327,101 @@ mod tests {
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_keeps_existing_response_session_headers() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for header_name in ["session_id", "x-session-id"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header_name,
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "codex");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"codex_d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_prefers_conversation_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-conv-id",
|
||||
"conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_falls_back_to_session_header() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for conversation_id in ["", " "] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-grok-conv-id", conversation_id.parse().unwrap());
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_ignores_request_and_codex_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-req-id",
|
||||
"request-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-session-id",
|
||||
"codex-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(result.source, SessionIdSource::Generated);
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_generates_new_when_not_found() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -4,15 +4,32 @@
|
||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||
|
||||
type PerAppLocks = Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>;
|
||||
|
||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||
///
|
||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
pub struct SwitchLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
locks: PerAppLocks,
|
||||
}
|
||||
|
||||
impl Default for SwitchLockManager {
|
||||
fn default() -> Self {
|
||||
// Some commands construct a short-lived AppState around the shared
|
||||
// database before running a blocking sync. A per-ProxyService map
|
||||
// would give those paths a different lock and defeat serialization
|
||||
// with provider rename/switch operations in the primary AppState.
|
||||
static LOCKS: OnceLock<PerAppLocks> = OnceLock::new();
|
||||
Self {
|
||||
locks: LOCKS
|
||||
.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SwitchLockManager {
|
||||
|
||||
@@ -116,6 +116,17 @@ pub struct ProxyTakeoverStatus {
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
pub pi: bool,
|
||||
pub pi_operational_state: PiTakeoverOperationalState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PiTakeoverOperationalState {
|
||||
#[default]
|
||||
Disabled,
|
||||
Active,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
/// Provider健康状态
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 使用高精度 Decimal 类型避免浮点数精度问题
|
||||
|
||||
use super::parser::TokenUsage;
|
||||
use super::semantics::InputTokenSemantics;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -46,13 +47,17 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false)
|
||||
Self::calculate_with_input_semantics(
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
pricing,
|
||||
cost_multiplier,
|
||||
)
|
||||
}
|
||||
|
||||
/// 按 app_type 选择输入 token 语义后计算成本。
|
||||
///
|
||||
/// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分;
|
||||
/// Claude/Anthropic 的 input_tokens 已经是 fresh input。
|
||||
/// Compatibility helper for existing callers. Live request paths use
|
||||
/// [`Self::calculate_with_input_semantics`] so product app ownership never
|
||||
/// stands in for the actual response parser/wire family.
|
||||
pub fn calculate_for_app(
|
||||
app_type: &str,
|
||||
usage: &TokenUsage,
|
||||
@@ -61,32 +66,37 @@ impl CostCalculator {
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read =
|
||||
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
|
||||
Self::calculate_with_cache_semantics(
|
||||
Self::calculate_with_input_semantics(
|
||||
if input_includes_cache_read {
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets
|
||||
} else {
|
||||
InputTokenSemantics::FreshExcludesCache
|
||||
},
|
||||
usage,
|
||||
pricing,
|
||||
cost_multiplier,
|
||||
input_includes_cache_read,
|
||||
)
|
||||
}
|
||||
|
||||
fn calculate_with_cache_semantics(
|
||||
pub fn calculate_with_input_semantics(
|
||||
input_semantics: InputTokenSemantics,
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
input_includes_cache_read: bool,
|
||||
) -> CostBreakdown {
|
||||
let million = Decimal::from(1_000_000);
|
||||
|
||||
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
|
||||
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
|
||||
let billable_input_tokens = if input_includes_cache_read {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_sub(usage.cache_read_tokens)
|
||||
.saturating_sub(usage.cache_creation_tokens)
|
||||
} else {
|
||||
usage.input_tokens
|
||||
};
|
||||
let billable_input_tokens =
|
||||
if input_semantics == InputTokenSemantics::TotalIncludesCacheBuckets {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_sub(usage.cache_read_tokens)
|
||||
.saturating_sub(usage.cache_creation_tokens)
|
||||
} else {
|
||||
usage.input_tokens
|
||||
};
|
||||
|
||||
// 各项基础成本(不含倍率)
|
||||
let input_cost =
|
||||
@@ -112,13 +122,15 @@ impl CostCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_calculate_for_app(
|
||||
app_type: &str,
|
||||
pub fn try_calculate_with_input_semantics(
|
||||
input_semantics: InputTokenSemantics,
|
||||
usage: &TokenUsage,
|
||||
pricing: Option<&ModelPricing>,
|
||||
cost_multiplier: Decimal,
|
||||
) -> Option<CostBreakdown> {
|
||||
pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier))
|
||||
pricing.map(|pricing| {
|
||||
Self::calculate_with_input_semantics(input_semantics, usage, pricing, cost_multiplier)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
use super::parser::TokenUsage;
|
||||
use super::semantics::InputTokenSemantics;
|
||||
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
|
||||
use crate::error::AppError;
|
||||
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
|
||||
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
|
||||
use rusqlite::OptionalExtension;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -72,6 +72,9 @@ pub struct RequestLog {
|
||||
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
|
||||
/// 错误行(未计价)为空字符串。
|
||||
pub pricing_model: String,
|
||||
/// Copied from the response parser/wire family at request admission.
|
||||
/// Product app ownership is intentionally not consulted at write time.
|
||||
pub input_token_semantics: InputTokenSemantics,
|
||||
pub usage: TokenUsage,
|
||||
pub cost: Option<CostBreakdown>,
|
||||
pub latency_ms: u64,
|
||||
@@ -121,12 +124,7 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics =
|
||||
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let input_token_semantics = log.input_token_semantics.stored_value();
|
||||
let semantic = UsageSemantic::from_log(log, input_token_semantics);
|
||||
let existing = Self::load_existing_semantic(&conn, &log.request_id)?;
|
||||
|
||||
@@ -266,6 +264,7 @@ impl<'a> UsageLogger<'a> {
|
||||
status_code: u16,
|
||||
error_message: String,
|
||||
latency_ms: u64,
|
||||
input_token_semantics: InputTokenSemantics,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
@@ -276,6 +275,7 @@ impl<'a> UsageLogger<'a> {
|
||||
request_model,
|
||||
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||
pricing_model: String::new(),
|
||||
input_token_semantics,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -307,6 +307,7 @@ impl<'a> UsageLogger<'a> {
|
||||
is_streaming: bool,
|
||||
session_id: Option<String>,
|
||||
provider_type: Option<String>,
|
||||
input_token_semantics: InputTokenSemantics,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
@@ -317,6 +318,7 @@ impl<'a> UsageLogger<'a> {
|
||||
request_model,
|
||||
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||
pricing_model: String::new(),
|
||||
input_token_semantics,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -451,6 +453,7 @@ impl<'a> UsageLogger<'a> {
|
||||
model: String,
|
||||
request_model: String,
|
||||
pricing_model: String,
|
||||
input_token_semantics: InputTokenSemantics,
|
||||
usage: TokenUsage,
|
||||
cost_multiplier: Decimal,
|
||||
latency_ms: u64,
|
||||
@@ -471,8 +474,8 @@ impl<'a> UsageLogger<'a> {
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate_for_app(
|
||||
&app_type,
|
||||
let cost = CostCalculator::try_calculate_with_input_semantics(
|
||||
input_token_semantics,
|
||||
&usage,
|
||||
pricing.as_ref(),
|
||||
cost_multiplier,
|
||||
@@ -485,6 +488,7 @@ impl<'a> UsageLogger<'a> {
|
||||
model,
|
||||
request_model,
|
||||
pricing_model,
|
||||
input_token_semantics,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
@@ -513,6 +517,7 @@ mod tests {
|
||||
model: "gpt-5.6".to_string(),
|
||||
request_model: "gpt-5.6".to_string(),
|
||||
pricing_model: "gpt-5.6".to_string(),
|
||||
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage: TokenUsage {
|
||||
input_tokens,
|
||||
output_tokens: 5,
|
||||
@@ -566,6 +571,7 @@ mod tests {
|
||||
"test-model".to_string(),
|
||||
"req-model".to_string(),
|
||||
"test-model".to_string(),
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
Decimal::from(1),
|
||||
100,
|
||||
@@ -687,6 +693,57 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
latency_ms, status_code, created_at, data_source
|
||||
) VALUES ('session:msg_desktop', '_session', 'claude',
|
||||
'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 2,
|
||||
cache_creation_tokens: 1,
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
message_id: Some("msg_desktop".to_string()),
|
||||
};
|
||||
let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app(
|
||||
"claude-desktop",
|
||||
"desktop-provider",
|
||||
));
|
||||
let mut proxy_log = request_log(&request_id, 10);
|
||||
proxy_log.provider_id = "desktop-provider".to_string();
|
||||
proxy_log.app_type = "claude-desktop".to_string();
|
||||
proxy_log.model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.request_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.pricing_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.usage = usage;
|
||||
|
||||
UsageLogger::new(&db).log_request(&proxy_log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, source, app_type): (i64, String, String) = conn.query_row(
|
||||
"SELECT COUNT(*), data_source, app_type FROM proxy_request_logs
|
||||
WHERE request_id = 'session:msg_desktop'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(source, "proxy");
|
||||
assert_eq!(app_type, "claude-desktop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -700,6 +757,7 @@ mod tests {
|
||||
500,
|
||||
"Internal Server Error".to_string(),
|
||||
50,
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
)?;
|
||||
|
||||
// 验证错误记录已插入
|
||||
@@ -727,6 +785,7 @@ mod tests {
|
||||
model: "grok-4.5".to_string(),
|
||||
request_model: "grok-4.5".to_string(),
|
||||
pricing_model: String::new(),
|
||||
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms: 1,
|
||||
@@ -747,7 +806,10 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
assert_eq!(
|
||||
semantics,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets.stored_value()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod calculator;
|
||||
pub mod logger;
|
||||
pub mod parser;
|
||||
pub mod semantics;
|
||||
|
||||
// 仅导出内部使用的类型,避免未使用警告
|
||||
#[allow(unused_imports)]
|
||||
@@ -13,3 +14,5 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
pub use logger::{RequestLog, UsageLogger};
|
||||
#[allow(unused_imports)]
|
||||
pub use parser::TokenUsage;
|
||||
#[allow(unused_imports)]
|
||||
pub use semantics::InputTokenSemantics;
|
||||
|
||||
@@ -30,6 +30,16 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Claude Code and Claude Desktop share Claude message ids with the session
|
||||
/// importer, so both use the bare `session:{message_id}` namespace. Other
|
||||
/// apps retain app/provider scoping to avoid collisions between upstreams.
|
||||
pub fn dedup_scope_for_app<'a>(
|
||||
app_type: &'a str,
|
||||
provider_id: &'a str,
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
(!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id))
|
||||
}
|
||||
|
||||
fn response_id(body: &Value, field: &str) -> Option<String> {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
@@ -479,6 +489,25 @@ mod tests {
|
||||
.starts_with("session:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_apps_share_the_session_request_id_namespace() {
|
||||
let usage = TokenUsage {
|
||||
message_id: Some("msg_123".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for app_type in ["claude", "claude-desktop"] {
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")),
|
||||
"session:msg_123"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")),
|
||||
"session:codex:provider-a:msg_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parsers_recover_ids_from_envelope_chunks() {
|
||||
let openai = vec![
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Input-token semantics carried with every live proxy request.
|
||||
//!
|
||||
//! `app_type` is a product/UI ownership dimension. It must not decide whether
|
||||
//! an upstream's input count already contains cache buckets: Pi can route the
|
||||
//! same logical app through four different wire families.
|
||||
|
||||
use crate::pi_config::gateway::PiGatewayApiFamily;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
pub enum InputTokenSemantics {
|
||||
/// OpenAI Responses/Completions and Google usage totals include cached
|
||||
/// input. Fresh input is total minus the reported cache buckets.
|
||||
TotalIncludesCacheBuckets = 1,
|
||||
/// Anthropic reports fresh input separately from cache reads/creation.
|
||||
FreshExcludesCache = 2,
|
||||
}
|
||||
|
||||
impl InputTokenSemantics {
|
||||
pub const fn stored_value(self) -> i64 {
|
||||
self as i64
|
||||
}
|
||||
|
||||
pub const fn for_pi_family(family: PiGatewayApiFamily) -> Self {
|
||||
match family {
|
||||
PiGatewayApiFamily::AnthropicMessages => Self::FreshExcludesCache,
|
||||
PiGatewayApiFamily::OpenAiCompletions
|
||||
| PiGatewayApiFamily::OpenAiResponses
|
||||
| PiGatewayApiFamily::GoogleGenerativeAi => Self::TotalIncludesCacheBuckets,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,10 @@ impl ConfigService {
|
||||
AppType::Hermes => {
|
||||
// Hermes uses additive mode, no live sync needed
|
||||
}
|
||||
AppType::Pi => {
|
||||
// Pi's shared models/settings documents are owned by the
|
||||
// catalog coordinator, never by this legacy live-sync path.
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -31,25 +31,46 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnvKeyword {
|
||||
Exact(&'static str),
|
||||
Prefix(&'static str),
|
||||
}
|
||||
|
||||
/// Get relevant keywords for each app
|
||||
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => vec!["ANTHROPIC"],
|
||||
"codex" => vec!["OPENAI"],
|
||||
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
|
||||
"claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
|
||||
"codex" => vec![EnvKeyword::Prefix("OPENAI")],
|
||||
"gemini" => vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI"),
|
||||
],
|
||||
"grokbuild" | "grok" => vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool {
|
||||
let upper_name = name.to_uppercase();
|
||||
keywords.iter().any(|keyword| match keyword {
|
||||
EnvKeyword::Exact(name) => upper_name == *name,
|
||||
EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check system environment variables (Windows Registry or Unix env)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check HKEY_CURRENT_USER\Environment
|
||||
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||
{
|
||||
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check current process environment
|
||||
for (key, value) in std::env::vars() {
|
||||
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&key, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: key,
|
||||
var_value: value,
|
||||
@@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
|
||||
/// Check shell configuration files for environment variable exports (Unix only)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
@@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let var_value = export_line[eq_pos + 1..].trim();
|
||||
|
||||
// Check if variable name contains any keyword
|
||||
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(var_name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: var_name.to_string(),
|
||||
var_value: var_value
|
||||
@@ -157,12 +178,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_keywords() {
|
||||
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("claude"),
|
||||
vec![EnvKeyword::Prefix("ANTHROPIC")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("codex"),
|
||||
vec![EnvKeyword::Prefix("OPENAI")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("gemini"),
|
||||
vec!["GEMINI", "GOOGLE_GEMINI"]
|
||||
vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI")
|
||||
]
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grokbuild"),
|
||||
vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grok"),
|
||||
get_keywords_for_app("grokbuild")
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<EnvKeyword>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_keywords_only_match_credentials() {
|
||||
let keywords = get_keywords_for_app("grokbuild");
|
||||
|
||||
assert!(matches_env_keyword("XAI_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("xai_api_key", &keywords));
|
||||
assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(matches_env_keyword("grok_default_model", &keywords));
|
||||
assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_HOME", &keywords));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_app_keywords_match_only_at_the_start() {
|
||||
let keywords = get_keywords_for_app("claude");
|
||||
|
||||
assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("anthropic_base_url", &keywords));
|
||||
assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,13 @@ impl McpService {
|
||||
AppType::Hermes => {
|
||||
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::Pi => {
|
||||
return Err(AppError::localized(
|
||||
"mcp.pi.unsupported",
|
||||
"固定版本的 Pi 核心没有原生 MCP 注册表",
|
||||
"The pinned Pi core has no native MCP registry",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -183,6 +190,13 @@ impl McpService {
|
||||
AppType::Hermes => {
|
||||
mcp::remove_server_from_hermes(id)?;
|
||||
}
|
||||
AppType::Pi => {
|
||||
return Err(AppError::localized(
|
||||
"mcp.pi.unsupported",
|
||||
"固定版本的 Pi 核心没有原生 MCP 注册表",
|
||||
"The pinned Pi core has no native MCP registry",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -227,7 +241,10 @@ impl McpService {
|
||||
servers: &IndexMap<String, McpServer>,
|
||||
app: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
|
||||
if matches!(
|
||||
app,
|
||||
AppType::OpenClaw | AppType::ClaudeDesktop | AppType::Pi
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -544,3 +561,32 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn global_mcp_projection_treats_pi_as_explicitly_not_applicable() {
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||
let mut servers = IndexMap::new();
|
||||
servers.insert(
|
||||
"example".to_string(),
|
||||
McpServer {
|
||||
id: "example".to_string(),
|
||||
name: "Example".to_string(),
|
||||
server: serde_json::json!({"command": "example"}),
|
||||
apps: Default::default(),
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
McpService::project_servers_to_app(&state, &servers, &AppType::Pi)
|
||||
.expect("Pi is intentionally outside the pinned core MCP registry");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
pub mod model_pricing;
|
||||
pub mod omo;
|
||||
pub(crate) mod pi_catalog;
|
||||
pub mod pi_prompt_files;
|
||||
pub mod profile;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
@@ -21,6 +23,7 @@ pub mod session_usage_gemini;
|
||||
pub mod session_usage_grokbuild;
|
||||
pub mod session_usage_opencode;
|
||||
pub mod skill;
|
||||
pub(crate) mod skill_deployment;
|
||||
pub mod speedtest;
|
||||
pub mod sql_helpers;
|
||||
pub mod stream_check;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::config::{atomic_write, write_json_file};
|
||||
use crate::database::NewProviderAggregate;
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::provider::Provider;
|
||||
@@ -288,7 +289,10 @@ impl OmoService {
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
state.db.save_provider("opencode", &provider)?;
|
||||
state.db.create_provider(NewProviderAggregate::from_input(
|
||||
"opencode",
|
||||
crate::services::provider::provider_to_mutation_input(provider.clone()),
|
||||
)?)?;
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
//! Pi native instruction files and prompt templates.
|
||||
//!
|
||||
//! AGENTS.md is also the Prompt-library projection. SYSTEM.md and
|
||||
//! APPEND_SYSTEM.md are direct native resources: file presence is activation
|
||||
//! and there is no shadow enabled flag.
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::pi_config::native::get_pi_agent_dir;
|
||||
use crate::pi_config::shared_file::{delete_shared_file, read_shared_file, replace_shared_file};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
|
||||
const MAX_PROMPT_FILE_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_TEMPLATE_SLUG_BYTES: usize = 128;
|
||||
static INSTRUCTION_FILE_LOCK: LazyLock<Arc<Mutex<()>>> = LazyLock::new(|| Arc::new(Mutex::new(())));
|
||||
|
||||
pub(crate) type PiInstructionFileGuard = OwnedMutexGuard<()>;
|
||||
|
||||
pub(crate) fn lock_instruction_files() -> Result<PiInstructionFileGuard, AppError> {
|
||||
Ok(futures::executor::block_on(
|
||||
INSTRUCTION_FILE_LOCK.clone().lock_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn lock_instruction_files_async() -> PiInstructionFileGuard {
|
||||
INSTRUCTION_FILE_LOCK.clone().lock_owned().await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PiPromptFileKind {
|
||||
GlobalContext,
|
||||
SystemOverride,
|
||||
SystemAppend,
|
||||
}
|
||||
|
||||
impl PiPromptFileKind {
|
||||
fn filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::GlobalContext => "AGENTS.md",
|
||||
Self::SystemOverride => "SYSTEM.md",
|
||||
Self::SystemAppend => "APPEND_SYSTEM.md",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PiPromptFileSnapshot {
|
||||
pub kind: PiPromptFileKind,
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub revision: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub struct PiPromptFileService;
|
||||
|
||||
impl PiPromptFileService {
|
||||
pub fn read(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
let guard = lock_instruction_files()?;
|
||||
Self::read_under_guard(&guard, kind)
|
||||
}
|
||||
|
||||
pub fn replace(
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
if kind == PiPromptFileKind::GlobalContext {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi AGENTS.md is managed through the Prompt library".to_string(),
|
||||
));
|
||||
}
|
||||
validate_direct_instruction_content(content)?;
|
||||
let guard = lock_instruction_files()?;
|
||||
Self::replace_under_guard(&guard, kind, expected_revision, content)
|
||||
}
|
||||
|
||||
pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result<bool, AppError> {
|
||||
if kind == PiPromptFileKind::GlobalContext {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi AGENTS.md is managed through the Prompt library".to_string(),
|
||||
));
|
||||
}
|
||||
let guard = lock_instruction_files()?;
|
||||
Self::delete_under_guard(&guard, kind, expected_revision)
|
||||
}
|
||||
|
||||
pub(crate) fn read_under_guard(
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
Self::read_at(&get_pi_agent_dir()?, kind)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_under_guard(
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
Self::replace_at(&get_pi_agent_dir()?, kind, expected_revision, content)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_under_guard(
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
Self::delete_at(&get_pi_agent_dir()?, kind, expected_revision)
|
||||
}
|
||||
|
||||
fn read_at(root: &Path, kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
let path = root.join(kind.filename());
|
||||
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt file")?;
|
||||
let exists = snapshot.exists();
|
||||
let content = match snapshot.bytes {
|
||||
Some(bytes) => String::from_utf8(bytes).map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"Pi prompt file must be UTF-8 ({}): {error}",
|
||||
path.display()
|
||||
))
|
||||
})?,
|
||||
None => String::new(),
|
||||
};
|
||||
Ok(PiPromptFileSnapshot {
|
||||
kind,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
exists,
|
||||
revision: snapshot.revision,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_at(
|
||||
root: &Path,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
fs::create_dir_all(root).map_err(|error| AppError::io(root, error))?;
|
||||
let path = root.join(kind.filename());
|
||||
replace_shared_file(
|
||||
&path,
|
||||
expected_revision,
|
||||
content.as_bytes(),
|
||||
MAX_PROMPT_FILE_BYTES,
|
||||
Some(0o600),
|
||||
"Pi prompt file",
|
||||
)?;
|
||||
Self::read_at(root, kind)
|
||||
}
|
||||
|
||||
fn delete_at(
|
||||
root: &Path,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
delete_shared_file(
|
||||
&root.join(kind.filename()),
|
||||
expected_revision,
|
||||
MAX_PROMPT_FILE_BYTES,
|
||||
"Pi prompt file",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PiPromptTemplate {
|
||||
pub slug: String,
|
||||
pub content: String,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
pub struct PiPromptTemplateService;
|
||||
|
||||
impl PiPromptTemplateService {
|
||||
pub fn list() -> Result<Vec<PiPromptTemplate>, AppError> {
|
||||
Self::list_at(&get_pi_agent_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
pub fn upsert(
|
||||
slug: &str,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptTemplate, AppError> {
|
||||
Self::upsert_at(
|
||||
&get_pi_agent_dir()?.join("prompts"),
|
||||
slug,
|
||||
expected_revision,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete(slug: &str, expected_revision: &str) -> Result<bool, AppError> {
|
||||
validate_template_slug(slug)?;
|
||||
delete_shared_file(
|
||||
&template_path(&get_pi_agent_dir()?.join("prompts"), slug),
|
||||
expected_revision,
|
||||
MAX_PROMPT_FILE_BYTES,
|
||||
"Pi prompt template",
|
||||
)
|
||||
}
|
||||
|
||||
fn list_at(dir: &Path) -> Result<Vec<PiPromptTemplate>, AppError> {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(AppError::io(dir, error)),
|
||||
};
|
||||
let mut templates = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| AppError::io(dir, error))?;
|
||||
let path = entry.path();
|
||||
let Some(slug) = path.file_stem().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("md")
|
||||
|| validate_template_slug(slug).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt template")?;
|
||||
let Some(bytes) = snapshot.bytes else {
|
||||
continue;
|
||||
};
|
||||
let content = String::from_utf8(bytes).map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"Pi prompt template must be UTF-8 ({}): {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
templates.push(PiPromptTemplate {
|
||||
slug: slug.to_string(),
|
||||
content,
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
}
|
||||
templates.sort_by(|left, right| left.slug.cmp(&right.slug));
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
fn upsert_at(
|
||||
dir: &Path,
|
||||
slug: &str,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptTemplate, AppError> {
|
||||
validate_template_slug(slug)?;
|
||||
fs::create_dir_all(dir).map_err(|error| AppError::io(dir, error))?;
|
||||
let snapshot = replace_shared_file(
|
||||
&template_path(dir, slug),
|
||||
expected_revision,
|
||||
content.as_bytes(),
|
||||
MAX_PROMPT_FILE_BYTES,
|
||||
Some(0o600),
|
||||
"Pi prompt template",
|
||||
)?;
|
||||
Ok(PiPromptTemplate {
|
||||
slug: slug.to_string(),
|
||||
content: content.to_string(),
|
||||
revision: snapshot.revision,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn template_path(dir: &Path, slug: &str) -> PathBuf {
|
||||
dir.join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
fn validate_direct_instruction_content(content: &str) -> Result<(), AppError> {
|
||||
if content.trim().is_empty() {
|
||||
Err(AppError::InvalidInput(
|
||||
"Pi SYSTEM.md and APPEND_SYSTEM.md content cannot be blank; delete the file to deactivate it"
|
||||
.to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_template_slug(slug: &str) -> Result<(), AppError> {
|
||||
let valid = !slug.is_empty()
|
||||
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
|
||||
&& slug != "."
|
||||
&& slug != ".."
|
||||
&& slug.trim() == slug
|
||||
&& !slug.starts_with('.')
|
||||
&& !slug.ends_with('.')
|
||||
&& !slug
|
||||
.chars()
|
||||
.any(|character| character.is_control() || matches!(character, '/' | '\\'));
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::InvalidInput(
|
||||
"Pi prompt-template slug must be one visible filename (1-128 UTF-8 bytes)".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_instruction_files_use_presence_and_revision_as_native_state() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
for kind in [
|
||||
PiPromptFileKind::GlobalContext,
|
||||
PiPromptFileKind::SystemOverride,
|
||||
PiPromptFileKind::SystemAppend,
|
||||
] {
|
||||
let missing = PiPromptFileService::read_at(temp.path(), kind).expect("missing");
|
||||
assert!(!missing.exists);
|
||||
// scripts/pi-transport-capture.mjs executes pinned Pi's
|
||||
// DefaultResourceLoader at
|
||||
// ab366ebe94cacd419d986be454f12b1b9913aaca and records all three
|
||||
// zero-byte files as present resources.
|
||||
let empty = PiPromptFileService::replace_at(temp.path(), kind, "missing", "")
|
||||
.expect("create empty instruction file");
|
||||
assert!(empty.exists);
|
||||
assert_eq!(empty.content, "");
|
||||
let saved =
|
||||
PiPromptFileService::replace_at(temp.path(), kind, &empty.revision, "content")
|
||||
.expect("replace");
|
||||
assert!(saved.exists);
|
||||
assert_eq!(saved.content, "content");
|
||||
assert!(PiPromptFileService::delete_at(temp.path(), kind, "missing").is_err());
|
||||
assert!(
|
||||
PiPromptFileService::delete_at(temp.path(), kind, &saved.revision).expect("delete")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_instruction_save_rejects_blank_content_without_redefining_native_presence() {
|
||||
for content in ["", " \n\t"] {
|
||||
assert!(validate_direct_instruction_content(content).is_err());
|
||||
}
|
||||
assert!(validate_direct_instruction_content("# Explicit override").is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn direct_instruction_entry_never_reports_failure_with_its_attempt_live() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp
|
||||
.path()
|
||||
.join(PiPromptFileKind::SystemOverride.filename());
|
||||
|
||||
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
|
||||
PiPromptFileService::replace_at(
|
||||
temp.path(),
|
||||
PiPromptFileKind::SystemOverride,
|
||||
"missing",
|
||||
"created",
|
||||
)
|
||||
.expect_err("failed create must be compensated");
|
||||
assert!(!path.exists());
|
||||
|
||||
let before = PiPromptFileService::replace_at(
|
||||
temp.path(),
|
||||
PiPromptFileKind::SystemOverride,
|
||||
"missing",
|
||||
"before",
|
||||
)
|
||||
.expect("seed");
|
||||
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
|
||||
PiPromptFileService::replace_at(
|
||||
temp.path(),
|
||||
PiPromptFileKind::SystemOverride,
|
||||
&before.revision,
|
||||
"after",
|
||||
)
|
||||
.expect_err("failed replace must restore its before-image");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("before restored"),
|
||||
"before"
|
||||
);
|
||||
|
||||
let before = PiPromptFileService::read_at(temp.path(), PiPromptFileKind::SystemOverride)
|
||||
.expect("snapshot");
|
||||
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
|
||||
PiPromptFileService::delete_at(
|
||||
temp.path(),
|
||||
PiPromptFileKind::SystemOverride,
|
||||
&before.revision,
|
||||
)
|
||||
.expect_err("failed delete must restore its before-image");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("before restored"),
|
||||
"before"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_reject_ambiguous_or_traversing_slugs() {
|
||||
for slug in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
".hidden",
|
||||
"trailing.",
|
||||
" padded",
|
||||
"a/b",
|
||||
r"a\b",
|
||||
] {
|
||||
assert!(validate_template_slug(slug).is_err(), "{slug:?}");
|
||||
}
|
||||
for slug in ["review-pr", "release.v2", "评审", "SYSTEM"] {
|
||||
assert!(validate_template_slug(slug).is_ok(), "{slug:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_template_is_present_and_round_trips_like_pinned_pi() {
|
||||
// scripts/pi-transport-capture.mjs executes pinned Pi
|
||||
// ab366ebe94cacd419d986be454f12b1b9913aaca and confirms that an empty
|
||||
// prompts/empty.md is discovered as an active template.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let created = PiPromptTemplateService::upsert_at(temp.path(), "empty", "missing", "")
|
||||
.expect("create empty template");
|
||||
assert_eq!(created.content, "");
|
||||
let listed = PiPromptTemplateService::list_at(temp.path()).expect("list templates");
|
||||
assert_eq!(listed, vec![created]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{NewEndpoint, ProviderKey};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
@@ -47,9 +48,10 @@ pub fn add_custom_endpoint(
|
||||
));
|
||||
}
|
||||
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state
|
||||
.db
|
||||
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -61,9 +63,8 @@ pub fn remove_custom_endpoint(
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
state
|
||||
.db
|
||||
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state.db.remove_provider_endpoint(&key, &normalized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -76,17 +77,10 @@ pub fn update_endpoint_last_used(
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
// Get provider, update last_used, save back
|
||||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get_mut(provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
endpoint.last_used = Some(now_millis());
|
||||
state.db.save_provider(app_type.as_str(), provider)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state
|
||||
.db
|
||||
.touch_provider_endpoint(&key, &normalized, now_millis())
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds
|
||||
|
||||
@@ -19,7 +19,10 @@ use crate::store::AppState;
|
||||
use super::gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
||||
};
|
||||
use super::normalize_claude_models_in_value;
|
||||
use super::{
|
||||
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
|
||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||
};
|
||||
|
||||
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
||||
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
|
||||
@@ -527,6 +530,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::Pi
|
||||
| AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
@@ -601,6 +605,7 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::Pi
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
@@ -660,6 +665,7 @@ fn apply_common_config_to_settings(
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::Pi
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
@@ -1162,6 +1168,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?;
|
||||
log::debug!("Hermes provider '{}' written to live config", provider.id);
|
||||
}
|
||||
AppType::Pi => {
|
||||
return Err(AppError::localized(
|
||||
"pi.live.requires_catalog_coordinator",
|
||||
"Pi 的共享 models.json 必须通过 Pi 目录协调器写入",
|
||||
"Pi's shared models.json must be written through the Pi catalog coordinator",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1278,7 +1291,17 @@ fn sync_current_provider_for_app_respecting_takeover(
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
// Sync providers based on mode
|
||||
for app_type in AppType::all() {
|
||||
if matches!(app_type, AppType::Pi) {
|
||||
crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?;
|
||||
continue;
|
||||
}
|
||||
if app_type.is_additive_mode() {
|
||||
// Provider rename and every additive live mutation share this
|
||||
// per-app lock. Acquire it before reading the catalog so a key
|
||||
// cannot be renamed after this sync captured a stale provider map.
|
||||
let _guard = futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
);
|
||||
// Additive mode: sync ALL providers
|
||||
sync_all_providers_to_live(state, &app_type)?;
|
||||
} else {
|
||||
@@ -1417,6 +1440,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = crate::hermes_config::yaml_to_json(&yaml_config)?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::Pi => Err(AppError::localized(
|
||||
"pi.live.requires_catalog_inspection",
|
||||
"Pi 的共享 models.json 必须通过 Pi 原生目录检查服务读取",
|
||||
"Pi's shared models.json must be read through the Pi native catalog inspection service",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,6 +1553,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::Pi => {
|
||||
return Err(AppError::localized(
|
||||
"pi.import.requires_catalog_coordinator",
|
||||
"Pi 原生供应商必须通过 Pi 目录导入流程导入",
|
||||
"Native Pi providers must be imported through the Pi catalog import flow",
|
||||
));
|
||||
}
|
||||
// OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
unreachable!("additive mode apps are handled by early return")
|
||||
@@ -1564,7 +1599,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
app_type.as_str(),
|
||||
provider_to_mutation_input(provider.clone()),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
)?;
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
@@ -1732,15 +1772,25 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
if existing_ids.contains(&id) {
|
||||
match state.db.get_provider_by_id(&id, "opencode") {
|
||||
match state.db.get_provider_aggregate("opencode", &id) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
|
||||
if existing.settings_config != settings_config || existing.name != display_name
|
||||
{
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.name = display_name;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
||||
);
|
||||
@@ -1767,7 +1817,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1817,12 +1872,22 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
if existing_ids.contains(&id) {
|
||||
match state.db.get_provider_by_id(&id, "openclaw") {
|
||||
match state.db.get_provider_aggregate("openclaw", &id) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != settings_config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
||||
);
|
||||
@@ -1855,7 +1920,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1892,12 +1962,22 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
}
|
||||
|
||||
if existing_ids.contains(&name) {
|
||||
match state.db.get_provider_by_id(&name, "hermes") {
|
||||
match state.db.get_provider_aggregate("hermes", &name) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = config;
|
||||
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update Hermes provider '{name}' from live config: {e}"
|
||||
);
|
||||
@@ -1923,7 +2003,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
+1406
-142
File diff suppressed because it is too large
Load Diff
+2433
-238
File diff suppressed because it is too large
Load Diff
+662
-58
@@ -565,6 +565,9 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::Pi => {
|
||||
return Ok(crate::pi_config::native::get_pi_agent_dir()?.join("skills"));
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置。
|
||||
@@ -581,6 +584,7 @@ impl SkillService {
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
|
||||
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?.join("skills"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -637,8 +641,19 @@ impl SkillService {
|
||||
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||||
let mut updated = existing.clone();
|
||||
updated.apps.set_enabled_for(current_app, true);
|
||||
if matches!(current_app, AppType::Pi) {
|
||||
let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
|
||||
&guard,
|
||||
db,
|
||||
&mut updated,
|
||||
true,
|
||||
)
|
||||
.map_err(|error| anyhow!(error.to_string()))?;
|
||||
return Ok(updated);
|
||||
}
|
||||
db.save_skill(&updated)?;
|
||||
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||||
Self::sync_installed_skill_to_app(db, &updated, current_app)?;
|
||||
log::info!(
|
||||
"Skill {} 已存在,更新 {:?} 启用状态",
|
||||
updated.name,
|
||||
@@ -671,6 +686,7 @@ impl SkillService {
|
||||
}
|
||||
|
||||
let dest = ssot_dir.join(&install_name);
|
||||
let destination_preexisted = dest.exists();
|
||||
|
||||
let mut repo_branch = skill.repo_branch.clone();
|
||||
|
||||
@@ -788,11 +804,39 @@ impl SkillService {
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
db.save_skill(&installed_skill)?;
|
||||
|
||||
// 同步到当前应用目录
|
||||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||
let installed_skill = if matches!(current_app, AppType::Pi) {
|
||||
let guard =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
let mut persisted = installed_skill.clone();
|
||||
// Let the deployment coordinator commit the desired bit and ledger
|
||||
// evidence together. Until then the row is deliberately disabled.
|
||||
persisted.apps.pi = false;
|
||||
if let Err(error) = db.save_skill(&persisted) {
|
||||
if !destination_preexisted {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
|
||||
&guard,
|
||||
db,
|
||||
&mut persisted,
|
||||
true,
|
||||
)
|
||||
{
|
||||
let _ = db.delete_skill(&persisted.id);
|
||||
if !destination_preexisted {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
}
|
||||
return Err(anyhow!(error.to_string()));
|
||||
}
|
||||
persisted
|
||||
} else {
|
||||
db.save_skill(&installed_skill)?;
|
||||
Self::sync_installed_skill_to_app(db, &installed_skill, current_app)?;
|
||||
installed_skill
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Skill {} 安装成功,已启用 {:?}",
|
||||
@@ -810,6 +854,8 @@ impl SkillService {
|
||||
/// 2. 从 SSOT 删除
|
||||
/// 3. 从数据库删除
|
||||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
|
||||
let deployment_guard =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
// 获取 skill 信息
|
||||
let skill = db
|
||||
.get_installed_skill(id)?
|
||||
@@ -828,8 +874,15 @@ impl SkillService {
|
||||
let backup_path = Self::create_uninstall_backup(&skill)?
|
||||
.map(|path| path.to_string_lossy().to_string());
|
||||
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
|
||||
&deployment_guard,
|
||||
db,
|
||||
&skill,
|
||||
)
|
||||
.map_err(|error| anyhow!(error.to_string()))?;
|
||||
|
||||
// 从所有应用目录删除
|
||||
for app in AppType::all() {
|
||||
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
|
||||
let _ = Self::remove_from_app(&directory, &app);
|
||||
}
|
||||
|
||||
@@ -1113,15 +1166,40 @@ impl SkillService {
|
||||
))
|
||||
})?;
|
||||
|
||||
// All Pi deployment mutations, SSOT replacement, and ledger
|
||||
// reconciliation share one process boundary. Downloading remains
|
||||
// outside the lock so a slow network cannot block toggles.
|
||||
let deployment_guard =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
|
||||
// 备份旧文件
|
||||
let _ = Self::create_uninstall_backup(&skill);
|
||||
|
||||
// 删除旧 SSOT 目录并复制新文件
|
||||
// Stage the exact old SSOT tree in the same directory. Reconstructing
|
||||
// it from the remote source is not a rollback: local files may differ.
|
||||
let dest = ssot_dir.join(&skill.directory);
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
let staged_previous = if fs::symlink_metadata(&dest).is_ok() {
|
||||
let staged = ssot_dir.join(format!(
|
||||
".{}.cc-switch-update-{}",
|
||||
skill.directory,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::rename(&dest, &staged)?;
|
||||
Some(staged)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
|
||||
if let Some(staged) = staged_previous.as_deref() {
|
||||
fs::rename(staged, &dest).with_context(|| {
|
||||
format!(
|
||||
"Skill update copy failed ({error}); restoring {} also failed",
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
|
||||
// 计算新哈希 + 解析新元数据
|
||||
let new_hash = Self::compute_dir_hash(&dest).ok();
|
||||
@@ -1151,14 +1229,71 @@ impl SkillService {
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
db.save_skill(&updated_skill)?;
|
||||
if let Err(error) = db.save_skill(&updated_skill) {
|
||||
let _ = Self::remove_path(&dest);
|
||||
if let Some(staged) = staged_previous.as_deref() {
|
||||
fs::rename(staged, &dest).with_context(|| {
|
||||
format!(
|
||||
"Skill metadata update failed ({error}); restoring {} also failed",
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
// 同步到所有已启用的应用目录
|
||||
for app in updated_skill.apps.enabled_apps() {
|
||||
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
|
||||
// Pi is the consistency-critical consumer: update its owned
|
||||
// deployment before best-effort legacy app copies.
|
||||
if updated_skill.apps.pi {
|
||||
if let Err(error) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill_under_guard(
|
||||
&deployment_guard,
|
||||
db,
|
||||
&updated_skill,
|
||||
)
|
||||
{
|
||||
let db_rollback = db.save_skill(&skill);
|
||||
let file_rollback = Self::remove_path(&dest).and_then(|_| {
|
||||
if let Some(staged) = staged_previous.as_deref() {
|
||||
fs::rename(staged, &dest).map_err(anyhow::Error::from)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
return match (db_rollback, file_rollback) {
|
||||
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
|
||||
(db_result, file_result) => Err(anyhow!(
|
||||
"Pi Skill update failed ({error}); DB rollback: {}; file rollback: {}",
|
||||
db_result
|
||||
.err()
|
||||
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
|
||||
file_result
|
||||
.err()
|
||||
.map_or_else(|| "ok".to_string(), |value| value.to_string())
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到所有已启用的其他应用目录
|
||||
for app in updated_skill
|
||||
.apps
|
||||
.enabled_apps()
|
||||
.into_iter()
|
||||
.filter(|app| !matches!(app, AppType::Pi))
|
||||
{
|
||||
if let Err(e) = Self::sync_installed_skill_to_app(db, &updated_skill, &app) {
|
||||
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
|
||||
}
|
||||
}
|
||||
if let Some(staged) = staged_previous {
|
||||
if let Err(error) = Self::remove_path(&staged) {
|
||||
log::warn!(
|
||||
"Failed to remove committed Skill update rollback staging '{}': {error}",
|
||||
staged.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Skill {} 更新成功", updated_skill.name);
|
||||
Ok(updated_skill)
|
||||
@@ -1201,7 +1336,10 @@ impl SkillService {
|
||||
|
||||
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
|
||||
///
|
||||
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
|
||||
/// Safety strategy: copy first while the old SSOT remains live, switch the
|
||||
/// setting, reconcile every app, then delete the old trees. Keeping both
|
||||
/// roots during reconciliation lets the Pi ownership ledger verify its old
|
||||
/// symlink/copy before atomically replacing it.
|
||||
pub fn migrate_storage(
|
||||
db: &Arc<Database>,
|
||||
target: SkillStorageLocation,
|
||||
@@ -1215,6 +1353,9 @@ impl SkillService {
|
||||
});
|
||||
}
|
||||
|
||||
let deployment_guard =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
|
||||
// 1. 解析旧目录和新目录(不改设置)
|
||||
let old_dir = Self::get_ssot_dir()?;
|
||||
let new_dir = match target {
|
||||
@@ -1225,18 +1366,18 @@ impl SkillService {
|
||||
};
|
||||
fs::create_dir_all(&new_dir)?;
|
||||
|
||||
// 2. 逐个移动 skill 目录
|
||||
// 2. Copy every valid tree. Do not rename/delete the old root before
|
||||
// Pi has verified the ownership identity recorded in its ledger.
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let mut result = MigrationResult {
|
||||
migrated_count: 0,
|
||||
skipped_count: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
let mut copied = Vec::<(PathBuf, PathBuf)>::new();
|
||||
|
||||
for skill in skills.values() {
|
||||
// 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。
|
||||
// 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill,
|
||||
// 不要整体中断——用户只是在切换存储位置。
|
||||
// Invalid DB rows are reported but never joined to either root.
|
||||
let directory = match Self::require_valid_directory(&skill.directory) {
|
||||
Ok(directory) => directory,
|
||||
Err(err) => {
|
||||
@@ -1253,32 +1394,90 @@ impl SkillService {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
if dst.exists() {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
if fs::symlink_metadata(&dst).is_ok() {
|
||||
for (_, copied_destination) in copied.iter().rev() {
|
||||
let _ = Self::remove_path(copied_destination);
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"Skill storage target already contains an unowned entry: {}",
|
||||
dst.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 优先 rename(同文件系统原子操作),失败则 copy+delete
|
||||
match fs::rename(&src, &dst) {
|
||||
Ok(()) => result.migrated_count += 1,
|
||||
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_dir_all(&src);
|
||||
result.migrated_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("{}: {e}", skill.directory));
|
||||
}
|
||||
},
|
||||
if let Err(error) = Self::copy_dir_recursive(&src, &dst) {
|
||||
let _ = Self::remove_path(&dst);
|
||||
for (_, copied_destination) in copied.iter().rev() {
|
||||
let _ = Self::remove_path(copied_destination);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
copied.push((src, dst));
|
||||
result.migrated_count += 1;
|
||||
}
|
||||
|
||||
// 3. 文件移动完成后才持久化设置
|
||||
crate::settings::set_skill_storage_location(target)?;
|
||||
// 3. Switch authority only after every new tree is complete.
|
||||
if let Err(error) = crate::settings::set_skill_storage_location(target) {
|
||||
for (_, copied_destination) in copied.iter().rev() {
|
||||
let _ = Self::remove_path(copied_destination);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
// 4. 刷新所有应用目录的 symlink(指向新 SSOT)
|
||||
for app in AppType::all() {
|
||||
let _ = Self::sync_to_app(db, &app);
|
||||
// 4. Reconcile Pi under the same mutex, then all legacy app views.
|
||||
let reconcile_result =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
|
||||
&deployment_guard,
|
||||
db,
|
||||
)
|
||||
.map_err(|error| anyhow!(error.to_string()))
|
||||
.and_then(|()| {
|
||||
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
|
||||
Self::sync_to_app(db, &app)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(error) = reconcile_result {
|
||||
let mut rollback_errors = Vec::new();
|
||||
if let Err(rollback) = crate::settings::set_skill_storage_location(current) {
|
||||
rollback_errors.push(format!("settings: {rollback}"));
|
||||
} else {
|
||||
if let Err(rollback) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
|
||||
&deployment_guard,
|
||||
db,
|
||||
)
|
||||
{
|
||||
rollback_errors.push(format!("Pi deployment: {rollback}"));
|
||||
}
|
||||
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
|
||||
if let Err(rollback) = Self::sync_to_app(db, &app) {
|
||||
rollback_errors.push(format!("{app:?}: {rollback}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, copied_destination) in copied.iter().rev() {
|
||||
if let Err(rollback) = Self::remove_path(copied_destination) {
|
||||
rollback_errors.push(format!("{}: {rollback}", copied_destination.display()));
|
||||
}
|
||||
}
|
||||
return if rollback_errors.is_empty() {
|
||||
Err(error)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Skill storage migration failed ({error}); rollback failures: {}",
|
||||
rollback_errors.join("; ")
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Only after every consumer points at the new root may the old
|
||||
// sources be removed. Cleanup errors are visible but do not roll back
|
||||
// an already-consistent authority switch.
|
||||
for (old_source, _) in &copied {
|
||||
if let Err(error) = Self::remove_path(old_source) {
|
||||
result
|
||||
.errors
|
||||
.push(format!("{}: {error}", old_source.display()));
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -1403,7 +1602,7 @@ impl SkillService {
|
||||
}
|
||||
|
||||
if !restored_skill.apps.is_empty() {
|
||||
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
|
||||
if let Err(err) = Self::sync_installed_skill_to_app(db, &restored_skill, current_app) {
|
||||
let _ = db.delete_skill(&restored_skill.id);
|
||||
let _ = fs::remove_dir_all(&restore_path);
|
||||
return Err(err);
|
||||
@@ -1429,6 +1628,14 @@ impl SkillService {
|
||||
.get_installed_skill(id)?
|
||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||
|
||||
if matches!(app, AppType::Pi) {
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::toggle(
|
||||
db, &mut skill, enabled,
|
||||
)
|
||||
.map_err(|error| anyhow!(error.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
skill.apps.set_enabled_for(app, enabled);
|
||||
|
||||
@@ -1517,6 +1724,11 @@ impl SkillService {
|
||||
db: &Arc<Database>,
|
||||
imports: Vec<ImportSkillSelection>,
|
||||
) -> Result<Vec<InstalledSkill>> {
|
||||
// Import can explicitly acquire or release Pi filesystem ownership.
|
||||
// Serialize the source scan, SSOT establishment, ownership decision and
|
||||
// desired-state transaction with every other Pi deployment operation.
|
||||
let deployment_guard =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let agents_lock = parse_agents_lock();
|
||||
let mut imported = Vec::new();
|
||||
@@ -1579,8 +1791,12 @@ impl SkillService {
|
||||
|
||||
// 复制到 SSOT
|
||||
let dest = ssot_dir.join(&dir_name);
|
||||
if !dest.exists() {
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
let created_ssot = !dest.exists();
|
||||
if created_ssot {
|
||||
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
|
||||
let _ = Self::remove_path(&dest);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析元数据
|
||||
@@ -1588,7 +1804,7 @@ impl SkillService {
|
||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||
|
||||
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
|
||||
let apps = selection.apps;
|
||||
let requested_apps = selection.apps;
|
||||
|
||||
// 从 lock 文件提取仓库信息
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
@@ -1599,7 +1815,8 @@ impl SkillService {
|
||||
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
|
||||
|
||||
// 创建记录
|
||||
let skill = InstalledSkill {
|
||||
let previous = db.get_installed_skill(&id)?;
|
||||
let mut skill = InstalledSkill {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
@@ -1608,14 +1825,54 @@ impl SkillService {
|
||||
repo_name,
|
||||
repo_branch,
|
||||
readme_url,
|
||||
apps,
|
||||
// save_skill intentionally preserves an existing Pi desired bit.
|
||||
// For a new row keep it disabled until the deployment ledger and
|
||||
// desired bit can commit in one transaction below.
|
||||
apps: SkillApps {
|
||||
pi: previous.as_ref().is_some_and(|installed| installed.apps.pi),
|
||||
..requested_apps.clone()
|
||||
},
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
db.save_skill(&skill)?;
|
||||
if let Err(error) = db.save_skill(&skill) {
|
||||
if created_ssot {
|
||||
let _ = Self::remove_path(&dest);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = crate::services::skill_deployment::PiSkillDeploymentService::import_desired_state_under_guard(
|
||||
&deployment_guard,
|
||||
db,
|
||||
&mut skill,
|
||||
requested_apps.pi,
|
||||
) {
|
||||
let db_rollback = if let Some(previous) = previous.as_ref() {
|
||||
db.save_skill(previous).map(|_| ())
|
||||
} else {
|
||||
db.delete_skill(&skill.id).map(|_| ())
|
||||
};
|
||||
let file_rollback = if created_ssot {
|
||||
Self::remove_path(&dest)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
return match (db_rollback, file_rollback) {
|
||||
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
|
||||
(db_result, file_result) => Err(anyhow!(
|
||||
"Pi Skill import failed ({error}); DB rollback: {}; SSOT rollback: {}",
|
||||
db_result
|
||||
.err()
|
||||
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
|
||||
file_result
|
||||
.err()
|
||||
.map_or_else(|| "ok".to_string(), |value| value.to_string())
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
imported.push(skill);
|
||||
}
|
||||
@@ -1655,6 +1912,19 @@ impl SkillService {
|
||||
crate::settings::get_skill_sync_method()
|
||||
}
|
||||
|
||||
fn sync_installed_skill_to_app(
|
||||
db: &Arc<Database>,
|
||||
skill: &InstalledSkill,
|
||||
app: &AppType,
|
||||
) -> Result<()> {
|
||||
if matches!(app, AppType::Pi) {
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill(db, skill)
|
||||
.map_err(|error| anyhow!(error.to_string()))
|
||||
} else {
|
||||
Self::sync_to_app_dir(&skill.directory, app)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||||
///
|
||||
/// 根据配置和平台选择最佳同步方式:
|
||||
@@ -1665,6 +1935,11 @@ impl SkillService {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(app, AppType::Pi) {
|
||||
return Err(anyhow!(
|
||||
"Pi Skill deployment requires the ownership ledger; use the database-aware reconciler"
|
||||
));
|
||||
}
|
||||
|
||||
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。
|
||||
let directory = Self::require_valid_directory(directory)?;
|
||||
@@ -1861,6 +2136,10 @@ impl SkillService {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(app, AppType::Pi) {
|
||||
return crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(db)
|
||||
.map_err(|error| anyhow!(error.to_string()));
|
||||
}
|
||||
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
@@ -2113,7 +2392,7 @@ impl SkillService {
|
||||
}
|
||||
|
||||
/// 静态方法:解析技能元数据
|
||||
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
|
||||
pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let content = content.trim_start_matches('\u{feff}');
|
||||
|
||||
@@ -2733,6 +3012,35 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy into a unique sibling and publish with an OS no-replace rename.
|
||||
/// A concurrent installer can win, but its directory is never overwritten.
|
||||
fn copy_dir_noreplace(src: &Path, dest: &Path) -> Result<()> {
|
||||
let parent = dest
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Skill destination has no parent: {}", dest.display()))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let name = dest
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.ok_or_else(|| anyhow!("Skill destination has an invalid name: {}", dest.display()))?;
|
||||
let staged = parent.join(format!(
|
||||
".{name}.cc-switch-install-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
if let Err(error) = Self::copy_dir_recursive(src, &staged) {
|
||||
let _ = fs::remove_dir_all(&staged);
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = crate::pi_config::shared_file::publish_path_noreplace(&staged, dest) {
|
||||
let _ = fs::remove_dir_all(&staged);
|
||||
return Err(anyhow!(
|
||||
"Skill destination was created concurrently ({}): {error}",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
|
||||
// 返回值会被整目录复制进 ~/.cc-switch/skill-backups/ 并由 get_skill_backups
|
||||
// 在界面上列出——脏 directory 在这里等于任意文件读取 + 外泄通道。
|
||||
@@ -2996,6 +3304,10 @@ impl SkillService {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let mut installed = Vec::new();
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
let mut claimed_directories = existing_skills
|
||||
.values()
|
||||
.map(|skill| skill.directory.to_ascii_lowercase())
|
||||
.collect::<HashSet<_>>();
|
||||
let zip_stem = zip_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
@@ -3062,6 +3374,32 @@ impl SkillService {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if claimed_directories.contains(&install_name.to_ascii_lowercase()) {
|
||||
log::warn!(
|
||||
"Skill directory '{}' appears more than once in the archive, skipping",
|
||||
install_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(current_app, AppType::Pi)
|
||||
&& meta.as_ref().is_none_or(|metadata| {
|
||||
metadata
|
||||
.name
|
||||
.as_deref()
|
||||
.is_none_or(|name| name.trim().is_empty())
|
||||
|| metadata
|
||||
.description
|
||||
.as_deref()
|
||||
.is_none_or(|description| description.trim().is_empty())
|
||||
})
|
||||
{
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"INVALID_SKILL_DIRECTORY",
|
||||
&[("directory", &install_name)],
|
||||
Some("checkSkillManifest"),
|
||||
)));
|
||||
}
|
||||
|
||||
let (name, description) = match meta {
|
||||
Some(m) => (
|
||||
@@ -3071,18 +3409,35 @@ impl SkillService {
|
||||
None => (install_name.clone(), None),
|
||||
};
|
||||
|
||||
let deployment_guard = matches!(current_app, AppType::Pi)
|
||||
.then(crate::services::skill_deployment::PiSkillDeploymentService::operation_guard);
|
||||
let pi_source_digest = if deployment_guard.is_some() {
|
||||
Some(
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::source_digest(
|
||||
&skill_dir,
|
||||
)
|
||||
.map_err(|error| anyhow!(error.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 复制到 SSOT
|
||||
let dest = ssot_dir.join(&install_name);
|
||||
if dest.exists() {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
if fs::symlink_metadata(&dest).is_ok() {
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIRECTORY_CONFLICT",
|
||||
&[("directory", &install_name)],
|
||||
Some("uninstallFirst"),
|
||||
)));
|
||||
}
|
||||
Self::copy_dir_recursive(&skill_dir, &dest)?;
|
||||
Self::copy_dir_noreplace(&skill_dir, &dest)?;
|
||||
|
||||
// 计算内容哈希
|
||||
let content_hash = Self::compute_dir_hash(&dest).ok();
|
||||
|
||||
// 创建 InstalledSkill 记录
|
||||
let skill = InstalledSkill {
|
||||
let mut skill = InstalledSkill {
|
||||
id: format!("local:{install_name}"),
|
||||
name,
|
||||
description,
|
||||
@@ -3097,17 +3452,75 @@ impl SkillService {
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
// 同步到当前应用目录
|
||||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||
if let Some(guard) = deployment_guard.as_ref() {
|
||||
// The coordinator commits Pi desired state and ownership
|
||||
// evidence together. Until then the portable row is inert.
|
||||
skill.apps.pi = false;
|
||||
if let Err(error) = db.save_skill(&skill) {
|
||||
let cleanup =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
|
||||
&dest,
|
||||
pi_source_digest
|
||||
.as_deref()
|
||||
.expect("Pi ZIP publication has a source digest"),
|
||||
);
|
||||
return match cleanup {
|
||||
Ok(()) => Err(error.into()),
|
||||
Err(cleanup) => Err(anyhow!(
|
||||
"Pi Skill ZIP database write failed ({error}); SSOT rollback failed ({cleanup})"
|
||||
)),
|
||||
};
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
|
||||
guard, db, &mut skill, true,
|
||||
)
|
||||
{
|
||||
if let Err(deployment_rollback) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
|
||||
guard, db, &skill,
|
||||
)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Pi Skill ZIP install failed ({error}); native ownership rollback failed ({deployment_rollback}); the DB row and SSOT were retained as recovery evidence"
|
||||
));
|
||||
}
|
||||
let db_rollback = db.delete_skill(&skill.id);
|
||||
if !matches!(&db_rollback, Ok(true)) {
|
||||
return Err(anyhow!(
|
||||
"Pi Skill ZIP install failed ({error}); DB rollback failed ({}); SSOT was retained",
|
||||
match db_rollback {
|
||||
Ok(false) => "row missing".to_string(),
|
||||
Err(value) => value.to_string(),
|
||||
Ok(true) => unreachable!(),
|
||||
}
|
||||
));
|
||||
}
|
||||
let file_rollback =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
|
||||
&dest,
|
||||
pi_source_digest
|
||||
.as_deref()
|
||||
.expect("Pi ZIP publication has a source digest"),
|
||||
);
|
||||
return match file_rollback {
|
||||
Ok(()) => Err(anyhow!(error.to_string())),
|
||||
Err(file_error) => Err(anyhow!(
|
||||
"Pi Skill ZIP install failed ({error}); SSOT rollback failed ({file_error})"
|
||||
)),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
db.save_skill(&skill)?;
|
||||
Self::sync_installed_skill_to_app(db, &skill, current_app)?;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Skill {} installed from ZIP, enabled for {:?}",
|
||||
skill.name,
|
||||
current_app
|
||||
);
|
||||
claimed_directories.insert(install_name.to_ascii_lowercase());
|
||||
installed.push(skill);
|
||||
}
|
||||
|
||||
@@ -4013,6 +4426,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_replace_directory_publish_preserves_an_existing_destination() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let source = temp.path().join("source");
|
||||
let destination = temp.path().join("destination");
|
||||
fs::create_dir(&source).expect("source");
|
||||
fs::create_dir(&destination).expect("destination");
|
||||
fs::write(source.join("SKILL.md"), "managed").expect("source manifest");
|
||||
fs::write(destination.join("SKILL.md"), "external").expect("external manifest");
|
||||
|
||||
SkillService::copy_dir_noreplace(&source, &destination)
|
||||
.expect_err("an existing destination must win atomically");
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("SKILL.md")).expect("external destination"),
|
||||
"external"
|
||||
);
|
||||
assert!(
|
||||
fs::read_dir(temp.path())
|
||||
.expect("temp root")
|
||||
.all(|entry| !entry
|
||||
.expect("entry")
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.contains("cc-switch-install")),
|
||||
"a rejected staged publication must be cleaned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_local_zip_hands_back_a_guard_that_owns_the_tree() {
|
||||
use std::io::Write;
|
||||
@@ -4142,6 +4583,169 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn importing_a_native_pi_skill_adopts_exact_content_and_can_disable_it() {
|
||||
struct PiDirGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for PiDirGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
|
||||
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
struct StorageLocationGuard(SkillStorageLocation);
|
||||
impl Drop for StorageLocationGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = crate::settings::set_skill_storage_location(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let _home_guard = TestHomeGuard::set(temp.path());
|
||||
let pi_agent_dir = temp.path().join("pi-agent");
|
||||
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
|
||||
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
|
||||
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
|
||||
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
|
||||
.expect("select isolated SSOT");
|
||||
|
||||
let native = pi_agent_dir.join("skills").join("native-skill");
|
||||
write_skill(&native, "Native Skill");
|
||||
fs::write(native.join("details.txt"), "pinned native bytes").expect("native detail");
|
||||
let db = Arc::new(Database::memory().expect("memory db"));
|
||||
|
||||
let imported = SkillService::import_from_apps(
|
||||
&db,
|
||||
vec![ImportSkillSelection {
|
||||
directory: "native-skill".to_string(),
|
||||
apps: SkillApps::only(&AppType::Pi),
|
||||
}],
|
||||
)
|
||||
.expect("explicit Pi import should adopt the exact native tree");
|
||||
assert_eq!(imported.len(), 1);
|
||||
assert!(imported[0].apps.pi);
|
||||
|
||||
let statuses =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::inspect_all(&db)
|
||||
.expect("inspect Pi deployment");
|
||||
let status = statuses
|
||||
.get(&imported[0].id)
|
||||
.expect("imported status must exist");
|
||||
assert_eq!(
|
||||
status.ownership,
|
||||
crate::services::skill_deployment::PiSkillOwnership::Owned
|
||||
);
|
||||
assert_eq!(
|
||||
status.discovery,
|
||||
crate::services::skill_deployment::PiSkillDiscovery::Active
|
||||
);
|
||||
assert!(status.effectively_discovered);
|
||||
|
||||
SkillService::toggle_app(&db, &imported[0].id, &AppType::Pi, false)
|
||||
.expect("owned imported Pi Skill can be disabled");
|
||||
assert!(
|
||||
!native.exists(),
|
||||
"disabling an explicitly adopted native tree removes that owned deployment"
|
||||
);
|
||||
assert!(
|
||||
SkillService::get_ssot_dir()
|
||||
.expect("SSOT")
|
||||
.join("native-skill")
|
||||
.join("SKILL.md")
|
||||
.is_file(),
|
||||
"disabling Pi must preserve the managed SSOT"
|
||||
);
|
||||
assert!(
|
||||
!db.get_installed_skill(&imported[0].id)
|
||||
.expect("read imported row")
|
||||
.expect("row")
|
||||
.apps
|
||||
.pi
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn pi_zip_collision_rolls_back_database_and_ssot_without_touching_native_skill() {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
struct PiDirGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for PiDirGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
|
||||
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
struct StorageLocationGuard(SkillStorageLocation);
|
||||
impl Drop for StorageLocationGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = crate::settings::set_skill_storage_location(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let _home_guard = TestHomeGuard::set(temp.path());
|
||||
let pi_agent_dir = temp.path().join("pi-agent");
|
||||
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
|
||||
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
|
||||
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
|
||||
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
|
||||
.expect("isolated SSOT");
|
||||
|
||||
let native = pi_agent_dir.join("skills").join("collision");
|
||||
write_skill(&native, "Native collision");
|
||||
fs::write(native.join("native.txt"), "must survive").expect("native bytes");
|
||||
|
||||
let mut archive = Vec::new();
|
||||
{
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut archive));
|
||||
let options = SimpleFileOptions::default();
|
||||
zip.start_file("collision/SKILL.md", options)
|
||||
.expect("manifest entry");
|
||||
zip.write_all(b"---\nname: Imported\ndescription: Imported collision\n---\n")
|
||||
.expect("manifest bytes");
|
||||
zip.start_file("collision/imported.txt", options)
|
||||
.expect("payload entry");
|
||||
zip.write_all(b"must not remain").expect("payload bytes");
|
||||
zip.finish().expect("finish zip");
|
||||
}
|
||||
let zip_path = temp.path().join("collision.zip");
|
||||
fs::write(&zip_path, archive).expect("write zip");
|
||||
let db = Arc::new(Database::memory().expect("database"));
|
||||
|
||||
SkillService::install_from_zip(&db, &zip_path, &AppType::Pi)
|
||||
.expect_err("unowned native collision must fail");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(native.join("native.txt")).expect("native survives"),
|
||||
"must survive"
|
||||
);
|
||||
assert!(
|
||||
db.get_installed_skill("local:collision")
|
||||
.expect("read skill")
|
||||
.is_none(),
|
||||
"failed ZIP install must not leave desired state"
|
||||
);
|
||||
assert!(
|
||||
db.get_pi_skill_deployments("local:collision")
|
||||
.expect("read ledger")
|
||||
.is_empty(),
|
||||
"failed ZIP install must not create ownership evidence"
|
||||
);
|
||||
assert!(
|
||||
!SkillService::get_ssot_dir()
|
||||
.expect("SSOT")
|
||||
.join("collision")
|
||||
.exists(),
|
||||
"failed ZIP install must compensate its SSOT copy"
|
||||
);
|
||||
}
|
||||
|
||||
fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: id.to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,8 +54,7 @@ pub fn fresh_input_sql(alias: &str) -> String {
|
||||
format!(
|
||||
"CASE \
|
||||
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \
|
||||
WHEN {prefix}app_type IN ({app_type_list}) \
|
||||
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
|
||||
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
|
||||
AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \
|
||||
THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \
|
||||
WHEN {prefix}app_type IN ({app_type_list}) \
|
||||
@@ -144,6 +143,40 @@ mod tests {
|
||||
assert_eq!(total, 400 + 500 + 450 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_wire_semantics_override_logical_pi_app_type() {
|
||||
let conn = setup_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, input_tokens, cache_read_tokens,
|
||||
cache_creation_tokens, input_token_semantics
|
||||
) VALUES
|
||||
('pi-openai', 'pi', 1000, 700, 100, 1),
|
||||
('pi-anthropic', 'pi', 1000, 700, 100, 2)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sql = format!(
|
||||
"SELECT request_id, {} FROM proxy_request_logs ORDER BY request_id",
|
||||
fresh_input_sql("")
|
||||
);
|
||||
let values: Vec<(String, i64)> = conn
|
||||
.prepare(&sql)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
("pi-anthropic".to_string(), 1000),
|
||||
("pi-openai".to_string(), 200),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_input_handles_codex_with_cache_exceeding_input() {
|
||||
// Defensive: if a malformed Codex row somehow has cache > input,
|
||||
|
||||
@@ -181,6 +181,7 @@ impl StreamCheckService {
|
||||
}
|
||||
AppType::OpenClaw => Self::extract_openclaw_base_url(provider),
|
||||
AppType::Hermes => Self::extract_hermes_base_url(provider),
|
||||
AppType::Pi => Self::extract_pi_base_url(provider),
|
||||
AppType::ClaudeDesktop => ClaudeAdapter::new()
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))),
|
||||
@@ -323,6 +324,31 @@ impl StreamCheckService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pi endpoint inheritance is owned by the pinned composer. Reachability
|
||||
/// checks deliberately consume its first effective model instead of
|
||||
/// reimplementing provider/model fallback rules.
|
||||
fn extract_pi_base_url(provider: &Provider) -> Result<String, AppError> {
|
||||
let config: crate::pi_config::model::PiManagedProviderConfig =
|
||||
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
|
||||
AppError::InvalidInput(format!(
|
||||
"Pi provider '{}' is not a managed native configuration: {error}",
|
||||
provider.id
|
||||
))
|
||||
})?;
|
||||
let composition =
|
||||
crate::pi_config::native::compose_managed_pi_provider(&provider.id, &config)?;
|
||||
composition
|
||||
.models
|
||||
.first()
|
||||
.map(|model| model.base_url.clone())
|
||||
.ok_or_else(|| {
|
||||
AppError::InvalidInput(format!(
|
||||
"Pi provider '{}' has no effective models",
|
||||
provider.id
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }`
|
||||
///
|
||||
/// 用户未显式填 `options.baseURL` 时,按 `npm`(AI SDK 包)回退到包自带默认端点。
|
||||
@@ -501,6 +527,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_reachability_uses_composer_effective_model_endpoint() {
|
||||
let provider = make_provider(serde_json::json!({
|
||||
"name": "Pi",
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://provider.example/v1",
|
||||
"apiKey": "literal",
|
||||
"models": [{
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"baseUrl": "https://model.example/custom",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
StreamCheckService::resolve_base_url(&AppType::Pi, &provider).unwrap(),
|
||||
"https://model.example/custom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_base_url_uses_explicit_url_or_errors_when_missing() {
|
||||
// 有显式 base_url → 直接用
|
||||
|
||||
@@ -137,8 +137,9 @@ pub struct RequestLogDetail {
|
||||
pub output_tokens: u32,
|
||||
pub cache_read_tokens: u32,
|
||||
pub cache_creation_tokens: u32,
|
||||
/// Internal storage semantics; omitted from the UI/API payload.
|
||||
#[serde(skip)]
|
||||
/// Persisted request-level semantics used by both pricing and UI cache
|
||||
/// normalization. This must cross IPC; app-type inference is only a legacy
|
||||
/// fallback for rows written before the semantics column existed.
|
||||
pub input_token_semantics: i64,
|
||||
pub input_cost_usd: String,
|
||||
pub output_cost_usd: String,
|
||||
@@ -230,6 +231,12 @@ fn data_source_expr(log_alias: &str) -> String {
|
||||
format!("COALESCE({log_alias}.data_source, 'proxy')")
|
||||
}
|
||||
|
||||
fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
|
||||
format!(
|
||||
"{left} IN ({right}, CASE WHEN {right} = 'claude' THEN 'claude-desktop' ELSE {right} END)"
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径”
|
||||
/// 上折叠进 `claude`,其余 app_type 原样返回。
|
||||
///
|
||||
@@ -243,8 +250,8 @@ fn data_source_expr(log_alias: &str) -> String {
|
||||
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
|
||||
///
|
||||
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
|
||||
/// proxy_usage_log`)与额度检查(`check_provider_limits`)必须保留原始精确比较。
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的
|
||||
/// [`dedup_app_type_match_sql`],额度检查(`check_provider_limits`)仍保留原始精确比较。
|
||||
fn folded_app_type_sql(column: &str) -> String {
|
||||
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
|
||||
}
|
||||
@@ -298,6 +305,8 @@ fn push_provider_model_filters(
|
||||
pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
let data_source = data_source_expr(log_alias);
|
||||
let proxy_data_source = data_source_expr("proxy_dedup");
|
||||
let app_type_match =
|
||||
dedup_app_type_match_sql("proxy_dedup.app_type", &format!("{log_alias}.app_type"));
|
||||
format!(
|
||||
"NOT (
|
||||
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
|
||||
@@ -305,7 +314,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
SELECT 1
|
||||
FROM proxy_request_logs proxy_dedup
|
||||
WHERE {proxy_data_source} = 'proxy'
|
||||
AND proxy_dedup.app_type = {log_alias}.app_type
|
||||
AND {app_type_match}
|
||||
AND proxy_dedup.status_code >= 200
|
||||
AND proxy_dedup.status_code < 300
|
||||
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
|
||||
@@ -378,12 +387,13 @@ pub(crate) fn has_matching_proxy_usage_log(
|
||||
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
|
||||
|
||||
let l_data_source = data_source_expr("l");
|
||||
let app_type_match = dedup_app_type_match_sql("l.app_type", "?1");
|
||||
let sql = format!(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM proxy_request_logs l
|
||||
WHERE {l_data_source} = 'proxy'
|
||||
AND l.app_type = ?1
|
||||
AND {app_type_match}
|
||||
AND l.status_code >= 200
|
||||
AND l.status_code < 300
|
||||
AND l.input_tokens = ?3
|
||||
@@ -1644,10 +1654,10 @@ impl Database {
|
||||
let detail_sql = format!(
|
||||
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||
status_code, error_message, created_at, l.data_source, l.pricing_model,
|
||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model,
|
||||
l.input_token_semantics
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
@@ -1888,19 +1898,18 @@ impl Database {
|
||||
// 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。
|
||||
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
|
||||
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
|
||||
let cache_inclusive_app =
|
||||
crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str());
|
||||
let billable_input_tokens =
|
||||
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
|
||||
log.input_tokens as u64
|
||||
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
|
||||
(log.input_tokens as u64)
|
||||
.saturating_sub(log.cache_read_tokens as u64)
|
||||
.saturating_sub(log.cache_creation_tokens as u64)
|
||||
} else {
|
||||
// v12 and earlier: input included cache reads but excluded cache writes.
|
||||
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
|
||||
};
|
||||
let billable_input_tokens = if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
|
||||
log.input_tokens as u64
|
||||
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
|
||||
(log.input_tokens as u64)
|
||||
.saturating_sub(log.cache_read_tokens as u64)
|
||||
.saturating_sub(log.cache_creation_tokens as u64)
|
||||
} else if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
|
||||
// v12 and earlier: input included cache reads but excluded cache writes.
|
||||
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
|
||||
} else {
|
||||
log.input_tokens as u64
|
||||
};
|
||||
let input_cost =
|
||||
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
|
||||
let output_cost =
|
||||
@@ -2398,6 +2407,54 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginated_and_detail_ipc_serialize_persisted_input_semantics() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
insert_usage_log(
|
||||
&conn,
|
||||
"pi-semantics-ipc",
|
||||
"pi",
|
||||
"pi-provider",
|
||||
"gpt-test",
|
||||
"request",
|
||||
1,
|
||||
1_000,
|
||||
5,
|
||||
800,
|
||||
0,
|
||||
200,
|
||||
"0",
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_request_logs
|
||||
SET input_token_semantics = ?1
|
||||
WHERE request_id = 'pi-semantics-ipc'",
|
||||
[INPUT_TOKEN_SEMANTICS_TOTAL],
|
||||
)?;
|
||||
}
|
||||
|
||||
let page = db.get_request_logs(&LogFilters::default(), 0, 10)?;
|
||||
let page_json =
|
||||
serde_json::to_value(&page).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert_eq!(
|
||||
page_json["data"][0]["inputTokenSemantics"],
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
);
|
||||
|
||||
let detail = db
|
||||
.get_request_detail("pi-semantics-ipc")?
|
||||
.expect("request detail");
|
||||
let detail_json =
|
||||
serde_json::to_value(detail).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert_eq!(
|
||||
detail_json["inputTokenSemantics"],
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_legacy_nullable_logs_table(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE proxy_request_logs (
|
||||
@@ -2463,6 +2520,75 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_proxy_log_matches_claude_desktop_for_claude_session() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let key = DedupKey {
|
||||
app_type: "claude",
|
||||
model: "claude-sonnet-4-5",
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 10,
|
||||
cache_creation_tokens: 5,
|
||||
created_at: 1060,
|
||||
};
|
||||
assert!(has_matching_proxy_usage_log(&conn, &key)?);
|
||||
|
||||
let mut outside_window = key;
|
||||
outside_window.created_at = 1_601;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &outside_window)?);
|
||||
|
||||
let mut different_model = key;
|
||||
different_model.model = "claude-opus-4-5";
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_model)?);
|
||||
|
||||
let mut different_input = key;
|
||||
different_input.input_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_input)?);
|
||||
|
||||
let mut different_cache_creation = key;
|
||||
different_cache_creation.cache_creation_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(
|
||||
&conn,
|
||||
&different_cache_creation
|
||||
)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_filter_dedups_claude_session_against_desktop_proxy() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES
|
||||
('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy'),
|
||||
('claude-session', 'claude', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1060, 'session_log');",
|
||||
)?;
|
||||
|
||||
let filter = effective_usage_log_filter("l");
|
||||
let sql = format!("SELECT request_id FROM proxy_request_logs l WHERE {filter}");
|
||||
let request_ids = conn
|
||||
.prepare(&sql)?
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
assert_eq!(request_ids, vec!["desktop-proxy"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
|
||||
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode, pi};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -56,7 +56,7 @@ pub struct DeleteSessionOutcome {
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
|
||||
let (r1, r2, r3, r4, r5, r6, r7, r8) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
let h2 = s.spawn(claude::scan_sessions);
|
||||
let h3 = s.spawn(opencode::scan_sessions);
|
||||
@@ -64,6 +64,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let h5 = s.spawn(gemini::scan_sessions);
|
||||
let h6 = s.spawn(hermes::scan_sessions);
|
||||
let h7 = s.spawn(grokbuild::scan_sessions);
|
||||
let h8 = s.spawn(pi::scan_sessions);
|
||||
(
|
||||
h1.join().unwrap_or_default(),
|
||||
h2.join().unwrap_or_default(),
|
||||
@@ -72,6 +73,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
h5.join().unwrap_or_default(),
|
||||
h6.join().unwrap_or_default(),
|
||||
h7.join().unwrap_or_default(),
|
||||
h8.join().unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -83,6 +85,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
sessions.extend(r5);
|
||||
sessions.extend(r6);
|
||||
sessions.extend(r7);
|
||||
sessions.extend(r8);
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
@@ -111,6 +114,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
"gemini" => gemini::load_messages(path),
|
||||
"grokbuild" => grokbuild::load_messages(path),
|
||||
"hermes" => hermes::load_messages(path),
|
||||
"pi" => pi::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
@@ -173,6 +177,7 @@ fn delete_session_with_roots(
|
||||
grokbuild::delete_session(&validated_root, &validated_source, session_id)
|
||||
}
|
||||
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
|
||||
"pi" => pi::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
}
|
||||
@@ -203,6 +208,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
|
||||
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
|
||||
"grokbuild" => grokbuild::session_roots(),
|
||||
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
|
||||
"pi" => pi::session_roots(),
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod grokbuild;
|
||||
pub mod hermes;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
pub mod pi;
|
||||
mod utils;
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, truncate_summary, TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "pi";
|
||||
const MAX_TREE_ENTRIES: usize = 500_000;
|
||||
const MAX_TREE_ID_BYTES: usize = 256;
|
||||
const MAX_SCAN_DEPTH: usize = 8;
|
||||
const MAX_SESSION_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum SessionRootResolution {
|
||||
Available {
|
||||
root: PathBuf,
|
||||
source: &'static str,
|
||||
},
|
||||
RequiresProjectContext {
|
||||
configured_path: String,
|
||||
source: &'static str,
|
||||
},
|
||||
Unavailable {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum PiSessionDiscovery {
|
||||
Available {
|
||||
root: String,
|
||||
source: &'static str,
|
||||
},
|
||||
RequiresProjectContext {
|
||||
#[serde(rename = "configuredPath")]
|
||||
configured_path: String,
|
||||
source: &'static str,
|
||||
},
|
||||
Unavailable {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionHeader {
|
||||
id: String,
|
||||
cwd: String,
|
||||
timestamp: Option<i64>,
|
||||
version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionTree {
|
||||
header: SessionHeader,
|
||||
active_ids: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActiveSessionData {
|
||||
messages: Vec<SessionMessage>,
|
||||
first_user_message: Option<String>,
|
||||
last_message: Option<String>,
|
||||
explicit_name: Option<Option<String>>,
|
||||
last_active_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Pi keeps a relative `sessionDir` relative through SessionManager creation;
|
||||
/// its file operations therefore depend on the launching process cwd. A global
|
||||
/// session browser has no authoritative launch cwd, so relative values are
|
||||
/// deliberately non-enumerable and never fall back to another root.
|
||||
pub fn session_roots() -> Vec<PathBuf> {
|
||||
match resolve_session_root() {
|
||||
SessionRootResolution::Available { root, .. } => vec![root],
|
||||
SessionRootResolution::RequiresProjectContext { .. }
|
||||
| SessionRootResolution::Unavailable { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_discovery() -> PiSessionDiscovery {
|
||||
match resolve_session_root() {
|
||||
SessionRootResolution::Available { root, source } => PiSessionDiscovery::Available {
|
||||
root: root.to_string_lossy().into_owned(),
|
||||
source,
|
||||
},
|
||||
SessionRootResolution::RequiresProjectContext {
|
||||
configured_path,
|
||||
source,
|
||||
} => PiSessionDiscovery::RequiresProjectContext {
|
||||
configured_path,
|
||||
source,
|
||||
},
|
||||
SessionRootResolution::Unavailable { reason } => PiSessionDiscovery::Unavailable { reason },
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_session_root() -> SessionRootResolution {
|
||||
let home = crate::config::get_home_dir();
|
||||
if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR") {
|
||||
if !raw.is_empty() {
|
||||
return classify_configured_session_dir(
|
||||
raw.to_string_lossy().as_ref(),
|
||||
&home,
|
||||
"environment",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match crate::pi_config::native_settings::read_pi_native_defaults() {
|
||||
Ok(defaults) => {
|
||||
if let Some(value) = defaults.session_dir.filter(|value| !value.is_empty()) {
|
||||
return classify_configured_session_dir(&value, &home, "settings");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SessionRootResolution::Unavailable {
|
||||
reason: error.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match crate::pi_config::native::get_pi_agent_dir() {
|
||||
Ok(agent_dir) => SessionRootResolution::Available {
|
||||
root: agent_dir.join("sessions"),
|
||||
source: "default",
|
||||
},
|
||||
Err(error) => SessionRootResolution::Unavailable {
|
||||
reason: error.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_configured_session_dir(
|
||||
value: &str,
|
||||
home: &Path,
|
||||
source: &'static str,
|
||||
) -> SessionRootResolution {
|
||||
match resolve_global_session_dir(value, home) {
|
||||
Some(root) => SessionRootResolution::Available { root, source },
|
||||
None => SessionRootResolution::RequiresProjectContext {
|
||||
configured_path: value.to_string(),
|
||||
source,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global_session_dir(value: &str, home: &Path) -> Option<PathBuf> {
|
||||
let path = if value == "~" {
|
||||
home.to_path_buf()
|
||||
} else if let Some(suffix) = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"))
|
||||
{
|
||||
home.join(suffix)
|
||||
} else {
|
||||
PathBuf::from(value)
|
||||
};
|
||||
path.is_absolute().then_some(path)
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let Some(root) = session_roots().into_iter().next() else {
|
||||
match session_discovery() {
|
||||
PiSessionDiscovery::RequiresProjectContext {
|
||||
configured_path, ..
|
||||
} => log::warn!(
|
||||
"Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated"
|
||||
),
|
||||
PiSessionDiscovery::Unavailable { reason } => {
|
||||
log::warn!("Pi session discovery unavailable: {reason}")
|
||||
}
|
||||
PiSessionDiscovery::Available { .. } => {}
|
||||
}
|
||||
return Vec::new();
|
||||
};
|
||||
scan_sessions_in_root(&root)
|
||||
}
|
||||
|
||||
fn scan_sessions_in_root(root: &Path) -> Vec<SessionMeta> {
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(root, 0, &mut files);
|
||||
files
|
||||
.into_iter()
|
||||
.filter_map(|path| match parse_session(&path) {
|
||||
Ok(session) => Some(session),
|
||||
Err(error) => {
|
||||
log::debug!("Skipping invalid Pi session {}: {error}", path.display());
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let root = session_roots()
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "Relative Pi sessionDir cannot be globally resolved".to_string())?;
|
||||
load_messages_with_root(&root, path)
|
||||
}
|
||||
|
||||
fn load_messages_with_root(root: &Path, path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let (_, source) = validate_source_under_root(root, path)?;
|
||||
let tree = read_tree(&source)?;
|
||||
Ok(read_active_data(&source, &tree)?.messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if !is_valid_tree_id(session_id) {
|
||||
return Err("Invalid Pi session ID".to_string());
|
||||
}
|
||||
let (_, source) = validate_source_under_root(root, path)?;
|
||||
let tree = read_tree(&source)?;
|
||||
if tree.header.id != session_id {
|
||||
return Err(format!(
|
||||
"Pi session ID mismatch: expected {session_id}, found {}",
|
||||
tree.header.id
|
||||
));
|
||||
}
|
||||
fs::remove_file(&source)
|
||||
.map_err(|error| format!("Failed to delete Pi session {}: {error}", source.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Result<SessionMeta, String> {
|
||||
let source = path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?;
|
||||
let source_path = source
|
||||
.to_str()
|
||||
.ok_or_else(|| "Pi session path is not valid UTF-8".to_string())?
|
||||
.to_string();
|
||||
let tree = read_tree(&source)?;
|
||||
let data = read_active_data(&source, &tree)?;
|
||||
let title = data.explicit_name.flatten().or_else(|| {
|
||||
data.first_user_message
|
||||
.as_deref()
|
||||
.map(|message| truncate_summary(message, TITLE_MAX_CHARS))
|
||||
.filter(|message| !message.is_empty())
|
||||
.or_else(|| path_basename(&tree.header.cwd))
|
||||
});
|
||||
let summary = data
|
||||
.last_message
|
||||
.as_deref()
|
||||
.map(|message| truncate_summary(message, 160))
|
||||
.filter(|message| !message.is_empty());
|
||||
Ok(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: tree.header.id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir: (!tree.header.cwd.trim().is_empty()).then(|| tree.header.cwd.clone()),
|
||||
created_at: tree.header.timestamp,
|
||||
last_active_at: data.last_active_at.or(tree.header.timestamp),
|
||||
source_path: Some(source_path.clone()),
|
||||
resume_command: Some(format!(
|
||||
"pi --session {}",
|
||||
crate::session_manager::terminal::shell_escape(&source_path)
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_tree(path: &Path) -> Result<SessionTree, String> {
|
||||
validate_file_size(path)?;
|
||||
let reader = BufReader::new(
|
||||
File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?,
|
||||
);
|
||||
let mut header = None;
|
||||
let mut parents = HashMap::<String, Option<String>>::new();
|
||||
let mut latest_id = None;
|
||||
let mut legacy_previous_id = None;
|
||||
let mut entry_index = 0usize;
|
||||
for line in reader.lines() {
|
||||
let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if header.is_none() {
|
||||
header = Some(parse_header(&value)?);
|
||||
continue;
|
||||
}
|
||||
entry_index += 1;
|
||||
if entry_index > MAX_TREE_ENTRIES {
|
||||
return Err(format!(
|
||||
"Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit"
|
||||
));
|
||||
}
|
||||
let version = header
|
||||
.as_ref()
|
||||
.map_or(1, |item: &SessionHeader| item.version);
|
||||
let Some((id, parent_id)) =
|
||||
entry_identity(&value, version, entry_index, legacy_previous_id.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if parents.insert(id.clone(), parent_id).is_some() {
|
||||
return Err(format!("Pi session contains duplicate entry ID: {id}"));
|
||||
}
|
||||
latest_id = Some(id.clone());
|
||||
legacy_previous_id = Some(id);
|
||||
}
|
||||
let header = header.ok_or_else(|| "Pi session has no valid header".to_string())?;
|
||||
let mut active_ids = HashSet::new();
|
||||
let mut current = latest_id;
|
||||
while let Some(id) = current {
|
||||
if !active_ids.insert(id.clone()) {
|
||||
return Err(format!("Pi session tree contains a cycle at entry {id}"));
|
||||
}
|
||||
current = parents
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("Pi session entry references missing parent: {id}"))?
|
||||
.clone();
|
||||
}
|
||||
Ok(SessionTree { header, active_ids })
|
||||
}
|
||||
|
||||
fn read_active_data(path: &Path, tree: &SessionTree) -> Result<ActiveSessionData, String> {
|
||||
validate_file_size(path)?;
|
||||
let reader = BufReader::new(
|
||||
File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?,
|
||||
);
|
||||
let mut data = ActiveSessionData::default();
|
||||
let mut saw_header = false;
|
||||
let mut entry_index = 0usize;
|
||||
let mut legacy_previous_id = None;
|
||||
for line in reader.lines() {
|
||||
let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if !saw_header {
|
||||
if value.get("type").and_then(Value::as_str) == Some("session") {
|
||||
saw_header = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entry_index += 1;
|
||||
if entry_index > MAX_TREE_ENTRIES {
|
||||
return Err(format!(
|
||||
"Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit"
|
||||
));
|
||||
}
|
||||
let Some((id, _)) = entry_identity(
|
||||
&value,
|
||||
tree.header.version,
|
||||
entry_index,
|
||||
legacy_previous_id.as_deref(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
legacy_previous_id = Some(id.clone());
|
||||
if value.get("type").and_then(Value::as_str) == Some("session_info") {
|
||||
data.explicit_name = Some(
|
||||
value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
if !tree.active_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let entry_timestamp = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
if let Some(timestamp) = entry_timestamp {
|
||||
data.last_active_at = Some(timestamp);
|
||||
}
|
||||
match value.get("type").and_then(Value::as_str) {
|
||||
Some("session_info") => {}
|
||||
Some("message") => {
|
||||
let Some((role, content)) = value.get("message").and_then(parse_message) else {
|
||||
continue;
|
||||
};
|
||||
let timestamp = value
|
||||
.get("message")
|
||||
.and_then(|message| message.get("timestamp"))
|
||||
.and_then(parse_timestamp_to_ms)
|
||||
.or(entry_timestamp);
|
||||
if role == "user" && data.first_user_message.is_none() {
|
||||
data.first_user_message = Some(content.clone());
|
||||
}
|
||||
if matches!(role.as_str(), "user" | "assistant") {
|
||||
data.last_message = Some(content.clone());
|
||||
}
|
||||
data.messages.push(SessionMessage {
|
||||
role,
|
||||
content,
|
||||
ts: timestamp,
|
||||
});
|
||||
}
|
||||
Some("compaction") | Some("branch_summary") => {
|
||||
push_system(
|
||||
&mut data.messages,
|
||||
value
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
entry_timestamp,
|
||||
);
|
||||
}
|
||||
Some("custom_message")
|
||||
if value.get("display").and_then(Value::as_bool) != Some(false) =>
|
||||
{
|
||||
push_system(
|
||||
&mut data.messages,
|
||||
&value.get("content").map(extract_text).unwrap_or_default(),
|
||||
entry_timestamp,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn push_system(messages: &mut Vec<SessionMessage>, content: &str, ts: Option<i64>) {
|
||||
if !content.trim().is_empty() {
|
||||
messages.push(SessionMessage {
|
||||
role: "system".to_string(),
|
||||
content: content.to_string(),
|
||||
ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(value: &Value) -> Result<SessionHeader, String> {
|
||||
if value.get("type").and_then(Value::as_str) != Some("session") {
|
||||
return Err("Pi session header must be the first valid JSON entry".to_string());
|
||||
}
|
||||
let id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|id| is_valid_tree_id(id))
|
||||
.ok_or_else(|| "Pi session header has an invalid ID".to_string())?
|
||||
.to_string();
|
||||
let version = value.get("version").and_then(Value::as_u64).unwrap_or(1);
|
||||
if !(1..=3).contains(&version) {
|
||||
return Err(format!("Unsupported Pi session version: {version}"));
|
||||
}
|
||||
Ok(SessionHeader {
|
||||
id,
|
||||
cwd: value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
timestamp: value.get("timestamp").and_then(parse_timestamp_to_ms),
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_identity(
|
||||
value: &Value,
|
||||
version: u64,
|
||||
entry_index: usize,
|
||||
legacy_previous_id: Option<&str>,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
if version < 2 {
|
||||
return Some((
|
||||
format!("legacy-{entry_index}"),
|
||||
legacy_previous_id.map(str::to_string),
|
||||
));
|
||||
}
|
||||
let id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|id| is_valid_tree_id(id))?
|
||||
.to_string();
|
||||
let parent_id = match value.get("parentId") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(parent)) if is_valid_tree_id(parent) => Some(parent.clone()),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, parent_id))
|
||||
}
|
||||
|
||||
fn parse_message(message: &Value) -> Option<(String, String)> {
|
||||
let role = message.get("role").and_then(Value::as_str)?;
|
||||
let (display_role, content) = match role {
|
||||
"user" | "assistant" => (
|
||||
role.to_string(),
|
||||
message.get("content").map(extract_text).unwrap_or_default(),
|
||||
),
|
||||
"toolResult" => (
|
||||
"tool".to_string(),
|
||||
message.get("content").map(extract_text).unwrap_or_default(),
|
||||
),
|
||||
"bashExecution" => (
|
||||
"tool".to_string(),
|
||||
format!(
|
||||
"$ {}\n{}",
|
||||
message
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
message
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
),
|
||||
"branchSummary" | "compactionSummary" => (
|
||||
"system".to_string(),
|
||||
message
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
(!content.trim().is_empty()).then_some((display_role, content))
|
||||
}
|
||||
|
||||
fn validate_source_under_root(root: &Path, path: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
let root = root.canonicalize().map_err(|error| {
|
||||
format!(
|
||||
"Failed to resolve Pi session root {}: {error}",
|
||||
root.display()
|
||||
)
|
||||
})?;
|
||||
let source = path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?;
|
||||
if !source.starts_with(&root) {
|
||||
return Err(format!(
|
||||
"Pi session source is outside the session root: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&source)
|
||||
.map_err(|error| format!("Failed to inspect Pi session {}: {error}", source.display()))?;
|
||||
if !metadata.file_type().is_file()
|
||||
|| source.extension().and_then(|value| value.to_str()) != Some("jsonl")
|
||||
|| metadata.len() > MAX_SESSION_BYTES
|
||||
{
|
||||
return Err(format!("Invalid Pi session file: {}", source.display()));
|
||||
}
|
||||
Ok((root, source))
|
||||
}
|
||||
|
||||
fn validate_file_size(path: &Path) -> Result<(), String> {
|
||||
let metadata =
|
||||
fs::metadata(path).map_err(|error| format!("Failed to inspect Pi session: {error}"))?;
|
||||
if metadata.len() > MAX_SESSION_BYTES {
|
||||
Err(format!(
|
||||
"Pi session exceeds the {MAX_SESSION_BYTES}-byte safety limit"
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_tree_id(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
!bytes.is_empty()
|
||||
&& bytes.len() <= MAX_TREE_ID_BYTES
|
||||
&& bytes.first().is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& bytes.last().is_some_and(u8::is_ascii_alphanumeric)
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, depth: usize, output: &mut Vec<PathBuf>) {
|
||||
if depth > MAX_SCAN_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
collect_jsonl_files(&path, depth + 1, output);
|
||||
} else if file_type.is_file()
|
||||
&& path.extension().and_then(|value| value.to_str()) == Some("jsonl")
|
||||
&& entry
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.len() <= MAX_SESSION_BYTES)
|
||||
{
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn latest_leaf_defines_the_active_branch() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
fs::create_dir_all(&root).expect("root");
|
||||
let path = root.join("tree.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\
|
||||
{\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"question\"}}\n\
|
||||
{\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\
|
||||
{\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"active\"}}\n",
|
||||
)
|
||||
.expect("session");
|
||||
let messages = load_messages_with_root(&root, &path).expect("messages");
|
||||
assert_eq!(
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| message.content)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["question", "active"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_matches_global_name_and_malformed_line_semantics() {
|
||||
// Executed by scripts/pi-transport-capture.mjs against pinned Pi:
|
||||
// getSessionName() keeps the latest global session_info even when its
|
||||
// branch is inactive, and SessionManager.open() skips a malformed line.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("captured.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\
|
||||
{\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"root\"}}\n\
|
||||
{not valid json\n\
|
||||
{\"type\":\"session_info\",\"id\":\"dead-name\",\"parentId\":\"root\",\"name\":\"Abandoned branch name\"}\n\
|
||||
{\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"dead-name\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\
|
||||
{\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"user\",\"content\":\"active branch\"}}\n",
|
||||
)
|
||||
.expect("captured session");
|
||||
|
||||
let session = parse_session(&path).expect("parse capture semantics");
|
||||
assert_eq!(session.title.as_deref(), Some("Abandoned branch name"));
|
||||
assert_eq!(session.summary.as_deref(), Some("active branch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_root_is_explicitly_non_enumerable() {
|
||||
assert_eq!(
|
||||
resolve_global_session_dir(".pi/sessions", Path::new("/home/pi")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
classify_configured_session_dir(".pi/sessions", Path::new("/home/pi"), "settings"),
|
||||
SessionRootResolution::RequiresProjectContext {
|
||||
configured_path: ".pi/sessions".to_string(),
|
||||
source: "settings",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_generated_v3_shape_round_trips_all_consumed_fields() {
|
||||
// Generated by scripts/pi-transport-capture.mjs against pinned Pi
|
||||
// ab366ebe94cacd419d986be454f12b1b9913aaca using SessionManager APIs.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
fs::create_dir_all(&root).expect("root");
|
||||
let path = root.join("captured.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
"{\"type\":\"session\",\"version\":3,\"id\":\"cc-switch-capture-session\",\"timestamp\":\"2023-11-14T22:13:20.000Z\",\"cwd\":\"/work/captured\",\"parentSession\":null}\n\
|
||||
{\"type\":\"session_info\",\"id\":\"00000000-0000-7000-8000-000000000001\",\"parentId\":null,\"timestamp\":\"2023-11-14T22:13:20.100Z\",\"name\":\"Captured session\"}\n\
|
||||
{\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000002\",\"parentId\":\"00000000-0000-7000-8000-000000000001\",\"timestamp\":\"2023-11-14T22:13:20.200Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"captured question\"}],\"timestamp\":1700000000000}}\n\
|
||||
{\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000003\",\"parentId\":\"00000000-0000-7000-8000-000000000002\",\"timestamp\":\"2023-11-14T22:13:21.200Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"captured answer\"}],\"api\":\"openai-responses\",\"provider\":\"capture\",\"model\":\"capture-model\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1700000001000}}\n",
|
||||
)
|
||||
.expect("captured session");
|
||||
|
||||
let session = parse_session(&path).expect("parse capture-generated session");
|
||||
assert_eq!(session.session_id, "cc-switch-capture-session");
|
||||
assert_eq!(session.title.as_deref(), Some("Captured session"));
|
||||
assert_eq!(session.summary.as_deref(), Some("captured answer"));
|
||||
assert_eq!(session.project_dir.as_deref(), Some("/work/captured"));
|
||||
assert_eq!(session.created_at, Some(1_700_000_000_000));
|
||||
assert_eq!(session.last_active_at, Some(1_700_000_001_200));
|
||||
// scripts/pi-transport-capture.mjs executes pinned Pi's parseArgs with
|
||||
// ["--session", <SessionManager.getSessionFile()>] and records that the
|
||||
// exact path is returned in Args.session.
|
||||
assert!(session
|
||||
.resume_command
|
||||
.as_deref()
|
||||
.is_some_and(|command| command.starts_with("pi --session ")));
|
||||
|
||||
let messages = load_messages_with_root(&root, &path).expect("load messages");
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| (message.role.as_str(), message.content.as_str(), message.ts))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("user", "captured question", Some(1_700_000_000_000)),
|
||||
("assistant", "captured answer", Some(1_700_000_001_000)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_requires_containment_and_matching_header_id() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let root = temp.path().join("sessions");
|
||||
fs::create_dir_all(&root).expect("root");
|
||||
let path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
"{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n",
|
||||
)
|
||||
.expect("session");
|
||||
assert!(delete_session(&root, &path, "other").is_err());
|
||||
assert!(path.exists());
|
||||
assert!(delete_session(&root, &path, "session-1").expect("delete"));
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,7 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
||||
///
|
||||
/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」
|
||||
/// 的 `'\''` 序列绕过。
|
||||
fn shell_escape(value: &str) -> String {
|
||||
pub(crate) fn shell_escape(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user