47 KiB
API Deployment Observation Foundation Implementation Plan
Execution note: Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state.
Goal: Build Phase 2 of the approved API Runtime Foundation: a ktWorkflow deployment observation adapter that gathers Jenkins, K8s, Pod, /health/runtime, and task smoke evidence before any deployed feature is called complete.
Architecture: Keep API runtime health as the source contract and implement the observation workflow in mcp/ktWorkflow. The new tool generates a bounded, read-only NAS script by default, can execute it only when execute=true, normalizes command output into an API-compatible runtime evidence summary, and writes evidence under .kt-workspace/test-artifacts/deploy-observation. Deployment rollout evidence remains separate from functional smoke evidence.
Tech Stack: TypeScript ESM, @modelcontextprotocol/sdk, Zod, Node child_process, PowerShell, ssh nas, Kubernetes kubectl, Jenkins home/log read-only inspection, curl, pnpm.
Current Context
- API repo:
D:\MyFiles\KT\Node\kt-template-online-api, branchdev, currently clean at the time this plan was written. - ktWorkflow repo:
D:\MyFiles\KT\mcp\ktWorkflow, branchmain, currently has pre-existing changes inREADME.md,src/selfTest.ts, andsrc/tools/review.ts. - Do not create a
.worktree. The user explicitly prefers a normal development branch. - Do not overwrite the dirty ktWorkflow files. The execution phase must classify or preserve those changes before editing files that overlap this plan.
- API public health contract is already implemented as plain JSON:
export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked';
export interface RuntimeHealthCheck {
name: string;
status: RuntimeHealthStatus;
critical: boolean;
message: string;
detail?: Record<string, unknown>;
}
export interface RuntimeHealthReport {
service: 'kt-template-online-api';
checkedAt: string;
status: RuntimeHealthStatus;
checks: RuntimeHealthCheck[];
}
File Structure
-
Create
D:\MyFiles\KT\mcp\ktWorkflow\src\tools\deployObservation.ts- Builds the read-only NAS observation command.
- Parses marked command sections from stdout.
- Normalizes Jenkins, Deployment, Pod, runtime health, and smoke data into evidence.
- Writes JSON evidence under
.kt-workspace/test-artifacts/deploy-observation.
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\types.ts- Add
DeployObservationInput, evidence DTOs, and assertion types.
- Add
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\core\cli.ts- Add
parseDeployObservationCliArgs(argv).
- Add
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\registerTools.ts- Register MCP tool
kt_deploy_observation.
- Register MCP tool
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\server.ts- Add
--deploy-observationCLI branch.
- Add
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\core\constants.ts- Add
kt_deploy_observationtoregisteredToolNames.
- Add
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\tools\closeout.ts- Point
deploy-observationupgrade target to the new tool file.
- Point
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts- Add self-tests for CLI parsing, stdout section parsing, evidence normalization, and dry-run command generation.
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\package.json- Add
deploy-observationscript.
- Add
-
Modify
D:\MyFiles\KT\mcp\ktWorkflow\README.md- Document the new MCP tool and CLI.
-
Modify
D:\MyFiles\KT\TASKS.md- Record Phase 2 implementation and verification evidence after code is complete.
Task 0: Protect Branch And Existing Changes
Files:
-
Read:
D:\MyFiles\KT\mcp\ktWorkflow\README.md -
Read:
D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts -
Read:
D:\MyFiles\KT\mcp\ktWorkflow\src\tools\review.ts -
Step 1: Confirm repo states
Run:
git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch
git -C D:\MyFiles\KT\mcp\ktWorkflow status --short --branch
git -C D:\MyFiles\KT status --short --branch
Expected:
API repo is on dev and clean.
ktWorkflow repo shows existing modified README.md, src/selfTest.ts, src/tools/review.ts.
Root repo may be dirty from unrelated workspace records.
- Step 2: Inspect overlapping ktWorkflow changes
Run:
git -C D:\MyFiles\KT\mcp\ktWorkflow diff -- README.md src/selfTest.ts src/tools/review.ts
Expected:
The diff is read before edits. Existing hunks are preserved or deliberately carried into the development branch.
- Step 3: Use a normal development branch
Run:
git -C D:\MyFiles\KT\mcp\ktWorkflow branch --show-current
git -C D:\MyFiles\KT\mcp\ktWorkflow switch -c dev
Expected if dev does not exist:
Switched to a new branch 'dev'
Expected if dev already exists:
git -C D:\MyFiles\KT\mcp\ktWorkflow switch dev
Switched to branch 'dev'
- Step 4: Stop if switching would overwrite files
If Git refuses the branch switch because local changes would be overwritten, stop execution and report the exact file list. Do not run git reset, git checkout --, or any destructive cleanup without explicit user approval.
Task 1: Add Failing Self-Tests For Deploy Observation
Files:
-
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts -
Step 1: Add imports that should fail before implementation
Modify the import area in src/selfTest.ts:
import {
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs,
parseWorkstreamCloseoutCliArgs,
} from "./core/cli.js";
import {
buildDeployObservation,
buildDeployObservationSectionMap,
normalizeDeployObservationEvidence,
} from "./tools/deployObservation.js";
- Step 2: Add the deploy observation self-test block
Add this block after the existing workstream closeout self-tests and before the final test artifact write:
const deployObservationInput = parseDeployObservationCliArgs([
"node",
"server",
"--deploy-observation",
"--project",
"api",
"--job",
"kt-template-online-api",
"--build",
"132",
"--commit",
"abc1234",
"--image-tag",
"abc1234",
"--namespace",
"kt-prod",
"--deployment",
"kt-template-online-api",
"--container",
"api",
"--health-url",
"http://127.0.0.1:48085/health/runtime",
"--smoke",
"curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime",
]);
if (
deployObservationInput.project !== "api" ||
deployObservationInput.jobName !== "kt-template-online-api" ||
deployObservationInput.buildNumber !== "132" ||
deployObservationInput.expectedCommit !== "abc1234" ||
deployObservationInput.imageTag !== "abc1234" ||
deployObservationInput.namespace !== "kt-prod" ||
deployObservationInput.deployment !== "kt-template-online-api" ||
deployObservationInput.container !== "api" ||
deployObservationInput.healthUrl !==
"http://127.0.0.1:48085/health/runtime" ||
deployObservationInput.smoke !==
"curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime" ||
deployObservationInput.execute !== false
) {
throw new Error("deploy observation CLI parser self-check failed");
}
const deployObservationSections = buildDeployObservationSectionMap(
[
"__KT_SECTION:jenkins_meta__",
"buildNumber=132",
"result=SUCCESS",
"__KT_SECTION:jenkins_log_tail__",
"Checking out Revision abc1234",
"Finished: SUCCESS",
"__KT_SECTION:deployment_json__",
JSON.stringify({
metadata: { generation: 12 },
spec: {
template: {
spec: {
containers: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: "api",
},
],
},
},
},
status: {
observedGeneration: 12,
readyReplicas: 1,
updatedReplicas: 1,
},
}),
"__KT_SECTION:pods_json__",
JSON.stringify({
items: [
{
metadata: {
name: "kt-template-online-api-abc",
},
spec: {
containers: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: "api",
},
],
},
status: {
containerStatuses: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: "api",
ready: true,
restartCount: 0,
},
],
phase: "Running",
startTime: "2026-06-14T00:00:00Z",
},
},
],
}),
"__KT_SECTION:health_json__",
JSON.stringify({
checkedAt: "2026-06-14T00:00:01.000Z",
checks: [
{
critical: true,
message: "NestJS process answered runtime health request",
name: "process",
status: "live",
},
],
service: "kt-template-online-api",
status: "ready",
}),
"__KT_SECTION:smoke_text__",
"{\"service\":\"kt-template-online-api\",\"status\":\"ready\"}",
].join("\n"),
);
const deployObservationEvidence = normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: deployObservationSections,
});
if (
deployObservationEvidence.status !== "passed" ||
deployObservationEvidence.details.deployment.image !==
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234" ||
deployObservationEvidence.details.deployment.observedGeneration !== 12 ||
deployObservationEvidence.details.pod.name !==
"kt-template-online-api-abc" ||
deployObservationEvidence.details.pod.restartCount !== 0 ||
deployObservationEvidence.details.runtimeHealth.status !== "ready" ||
!deployObservationEvidence.assertions.every((item) => item.passed)
) {
throw new Error("deploy observation evidence normalization self-check failed");
}
const deployObservationDryRun = await buildDeployObservation({
deployment: "kt-template-online-api",
execute: false,
healthUrl: "http://127.0.0.1:48085/health/runtime",
imageTag: "abc1234",
jobName: "kt-template-online-api",
namespace: "kt-prod",
project: "api",
smoke: "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime",
});
const deployObservationCommandText = JSON.stringify(
deployObservationDryRun.commands,
);
if (
deployObservationDryRun.execute !== false ||
!deployObservationCommandText.includes("tr -d '\\015' | bash -s") ||
!deployObservationCommandText.includes("kubectl") ||
!deployObservationCommandText.includes("/health/runtime") ||
deployObservationDryRun.evidence
) {
throw new Error("deploy observation dry-run self-check failed");
}
- Step 3: Run self-test and confirm the expected failure
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test
Expected:
FAIL: Cannot find module './tools/deployObservation.js' or exported member parseDeployObservationCliArgs.
- Step 4: Commit the red test only if the team wants TDD commits
Run only when working branch policy allows red commits:
git -C D:\MyFiles\KT\mcp\ktWorkflow add src/selfTest.ts
git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "test: 覆盖部署观测工作流"
Expected:
Commit is created on dev, or the red test remains staged for the green implementation commit.
Task 2: Add Deploy Observation Types
Files:
-
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\types.ts -
Step 1: Add deploy observation status and input types
Insert after RemoteHealthCheckInput:
export type DeployObservationStatus =
| 'passed'
| 'failed'
| 'blocked'
| 'skipped';
export interface DeployObservationInput {
artifactRoot?: string;
buildNumber?: string;
container?: string;
deployment?: string;
execute?: boolean;
expectedCommit?: string;
healthUrl?: string;
imageTag?: string;
jobName?: string;
jenkinsHome?: string;
kubeconfigPath?: string;
namespace?: string;
project?: string;
selector?: string;
smoke?: string;
sshPort?: number;
sshTarget?: string;
}
- Step 2: Add evidence DTOs
Insert after DeployObservationInput:
export interface DeployObservationAssertion {
critical: boolean;
message: string;
name: string;
passed: boolean;
}
export interface DeployObservationCommand {
command: string;
name: string;
}
export interface DeployObservationJenkinsEvidence {
buildNumber: string | null;
commitMatched: boolean | null;
expectedCommit: string | null;
finishedStatus: string | null;
jobName: string;
}
export interface DeployObservationDeploymentEvidence {
container: string;
containerFound: boolean;
desiredReplicas: number | null;
generation: number | null;
image: string | null;
namespace: string;
observedGeneration: number | null;
readyReplicas: number | null;
updatedReplicas: number | null;
}
export interface DeployObservationPodEvidence {
containerFound: boolean;
image: string | null;
name: string | null;
phase: string | null;
ready: boolean | null;
restartCount: number | null;
}
export interface DeployObservationRuntimeHealthEvidence {
checkCount: number | null;
service: string | null;
status: string | null;
}
export interface DeployObservationEvidence {
assertions: DeployObservationAssertion[];
details: {
deployment: DeployObservationDeploymentEvidence;
eventsTail: string;
jenkins: DeployObservationJenkinsEvidence;
pod: DeployObservationPodEvidence;
runtimeHealth: DeployObservationRuntimeHealthEvidence;
smoke: {
command: string | null;
output: string;
};
};
endedAt: string;
environment: 'production';
operation: 'kt_deploy_observation';
project: string;
schemaVersion: 1;
startedAt: string;
status: DeployObservationStatus;
target: string;
taskType: 'deploy';
title: string;
}
export interface DeployObservationResult {
artifactPath?: string;
commands: DeployObservationCommand[];
evidence?: DeployObservationEvidence;
execute: boolean;
notes: string[];
requiredEvidence: string[];
result?: ExecResult;
}
- Step 3: Run typecheck and confirm the expected failure
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck
Expected:
FAIL because deployObservation.ts and parseDeployObservationCliArgs are not implemented yet.
Task 3: Implement The Deploy Observation Tool
Files:
-
Create:
D:\MyFiles\KT\mcp\ktWorkflow\src\tools\deployObservation.ts -
Step 1: Create the tool file
Create src/tools/deployObservation.ts with this implementation:
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import type {
DeployObservationAssertion,
DeployObservationCommand,
DeployObservationDeploymentEvidence,
DeployObservationEvidence,
DeployObservationInput,
DeployObservationJenkinsEvidence,
DeployObservationPodEvidence,
DeployObservationResult,
DeployObservationRuntimeHealthEvidence,
DeployObservationStatus,
} from '../types.js';
import { tryPowerShell } from '../core/exec.js';
import {
formatDateInShanghai,
resolveInsideRoot,
workspaceRoot,
} from '../core/workspace.js';
interface NormalizedInput extends Required<
Pick<
DeployObservationInput,
| 'artifactRoot'
| 'container'
| 'deployment'
| 'healthUrl'
| 'jenkinsHome'
| 'jobName'
| 'kubeconfigPath'
| 'namespace'
| 'project'
| 'selector'
| 'sshPort'
| 'sshTarget'
>
> {
buildNumber?: string;
execute: boolean;
expectedCommit?: string;
imageTag?: string;
smoke?: string;
}
interface NormalizeEvidenceInput {
checkedAt?: Date;
input: DeployObservationInput;
sectionMap: Map<string, string>;
}
function normalizeInput(input: DeployObservationInput = {}): NormalizedInput {
return {
artifactRoot:
input.artifactRoot || '.kt-workspace/test-artifacts/deploy-observation',
buildNumber: input.buildNumber?.trim() || undefined,
container: input.container?.trim() || 'api',
deployment: input.deployment?.trim() || 'kt-template-online-api',
execute: input.execute === true,
expectedCommit: input.expectedCommit?.trim() || undefined,
healthUrl:
input.healthUrl?.trim() ||
'http://127.0.0.1:48085/health/runtime',
imageTag: input.imageTag?.trim() || undefined,
jenkinsHome: input.jenkinsHome?.trim() || '/vol1/docker/jenkins/jenkins_home',
jobName: input.jobName?.trim() || 'KT-Template/KT-Template-API/main',
kubeconfigPath:
input.kubeconfigPath?.trim() ||
'/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml',
namespace: input.namespace?.trim() || 'kt-prod',
project: input.project?.trim() || 'api',
selector:
input.selector?.trim() || 'app=kt-template-online-api',
smoke: input.smoke?.trim() || undefined,
sshPort: input.sshPort || 2202,
sshTarget: input.sshTarget?.trim() || 'nas',
};
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function powerShellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function parseJsonObject(value: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getPath(source: unknown, keys: string[]): unknown {
return keys.reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[key];
}, source);
}
function getArray(source: unknown, keys: string[]): unknown[] {
const value = getPath(source, keys);
return Array.isArray(value) ? value : [];
}
function buildRemoteScript(input: NormalizedInput): string {
const smokeBlock = input.smoke
? [
"section smoke_text",
`${input.smoke} 2>&1 || true`,
].join('\n')
: [
"section smoke_text",
"echo 'no task smoke command provided'",
].join('\n');
return [
'set -u',
'section() { printf "\\n__KT_SECTION:%s__\\n" "$1"; }',
`JOB_NAME=${shellSingleQuote(input.jobName)}`,
`BUILD_NUMBER=${shellSingleQuote(input.buildNumber || '')}`,
`JENKINS_HOME=${shellSingleQuote(input.jenkinsHome)}`,
`KUBECONFIG_PATH=${shellSingleQuote(input.kubeconfigPath)}`,
`NAMESPACE=${shellSingleQuote(input.namespace)}`,
`DEPLOYMENT=${shellSingleQuote(input.deployment)}`,
`SELECTOR=${shellSingleQuote(input.selector)}`,
`HEALTH_URL=${shellSingleQuote(input.healthUrl)}`,
'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"',
'if [ -z "$BUILD_NUMBER" ] && [ -d "$JOB_DIR" ]; then BUILD_NUMBER="$(find "$JOB_DIR" -maxdepth 1 -type d -printf "%f\\n" 2>/dev/null | grep -E "^[0-9]+$" | sort -n | tail -1 || true)"; fi',
'BUILD_DIR="$JOB_DIR/$BUILD_NUMBER"',
'section jenkins_meta',
'printf "jobName=%s\\n" "$JOB_NAME"',
'printf "buildNumber=%s\\n" "$BUILD_NUMBER"',
'if [ -f "$BUILD_DIR/build.xml" ]; then grep -E "<result>|<number>|<SHA1>" "$BUILD_DIR/build.xml" | tail -20 || true; fi',
'section jenkins_log_tail',
'if [ -f "$BUILD_DIR/log" ]; then tail -160 "$BUILD_DIR/log" || true; else echo "jenkins log not found"; fi',
'section deployment_json',
'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get deployment "$DEPLOYMENT" -o json 2>/dev/null || true',
'section pods_json',
'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get pods -l "$SELECTOR" -o json 2>/dev/null || true',
'section events_tail',
'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get events --sort-by=.lastTimestamp 2>/dev/null | tail -80 || true',
'section health_json',
'curl -fsS --max-time 8 "$HEALTH_URL" 2>/dev/null || true',
smokeBlock,
].join('\n');
}
function buildPowerShellCommand(input: NormalizedInput): string {
const remoteScript = buildRemoteScript(input);
return [
`$remoteScript = @'`,
remoteScript,
`'@`,
`$remoteScript | ssh -p ${input.sshPort} ${input.sshTarget} "tr -d '\\015' | bash -s"`,
].join('\n');
}
export function buildDeployObservationCommands(
input: DeployObservationInput = {},
): DeployObservationCommand[] {
const normalized = normalizeInput(input);
return [
{
command: buildPowerShellCommand(normalized),
name: 'nas-deploy-observation',
},
];
}
export function buildDeployObservationSectionMap(stdout: string): Map<string, string> {
const sections = new Map<string, string>();
let currentName: string | null = null;
let currentLines: string[] = [];
for (const line of stdout.split(/\r?\n/)) {
const marker = line.match(/^__KT_SECTION:([a-z0-9_-]+)__$/i);
if (marker) {
if (currentName) sections.set(currentName, currentLines.join('\n').trim());
currentName = marker[1];
currentLines = [];
} else if (currentName) {
currentLines.push(line);
}
}
if (currentName) sections.set(currentName, currentLines.join('\n').trim());
return sections;
}
function normalizeJenkinsEvidence(
input: NormalizedInput,
sectionMap: Map<string, string>,
): DeployObservationJenkinsEvidence {
const meta = sectionMap.get('jenkins_meta') || '';
const log = sectionMap.get('jenkins_log_tail') || '';
const buildNumber =
input.buildNumber ||
meta.match(/^buildNumber=(.+)$/m)?.[1]?.trim() ||
null;
const finishedStatus =
log.match(/Finished:\s*([A-Z]+)/)?.[1] ||
meta.match(/<result>([^<]+)<\/result>/)?.[1] ||
null;
const expectedCommit = input.expectedCommit || null;
return {
buildNumber,
commitMatched: expectedCommit
? `${meta}\n${log}`.includes(expectedCommit)
: null,
expectedCommit,
finishedStatus,
jobName: input.jobName,
};
}
function normalizeDeploymentEvidence(
input: NormalizedInput,
sectionMap: Map<string, string>,
): DeployObservationDeploymentEvidence {
const deployment = parseJsonObject(sectionMap.get('deployment_json') || '');
const containers = getArray(deployment, [
'spec',
'template',
'spec',
'containers',
]);
const container = containers.find(
(item) =>
item &&
typeof item === 'object' &&
(item as Record<string, unknown>).name === input.container,
) as Record<string, unknown> | undefined;
return {
container: input.container,
generation: numberOrNull(getPath(deployment, ['metadata', 'generation'])),
image: stringOrNull(container?.image),
namespace: input.namespace,
observedGeneration: numberOrNull(
getPath(deployment, ['status', 'observedGeneration']),
),
readyReplicas: numberOrNull(getPath(deployment, ['status', 'readyReplicas'])),
updatedReplicas: numberOrNull(
getPath(deployment, ['status', 'updatedReplicas']),
),
};
}
function normalizePodEvidence(
input: NormalizedInput,
sectionMap: Map<string, string>,
): DeployObservationPodEvidence {
const pods = parseJsonObject(sectionMap.get('pods_json') || '');
const items = getArray(pods, ['items']);
const normalizedPods = items.map((item) => {
const pod = item as Record<string, unknown>;
const statuses = getArray(pod, ['status', 'containerStatuses']);
const status = statuses.find(
(candidate) =>
candidate &&
typeof candidate === 'object' &&
(candidate as Record<string, unknown>).name === input.container,
) as Record<string, unknown> | undefined;
return {
image: stringOrNull(status?.image),
name: stringOrNull(getPath(pod, ['metadata', 'name'])),
phase: stringOrNull(getPath(pod, ['status', 'phase'])),
ready: typeof status?.ready === 'boolean' ? status.ready : null,
restartCount: numberOrNull(status?.restartCount),
};
});
return (
normalizedPods.find(
(pod) =>
pod.phase === 'Running' &&
(!input.imageTag || pod.image?.includes(input.imageTag)),
) ||
normalizedPods.find((pod) => pod.phase === 'Running') ||
normalizedPods[0] || {
image: null,
name: null,
phase: null,
ready: null,
restartCount: null,
}
);
}
function normalizeRuntimeHealthEvidence(
sectionMap: Map<string, string>,
): DeployObservationRuntimeHealthEvidence {
const health = parseJsonObject(sectionMap.get('health_json') || '');
const checks = Array.isArray(health?.checks) ? health.checks : null;
return {
checkCount: checks ? checks.length : null,
service: stringOrNull(health?.service),
status: stringOrNull(health?.status),
};
}
function buildAssertion(
name: string,
passed: boolean,
message: string,
critical = true,
): DeployObservationAssertion {
return {
critical,
message,
name,
passed,
};
}
function aggregateStatus(assertions: DeployObservationAssertion[]): DeployObservationStatus {
if (assertions.some((item) => item.critical && !item.passed)) return 'failed';
if (assertions.some((item) => !item.critical && !item.passed)) return 'blocked';
return 'passed';
}
export function normalizeDeployObservationEvidence({
checkedAt = new Date(),
input,
sectionMap,
}: NormalizeEvidenceInput): DeployObservationEvidence {
const normalized = normalizeInput(input);
const jenkins = normalizeJenkinsEvidence(normalized, sectionMap);
const deployment = normalizeDeploymentEvidence(normalized, sectionMap);
const pod = normalizePodEvidence(normalized, sectionMap);
const runtimeHealth = normalizeRuntimeHealthEvidence(sectionMap);
const smokeOutput = sectionMap.get('smoke_text') || '';
const assertions = [
buildAssertion(
'jenkins-finished',
jenkins.finishedStatus === 'SUCCESS',
`Jenkins result is ${jenkins.finishedStatus || 'missing'}`,
),
buildAssertion(
'jenkins-commit',
jenkins.commitMatched !== false,
jenkins.expectedCommit
? `Expected commit ${jenkins.expectedCommit} was found in Jenkins evidence`
: 'No expected commit supplied; commit match skipped',
Boolean(jenkins.expectedCommit),
),
buildAssertion(
'deployment-observed-generation',
deployment.generation !== null &&
deployment.observedGeneration !== null &&
deployment.observedGeneration >= deployment.generation,
`Deployment observedGeneration=${deployment.observedGeneration} generation=${deployment.generation}`,
),
buildAssertion(
'deployment-ready-replicas',
deployment.updatedReplicas !== null &&
deployment.readyReplicas !== null &&
deployment.desiredReplicas !== null &&
deployment.updatedReplicas >= deployment.desiredReplicas &&
deployment.readyReplicas >= deployment.desiredReplicas,
`Deployment desired=${deployment.desiredReplicas} updated=${deployment.updatedReplicas} ready=${deployment.readyReplicas}`,
),
buildAssertion(
'pod-running-for-image',
pod.phase === 'Running' &&
pod.ready === true &&
(!normalized.imageTag || Boolean(pod.image?.includes(normalized.imageTag))),
`Pod ${pod.name || 'missing'} phase=${pod.phase || 'missing'} image=${pod.image || 'missing'}`,
),
buildAssertion(
'pod-restart-count-zero',
pod.restartCount === 0,
`Pod restartCount=${pod.restartCount}`,
),
buildAssertion(
'runtime-health-not-blocked',
runtimeHealth.service === 'kt-template-online-api' &&
runtimeHealth.status !== null &&
runtimeHealth.status !== 'blocked',
`Runtime health status=${runtimeHealth.status || 'missing'}`,
),
buildAssertion(
'task-smoke-present',
Boolean(normalized.smoke && smokeOutput.trim().length > 0),
normalized.smoke
? 'Task smoke command produced output'
: 'No task smoke command supplied; rollout evidence is not functional evidence',
Boolean(normalized.smoke),
),
];
return {
assertions,
details: {
deployment,
eventsTail: sectionMap.get('events_tail') || '',
jenkins,
pod,
runtimeHealth,
smoke: {
command: normalized.smoke || null,
output: smokeOutput,
},
},
endedAt: checkedAt.toISOString(),
environment: 'production',
operation: 'kt_deploy_observation',
project: normalized.project,
schemaVersion: 1,
startedAt: checkedAt.toISOString(),
status: aggregateStatus(assertions),
target: `${normalized.namespace}/${normalized.deployment}`,
taskType: 'deploy',
title: 'KT API deployment observation',
};
}
function writeEvidenceArtifact(
artifactRoot: string,
evidence: DeployObservationEvidence,
): string {
const root = resolveInsideRoot(artifactRoot);
const directory = path.join(root, formatDateInShanghai());
mkdirSync(directory, { recursive: true });
const filePath = path.join(
directory,
`${evidence.project}-${Date.now()}-deploy-observation.json`,
);
writeFileSync(filePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
return filePath;
}
export async function buildDeployObservation(
input: DeployObservationInput = {},
): Promise<DeployObservationResult> {
const normalized = normalizeInput(input);
const commands = buildDeployObservationCommands(normalized);
const requiredEvidence = [
'Jenkins job/build number and final Finished status',
'Expected commit hash match when a commit is supplied',
'K8s Deployment generation, observedGeneration, updatedReplicas, readyReplicas',
'Running Pod selected by current image tag with restartCount',
'GET /health/runtime response with service/status/check count',
'Task-specific smoke command output',
];
const notes = [
'Default execute=false only returns the bounded read-only command.',
'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.',
'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.',
'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.',
];
if (!normalized.execute) {
return {
commands,
execute: false,
notes,
requiredEvidence,
};
}
const command = commands[0]?.command || '';
const result = await tryPowerShell(command, workspaceRoot, 180_000);
const sectionMap = buildDeployObservationSectionMap(result.stdout);
const evidence = normalizeDeployObservationEvidence({
input: normalized,
sectionMap,
});
const artifactPath = writeEvidenceArtifact(normalized.artifactRoot, evidence);
return {
artifactPath,
commands,
evidence,
execute: true,
notes,
requiredEvidence,
result,
};
}
- Step 2: Run typecheck and confirm parser/register failures remain
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck
Expected:
FAIL only because parseDeployObservationCliArgs and tool registration are not wired yet.
Task 4: Wire CLI And MCP Registration
Files:
-
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\core\cli.ts -
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\registerTools.ts -
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\server.ts -
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\core\constants.ts -
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\package.json -
Step 1: Add CLI parser
Modify the type imports in src/core/cli.ts:
import type {
DeployObservationInput,
GlobalCodeReviewInput,
ObsidianContextInput,
ObsidianSyncInput,
ObsidianValidateInput,
WorkstreamCloseoutInput,
WorkstreamReusablePattern,
} from '../types.js';
Append this parser to src/core/cli.ts:
export function parseDeployObservationCliArgs(argv: string[]): DeployObservationInput {
const sshPort = Number(readOption(argv, ['--ssh-port', '--sshPort']));
return {
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
buildNumber: readOption(argv, ['--build', '--build-number', '--buildNumber']),
container: readOption(argv, ['--container']),
deployment: readOption(argv, ['--deployment']),
execute: argv.includes('--execute'),
expectedCommit: readOption(argv, ['--commit', '--expected-commit', '--expectedCommit']),
healthUrl: readOption(argv, ['--health-url', '--healthUrl']),
imageTag: readOption(argv, ['--image-tag', '--imageTag']),
jenkinsHome: readOption(argv, ['--jenkins-home', '--jenkinsHome']),
jobName: readOption(argv, ['--job', '--job-name', '--jobName']),
kubeconfigPath: readOption(argv, ['--kubeconfig', '--kubeconfig-path', '--kubeconfigPath']),
namespace: readOption(argv, ['--namespace']),
project: readOption(argv, ['--project']),
selector: readOption(argv, ['--selector']),
smoke: readOption(argv, ['--smoke']),
sshPort: Number.isFinite(sshPort) ? sshPort : undefined,
sshTarget: readOption(argv, ['--ssh', '--ssh-target', '--sshTarget']),
};
}
- Step 2: Register the MCP tool
Modify imports in src/registerTools.ts:
import { buildDeployObservation } from './tools/deployObservation.js';
Add this registration after kt_remote_health_check:
server.registerTool(
'kt_deploy_observation',
{
description:
'生成或执行 API 发布后的只读部署观测:Jenkins、K8s Deployment、Pod、/health/runtime 和任务 smoke,并输出运行态证据。',
inputSchema: {
artifactRoot: z.string().optional(),
buildNumber: z.string().optional(),
container: z.string().default('api'),
deployment: z.string().default('kt-template-online-api'),
execute: z.boolean().default(false),
expectedCommit: z.string().optional(),
healthUrl: z.string().default('http://127.0.0.1:48085/health/runtime'),
imageTag: z.string().optional(),
jenkinsHome: z.string().default('/vol1/docker/jenkins/jenkins_home'),
jobName: z.string().default('KT-Template/KT-Template-API/main'),
kubeconfigPath: z
.string()
.default('/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml'),
namespace: z.string().default('kt-prod'),
project: z.string().default('api'),
selector: z.string().default('app=kt-template-online-api'),
smoke: z.string().optional(),
sshPort: z.number().int().min(1).max(65_535).default(2202),
sshTarget: z.string().default('nas'),
},
title: 'KT Deploy Observation',
},
async (input) => response(await buildDeployObservation(input)),
);
- Step 3: Add CLI branch
Modify imports in src/server.ts:
import {
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs,
parseObsidianContextCliArgs,
parseObsidianCliArgs,
parseObsidianSyncCliArgs,
parseWorkstreamCloseoutCliArgs,
} from './core/cli.js';
import { buildDeployObservation } from './tools/deployObservation.js';
Add this branch before --self-test:
} else if (process.argv.includes('--deploy-observation')) {
console.log(
JSON.stringify(
await buildDeployObservation(parseDeployObservationCliArgs(process.argv)),
null,
2,
),
);
- Step 4: Add registered tool name
Add to registeredToolNames in src/core/constants.ts immediately after kt_remote_health_check:
'kt_deploy_observation',
- Step 5: Add package script
Add to scripts in package.json:
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation"
- Step 6: Run typecheck and self-test
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test
Expected:
typecheck passes.
self-test writes .kt-workspace/test-artifacts/ktWorkflow-self-test.json and exits successfully.
- Step 7: Commit the tool wiring
Run:
git -C D:\MyFiles\KT\mcp\ktWorkflow add src/tools/deployObservation.ts src/types.ts src/core/cli.ts src/registerTools.ts src/server.ts src/core/constants.ts src/selfTest.ts package.json
git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "feat: 添加部署观测工作流"
Expected:
Commit succeeds on dev and contains only ktWorkflow deploy observation code and tests.
Task 5: Update ktWorkflow Documentation And Closeout Guidance
Files:
-
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\README.md -
Modify:
D:\MyFiles\KT\mcp\ktWorkflow\src\tools\closeout.ts -
Step 1: Update closeout deploy-observation target
In src/tools/closeout.ts, change the deploy-observation target to:
'deploy-observation': {
files: ['src/tools/deployObservation.ts', 'src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
label: '部署观测',
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、/health/runtime、日志和 smoke 观测步骤写成固定计划。',
},
- Step 2: Update README capability list
Add this bullet to the capability list in README.md:
- 生成或执行部署观测:把 Jenkins build、commit、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据。
- Step 3: Update README tool table
Add this row after kt_remote_health_check:
| `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins、K8s、Pod、`/health/runtime` 和任务 smoke 证据 |
- Step 4: Update README scripts table
Add this row in the script table:
| `pnpm run deploy-observation` | 默认 dry-run 输出只读 NAS 观测命令;传 `--execute` 时执行 Jenkins/K8s/health/smoke 观测并写入 `.kt-workspace/test-artifacts/deploy-observation`。 |
- Step 5: Add README usage guidance
Add this guidance near the remote health check note:
- API 推送触发 Jenkins/K8s 后先运行 `pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"` 查看 dry-run 命令;确认需要线上只读观测时再加 `--execute`。Deployment/Pod 成功只算发布证据,功能完成还必须看 smoke 输出。
- Step 6: Run self-test
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test
Expected:
self-test passes after closeout guidance change.
- Step 7: Commit docs and closeout guidance
Run:
git -C D:\MyFiles\KT\mcp\ktWorkflow add README.md src/tools/closeout.ts
git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "docs: 补充部署观测工作流说明"
Expected:
Commit succeeds on dev with README and closeout guidance only.
Task 6: Validate Dry-Run And Read-Only Observation
Files:
-
Evidence output:
D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation -
Step 1: Confirm package manager and Node compatibility
Run:
node -v
pnpm -v
Get-Content D:\MyFiles\KT\mcp\ktWorkflow\package.json | Select-String -Pattern '"engines"|"packageManager"'
Expected:
Node satisfies >=20.19.0 and pnpm matches or is compatible with pnpm@10.28.2.
If Node does not satisfy engines, run:
nvm ls
nvm use 20.19.0
- Step 2: Run static validation
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test
Expected:
Both commands pass.
- Step 3: Run deploy observation dry-run
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"
Expected:
JSON output has execute=false, a nas-deploy-observation command, requiredEvidence, and notes.
No SSH command is executed.
No artifact is written.
- Step 4: Run read-only online observation
Run only after confirming NAS SSH is expected to be reachable:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --execute --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"
Expected:
JSON output includes execute=true, result.ok, evidence, and artifactPath.
artifactPath is under D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation.
Evidence contains Jenkins, Deployment, Pod, runtimeHealth, smoke, and assertions.
If Jenkins/K8s/health is unavailable, the evidence status is failed with concrete failing assertions.
- Step 5: Inspect artifact without leaking noisy payloads
Run:
Get-ChildItem D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation -Recurse -Filter *deploy-observation.json | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { Get-Content $_.FullName | ConvertFrom-Json | Select-Object title,status,target,operation,project }
Expected:
The latest artifact summary shows operation=kt_deploy_observation and a concrete status.
Task 7: Documentation Sync, Cleanup, Review, And Commits
Files:
-
Modify:
D:\MyFiles\KT\TASKS.md -
Step 1: Run doc sync plan
MCP call:
{
"changedFiles": [
"mcp/ktWorkflow/src/tools/deployObservation.ts",
"mcp/ktWorkflow/src/types.ts",
"mcp/ktWorkflow/src/core/cli.ts",
"mcp/ktWorkflow/src/registerTools.ts",
"mcp/ktWorkflow/src/server.ts",
"mcp/ktWorkflow/src/core/constants.ts",
"mcp/ktWorkflow/src/selfTest.ts",
"mcp/ktWorkflow/src/tools/closeout.ts",
"mcp/ktWorkflow/README.md",
"mcp/ktWorkflow/package.json"
],
"project": "mcp",
"taskType": "mcp"
}
Expected:
Doc sync identifies README and KT workflow documentation as handled; no API contract doc change is required because /health/runtime contract did not change.
If the MCP tool is unavailable in the execution environment, inspect mcp/ktWorkflow/README.md, Node/kt-template-online-api/README.md, Node/kt-template-online-api/API.md, and docs/obsidian/modules/KT 模块 - API Backend.md manually, then record the manual doc sync result in closeout evidence.
- Step 2: Update TASKS.md
Add a recent record with this content shape:
### 2026-06-14:API Runtime Foundation Phase 2 部署观测
- 范围:mcp/ktWorkflow、Node/kt-template-online-api docs
- 关键词:API Runtime Foundation、DeployObservationAdapter、Jenkins、K8s、/health/runtime、ktWorkflow
- 验证:pnpm --dir mcp/ktWorkflow run typecheck;pnpm --dir mcp/ktWorkflow run self-test;deploy-observation dry-run;deploy-observation --execute 只读观测或明确记录 NAS 不可达阻塞。
- Step 3: Run cleanup preview
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
Expected:
Output lists deleted=[] or a finite set of stale .kt-workspace artifacts.
If stale artifacts are listed, run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --execute
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
Expected:
Final dry-run shows deleted=[].
- Step 4: Run KT global review
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review
Expected:
No Critical or Important findings caused by this change.
Known pre-existing dirty files are classified separately.
- Step 5: Request KT global review
Use KT global review after implementation and before declaring the work complete. Review must cover:
mcp/ktWorkflow deploy observation command safety
evidence normalization correctness
Jenkins/K8s success not being treated as functional smoke success
dirty ktWorkflow files preserved rather than overwritten
Expected:
All Critical and Important findings are fixed or explicitly rejected with evidence before finishing.
- Step 6: Run closeout audit
Run:
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run workstream-closeout -- --title "API Runtime Foundation Phase 2 部署观测" --project mcp --project api --verification "typecheck/self-test/deploy-observation dry-run/read-only observation evidence" --doc-sync "kt_change_doc_sync README handled; API health contract unchanged" --cleanup "cleanup-history final dry-run deleted=[]" --cleanup-final-deleted 0 --review "global-review no Critical/Important findings caused by this change" --problem "无新卡点或已记录 NAS/Jenkins/K8s 只读观测阻塞" --solution "kt_deploy_observation 固化 Jenkins/K8s/health/smoke 观测" --pattern deploy-observation --kt-workflow-updated --upgrade-notes "新增 kt_deploy_observation 工具和 CLI"
Expected:
canReportComplete=true.
- Step 7: Commit root TASKS update if changed
Run:
git -C D:\MyFiles\KT status --short -- TASKS.md
git -C D:\MyFiles\KT add TASKS.md
git -C D:\MyFiles\KT commit -m "docs: 记录API部署观测重构进度"
Expected:
Root commit contains only the task record update made for this phase.
Self-Review
-
Spec coverage:
- Jenkins job/build number is covered by
jenkins_metaandjenkins_log_tail. - Commit hash is covered by
expectedCommitandjenkins-commitassertion. - Image tag is covered by Deployment container image and Pod image match.
- Deployment
generation,observedGeneration,updatedReplicas, andreadyReplicasare parsed fromdeployment_json. - Running Pod and restart count are parsed from
pods_json. - Failing logs/events are included through
jenkins_log_tailandevents_tail. /health/runtimeis fetched ashealth_jsonand normalized without exposing config topology.- Task-specific smoke is separate from rollout evidence through
task-smoke-present.
- Jenkins job/build number is covered by
-
Placeholder scan:
- This plan contains concrete file paths, code snippets, commands, expected outputs, and commit messages.
- No step asks the implementer to invent a missing function name or validation command.
-
Type consistency:
parseDeployObservationCliArgs,buildDeployObservation,buildDeployObservationSectionMap, andnormalizeDeployObservationEvidenceare introduced in early tasks and referenced consistently in later tasks.DeployObservationInput,DeployObservationEvidence, andDeployObservationResultlive insrc/types.tsand are consumed by the new tool and registration.