Merge remote-tracking branch 'origin/master' into feat/2010-hwlab-release-production-lane

# Conflicts:
#	docs/MDTODO/pr-merge-driven-automatic-delivery.md
This commit is contained in:
Codex
2026-07-15 13:03:01 +02:00
11 changed files with 416 additions and 26 deletions
+14
View File
@@ -173,6 +173,12 @@ export interface AgentRunLaneSpec {
readonly nodeKubeRoute: string;
readonly nodeUnideskWorkspace: string;
readonly version: string;
readonly status: {
readonly readOnlyCapture: {
readonly timeoutMs: number;
readonly configPath: string;
};
};
readonly source: {
readonly statusMode: "host-worktree" | "k3s-git-mirror";
readonly repository: string;
@@ -677,6 +683,8 @@ function parseLanes(input: Record<string, unknown>, nodes: Record<string, AgentR
function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, unknown>, configPath: string): AgentRunLaneSpec {
const path = `${configPath}.controlPlane.lanes.${lane}`;
const status = recordField(input, "status", path);
const readOnlyCapture = recordField(status, "readOnlyCapture", `${path}.status`);
const source = recordField(input, "source", path);
const runtime = recordField(input, "runtime", path);
const ci = recordField(input, "ci", path);
@@ -694,6 +702,12 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
nodeKubeRoute: node.kubeRoute,
nodeUnideskWorkspace: node.unideskWorkspace,
version,
status: {
readOnlyCapture: {
timeoutMs: integerField(readOnlyCapture, "timeoutMs", `${path}.status.readOnlyCapture`),
configPath: `${path}.status.readOnlyCapture.timeoutMs`,
},
},
source: {
statusMode,
repository: stringField(source, "repository", `${path}.source`),
+15 -7
View File
@@ -409,13 +409,15 @@ function giteaMirrorStatusCommand(spec: AgentRunLaneSpec): string {
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>): Promise<Record<string, unknown>> {
const spec = target.spec;
const consumerId = authority.consumer.consumerId;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"]));
const pacProbe = await timedStatusStage("pac", () => runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"]));
const pacStatus = record(pacProbe.value);
const pacSummary = record(pacStatus.summary);
const pacReadObservation = record(pacStatus.observation);
const latestPipelineRun = record(pacSummary.latestPipelineRun);
const artifact = record(pacSummary.artifact);
const pacArgo = record(pacSummary.argo);
const pipelineRunName = options.pipelineRun ?? stringOrNull(latestPipelineRun.name);
const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)]));
const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs }));
const runtimePayload = captureJsonPayload(runtimeProbe.value);
const manager = record(runtimePayload.manager);
const database = record(runtimePayload.database);
@@ -479,6 +481,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
pac: {
ready: pacSummary.ready ?? false,
webhookCount: pacSummary.webhookCount ?? null,
readObservation: pacReadObservation,
latestPipelineRun: {
name: pipelineRunName,
status: latestPipelineRun.status ?? null,
@@ -524,7 +527,9 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
ok: runtimeProbe.value.exitCode === 0 && pacStatus.ok === true && blockers.length === 0,
command: "agentrun control-plane status",
mode: "pipelines-as-code-migrated-lane",
mutation: false,
configPath: target.configPath,
readCaptureBudget: spec.status.readOnlyCapture,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary,
alignment: {
@@ -539,8 +544,9 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
migration,
},
timings: {
pacMs: pacProbe.elapsedMs,
runtimeMs: runtimeProbe.elapsedMs,
totalMs: runtimeProbe.elapsedMs,
totalMs: pacProbe.elapsedMs + runtimeProbe.elapsedMs,
},
disclosure: {
output: options.full || options.raw ? "full" : "compact-summary",
@@ -636,8 +642,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
if (deliveryAuthority.kind === "pac-pr-merge") return await statusPipelinesAsCodeLane(config, options, target, deliveryAuthority);
if (deliveryAuthority.kind === "unknown") return unknownDeliveryAuthorityStatus(options, target, deliveryAuthority);
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs }));
const sourcePayload = captureJsonPayload(sourceProbe.value);
const branchTipCommit = stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead);
const initialSourceCommit = options.sourceCommit
@@ -645,8 +651,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
?? stringOrNull(sourcePayload.localHead);
const pipelineRunName = options.pipelineRun ?? (initialSourceCommit ? agentRunPipelineRunName(spec, initialSourceCommit) : null);
const [runtimeProbe, mirrorProbe] = await Promise.all([
timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)])),
timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)])),
timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })),
timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })),
]);
const runtimePayload = captureJsonPayload(runtimeProbe.value);
const mirrorPayload = captureJsonPayload(mirrorProbe.value);
@@ -897,7 +903,9 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
ok: sourceProbe.value.exitCode === 0 && runtimeProbe.value.exitCode === 0 && mirrorProbe.value.exitCode === 0 && blockers.length === 0,
command: "agentrun control-plane status",
mode: "yaml-declared-node-lane",
mutation: false,
configPath: target.configPath,
readCaptureBudget: spec.status.readOnlyCapture,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary: options.full || options.raw ? detailedSummary : commanderSummary,
alignment: {
+28 -1
View File
@@ -75,11 +75,14 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
const next = record(result.next);
const disclosure = record(result.disclosure);
const timings = record(result.timings);
const readCaptureBudget = record(result.readCaptureBudget);
const blockers = Array.isArray(summary.blockers) ? summary.blockers.map(String) : [];
const warnings = Array.isArray(summary.warnings) ? summary.warnings.map(String) : [];
const migration = record(summary.migration);
if (migration.migrated === true && migration.sourceAuthority === "gitea-pipelines-as-code") {
const pac = record(summary.pac);
const pacRead = record(pac.readObservation);
const pacFirstBreak = record(pacRead.firstBreak);
const latestPipelineRun = record(pac.latestPipelineRun);
const artifact = record(pac.artifact);
const taskRuns = Array.isArray(pac.taskRuns) ? pac.taskRuns.map(record) : [];
@@ -119,6 +122,24 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
"TASKRUN DURATIONS",
taskRows.length === 0 ? "-" : renderTable(["TASKRUN", "STATUS", "REASON", "DURATION_S"], taskRows),
"",
"PAC READ OBSERVATION",
renderTable(["STAGE", "STATUS", "ELAPSED_MS", "TIMEOUT_MS", "EXIT", "FIRST_BREAK"], [[
displayValue(pacRead.stage),
displayValue(pacRead.status),
displayValue(pacRead.elapsedMs),
displayValue(pacRead.timeoutMs),
displayValue(pacRead.exitCode),
displayValue(pacFirstBreak.code),
]]),
` budget-config: ${displayValue(pacRead.configPath)}`,
` next-read-only: ${displayValue(pacRead.next)}`,
"",
"AGENTRUN READ CAPTURE BUDGET",
renderTable(["TIMEOUT_MS", "CONFIG_PATH"], [[
displayValue(readCaptureBudget.timeoutMs),
displayValue(readCaptureBudget.configPath),
]]),
"",
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((warning) => `- ${warning}`)].join("\n"),
"",
"NEXT",
@@ -127,7 +148,7 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
` pac: ${displayValue(next.pacStatus ?? disclosure.pacStatusCommand ?? "-")}`,
` full: ${displayValue(next.statusFull ?? disclosure.fullCommand ?? "bun scripts/cli.ts agentrun control-plane status --full")}`,
"",
`TIMINGS runtime=${displayValue(timings.runtimeMs)}ms total=${displayValue(timings.totalMs)}ms`,
`TIMINGS pac=${displayValue(timings.pacMs)}ms runtime=${displayValue(timings.runtimeMs)}ms total=${displayValue(timings.totalMs)}ms`,
].filter((line): line is string => line !== null);
return renderedCliResult(result.ok !== false, "agentrun control-plane status", `${lines.join("\n")}\n`);
}
@@ -163,6 +184,12 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
],
),
"",
"AGENTRUN READ CAPTURE BUDGET",
renderTable(["TIMEOUT_MS", "CONFIG_PATH"], [[
displayValue(readCaptureBudget.timeoutMs),
displayValue(readCaptureBudget.configPath),
]]),
"",
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((warning) => `- ${warning}`)].join("\n"),
"",
"NEXT",
+2 -2
View File
@@ -137,8 +137,8 @@ export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?:
export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null;
export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
const result = await runSshCommandCapture(config, target, args);
export async function capture(config: UniDeskConfig, target: string, args: string[], options: { runtimeTimeoutMs?: number } = {}): Promise<AgentRunBridgeCaptureResult> {
const result = await runSshCommandCapture(config, target, args, undefined, options);
const plan = agentRunBridgeCapturePlan(config, target);
return attachBridgeExecution(result, { ...plan, reason: `unified-ssh-capture:${plan.reason}` });
}
@@ -5,7 +5,8 @@ import { resolve } from "node:path";
import { createRequire } from "node:module";
import { sentinelPublishImageBuildShell } from "./hwlab-node-web-sentinel-cicd-jobs";
import type { SentinelCicdState } from "./hwlab-node-web-sentinel-cicd-shared";
import { compactHistoryJson, compactStatusSummary, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code";
import { renderAgentRunControlPlaneStatusSummary } from "./agentrun/public-exposure";
import { compactHistoryJson, compactStatusSummary, pacReadOnlyCaptureObservation, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code";
const require = createRequire(import.meta.url);
const { parsePacLogRecords } = require("../native/cicd/pac-status-evaluator.cjs") as {
@@ -55,6 +56,73 @@ describe("PaC 失败证据合同", () => {
expect(compactStatus).toMatchObject({ artifact: { traceId: row.traceId, firstBreak: row.firstBreak, error: row.error } });
});
test("只读 capture timeout 保留阶段、预算、首断点与下一步", () => {
const observation = pacReadOnlyCaptureObservation({
action: "history",
targetId: "NC01",
consumerId: "agentrun-nc01-v02",
result: { exitCode: 124, stdout: "", stderr: "UNIDESK_SSH_RUNTIME_TIMEOUT" },
parsed: null,
elapsedMs: 25001,
timeoutMs: 25000,
configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs",
next: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 5",
});
expect(observation).toMatchObject({
ok: false,
status: "timeout",
target: "NC01",
consumer: "agentrun-nc01-v02",
stage: "history-remote-observe",
elapsedMs: 25001,
timeoutMs: 25000,
configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs",
exitCode: 124,
firstBreak: { code: "pac-read-timeout", phase: "remote-read" },
});
const output = renderHistory({ rows: [], config: {}, historyErrors: [], deliveryBudget: {}, observation, next: {} }).renderedText;
expect(output).toContain("READ OBSERVATION");
expect(output).toContain("history-remote-observe");
expect(output).toContain("pac-read-timeout");
expect(output).toContain("config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs");
expect(output).toContain("next-read-only:");
});
test("AgentRun status 文本披露 lane 与 PaC 两层 YAML capture 预算", () => {
const output = renderAgentRunControlPlaneStatusSummary({
ok: false,
target: { node: { id: "NC01" }, lane: "nc01-v02" },
summary: {
migration: { migrated: true, sourceAuthority: "gitea-pipelines-as-code" },
pac: {
readObservation: {
stage: "status-remote-observe",
status: "timeout",
elapsedMs: 25001,
timeoutMs: 25000,
configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs",
exitCode: 124,
firstBreak: { code: "pac-read-timeout" },
next: "bun scripts/cli.ts platform-infra pipelines-as-code status --full",
},
},
blockers: ["pac-not-ready"],
warnings: [],
nextAction: {},
},
readCaptureBudget: {
timeoutMs: 20000,
configPath: "config/agentrun.yaml.controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs",
},
next: {},
timings: {},
}).renderedText;
expect(output).toContain("PAC READ OBSERVATION");
expect(output).toContain("config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs");
expect(output).toContain("AGENTRUN READ CAPTURE BUDGET");
expect(output).toContain("config/agentrun.yaml.controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs");
});
test("feature-config warning 在 status、history、debug-step 同构有界投影", () => {
const observation = extractPacSourceObservation([
{
+166 -14
View File
@@ -110,6 +110,10 @@ export interface PacConfig {
deliveryTiming: PacDeliveryTimingPolicy;
observability: {
configRef: string;
readOnlyCapture: {
timeoutMs: number;
configPath: string;
};
tracesEndpoint: string;
serviceName: string;
propagation: string[];
@@ -293,6 +297,26 @@ interface SecretMaterial {
webhookPath: string;
}
type PacReadOnlyAction = "status" | "history" | "debug-step";
interface PacReadOnlyCaptureObservation {
readonly ok: boolean;
readonly status: "succeeded" | "timeout" | "remote-failure" | "empty-response" | "parse-failure";
readonly target: string;
readonly consumer: string;
readonly stage: string;
readonly elapsedMs: number;
readonly timeoutMs: number;
readonly configPath: string;
readonly exitCode: number;
readonly stdoutBytes: number;
readonly stderrBytes: number;
readonly partial: boolean;
readonly firstBreak: { readonly code: string; readonly phase: string };
readonly next: string;
readonly valuesPrinted: false;
}
export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return help(args[1] ?? null);
@@ -510,6 +534,7 @@ export function parsePacConfigDocument(
const display = y.objectField(root, "display", "");
const deliveryTiming = y.objectField(root, "deliveryTiming", "");
const observability = y.objectField(root, "observability", "");
const readOnlyCapture = y.objectField(observability, "readOnlyCapture", "observability");
const observabilitySampling = y.objectField(observability, "sampling", "observability");
const exporterFailure = y.objectField(observability, "exporterFailure", "observability");
const admin = y.objectField(gitea, "admin", "gitea");
@@ -549,6 +574,10 @@ export function parsePacConfigDocument(
},
observability: {
configRef: y.stringField(observability, "configRef", "observability"),
readOnlyCapture: {
timeoutMs: positiveInteger(readOnlyCapture, "timeoutMs", "observability.readOnlyCapture"),
configPath: `${configLabel}#observability.readOnlyCapture.timeoutMs`,
},
tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"),
serviceName: y.stringField(observability, "serviceName", "observability"),
propagation: y.stringArrayField(observability, "propagation", "observability"),
@@ -1350,14 +1379,15 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
const consumer = resolveConsumer(pac, options.consumerId);
const repository = resolveRepository(pac, consumer.repositoryRef);
const secrets = ensureSecrets(pac, false);
const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId);
const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""), `${stringValue(next.status)} --full`);
const { result, parsed, observation } = observed;
const summary = parsed === null ? null : statusSummary(parsed);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
const deliveryBudget = observePacStatusDeliveryBudget({ policy: pac.deliveryTiming, targetId: target.id, consumerId: consumer.id, summary });
const deliveryWarning = record(deliveryBudget.warning);
return {
ok: result.exitCode === 0 && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
ok: observation.ok && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
action: "platform-infra-pipelines-as-code-status",
mutation: false,
target: targetSummary(target),
@@ -1367,9 +1397,10 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
coverage: consumerCoverage(pac, target.id),
summary,
deliveryBudget,
observation,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
warnings: [...pac.validationWarnings, ...(Object.keys(deliveryWarning).length === 0 ? [] : [deliveryWarning])],
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
next,
};
}
@@ -1602,8 +1633,11 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef);
const secrets = ensureSecrets(pac, false);
const result = await capture(config, target.route, ["sh"], remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers));
const parsed = parseJsonOutput(result.stdout);
const next = selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId);
const observed = await capturePacReadOnly(config, pac, "history", target, firstConsumer, remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers), stringValue(record(next).history));
const { result, parsed, observation } = observed;
const remote = parsed ?? compactCapture(result, { full: true });
const historyErrors = arrayRecords(record(remote).historyErrors);
const rows = arrayRecords(record(remote).rows);
@@ -1618,7 +1652,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
.map((observation) => record(observation.warning))
.filter((warning) => Object.keys(warning).length > 0);
return {
ok: result.exitCode === 0 && parsed?.ok !== false && historyErrors.length === 0,
ok: observation.ok && parsed?.ok !== false && historyErrors.length === 0,
action: "platform-infra-pipelines-as-code-history",
mutation: false,
target: targetSummary(target),
@@ -1633,6 +1667,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
detailId: options.detailId,
rows,
deliveryBudget,
observation,
historyErrors,
warnings: [...pac.validationWarnings, ...deliveryWarnings],
controlPlane: parsed === null ? undefined : {
@@ -1646,9 +1681,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
valuesPrinted: false,
},
remote: parsed === null || options.raw ? remote : undefined,
next: selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId),
next,
};
}
@@ -1698,12 +1731,13 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
}
const consumer = resolveConsumer(pac, selection.selectedConsumerIds[0] ?? null);
const repository = resolveRepository(pac, consumer.repositoryRef);
const result = await capture(config, target.route, ["sh"], remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), ""));
const parsed = parseJsonOutput(result.stdout);
const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId);
const observed = await capturePacReadOnly(config, pac, "debug-step", target, consumer, remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), ""), stringValue(next.status));
const { result, parsed, observation } = observed;
const fixtureChecks = parsed === null ? [] : arrayRecords(parsed.checks);
const fixtureDisclosure = pacDebugFixtureDisclosure(fixtureChecks, options.detailId);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
ok: observation.ok && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-debug-step",
mutation: false,
target: targetSummary(target),
@@ -1713,12 +1747,102 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
fixtureGate: { ...fixtureDisclosure.fixtureGate, ok: parsed !== null && fixtureDisclosure.fixtureGate.ok === true },
checks: fixtureDisclosure.checks,
realRun: parsed === null ? null : parsed.realRun ?? null,
observation,
remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined,
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
next,
valuesPrinted: false,
};
}
async function capturePacReadOnly(
config: UniDeskConfig,
pac: PacConfig,
action: PacReadOnlyAction,
target: PacTarget,
consumer: PacConsumer,
script: string,
next: string,
): Promise<{ readonly result: Awaited<ReturnType<typeof capture>>; readonly parsed: Record<string, unknown> | null; readonly observation: PacReadOnlyCaptureObservation }> {
const stage = `${action}-remote-observe`;
const startedAt = Date.now();
const captureBudget = pac.observability.readOnlyCapture;
printPacReadOnlyProgress(target, consumer, stage, "started", startedAt, captureBudget);
const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: captureBudget.timeoutMs });
const parsed = parseJsonOutput(result.stdout);
const observation = pacReadOnlyCaptureObservation({
action,
targetId: target.id,
consumerId: consumer.id,
result,
parsed,
elapsedMs: Date.now() - startedAt,
timeoutMs: captureBudget.timeoutMs,
configPath: captureBudget.configPath,
next,
});
printPacReadOnlyProgress(target, consumer, stage, observation.status, startedAt, {
exitCode: result.exitCode,
firstBreak: observation.firstBreak.code,
});
return { result, parsed, observation };
}
export function pacReadOnlyCaptureObservation(options: {
readonly action: PacReadOnlyAction;
readonly targetId: string;
readonly consumerId: string;
readonly result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string };
readonly parsed: Record<string, unknown> | null;
readonly elapsedMs: number;
readonly timeoutMs: number;
readonly configPath: string;
readonly next: string;
}): PacReadOnlyCaptureObservation {
const stdoutBytes = Buffer.byteLength(options.result.stdout, "utf8");
const stderrBytes = Buffer.byteLength(options.result.stderr, "utf8");
const timedOut = options.result.exitCode === 124 || /ssh-runtime-timeout|top-level runtime limit/iu.test(options.result.stderr);
const status = timedOut
? "timeout"
: options.result.exitCode !== 0
? "remote-failure"
: options.result.stdout.trim().length === 0
? "empty-response"
: options.parsed === null
? "parse-failure"
: "succeeded";
const firstBreakCode = status === "succeeded" ? "none" : `pac-read-${status}`;
return {
ok: status === "succeeded",
status,
target: options.targetId,
consumer: options.consumerId,
stage: `${options.action}-remote-observe`,
elapsedMs: options.elapsedMs,
timeoutMs: options.timeoutMs,
configPath: options.configPath,
exitCode: options.result.exitCode,
stdoutBytes,
stderrBytes,
partial: stdoutBytes > 0 || stderrBytes > 0,
firstBreak: { code: firstBreakCode, phase: "remote-read" },
next: options.next,
valuesPrinted: false,
};
}
function printPacReadOnlyProgress(target: PacTarget, consumer: PacConsumer, stage: string, status: string, startedAt: number, detail: Record<string, unknown>): void {
process.stderr.write(`${JSON.stringify({
event: "platform-infra.pipelines-as-code.read.progress",
target: target.id,
consumer: consumer.id,
stage,
status,
elapsedMs: Date.now() - startedAt,
...detail,
valuesPrinted: false,
})}\n`);
}
export function pacDebugFixtureDisclosure(fixtureChecks: readonly Record<string, unknown>[], detailId: string | null): {
readonly fixtureGate: Record<string, unknown>;
readonly checks: readonly Record<string, unknown>[];
@@ -2342,6 +2466,7 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
capabilities: record(result.config).capabilities,
warnings: result.warnings,
deliveryBudget: result.deliveryBudget,
observation: result.observation,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -2465,6 +2590,7 @@ export function compactHistoryJson(result: Record<string, unknown>, includeTaskD
consumers: result.consumers,
detailId: result.detailId,
deliveryBudget: result.deliveryBudget,
observation: result.observation,
rows: arrayRecords(result.rows).map((row) => {
const taskRuns = record(row.taskRuns);
const artifact = record(row.artifact);
@@ -2718,6 +2844,7 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
]]),
` hint: ${compactLine(stringValue(diagnostics.hint))}`,
` pipeline-run: ${latest.name === undefined ? "-" : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${stringValue(record(result.target).id)} --id ${stringValue(latest.name)}`}`,
...renderPacReadObservation(result),
"",
"NEXT",
` full: ${stringValue(record(result.next).status)} --full`,
@@ -2912,6 +3039,7 @@ export function renderHistory(result: Record<string, unknown>): RenderedCliResul
];
}))),
...(detailId === "-" || rows.length !== 1 ? [] : renderHistoryDetail(rows[0] ?? {})),
...renderPacReadObservation(result),
"",
"NEXT",
rows.length > 0 && detailId === "-" ? ` detail: ${stringValue(record(result.next).history)} --id ${stringValue(rows[0]?.id ?? rows[0]?.pipelineRun)}` : null,
@@ -2987,6 +3115,7 @@ export function renderDebugStep(result: Record<string, unknown>): RenderedCliRes
`${stringValue(item.expectedOk)}/${stringValue(item.expectedCode)}`,
`${stringValue(item.actualOk)}/${stringValue(item.actualCode)}`,
]))),
...renderPacReadObservation(result),
"",
"NEXT",
` status: ${stringValue(record(result.next).status)}`,
@@ -2994,6 +3123,29 @@ export function renderDebugStep(result: Record<string, unknown>): RenderedCliRes
return rendered(result, "platform-infra pipelines-as-code debug-step", lines);
}
function renderPacReadObservation(result: Record<string, unknown>): string[] {
const observation = record(result.observation);
if (Object.keys(observation).length === 0) return [];
const firstBreak = record(observation.firstBreak);
return [
"",
"READ OBSERVATION",
...table(["TARGET", "CONSUMER", "STAGE", "STATUS", "ELAPSED_MS", "TIMEOUT_MS", "EXIT", "PARTIAL", "FIRST_BREAK"], [[
stringValue(observation.target),
stringValue(observation.consumer),
stringValue(observation.stage),
stringValue(observation.status),
stringValue(observation.elapsedMs),
stringValue(observation.timeoutMs),
stringValue(observation.exitCode),
boolText(observation.partial),
stringValue(firstBreak.code),
]]),
` budget-config: ${stringValue(observation.configPath)}`,
` next-read-only: ${stringValue(observation.next)}`,
];
}
function parseApplyOptions(args: string[]): ApplyOptions {
const commonArgs: string[] = [];
let confirm = false;