patchwarden 1.1.0 → 1.5.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 (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,392 @@
1
+ /**
2
+ * PatchWarden Control Center — runtime state readers and domain helpers.
3
+ *
4
+ * Provides health probing, runtime file readers (tunnel status, tool manifest,
5
+ * watcher status), stale-task classification, the control-center status/event
6
+ * timeline, hidden-id UI state, and small shared helpers (verdict parsing,
7
+ * file mtime, id validation). These are pure helpers consumed by the route
8
+ * modules; they hold no HTTP-layer concerns beyond reusing `sendJson`.
9
+ */
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { get as httpGet } from "node:http";
13
+ import { listTasks } from "../tools/listTasks.js";
14
+ import { listAgents } from "../tools/listAgents.js";
15
+ import { readWatcherStatus } from "../watcherStatus.js";
16
+ import { resolveWorkspaceRoot } from "../config.js";
17
+ import { PATCHWARDEN_VERSION } from "../version.js";
18
+ import { logger } from "../logging.js";
19
+ import { config, controlCenterEventsPath, controlCenterStatusPath, errorMessage, getControlCenterLogDir, getRuntimeRoot, host, MAX_EVENT_LINES, port, readJsonFileSafe, } from "./shared.js";
20
+ export function probeHealthStatus(targetUrl) {
21
+ return new Promise((resolve) => {
22
+ const controller = new AbortController();
23
+ let settled = false;
24
+ const finish = (result) => {
25
+ if (settled)
26
+ return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ resolve(result);
30
+ };
31
+ const timer = setTimeout(() => {
32
+ try {
33
+ controller.abort();
34
+ }
35
+ catch { /* ignore */ }
36
+ finish({ available: false, status: null, reason: "timeout after 2000ms" });
37
+ }, 2000);
38
+ try {
39
+ const req = httpGet(targetUrl, { signal: controller.signal }, (resp) => {
40
+ resp.resume();
41
+ const status = resp.statusCode ?? 0;
42
+ // Only 2xx counts as available. A 404 from an unrelated service (or a
43
+ // 4xx/5xx from the real service) must NOT be treated as healthy.
44
+ if (status >= 200 && status < 300) {
45
+ finish({ available: true, status, reason: null });
46
+ }
47
+ else {
48
+ finish({ available: false, status, reason: `unexpected status ${status}` });
49
+ }
50
+ });
51
+ req.on("error", (err) => {
52
+ finish({ available: false, status: null, reason: err.message });
53
+ });
54
+ }
55
+ catch (err) {
56
+ finish({ available: false, status: null, reason: errorMessage(err) });
57
+ }
58
+ });
59
+ }
60
+ export async function probeRuntimeHealth(baseUrl) {
61
+ const [h, r] = await Promise.all([
62
+ probeHealthStatus(`${baseUrl}/healthz`),
63
+ probeHealthStatus(`${baseUrl}/readyz`),
64
+ ]);
65
+ if (h.available && r.available && h.status !== null && r.status !== null) {
66
+ return { available: true, reason: null, healthz: { status: h.status }, readyz: { status: r.status } };
67
+ }
68
+ const failed = !h.available ? h : r;
69
+ return { available: false, reason: failed.reason ?? "unavailable", healthz: null, readyz: null };
70
+ }
71
+ // ── Runtime file readers ──────────────────────────────────────────
72
+ export function readTunnelStatus(direct) {
73
+ const filePath = join(getRuntimeRoot(direct), "tunnel-status.json");
74
+ if (!existsSync(filePath))
75
+ return { observed: false };
76
+ try {
77
+ const data = readJsonFileSafe(filePath);
78
+ if (data === null)
79
+ return { observed: true, error: "invalid JSON" };
80
+ return { observed: true, ...data };
81
+ }
82
+ catch (err) {
83
+ return { observed: true, error: errorMessage(err) };
84
+ }
85
+ }
86
+ export function readToolManifest(direct) {
87
+ const empty = {
88
+ tool_profile: null,
89
+ tool_count: null,
90
+ schema_epoch: null,
91
+ tool_manifest_sha256: null,
92
+ tool_names: null,
93
+ };
94
+ const filePath = join(getRuntimeRoot(direct), "tool-manifest.json");
95
+ if (!existsSync(filePath))
96
+ return empty;
97
+ const data = readJsonFileSafe(filePath);
98
+ if (!data)
99
+ return empty;
100
+ return {
101
+ tool_profile: typeof data.tool_profile === "string" ? data.tool_profile : null,
102
+ tool_count: typeof data.tool_count === "number" ? data.tool_count : null,
103
+ schema_epoch: typeof data.schema_epoch === "string" ? data.schema_epoch : null,
104
+ tool_manifest_sha256: typeof data.tool_manifest_sha256 === "string" ? data.tool_manifest_sha256 : null,
105
+ tool_names: Array.isArray(data.tool_names) ? data.tool_names : null,
106
+ };
107
+ }
108
+ export function readTunnelUrl(direct) {
109
+ const filePath = join(getRuntimeRoot(direct), "tunnel-health-url.txt");
110
+ if (!existsSync(filePath))
111
+ return { url: null, reason: "tunnel-health-url.txt not found" };
112
+ try {
113
+ const content = readFileSync(filePath, "utf-8").trim();
114
+ if (!content)
115
+ return { url: null, reason: "tunnel-health-url.txt is empty" };
116
+ return { url: content, reason: null };
117
+ }
118
+ catch (err) {
119
+ return { url: null, reason: errorMessage(err) };
120
+ }
121
+ }
122
+ // ── Safe wrappers around reusable modules ─────────────────────────
123
+ export function readWatcherStatusSafe() {
124
+ try {
125
+ return readWatcherStatus(config);
126
+ }
127
+ catch (err) {
128
+ return {
129
+ status: "unreadable",
130
+ available: false,
131
+ stale_after_seconds: config.watcherStaleSeconds,
132
+ last_heartbeat_at: null,
133
+ heartbeat_age_seconds: null,
134
+ heartbeat_pid: null,
135
+ instance_id: null,
136
+ launcher_pid: null,
137
+ reason: errorMessage(err),
138
+ activity: null,
139
+ };
140
+ }
141
+ }
142
+ export function listAgentsSafe() {
143
+ try {
144
+ return listAgents().agents;
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ export function resolveWorkspaceRootSafe() {
151
+ try {
152
+ return resolveWorkspaceRoot(config);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ export const TERMINAL_TASK_STATUSES = new Set([
159
+ "done",
160
+ "done_by_agent",
161
+ "failed",
162
+ "failed_verification",
163
+ "failed_scope_violation",
164
+ "failed_policy_violation",
165
+ "canceled",
166
+ "timeout",
167
+ ]);
168
+ /**
169
+ * Classify a task as stale based on Phase 2 rules:
170
+ * - status=running but last_heartbeat_at exceeds threshold
171
+ * - phase=collecting_artifacts exceeds threshold
172
+ * - current_command=null AND watcher currently healthy
173
+ * - task last_heartbeat_at significantly earlier than current watcher heartbeat
174
+ *
175
+ * Only pending/running tasks can be stale; terminal tasks are never stale.
176
+ */
177
+ export function classifyStaleTask(task, watcher, nowMs = Date.now()) {
178
+ const reasons = [];
179
+ if (TERMINAL_TASK_STATUSES.has(task.status)) {
180
+ return { is_stale: false, stale_reasons: reasons };
181
+ }
182
+ // Only pending/running are candidates for staleness.
183
+ if (task.status !== "pending" && task.status !== "running") {
184
+ return { is_stale: false, stale_reasons: reasons };
185
+ }
186
+ const staleThresholdMs = config.watcherStaleSeconds * 1000;
187
+ const hbMs = Date.parse(task.last_heartbeat_at || "");
188
+ const heartbeatAgeMs = Number.isFinite(hbMs) ? Math.max(0, nowMs - hbMs) : null;
189
+ // Rule 1: running with stale heartbeat
190
+ if (task.status === "running" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
191
+ reasons.push("heartbeat_stale");
192
+ }
193
+ // Rule 2: collecting_artifacts phase exceeds threshold
194
+ if (task.phase === "collecting_artifacts" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
195
+ reasons.push("collecting_artifacts_stale");
196
+ }
197
+ // Rule 3: running with no current_command while watcher is healthy
198
+ if (task.status === "running" &&
199
+ (task.current_command === null || task.current_command === "") &&
200
+ watcher.status === "healthy") {
201
+ reasons.push("running_no_command_watcher_healthy");
202
+ }
203
+ // Rule 4: task heartbeat significantly earlier than watcher heartbeat
204
+ if (heartbeatAgeMs !== null && watcher.last_heartbeat_at) {
205
+ const watcherHbMs = Date.parse(watcher.last_heartbeat_at);
206
+ if (Number.isFinite(watcherHbMs)) {
207
+ const gapMs = watcherHbMs - hbMs;
208
+ // Task heartbeat is "significantly earlier" than watcher heartbeat when
209
+ // the task has not heartbeat for at least 2x the stale threshold while
210
+ // the watcher is alive.
211
+ if (gapMs > staleThresholdMs * 2 && watcher.status === "healthy") {
212
+ reasons.push("heartbeat_far_behind_watcher");
213
+ }
214
+ }
215
+ }
216
+ return { is_stale: reasons.length > 0, stale_reasons: reasons };
217
+ }
218
+ export function augmentTaskWithStale(task, watcher, nowMs = Date.now()) {
219
+ const cls = classifyStaleTask(task, watcher, nowMs);
220
+ return { ...task, is_stale: cls.is_stale, stale_reasons: cls.stale_reasons };
221
+ }
222
+ export function listTasksForStatus() {
223
+ try {
224
+ const result = listTasks({ limit: 100 });
225
+ const watcher = result.watcher;
226
+ const now = Date.now();
227
+ let active = 0;
228
+ let stale = 0;
229
+ const staleTaskIds = [];
230
+ const augmented = result.tasks.map((t) => {
231
+ const a = augmentTaskWithStale(t, watcher, now);
232
+ if (t.status === "pending" || t.status === "running")
233
+ active++;
234
+ if (a.is_stale) {
235
+ stale++;
236
+ staleTaskIds.push(t.task_id);
237
+ }
238
+ return a;
239
+ });
240
+ return { tasks: augmented, total: result.total, active, stale, stale_task_ids: staleTaskIds, reason: null };
241
+ }
242
+ catch (err) {
243
+ return { tasks: [], total: 0, active: 0, stale: 0, stale_task_ids: [], reason: errorMessage(err) };
244
+ }
245
+ }
246
+ export function writeStatusFile() {
247
+ const status = {
248
+ pid: process.pid,
249
+ port,
250
+ started_at: new Date().toISOString(),
251
+ url: `http://${host}:${port}/`,
252
+ version: PATCHWARDEN_VERSION,
253
+ };
254
+ try {
255
+ mkdirSync(getControlCenterLogDir(), { recursive: true });
256
+ writeFileSync(controlCenterStatusPath, JSON.stringify(status, null, 2), "utf-8");
257
+ }
258
+ catch (err) {
259
+ logger.error("[control-center] Failed to write status file", { error: errorMessage(err) });
260
+ }
261
+ }
262
+ export function removeStatusFile() {
263
+ try {
264
+ if (existsSync(controlCenterStatusPath)) {
265
+ unlinkSync(controlCenterStatusPath);
266
+ }
267
+ }
268
+ catch (err) {
269
+ logger.error("[control-center] Failed to remove status file", { error: errorMessage(err) });
270
+ }
271
+ }
272
+ /**
273
+ * Append a single event line to the JSONL timeline. Best-effort: a write
274
+ * failure is logged but never crashes the server. Trims the file to
275
+ * MAX_EVENT_LINES lazily when it grows past 1.5x the cap, so we don't pay the
276
+ * trim cost on every event.
277
+ */
278
+ export function recordEvent(type, payload) {
279
+ const event = {
280
+ timestamp: new Date().toISOString(),
281
+ type,
282
+ payload,
283
+ };
284
+ try {
285
+ mkdirSync(getControlCenterLogDir(), { recursive: true });
286
+ appendFileSync(controlCenterEventsPath, JSON.stringify(event) + "\n", "utf-8");
287
+ }
288
+ catch (err) {
289
+ logger.error("[control-center] Failed to write event", { error: errorMessage(err) });
290
+ }
291
+ // Lazy trim: only when the file grows well past the cap.
292
+ try {
293
+ const stat = statSync(controlCenterEventsPath);
294
+ if (stat.size > 512 * 1024) {
295
+ trimEventsFile();
296
+ }
297
+ }
298
+ catch {
299
+ /* ignore */
300
+ }
301
+ }
302
+ function trimEventsFile() {
303
+ try {
304
+ if (!existsSync(controlCenterEventsPath))
305
+ return;
306
+ const raw = readFileSync(controlCenterEventsPath, "utf-8");
307
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
308
+ if (lines.length <= MAX_EVENT_LINES)
309
+ return;
310
+ const trimmed = lines.slice(lines.length - MAX_EVENT_LINES);
311
+ writeFileSync(controlCenterEventsPath, trimmed.join("\n") + "\n", "utf-8");
312
+ }
313
+ catch (err) {
314
+ logger.error("[control-center] Failed to trim events file", { error: errorMessage(err) });
315
+ }
316
+ }
317
+ export function readEvents(limit) {
318
+ if (!existsSync(controlCenterEventsPath))
319
+ return [];
320
+ try {
321
+ const raw = readFileSync(controlCenterEventsPath, "utf-8");
322
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
323
+ const sliced = lines.slice(Math.max(0, lines.length - limit));
324
+ const events = [];
325
+ for (const line of sliced) {
326
+ try {
327
+ events.push(JSON.parse(line));
328
+ }
329
+ catch {
330
+ /* skip malformed line */
331
+ }
332
+ }
333
+ return events;
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ }
339
+ // ── Hidden stale task IDs (local UI state) ────────────────────────
340
+ export function readHiddenStaleIds() {
341
+ const p = join(getControlCenterLogDir(), "hidden-stale-ids.json");
342
+ if (!existsSync(p))
343
+ return [];
344
+ return readJsonFileSafe(p) || [];
345
+ }
346
+ export function writeHiddenStaleIds(ids) {
347
+ const dir = getControlCenterLogDir();
348
+ if (!existsSync(dir))
349
+ mkdirSync(dir, { recursive: true });
350
+ writeFileSync(join(dir, "hidden-stale-ids.json"), JSON.stringify(ids, null, 2));
351
+ }
352
+ // ── Hidden direct session IDs (local UI state) ────────────────────
353
+ export function readHiddenDirectSessionIds() {
354
+ const p = join(getControlCenterLogDir(), "hidden-direct-session-ids.json");
355
+ if (!existsSync(p))
356
+ return [];
357
+ return readJsonFileSafe(p) || [];
358
+ }
359
+ export function writeHiddenDirectSessionIds(ids) {
360
+ const dir = getControlCenterLogDir();
361
+ if (!existsSync(dir))
362
+ mkdirSync(dir, { recursive: true });
363
+ writeFileSync(join(dir, "hidden-direct-session-ids.json"), JSON.stringify(ids, null, 2));
364
+ }
365
+ export function isValidDirectSessionId(sessionId) {
366
+ // Allow alphanumeric, dash, underscore only — reject path traversal and NUL.
367
+ return (sessionId.length > 0 &&
368
+ /^[A-Za-z0-9_-]+$/.test(sessionId) &&
369
+ !sessionId.includes("\0"));
370
+ }
371
+ export function isValidTaskId(taskId) {
372
+ return !(taskId === "." ||
373
+ taskId === ".." ||
374
+ taskId.includes("/") ||
375
+ taskId.includes("\\") ||
376
+ taskId.includes("\0"));
377
+ }
378
+ // ── Small shared helpers ──────────────────────────────────────────
379
+ export function parseReviewVerdict(content) {
380
+ // independent-review.md format: "**Verdict**: PASS" (case-insensitive)
381
+ const m = content.match(/\*\*Verdict\*\*\s*:\s*([A-Za-z]+)/);
382
+ return m ? m[1].toLowerCase() : null;
383
+ }
384
+ export function fileMtimeIso(filePath) {
385
+ try {
386
+ const m = statSync(filePath).mtime;
387
+ return m ? m.toISOString() : null;
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * PatchWarden Control Center — HTTP server creation, routing, and lifecycle.
3
+ *
4
+ * This module wires together the route handlers (routes/*.ts), middleware
5
+ * (auth/static), and the shared/runtime helpers. It owns the `handleRequest`
6
+ * router that dispatches every GET/POST route, the POST control-token gate,
7
+ * and the server bootstrap/shutdown lifecycle (status file, activity events,
8
+ * SIGINT/SIGTERM).
9
+ *
10
+ * Entry point: `controlCenter.ts` imports `startServer()` from here.
11
+ */
12
+ import { type Server } from "node:http";
13
+ export declare function startServer(): Server;