diff --git a/lib/client.js b/lib/client.js index 4926129..51e2135 100644 --- a/lib/client.js +++ b/lib/client.js @@ -274,6 +274,8 @@ window.__ModuleLoader__.load({ unsubscribe; /** Revision answered by a superseded write still ahead of the mirror. */ pendingRevision; + /** 最近一次被宿主拒绝的写入原因(原始 message);写入成功后清空。 */ + mutationFailure; constructor(resolveRemote, mirror, namespace) { this.resolveRemote = resolveRemote; this.mirror = mirror; @@ -327,14 +329,17 @@ window.__ModuleLoader__.load({ const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision; const response = await this.mutateRemote(ownedOps, revision); if (!response.ok) { + this.mutationFailure = response.error?.message; await this.recover(generation); return response.error?.message?.includes("SETTINGS_CONFLICT") ? "conflict" : "failed"; } const row = response.value; if (row?.ns !== this.namespace || decodeRow(row.value) === void 0 || typeof row.revision !== "number") { + this.mutationFailure = "the settings write answered an unexpected row"; await this.recover(generation); return "failed"; } + this.mutationFailure = void 0; if (this.disposed) return "accepted"; if (generation === this.writeGeneration) { this.pendingRevision = void 0; @@ -343,6 +348,15 @@ window.__ModuleLoader__.load({ return "accepted"; }); } + /** + * 最近一次被拒绝的写入,宿主给出的原始原因;写入成功后清空。 + * + * 只在诊断提示里使用:固定文案无法区分锁超时、权限拒绝与被拒取值, + * 这段原文是用户唯一能自查的证据。 + */ + lastMutationFailure() { + return this.mutationFailure; + } async mutateRemote(ops, revision) { try { const namespace = this.resolveRemote(); @@ -462,6 +476,7 @@ window.__ModuleLoader__.load({ set: (field, value) => scope.set(field, value), unset: (field) => scope.unset(field), mutate: (ops, revision) => scope.mutate(ops, revision), + lastMutationFailure: () => scope.lastMutationFailure(), refresh: () => { mirror.load(); }, @@ -704,10 +719,11 @@ window.__ModuleLoader__.load({ let outcome; try { outcome = this.closed ? closedResult() : await this.execute(task.command); - } catch { + } catch (error) { outcome = { ...result(), - config: "unconfirmed" + config: "unconfirmed", + configError: readableErrorText(error) }; } task.resolve(outcome); @@ -779,21 +795,43 @@ window.__ModuleLoader__.load({ } return success; } + /** 一次确认尝试的结果,外加宿主拒绝它时的原始原因(成功或未发出时为空)。 */ async commit(ops, baseline) { - if (this.closed) return "cancelled"; + if (this.closed) return { + confirmation: "cancelled", + failure: void 0 + }; const snapshot = this.scope.getSnapshot(); - if (!snapshot.writable || this.scope.mutate === void 0) return "failed"; - if (baseline && [...baseline].some(([field, before]) => !sameValue(before, fieldBaseline(snapshot, field)))) return "conflict"; + if (!snapshot.writable) return { + confirmation: "failed", + failure: "the settings scope is not writable from this page" + }; + if (this.scope.mutate === void 0) return { + confirmation: "failed", + failure: "this host does not expose the atomic settings mutation" + }; + if (baseline && [...baseline].some(([field, before]) => !sameValue(before, fieldBaseline(snapshot, field)))) return { + confirmation: "conflict", + failure: "a pending field changed under this write" + }; try { - return await this.scope.mutate(ops, snapshot.revision); - } catch { - return "failed"; + const confirmation = await this.scope.mutate(ops, snapshot.revision); + return { + confirmation, + failure: confirmation === "accepted" ? void 0 : this.scope.lastMutationFailure?.() + }; + } catch (error) { + return { + confirmation: "failed", + failure: readableErrorText(error) + }; } } async configure(outcome, ops, baseline) { if (ops.length === 0) return true; - const confirmation = await this.commit(ops, baseline); + const { confirmation, failure } = await this.commit(ops, baseline); outcome.config = confirmation === "accepted" ? "confirmed" : confirmation === "conflict" ? "conflict" : "unconfirmed"; + if (failure !== void 0 && confirmation !== "accepted" && confirmation !== "conflict") outcome.configError = failure; if (confirmation === "cancelled" || this.closed) outcome.issue = "closed"; return confirmation === "accepted" && !this.closed; } @@ -3243,7 +3281,9 @@ window.__ModuleLoader__.load({ saved: "设置已保存", saveFailed: "保存失败,请重试。", writeConflict: "待提交字段已被其他操作修改,配置未写入;请核对后再保存。", - writeConfigUnconfirmed: "配置提交未获确认;密钥未写入,请核对宿主状态后重试。", + writeConfigUnconfirmed: "配置提交未获确认,请核对宿主状态后重试。", + writeConfigFailedDetail: "宿主返回:{error}", + writeConfigRetryable: "这条原因看起来是临时的(配置档案被占用或等待写入锁超时),稍后重试通常能成功。", writeConfigSaved: "配置已保存。", writeKeyNotIssued: "密钥操作尚未发出;请核对后重试。", writeKeyUnconfirmed: "密钥操作未获确认;请核对后手动重试。", @@ -3289,7 +3329,7 @@ window.__ModuleLoader__.load({ usageServiceUnavailable: "Command Code 服务暂时不可用", usageServiceUnavailableHint: "服务端返回了错误(5xx),稍后点击刷新重试。", usageNetworkError: "无法连接 Command Code 服务", - usageNetworkHint: "所有请求都没有到达服务端。请检查网络连接或 API 地址设置。", + usageNetworkHint: "所有请求都没有到达服务端。每条失败记录都会列出它尝试的地址,请核对其中的主机与端口是否和 API 地址设置一致,并检查网络连接。", usageInvalidResponse: "Command Code 响应无法读取", usageInvalidResponseHint: "已收到响应,但正文读取或解析失败。下方详情可区分读取超时、中断与格式异常;可先点击刷新重试。", usageUpdated: "更新于", @@ -3514,7 +3554,9 @@ window.__ModuleLoader__.load({ saved: "Settings saved", saveFailed: "Save failed, please retry.", writeConflict: "Pending fields were modified by another operation; the configuration was not written. Please review and save again.", - writeConfigUnconfirmed: "The configuration submission was not confirmed; the key was not written. Please check the host state and retry.", + writeConfigUnconfirmed: "The configuration submission was not confirmed. Please check the host state and retry.", + writeConfigFailedDetail: "The host reported: {error}", + writeConfigRetryable: "That reason looks transient (a busy profile or a lock timeout); retrying usually works.", writeConfigSaved: "Configuration saved.", writeKeyNotIssued: "The key operation has not been issued; please review and retry.", writeKeyUnconfirmed: "The key operation was not confirmed; please review and retry manually.", @@ -3560,7 +3602,7 @@ window.__ModuleLoader__.load({ usageServiceUnavailable: "The Command Code service is temporarily unavailable", usageServiceUnavailableHint: "The server returned errors (5xx); try Refresh again later.", usageNetworkError: "Could not reach the Command Code service", - usageNetworkHint: "No request reached the server. Check your network connection or the API base setting.", + usageNetworkHint: "No request reached the server. Each failed attempt lists the address it tried — compare its host and port with the API base setting, and check the network connection.", usageInvalidResponse: "Could not read the Command Code response", usageInvalidResponseHint: "Responses arrived, but reading or parsing their bodies failed. Details below distinguish timeouts, interrupted reads, and invalid formats. Try Refresh again.", usageUpdated: "Updated", @@ -3703,6 +3745,11 @@ window.__ModuleLoader__.load({ else if (result.credential === "unconfirmed") key = "writeKeyUnconfirmed"; else if (result.credential === "not-issued") key = "writeKeyNotIssued"; const messages = key ? [t(key)] : []; + if (result.configError !== void 0 && result.configError !== "") { + const reason = result.configError.length > 200 ? `${result.configError.slice(0, 200)}…` : result.configError; + messages.push(t("writeConfigFailedDetail").replace("{error}", reason)); + if (configErrorLooksTransient(result.configError)) messages.push(t("writeConfigRetryable")); + } if (result.issue === "target-changed" && result.credentialRef) { messages.push(t(result.credential === "confirmed" ? "writeKeyConfirmed" : "writeKeyUnconfirmed")); messages.push(t("writeCredentialRefNote").replace("{ref}", result.credentialRef)); @@ -3711,6 +3758,17 @@ window.__ModuleLoader__.load({ if (result.refreshFailed) messages.push(t("writeRefreshFailed")); return messages.join(" "); } + /** + * 宿主拒绝写入的原因是否值得立刻重试。 + * + * 设置写入是“再试一次可能就好了”和“再试一百次也一样”的分界线:配置文件被 + * 别的进程持有的锁挡住(`atomic-write: timed out waiting for the writer lock + * at …`)、宿主报告 `PROFILE_BUSY`、EBUSY/EAGAIN 这类都是前者;权限拒绝、 + * 结构校验失败是后者。判错的代价只是多一句提示,但少这一句用户只能盲试。 + */ + function configErrorLooksTransient(message) { + return /PROFILE_BUSY|EAGAIN|EBUSY|ETIMEDOUT|EAI_AGAIN|writer lock|timed out|timeout|temporarily unavailable|busy/i.test(message); + } //#endregion //#region src/plan-tiers.ts /** Shared plan-tier presentation facts; safe to inline into the browser bundle. */ @@ -7719,7 +7777,7 @@ window.__ModuleLoader__.load({ errorServiceUnavailable: "Command Code 服务暂时不可用", errorServiceUnavailableHint: "服务端返回错误(5xx),请稍后点击刷新重试。", errorNetwork: "无法连接 Command Code 服务", - errorNetworkHint: "所有请求都没有到达服务端。请检查网络连接或 API 地址设置。", + errorNetworkHint: "所有请求都没有到达服务端。每条失败记录都会列出它尝试的地址,请核对其中的主机与端口是否和 API 地址设置一致,并检查网络连接。", errorInvalidResponse: "Command Code 响应无法读取", errorInvalidResponseHint: "已收到响应,但正文读取或解析失败。下方详情可区分读取超时、中断与格式异常;可先点击刷新重试。", errorGeneric: "用量获取失败" @@ -7777,7 +7835,7 @@ window.__ModuleLoader__.load({ errorServiceUnavailable: "The Command Code service is temporarily unavailable", errorServiceUnavailableHint: "The server returned errors (5xx). Try Refresh again in a moment.", errorNetwork: "Could not reach the Command Code service", - errorNetworkHint: "No request reached the server. Check your network connection or the API base setting.", + errorNetworkHint: "No request reached the server. Each failed attempt lists the address it tried — compare its host and port with the API base setting, and check the network connection.", errorInvalidResponse: "Could not read the Command Code response", errorInvalidResponseHint: "Responses arrived, but reading or parsing their bodies failed. Details below distinguish timeouts, interrupted reads, and invalid formats. Try Refresh again.", errorGeneric: "Could not fetch account usage" diff --git a/lib/index.js b/lib/index.js index 60960d7..0cb803b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -8703,11 +8703,12 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { } const failures = []; const failed = []; + const targetOf = (path) => `${base.replace(/\/+$/, "")}${path}`; const getJson = async (path, share = true) => { try { const { status, record, responseFailure } = await this.accountJsonGet(base, key, path, connection.requestTimeoutMs, share); if (record === void 0) { - failures.push(`${path}: HTTP ${status}${responseFailure ? `;${responseFailure}` : ""}`); + failures.push(`${targetOf(path)}: HTTP ${status}${responseFailure ? `;${responseFailure}` : ""}`); failed.push(responseFailure ? { kind: "invalid-response", status @@ -8719,7 +8720,7 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { } return record; } catch (error) { - failures.push(`${path}: ${readableErrorText(error)}`); + failures.push(`${targetOf(path)}: ${readableErrorText(error)}`); failed.push({ kind: "transport" }); return; } @@ -9955,6 +9956,26 @@ function probeOutcomeForStatus(status) { return UP_STATUSES.has(status) ? "ok" : "http-error"; } /** +* Resolve the URL one probe must hit for a configured base. +* +* The configured base is an ENDPOINT, not an origin: every model request this +* plugin issues is built by string concatenation (`${apiBase}${path}`), so the +* base's path prefix is load-bearing. A reverse proxy mounted on a prefix +* (`http://127.0.0.1:18797/bili/https://api.commandcode.ai`) routes only +* inside that prefix — `new URL('/', base)` targets the proxy's own root +* instead and reports an HTTP failure for a base that carries real traffic. +* Keep the configured path and only normalize the trailing slash, so the +* monitor's body-less GET lands on the same shape the real requests use. +* +* Throws when `URL` cannot parse the base; the caller turns that into +* `unreachable`. +*/ +function probeTargetOf(apiBase) { + const parsed = new URL(apiBase.trim()); + if (!parsed.pathname.endsWith("/")) parsed.pathname = `${parsed.pathname}/`; + return parsed.toString(); +} +/** * Probe the configured API base once. Never rejects: an invalid base, a * timeout and a transport failure all resolve to an outcome. * @@ -9966,7 +9987,7 @@ async function probeCommandCode(options) { const now = options.now ?? Date.now; let url; try { - url = new URL("/", options.apiBase).toString(); + url = probeTargetOf(options.apiBase); } catch { return { outcome: "unreachable", diff --git a/src/adapter.ts b/src/adapter.ts index 77c7177..2372f38 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -3170,6 +3170,11 @@ export class CommandCodeAdapter `${base.replace(/\/+$/, '')}${path}` const getJson = async (path: string, share = true): Promise | undefined> => { try { @@ -3185,13 +3190,13 @@ export class CommandCodeAdapter = { saved: '设置已保存', saveFailed: '保存失败,请重试。', writeConflict: '待提交字段已被其他操作修改,配置未写入;请核对后再保存。', - writeConfigUnconfirmed: '配置提交未获确认;密钥未写入,请核对宿主状态后重试。', + writeConfigUnconfirmed: '配置提交未获确认,请核对宿主状态后重试。', + writeConfigFailedDetail: '宿主返回:{error}', + writeConfigRetryable: '这条原因看起来是临时的(配置档案被占用或等待写入锁超时),稍后重试通常能成功。', writeConfigSaved: '配置已保存。', writeKeyNotIssued: '密钥操作尚未发出;请核对后重试。', writeKeyUnconfirmed: '密钥操作未获确认;请核对后手动重试。', @@ -413,7 +417,7 @@ export const zh: Record = { usageServiceUnavailable: 'Command Code 服务暂时不可用', usageServiceUnavailableHint: '服务端返回了错误(5xx),稍后点击刷新重试。', usageNetworkError: '无法连接 Command Code 服务', - usageNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。', + usageNetworkHint: '所有请求都没有到达服务端。每条失败记录都会列出它尝试的地址,请核对其中的主机与端口是否和 API 地址设置一致,并检查网络连接。', usageInvalidResponse: 'Command Code 响应无法读取', usageInvalidResponseHint: '已收到响应,但正文读取或解析失败。下方详情可区分读取超时、中断与格式异常;可先点击刷新重试。', usageUpdated: '更新于', @@ -642,7 +646,9 @@ export const en: Record = { saved: 'Settings saved', saveFailed: 'Save failed, please retry.', writeConflict: 'Pending fields were modified by another operation; the configuration was not written. Please review and save again.', - writeConfigUnconfirmed: 'The configuration submission was not confirmed; the key was not written. Please check the host state and retry.', + writeConfigUnconfirmed: 'The configuration submission was not confirmed. Please check the host state and retry.', + writeConfigFailedDetail: 'The host reported: {error}', + writeConfigRetryable: 'That reason looks transient (a busy profile or a lock timeout); retrying usually works.', writeConfigSaved: 'Configuration saved.', writeKeyNotIssued: 'The key operation has not been issued; please review and retry.', writeKeyUnconfirmed: 'The key operation was not confirmed; please review and retry manually.', @@ -688,7 +694,7 @@ export const en: Record = { usageServiceUnavailable: 'The Command Code service is temporarily unavailable', usageServiceUnavailableHint: 'The server returned errors (5xx); try Refresh again later.', usageNetworkError: 'Could not reach the Command Code service', - usageNetworkHint: 'No request reached the server. Check your network connection or the API base setting.', + usageNetworkHint: 'No request reached the server. Each failed attempt lists the address it tried — compare its host and port with the API base setting, and check the network connection.', usageInvalidResponse: 'Could not read the Command Code response', usageInvalidResponseHint: 'Responses arrived, but reading or parsing their bodies failed. Details below distinguish timeouts, interrupted reads, and invalid formats. Try Refresh again.', usageUpdated: 'Updated', @@ -835,6 +841,15 @@ export function settingsWriteNotice( else if (result.credential === 'unconfirmed') key = 'writeKeyUnconfirmed' else if (result.credential === 'not-issued') key = 'writeKeyNotIssued' const messages = key ? [t(key)] : [] + if (result.configError !== undefined && result.configError !== '') { + // The fixed copy can only say "not confirmed"; the Host's own words are the + // only part the user can act on. A wait for `package.json.lock` that timed + // out, an authorization refusal and a rejected value must be tellable + // apart on the page. Long reasons are clipped so the notice stays a notice. + const reason = result.configError.length > 200 ? `${result.configError.slice(0, 200)}…` : result.configError + messages.push(t('writeConfigFailedDetail').replace('{error}', reason)) + if (configErrorLooksTransient(result.configError)) messages.push(t('writeConfigRetryable')) + } if (result.issue === 'target-changed' && result.credentialRef) { messages.push(t(result.credential === 'confirmed' ? 'writeKeyConfirmed' : 'writeKeyUnconfirmed')) messages.push(t('writeCredentialRefNote').replace('{ref}', result.credentialRef)) @@ -843,3 +858,16 @@ export function settingsWriteNotice( if (result.refreshFailed) messages.push(t('writeRefreshFailed')) return messages.join(' ') } + +/** + * 宿主拒绝写入的原因是否值得立刻重试。 + * + * 设置写入是“再试一次可能就好了”和“再试一百次也一样”的分界线:配置文件被 + * 别的进程持有的锁挡住(`atomic-write: timed out waiting for the writer lock + * at …`)、宿主报告 `PROFILE_BUSY`、EBUSY/EAGAIN 这类都是前者;权限拒绝、 + * 结构校验失败是后者。判错的代价只是多一句提示,但少这一句用户只能盲试。 + */ +function configErrorLooksTransient(message: string): boolean { + return /PROFILE_BUSY|EAGAIN|EBUSY|ETIMEDOUT|EAI_AGAIN|writer lock|timed out|timeout|temporarily unavailable|busy/i + .test(message) +} diff --git a/src/client/panel-copy.ts b/src/client/panel-copy.ts index 29244b9..5cd1c95 100644 --- a/src/client/panel-copy.ts +++ b/src/client/panel-copy.ts @@ -159,7 +159,7 @@ export const PANEL_COPY_ZH: Record = { errorServiceUnavailable: 'Command Code 服务暂时不可用', errorServiceUnavailableHint: '服务端返回错误(5xx),请稍后点击刷新重试。', errorNetwork: '无法连接 Command Code 服务', - errorNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。', + errorNetworkHint: '所有请求都没有到达服务端。每条失败记录都会列出它尝试的地址,请核对其中的主机与端口是否和 API 地址设置一致,并检查网络连接。', errorInvalidResponse: 'Command Code 响应无法读取', errorInvalidResponseHint: '已收到响应,但正文读取或解析失败。下方详情可区分读取超时、中断与格式异常;可先点击刷新重试。', errorGeneric: '用量获取失败', @@ -218,7 +218,7 @@ export const PANEL_COPY_EN: Record = { errorServiceUnavailable: 'The Command Code service is temporarily unavailable', errorServiceUnavailableHint: 'The server returned errors (5xx). Try Refresh again in a moment.', errorNetwork: 'Could not reach the Command Code service', - errorNetworkHint: 'No request reached the server. Check your network connection or the API base setting.', + errorNetworkHint: 'No request reached the server. Each failed attempt lists the address it tried — compare its host and port with the API base setting, and check the network connection.', errorInvalidResponse: 'Could not read the Command Code response', errorInvalidResponseHint: 'Responses arrived, but reading or parsing their bodies failed. Details below distinguish timeouts, interrupted reads, and invalid formats. Try Refresh again.', errorGeneric: 'Could not fetch account usage', diff --git a/src/client/settings-scope.ts b/src/client/settings-scope.ts index 740e35e..fbeff42 100644 --- a/src/client/settings-scope.ts +++ b/src/client/settings-scope.ts @@ -312,6 +312,8 @@ class RemoteSettingsScope implements SettingsScope { private unsubscribe: (() => void) | undefined /** Revision answered by a superseded write still ahead of the mirror. */ private pendingRevision: number | undefined + /** 最近一次被宿主拒绝的写入原因(原始 message);写入成功后清空。 */ + private mutationFailure: string | undefined constructor( resolveRemote: SettingsRemoteResolver, @@ -366,14 +368,20 @@ class RemoteSettingsScope implements SettingsScope { const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision const response = await this.mutateRemote(ownedOps, revision) if (!response.ok) { + // Keep the Host's own words. The fixed client copy ("the key was not + // written") cannot tell a lock timeout from an authorization refusal, + // so the reason has to survive this far to stay diagnosable. + this.mutationFailure = response.error?.message await this.recover(generation) return response.error?.message?.includes('SETTINGS_CONFLICT') ? 'conflict' : 'failed' } const row = response.value if (row?.ns !== this.namespace || decodeRow(row.value) === undefined || typeof row.revision !== 'number') { + this.mutationFailure = 'the settings write answered an unexpected row' await this.recover(generation) return 'failed' } + this.mutationFailure = undefined if (this.disposed) return 'accepted' if (generation === this.writeGeneration) { this.pendingRevision = undefined @@ -385,6 +393,16 @@ class RemoteSettingsScope implements SettingsScope { }) } + /** + * 最近一次被拒绝的写入,宿主给出的原始原因;写入成功后清空。 + * + * 只在诊断提示里使用:固定文案无法区分锁超时、权限拒绝与被拒取值, + * 这段原文是用户唯一能自查的证据。 + */ + lastMutationFailure(): string | undefined { + return this.mutationFailure + } + private async mutateRemote( ops: readonly SettingsPathOp[], revision: number | undefined, @@ -525,6 +543,7 @@ export function createSettingsScope( set: (field, value) => scope.set(field, value), unset: (field) => scope.unset(field), mutate: (ops, revision) => scope.mutate(ops, revision), + lastMutationFailure: () => scope.lastMutationFailure(), refresh: () => { void mirror.load() }, dispose: async () => { for (const dispose of disposers.splice(0)) dispose() diff --git a/src/client/settings-write.ts b/src/client/settings-write.ts index 089e246..b1e8a8a 100644 --- a/src/client/settings-write.ts +++ b/src/client/settings-write.ts @@ -1,6 +1,7 @@ /** 设置写入过程:同一控制器的意图按顺序执行,配置确认与凭据确认分别保留。 * 生产使用现有远端设置/凭据,测试替换这些依赖;不提供跨服务事务。 */ import { readVisibility } from '../model-visibility.ts' +import { readableErrorText } from '../readable-error.ts' import type { SettingsPageApi, SettingsScope, SettingsScopeSnapshot } from './settings.ts' import type { SettingsMutationResult, SettingsPathOp } from './settings-scope.ts' @@ -30,6 +31,12 @@ export interface SettingsWriteResult { refreshFailed: boolean /** 实际发出调用的原引用,仅用于核对;从不包含密钥内容。 */ credentialRef: string | undefined + /** + * 配置写入未确认时,宿主给出的原始原因(`atomic-write: timed out waiting + * for the writer lock …`、`SETTINGS_CONFLICT`、权限拒绝等)。固定文案无法 + * 区分这些情况,提示层必须能把原文交给用户自查。 + */ + configError?: string } export type SettingsWriteIntent = @@ -138,7 +145,7 @@ export class SettingsWriter { if (task === undefined) break let outcome: SettingsWriteResult try { outcome = this.closed ? closedResult() : await this.execute(task.command) } - catch { outcome = { ...result(), config: 'unconfirmed' } } + catch (error: unknown) { outcome = { ...result(), config: 'unconfirmed', configError: readableErrorText(error) } } task.resolve(outcome) } } finally { this.running = false } @@ -197,19 +204,41 @@ export class SettingsWriter { return success } - private async commit(ops: readonly SettingsPathOp[], baseline?: ReadonlyMap): Promise { - if (this.closed) return 'cancelled' + /** 一次确认尝试的结果,外加宿主拒绝它时的原始原因(成功或未发出时为空)。 */ + private async commit( + ops: readonly SettingsPathOp[], + baseline?: ReadonlyMap, + ): Promise<{ confirmation: SettingsMutationResult; failure: string | undefined }> { + if (this.closed) return { confirmation: 'cancelled', failure: undefined } const snapshot = this.scope.getSnapshot() - if (!snapshot.writable || this.scope.mutate === undefined) return 'failed' - if (baseline && [...baseline].some(([field, before]) => !sameValue(before, fieldBaseline(snapshot, field)))) return 'conflict' - try { return await this.scope.mutate(ops, snapshot.revision) } - catch { return 'failed' } + if (!snapshot.writable) { + return { confirmation: 'failed', failure: 'the settings scope is not writable from this page' } + } + if (this.scope.mutate === undefined) { + return { confirmation: 'failed', failure: 'this host does not expose the atomic settings mutation' } + } + if (baseline && [...baseline].some(([field, before]) => !sameValue(before, fieldBaseline(snapshot, field)))) { + return { confirmation: 'conflict', failure: 'a pending field changed under this write' } + } + try { + const confirmation = await this.scope.mutate(ops, snapshot.revision) + return { + confirmation, + failure: confirmation === 'accepted' ? undefined : this.scope.lastMutationFailure?.(), + } + } catch (error: unknown) { + return { confirmation: 'failed', failure: readableErrorText(error) } + } } private async configure(outcome: SettingsWriteResult, ops: readonly SettingsPathOp[], baseline?: ReadonlyMap): Promise { if (ops.length === 0) return true - const confirmation = await this.commit(ops, baseline) + const { confirmation, failure } = await this.commit(ops, baseline) outcome.config = confirmation === 'accepted' ? 'confirmed' : confirmation === 'conflict' ? 'conflict' : 'unconfirmed' + // A conflict already has actionable copy ("pending fields changed"). Every + // other refusal needs the Host verbatim reason, or the user cannot tell a + // lock timeout from an authorization refusal. + if (failure !== undefined && confirmation !== 'accepted' && confirmation !== 'conflict') outcome.configError = failure if (confirmation === 'cancelled' || this.closed) outcome.issue = 'closed' return confirmation === 'accepted' && !this.closed } diff --git a/src/client/settings.ts b/src/client/settings.ts index 81290b5..c06da42 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -31,6 +31,8 @@ export interface SettingsScope { unset(field: string): Promise /** 多字段共用一次宿主版本检查并返回确认;缺少此能力不能提交配置。 */ mutate?(ops: readonly SettingsPathOp[], expectedRevision?: number): Promise + /** 最近一次被拒绝的写入原因(宿主原始 message);只用于诊断提示。 */ + lastMutationFailure?(): string | undefined } /** Result envelope returned by one current Typert Remote call. */ diff --git a/src/service-probe.ts b/src/service-probe.ts index bbf0bba..ce716f7 100644 --- a/src/service-probe.ts +++ b/src/service-probe.ts @@ -1,13 +1,18 @@ /** * Host-side connectivity probe behind `commandcode/probe`. * - * Sends one GET to the configured API base root and classifies the answer the - * way Upptime's monitor does: its default `expectedStatusCodes` are the 2xx - * family (minus 209+ quirks) plus 3xx redirects, so a 200/301 counts as up and + * Sends one GET to the configured API base and classifies the answer the way + * Upptime's monitor does: its default `expectedStatusCodes` are the 2xx family + * (minus 209+ quirks) plus 3xx redirects, so a 200/301 counts as up and * anything else (401, 404, 5xx) is an HTTP-level failure. A timeout or a * network error is reported separately, because "cannot reach it" and "it * answered 500" are different problems for the user. * + * The base is probed AS CONFIGURED, path prefix included: this plugin sends + * model requests by concatenating `${apiBase}${path}`, so a base behind a + * prefix reverse proxy only answers inside that prefix, and collapsing to the + * host root would report a false failure for a base that works. + * * The probe never throws: every failure becomes an outcome the row can render. * The request is deliberately unauthenticated and body-less — it must not touch * the credential seam or the quota, and it measures the same surface the status @@ -37,6 +42,27 @@ export function probeOutcomeForStatus(status: number): ProbeOutcome { return UP_STATUSES.has(status) ? 'ok' : 'http-error' } +/** + * Resolve the URL one probe must hit for a configured base. + * + * The configured base is an ENDPOINT, not an origin: every model request this + * plugin issues is built by string concatenation (`${apiBase}${path}`), so the + * base's path prefix is load-bearing. A reverse proxy mounted on a prefix + * (`http://127.0.0.1:18797/bili/https://api.commandcode.ai`) routes only + * inside that prefix — `new URL('/', base)` targets the proxy's own root + * instead and reports an HTTP failure for a base that carries real traffic. + * Keep the configured path and only normalize the trailing slash, so the + * monitor's body-less GET lands on the same shape the real requests use. + * + * Throws when `URL` cannot parse the base; the caller turns that into + * `unreachable`. + */ +export function probeTargetOf(apiBase: string): string { + const parsed = new URL(apiBase.trim()) + if (!parsed.pathname.endsWith('/')) parsed.pathname = `${parsed.pathname}/` + return parsed.toString() +} + /** * Probe the configured API base once. Never rejects: an invalid base, a * timeout and a transport failure all resolve to an outcome. @@ -55,9 +81,7 @@ export async function probeCommandCode(options: { const now = options.now ?? Date.now let url: string try { - // The monitor probes the host root, not the configured path: a base of - // `https://host/v1` still answers on `/` for the official deployment. - url = new URL('/', options.apiBase).toString() + url = probeTargetOf(options.apiBase) } catch { return { outcome: 'unreachable', status: 0, latencyMs: 0, checkedAt: now() } } diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index dd93719..3cebd96 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -7689,7 +7689,32 @@ test('getUsage() classifies an all-transport-failure run as network', async () = // an unparseable API base and a per-request timeout, so the card renders // these rather than only the generic "check your connection" hint. assert.equal(report.failures.length, 4) - assert.match(report.failures[0] ?? '', /^\/alpha\/whoami: fetch failed/) + // Issue #111: each record names the resolved address, not just the endpoint + // path — a path-only record cannot tell a dead proxy port from a wrong path. + assert.match(report.failures[0] ?? '', /^https:\/\/api\.commandcode\.ai\/alpha\/whoami: fetch failed/) +}) + +test('getUsage() failure records keep a path-prefixed API base intact', async () => { + const base = 'http://127.0.0.1:18797/bili/https://api.commandcode.ai' + const adapter = makeAdapter({ + options: () => ({ + apiBase: base, + workingDir: '/tmp/project', + modelsCachePath: '/tmp/cc-models-cache.json', + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + streamIdleTimeoutMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS, + protocol: 'cli' as const, + }), + fetchImpl: (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch, + }) + const report = await adapter.getUsage('user_test_key') + assert.equal(report.blocked, 'network') + assert.equal(report.failures.length, 4) + for (const failure of report.failures) { + assert.ok(failure.startsWith(`${base}/alpha/`), failure) + } }) test('getUsage() asks for identity encoding when a host would otherwise pass raw Brotli', async () => { diff --git a/tests/service-probe.test.ts b/tests/service-probe.test.ts index 3c374b0..226e404 100644 --- a/tests/service-probe.test.ts +++ b/tests/service-probe.test.ts @@ -4,7 +4,8 @@ * The probe is the row's real-time half, so its classification is the part that * must not drift: the official monitor's default `expectedStatusCodes` decide * what "up" means, a timeout and a transport failure stay distinguishable, and - * the request always goes to the host root. The wire codec is pinned here too, + * the request goes to the CONFIGURED base — path prefix included — instead of + * being collapsed to the host root. The wire codec is pinned here too, * because the browser trusts `parseProbeResult` to reject anything the Host * should never have sent. */ @@ -12,7 +13,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { PROBE_TIMEOUT_MS, probeCommandCode, probeOutcomeForStatus } from '../src/service-probe.ts' +import { PROBE_TIMEOUT_MS, probeCommandCode, probeOutcomeForStatus, probeTargetOf } from '../src/service-probe.ts' import { parseProbeResult } from '../src/service-probe-wire.ts' /** Minimal Response stand-in; the probe only reads `status` and cancels the body. */ @@ -51,13 +52,37 @@ test('probeCommandCode reports a reachable endpoint with latency', async () => { assert.equal(probe.checkedAt, 1234) }) -test('probeCommandCode always probes the host root, even from a pathed base', async () => { +test('probeTargetOf keeps the configured path and normalizes only the trailing slash', () => { + assert.equal(probeTargetOf('https://api.commandcode.ai'), 'https://api.commandcode.ai/') + assert.equal(probeTargetOf('https://example.test/some/path'), 'https://example.test/some/path/') + assert.equal(probeTargetOf('https://example.test/some/path/'), 'https://example.test/some/path/') + // A prefix reverse proxy routes only inside its prefix: collapsing to `/` + // probes the proxy itself and reports a failure for a base that works. + assert.equal( + probeTargetOf('http://127.0.0.1:18797/bili/https://api.commandcode.ai'), + 'http://127.0.0.1:18797/bili/https://api.commandcode.ai/', + ) + assert.throws(() => probeTargetOf('')) +}) + +test('probeCommandCode probes the configured base itself, path prefix included', async () => { const urls: string[] = [] await probeCommandCode({ - apiBase: 'https://example.test/some/path//', + apiBase: 'https://example.test/some/path', fetchImpl: recordingFetch(urls), }) - assert.deepEqual(urls, ['https://example.test/']) + assert.deepEqual(urls, ['https://example.test/some/path/']) +}) + +test('probeCommandCode keeps a reverse-proxy prefix instead of probing the host root', async () => { + const urls: string[] = [] + const probe = await probeCommandCode({ + apiBase: 'http://127.0.0.1:18797/bili/https://api.commandcode.ai', + fetchImpl: recordingFetch(urls, 404), + }) + assert.deepEqual(urls, ['http://127.0.0.1:18797/bili/https://api.commandcode.ai/']) + assert.equal(probe.outcome, 'http-error') + assert.equal(probe.status, 404) }) test('probeCommandCode keeps an HTTP failure distinct from a transport failure', async () => { diff --git a/tests/settings-scope.test.ts b/tests/settings-scope.test.ts index 0f9af9a..841325b 100644 --- a/tests/settings-scope.test.ts +++ b/tests/settings-scope.test.ts @@ -528,3 +528,19 @@ test('销毁后已发配置的接受结果保留,未发任务明确取消', as await closing assert.equal(fake.mutateCalls().length, 1) }) + +test('被拒的写入保留宿主原文,下一次成功写入清空', async () => { + const fake = fakeContext() + fake.answerDescribe({ writable: true, namespaces: [row()] }) + const scope = createSettingsScope>(fake.context, 'llm-commandcode', fake.resolveRemote) + await flush() + fake.answerMutate({ error: 'atomic-write: timed out waiting for the writer lock at /home/u/.dsh/package.json.lock' }) + assert.equal(await scope.mutate([{ op: 'set', path: ['apiBase'], value: 'https://synthetic.example' }]), 'failed') + // Issue #110: the Host's own wording is the only actionable part of the + // notice, so the scope has to keep it instead of collapsing to 'failed'. + assert.match(scope.lastMutationFailure?.() ?? '', /waiting for the writer lock/) + fake.answerMutate({ ...row({ revision: 8 }), ns: 'llm-commandcode' }) + assert.equal(await scope.mutate([{ op: 'set', path: ['apiBase'], value: 'https://synthetic.example' }]), 'accepted') + assert.equal(scope.lastMutationFailure?.(), undefined) + await scope.dispose() +}) diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 91ca435..f7ac35e 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1862,3 +1862,43 @@ test('首次事实读取失败显示未知,明确确认后才解释配置存 assert.equal(controller.state().accounts[0]?.credentialKnown, true) controller.dispose() }) + +test('写入被拒的提示带上宿主原文,只有瞬时原因才标可重试', () => { + const base = { + config: 'unconfirmed' as const, + credential: 'none' as const, + issue: undefined, + refreshFailed: false, + credentialRef: undefined, + } + // Issue #110: "not confirmed" alone cannot separate a lock timeout from an + // authorization refusal, so the Host's own wording rides along — and the + // stale "the key was not written" claim is gone. + const locked = settingsWriteNotice({ + ...base, + configError: 'atomic-write: timed out waiting for the writer lock at /home/u/.dsh/package.json.lock', + }, key => en[key]) + assert.match(locked, /The host reported: atomic-write: timed out waiting for the writer lock/) + assert.match(locked, /looks transient/) + assert.doesNotMatch(locked, /the key was not written/i) + + const denied = settingsWriteNotice({ + ...base, + configError: 'permission denied: cannot write /home/u/.dsh/profiles/web/package.json', + }, key => zh[key]) + assert.match(denied, /宿主返回:permission denied/) + assert.doesNotMatch(denied, /临时的/) +}) + +test('写入被拒的提示截断过长的宿主原文', () => { + const notice = settingsWriteNotice({ + config: 'unconfirmed', + credential: 'none', + issue: undefined, + refreshFailed: false, + credentialRef: undefined, + configError: 'x'.repeat(400), + }, key => en[key]) + assert.ok(notice.includes(`${'x'.repeat(200)}…`)) + assert.ok(!notice.includes('x'.repeat(201))) +})