From f5a7a156c49ebe8c2fefecd1558c1a7f3973bd35 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 05:53:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(cli):=20=E9=81=BF=E5=85=8D=E6=8A=8A?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=B7=B2=E5=8F=97=E7=90=86=E6=88=96=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=9C=AA=E7=9F=A5=E8=AF=AF=E8=AF=BB=E4=B8=BA=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 升级后 status 的 ok 与健康检查退出码一致,HTTP 成功另用 httpOk 表达;未知健康返回 null。传输和协议失败省略无法证明的 retryable 标记,使用 kind/outcome 表达未知结果,避免重复副作用。设备返回依据真实生命周期解释取消、过期和幂等重放,普通工具 JSON 仍保持原义。 --- packages/cli/src/commands/call.ts | 18 +++++-- packages/cli/src/commands/device.ts | 45 +++-------------- packages/cli/src/commands/status.ts | 8 +-- packages/cli/src/deviceOutput.ts | 44 +++++++++++++++++ packages/cli/src/http.ts | 27 ++++++++--- packages/cli/src/output.ts | 7 ++- packages/cli/src/program.ts | 10 ++-- packages/cli/test/argSemantics.test.ts | 4 +- packages/cli/test/callUx.test.ts | 28 +++++++++-- packages/cli/test/device.test.ts | 67 ++++++++++++++++++++++++-- packages/cli/test/http.test.ts | 28 +++++++---- packages/cli/test/status.test.ts | 54 +++++++++++++++++++++ 12 files changed, 262 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/deviceOutput.ts create mode 100644 packages/cli/test/status.test.ts diff --git a/packages/cli/src/commands/call.ts b/packages/cli/src/commands/call.ts index de826fe7..235f57eb 100644 --- a/packages/cli/src/commands/call.ts +++ b/packages/cli/src/commands/call.ts @@ -1,3 +1,4 @@ +import { deviceOperationDetailSchema } from '@tool-bridge/core/protocol' import { readFileSync } from 'node:fs' import { Command } from 'commander' import { collect, parseKeyValueSpecs, parsePositiveInt, resolveTarget, withGlobalOpts } from '../args' @@ -8,6 +9,7 @@ import { type Target, withClient, } from '../http' +import { printDeviceOperation } from '../deviceOutput' import { printJson, printLine } from '../output' import { printMarkdown } from '../markdown' import { readStdinRaw } from '../stdin' @@ -121,8 +123,6 @@ export async function attachFeedbackHint( `hint: known pitfalls from other agents — details: tb feedback get ${cleanPath} `, ...items.map(f => ` - ${f.id} (${f.score >= 0 ? '+' : ''}${f.score}) "${f.title}"`), ].join('\n') - } else { - err.hint = `hint: no known pitfalls recorded for this path yet — if you figure this out, help the next agent:\n tb feedback submit ${cleanPath} --title "" --detail ""` } } catch { // hint 拉取失败不影响主错误报告 @@ -136,7 +136,8 @@ export async function attachFeedbackHint( * `tb call system/status/get`、`tb call docs/context7/resolve-library-id`。 * arguments 四种给法互斥:第二 positional(裸 JSON)/ `--args` / `--args-file`(`-` = stdin)/ * 可重复 `--arg k=v`(扁平标量,见 parseArgScalar)。 - * 默认人类模式:markdown 原样打印;`--json`:输出原始 JSON。TBError → stderr + exit 1。 + * 默认人类模式呈现 Markdown/operation;`--json` 保留原结果或显式 delivery 的 SDK 返回。 + * 错误经根边界落地:文本到 stderr,JSON 到 stdout,均 exit 1。 */ export function callCommand() { return withGlobalOpts(new Command('call')) @@ -233,8 +234,15 @@ Examples: }), ) if (response.status === 202) { - const operation = JSON.parse(response.text) as { operationId?: string, state?: string } - printLine(`queued ${operation.operationId ?? 'operation'} (${operation.state ?? 'queued'})`) + const operation = deviceOperationDetailSchema.safeParse(response.json) + if (!operation.success) { + const error = new CliError('gateway returned an invalid device operation; the request outcome is unknown', 'internal') + error.kind = 'protocol' + error.outcome = 'unknown' + throw error + } + printLine('delivery: mailbox') + printDeviceOperation(operation.data) } else { printMarkdown(response.text) } diff --git a/packages/cli/src/commands/device.ts b/packages/cli/src/commands/device.ts index b092bb45..4cd261ad 100644 --- a/packages/cli/src/commands/device.ts +++ b/packages/cli/src/commands/device.ts @@ -1,11 +1,11 @@ import type { - DeviceOperationDetail, DeviceOperationState, DeviceOperationSummary, } from '@tool-bridge/sdk/client' import { Command } from 'commander' import type { Node, Page } from '../types' import { collect, parsePageOpts, resolveTarget, withGlobalOpts, withPageOpts } from '../args' +import { operationMeaning, printDeviceOperation } from '../deviceOutput' import { callDirect, CliError, withClient } from '../http' import { printJson, printLine, table } from '../output' @@ -56,12 +56,12 @@ export function deviceLsCommand() { // 能被看出来。 printLine( table( - ['DEVICE_ID', 'PATH', 'ONLINE', 'LAST_SEEN', 'DESCRIPTION'], + ['DEVICE_ID', 'PATH', 'RECORDED_ONLINE', 'LAST_SEEN', 'DESCRIPTION'], devices.map(n => [ deviceIdFromPath(n.path), n.path, n.online ? 'yes' : 'no', - n.lastSeenAt ? new Date(n.lastSeenAt).toLocaleString() : '-', + n.lastSeenAt ?? '-', n.description ?? '', ]), ), @@ -81,58 +81,25 @@ function states(values: readonly string[]): DeviceOperationState[] | undefined { return unique as DeviceOperationState[] } -function executionMeaning(operation: DeviceOperationSummary): string { - if (operation.state === 'expired' && operation.executionMayHaveOccurred) { - return 'may-have-run' - } - if (operation.state === 'result_unknown') return 'started/result-unknown' - return '-' -} - function printOperationList(page: { cursor?: string, items: DeviceOperationSummary[] }): void { if (page.items.length === 0) { printLine(page.cursor ? '(no visible operations on this page)' : '(no device operations)') } else { printLine(table( - ['OPERATION_ID', 'STATE', 'TARGET', 'ATTEMPT', 'UPDATED', 'EXECUTION'], + ['OPERATION_ID', 'STATE', 'TARGET', 'CLAIM_ATTEMPTS', 'UPDATED', 'MEANING'], page.items.map(operation => [ operation.operationId, operation.state, operation.targetPath, String(operation.attempt), operation.updatedAt, - executionMeaning(operation), + operationMeaning(operation), ]), )) } if (page.cursor) printLine(`next cursor: ${page.cursor}`) } -function printOperation(operation: DeviceOperationDetail): void { - printLine(table( - ['FIELD', 'VALUE'], - [ - ['operationId', operation.operationId], - ['deviceId', operation.deviceId], - ['state', operation.state], - ['target', operation.targetPath], - ['attempt', String(operation.attempt)], - ['createdAt', operation.createdAt], - ['expiresAt', operation.expiresAt], - ['updatedAt', operation.updatedAt], - ['execution', executionMeaning(operation)], - ...(operation.cancelRequestedAt === undefined - ? [] - : [['cancelRequestedAt', operation.cancelRequestedAt]]), - ], - )) - if (operation.state === 'expired' && operation.executionMayHaveOccurred) { - printLine('warning: this operation was claimed before expiry and may have executed') - } - if (operation.error !== undefined) printLine(`error: ${operation.error.code}: ${operation.error.message}`) - if (operation.result !== undefined) printLine(`result: ${JSON.stringify(operation.result)}`) -} - export function deviceOperationListCommand() { return withPageOpts(withGlobalOpts(new Command('ls'))) .alias('list') @@ -171,7 +138,7 @@ function deviceOperationReadCommand(command: 'get' | 'cancel') { ? await client.deviceOperations.get(deviceId, operationId) : await client.deviceOperations.cancel(deviceId, operationId)) if (opts.json) printJson(operation) - else printOperation(operation) + else printDeviceOperation(operation) }) } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0aad0213..0b0fb43c 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -36,12 +36,13 @@ export function statusCommand() { } const parsed = (typeof body === 'object' && body !== null ? body : {}) as HealthzBody - const healthy = res.ok && parsed.healthy === true + const healthy = typeof parsed.healthy === 'boolean' ? res.ok && parsed.healthy : null if (asJson) { process.stdout.write( `${JSON.stringify({ - ok: res.ok, + ok: healthy === true, + httpOk: res.ok, status: res.status, healthy, url, @@ -51,7 +52,8 @@ export function statusCommand() { ) } else { process.stdout.write(`endpoint: ${url}\n`) - process.stdout.write(`status: ${res.status} (${healthy ? 'healthy' : 'unhealthy'})\n`) + process.stdout.write(`http: ${res.status}\n`) + process.stdout.write(`health: ${healthy === null ? 'unknown (response did not report a valid health state)' : healthy ? 'healthy' : 'unhealthy'}\n`) if (parsed.version) process.stdout.write(`version: ${parsed.version}\n`) } diff --git a/packages/cli/src/deviceOutput.ts b/packages/cli/src/deviceOutput.ts new file mode 100644 index 00000000..bba76524 --- /dev/null +++ b/packages/cli/src/deviceOutput.ts @@ -0,0 +1,44 @@ +import type { DeviceOperationDetail, DeviceOperationSummary } from '@tool-bridge/sdk/client' +import { printLine, table } from './output' + +/** 描述记录能证明的状态;claim/barrier 均不能证明 handler 已经执行。 */ +export function operationMeaning(operation: DeviceOperationSummary): string { + switch (operation.state) { + case 'queued': return 'waiting for the device to claim it' + case 'claimed': return operation.cancelRequestedAt === undefined + ? 'claimed by the device; completion not reported' + : 'cancellation requested; execution may still continue' + case 'succeeded': return 'device reported success' + case 'failed': return 'device reported failure' + case 'rejected': return 'device rejected the operation' + case 'cancelled': return 'cancelled before execution' + case 'result_unknown': return 'may have started; result unknown' + case 'expired': return operation.executionMayHaveOccurred + ? 'expired; may have executed, result unknown' + : 'expired before execution' + } +} + +export function printDeviceOperation(operation: DeviceOperationDetail): void { + printLine(`state: ${operation.state} (${operationMeaning(operation)})`) + printLine(table( + ['FIELD', 'VALUE'], + [ + ['operation', operation.operationId], + ['device', operation.deviceId], + ['command', operation.targetPath], + ['claim attempts', String(operation.attempt)], + ['created', operation.createdAt], + ['expires', operation.expiresAt], + ['updated', operation.updatedAt], + ...(operation.cancelRequestedAt === undefined + ? [] + : [['cancellation requested', operation.cancelRequestedAt]]), + ], + )) + if (operation.state === 'result_unknown' || (operation.state === 'expired' && operation.executionMayHaveOccurred)) { + printLine('Execution is uncertain; check the business result before retrying.') + } + if (operation.error !== undefined) printLine(`error: ${operation.error.code}: ${operation.error.message}`) + if (operation.result !== undefined) printLine(`result: ${JSON.stringify(operation.result)}`) +} diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index 864aeefe..8d82e235 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -1,5 +1,6 @@ /** CLI 宿主适配:SDK neutral client + CliError/对象直传边界。 */ import { + type ClientErrorKind, type ContextUploadGrant, createToolBridgeClient, parseContextUploadGrant as parseSdkContextUploadGrant, @@ -13,8 +14,10 @@ import { /** CLI 错误:携带可选 TBError code/retryable,统一由 output.reportError 落地为退出码 1。 */ export class CliError extends Error { readonly code?: string - /** TBError 的 retryable 语义(true → 呈现"try again"提示);本地错误缺席。 */ + /** 服务端的 retryable 标记;传输失败无法判断重试安全性,留空。 */ readonly retryable?: boolean + kind?: ClientErrorKind + outcome?: 'unknown' /** 附加提示(如 ~feedback 已知坑),reportError 在主错误后落地。 */ hint?: string /** 该 path 的 feedback 头部条目(--json 时结构化输出)。 */ @@ -76,27 +79,37 @@ export interface ApiResult { /** 无显式 --timeout 时的单请求等待上限(上游长查询可用 --timeout 加大)。 */ export const DEFAULT_TIMEOUT_MS = 120_000 +function uncertainResponse(message: string, code: string, kind: ClientErrorKind): CliError { + const error = new CliError(`${message}; the operation outcome is unknown. Check its state before retrying`, code) + error.kind = kind + error.outcome = 'unknown' + return error +} + function clientError(error: unknown, target: Target): CliError { if (error instanceof ToolBridgeClientError) { if (error.kind === 'timeout') { const timeoutMs = target.timeoutMs ?? DEFAULT_TIMEOUT_MS - return new CliError( - `request timed out after ${Math.round(timeoutMs / 1000)}s — the upstream may still be processing; retry or raise --timeout`, + return uncertainResponse( + `request timed out after ${timeoutMs / 1000}s; no result was received`, 'unavailable', - true, + 'timeout', ) } if (error.kind === 'network') { const detail = error.networkCode === undefined ? '' : ` (${error.networkCode})` - return new CliError(`request failed: gateway unavailable${detail}`, 'unavailable', true) + return uncertainResponse(`request failed: no complete response received${detail}`, 'unavailable', 'network') } - return new CliError( + if (error.kind === 'protocol') return uncertainResponse(error.message, error.code, 'protocol') + const cli = new CliError( error.message, error.code === 'network' ? 'unavailable' : error.code, error.retryable, ) + cli.kind = error.kind + return cli } - return new CliError('request failed: gateway unavailable', 'unavailable', true) + return uncertainResponse('request failed: no complete response received', 'unavailable', 'network') } /** 当前 target 对应的 SDK client;保留 CLI 的 fetch 注入与单请求 timeout 默认。 */ diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index a1bcdd2f..238f3e59 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -23,14 +23,17 @@ export function reportError(asJson: boolean, err: unknown, exitCode = 1): void { ok: false, error: message, code: cli?.code, + kind: cli?.kind, + outcome: cli?.outcome, retryable: cli?.retryable, hint: cli?.hint, feedback: cli?.feedback, })}\n`, ) } else { - const retry = cli?.retryable === true ? ' (retryable — try again)' : '' - process.stderr.write(`error: ${message}${retry}\n`) + const code = cli?.code === undefined ? '' : ` [${cli.code}]` + process.stderr.write(`error${code}: ${message}\n`) + if (cli?.retryable !== undefined) process.stderr.write(`retryable: ${cli.retryable ? 'yes' : 'no'}\n`) if (cli?.hint) process.stderr.write(`${cli.hint}\n`) } process.exitCode = exitCode > 0 ? exitCode : 1 diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 4ec05497..160dd2d2 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -68,14 +68,14 @@ export function buildProgram() { 'tool-bridge CLI — one gateway for tools, context stores and devices.\nStart with `tb login`, explore with `tb ls` / `tb help `, invoke with `tb call `.\nGlobal options work before, between, or after subcommands.', ) .helpCommand(false) - // 尾部引导:把 feedback 变成 Agent 的使用习惯(用前查经验、踩坑后回馈)。 + // Feedback 是按需的排障入口;不保证每个路径都有记录,也不是每次调用的前置步骤。 program.addHelpText( 'after', ` -Agent feedback — every path carries experience from other agents: - before using a tool: tb feedback ls (top entries also show up in \`tb help \`) - hit a pitfall: tb feedback submit --title "" --detail "" - rate what helped you: tb feedback vote up|down`, +Operational feedback (when troubleshooting): + inspect recorded advice: tb feedback ls (when available, also included in \`tb help \`) + record a finding: tb feedback submit --title "" --detail "" + rate recorded advice: tb feedback vote up|down`, ) program.addCommand(keysCommand()) program.addCommand(maintenanceCommand()) diff --git a/packages/cli/test/argSemantics.test.ts b/packages/cli/test/argSemantics.test.ts index cfdf71e0..5f55ee4f 100644 --- a/packages/cli/test/argSemantics.test.ts +++ b/packages/cli/test/argSemantics.test.ts @@ -149,7 +149,9 @@ describe('真正的全局参数', () => { expect(JSON.parse(stdoutText())).toMatchObject({ ok: false, code: 'unavailable', - error: 'request failed: gateway unavailable', + error: expect.stringContaining('request failed: no complete response received'), + kind: 'network', + outcome: 'unknown', }) expect(stdoutText()).not.toContain('transport details') expect(process.exitCode).toBe(1) diff --git a/packages/cli/test/callUx.test.ts b/packages/cli/test/callUx.test.ts index 0fadb21e..b7f6f68e 100644 --- a/packages/cli/test/callUx.test.ts +++ b/packages/cli/test/callUx.test.ts @@ -27,7 +27,7 @@ function withStdin(content: string): () => void { * 本轮 Agent 体验修复的回归面: * - `tb call` 第二 positional 直接当 arguments JSON(误写 `--json '{...}'` 也自然工作); * - `--arg k=v` 扁平标量与 `--args-file -`(stdin)两条便利入口,及与整块 JSON 的四源互斥; - * - 失败现场的 ~feedback 提示(有条目列 top、无条目引导 submit、拉取失败静默); + * - 失败现场的 ~feedback 提示(有条目列 top、无条目不加提示、拉取失败静默); * - retryable 呈现与 `--timeout` 解析。 */ @@ -300,7 +300,8 @@ describe('tb call — 失败现场的 ~feedback 提示', () => { // 第二请求打到该 path 的 ~feedback expect(String(fn.mock.calls[1]?.[0])).toBe('https://gw/logs/sls/query/~feedback') const stderr = written(process.stderr) - expect(stderr).toContain('(retryable — try again)') + expect(stderr).toContain('retryable: yes') + expect(stderr).not.toContain('try again') expect(stderr).toContain('known pitfalls from other agents') expect(stderr).toContain('fb_a1 (+4) "index does not cover JSON content"') expect(stderr).toContain('tb feedback get logs/sls/query') @@ -330,11 +331,13 @@ describe('tb call — 失败现场的 ~feedback 提示', () => { }) }) - it('无 feedback 条目 → 引导 submit(把踩坑经验留给下一个 agent)', async () => { + it('无 feedback 条目 → 只保留实际错误,不追加无助于理解结果的提交提示', async () => { sequenceFetch([upstreamDown, { body: { items: [] } }]) await runCli(['call', 'logs/sls/query', ...GLOBALS]) expect(process.exitCode).toBe(1) - expect(written(process.stderr)).toContain('tb feedback submit logs/sls/query') + expect(written(process.stderr)).toContain('upstream unavailable: timed out') + expect(written(process.stderr)).not.toContain('hint:') + expect(written(process.stderr)).not.toContain('tb feedback submit') }) it('feedback 拉取失败 → 静默,主错误照常呈现', async () => { @@ -357,6 +360,23 @@ describe('tb call — 失败现场的 ~feedback 提示', () => { }) describe('--timeout 解析(resolveTarget)', () => { + it.each([false, true])('lost response after call stays unknown and never retries (json=%s)', async (asJson) => { + const fetcher = sequenceFetch([new Error('connection lost after dispatch'), { body: { items: [] } }]) + await runCli(['call', 'tools/mail/send', ...(asJson ? ['--json'] : []), ...GLOBALS]) + const output = written(asJson ? process.stdout : process.stderr) + expect(output).toContain('outcome is unknown') + expect(output).not.toContain('try again') + expect(output).not.toContain('connection lost after dispatch') + if (asJson) { + const result = JSON.parse(output) + expect(result).toMatchObject({ ok: false, code: 'unavailable', kind: 'network', outcome: 'unknown' }) + expect(result).not.toHaveProperty('retryable') + expect(written(process.stderr)).toBe('') + } else expect(written(process.stdout)).toBe('') + expect(fetcher.mock.calls.filter(call => String(call[0]) === 'https://gw/tools/mail/send')).toHaveLength(1) + expect(process.exitCode).toBe(1) + }) + it('秒 → 毫秒;支持小数', () => { expect(resolveTarget({ baseUrl: 'https://gw', timeout: '30' }).timeoutMs).toBe(30_000) expect(resolveTarget({ baseUrl: 'https://gw', timeout: '2.5' }).timeoutMs).toBe(2500) diff --git a/packages/cli/test/device.test.ts b/packages/cli/test/device.test.ts index 5fa16c89..f69bea1a 100644 --- a/packages/cli/test/device.test.ts +++ b/packages/cli/test/device.test.ts @@ -209,8 +209,8 @@ describe('tb device ls', () => { await runCli(['device', 'ls', '--base-url', 'https://gw', '--sk', 'tbk_x']) const lines = stdoutText().split('\n') expect(lines[0]).toContain('LAST_SEEN') - // 本地化渲染依赖时区,用同一转换求期望值而非硬编码字面量。 - expect(lines[1]).toContain(new Date(lastSeenAt).toLocaleString()) + expect(lines[0]).toContain('RECORDED_ONLINE') + expect(lines[1]).toContain(lastSeenAt) expect(lines[2]).toContain('-') }) }) @@ -231,6 +231,67 @@ const operation = { } describe('tb device durable operations', () => { + it.each([ + { state: 'queued', meaning: 'waiting for the device to claim it' }, + { state: 'claimed', meaning: 'completion not reported' }, + { state: 'succeeded', meaning: 'device reported success', result: { value: 42 } }, + ])('202 presents the actual $state state, including an existing idempotent result', async ({ state, meaning, result }) => { + const value = { ...operation, state, ...(result === undefined ? {} : { result }) } + const fetcher = captureFetch(value, 202) + await runCli(['call', operation.targetPath, '--delivery', 'fallback', '--base-url', 'https://gw']) + expect(stdoutText()).toContain(`state: ${state}`) + expect(stdoutText()).toContain(meaning) + expect(stdoutText()).toContain(operation.deviceId) + expect(stdoutText()).toContain(operation.operationId) + expect(stdoutText()).toContain(operation.targetPath) + expect(stdoutText()).toContain(operation.expiresAt) + if (result !== undefined) expect(stdoutText()).toContain(JSON.stringify(result)) + if (state !== 'queued') expect(stdoutText()).not.toContain('queued') + expect(fetcher).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(0) + }) + + it('invalid 202 never fabricates a queued operation', async () => { + captureFetch({ operationId: 'incomplete' }, 202) + await runCli(['call', operation.targetPath, '--delivery', 'mailbox', '--base-url', 'https://gw']) + expect(stdoutText()).toBe('') + const error = vi.mocked(process.stderr.write).mock.calls.map(call => String(call[0])).join('') + expect(error).toContain('invalid device operation') + expect(error).toContain('outcome is unknown') + expect(error).not.toContain('try again') + expect(process.exitCode).toBe(1) + }) + + it.each([ + { state: 'cancelled', executionMayHaveOccurred: false, meaning: 'cancelled before execution' }, + { state: 'claimed', executionMayHaveOccurred: true, meaning: 'cancellation requested; execution may still continue' }, + { state: 'succeeded', executionMayHaveOccurred: true, meaning: 'device reported success' }, + ])('cancel reports $state without implying that a running operation stopped', async ({ state, executionMayHaveOccurred, meaning }) => { + const value = { ...operation, state, executionMayHaveOccurred, cancelRequestedAt: operation.updatedAt } + const fetcher = captureFetch(value) + await runCli(['device', 'op', 'cancel', operation.deviceId, operation.operationId, '--base-url', 'https://gw']) + expect(stdoutText()).toContain(`state: ${state} (${meaning})`) + if (state !== 'cancelled') expect(stdoutText()).not.toContain('cancelled before execution') + expect(fetcher).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(0) + }) + + it.each([ + { state: 'result_unknown', executionMayHaveOccurred: true }, + { state: 'expired', executionMayHaveOccurred: true }, + ])('$state preserves execution uncertainty in text and the original JSON record', async (fields) => { + const value = { ...operation, ...fields, attempt: 1 } + captureFetch(value) + const argv = ['device', 'op', 'get', operation.deviceId, operation.operationId, '--base-url', 'https://gw'] + await runCli(argv) + expect(stdoutText()).toContain('result unknown') + expect(stdoutText()).toContain('check the business result before retrying') + expect(stdoutText()).not.toContain('started/result-unknown') + vi.mocked(process.stdout.write).mockClear() + await runCli([...argv, '--json']) + expect(JSON.parse(stdoutText())).toEqual(value) + }) + it('call delivery reuses argument parsing and sends mailbox controls once', async () => { const fn = captureFetch(operation, 202) await runCli([ @@ -288,7 +349,7 @@ describe('tb device durable operations', () => { deviceId: 'phone-1', opts: { limit: 10, states: ['expired'] }, }) - expect(stdoutText()).toContain('may-have-run') + expect(stdoutText()).toContain('may have executed, result unknown') expect(stdoutText()).toContain('next cursor: next') }) diff --git a/packages/cli/test/http.test.ts b/packages/cli/test/http.test.ts index 6ea961e3..7ca7dd31 100644 --- a/packages/cli/test/http.test.ts +++ b/packages/cli/test/http.test.ts @@ -52,7 +52,7 @@ describe('apiFetch 构造请求', () => { expect(JSON.parse(init.body as string)).toEqual({ a: 1 }) }) - it('网络错误 → CliError,带 unavailable/retryable(不再是无 code 的裸消息)', async () => { + it('网络错误 → 结果未知,不承诺可安全重试', async () => { setFetch( vi.fn(async () => { throw new Error('fetch failed') @@ -61,7 +61,9 @@ describe('apiFetch 构造请求', () => { await expect(apiFetch(TARGET, { path: '/x' })).rejects.toMatchObject({ message: expect.stringMatching(/request failed/), code: 'unavailable', - retryable: true, + retryable: undefined, + kind: 'network', + outcome: 'unknown', }) }) @@ -74,7 +76,9 @@ describe('apiFetch 构造请求', () => { await expect(apiFetch(TARGET, { path: '/x' })).rejects.toMatchObject({ message: expect.stringMatching(/ECONNREFUSED/), code: 'unavailable', - retryable: true, + retryable: undefined, + kind: 'network', + outcome: 'unknown', }) }) @@ -89,7 +93,9 @@ describe('apiFetch 构造请求', () => { ) await expect(apiFetch(TARGET, { path: '/x' })).rejects.toMatchObject({ code: 'unavailable', - retryable: true, + retryable: undefined, + kind: 'network', + outcome: 'unknown', }) }) }) @@ -148,18 +154,20 @@ describe('apiJson TBError 归一', () => { }) }) - it('2xx 但响应体非法 JSON → internal/retryable', async () => { + it('2xx 但响应体非法 JSON → internal,业务结果未知', async () => { mockOnce('not json', { status: 200, headers: { 'content-type': 'application/json' } }) await expect(apiJson(TARGET, { path: '/x' })).rejects.toMatchObject({ message: expect.stringMatching(/invalid JSON/), code: 'internal', - retryable: true, + retryable: undefined, + kind: 'protocol', + outcome: 'unknown', }) }) }) describe('apiFetch 超时', () => { - it('timeoutMs 到点 → retryable CliError(unavailable),message 提示 --timeout', async () => { + it('timeoutMs 到点 → 精确时长、结果未知,不建议直接重试', async () => { setFetch( vi.fn( (_url: string, init: RequestInit) => @@ -170,8 +178,10 @@ describe('apiFetch 超时', () => { ) await expect(apiFetch({ ...TARGET, timeoutMs: 30 }, { path: '/x' })).rejects.toMatchObject({ code: 'unavailable', - retryable: true, - message: expect.stringMatching(/timed out .* --timeout/), + retryable: undefined, + kind: 'timeout', + outcome: 'unknown', + message: expect.stringContaining('timed out after 0.03s; no result was received'), }) }) diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts new file mode 100644 index 00000000..816f3334 --- /dev/null +++ b/packages/cli/test/status.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetFetch, setFetch } from '../src/http' +import { runCli } from './cliHarness' + +function stdout(): string { + return vi.mocked(process.stdout.write).mock.calls.map(call => String(call[0])).join('') +} + +beforeEach(() => { + process.exitCode = 0 + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) +}) + +afterEach(() => { + process.exitCode = 0 + resetFetch() + vi.restoreAllMocks() +}) + +describe('status result meaning', () => { + it.each([ + { body: { healthy: true, version: 'test' }, status: 200, healthy: true, exit: 0 }, + { body: { healthy: false }, status: 200, healthy: false, exit: 1 }, + { body: { healthy: false }, status: 503, healthy: false, exit: 1 }, + { body: { healthy: true }, status: 503, healthy: false, exit: 1 }, + { body: {}, status: 200, healthy: null, exit: 1 }, + { body: { healthy: 'true' }, status: 200, healthy: null, exit: 1 }, + { body: 'not JSON', status: 200, healthy: null, exit: 1 }, + ])('HTTP $status / $body reports health independently from HTTP success', async ({ body, status, healthy, exit }) => { + const fetcher = vi.fn(async () => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status })) + setFetch(fetcher as typeof fetch) + await runCli(['status', '--base-url', 'https://gw', '--json']) + expect(JSON.parse(stdout())).toMatchObject({ + ok: exit === 0, + httpOk: status >= 200 && status < 300, + status, + healthy, + body, + }) + expect(process.exitCode).toBe(exit) + expect(fetcher).toHaveBeenCalledOnce() + expect(vi.mocked(process.stderr.write)).not.toHaveBeenCalled() + }) + + it('human output distinguishes unknown health from unhealthy', async () => { + setFetch(vi.fn(async () => new Response('{}')) as typeof fetch) + await runCli(['status', '--base-url', 'https://gw']) + expect(stdout()).toContain('http: 200') + expect(stdout()).toContain('health: unknown') + expect(stdout()).not.toContain('unhealthy') + expect(process.exitCode).toBe(1) + }) +}) From 3e4328f22d738d55da21dbac151659725a1d6fc2 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 05:54:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(cli):=20=E8=AE=A9=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=BD=93=E5=89=8D=E9=A1=B5=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=8F=8A=E9=85=8D=E7=BD=AE=E5=AE=9E=E9=99=85=E7=94=9F=E6=95=88?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空页仍可能有后续 cursor,配置保存也不等于已应用;文本返回直接交代这两类边界并保留原始 JSON 数据。CLI 升至 0.32.0:健康检查和传输失败的机器输出含义有变化,按 0.x minor 交付以便消费者显式升级。verify、全仓 build、最终 tarball 干净安装与 bin 烟测已通过。 --- packages/cli/package.json | 2 +- packages/cli/src/commands/ctx.ts | 5 +- packages/cli/src/commands/management.ts | 60 ++++++++++++-- packages/cli/src/commands/search.ts | 7 +- packages/cli/src/commands/store.ts | 5 +- packages/cli/test/ctx.test.ts | 24 ++++++ packages/cli/test/management.test.ts | 104 ++++++++++++++++++++++++ packages/cli/test/search.test.ts | 38 ++++++++- packages/cli/test/store.test.ts | 41 ++++++++++ 9 files changed, 271 insertions(+), 15 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index eec2ae9b..12f03799 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@tool-bridge/cli", - "version": "0.31.0", + "version": "0.32.0", "description": "tb \u2014 Tool Bridge CLI: one gateway for HTBP/MCP/HTTP tools, contexts, and device shells", "type": "module", "license": "MIT", diff --git a/packages/cli/src/commands/ctx.ts b/packages/cli/src/commands/ctx.ts index 8171de13..63d0e9eb 100644 --- a/packages/cli/src/commands/ctx.ts +++ b/packages/cli/src/commands/ctx.ts @@ -69,7 +69,8 @@ function readBinaryFile(file: string): Buffer { function printEntries(page: Page): void { const items = page.items ?? [] if (items.length === 0) { - printLine('(no entries)') + printLine('(no entries on this page)') + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) return } const rows = items.map(m => [ @@ -78,7 +79,7 @@ function printEntries(page: Page): void { m.updatedAt ?? '', ]) printLine(table(['URI', 'SIZE', 'UPDATED'], rows)) - if (page.cursor) printLine(`next cursor: ${page.cursor}`) + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) } /** `tb ctx ls [prefix]` —— 浅层列表(ContextProvider.List)。 */ diff --git a/packages/cli/src/commands/management.ts b/packages/cli/src/commands/management.ts index 2522a58b..c5861837 100644 --- a/packages/cli/src/commands/management.ts +++ b/packages/cli/src/commands/management.ts @@ -1,17 +1,19 @@ import { + type ConfigStatus, createSetupClient, parseConfigUpdate, parseRuntimeConfig, parseStorageRotate, parseStorageWrite, + type RuntimeConfig, } from '@tool-bridge/sdk/client' import { readFile } from 'node:fs/promises' import { spawn } from 'node:child_process' import { Command } from 'commander' import { callDirect, CliError, getFetch, requireTarget } from '../http' import { resolveTarget, withGlobalOpts } from '../args' +import { printJson, printLine, table } from '../output' import { readStdinRaw } from '../stdin' -import { printJson } from '../output' async function readInput(file: string): Promise { if (file === '-' && process.stdin.isTTY) throw new CliError('provide JSON through stdin or --file ') @@ -33,29 +35,75 @@ function revision(value: string): number { return Number(value) } +function printConfigStatus(status: ConfigStatus): void { + const descriptions: Record = { + applied: 'saved settings are effective on the responding replica', + pending: 'saved revision is not yet effective on the responding replica', + applying: 'configuration application is in progress', + failed: 'configuration application has an error', + } + printLine(`State: ${status.state} — ${descriptions[status.state]}`) + printLine(`Desired revision (saved): ${status.revision}`) + printLine(`Effective revision (responding replica): ${status.appliedRevision === 0 ? 'none confirmed' : status.appliedRevision}`) + if (status.lastError) printLine(`Last application error: ${status.lastError}`) + if (status.appliedRevision === 0) printLine('Reported effective settings below are not confirmed as applied on this replica.') + const keys = [...new Set([...Object.keys(status.desired), ...Object.keys(status.effective)])] as Array + printLine(table( + ['Setting', 'Desired (saved)', 'Reported effective'], + keys.map(key => [key, JSON.stringify(status.desired[key]) ?? '(not reported)', JSON.stringify(status.effective[key]) ?? '(not reported)']), + )) +} + export function configCommand() { const command = new Command('config').description('Manage instance settings and their applied revision (admin)') - for (const name of ['schema', 'get', 'status'] as const) { + command.addCommand(withGlobalOpts(new Command('schema')) + .description('Read the runtime settings JSON Schema') + .action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/schema')))) + for (const name of ['get', 'status'] as const) { command.addCommand(withGlobalOpts(new Command(name)) .description(`${name} instance configuration`) - .action(async opts => printJson(await callDirect(resolveTarget(opts), `/system/config/${name}`)))) + .action(async (opts) => { + const result = await callDirect(resolveTarget(opts), `/system/config/${name}`) + if (opts.json) printJson(result) + else printConfigStatus(result) + })) } command.addCommand(withGlobalOpts(new Command('validate')) .description('Validate runtime settings without saving') .option('--file ', 'JSON file; - reads stdin', '-') - .action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/validate', parseRuntimeConfig(await readInput(opts.file)))))) + .action(async (opts) => { + const settings = await callDirect(resolveTarget(opts), '/system/config/validate', parseRuntimeConfig(await readInput(opts.file))) + if (opts.json) printJson(settings) + else { + printLine('Configuration is valid; no settings were saved or applied.') + printLine('Validated settings (including defaults):') + printJson(settings) + } + })) command.addCommand(withGlobalOpts(new Command('update')) .description('Save desired runtime settings; run apply separately') .requiredOption('--revision ', 'Current configuration revision', revision) .option('--file ', 'Runtime settings JSON file; - reads stdin', '-') .action(async (opts) => { const payload = parseConfigUpdate({ expectedRevision: opts.revision, settings: await readInput(opts.file) }) - printJson(await callDirect(resolveTarget(opts), '/system/config/update', payload)) + const result = await callDirect(resolveTarget(opts), '/system/config/update', payload) + if (opts.json) printJson(result) + else { + printLine('Configuration update saved; this command does not apply settings.') + printConfigStatus(result) + } })) command.addCommand(withGlobalOpts(new Command('apply')) .description('Apply the saved revision and report effective settings') .requiredOption('--revision ', 'Saved configuration revision', revision) - .action(async opts => printJson(await callDirect(resolveTarget(opts), '/system/config/apply', { expectedRevision: opts.revision })))) + .action(async (opts) => { + const result = await callDirect(resolveTarget(opts), '/system/config/apply', { expectedRevision: opts.revision }) + if (opts.json) printJson(result) + else { + printLine(`Apply result for requested revision ${opts.revision}:`) + printConfigStatus(result) + } + })) return command } diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 295b2f31..398d026e 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -62,7 +62,10 @@ function printPartialNotice(page: ToolSearchPage): void { function printSearchPage(page: ToolSearchPage, withSchemas = false): void { if (page.items.length === 0) { - printLine('(no visible tools found)') + printLine(page.partial === true + ? '(no visible tools on this page; search results are incomplete)' + : '(no visible tools on this page)') + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) return } printLine( @@ -80,7 +83,7 @@ function printSearchPage(page: ToolSearchPage, withSchemas = false): void { ), ) if (withSchemas) printSearchSchemas(page) - if (page.cursor) printLine(`next cursor: ${page.cursor}`) + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) } /** `tb search ` —— 在当前 SK 可见且可调用的全局工具中检索。 */ diff --git a/packages/cli/src/commands/store.ts b/packages/cli/src/commands/store.ts index 116f9a02..cda9f350 100644 --- a/packages/cli/src/commands/store.ts +++ b/packages/cli/src/commands/store.ts @@ -122,14 +122,15 @@ function fileSize(file: string): number { function printObjects(page: StoreListPage): void { if (page.items.length === 0) { - printLine('(no Store objects)') + printLine('(no Store objects on this page)') + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) return } printLine(table( ['URI', 'SIZE', 'TYPE', 'READY'], page.items.map(item => [item.uri, String(item.size), item.contentType, item.readyAt]), )) - if (page.cursor) printLine(`next cursor: ${page.cursor}`) + if (page.cursor) printLine(`more pages available; next cursor: ${page.cursor}`) } async function writeResponse(response: Response, out: string | undefined): Promise { diff --git a/packages/cli/test/ctx.test.ts b/packages/cli/test/ctx.test.ts index 02aeaf68..e72515af 100644 --- a/packages/cli/test/ctx.test.ts +++ b/packages/cli/test/ctx.test.ts @@ -34,6 +34,28 @@ afterEach(() => { }) describe('tb ctx ls', () => { + it.each([undefined, 'next'])('空页仅描述当前页且保留 cursor: %s', async (cursor) => { + const page = { items: [], ...(cursor === undefined ? {} : { cursor }) } + const fn = captureFetch(page) + + await runCli(['ctx', 'ls', 'ctx/notes', ...gw]) + + expect(stdoutText()).toContain('(no entries on this page)') + expect(stdoutText().includes('more pages available; next cursor: next')).toBe(cursor !== undefined) + expect(fn).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(0) + }) + + it('空页的 JSON 保留原始 wire 与 cursor', async () => { + const page = { items: [], cursor: 'next' } + const fn = captureFetch(page) + + await runCli(['ctx', 'ls', 'ctx/notes', '--json', ...gw]) + + expect(JSON.parse(stdoutText())).toEqual(page) + expect(fn).toHaveBeenCalledOnce() + }) + it('无 prefix → List{path:""},不带 opts', async () => { const fn = captureFetch({ items: [] }) await runCli(['ctx', 'ls', 'ctx/notes', ...gw, '--json']) @@ -87,12 +109,14 @@ describe('tb ctx ls', () => { metadata: {}, }, ], + cursor: 'next', }) await runCli(['ctx', 'ls', 'ctx/notes', ...gw]) const printed = stdoutText() expect(printed).toContain('node://ctx/notes/a.md') expect(printed).toContain('12') expect(printed).toContain('2026-07-07T00:00:00Z') + expect(printed).toContain('more pages available; next cursor: next') expect(process.exitCode).toBe(0) }) }) diff --git a/packages/cli/test/management.test.ts b/packages/cli/test/management.test.ts index bcd53e08..ce62098d 100644 --- a/packages/cli/test/management.test.ts +++ b/packages/cli/test/management.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { type ConfigStatus, parseRuntimeConfig } from '@tool-bridge/sdk/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -11,6 +12,25 @@ let directory = '' let requests: Array<{ init: RequestInit, path: string }> = [] let output: string[] = [] const target = ['--base-url', 'https://tb.test', '--sk', 'tbk_admin', '--json'] +const humanTarget = target.filter(value => value !== '--json') + +function configStatus(overrides: Partial = {}): ConfigStatus { + return { + revision: 13, + appliedRevision: 12, + state: 'pending', + desired: parseRuntimeConfig({ maxHops: 7, remoteAllowlist: ['remote.test'] }), + effective: parseRuntimeConfig({ maxHops: 4 }), + ...overrides, + } +} + +function respondWith(value: unknown): void { + setFetch(vi.fn(async (input, init) => { + requests.push({ path: new URL(String(input)).pathname, init: init ?? {} }) + return new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } }) + })) +} beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'tb-management-')) @@ -120,3 +140,87 @@ describe('instance management command parity', () => { expect(fetcher).toHaveBeenCalledTimes(1) }) }) + +describe('configuration output', () => { + it.each(['get', 'status'])('%s distinguishes saved settings from this replica’s effective settings', async (command) => { + respondWith(configStatus()) + await runCli(['config', command, ...humanTarget]) + const text = output.join('') + expect(text).toContain('State: pending — saved revision is not yet effective on the responding replica') + expect(text).toContain('Desired revision (saved): 13') + expect(text).toContain('Effective revision (responding replica): 12') + expect(text).toMatch(/maxHops\s+7\s+4/) + expect(text).toMatch(/remoteAllowlist\s+\["remote.test"\]\s+\[\]/) + for (const key of Object.keys(configStatus().desired)) expect(text).toContain(key) + expect(requests).toHaveLength(1) + }) + + it('update reports saving without claiming application, and preserves an existing application error', async () => { + vi.mocked(readStdinRaw).mockResolvedValue('{"maxHops":7}') + respondWith(configStatus({ state: 'failed', lastError: 'runtime configuration could not be applied' })) + await runCli(['config', 'update', '--revision', '12', ...humanTarget]) + expect(output.join('')).toContain('Configuration update saved; this command does not apply settings.') + expect(output.join('')).toContain('State: failed') + expect(output.join('')).toContain('Effective revision (responding replica): 12') + expect(output.join('')).toContain('Last application error: runtime configuration could not be applied') + }) + + it('validate reports no persistence and includes the server-returned defaults', async () => { + vi.mocked(readStdinRaw).mockResolvedValue('{"maxHops":7}') + const settings = parseRuntimeConfig({ maxHops: 7 }) + respondWith(settings) + await runCli(['config', 'validate', ...humanTarget]) + expect(output.join('')).toContain('Configuration is valid; no settings were saved or applied.') + expect(JSON.parse(output.slice(2).join(''))).toEqual(settings) + expect(requests.map(request => request.path)).toEqual(['/system/config/validate']) + }) + + it('apply preserves a newer saved revision while reporting the requested revision actually effective', async () => { + respondWith(configStatus({ revision: 14, appliedRevision: 13 })) + await runCli(['config', 'apply', '--revision', '13', ...humanTarget]) + expect(output.join('')).toContain('Apply result for requested revision 13:') + expect(output.join('')).toContain('State: pending') + expect(output.join('')).toContain('Desired revision (saved): 14') + expect(output.join('')).toContain('Effective revision (responding replica): 13') + expect(output.join('')).not.toContain('State: applied') + }) + + it.each(['applying', 'failed'] as const)('apply does not convert a returned %s state into success', async (state) => { + respondWith(configStatus({ state })) + await runCli(['config', 'apply', '--revision', '13', ...humanTarget]) + expect(output.join('')).toContain(`State: ${state}`) + expect(output.join('')).toContain('Effective revision (responding replica): 12') + expect(output.join('')).not.toContain('State: applied') + }) + + it('applied status limits its claim to the responding replica', async () => { + const status = configStatus({ state: 'applied', appliedRevision: 13 }) + respondWith({ ...status, effective: status.desired }) + await runCli(['config', 'status', ...humanTarget]) + expect(output.join('')).toContain('State: applied — saved settings are effective on the responding replica') + }) + + it('revision zero does not present the reported snapshot as confirmed effective', async () => { + respondWith(configStatus({ state: 'failed', appliedRevision: 0 })) + await runCli(['config', 'status', ...humanTarget]) + expect(output.join('')).toContain('Effective revision (responding replica): none confirmed') + expect(output.join('')).toContain('Reported effective settings below are not confirmed as applied on this replica.') + expect(output.join('')).toMatch(/maxHops\s+7\s+4/) + }) + + it.each(['get', 'status', 'update', 'apply', 'validate'])('%s --json preserves the existing wire result', async (command) => { + const result = command === 'validate' ? parseRuntimeConfig({ maxHops: 7 }) : configStatus() + vi.mocked(readStdinRaw).mockResolvedValue('{"maxHops":7}') + respondWith(result) + const revisionArgs = command === 'update' || command === 'apply' ? ['--revision', '12'] : [] + await runCli(['config', command, ...revisionArgs, ...target]) + expect(JSON.parse(output.join(''))).toEqual(result) + }) + + it('schema remains JSON without requiring --json', async () => { + const schema = { type: 'object', properties: { maxHops: { type: 'integer', default: 4 } } } + respondWith(schema) + await runCli(['config', 'schema', ...humanTarget]) + expect(JSON.parse(output.join(''))).toEqual(schema) + }) +}) diff --git a/packages/cli/test/search.test.ts b/packages/cli/test/search.test.ts index 35a41c18..05c353f5 100644 --- a/packages/cli/test/search.test.ts +++ b/packages/cli/test/search.test.ts @@ -108,7 +108,7 @@ describe('tb search', () => { expect(output).toContain('3/4') expect(output).toContain('write') expect(output).toContain('Create a calendar event') - expect(output).toContain('next cursor: c2') + expect(output).toContain('more pages available; next cursor: c2') }) it('partial 状态写 stderr,JSON stdout 保持完整 federation evidence', async () => { @@ -239,7 +239,41 @@ describe('tb search', () => { it('无结果时 --schemas 不追加任何段', async () => { jsonFetch({ items: [] }) await runCli(['search', 'calendar', '--schemas', ...gateway]) - expect(written(process.stdout)).toBe('(no visible tools found)\n') + expect(written(process.stdout)).toBe('(no visible tools on this page)\n') + }) + + it.each([ + { partial: false, cursor: 'next' }, + { partial: true, cursor: 'next' }, + { partial: true, cursor: undefined }, + ])('空页保留分页与不完整状态: %j', async ({ partial, cursor }) => { + const page = { items: [], partial, ...(cursor === undefined ? {} : { cursor }) } + const fn = jsonFetch(page) + + await runCli(['search', 'calendar', '--schemas', ...gateway]) + + const output = written(process.stdout) + expect(output).toContain('no visible tools on this page') + expect(output.includes('search results are incomplete')).toBe(partial) + expect(output.includes('more pages available; next cursor: next')).toBe(cursor !== undefined) + expect(fn).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(0) + }) + + it('不完整空页的 JSON 保留原始 wire 与 cursor', async () => { + const page = { + items: [], + cursor: 'next', + partial: true, + sources: [{ path: 'remotes/work', status: 'timed_out' }], + } + const fn = jsonFetch(page) + + await runCli(['search', 'calendar', '--json', ...gateway]) + + expect(JSON.parse(written(process.stdout))).toEqual(page) + expect(written(process.stderr)).toContain('partial search results') + expect(fn).toHaveBeenCalledOnce() }) it('非法枚举、覆盖率、路径与 limit 都在请求前拒绝', async () => { diff --git a/packages/cli/test/store.test.ts b/packages/cli/test/store.test.ts index 83454c92..9ac285e1 100644 --- a/packages/cli/test/store.test.ts +++ b/packages/cli/test/store.test.ts @@ -141,6 +141,47 @@ describe('tb store upload', () => { }) describe('tb store management', () => { + it.each([undefined, 'next'])('空页仅描述当前页且保留 cursor: %s', async (cursor) => { + const page = { items: [], ...(cursor === undefined ? {} : { cursor }) } + const fetcher = vi.fn(async () => new Response(JSON.stringify(page), { status: 200 })) + setFetch(fetcher as typeof fetch) + + await runCli(['store', 'list', ...gw]) + + const stdout = vi.mocked(process.stdout.write).mock.calls.map(call => String(call[0])).join('') + expect(stdout).toContain('(no Store objects on this page)') + expect(stdout.includes('more pages available; next cursor: next')).toBe(cursor !== undefined) + expect(fetcher).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(0) + }) + + it('空页的 JSON 保留原始 wire 与 cursor', async () => { + const page = { items: [], cursor: 'next' } + const fetcher = vi.fn(async () => new Response(JSON.stringify(page), { status: 200 })) + setFetch(fetcher as typeof fetch) + + await runCli(['store', 'list', '--json', ...gw]) + + const stdout = vi.mocked(process.stdout.write).mock.calls.map(call => String(call[0])).join('') + expect(JSON.parse(stdout)).toEqual(page) + expect(fetcher).toHaveBeenCalledOnce() + }) + + it('非空页也说明还有分页,且不泄漏 Store capability', async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ + items: [READY], cursor: 'next', + }), { status: 200 })) + setFetch(fetcher as typeof fetch) + + await runCli(['store', 'list', ...gw]) + + const stdout = vi.mocked(process.stdout.write).mock.calls.map(call => String(call[0])).join('') + expect(stdout).toContain(READY.uri) + expect(stdout).toContain('more pages available; next cursor: next') + expect(stdout).not.toContain('must-not-escape') + expect(fetcher).toHaveBeenCalledOnce() + }) + it('本地使用 SDK 的严格 Store URI parser,短 object id 不触发请求', async () => { const fetcher = vi.fn() setFetch(fetcher as typeof fetch) From 84ac05d579c5a179cd7120e7c32f1581fc3b69e2 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 05:54:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(cli):=20=E6=98=8E=E7=A1=AE=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E8=AF=81=E6=8D=AE=E8=BE=B9=E7=95=8C=E4=BB=A5=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=AF=AF=E5=88=A4=E5=AE=8C=E6=88=90=E6=88=96=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/cli/argument-contract.mdx | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/llmdoc/cli/argument-contract.mdx b/llmdoc/cli/argument-contract.mdx index cf4b05e8..fd1156d5 100644 --- a/llmdoc/cli/argument-contract.mdx +++ b/llmdoc/cli/argument-contract.mdx @@ -1,5 +1,5 @@ --- -description: tb CLI 参数契约:解析/客户端/服务端三层检查、keyword-only search、credential stdin/repeatable field 语义、三入口对等与确认门。 +description: tb CLI 参数与返回契约:三层检查、健康状态与退出码、传输失败结果未知、分页完整性、配置保存/生效、credential 输入与三入口对等。 kind: guide relations: requires: @@ -20,6 +20,8 @@ code: - packages/cli/src/confirm.ts - packages/cli/src/contentType.ts - packages/cli/src/http.ts + - packages/cli/src/output.ts + - packages/cli/src/deviceOutput.ts - packages/cli/src/registry.ts - packages/cli/src/scope.ts - packages/cli/src/stdin.ts @@ -47,6 +49,7 @@ code: - packages/cli/src/commands/login.ts - packages/cli/examples/structured-command-profile.json - packages/cli/test/device.test.ts + - packages/cli/test/status.test.ts - packages/cli/test/strictParsing.test.ts - packages/sdk/src/client/client.ts - packages/sdk/src/client/management.ts @@ -57,7 +60,7 @@ code: - packages/sdk/src/device/storeUpload.ts --- -# CLI 参数契约检查 +# CLI 参数与返回契约检查 CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查三层:解析层、客户端语义层、服务端权限层;任一层缺失都会产生管理旁路或误导性帮助。wire 层面的命令契约(builtin 命令表、错误码、Help 模型)以 [protocol/htbp-contract](../protocol/htbp-contract.mdx) 为准,本文只覆盖 CLI 侧的评审纪律。 @@ -84,8 +87,8 @@ CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查 realtime 失败后执行第二条 enqueue 命令,fallback 是否入队完全由 gateway 的 dispatch certainty 决定。 - `tb device op ls|get|cancel` 是 operation 管理面:ls 按 deviceId 分页并可 repeatable `--state` 过滤, get/cancel 同时要求 deviceId 与 operationId。人类输出必须区分 - `result_unknown = 已开始/结果未知` 与 `expired + executionMayHaveOccurred = 可能执行过`,避免把 expired - 误写成安全未执行;`--json` 保留完整固定 wire。 + `result_unknown = 可能已开始/结果未知` 与 `expired + executionMayHaveOccurred = 可能执行过`,避免把 + journal barrier 当成副作用已发生的证明,或把 expired 误写成安全未执行;`--json` 保留完整固定 wire。 - `store upload` 从文件 stat 得到 size,流式发送,并按 grant 自动选择 relay/direct;relay PUT 返回 descriptor,direct 才调用 capability-only complete。`--idempotency-key` 是 owner-scoped create 重试 key,不是覆盖路径。文件 size 大于当次 `maxBytes` 时在本地早拒,服务端仍做权威限额。 @@ -100,6 +103,33 @@ CLI 是公共控制面,不是 API 的宽松包装。修改命令时同时检查 `--path-prefix` 与 repeatable `--effect` 必须原样下发 SDK wire;CLI 只做非法枚举/组合的早拒,不能 自己重排或过滤服务端结果。partial source 状态写 stderr warning,JSON page 保持机器可读。 +## 返回语义 + +CLI 输出只能声明当前证据能证明的结果。人类文本与 JSON 必须同义,区分请求成功、业务终态和结果未知; +不能用 HTTP 成功、operation identity 或本页为空替代业务判断。任意工具结果与既有控制面 JSON 保持原义, +可读性说明放在人类输出中,不为统一外观给业务数据增加通用包装。 + +- `tb status` 的 `ok` 表示 HTTP 成功且响应明确报告健康,与退出码成功条件一致;`httpOk` 单独表示 + HTTP 成功。缺少合法 boolean 健康字段时 `healthy:null`,文本为 unknown,退出失败;不能宣称不健康 + 或仅凭 HTTP 200 报健康。原始响应保留在 `body` 供判断。 +- CLI 传输超时、断连或协议解析失败只证明没有取得可靠结果,不证明请求未执行。统一错误保留 `kind`, + 这类结果标记 `outcome:'unknown'`,不自行补 `retryable:true`;服务端 HTTP 错误仍保留其 retryable + 声明。文本呈现错误码与明确的重试标记,但不能把可重试机械改写成无条件“再试一次”。 +- `tb call` 的 Mailbox 受理结果与 `tb device op get|cancel` 共用状态解释,以 operation 实际 `state` + 为准;HTTP 202 也可能返回幂等命中的既有终态,不能固定写 queued。固定 operation 必须复用权威 + schema 验证,不从缺失字段猜默认状态。claimed 后的取消仅表示已请求取消,不能报告执行已停止; + claim attempts 也不等于 handler 执行次数。 +- 分页输出的空结果仅限定当前页;有 cursor 时即使 items 为空也必须显示后续页信号。搜索的 partial + 与分页是独立维度,不能将不完整搜索说成全局无匹配,也不能丢弃 partial source warning。 +- `tb config` 文本区分 desired(已保存)与 responding replica 的 effective;validate 不保存,update + 不应用,apply 返回后仍按 state/appliedRevision 判断。未确认 applied revision 时不能把报告的 + effective 值当成已生效;`--json` 保持服务端原 shape。 +- 错误附带的 feedback 只展示实际返回的条目。空反馈不自动追加 submit 邀请,避免将结果诊断变成无关任务。 + +评审须覆盖同一响应在文本、JSON、退出码上的含义,尤其是 HTTP 成功但健康未知、结果未知、取消未完成、 +空页带 cursor 与配置保存未生效。普通工具业务失败仍属于其自身返回契约,不能由 CLI 猜测任意 payload +的业务状态来重写退出码。 + ## 类型契约 Commander 的 `.argument()`/`.option()` 链同时是运行时解析、help 与 action callback 类型的真源。命令文件 From a1e4fe212f4c27e71cf164ac0403084ff5514116 Mon Sep 17 00:00:00 2001 From: DJJ Date: Sun, 6 Sep 2026 05:54:40 +0800 Subject: [PATCH 4/4] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index bf2c08d3..9f1d8f6a 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -15,10 +15,10 @@ "validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478" }, "cli/agent-skill-integration.mdx": { - "validatedRevision": "02fca70d0d4ccca088fc4c44a908243b96eda5aa" + "validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2" }, "cli/argument-contract.mdx": { - "validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478" + "validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2" }, "dashboard/canvas-architecture.mdx": { "validatedRevision": "41c39bccb8055f2d3bffa904b5cc2e30c0b43c5b" @@ -57,7 +57,7 @@ "validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478" }, "device/durable-mailbox.mdx": { - "validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478" + "validatedRevision": "84ac05d579c5a179cd7120e7c32f1581fc3b69e2" }, "hosts-deploy/managed-configuration.mdx": { "validatedRevision": "2b45dc99c607f59351d214845fe8e0c6b912f478"