feat: sync g14 harness and device-pod cli
This commit is contained in:
+298
-2
@@ -57,6 +57,7 @@ async function clientCommand(context: any) {
|
||||
if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next);
|
||||
if (group === "runtime") return runtimeCommand(next);
|
||||
if (group === "agent") return agentCommand(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);
|
||||
@@ -84,7 +85,10 @@ function help() {
|
||||
"hwlab-cli client rpc system.health [--full]",
|
||||
"hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000",
|
||||
"hwlab-cli client agent trace TRACE_ID",
|
||||
"hwlab-cli client agent cancel TRACE_ID"
|
||||
"hwlab-cli client agent cancel TRACE_ID",
|
||||
"hwlab-cli client harness submit --message TEXT --provider-profile deepseek",
|
||||
"hwlab-cli client harness wait TRACE_ID --timeout-ms 55000",
|
||||
"hwlab-cli client harness audit TRACE_ID --require-bootsharp"
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -277,6 +281,172 @@ async function agentCommand(context: any) {
|
||||
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
|
||||
}
|
||||
|
||||
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]",
|
||||
"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 timeoutMs = Math.min(numberOption(context.parsed.timeoutMs) ?? 55000, 60000);
|
||||
const pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? 1000, 250);
|
||||
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,
|
||||
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,
|
||||
timeoutMs,
|
||||
body: lastResponse ? responseBodyForCli(lastResponse.body, context.parsed) : null
|
||||
}, "timeout");
|
||||
}
|
||||
|
||||
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 = text(parsed.message ?? parsed.text) || text(context.stdinText);
|
||||
@@ -444,6 +614,13 @@ async function rpcParamsValue(context: any) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -661,6 +838,110 @@ function compactAssistantStream(value: any) {
|
||||
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,
|
||||
"Prefer the main hwlab-cli client entry or short documented device-pod command."
|
||||
),
|
||||
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."
|
||||
: "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,
|
||||
@@ -714,7 +995,9 @@ function safeUser(value: any) {
|
||||
}
|
||||
|
||||
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: status !== "timeout", action, status, ...data }; }
|
||||
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 }); }
|
||||
@@ -729,3 +1012,16 @@ function pruneUndefined<T extends Record<string, any>>(value: T): T { return Obj
|
||||
function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } }
|
||||
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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user