patchwarden 0.4.0

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 (153) hide show
  1. package/Check-PatchWarden-Health.cmd +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +526 -0
  4. package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  5. package/Restart-PatchWarden.cmd +19 -0
  6. package/Start-PatchWarden-Tunnel.cmd +7 -0
  7. package/dist/config.d.ts +24 -0
  8. package/dist/config.js +131 -0
  9. package/dist/doctor.d.ts +10 -0
  10. package/dist/doctor.js +388 -0
  11. package/dist/errors.d.ts +16 -0
  12. package/dist/errors.js +27 -0
  13. package/dist/httpServer.d.ts +17 -0
  14. package/dist/httpServer.js +255 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +24 -0
  17. package/dist/runner/changeCapture.d.ts +43 -0
  18. package/dist/runner/changeCapture.js +245 -0
  19. package/dist/runner/cli.d.ts +9 -0
  20. package/dist/runner/cli.js +26 -0
  21. package/dist/runner/runTask.d.ts +8 -0
  22. package/dist/runner/runTask.js +821 -0
  23. package/dist/runner/watch.d.ts +19 -0
  24. package/dist/runner/watch.js +136 -0
  25. package/dist/security/commandGuard.d.ts +22 -0
  26. package/dist/security/commandGuard.js +56 -0
  27. package/dist/security/contentRedaction.d.ts +12 -0
  28. package/dist/security/contentRedaction.js +65 -0
  29. package/dist/security/pathGuard.d.ts +3 -0
  30. package/dist/security/pathGuard.js +98 -0
  31. package/dist/security/planGuard.d.ts +1 -0
  32. package/dist/security/planGuard.js +65 -0
  33. package/dist/security/sensitiveGuard.d.ts +2 -0
  34. package/dist/security/sensitiveGuard.js +60 -0
  35. package/dist/smoke-test.d.ts +15 -0
  36. package/dist/smoke-test.js +989 -0
  37. package/dist/taskProgress.d.ts +6 -0
  38. package/dist/taskProgress.js +36 -0
  39. package/dist/taskRuntime.d.ts +10 -0
  40. package/dist/taskRuntime.js +25 -0
  41. package/dist/tools/auditTask.d.ts +19 -0
  42. package/dist/tools/auditTask.js +307 -0
  43. package/dist/tools/cancelTask.d.ts +30 -0
  44. package/dist/tools/cancelTask.js +64 -0
  45. package/dist/tools/createTask.d.ts +42 -0
  46. package/dist/tools/createTask.js +188 -0
  47. package/dist/tools/getPlan.d.ts +12 -0
  48. package/dist/tools/getPlan.js +29 -0
  49. package/dist/tools/getTaskFile.d.ts +24 -0
  50. package/dist/tools/getTaskFile.js +84 -0
  51. package/dist/tools/getTaskProgress.d.ts +1 -0
  52. package/dist/tools/getTaskProgress.js +4 -0
  53. package/dist/tools/getTaskStatus.d.ts +46 -0
  54. package/dist/tools/getTaskStatus.js +36 -0
  55. package/dist/tools/getTaskStdoutTail.d.ts +9 -0
  56. package/dist/tools/getTaskStdoutTail.js +56 -0
  57. package/dist/tools/getTaskSummary.d.ts +66 -0
  58. package/dist/tools/getTaskSummary.js +237 -0
  59. package/dist/tools/healthCheck.d.ts +79 -0
  60. package/dist/tools/healthCheck.js +230 -0
  61. package/dist/tools/killTask.d.ts +15 -0
  62. package/dist/tools/killTask.js +4 -0
  63. package/dist/tools/listAgents.d.ts +12 -0
  64. package/dist/tools/listAgents.js +42 -0
  65. package/dist/tools/listTasks.d.ts +36 -0
  66. package/dist/tools/listTasks.js +94 -0
  67. package/dist/tools/listWorkspace.d.ts +10 -0
  68. package/dist/tools/listWorkspace.js +54 -0
  69. package/dist/tools/readWorkspaceFile.d.ts +8 -0
  70. package/dist/tools/readWorkspaceFile.js +26 -0
  71. package/dist/tools/registry.d.ts +24 -0
  72. package/dist/tools/registry.js +566 -0
  73. package/dist/tools/retryTask.d.ts +8 -0
  74. package/dist/tools/retryTask.js +45 -0
  75. package/dist/tools/savePlan.d.ts +10 -0
  76. package/dist/tools/savePlan.js +28 -0
  77. package/dist/tools/taskOutputs.d.ts +49 -0
  78. package/dist/tools/taskOutputs.js +136 -0
  79. package/dist/tools/taskTemplates.d.ts +15 -0
  80. package/dist/tools/taskTemplates.js +116 -0
  81. package/dist/tools/toolCatalog.d.ts +19 -0
  82. package/dist/tools/toolCatalog.js +71 -0
  83. package/dist/tools/waitForTask.d.ts +25 -0
  84. package/dist/tools/waitForTask.js +59 -0
  85. package/dist/version.d.ts +2 -0
  86. package/dist/version.js +2 -0
  87. package/dist/watcherStatus.d.ts +20 -0
  88. package/dist/watcherStatus.js +77 -0
  89. package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
  90. package/docs/demo.md +58 -0
  91. package/docs/migration-from-safe-bifrost.md +31 -0
  92. package/docs/release-v0.3.0.md +43 -0
  93. package/docs/release-v0.4.0.md +74 -0
  94. package/examples/config.example.json +40 -0
  95. package/examples/openai-tunnel/README.md +48 -0
  96. package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
  97. package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
  98. package/package.json +73 -0
  99. package/scripts/brand-check.js +44 -0
  100. package/scripts/doctor-smoke.js +52 -0
  101. package/scripts/get-patchwarden-health.ps1 +114 -0
  102. package/scripts/http-mcp-smoke.js +351 -0
  103. package/scripts/lifecycle-smoke.js +453 -0
  104. package/scripts/mcp-manifest-check.js +120 -0
  105. package/scripts/mcp-smoke.js +336 -0
  106. package/scripts/pack-clean.js +142 -0
  107. package/scripts/patchwarden-mcp-stdio.cmd +7 -0
  108. package/scripts/restart-patchwarden.ps1 +242 -0
  109. package/scripts/start-patchwarden-tunnel.ps1 +503 -0
  110. package/scripts/tunnel-supervisor-smoke.js +77 -0
  111. package/scripts/watcher-supervisor-smoke.js +195 -0
  112. package/src/config.ts +165 -0
  113. package/src/doctor.ts +438 -0
  114. package/src/errors.ts +26 -0
  115. package/src/httpServer.ts +277 -0
  116. package/src/index.ts +34 -0
  117. package/src/runner/changeCapture.ts +304 -0
  118. package/src/runner/cli.ts +34 -0
  119. package/src/runner/runTask.ts +935 -0
  120. package/src/runner/watch.ts +152 -0
  121. package/src/security/commandGuard.ts +97 -0
  122. package/src/security/contentRedaction.ts +85 -0
  123. package/src/security/pathGuard.ts +175 -0
  124. package/src/security/planGuard.ts +93 -0
  125. package/src/security/sensitiveGuard.ts +72 -0
  126. package/src/smoke-test.ts +1078 -0
  127. package/src/taskProgress.ts +41 -0
  128. package/src/taskRuntime.ts +37 -0
  129. package/src/tools/auditTask.ts +340 -0
  130. package/src/tools/cancelTask.ts +70 -0
  131. package/src/tools/createTask.ts +337 -0
  132. package/src/tools/getPlan.ts +48 -0
  133. package/src/tools/getTaskFile.ts +111 -0
  134. package/src/tools/getTaskProgress.ts +5 -0
  135. package/src/tools/getTaskStatus.ts +85 -0
  136. package/src/tools/getTaskStdoutTail.ts +61 -0
  137. package/src/tools/getTaskSummary.ts +307 -0
  138. package/src/tools/healthCheck.ts +234 -0
  139. package/src/tools/killTask.ts +5 -0
  140. package/src/tools/listAgents.ts +54 -0
  141. package/src/tools/listTasks.ts +139 -0
  142. package/src/tools/listWorkspace.ts +71 -0
  143. package/src/tools/readWorkspaceFile.ts +41 -0
  144. package/src/tools/registry.ts +666 -0
  145. package/src/tools/retryTask.ts +53 -0
  146. package/src/tools/savePlan.ts +46 -0
  147. package/src/tools/taskOutputs.ts +155 -0
  148. package/src/tools/taskTemplates.ts +162 -0
  149. package/src/tools/toolCatalog.ts +92 -0
  150. package/src/tools/waitForTask.ts +90 -0
  151. package/src/version.ts +2 -0
  152. package/src/watcherStatus.ts +102 -0
  153. package/tsconfig.json +17 -0
