Merge remote-tracking branch 'origin/master'

This commit is contained in:
Codex
2026-07-11 20:30:27 +02:00
78 changed files with 4039 additions and 1548 deletions
@@ -25,6 +25,84 @@ function exactSkipMarker(records, event) {
const pacContractTasks = new Set(["plan-artifacts", "collect-artifacts", "gitops-promote"]);
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
function pipelineRunMatchesConsumer(consumerInput, itemInput) {
const consumer = record(consumerInput);
const item = record(itemInput);
const metadata = record(item.metadata);
const labels = record(metadata.labels);
const name = stringOrNull(metadata.name) || "";
const prefix = stringOrNull(consumer.pipelineRunPrefix) || "";
const pipeline = stringOrNull(consumer.pipeline) || "";
return (prefix.length > 0 && name.startsWith(prefix))
|| (pipeline.length > 0 && labels["tekton.dev/pipeline"] === pipeline)
|| (pipeline.length > 0 && labels["tekton.dev/pipelineName"] === pipeline);
}
function classifyPacPipelineRun(consumerInput, itemInput) {
const consumer = record(consumerInput);
const item = record(itemInput);
const metadata = record(item.metadata);
const labels = record(metadata.labels);
const annotations = record(metadata.annotations);
const candidate = pipelineRunMatchesConsumer(consumer, item);
const eventType = firstString(
labels["pipelinesascode.tekton.dev/event-type"],
annotations["pipelinesascode.tekton.dev/event-type"],
);
const repository = firstString(
labels["pipelinesascode.tekton.dev/repository"],
annotations["pipelinesascode.tekton.dev/repository"],
);
const managedBy = stringOrNull(labels["app.kubernetes.io/managed-by"]);
const provider = stringOrNull(annotations["pipelinesascode.tekton.dev/git-provider"]);
const expectedRepository = stringOrNull(consumer.repository);
const outer = candidate
&& eventType === "push"
&& expectedRepository !== null
&& repository === expectedRepository
&& managedBy === "pipelinesascode.tekton.dev"
&& provider === "gitea";
const inner = candidate
&& !outer
&& labels["app.kubernetes.io/part-of"] === "hwlab-web-probe-sentinel"
&& labels["unidesk.ai/ci-system"] === "tekton"
&& annotations["unidesk.ai/source-authority"] === "gitea-snapshot"
&& typeof annotations["unidesk.ai/publish-gitops"] === "string";
const deliveryClass = outer
? "outer-pac-event"
: inner
? "inner-deterministic-publish"
: "unknown";
const reason = outer
? "observed-pac-push-metadata-matches-consumer"
: inner
? "deterministic-sentinel-publish-without-proven-parent"
: candidate
? "consumer-candidate-lacks-classification-evidence"
: "not-a-consumer-candidate";
return {
deliveryClass,
reason,
candidate,
deliveryAuthorityEligible: outer,
deliveryOwner: outer ? "pac-controller-observed" : null,
executionOwner: inner ? "tekton-child-unattached" : null,
parentPipelineRun: null,
parentRelation: inner ? "unproven" : null,
metadataObservationOnly: outer,
admissionProvenanceVerified: false,
valuesPrinted: false,
};
}
function taskTerminalRecord(value) {
const taskRun = record(value);
const metadata = record(taskRun.metadata);
@@ -728,10 +806,12 @@ function runPacStatusFixtureChecks() {
}
module.exports = {
classifyPacPipelineRun,
evaluatePacStatus,
extractPacArtifactEvidence,
extractPacSourceObservation,
parsePacLogRecords,
runPacStatusFixtureChecks,
pipelineRunMatchesConsumer,
taskTerminalRecord,
};
+106 -31
View File
@@ -33,6 +33,12 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import {
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "../cicd-delivery-authority";
import type { CleanupReleasedPvOptions, CleanupRunnersOptions, CleanupRunsOptions, CleanupSessionPvcsOptions, ConfirmOptions, GitMirrorOptions, LaneConfirmOptions, RefreshOptions, SecretSyncOptions, StatusOptions } from "./options";
import { agentRunControlPlaneStatusCommand } from "./public-exposure";
@@ -258,11 +264,37 @@ export function positiveIntegerOption(args: string[], name: string, defaultValue
export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOptions): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const statusCommand = `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`;
const next = deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: statusCommand,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${spec.nodeId} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
status: statusCommand,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
platformMaintenanceHelp: "bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
valuesPrinted: false,
};
return {
ok: true,
command: "agentrun control-plane plan",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
plannedChecks: [
"source-branch-exists",
@@ -278,12 +310,7 @@ export async function controlPlanePlan(_config: UniDeskConfig, options: StatusOp
mutation: false,
note: "plan/status are read-only. Long writes must use controlled AgentRun/Platform DB commands and this YAML target.",
},
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null,
postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null,
controlPlaneApply: `bun scripts/cli.ts agentrun control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --dry-run`,
},
next,
valuesPrinted: false,
};
}
@@ -345,29 +372,18 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
return await statusYamlLane(config, options, resolveAgentRunLaneTarget(options));
}
function isPipelinesAsCodeMigratedLane(spec: AgentRunLaneSpec): boolean {
return spec.source.remote.includes("gitea-http.")
|| spec.source.remote.includes("/mirrors/")
|| spec.gitMirror.readUrl.includes("gitea-http.")
|| spec.gitops.repoURL.includes("gitea-http.")
|| spec.gitMirror.readUrl.includes("/mirrors/");
}
function pacStatusCommand(spec: AgentRunLaneSpec, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${pacConsumerId(spec)}${full ? " --full" : ""}`;
}
function pacConsumerId(spec: AgentRunLaneSpec): string {
return `agentrun-${spec.lane.toLowerCase()}`;
function pacStatusCommand(spec: AgentRunLaneSpec, consumerId: string, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId} --consumer ${consumerId}${full ? " --full" : ""}`;
}
function giteaMirrorStatusCommand(spec: AgentRunLaneSpec): string {
return `bun scripts/cli.ts platform-infra gitea mirror status --target ${spec.nodeId}`;
}
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
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 pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", pacConsumerId(spec), "--full"]));
const consumerId = authority.consumer.consumerId;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"]));
const pacSummary = record(pacStatus.summary);
const latestPipelineRun = record(pacSummary.latestPipelineRun);
const artifact = record(pacSummary.artifact);
@@ -412,13 +428,13 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
};
const nextAction = aligned
? { code: "none", summary: "PaC/Gitea migrated lane aligned; no action required", command: null }
: blockers.includes("argo-revision-stale")
? { code: "argo-refresh", summary: "GitOps commit is published but Argo has not observed it yet", command: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm` }
: { code: "inspect-pac-status", summary: `PaC/Gitea closeout blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, true) };
: { code: "inspect-automatic-delivery", summary: `PaC/Gitea automatic delivery is blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, consumerId, true) };
const migration = {
migrated: true,
sourceAuthority: "gitea-pipelines-as-code",
triggerPath: "Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
consumerId,
configRefs: authority.consumer.configRefs,
triggerPath: "GitHub PR merge -> GitHub webhook -> Gitea mirror/snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
legacyGitMirrorDisposition: "migration-readonly",
legacyTriggerCurrentDisabled: true,
valuesPrinted: false,
@@ -426,6 +442,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
const summary = {
aligned,
runtimeAligned: runtimeAlignment.runtimeAligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -486,6 +503,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
summary,
alignment: {
aligned,
deliveryAuthority: authority,
blockers,
warnings: [],
sourceCommit,
@@ -504,14 +522,15 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
raw: options.raw,
legacyGitMirror: "migration-readonly",
fullCommand: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatusCommand: pacStatusCommand(spec, true),
pacStatusCommand: pacStatusCommand(spec, consumerId, true),
},
next: {
action: nextAction,
pacStatus: pacStatusCommand(spec),
statusFull: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatus: pacStatusCommand(spec, consumerId),
pacHistory: record(pacReadOnlyNext(spec.nodeId, consumerId)).history,
giteaMirrorStatus: giteaMirrorStatusCommand(spec),
refresh: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
triggerCurrent: null,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
@@ -534,9 +553,62 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
return result;
}
function unknownDeliveryAuthorityStatus(options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }, authority: Extract<CicdDeliveryAuthority, { kind: "unknown" }>): Record<string, unknown> {
const spec = target.spec;
const statusFull = agentRunControlPlaneStatusCommand(spec, options, true);
const nextAction = {
code: "delivery-authority-unknown",
summary: "无法从 owning YAML 唯一确认 PaC 或 legacy delivery authority;已停止生成任何人工推进提示。",
command: null,
};
return {
ok: false,
command: "agentrun control-plane status",
mode: "delivery-authority-unknown",
mutation: false,
configPath: target.configPath,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
nextAction,
},
alignment: {
aligned: false,
runtimeAligned: false,
blockers: ["delivery-authority-unknown"],
warnings: [],
deliveryAuthority: authority,
},
disclosure: {
output: options.full || options.raw ? "full" : "compact-summary",
full: options.full,
raw: options.raw,
fullCommand: statusFull,
},
next: {
statusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
},
valuesPrinted: false,
};
}
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
if (isPipelinesAsCodeMigratedLane(spec)) return await statusPipelinesAsCodeLane(config, options, target);
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
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)]));
@@ -714,6 +786,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const detailedSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -777,6 +850,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
const commanderSummary = {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
@@ -803,6 +877,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
alignment: {
aligned,
runtimeAligned,
deliveryAuthority,
ciEvidenceMissing,
mirrorAlreadySynced,
blockers,
+73 -25
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { decideCicdDeliveryMutation, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { AgentRunResourceVerb, AgentRunRestCompatGroup } from "./utils";
import { controlPlaneApply, controlPlanePlan, parseCleanupReleasedPvOptions, parseCleanupRunnersOptions, parseCleanupRunsOptions, parseCleanupSessionPvcsOptions, parseConfirmOptions, parseGitMirrorOptions, parseLaneConfirmOptions, parseRefreshOptions, parseSecretSyncOptions, status } from "./control-plane";
@@ -68,25 +69,14 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun explain task",
"bun scripts/cli.ts agentrun explain session-policy",
"bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --secret-id tool-github-pr-token --confirm",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane restart --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
"bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
"bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"bun scripts/cli.ts agentrun control-plane status",
"bun scripts/cli.ts agentrun control-plane status --full",
"bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
"bun scripts/cli.ts agentrun control-plane status --source-commit <full-sha>",
"bun scripts/cli.ts agentrun control-plane expose --dry-run",
"bun scripts/cli.ts agentrun control-plane expose --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
"bun scripts/cli.ts agentrun control-plane trigger-current --confirm",
"bun scripts/cli.ts agentrun control-plane refresh --dry-run",
"bun scripts/cli.ts agentrun control-plane refresh --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
@@ -101,12 +91,12 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait",
"bun scripts/cli.ts agentrun git-mirror status",
"bun scripts/cli.ts agentrun git-mirror status --full",
"bun scripts/cli.ts agentrun git-mirror sync --confirm",
"bun scripts/cli.ts agentrun git-mirror flush --confirm",
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
],
resources: ["task/qt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and --node/--lane targets a YAML-declared runtime lane.",
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
};
}
@@ -141,11 +131,17 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs));
if (action === "trigger-current") {
const options = parseTriggerOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "trigger-current");
if (!decision.allowed) return decision.result;
const result = await triggerCurrent(config, options);
return options.full || options.raw || !isRecord(result.target) ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN TRIGGER CURRENT");
}
if (action === "refresh") {
const options = parseRefreshOptions(actionArgs);
const target = resolveAgentRunLaneTarget(options);
const decision = agentRunDeliveryMutationDecision(target, "refresh");
if (!decision.allowed) return decision.result;
const result = await refresh(config, options);
return options.full || options.raw ? result : renderAgentRunControlPlaneActionSummary(result, "AGENTRUN CONTROL-PLANE REFRESH");
}
@@ -166,7 +162,10 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "status") return await gitMirrorStatus(config, parseGitMirrorStatusOptions(actionArgs));
if (action === "sync" || action === "flush") {
const options = parseGitMirrorOptions(actionArgs);
const { spec } = resolveAgentRunLaneTarget(options);
const target = resolveAgentRunLaneTarget(options);
const { spec } = target;
const decision = agentRunDeliveryMutationDecision(target, `git-mirror-${action}`);
if (!decision.allowed) return decision.result;
if (options.confirm && !options.wait) {
return startAsyncAgentRunJob(
`agentrun_${spec.lane}_git_mirror_${action}`,
@@ -180,6 +179,29 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
return unsupported(route.canonicalArgs);
}
function agentRunDeliveryMutationDecision(
target: ReturnType<typeof resolveAgentRunLaneTarget>,
action: "trigger-current" | "refresh" | "git-mirror-sync" | "git-mirror-flush",
) {
const authority = resolveCicdDeliveryAuthority({
node: target.spec.nodeId,
lane: target.spec.lane,
sourceRepository: target.spec.source.repository,
sourceBranch: target.spec.source.branch,
declaredSourceAuthorityMode: target.spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
return decideCicdDeliveryMutation(authority, {
command: action.startsWith("git-mirror-")
? `agentrun git-mirror ${action.slice("git-mirror-".length)}`
: `agentrun control-plane ${action}`,
statusCommand: `bun scripts/cli.ts agentrun control-plane status --node ${target.spec.nodeId} --lane ${target.spec.lane}`,
action,
node: target.spec.nodeId,
lane: target.spec.lane,
});
}
export function isAgentRunRestCompatGroup(group: string | undefined): group is AgentRunRestCompatGroup {
return group === "queue" || group === "sessions" || group === "aipod-specs" || group === "aipods" || group === "runs" || group === "commands" || group === "runner";
}
@@ -285,32 +307,58 @@ export function agentRunHelpText(args: string[]): string {
}
if (verb === "explain") return agentRunExplain(kind ?? "task");
if (verb === "control-plane") {
if (kind === "legacy-cicd") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <trigger-current|refresh> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"Scope: explicit legacy/manual CI/CD only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed and must not use these commands for delivery or recovery.",
].join("\n");
}
if (kind === "platform-maintenance") {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <apply|secret-sync|restart|expose> --node <node> --lane <lane> [--dry-run|--confirm]",
"",
"Scope: explicit YAML-declared platform/configuration maintenance only.",
"These commands do not replace the PaC source delivery chain and must not be used after a source PR merge to complete or recover rollout.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun control-plane <action> [options]",
"",
"Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Default actions: plan, status, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Examples:",
" bun scripts/cli.ts agentrun control-plane plan --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane apply --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02",
" bun scripts/cli.ts agentrun control-plane secret-sync --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 10",
" bun scripts/cli.ts agentrun control-plane status",
" bun scripts/cli.ts agentrun control-plane status --pipeline-run <yaml-lane-pipelinerun>",
" bun scripts/cli.ts agentrun control-plane expose --dry-run",
" bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runners --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-session-pvcs --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
"",
"Scoped maintenance help:",
" bun scripts/cli.ts agentrun control-plane platform-maintenance --help",
" bun scripts/cli.ts agentrun control-plane legacy-cicd --help",
"PaC migrated consumer 的唯一触发是 GitHub PR merge;自动链故障只用 status/history 调查并修 owning YAML/controller/source。",
].join("\n");
}
if (verb === "provider-profile") return providerProfileHelp();
if (verb === "git-mirror") {
if (kind === "legacy-ops") {
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <sync|flush> --node <node> --lane <lane> [--dry-run|--confirm] [--wait]",
"",
"Scope: explicit legacy/manual mirror maintenance only.",
"The owning YAML delivery authority must resolve to legacy-manual. PaC migrated or unknown authority must fail closed.",
].join("\n");
}
return [
"Usage: bun scripts/cli.ts agentrun git-mirror <status|sync|flush> [--full|--raw|--confirm]",
"Usage: bun scripts/cli.ts agentrun git-mirror status [--full|--raw]",
"",
"Confirmed sync/flush returns an async job unless --wait is set.",
"PaC migrated consumer uses platform-infra Gitea/PaC status and never uses manual mirror sync/flush to complete delivery.",
"Explicit legacy operations: bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
].join("\n");
}
if (verb !== undefined && isAgentRunRestCompatGroup(verb)) {
+22 -4
View File
@@ -32,6 +32,7 @@ import {
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import { pacAutomaticDeliveryFix, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
import type { GitMirrorStatusOptions } from "./options";
import { readGitMirrorStatus } from "./rest-bridge";
@@ -473,6 +474,14 @@ export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOptions = { full: false, raw: false, node: null, lane: null }): Promise<Record<string, unknown>> {
const target = resolveAgentRunLaneTarget(options);
const spec = target.spec;
const deliveryAuthority = resolveCicdDeliveryAuthority({
node: spec.nodeId,
lane: spec.lane,
sourceRepository: spec.source.repository,
sourceBranch: spec.source.branch,
declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null,
declaredConfigRef: target.configPath,
});
const observation = await readGitMirrorStatus(config, target);
const summary = observation.summary;
return {
@@ -480,6 +489,7 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
command: "agentrun git-mirror status",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
deliveryAuthority,
target: agentRunLaneSummary(spec),
namespace: spec.gitMirror.namespace,
readUrl: spec.gitMirror.readUrl,
@@ -496,9 +506,17 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`,
rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`,
},
next: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
next: deliveryAuthority.kind === "pac-pr-merge"
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
: deliveryAuthority.kind === "unknown"
? {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
valuesPrinted: false,
}
: {
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
},
};
}
+5 -2
View File
@@ -359,7 +359,10 @@ export function unsupported(args: string[]): RenderedCliResult {
" agentrun aipod-specs|queue|runs|commands|runner|sessions",
"",
"Operations:",
" agentrun control-plane status|trigger-current|refresh",
" agentrun git-mirror status|sync|flush",
" agentrun control-plane status",
" agentrun git-mirror status",
" agentrun control-plane legacy-cicd --help",
" agentrun git-mirror legacy-ops --help",
" agentrun control-plane platform-maintenance --help",
].join("\n"));
}
@@ -0,0 +1,176 @@
// Responsibility: fail-closed delivery authority and safe navigation for the retired branch-follower surface.
import {
pacAutomaticDeliveryFix,
pacNodeReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
} from "./cicd-delivery-authority";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
export type BranchFollowerDeliveryAuthority =
| {
readonly kind: "retired-readonly";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly pacAuthorities: readonly Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>[];
readonly reason: "all-selected-followers-retired-to-pac";
}
| {
readonly kind: "legacy-manual";
readonly mutationHintsAllowed: true;
readonly followers: readonly string[];
readonly reason: "all-selected-followers-explicit-legacy-manual";
}
| {
readonly kind: "unknown";
readonly mutationHintsAllowed: false;
readonly followers: readonly string[];
readonly reason: "authority-not-declared" | "authority-mixed" | "retired-pac-identity-invalid" | "legacy-conflicts-with-pac";
readonly details: readonly Record<string, unknown>[];
};
export function branchFollowerDeliveryAuthority(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): BranchFollowerDeliveryAuthority {
const followers = selectedFollowers(registry, options);
const rows = followers.map(resolveFollowerAuthority);
if (rows.every((row) => row.kind === "retired-readonly")) {
return {
kind: "retired-readonly",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
pacAuthorities: rows.map((row) => row.pacAuthority).filter((item): item is Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> => item !== null),
reason: "all-selected-followers-retired-to-pac",
};
}
if (rows.every((row) => row.kind === "legacy-manual")) {
return {
kind: "legacy-manual",
mutationHintsAllowed: true,
followers: followers.map((follower) => follower.id),
reason: "all-selected-followers-explicit-legacy-manual",
};
}
const reason = rows.some((row) => row.reason === "retired-pac-identity-invalid")
? "retired-pac-identity-invalid"
: rows.some((row) => row.reason === "legacy-conflicts-with-pac")
? "legacy-conflicts-with-pac"
: rows.some((row) => row.kind === "unknown")
? "authority-not-declared"
: "authority-mixed";
return {
kind: "unknown",
mutationHintsAllowed: false,
followers: followers.map((follower) => follower.id),
reason,
details: rows.map((row) => ({ follower: row.follower, kind: row.kind, reason: row.reason, authority: row.pacAuthority })),
};
}
export function branchFollowerMutationGuard(
registry: BranchFollowerRegistry,
options: ParsedOptions,
): Record<string, unknown> | null {
const deliveryMutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| (options.action === "debug-step" && options.debugStep === "state-write");
const retiredCleanup = options.action === "cleanup-state";
if (!deliveryMutation && !retiredCleanup) return null;
const authority = branchFollowerDeliveryAuthority(registry, options);
if (deliveryMutation && authority.kind === "legacy-manual" && options.scope === "legacy-cicd") return null;
if (retiredCleanup && authority.kind === "retired-readonly" && options.scope === "retired-maintenance") return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: authority.kind === "retired-readonly"
? "retired-branch-follower-mutation-blocked"
: authority.kind === "legacy-manual"
? "legacy-branch-follower-scope-required"
: "branch-follower-delivery-authority-unknown",
reason: retiredCleanup
? "退役状态清理只允许通过 retired-maintenance scoped command;默认入口不修改控制器状态。"
: "该 branch-follower 已退役或 authority 无法唯一确认;CLI 不执行交付写操作。",
deliveryAuthority: authority,
next: branchFollowerReadOnlyNext(registry, options, authority),
valuesPrinted: false,
};
}
export function branchFollowerReadOnlyNext(
registry: BranchFollowerRegistry,
options: ParsedOptions,
authority = branchFollowerDeliveryAuthority(registry, options),
): Record<string, unknown> {
const suffix = options.followerId === null ? "" : ` --follower ${options.followerId}`;
const nodes = authority.kind === "retired-readonly"
? Array.from(new Set(authority.pacAuthorities.map((item) => item.consumer.node)))
: [];
const followerNext = {
status: `bun scripts/cli.ts cicd branch-follower status${suffix}`,
events: `bun scripts/cli.ts cicd branch-follower events${suffix}`,
logs: `bun scripts/cli.ts cicd branch-follower logs${suffix}`,
fixAutomaticDelivery: authority.kind === "unknown"
? pacAutomaticDeliveryFix(authorityFromUnknown(authority))
: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
};
if (nodes.length !== 1) return followerNext;
const pacNext = pacNodeReadOnlyNext(nodes[0] as string);
return {
...followerNext,
pacStatus: pacNext.status,
pacHistory: pacNext.history,
};
}
function selectedFollowers(registry: BranchFollowerRegistry, options: ParsedOptions): FollowerSpec[] {
const selected = options.followerId === null
? registry.followers
: registry.followers.filter((follower) => follower.id === options.followerId);
if (selected.length === 0) throw new Error(`unknown follower ${options.followerId ?? "-"}`);
return selected;
}
function resolveFollowerAuthority(follower: FollowerSpec): {
follower: string;
kind: "retired-readonly" | "legacy-manual" | "unknown";
reason: string;
pacAuthority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> | null;
} {
const pacAuthority = resolveCicdDeliveryAuthority({
node: follower.target.node,
lane: follower.target.lane,
sourceRepository: follower.source.repository,
sourceBranch: follower.source.branch,
});
if (follower.deliveryAuthority === "retired-readonly") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "retired-readonly", reason: "exact-pac-consumer", pacAuthority }
: { follower: follower.id, kind: "unknown", reason: "retired-pac-identity-invalid", pacAuthority: null };
}
if (follower.deliveryAuthority === "legacy-manual") {
return pacAuthority.kind === "pac-pr-merge"
? { follower: follower.id, kind: "unknown", reason: "legacy-conflicts-with-pac", pacAuthority }
: { follower: follower.id, kind: "legacy-manual", reason: "explicit-legacy-manual", pacAuthority: null };
}
return { follower: follower.id, kind: "unknown", reason: "authority-not-declared", pacAuthority: null };
}
function authorityFromUnknown(authority: Extract<BranchFollowerDeliveryAuthority, { kind: "unknown" }>): Extract<CicdDeliveryAuthority, { kind: "unknown" }> {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-identity-mismatch",
detail: authority.reason,
configRefs: ["config/cicd-branch-followers.yaml", "config/platform-infra/pipelines-as-code.yaml"],
valuesPrinted: false,
};
}
+116 -65
View File
@@ -2,7 +2,7 @@
// Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { isAbsolute } from "node:path";
import { isAbsolute, resolve } from "node:path";
import { repoRoot, rootPath, type UniDeskConfig } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
@@ -34,11 +34,16 @@ import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown";
import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown";
import { runBranchFollowerGate } from "./cicd-gates";
import { buildCicdHelp } from "./cicd-help";
import {
branchFollowerDeliveryAuthority,
branchFollowerMutationGuard,
branchFollowerReadOnlyNext,
} from "./cicd-branch-follower-authority";
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler";
import { buildFollowerTimings, compactListTimings, compactTimings, storedFollowerTimingsForStatus, timingPerformanceSummary } from "./cicd-timings";
import { timingAttributionSummary } from "./cicd-timing-attribution";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, BranchFollowerScope, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import {
arrayField,
asRecord,
@@ -56,20 +61,32 @@ const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml";
const SPEC_REF = "PJ2026-01060703";
const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower";
export function cicdHelp(): unknown {
return buildCicdHelp(DEFAULT_CONFIG_PATH, `${SPEC_REF} ${SPEC_VERSION}`);
export function cicdHelp(scope: BranchFollowerScope = "default", configPath = DEFAULT_CONFIG_PATH): unknown {
let legacyConfigured = false;
if (scope === "legacy-cicd" && isCanonicalBranchFollowerConfigPath(configPath)) {
try {
legacyConfigured = readRegistry(configPath).followers.some((follower) => follower.deliveryAuthority === "legacy-manual");
} catch {
legacyConfigured = false;
}
}
return buildCicdHelp(configPath, `${SPEC_REF} ${SPEC_VERSION}`, scope, legacyConfigured);
}
export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
const top = args[0];
if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json");
if (top !== "branch-follower") {
throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate");
throw new Error("cicd usage: cicd branch-follower status|events|logs|taskrun|runtime|debug-step");
}
const options = parseOptions(args.slice(1));
const command = commandLabel(options);
if (options.action === "help") return renderMachine(command, cicdHelp(), "json");
if (options.action === "help") return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
const configBlocked = branchFollowerConfigMutationGuard(options);
if (configBlocked !== null) return renderMachine(command, configBlocked, options.output === "yaml" ? "yaml" : "json", false);
const registry = readRegistry(options.configPath);
const blocked = branchFollowerMutationGuard(registry, options);
if (blocked !== null) return renderMachine(command, blocked, options.output === "yaml" ? "yaml" : "json", false);
switch (options.action) {
case "plan":
return renderResult(command, buildPlan(registry, options), options);
@@ -96,21 +113,60 @@ export async function runCicdCommand(_config: UniDeskConfig | null, args: string
case "gate":
return renderResult(command, await runGate(registry, options), options);
case "help":
return renderMachine(command, cicdHelp(), "json");
return renderMachine(command, cicdHelp(options.scope, options.configPath), "json");
}
}
function branchFollowerConfigMutationGuard(options: ParsedOptions): Record<string, unknown> | null {
const mutation = options.action === "apply"
|| options.action === "run-once"
|| options.action === "job"
|| options.action === "gate"
|| options.action === "cleanup-state"
|| options.action === "debug-step" && options.debugStep === "state-write";
if (!mutation || isCanonicalBranchFollowerConfigPath(options.configPath)) return null;
return {
ok: false,
action: options.action,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
reason: "branch-follower 写操作只信任仓库内固定 owning YAML;--config 仅用于只读诊断,不能声明或扩大 delivery authority。",
configPath: options.configPath,
owningConfigPath: DEFAULT_CONFIG_PATH,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
fixAutomaticDelivery: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
valuesPrinted: false,
},
valuesPrinted: false,
};
}
function isCanonicalBranchFollowerConfigPath(configPath: string): boolean {
const absolute = isAbsolute(configPath) ? resolve(configPath) : resolve(rootPath(configPath));
return absolute === resolve(rootPath(DEFAULT_CONFIG_PATH));
}
function parseOptions(args: string[]): ParsedOptions {
const actionToken = args[0];
const firstToken = args[0];
const scope: BranchFollowerScope = firstToken === "legacy-cicd" || firstToken === "retired-maintenance" ? firstToken : "default";
const actionOffset = scope === "default" ? 0 : 1;
const actionToken = args[actionOffset];
if (actionToken === undefined || isHelpToken(actionToken)) {
return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1));
const options = defaultOptions("help", []);
options.scope = scope;
return options;
}
if (!["plan", "apply", "status", "run-once", "debug-step", "cleanup-state", "events", "logs", "taskrun", "job", "runtime", "gate"].includes(actionToken)) {
throw new Error(`cicd branch-follower unknown action: ${actionToken}`);
}
const action = actionToken as BranchFollowerAction;
if (scope === "default" && action === "plan") {
throw new Error("retired branch-follower default surface is read-only; use status/events/logs/taskrun/runtime/debug-step or PaC status/history");
}
const options = defaultOptions(action, []);
const rest = args.slice(1);
options.scope = scope;
const rest = args.slice(actionOffset + 1);
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index] ?? "";
if (isHelpToken(arg)) {
@@ -205,6 +261,9 @@ function parseOptions(args: string[]): ParsedOptions {
throw new Error(`${options.action} requires --follower <id>`);
}
if (options.action === "gate" && (options.followerId === null || options.gate === null)) throw new Error("gate requires --follower <id> --gate <name>");
if (options.scope === "retired-maintenance" && options.action !== "cleanup-state" && options.action !== "help") {
throw new Error("cicd branch-follower retired-maintenance only supports cleanup-state");
}
return options;
}
@@ -225,6 +284,7 @@ function isInClusterRuntime(): boolean {
function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions {
return {
action,
scope: "default",
configPath: DEFAULT_CONFIG_PATH,
followerId: null,
all: false,
@@ -393,6 +453,7 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
return {
id: simpleId(stringField(root, "id", label), `${label}.id`),
enabled: booleanField(root, "enabled", label),
deliveryAuthority: followerDeliveryAuthority(root.deliveryAuthority),
adapter: stringField(root, "adapter", label),
description: stringField(root, "description", label),
source: {
@@ -432,6 +493,10 @@ function parseFollower(root: Record<string, unknown>, index: number): FollowerSp
};
}
function followerDeliveryAuthority(value: unknown): FollowerSpec["deliveryAuthority"] {
return value === "retired-readonly" || value === "legacy-manual" ? value : "unknown";
}
function parseNativeStatus(root: Record<string, unknown>, label: string): NativeStatusSpec {
const source = recordField(root, "source", label);
const tekton = asOptionalRecord(root.tekton);
@@ -514,6 +579,7 @@ function stringMap(root: Record<string, unknown>, label: string): Record<string,
}
function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record<string, unknown> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = selected.map((follower) => {
const branchValue = safeResolveString(follower.source.branchRef);
@@ -528,6 +594,7 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
return {
id: follower.id,
enabled: follower.enabled,
deliveryAuthority: follower.deliveryAuthority,
adapter: follower.adapter,
description: follower.description,
source: {
@@ -570,14 +637,9 @@ function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Re
loop: registry.controller.loop,
budgets: registry.controller.budgets,
},
deliveryAuthority,
followers,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
legacyStatus: "bun scripts/cli.ts cicd branch-follower status",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -619,21 +681,21 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed
sourceMode: "k8s-git-mirror-to-emptyDir",
},
command: commandCompact(result, options),
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? "<id>"}`,
dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise<Record<string, unknown>> {
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
let k8s = readK8sState(registry, options);
const beforeRefreshStateByFollower = k8s.stateByFollower;
const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0);
const refresh = wantsLive && !options.inCluster ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null;
const refresh = wantsLive && !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
if (refresh !== null) k8s = readK8sState(registry, options);
const shouldLive = wantsLive && options.inCluster;
const shouldLive = wantsLive && options.inCluster && legacyScoped;
const selected = selectFollowers(registry, options, { includeDisabled: true });
const followers = [];
const detailedFollowers = options.full;
@@ -647,21 +709,16 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti
ok: k8s.ok && followers.every((item) => item.ok !== false),
action: "status",
live: wantsLive,
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state",
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state-readonly",
liveRefresh: liveRefreshSummary(wantsLive, shouldLive, refresh),
registry: registrySummary(registry),
deliveryAuthority,
controller: controllerStatusSummary(registry, k8s),
followers,
refresh,
errors: k8s.errors,
warnings: k8s.warnings,
next: {
pacStatus: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
pacHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
legacyApply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
legacyDryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run",
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -683,10 +740,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
job: refresh,
followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false, false, before.stateByFollower[follower.id] ?? {})),
warnings: refresh.ok ? [] : [`reconcile job failed: ${refresh.message}`],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
const selected = selectFollowers(registry, options, { includeDisabled: false });
@@ -740,10 +794,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions)
followers: results,
stateWrites,
warnings: stateWriteWarnings,
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -775,12 +826,7 @@ function cleanupState(registry: BranchFollowerRegistry, options: ParsedOptions):
parsedDownstreamCliOutput: false,
command: command === null ? null : commandCompact(command, options),
errors: before.ok ? [] : [before.error],
next: {
status: "bun scripts/cli.ts cicd branch-follower status",
runOnce: options.followerId === null
? "bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait"
: `bun scripts/cli.ts cicd branch-follower run-once --follower ${options.followerId} --confirm --wait`,
},
next: branchFollowerReadOnlyNext(registry, options),
};
}
@@ -800,8 +846,12 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
}
const follower = registry.followers.find((item) => item.id === options.followerId);
if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`);
if (!options.inCluster) {
const refresh = runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true });
const deliveryAuthority = branchFollowerDeliveryAuthority(registry, options);
const legacyScoped = deliveryAuthority.kind === "legacy-manual" && options.scope === "legacy-cicd";
if (!options.inCluster || !legacyScoped) {
const refresh = !options.inCluster && legacyScoped
? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true })
: null;
const k8s = readK8sState(registry, options);
const stored = k8s.stateByFollower[follower.id] ?? {};
const storedSource = asOptionalRecord(stored.source);
@@ -809,7 +859,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
const command = asOptionalRecord(stored.command);
const native = asOptionalRecord(command?.payload);
return {
ok: refresh.ok && k8s.ok && Object.keys(stored).length > 0,
ok: (refresh === null || refresh.ok) && k8s.ok && Object.keys(stored).length > 0,
action: options.action,
follower: follower.id,
adapter: follower.adapter,
@@ -826,11 +876,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
native,
refresh,
errors: k8s.errors,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
const live = await readAdapterStatus(registry, follower, options);
@@ -850,10 +896,7 @@ async function runFollowerDrillDown(registry: BranchFollowerRegistry, options: P
message: live.message,
},
native: live.payload,
next: {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
},
next: branchFollowerReadOnlyNext(registry, options, deliveryAuthority),
};
}
@@ -2015,7 +2058,7 @@ function mergeFollowerStatus(
evidence: detailed ? evidence : null,
reconcileTimeline: detailed ? reconcileTimeline : null,
rawStateDiagnostic: detailed ? asOptionalRecord(stored.rawStateDiagnostic) : null,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
};
if (!detailed) return summary;
return {
@@ -2498,11 +2541,20 @@ function registrySummary(registry: BranchFollowerRegistry): Record<string, unkno
stateConfigMapName: registry.controller.stateConfigMapName,
leaseName: registry.controller.leaseName,
},
followers: registry.followers.map((item) => item.id),
followers: registry.followers.map((item) => ({ id: item.id, enabled: item.enabled, deliveryAuthority: item.deliveryAuthority })),
};
}
function redactCommands(follower: FollowerSpec): Record<string, string> {
if (follower.deliveryAuthority !== "legacy-manual") {
return {
plan: "retired-readonly",
status: "native:k8s-state+tekton+argocd+runtime",
trigger: "blocked-by-delivery-authority",
events: "native:k8s-readonly",
logs: "native:k8s-readonly",
};
}
return {
plan: follower.commands.plan.argv.join(" "),
status: "native:k8s-git-mirror+tekton+argocd+runtime",
@@ -2656,11 +2708,9 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
const next: Record<string, string> = {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`,
dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`,
trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`,
events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`,
logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
};
if (follower.nativeStatus.tekton !== null) next.pipelineRuns = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
if (follower.nativeStatus.argo !== null) next.argoApplication = `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`;
@@ -2804,8 +2854,9 @@ function tailText(text: string, maxChars: number): string {
}
function commandLabel(options: ParsedOptions): string {
if (options.taskRunName !== null) return "cicd branch-follower taskrun";
return `cicd branch-follower ${options.action}`;
const scope = options.scope === "default" ? "" : ` ${options.scope}`;
if (options.taskRunName !== null) return `cicd branch-follower${scope} taskrun`;
return `cicd branch-follower${scope} ${options.action}`;
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
+3 -119
View File
@@ -5,9 +5,8 @@ import { existsSync, readFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types";
import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render";
import { taskRunItems } from "./cicd-taskruns";
import { redactText, shQuote } from "./platform-infra-ops-library";
import { redactText } from "./platform-infra-ops-library";
type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult;
@@ -23,7 +22,6 @@ export interface CicdDebugDeps {
export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise<Record<string, unknown>> {
const step = options.debugStep ?? "state-read";
if (options.followerId === null) throw new Error("debug-step requires --follower <id>");
if (!options.inCluster) return runTargetDebugStepJob(registry, options, step, deps);
const selected = deps.selectFollowers(registry, options, { includeDisabled: true });
if (selected.length !== 1) throw new Error("debug-step operates on exactly one follower");
@@ -53,7 +51,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
stateWrite: { ok: false, skipped: true, reason: "stored-state-missing" },
@@ -74,7 +72,7 @@ export async function buildDebugStep(registry: BranchFollowerRegistry, options:
action: "debug-step",
step,
follower: follower.id,
execution: "k8s-native-in-cluster",
execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
dryRun: !options.confirm,
stateBefore: stateSnapshot(before, follower.id),
controllerSource,
@@ -128,123 +126,10 @@ export function renderDebugStepHuman(payload: Record<string, unknown>): string {
`controller-source: ${next?.controllerSource ?? "-"}`,
`status-read: ${next?.statusRead ?? "-"}`,
`decide: ${next?.decide ?? "-"}`,
`state-write: ${next?.stateWrite ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
function runTargetDebugStepJob(registry: BranchFollowerRegistry, options: ParsedOptions, step: BranchFollowerDebugStep, deps: CicdDebugDeps): Record<string, unknown> {
const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds;
const jobName = `${registry.controller.deploymentName}-debug-${step}-${Date.now().toString(36)}`.replace(/[^a-z0-9-]+/gu, "-").slice(0, 63);
const manifest = renderControllerDebugJob(registry, options, jobName, step, timeoutSeconds);
const manifestYaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
const script = [
"set -eu",
"tmp=$(mktemp)",
"base64 -d >\"$tmp\" <<'UNIDESK_CICD_DEBUG_JOB_B64'",
Buffer.from(manifestYaml, "utf8").toString("base64"),
"UNIDESK_CICD_DEBUG_JOB_B64",
`kubectl -n ${shQuote(registry.controller.namespace)} delete job ${shQuote(jobName)} --ignore-not-found=true >/dev/null 2>&1 || true`,
`kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`,
waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds),
].join("\n");
const result = deps.runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000);
const parsed = parseLastJsonObject(result.stdout);
const state = deps.readK8sState(registry, options);
const followerId = options.followerId ?? "";
const compact = compactTargetDebugResult(parsed);
const ok = result.exitCode === 0 && parsed?.ok !== false;
const includeTargetTail = !ok || parsed === null;
const fallbackStateAfter = stateSnapshot(state, followerId);
return {
ok,
action: "debug-step",
step,
follower: followerId,
execution: "k8s-native-debug-job",
dryRun: !options.confirm,
stateBefore: compact?.stateBefore ?? compactStateLike(asOptionalRecord(parsed?.stateBefore)),
controllerSource: compact?.controllerSource ?? asOptionalRecord(parsed?.controllerSource),
status: compact?.status ?? null,
decision: compact?.decision ?? null,
stateWrite: compact?.stateWrite ?? null,
target: {
name: jobName,
namespace: registry.controller.namespace,
exitCode: result.exitCode,
timedOut: result.timedOut,
parsed: parsed !== null,
stdoutTail: includeTargetTail ? redactText(tailText(result.stdout, 1000)) : "",
stderrTail: includeTargetTail ? redactText(tailText(result.stderr, 800)) : "",
},
stateAfter: compact?.stateAfter ?? compactStateLike(asOptionalRecord(parsed?.stateAfter) ?? fallbackStateAfter),
parsedDownstreamCliOutput: false,
next: debugNext(followerId),
};
}
function compactTargetDebugResult(parsed: Record<string, unknown> | null): Record<string, unknown> | null {
if (parsed === null) return null;
const stateBefore = asOptionalRecord(parsed.stateBefore);
const controllerSource = asOptionalRecord(parsed.controllerSource);
const status = asOptionalRecord(parsed.status);
const decision = asOptionalRecord(parsed.decision);
const stateWrite = asOptionalRecord(parsed.stateWrite);
const stateAfter = asOptionalRecord(parsed.stateAfter);
return {
ok: parsed.ok === true,
action: stringOrNull(parsed.action),
step: stringOrNull(parsed.step),
follower: stringOrNull(parsed.follower),
stateBefore: compactStateLike(stateBefore),
controllerSource: controllerSource === null ? null : {
ok: controllerSource.ok === true,
repository: stringOrNull(controllerSource.repository),
branch: stringOrNull(controllerSource.branch),
head: stringOrNull(controllerSource.head),
registrySha256: stringOrNull(controllerSource.registrySha256),
closeoutRereadMarker: controllerSource.closeoutRereadMarker === true,
branchFollowerFile: asOptionalRecord(controllerSource.branchFollowerFile),
errors: Array.isArray(controllerSource.errors) ? controllerSource.errors.map(String).slice(0, 5) : [],
},
status: status === null ? null : {
ok: status.ok === true,
phase: stringOrNull(status.phase),
observedSha: stringOrNull(status.observedSha),
targetSha: stringOrNull(status.targetSha),
aligned: status.aligned === true ? true : status.aligned === false ? false : null,
pipelineRun: stringOrNull(status.pipelineRun),
message: stringOrNull(status.message),
gates: asOptionalRecord(status.gates),
},
decision: decision === null ? null : {
phase: stringOrNull(decision.phase),
observedSha: stringOrNull(decision.observedSha),
targetSha: stringOrNull(decision.targetSha),
decision: stringOrNull(decision.decision),
totalSeconds: numberOrNull(asOptionalRecord(decision.timings)?.totalSeconds),
totalStatus: stringOrNull(asOptionalRecord(decision.timings)?.totalStatus),
},
stateWrite,
stateAfter: compactStateLike(stateAfter),
};
}
function compactStateLike(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
const metadata = asOptionalRecord(value.metadata);
return {
ok: value.ok === true,
phase: stringOrNull(value.phase),
observedSha: stringOrNull(value.observedSha),
targetSha: stringOrNull(value.targetSha),
totalSeconds: numberOrNull(value.totalSeconds),
timingStatus: stringOrNull(value.timingStatus),
resourceVersion: stringOrNull(metadata?.resourceVersion),
rawStateDiagnostic: asOptionalRecord(value.rawStateDiagnostic),
};
}
function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
const head = commandText(["git", "rev-parse", "HEAD"]);
const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
@@ -573,7 +458,6 @@ function debugNext(followerId: string): Record<string, string> {
controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`,
decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`,
stateWrite: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-write --confirm`,
};
}
+577
View File
@@ -0,0 +1,577 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { runAgentRunCommand, agentRunHelpText } from "./agentrun/entry";
import { cicdHelp as branchFollowerHelp, runCicdCommand as runBranchFollowerCommand } from "./cicd-branch-follower";
import { branchFollowerReadOnlyNext } from "./cicd-branch-follower-authority";
import { cicdGiteaActionsPocHelp, runGiteaActionsPocCommand } from "./cicd-gitea-actions-poc";
import { unsupported as unsupportedAgentRunCommand } from "./agentrun/utils";
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacReadOnlyNext,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthorityCatalog,
} from "./cicd-delivery-authority";
import { hwlabNodeHelp } from "./hwlab-node-help";
import { runHwlabNodeCommand } from "./hwlab-node/entry";
import {
guardedSentinelDeliveryAction,
runWebProbeSentinelCommand,
webProbeSentinelDeliveryAuthorityFromCatalog,
} from "./hwlab-node-web-sentinel-cicd";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { parseNodeWebProbeSentinelOptions } from "./hwlab-node/web-probe-observe";
import { nodeRuntimeVisiblePipelineRunDiagnostics, summarizeNodeRuntimeControlPlaneStatus } from "./hwlab-node/render";
import { renderControlPlaneResult, renderPublishCurrentResult } from "./hwlab-node-web-sentinel-cicd-shared";
import { resolvePacHistoryConsumerSelection, runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code";
import { giteaHelp, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { platformInfraHelp } from "./platform-infra/entry";
import {
authorizeSentinelPacInternalPublish,
readSentinelPacInternalPublishCapability,
type SentinelPacInternalPublishCapability,
} from "./sentinel-pac-execution-context";
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
const mutationCommand = /\b(?:apply|closeout|trigger-current|refresh|sync|flush|webhook-test)\b/u;
const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-evaluator.cjs") as {
classifyPacPipelineRun: (consumer: Record<string, unknown>, item: Record<string, unknown>) => Record<string, unknown>;
};
describe("YAML 组合的 CI/CD delivery authority", () => {
test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => {
const catalog = readCicdDeliveryAuthorityCatalog();
expect(catalog.consumers).toHaveLength(8);
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(8);
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01"]) {
const authority = resolveCicdDeliveryAuthority({ consumerId });
expect(authority.kind).toBe("pac-pr-merge");
expect(authority.mutationHintsAllowed).toBe(false);
}
});
test("repository identity 从通用 Git remote 解析,不依赖仓库特例", () => {
expect(gitRepositoryIdentity("git@github.com:acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("https://example.test/acme/widgets.git")).toBe("acme/widgets");
expect(gitRepositoryIdentity("not-a-repository")).toBeNull();
});
test("未知、冲突和歧义 authority 均 fail-closed", () => {
expect(resolveCicdDeliveryAuthority({ node: "NC01", lane: "v03" })).toMatchObject({
kind: "unknown",
reason: "authority-ambiguous",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03", node: "JD01" })).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({ declaredSourceAuthorityMode: "giteaSnapshot", declaredConfigRef: "fixture.yaml" })).toMatchObject({
kind: "unknown",
reason: "authority-not-declared",
mutationHintsAllowed: false,
});
expect(resolveCicdDeliveryAuthority({
node: "NC01",
lane: "nc01-v02",
sourceRepository: "acme/drifted",
sourceBranch: "v0.2",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "config/agentrun.yaml",
})).toMatchObject({
kind: "unknown",
reason: "authority-identity-mismatch",
mutationHintsAllowed: false,
});
});
test("只有显式 legacy-manual authority 允许旧 mutation 实现", () => {
const catalog: CicdDeliveryAuthorityCatalog = {
consumers: [{
consumerId: "arbitrary-pac",
node: "NODE-A",
lane: "lane-a",
namespace: "ci-a",
pipelineRunPrefix: "widgets-a-",
repositoryRef: "repo-a",
sourceRepository: "acme/widgets",
sourceBranch: "main",
sourceSnapshotPrefix: "refs/unidesk/snapshots/widgets/main",
giteaOwner: "mirrors",
giteaRepository: "acme-widgets",
giteaRepositoryUrl: "https://gitea.example.test/mirrors/acme-widgets",
pacControllerVersion: "v0.48.0",
configRefs: ["pac.yaml", "gitea.yaml"],
}],
configRefs: ["pac.yaml", "gitea.yaml"],
};
const legacy = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
node: "NODE-B",
lane: "lane-b",
sourceRepository: "acme/legacy",
sourceBranch: "release",
declaredSourceAuthorityMode: "gitMirrorSnapshot",
declaredConfigRef: "legacy-lanes.yaml",
});
expect(legacy).toMatchObject({ kind: "legacy-manual", mutationHintsAllowed: true });
expect(decideCicdDeliveryMutation(legacy, {
command: "fixture trigger-current",
statusCommand: "fixture status",
action: "trigger-current",
node: "NODE-B",
lane: "lane-b",
})).toMatchObject({ allowed: true });
});
test("只读 Next 只有 status/history 和稳定自动链修复引用", () => {
const next = pacReadOnlyNext("NC01", "hwlab-nc01-v03");
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).toBe("docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界");
expect(PAC_AUTOMATIC_DELIVERY_REFERENCE).not.toContain("issues/");
});
});
describe("migrated CLI 执行 guard 与提示清理", () => {
test("AgentRun migrated trigger/refresh/mirror mutation 在远端调用前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "nc01-v02", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "nc01-v02", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runAgentRunCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("HWLAB migrated delivery mutation 在异步 job 创建前结构化拒绝", async () => {
const commands = [
["control-plane", "trigger-current", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "refresh", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["control-plane", "source-workspace", "sync", "--node", "NC01", "--lane", "v03", "--confirm"],
["git-mirror", "sync", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
["git-mirror", "flush", "--node", "NC01", "--lane", "v03", "--confirm", "--wait"],
];
for (const args of commands) {
const result = await runHwlabNodeCommand({} as never, args) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel migrated delivery mutation 在 state load 和异步 job 前拒绝", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const sourceOverride = { sourceCommit: null, sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: null };
const commands = [
{ kind: "image", action: "build", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, sourceOverride },
{ kind: "control-plane", action: "trigger-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: false, reason: null, sourceOverride },
{ kind: "publish", action: "publish-current", node: "NC01", lane: "v03", sentinelId: null, dryRun: true, confirm: false, wait: false, timeoutSeconds: 30, rerun: false, manualRecovery: true, reason: "explicit recovery", sourceOverride },
];
for (const options of commands) {
const result = runWebProbeSentinelCommand(spec, options as never) as unknown as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, mode: "pac-pr-merge-mutation-blocked", mutation: false });
expect(Object.keys(result.next as Record<string, unknown>).sort()).toEqual([
"controlPlaneStatus",
"fixAutomaticDelivery",
"history",
"pacHistory",
"pacStatus",
"status",
]);
expect(JSON.stringify(result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 稳定 PaC consumer 漂移或缺失时保持 unknown,不降级为 legacy", () => {
const spec = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]).spec;
const catalog = readCicdDeliveryAuthorityCatalog();
const consumerId = "sentinel-nc01-v03";
const drifted: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.map((consumer) => consumer.consumerId === consumerId
? { ...consumer, node: "DRIFTED" }
: consumer),
};
const missing: CicdDeliveryAuthorityCatalog = {
...catalog,
consumers: catalog.consumers.filter((consumer) => consumer.consumerId !== consumerId),
};
for (const fixture of [drifted, missing]) {
const authority = webProbeSentinelDeliveryAuthorityFromCatalog(spec, fixture);
expect(authority).toMatchObject({ kind: "unknown", mutationHintsAllowed: false });
const decision = decideCicdDeliveryMutation(authority, {
command: "web-probe sentinel publish-current",
statusCommand: "web-probe sentinel control-plane status",
action: "sentinel-publish-current",
node: spec.nodeId,
lane: spec.lane,
});
expect(decision).toMatchObject({ allowed: false });
if (!decision.allowed) expect(JSON.stringify(decision.result.next)).not.toMatch(mutationCommand);
}
});
test("Web sentinel 内部 publish capability 默认关闭,启用后仍因 admission provenance 缺失而拒绝", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "sentinel-nc01-v03" });
expect(authority.kind).toBe("pac-pr-merge");
const options = {
kind: "publish",
action: "publish-current",
node: "NC01",
lane: "v03",
sentinelId: "nc01-web-probe-sentinel",
dryRun: false,
confirm: true,
wait: true,
timeoutSeconds: 180,
rerun: false,
manualRecovery: false,
reason: null,
internalExecution: "pac-controller",
sourceOverride: { sourceCommit: "a".repeat(40), sourceStageRef: null, sourceMirrorCommit: null, sourceAuthority: "gitea-snapshot" },
} as const;
const capability = readSentinelPacInternalPublishCapability();
expect(capability).toMatchObject({ enabled: false, admissionProvenance: "unavailable" });
expect(authorizeSentinelPacInternalPublish(options, authority, capability)).toMatchObject({
allowed: false,
reason: "capability-disabled",
});
const enabled: SentinelPacInternalPublishCapability = { ...capability, enabled: true };
expect(authorizeSentinelPacInternalPublish(options, authority, enabled)).toMatchObject({
allowed: false,
reason: "admission-provenance-unavailable",
});
expect(authorizeSentinelPacInternalPublish({ ...options, internalExecution: null }, authority, enabled)).toMatchObject({
allowed: false,
reason: "operator-entrypoint",
});
});
test("Web sentinel 保持旧自动入口,不把 default SA、Pod env 或可写 metadata 当 creator proof", () => {
const pacYaml = readFileSync("config/platform-infra/pipelines-as-code.yaml", "utf8");
expect(pacYaml).toContain("sentinelInternalPublish:");
expect(pacYaml).toContain("enabled: false");
expect(pacYaml).toContain("issues/1769");
expect(pacYaml).toContain("service_account: default");
for (const node of ["jd01", "nc01"] as const) {
const source = readFileSync(`.tekton/web-probe-sentinel-${node}-pac.yaml`, "utf8");
expect(source).toContain("serviceAccountName: default");
expect(source).toContain("web-probe sentinel publish-current \\");
expect(source).not.toContain("pac-publish-current");
expect(source).not.toContain("UNIDESK_PAC_INTERNAL_ENTRYPOINT");
expect(source).not.toContain("UNIDESK_PAC_POD_UID");
}
const parsed = parseNodeWebProbeSentinelOptions([
"pac-publish-current",
"--node", "NC01",
"--lane", "v03",
"--sentinel", "nc01-web-probe-sentinel",
"--confirm",
"--wait",
"--source-commit", "a".repeat(40),
"--source-stage-ref", `refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01/${"a".repeat(40)}`,
"--source-authority", "gitea-snapshot",
]);
expect(parsed.sentinel).toMatchObject({ kind: "publish", internalExecution: "pac-controller", manualRecovery: false });
});
test("PaC 外层事件与内层 deterministic publish 分开归类,只有外层可驱动 delivery", () => {
const consumer = { repository: "sentinel-nc01-v03", pipelineRunPrefix: "hwlab-web-probe-sentinel-nc01-", pipeline: "hwlab-web-probe-sentinel-nc01" };
const outer = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-9h49j",
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": "sentinel-nc01-v03",
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
};
const inner = {
metadata: {
name: "hwlab-web-probe-sentinel-nc01-web-probe-sentinel-b125fb358d46",
labels: {
"app.kubernetes.io/part-of": "hwlab-web-probe-sentinel",
"unidesk.ai/ci-system": "tekton",
},
annotations: {
"unidesk.ai/source-authority": "gitea-snapshot",
"unidesk.ai/publish-gitops": "true",
},
},
};
expect(pacEvaluator.classifyPacPipelineRun(consumer, outer)).toMatchObject({
deliveryClass: "outer-pac-event",
deliveryAuthorityEligible: true,
admissionProvenanceVerified: false,
metadataObservationOnly: true,
});
expect(pacEvaluator.classifyPacPipelineRun(consumer, inner)).toMatchObject({
deliveryClass: "inner-deterministic-publish",
deliveryAuthorityEligible: false,
parentPipelineRun: null,
parentRelation: "unproven",
});
const catalog = readCicdDeliveryAuthorityCatalog();
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host"]) {
const configured = catalog.consumers.find((item) => item.consumerId === consumerId);
if (configured === undefined) throw new Error(`missing classification fixture ${consumerId}`);
const classified = pacEvaluator.classifyPacPipelineRun({
repository: configured.repositoryRef,
pipelineRunPrefix: configured.pipelineRunPrefix,
}, {
metadata: {
name: `${configured.pipelineRunPrefix}-outer-fixture`,
labels: {
"app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev",
"pipelinesascode.tekton.dev/event-type": "push",
"pipelinesascode.tekton.dev/repository": configured.repositoryRef,
},
annotations: { "pipelinesascode.tekton.dev/git-provider": "gitea" },
},
});
expect(classified).toMatchObject({ deliveryClass: "outer-pac-event", deliveryAuthorityEligible: true });
}
});
test("Web sentinel 业务观测和仪表盘动作不被 delivery guard 误阻断", () => {
for (const options of [
{ kind: "dashboard", action: "trigger" },
{ kind: "validate", action: "validate" },
{ kind: "maintenance", action: "status" },
{ kind: "control-plane", action: "status" },
{ kind: "image", action: "status" },
]) {
expect(guardedSentinelDeliveryAction(options as never)).toBeNull();
}
});
test("HWLAB degraded summary 不再返回 flush 或 triggerCurrent", () => {
const scoped = parseNodeScopedDelegatedOptions("control-plane", ["status", "--node", "NC01", "--lane", "v03"]);
const summary = summarizeNodeRuntimeControlPlaneStatus({ ok: false, degradedReason: "git-mirror-pending-flush" }, scoped);
expect(summary.nextAction).toBe("bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer hwlab-nc01-v03");
expect(summary.next).not.toHaveProperty("triggerCurrent");
expect(summary.next).not.toHaveProperty("gitMirrorFlush");
expect(JSON.stringify(summary.next)).not.toMatch(mutationCommand);
});
test("HWLAB migrated PipelineRun diagnostics 不泄漏 rerun Next 或文本提示", () => {
const authority = resolveCicdDeliveryAuthority({ consumerId: "hwlab-nc01-v03" });
const diagnostics = nodeRuntimeVisiblePipelineRunDiagnostics({
failureSummary: { nextAction: "choose controlled rerun" },
next: { rerun: "bun scripts/cli.ts hwlab nodes control-plane trigger-current --confirm --rerun" },
}, authority);
expect(diagnostics?.next).toBeNull();
expect(JSON.stringify(diagnostics)).not.toContain("trigger-current");
expect(JSON.stringify(diagnostics)).not.toContain("controlled rerun");
expect(JSON.stringify(diagnostics)).toContain("不得人工 rerun");
});
test("默认 help 只暴露观察入口,legacy/platform 写入口只在 scoped help", async () => {
const agentRunDefault = agentRunHelpText(["control-plane", "--help"]);
const hwlabDefault = JSON.stringify(hwlabNodeHelp());
const pacDefault = JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help"]));
const giteaDefault = JSON.stringify(giteaHelp());
expect(agentRunDefault).not.toMatch(mutationCommand);
expect(hwlabDefault).not.toMatch(mutationCommand);
expect(pacDefault).not.toMatch(mutationCommand);
expect(giteaDefault).not.toMatch(mutationCommand);
expect(agentRunHelpText(["control-plane", "legacy-cicd", "--help"])).toContain("trigger-current");
expect(JSON.stringify(hwlabNodeHelp("legacy-cicd"))).toContain("git-mirror flush");
expect(JSON.stringify(await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]))).toContain("pipelines-as-code apply");
expect(JSON.stringify(giteaHelp("platform-bootstrap"))).toContain("platform-infra gitea apply");
});
test("所有 PaC consumer 与 AgentRun/HWLAB plan 均只返回 authority-aware 只读 Next", async () => {
const results = [
await runAgentRunCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "nc01-v02", "--raw"]),
await runHwlabNodeCommand({} as never, ["control-plane", "plan", "--node", "NC01", "--lane", "v03", "--raw"]),
...await Promise.all(readCicdDeliveryAuthorityCatalog().consumers.map((consumer) => (
runPlatformInfraPipelinesAsCodeCommand({} as never, ["plan", "--target", consumer.node, "--consumer", consumer.consumerId, "--raw"])
))),
] as Record<string, unknown>[];
for (const result of results) {
const next = result.next as Record<string, unknown>;
expect(Object.keys(next).sort()).toEqual(["fixAutomaticDelivery", "history", "status", "valuesPrinted"]);
expect(JSON.stringify(next)).not.toMatch(mutationCommand);
}
});
test("Gitea 与 mirror 默认 plan 只返回只读 Next", async () => {
const plans = [
await runPlatformInfraGiteaCommand({} as never, ["plan", "--target", "NC01", "--raw"]),
await runPlatformInfraGiteaCommand({} as never, ["mirror", "plan", "--target", "NC01", "--raw"]),
] as Record<string, unknown>[];
for (const plan of plans) {
expect(plan.mutation).toBe(false);
expect(JSON.stringify(plan.next)).not.toMatch(mutationCommand);
expect(JSON.stringify(plan.next)).toContain("status");
expect(JSON.stringify(plan.next)).toContain(PAC_AUTOMATIC_DELIVERY_REFERENCE);
}
});
test("PaC history detail 只按唯一 consumer 生成 Next,四类前缀无默认 consumer 回退", () => {
const ids = ["sentinel-nc01-v03", "unidesk-host", "agentrun-nc01-v02", "hwlab-nc01-v03"];
const consumers = readCicdDeliveryAuthorityCatalog().consumers.filter((consumer) => ids.includes(consumer.consumerId)).map((consumer) => ({
id: consumer.consumerId,
node: consumer.node,
pipelineRunPrefix: consumer.pipelineRunPrefix,
}));
for (const consumer of consumers) {
const detailId = `${consumer.pipelineRunPrefix}-fixture`;
expect(resolvePacHistoryConsumerSelection(consumers, "NC01", null, detailId)).toEqual({
selectedConsumerIds: [consumer.id],
nextConsumerId: consumer.id,
});
}
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", null, "no-matching-pipelinerun")).toThrow("matched 0");
expect(() => resolvePacHistoryConsumerSelection([
{ id: "a", node: "NC01", pipelineRunPrefix: "overlap-" },
{ id: "b", node: "NC01", pipelineRunPrefix: "overlap-run" },
], "NC01", null, "overlap-run-1")).toThrow("matched 2");
expect(() => resolvePacHistoryConsumerSelection(consumers, "NC01", "hwlab-nc01-v03", "hwlab-web-probe-sentinel-nc01-fixture")).toThrow("belongs to sentinel-nc01-v03");
});
test("PaC、HWLAB scoped help 的真实 CLI 路由可达且默认入口不泄漏 delivery mutation", () => {
const pacDefault = runCliJson(["platform-infra", "pipelines-as-code", "--help"]);
const pacBootstrap = runCliJson(["platform-infra", "pipelines-as-code", "help", "platform-bootstrap"]);
const pacCompatibility = runCliJson(["platform-infra", "pipelines-as-code", "help", "compatibility-diagnostics"]);
const hwlabPlatform = runCliJson(["hwlab", "nodes", "control-plane", "platform-maintenance", "--help"]);
const hwlabLegacy = runCliJson(["hwlab", "nodes", "control-plane", "legacy-cicd", "--help"]);
expect(JSON.stringify(pacDefault.data)).not.toMatch(mutationCommand);
expect(JSON.stringify(pacBootstrap.data)).toContain("pipelines-as-code apply");
expect(JSON.stringify(pacCompatibility.data)).not.toMatch(/webhook-test|trigger-current|\bsync\b|\bflush\b|apply --/u);
expect((hwlabPlatform.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane platform-maintenance");
expect((hwlabLegacy.data as Record<string, unknown>).command).toBe("hwlab nodes control-plane legacy-cicd");
});
test("PaC webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["webhook-test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-pipelines-as-code-command" });
expect(JSON.stringify(result.help)).not.toContain("webhook-test");
expect(readFileSync("scripts/src/platform-infra-pipelines-as-code-remote.sh", "utf8")).not.toContain("webhook-test");
});
test("Gitea webhook-test 已从公开路由与远端执行器移除", async () => {
const result = await runPlatformInfraGiteaCommand({} as never, ["mirror", "webhook", "test", "--target", "NC01", "--confirm"]) as Record<string, unknown>;
expect(result).toMatchObject({ ok: false, error: "unsupported-platform-infra-gitea-mirror-webhook-command" });
expect(readFileSync("scripts/src/platform-infra-gitea-remote.sh", "utf8")).not.toContain("mirror-webhook-test");
});
test("Sentinel migrated 文本输出只渲染实际只读 status/history/fix,不打印空 mutation 标签", () => {
const next = {
status: "bun scripts/cli.ts web-probe sentinel control-plane status --node NC01 --lane v03",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer sentinel-nc01-v03",
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
for (const renderedText of [
renderPublishCurrentResult({ command: "fixture publish", ok: false, next }),
renderControlPlaneResult({ command: "fixture status", ok: false, next }),
]) {
expect(renderedText).toContain("status:");
expect(renderedText).toContain("history:");
expect(renderedText).toContain("fix-automatic-delivery:");
expect(renderedText).not.toMatch(/pac-closeout:|manual-recovery:|trigger-current:|apply:|sync:|flush:/u);
}
});
test("retired branch-follower 写操作和默认 cleanup 在任何远端执行前结构化拒绝", async () => {
const commands = [
["branch-follower", "apply", "--confirm", "--json"],
["branch-follower", "run-once", "--follower", "hwlab-jd01-v03", "--confirm", "--wait", "--json"],
["branch-follower", "job", "--follower", "agentrun-jd01-v02", "--job", "image-build", "--json"],
["branch-follower", "gate", "--follower", "hwlab-jd01-v03", "--gate", "runtime-closeout", "--confirm", "--json"],
["branch-follower", "debug-step", "--follower", "web-probe-sentinel-master", "--step", "state-write", "--confirm", "--json"],
["branch-follower", "cleanup-state", "--all", "--confirm", "--json"],
];
for (const args of commands) {
const rendered = await runBranchFollowerCommand(null, args);
const payload = JSON.parse(rendered.renderedText) as Record<string, unknown>;
expect(payload).toMatchObject({ ok: false, mutation: false });
expect(JSON.stringify(payload.next)).not.toMatch(mutationCommand);
}
const forged = await runBranchFollowerCommand(null, ["branch-follower", "legacy-cicd", "apply", "--config", "/tmp/forged-legacy.yaml", "--confirm", "--json"]);
expect(JSON.parse(forged.renderedText)).toMatchObject({
ok: false,
mutation: false,
mode: "noncanonical-branch-follower-config-mutation-blocked",
});
expect(Object.keys((JSON.parse(forged.renderedText) as { next: Record<string, unknown> }).next).sort()).toEqual([
"fixAutomaticDelivery",
"status",
"valuesPrinted",
]);
await expect(runBranchFollowerCommand(null, ["branch-follower", "plan", "--json"])).rejects.toThrow("default surface is read-only");
});
test("branch-follower unknown 与多 node scope 不猜测 JD01 PaC 入口", () => {
const options = { followerId: null } as ParsedOptions;
const unknownRegistry = {
followers: [{
id: "unknown-fixture",
deliveryAuthority: "unknown",
source: { repository: "acme/unknown", branch: "main" },
target: { node: "NODE-X", lane: "lane-x" },
} as FollowerSpec],
} as BranchFollowerRegistry;
const unknownNext = branchFollowerReadOnlyNext(unknownRegistry, options);
expect(Object.keys(unknownNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(unknownNext)).not.toContain("JD01");
const catalog = readCicdDeliveryAuthorityCatalog();
const consumers = ["hwlab-jd01-v03", "hwlab-nc01-v03"].map((id) => {
const consumer = catalog.consumers.find((item) => item.consumerId === id);
if (consumer === undefined) throw new Error(`missing fixture consumer ${id}`);
return {
id: `${id}-retired-follower`,
deliveryAuthority: "retired-readonly",
source: { repository: consumer.sourceRepository, branch: consumer.sourceBranch },
target: { node: consumer.node, lane: consumer.lane },
} as FollowerSpec;
});
const multiNodeNext = branchFollowerReadOnlyNext({ followers: consumers } as BranchFollowerRegistry, options);
expect(Object.keys(multiNodeNext).sort()).toEqual(["events", "fixAutomaticDelivery", "logs", "status", "valuesPrinted"]);
expect(JSON.stringify(multiNodeNext)).not.toContain("--target JD01");
});
test("retired branch-follower 与 Gitea Actions POC 默认帮助只读", async () => {
expect(JSON.stringify(branchFollowerHelp())).not.toMatch(mutationCommand);
expect(JSON.stringify(cicdGiteaActionsPocHelp())).not.toMatch(mutationCommand);
await expect(runGiteaActionsPocCommand(null, ["apply"])).rejects.toThrow("intentionally unavailable");
});
test("platform-infra 默认 delivery 帮助和 AgentRun 失败帮助不暴露人工推进", () => {
const platformHelp = platformInfraHelp() as { usage: string[] };
const deliveryUsage = platformHelp.usage.filter((line) => line.includes("gitea") || line.includes("pipelines-as-code"));
expect(JSON.stringify(deliveryUsage)).not.toMatch(mutationCommand);
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01");
expect(deliveryUsage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap");
const agentRunOperations = unsupportedAgentRunCommand(["unexpected"]).renderedText.split("Operations:")[1] ?? "";
expect(agentRunOperations).not.toMatch(mutationCommand);
const root = runCliJson(["--help"]);
expect(JSON.stringify(root)).not.toMatch(/platform-infra gitea apply|platform-infra gitea mirror sync/u);
});
});
function runCliJson(args: string[]): { ok: boolean; command: string; data: unknown } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
timeout: 15_000,
});
if (result.status !== 0) throw new Error(`CLI ${args.join(" ")} failed: ${result.stderr}`);
return JSON.parse(result.stdout) as { ok: boolean; command: string; data: unknown };
}
+388
View File
@@ -0,0 +1,388 @@
import { rootPath } from "./config";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
const giteaConfigPath = "config/platform-infra/gitea.yaml";
export const PAC_AUTOMATIC_DELIVERY_REFERENCE = "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界";
export interface PacDeliveryConsumerAuthority {
readonly consumerId: string;
readonly node: string;
readonly lane: string;
readonly namespace: string;
readonly pipelineRunPrefix: string;
readonly repositoryRef: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly giteaOwner: string;
readonly giteaRepository: string;
readonly giteaRepositoryUrl: string;
readonly pacControllerVersion: string;
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityCatalog {
readonly consumers: readonly PacDeliveryConsumerAuthority[];
readonly configRefs: readonly [string, string];
}
export interface CicdDeliveryAuthorityQuery {
readonly consumerId?: string | null;
readonly node?: string | null;
readonly lane?: string | null;
readonly sourceRepository?: string | null;
readonly sourceBranch?: string | null;
readonly declaredSourceAuthorityMode?: string | null;
readonly declaredConfigRef?: string | null;
}
export interface CicdDeliveryMutationContext {
readonly command: string;
readonly statusCommand: string;
readonly action: string;
readonly node: string;
readonly lane: string;
}
export type CicdDeliveryMutationDecision =
| {
readonly allowed: true;
readonly authority: Extract<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
}
| {
readonly allowed: false;
readonly authority: Exclude<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
readonly result: Record<string, unknown>;
};
export type CicdDeliveryAuthority =
| {
readonly kind: "pac-pr-merge";
readonly migrated: true;
readonly mutationHintsAllowed: false;
readonly consumer: PacDeliveryConsumerAuthority;
readonly reason: "exact-pac-consumer";
readonly valuesPrinted: false;
}
| {
readonly kind: "legacy-manual";
readonly migrated: false;
readonly mutationHintsAllowed: true;
readonly consumer: null;
readonly reason: "explicit-legacy-source-authority";
readonly declaredSourceAuthorityMode: string;
readonly declaredConfigRef: string;
readonly valuesPrinted: false;
}
| {
readonly kind: "unknown";
readonly migrated: false;
readonly mutationHintsAllowed: false;
readonly consumer: null;
readonly reason: "authority-config-invalid" | "authority-identity-mismatch" | "authority-not-declared" | "authority-ambiguous";
readonly detail: string;
readonly configRefs: readonly string[];
readonly valuesPrinted: false;
};
interface PacRepositoryRow {
readonly id: string;
readonly owner: string;
readonly repo: string;
readonly sourceBranch: string;
readonly sourceSnapshotPrefix: string;
readonly url: string;
}
interface GiteaRepositoryRow {
readonly targetId: string;
readonly owner: string;
readonly repo: string;
readonly sourceRepository: string;
readonly sourceBranch: string;
}
const legacyAuthorityModes = new Set(["gitMirrorSnapshot", "git-mirror-snapshot", "k8s-git-mirror-snapshot"]);
export function readCicdDeliveryAuthorityCatalog(): CicdDeliveryAuthorityCatalog {
const pacRoot = composedYaml(pacConfigPath, "platform-infra-pipelines-as-code");
const giteaRoot = composedYaml(giteaConfigPath, "platform-infra-gitea");
const release = record(pacRoot.release, `${pacConfigPath}#release`);
const pacControllerVersion = requiredString(release.version, `${pacConfigPath}#release.version`);
const pacRepositories = recordArray(pacRoot.repositories, `${pacConfigPath}#repositories`).map((item, index) => {
const path = `${pacConfigPath}#repositories[${index}]`;
const params = record(item.params, `${path}.params`);
return {
id: requiredString(item.id ?? item.name, `${path}.id`),
owner: requiredString(item.owner, `${path}.owner`),
repo: requiredString(item.repo, `${path}.repo`),
sourceBranch: requiredString(params.source_branch, `${path}.params.source_branch`),
sourceSnapshotPrefix: requiredString(params.source_snapshot_prefix, `${path}.params.source_snapshot_prefix`),
url: requiredString(item.url, `${path}.url`),
} satisfies PacRepositoryRow;
});
const sourceAuthority = record(giteaRoot.sourceAuthority, `${giteaConfigPath}#sourceAuthority`);
const giteaRepositories = recordArray(sourceAuthority.repositories, `${giteaConfigPath}#sourceAuthority.repositories`).map((item, index) => {
const path = `${giteaConfigPath}#sourceAuthority.repositories[${index}]`;
const upstream = record(item.upstream, `${path}.upstream`);
const gitea = record(item.gitea, `${path}.gitea`);
return {
targetId: requiredString(item.targetId, `${path}.targetId`),
owner: requiredString(gitea.owner, `${path}.gitea.owner`),
repo: requiredString(gitea.name, `${path}.gitea.name`),
sourceRepository: requiredString(upstream.repository, `${path}.upstream.repository`),
sourceBranch: requiredString(upstream.branch, `${path}.upstream.branch`),
} satisfies GiteaRepositoryRow;
});
const consumers = recordArray(pacRoot.consumers, `${pacConfigPath}#consumers`).map((item, index) => {
const path = `${pacConfigPath}#consumers[${index}]`;
const consumerId = requiredString(item.id, `${path}.id`);
const node = requiredString(item.node, `${path}.node`);
const lane = requiredString(item.lane, `${path}.lane`);
const namespace = requiredString(item.namespace, `${path}.namespace`);
const pipelineRunPrefix = requiredString(item.pipelineRunPrefix, `${path}.pipelineRunPrefix`);
const repositoryRef = requiredString(item.repositoryRef, `${path}.repositoryRef`);
const pacRepository = exactlyOne(
pacRepositories.filter((candidate) => same(candidate.id, repositoryRef)),
`${path}.repositoryRef=${repositoryRef}`,
);
const authorityRepository = exactlyOne(
giteaRepositories.filter((candidate) => same(candidate.targetId, node)
&& same(candidate.owner, pacRepository.owner)
&& same(candidate.repo, pacRepository.repo)
&& same(candidate.sourceBranch, pacRepository.sourceBranch)),
`${path} source authority ${node}/${pacRepository.owner}/${pacRepository.repo}@${pacRepository.sourceBranch}`,
);
return {
consumerId,
node,
lane,
namespace,
pipelineRunPrefix,
repositoryRef,
sourceRepository: authorityRepository.sourceRepository,
sourceBranch: authorityRepository.sourceBranch,
sourceSnapshotPrefix: pacRepository.sourceSnapshotPrefix,
giteaOwner: pacRepository.owner,
giteaRepository: pacRepository.repo,
giteaRepositoryUrl: pacRepository.url,
pacControllerVersion,
configRefs: [pacConfigPath, giteaConfigPath],
} satisfies PacDeliveryConsumerAuthority;
});
const duplicateIds = consumers.filter((item, index) => consumers.findIndex((candidate) => same(candidate.consumerId, item.consumerId)) !== index);
if (duplicateIds.length > 0) throw new Error(`${pacConfigPath} contains duplicate consumer ids: ${duplicateIds.map((item) => item.consumerId).join(", ")}`);
return { consumers, configRefs: [pacConfigPath, giteaConfigPath] };
}
export function resolveCicdDeliveryAuthority(query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
try {
return resolveCicdDeliveryAuthorityFromCatalog(readCicdDeliveryAuthorityCatalog(), query);
} catch (error) {
return {
kind: "unknown",
migrated: false,
mutationHintsAllowed: false,
consumer: null,
reason: "authority-config-invalid",
detail: error instanceof Error ? error.message : String(error),
configRefs: [pacConfigPath, giteaConfigPath],
valuesPrinted: false,
};
}
}
export function resolveCicdDeliveryAuthorityFromCatalog(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
const suppliedIdentity = [query.consumerId, query.node, query.lane, query.sourceRepository, query.sourceBranch]
.some((value) => normalized(value) !== null);
const candidates = suppliedIdentity ? catalog.consumers.filter((consumer) => matchesQuery(consumer, query)) : [];
const stableCandidates = stableIdentityCandidates(catalog, query);
if (candidates.length === 1) {
return {
kind: "pac-pr-merge",
migrated: true,
mutationHintsAllowed: false,
consumer: candidates[0],
reason: "exact-pac-consumer",
valuesPrinted: false,
};
}
if (candidates.length > 1) {
return unknownAuthority("authority-ambiguous", `identity matches multiple PaC consumers: ${candidates.map((item) => item.consumerId).join(", ")}`, catalog.configRefs);
}
if (suppliedIdentity && query.consumerId !== undefined && query.consumerId !== null) {
return unknownAuthority("authority-identity-mismatch", `consumer identity does not match ${query.consumerId}`, catalog.configRefs);
}
if (stableCandidates.length > 0) {
return unknownAuthority(
stableCandidates.length > 1 ? "authority-ambiguous" : "authority-identity-mismatch",
`stable PaC identity conflicts with source details: ${stableCandidates.map((item) => item.consumerId).join(", ")}`,
catalog.configRefs,
);
}
const declaredMode = normalized(query.declaredSourceAuthorityMode);
const declaredConfigRef = normalized(query.declaredConfigRef);
if (declaredMode !== null && legacyAuthorityModes.has(declaredMode) && declaredConfigRef !== null) {
return {
kind: "legacy-manual",
migrated: false,
mutationHintsAllowed: true,
consumer: null,
reason: "explicit-legacy-source-authority",
declaredSourceAuthorityMode: declaredMode,
declaredConfigRef,
valuesPrinted: false,
};
}
return unknownAuthority(
suppliedIdentity ? "authority-identity-mismatch" : "authority-not-declared",
suppliedIdentity ? "no exact PaC consumer or explicit legacy authority matched" : "delivery authority identity is not declared",
catalog.configRefs,
);
}
function stableIdentityCandidates(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): readonly PacDeliveryConsumerAuthority[] {
const consumerId = normalized(query.consumerId);
if (consumerId !== null) return catalog.consumers.filter((consumer) => same(consumer.consumerId, consumerId));
const node = normalized(query.node);
const lane = normalized(query.lane);
if (node === null || lane === null) return [];
return catalog.consumers.filter((consumer) => same(consumer.node, node) && same(consumer.lane, lane));
}
export function decideCicdDeliveryMutation(authority: CicdDeliveryAuthority, context: CicdDeliveryMutationContext): CicdDeliveryMutationDecision {
if (authority.kind === "legacy-manual") return { allowed: true, authority };
const next = authority.kind === "pac-pr-merge"
? pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId)
: {
status: context.statusCommand,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
return {
allowed: false,
authority,
result: {
ok: false,
command: context.command,
mode: authority.kind === "pac-pr-merge" ? "pac-pr-merge-mutation-blocked" : "delivery-authority-unknown",
mutation: false,
requestedAction: context.action,
node: context.node,
lane: context.lane,
reason: authority.kind === "pac-pr-merge"
? "该 lane 已迁移到 PaCGitHub PR merge 是唯一交付触发,CLI 不执行人工推进。"
: "无法从 owning YAML 唯一确认 delivery authorityCLI 已 fail-closed。",
deliveryAuthority: authority,
next,
valuesPrinted: false,
},
};
}
export function gitRepositoryIdentity(remoteUrl: string): string | null {
const value = remoteUrl.trim();
if (value.length === 0) return null;
const scp = value.match(/^[^@\s]+@[^:\s]+:([^\s?#]+?)(?:\.git)?$/u);
if (scp !== null) return normalizedRepositoryPath(scp[1]);
try {
const url = new URL(value);
return normalizedRepositoryPath(url.pathname);
} catch {
return normalizedRepositoryPath(value);
}
}
export function pacAutomaticDeliveryFix(authority: CicdDeliveryAuthority): Record<string, unknown> {
const configRefs = authority.kind === "pac-pr-merge" ? authority.consumer.configRefs : authority.kind === "unknown" ? authority.configRefs : [authority.declaredConfigRef];
return automaticDeliveryFix(configRefs, authority.kind === "unknown" ? "fix-delivery-authority" : "fix-automatic-delivery");
}
export function pacReadOnlyNext(targetId: string, consumerId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumerId}`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
export function pacNodeReadOnlyNext(targetId: string): Record<string, unknown> {
return {
status: `bun scripts/cli.ts cicd status --node ${targetId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --limit 10`,
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
valuesPrinted: false,
};
}
function automaticDeliveryFix(configRefs: readonly string[], code: "fix-automatic-delivery" | "fix-delivery-authority"): Record<string, unknown> {
return {
code,
reference: PAC_AUTOMATIC_DELIVERY_REFERENCE,
configRefs,
instruction: "修复 owning YAML、controller 或源码中的自动链缺陷,并以新的正常 GitHub PR merge 验收;不得人工补齐当前交付。",
mutation: false,
valuesPrinted: false,
};
}
function normalizedRepositoryPath(value: string): string | null {
const result = value.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").trim();
if (!/^[^/\s]+\/[^/\s]+$/u.test(result)) return null;
return result;
}
function matchesQuery(consumer: PacDeliveryConsumerAuthority, query: CicdDeliveryAuthorityQuery): boolean {
return matches(consumer.consumerId, query.consumerId)
&& matches(consumer.node, query.node)
&& matches(consumer.lane, query.lane)
&& matches(consumer.sourceRepository, query.sourceRepository)
&& matches(consumer.sourceBranch, query.sourceBranch);
}
function matches(actual: string, expected: string | null | undefined): boolean {
const value = normalized(expected);
return value === null || same(actual, value);
}
function same(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
function normalized(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const result = value.trim();
return result.length === 0 ? null : result;
}
function unknownAuthority(reason: Extract<CicdDeliveryAuthority, { kind: "unknown" }>["reason"], detail: string, configRefs: readonly string[]): CicdDeliveryAuthority {
return { kind: "unknown", migrated: false, mutationHintsAllowed: false, consumer: null, reason, detail, configRefs, valuesPrinted: false };
}
function composedYaml(path: string, kind: string): Record<string, unknown> {
return materializeYamlComposition(readYamlRecord<Record<string, unknown>>(rootPath(...path.split("/")), kind), { label: path }).value;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value.map((item, index) => record(item, `${path}[${index}]`));
}
function requiredString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}
function exactlyOne<T>(items: T[], path: string): T {
if (items.length !== 1) throw new Error(`${path} must resolve exactly one row; got ${items.length}`);
return items[0];
}
+59 -21
View File
@@ -1,36 +1,74 @@
// SPEC: PJ2026-01060703 CI/CD branch follower help text.
// Responsibility: bounded CLI help payloads for the branch-follower entrypoint.
// Responsibility: bounded, authority-scoped CLI help for the retired branch-follower entrypoint.
import type { BranchFollowerScope } from "./cicd-types";
export function buildCicdHelp(configPath: string, spec: string): unknown {
export function buildCicdHelp(
configPath: string,
spec: string,
scope: BranchFollowerScope = "default",
legacyConfigured = false,
): unknown {
if (scope === "retired-maintenance") {
return {
command: "cicd branch-follower retired-maintenance cleanup-state",
scope,
mutation: "explicit-confirm-required",
usage: [
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --dry-run",
"bun scripts/cli.ts cicd branch-follower retired-maintenance cleanup-state --all --confirm",
],
boundary: "仅清理 owning YAML 已标记 retired-readonly 的历史 follower state;不得触发、恢复或补齐交付。",
config: configPath,
spec,
};
}
if (scope === "legacy-cicd") {
return {
command: "cicd branch-follower legacy-cicd",
scope,
configured: legacyConfigured,
mutation: legacyConfigured ? "explicit-confirm-required" : false,
usage: legacyConfigured
? [
"bun scripts/cli.ts cicd branch-follower legacy-cicd plan --follower <id>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd apply --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd run-once --follower <id> --confirm --wait",
"bun scripts/cli.ts cicd branch-follower legacy-cicd debug-step --follower <id> --step state-write --confirm",
"bun scripts/cli.ts cicd branch-follower legacy-cicd job --follower <id> --job <name>",
"bun scripts/cli.ts cicd branch-follower legacy-cicd gate --follower <id> --gate <name> --confirm",
]
: [],
boundary: legacyConfigured
? "仅 owning YAML 精确声明 legacy-manual 的 follower 可执行;PaC、retired-readonly 与 unknown 一律 fail-closed。"
: "当前 owning YAML 未配置 legacy-manual follower;本 scope 不提供写入口。",
config: configPath,
spec,
};
}
return {
command: "cicd branch-follower plan|apply|status|run-once|debug-step|cleanup-state|events|logs|taskrun|job|runtime|gate",
command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step",
deprecated: true,
mode: "retired-readonly-for-jd01-consumers",
mode: "retired-readonly",
replacement: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
issue: "https://github.com/pikasTech/unidesk/issues/1560",
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
output: "默认输出文本;机器输出使用 --json、--raw 或 -o json|yaml",
usage: [
"bun scripts/cli.ts cicd branch-follower status",
"bun scripts/cli.ts cicd branch-follower status --live",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower events --follower agentrun-jd01-v02",
"bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master",
"bun scripts/cli.ts cicd branch-follower taskrun --follower hwlab-jd01-v03 --taskrun runtime-ready --logs-tail 120 --json",
"bun scripts/cli.ts cicd branch-follower job --follower agentrun-jd01-v02 --source-commit <sha> --job image-build --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate control-plane-refresh --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate git-mirror-flush --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower hwlab-jd01-v03 --gate runtime-closeout --source-commit <sha> --confirm --json",
"bun scripts/cli.ts cicd branch-follower gate --follower agentrun-jd01-v02 --gate reuse-plan --source-commit <sha> --json",
"bun scripts/cli.ts cicd branch-follower runtime --follower hwlab-jd01-v03 --json",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step controller-source",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step state-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step status-read",
"bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel-master --step decide",
],
config: configPath,
spec,
description: "Retired branch-follower controller surface for the JD01 migrated consumers. Use PaC status/history for current closeout; branch-follower commands remain only for historical state and bounded migration diagnostics.",
currentCloseout: [
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
],
description: "退役 branch-follower 的只读历史状态与有界下钻入口;当前交付只观察 PaC 自动链。",
next: {
status: "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+2 -2
View File
@@ -159,15 +159,15 @@ function missingPolicyPayload(action: string, follower: FollowerSpec, registry:
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before drill-down`,
query,
policy: null,
result: null,
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+3 -3
View File
@@ -14,7 +14,7 @@ interface NodeStatusOptions {
export function cicdNodeStatusHelp(): Record<string, unknown> {
return {
command: "cicd status --node <NODE>",
description: "Read a node-level CI/CD closeout summary for every Pipelines-as-Code consumer on the node.",
description: "只读汇总节点上每个 Pipelines-as-Code consumer 的自动交付状态。",
usage: [
"bun scripts/cli.ts cicd status --node NC01",
"bun scripts/cli.ts cicd status --node NC01 --full",
@@ -119,9 +119,9 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
"",
] : []),
"NEXT",
` refresh: ${stringValue(record(result.next).status)}`,
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
` closeout: ${stringValue(record(result.next).closeout)}`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
@@ -35,8 +35,9 @@ describe("PR 合并驱动的自动交付文档合同", () => {
test("状态与单步入口只读,自动链不通时不得人工恢复", () => {
expect(skill).toContain(
"status/history/events/logs/closeout/debug-step 可用于定位,但不得改变交付状态",
"status/history/events/logs/debug-step 可用于定位,但不得改变交付状态",
);
expect(skill).toContain("`closeout` 只属于显式 compatibility diagnostics");
expect(skill).toContain("PaC migrated consumer 禁止执行 `trigger-current`");
expect(skill).not.toContain("需要保留的旧命令只能标为 debug/test/recovery");
expect(reference).toContain("`closeout` 只保留为可选的只读历史/诊断兼容入口");
@@ -68,6 +69,31 @@ describe("PR 合并驱动的自动交付文档合同", () => {
expect(reference).toContain("不是合并后的 CI/CD 触发、同步、恢复或补跑入口");
});
test("delivery authority 由两份 YAML 组合,unknown 与 migrated 都禁止 mutation", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("config/platform-infra/gitea.yaml");
expect(document).toContain("config/platform-infra/pipelines-as-code.yaml");
expect(document).toMatch(/unknown[\s\S]*mutation=false|unknown[\s\S]*`mutation=false`/);
expect(document).toMatch(/legacy-(?:manual|cicd)|legacy\/manual/);
}
});
test("durable inbox 只证明 accepted,同 delivery refs proof 才能 committed", () => {
for (const document of [skill, reference, platform]) {
expect(document).toContain("202 Accepted");
expect(document).toContain("accepted");
expect(document).toContain("processing");
expect(document).toContain("committed");
expect(document).toContain("failed");
expect(document).toContain("deliveryId");
expect(document).toMatch(/fsync[\s\S]*atomic (?:rename|write)/);
expect(document).toMatch(/immutable snapshot[\s\S]*authority branch|authority branch[\s\S]*immutable snapshot/);
expect(document).toMatch(/重新读取 refs|refs proof/);
expect(document).toMatch(/不是.*(?:source|源码).*第二份真相|不是业务 source\/ref 的第二份真相/);
expect(document).not.toMatch(/只有 HTTP 200|返回 HTTP 200 后.*committed/);
}
});
test("可调整预算继续归属 YAML,长期规则不固化当前数值", () => {
expect(skill).toContain("wall-clock 目标及预算只由 owning YAML 声明");
expect(skill).not.toContain("低于 2 分钟");
+14 -8
View File
@@ -64,9 +64,10 @@ function renderPlanHuman(payload: Record<string, unknown>): string {
"NEXT",
`pac-status: ${next?.pacStatus ?? "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01"}`,
`pac-history: ${next?.pacHistory ?? "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10"}`,
`legacy-apply: ${next?.legacyApply ?? "-"}`,
`legacy-status: ${next?.legacyStatus ?? next?.status ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].join("\n");
}
@@ -88,7 +89,8 @@ function renderApplyHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`dry-run: ${next?.dryRun ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -148,8 +150,10 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
"NEXT",
`pac-status: bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01`,
`pac-history: bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10`,
`legacy-live-status: ${next?.liveStatus ?? "-"}`,
`legacy-dry-run: ${next?.legacyDryRun ?? next?.dryRun ?? "-"}`,
`status: ${next?.status ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
`fix-automatic-delivery: ${asOptionalRecord(next?.fixAutomaticDelivery)?.reference ?? next?.fixAutomaticDelivery ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
@@ -205,7 +209,8 @@ function renderRunOnceHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`live-status: ${next?.liveStatus ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].join("\n");
}
@@ -233,7 +238,8 @@ function renderCleanupStateHuman(payload: Record<string, unknown>): string {
"",
"NEXT",
`status: ${next?.status ?? "-"}`,
`run-once: ${next?.runOnce ?? "-"}`,
`events: ${next?.events ?? "-"}`,
`logs: ${next?.logs ?? "-"}`,
"",
].filter((line) => line !== "").join("\n");
}
+2 -2
View File
@@ -23,7 +23,7 @@ export async function runBranchFollowerTaskRunDrillDown(
follower: follower.id,
adapter: follower.adapter,
degradedReason: "drilldown-policy-missing",
message: `follower ${follower.id} registry is missing drillDown policy; apply the current config before TaskRun drill-down`,
message: `follower ${follower.id} registry is missing drillDown policy; inspect the owning YAML before TaskRun drill-down`,
query: {
taskRun,
pipelineRun: options.pipelineRunName,
@@ -35,8 +35,8 @@ export async function runBranchFollowerTaskRunDrillDown(
statusAuthority: options.inCluster ? "kubernetes-api-serviceaccount" : "target-node-kubectl-raw",
parsedDownstreamCliOutput: false,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
fixAutomaticDelivery: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界",
},
};
}
+4
View File
@@ -3,6 +3,8 @@
export type OutputMode = "human" | "json" | "yaml";
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "debug-step" | "cleanup-state" | "events" | "logs" | "taskrun" | "job" | "runtime" | "gate";
export type BranchFollowerScope = "default" | "legacy-cicd" | "retired-maintenance";
export type BranchFollowerDeclaredDeliveryAuthority = "retired-readonly" | "legacy-manual" | "unknown";
export type BranchFollowerDebugStep = "state-read" | "controller-source" | "status-read" | "decide" | "state-write";
export type BranchFollowerGate = "reuse-plan" | "ci-taskrun-plan" | "cd-rollout-plan" | "post-deploy-health" | "control-plane-refresh" | "git-mirror-flush" | "runtime-closeout";
export type BranchFollowerPhase =
@@ -19,6 +21,7 @@ export type BranchFollowerPhase =
export interface ParsedOptions {
action: BranchFollowerAction;
scope: BranchFollowerScope;
configPath: string;
followerId: string | null;
all: boolean;
@@ -54,6 +57,7 @@ export interface CommandSpec {
export interface FollowerSpec {
id: string;
enabled: boolean;
deliveryAuthority: BranchFollowerDeclaredDeliveryAuthority;
adapter: string;
description: string;
source: {
+2 -2
View File
@@ -13,7 +13,7 @@ export function cicdHelp(): unknown {
output: "text by default for subcommands; top-level help is json",
nodeStatus: {
primary: "bun scripts/cli.ts cicd status --node NC01",
note: "Use this for node-level CI/CD status. It aggregates all current Pipelines-as-Code consumers for the node, including AgentRun, HWLAB and Web sentinel.",
note: "用于只读汇总节点上的 AgentRun、HWLAB、Web sentinel 与 unidesk-host PaC consumer。",
},
migration: {
issue: "https://github.com/pikasTech/unidesk/issues/1560",
@@ -22,7 +22,7 @@ export function cicdHelp(): unknown {
primaryHistory: "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10",
archived: "cicd gitea-actions-poc",
deprecated: "cicd branch-follower",
note: "JD01 CI/CD source/trigger authority is Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime. Gitea Actions POC and branch-follower are historical or migration-only surfaces.",
note: "当前 source/trigger authority 是 GitHub PR merge -> Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtimeGitea Actions POC branch-follower 仅保留只读历史观察。",
},
subcommands: [
cicdNodeStatusHelp(),
+19 -2
View File
@@ -63,7 +63,7 @@ export function rootHelp(): unknown {
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
{ command: "cicd status --node <NODE>", description: "Show one node's CI/CD closeout summary across current Pipelines-as-Code consumers, including PipelineRun, Argo and runtime readiness." },
{ command: "cicd gitea-actions-poc plan|status", description: "Archived read-only CI/CD migration evidence for GH-1548/GH-1549; never use as a delivery or closeout path." },
{ command: "cicd branch-follower status|events|logs", description: "Archived migration diagnostics only; PaC-migrated NC01 lane must use cicd status and platform-infra pipelines-as-code closeout/status/history." },
{ command: "cicd branch-follower status|events|logs|taskrun|runtime|debug-step", description: "退役 branch-follower 的只读历史诊断;当前 lane 只使用 cicd status platform-infra pipelines-as-code status/history 观察自动链。" },
{ command: "dev-env validate|prewarm-images|worktree add", description: "Validate D601 guardrails, prewarm dev images, or create UniDesk task worktrees with .worktreecopy local config copying." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
@@ -777,6 +777,8 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --limit 10",
],
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
};
@@ -900,7 +902,18 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "gh") return ghScopedHelp(args.slice(1)) ?? ghHelp();
if (top === "cicd") return loadHelp(async () => (await import("./cicd")).cicdHelp(), cicdHelpSummary());
if (top === "agentrun") return loadHelp(async () => (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary());
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac") && args[2] === "source-artifact") return null;
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac")) {
return loadHelp(
async () => (await import("./platform-infra-pipelines-as-code")).runPlatformInfraPipelinesAsCodeCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra" && sub === "gitea") {
return loadHelp(
async () => (await import("./platform-infra-gitea")).runPlatformInfraGiteaCommand({} as never, args.slice(2)),
platformInfraHelpSummary(),
);
}
if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary());
if (top === "platform-db") return platformDbHelp();
if (top === "secrets") return secretsHelp();
@@ -909,6 +922,10 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") {
return loadHelp(async () => (await import("./hwlab-node-control-plane")).hwlabNodeControlPlaneInfraHelp(), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && (args[3] === "platform-maintenance" || args[3] === "legacy-cicd")) {
const scope = args[3];
return loadHelp(async () => (await import("./hwlab-node-help")).hwlabNodeHelp(scope), hwlabNodeHelpSummary());
}
if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "test-accounts") {
return loadHelp(async () => (await import("./hwlab-test-accounts")).hwlabTestAccountsHelp(), hwlabNodeHelpSummary());
}
+43 -11
View File
@@ -3,7 +3,38 @@
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
export function hwlabNodeHelp(): Record<string, unknown> {
export function hwlabNodeHelp(scope: "legacy-cicd" | "platform-maintenance" | null = null): Record<string, unknown> {
if (scope === "legacy-cicd") {
return {
ok: true,
command: "hwlab nodes control-plane legacy-cicd",
description: "仅用于 owning YAML 明确解析为 legacy-manual 的旧交付入口。PaC 或 unknown authority 会 fail-closed。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes git-mirror sync --node <node> --lane <lane> --confirm --wait",
"bun scripts/cli.ts hwlab nodes git-mirror flush --node <node> --lane <lane> --confirm --wait",
],
valuesPrinted: false,
};
}
if (scope === "platform-maintenance") {
return {
ok: true,
command: "hwlab nodes control-plane platform-maintenance",
description: "YAML 声明的平台配置、Secret 与容量维护;不得作为 PaC 源码交付的补偿或恢复入口。",
examples: [
"bun scripts/cli.ts hwlab nodes control-plane apply --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane public-exposure --node <node> --lane <lane> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node <node> --lane <lane> --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node <node> --lane <lane> --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes secret status --node <node> --lane <lane> --name <secret>",
],
valuesPrinted: false,
};
}
return {
ok: true,
command: "hwlab nodes",
@@ -13,12 +44,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes control-plane infra plan --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes control-plane status --node <node> --lane <lane> --json",
"bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node JD01 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node JD01 --lane v03 --min-age-minutes 30 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-released-pvs --node JD01 --lane v03 --limit 200 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-images --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane <lane>",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <node> --consumer <consumer>",
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo",
@@ -26,14 +53,16 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes test-accounts status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes observability performance-summary --node <node> --lane <lane>",
"bun scripts/cli.ts web-probe --help",
"bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
"bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
],
actions: {
"control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
"control-plane": "YAML-first node-local CI/CD status, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention domains; delivery writes are documented only by scoped help.",
"git-mirror": "Inspect or operate the selected node/lane source mirror.",
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
"hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.",
secret: "Inspect YAML-declared runtime Secrets without printing secret values; writes are documented only by platform-maintenance help.",
"test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.",
observability: "Read runtime metrics and authenticated Web Performance summaries.",
},
@@ -43,7 +72,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"control-plane cleanup-runs deletes terminal PipelineRuns; cleanup-released-pvs deletes only orphaned hwlab-ci PipelineRun PVCs with no active pod mounts and Released local-path/Delete PVs.",
"cleanup-legacy-docker-images is a transitional legacy-cache GC for Docker images matching YAML allowlisted repositories; it protects container-referenced images, does not run prune, and does not touch Docker volumes.",
"cleanup-legacy-docker-registry-volume removes only an exited legacy Docker registry container and its unique /var/lib/registry volume after the YAML-declared k8s node-local-registry is ready.",
"`trigger-current --confirm --wait` is the one-command CICD path for current node/lane runtime publish.",
"PaC consumer 只由 GitHub PR merge 触发;默认帮助只给只读 status/history,自动链故障必须修 owning YAML、controller 或源码。",
"旧交付与平台维护写入口分别只在 legacy-cicd、platform-maintenance scoped help 中展示。",
"control-plane status reads its total budget, heartbeat cadence, termination grace period, and probe switches from config/hwlab-node-control-plane.yaml; --json keeps stdout as one compact JSON document while progress remains on stderr.",
],
};
@@ -65,6 +95,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin public --target-path /projects/mdtodo --sample-interval-ms 5000",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateExistingSessionRefresh --profile pure-kafka-live --session-id ses_existing",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchKafkaDebugReplay",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchTraceReadability",
"bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx",
@@ -86,8 +117,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe gc --node JD01 --lane v03 --keep-hours 24 --confirm",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts web-probe sentinel plan --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer sentinel-jd01-v03 --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer sentinel-jd01-v03",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer sentinel-jd01-v03 --limit 10",
"bun scripts/cli.ts web-probe sentinel dashboard verify --node <node> --lane <lane> --sentinel workbench-dsflash-go-tool-call-10x",
"bun scripts/cli.ts web-probe sentinel dashboard screenshot --node <node> --lane <lane> --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel dashboard trigger --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
@@ -103,7 +134,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated JD01 formal closeout uses platform-infra pipelines-as-code, while publish-current is debug/recovery only.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views; migrated consumers are delivered only by the automatic PR-merge chain.",
},
notes: [
"Named internal/public origins, default origin, per-origin browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.",
@@ -117,6 +148,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
"Typed observe commands inherit the semantic origin selected by observe start and must not embed a replacement URL or IP.",
"validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.",
"validateExistingSessionRefresh is a YAML-profiled background command: it submits no prompt and proves an existing session keeps DOM identity while Kafka retention reaches the shared live SSE phase.",
"validateWorkbenchKafkaDebugReplay 复用 observer 当前 Workbench session,以 owning YAML 中的 topic、group prefix 和超时验证隔离 Kafka 重放,并通过精确 command id 查询结果。",
"validateWorkbenchTraceReadability 只检查当前 Workbench 会话中的产品 Trace 卡片。",
"该命令排除隔离调试面板,并分别验证运行中默认展开、source authority 与终态保留。",
@@ -176,6 +176,14 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
]]),
"",
] : []),
...(exactCommand.type === "validateExistingSessionRefresh" && exactReplayEvidence !== null ? [
...renderExistingSessionRefreshEvidence(
exactReplayEvidence,
exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256,
record(exactResult?.screenshot ?? exactErrorDetails?.screenshot)?.sha256,
),
"",
] : []),
...(exactCommand.type === "validateWorkbenchKafkaDebugReplay" && exactReplayEvidence !== null ? [
...renderWorkbenchKafkaDebugReplayEvidence(
exactReplayEvidence,
@@ -236,6 +244,69 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
return lines.join("\n");
}
function renderExistingSessionRefreshEvidence(
evidence: Record<string, unknown>,
reportSha: unknown,
screenshotSha: unknown,
): string[] {
const before = record(evidence.before);
const after = record(evidence.after);
const replay = record(evidence.refreshReplay);
const counts = record(replay?.counts);
const product = record(evidence.productSse);
const failure = nullableRecord(evidence.failure);
const traceIds = Array.isArray(evidence.traceIds) ? evidence.traceIds.slice(0, 4).join(",") : "";
const partitions = Array.isArray(replay?.topicPartitions) ? replay.topicPartitions.slice(0, 8).join(",") : "";
const barrier = Array.isArray(replay?.barrier)
? replay.barrier.slice(0, 8).map((item) => {
const row = record(item);
return `${webObserveText(row.partition)}:${webObserveText(row.endOffset)}`;
}).join(",")
: "";
return [
"Workbench 既有 Session 刷新:",
webObserveTable(["SESSION", "TRACE_IDS", "PHASE", "CODE", "MSG_BEFORE", "MSG_AFTER", "SOURCES", "OPEN", "CONNECTED", "BUSINESS", "FAILURES", "FORBIDDEN", "REPORT_SHA", "SCREENSHOT_SHA"], [[
webObserveShort(webObserveText(evidence.sessionId), 32),
webObserveShort(webObserveText(traceIds), 40),
evidence.phase,
evidence.code,
before?.messageCount,
after?.messageCount,
product?.browserSourceCount,
product?.openCount,
product?.connectedEventCount,
product?.businessEventCount,
product?.typedFailureCount,
evidence.forbiddenRequestCount,
webObserveShort(webObserveText(reportSha), 32),
webObserveShort(webObserveText(screenshotSha), 32),
]]),
"Kafka refresh handoff:",
webObserveTable(["TOPIC", "PARTITIONS", "BARRIER", "MATCHED", "REPLAYED", "BUFFERED", "BUFFERED_DELIVERED", "LIVE_DELIVERED", "DEDUPLICATED", "LIVE"], [[
webObserveShort(webObserveText(replay?.topic), 32),
webObserveShort(webObserveText(partitions), 24),
webObserveShort(webObserveText(barrier), 56),
counts?.matched,
counts?.replayed,
counts?.buffered,
counts?.bufferedDelivered,
counts?.liveDelivered,
counts?.deduplicated,
product?.livePhase,
]]),
...(failure !== null ? [
"Typed failure:",
webObserveTable(["PHASE", "CODE", "STREAM_PHASE", "STREAM_CODE", "MESSAGE"], [[
failure.phase,
failure.code,
failure.streamPhase,
failure.streamCode,
webObserveShort(webObserveText(failure.message), 96),
]]),
] : []),
];
}
function renderWorkbenchKafkaDebugReplayEvidence(
evidence: Record<string, unknown>,
reportSha: unknown,
@@ -391,6 +462,14 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(observerCommand?.type === "validateExistingSessionRefresh" && replayEvidence !== null ? [
"",
...renderExistingSessionRefreshEvidence(
replayEvidence,
result?.reportSha256 ?? details?.reportSha256,
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(observerCommand?.type === "validateWorkbenchTraceReadability" && replayEvidence !== null ? [
"",
...renderWorkbenchTraceReadabilityEvidence(
@@ -102,6 +102,7 @@ async function processCommand(command) {
expectedActionWaitMs: command.expectedActionWaitMs,
}), "sendPrompt");
case "validateRealtimeFanout": return validateRealtimeFanout(command);
case "validateExistingSessionRefresh": return validateExistingSessionRefresh(command);
case "validateWorkbenchKafkaDebugReplay": return withObserverSync(await validateWorkbenchKafkaDebugReplay(command), "validateWorkbenchKafkaDebugReplay");
case "validateWorkbenchTraceReadability": return withObserverSync(await validateWorkbenchTraceReadability(command), "validateWorkbenchTraceReadability");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer");
@@ -172,6 +172,88 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte
}, sessionId, "live", liveProfile));
});
test("existing session refresh rejects duplicate or reordered Workbench identities", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`${source}\nreturn realtimeAssertSameWorkbenchDom;`) as () => (
before: Record<string, unknown>, after: Record<string, unknown>,
) => void;
const assertSame = build();
const before = { messageCount: 2, identities: ["user:m1:", "agent:m2:t1"], missingIdentityRows: [], duplicateIdentities: [] };
assert.doesNotThrow(() => assertSame(before, { ...before }));
assert.throws(() => assertSame(before, { ...before, identities: [...before.identities].reverse() }), /identity or order changed/u);
assert.throws(() => assertSame(before, { ...before, duplicateIdentities: ["agent:m2:t1"] }), /duplicate message identities/u);
assert.throws(() => assertSame(before, { ...before, missingIdentityRows: [1] }), /without a stable message identity/u);
});
test("existing session refresh failures preserve typed phase and code", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`function errorSummary(error) { return { message: error.message }; }\n${source}\nreturn { realtimeExistingRefreshFailure, realtimeExistingRefreshStreamError };`) as () => {
realtimeExistingRefreshFailure: (phase: string, error: Error & { details?: Record<string, unknown> }) => Record<string, unknown>;
realtimeExistingRefreshStreamError: (value: Record<string, unknown>) => Error & { details?: Record<string, unknown> };
};
const helpers = build();
const error = helpers.realtimeExistingRefreshStreamError({
phase: "flushing",
error: { code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap", retryable: true },
fallback: false,
});
const failure = helpers.realtimeExistingRefreshFailure("retention-barrier", error);
assert.equal(failure.phase, "retention-barrier");
assert.equal(failure.code, "retention_barrier_failed");
assert.equal(failure.streamPhase, "flushing");
assert.equal(failure.streamCode, "workbench_kafka_refresh_barrier_gap");
assert.equal((failure.streamFailure as Record<string, unknown>).fallback, false);
assert.deepEqual(failure.valuesRedacted, true);
});
test("existing session refresh preserves rejected connected evidence and rejects missing browser replay delivery", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(
"sanitize",
`${source}\nreturn { realtimeExistingRefreshConnectedEvidence, realtimeAssertExistingRefreshDelivery };`,
) as (sanitize: <T>(value: T) => T) => {
realtimeExistingRefreshConnectedEvidence: (connected: Record<string, any>, sessionId: string, profile: Record<string, any>) => Record<string, unknown>;
realtimeAssertExistingRefreshDelivery: (browser: Record<string, unknown>, refreshReplay: Record<string, any>) => void;
};
const helpers = build((value) => value);
const sessionId = "ses_existing";
const refreshReplay = {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
};
const profile = {
expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true } },
expectedProductSse: { deliverySemantics: "kafka-retention-then-live", liveOnly: false, replay: true, replaySupported: true, lossPossible: false, refreshHandoff: true },
};
const connected = {
realtimeSource: "hwlab.event.v1",
deliverySemantics: "invalid",
liveOnly: false,
replay: true,
replaySupported: true,
lossPossible: false,
filters: { sessionId, traceId: null },
capabilities: profile.expectedKafka.capabilities,
refreshReplay,
};
assert.throws(
() => helpers.realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile),
(error: Error & { details?: Record<string, any> }) => {
assert.deepEqual(error.details?.refreshReplay?.barrier, refreshReplay.barrier);
assert.equal(error.details?.refreshReplay?.counts?.replayed, 8);
return true;
},
);
assert.doesNotThrow(() => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 9 }, refreshReplay));
assert.throws(
() => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 8 }, refreshReplay),
/fewer than replayed plus buffered delivery 9/u,
);
});
test("realtime fanout proves one formal user event through AgentRun, HWLAB Kafka, and product SSE", () => {
const source = nodeWebObserveRunnerRealtimeSource();
const build = new Function(`${source}\nreturn realtimeValidateTurnEvidence;`) as () => (input: Record<string, unknown>) => void;
@@ -327,6 +327,391 @@ async function validateRealtimeFanout(command) {
}
}
async function validateExistingSessionRefresh(command) {
const profileId = String(command.profile || "").trim();
const profile = realtimeFanoutProfiles[profileId];
if (!profile || typeof profile !== "object") throw new Error("validateExistingSessionRefresh profile is not declared by the owning YAML: " + profileId);
const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs);
if (effective.expectedProductSse.refreshHandoff !== true) throw new Error("validateExistingSessionRefresh requires a YAML profile with refresh handoff enabled");
const reportDir = path.join(stateDir, "artifacts", "existing-session-refresh", safeId(command.id));
await mkdir(reportDir, { recursive: true, mode: 0o700 });
const network = [];
const requestListener = (request) => {
let url;
try { url = new URL(request.url()); } catch { return; }
if (url.origin !== new URL(baseUrl).origin) return;
network.push({
at: Date.now(),
method: request.method(),
path: url.pathname,
sessionId: url.searchParams.get("sessionId"),
traceId: url.searchParams.get("traceId"),
afterSeq: url.searchParams.get("afterSeq"),
lastEventId: request.headers()["last-event-id"] || null,
valuesRedacted: true,
});
if (network.length > 1000) network.splice(0, network.length - 1000);
};
const report = {
ok: false,
contract: "web-probe-existing-session-refresh-v1",
commandId: command.id,
profile: profileId,
origin,
startedAt: new Date().toISOString(),
valuesRedacted: true,
};
let phase = "session-selection";
let sessionId = null;
let refreshReplay = null;
let screenshot = null;
let requestPage = null;
try {
const controlRecovery = await ensureControlPageResponsiveForCommand("validateExistingSessionRefresh");
const initial = await workbenchSessionSnapshot();
sessionId = String(command.sessionId || initial?.activeSessionId || initial?.routeSessionId || "").trim();
if (!sessionId) throw new Error("validateExistingSessionRefresh requires --session-id or an active Workbench session");
if (!/^ses_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new Error("validateExistingSessionRefresh session id is invalid");
Object.assign(report, { sessionId, controlRecovery });
if (initial?.activeSessionId !== sessionId || initial?.routeSessionId !== sessionId) {
await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
const selected = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
if (selected.ok !== true) throw new Error("existing session did not hydrate before refresh validation");
}
const before = await realtimeWorkbenchDomIdentity();
if (before.messageCount < 1) throw new Error("existing session has no rendered messages to validate across refresh");
report.before = before;
phase = "refresh-navigation";
requestPage = page;
requestPage.on("request", requestListener);
await realtimeArmExistingSessionRefreshProbe(requestPage, effective);
await requestPage.reload({ waitUntil: "domcontentloaded", timeout: effective.barrierTimeoutMs });
const settled = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
if (settled.ok !== true) throw new Error("existing session did not hydrate after refresh");
phase = "retention-barrier";
const refreshState = await realtimePoll(async () => page.evaluate(() => {
const state = window.__unideskExistingSessionRefresh;
const streamFailure = state?.failures?.at(-1) || null;
if (streamFailure) return { streamFailure };
const connected = state?.connected?.at(-1) || null;
return connected ? { connected } : null;
}), effective.barrierTimeoutMs, "existing session refresh connected evidence");
if (refreshState.streamFailure) throw realtimeExistingRefreshStreamError(refreshState.streamFailure);
const connected = refreshState.connected;
let connectedSummary = null;
try {
const connectedEvidence = realtimeExistingRefreshConnectedEvidence(connected, sessionId, effective);
connectedSummary = connectedEvidence.connected;
refreshReplay = connectedEvidence.refreshReplay;
} catch (error) {
connectedSummary = error?.details?.connected || null;
refreshReplay = error?.details?.refreshReplay || null;
if (connectedSummary) report.connected = connectedSummary;
throw error;
}
phase = "live-handoff";
await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs);
await sleep(effective.reconnectQuietMs);
const after = await realtimeWorkbenchDomIdentity();
realtimeAssertSameWorkbenchDom(before, after);
const browserEvidence = await page.evaluate(() => JSON.parse(JSON.stringify(window.__unideskExistingSessionRefresh || {})));
if (browserEvidence.sources.length !== 1) throw new Error("main Workbench product EventSource count is " + browserEvidence.sources.length + ", expected 1");
if (browserEvidence.sources[0]?.opened !== 1) throw new Error("main Workbench product EventSource open count is not 1");
if (browserEvidence.connected.length !== 1) throw new Error("main Workbench connected event count is " + browserEvidence.connected.length + ", expected 1");
if (browserEvidence.failures.length !== 0) throw realtimeExistingRefreshStreamError(browserEvidence.failures.at(-1));
if (browserEvidence.errors.length !== 0) throw new Error("main Workbench product EventSource observed an error");
realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay);
const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId);
const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths));
if (mainStreams.length !== 1) throw new Error("main Workbench session SSE request count is " + mainStreams.length + ", expected 1");
if (mainStreams[0].traceId !== null || mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench product SSE used a trace or cursor scope");
if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(","));
phase = "evidence-write";
const screenshotFile = path.join(reportDir, "existing-session-refresh.png");
await page.screenshot({ path: screenshotFile, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
const screenshotMeta = await fileMeta(screenshotFile);
screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true };
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot });
const productSse = {
requestCount: mainStreams.length,
browserSourceCount: browserEvidence.sources.length,
openCount: browserEvidence.sources[0]?.opened || 0,
connectedEventCount: browserEvidence.connected.length,
businessEventCount: browserEvidence.businessEventCount,
typedFailureCount: browserEvidence.failures.length,
livePhase: refreshReplay?.phase === "live",
valuesRedacted: true,
};
Object.assign(report, {
ok: true,
status: "passed",
phase: "live",
code: "refresh_live_handoff_validated",
completedAt: new Date().toISOString(),
after,
connected: connectedSummary,
refreshReplay,
productSse,
forbiddenRequests: { count: 0, rows: [], valuesRedacted: true },
screenshot,
valuesRedacted: true,
});
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh");
report.artifacts = artifacts;
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report");
const observer = await syncObserverPageToControlSession("validateExistingSessionRefresh", sessionId);
await appendJsonl(files.control, eventRecord("existing-session-refresh-validated", {
profile: profileId,
sessionId,
traceIds: after.traceIds,
phase: "live",
code: "refresh_live_handoff_validated",
reportPath: reportArtifact.path,
reportSha256: reportArtifact.sha256,
screenshotSha256: screenshot.sha256,
valuesRedacted: true,
}));
return {
ok: true,
status: "passed",
profile: profileId,
sessionId,
traceIds: after.traceIds,
phase: "live",
code: "refresh_live_handoff_validated",
before: realtimeWorkbenchDomSummary(before),
after: realtimeWorkbenchDomSummary(after),
refreshReplay,
productSse,
forbiddenRequestCount: 0,
reportPath: reportArtifact.path,
reportSha256: reportArtifact.sha256,
screenshot,
observer,
valuesRedacted: true,
};
} catch (error) {
const failure = realtimeExistingRefreshFailure(phase, error);
if (!screenshot) {
screenshot = await captureScreenshot("existing-session-refresh-failed-" + safeId(command.id))
.then((artifact) => ({ path: artifact.path, sha256: artifact.sha256, valuesRedacted: true }))
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
}
Object.assign(report, {
ok: false,
status: "failed",
completedAt: new Date().toISOString(),
phase: failure.phase,
code: failure.code,
refreshReplay,
failure,
screenshot,
valuesRedacted: true,
});
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh").catch(() => null);
if (artifacts) report.artifacts = artifacts;
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report").catch(() => null);
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = {
...(wrapped.details || {}),
profile: profileId,
sessionId,
traceIds: report.after?.traceIds || report.before?.traceIds || [],
phase: failure.phase,
code: failure.code,
failure,
before: report.before ? realtimeWorkbenchDomSummary(report.before) : null,
after: report.after ? realtimeWorkbenchDomSummary(report.after) : null,
refreshReplay,
screenshot,
reportPath: reportArtifact?.path || null,
reportSha256: reportArtifact?.sha256 || null,
valuesRedacted: true,
};
throw wrapped;
} finally {
if (requestPage) requestPage.off("request", requestListener);
await page.evaluate(() => window.sessionStorage.removeItem("__unideskExistingSessionRefreshCapture")).catch(() => {});
}
}
function realtimeExistingRefreshFailure(phase, error) {
const codes = {
"session-selection": "session_selection_failed",
"refresh-navigation": "session_hydration_failed",
"retention-barrier": "retention_barrier_failed",
"live-handoff": "live_handoff_failed",
"evidence-write": "evidence_write_failed",
};
const streamFailure = realtimeExistingRefreshStreamFailure(error?.details?.streamFailure);
return {
phase,
code: codes[phase] || "existing_session_refresh_failed",
streamPhase: streamFailure?.phase || null,
streamCode: streamFailure?.code || null,
streamFailure,
error: errorSummary(error),
valuesRedacted: true,
};
}
function realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile) {
const connectedSummary = realtimeConnectedSummary(connected);
const refreshReplay = connectedSummary.refreshReplay;
try {
realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", profile, { requireRetainedEvents: true });
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = { ...(wrapped.details || {}), connected: connectedSummary, refreshReplay, valuesRedacted: true };
throw wrapped;
}
return { connected: connectedSummary, refreshReplay, valuesRedacted: true };
}
function realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay) {
const businessEventCount = Number(browserEvidence?.businessEventCount);
const replayed = Number(refreshReplay?.counts?.replayed);
const bufferedDelivered = Number(refreshReplay?.counts?.bufferedDelivered);
if (![businessEventCount, replayed, bufferedDelivered].every((value) => Number.isInteger(value) && value >= 0)) {
throw new Error("existing session refresh delivery counts are invalid");
}
const minimumDelivered = replayed + bufferedDelivered;
if (businessEventCount < minimumDelivered) {
throw new Error("existing session refresh browser observed " + businessEventCount + " business events, fewer than replayed plus buffered delivery " + minimumDelivered);
}
}
function realtimeExistingRefreshStreamError(value) {
const streamFailure = realtimeExistingRefreshStreamFailure(value);
const error = new Error(streamFailure?.message || "existing session refresh stream reported a typed failure");
error.details = { streamFailure, valuesRedacted: true };
return error;
}
function realtimeExistingRefreshStreamFailure(value) {
const nested = value?.error && typeof value.error === "object" ? value.error : value;
const phase = typeof value?.phase === "string" ? value.phase : typeof nested?.phase === "string" ? nested.phase : null;
const code = typeof nested?.code === "string" ? nested.code : typeof value?.code === "string" ? value.code : null;
const message = typeof nested?.message === "string" ? nested.message.slice(0, 240) : typeof value?.message === "string" ? value.message.slice(0, 240) : null;
if (!phase && !code && !message) return null;
return {
phase,
code,
message,
retryable: nested?.retryable === true,
fallback: value?.fallback === true,
valuesRedacted: true,
};
}
async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
await targetPage.addInitScript(() => {
const markerKey = "__unideskExistingSessionRefreshCapture";
const raw = window.sessionStorage.getItem(markerKey);
if (!raw) return;
window.sessionStorage.removeItem(markerKey);
let config;
try { config = JSON.parse(raw); } catch { return; }
const NativeEventSource = window.EventSource;
const state = { sources: [], connected: [], businessEventCount: 0, failures: [], errors: [] };
window.__unideskExistingSessionRefresh = state;
if (typeof NativeEventSource !== "function") {
state.errors.push({ kind: "eventsource-unavailable" });
return;
}
class ObservedEventSource extends NativeEventSource {
constructor(url, options) {
super(url, options);
let pathname = "";
try { pathname = new URL(String(url), window.location.href).pathname; } catch {}
if (pathname !== config.productEventsPath) return;
state.sources.push({ url: String(url), opened: 0 });
const source = state.sources[state.sources.length - 1];
this.addEventListener("open", () => { source.opened += 1; });
this.addEventListener(config.productReadyEvent, (event) => {
try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); }
});
this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; });
this.addEventListener("workbench.error", (event) => {
try { state.failures.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "workbench-error-parse" }); }
});
this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); });
}
}
Object.defineProperty(ObservedEventSource, "CONNECTING", { value: NativeEventSource.CONNECTING });
Object.defineProperty(ObservedEventSource, "OPEN", { value: NativeEventSource.OPEN });
Object.defineProperty(ObservedEventSource, "CLOSED", { value: NativeEventSource.CLOSED });
window.EventSource = ObservedEventSource;
});
await targetPage.evaluate((config) => {
window.sessionStorage.setItem("__unideskExistingSessionRefreshCapture", JSON.stringify(config));
}, {
productEventsPath: profile.productEventsPath,
productReadyEvent: profile.productReadyEvent,
businessEvent: profile.businessEvent,
});
}
async function realtimeWorkbenchDomIdentity() {
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll("#conversation-list > .message-card"));
const rows = cards.map((element, index) => ({
index,
role: element.getAttribute("data-role") || null,
messageId: element.getAttribute("data-message-id") || null,
traceId: element.getAttribute("data-trace-id") || null,
status: element.getAttribute("data-status") || null,
}));
const identity = (row) => [row.role || "", row.messageId || "", row.traceId || ""].join(":");
const identities = rows.map(identity);
return {
messageCount: rows.length,
identities,
missingIdentityRows: rows.filter((row) => !row.role || (!row.messageId && !row.traceId)).map((row) => row.index),
duplicateIdentities: [...new Set(identities.filter((value, index, all) => all.indexOf(value) !== index))],
traceIds: [...new Set(rows.map((row) => row.traceId).filter(Boolean))],
rows,
valuesRedacted: true,
};
});
}
function realtimeAssertSameWorkbenchDom(before, after) {
if (before.missingIdentityRows.length > 0 || after.missingIdentityRows.length > 0) throw new Error("Workbench DOM contains rows without a stable message identity");
if (before.duplicateIdentities.length > 0 || after.duplicateIdentities.length > 0) throw new Error("Workbench DOM contains duplicate message identities");
if (before.messageCount !== after.messageCount) throw new Error("Workbench message count changed across refresh: " + before.messageCount + " -> " + after.messageCount);
if (JSON.stringify(before.identities) !== JSON.stringify(after.identities)) throw new Error("Workbench message identity or order changed across refresh");
}
async function realtimeWaitForSameWorkbenchDom(before, timeoutMs) {
let latest = null;
return realtimePoll(async () => {
latest = await realtimeWorkbenchDomIdentity();
try {
realtimeAssertSameWorkbenchDom(before, latest);
return latest;
} catch {
return null;
}
}, timeoutMs, "existing session Workbench DOM identity restoration").catch((error) => {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = { ...(wrapped.details || {}), before: realtimeWorkbenchDomSummary(before), after: realtimeWorkbenchDomSummary(latest), valuesRedacted: true };
throw wrapped;
});
}
function realtimeWorkbenchDomSummary(value) {
return value ? {
messageCount: value.messageCount,
identities: Array.isArray(value.identities) ? value.identities.slice(0, 80) : [],
missingIdentityRows: Array.isArray(value.missingIdentityRows) ? value.missingIdentityRows.slice(0, 20) : [],
duplicateIdentities: Array.isArray(value.duplicateIdentities) ? value.duplicateIdentities.slice(0, 20) : [],
traceIds: Array.isArray(value.traceIds) ? value.traceIds.slice(0, 20) : [],
valuesRedacted: true,
} : null;
}
function realtimeFanoutEffectiveProfile(profile, durationMs) {
const debugStreams = Array.isArray(profile.debugStreams) ? profile.debugStreams.map(String) : [];
if (Number(profile.subscriberCount) !== 2) throw new Error("realtime fanout profile subscriberCount must be 2");
@@ -1041,7 +1426,7 @@ function realtimeDebugSnapshotSummary(snapshot) {
};
}
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots) {
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots, artifactPrefix = "realtime-fanout") {
const requestsFile = path.join(reportDir, "request-ledger.jsonl");
const eventsFile = path.join(reportDir, "events.jsonl");
await writeFile(requestsFile, network.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 });
@@ -1059,16 +1444,16 @@ async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots
events: { path: eventsFile, ...eventMeta, count: eventRows.length, valuesRedacted: true },
valuesRedacted: true,
};
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-events", commandId: activeCommandId, ...artifacts.events });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-events", commandId: activeCommandId, ...artifacts.events });
return artifacts;
}
async function realtimeWriteReport(reportDir, report) {
async function realtimeWriteReport(reportDir, report, artifactKind = "realtime-fanout-report") {
const reportFile = path.join(reportDir, "report.json");
await writeFile(reportFile, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
const meta = await fileMeta(reportFile);
const artifact = { kind: "realtime-fanout-report", path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
const artifact = { kind: artifactKind, path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
return artifact;
}
@@ -72,6 +72,9 @@ export type WebProbeSentinelOptions =
readonly wait: boolean;
readonly timeoutSeconds: number;
readonly rerun: boolean;
readonly manualRecovery: boolean;
readonly reason: string | null;
readonly internalExecution: "pac-controller" | null;
readonly sourceOverride: WebProbeSentinelSourceOverrideOptions;
}
| {
@@ -465,14 +468,16 @@ export function renderPublishResult(publish: Record<string, unknown>): string {
lines.push(
"",
"PUBLISH_DRILLDOWN",
` status: ${commands.cliStatus ?? "-"}`,
` logs: ${commands.logs ?? "-"}`,
` describe: ${commands.describe ?? "-"}`,
` manual-recovery: ${commands.publishCurrent ?? "-"}`,
` git-mirror: ${commands.gitMirrorStatus ?? "-"}`,
` sync: ${commands.gitMirrorSync ?? "-"}`,
` flush: ${commands.gitMirrorFlush ?? "-"}`,
` apply: ${commands.controlPlaneApply ?? "-"}`,
...commandRows([
["status", commands.cliStatus],
["logs", commands.logs],
["describe", commands.describe],
["manual-recovery", commands.publishCurrent],
["git-mirror", commands.gitMirrorStatus],
["sync", commands.gitMirrorSync],
["flush", commands.gitMirrorFlush],
["apply", commands.controlPlaneApply],
]),
);
}
return lines.join("\n");
@@ -592,43 +597,14 @@ export function renderPublishCurrentResult(result: Record<string, unknown>): str
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` status: ${next.controlPlaneStatus ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" formal closeout for migrated JD01 sentinel CI/CD is platform-infra pipelines-as-code closeout/status/history.",
" publish-current is a debug/test/recovery step only; operator-side recovery must be explicit.",
" migrated PaC output only exposes status/history and the stable automatic-chain repair reference.",
` end-to-end and stage budgets are read from ${Object.keys(validationPlan).length > 0 ? "publishCurrent YAML and runtime.healthPath" : "YAML-required publishCurrent fields"}.`,
" CI/CD validation only checks the configured health endpoint; web-probe, Playwright and browser dashboard checks are post-deploy evidence, not this gate.",
" image build uses Tekton PipelineRun and BuildKit; this command does not require Docker daemon/socket/build.",
@@ -670,12 +646,7 @@ export function renderImageResult(result: Record<string, unknown>): string {
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
"NEXT",
` status: ${next.status ?? "-"}`,
` dry-run: ${next.dryRun ?? "-"}`,
` confirm: ${next.confirm ?? "-"}`,
` trigger: ${next.controlPlaneTrigger ?? "-"}`,
` control-plane: ${next.controlPlanePlan ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" valuesRedacted=true; image status shows refs, hashes and object names only.",
@@ -758,13 +729,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
flush.mode === "async-job" ? `ASYNC_FLUSH_STATUS\n ${flushNext.status ?? "-"}` : "ASYNC_FLUSH_STATUS\n-",
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` status: ${next.controlPlaneStatus ?? next.status ?? "-"}`,
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
renderSentinelNext({ ...next, pipelineRunStatus: drillDown.pipelineRunStatus }),
"",
"DISCLOSURE",
" compact success view keeps CI/CD closeout fields below the global stdout guard.",
@@ -801,17 +766,7 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
]]),
].join("\n"),
"",
Object.keys(drillDown).length === 0 ? "DRILLDOWN\n-" : [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
` pipeline-run: ${drillDown.pipelineRunStatus ?? "-"}`,
` pipeline-run-full: ${drillDown.pipelineRunFull ?? "-"}`,
` pac-status: ${drillDown.consumerStatus ?? "-"}`,
` image-status: ${drillDown.imageStatus ?? "-"}`,
` git-mirror: ${drillDown.gitMirrorStatus ?? "-"}`,
` flush: ${drillDown.gitMirrorFlush ?? "-"}`,
` async-flush-status: ${drillDown.asyncFlushStatus ?? "-"}`,
].join("\n"),
renderSentinelDrillDown(drillDown),
"",
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "STAGE_REF", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), short(record(sourceMirrorSync.payload).stageRef), sourceMirrorSync.elapsedMs ?? "-"]]),
"",
@@ -850,32 +805,9 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
"",
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
"",
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
` pac-closeout: ${recoveryNext.pacCloseout ?? "-"}`,
` manual-recovery: ${recoveryNext.publishCurrent ?? "-"}`,
` status: ${recoveryNext.nextStatus ?? "-"}`,
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
].join("\n"),
renderSentinelRecoveryNext(recoveryNext),
"",
"NEXT",
` pac-closeout: ${next.pacCloseout ?? "-"}`,
` pac-status: ${next.pacStatus ?? "-"}`,
` pac-history: ${next.pacHistory ?? "-"}`,
` plan: ${next.plan ?? "-"}`,
` status: ${next.status ?? "-"}`,
` image: ${next.image ?? "-"}`,
` trigger-current: ${next.triggerCurrent ?? "-"}`,
` apply: ${next.apply ?? "-"}`,
` validate: ${next.validate ?? "-"}`,
` quick-verify: ${next.quickVerify ?? "-"}`,
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
` sync: ${next.gitMirrorSync ?? "-"}`,
` flush: ${next.gitMirrorFlush ?? "-"}`,
renderSentinelNext(next),
"",
"DISCLOSURE",
" default view is a bounded CI/CD summary; full manifest content is represented by object counts and sha256.",
@@ -883,6 +815,89 @@ export function renderControlPlaneResult(result: Record<string, unknown>): strin
].join("\n");
}
function renderSentinelNext(next: Record<string, unknown>): string {
const status = commandText(next.pacStatus) ?? commandText(next.status) ?? commandText(next.controlPlaneStatus);
const controlPlaneStatus = commandText(next.controlPlaneStatus);
const history = commandText(next.pacHistory) ?? commandText(next.history);
const fix = commandText(next.fixAutomaticDelivery) ?? commandText(record(next.fixAutomaticDelivery).reference);
const rows: Array<[string, unknown]> = [
["status", status],
["control-plane-status", controlPlaneStatus === status ? null : controlPlaneStatus],
["history", history],
["fix-automatic-delivery", fix],
["pipeline-run", next.pipelineRunStatus],
["events", next.events],
["logs", next.logs],
["pac-closeout", next.pacCloseout],
["plan", next.plan ?? next.controlPlanePlan],
["image", next.image],
["image-status", next.imageStatus],
["dry-run", next.dryRun],
["confirm", next.confirm],
["trigger", next.controlPlaneTrigger ?? next.triggerCurrent],
["apply", next.apply],
["validate", next.validate],
["quick-verify", next.quickVerify],
["post-deploy-dashboard", next.dashboardVerify],
["git-mirror", next.gitMirrorStatus],
["sync", next.gitMirrorSync],
["flush", next.gitMirrorFlush],
];
const lines = commandRows(rows);
return ["NEXT", ...(lines.length === 0 ? ["-"] : lines)].join("\n");
}
function renderSentinelDrillDown(drillDown: Record<string, unknown>): string {
if (Object.keys(drillDown).length === 0) return "DRILLDOWN\n-";
const lines = commandRows([
["pipeline-run", drillDown.pipelineRunStatus],
["pipeline-run-full", drillDown.pipelineRunFull],
["pac-status", drillDown.consumerStatus],
["image-status", drillDown.imageStatus],
["git-mirror", drillDown.gitMirrorStatus],
["flush", drillDown.gitMirrorFlush],
["async-flush-status", drillDown.asyncFlushStatus],
]);
return [
"DRILLDOWN",
table(["PIPELINERUN", "ASYNC_FLUSH_JOB"], [[drillDown.pipelineRun ?? "-", drillDown.asyncFlushJob ?? "-"]]),
...lines,
].join("\n");
}
function renderSentinelRecoveryNext(recoveryNext: Record<string, unknown>): string {
if (Object.keys(recoveryNext).length === 0) return "RECOVERY_NEXT\n-";
const lines = commandRows([
["status", recoveryNext.nextStatus ?? recoveryNext.pacStatus ?? recoveryNext.status],
["history", recoveryNext.pacHistory ?? recoveryNext.history],
["fix-automatic-delivery", commandText(recoveryNext.fixAutomaticDelivery) ?? commandText(record(recoveryNext.fixAutomaticDelivery).reference)],
["git-mirror", recoveryNext.gitMirrorStatus],
["pac-closeout", recoveryNext.pacCloseout],
["manual-recovery", recoveryNext.publishCurrent],
["sync", recoveryNext.gitMirrorSync],
["flush", recoveryNext.gitMirrorFlush],
["apply", recoveryNext.controlPlaneApply],
]);
return [
"RECOVERY_NEXT",
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason ?? "-", recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
...lines,
].join("\n");
}
function commandRows(rows: Array<[string, unknown]>): string[] {
return rows.flatMap(([label, value]) => {
const command = commandText(value);
return command === null ? [] : [` ${label}: ${command}`];
});
}
function commandText(value: unknown): string | null {
if (typeof value !== "string") return null;
const command = value.trim();
return command.length === 0 || command === "-" ? null : command;
}
export function renderObservedStatus(observed: Record<string, unknown>): string {
const rows = [
observedStatusRow("source", observed.sourceMirror),
+88 -75
View File
@@ -16,11 +16,22 @@ import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref";
import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import {
guardedSentinelDeliveryAction,
publishCurrentNext,
renderBlockedSentinelDeliveryMutation,
sentinelReadOnlyNext,
sentinelSourceOverrideFromOptions,
sentinelSourceResolveMode,
webProbeSentinelDeliveryAuthority,
type SentinelSourceOverride,
} from "./hwlab-node/sentinel-delivery-authority";
import type { RenderedCliResult } from "./output";
import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelInspect, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe";
@@ -136,21 +147,26 @@ export {
table,
text,
};
export {
guardedSentinelDeliveryAction,
webProbeSentinelDeliveryAuthority,
webProbeSentinelDeliveryAuthorityFromCatalog,
webProbeSentinelDeliveryMutationDecision,
} from "./hwlab-node/sentinel-delivery-authority";
export type { SentinelSourceOverride } from "./hwlab-node/sentinel-delivery-authority";
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-07-01-p16-cicd-source-snapshot";
export type SourceResolveMode = "cached" | "sync";
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
const guardedAction = guardedSentinelDeliveryAction(options);
if (guardedAction !== null) {
const blocked = renderBlockedSentinelDeliveryMutation(spec, options, guardedAction.command, guardedAction.action);
if (blocked !== null) return blocked;
}
requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), sentinelSourceOverrideFromOptions(options));
if (options.kind === "image") return runSentinelImage(state, options);
@@ -164,28 +180,9 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
return runSentinelReport(state, options);
}
function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
function sentinelSourceResolveMode(options: WebProbeSentinelOptions): SourceResolveMode {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "image" }>): RenderedCliResult {
const command = `web-probe sentinel image ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (options.action === "build" && options.confirm) {
if (!options.wait) return renderAsyncSentinelJob(state, "image", "build", options.timeoutSeconds);
return runSentinelImageBuildConfirmed(state, options);
@@ -210,12 +207,15 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
blocker: sourceMirrorReady
? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" }
: { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
},
deliveryAuthority,
next: deliveryAuthority.kind === "legacy-manual"
? {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
}
: sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
return rendered(result.ok, command, renderImageResult(result));
@@ -223,6 +223,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "control-plane" }>): RenderedCliResult {
const command = `web-probe sentinel control-plane ${options.action}`;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const mutationAction = options.action === "apply" || options.action === "trigger-current";
if (options.confirm && mutationAction) {
if (!options.wait) return renderAsyncSentinelJob(state, "control-plane", options.action, options.timeoutSeconds);
@@ -241,6 +242,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
deliveryAuthority,
specRef: SPEC_REF,
source: state.sourceHead,
image: state.image,
@@ -1094,29 +1096,6 @@ function publishCurrentBlocker(controlResult: Record<string, unknown>, health: R
return { code: "sentinel-publish-current-blocked", reason: "publish-current did not satisfy all checks", valuesRedacted: true };
}
function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
function sentinelPacConsumerId(state: SentinelCicdState): string {
return `sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`;
}
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
@@ -2009,6 +1988,8 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
const argoReady = argo.ok === true;
const runtimeReady = record(observed.runtime).ok === true;
const cadenceReady = cadence.ok === true;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const legacyDelivery = deliveryAuthority.kind === "legacy-manual";
const code = sourceReady && !registryPresent
? "sentinel-publish-half-state-registry-missing"
: registryPresent && !gitopsReady
@@ -2020,7 +2001,7 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: !cadenceReady
? "sentinel-cadence-not-ready"
: "sentinel-control-plane-observed-not-ready";
const reason = code === "sentinel-publish-half-state-registry-missing"
const legacyReason = code === "sentinel-publish-half-state-registry-missing"
? "source mirror contains the selected commit, but the expected registry tag is missing; inspect PaC closeout/history before considering explicit manual recovery"
: code === "sentinel-gitops-manifest-not-updated"
? "registry contains the selected image, but GitOps manifest is not yet updated to a digest-pinned runtime image"
@@ -2031,6 +2012,9 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
: code === "sentinel-cadence-not-ready"
? "runtime is aligned, but cadence CronJob validation did not pass"
: "one or more source, registry, GitOps, Argo, runtime or cadence checks did not pass";
const reason = legacyDelivery
? legacyReason
: `Web sentinel automatic delivery observation is not aligned (${code}); inspect read-only status/history and fix the owning YAML, controller, or source.`;
const next = publishCurrentNext(state);
const controlNext = controlPlaneNext(state, "trigger-current");
const shouldRetryPublish = code === "sentinel-publish-half-state-registry-missing" || code === "sentinel-gitops-manifest-not-updated";
@@ -2055,26 +2039,38 @@ function sentinelObservedStatusDiagnosis(state: SentinelCicdState, value: unknow
argo: `${argo.syncStatus ?? "-"} ${argo.healthStatus ?? "-"} ${short(argo.revision)}/${short(argo.expectedRevision)}`,
runtime: `ready=${runtimeDeployment.readyReplicas ?? "-"}/${runtimeDeployment.desiredReplicas ?? "-"} image=${short(runtimeDeployment.image)} expected=${short(runtimeDeployment.expectedImage)}`,
pipelineRun,
warning: code === "sentinel-publish-half-state-registry-missing"
deliveryAuthority,
warning: !legacyDelivery
? `自动链未对齐;检查 ${next.pacStatus ?? next.status}${next.pacHistory ?? next.status},按 ${next.fixAutomaticDelivery} 修复后用新的正常 GitHub PR merge 验收。`
: code === "sentinel-publish-half-state-registry-missing"
? `source mirror already exposes ${short(state.sourceHead.commit)} but registry tag ${state.image.tag} is missing; run ${next.pacCloseout} and inspect ${next.pacHistory}.`
: code === "sentinel-git-mirror-not-in-sync"
? `runtime is aligned but git-mirror is not in sync; run ${next.gitMirrorSync} and then recheck ${next.controlPlaneStatus}.`
: null,
blocker: { code, reason, valuesRedacted: true },
recoveryNext: {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
},
recoveryNext: legacyDelivery
? {
reason,
pipelineRun,
digestRef: registryPresent ? expectedRuntimeImageFromRegistry(state, record(observed.registry)) : null,
gitopsCommit: gitops.revision ?? null,
pacCloseout: next.pacCloseout,
publishCurrent: shouldRetryPublish ? next.manualRecovery : null,
nextStatus: next.pacStatus,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorSync: !gitMirrorReady ? next.gitMirrorSync : null,
gitMirrorFlush: null,
controlPlaneApply: shouldRetryPublish || code === "sentinel-runtime-not-aligned" ? controlNext.apply : null,
valuesRedacted: true,
}
: {
reason,
status: next.status,
pacStatus: next.pacStatus,
pacHistory: next.pacHistory,
fixAutomaticDelivery: next.fixAutomaticDelivery,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
@@ -2485,10 +2481,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
}
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
@@ -2512,6 +2510,14 @@ function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelCont
function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish: unknown, flush: unknown, observed: unknown): Record<string, unknown> | null {
const payload = record(record(publish).payload);
if (ok || nonEmptyString(payload.digestRef) === null) return null;
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") {
return {
reason: "平台维护后自动交付仍未对齐;只读检查 sentinel/PaC 状态并修复自动链,不得人工 publish、sync 或 flush。",
...sentinelReadOnlyNext(state, deliveryAuthority),
valuesRedacted: true,
};
}
const next = controlPlaneNext(state, "apply");
const flushRecord = record(flush);
const observedRecord = record(observed);
@@ -2535,12 +2541,13 @@ function controlPlaneRecoveryNext(state: SentinelCicdState, ok: boolean, publish
function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, flush: unknown): Record<string, unknown> {
const pipelineRunName = nonEmptyString(pipelineRun);
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
const next = controlPlaneNext(state, "status");
const pacBase = "bun scripts/cli.ts platform-infra pipelines-as-code";
const flushRecord = record(flush);
const flushNext = record(flushRecord.next);
const flushJob = record(flushRecord.job);
return {
const readOnly = {
pipelineRun: pipelineRunName,
pipelineRunStatus: pipelineRunName === null
? null
@@ -2549,12 +2556,18 @@ function controlPlaneDrillDown(state: SentinelCicdState, pipelineRun: unknown, f
? null
: `${pacBase} history --target ${state.spec.nodeId} --id ${pipelineRunName} --full`,
consumerStatus: `${pacBase} status --target ${state.spec.nodeId} --consumer sentinel-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}`,
controlPlaneStatus: next.controlPlaneStatus ?? next.status,
fixAutomaticDelivery: next.fixAutomaticDelivery ?? PAC_AUTOMATIC_DELIVERY_REFERENCE,
valuesRedacted: true,
};
if (deliveryAuthority.kind !== "legacy-manual") return readOnly;
return {
...readOnly,
imageStatus: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
gitMirrorStatus: next.gitMirrorStatus,
gitMirrorFlush: next.gitMirrorFlush,
asyncFlushJob: nonEmptyString(flushJob.id),
asyncFlushStatus: nonEmptyString(flushNext.status),
valuesRedacted: true,
};
}
+2 -2
View File
@@ -202,7 +202,7 @@ export function nodeRuntimeBaseImageCommand(scoped: ReturnType<typeof parseNodeS
: "node-runtime-image-seed-failed",
next: scoped.dryRun
? { preload: `bun scripts/cli.ts hwlab nodes control-plane runtime-image preload --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: { triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm --rerun` },
: { status: `bun scripts/cli.ts hwlab nodes control-plane runtime-image status --node ${scoped.node} --lane ${scoped.lane}` },
};
}
@@ -416,7 +416,7 @@ export function nodeRuntimeApply(scoped: ReturnType<typeof parseNodeScopedDelega
next: scoped.dryRun
? { apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm` }
: {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
...(sshTcpPoolDiagnostics === null ? {} : { sshPoolStatus: `bun scripts/cli.ts debug ssh-pool ${scoped.node}` }),
},
};
@@ -0,0 +1,92 @@
import {
decideCicdDeliveryMutation,
gitRepositoryIdentity,
pacAutomaticDeliveryFix,
pacReadOnlyNext,
resolveCicdDeliveryAuthority,
type CicdDeliveryAuthority,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
export interface HwlabNodeDeliveryTarget {
readonly node: string;
readonly lane: string;
readonly spec: HwlabRuntimeLaneSpec;
}
export function hwlabNodeDeliveryAuthority(target: HwlabNodeDeliveryTarget): CicdDeliveryAuthority {
return resolveCicdDeliveryAuthority({
node: target.node,
lane: target.lane,
sourceRepository: gitRepositoryIdentity(target.spec.gitUrl),
sourceBranch: target.spec.sourceBranch,
declaredSourceAuthorityMode: target.spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function hwlabNodeDeliveryMutationDecision(
target: HwlabNodeDeliveryTarget,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(hwlabNodeDeliveryAuthority(target), {
command,
statusCommand: `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`,
action,
node: target.node,
lane: target.lane,
});
}
export function hwlabNodeDeliveryNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
runtimeStatusFull: string,
): Record<string, unknown> {
if (authority.kind === "pac-pr-merge") {
return {
runtimeStatusFull,
...pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId),
};
}
if (authority.kind === "unknown") {
return {
runtimeStatusFull,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
full: runtimeStatusFull,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${target.node} --lane ${target.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${target.node} --lane ${target.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${target.node} --lane ${target.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${target.node} --lane ${target.lane}`,
};
}
export function hwlabNodePlanNext(
target: HwlabNodeDeliveryTarget,
authority: CicdDeliveryAuthority,
): Record<string, unknown> {
const status = `bun scripts/cli.ts hwlab nodes control-plane status --node ${target.node} --lane ${target.lane}`;
if (authority.kind === "pac-pr-merge") {
return pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId);
}
if (authority.kind === "unknown") {
return {
status,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.node} --limit 10`,
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
valuesPrinted: false,
};
}
return {
status,
platformMaintenanceHelp: "bun scripts/cli.ts hwlab nodes control-plane platform-maintenance --help",
legacyCicdHelp: "bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help",
valuesPrinted: false,
};
}
+17 -1
View File
@@ -46,6 +46,7 @@ import { emitNodeRuntimeStatusWorkerEvent, isNodeRuntimeStatusWorker, readNodeRu
import { legacyHwlabNodeWebProbeUnsupported } from "../web-probe";
import { startNodeDelegatedJob } from "./web-probe";
import { assertKnownOptions } from "./web-probe-observe";
import { hwlabNodeDeliveryMutationDecision } from "./delivery-authority";
export { hwlabNodeHelp, hwlabNodeWebProbeHelp, hwlabNodeObservabilityHelp } from "../hwlab-node-help";
@@ -140,6 +141,7 @@ export type NodeWebProbeObserveCommandType =
| "newSession"
| "sendPrompt"
| "validateRealtimeFanout"
| "validateExistingSessionRefresh"
| "validateWorkbenchKafkaDebugReplay"
| "validateWorkbenchTraceReadability"
| "steer"
@@ -571,7 +573,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
if (args.length === 1 || args.includes("--help") || args.includes("-h") || args[1] === "help") return hwlabNodeObservabilityHelp();
return runNodeObservability(parseNodeObservabilityOptions(args.slice(1)));
}
if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp();
if (args.includes("--help") || args.includes("-h")) {
const scope = args.includes("legacy-cicd") ? "legacy-cicd" : args.includes("platform-maintenance") ? "platform-maintenance" : null;
return hwlabNodeHelp(scope);
}
if (domain === "control-plane" || domain === "git-mirror") {
return runNodeDelegatedDomain(_config, domain, args.slice(1));
}
@@ -614,6 +619,17 @@ export function parseNodeObservabilityOptions(args: string[]): NodeObservability
export async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const scoped = parseNodeScopedDelegatedOptions(domain, args);
const guardedDeliveryAction = domain === "git-mirror" && (scoped.action === "sync" || scoped.action === "flush")
|| domain === "control-plane" && (scoped.action === "trigger-current" || scoped.action === "refresh" || scoped.action === "sync")
|| domain === "control-plane" && scoped.action === "source-workspace" && scoped.originalArgs[1] === "sync";
if (guardedDeliveryAction) {
const decision = hwlabNodeDeliveryMutationDecision(
scoped,
`hwlab nodes ${domain} ${scoped.action}${scoped.action === "source-workspace" ? " sync" : ""}`,
scoped.action === "source-workspace" ? "source-workspace-sync" : scoped.action,
);
if (!decision.allowed) return decision.result;
}
if (domain === "control-plane" && scoped.action === "public-exposure") {
return runNodePublicExposure({
action: "public-exposure",
+4 -8
View File
@@ -38,9 +38,11 @@ import { compactCommandResultRedacted, record, shellQuote } from "./utils";
import { readBootstrapAdminPasswordMaterial } from "./web-probe";
import { webProbeCredential } from "./web-probe-observe";
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { hwlabNodeDeliveryAuthority, hwlabNodePlanNext } from "./delivery-authority";
export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const activeExternalPostgres = hwlabRuntimeActiveExternalPostgres(scoped.spec);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
return {
ok: true,
command: `hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
@@ -48,6 +50,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
mutation: false,
node: scoped.node,
lane: scoped.lane,
deliveryAuthority,
expected: nodeRuntimeExpected(scoped.spec),
checks: {
nodeScopedTargetConfigured: true,
@@ -58,14 +61,7 @@ export function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeS
localPostgresExpectedAbsent: nodeRuntimeLocalPostgresExpectedAbsent(scoped.spec),
publicExposureDeclared: scoped.spec.publicExposure !== null,
},
next: {
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${scoped.node} --lane ${scoped.lane}`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
platformDbStatus: activeExternalPostgres === undefined
? null
: `bun scripts/cli.ts platform-db postgres status --config ${activeExternalPostgres.configRef}`,
publicExposure: scoped.spec.publicExposure === null ? null : `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
next: hwlabNodePlanNext(scoped, deliveryAuthority),
};
}
@@ -525,7 +525,6 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor
valuesRedacted: true,
next: ok
? {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
}
: { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` },
+47 -18
View File
@@ -42,6 +42,8 @@ import { readNodeRuntimeStatusPolicy, runNodeRuntimeStatusProbe, skippedNodeRunt
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay";
import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
import type { CicdDeliveryAuthority } from "../cicd-delivery-authority";
const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd();
const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd();
@@ -346,6 +348,9 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
runtimeDegradedReason,
publicReady,
});
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
const visiblePipelineRunDiagnostics = nodeRuntimeVisiblePipelineRunDiagnostics(pipelineRunDiagnostics, deliveryAuthority);
const runtimeStatusFull = `${nodeRuntimeStatusCommand(scoped)} --full`;
const fullStatus = {
ok: controlPlaneReady && runtimeReady && argoReady && pipelineRunReady && publicReady && gitMirrorReady,
command: `hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
@@ -389,7 +394,7 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
result: compactRuntimeCommand(argo),
},
pipelineRun: pipelineRunProbe,
pipelineRunDiagnostics,
pipelineRunDiagnostics: visiblePipelineRunDiagnostics,
runtime: {
ready: runtimeReady,
skipped: !statusPolicy.probes.runtime,
@@ -420,17 +425,32 @@ export function nodeRuntimeControlPlaneStatus(scoped: ReturnType<typeof parseNod
postgresObjects: postgresObjects === null ? null : compactRuntimeCommand(postgresObjects),
},
degradedReason,
next: {
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
apply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
deliveryAuthority,
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, runtimeStatusFull),
};
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped);
const summary = summarizeNodeRuntimeControlPlaneStatus(fullStatus, scoped, deliveryAuthority);
if (scoped.originalArgs.includes("--full") || scoped.originalArgs.includes("--raw")) return { ...fullStatus, summary };
return summary;
}
export function nodeRuntimeVisiblePipelineRunDiagnostics(
diagnostics: Record<string, unknown> | null,
deliveryAuthority: CicdDeliveryAuthority,
): Record<string, unknown> | null {
if (deliveryAuthority.kind === "legacy-manual" || diagnostics === null) return diagnostics;
const failureSummary = record(diagnostics.failureSummary);
return {
...diagnostics,
failureSummary: Object.keys(failureSummary).length === 0
? diagnostics.failureSummary ?? null
: {
...failureSummary,
nextAction: "检查失败 TaskRun 与有界日志,修复 owning YAML、controller 或源码中的自动链缺陷,再以新的正常 GitHub PR merge 验收;不得人工 rerun。",
},
next: null,
};
}
export function parseNodeRuntimeWorkloadReadiness(text: string): Array<Record<string, unknown>> {
return text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => {
const [ref = "", counts = ""] = line.split(/\t/u);
@@ -724,7 +744,11 @@ export function nodeRuntimePipelinePendingTaskRunSummaries(
});
}
export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
export function summarizeNodeRuntimeControlPlaneStatus(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): Record<string, unknown> {
const pipelineRun = record(status.pipelineRun);
const pipelineRunDiagnostics = record(status.pipelineRunDiagnostics);
const argo = record(status.argo);
@@ -770,7 +794,7 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
pendingTaskRuns: pipelineRunDiagnostics.pendingTaskRuns ?? [],
unscheduledPods: pipelineRunDiagnostics.unscheduledPods ?? [],
schedulingMessages: pipelineRunDiagnostics.schedulingMessages ?? [],
next: pipelineRunDiagnostics.next ?? null,
next: deliveryAuthority.kind === "legacy-manual" ? pipelineRunDiagnostics.next ?? null : null,
},
},
argo: {
@@ -842,14 +866,9 @@ export function summarizeNodeRuntimeControlPlaneStatus(status: Record<string, un
flushNeeded: gitMirrorCompact.flushNeeded === true,
githubInSync: gitMirrorCompact.githubInSync === true,
},
nextAction: nodeRuntimeStatusNextAction(status, scoped),
next: {
full: `${nodeRuntimeStatusCommand(scoped)} --full`,
plan: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${scoped.node} --lane ${scoped.lane}`,
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${scoped.node} --lane ${scoped.lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${scoped.node} --lane ${scoped.lane} --confirm --wait`,
webProbe: `bun scripts/cli.ts web-probe run --node ${scoped.node} --lane ${scoped.lane}`,
},
deliveryAuthority,
nextAction: nodeRuntimeStatusNextAction(status, scoped, deliveryAuthority),
next: hwlabNodeDeliveryNext(scoped, deliveryAuthority, `${nodeRuntimeStatusCommand(scoped)} --full`),
};
}
@@ -967,8 +986,18 @@ function argoEventRows(text: string): Record<string, unknown>[] {
}).filter((item): item is Record<string, unknown> => item !== null);
}
export function nodeRuntimeStatusNextAction(status: Record<string, unknown>, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
export function nodeRuntimeStatusNextAction(
status: Record<string, unknown>,
scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>,
deliveryAuthority: CicdDeliveryAuthority = hwlabNodeDeliveryAuthority(scoped),
): string {
const reason = typeof status.degradedReason === "string" ? status.degradedReason : null;
if (deliveryAuthority.kind === "pac-pr-merge") {
return reason === null
? `${nodeRuntimeStatusCommand(scoped)} --full`
: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${deliveryAuthority.consumer.node} --consumer ${deliveryAuthority.consumer.consumerId}`;
}
if (deliveryAuthority.kind === "unknown") return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === null) return `${nodeRuntimeStatusCommand(scoped)} --full`;
if (reason === "control-plane-not-ready" || reason === "runtime-namespace-missing") {
return `bun scripts/cli.ts hwlab nodes control-plane apply --node ${scoped.node} --lane ${scoped.lane} --confirm`;
+6 -2
View File
@@ -197,7 +197,6 @@ export function sshTcpPoolDiagnosticsFromCommand(spec: HwlabRuntimeLaneSpec, res
poolStatus: `bun scripts/cli.ts debug ssh-pool ${spec.nodeId}`,
retrySmoke: `trans ${spec.nodeId} argv true`,
retryApply: `bun scripts/cli.ts hwlab nodes control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
retryTriggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
},
};
}
@@ -210,7 +209,12 @@ export function nodeRuntimeUnsupportedAction(scoped: ReturnType<typeof parseNode
lane: scoped.lane,
mutation: false,
degradedReason: "unsupported-node-scoped-runtime-action",
message: "node-scoped runtime currently supports plan/status/apply/refresh/sync/trigger-current/cleanup-runs/cleanup-released-pvs/cleanup-legacy-docker-images/cleanup-legacy-docker-registry-volume/runtime-image/runtime-migration/source-workspace",
message: "node-scoped runtime default help exposes read-only plan/status; delivery mutations are available only from explicit legacy-cicd help, and platform maintenance from platform-maintenance help",
next: {
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
legacyHelp: "bun scripts/cli.ts hwlab nodes help legacy-cicd",
maintenanceHelp: "bun scripts/cli.ts hwlab nodes help platform-maintenance",
},
expected: nodeRuntimeExpected(scoped.spec),
};
}
@@ -0,0 +1,207 @@
import {
PAC_AUTOMATIC_DELIVERY_REFERENCE,
decideCicdDeliveryMutation,
readCicdDeliveryAuthorityCatalog,
resolveCicdDeliveryAuthority,
resolveCicdDeliveryAuthorityFromCatalog,
type CicdDeliveryAuthority,
type CicdDeliveryAuthorityCatalog,
type CicdDeliveryMutationDecision,
} from "../cicd-delivery-authority";
import { authorizeSentinelPacInternalPublish } from "../sentinel-pac-execution-context";
import {
record,
rendered,
sentinelCliSuffix,
text,
type SentinelCicdState,
type WebProbeSentinelOptions,
type WebProbeSentinelSourceAuthority,
} from "../hwlab-node-web-sentinel-cicd-shared";
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { RenderedCliResult } from "../output";
export function webProbeSentinelDeliveryAuthority(spec: HwlabRuntimeLaneSpec): CicdDeliveryAuthority {
try {
return webProbeSentinelDeliveryAuthorityFromCatalog(spec, readCicdDeliveryAuthorityCatalog());
} catch {
return resolveCicdDeliveryAuthority({
consumerId: sentinelPacConsumerId(spec),
node: spec.nodeId,
lane: spec.lane,
});
}
}
export function webProbeSentinelDeliveryAuthorityFromCatalog(
spec: HwlabRuntimeLaneSpec,
catalog: CicdDeliveryAuthorityCatalog,
): CicdDeliveryAuthority {
const consumerId = sentinelPacConsumerId(spec);
const exactConsumer = resolveCicdDeliveryAuthorityFromCatalog(catalog, {
consumerId,
node: spec.nodeId,
lane: spec.lane,
});
if (exactConsumer.kind === "pac-pr-merge") return exactConsumer;
const stableSentinelIdentity = spec.sourceAuthority?.mode === "giteaSnapshot"
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase() === consumerId.toLowerCase())
|| catalog.consumers.some((consumer) => consumer.consumerId.toLowerCase().startsWith("sentinel-")
&& consumer.node.toLowerCase() === spec.nodeId.toLowerCase()
&& consumer.lane.toLowerCase() === spec.lane.toLowerCase());
if (stableSentinelIdentity) return exactConsumer;
return resolveCicdDeliveryAuthorityFromCatalog(catalog, {
declaredSourceAuthorityMode: spec.sourceAuthority?.mode ?? null,
declaredConfigRef: hwlabRuntimeLaneConfigPath(),
});
}
export function sentinelPacConsumerId(spec: HwlabRuntimeLaneSpec): string {
return `sentinel-${spec.nodeId.toLowerCase()}-${spec.lane}`;
}
export function webProbeSentinelDeliveryMutationDecision(
spec: HwlabRuntimeLaneSpec,
command: string,
action: string,
): CicdDeliveryMutationDecision {
return decideCicdDeliveryMutation(webProbeSentinelDeliveryAuthority(spec), {
command,
statusCommand: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
action,
node: spec.nodeId,
lane: spec.lane,
});
}
export function guardedSentinelDeliveryAction(options: WebProbeSentinelOptions): { command: string; action: string } | null {
if (options.kind === "image" && options.action === "build") {
return { command: "web-probe sentinel image build", action: "sentinel-image-build" };
}
if (options.kind === "control-plane" && options.action === "trigger-current") {
return { command: "web-probe sentinel control-plane trigger-current", action: "sentinel-control-plane-trigger-current" };
}
if (options.kind === "publish" && (options.internalExecution === "pac-controller" || options.manualRecovery)) {
return { command: "web-probe sentinel publish-current", action: "sentinel-publish-current" };
}
return null;
}
export function renderBlockedSentinelDeliveryMutation(
spec: HwlabRuntimeLaneSpec,
options: WebProbeSentinelOptions,
command: string,
action: string,
): RenderedCliResult | null {
const decision = webProbeSentinelDeliveryMutationDecision(spec, command, action);
if (decision.allowed) return null;
const executionAuthorization = options.kind === "publish" && options.internalExecution === "pac-controller"
? authorizeSentinelPacInternalPublish(options, decision.authority)
: null;
const result = {
...decision.result,
...(executionAuthorization === null ? {} : {
mode: executionAuthorization.mode,
reason: executionAuthorization.reason,
capability: executionAuthorization.capability,
}),
executionAuthorization,
next: sentinelReadOnlyNextForSpec(spec, options.sentinelId, decision.authority),
sentinelId: options.sentinelId,
valuesRedacted: true,
};
const next = record(result.next);
const fixReference = typeof next.fixAutomaticDelivery === "string"
? next.fixAutomaticDelivery
: record(next.fixAutomaticDelivery).reference ?? PAC_AUTOMATIC_DELIVERY_REFERENCE;
return Object.assign(
rendered(false, command, [
"WEB SENTINEL DELIVERY BLOCKED",
`mode=${text(result.mode)} mutation=false authority=${text(record(result.deliveryAuthority).kind)}`,
`status=${text(next.status)}`,
`history=${text(next.history ?? next.pacHistory)}`,
`fix=${text(fixReference)}`,
"GitHub PR merge 是唯一正式交付触发;修复自动链后用新的正常合并事件验收。",
].join("\n")),
result,
);
}
export function sentinelReadOnlyNextForSpec(
spec: HwlabRuntimeLaneSpec,
sentinelId: string | null,
authority: CicdDeliveryAuthority,
): Record<string, string> {
const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
const controlPlaneStatus = `bun scripts/cli.ts web-probe sentinel control-plane status --node ${spec.nodeId} --lane ${spec.lane}${suffix}`;
const base = {
status: controlPlaneStatus,
controlPlaneStatus,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
if (authority.kind !== "pac-pr-merge") return base;
const pacHistory = `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId} --limit 10`;
return {
...base,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${authority.consumer.node} --consumer ${authority.consumer.consumerId}`,
pacHistory,
history: pacHistory,
};
}
export function sentinelReadOnlyNext(
state: SentinelCicdState,
authority: CicdDeliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec),
): Record<string, string> {
return sentinelReadOnlyNextForSpec(state.spec, state.sentinelId, authority);
}
export function publishCurrentNext(state: SentinelCicdState): Record<string, string> {
const deliveryAuthority = webProbeSentinelDeliveryAuthority(state.spec);
if (deliveryAuthority.kind !== "legacy-manual") return sentinelReadOnlyNext(state, deliveryAuthority);
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
const consumer = sentinelPacConsumerId(state.spec);
const sourceCommit = state.sourceHead.commit === null ? "" : ` --source-commit ${state.sourceHead.commit}`;
return {
pacCloseout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${node} --consumer ${consumer}${sourceCommit} --wait`,
pacStatus: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${node} --consumer ${consumer}`,
pacHistory: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${node} --consumer ${consumer} --limit 10`,
manualRecovery: `bun scripts/cli.ts web-probe sentinel publish-current --node ${node} --lane ${lane}${suffix} --confirm --wait --manual-recovery --reason <reason>`,
controlPlaneStatus: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
dashboardVerify: `bun scripts/cli.ts web-probe sentinel dashboard verify --node ${node} --lane ${lane}${suffix}`,
gitMirrorStatus: `bun scripts/cli.ts hwlab nodes git-mirror status --node ${node} --lane ${lane}`,
gitMirrorSync: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${node} --lane ${lane} --confirm`,
gitMirrorFlush: `bun scripts/cli.ts hwlab nodes git-mirror flush --node ${node} --lane ${lane} --confirm --wait`,
};
}
export function sentinelSourceResolveMode(options: WebProbeSentinelOptions): "cached" | "sync" {
if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync";
if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync";
if (options.kind === "publish" && options.confirm && options.wait) return "sync";
return "cached";
}
export interface SentinelSourceOverride {
readonly commit: string;
readonly stageRef?: string | null;
readonly mirrorCommit?: string | null;
readonly sourceAuthority: WebProbeSentinelSourceAuthority;
}
export function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null {
if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null;
const override = options.sourceOverride;
if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null;
if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source");
return {
commit: override.sourceCommit,
stageRef: override.sourceStageRef,
mirrorCommit: override.sourceMirrorCommit,
sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot",
};
}
+15 -2
View File
@@ -37,6 +37,7 @@ import { parseJsonObject, record, shellQuote, statusText } from "./utils";
import { webObserveTable } from "./web-observe-render";
import { nodeRuntimeGitMirrorTarget, printNodeRuntimeTriggerProgress, sleepSync } from "./web-probe";
import { webObserveShort, webObserveText } from "./web-probe-observe";
import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority";
type NodeScopedDelegatedOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>;
export type NodeRuntimeGitMirrorRunOptions = {
@@ -61,8 +62,20 @@ export function nodeRuntimeExternalPostgresSecretRows(secrets: Record<string, un
}
export function nodeRuntimeGitMirrorStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
if (scoped.spec.sourceAuthority?.mode === "giteaSnapshot") return nodeRuntimeGiteaSourceStatus(scoped);
return nodeRuntimeLegacyGitMirrorStatus(scoped);
const result = scoped.spec.sourceAuthority?.mode === "giteaSnapshot"
? nodeRuntimeGiteaSourceStatus(scoped)
: nodeRuntimeLegacyGitMirrorStatus(scoped);
const deliveryAuthority = hwlabNodeDeliveryAuthority(scoped);
if (deliveryAuthority.kind === "legacy-manual") return { ...result, deliveryAuthority };
return {
...result,
deliveryAuthority,
next: hwlabNodeDeliveryNext(
scoped,
deliveryAuthority,
`bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane} --full`,
),
};
}
function nodeRuntimeGiteaSourceStatus(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
@@ -66,6 +66,98 @@ test("observe status renderer exposes the exact command phase and failed report
assert.match(text, /terminal event arrived before subscriber disconnect/u);
});
test("observe status renderer exposes existing-session refresh handoff and typed failure evidence", () => {
const base = {
ok: true,
status: "running",
command: "web-probe observe status",
id: "webobs-fixture",
node: "NC01",
lane: "v03",
next: {},
};
const rendered = withWebObserveStatusRendered({
...base,
observer: {
processAlive: true,
manifest: {},
heartbeat: {},
diagnostics: {},
commands: {},
exactCommand: {
commandId: "cmd-existing-refresh",
type: "validateExistingSessionRefresh",
phase: "done",
ok: true,
result: {
status: "passed",
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "live",
code: "refresh_live_handoff_validated",
before: { messageCount: 2 },
after: { messageCount: 2 },
refreshReplay: {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
},
productSse: { browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true },
forbiddenRequestCount: 0,
reportSha256: "sha256:existing-refresh-report",
screenshot: { sha256: "sha256:existing-refresh-image" },
},
},
tails: { samples: [], control: [], network: [] },
},
});
const text = String(rendered.renderedText);
assert.match(text, /Workbench Session /u);
assert.match(text, /ses_existing.*trc_existing.*live.*refresh_live_handoff_validated.*2.*2.*1.*1.*1.*9.*0.*0/u);
assert.match(text, /hwlab\.event\.v1.*0.*0:18.*8.*8.*1.*1.*0.*0.*true/u);
assert.match(text, /sha256:existing-refresh-report.*sha256:existing-refresh-image/u);
const failed = withWebObserveStatusRendered({
...base,
observer: {
processAlive: true,
manifest: {},
heartbeat: {},
diagnostics: {},
commands: {},
exactCommand: {
commandId: "cmd-existing-refresh-failed",
type: "validateExistingSessionRefresh",
phase: "failed",
ok: false,
error: {
message: "Kafka retention barrier gap",
details: {
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "retention-barrier",
code: "retention_barrier_failed",
failure: {
phase: "retention-barrier",
code: "retention_barrier_failed",
streamPhase: "flushing",
streamCode: "workbench_kafka_refresh_barrier_gap",
message: "Kafka retention barrier gap",
},
reportSha256: "sha256:failed-existing-refresh-report",
},
},
},
tails: { samples: [], control: [], network: [] },
},
});
const failedText = String(failed.renderedText);
assert.match(failedText, /Typed failure:/u);
assert.match(failedText, /retention-barrier.*retention_barrier_failed.*flushing.*workbench_kafka_refresh_barrier_gap.*Kafka retention barrier gap/u);
});
test("observe status renderer exposes bounded Workbench Kafka debug replay evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
@@ -58,6 +58,91 @@ test("observe status returns one exact completed command without prompt payloads
assert.equal(status.exactCommand.valuesRedacted, true);
});
test("observe status preserves bounded existing-session refresh evidence", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-"));
const commandId = "cmd-existing-refresh";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "validateExistingSessionRefresh",
completedAt: "2026-07-11T12:00:00.000Z",
result: {
ok: true,
status: "passed",
profile: "pure-kafka-live",
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "live",
code: "refresh_live_handoff_validated",
before: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] },
after: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] },
refreshReplay: {
phase: "live",
topic: "hwlab.event.v1",
topicPartitions: [0],
barrier: [{ partition: 0, endOffset: "18" }],
counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 },
},
productSse: { requestCount: 1, browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true },
forbiddenRequestCount: 0,
reportPath: "/tmp/existing-refresh-report.json",
reportSha256: "sha256:existing-refresh-report",
screenshot: { path: "/tmp/existing-refresh.png", sha256: "sha256:existing-refresh-image" },
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
const result = status.exactCommand.result;
assert.equal(result.phase, "live");
assert.equal(result.code, "refresh_live_handoff_validated");
assert.equal(result.before.messageCount, 2);
assert.equal(result.before.identities, undefined);
assert.deepEqual(result.refreshReplay.barrier, [{ partition: 0, endOffset: "18" }]);
assert.equal(result.refreshReplay.counts.replayed, 8);
assert.equal(result.productSse.typedFailureCount, 0);
assert.equal(result.reportSha256, "sha256:existing-refresh-report");
assert.equal(result.screenshot.sha256, "sha256:existing-refresh-image");
});
test("observe status preserves typed existing-session refresh stream failures", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-failure-"));
const commandId = "cmd-existing-refresh-failed";
await mkdir(join(stateDir, "commands", "failed"), { recursive: true });
await writeFile(join(stateDir, "commands", "failed", `${commandId}.json`), JSON.stringify({
ok: false,
commandId,
type: "validateExistingSessionRefresh",
failedAt: "2026-07-11T12:00:00.000Z",
error: {
message: "Kafka retention barrier gap",
details: {
sessionId: "ses_existing",
traceIds: ["trc_existing"],
phase: "retention-barrier",
code: "retention_barrier_failed",
failure: {
phase: "retention-barrier",
code: "retention_barrier_failed",
streamPhase: "flushing",
streamCode: "workbench_kafka_refresh_barrier_gap",
streamFailure: { phase: "flushing", code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap" },
},
reportSha256: "sha256:failed-existing-refresh-report",
},
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
const details = status.exactCommand.error.details;
assert.equal(details.phase, "retention-barrier");
assert.equal(details.code, "retention_barrier_failed");
assert.equal(details.failure.streamPhase, "flushing");
assert.equal(details.failure.streamCode, "workbench_kafka_refresh_barrier_gap");
assert.equal(details.failure.message, "Kafka retention barrier gap");
assert.deepEqual(details.traceIds, ["trc_existing"]);
});
test("observe status keeps exact command not-found structured", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-missing-"));
const status = await runStatusScript(stateDir, "cmd-missing");
+19 -2
View File
@@ -79,6 +79,23 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
rawHwlabEventWindow:raw?{available:raw.available??null,contract:raw.contract||null,reason:raw.reason||null,samePage:raw.samePage??null,productEventsPath:raw.productEventsPath||null,productEventSourceRequestCount:raw.productEventSourceRequestCount??null,sourceContractPresent:raw.sourceContractPresent??null,unfilteredContractPresent:raw.unfilteredContractPresent??null,textBytes:raw.textBytes??null,textHash:raw.textHash||null,textPresent:raw.textPresent??null,counts:rawCounts?{received:rawCounts.received??null,retained:rawCounts.retained??null,decoded:rawCounts.decoded??null,rejected:rawCounts.rejected??null,evicted:rawCounts.evicted??null,bytes:rawCounts.bytes??null}:null,valuesRedacted:true}:null
};
};
const compactExistingSessionRefreshEvidence=(value)=>{
const item=value&&typeof value==='object'?value:null;if(!item)return{};
const replay=item.refreshReplay&&typeof item.refreshReplay==='object'?item.refreshReplay:null;
const counts=replay&&replay.counts&&typeof replay.counts==='object'?replay.counts:null;
const product=item.productSse&&typeof item.productSse==='object'?item.productSse:null;
const before=item.before&&typeof item.before==='object'?item.before:null;
const after=item.after&&typeof item.after==='object'?item.after:null;
const failure=item.failure&&typeof item.failure==='object'?item.failure:null;
const streamFailure=failure&&failure.streamFailure&&typeof failure.streamFailure==='object'?failure.streamFailure:item.streamFailure&&typeof item.streamFailure==='object'?item.streamFailure:null;
const compactDom=(dom)=>dom?{messageCount:dom.messageCount??null,traceIds:Array.isArray(dom.traceIds)?dom.traceIds.slice(0,4):[],missingIdentityCount:Array.isArray(dom.missingIdentityRows)?dom.missingIdentityRows.length:null,duplicateIdentityCount:Array.isArray(dom.duplicateIdentities)?dom.duplicateIdentities.length:null,valuesRedacted:true}:null;
return{
before:compactDom(before),after:compactDom(after),
refreshReplay:replay?{phase:replay.phase||null,topic:replay.topic||null,topicPartitions:Array.isArray(replay.topicPartitions)?replay.topicPartitions.slice(0,8):[],barrier:Array.isArray(replay.barrier)?replay.barrier.slice(0,8).map((entry)=>({partition:entry?.partition??null,endOffset:entry?.endOffset||null})):[],counts:counts?{matched:counts.matched??null,replayed:counts.replayed??null,buffered:counts.buffered??null,bufferedDelivered:counts.bufferedDelivered??null,liveDelivered:counts.liveDelivered??null,deduplicated:counts.deduplicated??null}:null,valuesRedacted:true}:null,
productSse:product?{requestCount:product.requestCount??null,browserSourceCount:product.browserSourceCount??null,openCount:product.openCount??null,connectedEventCount:product.connectedEventCount??null,businessEventCount:product.businessEventCount??null,typedFailureCount:product.typedFailureCount??null,livePhase:product.livePhase??null,valuesRedacted:true}:null,
failure:failure||streamFailure?{phase:failure&&failure.phase||item.phase||null,code:failure&&failure.code||item.code||null,streamPhase:failure&&failure.streamPhase||streamFailure&&streamFailure.phase||null,streamCode:failure&&failure.streamCode||streamFailure&&streamFailure.code||null,message:short(streamFailure&&streamFailure.message||failure&&failure.error&&failure.error.message||'',200),valuesRedacted:true}:null
};
};
const compactTraceSnapshot=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;return{status:item.status||null,traceId:item.traceId||null,disclosure:disclosure?{available:disclosure.available??null,open:disclosure.open??null,eventCount:disclosure.eventCount??null,readableRowCount:disclosure.readableRowCount??null,renderedRowCount:disclosure.renderedRowCount??null,rowWindowed:disclosure.rowWindowed??null}:null,rowCount:item.rowCount??null,readableRowCount:item.readableRowCount??null,sourceAuthorityRowCount:item.sourceAuthorityRowCount??null,readableSourceRowCount:item.readableSourceRowCount??null,projectedAuthorityRowCount:item.projectedAuthorityRowCount??null,rowSetHash:item.rowSetHash||null,valuesRedacted:true};};
const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;};
const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};};
@@ -97,7 +114,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
if(!parsed)continue;
const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null;
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
@@ -396,7 +413,7 @@ walk(dir);
const selectedFiles=out.slice(0,maxFiles);
const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}});
const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0);
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'list',view:'files',fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
`)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")} ${shellQuote(collectGrep ?? "")}`;
}
@@ -753,6 +753,12 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
if (!options.commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!options.commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
if (type === "validateExistingSessionRefresh") {
if (!options.commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[options.commandProfile.trim()] === undefined) {
throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${options.commandProfile.trim()}`);
}
}
if (type === "validateWorkbenchKafkaDebugReplay" && spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) {
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
}
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
@@ -6,6 +6,7 @@ import type { CommandResult } from "../command";
import type { NodeWebProbeObserveOptions } from "./entry";
import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect";
import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render";
import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts";
describe("web-probe observe collect child JSON recovery", () => {
test("recovers collect JSON from trans stdout dump", () => {
@@ -171,6 +172,28 @@ test("accepts the standard file collect envelope without requiring a summary vie
expect(collect.file.byteCount).toBe(21885);
});
test("accepts the structured files listing envelope when --file is absent", () => {
const options = collectOptions();
options.collectView = "files";
const stateDir = mkdtempSync(join(tmpdir(), "unidesk-web-observe-files-list-"));
writeFileSync(join(stateDir, "manifest.json"), "{}\n");
const child = Bun.spawnSync(["bash", "-lc", nodeWebObserveCollectNodeScript(options.maxFiles, null, null, null)], {
env: { ...process.env, state_dir: stateDir },
stdout: "pipe",
stderr: "pipe",
});
expect(child.exitCode).toBe(0);
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
command: ["trans", "NC01:/workspace", "sh"], cwd: "/repo", exitCode: 0,
stdout: Buffer.from(child.stdout).toString("utf8"),
stderr: Buffer.from(child.stderr).toString("utf8"), signal: null, timedOut: false,
} satisfies CommandResult);
expect(payload.ok).toBe(true);
expect(payload.status).toBe("collected");
expect((payload.collect as Record<string, unknown>).mode).toBe("list");
expect((payload.collect as Record<string, unknown>).view).toBe("files");
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
@@ -99,7 +99,7 @@ export function isWebObserveCollectJsonContract(value: Record<string, unknown>,
|| stringOrNull(value.error) !== null;
}
return command === "web-probe-observe collect"
&& (view !== null || mode === "file")
&& (view !== null || mode === "file" || mode === "list")
&& stateDir !== null;
}
@@ -69,6 +69,17 @@ test("validateRealtimeFanout rejects profiles absent from the owning YAML", () =
);
});
test("validateExistingSessionRefresh parses an existing session without prompts", () => {
const options = parseNodeWebProbeObserveOptions(
["command", "--type", "validateExistingSessionRefresh", "--profile", "pure-kafka-live", "--session-id", "ses_existing"],
"NC01", "v03", spec, "webobs-fixture", null,
);
assert.equal(options.commandType, "validateExistingSessionRefresh");
assert.equal(options.commandProfile, "pure-kafka-live");
assert.equal(options.commandSessionId, "ses_existing");
assert.equal(options.commandText, null);
});
test("validateWorkbenchKafkaDebugReplay is a YAML-enabled argument-free typed command", () => {
const options = parseNodeWebProbeObserveOptions(
["command", "--type", "validateWorkbenchKafkaDebugReplay"],
+32 -6
View File
@@ -52,6 +52,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "image"
&& sentinelActionRaw !== "control-plane"
&& sentinelActionRaw !== "publish-current"
&& sentinelActionRaw !== "pac-publish-current"
&& sentinelActionRaw !== "validate"
&& sentinelActionRaw !== "maintenance"
&& sentinelActionRaw !== "dashboard"
@@ -60,7 +61,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "inspect-url"
&& sentinelActionRaw !== "inspect-id"
) {
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel usage: sentinel plan|status|image status|control-plane status|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN; migrated delivery is triggered only by GitHub PR merge");
}
assertKnownOptions(args, new Set([
"--node",
@@ -131,19 +132,37 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
const imageAction = args[1];
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]");
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status --node NODE --lane vNN; migrated image delivery is triggered only by GitHub PR merge");
sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, sourceOverride };
} else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1];
if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") {
throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel control-plane usage: control-plane plan|status --node NODE --lane vNN; delivery writes are unavailable for migrated PaC consumers");
}
sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), sourceOverride };
} else if (sentinelActionRaw === "publish-current") {
} else if (sentinelActionRaw === "publish-current" || sentinelActionRaw === "pac-publish-current") {
const manualRecovery = args.includes("--manual-recovery") || args.includes("--recovery");
const reason = optionValue(args, "--reason") ?? null;
if (manualRecovery && (reason === null || reason.trim().length < 8)) throw new Error("web-probe sentinel publish-current manual recovery requires --reason with at least 8 characters");
sentinel = { kind: "publish", action: "publish-current", node, lane, sentinelId, dryRun: dryRun || !confirm, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), manualRecovery, reason, sourceOverride };
if (sentinelActionRaw === "pac-publish-current" && (manualRecovery || args.includes("--rerun"))) {
throw new Error("web-probe sentinel pac-publish-current does not accept recovery or rerun options");
}
sentinel = {
kind: "publish",
action: "publish-current",
node,
lane,
sentinelId,
dryRun: dryRun || !confirm,
confirm,
wait: args.includes("--wait"),
timeoutSeconds,
rerun: args.includes("--rerun"),
manualRecovery,
reason,
internalExecution: sentinelActionRaw === "pac-publish-current" ? "pac-controller" : null,
sourceOverride,
};
} else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -600,6 +619,12 @@ export function parseNodeWebProbeObserveOptions(
if (!commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
if (observeActionRaw === "command" && commandType === "validateExistingSessionRefresh") {
if (!commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[commandProfile.trim()] === undefined) {
throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${commandProfile.trim()}`);
}
}
if (observeActionRaw === "command" && commandType === "validateWorkbenchKafkaDebugReplay") {
if (spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) {
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
@@ -705,6 +730,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "newSession"
|| value === "sendPrompt"
|| value === "validateRealtimeFanout"
|| value === "validateExistingSessionRefresh"
|| value === "validateWorkbenchKafkaDebugReplay"
|| value === "validateWorkbenchTraceReadability"
|| value === "steer"
@@ -742,7 +768,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateExistingSessionRefresh, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -0,0 +1,3 @@
import { startServer } from "./server.mjs";
startServer();
@@ -1,11 +1,10 @@
import { spawn } from "node:child_process";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
export function createGithubSyncHandler(config) {
const controller = config.controller ?? createDurableSyncController(config);
@@ -1358,14 +1357,3 @@ function positiveIntegerEnv(name) {
if (!Number.isFinite(value) || value < 1) throw new Error(`invalid positive integer env ${name}`);
return value;
}
export function isMainModule(argvPath = process.argv[1], moduleUrl = import.meta.url) {
if (!argvPath) return false;
try {
return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(new URL(moduleUrl))).href;
} catch {
return pathToFileURL(argvPath).href === moduleUrl;
}
}
if (isMainModule()) startServer();
@@ -5,14 +5,12 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import test from "node:test";
import {
createGithubSyncHandler,
createDurableInboxStore,
createDurableSyncController,
createSyncCoordinator,
isMainModule,
run,
runResult,
syncRepository,
@@ -20,17 +18,21 @@ import {
const zeroDelay = async () => {};
test("main module detection follows Kubernetes ConfigMap symlinks", () => {
test("explicit entrypoint starts through Kubernetes ConfigMap symlinks", () => {
const root = mkdtempSync(join(tmpdir(), "gitea-sync-main-"));
try {
const dataDirectory = join(root, "..data");
const source = join(dataDirectory, "server.mjs");
const mounted = join(root, "server.mjs");
const serverSource = join(dataDirectory, "server.mjs");
const entrypointSource = join(dataDirectory, "entrypoint.mjs");
const marker = join(root, "started");
mkdirSync(dataDirectory);
writeFileSync(source, "export {};\n", { flag: "wx" });
symlinkSync(join("..data", "server.mjs"), mounted);
writeFileSync(serverSource, `import { writeFileSync } from "node:fs";\nexport function startServer() { writeFileSync(${JSON.stringify(marker)}, "started\\n"); }\n`, { flag: "wx" });
writeFileSync(entrypointSource, 'import { startServer } from "./server.mjs";\nstartServer();\n', { flag: "wx" });
symlinkSync(join("..data", "server.mjs"), join(root, "server.mjs"));
symlinkSync(join("..data", "entrypoint.mjs"), join(root, "entrypoint.mjs"));
assert.equal(isMainModule(mounted, pathToFileURL(source).href), true);
execFileSync(process.execPath, [join(root, "entrypoint.mjs")], { stdio: "pipe" });
assert.equal(existsSync(marker), true);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -60,7 +60,9 @@ test("owning YAML renders one child Application and the complete durable bridge
expect(manifest).toContain("argocd.argoproj.io/hook: PreSync");
expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-2"');
expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-1"');
expect(manifest).toContain("node /etc/gitea-github-sync-candidate/server.mjs &");
expect(manifest).toContain("node /etc/gitea-github-sync-candidate/entrypoint.mjs &");
expect(manifest).toContain('import { startServer } from "./server.mjs";');
expect(manifest).toContain("- /etc/gitea-github-sync/entrypoint.mjs");
expect(manifest).toContain("mountPath: /etc/gitea-github-sync-candidate");
expect(manifest).toContain("name: candidate-inbox\n emptyDir: {}");
expect(manifest).toContain('gate: "gitea-github-sync-candidate-startup"');
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh");
test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => {
const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01");
const oversizedManifest = `${currentManifest}\n${"payload-$HOME-'quote'-$(command)\n".repeat(8192)}`;
expect(Buffer.byteLength(oversizedManifest)).toBeGreaterThan(Buffer.byteLength(currentManifest));
expect(Buffer.byteLength(Buffer.from(oversizedManifest).toString("base64"))).toBeGreaterThan(128 * 1024);
const payloads = {
manifest: oversizedManifest,
repositories: JSON.stringify([{ key: "sentinel", branch: "master" }]),
statusEvaluator: "print('ok')\n",
frpcToml: "serverAddr = '127.0.0.1'\n",
};
const materializer = renderGiteaRemotePayloadMaterializer(payloads);
const result = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -eu",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
materializer,
'unidesk_gitea_materialize_payloads "$tmp"',
"python3 - \"$tmp\" <<'PY'",
"import hashlib, json, pathlib, sys",
"root = pathlib.Path(sys.argv[1])",
"print(json.dumps({p.name: {'bytes': p.stat().st_size, 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in root.iterdir()}, sort_keys=True))",
"PY",
].join("\n"),
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const observed = JSON.parse(result.stdout) as Record<string, { bytes: number; sha256: string }>;
expect(observed["gitea.k8s.yaml"]).toEqual({
bytes: Buffer.byteLength(oversizedManifest),
sha256: createHash("sha256").update(oversizedManifest).digest("hex"),
});
expect(materializer).not.toContain("UNIDESK_GITEA_MANIFEST_B64");
const summary = summarizeGiteaRemotePayloads(payloads) as any;
expect(summary.kind).toBe("trans-stdin-file-envelope");
expect(summary.valuesPrinted).toBe(false);
expect(JSON.stringify(summary)).not.toContain("payload-$HOME");
});
test("Gitea remote runner consumes materialized files instead of base64 environment variables", () => {
const source = readFileSync(remoteScriptPath, "utf8");
expect(source).toContain('unidesk_gitea_materialize_payloads "$tmp"');
expect(source).toContain('$tmp/gitea.k8s.yaml');
expect(source).toContain('$tmp/repos.json');
expect(source).not.toContain("UNIDESK_GITEA_MANIFEST_B64");
expect(source).not.toContain("UNIDESK_GITEA_STATUS_EVALUATOR_B64");
expect(source).not.toContain("UNIDESK_GITEA_REPOS_B64");
expect(source).not.toContain("UNIDESK_GITEA_FRPC_TOML_B64");
expect(source).toContain("kubectl apply --server-side --force-conflicts --dry-run=server");
expect(source).toContain("target server dry-run failed; manifest apply skipped");
expect(source.indexOf("kubectl apply --server-side --force-conflicts --dry-run=server")).toBeLessThan(
source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'),
);
});
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
export interface GiteaRemotePayloads {
manifest: string;
repositories: string;
statusEvaluator: string;
frpcToml: string;
}
interface GiteaRemotePayloadFile {
role: keyof GiteaRemotePayloads;
name: string;
value: string;
}
const payloadFiles = (payloads: GiteaRemotePayloads): GiteaRemotePayloadFile[] => [
{ role: "manifest", name: "gitea.k8s.yaml", value: payloads.manifest },
{ role: "repositories", name: "repos.json", value: payloads.repositories },
{ role: "statusEvaluator", name: "platform_infra_gitea_status_evaluator.py", value: payloads.statusEvaluator },
{ role: "frpcToml", name: "frpc.toml", value: payloads.frpcToml },
];
function payloadMarker(file: GiteaRemotePayloadFile): string {
return `__UNIDESK_GITEA_${file.role.toUpperCase()}_STDIN_PAYLOAD__`;
}
export function renderGiteaRemotePayloadMaterializer(payloads: GiteaRemotePayloads): string {
const writes = payloadFiles(payloads).map((file) => {
const encoded = Buffer.from(file.value, "utf8").toString("base64");
const marker = payloadMarker(file);
return [
` if ! base64 -d >"$unidesk_gitea_payload_dir/${file.name}" <<'${marker}'`,
encoded,
marker,
" then",
" return 1",
" fi",
].join("\n");
});
return [
"unidesk_gitea_materialize_payloads() {",
" unidesk_gitea_payload_dir=$1",
" umask 077",
...writes,
"}",
].join("\n");
}
export function summarizeGiteaRemotePayloads(payloads: GiteaRemotePayloads): Record<string, unknown> {
const files = payloadFiles(payloads).map((file) => ({
role: file.role,
bytes: Buffer.byteLength(file.value, "utf8"),
sha256: `sha256:${createHash("sha256").update(file.value).digest("hex").slice(0, 16)}`,
}));
return {
kind: "trans-stdin-file-envelope",
materialization: "target-private-tmpdir",
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
files,
valuesPrinted: false,
};
}
+32 -26
View File
@@ -5,7 +5,10 @@ tmp="$(mktemp -d)"
export tmp
trap 'rm -rf "$tmp"' EXIT
printf '%s' "$UNIDESK_GITEA_STATUS_EVALUATOR_B64" | base64 -d >"$tmp/platform_infra_gitea_status_evaluator.py"
if ! unidesk_gitea_materialize_payloads "$tmp"; then
printf '%s\n' '{"ok":false,"error":"gitea-stdin-payload-materialization-failed","valuesPrinted":false}'
exit 1
fi
json_tail() {
name="$1"
@@ -31,12 +34,10 @@ capture_json() {
run_apply() {
manifest="$tmp/gitea.k8s.yaml"
printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest"
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl create namespace "$UNIDESK_GITEA_NAMESPACE" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/namespace.out" 2>"$tmp/namespace.err"
fi
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
printf '%s' "$UNIDESK_GITEA_FRPC_TOML_B64" | base64 -d >"$tmp/frpc.toml"
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \
--from-file="$UNIDESK_GITEA_FRPC_SECRET_KEY=$tmp/frpc.toml" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/frpc-secret.out" 2>"$tmp/frpc-secret.err"
@@ -67,15 +68,23 @@ run_apply() {
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
fi
fi
dry_arg=""
apply_args="--server-side --force-conflicts"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
dry_arg="--dry-run=client"
apply_args=""
fi
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err"
server_dry_run_rc=$?
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_rc" -eq 0 ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply $apply_args $dry_arg --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
if [ "$server_dry_run_rc" -ne 0 ]; then
apply_rc=1
: >"$tmp/apply.out"
printf '%s\n' "target server dry-run failed; manifest apply skipped" >"$tmp/apply.err"
elif [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
apply_rc=0
cp "$tmp/server-dry-run.out" "$tmp/apply.out"
: >"$tmp/apply.err"
else
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
fi
else
apply_rc=1
: >"$tmp/apply.out"
@@ -150,9 +159,9 @@ run_apply() {
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-rollout.err"
fi
fi
python3 - "$frpc_rc" "$webhook_secret_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
python3 - "$frpc_rc" "$webhook_secret_rc" "$server_dry_run_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/server-dry-run.out" "$tmp/server-dry-run.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
import json, os, sys
frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:8]]
frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:9]]
def text(path, limit=3000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
@@ -160,7 +169,7 @@ def text(path, limit=3000):
return ""
dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1"
payload = {
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"route": os.environ.get("UNIDESK_GITEA_ROUTE"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
@@ -173,13 +182,14 @@ payload = {
"networkPolicy": "allow-all",
},
"steps": {
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])},
"frpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[14]), "stderrTail": text(sys.argv[15])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[16]), "stderrTail": text(sys.argv[17])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[18]), "stderrTail": text(sys.argv[19])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[20]), "stderrTail": text(sys.argv[21])},
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"frpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[11]), "stderrTail": text(sys.argv[12])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[13]), "stderrTail": text(sys.argv[14])},
"serverDryRun": {"exitCode": server_dry_run_rc, "stdoutTail": text(sys.argv[15]), "stderrTail": text(sys.argv[16])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[17]), "stderrTail": text(sys.argv[18])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[19]), "stderrTail": text(sys.argv[20])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[21]), "stderrTail": text(sys.argv[22])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[23]), "stderrTail": text(sys.argv[24])},
},
"valuesPrinted": False,
}
@@ -356,7 +366,7 @@ PY
}
mirror_repos_json() {
printf '%s' "$UNIDESK_GITEA_REPOS_B64" | base64 -d >"$tmp/repos.json"
test -f "$tmp/repos.json"
}
admin_basic_b64() {
@@ -891,10 +901,6 @@ sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_webhook_test() {
printf '{"ok":false,"mutation":false,"mode":"manual-source-delivery-test-disabled","error":"only real GitHub PR merge deliveries may enter the durable inbox","valuesPrinted":false}\n'
exit 2
}
case "$UNIDESK_GITEA_ACTION" in
apply) run_apply ;;
status) run_status ;;
+72 -83
View File
@@ -18,12 +18,15 @@ import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } f
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy";
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority";
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
const configLabel = "config/platform-infra/gitea.yaml";
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh");
const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs");
const githubSyncEntrypointFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-entrypoint.mjs");
const statusEvaluatorFile = rootPath("scripts", "src", "platform_infra_gitea_status_evaluator.py");
const fieldManager = "unidesk-platform-infra-gitea";
const y = createYamlFieldReader(configLabel);
@@ -319,7 +322,7 @@ interface MirrorRemoteParams {
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return giteaHelp();
if (action === "help" || action === "--help") return giteaHelp(args[1]);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -345,17 +348,31 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args:
};
}
export function giteaHelp(): Record<string, unknown> {
export function giteaHelp(scope?: string): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra gitea help platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
],
boundary: "这些命令只用于平台首次引导或独立平台维护,不能补齐 PR merge 后的 source delivery。",
};
}
return {
command: "platform-infra gitea status|validate|mirror status|mirror webhook status",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea validate --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror status --target <NODE> [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE> [--full|--raw]",
],
boundary: "PR merge is the only delivery trigger; these default entries are read-only observation and validation, never post-merge repair actions.",
scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"],
boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。",
};
}
@@ -830,7 +847,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
objects: manifestObjectSummary(manifest),
},
policy,
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
@@ -842,7 +859,8 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets });
const result = await capture(config, target.route, ["sh"], remote.script);
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && caddyExposureNeeded(gitea, target)
? await applyGiteaCaddyBlock(config, gitea)
@@ -856,17 +874,18 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(gitea, target),
policy,
publicExposure: publicExposureSummary(gitea),
payloadTransport: remote.payloadSummary,
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
next: giteaReadOnlyNextCommands(target.id),
};
}
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }));
const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
const parsed = parseJsonOutput(result.stdout);
const summary = parsed === null ? null : statusSummary(parsed);
return {
@@ -877,17 +896,14 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
config: configSummary(gitea, target),
summary,
remote: options.raw ? parsed : options.full ? parsed : summary ?? compactCapture(result, { full: true }),
next: {
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${target.id} --confirm`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${target.id}`,
},
next: giteaReadOnlyNextCommands(target.id),
};
}
async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }));
const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -950,11 +966,6 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
const result = await mirrorWebhookStatus(config, options);
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
}
if (action === "test") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookTest(config, options);
return options.full || options.raw ? result : renderMirrorWebhookTest(result);
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
@@ -974,7 +985,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
next: mirrorSetupNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -986,7 +997,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
@@ -1020,7 +1031,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, true, true);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1051,7 +1062,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1072,7 +1083,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
@@ -1091,7 +1102,7 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
const target = resolveTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }));
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
@@ -1109,21 +1120,6 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
};
}
async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
return {
ok: false,
action: "platform-infra-gitea-mirror-webhook-test",
mutation: false,
mode: "manual-source-delivery-test-disabled",
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
error: "manual webhook source-delivery tests are disabled; only real GitHub PR merge deliveries may enter the durable inbox",
next: mirrorReadOnlyNextCommands(target.id),
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
@@ -1296,7 +1292,8 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri
app.kubernetes.io/managed-by: unidesk`;
const reposJson = JSON.stringify(repositoriesForTarget(gitea, target).map(remoteRepoSpec), null, 2);
const serverSource = readFileSync(githubSyncServerFile, "utf8");
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).digest("hex").slice(0, 16);
const entrypointSource = readFileSync(githubSyncEntrypointFile, "utf8");
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).update("\0").update(entrypointSource).digest("hex").slice(0, 16);
const gate = sync.bridge.candidateGate;
const candidateManifest = gate.enabled ? `---
apiVersion: v1
@@ -1315,6 +1312,8 @@ data:
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
entrypoint.mjs: |
${indentBlock(entrypointSource, 4)}
---
apiVersion: batch/v1
kind: Job
@@ -1349,7 +1348,7 @@ spec:
- -eu
- -c
- |
node /etc/gitea-github-sync-candidate/server.mjs &
node /etc/gitea-github-sync-candidate/entrypoint.mjs &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT INT TERM
node --input-type=module - ${gate.healthTimeoutMs} ${gate.pollIntervalMs} <<'NODE'
@@ -1463,6 +1462,8 @@ data:
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
entrypoint.mjs: |
${indentBlock(entrypointSource, 4)}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -1533,7 +1534,7 @@ spec:
imagePullPolicy: IfNotPresent
command:
- node
- /etc/gitea-github-sync/server.mjs
- /etc/gitea-github-sync/entrypoint.mjs
ports:
- name: http
containerPort: ${sync.bridge.httpPort}
@@ -1660,7 +1661,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
value: ${yamlQuote(value)}`).join("\n");
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const env: Record<string, string> = {
@@ -1679,10 +1680,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_DRY_RUN: options.dryRun ? "1" : "0",
UNIDESK_GITEA_WAIT: options.wait ? "1" : "0",
UNIDESK_GITEA_FULL: options.full ? "1" : "0",
UNIDESK_GITEA_MANIFEST_B64: Buffer.from(manifest, "utf8").toString("base64"),
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
UNIDESK_GITEA_REPOS_B64: Buffer.from(JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))), "utf8").toString("base64"),
UNIDESK_GITEA_STATUS_EVALUATOR_B64: Buffer.from(readFileSync(statusEvaluatorFile, "utf8"), "utf8").toString("base64"),
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
@@ -1702,7 +1700,6 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
env.UNIDESK_GITEA_FRPC_SECRET_KEY = params.frpcSecret.secretKey;
env.UNIDESK_GITEA_FRPC_TOML_B64 = Buffer.from(params.frpcSecret.frpcToml, "utf8").toString("base64");
}
if (params.secrets !== undefined) {
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
@@ -1711,7 +1708,16 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
const payloads = {
manifest,
repositories: JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))),
statusEvaluator: readFileSync(statusEvaluatorFile, "utf8"),
frpcToml: params.frpcSecret?.frpcToml ?? "",
};
return {
script: `${exports}\n${renderGiteaRemotePayloadMaterializer(payloads)}\n${readFileSync(remoteScriptFile, "utf8")}`,
payloadSummary: summarizeGiteaRemotePayloads(payloads),
};
}
function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string): Array<Record<string, unknown>> {
@@ -2064,22 +2070,13 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
};
}
function mirrorSetupNextCommands(targetId: string): Record<string, string> {
return {
bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookApply: `bun scripts/cli.ts platform-infra gitea mirror webhook apply --target ${targetId} --confirm`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
};
}
function mirrorReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
statusFull: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
webhookStatusFull: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --full`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -2278,8 +2275,10 @@ function renderMirrorPlan(result: Record<string, unknown>): RenderedCliResult {
...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)),
"",
"NEXT",
` bootstrap: ${stringValue(next.bootstrap)}`,
` status: ${stringValue(next.status)}`,
` full: ${stringValue(next.statusFull)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Disclosure: credentials are summarized by presence/fingerprint only.",
]);
@@ -2403,22 +2402,6 @@ function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCli
]);
}
function renderMirrorWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), boolText(repo.pingOk), boolText(repo.testOk), boolText(repo.sourceBundleReady), stringValue(repo.sourceCommit ?? repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook test", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK TEST",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"TEST",
...(repos.length === 0 ? ["-"] : table(["KEY", "GH_PING", "POST_OK", "SNAPSHOT_READY", "SOURCE", "SNAPSHOT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const target = record(config.target);
@@ -2441,10 +2424,11 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"NEXT",
` dry-run: ${stringValue(next.dryRun)}`,
` apply: ${stringValue(next.apply)}`,
` status: ${stringValue(next.status)}`,
` validate: ${stringValue(next.validate)}`,
` mirror-status: ${stringValue(next.mirrorStatus)}`,
` webhook-status: ${stringValue(next.webhookStatus)}`,
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
"",
"Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.",
"Disclosure: Secret values are not printed; this stage does not create runner credentials.",
@@ -2453,8 +2437,10 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
function renderApply(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const payload = record(result.payloadTransport);
const remote = record(result.remote);
const steps = record(remote.steps);
const serverDryRunStep = record(steps.serverDryRun);
const frpcStep = record(steps.frpcSecret);
const frpcCleanupStep = record(steps.frpcCleanup);
const webhookSecretStep = record(steps.webhookSyncSecret);
@@ -2466,9 +2452,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra gitea apply", [
"PLATFORM-INFRA GITEA APPLY",
...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]),
...table(["TRANSPORT", "BYTES", "MATERIALIZATION", "VALUES"], [[stringValue(payload.kind), stringValue(payload.totalBytes), stringValue(payload.materialization), stringValue(payload.valuesPrinted)]]),
"",
"STEPS",
...table(["STEP", "EXIT", "DETAIL"], [
["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))],
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.stdoutTail)))],
["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))],
@@ -2604,12 +2592,13 @@ function parseCommonOptions(args: string[]): CommonOptions {
return { targetId, full, raw };
}
function nextCommands(targetId: string): Record<string, string> {
function giteaReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
dryRun: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --dry-run`,
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea status --target ${targetId}`,
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${targetId}`,
mirrorStatus: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
};
}
@@ -261,6 +261,7 @@ pipeline_rows() {
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file"
node - "$payload_file" <<'NODE'
const fs = require('node:fs');
const { classifyPacPipelineRun, pipelineRunMatchesConsumer } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const input = fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}';
const data = input ? JSON.parse(input) : { items: [] };
function firstString(...values) {
@@ -311,17 +312,19 @@ function durationSeconds(item) {
}
const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX;
const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME;
const consumer = {
id: process.env.UNIDESK_PAC_CONSUMER_ID,
repository: process.env.UNIDESK_PAC_REPOSITORY_NAME,
pipelineRunPrefix: prefix,
pipeline,
};
const rows = (data.items || [])
.filter((item) => {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(prefix)
|| labels['tekton.dev/pipeline'] === pipeline
|| labels['tekton.dev/pipelineName'] === pipeline;
})
.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0))
.filter((item) => pipelineRunMatchesConsumer(consumer, item))
.map((item) => ({ item, classification: classifyPacPipelineRun(consumer, item) }))
.filter(({ classification }) => classification.deliveryClass === 'outer-pac-event' && classification.deliveryAuthorityEligible === true)
.sort((a, b) => Date.parse(b.item.metadata?.creationTimestamp || 0) - Date.parse(a.item.metadata?.creationTimestamp || 0))
.slice(0, 8)
.map((item) => {
.map(({ item, classification }) => {
const c = cond(item);
const params = mapParams(item.spec?.params);
return {
@@ -333,6 +336,9 @@ const rows = (data.items || [])
completionTime: item.status?.completionTime || null,
durationSeconds: durationSeconds(item),
sourceCommit: extractCommit(item, params),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
classification,
};
});
process.stdout.write(JSON.stringify(rows));
@@ -344,7 +350,7 @@ history_rows() {
node <<'NODE'
const fs = require('node:fs');
const cp = require('node:child_process');
const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const { classifyPacPipelineRun, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5));
const detailId = process.env.UNIDESK_PAC_HISTORY_ID || '';
const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8'));
@@ -612,6 +618,7 @@ function rowFor(consumer, item, taskRuns) {
...evidence.sourceObservation,
sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit,
};
const classification = classifyPacPipelineRun(consumer, item);
return {
id: item.metadata.name,
consumer: consumer.id,
@@ -634,6 +641,13 @@ function rowFor(consumer, item, taskRuns) {
branch: extractBranch(item, params),
eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']),
sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']),
deliveryClass: classification.deliveryClass,
deliveryAuthorityEligible: classification.deliveryAuthorityEligible,
deliveryOwner: classification.deliveryOwner,
executionOwner: classification.executionOwner,
parentPipelineRun: classification.parentPipelineRun,
parentRelation: classification.parentRelation,
classification,
envReuse: {
status: evidence.status,
buildSkippedCount: evidence.buildSkippedCount,
@@ -645,19 +659,15 @@ function rowFor(consumer, item, taskRuns) {
artifact: evidence.artifact,
collector: evidence.collector,
taskRuns: tasks,
source: 'gitea-pac-tekton-live',
source: classification.deliveryClass === 'outer-pac-event'
? 'gitea-pac-event-observed'
: classification.deliveryClass === 'inner-deterministic-publish'
? 'tekton-deterministic-publish-observed'
: 'tekton-unclassified-observed',
valuesPrinted: false,
};
}
function pipelineRunMatchesConsumer(consumer, item) {
const labels = item.metadata?.labels || {};
const name = item.metadata?.name || '';
return name.startsWith(consumer.pipelineRunPrefix)
|| labels['tekton.dev/pipeline'] === consumer.pipeline
|| labels['tekton.dev/pipelineName'] === consumer.pipeline;
}
const rows = [];
const consumerSummaries = [];
for (const consumer of consumers) {
@@ -671,7 +681,18 @@ for (const consumer of consumers) {
matches = matches.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0));
if (!detailId) matches = matches.slice(0, limit);
for (const item of matches) rows.push(rowFor(consumer, item, taskRuns));
consumerSummaries.push({ id: consumer.id, namespace: consumer.namespace, repository: consumer.repository, pipelineRunPrefix: consumer.pipelineRunPrefix, totalPipelineRuns: (pipelineRuns.items || []).length, matched: matches.length });
const classified = matches.map((item) => classifyPacPipelineRun(consumer, item));
consumerSummaries.push({
id: consumer.id,
namespace: consumer.namespace,
repository: consumer.repository,
pipelineRunPrefix: consumer.pipelineRunPrefix,
totalPipelineRuns: (pipelineRuns.items || []).length,
matched: matches.length,
outerPacEvents: classified.filter((item) => item.deliveryClass === 'outer-pac-event').length,
innerDeterministicPublishes: classified.filter((item) => item.deliveryClass === 'inner-deterministic-publish').length,
unknown: classified.filter((item) => item.deliveryClass === 'unknown').length,
});
}
rows.sort((a, b) => Date.parse(b.triggeredAt || 0) - Date.parse(a.triggeredAt || 0));
process.stdout.write(JSON.stringify({ rows, consumers: consumerSummaries, errors: kubectlJsonErrors }));
@@ -1246,6 +1267,7 @@ function breakAt(code, stage, reason) {
}
if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed');
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`);
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
@@ -1269,6 +1291,11 @@ const realRun = {
reason: row.reason || null,
sourceCommit: row.commit || null,
sourceMatched: sourceObservation.sourceMatched === true,
deliveryClass: row.deliveryClass || 'unknown',
deliveryAuthorityEligible: row.deliveryAuthorityEligible === true,
deliveryOwner: row.deliveryOwner || null,
executionOwner: row.executionOwner || null,
parentRelation: row.parentRelation || null,
valuesPrinted: false,
},
mode: sourceObservation.mode || null,
@@ -1316,19 +1343,10 @@ if (!out.ok) process.exitCode = 1;
NODE
}
webhook_test_action() {
hooks=$(hook_summary)
hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))')
test -n "$hook_id"
gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id/tests" '{}' >/dev/null
printf '{"ok":true,"mutation":true,"webhookTestSent":true,"hookId":"%s","valuesPrinted":false}\n' "$(json_string "$hook_id")"
}
case "$UNIDESK_PAC_ACTION" in
apply) apply_action ;;
status) status_action ;;
history) history_action ;;
debug-step) debug_step_action ;;
webhook-test) webhook_test_action ;;
*) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;;
esac
+186 -306
View File
@@ -3,9 +3,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
import { dirname, join } from "node:path";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
import { nodeRuntimeEnsureGitMirrorFlushed } from "./hwlab-node/status";
import { runGitMirrorJob as runAgentRunGitMirrorJob } from "./agentrun/rest-bridge";
import type { RenderedCliResult } from "./output";
import {
capture,
@@ -17,6 +14,7 @@ import {
sha256Fingerprint,
} from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
import {
canonicalizePacPipelineSpec,
pacSourceArtifactSafeError,
@@ -78,6 +76,13 @@ interface PacConfig {
controllerServicePort: number;
waitTimeoutSeconds: number;
};
capabilities: {
sentinelInternalPublish: {
enabled: boolean;
admissionProvenance: "unavailable";
trackingIssue: string;
};
};
closeout: {
gitOpsMirrorFlush: {
enabled: boolean;
@@ -167,6 +172,17 @@ interface HistoryOptions extends CommonOptions {
detailId: string | null;
}
export interface PacHistoryConsumerIdentity {
readonly id: string;
readonly node: string;
readonly pipelineRunPrefix: string;
}
export interface PacHistoryConsumerSelection {
readonly selectedConsumerIds: readonly string[];
readonly nextConsumerId: string | null;
}
interface CloseoutOptions extends CommonOptions {
sourceCommit: string | null;
wait: boolean;
@@ -178,10 +194,6 @@ interface ApplyOptions extends CommonOptions {
wait: boolean;
}
interface WebhookTestOptions extends CommonOptions {
confirm: boolean;
}
interface SecretMaterial {
adminUsername: string;
adminPassword: string;
@@ -193,7 +205,7 @@ interface SecretMaterial {
export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "help" || action === "--help") return help();
if (action === "help" || action === "--help") return help(args[1] ?? null);
if (action === "plan") {
const options = parseCommonOptions(args.slice(1));
const result = plan(options);
@@ -224,11 +236,6 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const result = await debugStep(config, options);
return options.full || options.raw ? result : options.json ? result : renderDebugStep(result);
}
if (action === "webhook-test") {
const options = parseWebhookTestOptions(args.slice(1));
const result = await webhookTest(config, options);
return options.json || options.full || options.raw ? result : renderWebhookTest(result);
}
if (action === "source-artifact") {
if (args.length === 1 || args.slice(1).includes("--help") || args.slice(1).includes("-h")) return sourceArtifactHelp();
const structuredError = args.includes("--json") || args.includes("--full");
@@ -275,7 +282,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
return sourceArtifactValidationError(safeError);
}
}
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() };
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help(null) };
}
function sourceArtifactHelp(): RenderedCliResult {
@@ -307,31 +314,57 @@ function sourceArtifactValidationError(error: ReturnType<typeof pacSourceArtifac
};
}
function help(): Record<string, unknown> {
function help(scope: string | null): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
command: "platform-infra pipelines-as-code help platform-bootstrap",
scope: "explicit-initial-platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --confirm",
],
boundary: "仅用于首次安装或显式平台 bootstrap;不得在 source PR 合并后用于交付、恢复或补跑。",
mutation: "explicit-confirm-required",
};
}
if (scope === "compatibility-diagnostics") {
return {
command: "platform-infra pipelines-as-code help compatibility-diagnostics",
scope: "explicit-readonly-or-connectivity-diagnostics",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target <NODE> --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer <consumer> --full",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <consumer> --limit 10 --full",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> --json",
],
boundary: "本 scope 只允许读取现有 PaC/Tekton/Argo 证据;不得 POST hook test、伪造 push、交付、恢复或补跑。",
};
}
return {
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step|source-artifact",
command: "platform-infra pipelines-as-code plan|status|history|debug-step|source-artifact",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
],
diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.",
boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.",
maintenanceHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
compatibilityHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
boundary: "唯一正式触发链:GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime。",
};
}
function readPacConfig(): PacConfig {
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
const release = y.objectField(root, "release", "");
const capabilities = y.objectField(root, "capabilities", "");
const sentinelInternalPublish = y.objectField(capabilities, "sentinelInternalPublish", "capabilities");
const closeout = y.objectField(root, "closeout", "");
const gitOpsMirrorFlush = y.objectField(closeout, "gitOpsMirrorFlush", "closeout");
const gitea = y.objectField(root, "gitea", "");
@@ -369,6 +402,13 @@ function readPacConfig(): PacConfig {
controllerServicePort: y.portField(release, "controllerServicePort", "release"),
waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"),
},
capabilities: {
sentinelInternalPublish: {
enabled: y.booleanField(sentinelInternalPublish, "enabled", "capabilities.sentinelInternalPublish"),
admissionProvenance: y.enumField(sentinelInternalPublish, "admissionProvenance", "capabilities.sentinelInternalPublish", ["unavailable"] as const),
trackingIssue: urlField(sentinelInternalPublish, "trackingIssue", "capabilities.sentinelInternalPublish"),
},
},
closeout: {
gitOpsMirrorFlush: {
enabled: y.booleanField(gitOpsMirrorFlush, "enabled", "closeout.gitOpsMirrorFlush"),
@@ -712,8 +752,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
config: configSummary(pac, consumer, repository),
release: pac.release,
repository: repositorySummary(repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
secrets,
policy: policyChecks(repository),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -738,7 +777,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(pac, consumer, repository),
release: { version: pac.release.version, manifestUrl: pac.release.manifestUrl },
repository: repositorySummary(repository),
consumer,
consumer: consumerObservationSummary(consumer),
secrets: secretSummaries(pac),
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
@@ -754,14 +793,15 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
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 summary = parsed === null ? null : statusSummary(parsed);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
return {
ok: result.exitCode === 0 && summary?.ready === true,
ok: result.exitCode === 0 && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
action: "platform-infra-pipelines-as-code-status",
mutation: false,
target: targetSummary(target),
config: compactConfigSummary(pac, consumer, repository),
consumer,
closeout: closeoutDeclaration(pac, consumer),
consumer: consumerObservationSummary(consumer),
deliveryAuthority,
coverage: consumerCoverage(pac, target.id),
summary,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
@@ -827,12 +867,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
elapsedMs: Date.now() - startedAt,
valuesPrinted: false,
},
next: {
status: `bun scripts/cli.ts cicd status --node ${nodeId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.id} --limit 10`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --wait`,
valuesPrinted: false,
},
next: pacNodeReadOnlyNext(target.id),
details: options.full || options.raw ? statuses : undefined,
valuesPrinted: false,
};
@@ -859,20 +894,20 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit;
const ciReady = closeoutCiReady(current, options.sourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", "started", startedAt, {
required: pac.closeout.gitOpsMirrorFlush.enabled && consumer.closeoutGitOpsMirrorFlush,
ciReady,
sourceCommit: observedSourceCommit,
});
let gitOpsMirrorFlush = await runCloseoutGitOpsMirrorFlush(config, pac, consumer, summary, latest, ciReady, observedSourceCommit);
printCloseoutProgress(target, consumer, "gitops-mirror-flush", record(gitOpsMirrorFlush).ok === false ? "failed" : "completed", startedAt, {
mode: record(gitOpsMirrorFlush).mode,
executed: record(gitOpsMirrorFlush).executed === true,
required: record(gitOpsMirrorFlush).required === true,
});
const gitOpsMirrorFlushReady = record(gitOpsMirrorFlush).ok !== false;
let gitOpsMirrorFlush: Record<string, unknown> = {
ok: true,
enabled: false,
configured: consumer.closeoutGitOpsMirrorFlush,
required: false,
applicable: false,
mode: "automatic-chain-owned-observation",
executed: false,
reason: "PaC migrated delivery owns GitOps publication and mirror persistence; compatibility closeout is read-only.",
configSource: configLabel,
valuesPrinted: false,
};
const runtimeStartedAt = Date.now();
if (ciReady && gitOpsMirrorFlushReady) {
if (ciReady) {
current = await observeCloseoutStatus(config, options, target, consumer, "runtime", 1, startedAt);
runtimeAttempts += 1;
while (options.wait && !closeoutReady(current, options.sourceCommit) && Date.now() - runtimeStartedAt < waitMs) {
@@ -892,7 +927,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
afterSummary: summary,
valuesPrinted: false,
};
const ready = baseReady && gitOpsMirrorFlushReady;
const ready = baseReady;
printCloseoutProgress(target, consumer, "closeout", ready ? "completed" : "blocked", startedAt, {
ready,
sourceMatched,
@@ -903,7 +938,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis
return {
ok: ready,
action: "platform-infra-pipelines-as-code-closeout",
mutation: record(gitOpsMirrorFlush).executed === true,
mutation: false,
target: targetSummary(target),
consumer,
sourceCommit: options.sourceCommit,
@@ -962,13 +997,8 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage:
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const targetConsumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === target.id.toLowerCase());
const inferredDetailConsumers = options.detailId === null
? []
: targetConsumers.filter((consumer) => options.detailId?.startsWith(consumer.pipelineRunPrefix));
const selectedConsumers = options.consumerId === null
? inferredDetailConsumers.length === 1 ? inferredDetailConsumers : targetConsumers
: [resolveConsumer(pac, options.consumerId)];
const selection = resolvePacHistoryConsumerSelection(pac.consumers, target.id, options.consumerId, options.detailId);
const selectedConsumers = selection.selectedConsumerIds.map((id) => resolveConsumer(pac, id));
const firstConsumer = selectedConsumers[0];
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef);
@@ -1003,7 +1033,46 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
valuesPrinted: false,
},
remote: parsed === null || options.raw ? remote : undefined,
next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId),
next: selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId),
};
}
export function resolvePacHistoryConsumerSelection(
consumers: readonly PacHistoryConsumerIdentity[],
targetId: string,
requestedConsumerId: string | null,
detailId: string | null,
): PacHistoryConsumerSelection {
const targetConsumers = consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase());
if (targetConsumers.length === 0) throw new Error(`no Pipelines-as-Code consumers are configured for target ${targetId}`);
const requestedConsumer = requestedConsumerId === null
? null
: consumers.find((consumer) => consumer.id.toLowerCase() === requestedConsumerId.toLowerCase()) ?? null;
if (requestedConsumerId !== null && requestedConsumer === null) {
throw new Error(`unknown Pipelines-as-Code consumer ${requestedConsumerId}`);
}
if (requestedConsumer !== null && requestedConsumer.node.toLowerCase() !== targetId.toLowerCase()) {
throw new Error(`Pipelines-as-Code consumer ${requestedConsumer.id} belongs to ${requestedConsumer.node}, not target ${targetId}`);
}
if (detailId !== null) {
const matches = targetConsumers.filter((consumer) => detailId.startsWith(consumer.pipelineRunPrefix));
if (matches.length !== 1) {
throw new Error(`PipelineRun ${detailId} must match exactly one consumer on target ${targetId}; matched ${matches.length}`);
}
const matched = matches[0] as PacHistoryConsumerIdentity;
if (requestedConsumer !== null && requestedConsumer.id.toLowerCase() !== matched.id.toLowerCase()) {
throw new Error(`PipelineRun ${detailId} belongs to ${matched.id}, not requested consumer ${requestedConsumer.id}`);
}
return { selectedConsumerIds: [matched.id], nextConsumerId: matched.id };
}
if (requestedConsumer !== null) {
return { selectedConsumerIds: [requestedConsumer.id], nextConsumerId: requestedConsumer.id };
}
return {
selectedConsumerIds: targetConsumers.map((consumer) => consumer.id),
nextConsumerId: targetConsumers.length === 1 ? (targetConsumers[0] as PacHistoryConsumerIdentity).id : null,
};
}
@@ -1030,26 +1099,6 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
};
}
async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
const consumer = resolveConsumer(pac, options.consumerId);
const repository = resolveRepository(pac, consumer.repositoryRef);
if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" };
const secrets = ensureSecrets(pac, false);
const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, repository, consumer, { ...options, dryRun: false, wait: false }, secrets, ""));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-webhook-test",
mutation: true,
target: targetSummary(target),
consumer,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
}
async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
const response = await fetch(pac.release.manifestUrl);
if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`);
@@ -1058,7 +1107,7 @@ async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
return text;
}
function remoteScript(action: "apply" | "status" | "history" | "debug-step" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
const env: Record<string, string> = {
UNIDESK_PAC_ACTION: action,
@@ -1385,11 +1434,11 @@ function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRep
path: configLabel,
metadata: pac.metadata,
release: pac.release,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch },
display: pac.display,
repositories: pac.repositories.map(repositorySummary),
consumers: pac.consumers,
consumers: pac.consumers.map(consumerObservationSummary),
repository: repositorySummary(repository),
consumer,
valuesPrinted: false,
@@ -1400,7 +1449,7 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
return {
path: configLabel,
releaseVersion: pac.release.version,
closeout: pac.closeout,
capabilities: capabilitySummary(pac),
repository: repository.name,
providerType: repository.providerType,
sourceUrl: repository.cloneUrl,
@@ -1412,15 +1461,13 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository:
};
}
function closeoutDeclaration(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
const config = pac.closeout.gitOpsMirrorFlush;
function capabilitySummary(pac: PacConfig): Record<string, unknown> {
return {
enabled: config.enabled,
configured: consumer.closeoutGitOpsMirrorFlush,
required: config.enabled && consumer.closeoutGitOpsMirrorFlush,
lane: consumer.closeoutGitOpsMirrorLane ?? consumer.lane,
waitTimeoutSeconds: config.waitTimeoutSeconds,
maxAttempts: config.maxAttempts,
sentinelInternalPublish: {
...pac.capabilities.sentinelInternalPublish,
configRef: `${configLabel}#capabilities.sentinelInternalPublish`,
valuesPrinted: false,
},
valuesPrinted: false,
};
}
@@ -1441,34 +1488,38 @@ function repositorySummary(repository: PacRepository): Record<string, unknown> {
};
}
function consumerObservationSummary(consumer: PacConsumer): Record<string, unknown> {
return {
id: consumer.id,
node: consumer.node,
lane: consumer.lane,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
valuesPrinted: false,
};
}
function consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
return pac.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
const suffix = consumerSuffix(consumer.id, pac.defaults.consumerId);
return {
consumer: consumer.id,
source: `${repository.owner}/${repository.repo}@${repository.params.source_branch ?? "-"}`,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id}`,
};
});
}
function consumerSuffix(consumerId: string, defaultConsumerId: string): string {
return consumerId === defaultConsumerId ? "" : ` --consumer ${consumerId}`;
}
function nextCommands(targetId: string, consumerId: string, defaultConsumerId: string): Record<string, string> {
const suffix = consumerSuffix(consumerId, defaultConsumerId);
return {
apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${targetId}${suffix} --wait`,
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`,
};
function nextCommands(targetId: string, consumerId: string, _defaultConsumerId: string): Record<string, unknown> {
return pacReadOnlyNext(targetId, consumerId);
}
function compactPlanJson(result: Record<string, unknown>): Record<string, unknown> {
@@ -1483,7 +1534,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
path: config.path,
metadata: config.metadata,
release: config.release,
closeout: config.closeout,
capabilities: config.capabilities,
gitea: config.gitea,
display: config.display,
valuesPrinted: false,
@@ -1500,7 +1551,6 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
},
closeout: result.closeout,
secrets: result.secrets,
policy: result.policy,
next: result.next,
@@ -1523,7 +1573,8 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
},
closeout: result.closeout,
deliveryAuthority: result.deliveryAuthority,
capabilities: record(result.config).capabilities,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -1711,7 +1762,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
const repository = record(result.repository);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const internalPublish = record(record(config.capabilities).sentinelInternalPublish);
const secrets = arrayRecords(result.secrets);
const policy = arrayRecords(result.policy);
const lines = [
@@ -1726,25 +1777,23 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
["cd", "Argo", stringValue(consumer.argoApplication)],
]),
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
"",
"SECRETS",
...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])),
"",
"POLICY",
...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])),
"",
"NEXT",
` apply: ${stringValue(record(result.next).apply)}`,
"CAPABILITIES",
...table(["NAME", "ENABLED", "PROVENANCE", "TRACKING"], [[
"sentinelInternalPublish",
boolText(internalPublish.enabled),
stringValue(internalPublish.admissionProvenance),
stringValue(internalPublish.trackingIssue),
]]),
"",
"READ-ONLY NEXT",
` status: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
];
return rendered(result, "platform-infra pipelines-as-code plan", lines);
}
@@ -1770,7 +1819,8 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);
const closeout = record(result.closeout);
const deliveryAuthority = record(result.deliveryAuthority);
const internalPublish = record(record(record(result.config).capabilities).sentinelInternalPublish);
const coverage = arrayRecords(result.coverage);
const latest = record(summary.latestPipelineRun);
const taskRuns = arrayRecords(summary.taskRuns);
@@ -1788,18 +1838,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
...table(["CONSUMER", "SOURCE", "NAMESPACE", "PIPELINE", "ARGO_APP"], coverage.map((item) => [stringValue(item.consumer), stringValue(item.source), stringValue(item.namespace), stringValue(item.pipeline), stringValue(item.argoApplication)])),
"",
]),
...table(["CONSUMER", "READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
...table(["CONSUMER", "READY", "AUTHORITY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), stringValue(deliveryAuthority.kind), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
` repository-condition: ${compactLine(stringValue(summary.repositoryCondition))}`,
"",
"CLOSEOUT",
...table(["GLOBAL_ENABLED", "CONSUMER_CONFIGURED", "REQUIRED", "LANE", "WAIT_S", "MAX_ATTEMPTS"], [[
boolText(closeout.enabled),
boolText(closeout.configured),
boolText(closeout.required),
stringValue(closeout.lane),
stringValue(closeout.waitTimeoutSeconds),
stringValue(closeout.maxAttempts),
]]),
` sentinel-internal-publish: enabled=${boolText(internalPublish.enabled)} provenance=${stringValue(internalPublish.admissionProvenance)} tracking=${stringValue(internalPublish.trackingIssue)}`,
"",
"GITEA HOOKS",
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
@@ -1852,9 +1893,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
` 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)}`}`,
"",
"NEXT",
` closeout: ${stringValue(record(result.next).closeout)}`,
` full: ${stringValue(record(result.next).status)} --full`,
` all-history: ${stringValue(record(result.next).history).replace(/ --consumer [^ ]+/u, "")} --limit 10`,
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
];
return rendered(result, "platform-infra pipelines-as-code status", lines);
}
@@ -1905,7 +1946,7 @@ function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
"CICD DIAGNOSIS",
...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]),
"",
"GITOPS MIRROR FLUSH",
"AUTOMATIC DELIVERY OBSERVATION",
...table(["ENABLED", "REQUIRED", "OK", "MODE", "EXECUTED", "PENDING", "IN_SYNC"], [[
boolText(gitOpsMirrorFlush.enabled),
boolText(gitOpsMirrorFlush.required),
@@ -1945,10 +1986,11 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
]),
"",
"TRIGGERS",
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "REPO", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "CLASS", "OWNER", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [
stringValue(row.displayTime) === "-" ? isoShort(stringValue(row.triggeredAt)) : stringValue(row.displayTime),
stringValue(row.consumer),
stringValue(row.repo),
stringValue(row.deliveryClass),
stringValue(row.deliveryOwner ?? row.executionOwner),
statusText(row),
stringValue(row.durationSeconds),
short(stringValue(row.commit)),
@@ -1964,18 +2006,6 @@ function renderHistory(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code history", lines.filter((line): line is string => line !== null));
}
function renderWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const remote = record(result.remote);
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE WEBHOOK TEST",
...table(["OK", "MUTATION", "HOOK", "SENT"], [[boolText(result.ok), boolText(result.mutation), stringValue(remote.hookId), boolText(remote.webhookTestSent)]]),
"",
"NEXT",
` status: ${stringValue(record(result.next).status)}`,
];
return rendered(result, "platform-infra pipelines-as-code webhook-test", lines);
}
function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
const checks = arrayRecords(result.checks);
const realRun = record(result.realRun);
@@ -1998,8 +2028,10 @@ function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
...(Object.keys(realRun).length === 0 ? [] : [
"",
"REAL PIPELINERUN EVIDENCE",
...table(["FOUND", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
...table(["FOUND", "CLASS", "AUTHORITY", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
boolText(realRun.found),
stringValue(pipelineRun.deliveryClass),
boolText(pipelineRun.deliveryAuthorityEligible),
stringValue(pipelineRun.status ?? pipelineRun.reason),
short(stringValue(pipelineRun.sourceCommit), 16),
stringValue(realRun.mode),
@@ -2064,23 +2096,6 @@ function parseApplyOptions(args: string[]): ApplyOptions {
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
}
function parseWebhookTestOptions(args: string[]): WebhookTestOptions {
const commonArgs: string[] = [];
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") confirm = true;
else {
commonArgs.push(arg);
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
commonArgs.push(args[index + 1] ?? "");
index += 1;
}
}
}
return { ...parseCommonOptions(commonArgs), confirm };
}
function parseCloseoutOptions(args: string[]): CloseoutOptions {
const commonArgs: string[] = [];
let sourceCommit: string | null = null;
@@ -2250,146 +2265,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runCloseoutGitOpsMirrorFlush(config: UniDeskConfig, pac: PacConfig, consumer: PacConsumer, summary: Record<string, unknown>, latest: Record<string, unknown>, baseReady: boolean, observedSourceCommit: string): Promise<Record<string, unknown>> {
const cfg = pac.closeout.gitOpsMirrorFlush;
const configSource = `${configLabel}.closeout.gitOpsMirrorFlush`;
const required = cfg.enabled && consumer.closeoutGitOpsMirrorFlush;
if (baseReady && stringValue(record(summary.diagnostics).code) === "pac-ready-no-runtime-change") {
return {
ok: true,
enabled: cfg.enabled,
required,
applicable: false,
mode: "retained-no-runtime-change",
executed: false,
reason: "the successful source observation produced no GitOps change, so there is no new revision to flush",
configSource,
valuesPrinted: false,
};
}
if (!required) {
return {
ok: true,
enabled: cfg.enabled,
required: false,
mode: cfg.enabled ? "consumer-not-enabled" : "disabled",
executed: false,
configSource,
valuesPrinted: false,
};
}
if (!baseReady) {
return {
ok: true,
enabled: true,
required: true,
mode: "waiting-for-pac-ready",
executed: false,
configSource,
valuesPrinted: false,
};
}
const pipelineRun = stringValue(latest.name);
if (observedSourceCommit === "-" || pipelineRun === "-") {
return {
ok: false,
enabled: true,
required: true,
mode: "missing-closeout-context",
executed: false,
configSource,
degradedReason: "pac-closeout-gitops-mirror-context-missing",
valuesPrinted: false,
};
}
if (consumer.id.startsWith("agentrun-")) {
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => runAgentRunGitMirrorJob(config, "flush", {
node: consumer.node,
lane: consumer.lane,
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds: cfg.waitTimeoutSeconds,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.ok === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: record(record(flush.status).summary),
afterSummary: record(record(flush.status).summary),
jobName: flush.jobName ?? null,
degradedReason: flush.ok === true ? undefined : "pac-closeout-agentrun-gitops-mirror-flush-failed",
next: record(flush.next),
flush,
progressLines,
valuesPrinted: false,
};
}
const deadlineMs = Date.now() + (cfg.waitTimeoutSeconds * 1000);
const gitOpsMirrorLane = consumer.closeoutGitOpsMirrorLane ?? consumer.lane;
const scoped = parseNodeScopedDelegatedOptions("git-mirror", [
"flush",
"--node",
consumer.node,
"--lane",
gitOpsMirrorLane,
"--confirm",
"--wait",
"--timeout-seconds",
String(cfg.waitTimeoutSeconds),
]);
const progressLines: string[] = [];
const flush = await captureStderrLines(progressLines, () => nodeRuntimeEnsureGitMirrorFlushed(scoped, "post", observedSourceCommit, pipelineRun, null, {
deadlineMs,
maxAttempts: cfg.maxAttempts,
}));
return {
ok: flush.ok === true,
enabled: true,
required: true,
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
executed: flush.executed === true,
sourceCommit: observedSourceCommit,
pipelineRun,
configSource,
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
maxAttempts: cfg.maxAttempts,
beforeSummary: flush.beforeSummary ?? null,
afterSummary: flush.afterSummary ?? null,
jobName: flush.jobName ?? null,
degradedReason: flush.degradedReason ?? undefined,
next: flush.next ?? undefined,
flush,
progressLines,
valuesPrinted: false,
};
}
async function captureStderrLines<T>(lines: string[], callback: () => T | Promise<T>): Promise<T> {
const stderr = process.stderr as typeof process.stderr & { write: (...args: unknown[]) => boolean };
const originalWrite = stderr.write.bind(process.stderr);
stderr.write = ((chunk: unknown, ...args: unknown[]): boolean => {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
lines.push(text);
originalWrite(text);
const callbackArg = args.find((arg) => typeof arg === "function") as (() => void) | undefined;
callbackArg?.();
return true;
}) as typeof stderr.write;
try {
return await callback();
} finally {
stderr.write = originalWrite as typeof stderr.write;
}
}
function pipelineRunGate(latest: Record<string, unknown>): Record<string, unknown> {
const name = stringValue(latest.name);
@@ -2567,6 +2442,11 @@ function renderHistoryDetail(row: Record<string, unknown>): string[] {
"DETAIL",
...table(["FIELD", "VALUE"], [
["id", stringValue(row.id ?? row.pipelineRun)],
["deliveryClass", stringValue(row.deliveryClass)],
["deliveryAuthorityEligible", stringValue(row.deliveryAuthorityEligible)],
["deliveryOwner", stringValue(row.deliveryOwner)],
["executionOwner", stringValue(row.executionOwner)],
["parentRelation", stringValue(row.parentRelation)],
["pipeline", stringValue(row.pipeline)],
["branch", stringValue(row.branch)],
["commit", stringValue(row.commit)],
+7 -6
View File
@@ -462,13 +462,14 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer <consumer> --limit 10 [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --id <pipelinerun> [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> [--id <pipelinerun>] [--json]",
"bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
"bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and read-only Gitea + Pipelines-as-Code observation for migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target,
codexPool: {
usage: [
@@ -0,0 +1,93 @@
// Responsibility: expose the YAML-owned sentinel internal publish capability without claiming provenance.
import { rootPath } from "./config";
import type { CicdDeliveryAuthority } from "./cicd-delivery-authority";
import type { WebProbeSentinelOptions } from "./hwlab-node-web-sentinel-cicd-shared";
import { readYamlRecord } from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
const configPath = "config/platform-infra/pipelines-as-code.yaml";
const configRef = `${configPath}#capabilities.sentinelInternalPublish`;
export interface SentinelPacInternalPublishCapability {
readonly enabled: boolean;
readonly admissionProvenance: "unavailable";
readonly trackingIssue: string;
readonly configRef: string;
readonly valuesPrinted: false;
}
export type SentinelPacInternalPublishAuthorization = {
readonly allowed: false;
readonly mode: "sentinel-pac-internal-publish-blocked";
readonly reason:
| "operator-entrypoint"
| "delivery-authority-not-pac"
| "capability-disabled"
| "admission-provenance-unavailable";
readonly capability: SentinelPacInternalPublishCapability;
readonly valuesPrinted: false;
};
export function readSentinelPacInternalPublishCapability(): SentinelPacInternalPublishCapability {
const raw = readYamlRecord<Record<string, unknown>>(rootPath(...configPath.split("/")), "platform-infra-pipelines-as-code");
const composed = materializeYamlComposition(raw, { label: configPath }).value;
const capabilities = record(composed.capabilities, `${configPath}#capabilities`);
const capability = record(capabilities.sentinelInternalPublish, configRef);
const enabled = booleanField(capability.enabled, `${configRef}.enabled`);
const admissionProvenance = stringField(capability.admissionProvenance, `${configRef}.admissionProvenance`);
if (admissionProvenance !== "unavailable") {
throw new Error(`${configRef}.admissionProvenance must remain unavailable until the admission contract is implemented`);
}
const trackingIssue = stringField(capability.trackingIssue, `${configRef}.trackingIssue`);
if (!/^https:\/\/github\.com\/pikasTech\/unidesk\/issues\/\d+$/u.test(trackingIssue)) {
throw new Error(`${configRef}.trackingIssue must reference the owning GitHub issue`);
}
return {
enabled,
admissionProvenance,
trackingIssue,
configRef,
valuesPrinted: false,
};
}
export function authorizeSentinelPacInternalPublish(
options: WebProbeSentinelOptions,
authority: CicdDeliveryAuthority,
capability = readSentinelPacInternalPublishCapability(),
): SentinelPacInternalPublishAuthorization {
if (options.kind !== "publish" || options.internalExecution !== "pac-controller") {
return denied("operator-entrypoint", capability);
}
if (authority.kind !== "pac-pr-merge") return denied("delivery-authority-not-pac", capability);
if (!capability.enabled) return denied("capability-disabled", capability);
return denied("admission-provenance-unavailable", capability);
}
function denied(
reason: SentinelPacInternalPublishAuthorization["reason"],
capability: SentinelPacInternalPublishCapability,
): SentinelPacInternalPublishAuthorization {
return {
allowed: false,
mode: "sentinel-pac-internal-publish-blocked",
reason,
capability,
valuesPrinted: false,
};
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function booleanField(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function stringField(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
return value.trim();
}