3593 lines
172 KiB
TypeScript
3593 lines
172 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "./hwlab-cli/composer-policy.ts";
|
|
import { traceDisplayRows, traceNoiseEventCount } from "./hwlab-cli/trace-renderer.ts";
|
|
import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts";
|
|
|
|
const VERSION = "0.2.0-client";
|
|
const CLI_NAME = "hwlab-cli";
|
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
const DEFAULT_AGENT_TIMEOUT_MS = 120000;
|
|
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
const HARNESS_WAIT_DEFAULT_TIMEOUT_MS = 50000;
|
|
const HARNESS_WAIT_MAX_TIMEOUT_MS = 50000;
|
|
const DEFAULT_GATEWAY_SESSION_ID = "gws_D601_F103";
|
|
const DEFAULT_GATEWAY_RESOURCE_ID = "res_windows_host";
|
|
const DEFAULT_GATEWAY_CAPABILITY_ID = "cap_windows_cmd_exec";
|
|
const DEFAULT_GATEWAY_PROJECT_ID = "prj_mvp_topology";
|
|
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_device_pod_workbench";
|
|
|
|
type EnvLike = Record<string, string | undefined>;
|
|
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
|
type FetchLike = typeof fetch;
|
|
|
|
type CliOptions = {
|
|
env?: EnvLike;
|
|
fetchImpl?: FetchLike;
|
|
stdinText?: string;
|
|
cwd?: string;
|
|
now?: () => string;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
};
|
|
|
|
export async function main(argv = process.argv.slice(2), options: CliOptions = {}) {
|
|
const result = await runHwlabCli(argv, options);
|
|
console.log(JSON.stringify(result.payload, null, 2));
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
|
|
export async function runHwlabCli(argv: string[], options: CliOptions = {}) {
|
|
const env = options.env ?? process.env;
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
try {
|
|
const parsed = parseOptions(argv);
|
|
const target = parsed._[0] || "help";
|
|
const context = {
|
|
parsed,
|
|
env,
|
|
fetchImpl: options.fetchImpl ?? fetch,
|
|
stdinText: options.stdinText,
|
|
cwd: options.cwd ?? process.cwd(),
|
|
now,
|
|
sleep: options.sleep ?? wait
|
|
};
|
|
const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : help();
|
|
return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) };
|
|
} catch (error) {
|
|
return { exitCode: 1, payload: withMeta(failure("hwlab-cli", error), now) };
|
|
}
|
|
}
|
|
|
|
async function clientCommand(context: any) {
|
|
const group = context.rest[0] || "help";
|
|
const next = { ...context, rest: context.rest.slice(1) };
|
|
if (["help", "--help", "-h"].includes(group)) return help();
|
|
if (context.parsed.help === true) {
|
|
const groupHelp = clientSubcommandHelp(group);
|
|
if (groupHelp) return groupHelp;
|
|
}
|
|
if (group === "auth") return authCommand(next);
|
|
if (group === "access") return accessCommand(next);
|
|
if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesCommand(next);
|
|
if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next);
|
|
if (group === "runtime") return runtimeCommand(next);
|
|
if (group === "gateway") return gatewayCommand(next);
|
|
if (group === "agent") return agentCommand(next);
|
|
if (group === "session" || group === "sessions") return sessionCommand(next);
|
|
if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessCommand(next);
|
|
if (group === "workbench") return workbenchCommand(next);
|
|
if (group === "rpc") return rpcCommand(next);
|
|
if (group === "request") return requestCommand(next);
|
|
throw cliError("unsupported_client_command", `unsupported client command: ${group}`, { group });
|
|
}
|
|
|
|
function clientSubcommandHelp(group: string) {
|
|
if (group === "auth") return authHelp();
|
|
if (group === "access") return accessHelp();
|
|
if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesHelp();
|
|
if (group === "device-pods" || group === "device-pod") return devicePodsHelp();
|
|
if (group === "runtime") return runtimeHelp();
|
|
if (group === "gateway") return gatewayHelp();
|
|
if (group === "agent") return agentHelp();
|
|
if (group === "session" || group === "sessions") return sessionHelp();
|
|
if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessHelp();
|
|
if (group === "workbench") return workbenchHelp();
|
|
if (group === "rpc") return rpcHelp();
|
|
if (group === "request") return requestHelp();
|
|
return null;
|
|
}
|
|
|
|
function help() {
|
|
return ok("help", {
|
|
version: VERSION,
|
|
contractVersion: "hwlab-web-equivalent-client-v1",
|
|
mode: "short-connection-client",
|
|
endpointResolver: {
|
|
defaultBaseUrl: null,
|
|
behavior: "fail-closed unless HWLAB_RUNTIME_* env or HWLAB_RUNTIME_NAMESPACE resolves the current lane",
|
|
explicitOverride: "local-debug-only; forbidden when HWLAB_RUNTIME_ENDPOINT_LOCKED=1"
|
|
},
|
|
stateFile: ".state/hwlab-cli/session.json",
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
jobTemplate: false,
|
|
usage: [
|
|
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --username USER --password-env HWLAB_PASSWORD",
|
|
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --profile NAME --username USER --password-env HWLAB_PASSWORD",
|
|
"hwlab-cli client auth status",
|
|
"hwlab-cli client auth session",
|
|
"hwlab-cli client auth profiles",
|
|
"hwlab-cli client access summary",
|
|
"hwlab-cli client access users list",
|
|
"hwlab-cli client access users inspect USER_ID",
|
|
"hwlab-cli client access users set-role USER_ID --role admin|user [--status active|disabled]",
|
|
"hwlab-cli client access device-pods grant USER_ID POD_ID --relation viewer",
|
|
"hwlab-cli client access device-pods revoke USER_ID POD_ID --relation viewer",
|
|
"hwlab-cli client access tools grant USER_ID hwpod",
|
|
"hwlab-cli client access tools revoke USER_ID hwpod",
|
|
"hwlab-cli client access check --user USER_ID --relation viewer --object device_pod:POD_ID",
|
|
"hwlab-cli client provider-profiles list",
|
|
"hwlab-cli client provider-profiles set-key deepseek --key-stdin",
|
|
"hwlab-cli client provider-profiles validate deepseek --wait --timeout-ms 120000",
|
|
"hwlab-cli client device-pods list",
|
|
"hwlab-cli client device-pods status device-pod-71-freq",
|
|
"hwlab-cli client runtime routes",
|
|
"hwlab-cli client gateway sessions",
|
|
"hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103",
|
|
"hwlab-cli client session list",
|
|
"hwlab-cli client session create",
|
|
"hwlab-cli client session switch cnv_...",
|
|
"hwlab-cli client session delete cnv_... --confirm",
|
|
"hwlab-cli client session inspect cnv_... [--trace-id trc_...]",
|
|
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
|
|
"hwlab-cli client workbench restore --profile NAME",
|
|
"hwlab-cli client workbench status --profile NAME",
|
|
"hwlab-cli client workbench watch --profile NAME --after-revision REVISION",
|
|
"hwlab-cli client workbench reset --profile NAME --confirm",
|
|
"hwlab-cli client request GET /v1/access/status [--full]",
|
|
"hwlab-cli client request GET /v1/web-performance/summary [--full]",
|
|
"hwlab-cli client rpc system.health [--full]",
|
|
"hwlab-cli client agent session create --provider-profile minimax-m3",
|
|
"hwlab-cli client agent session select SESSION_ID",
|
|
"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|codex-api|minimax-m3",
|
|
"hwlab-cli client agent send --message TEXT --wait --timeout-ms 50000",
|
|
"hwlab-cli client agent result TRACE_ID",
|
|
"hwlab-cli client agent trace TRACE_ID [--render web]",
|
|
"hwlab-cli client agent inspect --trace-id TRACE_ID",
|
|
"hwlab-cli client agent cancel TRACE_ID",
|
|
"hwlab-cli client agent steer TRACE_ID --message TEXT|--message-file PATH",
|
|
"hwlab-cli client harness submit --message TEXT --provider-profile deepseek|codex-api|minimax-m3",
|
|
"hwlab-cli client harness wait TRACE_ID --timeout-ms 50000",
|
|
"hwlab-cli client harness audit TRACE_ID --require-bootsharp"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function authCommand(context: any) {
|
|
const subcommand = context.rest[0] || "session";
|
|
if (wantsHelp(context)) return authHelp();
|
|
if (subcommand === "login") return authLogin(context);
|
|
if (subcommand === "oidc-login") {
|
|
const response = await fetch(`${runtimeEndpoint(context.parsed, context.env, "web").baseUrl}/auth/oidc/login?returnTo=/`, { method: "GET", redirect: "manual" });
|
|
const location = response.headers.get("location") ?? "";
|
|
return ok("client.auth.oidc-login", {
|
|
baseUrl: runtimeEndpoint(context.parsed, context.env, "web").baseUrl,
|
|
authorizeUrl: location,
|
|
hint: "open this URL in a browser to complete OIDC login; once the callback returns, run `client auth whoami` to verify the session"
|
|
});
|
|
}
|
|
if (subcommand === "status") return authStatus(context);
|
|
if (subcommand === "profiles") return authProfiles(context);
|
|
if (subcommand === "whoami") {
|
|
const response = await requestJson({ ...context, method: "GET", path: "/v1/users/me" });
|
|
return responsePayload("client.auth.whoami", response, context, {
|
|
route: route("GET", "/v1/users/me"),
|
|
actor: response.body?.actor ?? null,
|
|
authMethod: response.body?.authMethod ?? null
|
|
});
|
|
}
|
|
if (subcommand === "session") {
|
|
const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }));
|
|
const response = await requestJson({ ...context, method: "GET", path: "/auth/session" });
|
|
return responsePayload("client.auth.session", response, context, { route: route("GET", "/auth/session"), localSession });
|
|
}
|
|
if (subcommand === "logout") {
|
|
const response = await requestJson({ ...context, method: "POST", path: "/auth/logout" });
|
|
await clearSession(context);
|
|
return responsePayload("client.auth.logout", response, context, { route: route("POST", "/auth/logout"), localSessionCleared: true });
|
|
}
|
|
throw cliError("unsupported_auth_command", `unsupported auth command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function authHelp() {
|
|
return ok("client.auth.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"login --username USER --password-env HWLAB_PASSWORD [--profile NAME]",
|
|
"oidc-login",
|
|
"status [--profile NAME]",
|
|
"whoami",
|
|
"session [--profile NAME]",
|
|
"profiles",
|
|
"logout [--profile NAME]"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function accessCommand(context: any) {
|
|
const subcommand = context.rest[0] || "summary";
|
|
if (wantsHelp(context)) return accessHelp();
|
|
if (subcommand === "summary") {
|
|
const pathName = "/v1/admin/access/summary";
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.access.summary", response, context, { route: route("GET", pathName), body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "users") {
|
|
return accessUsersCommand({ ...context, rest: context.rest.slice(1) });
|
|
}
|
|
if (subcommand === "device-pods" || subcommand === "device-pod") {
|
|
return accessDevicePodsCommand({ ...context, rest: context.rest.slice(1) });
|
|
}
|
|
if (subcommand === "tools") {
|
|
return accessToolsCommand({ ...context, rest: context.rest.slice(1) });
|
|
}
|
|
if (subcommand === "check") {
|
|
return accessCheckCommand({ ...context, rest: context.rest.slice(1) });
|
|
}
|
|
throw cliError("unsupported_access_command", `unsupported access command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
async function accessUsersCommand(context: any) {
|
|
const subcommand = context.rest[0] || "list";
|
|
if (subcommand === "list") {
|
|
const pathName = "/v1/admin/access/users";
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.access.users.list", response, context, { route: route("GET", pathName), body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "inspect") {
|
|
const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId");
|
|
const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.access.users.inspect", response, context, { route: route("GET", pathName), userId, body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "set-role") {
|
|
const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId");
|
|
const role = normalizeAccessUserRole(context.parsed.role ?? context.rest[2]);
|
|
const status = context.parsed.status === undefined ? undefined : normalizeAccessUserStatus(context.parsed.status);
|
|
const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}`;
|
|
const response = await requestJson({ ...context, method: "PATCH", path: pathName, body: pruneUndefined({ role, status }) });
|
|
return responsePayload("client.access.users.set-role", response, context, { route: route("PATCH", pathName), userId, role, status, body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
throw cliError("unsupported_access_users_command", `unsupported access users command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
async function accessDevicePodsCommand(context: any) {
|
|
const subcommand = context.rest[0] || "help";
|
|
if (subcommand !== "grant" && subcommand !== "revoke") throw cliError("unsupported_access_device_pods_command", `unsupported access device-pods command: ${subcommand}`, { subcommand });
|
|
const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId");
|
|
const devicePodId = requiredText(context.parsed.devicePodId ?? context.parsed.podId ?? context.rest[2], "devicePodId");
|
|
const relation = normalizeDevicePodRelation(context.parsed.relation ?? context.rest[3]);
|
|
const method = subcommand === "grant" ? "PUT" : "DELETE";
|
|
const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`;
|
|
const response = await requestJson({ ...context, method, path: pathName, body: {} });
|
|
return responsePayload(`client.access.device-pods.${subcommand}`, response, context, { route: route(method, pathName), userId, devicePodId, relation, body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function accessToolsCommand(context: any) {
|
|
const subcommand = context.rest[0] || "help";
|
|
if (subcommand !== "grant" && subcommand !== "revoke") throw cliError("unsupported_access_tools_command", `unsupported access tools command: ${subcommand}`, { subcommand });
|
|
const userId = requiredText(context.parsed.userId ?? context.rest[1], "userId");
|
|
const toolId = normalizeToolId(context.parsed.toolId ?? context.rest[2]);
|
|
const method = subcommand === "grant" ? "PUT" : "DELETE";
|
|
const pathName = `/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`;
|
|
const response = await requestJson({ ...context, method, path: pathName, body: {} });
|
|
return responsePayload(`client.access.tools.${subcommand}`, response, context, { route: route(method, pathName), userId, toolId, relation: "can_use", body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function accessCheckCommand(context: any) {
|
|
const userId = requiredText(context.parsed.user ?? context.parsed.userId ?? context.parsed.actorId ?? context.rest[0], "userId");
|
|
const relation = requiredText(context.parsed.relation ?? context.rest[1], "relation");
|
|
const object = requiredText(context.parsed.object ?? context.rest[2], "object");
|
|
const pathName = "/v1/admin/access/check";
|
|
const response = await requestJson({ ...context, method: "POST", path: pathName, body: { userId, relation, object } });
|
|
return responsePayload("client.access.check", response, context, { route: route("POST", pathName), userId, relation, object, body: adminAccessBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
function accessHelp() {
|
|
return ok("client.access.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
sameOrigin: true,
|
|
commands: [
|
|
"summary",
|
|
"users list",
|
|
"users inspect USER_ID",
|
|
"users set-role USER_ID --role admin|user [--status active|disabled]",
|
|
"device-pods grant USER_ID POD_ID --relation viewer|operator|profile_editor|job_submitter",
|
|
"device-pods revoke USER_ID POD_ID --relation viewer|operator|profile_editor|job_submitter",
|
|
"tools grant USER_ID hwpod|unidesk_ssh|trans_cmd|github_pr",
|
|
"tools revoke USER_ID hwpod|unidesk_ssh|trans_cmd|github_pr",
|
|
"check --user USER_ID --relation REL --object TYPE:ID"
|
|
],
|
|
routes: {
|
|
summary: route("GET", "/v1/admin/access/summary"),
|
|
users: route("GET", "/v1/admin/access/users")
|
|
}
|
|
});
|
|
}
|
|
|
|
async function providerProfilesCommand(context: any) {
|
|
const subcommand = context.rest[0] || "list";
|
|
if (wantsHelp(context)) return providerProfilesHelp();
|
|
if (subcommand === "list") {
|
|
const pathName = "/v1/admin/provider-profiles";
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.provider-profiles.list", response, context, { route: route("GET", pathName), body: providerProfileBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "set-key" || subcommand === "set" || subcommand === "credential") {
|
|
const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]);
|
|
if (context.parsed.keyStdin !== true) throw cliError("key_stdin_required", "set-key requires --key-stdin so the API key is not passed in argv", { command: "provider-profiles set-key PROFILE --key-stdin" });
|
|
const apiKey = await providerProfileApiKeyFromStdin(context);
|
|
const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`;
|
|
const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { apiKey }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
return responsePayload("client.provider-profiles.set-key", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false });
|
|
}
|
|
if (subcommand === "validate") {
|
|
return providerProfilesValidate(context);
|
|
}
|
|
if (subcommand === "validation" || subcommand === "result") {
|
|
const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]);
|
|
const validationId = requiredText(context.parsed.validationId ?? context.rest[2], "validationId");
|
|
const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.provider-profiles.validation", response, context, { route: route("GET", pathName), profile, validationId, body: providerProfileBodyForCli(response.body, context.parsed) });
|
|
}
|
|
throw cliError("unsupported_provider_profiles_command", `unsupported provider-profiles command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
async function providerProfilesValidate(context: any) {
|
|
const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]);
|
|
const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validate`;
|
|
const response = await requestJson({ ...context, method: "POST", path: pathName, body: {}, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
const body = providerProfileBodyForCli(response.body, context.parsed);
|
|
const validationId = text(response.body?.data?.validationId ?? response.body?.validationId);
|
|
if (context.parsed.wait !== true || !responseSucceeded(response) || !validationId) {
|
|
return responsePayload("client.provider-profiles.validate", response, context, { route: route("POST", pathName), profile, validationId: validationId || undefined, waited: false, body, waitPolicy: providerProfileWaitPolicy(profile, validationId) });
|
|
}
|
|
|
|
const timeoutMs = Math.min(numberOption(context.parsed.timeoutMs) ?? 120000, 120000);
|
|
const pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 500);
|
|
const deadline = Date.now() + timeoutMs;
|
|
let polls = 0;
|
|
let lastResponse = response;
|
|
let lastBody = body;
|
|
while (Date.now() < deadline) {
|
|
const status = validationTerminalStatus(lastResponse.body);
|
|
if (status.terminal) {
|
|
return responsePayload("client.provider-profiles.validate", lastResponse, context, { route: route("GET", `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`), profile, validationId, waited: true, timedOut: false, polls, body: lastBody, waitPolicy: providerProfileWaitPolicy(profile, validationId) });
|
|
}
|
|
await context.sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
polls += 1;
|
|
const resultPath = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`;
|
|
lastResponse = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) });
|
|
lastBody = providerProfileBodyForCli(lastResponse.body, context.parsed);
|
|
}
|
|
return ok("client.provider-profiles.validate", {
|
|
baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(context.parsed, context.env, context.endpointKind ?? "web"),
|
|
route: route("GET", `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`),
|
|
profile,
|
|
validationId,
|
|
waited: true,
|
|
timedOut: true,
|
|
polls,
|
|
timeoutMsRequested: numberOption(context.parsed.timeoutMs) ?? 120000,
|
|
timeoutMsEffective: timeoutMs,
|
|
body: lastBody,
|
|
waitPolicy: providerProfileWaitPolicy(profile, validationId)
|
|
}, "timeout");
|
|
}
|
|
|
|
function providerProfilesHelp() {
|
|
return ok("client.provider-profiles.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
sameOrigin: true,
|
|
valuesPrinted: false,
|
|
commands: [
|
|
"list",
|
|
"set-key PROFILE --key-stdin",
|
|
"validate PROFILE [--wait] [--timeout-ms N] [--poll-interval-ms N]",
|
|
"validation PROFILE VALIDATION_ID"
|
|
],
|
|
routes: {
|
|
list: route("GET", "/v1/admin/provider-profiles"),
|
|
setKey: route("PUT", "/v1/admin/provider-profiles/:profile/credential"),
|
|
validate: route("POST", "/v1/admin/provider-profiles/:profile/validate"),
|
|
validation: route("GET", "/v1/admin/provider-profiles/:profile/validations/:validationId")
|
|
}
|
|
});
|
|
}
|
|
|
|
async function providerProfileApiKeyFromStdin(context: any) {
|
|
const apiKey = context.stdinText !== undefined ? String(context.stdinText).trimEnd() : await readStdin();
|
|
if (apiKey.trim().length < 8) throw cliError("api_key_too_short", "provider profile API key from stdin is too short", { minLength: 8 });
|
|
return apiKey.trim();
|
|
}
|
|
|
|
function normalizeProviderProfile(value: unknown) {
|
|
const profile = text(value).toLowerCase();
|
|
const normalized = profile === "codex" ? "codex-api" : profile;
|
|
if (["deepseek", "codex-api", "minimax-m3"].includes(normalized)) return normalized;
|
|
throw cliError("invalid_provider_profile", "provider profile must be deepseek, codex-api, or minimax-m3", { profile });
|
|
}
|
|
|
|
function validationTerminalStatus(body: any) {
|
|
const status = text(body?.data?.result?.terminalStatus ?? body?.data?.status ?? body?.result?.terminalStatus ?? body?.status).toLowerCase();
|
|
return { status, terminal: ["completed", "failed", "canceled", "cancelled", "timeout", "blocked"].includes(status) };
|
|
}
|
|
|
|
function providerProfileWaitPolicy(profile: string, validationId: string) {
|
|
return pruneUndefined({
|
|
webEquivalent: true,
|
|
shortConnection: true,
|
|
remotePassthroughSafe: true,
|
|
defaultWait: false,
|
|
profile,
|
|
validationId: text(validationId) || undefined,
|
|
maxTimeoutMs: 120000,
|
|
nextCommands: text(validationId) ? [`hwlab-cli client provider-profiles validation ${profile} ${validationId}`, `hwlab-cli client provider-profiles validate ${profile} --wait --timeout-ms 120000`] : [`hwlab-cli client provider-profiles validate ${profile} --wait --timeout-ms 120000`]
|
|
});
|
|
}
|
|
|
|
function normalizeDevicePodRelation(value: unknown) {
|
|
const relation = text(value).replace(/-/gu, "_");
|
|
if (["viewer", "operator", "profile_editor", "job_submitter"].includes(relation)) return relation;
|
|
throw cliError("invalid_device_pod_relation", "device-pod relation must be viewer, operator, profile_editor, or job_submitter", { relation });
|
|
}
|
|
|
|
function normalizeToolId(value: unknown) {
|
|
const toolId = text(value).replace(/-/gu, "_");
|
|
if (["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"].includes(toolId)) return toolId;
|
|
throw cliError("invalid_tool_id", "tool must be hwpod, unidesk_ssh, trans_cmd, or github_pr", { toolId });
|
|
}
|
|
|
|
function normalizeAccessUserRole(value: unknown) {
|
|
const role = text(value);
|
|
if (role === "admin" || role === "user") return role;
|
|
throw cliError("invalid_access_user_role", "role must be admin or user", { role });
|
|
}
|
|
|
|
function normalizeAccessUserStatus(value: unknown) {
|
|
const status = text(value);
|
|
if (status === "active" || status === "disabled") return status;
|
|
throw cliError("invalid_access_user_status", "status must be active or disabled", { status });
|
|
}
|
|
|
|
async function authStatus(context: any) {
|
|
const endpoint = runtimeEndpoint(context.parsed, context.env, "web");
|
|
const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }));
|
|
const apiKey = explicitApiKey(context.parsed, context.env);
|
|
return ok("client.auth.status", {
|
|
baseUrl: endpoint.baseUrl,
|
|
runtimeEndpoint: runtimeEndpointVisibility(endpoint),
|
|
stateFile: localSession.stateFile,
|
|
localSession,
|
|
apiKey: apiKey ? { source: "env-or-explicit", prefix: apiKeyPrefixSummary(apiKey) } : { source: null, prefix: null },
|
|
nextCommands: authNextCommands({ baseUrl: endpoint.baseUrl, username: text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin", stateFile: localSession.stateFile })
|
|
});
|
|
}
|
|
|
|
async function authProfiles(context: any) {
|
|
const dir = profileStateDir(context.parsed, context.cwd ?? process.cwd(), context.env);
|
|
let entries: string[] = [];
|
|
try {
|
|
entries = await readdir(dir);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
const profiles = [];
|
|
for (const entry of entries.filter((name) => name.endsWith(".json")).sort()) {
|
|
const profile = entry.slice(0, -5);
|
|
const state = await readSessionState({ parsed: { ...context.parsed, profile }, env: context.env, cwd: context.cwd ?? process.cwd() });
|
|
profiles.push({ profile, ...sessionStateSummary(state) });
|
|
}
|
|
return ok("client.auth.profiles", {
|
|
baseUrl: baseUrl(context.parsed, context.env),
|
|
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
|
|
profileDir: dir,
|
|
profiles,
|
|
profileCount: profiles.length
|
|
});
|
|
}
|
|
|
|
async function authLogin(context: any) {
|
|
const { parsed, env } = context;
|
|
const username = text(parsed.username ?? env.HWLAB_USERNAME) || "admin";
|
|
const password = await passwordValue(context);
|
|
const response = await requestJson({ ...context, method: "POST", path: "/auth/login", body: { username, password }, auth: false });
|
|
const cookie = cookieFromResponse(response);
|
|
const success = isHttpSuccess(response) && response.body?.authenticated === true;
|
|
if (success && cookie) {
|
|
await saveSession(context, {
|
|
baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(parsed, env),
|
|
cookie,
|
|
user: safeUser(response.body?.user ?? response.body?.actor),
|
|
expiresAt: textOrNull(response.body?.expiresAt),
|
|
updatedAt: context.now()
|
|
});
|
|
}
|
|
return responsePayload("client.auth.login", response, context, {
|
|
route: route("POST", "/auth/login"),
|
|
username,
|
|
cookieStored: Boolean(success && cookie),
|
|
body: authBodySummary(response.body)
|
|
});
|
|
}
|
|
|
|
async function devicePodsCommand(context: any) {
|
|
const subcommand = context.rest[0] || "list";
|
|
if (wantsHelp(context)) return devicePodsHelp();
|
|
if (subcommand === "list") {
|
|
const response = await requestJson({ ...context, method: "GET", path: "/v1/device-pods" });
|
|
return responsePayload("client.device-pods.list", response, context, { route: route("GET", "/v1/device-pods"), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
const podId = requiredText(context.parsed.podId ?? context.rest[1], "podId");
|
|
if (subcommand === "status" || subcommand === "show") {
|
|
const pathName = `/v1/device-pods/${encodeURIComponent(podId)}/status`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.device-pods.status", response, context, { route: route("GET", pathName), devicePodId: podId, body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "events") {
|
|
const limit = numberOption(context.parsed.limit) ?? 120;
|
|
const pathName = `/v1/device-pods/${encodeURIComponent(podId)}/events?limit=${encodeURIComponent(String(limit))}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.device-pods.events", response, context, { route: route("GET", pathName), devicePodId: podId, body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "probe") return devicePodProbe(context, podId);
|
|
throw cliError("unsupported_device_pods_command", `unsupported device-pods command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function devicePodsHelp() {
|
|
return ok("client.device-pods.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"list",
|
|
"status POD_ID",
|
|
"events POD_ID [--limit N]",
|
|
"probe POD_ID [--max-bytes N]"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function runtimeCommand(context: any) {
|
|
const subcommand = context.rest[0] || "routes";
|
|
if (wantsHelp(context)) return runtimeHelp();
|
|
if (subcommand !== "routes" && subcommand !== "pods") {
|
|
throw cliError("unsupported_runtime_command", `unsupported runtime command: ${subcommand}`, { subcommand });
|
|
}
|
|
const response = await requestJson({ ...context, method: "GET", path: "/v1/live-builds", auth: false });
|
|
const services = liveBuildServices(response.body);
|
|
const requested = text(context.parsed.serviceId);
|
|
const selected = services.filter((item: any) => {
|
|
const serviceId = text(item?.serviceId ?? item?.id ?? item?.name);
|
|
return !requested || serviceId === requested;
|
|
}).map(runtimeRouteSummary).filter(Boolean);
|
|
return responsePayload("client.runtime.routes", response, context, {
|
|
route: route("GET", "/v1/live-builds"),
|
|
discovery: {
|
|
ok: true,
|
|
source: "cloud-web:/v1/live-builds",
|
|
serviceCount: services.length,
|
|
routeCount: selected.length,
|
|
readyRouteCount: selected.filter((item: any) => Boolean(item.unideskRoute)).length
|
|
},
|
|
serviceId: requested || null,
|
|
items: selected,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
function runtimeHelp() {
|
|
return ok("client.runtime.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"routes [--service-id SERVICE]",
|
|
"pods [--service-id SERVICE]"
|
|
]
|
|
});
|
|
}
|
|
|
|
function liveBuildServices(body: any) {
|
|
const builds = Array.isArray(body?.builds) ? body.builds : [];
|
|
if (builds.length > 0) return builds;
|
|
return Array.isArray(body?.services) ? body.services : [];
|
|
}
|
|
|
|
function runtimeRouteSummary(value: any) {
|
|
const serviceId = text(value?.serviceId ?? value?.id ?? value?.name);
|
|
const runtime = value?.runtime && typeof value.runtime === "object" ? value.runtime : {};
|
|
const pod = runtime?.pod && typeof runtime.pod === "object" ? runtime.pod : {};
|
|
const podText = typeof value?.pod === "string" ? value.pod : typeof runtime?.pod === "string" ? runtime.pod : "";
|
|
const podName = text(
|
|
value?.podName ??
|
|
runtime?.podName ??
|
|
pod?.name ??
|
|
podText ??
|
|
value?.live?.podName ??
|
|
value?.health?.podName
|
|
);
|
|
const namespace = text(
|
|
value?.namespace ??
|
|
runtime?.namespace ??
|
|
pod?.namespace ??
|
|
value?.live?.namespace ??
|
|
value?.desiredState?.namespace
|
|
) || "hwlab-v02";
|
|
const container = text(
|
|
value?.container ??
|
|
value?.containerName ??
|
|
runtime?.container ??
|
|
runtime?.containerName ??
|
|
pod?.container ??
|
|
pod?.containerName ??
|
|
value?.live?.containerName
|
|
) || defaultRuntimeContainer(serviceId);
|
|
if (!serviceId && !podName) return null;
|
|
const unideskRoute = normalizeUnideskPodRoute(
|
|
text(value?.unideskRoute ?? runtime?.unideskRoute ?? pod?.unideskRoute),
|
|
namespace,
|
|
podName,
|
|
container
|
|
);
|
|
return pruneUndefined({
|
|
serviceId,
|
|
podName,
|
|
namespace,
|
|
container,
|
|
unideskRoute,
|
|
source: "cloud-web:/v1/live-builds",
|
|
useWith: unideskRoute ? `bun scripts/cli.ts ssh '${unideskRoute}' script -- 'pwd'` : undefined
|
|
});
|
|
}
|
|
|
|
function defaultRuntimeContainer(serviceId: string) {
|
|
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
|
|
return "";
|
|
}
|
|
|
|
function normalizeUnideskPodRoute(routeValue: string, namespace: string, podName: string, container: string) {
|
|
if (podName) return `G14:k3s:${namespace}:pod:${podName}${container ? `:${container}` : ""}`;
|
|
if (!routeValue) return null;
|
|
return routeValue.replace(/:pod\//gu, ":pod:");
|
|
}
|
|
|
|
async function devicePodProbe(context: any, podId: string) {
|
|
const encoded = encodeURIComponent(podId);
|
|
const paths = [
|
|
`/v1/device-pods/${encoded}/debug-probe/chip-id`,
|
|
`/v1/device-pods/${encoded}/io-probe/uart/1`,
|
|
`/v1/device-pods/${encoded}/io-probe/uart/1/tail?maxBytes=${encodeURIComponent(String(numberOption(context.parsed.maxBytes) ?? 12000))}`
|
|
];
|
|
const probes = await Promise.all(paths.map(async (pathName) => {
|
|
try {
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return compactProbe("GET", pathName, response);
|
|
} catch (error) {
|
|
return { route: route("GET", pathName), ok: false, error: errorSummary(error) };
|
|
}
|
|
}));
|
|
const failed = probes.some((probe) => probe.ok === false);
|
|
return ok("client.device-pods.probe", { status: failed ? "degraded" : "succeeded", devicePodId: podId, probes }, failed ? "degraded" : "succeeded");
|
|
}
|
|
|
|
async function gatewayCommand(context: any) {
|
|
const subcommand = context.rest[0] || "sessions";
|
|
if (["help", "--help", "-h"].includes(subcommand)) return gatewayHelp();
|
|
const apiContext = gatewayApiContext(context);
|
|
if (subcommand === "sessions") {
|
|
const response = await requestJson({
|
|
...apiContext,
|
|
method: "GET",
|
|
path: "/v1/gateway/sessions",
|
|
auth: context.parsed.noAuth === true ? false : context.parsed.auth === true
|
|
});
|
|
return responsePayload("client.gateway.sessions", response, apiContext, {
|
|
route: route("GET", "/v1/gateway/sessions"),
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
if (subcommand === "invoke") {
|
|
const command = requiredText(context.parsed.command ?? context.rest.slice(1).join(" "), "command");
|
|
const result = await invokeGatewayShell(apiContext, {
|
|
name: "invoke",
|
|
command,
|
|
timeoutMs: numberOption(context.parsed.commandTimeoutMs) ?? numberOption(context.parsed.timeoutMs) ?? 30000
|
|
});
|
|
return ok("client.gateway.invoke", { status: result.ok ? "succeeded" : "failed", result }, result.ok ? "succeeded" : "failed");
|
|
}
|
|
if (subcommand === "pressure") return gatewayPressure(apiContext);
|
|
throw cliError("unsupported_gateway_command", `unsupported gateway command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function gatewayHelp() {
|
|
return ok("client.gateway.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"sessions",
|
|
"invoke --command CMD [--gateway-session-id ID]",
|
|
"pressure [--gateway-session-id ID] [--large-bytes N] [--parallel N]"
|
|
]
|
|
});
|
|
}
|
|
|
|
function gatewayApiContext(context: any) {
|
|
return { ...context, endpointKind: "api" };
|
|
}
|
|
|
|
async function gatewayPressure(context: any) {
|
|
const largeBytes = boundedInteger(context.parsed.largeBytes, 131072, 4096, 1048576);
|
|
const parallel = boundedInteger(context.parsed.parallel, 8, 0, 32);
|
|
const requestTimeoutMs = boundedInteger(context.parsed.requestTimeoutMs, 45000, 1000, 180000);
|
|
const timeoutScenarioMs = boundedInteger(context.parsed.timeoutScenarioMs, 1000, 250, 30000);
|
|
const startedAt = Date.now();
|
|
const scenarios = gatewayPressureScenarios({ largeBytes, timeoutScenarioMs });
|
|
const results = [];
|
|
for (const scenario of scenarios) {
|
|
results.push(await invokeGatewayShell(context, { ...scenario, requestTimeoutMs }));
|
|
}
|
|
if (parallel > 0) {
|
|
const parallelResults = await Promise.all(Array.from({ length: parallel }, (_, index) => invokeGatewayShell(context, {
|
|
name: `parallel-${index + 1}`,
|
|
kind: "parallel",
|
|
command: powershellEncodedCommand(`Start-Sleep -Milliseconds 3500; Write-Output 'parallel-${index + 1}-ok'`),
|
|
timeoutMs: 12000,
|
|
requestTimeoutMs,
|
|
allowGatewayBusy: true,
|
|
marker: `parallel-${index + 1}-ok`
|
|
})));
|
|
results.push(...parallelResults);
|
|
}
|
|
const failures = results.filter((item) => item.ok !== true);
|
|
return ok("client.gateway.pressure", {
|
|
status: failures.length > 0 ? "failed" : "succeeded",
|
|
baseUrl: baseUrl(context.parsed, context.env, "api"),
|
|
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "api")),
|
|
gatewaySessionId: gatewaySelector(context).gatewaySessionId,
|
|
scenarioCount: results.length,
|
|
failedCount: failures.length,
|
|
elapsedMs: Date.now() - startedAt,
|
|
config: { largeBytes, parallel, requestTimeoutMs, timeoutScenarioMs },
|
|
results,
|
|
recommendation: failures.length > 0
|
|
? "Gateway transport pressure found failures; inspect failed result.request/result.rpcSummary before continuing device-pod work."
|
|
: "Gateway transport pressure passed: large stdout/stderr and over-capacity parallel requests returned bounded structured results without request blackholes."
|
|
}, failures.length > 0 ? "failed" : "succeeded");
|
|
}
|
|
|
|
function gatewayPressureScenarios({ largeBytes, timeoutScenarioMs }: { largeBytes: number; timeoutScenarioMs: number }) {
|
|
return [
|
|
{ name: "small-stdout", kind: "small", command: "echo hwlab-gateway-pressure-small-ok", timeoutMs: 10000, marker: "hwlab-gateway-pressure-small-ok" },
|
|
{ name: "large-stdout", kind: "large-stdout", command: powershellEncodedCommand(`[Console]::Out.Write(('O' * ${largeBytes}))`), timeoutMs: 20000, expectStdoutTruncated: true },
|
|
{ name: "long-single-line", kind: "long-line", command: powershellEncodedCommand(`[Console]::Out.Write(('L' * ${largeBytes}))`), timeoutMs: 20000, expectStdoutTruncated: true },
|
|
{ name: "stderr-flood", kind: "stderr", command: powershellEncodedCommand(`[Console]::Error.Write(('E' * ${largeBytes}))`), timeoutMs: 20000, expectStderrTruncated: true },
|
|
{ name: "timeout-kill", kind: "timeout", command: powershellEncodedCommand("Start-Sleep -Seconds 5; Write-Output 'timeout-missed'"), timeoutMs: timeoutScenarioMs, expectTimedOut: true }
|
|
];
|
|
}
|
|
|
|
function powershellEncodedCommand(script: string) {
|
|
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
|
|
}
|
|
|
|
async function invokeGatewayShell(context: any, scenario: any) {
|
|
const selector = gatewaySelector(context);
|
|
const traceId = text(context.parsed.traceId) || makeId(`trc_gateway_${scenario.name.replace(/[^a-z0-9]+/giu, "_")}`);
|
|
const requestId = text(context.parsed.id) || makeId(`req_gateway_${scenario.name.replace(/[^a-z0-9]+/giu, "_")}`);
|
|
const body = {
|
|
gatewaySessionId: selector.gatewaySessionId,
|
|
resourceId: selector.resourceId,
|
|
capabilityId: selector.capabilityId,
|
|
projectId: selector.projectId,
|
|
input: {
|
|
command: scenario.command,
|
|
timeoutMs: scenario.timeoutMs
|
|
}
|
|
};
|
|
const startedAt = Date.now();
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/v1/rpc/hardware.invoke.shell",
|
|
body,
|
|
auth: context.parsed.auth === true,
|
|
timeoutMs: scenario.requestTimeoutMs ?? numberOption(context.parsed.requestTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
|
|
extraHeaders: {
|
|
"x-trace-id": traceId,
|
|
"x-request-id": requestId
|
|
}
|
|
});
|
|
return summarizeGatewayScenario({ scenario, response, traceId, requestId, startedAt });
|
|
}
|
|
|
|
function gatewaySelector(context: any) {
|
|
return {
|
|
gatewaySessionId: text(context.parsed.gatewaySessionId) || DEFAULT_GATEWAY_SESSION_ID,
|
|
resourceId: text(context.parsed.resourceId) || DEFAULT_GATEWAY_RESOURCE_ID,
|
|
capabilityId: text(context.parsed.capabilityId) || DEFAULT_GATEWAY_CAPABILITY_ID,
|
|
projectId: text(context.parsed.projectId) || DEFAULT_GATEWAY_PROJECT_ID
|
|
};
|
|
}
|
|
|
|
function summarizeGatewayScenario({ scenario, response, traceId, requestId, startedAt }: any) {
|
|
const rpc = response.body && typeof response.body === "object" ? response.body : {};
|
|
const result = rpc.result && typeof rpc.result === "object" ? rpc.result : {};
|
|
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
|
|
const stdout = String(dispatch.stdout ?? "");
|
|
const stderr = String(dispatch.stderr ?? "");
|
|
const gatewayBusy = dispatch.error?.data?.reason === "gateway_busy" || /gateway is busy/iu.test(String(dispatch.error?.message ?? dispatch.message ?? ""));
|
|
const okByKind = gatewayScenarioPass({ scenario, response, dispatch, stdout, stderr, gatewayBusy });
|
|
return pruneUndefined({
|
|
name: scenario.name,
|
|
kind: scenario.kind,
|
|
ok: okByKind.ok,
|
|
reason: okByKind.reason,
|
|
httpStatus: response.status,
|
|
elapsedMs: Date.now() - startedAt,
|
|
requestElapsedMs: response.elapsedMs,
|
|
traceId,
|
|
requestId,
|
|
operationId: result.operationId ?? dispatch.operationId,
|
|
rpcStatus: result.status,
|
|
dispatchStatus: dispatch.dispatchStatus,
|
|
shellExecuted: dispatch.shellExecuted,
|
|
exitCode: dispatch.exitCode,
|
|
timedOut: dispatch.timedOut,
|
|
gatewayBusy,
|
|
stdoutBytes: Buffer.byteLength(stdout, "utf8"),
|
|
stderrBytes: Buffer.byteLength(stderr, "utf8"),
|
|
stdoutTruncated: dispatch.stdoutTruncated,
|
|
stderrTruncated: dispatch.stderrTruncated,
|
|
stdoutSha256: stdout ? sha256(stdout) : undefined,
|
|
stderrSha256: stderr ? sha256(stderr) : undefined,
|
|
stdoutPreview: stdout ? preview(stdout, 160) : undefined,
|
|
stderrPreview: stderr ? preview(stderr, 160) : undefined,
|
|
error: rpc.error ?? dispatch.error,
|
|
message: dispatch.message,
|
|
commandPreview: preview(scenario.command, 220)
|
|
});
|
|
}
|
|
|
|
function gatewayScenarioPass({ scenario, response, dispatch, stdout, stderr, gatewayBusy }: any) {
|
|
if (!isHttpSuccess(response)) return { ok: false, reason: `http_${response.status}` };
|
|
if (scenario.allowGatewayBusy && gatewayBusy) return { ok: true, reason: "structured_gateway_busy" };
|
|
if (scenario.expectTimedOut) {
|
|
return dispatch.timedOut === true || dispatch.dispatchStatus === "timed_out"
|
|
? { ok: true, reason: "timeout_structured" }
|
|
: { ok: false, reason: "timeout_not_observed" };
|
|
}
|
|
if (scenario.expectStdoutTruncated && dispatch.stdoutTruncated !== true) return { ok: false, reason: "stdout_not_truncated" };
|
|
if (scenario.expectStderrTruncated && dispatch.stderrTruncated !== true) return { ok: false, reason: "stderr_not_truncated" };
|
|
if (scenario.marker && !stdout.includes(scenario.marker)) return { ok: false, reason: "marker_missing" };
|
|
if (dispatch.exitCode !== 0) return { ok: false, reason: `exit_${dispatch.exitCode}` };
|
|
if (scenario.expectStdoutTruncated && Buffer.byteLength(stdout, "utf8") === 0) return { ok: false, reason: "stdout_empty" };
|
|
if (scenario.expectStderrTruncated && Buffer.byteLength(stderr, "utf8") === 0) return { ok: false, reason: "stderr_empty" };
|
|
return { ok: true, reason: "completed" };
|
|
}
|
|
|
|
async function agentCommand(context: any) {
|
|
const subcommand = context.rest[0] || "send";
|
|
if (wantsHelp(context)) return agentHelp();
|
|
if (subcommand === "session" || subcommand === "sessions") return agentSessionCommand(context);
|
|
if (subcommand === "send") return agentSend(context);
|
|
if (subcommand === "composer") return agentComposer(context);
|
|
if (subcommand === "result") {
|
|
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
|
|
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.agent.result", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
if (subcommand === "trace") {
|
|
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
|
|
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
const traceBody = traceBodyForCli(response.body, context.parsed);
|
|
return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId, body: traceBody, ...traceResponseAliases(traceBody, context.parsed) });
|
|
}
|
|
if (subcommand === "inspect") {
|
|
return agentInspect(context);
|
|
}
|
|
if (subcommand === "steer") return agentSteer(context);
|
|
if (subcommand === "cancel") {
|
|
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
|
|
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } });
|
|
return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId });
|
|
}
|
|
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
async function agentInspect(context: any) {
|
|
const pathName = agentInspectPath(context);
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
if (responseSucceeded(response) || !shouldFallbackInspectBySession(context, response)) {
|
|
return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
const fallback = await inspectConversationBySessionId(context, requiredSessionId(context.parsed.sessionId));
|
|
if (!fallback) {
|
|
return responsePayload("client.agent.inspect", response, context, {
|
|
route: route("GET", pathName),
|
|
fallbackLookup: { attempted: true, source: "client.session.list", found: false },
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function shouldFallbackInspectBySession(context: any, response: any): boolean {
|
|
if (!text(context.parsed.sessionId)) return false;
|
|
if (text(context.parsed.traceId) || text(context.parsed.conversationId) || text(context.parsed.threadId)) return false;
|
|
return response.status === 404 || response.body?.status === "not_found" || response.body?.ok === false;
|
|
}
|
|
|
|
async function inspectConversationBySessionId(context: any, sessionId: string) {
|
|
const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
|
|
const limit = numberOption(context.parsed.limit) ?? 100;
|
|
const listPath = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`;
|
|
const listResponse = await requestJson({ ...context, method: "GET", path: listPath });
|
|
if (!responseSucceeded(listResponse)) return null;
|
|
const match = Array.isArray(listResponse.body?.conversations)
|
|
? listResponse.body.conversations.find((item: any) => text(item?.sessionId ?? item?.session?.sessionId) === sessionId) ?? null
|
|
: null;
|
|
const conversationId = text(match?.conversationId);
|
|
if (!conversationId) return null;
|
|
const inspectPath = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
|
|
const inspectResponse = await requestJson({ ...context, method: "GET", path: inspectPath, extraHeaders: text(match?.lastTraceId) ? { "x-trace-id": text(match.lastTraceId) } : undefined });
|
|
return responsePayload("client.agent.inspect", inspectResponse, context, {
|
|
route: route("GET", inspectPath),
|
|
conversation: sessionSummary(inspectResponse.body?.conversation ?? match),
|
|
fallbackLookup: {
|
|
attempted: true,
|
|
source: "client.session.list",
|
|
listRoute: route("GET", listPath),
|
|
matchedSessionId: sessionId,
|
|
conversationId,
|
|
traceId: text(match?.lastTraceId)
|
|
},
|
|
body: responseBodyForCli(inspectResponse.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
function agentHelp() {
|
|
return ok("client.agent.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"session create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]",
|
|
"session select SESSION_ID [--workspace-id ID]",
|
|
"session status SESSION_ID",
|
|
"session list [--limit N]",
|
|
"send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [--provider-profile deepseek|codex-api|minimax-m3] [default: short return]",
|
|
"composer status [--profile NAME] [--full]",
|
|
"composer submit --message TEXT|--message-file PATH [--profile NAME] [default: auto turn/steer]",
|
|
"send --message TEXT --wait [--timeout-ms N] [--poll-interval-ms N]",
|
|
"result TRACE_ID [--full]",
|
|
"trace TRACE_ID [--render web] [--limit N] [--full]",
|
|
"inspect --trace-id TRACE_ID|--conversation-id ID|--session-id ID|--thread-id ID",
|
|
"steer TRACE_ID --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID]",
|
|
"cancel TRACE_ID"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function sessionCommand(context: any) {
|
|
const subcommand = context.rest[0] || "list";
|
|
if (wantsHelp(context)) return sessionHelp();
|
|
if (subcommand === "list" || subcommand === "ls") return sessionListCommand(context);
|
|
if (subcommand === "current" || subcommand === "status") return sessionCurrentCommand(context);
|
|
if (subcommand === "create" || subcommand === "new") return sessionCreateCommand(context);
|
|
if (subcommand === "switch" || subcommand === "select") return sessionSwitchCommand(context);
|
|
if (subcommand === "delete" || subcommand === "rm") return sessionDeleteCommand(context);
|
|
if (subcommand === "inspect" || subcommand === "describe" || subcommand === "show") return sessionInspectCommand(context);
|
|
if (subcommand === "final-response" || subcommand === "final") return sessionFinalResponseCommand(context);
|
|
throw cliError("unsupported_session_command", `unsupported session command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function sessionHelp() {
|
|
return ok("client.session.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
webEquivalent: true,
|
|
commands: [
|
|
"list [--limit N]",
|
|
"current",
|
|
"create [--conversation-id ID] [--session-id ID] [--thread-id ID]",
|
|
"switch CONVERSATION_ID|--conversation-id ID",
|
|
"delete CONVERSATION_ID|--conversation-id ID --confirm",
|
|
"inspect CONVERSATION_ID|--conversation-id ID [--trace-id TRACE]",
|
|
"final-response CONVERSATION_ID|--conversation-id ID [--trace-id TRACE] [--project-id PROJECT] [--fallback-text TEXT]"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function sessionInspectCommand(context: any) {
|
|
const conversationId = text(context.rest[1]) ?? text(context.parsed.conversationId);
|
|
if (!conversationId) {
|
|
throw cliError("conversation_id_required", "session inspect requires CONVERSATION_ID or --conversation-id", { usage: "hwlab-cli client session inspect <conversationId>" });
|
|
}
|
|
const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
|
|
const headers = text(context.parsed.traceId) ? { "x-trace-id": text(context.parsed.traceId) } : undefined;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName, extraHeaders: headers });
|
|
return responsePayload("client.session.inspect", response, context, {
|
|
route: route("GET", pathName),
|
|
lookup: clean({
|
|
conversationId,
|
|
traceId: text(context.parsed.traceId)
|
|
}),
|
|
summary: sessionSummary(response.body?.conversation),
|
|
conversation: response.body?.conversation ?? null,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function sessionListCommand(context: any) {
|
|
const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
|
|
const limit = numberOption(context.parsed.limit) ?? 100;
|
|
const pathName = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.session.list", response, context, {
|
|
route: route("GET", pathName),
|
|
sessions: sessionSummaries(response.body?.conversations),
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function sessionFinalResponseCommand(context: any) {
|
|
const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId);
|
|
const inspectPath = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
|
|
const inspectResponse = await requestJson({ ...context, method: "GET", path: inspectPath });
|
|
const conversation = inspectResponse.body?.conversation ?? null;
|
|
if (!responseSucceeded(inspectResponse) || !conversation) {
|
|
const validation = finalResponseEarlyValidation({
|
|
conversationId,
|
|
routeName: "inspect",
|
|
response: inspectResponse,
|
|
failure: conversation ? "inspect-request-failed" : "inspect-conversation-missing"
|
|
});
|
|
return {
|
|
...responsePayload("client.session.final-response", inspectResponse, context, {
|
|
route: route("GET", inspectPath),
|
|
conversationId,
|
|
routes: { inspect: route("GET", inspectPath) },
|
|
httpStatuses: { inspect: inspectResponse.status },
|
|
validation,
|
|
inspect: finalResponseConversationSummary(conversation),
|
|
body: context.parsed.full === true ? { inspect: inspectResponse.body } : compactApiBody(inspectResponse.body)
|
|
}),
|
|
ok: false,
|
|
status: "failed"
|
|
};
|
|
}
|
|
const projectId = text(context.parsed.projectId) || text(conversation?.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
|
|
const limit = numberOption(context.parsed.limit) ?? 100;
|
|
const listPath = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`;
|
|
const listResponse = await requestJson({ ...context, method: "GET", path: listPath });
|
|
if (!responseSucceeded(listResponse)) {
|
|
const validation = finalResponseEarlyValidation({
|
|
conversationId,
|
|
projectId,
|
|
routeName: "list",
|
|
response: listResponse,
|
|
failure: "list-request-failed"
|
|
});
|
|
return {
|
|
...responsePayload("client.session.final-response", listResponse, context, {
|
|
route: route("GET", listPath),
|
|
conversationId,
|
|
projectId,
|
|
routes: { inspect: route("GET", inspectPath), list: route("GET", listPath) },
|
|
httpStatuses: { inspect: inspectResponse.status, list: listResponse.status },
|
|
validation,
|
|
inspect: finalResponseConversationSummary(conversation),
|
|
body: context.parsed.full === true ? { inspect: inspectResponse.body, list: listResponse.body } : compactApiBody(listResponse.body)
|
|
}),
|
|
ok: false,
|
|
status: "failed"
|
|
};
|
|
}
|
|
const listedConversation = Array.isArray(listResponse.body?.conversations)
|
|
? listResponse.body.conversations.find((item: any) => text(item?.conversationId) === conversationId) ?? null
|
|
: null;
|
|
const effectiveConversation = listedConversation ?? conversation;
|
|
const resolvedTraceId = text(context.parsed.traceId ?? effectiveConversation?.lastTraceId ?? latestAgentMessage(effectiveConversation)?.traceId);
|
|
if (!resolvedTraceId) {
|
|
const validation = finalResponseEarlyValidation({
|
|
conversationId,
|
|
projectId,
|
|
routeName: "conversation",
|
|
response: listResponse,
|
|
failure: "trace-id-missing"
|
|
});
|
|
return {
|
|
...responsePayload("client.session.final-response", listResponse, context, {
|
|
route: route("GET", listPath),
|
|
conversationId,
|
|
projectId,
|
|
routes: { inspect: route("GET", inspectPath), list: route("GET", listPath) },
|
|
httpStatuses: { inspect: inspectResponse.status, list: listResponse.status },
|
|
validation,
|
|
inspect: finalResponseConversationSummary(conversation),
|
|
list: finalResponseConversationSummary(listedConversation),
|
|
body: context.parsed.full === true ? { inspect: inspectResponse.body, list: listResponse.body } : undefined
|
|
}),
|
|
ok: false,
|
|
status: "failed"
|
|
};
|
|
}
|
|
const traceId = requiredTraceId(resolvedTraceId);
|
|
const resultPath = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
|
const resultResponse = await requestJson({ ...context, method: "GET", path: resultPath });
|
|
const validation = finalResponseValidation({
|
|
conversationId,
|
|
projectId,
|
|
inspectConversation: conversation,
|
|
listedConversation,
|
|
traceId,
|
|
resultBody: resultResponse.body,
|
|
fallbackText: text(context.parsed.fallbackText) || "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"
|
|
});
|
|
const success = responseSucceeded(inspectResponse) && responseSucceeded(listResponse) && responseSucceeded(resultResponse) && validation.ok;
|
|
return {
|
|
...responsePayload("client.session.final-response", resultResponse, context, {
|
|
route: route("GET", resultPath),
|
|
conversationId,
|
|
projectId,
|
|
traceId,
|
|
routes: {
|
|
inspect: route("GET", inspectPath),
|
|
list: route("GET", listPath),
|
|
result: route("GET", resultPath)
|
|
},
|
|
httpStatuses: {
|
|
inspect: inspectResponse.status,
|
|
list: listResponse.status,
|
|
result: resultResponse.status
|
|
},
|
|
validation,
|
|
inspect: finalResponseConversationSummary(conversation),
|
|
list: finalResponseConversationSummary(listedConversation),
|
|
result: finalResponseResultSummary(resultResponse.body),
|
|
body: context.parsed.full === true ? {
|
|
inspect: inspectResponse.body,
|
|
list: listResponse.body,
|
|
result: resultResponse.body
|
|
} : finalResponseResultSummary(resultResponse.body)
|
|
}),
|
|
ok: success,
|
|
status: success ? "succeeded" : "failed"
|
|
};
|
|
}
|
|
|
|
async function sessionCurrentCommand(context: any) {
|
|
const workspace = await restoreWorkbenchWorkspace(context, { quiet: false });
|
|
return ok("client.session.current", {
|
|
baseUrl: baseUrl(context.parsed, context.env),
|
|
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
|
|
route: route("GET", `/v1/workbench/workspace?projectId=${encodeURIComponent(text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID)}`),
|
|
workspace,
|
|
current: sessionSummary(workspace?.selectedConversation) ?? sessionSummary({
|
|
conversationId: workspace?.selectedConversationId,
|
|
sessionId: workspace?.selectedAgentSessionId,
|
|
threadId: workspace?.threadId,
|
|
status: workspace?.sessionStatus,
|
|
lastTraceId: workspace?.activeTraceId,
|
|
updatedAt: workspace?.updatedAt
|
|
})
|
|
});
|
|
}
|
|
|
|
async function sessionCreateCommand(context: any) {
|
|
const workspace = await restoreWorkbenchWorkspace(context, { quiet: false });
|
|
const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId);
|
|
if (!workspaceId) throw cliError("workspace_restore_required", "session create requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" });
|
|
const conversationId = safeOptionalConversationId(context.parsed.conversationId);
|
|
const sessionId = safeOptionalSessionId(context.parsed.sessionId);
|
|
const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`;
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: pathName,
|
|
body: clean({
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
create: true,
|
|
conversationId,
|
|
sessionId,
|
|
threadId: text(context.parsed.threadId),
|
|
updatedByClient: "hwlab-cli"
|
|
})
|
|
});
|
|
if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace);
|
|
return responsePayload("client.session.create", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function sessionSwitchCommand(context: any) {
|
|
const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId);
|
|
const workspace = await restoreWorkbenchWorkspace(context, { quiet: false });
|
|
const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId);
|
|
if (!workspaceId) throw cliError("workspace_restore_required", "session switch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" });
|
|
const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`;
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: pathName,
|
|
body: clean({
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
conversationId,
|
|
sessionId: safeOptionalSessionId(context.parsed.sessionId),
|
|
threadId: text(context.parsed.threadId),
|
|
updatedByClient: "hwlab-cli"
|
|
})
|
|
});
|
|
if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace);
|
|
return responsePayload("client.session.switch", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function sessionDeleteCommand(context: any) {
|
|
const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId);
|
|
if (context.parsed.confirm !== true) throw cliError("session_delete_confirmation_required", "session delete requires --confirm", { flag: "--confirm", conversationId });
|
|
const workspace = await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId);
|
|
const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "DELETE",
|
|
path: pathName,
|
|
body: clean({
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
workspaceId,
|
|
updatedByClient: "hwlab-cli"
|
|
})
|
|
});
|
|
if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace);
|
|
return responsePayload("client.session.delete", response, context, { route: route("DELETE", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function agentSessionCommand(context: any) {
|
|
const subcommand = context.rest[1] || "status";
|
|
if (["help", "--help", "-h"].includes(subcommand)) return agentSessionHelp();
|
|
if (subcommand === "create" || subcommand === "new") return agentSessionCreate(context);
|
|
if (subcommand === "select") return agentSessionSelect(context);
|
|
if (subcommand === "status" || subcommand === "show") return agentSessionStatus(context);
|
|
if (subcommand === "list" || subcommand === "ls") return agentSessionList(context);
|
|
throw cliError("unsupported_agent_session_command", `unsupported agent session command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function agentSessionHelp() {
|
|
return ok("client.agent.session.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]",
|
|
"select SESSION_ID [--workspace-id ID]",
|
|
"status SESSION_ID",
|
|
"list [--limit N]"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function agentSessionCreate(context: any) {
|
|
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const body = clean({
|
|
conversationId: text(context.parsed.conversationId),
|
|
sessionId: text(context.parsed.sessionId),
|
|
providerProfile: text(context.parsed.providerProfile) || "minimax-m3",
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId),
|
|
updatedByClient: "hwlab-cli"
|
|
});
|
|
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/sessions", body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body);
|
|
const session = agentSessionSummary(response.body?.session, body);
|
|
return responsePayload("client.agent.session.create", response, context, {
|
|
route: route("POST", "/v1/agent/sessions"),
|
|
...session,
|
|
session: response.body?.session ?? null,
|
|
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function agentSessionSelect(context: any) {
|
|
const sessionId = requiredText(context.rest[2] ?? context.parsed.sessionId, "sessionId");
|
|
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}/select`;
|
|
const body = clean({
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId),
|
|
providerProfile: text(context.parsed.providerProfile),
|
|
updatedByClient: "hwlab-cli"
|
|
});
|
|
const response = await requestJson({ ...context, method: "POST", path: pathName, body, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
if (responseSucceeded(response)) await saveManualAgentSessionWorkspaceState(context, response.body, body);
|
|
const session = agentSessionSummary(response.body?.session, { sessionId, providerProfile: body.providerProfile });
|
|
return responsePayload("client.agent.session.select", response, context, {
|
|
route: route("POST", pathName),
|
|
...session,
|
|
session: response.body?.session ?? null,
|
|
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function agentSessionStatus(context: any) {
|
|
const sessionId = requiredText(context.rest[2] ?? context.parsed.sessionId, "sessionId");
|
|
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.agent.session.status", response, context, { route: route("GET", pathName), ...agentSessionSummary(response.body?.session, { sessionId }), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function fetchAgentSessionForSend(context: any, sessionId: string) {
|
|
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
if (!isHttpSuccess(response)) return { response, session: null };
|
|
const session = response.body?.session && typeof response.body.session === "object" ? response.body.session : null;
|
|
return { response, session };
|
|
}
|
|
|
|
async function agentSessionList(context: any) {
|
|
const params = new URLSearchParams();
|
|
params.set("projectId", text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID);
|
|
const limit = numberOption(context.parsed.limit);
|
|
if (limit) params.set("limit", String(limit));
|
|
const pathName = `/v1/agent/sessions?${params.toString()}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.agent.session.list", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function agentComposer(context: any) {
|
|
const subcommand = context.rest[1] || "status";
|
|
if (subcommand === "status") return agentComposerStatus(context);
|
|
if (subcommand === "submit" || subcommand === "send") return agentComposerSubmit(context);
|
|
throw cliError("unsupported_agent_composer_command", `unsupported agent composer command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
async function agentComposerStatus(context: any) {
|
|
const workspaceState = await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
|
const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState));
|
|
return ok("client.agent.composer.status", {
|
|
baseUrl: baseUrl(context.parsed, context.env),
|
|
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
|
|
status: "succeeded",
|
|
route: route("POST", composer.route),
|
|
composer,
|
|
workspace: workspaceState,
|
|
workspaceRestore: workspaceState ? "succeeded" : "unavailable",
|
|
nextCommands: composer.submitMode === "steer"
|
|
? [`hwlab-cli client agent composer submit --message TEXT`, `hwlab-cli client agent trace ${composer.targetTraceId} --render web`]
|
|
: ["hwlab-cli client agent composer submit --message TEXT"]
|
|
});
|
|
}
|
|
|
|
async function agentComposerSubmit(context: any) {
|
|
const message = await messageValue(context);
|
|
if (!message) throw cliError("message_required", "client agent composer submit requires --message or stdin text");
|
|
const workspaceState = context.parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const localState = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
|
const composer = computeCliComposerState(context, workspaceState ?? workspaceSummaryFromSession(localState));
|
|
if (composer.submitMode === "steer") {
|
|
const traceId = requiredTraceId(composer.targetTraceId ?? context.parsed.traceId ?? context.parsed.targetTraceId);
|
|
const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer");
|
|
const body = clean({
|
|
traceId,
|
|
targetTraceId: traceId,
|
|
steerTraceId,
|
|
message,
|
|
conversationId: text(context.parsed.conversationId) || composer.conversationId,
|
|
sessionId: text(context.parsed.sessionId) || composer.sessionId,
|
|
threadId: text(context.parsed.threadId) || composer.threadId
|
|
});
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/v1/agent/chat/steer",
|
|
body,
|
|
timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
|
|
extraHeaders: {
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
}
|
|
});
|
|
return responsePayload("client.agent.composer.submit", response, context, {
|
|
route: route("POST", "/v1/agent/chat/steer"),
|
|
traceId,
|
|
steerTraceId,
|
|
composer,
|
|
workspace: workspaceSummaryFromSession(await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
|
|
body: responseBodyForCli(response.body, context.parsed),
|
|
waitPolicy: {
|
|
defaultWait: false,
|
|
webEquivalent: true,
|
|
reason: "Composer detected an active Web turn and submitted steer through the same Cloud Web path.",
|
|
nextCommands: [
|
|
`hwlab-cli client agent result ${traceId}`,
|
|
`hwlab-cli client agent trace ${traceId} --render web`
|
|
]
|
|
}
|
|
});
|
|
}
|
|
const sendContext = {
|
|
...context,
|
|
parsed: {
|
|
...context.parsed,
|
|
message,
|
|
conversationId: text(context.parsed.conversationId) || composer.conversationId,
|
|
sessionId: text(context.parsed.sessionId) || composer.sessionId,
|
|
threadId: text(context.parsed.threadId) || composer.threadId,
|
|
workspaceId: text(context.parsed.workspaceId) || composer.workspaceId,
|
|
expectedWorkspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? composer.workspaceRevision
|
|
}
|
|
};
|
|
const result = await agentSend(sendContext);
|
|
return { ...result, action: "client.agent.composer.submit", composer };
|
|
}
|
|
|
|
function computeCliComposerState(context: any, workspaceState: any) {
|
|
const state = workspaceState;
|
|
return computeCodeAgentComposerState({
|
|
workspace: state,
|
|
conversationId: text(context.parsed.conversationId) || text(state?.selectedConversationId),
|
|
sessionId: text(context.parsed.sessionId) || text(state?.selectedAgentSessionId),
|
|
threadId: text(context.parsed.threadId) || text(state?.threadId),
|
|
sessionStatus: text(context.parsed.sessionStatus) || text(state?.sessionStatus),
|
|
targetTraceId: text(context.parsed.traceId ?? context.parsed.targetTraceId) || text(state?.activeTraceId),
|
|
workspaceId: text(context.parsed.workspaceId) || text(state?.workspaceId),
|
|
workspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? state?.revision,
|
|
messages: Array.isArray(state?.messages) ? state.messages : undefined
|
|
});
|
|
}
|
|
|
|
async function agentSteer(context: any) {
|
|
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId ?? context.parsed.targetTraceId);
|
|
const message = await messageValue(context);
|
|
if (!message) throw cliError("message_required", "client agent steer requires --message or stdin text");
|
|
const steerTraceId = text(context.parsed.steerTraceId) || makeId("trc_steer");
|
|
const body = clean({
|
|
traceId,
|
|
targetTraceId: traceId,
|
|
steerTraceId,
|
|
message,
|
|
conversationId: text(context.parsed.conversationId),
|
|
sessionId: text(context.parsed.sessionId),
|
|
threadId: text(context.parsed.threadId)
|
|
});
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/v1/agent/chat/steer",
|
|
body,
|
|
timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
|
|
extraHeaders: {
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
}
|
|
});
|
|
return responsePayload("client.agent.steer", response, context, {
|
|
route: route("POST", "/v1/agent/chat/steer"),
|
|
traceId,
|
|
steerTraceId,
|
|
body: responseBodyForCli(response.body, context.parsed),
|
|
waitPolicy: {
|
|
defaultWait: false,
|
|
webEquivalent: true,
|
|
reason: "Steer is applied to the active Code Agent turn; observe the original target trace/result to confirm whether the runner accepted and applied it.",
|
|
nextCommands: [
|
|
`hwlab-cli client agent result ${traceId}`,
|
|
`hwlab-cli client agent trace ${traceId} --render web`
|
|
]
|
|
}
|
|
});
|
|
}
|
|
|
|
function agentInspectPath(context: any) {
|
|
const params = new URLSearchParams();
|
|
const traceId = text(context.rest[1] ?? context.parsed.traceId);
|
|
const conversationId = text(context.parsed.conversationId);
|
|
const sessionId = text(context.parsed.sessionId);
|
|
const threadId = text(context.parsed.threadId);
|
|
if (traceId) params.set("traceId", requiredTraceId(traceId));
|
|
if (conversationId) params.set("conversationId", conversationId);
|
|
if (sessionId) params.set("sessionId", sessionId);
|
|
if (threadId) params.set("threadId", threadId);
|
|
if ([...params.keys()].length === 0) {
|
|
throw cliError("missing_inspect_query", "client agent inspect requires --trace-id, --conversation-id, --session-id, or --thread-id");
|
|
}
|
|
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);
|
|
const sessionStatus = text(body.session?.status ?? body.conversationFacts?.sessionStatus ?? body.runnerTrace?.sessionStatus ?? body.status);
|
|
if (isCodeAgentSessionUnusableStatus(sessionStatus)) {
|
|
throw cliError("session_not_usable", "client agent send --from-trace refuses to reuse a failed/stale Code Agent session; create/select a new session first", {
|
|
fromTrace: traceId,
|
|
conversationId: conversationId || null,
|
|
sessionId: sessionId || null,
|
|
threadId: threadId || null,
|
|
sessionStatus,
|
|
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
|
|
});
|
|
}
|
|
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,
|
|
sessionStatus: sessionStatus || null,
|
|
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();
|
|
if (subcommand === "health") {
|
|
const response = await requestJson({ ...context, method: "GET", path: "/health/live", auth: false });
|
|
return responsePayload("client.harness.health", response, context, {
|
|
route: route("GET", "/health/live"),
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
if (subcommand === "submit") return harnessSubmit({ ...context, rest: context.rest.slice(1) });
|
|
if (subcommand === "result") return harnessResult({ ...context, rest: context.rest.slice(1) });
|
|
if (subcommand === "trace") return harnessTrace({ ...context, rest: context.rest.slice(1) });
|
|
if (subcommand === "wait") return harnessWait({ ...context, rest: context.rest.slice(1) });
|
|
if (subcommand === "audit") return harnessAudit({ ...context, rest: context.rest.slice(1) });
|
|
throw cliError("unsupported_harness_command", `unsupported harness command: ${subcommand}`, { subcommand });
|
|
}
|
|
|
|
function harnessHelp() {
|
|
return ok("client.harness.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
aliases: ["harness", "harness-ops", "harness-opt"],
|
|
commands: [
|
|
"health [--base-url URL]",
|
|
"submit --message TEXT|--message-file PATH [--conversation-id ID] [--trace-id ID] [--provider-profile deepseek|codex-api|minimax-m3]",
|
|
"result TRACE_ID [--full]",
|
|
"trace TRACE_ID [--limit N] [--full]",
|
|
"wait TRACE_ID [--timeout-ms N] [--poll-interval-ms N]",
|
|
"audit TRACE_ID|--trace-file PATH [--strict] [--require-bootsharp]"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function harnessSubmit(context: any) {
|
|
const message = await messageValue(context);
|
|
if (!message.trim()) {
|
|
throw cliError(
|
|
"message_required",
|
|
"client harness submit requires --message, --message-file, or stdin text"
|
|
);
|
|
}
|
|
const traceId = text(context.parsed.traceId) || makeId("trc");
|
|
const conversationId = text(context.parsed.conversationId) || makeId("cnv");
|
|
const body = clean({
|
|
message,
|
|
conversationId,
|
|
sessionId: text(context.parsed.sessionId),
|
|
threadId: text(context.parsed.threadId),
|
|
traceId,
|
|
providerProfile: text(context.parsed.providerProfile) || "deepseek",
|
|
timeoutMs: numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs),
|
|
hardTimeoutMs: numberOption(context.parsed.hardTimeoutMs),
|
|
gatewayShellTimeoutMs: numberOption(context.parsed.gatewayShellTimeoutMs),
|
|
shortConnection: true,
|
|
projectId: text(context.parsed.projectId) || "prj_harness_ops"
|
|
});
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/v1/agent/chat",
|
|
body,
|
|
timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
|
|
extraHeaders: {
|
|
"x-trace-id": traceId,
|
|
prefer: "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
}
|
|
});
|
|
return responsePayload("client.harness.submit", response, context, {
|
|
route: route("POST", "/v1/agent/chat"),
|
|
traceId,
|
|
conversationId,
|
|
accepted: response.body?.accepted ?? false,
|
|
resultUrl: response.body?.resultUrl ?? `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
|
|
traceUrl: response.body?.traceUrl ?? `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function harnessResult(context: any) {
|
|
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
|
|
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
return responsePayload("client.harness.result", response, context, {
|
|
route: route("GET", pathName),
|
|
traceId,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
async function harnessTrace(context: any) {
|
|
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
|
|
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
const body = context.parsed.full === true
|
|
? response.body
|
|
: compactTraceBody(response.body, numberOption(context.parsed.limit) ?? 24);
|
|
return responsePayload("client.harness.trace", response, context, {
|
|
route: route("GET", pathName),
|
|
traceId,
|
|
body
|
|
});
|
|
}
|
|
|
|
async function harnessWait(context: any) {
|
|
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
|
|
const requestedTimeoutMs = numberOption(context.parsed.timeoutMs) ?? HARNESS_WAIT_DEFAULT_TIMEOUT_MS;
|
|
const timeoutMs = Math.min(requestedTimeoutMs, HARNESS_WAIT_MAX_TIMEOUT_MS);
|
|
const pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? 1000, 250);
|
|
const waitPolicy = harnessWaitPolicy(traceId, requestedTimeoutMs, timeoutMs);
|
|
const deadline = Date.now() + timeoutMs;
|
|
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
|
let polls = 0;
|
|
let lastResponse: any = null;
|
|
while (Date.now() < deadline) {
|
|
polls += 1;
|
|
lastResponse = await requestJson({
|
|
...context,
|
|
method: "GET",
|
|
path: pathName,
|
|
timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000)
|
|
});
|
|
if (lastResponse.status === 200 && lastResponse.body?.status && lastResponse.body.status !== "running") {
|
|
return responsePayload("client.harness.wait", lastResponse, context, {
|
|
route: route("GET", pathName),
|
|
traceId,
|
|
polls,
|
|
timedOut: false,
|
|
timeoutMsRequested: requestedTimeoutMs,
|
|
timeoutMsEffective: timeoutMs,
|
|
waitPolicy,
|
|
body: responseBodyForCli(lastResponse.body, context.parsed)
|
|
});
|
|
}
|
|
await context.sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
}
|
|
return ok("client.harness.wait", {
|
|
status: "timeout",
|
|
traceId,
|
|
route: route("GET", pathName),
|
|
polls,
|
|
timedOut: true,
|
|
timeoutMsRequested: requestedTimeoutMs,
|
|
timeoutMsEffective: timeoutMs,
|
|
waitPolicy,
|
|
body: lastResponse ? responseBodyForCli(lastResponse.body, context.parsed) : null
|
|
}, "timeout");
|
|
}
|
|
|
|
function harnessWaitPolicy(traceId: string, requestedTimeoutMs: number, timeoutMs: number) {
|
|
return {
|
|
remotePassthroughSafe: true,
|
|
capped: requestedTimeoutMs > timeoutMs,
|
|
reason: "UniDesk ssh/tran has a 60s top-level runtime timeout; keep harness wait below that and continue with short polling.",
|
|
nextCommands: [
|
|
`hwlab-cli client harness result ${traceId}`,
|
|
`hwlab-cli client harness trace ${traceId} --limit 24`,
|
|
`hwlab-cli client harness audit ${traceId} --require-bootsharp`
|
|
]
|
|
};
|
|
}
|
|
|
|
async function harnessAudit(context: any) {
|
|
let traceObject: any;
|
|
if (typeof context.parsed.traceFile === "string") {
|
|
traceObject = JSON.parse(await readFile(path.resolve(context.cwd, context.parsed.traceFile), "utf8"));
|
|
} else {
|
|
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "GET",
|
|
path: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`
|
|
});
|
|
traceObject = response.body;
|
|
}
|
|
const report = auditTrace(traceObject, { requireBootsharp: context.parsed.requireBootsharp === true });
|
|
const status = context.parsed.strict === true && report.frictionSignals.length > 0
|
|
? "failed"
|
|
: "succeeded";
|
|
return ok("client.harness.audit", {
|
|
traceId: traceObject?.traceId ?? context.rest[0] ?? null,
|
|
...report
|
|
}, status);
|
|
}
|
|
|
|
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 workspaceState = parsed.noWorkspace === true ? null : await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const explicitConversationId = text(parsed.conversationId);
|
|
const replayConversationId = replay.conversationId;
|
|
const requestedConversationId = explicitConversationId || replayConversationId;
|
|
const workspaceConversationId = text(workspaceState?.selectedConversationId);
|
|
const explicitSessionId = text(parsed.sessionId);
|
|
let explicitSessionRecord = null;
|
|
if (explicitSessionId && !text(parsed.providerProfile)) {
|
|
const lookup = await fetchAgentSessionForSend(context, explicitSessionId);
|
|
if (!isHttpSuccess(lookup.response)) {
|
|
return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", `/v1/agent/sessions/${encodeURIComponent(explicitSessionId)}`), traceId, sessionId: explicitSessionId, replay: replay.summary });
|
|
}
|
|
explicitSessionRecord = lookup.session;
|
|
if (requestedConversationId && text(explicitSessionRecord?.conversationId) && text(explicitSessionRecord.conversationId) !== requestedConversationId) {
|
|
throw cliError("session_conversation_mismatch", "client agent send --session-id <SESSION> --conversation-id <CONV> points at a session whose conversationId does not match; re-create the session for the conversation or pass --provider-profile to keep the user-specified profile without inheriting the session identity", {
|
|
traceId,
|
|
sessionId: explicitSessionId,
|
|
requestedConversationId,
|
|
sessionConversationId: text(explicitSessionRecord.conversationId),
|
|
nextCommands: [
|
|
`hwlab-cli client agent session create --conversation-id ${requestedConversationId}`,
|
|
"hwlab-cli client agent send --session-id <SESSION_ID> --message TEXT"
|
|
]
|
|
});
|
|
}
|
|
}
|
|
const explicitSessionConversationId = text(explicitSessionRecord?.conversationId);
|
|
const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId;
|
|
const sessionId = explicitSessionId || replay.sessionId || (allowWorkspaceSession ? text(workspaceState?.selectedAgentSessionId) : "");
|
|
const conversationId = requestedConversationId || explicitSessionConversationId || workspaceConversationId;
|
|
const workspaceSessionId = text(workspaceState?.selectedAgentSessionId);
|
|
const workspaceMatchesSelectedSession = workspaceSessionId === sessionId && (!conversationId || workspaceConversationId === conversationId);
|
|
if (!sessionId) {
|
|
throw cliError("session_required", "client agent send requires an explicit Code Agent session; run client agent session create first", {
|
|
traceId,
|
|
conversationId: conversationId || null,
|
|
nextCommands: [
|
|
"hwlab-cli client agent session create --provider-profile minimax-m3",
|
|
"hwlab-cli client agent send --session-id <SESSION_ID> --message TEXT --provider-profile minimax-m3"
|
|
]
|
|
});
|
|
}
|
|
const sessionStatus = explicitSessionRecord ? text(explicitSessionRecord.status) : (workspaceSessionId === sessionId ? text(workspaceState?.sessionStatus) : "");
|
|
if (isCodeAgentSessionUnusableStatus(sessionStatus) && !text(parsed.ignoreSessionStatus)) {
|
|
throw cliError("session_not_usable", `client agent send refuses to reuse ${sessionStatus} Code Agent session; create/select a new session first`, {
|
|
traceId,
|
|
conversationId: conversationId || null,
|
|
sessionId,
|
|
sessionStatus,
|
|
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
|
|
});
|
|
}
|
|
if (!conversationId) {
|
|
throw cliError("session_conversation_required", "selected Code Agent session does not expose conversationId; create a new session before sending", {
|
|
traceId,
|
|
sessionId,
|
|
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
|
|
});
|
|
}
|
|
const threadId = text(parsed.threadId) || replay.threadId || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : "");
|
|
const retryOf = text(parsed.retryOf) || replay.retryOf;
|
|
const continuation = agentContinuationSummary({
|
|
conversationId,
|
|
sessionId,
|
|
threadId,
|
|
retryOf,
|
|
fromTrace: replay.fromTrace
|
|
});
|
|
const requestProviderProfile = text(parsed.providerProfile)
|
|
|| text(explicitSessionRecord?.providerProfile)
|
|
|| (workspaceMatchesSelectedSession ? text(workspaceState?.providerProfile) : "")
|
|
|| "deepseek";
|
|
const requestBody = clean({
|
|
message,
|
|
conversationId,
|
|
sessionId,
|
|
threadId,
|
|
retryOf,
|
|
traceId,
|
|
providerProfile: requestProviderProfile,
|
|
gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs),
|
|
shortConnection: true,
|
|
projectId: text(parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
workspaceId: text(parsed.workspaceId) || text(workspaceState?.workspaceId),
|
|
expectedWorkspaceRevision: numberOption(parsed.expectedWorkspaceRevision) ?? workspaceState?.revision,
|
|
updatedByClient: "hwlab-cli"
|
|
});
|
|
const accepted = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/v1/agent/chat",
|
|
body: requestBody,
|
|
timeoutMs: numberOption(parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
|
|
extraHeaders: {
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
}
|
|
});
|
|
if (!isHttpSuccess(accepted)) {
|
|
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary });
|
|
}
|
|
await saveAcceptedAgentWorkspaceState(context, accepted.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile });
|
|
if (parsed.wait !== true) {
|
|
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, workspace: workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })), waited: false, waitPolicy: agentSendWaitPolicy(traceId) });
|
|
}
|
|
const result = await pollAgentResult(context, traceId, accepted.body);
|
|
const completedWorkspace = await saveCompletedAgentWorkspaceState(context, result.response?.body, { traceId, conversationId, sessionId, threadId, providerProfile: requestBody.providerProfile });
|
|
return ok("client.agent.send", {
|
|
status: result.final ? "succeeded" : "timeout",
|
|
route: route("POST", "/v1/agent/chat"),
|
|
traceId,
|
|
conversationId,
|
|
continuation,
|
|
replay: replay.summary,
|
|
accepted: compactBody(accepted.body),
|
|
result: result.response ? compactResponse(result.response) : null,
|
|
workspace: completedWorkspace ?? workspaceSummaryFromSession(await loadStoredState({ parsed, env: context.env, cwd: context.cwd ?? process.cwd() })),
|
|
polls: result.polls,
|
|
timeoutMs: result.timeoutMs,
|
|
resultUrl: result.resultPath
|
|
}, result.final ? "succeeded" : "timeout");
|
|
}
|
|
|
|
async function saveManualAgentSessionWorkspaceState(context: any, body: any, fallback: any = {}) {
|
|
const workspace = body?.workspace ? normalizeWorkbenchWorkspace(body.workspace) : null;
|
|
if (workspace) {
|
|
await saveWorkspaceState(context, body.workspace);
|
|
return workspace;
|
|
}
|
|
const sessionRecord = body?.session && typeof body.session === "object" ? body.session : null;
|
|
if (!sessionRecord) return null;
|
|
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
|
|
const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {};
|
|
const nextWorkspace = clean({
|
|
...existing,
|
|
selectedConversationId: text(sessionRecord.conversationId) || text(existing.selectedConversationId),
|
|
selectedAgentSessionId: text(sessionRecord.sessionId) || text(existing.selectedAgentSessionId),
|
|
threadId: text(sessionRecord.threadId) || text(existing.threadId),
|
|
activeTraceId: null,
|
|
providerProfile: text(fallback.providerProfile) || text(sessionRecord.providerProfile) || text(existing.providerProfile),
|
|
sessionStatus: text(sessionRecord.status) || text(existing.sessionStatus),
|
|
selectedConversation: clean({
|
|
conversationId: text(sessionRecord.conversationId) || text(existing.selectedConversation?.conversationId),
|
|
sessionId: text(sessionRecord.sessionId) || text(existing.selectedConversation?.sessionId),
|
|
threadId: text(sessionRecord.threadId) || text(existing.selectedConversation?.threadId)
|
|
}),
|
|
updatedByClient: "hwlab-cli",
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: nextWorkspace, updatedAt: context.now() });
|
|
return nextWorkspace;
|
|
}
|
|
|
|
async function pollAgentResult(context: any, traceId: string, acceptedBody: any) {
|
|
const timeoutMs = numberOption(context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS;
|
|
const startedAt = Date.now();
|
|
const resultPath = text(acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
|
let polls = 0;
|
|
let lastResponse = null;
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
polls += 1;
|
|
const response = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) });
|
|
lastResponse = response;
|
|
if (response.status === 200 && response.body?.status && response.body.status !== "running") {
|
|
return { final: true, response, polls, timeoutMs, resultPath };
|
|
}
|
|
await context.sleep(pollIntervalMs);
|
|
}
|
|
return { final: false, response: lastResponse, polls, timeoutMs, resultPath };
|
|
}
|
|
|
|
function agentSendWaitPolicy(traceId: string) {
|
|
return {
|
|
defaultWait: false,
|
|
webEquivalent: true,
|
|
reason: "Cloud Web submits /v1/agent/chat as a short request and observes progress by polling result/trace; CLI follows the same path by default to avoid UniDesk ssh/tran 60s runtime disconnects.",
|
|
waitCommand: `hwlab-cli client agent send --from-trace ${traceId} --message TEXT --wait`,
|
|
nextCommands: [
|
|
`hwlab-cli client agent result ${traceId}`,
|
|
`hwlab-cli client agent trace ${traceId} --render web`,
|
|
`hwlab-cli client workbench status`
|
|
]
|
|
};
|
|
}
|
|
|
|
async function workbenchCommand(context: any) {
|
|
const subcommand = context.rest[0] || "summary";
|
|
if (wantsHelp(context)) return workbenchHelp();
|
|
if (subcommand === "restore" || subcommand === "status") return workbenchRestoreCommand(context, subcommand);
|
|
if (subcommand === "watch") return workbenchWatchCommand(context);
|
|
if (subcommand === "reset") return workbenchResetCommand(context);
|
|
if (subcommand !== "summary") throw cliError("unsupported_workbench_command", `unsupported workbench command: ${subcommand}`, { subcommand });
|
|
const podId = text(context.parsed.podId) || "device-pod-71-freq";
|
|
const encodedPodId = encodeURIComponent(podId);
|
|
const paths = [
|
|
"/health/live",
|
|
"/v1",
|
|
"/v1/live-builds",
|
|
"/v1/device-pods",
|
|
`/v1/device-pods/${encodedPodId}/status`,
|
|
`/v1/device-pods/${encodedPodId}/events?limit=${encodeURIComponent(String(numberOption(context.parsed.limit) ?? 120))}`,
|
|
`/v1/device-pods/${encodedPodId}/debug-probe/chip-id`,
|
|
`/v1/device-pods/${encodedPodId}/io-probe/uart/1`,
|
|
`/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=${encodeURIComponent(String(numberOption(context.parsed.maxBytes) ?? 12000))}`
|
|
];
|
|
const probes = await Promise.all(paths.map(async (pathName) => {
|
|
try {
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName, auth: !["/health/live", "/v1", "/v1/live-builds"].includes(pathName) });
|
|
return compactProbe("GET", pathName, response);
|
|
} catch (error) {
|
|
return { route: route("GET", pathName), ok: false, error: errorSummary(error) };
|
|
}
|
|
}));
|
|
const failed = probes.some((probe) => probe.ok === false);
|
|
const endpoint = runtimeEndpoint(context.parsed, context.env, "web");
|
|
return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: endpoint.baseUrl, runtimeEndpoint: runtimeEndpointVisibility(endpoint), devicePodId: podId, probes }, failed ? "degraded" : "succeeded");
|
|
}
|
|
|
|
function workbenchHelp() {
|
|
return ok("client.workbench.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"summary [--pod-id POD_ID]",
|
|
"restore [--profile NAME]",
|
|
"status [--profile NAME]",
|
|
"watch [--after-revision REVISION]",
|
|
"reset --confirm"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function workbenchRestoreCommand(context: any, subcommand: string) {
|
|
const workspace = await restoreWorkbenchWorkspace(context, { quiet: false });
|
|
return ok(`client.workbench.${subcommand}`, {
|
|
baseUrl: baseUrl(context.parsed, context.env),
|
|
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
|
|
stateFile: stateFile(context.parsed, context.cwd ?? process.cwd(), context.env),
|
|
workspace,
|
|
localSession: sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }))
|
|
});
|
|
}
|
|
|
|
async function workbenchWatchCommand(context: any) {
|
|
const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
|
const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId);
|
|
if (!workspaceId) throw cliError("workspace_restore_required", "workbench watch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" });
|
|
const afterRevision = numberOption(context.parsed.afterRevision) ?? (Number(session?.workspace?.revision ?? 0) || 0);
|
|
const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/events?afterRevision=${encodeURIComponent(String(afterRevision))}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName });
|
|
if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace);
|
|
return responsePayload("client.workbench.watch", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function workbenchResetCommand(context: any) {
|
|
if (context.parsed.confirm !== true) throw cliError("workspace_reset_confirmation_required", "workbench reset requires --confirm", { flag: "--confirm" });
|
|
const session = await loadSession({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() });
|
|
const workspaceId = text(context.parsed.workspaceId) || text(session?.workspace?.workspaceId);
|
|
if (!workspaceId) throw cliError("workspace_restore_required", "workbench reset requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" });
|
|
const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/reset`;
|
|
const response = await requestJson({ ...context, method: "POST", path: pathName, body: { confirm: true, updatedByClient: "hwlab-cli" } });
|
|
if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace);
|
|
return responsePayload("client.workbench.reset", response, context, { route: route("POST", pathName), body: responseBodyForCli(response.body, context.parsed) });
|
|
}
|
|
|
|
async function requestCommand(context: any) {
|
|
if (wantsHelp(context)) return requestHelp();
|
|
const method = requiredText(context.rest[0], "method").toUpperCase();
|
|
const pathName = normalizeRequestPath(requiredText(context.rest[1], "path"));
|
|
const body = await requestBodyValue(context);
|
|
const traceId = text(context.parsed.traceId);
|
|
const response = await requestJson({
|
|
...context,
|
|
method,
|
|
path: pathName,
|
|
body,
|
|
auth: context.parsed.public === true ? false : true,
|
|
extraHeaders: clean({
|
|
...(traceId ? { "x-trace-id": traceId } : {}),
|
|
...(context.parsed.preferAsync === true ? { prefer: "respond-async" } : {})
|
|
})
|
|
});
|
|
return responsePayload("client.request", response, context, {
|
|
route: route(method, pathName),
|
|
requestedPath: pathName,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
function requestHelp() {
|
|
return ok("client.request.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"GET /path [--full]",
|
|
"GET /v1/web-performance/summary [--full]",
|
|
"POST /path --body-json '{...}' [--full]",
|
|
"METHOD /path --body-file FILE"
|
|
]
|
|
});
|
|
}
|
|
|
|
async function rpcCommand(context: any) {
|
|
if (wantsHelp(context)) return rpcHelp();
|
|
const method = requiredText(context.rest[0] ?? context.parsed.method, "method");
|
|
const params = await rpcParamsValue(context);
|
|
const traceId = text(context.parsed.traceId) || makeId("trc");
|
|
const id = text(context.parsed.id) || makeId("req");
|
|
const environment = text(context.parsed.environment) || "dev";
|
|
const serviceId = text(context.parsed.serviceId) || "hwlab-cloud-web";
|
|
const body = {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
method,
|
|
params,
|
|
meta: { traceId, serviceId, environment }
|
|
};
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "POST",
|
|
path: "/json-rpc",
|
|
body,
|
|
extraHeaders: { "x-trace-id": traceId }
|
|
});
|
|
return responsePayload("client.rpc", response, context, {
|
|
route: route("POST", "/json-rpc"),
|
|
rpcMethod: method,
|
|
traceId,
|
|
requestId: id,
|
|
body: responseBodyForCli(response.body, context.parsed)
|
|
});
|
|
}
|
|
|
|
function rpcHelp() {
|
|
return ok("client.rpc.help", {
|
|
serviceRuntime: false,
|
|
imagePublished: false,
|
|
commands: [
|
|
"METHOD [--params-json '{...}']",
|
|
"METHOD --params-file FILE",
|
|
"METHOD --full"
|
|
]
|
|
});
|
|
}
|
|
|
|
function wantsHelp(context: any) {
|
|
const token = text(context.rest?.[0]);
|
|
return context.parsed.help === true || token === "help" || token === "--help" || token === "-h";
|
|
}
|
|
|
|
async function rpcParamsValue(context: any) {
|
|
const { parsed } = context;
|
|
const raw = typeof parsed.paramsJson === "string"
|
|
? parsed.paramsJson
|
|
: typeof parsed.params === "string"
|
|
? parsed.params
|
|
: typeof parsed.paramsFile === "string"
|
|
? await readFile(path.resolve(context.cwd, parsed.paramsFile), "utf8")
|
|
: "{}";
|
|
try {
|
|
const parsedParams = JSON.parse(raw);
|
|
if (!parsedParams || typeof parsedParams !== "object" || Array.isArray(parsedParams)) throw new Error("params must be a JSON object");
|
|
return parsedParams;
|
|
} catch (error) {
|
|
throw cliError("invalid_params_json", "rpc params must be a JSON object", { message: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function messageValue(context: any) {
|
|
if (typeof context.parsed.message === "string") return context.parsed.message;
|
|
if (typeof context.parsed.text === "string") return context.parsed.text;
|
|
if (typeof context.parsed.messageFile === "string") return await readFile(path.resolve(context.cwd, context.parsed.messageFile), "utf8");
|
|
return text(context.stdinText);
|
|
}
|
|
|
|
async function requestBodyValue(context: any) {
|
|
const { parsed } = context;
|
|
const raw = typeof parsed.bodyJson === "string"
|
|
? parsed.bodyJson
|
|
: typeof parsed.json === "string"
|
|
? parsed.json
|
|
: typeof parsed.bodyFile === "string"
|
|
? await readFile(path.resolve(context.cwd, parsed.bodyFile), "utf8")
|
|
: parsed.bodyStdin === true
|
|
? (context.stdinText !== undefined ? context.stdinText : await readStdin())
|
|
: null;
|
|
if (raw === null) return undefined;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
throw cliError("invalid_body_json", "request body must be valid JSON", { message: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
function normalizeRequestPath(value: string) {
|
|
const pathName = value.trim();
|
|
if (!pathName.startsWith("/")) throw cliError("invalid_request_path", "request path must be a Cloud Web relative path starting with /", { path: value });
|
|
if (pathName.startsWith("//") || /^[a-z][a-z0-9+.-]*:\/\//iu.test(pathName)) throw cliError("invalid_request_path", "request path must not be an absolute URL", { path: value });
|
|
return pathName;
|
|
}
|
|
|
|
async function requestJson(context: any) {
|
|
const { parsed, env, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs } = context;
|
|
const endpoint = runtimeEndpoint(parsed, env, context.endpointKind ?? "web");
|
|
const resolvedBaseUrl = endpoint.baseUrl;
|
|
const url = `${resolvedBaseUrl}${pathName}`;
|
|
const requestTimeoutMs = timeoutMs ?? numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
try {
|
|
const effectiveAuth = auth && parsed.noAuth !== true;
|
|
const apiKey = effectiveAuth ? explicitApiKey(parsed, env) : "";
|
|
if (apiKey) {
|
|
const response = await sendJsonRequest({ context, url, method, body, apiKey, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
|
|
const authState = {
|
|
required: effectiveAuth,
|
|
baseUrl: resolvedBaseUrl,
|
|
username: null,
|
|
stateFile: null,
|
|
localSession: null,
|
|
cookieSource: null,
|
|
apiKeySource: "env-or-explicit",
|
|
apiKeyPrefix: apiKeyPrefixSummary(apiKey),
|
|
autoLoginAttempted: false,
|
|
autoLoginStatus: null,
|
|
autoLoginHttpStatus: null,
|
|
retryAfterLogin: false
|
|
};
|
|
return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
|
|
}
|
|
const explicitCookie = explicitAuthCookie(parsed, env);
|
|
const sessionState = effectiveAuth && !explicitCookie && parsed.noSession !== true
|
|
? await readSessionState({ parsed, env, cwd: cwd ?? process.cwd() })
|
|
: null;
|
|
const session = sessionState?.session ?? null;
|
|
let cookie = effectiveAuth ? explicitCookie || session?.cookie || null : null;
|
|
const requestedUsername = text(parsed.username ?? env.HWLAB_USERNAME);
|
|
const sessionUsername = text(session?.user?.username ?? sessionState?.user?.username);
|
|
const authState: any = {
|
|
required: effectiveAuth,
|
|
baseUrl: resolvedBaseUrl,
|
|
username: requestedUsername || sessionUsername || (explicitCookie ? undefined : "admin"),
|
|
stateFile: stateFile(parsed, cwd ?? process.cwd(), env),
|
|
localSession: sessionState ? sessionStateSummary(sessionState) : null,
|
|
cookieSource: cookie ? explicitCookie ? "explicit" : "state" : null,
|
|
autoLoginAttempted: false,
|
|
autoLoginStatus: null,
|
|
autoLoginHttpStatus: null,
|
|
retryAfterLogin: false
|
|
};
|
|
if (effectiveAuth && !cookie && autoAuthAllowed(parsed)) {
|
|
const login = await autoLoginSession(context, controller.signal);
|
|
authState.autoLoginAttempted = true;
|
|
authState.autoLoginStatus = login.status;
|
|
authState.autoLoginHttpStatus = login.httpStatus ?? null;
|
|
authState.cookieSource = login.cookie ? "auto-login" : null;
|
|
cookie = login.cookie ?? null;
|
|
}
|
|
let response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
|
|
if (effectiveAuth && response.status === 401 && !explicitCookie && autoAuthAllowed(parsed)) {
|
|
const login = await autoLoginSession(context, controller.signal, { force: true });
|
|
authState.autoLoginAttempted = true;
|
|
authState.autoLoginStatus = login.status;
|
|
authState.autoLoginHttpStatus = login.httpStatus ?? null;
|
|
if (login.cookie) {
|
|
authState.cookieSource = "auto-login";
|
|
authState.retryAfterLogin = true;
|
|
cookie = login.cookie;
|
|
response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
|
|
}
|
|
}
|
|
return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function sendJsonRequest({ context, url, method, body, cookie, apiKey, extraHeaders, signal, pathName, timeoutMs }: any) {
|
|
const headers = clean({
|
|
accept: "application/json",
|
|
...(body ? { "content-type": "application/json" } : {}),
|
|
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
});
|
|
const startedAt = Date.now();
|
|
try {
|
|
const response = await context.fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal });
|
|
const textBody = await response.text();
|
|
return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName, elapsedMs: Date.now() - startedAt, timeoutMs };
|
|
} catch (error) {
|
|
const timedOut = signal?.aborted === true || error?.name === "AbortError";
|
|
const code = timedOut ? "request_timeout" : "request_failed";
|
|
const message = timedOut
|
|
? `${method} ${pathName} timed out after ${timeoutMs}ms`
|
|
: `${method} ${pathName} failed: ${error?.message ?? String(error)}`;
|
|
return {
|
|
status: 0,
|
|
headers: null,
|
|
body: { ok: false, status: "failed", error: { code, message } },
|
|
url,
|
|
method,
|
|
path: pathName,
|
|
elapsedMs: Date.now() - startedAt,
|
|
timeoutMs,
|
|
timedOut,
|
|
transportError: { code, name: error?.name ?? null, message: error?.message ?? String(error) }
|
|
};
|
|
}
|
|
}
|
|
|
|
async function autoLoginSession(context: any, signal: AbortSignal, { force = false } = {}) {
|
|
const password = await optionalPasswordValue(context);
|
|
if (!password) return { status: "credentials_missing", cookie: null };
|
|
const username = text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin";
|
|
const endpoint = runtimeEndpoint(context.parsed, context.env, context.endpointKind ?? "web");
|
|
const response = await sendJsonRequest({
|
|
context,
|
|
url: `${endpoint.baseUrl}/auth/login`,
|
|
method: "POST",
|
|
pathName: "/auth/login",
|
|
body: { username, password },
|
|
cookie: null,
|
|
extraHeaders: {},
|
|
signal
|
|
});
|
|
const cookie = cookieFromResponse(response);
|
|
const success = isHttpSuccess(response) && response.body?.authenticated === true && cookie;
|
|
if (success) {
|
|
await saveSession(context, {
|
|
baseUrl: endpoint.baseUrl,
|
|
cookie,
|
|
user: safeUser(response.body?.user ?? response.body?.actor),
|
|
expiresAt: textOrNull(response.body?.expiresAt),
|
|
updatedAt: context.now(),
|
|
refreshedAfter401: force === true || undefined
|
|
});
|
|
}
|
|
return { status: success ? "succeeded" : `failed_http_${response.status}`, httpStatus: response.status, cookie: success ? cookie : null };
|
|
}
|
|
|
|
function parseOptions(argv: string[]): ParsedArgs {
|
|
const out: ParsedArgs = { _: [] };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const item = argv[index] ?? "";
|
|
if (!item.startsWith("--")) { out._.push(item); continue; }
|
|
const eq = item.indexOf("=");
|
|
const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2);
|
|
const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase());
|
|
if (eq >= 0) { out[key] = item.slice(eq + 1); continue; }
|
|
const next = argv[index + 1];
|
|
if (next && !next.startsWith("--")) { out[key] = next; index += 1; }
|
|
else out[key] = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function passwordValue({ parsed, env, stdinText }: any) {
|
|
const password = await optionalPasswordValue({ parsed, env, stdinText });
|
|
return requiredText(password, "password");
|
|
}
|
|
|
|
async function optionalPasswordValue({ parsed, env, stdinText }: any) {
|
|
if (typeof parsed.passwordEnv === "string") return text(env[parsed.passwordEnv]);
|
|
if (parsed.passwordStdin === true) return stdinText !== undefined ? stdinText.trimEnd() : await readStdin();
|
|
return text(parsed.password ?? env.HWLAB_PASSWORD);
|
|
}
|
|
|
|
async function readStdin() {
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
return Buffer.concat(chunks).toString("utf8").trimEnd();
|
|
}
|
|
|
|
function baseUrl(parsed: ParsedArgs, env: EnvLike, kind: "web" | "api" = "web") {
|
|
return runtimeEndpoint(parsed, env, kind).baseUrl;
|
|
}
|
|
|
|
function runtimeEndpoint(parsed: ParsedArgs, env: EnvLike, kind: "web" | "api" = "web") {
|
|
return resolveRuntimeEndpoint({ kind, parsed, env });
|
|
}
|
|
|
|
async function authCookie({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
|
|
const explicit = explicitAuthCookie(parsed, env);
|
|
if (explicit) return explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}`;
|
|
if (parsed.noSession === true) return null;
|
|
const session = await loadSession({ parsed, env, cwd });
|
|
return session?.cookie ?? null;
|
|
}
|
|
|
|
function explicitAuthCookie(parsed: ParsedArgs, env: EnvLike) {
|
|
const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
|
|
return explicit ? explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}` : null;
|
|
}
|
|
|
|
const HWLAB_API_KEY_PREFIX = "hwl_live_";
|
|
function explicitApiKey(parsed: ParsedArgs, env: EnvLike) {
|
|
const explicit = text(parsed.apiKey ?? parsed.bearerToken ?? env.HWLAB_API_KEY ?? env.HWLAB_BEARER_TOKEN);
|
|
if (!explicit) return "";
|
|
if (explicit.startsWith(HWLAB_API_KEY_PREFIX)) return explicit;
|
|
return "";
|
|
}
|
|
function apiKeyPrefixSummary(secret) {
|
|
const text = String(secret ?? "");
|
|
if (!text) return "";
|
|
if (text.length <= 12) return text;
|
|
return `${text.slice(0, 12)}\u2026`;
|
|
}
|
|
|
|
function autoAuthAllowed(parsed: ParsedArgs) {
|
|
return parsed.noAuth !== true && parsed.noAutoAuth !== true;
|
|
}
|
|
|
|
async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
|
|
return (await readSessionState({ parsed, env, cwd })).session;
|
|
}
|
|
|
|
async function loadStoredState({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
|
|
const state = await readSessionState({ parsed, env, cwd });
|
|
return state.stored ?? state.session ?? null;
|
|
}
|
|
|
|
async function readSessionState({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
|
|
const file = stateFile(parsed, cwd, env);
|
|
const expectedBaseUrl = baseUrl(parsed, env);
|
|
let raw = "";
|
|
try {
|
|
raw = await readFile(file, "utf8");
|
|
} catch (error) {
|
|
return {
|
|
stateFile: file,
|
|
expectedBaseUrl,
|
|
exists: false,
|
|
readable: false,
|
|
usable: false,
|
|
ignoredReason: error?.code === "ENOENT" ? "state_file_missing" : "state_file_unreadable",
|
|
session: null
|
|
};
|
|
}
|
|
let stored: any = null;
|
|
try {
|
|
stored = JSON.parse(raw);
|
|
} catch {
|
|
return { stateFile: file, expectedBaseUrl, exists: true, readable: true, parseOk: false, usable: false, ignoredReason: "state_file_invalid_json", session: null };
|
|
}
|
|
const storedBaseUrl = text(stored?.baseUrl);
|
|
const cookieStored = Boolean(text(stored?.cookie));
|
|
const baseUrlMatches = storedBaseUrl === expectedBaseUrl;
|
|
const runtimeScopeMatches = !baseUrlMatches && sameRuntimeEndpointScope(storedBaseUrl, expectedBaseUrl);
|
|
const endpointMatches = baseUrlMatches || runtimeScopeMatches;
|
|
const usable = Boolean(endpointMatches && cookieStored);
|
|
return {
|
|
stateFile: file,
|
|
expectedBaseUrl,
|
|
exists: true,
|
|
readable: true,
|
|
parseOk: true,
|
|
usable,
|
|
ignoredReason: usable ? null : !endpointMatches ? "base_url_mismatch" : "cookie_missing",
|
|
baseUrlMatch: baseUrlMatches ? "exact" : runtimeScopeMatches ? "runtime_scope" : "none",
|
|
baseUrl: storedBaseUrl || null,
|
|
cookieStored,
|
|
user: safeUser(stored?.user),
|
|
expiresAt: textOrNull(stored?.expiresAt),
|
|
updatedAt: textOrNull(stored?.updatedAt),
|
|
stored,
|
|
session: usable ? stored : null
|
|
};
|
|
}
|
|
|
|
async function saveSession(context: any, session: Record<string, unknown>) {
|
|
const file = stateFile(context.parsed, context.cwd, context.env);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
await chmod(file, 0o600).catch(() => {});
|
|
}
|
|
|
|
async function clearSession(context: any) {
|
|
await rm(stateFile(context.parsed, context.cwd, context.env), { force: true });
|
|
}
|
|
|
|
function stateFile(parsed: ParsedArgs, cwd: string, env: EnvLike = process.env) {
|
|
const explicit = text(parsed.stateFile);
|
|
if (explicit) return path.resolve(cwd, explicit);
|
|
const profile = profileName(parsed);
|
|
if (profile) return path.join(profileStateDir(parsed, cwd, env), `${profile}.json`);
|
|
return path.resolve(cwd, ".state/hwlab-cli/session.json");
|
|
}
|
|
|
|
function profileName(parsed: ParsedArgs) {
|
|
const value = text(parsed.profile);
|
|
if (!value) return "";
|
|
if (!/^[A-Za-z0-9_.:-]+$/u.test(value)) {
|
|
throw cliError("invalid_profile_name", "profile may only contain letters, numbers, dot, underscore, colon, and dash", { profile: value });
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function profileStateDir(parsed: ParsedArgs, cwd: string, env: EnvLike = process.env) {
|
|
return path.resolve(cwd, ".state/hwlab-cli/profiles", sha256(baseUrl(parsed, env)).slice(0, 16));
|
|
}
|
|
|
|
function cookieFromResponse(response: any) {
|
|
const values = typeof response.headers?.getSetCookie === "function"
|
|
? response.headers.getSetCookie()
|
|
: [response.headers?.get?.("set-cookie")].filter(Boolean);
|
|
const raw = values.find((value: string) => /(?:^|,)\s*hwlab_session=/u.test(value)) ?? values[0];
|
|
if (!raw) return null;
|
|
const match = String(raw).match(/hwlab_session=[^;,\s]+/u);
|
|
return match?.[0] ?? String(raw).split(";", 1)[0];
|
|
}
|
|
|
|
function responsePayload(action: string, response: any, context: any, extra: Record<string, unknown> = {}) {
|
|
const success = responseSucceeded(response);
|
|
return {
|
|
ok: success,
|
|
action,
|
|
status: success ? "succeeded" : "failed",
|
|
baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(context.parsed, context.env, context.endpointKind ?? "web"),
|
|
runtimeEndpoint: runtimeEndpointVisibility(response.runtimeEndpoint ?? runtimeEndpoint(context.parsed, context.env, context.endpointKind ?? "web")),
|
|
httpStatus: response.status,
|
|
route: extra.route ?? route(response.method, response.path),
|
|
request: requestVisibility(response),
|
|
...(response.auth ? { auth: response.auth } : {}),
|
|
...(response.authDiagnosis ? { authDiagnosis: response.authDiagnosis } : {}),
|
|
...extra,
|
|
body: extra.body ?? response.body
|
|
};
|
|
}
|
|
|
|
function responseSucceeded(response: any) {
|
|
return isHttpSuccess(response) && response.body?.ok !== false;
|
|
}
|
|
|
|
function requestVisibility(response: any) {
|
|
return pruneUndefined({
|
|
method: response.method,
|
|
path: response.path,
|
|
url: response.url,
|
|
httpStatus: response.status,
|
|
elapsedMs: response.elapsedMs,
|
|
timeoutMs: response.timeoutMs,
|
|
timedOut: response.timedOut === true ? true : undefined,
|
|
transportError: response.transportError ? pruneUndefined({ code: response.transportError.code, name: response.transportError.name, message: response.transportError.message }) : undefined
|
|
});
|
|
}
|
|
|
|
function authVisibility(authState: any) {
|
|
if (!authState?.required) return { required: false };
|
|
return pruneUndefined({
|
|
required: true,
|
|
baseUrl: authState.baseUrl,
|
|
username: authState.username,
|
|
cookieSource: authState.cookieSource,
|
|
stateFile: authState.stateFile,
|
|
localSession: authState.localSession,
|
|
apiKeySource: authState.apiKeySource,
|
|
apiKeyPrefix: authState.apiKeyPrefix,
|
|
autoLoginAttempted: authState.autoLoginAttempted,
|
|
autoLoginStatus: authState.autoLoginStatus,
|
|
autoLoginHttpStatus: authState.autoLoginHttpStatus,
|
|
retryAfterLogin: authState.retryAfterLogin
|
|
});
|
|
}
|
|
|
|
function authDiagnosis(response: any, authState: any) {
|
|
if (!authState?.required) return null;
|
|
if (response.status !== 401 && response.status !== 403) return null;
|
|
if (response.status === 401 && authState.apiKeySource) {
|
|
return {
|
|
code: "api_key_invalid",
|
|
status: "unauthorized",
|
|
message: "HWLAB_API_KEY was rejected by the cloud-api; regenerate the key with `client auth default` or check that the env value matches the one shown in the Web API key page.",
|
|
httpStatus: response.status,
|
|
apiKeySource: authState.apiKeySource,
|
|
apiKeyPrefix: authState.apiKeyPrefix,
|
|
nextCommands: ["bun tools/hwlab-cli/bin/hwlab-cli.ts client request POST /v1/api-keys -H 'content-type: application/json' --body '{\"name\":\"rotated\"}' --help"]
|
|
};
|
|
}
|
|
if (response.status === 403) {
|
|
return {
|
|
code: "auth_forbidden",
|
|
status: "forbidden",
|
|
message: "当前账号无权访问该受保护资源,可能是 trace/result 不属于当前 owner,或当前用户不是 admin。",
|
|
httpStatus: response.status,
|
|
cookieSource: authState.cookieSource,
|
|
autoLoginAttempted: authState.autoLoginAttempted,
|
|
retryAfterLogin: authState.retryAfterLogin,
|
|
stateFile: authState.stateFile,
|
|
localSession: authState.localSession,
|
|
nextCommands: authNextCommands(authState)
|
|
};
|
|
}
|
|
const missingCredentials = authState.autoLoginAttempted && authState.autoLoginStatus === "credentials_missing";
|
|
return {
|
|
code: missingCredentials ? "auth_credentials_missing" : "auth_required_or_expired",
|
|
status: missingCredentials ? "credentials_missing" : "unauthorized",
|
|
message: missingCredentials
|
|
? "需要登录后访问该受保护资源;请提供 --password-env、--password、--password-stdin 或 HWLAB_PASSWORD,让 CLI 自动登录。"
|
|
: "登录态不存在或已过期;CLI 已按可用凭据尝试自动登录,仍未通过认证。",
|
|
httpStatus: response.status,
|
|
cookieSource: authState.cookieSource,
|
|
autoLoginAttempted: authState.autoLoginAttempted,
|
|
autoLoginStatus: authState.autoLoginStatus,
|
|
autoLoginHttpStatus: authState.autoLoginHttpStatus,
|
|
retryAfterLogin: authState.retryAfterLogin,
|
|
stateFile: authState.stateFile,
|
|
localSession: authState.localSession,
|
|
nextCommands: authNextCommands(authState)
|
|
};
|
|
}
|
|
|
|
function sessionStateSummary(state: any) {
|
|
return pruneUndefined({
|
|
stateFile: state.stateFile,
|
|
expectedBaseUrl: state.expectedBaseUrl,
|
|
exists: state.exists,
|
|
readable: state.readable,
|
|
parseOk: state.parseOk,
|
|
usable: state.usable,
|
|
ignoredReason: state.ignoredReason,
|
|
baseUrlMatch: state.baseUrlMatch,
|
|
baseUrl: state.baseUrl,
|
|
cookieStored: state.cookieStored,
|
|
user: state.user,
|
|
expiresAt: state.expiresAt,
|
|
updatedAt: state.updatedAt
|
|
});
|
|
}
|
|
|
|
function authNextCommands(authState: any) {
|
|
const resolvedBaseUrl = text(authState?.baseUrl);
|
|
const username = text(authState?.username) || "admin";
|
|
const stateFileArg = text(authState?.stateFile) ? ` --state-file ${shellArg(authState.stateFile)}` : "";
|
|
const cli = "bun tools/hwlab-cli/bin/hwlab-cli.ts";
|
|
return [
|
|
`export HWLAB_PASSWORD='<password>'`,
|
|
`${cli} client auth login --runtime-namespace <hwlab-namespace> --username ${shellArg(username)} --password-env HWLAB_PASSWORD${stateFileArg}`,
|
|
`${cli} client auth status --runtime-namespace <hwlab-namespace>${stateFileArg}`
|
|
];
|
|
}
|
|
|
|
async function restoreWorkbenchWorkspace(context: any, { quiet = false } = {}) {
|
|
const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
|
|
const pathName = `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`;
|
|
const response = await requestJson({ ...context, method: "GET", path: pathName, timeoutMs: numberOption(context.parsed.restoreTimeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
if (!isHttpSuccess(response) || response.body?.ok === false) {
|
|
if (quiet) return null;
|
|
throw cliError("workspace_restore_failed", "failed to restore workbench workspace", { httpStatus: response.status, body: compactApiBody(response.body) });
|
|
}
|
|
const workspace = normalizeWorkbenchWorkspace(response.body?.workspace);
|
|
await saveWorkspaceState(context, response.body?.workspace);
|
|
return workspace;
|
|
}
|
|
|
|
async function saveAcceptedAgentWorkspaceState(context: any, acceptedBody: any, fallback: any) {
|
|
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
|
|
const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {};
|
|
const existingMatchesFallback = Boolean(
|
|
text(fallback.conversationId) && text(fallback.sessionId) &&
|
|
text(existing.selectedConversationId) === text(fallback.conversationId) &&
|
|
text(existing.selectedAgentSessionId) === text(fallback.sessionId)
|
|
);
|
|
const workspace = clean({
|
|
...existing,
|
|
workspaceId: text(acceptedBody?.workspaceId) || text(existing.workspaceId),
|
|
revision: numberOption(acceptedBody?.workspaceRevision) ?? existing.revision,
|
|
selectedConversationId: text(fallback.conversationId) || text(existing.selectedConversationId),
|
|
selectedAgentSessionId: text(fallback.sessionId) || text(existing.selectedAgentSessionId),
|
|
threadId: text(acceptedBody?.threadId ?? acceptedBody?.session?.threadId ?? acceptedBody?.providerTrace?.threadId) || text(fallback.threadId) || (existingMatchesFallback ? text(existing.threadId) : ""),
|
|
activeTraceId: text(acceptedBody?.traceId) || text(fallback.traceId),
|
|
providerProfile: text(fallback.providerProfile) || text(existing.providerProfile),
|
|
updatedByClient: "hwlab-cli",
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace });
|
|
}
|
|
|
|
async function saveCompletedAgentWorkspaceState(context: any, resultBody: any, fallback: any) {
|
|
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
|
|
const existing = session.workspace && typeof session.workspace === "object" ? session.workspace : {};
|
|
const resultWorkspace = resultBody ? workspaceWithAgentResult(existing, resultBody, fallback) : null;
|
|
if (resultWorkspace) {
|
|
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: resultWorkspace, updatedAt: context.now() });
|
|
}
|
|
const workspaceId = text(existing.workspaceId);
|
|
if (!workspaceId) return resultWorkspace;
|
|
const status = text(resultBody?.status);
|
|
const terminal = status && status !== "running";
|
|
const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`;
|
|
const body = clean({
|
|
projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID,
|
|
expectedRevision: numberOption(existing.revision),
|
|
selectedConversationId: text(resultBody?.conversationId) || text(fallback.conversationId) || text(existing.selectedConversationId),
|
|
selectedAgentSessionId: text(resultBody?.sessionId) || text(fallback.sessionId) || text(existing.selectedAgentSessionId),
|
|
activeTraceId: terminal ? undefined : text(resultBody?.traceId) || text(fallback.traceId),
|
|
providerProfile: text(fallback.providerProfile) || text(existing.providerProfile),
|
|
sessionStatus: status || undefined,
|
|
threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId),
|
|
lastTraceId: text(resultBody?.traceId) || text(fallback.traceId),
|
|
messages: resultBody ? compactAgentMessagesForWorkspace(resultBody, fallback) : undefined,
|
|
updatedByClient: "hwlab-cli"
|
|
});
|
|
if (terminal) body.activeTraceId = null;
|
|
const response = await requestJson({
|
|
...context,
|
|
method: "PATCH",
|
|
path: pathName,
|
|
body,
|
|
timeoutMs: DEFAULT_TIMEOUT_MS
|
|
});
|
|
if (!responseSucceeded(response)) return resultWorkspace;
|
|
if (response.body?.workspace) {
|
|
const workspace = workspaceWithAgentResult(response.body.workspace, resultBody, fallback);
|
|
await saveWorkspaceState(context, workspace);
|
|
return workspace;
|
|
}
|
|
const restored = await restoreWorkbenchWorkspace(context, { quiet: true });
|
|
const workspace = workspaceWithAgentResult(restored, resultBody, fallback);
|
|
if (workspace) await saveWorkspaceState(context, workspace);
|
|
return workspace ?? resultWorkspace;
|
|
}
|
|
|
|
function workspaceWithAgentResult(workspace: any, resultBody: any, fallback: any) {
|
|
const base = normalizeWorkbenchWorkspace(workspace) ?? {};
|
|
const conversationId = text(resultBody?.conversationId) || text(fallback.conversationId) || text(base.selectedConversationId);
|
|
const sessionId = text(resultBody?.sessionId) || text(fallback.sessionId) || text(base.selectedAgentSessionId);
|
|
const resultThreadId = text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId);
|
|
const baseConversationMatches = !conversationId || text(base.selectedConversationId) === conversationId;
|
|
const baseSessionMatches = !sessionId || text(base.selectedAgentSessionId) === sessionId;
|
|
const nestedConversationMatches = selectedConversationMatches(base.selectedConversation, { conversationId, sessionId });
|
|
const baseThreadId = baseConversationMatches && baseSessionMatches ? text(base.threadId) : "";
|
|
const selectedConversation = baseConversationMatches && baseSessionMatches && nestedConversationMatches
|
|
? base.selectedConversation
|
|
: clean({ conversationId, sessionId, threadId: resultThreadId || baseThreadId });
|
|
return clean({
|
|
...base,
|
|
selectedConversationId: conversationId,
|
|
selectedAgentSessionId: sessionId,
|
|
providerProfile: text(fallback.providerProfile) || text(base.providerProfile),
|
|
sessionStatus: text(resultBody?.status) || text(base.sessionStatus),
|
|
threadId: resultThreadId || baseThreadId,
|
|
selectedConversation,
|
|
updatedByClient: "hwlab-cli"
|
|
});
|
|
}
|
|
|
|
function selectedConversationMatches(selectedConversation: any, expected: any) {
|
|
if (!selectedConversation || typeof selectedConversation !== "object") return false;
|
|
const conversationId = text(expected.conversationId);
|
|
const sessionId = text(expected.sessionId);
|
|
return (!conversationId || text(selectedConversation.conversationId) === conversationId) && (!sessionId || text(selectedConversation.sessionId ?? selectedConversation.session?.sessionId) === sessionId);
|
|
}
|
|
|
|
async function saveWorkspaceState(context: any, workspaceBody: any) {
|
|
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
|
|
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: normalizeWorkbenchWorkspace(workspaceBody), updatedAt: context.now() });
|
|
}
|
|
|
|
function normalizeWorkbenchWorkspace(workspace: any) {
|
|
if (!workspace || typeof workspace !== "object") return null;
|
|
const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {};
|
|
return clean({
|
|
workspaceId: text(workspace.workspaceId),
|
|
ownerUserId: text(workspace.ownerUserId),
|
|
projectId: text(workspace.projectId),
|
|
revision: numberOption(workspace.revision),
|
|
selectedConversationId: text(workspace.selectedConversationId ?? workspaceJson.selectedConversationId),
|
|
selectedAgentSessionId: text(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId),
|
|
selectedDevicePodId: text(workspace.selectedDevicePodId ?? workspaceJson.selectedDevicePodId),
|
|
activeTraceId: text(workspace.activeTraceId ?? workspaceJson.activeTraceId),
|
|
providerProfile: text(workspace.providerProfile ?? workspaceJson.providerProfile),
|
|
sessionStatus: text(workspaceJson.sessionStatus),
|
|
threadId: workbenchThreadId(workspace, workspaceJson),
|
|
updatedAt: text(workspace.updatedAt),
|
|
updatedByClient: text(workspace.updatedByClient),
|
|
messages: Array.isArray(workspaceJson.messages) ? workspaceJson.messages : undefined,
|
|
selectedConversation: workspace.selectedConversation ? compactApiBody(workspace.selectedConversation) : undefined,
|
|
valuesRedacted: workspace.valuesRedacted === true,
|
|
secretMaterialStored: workspace.secretMaterialStored === true
|
|
});
|
|
}
|
|
|
|
function sessionSummaries(conversations: any) {
|
|
return Array.isArray(conversations) ? conversations.map(sessionSummary).filter(Boolean) : [];
|
|
}
|
|
|
|
function sessionSummary(conversation: any) {
|
|
if (!conversation || typeof conversation !== "object") return null;
|
|
return clean({
|
|
conversationId: text(conversation.conversationId),
|
|
sessionId: text(conversation.sessionId ?? conversation.session?.sessionId),
|
|
threadId: text(conversation.threadId ?? conversation.session?.threadId),
|
|
status: text(conversation.status ?? conversation.snapshot?.sessionStatus),
|
|
lastTraceId: text(conversation.lastTraceId),
|
|
updatedAt: text(conversation.updatedAt),
|
|
messageCount: numberOrZero(conversation.messageCount) || (Array.isArray(conversation.messages) ? conversation.messages.length : 0) || undefined,
|
|
firstUserMessagePreview: text(conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview) || undefined,
|
|
valuesRedacted: conversation.valuesRedacted === true ? true : undefined
|
|
});
|
|
}
|
|
|
|
function latestAgentMessage(conversation: any) {
|
|
const messages = Array.isArray(conversation?.messages) ? conversation.messages : [];
|
|
return [...messages].reverse().find((message: any) => text(message?.role) === "agent") ?? null;
|
|
}
|
|
|
|
function finalResponseConversationSummary(conversation: any) {
|
|
if (!conversation || typeof conversation !== "object") return null;
|
|
const latest = latestAgentMessage(conversation);
|
|
return clean({
|
|
found: true,
|
|
conversationId: text(conversation.conversationId),
|
|
projectId: text(conversation.projectId),
|
|
sessionId: text(conversation.sessionId ?? conversation.session?.sessionId),
|
|
threadId: text(conversation.threadId ?? conversation.session?.threadId),
|
|
status: text(conversation.status ?? conversation.snapshot?.sessionStatus),
|
|
lastTraceId: text(conversation.lastTraceId),
|
|
updatedAt: text(conversation.updatedAt),
|
|
snapshotSource: text(conversation.snapshot?.source),
|
|
latestAgent: latest ? clean({
|
|
id: text(latest.id),
|
|
status: text(latest.status),
|
|
traceId: text(latest.traceId),
|
|
updatedAt: text(latest.updatedAt),
|
|
textPreview: preview(latest.text, 240)
|
|
}) : undefined
|
|
});
|
|
}
|
|
|
|
function finalResponseResultSummary(body: any) {
|
|
const resultText = assistantText(body) || "";
|
|
return clean({
|
|
status: text(body?.status),
|
|
traceId: text(body?.traceId),
|
|
conversationId: text(body?.conversationId),
|
|
sessionId: text(body?.sessionId),
|
|
threadId: text(body?.threadId ?? body?.session?.threadId ?? body?.providerTrace?.threadId),
|
|
assistantTextPreview: preview(resultText, 240),
|
|
assistantTextChars: resultText.length || undefined
|
|
});
|
|
}
|
|
|
|
function finalResponseEarlyValidation({ conversationId, projectId, routeName, response, failure }: any) {
|
|
const failures = [failure].filter(Boolean);
|
|
return {
|
|
ok: false,
|
|
state: "failed",
|
|
summary: `${routeName} step failed before final response comparison`,
|
|
conversationId,
|
|
projectId: text(projectId) || undefined,
|
|
traceId: undefined,
|
|
checks: {
|
|
inspectFound: false,
|
|
listFound: false,
|
|
resultHasFinalText: false,
|
|
fallbackTextAbsent: false
|
|
},
|
|
httpStatus: response?.status,
|
|
error: response?.body?.error && typeof response.body.error === "object"
|
|
? clean({ code: text(response.body.error.code), message: text(response.body.error.message) })
|
|
: compactApiBody(response?.body),
|
|
failures,
|
|
nextCommands: [
|
|
`hwlab-cli client session inspect ${conversationId} --full`,
|
|
text(projectId) ? `hwlab-cli client session list --project-id ${projectId} --limit 20 --full` : "hwlab-cli client auth status"
|
|
]
|
|
};
|
|
}
|
|
|
|
function finalResponseValidation({ conversationId, projectId, inspectConversation, listedConversation, traceId, resultBody, fallbackText }: any) {
|
|
const inspectedLatest = latestAgentMessage(inspectConversation);
|
|
const listedLatest = latestAgentMessage(listedConversation);
|
|
const inspectText = text(inspectedLatest?.text);
|
|
const listText = text(listedLatest?.text);
|
|
const resultText = assistantText(resultBody) || "";
|
|
const fallback = text(fallbackText);
|
|
const textPairs = [
|
|
{ source: "inspect", text: inspectText },
|
|
{ source: "list", text: listText },
|
|
{ source: "result", text: resultText }
|
|
];
|
|
const fallbackHits = fallback ? textPairs.filter((item) => item.text.includes(fallback)).map((item) => item.source) : [];
|
|
const failures = [
|
|
!inspectConversation ? "inspect-conversation-missing" : "",
|
|
!listedConversation ? "list-conversation-missing" : "",
|
|
!traceId ? "trace-id-missing" : "",
|
|
!resultText ? "result-final-response-missing" : "",
|
|
text(inspectConversation?.lastTraceId) !== traceId ? "inspect-last-trace-mismatch" : "",
|
|
listedConversation && text(listedConversation?.lastTraceId) !== traceId ? "list-last-trace-mismatch" : "",
|
|
inspectedLatest && text(inspectedLatest.traceId) !== traceId ? "inspect-latest-agent-trace-mismatch" : "",
|
|
listedLatest && text(listedLatest.traceId) !== traceId ? "list-latest-agent-trace-mismatch" : "",
|
|
inspectText && resultText && inspectText !== resultText ? "inspect-text-result-mismatch" : "",
|
|
listText && resultText && listText !== resultText ? "list-text-result-mismatch" : "",
|
|
fallbackHits.length > 0 ? "fallback-text-present" : ""
|
|
].filter(Boolean);
|
|
return {
|
|
ok: failures.length === 0,
|
|
state: failures.length === 0 ? "passed" : "failed",
|
|
summary: failures.length === 0
|
|
? "list, inspect, and agent result agree on the final assistant response"
|
|
: `final response validation failed with ${failures.length} issue(s)`,
|
|
conversationId,
|
|
projectId,
|
|
traceId,
|
|
checks: {
|
|
inspectFound: Boolean(inspectConversation),
|
|
listFound: Boolean(listedConversation),
|
|
resultHasFinalText: Boolean(resultText),
|
|
inspectLastTraceMatches: text(inspectConversation?.lastTraceId) === traceId,
|
|
listLastTraceMatches: listedConversation ? text(listedConversation?.lastTraceId) === traceId : false,
|
|
inspectLatestAgentTraceMatches: inspectedLatest ? text(inspectedLatest.traceId) === traceId : false,
|
|
listLatestAgentTraceMatches: listedLatest ? text(listedLatest.traceId) === traceId : false,
|
|
inspectTextMatchesResult: Boolean(inspectText && resultText && inspectText === resultText),
|
|
listTextMatchesResult: Boolean(listText && resultText && listText === resultText),
|
|
fallbackTextAbsent: fallbackHits.length === 0
|
|
},
|
|
fallbackText: fallback ? { checked: true, presentIn: fallbackHits } : { checked: false, presentIn: [] },
|
|
repair: clean({
|
|
inspectSnapshotSource: text(inspectConversation?.snapshot?.source),
|
|
listSnapshotSource: text(listedConversation?.snapshot?.source),
|
|
appearsRepaired: text(inspectConversation?.snapshot?.source).includes("repair") || text(listedConversation?.snapshot?.source).includes("repair")
|
|
}),
|
|
previews: {
|
|
inspectText: preview(inspectText, 200),
|
|
listText: preview(listText, 200),
|
|
resultText: preview(resultText, 200)
|
|
},
|
|
failures,
|
|
nextCommands: [
|
|
`hwlab-cli client session inspect ${conversationId} --full`,
|
|
`hwlab-cli client session list --project-id ${projectId} --limit 20 --full`,
|
|
`hwlab-cli client agent result ${traceId} --full`
|
|
]
|
|
};
|
|
}
|
|
function numberOrZero(value: any) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
function workbenchThreadId(workspace: any, workspaceJson: any) {
|
|
const selectedConversation = workspace?.selectedConversation && typeof workspace.selectedConversation === "object" ? workspace.selectedConversation : {};
|
|
const selectedAgentSession = workspace?.selectedAgentSession && typeof workspace.selectedAgentSession === "object" ? workspace.selectedAgentSession : {};
|
|
const selectedConversationJson = workspaceJson?.selectedConversation && typeof workspaceJson.selectedConversation === "object" ? workspaceJson.selectedConversation : {};
|
|
const selectedAgentSessionJson = workspaceJson?.selectedAgentSession && typeof workspaceJson.selectedAgentSession === "object" ? workspaceJson.selectedAgentSession : {};
|
|
const topConversationId = text(workspace?.selectedConversationId ?? workspaceJson?.selectedConversationId);
|
|
const topSessionId = text(workspace?.selectedAgentSessionId ?? workspaceJson?.selectedAgentSessionId);
|
|
const selectedConversationThreadId = selectedConversationMatches(selectedConversation, { conversationId: topConversationId, sessionId: topSessionId })
|
|
? text(selectedConversation.threadId ?? selectedConversation.session?.threadId)
|
|
: "";
|
|
const selectedConversationJsonThreadId = selectedConversationMatches(selectedConversationJson, { conversationId: topConversationId, sessionId: topSessionId })
|
|
? text(selectedConversationJson.threadId ?? selectedConversationJson.session?.threadId)
|
|
: "";
|
|
return selectedConversationThreadId
|
|
|| text(selectedAgentSession.threadId)
|
|
|| selectedConversationJsonThreadId
|
|
|| text(selectedAgentSessionJson.threadId)
|
|
|| text(workspaceJson?.threadId)
|
|
|| text(workspace?.threadId);
|
|
}
|
|
|
|
function workspaceSummaryFromSession(session: any) {
|
|
return normalizeWorkbenchWorkspace(session?.workspace);
|
|
}
|
|
|
|
function agentSessionSummary(session: any, fallback: any = {}) {
|
|
return pruneUndefined({
|
|
sessionId: text(session?.sessionId) || text(fallback.sessionId),
|
|
conversationId: text(session?.conversationId) || text(fallback.conversationId),
|
|
threadId: text(session?.threadId) || text(fallback.threadId),
|
|
providerProfile: text(session?.providerProfile) || text(fallback.providerProfile),
|
|
sessionStatus: text(session?.status) || text(fallback.status),
|
|
sessionUsable: typeof session?.usable === "boolean" ? session.usable : undefined
|
|
});
|
|
}
|
|
|
|
function compactAgentMessagesForWorkspace(resultBody: any, fallback: any) {
|
|
return [{
|
|
id: makeId("msg"),
|
|
role: "agent",
|
|
title: "Code Agent result",
|
|
text: assistantText(resultBody) || text(resultBody?.error?.message) || text(resultBody?.status),
|
|
status: text(resultBody?.status) || "unknown",
|
|
traceId: text(resultBody?.traceId) || text(fallback.traceId),
|
|
conversationId: text(resultBody?.conversationId) || text(fallback.conversationId),
|
|
sessionId: text(resultBody?.sessionId) || text(fallback.sessionId),
|
|
threadId: text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId),
|
|
updatedAt: new Date().toISOString()
|
|
}];
|
|
}
|
|
|
|
function compactProbe(method: string, pathName: string, response: any) {
|
|
return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactApiBody(response.body) };
|
|
}
|
|
|
|
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; 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),
|
|
retryOf: text(value.retryOf),
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function responseBodyForCli(body: any, parsed: ParsedArgs) {
|
|
return parsed.full === true ? body : compactApiBody(body);
|
|
}
|
|
|
|
function adminAccessBodyForCli(body: any, parsed: ParsedArgs) {
|
|
if (parsed.full === true) return body;
|
|
const access = body?.access && typeof body.access === "object" ? body.access : null;
|
|
const source = access ?? body;
|
|
const users = Array.isArray(source?.users) ? source.users : [];
|
|
const devicePods = Array.isArray(source?.devicePods) ? source.devicePods : [];
|
|
const tuples = Array.isArray(source?.tuples) ? source.tuples : [];
|
|
const openfga = source?.openfga ?? body?.openfga;
|
|
return pruneUndefined({
|
|
actor: compactApiBody(source?.actor ?? body?.actor),
|
|
subject: compactApiBody(body?.subject),
|
|
user: compactApiBody(source?.user ?? body?.user),
|
|
contractVersion: text(source?.contractVersion ?? body?.contractVersion) || undefined,
|
|
openfga: openfga ? pruneUndefined({
|
|
mode: text(openfga.mode),
|
|
ready: openfga.ready === true,
|
|
status: text(openfga.status),
|
|
configured: openfga.configured === true,
|
|
storeId: text(openfga.storeId),
|
|
modelId: text(openfga.modelId),
|
|
degradedReason: text(openfga.degradedReason),
|
|
valuesRedacted: openfga.valuesRedacted === true
|
|
}) : undefined,
|
|
counts: source?.counts,
|
|
supported: source?.supported,
|
|
users: users.length > 0 ? users.map(adminAccessUserSummaryForCli) : undefined,
|
|
userCount: users.length || undefined,
|
|
devicePods: devicePods.length > 0 ? devicePods.map(adminAccessDevicePodForCli) : undefined,
|
|
devicePodCount: devicePods.length || undefined,
|
|
tools: source?.tools,
|
|
tupleCount: tuples.length || source?.tupleCount,
|
|
tuples: tuples.length > 0 ? tuples.map(adminAccessTupleForCli) : undefined,
|
|
authorization: body?.authorization ? pruneUndefined({
|
|
mode: text(body.authorization.mode),
|
|
allowed: body.authorization.allowed === true,
|
|
fgaAllowed: body.authorization.fgaAllowed ?? undefined,
|
|
mismatch: body.authorization.mismatch === true,
|
|
decisionSource: text(body.authorization.decisionSource),
|
|
relation: text(body.authorization.relation),
|
|
object: text(body.authorization.object),
|
|
degradedReason: text(body.authorization.degradedReason),
|
|
valuesRedacted: body.authorization.valuesRedacted === true
|
|
}) : undefined,
|
|
result: body?.result ? compactApiBody(body.result) : undefined,
|
|
error: body?.error ? compactApiBody(body.error) : undefined,
|
|
changed: body?.changed === true ? true : undefined,
|
|
updated: body?.updated === true ? true : undefined,
|
|
ok: body?.ok,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function adminAccessUserSummaryForCli(value: any) {
|
|
return pruneUndefined({
|
|
user: compactApiBody(value?.user),
|
|
tupleCount: value?.tupleCount,
|
|
devicePodCount: value?.devicePodCount,
|
|
tools: value?.tools
|
|
});
|
|
}
|
|
|
|
function adminAccessDevicePodForCli(value: any) {
|
|
return pruneUndefined({
|
|
devicePod: compactApiBody(value?.devicePod),
|
|
relations: value?.relations
|
|
});
|
|
}
|
|
|
|
function adminAccessTupleForCli(value: any) {
|
|
return pruneUndefined({
|
|
userId: text(value?.userId),
|
|
relation: text(value?.relation),
|
|
object: text(value?.object),
|
|
createdAt: text(value?.createdAt)
|
|
});
|
|
}
|
|
|
|
function providerProfileBodyForCli(body: any, parsed: ParsedArgs) {
|
|
if (parsed.full === true) return redactSecretLike(body);
|
|
const source = body?.data && typeof body.data === "object" ? body.data : body;
|
|
const items = Array.isArray(body?.items) ? body.items : Array.isArray(source?.items) ? source.items : [];
|
|
return pruneUndefined({
|
|
ok: body?.ok,
|
|
status: text(body?.status) || text(source?.status) || undefined,
|
|
contractVersion: text(body?.contractVersion) || text(source?.contractVersion) || undefined,
|
|
actor: compactApiBody(body?.actor ?? source?.actor),
|
|
profile: text(body?.profile ?? source?.profile) || undefined,
|
|
delegation: providerProfileDelegationForCli(body?.delegation ?? source?.delegation),
|
|
items: items.length > 0 ? items.map(providerProfileStatusForCli) : undefined,
|
|
count: items.length || body?.count || source?.count,
|
|
profileStatus: source?.profile ? providerProfileStatusForCli(source) : undefined,
|
|
validation: providerProfileValidationForCli(source?.validationId ? source : body?.validationId ? body : null),
|
|
error: body?.error ? compactApiBody(body.error) : undefined,
|
|
valuesPrinted: false,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function providerProfileStatusForCli(value: any) {
|
|
return pruneUndefined({
|
|
profile: text(value?.profile),
|
|
backendProfile: text(value?.backendProfile),
|
|
backendKind: text(value?.backendKind),
|
|
configured: value?.configured === true,
|
|
failureKind: text(value?.failureKind) || undefined,
|
|
secretRef: providerSecretRefForCli(value?.secretRef),
|
|
resourceVersion: text(value?.resourceVersion) || undefined,
|
|
credentialHashSuffix: text(value?.credentialHashSuffix) || undefined,
|
|
configHashSuffix: text(value?.configHashSuffix) || undefined,
|
|
updatedAt: text(value?.updatedAt) || undefined,
|
|
keyPresence: value?.keyPresence,
|
|
bridge: providerBridgeForCli(value?.bridge),
|
|
lastValidation: providerProfileValidationForCli(value?.lastValidation),
|
|
requiresExternalBridgeUpdate: value?.requiresExternalBridgeUpdate === true ? true : undefined,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function providerProfileValidationForCli(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const result = value.result && typeof value.result === "object" ? value.result : {};
|
|
const agentRun = result.agentRun && typeof result.agentRun === "object" ? result.agentRun : {};
|
|
const runnerJobs = Array.isArray(value.runnerJobs) ? value.runnerJobs : [];
|
|
return pruneUndefined({
|
|
validationId: text(value.validationId) || undefined,
|
|
profile: text(value.profile) || undefined,
|
|
runId: text(value.runId) || text(agentRun.runId) || undefined,
|
|
commandId: text(value.commandId) || text(agentRun.commandId) || undefined,
|
|
jobName: text(runnerJobs[0]?.jobName) || text(agentRun.jobName) || undefined,
|
|
traceId: text(result.traceId ?? agentRun.traceId) || undefined,
|
|
status: text(value.status) || text(result.status) || undefined,
|
|
terminalStatus: text(result.terminalStatus) || undefined,
|
|
failureKind: text(result.failureKind) || undefined,
|
|
bridge: providerBridgeForCli(value.bridge),
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function providerSecretRefForCli(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({ namespace: text(value.namespace), name: text(value.name), keys: Array.isArray(value.keys) ? value.keys.map(text).filter(Boolean) : undefined, mountPath: text(value.mountPath) || undefined });
|
|
}
|
|
|
|
function providerBridgeForCli(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({ kind: text(value.kind), route: text(value.route), serviceUrl: text(value.serviceUrl), responsesPath: text(value.responsesPath), forbiddenHosts: Array.isArray(value.forbiddenHosts) ? value.forbiddenHosts.map(text).filter(Boolean) : undefined, valuesPrinted: false });
|
|
}
|
|
|
|
function providerProfileDelegationForCli(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({ provider: text(value.provider), requestId: text(value.requestId), agentRunBaseUrl: text(value.agentRunBaseUrl), agentRunHost: text(value.agentRunHost), traceId: text(value.traceId), httpStatus: value.httpStatus, elapsedMs: value.elapsedMs, valuesPrinted: false });
|
|
}
|
|
|
|
function redactSecretLike(value: any): any {
|
|
if (Array.isArray(value)) return value.map(redactSecretLike);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, secretLikeField(key) ? "[redacted]" : redactSecretLike(item)]));
|
|
}
|
|
if (typeof value === "string" && /^sk-[A-Za-z0-9_-]{10,}/u.test(value)) return "[redacted]";
|
|
return value;
|
|
}
|
|
|
|
function secretLikeField(key: string) {
|
|
const lower = key.toLowerCase();
|
|
return ["apikey", "api_key", "authorization", "auth.json", "config.toml", "authjson", "configtoml"].includes(lower) || lower.includes("password") || lower.endsWith("token");
|
|
}
|
|
|
|
function traceBodyForCli(body: any, parsed: ParsedArgs) {
|
|
if (text(parsed.render) === "web") return webTraceRenderBody(body, parsed);
|
|
return responseBodyForCli(body, parsed);
|
|
}
|
|
|
|
function webTraceRenderBody(traceObject: any, parsed: ParsedArgs) {
|
|
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
|
|
const rows = traceDisplayRows(traceObject ?? {}, events);
|
|
const fallbackRows = rows.length > 0 ? [] : persistentTraceFallbackRows(traceObject);
|
|
const allRows = rows.length > 0 ? rows : fallbackRows;
|
|
const noiseEventCount = traceNoiseEventCount(events);
|
|
const rowLimit = Math.max(1, Math.min(numberOption(parsed.limit) ?? 80, 500));
|
|
const rowTail = parsed.full === true ? allRows : allRows.slice(-rowLimit);
|
|
return pruneUndefined({
|
|
traceId: traceObject?.traceId ?? null,
|
|
status: traceObject?.traceStatus ?? traceObject?.status ?? null,
|
|
traceStatus: traceObject?.traceStatus ?? traceObject?.status ?? null,
|
|
render: "web",
|
|
renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows",
|
|
sourceEventCount: traceObject?.eventCount ?? events.length,
|
|
renderedRowCount: allRows.length,
|
|
returnedRowCount: rowTail.length,
|
|
noiseEventCount,
|
|
omittedNoiseCount: noiseEventCount,
|
|
rowLimit: parsed.full === true ? null : rowLimit,
|
|
eventCountMismatch: Number.isInteger(traceObject?.eventCount) && traceObject.eventCount !== events.length ? {
|
|
declared: traceObject.eventCount,
|
|
loaded: events.length
|
|
} : undefined,
|
|
assistantText: assistantText(traceObject),
|
|
rows: rowTail.map(compactTraceRenderRow),
|
|
retention: traceObject?.retention,
|
|
fallback: traceObject?.fallback,
|
|
finalResponse: traceObject?.finalResponse,
|
|
traceSummary: traceObject?.traceSummary,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function persistentTraceFallbackRows(traceObject: any) {
|
|
const fallback = traceObject?.fallback && typeof traceObject.fallback === "object" ? traceObject.fallback : null;
|
|
const finalResponse = traceObject?.finalResponse ?? fallback?.finalResponse;
|
|
const traceSummary = traceObject?.traceSummary ?? fallback?.traceSummary;
|
|
const textValue = text(finalResponse?.text ?? finalResponse?.textPreview ?? traceSummary?.finalAssistantRow?.textPreview);
|
|
if (!fallback?.available && !textValue && !traceSummary) return [];
|
|
const body = [
|
|
textValue ? `final assistant: ${textValue}` : "final assistant: unavailable in persisted snapshot",
|
|
`source: ${text(traceSummary?.source ?? fallback?.source ?? "agent-session-snapshot")}`,
|
|
`terminalStatus: ${text(traceSummary?.terminalStatus ?? finalResponse?.status ?? traceObject?.status ?? "unknown")}`,
|
|
`sourceEventCount: ${traceSummary?.sourceEventCount ?? traceObject?.eventCount ?? 0}`,
|
|
fallback?.conversationId ? `conversationId: ${fallback.conversationId}` : null,
|
|
fallback?.sessionId ? `sessionId: ${fallback.sessionId}` : null,
|
|
fallback?.threadId ? `threadId: ${fallback.threadId}` : null,
|
|
fallback?.agentRun?.runId ? `runId: ${fallback.agentRun.runId}` : null,
|
|
fallback?.agentRun?.commandId ? `commandId: ${fallback.agentRun.commandId}` : null
|
|
].filter(Boolean).join("\n");
|
|
return [{
|
|
rowId: `${traceObject?.traceId ?? "trace"}:persisted-summary`,
|
|
seq: null,
|
|
tone: "info",
|
|
header: "历史 trace 已过期,显示持久化摘要",
|
|
bodyFormat: "text",
|
|
terminal: true,
|
|
body
|
|
}];
|
|
}
|
|
|
|
function traceResponseAliases(traceBody: any, parsed: ParsedArgs) {
|
|
if (text(parsed.render) !== "web" || !traceBody || typeof traceBody !== "object") return {};
|
|
const rendered = pruneUndefined({
|
|
traceId: traceBody.traceId ?? null,
|
|
traceStatus: traceBody.status ?? null,
|
|
render: traceBody.render,
|
|
renderer: traceBody.renderer,
|
|
sourceEventCount: traceBody.sourceEventCount,
|
|
renderedRowCount: traceBody.renderedRowCount,
|
|
returnedRowCount: traceBody.returnedRowCount,
|
|
noiseEventCount: traceBody.noiseEventCount,
|
|
omittedNoiseCount: traceBody.omittedNoiseCount,
|
|
rowLimit: traceBody.rowLimit,
|
|
rows: traceBody.rows,
|
|
eventCountMismatch: traceBody.eventCountMismatch,
|
|
assistantText: traceBody.assistantText,
|
|
retention: traceBody.retention,
|
|
fallback: traceBody.fallback,
|
|
finalResponse: traceBody.finalResponse,
|
|
traceSummary: traceBody.traceSummary
|
|
});
|
|
return {
|
|
traceStatus: traceBody.status ?? null,
|
|
rendered,
|
|
data: rendered
|
|
};
|
|
}
|
|
|
|
function compactTraceRenderRow(row: any) {
|
|
return pruneUndefined({
|
|
rowId: row?.rowId ?? null,
|
|
seq: row?.seq ?? null,
|
|
tone: row?.tone ?? null,
|
|
header: row?.header ?? null,
|
|
bodyFormat: row?.bodyFormat ?? null,
|
|
terminal: row?.terminal === true ? true : undefined,
|
|
body: row?.body
|
|
? (row?.terminal === true ? String(row.body) : preview(String(row.body), 1200))
|
|
: undefined
|
|
});
|
|
}
|
|
|
|
function compactApiBody(body: any): any {
|
|
if (Array.isArray(body)) return { itemCount: body.length, items: body.slice(0, 20).map(compactApiBody), fullBodyAvailable: true };
|
|
if (!body || typeof body !== "object") return body;
|
|
if (body.jsonrpc === "2.0" || Object.hasOwn(body, "result")) return compactRpcBody(body);
|
|
return pruneUndefined({
|
|
...compactBody(body),
|
|
assistantText: assistantText(body),
|
|
reply: compactReply(body.reply ?? body.message),
|
|
id: body.id,
|
|
username: body.username,
|
|
displayName: body.displayName,
|
|
role: body.role,
|
|
status: body.status,
|
|
eventId: body.eventId,
|
|
jobId: body.jobId,
|
|
name: body.name,
|
|
devicePodId: body.devicePodId,
|
|
targetId: body.targetId,
|
|
profile: body.profile,
|
|
ts: body.ts,
|
|
level: body.level,
|
|
scope: body.scope,
|
|
intent: body.intent,
|
|
route: body.route,
|
|
environment: body.environment,
|
|
adapter: body.adapter,
|
|
sourceKind: body.sourceKind,
|
|
liveBackend: body.liveBackend,
|
|
agentRun: compactAgentRun(body.agentRun),
|
|
rowCount: body.rowCount,
|
|
columns: Array.isArray(body.columns) ? body.columns : undefined,
|
|
roles: Array.isArray(body.roles) ? body.roles : undefined,
|
|
routes: body.routes && typeof body.routes === "object" ? Object.keys(body.routes) : undefined,
|
|
devicePods: Array.isArray(body.devicePods) ? body.devicePods.slice(0, 20).map(compactApiBody) : undefined,
|
|
events: Array.isArray(body.events) ? body.events.slice(0, 20).map(compactApiBody) : undefined,
|
|
lines: Array.isArray(body.lines) ? body.lines.slice(0, 20) : undefined,
|
|
assistantStreams: Array.isArray(body.assistantStreams) ? body.assistantStreams.slice(-3).map(compactAssistantStream) : undefined,
|
|
refs: body.refs,
|
|
eventCount: body.eventCount,
|
|
elapsedMs: body.elapsedMs,
|
|
methodsCount: Array.isArray(body.methods) ? body.methods.length : undefined,
|
|
rowsCount: Array.isArray(body.rows) ? body.rows.length : undefined,
|
|
devicePodsCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined,
|
|
eventsCount: Array.isArray(body.events) ? body.events.length : undefined,
|
|
linesCount: Array.isArray(body.lines) ? body.lines.length : undefined,
|
|
messagesCount: Array.isArray(body.messages) ? body.messages.length : undefined,
|
|
assistantStreamsCount: Array.isArray(body.assistantStreams) ? body.assistantStreams.length : undefined,
|
|
servicesCount: Array.isArray(body.services) ? body.services.length : undefined,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function compactAgentRun(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({
|
|
adapter: value.adapter,
|
|
runId: value.runId,
|
|
commandId: value.commandId,
|
|
targetCommandId: value.targetCommandId,
|
|
steerCommandId: value.steerCommandId,
|
|
lastSteerCommandId: value.lastSteerCommandId,
|
|
attemptId: value.attemptId,
|
|
runnerId: value.runnerId,
|
|
jobName: value.jobName,
|
|
namespace: value.namespace,
|
|
backendProfile: value.backendProfile,
|
|
status: value.status,
|
|
runStatus: value.runStatus,
|
|
commandState: value.commandState,
|
|
terminalStatus: value.terminalStatus,
|
|
reuseEligible: value.reuseEligible,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function assistantText(body: any) {
|
|
const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText ?? body?.finalResponse?.text ?? body?.fallback?.finalResponse?.text);
|
|
if (direct) return direct;
|
|
if (Array.isArray(body?.assistantStreams)) {
|
|
for (const item of [...body.assistantStreams].reverse()) {
|
|
const value = text(item?.text ?? item?.lastChunk);
|
|
if (value) return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function compactReply(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({ messageId: value.messageId, role: value.role, content: value.content, createdAt: value.createdAt });
|
|
}
|
|
|
|
function compactAssistantStream(value: any) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
return pruneUndefined({ status: value.status, chunkCount: value.chunkCount, text: value.text, lastChunk: value.lastChunk, updatedAt: value.updatedAt });
|
|
}
|
|
|
|
function compactTraceBody(traceObject: any, limit: number) {
|
|
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
|
|
const tail = events.slice(-limit).map((event: any) => ({
|
|
at: event.createdAt ?? event.timestamp ?? null,
|
|
label: event.label ?? null,
|
|
status: event.status ?? null,
|
|
waitingFor: event.waitingFor ?? null,
|
|
message: preview(event.message ?? event.command ?? event.error ?? null, 240)
|
|
}));
|
|
return pruneUndefined({
|
|
traceId: traceObject?.traceId ?? null,
|
|
status: traceObject?.status ?? null,
|
|
waitingFor: traceObject?.waitingFor ?? traceObject?.lastEvent?.waitingFor ?? null,
|
|
eventCount: traceObject?.eventCount ?? events.length,
|
|
lastEvent: traceObject?.lastEvent ?? events.at(-1) ?? null,
|
|
eventTail: tail,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function auditTrace(traceObject: any, { requireBootsharp = false } = {}) {
|
|
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
|
|
if (traceObject?.status === "missing" && events.length === 0) {
|
|
return {
|
|
status: "trace_missing",
|
|
summary: { hasHwpod: false, hasBootsharp: false, hasApplyPatch: false, frictionCount: 0 },
|
|
frictionSignals: [],
|
|
recommendation: "Trace is missing. Check the traceId and poll result/trace after submit."
|
|
};
|
|
}
|
|
const textValue = commandTextFromEvents(events);
|
|
const signals = [
|
|
signal(
|
|
"low_level_tran",
|
|
/\/app\/tools\/(?:hwlab-gateway-)?tran\.mjs/u,
|
|
textValue,
|
|
"Use named device-pod operations through the CLI instead of low-level transport."
|
|
),
|
|
signal(
|
|
"long_device_pod_cli_path",
|
|
/node\s+\/app\/(?:tools\/device-pod-cli\.mjs|skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs)/u,
|
|
textValue,
|
|
"Use hwpod in HWLAB code-agent runners; if hwpod is absent, fix the runner image/package instead of invoking the long wrapper path."
|
|
),
|
|
signal(
|
|
"temporary_script_workaround",
|
|
temporaryScriptPattern,
|
|
textValue,
|
|
"Add or use a named device-pod operation instead of staging temporary scripts."
|
|
),
|
|
signal(
|
|
"workspace_put_text_edit",
|
|
/(?:\S*workspace:\/\S*\s+put\s+(?!status\b)\S+|\bworkspace\s+put\s+(?!status\b)\S+)/u,
|
|
textValue,
|
|
"Workspace put is available, but source text edits should prefer apply-patch unless whole-file write is intentional."
|
|
),
|
|
signal(
|
|
"status_shell_parsing",
|
|
statusShellParsingPattern,
|
|
textValue,
|
|
"Use compact JSON fields returned by harness result/trace instead of shell parsing."
|
|
),
|
|
requireBootsharp && !/\bbootsharp\b/u.test(textValue)
|
|
? {
|
|
kind: "missing_bootsharp",
|
|
count: 1,
|
|
examples: [],
|
|
hint: "No bootsharp/bootstrap device-pod context call was found in retained trace events."
|
|
}
|
|
: null
|
|
].filter(Boolean);
|
|
return {
|
|
status: signals.length > 0 ? "friction_detected" : "clean",
|
|
summary: {
|
|
hasHwpod: /\bhwpod\b/u.test(textValue),
|
|
hasBootsharp: /\bbootsharp\b/u.test(textValue),
|
|
hasApplyPatch: /\bapply-patch\b/u.test(textValue),
|
|
frictionCount: signals.length
|
|
},
|
|
frictionSignals: signals,
|
|
recommendation: signals.length > 0
|
|
? "Treat these as tool-friction signals and improve named CLI/device-pod operations; this is not a runtime gate. Prefer hwpod for device-pod commands."
|
|
: "No obvious device-pod transport friction in retained trace events."
|
|
};
|
|
}
|
|
|
|
const temporaryScriptPattern = /(?:cat\s*>\s*\S+\.(?:py|ps1|js|mjs|bat)\b|upload\b[^\n]*\.(?:py|ps1|js|mjs|bat)\b|(?:python|powershell|pwsh)\s+\S+\.(?:py|ps1)\b)/iu;
|
|
const statusShellParsingPattern = /(?:device-pod-cli|hwpod|hwlab-cli)\b[^\n]*(?:\bstatus\b|\btrace\b)[^\n]*(?:\|\s*(?:grep|head|python3?\s+-c|jq)\b|;\s*done)/iu;
|
|
|
|
function commandTextFromEvents(events: any[]) {
|
|
return events.map((event: any) => {
|
|
if (typeof event?.command === "string") return event.command;
|
|
const label = String(event?.label ?? "");
|
|
const isToolEvent = event?.type === "tool_call" || event?.stage === "tool_call" || label.includes("commandExecution");
|
|
return isToolEvent && typeof event?.message === "string" ? event.message : null;
|
|
}).filter(Boolean).join("\n");
|
|
}
|
|
|
|
function signal(kind: string, pattern: RegExp, textValue: string, hint: string) {
|
|
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
const matches = [...textValue.matchAll(new RegExp(pattern.source, flags))];
|
|
return matches.length > 0 ? { kind, count: matches.length, examples: matches.slice(0, 3).map((match) => preview(match[0], 220)), hint } : null;
|
|
}
|
|
|
|
function compactRpcBody(body: any) {
|
|
return pruneUndefined({
|
|
jsonrpc: body.jsonrpc,
|
|
id: body.id,
|
|
error: body.error,
|
|
meta: body.meta,
|
|
result: body.result && typeof body.result === "object" ? compactApiBody(body.result) : body.result,
|
|
fullBodyAvailable: true
|
|
});
|
|
}
|
|
|
|
function compactBody(body: any) {
|
|
if (!body || typeof body !== "object") return body;
|
|
return {
|
|
ok: body.ok,
|
|
status: body.status,
|
|
authenticated: body.authenticated,
|
|
actor: body.actor,
|
|
user: body.user,
|
|
contractVersion: body.contractVersion,
|
|
serviceId: body.serviceId,
|
|
ready: body.ready,
|
|
selectedDevicePodId: body.selectedDevicePodId,
|
|
devicePodCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined,
|
|
traceId: body.traceId,
|
|
traceStatus: body.traceStatus,
|
|
conversationId: body.conversationId,
|
|
sessionId: body.sessionId,
|
|
threadId: body.threadId,
|
|
workspaceId: body.workspaceId,
|
|
workspaceRevision: body.workspaceRevision,
|
|
accepted: body.accepted,
|
|
shortConnection: body.shortConnection,
|
|
resultUrl: body.resultUrl,
|
|
traceUrl: body.traceUrl,
|
|
error: body.error,
|
|
blocker: body.blocker,
|
|
summary: body.summary,
|
|
retention: body.retention,
|
|
fallback: body.fallback,
|
|
finalResponse: body.finalResponse,
|
|
traceSummary: body.traceSummary,
|
|
freshness: body.freshness,
|
|
profileHash: body.profileHash,
|
|
targetId: body.targetId,
|
|
source: body.source
|
|
};
|
|
}
|
|
|
|
function authBodySummary(body: any) {
|
|
if (!body || typeof body !== "object") return body;
|
|
return { authenticated: body.authenticated, user: safeUser(body.user ?? body.actor), expiresAt: textOrNull(body.expiresAt), error: body.error };
|
|
}
|
|
|
|
function safeUser(value: any) {
|
|
if (!value || typeof value !== "object") return null;
|
|
return clean({ id: text(value.id), username: text(value.username), role: text(value.role), displayName: text(value.displayName) });
|
|
}
|
|
|
|
function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; }
|
|
function ok(action: string, data: Record<string, unknown> = {}, status = "succeeded") {
|
|
return { ok: !["failed", "timeout"].includes(status), action, status, ...data };
|
|
}
|
|
function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; }
|
|
function errorSummary(error: any) { return { code: error?.code ?? "hwlab_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; }
|
|
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
|
|
function route(method: string, pathName: string) { return { method, path: pathName }; }
|
|
function isHttpSuccess(response: any) { return response.status >= 200 && response.status < 300; }
|
|
function text(value: unknown) { return String(value ?? "").trim(); }
|
|
function textOrNull(value: unknown) { const result = text(value); return result || null; }
|
|
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; }
|
|
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
|
|
function boundedInteger(value: unknown, fallback: number, min: number, max: number) {
|
|
const parsed = numberOption(value) ?? fallback;
|
|
return Math.min(max, Math.max(min, parsed));
|
|
}
|
|
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false && item !== null)) as T; }
|
|
function pruneUndefined<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T; }
|
|
function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } }
|
|
function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); }
|
|
function makeId(prefix: string) { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; }
|
|
function wait(ms: number) { return new Promise<void>((resolve) => setTimeout(resolve, ms)); }
|
|
function requiredTraceId(value: unknown) {
|
|
const result = requiredText(value, "traceId");
|
|
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(result)) {
|
|
throw cliError("invalid_trace_id", "traceId must start with trc_", { traceId: result });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function requiredConversationId(value: unknown) {
|
|
const result = requiredText(value, "conversationId");
|
|
if (!/^cnv_[A-Za-z0-9_.:-]+$/u.test(result)) {
|
|
throw cliError("invalid_conversation_id", "conversationId must start with cnv_", { conversationId: result });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function requiredSessionId(value: unknown) {
|
|
const result = requiredText(value, "sessionId");
|
|
if (!/^ses_[A-Za-z0-9_.:-]+$/u.test(result)) {
|
|
throw cliError("invalid_session_id", "sessionId must start with ses_", { sessionId: result });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function safeOptionalConversationId(value: unknown) {
|
|
const result = text(value);
|
|
if (!result) return "";
|
|
if (!/^cnv_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_conversation_id", "conversationId must start with cnv_", { conversationId: result });
|
|
return result;
|
|
}
|
|
|
|
function safeOptionalSessionId(value: unknown) {
|
|
const result = text(value);
|
|
if (!result) return "";
|
|
if (!/^ses_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_session_id", "sessionId must start with ses_", { sessionId: result });
|
|
return result;
|
|
}
|
|
|
|
function preview(value: unknown, max = 500) {
|
|
if (value === undefined || value === null) return null;
|
|
const result = String(value).replace(/\s+/gu, " ").trim();
|
|
return result.length > max ? `${result.slice(0, max)}...` : result;
|
|
}
|
|
|
|
function shellArg(value: unknown) {
|
|
const textValue = String(value ?? "");
|
|
return /^[A-Za-z0-9_./:@-]+$/u.test(textValue) ? textValue : `'${textValue.replace(/'/gu, `'"'"'`)}'`;
|
|
}
|