# 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`, branch `dev`, currently clean at the time this plan was written. - ktWorkflow repo: `D:\MyFiles\KT\mcp\ktWorkflow`, branch `main`, currently has pre-existing changes in `README.md`, `src/selfTest.ts`, and `src/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: ```ts export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked'; export interface RuntimeHealthCheck { name: string; status: RuntimeHealthStatus; critical: boolean; message: string; detail?: Record; } 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. - Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\core\cli.ts` - Add `parseDeployObservationCliArgs(argv)`. - Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\registerTools.ts` - Register MCP tool `kt_deploy_observation`. - Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\server.ts` - Add `--deploy-observation` CLI branch. - Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\core\constants.ts` - Add `kt_deploy_observation` to `registeredToolNames`. - Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\closeout.ts` - Point `deploy-observation` upgrade target to the new tool file. - 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-observation` script. - 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: ```powershell 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: ```text 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: ```powershell git -C D:\MyFiles\KT\mcp\ktWorkflow diff -- README.md src/selfTest.ts src/tools/review.ts ``` Expected: ```text 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: ```powershell 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: ```text Switched to a new branch 'dev' ``` Expected if `dev` already exists: ```powershell git -C D:\MyFiles\KT\mcp\ktWorkflow switch dev ``` ```text 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`: ```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: ```ts 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: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test ``` Expected: ```text 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: ```powershell git -C D:\MyFiles\KT\mcp\ktWorkflow add src/selfTest.ts git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "test: 覆盖部署观测工作流" ``` Expected: ```text 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`: ```ts 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`: ```ts 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: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck ``` Expected: ```text 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: ```ts 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; } 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 | null { try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? (parsed as Record) : null; } catch { return null; } } function getPath(source: unknown, keys: string[]): unknown { return keys.reduce((current, key) => { if (!current || typeof current !== 'object') return undefined; return (current as Record)[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 "||" "$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 { const sections = new Map(); 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, ): 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>/)?.[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, ): 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).name === input.container, ) as Record | 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, ): DeployObservationPodEvidence { const pods = parseJsonObject(sectionMap.get('pods_json') || ''); const items = getArray(pods, ['items']); const normalizedPods = items.map((item) => { const pod = item as Record; const statuses = getArray(pod, ['status', 'containerStatuses']); const status = statuses.find( (candidate) => candidate && typeof candidate === 'object' && (candidate as Record).name === input.container, ) as Record | 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, ): 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 { 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: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck ``` Expected: ```text 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`: ```ts import type { DeployObservationInput, GlobalCodeReviewInput, ObsidianContextInput, ObsidianSyncInput, ObsidianValidateInput, WorkstreamCloseoutInput, WorkstreamReusablePattern, } from '../types.js'; ``` Append this parser to `src/core/cli.ts`: ```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`: ```ts import { buildDeployObservation } from './tools/deployObservation.js'; ``` Add this registration after `kt_remote_health_check`: ```ts 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`: ```ts import { parseDeployObservationCliArgs, parseGlobalReviewCliArgs, parseObsidianContextCliArgs, parseObsidianCliArgs, parseObsidianSyncCliArgs, parseWorkstreamCloseoutCliArgs, } from './core/cli.js'; import { buildDeployObservation } from './tools/deployObservation.js'; ``` Add this branch before `--self-test`: ```ts } 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`: ```ts 'kt_deploy_observation', ``` - [ ] **Step 5: Add package script** Add to `scripts` in `package.json`: ```json "deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation" ``` - [ ] **Step 6: Run typecheck and self-test** Run: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test ``` Expected: ```text typecheck passes. self-test writes .kt-workspace/test-artifacts/ktWorkflow-self-test.json and exits successfully. ``` - [ ] **Step 7: Commit the tool wiring** Run: ```powershell 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: ```text 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: ```ts '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`: ```markdown - 生成或执行部署观测:把 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`: ```markdown | `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins、K8s、Pod、`/health/runtime` 和任务 smoke 证据 | ``` - [ ] **Step 4: Update README scripts table** Add this row in the script table: ```markdown | `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: ```markdown - 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: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test ``` Expected: ```text self-test passes after closeout guidance change. ``` - [ ] **Step 7: Commit docs and closeout guidance** Run: ```powershell 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: ```text 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: ```powershell node -v pnpm -v Get-Content D:\MyFiles\KT\mcp\ktWorkflow\package.json | Select-String -Pattern '"engines"|"packageManager"' ``` Expected: ```text Node satisfies >=20.19.0 and pnpm matches or is compatible with pnpm@10.28.2. ``` If Node does not satisfy `engines`, run: ```powershell nvm ls nvm use 20.19.0 ``` - [ ] **Step 2: Run static validation** Run: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test ``` Expected: ```text Both commands pass. ``` - [ ] **Step 3: Run deploy observation dry-run** Run: ```powershell 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: ```text 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: ```powershell 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: ```text 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: ```powershell 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: ```text 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: ```json { "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: ```text 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: ```markdown ### 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: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run ``` Expected: ```text Output lists deleted=[] or a finite set of stale .kt-workspace artifacts. ``` If stale artifacts are listed, run: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --execute pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run ``` Expected: ```text Final dry-run shows deleted=[]. ``` - [ ] **Step 4: Run KT global review** Run: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review ``` Expected: ```text 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: ```text 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: ```text All Critical and Important findings are fixed or explicitly rejected with evidence before finishing. ``` - [ ] **Step 6: Run closeout audit** Run: ```powershell 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: ```text canReportComplete=true. ``` - [ ] **Step 7: Commit root TASKS update if changed** Run: ```powershell 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: ```text Root commit contains only the task record update made for this phase. ``` ## Self-Review - Spec coverage: - Jenkins job/build number is covered by `jenkins_meta` and `jenkins_log_tail`. - Commit hash is covered by `expectedCommit` and `jenkins-commit` assertion. - Image tag is covered by Deployment container image and Pod image match. - Deployment `generation`, `observedGeneration`, `updatedReplicas`, and `readyReplicas` are parsed from `deployment_json`. - Running Pod and restart count are parsed from `pods_json`. - Failing logs/events are included through `jenkins_log_tail` and `events_tail`. - `/health/runtime` is fetched as `health_json` and normalized without exposing config topology. - Task-specific smoke is separate from rollout evidence through `task-smoke-present`. - 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`, and `normalizeDeployObservationEvidence` are introduced in early tasks and referenced consistently in later tasks. - `DeployObservationInput`, `DeployObservationEvidence`, and `DeployObservationResult` live in `src/types.ts` and are consumed by the new tool and registration.