devops-whc 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENT_MCP_USAGE.md +394 -0
  2. package/LICENSE +15 -0
  3. package/README.md +208 -0
  4. package/WHC_MCP_REQUIREMENTS.md +112 -0
  5. package/dist/audit/audit-logger.js +57 -0
  6. package/dist/clients/ssh-client.js +199 -0
  7. package/dist/clients/whc-uapi-client.js +178 -0
  8. package/dist/clients/wpcli-client.js +125 -0
  9. package/dist/config/env.js +132 -0
  10. package/dist/contracts/deployment.js +2 -0
  11. package/dist/contracts/envelope.js +2 -0
  12. package/dist/dispatcher/tool-dispatcher.js +145 -0
  13. package/dist/handlers/whc-check-health.js +131 -0
  14. package/dist/handlers/whc-db-backup.js +111 -0
  15. package/dist/handlers/whc-deploy.js +381 -0
  16. package/dist/handlers/whc-get-logs.js +108 -0
  17. package/dist/handlers/whc-pipeline-status.js +96 -0
  18. package/dist/handlers/whc-prepare.js +127 -0
  19. package/dist/handlers/whc-rollback.js +141 -0
  20. package/dist/handlers/whc-setup-remote.js +262 -0
  21. package/dist/handlers/whc-ssh-exec.js +138 -0
  22. package/dist/handlers/whc-verify.js +304 -0
  23. package/dist/idempotency/store.js +13 -0
  24. package/dist/index.js +109 -0
  25. package/dist/policy/policy-engine.js +41 -0
  26. package/dist/probes/connectivity.js +41 -0
  27. package/dist/registry/tool-registry.js +69 -0
  28. package/dist/schemas/whc-check-health.js +55 -0
  29. package/dist/schemas/whc-db-backup.js +29 -0
  30. package/dist/schemas/whc-deploy.js +66 -0
  31. package/dist/schemas/whc-get-logs.js +25 -0
  32. package/dist/schemas/whc-pipeline-status.js +24 -0
  33. package/dist/schemas/whc-prepare.js +29 -0
  34. package/dist/schemas/whc-rollback.js +58 -0
  35. package/dist/schemas/whc-setup-remote.js +60 -0
  36. package/dist/schemas/whc-ssh-exec.js +117 -0
  37. package/dist/schemas/whc-verify.js +28 -0
  38. package/dist/server-entry.js +8 -0
  39. package/dist/server.js +381 -0
  40. package/dist/services/deploy-runtime-ops.js +104 -0
  41. package/dist/services/deployment-locks.js +34 -0
  42. package/dist/state/workspace-state.js +201 -0
  43. package/package.json +48 -0
  44. package/scripts/prepare-first-time.cjs +75 -0
  45. package/scripts/start-mcp.cjs +42 -0