@@ -0,0 +1,237 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { getConfig, getTasksDir } from "../config.js";
4
+ import { guardReadPath } from "../security/pathGuard.js";
5
+ import { getTaskStatus } from "./getTaskStatus.js";
6
+ import { redactSensitiveValue } from "../security/contentRedaction.js";
7
+ const TERMINAL_STATUSES = new Set([
8
+ "done",
9
+ "failed",
10
+ "failed_verification",
11
+ "failed_scope_violation",
12
+ "failed_policy_violation",
13
+ "canceled",
14
+ ]);
15
+ export function getTaskSummary(taskId) {
16
+ const config = getConfig();
17
+ const taskDir = resolve(getTasksDir(config), taskId);
18
+ const statusFile = join(taskDir, "status.json");
19
+ guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
20
+ const status = getTaskStatus(taskId);
21
+ const resultRead = tryReadJson(join(taskDir, "result.json"));
22
+ const verifyRead = tryReadJson(join(taskDir, "verify.json"));
23
+ const result = resultRead.data;
24
+ const verify = verifyRead.data;
25
+ const terminal = TERMINAL_STATUSES.has(String(status.status));
26
+ const outOfScope = asArray(result.out_of_scope_changes ?? status.out_of_scope_changes);
27
+ const verifyStatus = String(verify.status ?? result.verify_status ?? result.verify?.status ?? status.verify_status ?? "not_available");
28
+ const errors = [status.error, ...asArray(result.errors), ...asArray(result.known_issues)]
29
+ .filter((value) => typeof value === "string" && value.trim() !== "");
30
+ const warnings = asArray(result.warnings).filter((value) => typeof value === "string");
31
+ const artifacts = Object.fromEntries([
32
+ "result.md",
33
+ "result.json",
34
+ "diff.patch",
35
+ "git.diff",
36
+ "test.log",
37
+ "verify.log",
38
+ "verify.json",
39
+ "changed-files.json",
40
+ "file-stats.json",
41
+ "rollback_scope_violation_plan.md",
42
+ ].map((name) => [name, existsSync(join(taskDir, name))]));
43
+ for (const required of ["result.md", "result.json", "diff.patch", "file-stats.json", "test.log", "verify.json"]) {
44
+ if (!artifacts[required])
45
+ warnings.push(`${required} is missing.`);
46
+ }
47
+ if (resultRead.error)
48
+ warnings.push(`result.json could not be parsed; using status.json/result.md fallback: ${resultRead.error}`);
49
+ if (verifyRead.error)
50
+ warnings.push(`verify.json could not be parsed; using status.json fallback: ${verifyRead.error}`);
51
+ let acceptanceStatus = "pending";
52
+ let acceptanceReviewedAt = null;
53
+ let acceptanceReviewer = null;
54
+ // Check for explicit human acceptance (takes precedence over computed status)
55
+ const acceptanceFile = join(taskDir, "acceptance.json");
56
+ if (existsSync(acceptanceFile)) {
57
+ try {
58
+ const acceptance = JSON.parse(readFileSync(acceptanceFile, "utf-8"));
59
+ if (acceptance.status === "accepted") {
60
+ acceptanceStatus = "ready_for_review";
61
+ acceptanceReviewedAt = acceptance.reviewed_at || null;
62
+ acceptanceReviewer = acceptance.reviewer || null;
63
+ }
64
+ else if (acceptance.status === "rejected") {
65
+ acceptanceStatus = "failed";
66
+ acceptanceReviewedAt = acceptance.reviewed_at || null;
67
+ acceptanceReviewer = acceptance.reviewer || null;
68
+ if (acceptance.notes) {
69
+ warnings.push(`Task was rejected by ${acceptance.reviewer || "human"} at ${acceptance.reviewed_at || "unknown time"}: ${acceptance.notes}`);
70
+ }
71
+ }
72
+ }
73
+ catch {
74
+ warnings.push("acceptance.json exists but could not be parsed.");
75
+ }
76
+ }
77
+ else if (terminal) {
78
+ if (status.status !== "done" || outOfScope.length > 0 || verifyStatus === "failed") {
79
+ acceptanceStatus = "failed";
80
+ }
81
+ else if (verifyStatus === "passed") {
82
+ acceptanceStatus = "ready_for_review";
83
+ }
84
+ else {
85
+ acceptanceStatus = "needs_review";
86
+ warnings.push("No passing verify_commands evidence is available; manual review is required.");
87
+ }
88
+ }
89
+ const startedAt = Date.parse(String(status.started_at || status.created_at || ""));
90
+ const finishedAt = Date.parse(String(status.finished_at || ""));
91
+ const elapsedMs = Number.isFinite(startedAt)
92
+ ? Math.max(0, (Number.isFinite(finishedAt) ? finishedAt : Date.now()) - startedAt)
93
+ : 0;
94
+ const changedFiles = asArray(result.changed_files ?? status.changed_files);
95
+ const verifyCommands = asArray(verify.commands ?? result.verify_commands ?? result.verify?.commands);
96
+ const testLogSummary = summarizeTestLog(join(taskDir, "test.log"));
97
+ const verificationSummary = buildVerificationSummary(verifyStatus, verifyCommands, testLogSummary);
98
+ // Extract failed command detail from verify records
99
+ const failedVerify = verifyCommands.find((cmd) => ["failed", "timed_out", "canceled"].includes(cmd?.status));
100
+ const failedCommandDetail = failedVerify ? {
101
+ command: String(failedVerify.command || ""),
102
+ exit_code: failedVerify.exit_code ?? null,
103
+ stderr_tail: String(failedVerify.stderr_tail || "").slice(0, 500),
104
+ duration_ms: Number(failedVerify.duration_ms || 0),
105
+ } : null;
106
+ // Collect log tails (last 5 lines of each log)
107
+ const logTails = {
108
+ stdout: readLogTail(join(taskDir, "stdout.log"), 5),
109
+ stderr: readLogTail(join(taskDir, "stderr.log"), 5),
110
+ test: readLogTail(join(taskDir, "test.log"), 5),
111
+ verify: readLogTail(join(taskDir, "verify.log"), 5),
112
+ };
113
+ const output = {
114
+ task_id: taskId,
115
+ status: String(status.status || "unknown"),
116
+ terminal,
117
+ acceptance_status: acceptanceStatus,
118
+ acceptance_reviewed_at: acceptanceReviewedAt,
119
+ acceptance_reviewer: acceptanceReviewer,
120
+ phase: String(status.phase || "unknown"),
121
+ agent: String(status.agent || result.agent || ""),
122
+ workspace_root: String(status.workspace_root || result.workspace_root || config.workspaceRoot),
123
+ repo_path: String(status.repo_path || result.repo_path || ""),
124
+ resolved_repo_path: String(status.resolved_repo_path || result.resolved_repo_path || ""),
125
+ changed_files: changedFiles,
126
+ out_of_scope_changes: outOfScope,
127
+ workspace_dirty_before: Boolean(status.workspace_dirty_before ?? result.workspace_dirty_before),
128
+ workspace_dirty_after: Boolean(status.workspace_dirty_after ?? status.workspace_dirty ?? result.workspace_dirty_after),
129
+ verify_status: verifyStatus,
130
+ verify_commands: verifyCommands,
131
+ last_heartbeat_at: String(status.last_heartbeat_at || status.updated_at || ""),
132
+ current_command: status.current_command ?? null,
133
+ elapsed_ms: elapsedMs,
134
+ summary: String(result.summary || readResultFallback(join(taskDir, "result.md")) || status.error || `Task is ${status.status || "unknown"}.`),
135
+ test_summary: verificationSummary.headline,
136
+ diff_available: Boolean((status.diff_available ?? (changedFiles.length > 0)) &&
137
+ (artifacts["diff.patch"] || artifacts["git.diff"])),
138
+ diff_truncated: Boolean(status.diff_truncated || result.warnings?.some?.((warning) => warning.includes("diff.patch was truncated"))),
139
+ result_available: artifacts["result.md"],
140
+ result_json_available: artifacts["result.json"],
141
+ verify_available: artifacts["verify.json"],
142
+ test_log_available: artifacts["test.log"],
143
+ warnings: [...new Set(warnings)],
144
+ errors: [...new Set(errors)],
145
+ artifacts,
146
+ plan_source: String(status.plan_source || result.plan_source || "saved"),
147
+ template: status.template || result.template || null,
148
+ change_policy: String(status.change_policy || result.change_policy || "repo_scoped_changes"),
149
+ failure_reason: result.failure_reason || status.error || null,
150
+ failed_command: result.failed_command || null,
151
+ suggested_next_action: String(result.suggested_next_action || (terminal ? "audit_task" : status.execution_blocked ? "health_check" : "wait_for_task")),
152
+ safe_followup_prompt: result.safe_followup_prompt || null,
153
+ verification_summary: verificationSummary,
154
+ failed_command_detail: failedCommandDetail,
155
+ log_tails: logTails,
156
+ watcher: status.watcher,
157
+ pending_reason: status.pending_reason || null,
158
+ execution_blocked: Boolean(status.execution_blocked),
159
+ };
160
+ const safe = redactSensitiveValue(output);
161
+ return {
162
+ ...safe.value,
163
+ redacted: safe.redacted,
164
+ redaction_categories: safe.redaction_categories,
165
+ };
166
+ }
167
+ function tryReadJson(path) {
168
+ if (!existsSync(path))
169
+ return { data: {} };
170
+ try {
171
+ return { data: JSON.parse(readFileSync(path, "utf-8")) };
172
+ }
173
+ catch (error) {
174
+ return { data: {}, error: error instanceof Error ? error.message : String(error) };
175
+ }
176
+ }
177
+ function asArray(value) {
178
+ return Array.isArray(value) ? value : [];
179
+ }
180
+ function summarizeTestLog(path) {
181
+ if (!existsSync(path))
182
+ return "test.log missing";
183
+ const text = readFileSync(path, "utf-8");
184
+ const exit = text.match(/Exit\s*code:\s*([^\r\n]+)/i)?.[1]?.trim();
185
+ return exit ? `Exit code: ${exit}` : text.trim().slice(0, 500) || "test.log empty";
186
+ }
187
+ function readResultFallback(path) {
188
+ if (!existsSync(path))
189
+ return "";
190
+ const text = readFileSync(path, "utf-8");
191
+ return text.match(/## Summary\s+([\s\S]*?)(?:\n## |\n---|$)/i)?.[1]?.trim().slice(0, 1000) || "";
192
+ }
193
+ function buildVerificationSummary(status, commands, testLogSummary) {
194
+ const passed = commands.filter((command) => command?.status === "passed").length;
195
+ const failed = commands.filter((command) => ["failed", "timed_out", "canceled"].includes(command?.status)).length;
196
+ const skipped = commands.filter((command) => command?.status === "skipped").length;
197
+ const evidenceText = [
198
+ ...commands.flatMap((command) => [command?.stdout_tail, command?.stderr_tail]),
199
+ testLogSummary,
200
+ ].filter((value) => typeof value === "string").join("\n");
201
+ const headline = extractTestHeadline(evidenceText)
202
+ || (commands.length > 0 ? `${passed}/${commands.length} verification commands passed` : testLogSummary);
203
+ return {
204
+ status,
205
+ command_count: commands.length,
206
+ passed_commands: passed,
207
+ failed_commands: failed,
208
+ skipped_commands: skipped,
209
+ headline,
210
+ };
211
+ }
212
+ function extractTestHeadline(text) {
213
+ const patterns = [
214
+ /\b\d+\s+passed(?:,\s*\d+\s+failed)?\b/i,
215
+ /\b\d+\s+tests?\s+passed\b/i,
216
+ /\btests?:\s*\d+\s+passed(?:,\s*\d+\s+failed)?\b/i,
217
+ ];
218
+ for (const pattern of patterns) {
219
+ const match = text.match(pattern);
220
+ if (match)
221
+ return match[0];
222
+ }
223
+ return "";
224
+ }
225
+ function readLogTail(path, lines) {
226
+ if (!existsSync(path))
227
+ return "(file not found)";
228
+ try {
229
+ const raw = readFileSync(path, "utf-8");
230
+ if (raw.length === 0)
231
+ return "(empty)";
232
+ return raw.split("\n").slice(-lines).join("\n");
233
+ }
234
+ catch {
235
+ return "(unreadable)";
236
+ }
237
+ }
@@ -0,0 +1,79 @@
1
+ import type { ToolCatalogSnapshot } from "./toolCatalog.js";
2
+ export interface HealthCheckInput {
3
+ detail?: "standard" | "self_diagnostic";
4
+ }
5
+ export declare function healthCheck(catalog?: ToolCatalogSnapshot, input?: HealthCheckInput): {
6
+ last_error: string | null;
7
+ self_diagnostic?: {
8
+ redacted: boolean;
9
+ redaction_categories: string[];
10
+ mode: string;
11
+ allowed_test_commands_count: number;
12
+ allowed_test_commands: string[];
13
+ configured_agents: string[];
14
+ recent_tasks_returned: number;
15
+ recent_failures: {
16
+ task_id: string;
17
+ status: import("./createTask.js").TaskStatus;
18
+ phase: import("./createTask.js").TaskPhase;
19
+ pending_reason: import("../watcherStatus.js").PendingReason;
20
+ error: string | null;
21
+ }[];
22
+ } | undefined;
23
+ status: string;
24
+ server_version: string;
25
+ schema_epoch: string;
26
+ tool_profile: string;
27
+ tool_count: number | null;
28
+ tool_names: string[];
29
+ tool_manifest_sha256: string | null;
30
+ profile_consistent: boolean;
31
+ profile_errors: string[];
32
+ catalog_consistent: boolean;
33
+ mismatch_report: string[];
34
+ tunnel_catalog_comparison: string;
35
+ connector_visibility: {
36
+ status: string;
37
+ verification: string;
38
+ refresh_steps: string[];
39
+ };
40
+ mcp_server: {
41
+ available: boolean;
42
+ pid: number;
43
+ uptime_seconds: number;
44
+ checked_at: string;
45
+ };
46
+ watcher: {
47
+ supervisor: Record<string, unknown>;
48
+ status: import("../watcherStatus.js").WatcherState;
49
+ available: boolean;
50
+ stale_after_seconds: number;
51
+ last_heartbeat_at: string | null;
52
+ heartbeat_age_seconds: number | null;
53
+ heartbeat_pid: number | null;
54
+ instance_id: string | null;
55
+ launcher_pid: number | null;
56
+ reason: string | null;
57
+ };
58
+ workspace_root: {
59
+ available: boolean;
60
+ path: string;
61
+ readable: boolean;
62
+ writable: boolean;
63
+ reason: string | null;
64
+ };
65
+ tasks_dir: {
66
+ available: boolean;
67
+ path: string;
68
+ readable: boolean;
69
+ writable: boolean;
70
+ reason: string | null;
71
+ };
72
+ tunnel: Record<string, unknown> & {
73
+ last_error: string | null;
74
+ };
75
+ agents: import("./listAgents.js").AgentAvailability[];
76
+ agent_status: {
77
+ [k: string]: string;
78
+ };
79
+ };
@@ -0,0 +1,230 @@
1
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
5
+ import { listAgents } from "./listAgents.js";
6
+ import { redactSensitiveContent, redactSensitiveValue } from "../security/contentRedaction.js";
7
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
8
+ import { CHATGPT_CORE_TOOL_NAMES, resolveToolProfile } from "./toolCatalog.js";
9
+ import { readWatcherStatus } from "../watcherStatus.js";
10
+ import { listTasks } from "./listTasks.js";
11
+ const SERVER_STARTED_AT = Date.now();
12
+ export function healthCheck(catalog, input = {}) {
13
+ const config = getConfig();
14
+ const workspaceRoot = resolveWorkspaceRoot(config);
15
+ const tasksDir = getTasksDir(config);
16
+ const watcher = {
17
+ ...readWatcherStatus(config),
18
+ supervisor: readWatcherSupervisorStatus(),
19
+ };
20
+ const agents = listAgents();
21
+ const workspace = directoryStatus(workspaceRoot, false);
22
+ const tasks = directoryStatus(tasksDir, true, workspaceRoot);
23
+ const tunnel = readTunnelStatus();
24
+ // Profile consistency: verify catalog matches the active profile
25
+ const profileErrors = [];
26
+ let profileConsistent = true;
27
+ if (catalog) {
28
+ const activeProfile = resolveToolProfile(config.toolProfile);
29
+ const expectedNames = activeProfile === "chatgpt_core"
30
+ ? [...CHATGPT_CORE_TOOL_NAMES]
31
+ : null; // full profile: all tools are accepted
32
+ if (expectedNames) {
33
+ const catalogNames = new Set(catalog.tool_names);
34
+ for (const name of expectedNames) {
35
+ if (!catalogNames.has(name)) {
36
+ profileErrors.push(`Expected tool "${name}" is missing from the active ${activeProfile} profile catalog.`);
37
+ profileConsistent = false;
38
+ }
39
+ }
40
+ if (catalog.tool_count !== expectedNames.length) {
41
+ profileErrors.push(`Profile ${activeProfile} expects ${expectedNames.length} tools but catalog reports ${catalog.tool_count}. This can cause tools to appear in the list but fail when called.`);
42
+ profileConsistent = false;
43
+ }
44
+ }
45
+ }
46
+ else {
47
+ profileErrors.push("No tool catalog snapshot available; profile consistency cannot be verified.");
48
+ profileConsistent = false;
49
+ }
50
+ const mismatchReport = [...profileErrors];
51
+ let tunnelCatalogComparison = "unavailable";
52
+ if (catalog && typeof tunnel.tool_profile === "string" && tunnel.tool_profile) {
53
+ if (tunnel.tool_profile !== catalog.tool_profile) {
54
+ tunnelCatalogComparison = "different_profile";
55
+ }
56
+ else if (typeof tunnel.tool_manifest_sha256 === "string" && tunnel.tool_manifest_sha256) {
57
+ tunnelCatalogComparison = tunnel.tool_manifest_sha256 === catalog.tool_manifest_sha256 ? "match" : "mismatch";
58
+ if (tunnelCatalogComparison === "mismatch") {
59
+ mismatchReport.push("The active MCP catalog hash differs from the tunnel startup manifest. Restart the owned tunnel process.");
60
+ }
61
+ }
62
+ }
63
+ const agentReady = agents.agents.every((agent) => agent.available);
64
+ const catalogConsistent = profileConsistent && mismatchReport.length === 0;
65
+ const status = watcher.available && workspace.available && tasks.available && agentReady && catalogConsistent
66
+ ? "healthy"
67
+ : "degraded";
68
+ return {
69
+ status,
70
+ server_version: catalog?.server_version || PATCHWARDEN_VERSION,
71
+ schema_epoch: catalog?.schema_epoch || TOOL_SCHEMA_EPOCH,
72
+ tool_profile: catalog?.tool_profile || "unknown",
73
+ tool_count: catalog?.tool_count ?? null,
74
+ tool_names: catalog?.tool_names || [],
75
+ tool_manifest_sha256: catalog?.tool_manifest_sha256 || null,
76
+ profile_consistent: profileConsistent,
77
+ profile_errors: profileErrors,
78
+ catalog_consistent: catalogConsistent,
79
+ mismatch_report: mismatchReport,
80
+ tunnel_catalog_comparison: tunnelCatalogComparison,
81
+ connector_visibility: {
82
+ status: "not_observable_server_side",
83
+ verification: "Refresh or reconnect the Connector and verify tools/list from a new ChatGPT conversation.",
84
+ refresh_steps: [
85
+ "1. Run Check-PatchWarden-Health.cmd to confirm tool_profile=chatgpt_core, tool_count=16, and catalog_consistent=true.",
86
+ "2. In ChatGPT Platform, refresh or reconnect the Connector (do not reuse an old session).",
87
+ "3. Open a NEW ChatGPT conversation (old conversations retain their cached tool catalog).",
88
+ "4. Call health_check in the new conversation; verify tool_manifest_sha256 matches the local report.",
89
+ ],
90
+ },
91
+ mcp_server: {
92
+ available: true,
93
+ pid: process.pid,
94
+ uptime_seconds: Math.round((Date.now() - SERVER_STARTED_AT) / 1000),
95
+ checked_at: new Date().toISOString(),
96
+ },
97
+ watcher,
98
+ workspace_root: workspace,
99
+ tasks_dir: tasks,
100
+ tunnel,
101
+ agents: agents.agents,
102
+ agent_status: Object.fromEntries(agents.agents.map((agent) => [agent.name, agent.available ? "ok" : "missing"])),
103
+ ...(input.detail === "self_diagnostic" ? { self_diagnostic: buildSelfDiagnostic(config) } : {}),
104
+ last_error: tunnel.last_error || (!watcher.available ? watcher.reason : null) || mismatchReport[0] || null,
105
+ };
106
+ }
107
+ function buildSelfDiagnostic(config) {
108
+ const recent = listTasks({ limit: 20 });
109
+ const failures = recent.tasks
110
+ .filter((task) => task.status.startsWith("failed") || task.status === "canceled")
111
+ .slice(0, 10)
112
+ .map((task) => ({
113
+ task_id: task.task_id,
114
+ status: task.status,
115
+ phase: task.phase,
116
+ pending_reason: task.pending_reason,
117
+ error: task.error,
118
+ }));
119
+ const diagnostic = {
120
+ mode: "self_diagnostic",
121
+ allowed_test_commands_count: config.allowedTestCommands.length,
122
+ allowed_test_commands: [...config.allowedTestCommands],
123
+ configured_agents: Object.keys(config.agents),
124
+ recent_tasks_returned: recent.tasks.length,
125
+ recent_failures: failures,
126
+ };
127
+ const safe = redactSensitiveValue(diagnostic);
128
+ return {
129
+ ...safe.value,
130
+ redacted: safe.redacted,
131
+ redaction_categories: safe.redaction_categories,
132
+ };
133
+ }
134
+ function readWatcherSupervisorStatus() {
135
+ const runtimeRoot = process.platform === "win32" && process.env.LOCALAPPDATA
136
+ ? join(process.env.LOCALAPPDATA, "patchwarden", "runtime")
137
+ : join(homedir(), ".patchwarden", "runtime");
138
+ const path = join(runtimeRoot, "watcher-status.json");
139
+ if (!existsSync(path))
140
+ return { observed: false, managed: false };
141
+ try {
142
+ const raw = JSON.parse(readFileSync(path, "utf-8").replace(/^\uFEFF/, ""));
143
+ return {
144
+ observed: true,
145
+ managed: Boolean(raw.managed),
146
+ status: safeText(raw.status, 40) || "unknown",
147
+ pid: Number.isInteger(Number(raw.pid)) ? Number(raw.pid) : null,
148
+ instance_id: safeText(raw.instance_id, 100) || null,
149
+ restart_attempts: Number.isInteger(Number(raw.restart_attempts)) ? Number(raw.restart_attempts) : 0,
150
+ last_error: safeText(raw.last_error, 500) || null,
151
+ checked_at: safeText(raw.checked_at, 80) || null,
152
+ };
153
+ }
154
+ catch {
155
+ return { observed: true, managed: false, status: "invalid_status_file" };
156
+ }
157
+ }
158
+ function directoryStatus(path, allowCreatable, createRoot) {
159
+ if (!existsSync(path)) {
160
+ if (allowCreatable) {
161
+ const parent = createRoot || dirname(path);
162
+ try {
163
+ accessSync(parent, constants.R_OK | constants.W_OK);
164
+ return { available: true, path, readable: false, writable: true, reason: "Directory will be created on first use." };
165
+ }
166
+ catch { }
167
+ }
168
+ return { available: false, path, readable: false, writable: false, reason: "Directory does not exist." };
169
+ }
170
+ let readable = false;
171
+ let writable = false;
172
+ try {
173
+ accessSync(path, constants.R_OK);
174
+ readable = true;
175
+ }
176
+ catch { }
177
+ try {
178
+ accessSync(path, constants.W_OK);
179
+ writable = true;
180
+ }
181
+ catch { }
182
+ return {
183
+ available: readable && writable,
184
+ path,
185
+ readable,
186
+ writable,
187
+ reason: readable && writable ? null : "Directory is not readable and writable by the current process.",
188
+ };
189
+ }
190
+ function readTunnelStatus() {
191
+ const runtimeRoot = process.platform === "win32" && process.env.LOCALAPPDATA
192
+ ? join(process.env.LOCALAPPDATA, "patchwarden", "runtime")
193
+ : join(homedir(), ".patchwarden", "runtime");
194
+ const statusFile = join(runtimeRoot, "tunnel-status.json");
195
+ if (!existsSync(statusFile)) {
196
+ return { observed: false, status: "not_observed", last_error: null };
197
+ }
198
+ try {
199
+ const text = readFileSync(statusFile, "utf-8");
200
+ const raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
201
+ const allowed = {
202
+ observed: true,
203
+ status: safeText(raw.status, 40) || "unknown",
204
+ reason_code: safeText(raw.reason_code, 80) || null,
205
+ ready: Boolean(raw.ready),
206
+ attempt: Number.isFinite(Number(raw.attempt)) ? Number(raw.attempt) : null,
207
+ pid: Number.isInteger(Number(raw.pid)) ? Number(raw.pid) : null,
208
+ checked_at: safeText(raw.checked_at, 80) || null,
209
+ next_retry_at: safeText(raw.next_retry_at, 80) || null,
210
+ last_error: safeText(raw.last_error, 500) || null,
211
+ server_version: safeText(raw.server_version, 40) || null,
212
+ schema_epoch: safeText(raw.schema_epoch, 80) || null,
213
+ tool_profile: safeText(raw.tool_profile, 40) || null,
214
+ tool_count: Number.isInteger(Number(raw.tool_count)) ? Number(raw.tool_count) : null,
215
+ tool_names: Array.isArray(raw.tool_names)
216
+ ? raw.tool_names.filter((name) => typeof name === "string").slice(0, 100)
217
+ : [],
218
+ tool_manifest_sha256: safeText(raw.tool_manifest_sha256, 80) || null,
219
+ core_tools_ready: Boolean(raw.core_tools_ready),
220
+ };
221
+ const redacted = redactSensitiveContent(JSON.stringify(allowed));
222
+ return JSON.parse(redacted.content);
223
+ }
224
+ catch {
225
+ return { observed: true, status: "invalid_status_file", last_error: "Tunnel status file is unreadable." };
226
+ }
227
+ }
228
+ function safeText(value, maxLength) {
229
+ return typeof value === "string" ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength) : "";
230
+ }
@@ -0,0 +1,15 @@
1
+ export declare function killTask(taskId: string): {
2
+ task_id: string;
3
+ previous_status: string;
4
+ new_status: string;
5
+ message: string;
6
+ cancel_requested?: undefined;
7
+ force_kill_requested?: undefined;
8
+ } | {
9
+ task_id: string;
10
+ previous_status: string;
11
+ new_status: string;
12
+ cancel_requested: boolean;
13
+ force_kill_requested: boolean;
14
+ message: string;
15
+ };
@@ -0,0 +1,4 @@
1
+ import { requestTaskTermination } from "./cancelTask.js";
2
+ export function killTask(taskId) {
3
+ return requestTaskTermination(taskId, true);
4
+ }
@@ -0,0 +1,12 @@
1
+ export interface AgentAvailability {
2
+ name: string;
3
+ configured: true;
4
+ available: boolean;
5
+ command: string;
6
+ reason: string | null;
7
+ checked_at: string;
8
+ }
9
+ export declare function listAgents(): {
10
+ agents: AgentAvailability[];
11
+ total: number;
12
+ };
@@ -0,0 +1,42 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { basename, delimiter, extname, isAbsolute, join } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ export function listAgents() {
5
+ const config = getConfig();
6
+ const checkedAt = new Date().toISOString();
7
+ const agents = Object.entries(config.agents)
8
+ .sort(([left], [right]) => left.localeCompare(right))
9
+ .map(([name, agent]) => {
10
+ const available = commandExists(agent.command);
11
+ return {
12
+ name,
13
+ configured: true,
14
+ available,
15
+ command: basename(agent.command),
16
+ reason: available ? null : "Configured executable was not found on disk or PATH.",
17
+ checked_at: checkedAt,
18
+ };
19
+ });
20
+ return { agents, total: agents.length };
21
+ }
22
+ function commandExists(command) {
23
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
24
+ return isFile(command);
25
+ }
26
+ const pathEntries = (process.env.PATH || "").split(delimiter).filter(Boolean);
27
+ const extensions = process.platform === "win32"
28
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
29
+ : [""];
30
+ const candidates = extname(command)
31
+ ? pathEntries.map((entry) => join(entry, command))
32
+ : pathEntries.flatMap((entry) => extensions.map((extension) => join(entry, `${command}${extension}`)));
33
+ return candidates.some(isFile);
34
+ }
35
+ function isFile(path) {
36
+ try {
37
+ return existsSync(path) && statSync(path).isFile();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
@@ -0,0 +1,36 @@
1
+ import type { TaskPhase, TaskStatus } from "./createTask.js";
2
+ import { type PendingReason, type WatcherState, type WatcherStatusSnapshot } from "../watcherStatus.js";
3
+ export interface TaskEntry {
4
+ task_id: string;
5
+ plan_id: string;
6
+ title: string;
7
+ agent: string;
8
+ status: TaskStatus;
9
+ phase: TaskPhase;
10
+ created_at: string;
11
+ updated_at: string;
12
+ workspace_root: string;
13
+ repo_path: string;
14
+ resolved_repo_path: string;
15
+ test_command: string;
16
+ verify_commands: string[];
17
+ error: string | null;
18
+ last_heartbeat_at: string;
19
+ current_command: string | null;
20
+ timeout_seconds: number;
21
+ pending_reason: PendingReason;
22
+ watcher_status: WatcherState;
23
+ }
24
+ export interface ListTasksInput {
25
+ status?: string;
26
+ repo_path?: string;
27
+ active_only?: boolean;
28
+ limit?: number;
29
+ }
30
+ export interface ListTasksOutput {
31
+ tasks: TaskEntry[];
32
+ total: number;
33
+ returned: number;
34
+ watcher: WatcherStatusSnapshot;
35
+ }
36
+ export declare function listTasks(input?: ListTasksInput): ListTasksOutput;