14d7b21df0
client.ts был god-object'ом на 5206 строк / ~65 методов / 5 ответственностей — любая правка рисковала всем write-path. Разнесли на доменные модули; DocmostClient остаётся ТОНКИМ ФАСАДОМ с прежним внешним контрактом. Чистый рефакторинг, поведение не меняется. closes #450 - Фасад client.ts (93 строки) композирует 10 миксинов над общим абстрактным базовым классом. Паттерн МИКСИНЫ (не context-object): тесты субклассируют DocmostClient и переопределяют seam'ы; единая цепочка прототипов сохраняет виртуальную диспетчеризацию this.<method> сквозь модули. - client/context.ts (база): общее состояние (axios, apiUrl, токены, кэши), конструктор + оба интерсептора, login/ensureAuthenticated/paginateAll/ resolvePageId/mutateLiveContentUnlocked + write-seam'ы mutatePage/replacePage. private→protected (внутреннее, не в публичном контракте). - Модули (каждый ≤730): read, pages, nodes-write, media (images/attachments/ drawio), comments, transforms, tables, stash, doc-validate. errors.ts — единый REST error-mapping (довершение #437; тексты сообщений без изменений). - Внешний контракт СОХРАНЁН (доказано компиляцией с обеих сторон): 53 публичных метода через `implements IXMixin` (без ручного зеркала, #446); оба Pick (in-app loader + tool-specs) резолвятся; множество async-методов 59==59. Нагруженные seam'ы (replaceImage один-лок #425, self-resolve #449, единый error-путь) байт-идентичны; ни одного дубля публичного метода. - zod v3→v4: investigate-only, отложено — SDK 1.29 поддерживает v4, но мажор- бамп трогает всю схема-поверхность; отдельным follow-up. Стоит на #449 (#475). Тесты: mcp node --test 800/800 (== база), tsc чисто в client/. Ни одной строки кода не потеряно; убраны 52 дублированных JSDoc-блока. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.9 KiB
TypeScript
166 lines
6.9 KiB
TypeScript
// Central REST error diagnostics (issues #437 + #450). SINGLE place that maps an
|
|
// axios error to the model-facing message. Extracted verbatim from client.ts;
|
|
// the constructor's response interceptor (see client/context.ts) routes every
|
|
// REST call through formatDocmostAxiosError so the whole surface is uniform.
|
|
import axios from "axios";
|
|
|
|
// --- Issue #437: central error diagnostics -------------------------------
|
|
// The agent only ever sees the thrown exception's `error.message`, so a failed
|
|
// tool must return an ACTIONABLE message (method, path, status, and the
|
|
// server's own validation text) instead of the opaque "Request failed with
|
|
// status code 400". These helpers + the response interceptor in the
|
|
// constructor are the single authoritative place that text is composed.
|
|
|
|
// Overall cap on the composed diagnostic message so the model context stays
|
|
// compact and a (whitelisted) server string can never blow up the text.
|
|
const ERROR_MESSAGE_CAP = 300;
|
|
// Only attempt to JSON.parse an arraybuffer body under this size: a larger
|
|
// binary body is never a JSON error envelope, so parsing it just wastes memory
|
|
// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch
|
|
// carries the JSON error envelope as raw bytes here).
|
|
const ERROR_BUFFER_PARSE_CAP = 4096;
|
|
|
|
// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant-
|
|
// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the
|
|
// canonical shape/length is enforced, not the version/variant nibble.
|
|
const FULL_UUID_RE =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/**
|
|
* Throw an actionable error BEFORE any network call when `value` is not a full
|
|
* canonical UUID. Absorbs #436: a truncated/short comment id used to reach the
|
|
* server and bounce back as an opaque 400/404 the agent could not self-correct;
|
|
* failing fast here names the exact fix.
|
|
*/
|
|
export function assertFullUuid(
|
|
tool: string,
|
|
param: string,
|
|
value: string,
|
|
): void {
|
|
if (typeof value !== "string" || !FULL_UUID_RE.test(value)) {
|
|
throw new Error(
|
|
`${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` +
|
|
`019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` +
|
|
`verbatim from listComments / createComment output.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
|
|
// so the message never leaks a host or query params. Resolves a relative
|
|
// config.url against config.baseURL, then discards everything but the path.
|
|
function requestPath(config: any): string {
|
|
const rawUrl = typeof config?.url === "string" ? config.url : "";
|
|
const base =
|
|
typeof config?.baseURL === "string" ? config.baseURL : undefined;
|
|
try {
|
|
// A dummy base makes an absolute config.url parse too; its host is dropped.
|
|
return new URL(rawUrl, base ?? "http://localhost").pathname;
|
|
} catch {
|
|
// Malformed url: still strip any query/fragment manually.
|
|
return rawUrl.split(/[?#]/)[0] || rawUrl;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compose the server-facing message from `error.response.data`, using ONLY the
|
|
* whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the
|
|
* raw response body, headers (Authorization!) and config are NEVER read here —
|
|
* a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in
|
|
* favour of the statusText.
|
|
*/
|
|
function extractServerMessage(data: any, statusText: string): string {
|
|
// class-validator envelope: { message: string | string[], error?: string }.
|
|
if (
|
|
data &&
|
|
typeof data === "object" &&
|
|
!Buffer.isBuffer(data) &&
|
|
!(data instanceof ArrayBuffer)
|
|
) {
|
|
const msg = (data as any).message;
|
|
if (Array.isArray(msg)) {
|
|
const joined = msg.filter((m) => typeof m === "string").join("; ");
|
|
if (joined) return joined;
|
|
} else if (typeof msg === "string" && msg) {
|
|
return msg;
|
|
}
|
|
const err = (data as any).error;
|
|
if (typeof err === "string" && err) return err;
|
|
return statusText;
|
|
}
|
|
|
|
// Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a
|
|
// failed arraybuffer fetch still surfaces the server's validation text.
|
|
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
|
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) {
|
|
try {
|
|
return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText);
|
|
} catch {
|
|
return statusText;
|
|
}
|
|
}
|
|
return statusText;
|
|
}
|
|
|
|
// A raw string / HTML body is never surfaced (may echo server internals).
|
|
return statusText;
|
|
}
|
|
|
|
/**
|
|
* Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic:
|
|
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
|
|
* or, when the request never got a response:
|
|
* `<METHOD> <path> failed: <code> (no response from server)`.
|
|
*
|
|
* Mutates the SAME error object (never a custom subclass) so the live
|
|
* axios.isAxiosError / error.response?.status / config._retry checks around the
|
|
* client keep working, and sets `_docmostFormatted` as a double-processing
|
|
* guard. A no-op on a non-axios or already-formatted error.
|
|
*/
|
|
export function formatDocmostAxiosError(error: any): void {
|
|
if (!error || error._docmostFormatted) return;
|
|
if (!axios.isAxiosError(error)) return;
|
|
|
|
const config: any = error.config ?? {};
|
|
const method =
|
|
typeof config.method === "string" ? config.method.toUpperCase() : "";
|
|
const methodPath = `${method} ${requestPath(config)}`.trim();
|
|
const response = error.response;
|
|
|
|
let message: string;
|
|
if (response) {
|
|
const statusText =
|
|
typeof response.statusText === "string" ? response.statusText : "";
|
|
const serverMessage = extractServerMessage(response.data, statusText);
|
|
message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`;
|
|
// Full body only to stderr under DEBUG (parity with downloadImage).
|
|
if (process.env.DEBUG) {
|
|
console.error(
|
|
"Docmost request failed; response body:",
|
|
JSON.stringify(response.data),
|
|
);
|
|
}
|
|
} else {
|
|
// No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout).
|
|
// Use ONLY error.code, never the raw error.message: axios network messages
|
|
// embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo
|
|
// ENOTFOUND host") and #437's invariant is that the host never reaches the
|
|
// model-visible message. code is set for essentially every real no-response
|
|
// error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full
|
|
// native message still goes to stderr under DEBUG.
|
|
const reason = error.code ?? "network error";
|
|
message = `${methodPath} failed: ${reason} (no response from server)`;
|
|
if (process.env.DEBUG) {
|
|
console.error("Docmost request failed; no response:", error.message);
|
|
}
|
|
}
|
|
|
|
if (message.length > ERROR_MESSAGE_CAP) {
|
|
message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…";
|
|
}
|
|
|
|
error.message = message;
|
|
(error as any)._docmostFormatted = true;
|
|
}
|