@@ -0,0 +1,381 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveDeploySafetyLevel = resolveDeploySafetyLevel;
4
+ exports.executeWhcDeploy = executeWhcDeploy;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const whc_uapi_client_1 = require("../clients/whc-uapi-client");
7
+ const deployment_locks_1 = require("../services/deployment-locks");
8
+ const ssh_client_1 = require("../clients/ssh-client");
9
+ const deploy_runtime_ops_1 = require("../services/deploy-runtime-ops");
10
+ /**
11
+ * Resolves the effective safety level for this deploy operation.
12
+ * staging_to_live + database or everything → Level D.
13
+ * All other writes remain at Level C.
14
+ */
15
+ function resolveDeploySafetyLevel(direction, syncScope) {
16
+ if (direction === "staging_to_live" && (syncScope === "database" || syncScope === "everything")) {
17
+ return "D";
18
+ }
19
+ return "C";
20
+ }
21
+ async function executeWhcDeploy(config, request, deps = {}) {
22
+ const startedAt = Date.now();
23
+ const actionIdFactory = deps.actionIdFactory ?? node_crypto_1.randomUUID;
24
+ const uapiClient = deps.uapiClient ?? new whc_uapi_client_1.WhcUapiClient(config);
25
+ const sshClient = deps.sshClient ?? new ssh_client_1.WhcSshClient(config);
26
+ const lockService = deps.lockService ?? new deployment_locks_1.InMemoryDeploymentLockService();
27
+ const verifyRunner = deps.verifyRunner ?? ((cfg, req) => (0, deploy_runtime_ops_1.runDeployVerification)(cfg, req, sshClient));
28
+ const rollbackRunner = deps.rollbackRunner ?? ((cfg, req) => (0, deploy_runtime_ops_1.runDeployRollback)(cfg, req, uapiClient));
29
+ const { workflow_mode, target_environment, release_intent, pipeline_id, source_profile, direction, sync_scope, repository_root, branch, rollback_branch, backup_reference, allow_emergency_without_backup, verify_after_deploy, auto_rollback_on_verify_failure, lock_key, } = request.payload;
30
+ const effectiveSafetyLevel = resolveDeploySafetyLevel(direction, sync_scope);
31
+ const needsBackupGate = direction === "staging_to_live" && (sync_scope === "database" || sync_scope === "everything");
32
+ const shouldVerify = verify_after_deploy ?? false;
33
+ const deployLockKey = lock_key ?? buildDefaultLockKey(target_environment, workflow_mode, repository_root);
34
+ const lockOwner = request.request_id;
35
+ const warnings = [];
36
+ if ((config.safety.enforceStagingFirst ?? true) && target_environment === "live") {
37
+ const isManagedPromoteFlow = workflow_mode === "managed_clone_sync" &&
38
+ direction === "staging_to_live" &&
39
+ release_intent === "promote";
40
+ if (!isManagedPromoteFlow) {
41
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", [
42
+ "Staging-first policy: direct live deploy is blocked.",
43
+ "Only promote flow from staging to live is allowed.",
44
+ "Required payload shape for live target:",
45
+ JSON.stringify({
46
+ workflow_mode: "managed_clone_sync",
47
+ target_environment: "live",
48
+ release_intent: "promote",
49
+ direction: "staging_to_live",
50
+ }, null, 2),
51
+ ].join("\n"));
52
+ }
53
+ }
54
+ if (target_environment === "staging" &&
55
+ config.workflowMode === "managed_clone_sync" &&
56
+ workflow_mode === "git_controlled" &&
57
+ !config.safety.allowStagingGitControlled) {
58
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", [
59
+ "Server mode lock: staging is configured as managed_clone_sync only (WHC_WORKFLOW_MODE=managed_clone_sync).",
60
+ "Request used workflow_mode='git_controlled' and was blocked to prevent cross-account deadlock loops.",
61
+ "Use this payload instead:",
62
+ JSON.stringify(buildStagingAlternativePayload(request), null, 2),
63
+ ].join("\n"));
64
+ }
65
+ if (config.safety.warnDynamicDataSync && sync_scope === "database") {
66
+ warnings.push("DATABASE sync detected — existing data on target will be overwritten. Ensure a backup exists before proceeding.");
67
+ }
68
+ if (config.safety.warnDynamicDataSync && sync_scope === "everything") {
69
+ warnings.push("FULL sync (files + database) detected — all data on target will be replaced. This is irreversible without a backup.");
70
+ }
71
+ if (workflow_mode === "managed_clone_sync" && release_intent === "deploy" && direction === "live_to_staging") {
72
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", "Invalid deploy mindset: managed live_to_staging is an environment refresh, not a release deploy. Use release_intent='refresh' for live_to_staging, or deploy a release candidate to staging via git_controlled workflow.");
73
+ }
74
+ if (workflow_mode === "git_controlled" && repository_root) {
75
+ if (target_environment === "staging" && config.paths.staging && repository_root !== config.paths.staging) {
76
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", [
77
+ `Invalid staging target for git_controlled deploy: repository_root='${repository_root}' does not match WHC_STAGING_PATH='${config.paths.staging}'.`,
78
+ "This prevents accidental deploys to a live path while target_environment is staging.",
79
+ "Use the configured staging path, or switch to managed_clone_sync refresh for cross-account WHC staging.",
80
+ "Copy-ready payload using configured staging path:",
81
+ JSON.stringify(buildSuggestedStagingGitControlledPayload(request, config.paths.staging), null, 2),
82
+ ].join("\n"));
83
+ }
84
+ const pathOwner = extractHomeOwner(repository_root);
85
+ if (pathOwner && pathOwner !== config.user) {
86
+ const lines = [
87
+ `Path owner mismatch: repository_root belongs to '${pathOwner}' but WHC_USER is '${config.user}'.`,
88
+ ];
89
+ if (target_environment === "staging") {
90
+ lines.push("git_controlled via UAPI cannot deploy to a separate-account staging environment.", "WHC managed staging uses a different cPanel account and is not reachable via these credentials.", "Use managed_clone_sync + release_intent='refresh' + direction='live_to_staging' to sync the environment,", "then deploy your release candidate over SSH using whc_ssh_exec, or configure separate staging credentials.", "Suggested alternative payload (managed_clone_sync refresh):", JSON.stringify(buildStagingAlternativePayload(request), null, 2));
91
+ }
92
+ else {
93
+ const suggestedPayload = buildSuggestedDeployPayload(request, config.user);
94
+ lines.push(`Use /home/${config.user}/... for git_controlled deploy or switch credentials.`, "Copy-ready payload using current WHC_USER:", JSON.stringify(suggestedPayload, null, 2));
95
+ }
96
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", lines.join("\n"));
97
+ }
98
+ }
99
+ if (direction === "live_to_staging" && sync_scope !== "files") {
100
+ warnings.push("Refreshing staging from live with database/everything scope — any staging-only changes will be lost.");
101
+ }
102
+ if (needsBackupGate && !backup_reference && !allow_emergency_without_backup) {
103
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", "Backup reference is required for staging_to_live database/everything sync. Provide payload.backup_reference or set allow_emergency_without_backup=true.");
104
+ }
105
+ if (needsBackupGate && !backup_reference && allow_emergency_without_backup) {
106
+ warnings.push("Emergency override enabled without backup_reference. Data loss recovery is not guaranteed.");
107
+ }
108
+ // Dry run: return preview without making changes
109
+ if (request.dry_run) {
110
+ const phaseCoverage = buildPhaseCoverage({
111
+ workflowMode: workflow_mode,
112
+ syncScope: sync_scope,
113
+ verifyStatus: "skipped",
114
+ dryRun: true,
115
+ });
116
+ return {
117
+ ok: true,
118
+ action_id: actionIdFactory(),
119
+ tool: "whc_deploy",
120
+ data: {
121
+ outcome: "dry_run_preview",
122
+ workflow_mode,
123
+ target_environment,
124
+ direction,
125
+ sync_scope,
126
+ repository_root,
127
+ branch,
128
+ rollback_branch,
129
+ lock_key: deployLockKey,
130
+ backup_reference,
131
+ pipeline_status: derivePipelineStatus(phaseCoverage),
132
+ phase_coverage: phaseCoverage,
133
+ warnings,
134
+ },
135
+ error: null,
136
+ meta: {
137
+ latency_ms: Date.now() - startedAt,
138
+ safety_level: effectiveSafetyLevel,
139
+ workflow_mode,
140
+ pipeline_id,
141
+ release_intent,
142
+ source_profile,
143
+ delivery_mechanism: workflow_mode === "git_controlled" ? "git_deploy" : "managed_sync",
144
+ },
145
+ };
146
+ }
147
+ const lockAcquired = lockService.acquire(deployLockKey, lockOwner);
148
+ if (!lockAcquired.ok) {
149
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "BUSINESS_POLICY_BLOCKED", `Concurrent deployment blocked: ${lockAcquired.reason}`);
150
+ }
151
+ try {
152
+ // managed_clone_sync mode: use WHC staging sync
153
+ if (workflow_mode === "managed_clone_sync") {
154
+ if (!direction) {
155
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "VALIDATION_ERROR", "managed_clone_sync mode requires 'direction' field.");
156
+ }
157
+ // WHC managed sync is represented with guarded intent + recovery contract.
158
+ const outcome = direction === "live_to_staging" ? "synced_live_to_staging" : "synced_staging_to_live";
159
+ let verifyStatus = "skipped";
160
+ let rollbackStatus = "not_needed";
161
+ if (shouldVerify) {
162
+ const verifyResult = await verifyRunner(config, request);
163
+ if (!verifyResult.ok) {
164
+ verifyStatus = "failed";
165
+ if (auto_rollback_on_verify_failure) {
166
+ rollbackStatus = "attempted";
167
+ const rollbackResult = await rollbackRunner(config, request);
168
+ rollbackStatus = rollbackResult.ok ? "succeeded" : "failed";
169
+ }
170
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "TEMPORARY_UPSTREAM_ERROR", `Post-deploy verification failed: ${verifyResult.message}`);
171
+ }
172
+ verifyStatus = "passed";
173
+ }
174
+ const phaseCoverage = buildPhaseCoverage({
175
+ workflowMode: workflow_mode,
176
+ syncScope: sync_scope,
177
+ verifyStatus,
178
+ dryRun: false,
179
+ });
180
+ return {
181
+ ok: true,
182
+ action_id: actionIdFactory(),
183
+ tool: "whc_deploy",
184
+ data: {
185
+ outcome,
186
+ workflow_mode,
187
+ target_environment,
188
+ direction,
189
+ sync_scope,
190
+ verify_status: verifyStatus,
191
+ rollback_status: rollbackStatus,
192
+ lock_key: deployLockKey,
193
+ backup_reference,
194
+ pipeline_status: derivePipelineStatus(phaseCoverage),
195
+ phase_coverage: phaseCoverage,
196
+ warnings,
197
+ },
198
+ error: null,
199
+ meta: {
200
+ latency_ms: Date.now() - startedAt,
201
+ safety_level: effectiveSafetyLevel,
202
+ workflow_mode,
203
+ pipeline_id,
204
+ release_intent,
205
+ source_profile,
206
+ delivery_mechanism: "managed_sync",
207
+ },
208
+ };
209
+ }
210
+ // git_controlled mode: trigger UAPI VersionControl::deployment
211
+ if (!repository_root) {
212
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "VALIDATION_ERROR", "git_controlled mode requires 'repository_root' field.");
213
+ }
214
+ let deployResult;
215
+ try {
216
+ deployResult = await uapiClient.triggerDeployment(repository_root, branch);
217
+ }
218
+ catch (error) {
219
+ const message = error instanceof Error ? error.message : "UAPI deployment trigger failed";
220
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "AUTH_ERROR", message);
221
+ }
222
+ if (!deployResult.ok) {
223
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "TEMPORARY_UPSTREAM_ERROR", deployResult.message);
224
+ }
225
+ let verifyStatus = "skipped";
226
+ let rollbackStatus = "not_needed";
227
+ if (shouldVerify) {
228
+ const verifyResult = await verifyRunner(config, request);
229
+ if (!verifyResult.ok) {
230
+ verifyStatus = "failed";
231
+ if (auto_rollback_on_verify_failure) {
232
+ rollbackStatus = "attempted";
233
+ const rollbackResult = await rollbackRunner(config, request);
234
+ rollbackStatus = rollbackResult.ok ? "succeeded" : "failed";
235
+ }
236
+ return buildErrorResponse(actionIdFactory(), startedAt, effectiveSafetyLevel, "TEMPORARY_UPSTREAM_ERROR", `Post-deploy verification failed: ${verifyResult.message}`);
237
+ }
238
+ verifyStatus = "passed";
239
+ }
240
+ const phaseCoverage = buildPhaseCoverage({
241
+ workflowMode: workflow_mode,
242
+ syncScope: sync_scope,
243
+ verifyStatus,
244
+ dryRun: false,
245
+ });
246
+ return {
247
+ ok: true,
248
+ action_id: actionIdFactory(),
249
+ tool: "whc_deploy",
250
+ data: {
251
+ outcome: "deployed",
252
+ workflow_mode,
253
+ target_environment,
254
+ repository_root,
255
+ branch,
256
+ rollback_branch,
257
+ deployment_id: deployResult.deployment_id,
258
+ verify_status: verifyStatus,
259
+ rollback_status: rollbackStatus,
260
+ lock_key: deployLockKey,
261
+ backup_reference,
262
+ pipeline_status: derivePipelineStatus(phaseCoverage),
263
+ phase_coverage: phaseCoverage,
264
+ warnings,
265
+ },
266
+ error: null,
267
+ meta: {
268
+ latency_ms: Date.now() - startedAt,
269
+ safety_level: effectiveSafetyLevel,
270
+ workflow_mode,
271
+ pipeline_id,
272
+ release_intent,
273
+ source_profile,
274
+ delivery_mechanism: "git_deploy",
275
+ },
276
+ };
277
+ }
278
+ finally {
279
+ lockService.release(deployLockKey, lockOwner);
280
+ }
281
+ }
282
+ function buildErrorResponse(actionId, startedAt, safetyLevel, code, message) {
283
+ return {
284
+ ok: false,
285
+ action_id: actionId,
286
+ tool: "whc_deploy",
287
+ data: null,
288
+ error: { code, message, retryable: code === "TEMPORARY_UPSTREAM_ERROR" },
289
+ meta: { latency_ms: Date.now() - startedAt, safety_level: safetyLevel },
290
+ };
291
+ }
292
+ function buildDefaultLockKey(targetEnvironment, workflowMode, repositoryRoot) {
293
+ return `whc_deploy:${targetEnvironment}:${workflowMode}:${repositoryRoot ?? "-"}`;
294
+ }
295
+ function extractHomeOwner(pathValue) {
296
+ const match = pathValue.match(/^\/home\/([^\/]+)\//);
297
+ return match?.[1];
298
+ }
299
+ function buildSuggestedDeployPayload(request, currentUser) {
300
+ return {
301
+ request_id: request.request_id,
302
+ actor: request.actor,
303
+ dry_run: request.dry_run,
304
+ confirmed: request.confirmed,
305
+ idempotency_key: request.idempotency_key,
306
+ payload: {
307
+ ...request.payload,
308
+ target_environment: "live",
309
+ repository_root: `/home/${currentUser}/public_html`,
310
+ },
311
+ };
312
+ }
313
+ function buildStagingAlternativePayload(request) {
314
+ return {
315
+ workflow_mode: "managed_clone_sync",
316
+ target_environment: "staging",
317
+ release_intent: "refresh",
318
+ pipeline_id: request.payload.pipeline_id,
319
+ source_profile: request.payload.source_profile,
320
+ direction: "live_to_staging",
321
+ sync_scope: "files",
322
+ verify_after_deploy: request.payload.verify_after_deploy,
323
+ auto_rollback_on_verify_failure: request.payload.auto_rollback_on_verify_failure,
324
+ };
325
+ }
326
+ function buildSuggestedStagingGitControlledPayload(request, stagingPath) {
327
+ return {
328
+ request_id: request.request_id,
329
+ actor: request.actor,
330
+ dry_run: request.dry_run,
331
+ confirmed: request.confirmed,
332
+ idempotency_key: request.idempotency_key,
333
+ payload: {
334
+ ...request.payload,
335
+ target_environment: "staging",
336
+ repository_root: stagingPath,
337
+ },
338
+ };
339
+ }
340
+ function buildPhaseCoverage(input) {
341
+ const notes = [];
342
+ let codeState = "excluded";
343
+ let runtimeState = "excluded";
344
+ let dataState = "excluded";
345
+ const deploymentState = input.dryRun ? "excluded" : "included";
346
+ if (input.workflowMode === "git_controlled") {
347
+ codeState = input.dryRun ? "excluded" : "included";
348
+ runtimeState = input.verifyStatus === "passed" ? "included" : "excluded";
349
+ dataState = "excluded";
350
+ notes.push("git_controlled deploy covers code transport/deployment state; data/content bootstrap is out of scope.");
351
+ }
352
+ if (input.workflowMode === "managed_clone_sync") {
353
+ if (input.syncScope === "files" || input.syncScope === "everything") {
354
+ codeState = input.dryRun ? "excluded" : "included";
355
+ }
356
+ if (input.syncScope === "database" || input.syncScope === "everything") {
357
+ dataState = input.dryRun ? "excluded" : "included";
358
+ }
359
+ runtimeState = "excluded";
360
+ notes.push("managed_clone_sync does not bootstrap app runtime configuration (plugin/theme activation, rewrite, Woo setup).", "Managed verification is transport-level unless additional runtime checks are run externally.");
361
+ }
362
+ if (input.dryRun) {
363
+ notes.push("dry_run preview only; no target state has been changed.");
364
+ }
365
+ return {
366
+ code_state: codeState,
367
+ runtime_state: runtimeState,
368
+ data_state: dataState,
369
+ deployment_state: deploymentState,
370
+ notes,
371
+ };
372
+ }
373
+ function derivePipelineStatus(phaseCoverage) {
374
+ if (phaseCoverage.code_state === "included" &&
375
+ phaseCoverage.runtime_state === "included" &&
376
+ phaseCoverage.data_state === "included" &&
377
+ phaseCoverage.deployment_state === "included") {
378
+ return "full_pass";
379
+ }
380
+ return "pass_partial";
381
+ }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeWhcGetLogs = executeWhcGetLogs;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const ssh_client_1 = require("../clients/ssh-client");
6
+ const LOG_PATHS = {
7
+ apache_error: {
8
+ live: "/usr/local/apache/logs/error_log",
9
+ staging: "/usr/local/apache/logs/error_log",
10
+ },
11
+ apache_access: {
12
+ live: "/usr/local/apache/logs/access_log",
13
+ staging: "/usr/local/apache/logs/access_log",
14
+ },
15
+ php_error: {
16
+ live: "~/public_html/error_log",
17
+ staging: "~/public_html/error_log",
18
+ },
19
+ cron: {
20
+ live: "~/var/log/cron_log",
21
+ staging: "~/var/log/cron_log",
22
+ },
23
+ };
24
+ function resolveLogPath(logType, targetEnvironment) {
25
+ return LOG_PATHS[logType]?.[targetEnvironment] ?? `~/logs/${logType}.log`;
26
+ }
27
+ async function executeWhcGetLogs(config, request, deps = {}) {
28
+ const startedAt = Date.now();
29
+ const actionIdFactory = deps.actionIdFactory ?? node_crypto_1.randomUUID;
30
+ const sshClient = deps.sshClient ?? new ssh_client_1.WhcSshClient(config);
31
+ const { target_environment, log_type, lines } = request.payload;
32
+ const logPath = resolveLogPath(log_type, target_environment);
33
+ const command = `tail -n ${lines} ${logPath} 2>/dev/null || echo '__LOG_NOT_FOUND__'`;
34
+ let execResult;
35
+ try {
36
+ if (target_environment === "staging" && config.sshTargets.staging?.privateKeyPath) {
37
+ const stagingTarget = config.sshTargets.staging;
38
+ execResult = await sshClient.execWithKey({
39
+ host: stagingTarget.host,
40
+ port: stagingTarget.port,
41
+ username: stagingTarget.username,
42
+ privateKeyPath: stagingTarget.privateKeyPath,
43
+ }, command);
44
+ }
45
+ else if (target_environment === "staging" && !config.sshTargets.staging) {
46
+ return {
47
+ ok: false,
48
+ action_id: actionIdFactory(),
49
+ tool: "whc_get_logs",
50
+ data: null,
51
+ error: {
52
+ code: "BUSINESS_POLICY_BLOCKED",
53
+ message: "Staging SSH target is not configured. Set WHC_STAGING_SSH_* env vars.",
54
+ retryable: false,
55
+ },
56
+ meta: { latency_ms: Date.now() - startedAt, safety_level: "A" },
57
+ };
58
+ }
59
+ else {
60
+ execResult = await sshClient.execWithKey(config.sshTargets.prod, command);
61
+ }
62
+ }
63
+ catch (error) {
64
+ const message = error instanceof Error ? error.message : "SSH exec failed";
65
+ return {
66
+ ok: false,
67
+ action_id: actionIdFactory(),
68
+ tool: "whc_get_logs",
69
+ data: null,
70
+ error: { code: "AUTH_ERROR", message, retryable: false },
71
+ meta: { latency_ms: Date.now() - startedAt, safety_level: "A" },
72
+ };
73
+ }
74
+ if (execResult.stdout.includes("__LOG_NOT_FOUND__")) {
75
+ return {
76
+ ok: true,
77
+ action_id: actionIdFactory(),
78
+ tool: "whc_get_logs",
79
+ data: {
80
+ target_environment,
81
+ log_type,
82
+ lines_returned: 0,
83
+ entries: [],
84
+ log_path: logPath,
85
+ },
86
+ error: null,
87
+ meta: { latency_ms: Date.now() - startedAt, safety_level: "A", delivery_mechanism: "ssh_exec" },
88
+ };
89
+ }
90
+ const entries = execResult.stdout
91
+ .split("\n")
92
+ .map((line) => line.trimEnd())
93
+ .filter((line) => line.length > 0);
94
+ return {
95
+ ok: true,
96
+ action_id: actionIdFactory(),
97
+ tool: "whc_get_logs",
98
+ data: {
99
+ target_environment,
100
+ log_type,
101
+ lines_returned: entries.length,
102
+ entries,
103
+ log_path: logPath,
104
+ },
105
+ error: null,
106
+ meta: { latency_ms: Date.now() - startedAt, safety_level: "A", delivery_mechanism: "ssh_exec" },
107
+ };
108
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeWhcPipelineStatus = executeWhcPipelineStatus;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const workspace_state_1 = require("../state/workspace-state");
6
+ async function executeWhcPipelineStatus(config, request) {
7
+ const startedAt = Date.now();
8
+ const rootDir = request.payload.workspace_root ?? process.cwd();
9
+ const status = (0, workspace_state_1.readPipelineStatus)(rootDir);
10
+ if (!status) {
11
+ return {
12
+ ok: true,
13
+ action_id: (0, node_crypto_1.randomUUID)(),
14
+ tool: "whc_pipeline_status",
15
+ data: {
16
+ started: false,
17
+ completed: false,
18
+ status: "idle",
19
+ tool: "",
20
+ request_id: "",
21
+ next_step: "Run whc_prepare then a write tool to initialize pipeline state.",
22
+ artifacts: {
23
+ state_file: (0, workspace_state_1.getPipelineStatusFile)(rootDir),
24
+ flow_log_file: config.flowLogPath ?? ".mcp/whc-mcp/logs/flow-events.jsonl",
25
+ },
26
+ },
27
+ error: null,
28
+ meta: {
29
+ latency_ms: Date.now() - startedAt,
30
+ safety_level: "A",
31
+ delivery_mechanism: "read_probe",
32
+ },
33
+ };
34
+ }
35
+ const statusRequestId = readString(status.request_id);
36
+ const requestedId = request.payload.request_id;
37
+ const effectiveId = requestedId && requestedId.length > 0 ? requestedId : statusRequestId;
38
+ const manifestFile = effectiveId ? (0, workspace_state_1.getReleaseManifestFile)(rootDir, effectiveId) : undefined;
39
+ return {
40
+ ok: true,
41
+ action_id: (0, node_crypto_1.randomUUID)(),
42
+ tool: "whc_pipeline_status",
43
+ data: {
44
+ started: readBoolean(status.started),
45
+ completed: readBoolean(status.completed),
46
+ status: readStatus(status.status),
47
+ tool: readString(status.tool),
48
+ request_id: statusRequestId,
49
+ action_id: readOptionalString(status.action_id),
50
+ target_environment: readOptionalString(status.target_environment),
51
+ pipeline_id: readOptionalString(status.pipeline_id),
52
+ release_intent: readOptionalString(status.release_intent),
53
+ started_at: readOptionalString(status.started_at),
54
+ completed_at: readOptionalString(status.completed_at),
55
+ next_step: readOptionalString(status.next_step),
56
+ error: readError(status.error),
57
+ artifacts: {
58
+ state_file: (0, workspace_state_1.getPipelineStatusFile)(rootDir),
59
+ manifest_file: manifestFile,
60
+ flow_log_file: config.flowLogPath ?? ".mcp/whc-mcp/logs/flow-events.jsonl",
61
+ },
62
+ },
63
+ error: null,
64
+ meta: {
65
+ latency_ms: Date.now() - startedAt,
66
+ safety_level: "A",
67
+ delivery_mechanism: "read_probe",
68
+ },
69
+ };
70
+ }
71
+ function readBoolean(input) {
72
+ return input === true;
73
+ }
74
+ function readString(input) {
75
+ return typeof input === "string" ? input : "";
76
+ }
77
+ function readOptionalString(input) {
78
+ return typeof input === "string" && input.length > 0 ? input : undefined;
79
+ }
80
+ function readStatus(input) {
81
+ if (input === "in_progress" || input === "success" || input === "failure") {
82
+ return input;
83
+ }
84
+ return "idle";
85
+ }
86
+ function readError(input) {
87
+ if (!input || typeof input !== "object") {
88
+ return null;
89
+ }
90
+ const code = readString(input.code);
91
+ const message = readString(input.message);
92
+ if (!code || !message) {
93
+ return null;
94
+ }
95
+ return { code, message };
96
+ }