fix: expose bounded session send decisions
This commit is contained in:
@@ -808,6 +808,174 @@ describe("AgentRun default transport contract", () => {
|
||||
rmSync(tempConfigPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session send plan and typed partial-write errors stay homologous and bounded", async () => {
|
||||
const sessionId = "sess_artificer_fixture";
|
||||
const runId = "run_partial_fixture";
|
||||
const commandId = "cmd_partial_fixture";
|
||||
const hiddenKubernetesPayload = "HIDDEN_KUBERNETES_PAYLOAD_MUST_NOT_BE_PRINTED";
|
||||
let postRequests = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
expect(request.headers.get("authorization")).toBe("Bearer secret-value");
|
||||
if (request.method === "GET" && url.pathname === `/api/v1/sessions/${sessionId}`) {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
sessionId,
|
||||
tenantId: "unidesk",
|
||||
projectId: "test",
|
||||
providerId: "NC01",
|
||||
backendProfile: "codex",
|
||||
conversationId: sessionId,
|
||||
metadata: { title: "Artificer fixture" },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === `/api/v1/sessions/${sessionId}/send`) {
|
||||
postRequests += 1;
|
||||
const body = await request.json() as Record<string, any>;
|
||||
if (body.payload?.prompt === "plan fixture") {
|
||||
expect(body.dryRun).toBe(true);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
action: "session-send-plan",
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
sessionId,
|
||||
decision: "turn",
|
||||
internalCommandType: "turn",
|
||||
activeBefore: null,
|
||||
reusedIdleRun: null,
|
||||
request: { method: "POST", path: `/api/v1/sessions/${sessionId}/send`, commandType: "turn", createRunnerJob: true, valuesPrinted: false },
|
||||
next: {
|
||||
confirm: { action: "send-session", operation: "send", resourceKind: "session", resourceName: sessionId, sessionId, inputKind: "prompt", valuesPrinted: false },
|
||||
note: "Remove --dry-run to perform the mutation.",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
ok: false,
|
||||
failureKind: "infra-failed",
|
||||
message: "kubectl get jobs failed with code 1",
|
||||
traceId: "trc_session_send_fixture",
|
||||
error: {
|
||||
name: "AgentRunError",
|
||||
failureKind: "infra-failed",
|
||||
message: "kubectl get jobs failed with code 1",
|
||||
details: {
|
||||
code: "runner-admission-failed",
|
||||
reason: "runner-retention-observation-failed",
|
||||
phase: "runner-admission",
|
||||
sessionId,
|
||||
runId,
|
||||
commandId,
|
||||
mutation: true,
|
||||
partialWrite: true,
|
||||
durable: true,
|
||||
resources: {
|
||||
session: { id: sessionId, state: "running" },
|
||||
run: { id: runId, status: "queued" },
|
||||
command: { id: commandId, state: "pending", type: "turn" },
|
||||
},
|
||||
recoveryActions: [
|
||||
{ operation: "describe", resourceKind: "session", resourceName: sessionId },
|
||||
{ operation: "events", resourceKind: "run", resourceName: runId, runId, afterSeq: 0, limit: 100 },
|
||||
],
|
||||
stderr: hiddenKubernetesPayload,
|
||||
rawKubernetesObject: { secret: hiddenKubernetesPayload },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
}, { status: 502 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
const previousKey = process.env.HWLAB_API_KEY;
|
||||
process.env.HWLAB_API_KEY = "secret-value";
|
||||
const tempConfigPath = `/tmp/unidesk-agentrun-session-send-test-${process.pid}-${Date.now()}.yaml`;
|
||||
try {
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
|
||||
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
|
||||
|
||||
const planArgs = ["send", `session/${sessionId}`, "--prompt", "plan fixture", "--dry-run"];
|
||||
const planHuman = renderedTextOf(await runAgentRunCommand(null, planArgs));
|
||||
expect(planHuman).toContain("Session send plan");
|
||||
expect(planHuman).toContain("Decision: turn");
|
||||
expect(planHuman).toContain("ReuseRun: false");
|
||||
expect(planHuman).toContain("CreateRunner: true");
|
||||
expect(planHuman).toContain("Mutation: false");
|
||||
expect(planHuman).not.toContain("[object Object]");
|
||||
const planJsonText = renderedTextOf(await runAgentRunCommand(null, [...planArgs, "-o", "json"]));
|
||||
const planYamlText = renderedTextOf(await runAgentRunCommand(null, [...planArgs, "-o", "yaml"]));
|
||||
const planJson = JSON.parse(planJsonText) as Record<string, any>;
|
||||
const planYaml = Bun.YAML.parse(planYamlText) as Record<string, any>;
|
||||
expect(planYaml).toEqual(planJson);
|
||||
expect(planJson).toMatchObject({ kind: "SessionSendPlan", decision: "turn", dryRun: true, mutation: false, partialWrite: false, reuseRun: false, createRunner: true });
|
||||
expect(planJson.resources.session).toEqual({ kind: "session", id: sessionId });
|
||||
expect(planJson.next.actions[0]).toMatchObject({ mutation: true });
|
||||
expect(planJson.next.actions[0].command).not.toContain("--dry-run");
|
||||
|
||||
const failureArgs = ["send", `session/${sessionId}`, "--prompt", "failure fixture"];
|
||||
const failureHumanResult = await runAgentRunCommand(null, failureArgs);
|
||||
const failureHuman = renderedTextOf(failureHumanResult);
|
||||
expect(failureHumanResult.ok).toBe(false);
|
||||
expect(failureHuman).toContain("Session send failed");
|
||||
expect(failureHuman).toContain("Error: validation-failed");
|
||||
expect(failureHuman).toContain("TypedFailure: infra-failed");
|
||||
expect(failureHuman).toContain("Code: runner-admission-failed");
|
||||
expect(failureHuman).toContain("Reason: runner-retention-observation-failed");
|
||||
expect(failureHuman).toContain("Mutation: true");
|
||||
expect(failureHuman).toContain("PartialWrite: true");
|
||||
expect(failureHuman).toContain(`session/${sessionId} state=running`);
|
||||
expect(failureHuman).toContain(`run/${runId} status=queued`);
|
||||
expect(failureHuman).toContain(`command/${commandId} state=pending type=turn`);
|
||||
expect(failureHuman).not.toContain(hiddenKubernetesPayload);
|
||||
expect(failureHuman).not.toContain("agentrun send");
|
||||
|
||||
const failureJsonResult = await runAgentRunCommand(null, [...failureArgs, "-o", "json"]);
|
||||
const failureYamlResult = await runAgentRunCommand(null, [...failureArgs, "-o", "yaml"]);
|
||||
const failureJsonText = renderedTextOf(failureJsonResult);
|
||||
const failureYamlText = renderedTextOf(failureYamlResult);
|
||||
const failureJson = JSON.parse(failureJsonText) as Record<string, any>;
|
||||
const failureYaml = Bun.YAML.parse(failureYamlText) as Record<string, any>;
|
||||
expect(failureJsonResult.ok).toBe(false);
|
||||
expect(failureYamlResult.ok).toBe(false);
|
||||
expect(failureYaml).toEqual(failureJson);
|
||||
expect(failureJson).toMatchObject({
|
||||
kind: "SessionSendError",
|
||||
mutation: true,
|
||||
partialWrite: true,
|
||||
error: {
|
||||
failureKind: "validation-failed",
|
||||
typedFailureKind: "infra-failed",
|
||||
code: "runner-admission-failed",
|
||||
reason: "runner-retention-observation-failed",
|
||||
traceId: "trc_session_send_fixture",
|
||||
},
|
||||
});
|
||||
for (const bounded of [planHuman, planJsonText, planYamlText, failureHuman, failureJsonText, failureYamlText]) {
|
||||
expect(Buffer.byteLength(bounded, "utf8")).toBeLessThan(7000);
|
||||
expect(bounded).not.toContain(hiddenKubernetesPayload);
|
||||
expect(bounded).not.toContain("rawKubernetesObject");
|
||||
}
|
||||
expect(postRequests).toBe(6);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
|
||||
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
|
||||
else process.env.HWLAB_API_KEY = previousKey;
|
||||
rmSync(tempConfigPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRun YAML tool credential binding", () => {
|
||||
|
||||
@@ -721,7 +721,10 @@ export function renderAgentRunSessionPolicyExplanation(args: string[] = [], opti
|
||||
}
|
||||
|
||||
export function safeAgentRunEnvelope(envelope: Record<string, unknown>): Record<string, unknown> {
|
||||
return pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "valuesPrinted"]);
|
||||
const safe = pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "traceId", "action", "decision", "internalCommandType", "dryRun", "mutation", "partialWrite", "sessionId", "runId", "commandId", "runnerJobId", "runnerAdmission", "recoveryActions", "valuesPrinted"]);
|
||||
const nestedError = record(envelope.error);
|
||||
if (Object.keys(nestedError).length > 0) safe.error = pickCompact(nestedError, ["name", "failureKind", "message", "code", "details"]);
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function normalizeAgentRunFailureKind(raw: string | null, httpStatus: number): AgentRunFailureKind {
|
||||
|
||||
@@ -11,5 +11,6 @@ export * from "./yaml-lane";
|
||||
export * from "./secrets";
|
||||
export * from "./git-mirror";
|
||||
export * from "./rest-bridge";
|
||||
export * from "./session-send-render";
|
||||
export * from "./config";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -38,6 +38,7 @@ import { agentRunDryRunPlan, readAgentRunClientConfig } from "./config";
|
||||
import { status } from "./control-plane";
|
||||
import { arrayRecords, displayValue, isRecord, nextPagedResourceCommand, parseTaskManifest, pickCompact, relativeAge, renderFailureLines, renderResourceNextLines, renderTable, resourceName, shortId, stringOrDash, truncateMultiline, truncateOneLine } from "./options";
|
||||
import { activeAgentRunRestTarget, agentRunRestRequest, runAgentRunRestCommand } from "./rest-bridge";
|
||||
import { renderSessionSendResult } from "./session-send-render";
|
||||
import { record, stringOrNull } from "./utils";
|
||||
|
||||
export function resolveAgentRunCancelPolicyTarget(config: UniDeskConfig | null, options: AgentRunResourceOptions): { configPath: string; spec: AgentRunLaneSpec; source: "selected-lane" | "default-lane" } | null {
|
||||
@@ -130,7 +131,7 @@ export async function resourceSessionPromptCommand(config: UniDeskConfig | null,
|
||||
if (ref.kind !== "session") throw new Error("send requires session/<sessionId>");
|
||||
const sessionArgs = ["send", ref.name, ...stripLeadingResource(args, ref.name)];
|
||||
const result = await runAgentRunRestCommand(config, "sessions", sessionArgs);
|
||||
return renderMutationSummary(command, result, options, options.dryRun ? "Session send plan" : "Session send submitted");
|
||||
return renderSessionSendResult(command, result, options);
|
||||
}
|
||||
|
||||
export function renderedCliResult(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { isHelpArg, renderAgentRunHelp } from "./entry";
|
||||
import { agentRunExplain, arrayRecords, shortId } from "./options";
|
||||
import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render";
|
||||
import { AgentRunRestError, renderAgentRunRestError, resolveAgentRunRestTarget, runAgentRunRestCommand, withAgentRunRestTarget } from "./rest-bridge";
|
||||
import { renderSessionSendError } from "./session-send-render";
|
||||
import { runnerJobObservationScript, type RunnerJobObservationIdentity } from "./runner-observation";
|
||||
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, record, stringOrNull } from "./utils";
|
||||
|
||||
@@ -88,7 +89,10 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
|
||||
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentRunRestError) return renderAgentRunRestError(command, error, options);
|
||||
if (error instanceof AgentRunRestError) {
|
||||
if (verb === "send") return renderSessionSendError(command, error.toPayload(command), options);
|
||||
return renderAgentRunRestError(command, error, options);
|
||||
}
|
||||
return renderedCliResult(false, command, `Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import type { RenderedCliResult } from "../output";
|
||||
import type { AgentRunResourceOptions } from "./utils";
|
||||
import { record, stringOrNull } from "./utils";
|
||||
|
||||
type SessionSendProjection = {
|
||||
kind: "SessionSendPlan" | "SessionSendResult" | "SessionSendError";
|
||||
ok: boolean;
|
||||
action: string;
|
||||
decision: string | null;
|
||||
internalCommandType: string | null;
|
||||
dryRun: boolean;
|
||||
mutation: boolean | null;
|
||||
partialWrite: boolean | null;
|
||||
reuseRun: boolean | null;
|
||||
createRunner: boolean | null;
|
||||
resources: Record<string, unknown>;
|
||||
runnerAdmission: Record<string, unknown> | null;
|
||||
request: Record<string, unknown> | null;
|
||||
error: Record<string, unknown> | null;
|
||||
next: {
|
||||
actions: Array<{ command: string; mutation: boolean; reason: string }>;
|
||||
note: string | null;
|
||||
};
|
||||
valuesPrinted: false;
|
||||
};
|
||||
|
||||
const resourceStateKeys = ["state", "status", "phase", "terminalStatus", "type"] as const;
|
||||
|
||||
export function renderSessionSendResult(command: string, raw: Record<string, unknown>, options: AgentRunResourceOptions): RenderedCliResult {
|
||||
if (options.raw) return renderSessionSendMachine(command, raw, "json", raw.ok !== false);
|
||||
const projection = sessionSendSuccessProjection(command, raw);
|
||||
if (options.output === "json" || options.output === "yaml") return renderSessionSendMachine(command, projection, options.output, true);
|
||||
return renderSessionSendHuman(command, projection);
|
||||
}
|
||||
|
||||
export function renderSessionSendError(command: string, raw: Record<string, unknown>, options: AgentRunResourceOptions): RenderedCliResult {
|
||||
if (options.raw) return renderSessionSendMachine(command, raw, "json", false);
|
||||
const projection = sessionSendErrorProjection(raw);
|
||||
if (options.output === "json" || options.output === "yaml") return renderSessionSendMachine(command, projection, options.output, false);
|
||||
return renderSessionSendHuman(command, projection);
|
||||
}
|
||||
|
||||
export function sessionSendSuccessProjection(command: string, raw: Record<string, unknown>): SessionSendProjection {
|
||||
const data = record(innerData(raw));
|
||||
const activeBefore = record(data.activeBefore);
|
||||
const reusedIdleRun = record(data.reusedIdleRun);
|
||||
const run = record(data.run);
|
||||
const commandResource = record(data.command);
|
||||
const runnerJob = record(data.runnerJob);
|
||||
const dispatchIntent = record(commandResource.dispatchIntent);
|
||||
const runnerAdmission = compactRunnerAdmission(record(data.runnerAdmission));
|
||||
const request = compactRequest(record(data.request));
|
||||
const decision = stringOrNull(data.decision);
|
||||
const dryRun = data.dryRun === true;
|
||||
const reuseRun = decision === null
|
||||
? null
|
||||
: decision === "steer" || Object.keys(reusedIdleRun).length > 0;
|
||||
const createRunner = resolveCreateRunner({ decision, reuseRun, request: record(data.request), runnerJob, runnerAdmission: record(data.runnerAdmission) });
|
||||
const sessionId = firstString(data.sessionId, record(run.sessionRef).sessionId, activeBefore.sessionId, reusedIdleRun.sessionId);
|
||||
const runId = firstString(run.id, data.runId, activeBefore.runId, reusedIdleRun.runId, record(data.runnerAdmission).runId);
|
||||
const commandId = firstString(commandResource.id, data.commandId, activeBefore.commandId, reusedIdleRun.commandId, record(data.runnerAdmission).commandId);
|
||||
const runnerJobId = firstString(runnerJob.id, runnerJob.runnerJobId, data.runnerJobId, record(data.runnerAdmission).plannedRunnerJobId, dispatchIntent.runnerJobId);
|
||||
const dispatchIntentId = firstString(dispatchIntent.id, record(data.runnerAdmission).dispatchIntentId);
|
||||
const mutation = booleanOrNull(data.mutation) ?? booleanOrNull(raw.mutation);
|
||||
const partialWrite = booleanOrNull(record(data.runnerAdmission).partialWrite) ?? false;
|
||||
const nextActions = dryRun
|
||||
? [{
|
||||
command: sessionSendConfirmCommand(command, sessionId),
|
||||
mutation: true,
|
||||
reason: "复用同一 prompt stdin,仅在确认计划后提交一次。",
|
||||
}]
|
||||
: readOnlyNextActions({ sessionId, runId, commandId, runnerJobId }, data.pollActions);
|
||||
return {
|
||||
kind: dryRun ? "SessionSendPlan" : "SessionSendResult",
|
||||
ok: raw.ok !== false,
|
||||
action: firstString(data.action, raw.action) ?? (dryRun ? "session-send-plan" : "session-send"),
|
||||
decision,
|
||||
internalCommandType: stringOrNull(data.internalCommandType),
|
||||
dryRun,
|
||||
mutation,
|
||||
partialWrite,
|
||||
reuseRun,
|
||||
createRunner,
|
||||
resources: compactResources({
|
||||
session: resourceIdentity("session", sessionId, record(data.session)),
|
||||
run: resourceIdentity("run", runId, run),
|
||||
command: resourceIdentity("command", commandId, commandResource),
|
||||
runnerJob: resourceIdentity("runnerjob", runnerJobId, runnerJob),
|
||||
dispatchIntent: resourceIdentity("dispatch-intent", dispatchIntentId, dispatchIntent),
|
||||
}),
|
||||
runnerAdmission,
|
||||
request,
|
||||
error: null,
|
||||
next: {
|
||||
actions: nextActions,
|
||||
note: dryRun
|
||||
? "dry-run 未写入 durable facts;移除 --dry-run 会由 manager 再次按 session durable state 决定 steer 或 turn。"
|
||||
: "只使用 manager 返回的资源 identity 做只读下钻;不要在客户端补做 runner 调度或状态修复。",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionSendErrorProjection(raw: Record<string, unknown>): SessionSendProjection {
|
||||
const agentrun = record(raw.agentrun);
|
||||
const nestedError = record(agentrun.error);
|
||||
const rootDetails = record(agentrun.details);
|
||||
const nestedDetails = record(nestedError.details);
|
||||
const details = { ...rootDetails, ...nestedDetails };
|
||||
const mutationSummary = record(details.mutationSummary);
|
||||
const partialWriteSummary = record(details.partialWrite);
|
||||
const resources = record(details.resources);
|
||||
const resourceIdentities = record(details.resourceIdentities);
|
||||
const session = firstRecord(record(resources.session), record(resourceIdentities.session), record(details.session));
|
||||
const run = firstRecord(record(resources.run), record(resourceIdentities.run), record(details.run));
|
||||
const commandResource = firstRecord(record(resources.command), record(resourceIdentities.command), record(details.command));
|
||||
const runnerJob = firstRecord(record(resources.runnerJob), record(resourceIdentities.runnerJob), record(details.runnerJob));
|
||||
const dispatchIntent = firstRecord(record(resources.dispatchIntent), record(resourceIdentities.dispatchIntent), record(commandResource.dispatchIntent), record(details.dispatchIntent));
|
||||
const runnerAdmission = compactRunnerAdmission(firstRecord(record(details.runnerAdmission), record(agentrun.runnerAdmission)));
|
||||
const sessionId = firstString(details.sessionId, session.id, session.sessionId, record(runnerAdmission).sessionId);
|
||||
const runId = firstString(details.runId, run.id, run.runId, record(runnerAdmission).runId);
|
||||
const commandId = firstString(details.commandId, commandResource.id, commandResource.commandId, record(runnerAdmission).commandId);
|
||||
const runnerJobId = firstString(details.runnerJobId, runnerJob.id, runnerJob.runnerJobId, record(runnerAdmission).plannedRunnerJobId, dispatchIntent.runnerJobId);
|
||||
const dispatchIntentId = firstString(details.dispatchIntentId, dispatchIntent.id, record(runnerAdmission).dispatchIntentId);
|
||||
const typedFailureKind = firstString(nestedError.failureKind, details.failureKind, agentrun.failureKind);
|
||||
const code = firstString(details.code, nestedError.code, agentrun.code);
|
||||
const reason = firstString(details.reason, details.degradedReason, details.changeReason, record(runnerAdmission).reason);
|
||||
const mutation = firstBoolean(details.mutation, mutationSummary.mutation, mutationSummary.occurred, record(runnerAdmission).mutation);
|
||||
const partialWrite = firstBoolean(
|
||||
typeof details.partialWrite === "boolean" ? details.partialWrite : null,
|
||||
partialWriteSummary.occurred,
|
||||
partialWriteSummary.value,
|
||||
mutationSummary.partialWrite,
|
||||
record(runnerAdmission).partialWrite,
|
||||
);
|
||||
const recoveryActions = Array.isArray(details.recoveryActions)
|
||||
? details.recoveryActions
|
||||
: Array.isArray(agentrun.recoveryActions)
|
||||
? agentrun.recoveryActions
|
||||
: [];
|
||||
const nextActions = readOnlyNextActions({ sessionId, runId, commandId, runnerJobId }, recoveryActions);
|
||||
const message = truncateOneLine(firstString(raw.message, nestedError.message, agentrun.message) ?? "AgentRun session send failed", 400);
|
||||
return {
|
||||
kind: "SessionSendError",
|
||||
ok: false,
|
||||
action: firstString(details.action, agentrun.action) ?? "session-send",
|
||||
decision: firstString(details.decision, agentrun.decision),
|
||||
internalCommandType: firstString(details.internalCommandType, agentrun.internalCommandType),
|
||||
dryRun: firstBoolean(details.dryRun, agentrun.dryRun) ?? false,
|
||||
mutation,
|
||||
partialWrite,
|
||||
reuseRun: firstBoolean(details.reuseRun, details.reusedRun),
|
||||
createRunner: firstBoolean(details.createRunner, details.createRunnerJob, record(runnerAdmission).state === "not-requested" ? false : null),
|
||||
resources: compactResources({
|
||||
session: resourceIdentity("session", sessionId, session),
|
||||
run: resourceIdentity("run", runId, run),
|
||||
command: resourceIdentity("command", commandId, commandResource),
|
||||
runnerJob: resourceIdentity("runnerjob", runnerJobId, runnerJob),
|
||||
dispatchIntent: resourceIdentity("dispatch-intent", dispatchIntentId, dispatchIntent),
|
||||
}),
|
||||
runnerAdmission,
|
||||
request: null,
|
||||
error: {
|
||||
failureKind: stringOrNull(raw.failureKind),
|
||||
typedFailureKind,
|
||||
code,
|
||||
reason,
|
||||
message,
|
||||
httpStatus: numberOrNull(raw.httpStatus),
|
||||
traceId: firstString(details.traceId, agentrun.traceId),
|
||||
phase: firstString(details.phase, details.stage),
|
||||
durable: firstBoolean(details.durable, record(runnerAdmission).durable),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
next: {
|
||||
actions: nextActions,
|
||||
note: partialWrite === true
|
||||
? "manager 已报告 partial write;先按资源 identity 只读核对 durable state,禁止盲目重发、客户端清理或另建 run。"
|
||||
: "先按资源 identity 核对 manager durable state;只有 manager 明确 fail-closed 或给出可重试合同后才能重发。",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSessionSendHuman(command: string, projection: SessionSendProjection): RenderedCliResult {
|
||||
const lines: string[] = [];
|
||||
if (projection.kind === "SessionSendError") {
|
||||
const error = record(projection.error);
|
||||
lines.push("Session send failed");
|
||||
lines.push(`Error: ${display(error.failureKind)}`);
|
||||
if (error.typedFailureKind !== null) lines.push(`TypedFailure: ${display(error.typedFailureKind)}`);
|
||||
if (error.code !== null) lines.push(`Code: ${display(error.code)}`);
|
||||
if (error.reason !== null) lines.push(`Reason: ${display(error.reason)}`);
|
||||
lines.push(`Message: ${display(error.message)}`);
|
||||
if (error.httpStatus !== null) lines.push(`HTTP: ${display(error.httpStatus)}`);
|
||||
} else {
|
||||
lines.push(projection.kind === "SessionSendPlan" ? "Session send plan" : "Session send submitted");
|
||||
lines.push(`OK: ${String(projection.ok)}`);
|
||||
}
|
||||
lines.push(`DryRun: ${String(projection.dryRun)}`);
|
||||
lines.push(`Decision: ${display(projection.decision)}`);
|
||||
lines.push(`CommandType: ${display(projection.internalCommandType)}`);
|
||||
lines.push(`ReuseRun: ${display(projection.reuseRun)}`);
|
||||
lines.push(`CreateRunner: ${display(projection.createRunner)}`);
|
||||
lines.push(`Mutation: ${display(projection.mutation)}`);
|
||||
lines.push(`PartialWrite: ${display(projection.partialWrite)}`);
|
||||
const resources = Object.values(projection.resources).filter((value) => value !== null).map(record);
|
||||
if (resources.length > 0) {
|
||||
lines.push("", "Resources:");
|
||||
for (const resource of resources) {
|
||||
const states = resourceStateKeys
|
||||
.map((key) => resource[key] === null || resource[key] === undefined ? null : `${key}=${display(resource[key])}`)
|
||||
.filter((value): value is string => value !== null);
|
||||
lines.push(` ${display(resource.kind)}/${display(resource.id)}${states.length === 0 ? "" : ` ${states.join(" ")}`}`);
|
||||
}
|
||||
}
|
||||
if (projection.runnerAdmission !== null) {
|
||||
const admission = projection.runnerAdmission;
|
||||
lines.push("", "RunnerAdmission:");
|
||||
lines.push(` mode=${display(admission.mode)} state=${display(admission.state)} reason=${display(admission.reason)} durable=${display(admission.durable)}`);
|
||||
}
|
||||
if (projection.next.actions.length > 0 || projection.next.note !== null) {
|
||||
lines.push("", "Next:");
|
||||
for (const action of projection.next.actions) lines.push(` ${action.command}`);
|
||||
if (projection.next.note !== null) lines.push(` Note: ${projection.next.note}`);
|
||||
}
|
||||
return { ok: projection.ok, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function compactResources(resources: Record<string, Record<string, unknown> | null>): Record<string, unknown> {
|
||||
return {
|
||||
session: resources.session,
|
||||
run: resources.run,
|
||||
command: resources.command,
|
||||
runnerJob: resources.runnerJob,
|
||||
dispatchIntent: resources.dispatchIntent,
|
||||
};
|
||||
}
|
||||
|
||||
function resourceIdentity(kind: string, id: string | null, value: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (id === null) return null;
|
||||
const identity: Record<string, unknown> = { kind, id };
|
||||
for (const key of resourceStateKeys) {
|
||||
const field = value[key];
|
||||
if (typeof field === "string" || typeof field === "number" || typeof field === "boolean") identity[key] = field;
|
||||
}
|
||||
if (kind === "dispatch-intent") {
|
||||
for (const key of ["runnerJobId", "dispatchOutcome", "actualRunnerJobId", "activeRunnerId", "attemptCount", "durable"] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || field === null) identity[key] = field;
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function compactRunnerAdmission(value: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (Object.keys(value).length === 0) return null;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of ["mode", "state", "reason", "failureKind", "sessionId", "runId", "commandId", "dispatchIntentId", "plannedRunnerJobId", "mutation", "partialWrite", "durable"] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || field === null) result[key] = typeof field === "string" ? truncateOneLine(field, 240) : field;
|
||||
}
|
||||
result.valuesPrinted = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
function compactRequest(value: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (Object.keys(value).length === 0) return null;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of ["method", "path", "commandType", "createRunnerJob", "reusedIdleRun"] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === "string" || typeof field === "boolean" || field === null) result[key] = typeof field === "string" ? truncateOneLine(field, 240) : field;
|
||||
}
|
||||
result.valuesPrinted = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveCreateRunner(input: { decision: string | null; reuseRun: boolean | null; request: Record<string, unknown>; runnerJob: Record<string, unknown>; runnerAdmission: Record<string, unknown> }): boolean | null {
|
||||
if (input.decision === "steer" || input.reuseRun === true) return false;
|
||||
const requested = booleanOrNull(input.request.createRunnerJob);
|
||||
if (requested !== null) return requested;
|
||||
const admissionState = stringOrNull(input.runnerAdmission.state);
|
||||
if (admissionState === "not-requested") return false;
|
||||
if (admissionState !== null) return true;
|
||||
if (Object.keys(input.runnerJob).length > 0) return true;
|
||||
return input.decision === null ? null : false;
|
||||
}
|
||||
|
||||
function readOnlyNextActions(ids: { sessionId: string | null; runId: string | null; commandId: string | null; runnerJobId: string | null }, rawActions: unknown): Array<{ command: string; mutation: boolean; reason: string }> {
|
||||
const commands: string[] = [];
|
||||
const add = (command: string | null): void => {
|
||||
if (command !== null && !commands.includes(command)) commands.push(command);
|
||||
};
|
||||
for (const rawAction of Array.isArray(rawActions) ? rawActions.slice(0, 12) : []) {
|
||||
if (typeof rawAction === "string") {
|
||||
if (isSafeReadCommand(rawAction)) add(rawAction);
|
||||
continue;
|
||||
}
|
||||
const action = record(rawAction);
|
||||
const operation = stringOrNull(action.operation);
|
||||
const resourceKind = stringOrNull(action.resourceKind);
|
||||
const resourceName = stringOrNull(action.resourceName);
|
||||
if (operation === "describe" && resourceKind !== null && resourceName !== null) add(`bun scripts/cli.ts agentrun describe ${resourceKind}/${resourceName}`);
|
||||
if (operation === "events") {
|
||||
const runId = firstString(action.runId, resourceKind === "run" ? resourceName : null);
|
||||
if (runId !== null) add(`bun scripts/cli.ts agentrun events run/${runId} --after-seq ${nonNegativeInteger(action.afterSeq) ?? 0} --limit ${positiveInteger(action.limit) ?? 100}`);
|
||||
}
|
||||
if (operation === "logs") {
|
||||
const sessionId = firstString(action.sessionId, resourceKind === "session" ? resourceName : null);
|
||||
if (sessionId !== null) add(`bun scripts/cli.ts agentrun logs session/${sessionId} --tail ${positiveInteger(action.limit) ?? 100}`);
|
||||
}
|
||||
}
|
||||
add(ids.sessionId === null ? null : `bun scripts/cli.ts agentrun describe session/${ids.sessionId}`);
|
||||
add(ids.runId === null ? null : `bun scripts/cli.ts agentrun events run/${ids.runId} --after-seq 0 --limit 100`);
|
||||
add(ids.runId === null || ids.commandId === null ? null : `bun scripts/cli.ts agentrun describe command/${ids.commandId} --run ${ids.runId}`);
|
||||
add(ids.runId === null || ids.runnerJobId === null ? null : `bun scripts/cli.ts agentrun describe runnerjob/${ids.runnerJobId} --run ${ids.runId}`);
|
||||
return commands.slice(0, 5).map((value) => ({ command: value, mutation: false, reason: "只读核对 manager durable facts。" }));
|
||||
}
|
||||
|
||||
function isSafeReadCommand(value: string): boolean {
|
||||
return /^bun scripts\/cli\.ts agentrun (?:get|describe|events|logs|result)\b/u.test(value.trim());
|
||||
}
|
||||
|
||||
function sessionSendConfirmCommand(command: string, sessionId: string | null): string {
|
||||
const canonical = command
|
||||
.replace(/\s+--dry-run\b/gu, "")
|
||||
.replace(/\s+(?:-o|--output)\s+(?:json|yaml|name|wide)\b/gu, "")
|
||||
.replace(/\s+(?:-o|--output)=(?:json|yaml|name|wide)\b/gu, "")
|
||||
.replace(/\s+--(?:raw|full)\b/gu, "")
|
||||
.trim();
|
||||
if (/(?:^|\s)--prompt-stdin(?:\s|$)/u.test(canonical)) return `bun scripts/cli.ts ${canonical}`;
|
||||
return `bun scripts/cli.ts agentrun send session/${sessionId ?? "<sessionId>"} --prompt-stdin`;
|
||||
}
|
||||
|
||||
function innerData(value: Record<string, unknown>): unknown {
|
||||
const data = value.data;
|
||||
if (typeof data === "object" && data !== null && !Array.isArray(data)) return data;
|
||||
return value;
|
||||
}
|
||||
|
||||
function firstRecord(...values: Record<string, unknown>[]): Record<string, unknown> {
|
||||
return values.find((value) => Object.keys(value).length > 0) ?? {};
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
const text = stringOrNull(value);
|
||||
if (text !== null) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstBoolean(...values: unknown[]): boolean | null {
|
||||
for (const value of values) {
|
||||
const boolean = booleanOrNull(value);
|
||||
if (boolean !== null) return boolean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function booleanOrNull(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number | null {
|
||||
return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : null;
|
||||
}
|
||||
|
||||
function truncateOneLine(value: string, max: number): string {
|
||||
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||
return normalized.length <= max ? normalized : `${normalized.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function display(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return truncateOneLine(String(value), 400);
|
||||
}
|
||||
|
||||
function renderSessionSendMachine(command: string, value: unknown, mode: "json" | "yaml", ok: boolean): RenderedCliResult {
|
||||
const renderedText = mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`;
|
||||
return { ok, command, renderedText, contentType: mode === "json" ? "application/json" : "application/yaml" };
|
||||
}
|
||||
Reference in New Issue
Block a user