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,307 @@
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
+
8
+ const TERMINAL_STATUSES = new Set([
9
+ "done",
10
+ "failed",
11
+ "failed_verification",
12
+ "failed_scope_violation",
13
+ "failed_policy_violation",
14
+ "canceled",
15
+ ]);
16
+
17
+ export interface TaskSummaryOutput {
18
+ task_id: string;
19
+ status: string;
20
+ terminal: boolean;
21
+ acceptance_status: "pending" | "ready_for_review" | "needs_review" | "failed";
22
+ acceptance_reviewed_at: string | null;
23
+ acceptance_reviewer: string | null;
24
+ phase: string;
25
+ agent: string;
26
+ workspace_root: string;
27
+ repo_path: string;
28
+ resolved_repo_path: string;
29
+ changed_files: unknown[];
30
+ out_of_scope_changes: unknown[];
31
+ workspace_dirty_before: boolean;
32
+ workspace_dirty_after: boolean;
33
+ verify_status: string;
34
+ verify_commands: unknown[];
35
+ last_heartbeat_at: string;
36
+ current_command: string | null;
37
+ elapsed_ms: number;
38
+ summary: string;
39
+ test_summary: string;
40
+ diff_available: boolean;
41
+ diff_truncated: boolean;
42
+ result_available: boolean;
43
+ result_json_available: boolean;
44
+ verify_available: boolean;
45
+ test_log_available: boolean;
46
+ warnings: string[];
47
+ errors: string[];
48
+ artifacts: Record<string, boolean>;
49
+ plan_source: string;
50
+ template: string | null;
51
+ change_policy: string;
52
+ failure_reason: string | null;
53
+ failed_command: string | null;
54
+ suggested_next_action: string;
55
+ safe_followup_prompt: string | null;
56
+ verification_summary: {
57
+ status: string;
58
+ command_count: number;
59
+ passed_commands: number;
60
+ failed_commands: number;
61
+ skipped_commands: number;
62
+ headline: string;
63
+ };
64
+ failed_command_detail: {
65
+ command: string;
66
+ exit_code: number | null;
67
+ stderr_tail: string;
68
+ duration_ms: number;
69
+ } | null;
70
+ log_tails: {
71
+ stdout: string;
72
+ stderr: string;
73
+ test: string;
74
+ verify: string;
75
+ };
76
+ redacted: boolean;
77
+ redaction_categories: string[];
78
+ watcher: unknown;
79
+ pending_reason: string | null;
80
+ execution_blocked: boolean;
81
+ }
82
+
83
+ export function getTaskSummary(taskId: string): TaskSummaryOutput {
84
+ const config = getConfig();
85
+ const taskDir = resolve(getTasksDir(config), taskId);
86
+ const statusFile = join(taskDir, "status.json");
87
+ guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
88
+ const status = getTaskStatus(taskId) as any;
89
+ const resultRead = tryReadJson(join(taskDir, "result.json"));
90
+ const verifyRead = tryReadJson(join(taskDir, "verify.json"));
91
+ const result = resultRead.data;
92
+ const verify = verifyRead.data;
93
+ const terminal = TERMINAL_STATUSES.has(String(status.status));
94
+ const outOfScope = asArray(result.out_of_scope_changes ?? status.out_of_scope_changes);
95
+ const verifyStatus = String(verify.status ?? result.verify_status ?? result.verify?.status ?? status.verify_status ?? "not_available");
96
+ const errors = [status.error, ...asArray(result.errors), ...asArray(result.known_issues)]
97
+ .filter((value): value is string => typeof value === "string" && value.trim() !== "");
98
+ const warnings = asArray(result.warnings).filter((value): value is string => typeof value === "string");
99
+ const artifacts = Object.fromEntries([
100
+ "result.md",
101
+ "result.json",
102
+ "diff.patch",
103
+ "git.diff",
104
+ "test.log",
105
+ "verify.log",
106
+ "verify.json",
107
+ "changed-files.json",
108
+ "file-stats.json",
109
+ "rollback_scope_violation_plan.md",
110
+ ].map((name) => [name, existsSync(join(taskDir, name))]));
111
+
112
+ for (const required of ["result.md", "result.json", "diff.patch", "file-stats.json", "test.log", "verify.json"]) {
113
+ if (!artifacts[required]) warnings.push(`${required} is missing.`);
114
+ }
115
+ if (resultRead.error) warnings.push(`result.json could not be parsed; using status.json/result.md fallback: ${resultRead.error}`);
116
+ if (verifyRead.error) warnings.push(`verify.json could not be parsed; using status.json fallback: ${verifyRead.error}`);
117
+
118
+ let acceptanceStatus: TaskSummaryOutput["acceptance_status"] = "pending";
119
+ let acceptanceReviewedAt: string | null = null;
120
+ let acceptanceReviewer: string | null = null;
121
+
122
+ // Check for explicit human acceptance (takes precedence over computed status)
123
+ const acceptanceFile = join(taskDir, "acceptance.json");
124
+ if (existsSync(acceptanceFile)) {
125
+ try {
126
+ const acceptance = JSON.parse(readFileSync(acceptanceFile, "utf-8"));
127
+ if (acceptance.status === "accepted") {
128
+ acceptanceStatus = "ready_for_review";
129
+ acceptanceReviewedAt = acceptance.reviewed_at || null;
130
+ acceptanceReviewer = acceptance.reviewer || null;
131
+ } else if (acceptance.status === "rejected") {
132
+ acceptanceStatus = "failed";
133
+ acceptanceReviewedAt = acceptance.reviewed_at || null;
134
+ acceptanceReviewer = acceptance.reviewer || null;
135
+ if (acceptance.notes) {
136
+ warnings.push(`Task was rejected by ${acceptance.reviewer || "human"} at ${acceptance.reviewed_at || "unknown time"}: ${acceptance.notes}`);
137
+ }
138
+ }
139
+ } catch {
140
+ warnings.push("acceptance.json exists but could not be parsed.");
141
+ }
142
+ } else if (terminal) {
143
+ if (status.status !== "done" || outOfScope.length > 0 || verifyStatus === "failed") {
144
+ acceptanceStatus = "failed";
145
+ } else if (verifyStatus === "passed") {
146
+ acceptanceStatus = "ready_for_review";
147
+ } else {
148
+ acceptanceStatus = "needs_review";
149
+ warnings.push("No passing verify_commands evidence is available; manual review is required.");
150
+ }
151
+ }
152
+
153
+ const startedAt = Date.parse(String(status.started_at || status.created_at || ""));
154
+ const finishedAt = Date.parse(String(status.finished_at || ""));
155
+ const elapsedMs = Number.isFinite(startedAt)
156
+ ? Math.max(0, (Number.isFinite(finishedAt) ? finishedAt : Date.now()) - startedAt)
157
+ : 0;
158
+ const changedFiles = asArray(result.changed_files ?? status.changed_files);
159
+ const verifyCommands = asArray(verify.commands ?? result.verify_commands ?? result.verify?.commands);
160
+ const testLogSummary = summarizeTestLog(join(taskDir, "test.log"));
161
+ const verificationSummary = buildVerificationSummary(verifyStatus, verifyCommands, testLogSummary);
162
+
163
+ // Extract failed command detail from verify records
164
+ const failedVerify = (verifyCommands as any[]).find((cmd: any) =>
165
+ ["failed", "timed_out", "canceled"].includes(cmd?.status)
166
+ );
167
+ const failedCommandDetail = failedVerify ? {
168
+ command: String(failedVerify.command || ""),
169
+ exit_code: failedVerify.exit_code ?? null,
170
+ stderr_tail: String(failedVerify.stderr_tail || "").slice(0, 500),
171
+ duration_ms: Number(failedVerify.duration_ms || 0),
172
+ } : null;
173
+
174
+ // Collect log tails (last 5 lines of each log)
175
+ const logTails = {
176
+ stdout: readLogTail(join(taskDir, "stdout.log"), 5),
177
+ stderr: readLogTail(join(taskDir, "stderr.log"), 5),
178
+ test: readLogTail(join(taskDir, "test.log"), 5),
179
+ verify: readLogTail(join(taskDir, "verify.log"), 5),
180
+ };
181
+
182
+ const output = {
183
+ task_id: taskId,
184
+ status: String(status.status || "unknown"),
185
+ terminal,
186
+ acceptance_status: acceptanceStatus,
187
+ acceptance_reviewed_at: acceptanceReviewedAt,
188
+ acceptance_reviewer: acceptanceReviewer,
189
+ phase: String(status.phase || "unknown"),
190
+ agent: String(status.agent || result.agent || ""),
191
+ workspace_root: String(status.workspace_root || result.workspace_root || config.workspaceRoot),
192
+ repo_path: String(status.repo_path || result.repo_path || ""),
193
+ resolved_repo_path: String(status.resolved_repo_path || result.resolved_repo_path || ""),
194
+ changed_files: changedFiles,
195
+ out_of_scope_changes: outOfScope,
196
+ workspace_dirty_before: Boolean(status.workspace_dirty_before ?? result.workspace_dirty_before),
197
+ workspace_dirty_after: Boolean(status.workspace_dirty_after ?? status.workspace_dirty ?? result.workspace_dirty_after),
198
+ verify_status: verifyStatus,
199
+ verify_commands: verifyCommands,
200
+ last_heartbeat_at: String(status.last_heartbeat_at || status.updated_at || ""),
201
+ current_command: status.current_command ?? null,
202
+ elapsed_ms: elapsedMs,
203
+ summary: String(result.summary || readResultFallback(join(taskDir, "result.md")) || status.error || `Task is ${status.status || "unknown"}.`),
204
+ test_summary: verificationSummary.headline,
205
+ diff_available: Boolean(
206
+ (status.diff_available ?? (changedFiles.length > 0)) &&
207
+ (artifacts["diff.patch"] || artifacts["git.diff"])
208
+ ),
209
+ diff_truncated: Boolean(status.diff_truncated || result.warnings?.some?.((warning: string) => warning.includes("diff.patch was truncated"))),
210
+ result_available: artifacts["result.md"],
211
+ result_json_available: artifacts["result.json"],
212
+ verify_available: artifacts["verify.json"],
213
+ test_log_available: artifacts["test.log"],
214
+ warnings: [...new Set(warnings)],
215
+ errors: [...new Set(errors)],
216
+ artifacts,
217
+ plan_source: String(status.plan_source || result.plan_source || "saved"),
218
+ template: status.template || result.template || null,
219
+ change_policy: String(status.change_policy || result.change_policy || "repo_scoped_changes"),
220
+ failure_reason: result.failure_reason || status.error || null,
221
+ failed_command: result.failed_command || null,
222
+ suggested_next_action: String(result.suggested_next_action || (terminal ? "audit_task" : status.execution_blocked ? "health_check" : "wait_for_task")),
223
+ safe_followup_prompt: result.safe_followup_prompt || null,
224
+ verification_summary: verificationSummary,
225
+ failed_command_detail: failedCommandDetail,
226
+ log_tails: logTails,
227
+ watcher: status.watcher,
228
+ pending_reason: status.pending_reason || null,
229
+ execution_blocked: Boolean(status.execution_blocked),
230
+ };
231
+ const safe = redactSensitiveValue(output);
232
+ return {
233
+ ...safe.value,
234
+ redacted: safe.redacted,
235
+ redaction_categories: safe.redaction_categories,
236
+ } as TaskSummaryOutput;
237
+ }
238
+
239
+ function tryReadJson(path: string): { data: Record<string, any>; error?: string } {
240
+ if (!existsSync(path)) return { data: {} };
241
+ try {
242
+ return { data: JSON.parse(readFileSync(path, "utf-8")) };
243
+ } catch (error) {
244
+ return { data: {}, error: error instanceof Error ? error.message : String(error) };
245
+ }
246
+ }
247
+
248
+ function asArray(value: unknown): any[] {
249
+ return Array.isArray(value) ? value : [];
250
+ }
251
+
252
+ function summarizeTestLog(path: string): string {
253
+ if (!existsSync(path)) return "test.log missing";
254
+ const text = readFileSync(path, "utf-8");
255
+ const exit = text.match(/Exit\s*code:\s*([^\r\n]+)/i)?.[1]?.trim();
256
+ return exit ? `Exit code: ${exit}` : text.trim().slice(0, 500) || "test.log empty";
257
+ }
258
+
259
+ function readResultFallback(path: string): string {
260
+ if (!existsSync(path)) return "";
261
+ const text = readFileSync(path, "utf-8");
262
+ return text.match(/## Summary\s+([\s\S]*?)(?:\n## |\n---|$)/i)?.[1]?.trim().slice(0, 1000) || "";
263
+ }
264
+
265
+ function buildVerificationSummary(status: string, commands: any[], testLogSummary: string) {
266
+ const passed = commands.filter((command) => command?.status === "passed").length;
267
+ const failed = commands.filter((command) => ["failed", "timed_out", "canceled"].includes(command?.status)).length;
268
+ const skipped = commands.filter((command) => command?.status === "skipped").length;
269
+ const evidenceText = [
270
+ ...commands.flatMap((command) => [command?.stdout_tail, command?.stderr_tail]),
271
+ testLogSummary,
272
+ ].filter((value): value is string => typeof value === "string").join("\n");
273
+ const headline = extractTestHeadline(evidenceText)
274
+ || (commands.length > 0 ? `${passed}/${commands.length} verification commands passed` : testLogSummary);
275
+ return {
276
+ status,
277
+ command_count: commands.length,
278
+ passed_commands: passed,
279
+ failed_commands: failed,
280
+ skipped_commands: skipped,
281
+ headline,
282
+ };
283
+ }
284
+
285
+ function extractTestHeadline(text: string): string {
286
+ const patterns = [
287
+ /\b\d+\s+passed(?:,\s*\d+\s+failed)?\b/i,
288
+ /\b\d+\s+tests?\s+passed\b/i,
289
+ /\btests?:\s*\d+\s+passed(?:,\s*\d+\s+failed)?\b/i,
290
+ ];
291
+ for (const pattern of patterns) {
292
+ const match = text.match(pattern);
293
+ if (match) return match[0];
294
+ }
295
+ return "";
296
+ }
297
+
298
+ function readLogTail(path: string, lines: number): string {
299
+ if (!existsSync(path)) return "(file not found)";
300
+ try {
301
+ const raw = readFileSync(path, "utf-8");
302
+ if (raw.length === 0) return "(empty)";
303
+ return raw.split("\n").slice(-lines).join("\n");
304
+ } catch {
305
+ return "(unreadable)";
306
+ }
307
+ }
@@ -0,0 +1,234 @@
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 type { ToolCatalogSnapshot } from "./toolCatalog.js";
9
+ import { CHATGPT_CORE_TOOL_NAMES, resolveToolProfile } from "./toolCatalog.js";
10
+ import { readWatcherStatus } from "../watcherStatus.js";
11
+ import { listTasks } from "./listTasks.js";
12
+
13
+ const SERVER_STARTED_AT = Date.now();
14
+
15
+ export interface HealthCheckInput {
16
+ detail?: "standard" | "self_diagnostic";
17
+ }
18
+
19
+ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInput = {}) {
20
+ const config = getConfig();
21
+ const workspaceRoot = resolveWorkspaceRoot(config);
22
+ const tasksDir = getTasksDir(config);
23
+ const watcher = {
24
+ ...readWatcherStatus(config),
25
+ supervisor: readWatcherSupervisorStatus(),
26
+ };
27
+
28
+ const agents = listAgents();
29
+ const workspace = directoryStatus(workspaceRoot, false);
30
+ const tasks = directoryStatus(tasksDir, true, workspaceRoot);
31
+ const tunnel = readTunnelStatus();
32
+
33
+ // Profile consistency: verify catalog matches the active profile
34
+ const profileErrors: string[] = [];
35
+ let profileConsistent = true;
36
+ if (catalog) {
37
+ const activeProfile = resolveToolProfile(config.toolProfile);
38
+ const expectedNames = activeProfile === "chatgpt_core"
39
+ ? [...CHATGPT_CORE_TOOL_NAMES]
40
+ : null; // full profile: all tools are accepted
41
+ if (expectedNames) {
42
+ const catalogNames = new Set(catalog.tool_names);
43
+ for (const name of expectedNames) {
44
+ if (!catalogNames.has(name)) {
45
+ profileErrors.push(`Expected tool "${name}" is missing from the active ${activeProfile} profile catalog.`);
46
+ profileConsistent = false;
47
+ }
48
+ }
49
+ if (catalog.tool_count !== expectedNames.length) {
50
+ profileErrors.push(
51
+ `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.`
52
+ );
53
+ profileConsistent = false;
54
+ }
55
+ }
56
+ } else {
57
+ profileErrors.push("No tool catalog snapshot available; profile consistency cannot be verified.");
58
+ profileConsistent = false;
59
+ }
60
+
61
+ const mismatchReport = [...profileErrors];
62
+ let tunnelCatalogComparison = "unavailable";
63
+ if (catalog && typeof tunnel.tool_profile === "string" && tunnel.tool_profile) {
64
+ if (tunnel.tool_profile !== catalog.tool_profile) {
65
+ tunnelCatalogComparison = "different_profile";
66
+ } else if (typeof tunnel.tool_manifest_sha256 === "string" && tunnel.tool_manifest_sha256) {
67
+ tunnelCatalogComparison = tunnel.tool_manifest_sha256 === catalog.tool_manifest_sha256 ? "match" : "mismatch";
68
+ if (tunnelCatalogComparison === "mismatch") {
69
+ mismatchReport.push("The active MCP catalog hash differs from the tunnel startup manifest. Restart the owned tunnel process.");
70
+ }
71
+ }
72
+ }
73
+
74
+ const agentReady = agents.agents.every((agent) => agent.available);
75
+ const catalogConsistent = profileConsistent && mismatchReport.length === 0;
76
+ const status = watcher.available && workspace.available && tasks.available && agentReady && catalogConsistent
77
+ ? "healthy"
78
+ : "degraded";
79
+ return {
80
+ status,
81
+ server_version: catalog?.server_version || PATCHWARDEN_VERSION,
82
+ schema_epoch: catalog?.schema_epoch || TOOL_SCHEMA_EPOCH,
83
+ tool_profile: catalog?.tool_profile || "unknown",
84
+ tool_count: catalog?.tool_count ?? null,
85
+ tool_names: catalog?.tool_names || [],
86
+ tool_manifest_sha256: catalog?.tool_manifest_sha256 || null,
87
+ profile_consistent: profileConsistent,
88
+ profile_errors: profileErrors,
89
+ catalog_consistent: catalogConsistent,
90
+ mismatch_report: mismatchReport,
91
+ tunnel_catalog_comparison: tunnelCatalogComparison,
92
+ connector_visibility: {
93
+ status: "not_observable_server_side",
94
+ verification: "Refresh or reconnect the Connector and verify tools/list from a new ChatGPT conversation.",
95
+ refresh_steps: [
96
+ "1. Run Check-PatchWarden-Health.cmd to confirm tool_profile=chatgpt_core, tool_count=16, and catalog_consistent=true.",
97
+ "2. In ChatGPT Platform, refresh or reconnect the Connector (do not reuse an old session).",
98
+ "3. Open a NEW ChatGPT conversation (old conversations retain their cached tool catalog).",
99
+ "4. Call health_check in the new conversation; verify tool_manifest_sha256 matches the local report.",
100
+ ],
101
+ },
102
+ mcp_server: {
103
+ available: true,
104
+ pid: process.pid,
105
+ uptime_seconds: Math.round((Date.now() - SERVER_STARTED_AT) / 1000),
106
+ checked_at: new Date().toISOString(),
107
+ },
108
+ watcher,
109
+ workspace_root: workspace,
110
+ tasks_dir: tasks,
111
+ tunnel,
112
+ agents: agents.agents,
113
+ agent_status: Object.fromEntries(agents.agents.map((agent) => [agent.name, agent.available ? "ok" : "missing"])),
114
+ ...(input.detail === "self_diagnostic" ? { self_diagnostic: buildSelfDiagnostic(config) } : {}),
115
+ last_error: tunnel.last_error || (!watcher.available ? watcher.reason : null) || mismatchReport[0] || null,
116
+ };
117
+ }
118
+
119
+ function buildSelfDiagnostic(config: ReturnType<typeof getConfig>) {
120
+ const recent = listTasks({ limit: 20 });
121
+ const failures = recent.tasks
122
+ .filter((task) => task.status.startsWith("failed") || task.status === "canceled")
123
+ .slice(0, 10)
124
+ .map((task) => ({
125
+ task_id: task.task_id,
126
+ status: task.status,
127
+ phase: task.phase,
128
+ pending_reason: task.pending_reason,
129
+ error: task.error,
130
+ }));
131
+ const diagnostic = {
132
+ mode: "self_diagnostic",
133
+ allowed_test_commands_count: config.allowedTestCommands.length,
134
+ allowed_test_commands: [...config.allowedTestCommands],
135
+ configured_agents: Object.keys(config.agents),
136
+ recent_tasks_returned: recent.tasks.length,
137
+ recent_failures: failures,
138
+ };
139
+ const safe = redactSensitiveValue(diagnostic);
140
+ return {
141
+ ...safe.value,
142
+ redacted: safe.redacted,
143
+ redaction_categories: safe.redaction_categories,
144
+ };
145
+ }
146
+
147
+ function readWatcherSupervisorStatus(): Record<string, unknown> {
148
+ const runtimeRoot = process.platform === "win32" && process.env.LOCALAPPDATA
149
+ ? join(process.env.LOCALAPPDATA, "patchwarden", "runtime")
150
+ : join(homedir(), ".patchwarden", "runtime");
151
+ const path = join(runtimeRoot, "watcher-status.json");
152
+ if (!existsSync(path)) return { observed: false, managed: false };
153
+ try {
154
+ const raw = JSON.parse(readFileSync(path, "utf-8").replace(/^\uFEFF/, ""));
155
+ return {
156
+ observed: true,
157
+ managed: Boolean(raw.managed),
158
+ status: safeText(raw.status, 40) || "unknown",
159
+ pid: Number.isInteger(Number(raw.pid)) ? Number(raw.pid) : null,
160
+ instance_id: safeText(raw.instance_id, 100) || null,
161
+ restart_attempts: Number.isInteger(Number(raw.restart_attempts)) ? Number(raw.restart_attempts) : 0,
162
+ last_error: safeText(raw.last_error, 500) || null,
163
+ checked_at: safeText(raw.checked_at, 80) || null,
164
+ };
165
+ } catch {
166
+ return { observed: true, managed: false, status: "invalid_status_file" };
167
+ }
168
+ }
169
+
170
+ function directoryStatus(path: string, allowCreatable: boolean, createRoot?: string) {
171
+ if (!existsSync(path)) {
172
+ if (allowCreatable) {
173
+ const parent = createRoot || dirname(path);
174
+ try {
175
+ accessSync(parent, constants.R_OK | constants.W_OK);
176
+ return { available: true, path, readable: false, writable: true, reason: "Directory will be created on first use." };
177
+ } catch {}
178
+ }
179
+ return { available: false, path, readable: false, writable: false, reason: "Directory does not exist." };
180
+ }
181
+ let readable = false;
182
+ let writable = false;
183
+ try { accessSync(path, constants.R_OK); readable = true; } catch {}
184
+ try { accessSync(path, constants.W_OK); writable = true; } catch {}
185
+ return {
186
+ available: readable && writable,
187
+ path,
188
+ readable,
189
+ writable,
190
+ reason: readable && writable ? null : "Directory is not readable and writable by the current process.",
191
+ };
192
+ }
193
+
194
+ function readTunnelStatus(): Record<string, unknown> & { last_error: string | null } {
195
+ const runtimeRoot = process.platform === "win32" && process.env.LOCALAPPDATA
196
+ ? join(process.env.LOCALAPPDATA, "patchwarden", "runtime")
197
+ : join(homedir(), ".patchwarden", "runtime");
198
+ const statusFile = join(runtimeRoot, "tunnel-status.json");
199
+ if (!existsSync(statusFile)) {
200
+ return { observed: false, status: "not_observed", last_error: null };
201
+ }
202
+ try {
203
+ const text = readFileSync(statusFile, "utf-8");
204
+ const raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
205
+ const allowed = {
206
+ observed: true,
207
+ status: safeText(raw.status, 40) || "unknown",
208
+ reason_code: safeText(raw.reason_code, 80) || null,
209
+ ready: Boolean(raw.ready),
210
+ attempt: Number.isFinite(Number(raw.attempt)) ? Number(raw.attempt) : null,
211
+ pid: Number.isInteger(Number(raw.pid)) ? Number(raw.pid) : null,
212
+ checked_at: safeText(raw.checked_at, 80) || null,
213
+ next_retry_at: safeText(raw.next_retry_at, 80) || null,
214
+ last_error: safeText(raw.last_error, 500) || null,
215
+ server_version: safeText(raw.server_version, 40) || null,
216
+ schema_epoch: safeText(raw.schema_epoch, 80) || null,
217
+ tool_profile: safeText(raw.tool_profile, 40) || null,
218
+ tool_count: Number.isInteger(Number(raw.tool_count)) ? Number(raw.tool_count) : null,
219
+ tool_names: Array.isArray(raw.tool_names)
220
+ ? raw.tool_names.filter((name: unknown) => typeof name === "string").slice(0, 100)
221
+ : [],
222
+ tool_manifest_sha256: safeText(raw.tool_manifest_sha256, 80) || null,
223
+ core_tools_ready: Boolean(raw.core_tools_ready),
224
+ };
225
+ const redacted = redactSensitiveContent(JSON.stringify(allowed));
226
+ return JSON.parse(redacted.content);
227
+ } catch {
228
+ return { observed: true, status: "invalid_status_file", last_error: "Tunnel status file is unreadable." };
229
+ }
230
+ }
231
+
232
+ function safeText(value: unknown, maxLength: number): string {
233
+ return typeof value === "string" ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength) : "";
234
+ }
@@ -0,0 +1,5 @@
1
+ import { requestTaskTermination } from "./cancelTask.js";
2
+
3
+ export function killTask(taskId: string) {
4
+ return requestTaskTermination(taskId, true);
5
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { basename, delimiter, extname, isAbsolute, join } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+
5
+ export interface AgentAvailability {
6
+ name: string;
7
+ configured: true;
8
+ available: boolean;
9
+ command: string;
10
+ reason: string | null;
11
+ checked_at: string;
12
+ }
13
+
14
+ export function listAgents(): { agents: AgentAvailability[]; total: number } {
15
+ const config = getConfig();
16
+ const checkedAt = new Date().toISOString();
17
+ const agents = Object.entries(config.agents)
18
+ .sort(([left], [right]) => left.localeCompare(right))
19
+ .map(([name, agent]) => {
20
+ const available = commandExists(agent.command);
21
+ return {
22
+ name,
23
+ configured: true as const,
24
+ available,
25
+ command: basename(agent.command),
26
+ reason: available ? null : "Configured executable was not found on disk or PATH.",
27
+ checked_at: checkedAt,
28
+ };
29
+ });
30
+ return { agents, total: agents.length };
31
+ }
32
+
33
+ function commandExists(command: string): boolean {
34
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
35
+ return isFile(command);
36
+ }
37
+
38
+ const pathEntries = (process.env.PATH || "").split(delimiter).filter(Boolean);
39
+ const extensions = process.platform === "win32"
40
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
41
+ : [""];
42
+ const candidates = extname(command)
43
+ ? pathEntries.map((entry) => join(entry, command))
44
+ : pathEntries.flatMap((entry) => extensions.map((extension) => join(entry, `${command}${extension}`)));
45
+ return candidates.some(isFile);
46
+ }
47
+
48
+ function isFile(path: string): boolean {
49
+ try {
50
+ return existsSync(path) && statSync(path).isFile();
51
+ } catch {
52
+ return false;
53
+ }
54
+ }