diff --git a/docs/reference/commander-collaboration.md b/docs/reference/commander-collaboration.md index 197452bf..c0064556 100644 --- a/docs/reference/commander-collaboration.md +++ b/docs/reference/commander-collaboration.md @@ -7,6 +7,7 @@ - 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。 - 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。 - 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。 +- Web/CLI 路径分叉属于优先级高的工具摩擦。浏览器工作台暴露问题后,先用 `hwlab-cli client` 走同一 Cloud Web base URL 复现;Code Agent continuation 优先用 `client agent send --from-trace ` 从 inspect 自动恢复 Web 上下文,device-pod 操作优先验证 `19666` 同源代理,再决定是否修业务本体。 - 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。 - 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。 - 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index dd3fafd7..8ff34617 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -9,6 +9,7 @@ - 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。 - 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url` 或 `HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。 - Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、trace/result 或 provider 问题后,必须先能用 `hwlab-cli client agent send/result/trace/inspect` 对同一 base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。 +- 从 Web trace 回放 Code Agent 问题时,优先用 `client agent send --from-trace ` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,自动带回原 `conversationId/sessionId/threadId` 并把 `retryOf` 指向来源 trace;只有 inspect 缺失时才手动传 `--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`。CLI 输出必须包含 replay 来源、inspect 状态和 redacted continuation 摘要。 - 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它允许显式 `--api-base-url` 指向 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。 - Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod::' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。 - `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev`、`v0.2`、`v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。 @@ -18,7 +19,7 @@ - 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。 - `device-pod-cli`/`hwpod` 的 `job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 - Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。 -- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。 +- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace`、`--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。 - AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resume;CLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 - harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。 @@ -47,7 +48,7 @@ | `hwlab-cli client gateway sessions` | `GET Cloud API /v1/gateway/sessions` | 显式 Cloud API 诊断入口,观察 gateway online/stale、inflight 和 capability;默认不带 Web cookie。 | | `hwlab-cli client gateway invoke` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 诊断入口,执行一次 bounded shell dispatch 并返回结构化 dispatch 摘要。 | | `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 | -| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--conversation-id`、`--session-id`、`--thread-id`、`--retry-of` 复现 Web continuation。 | +| `hwlab-cli client agent send` | `GET /v1/agent/chat/inspect` + `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--from-trace` 自动复现 Web continuation,也支持手动 `--conversation-id`、`--session-id`、`--thread-id`、`--retry-of`。 | | `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本。 | | `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 | | `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL;`harness-ops` 和 `harness-opt` 是同义别名。 | @@ -73,6 +74,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client agent send --message "你好" --provider-profile deepseek --timeout-ms 120000`,确认响应包含 accepted/result/trace 信息和 assistant 回复文本;若后端失败,输出必须包含 traceId、resultUrl 或 runnerTrace 摘要,不能无输出或只给旧 gate blocker。 +## T3.1 + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=`,再提交 `/v1/agent/chat`,输出包含 `replay.source`、`continuation.replayedFromTrace`、`conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。 + ## T4 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status`、`client request GET /v1/diagnostics/gate` 和 `client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 6c06b9d2..1134f691 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -7,6 +7,7 @@ - 向用户提供 Cloud Workbench、Code Agent 对话、live status、device-pod 右侧面板、trace 展示和帮助内容。 - 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、device-pod Service、FRP、Kubernetes 或 provider Secret。 - 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。 +- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 ## 内部架构 @@ -24,6 +25,7 @@ | `GET /help` | 返回可用 route 摘要。 | | `GET /v1`、`GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 | | `POST /v1/agent/chat`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 | +| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | ## 测试规格 @@ -48,6 +50,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。 +## T3.1 + +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:通过 `19666` Cloud Web 同源 path 对当前允许的 device-pod job/操作 POST 做只读或 dry-run 级验证,确认与 `19667` Cloud API 的 route policy 对齐;如果 Cloud API 返回业务级 4xx,Cloud Web 也应透传业务错误,不应在 Web 层直接 404。 + ## T4 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:运行 `bun run --cwd web/hwlab-cloud-web check`,确认输出或日志显示已执行 Cloud Web TypeScript 语义检查、自动发现的单元测试、bundle build 和 dist freshness 校验;不得用只跑 `bun build` 或浏览器手工刷新替代该检查。 @@ -61,7 +67,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, | 规格项 | 状态 | 说明 | | --- | --- | --- | | Workbench 首屏 | 已实现 | 当前页面直接进入工作台,不是 landing page。 | -| cloud-api 同源代理 | 已实现 | 受 route policy 控制。 | +| cloud-api 同源代理 | 已实现 | 受 route policy 控制;device-pod job POST 必须与 Cloud API route policy 对齐。 | | Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 | | device-pod 面板 | 未完全实现 | 当前主要消费 fake/只读 device-pod payload。 | | 完整多用户 admin/user UI | 未完全实现 | 登录态存在,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 | diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 4aa342e9..83173642 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -290,6 +290,53 @@ test("hwlab-cli client agent send preserves Web continuation fields", async () = assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); }); +test("hwlab-cli client agent send can replay continuation from an existing trace", async () => { + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "send", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a", + "--message", + "retry device pod question", + "--from-trace", + "trc_source_web", + "--trace-id", + "trc_replay_web", + "--no-wait" + ], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (String(url).endsWith("/v1/agent/chat/inspect?traceId=trc_source_web")) { + return new Response(JSON.stringify({ + ok: true, + status: "found", + latestTraceId: "trc_source_web", + query: { traceId: "trc_source_web" }, + conversationFacts: { conversationId: "cnv_source", sessionId: "ses_source" }, + session: { conversationId: "cnv_source", sessionId: "ses_source", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" } + }), { status: 200 }); + } + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_replay_web", resultUrl: "/v1/agent/chat/result/trc_replay_web" }), { status: 202 }); + }, + sleep: async () => {} + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_source_web"); + assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[1].body.conversationId, "cnv_source"); + assert.equal(calls[1].body.sessionId, "ses_source"); + assert.equal(calls[1].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(calls[1].body.retryOf, "trc_source_web"); + assert.equal(result.payload.continuation.replayedFromTrace, "trc_source_web"); + assert.equal(result.payload.replay.source, "cloud-web:/v1/agent/chat/inspect"); + assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false); +}); + test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => { const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl: async () => new Response(JSON.stringify({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index cd6949da..1734c96b 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -94,7 +94,7 @@ function help() { "hwlab-cli client workbench summary --pod-id device-pod-71-freq", "hwlab-cli client request GET /v1/access/status [--full]", "hwlab-cli client rpc system.health [--full]", - "hwlab-cli client agent send --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000", + "hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000", "hwlab-cli client agent result TRACE_ID", "hwlab-cli client agent trace TRACE_ID", "hwlab-cli client agent inspect --trace-id TRACE_ID", @@ -534,6 +534,55 @@ function agentInspectPath(context: any) { return `/v1/agent/chat/inspect?${params.toString()}`; } +async function resolveAgentReplayContext(context: any) { + const fromTrace = text(context.parsed.fromTrace); + if (!fromTrace) return { fromTrace: null, conversationId: "", sessionId: "", threadId: "", retryOf: "", summary: null }; + const traceId = requiredTraceId(fromTrace); + const pathName = `/v1/agent/chat/inspect?traceId=${encodeURIComponent(traceId)}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + if (!isHttpSuccess(response) || response.body?.ok === false) { + throw cliError("from_trace_inspect_failed", "client agent send --from-trace could not inspect the source trace", { + fromTrace: traceId, + route: route("GET", pathName), + httpStatus: response.status, + status: response.body?.status ?? null, + error: response.body?.error ?? null + }); + } + const body = response.body ?? {}; + const conversationId = safeReplayConversationId(body.query?.conversationId) || safeReplayConversationId(body.conversationFacts?.conversationId) || safeReplayConversationId(body.session?.conversationId) || ""; + const sessionId = safeReplaySessionId(body.query?.sessionId) || safeReplaySessionId(body.conversationFacts?.sessionId) || safeReplaySessionId(body.session?.sessionId) || ""; + const threadId = text(body.query?.threadId) || text(body.session?.threadId) || text(body.conversationFacts?.threadId); + return { + fromTrace: traceId, + conversationId, + sessionId, + threadId, + retryOf: traceId, + summary: pruneUndefined({ + source: "cloud-web:/v1/agent/chat/inspect", + fromTrace: traceId, + inspectHttpStatus: response.status, + status: body.status, + latestTraceId: body.latestTraceId, + conversationId, + sessionId, + threadId, + valuesPrinted: false + }) + }; +} + +function safeReplayConversationId(value: unknown) { + const result = text(value); + return /^cnv_[A-Za-z0-9_.:-]+$/u.test(result) ? result : ""; +} + +function safeReplaySessionId(value: unknown) { + const result = text(value); + return /^ses_[A-Za-z0-9_.:-]+$/u.test(result) ? result : ""; +} + async function harnessCommand(context: any) { const subcommand = context.rest[0] || "help"; if (["help", "--help", "-h"].includes(subcommand)) return harnessHelp(); @@ -724,20 +773,25 @@ async function agentSend(context: any) { const { parsed } = context; const message = await messageValue(context); if (!message) throw cliError("message_required", "client agent send requires --message or stdin text"); + const replay = await resolveAgentReplayContext(context); const traceId = text(parsed.traceId) || makeId("trc"); - const conversationId = text(parsed.conversationId) || makeId("cnv"); + const conversationId = text(parsed.conversationId) || replay.conversationId || makeId("cnv"); + const sessionId = text(parsed.sessionId) || replay.sessionId; + const threadId = text(parsed.threadId) || replay.threadId; + const retryOf = text(parsed.retryOf) || replay.retryOf; const continuation = agentContinuationSummary({ conversationId, - sessionId: text(parsed.sessionId), - threadId: text(parsed.threadId), - retryOf: text(parsed.retryOf) + sessionId, + threadId, + retryOf, + fromTrace: replay.fromTrace }); const requestBody = clean({ message, conversationId, - sessionId: text(parsed.sessionId), - threadId: text(parsed.threadId), - retryOf: text(parsed.retryOf), + sessionId, + threadId, + retryOf, traceId, providerProfile: text(parsed.providerProfile) || "deepseek", gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs), @@ -757,10 +811,10 @@ async function agentSend(context: any) { } }); if (!isHttpSuccess(accepted)) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary }); } if (parsed.noWait === true) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, waited: false }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, waited: false }); } const result = await pollAgentResult(context, traceId, accepted.body); return ok("client.agent.send", { @@ -769,6 +823,7 @@ async function agentSend(context: any) { traceId, conversationId, continuation, + replay: replay.summary, accepted: compactBody(accepted.body), result: result.response ? compactResponse(result.response) : null, polls: result.polls, @@ -1289,10 +1344,11 @@ function compactResponse(response: any) { return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) }; } -function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null }) { +function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null; fromTrace?: string | null }) { return pruneUndefined({ webEquivalent: true, shortConnection: true, + replayedFromTrace: text(value.fromTrace), conversationId: text(value.conversationId), sessionId: text(value.sessionId), threadId: text(value.threadId),