patchwarden 1.5.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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  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 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Control Center routes — runtime status, events, diagnostics, tunnel URLs.
3
+ *
4
+ * `handleStatus` is the dashboard's primary poll endpoint: it fans out health
5
+ * probes, watcher/task snapshots, and suggestion generation, and records
6
+ * observed state-change events (core/direct/watcher/task transitions) into the
7
+ * activity timeline. The remaining endpoints expose the control-center status
8
+ * file, the event timeline, diagnostics (redacted), and tunnel UI URLs.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../../version.js";
12
+ import { redactSensitiveValue } from "../../security/contentRedaction.js";
13
+ import { listAgentsSafe, listTasksForStatus, probeRuntimeHealth, readEvents, recordEvent, readToolManifest, readTunnelStatus, readTunnelUrl, readWatcherStatusSafe, resolveWorkspaceRootSafe, } from "../runtime.js";
14
+ import { findTunnelClientExecutable } from "./process.js";
15
+ import { config, controlCenterStatusPath, CORE_BASE_URL, DEFAULT_TUNNEL_CLIENT_EXE, DIRECT_BASE_URL, errorMessage, readJsonFileSafe, sendJson, } from "../shared.js";
16
+ function buildSuggestions(s) {
17
+ const out = [];
18
+ if (!s.core.available) {
19
+ out.push({
20
+ code: "core_stopped",
21
+ severity: "warning",
22
+ message: "Core 未运行,建议启动 Core profile",
23
+ action: "/api/core/start",
24
+ });
25
+ }
26
+ if (!s.direct.available) {
27
+ out.push({
28
+ code: "direct_stopped",
29
+ severity: "warning",
30
+ message: "Direct 未运行,建议启动 Direct profile",
31
+ action: "/api/direct/start",
32
+ });
33
+ }
34
+ if (s.watcher.status === "stale" || s.watcher.status === "unreadable") {
35
+ out.push({
36
+ code: "watcher_stale",
37
+ severity: "error",
38
+ message: "Watcher 处于 " + s.watcher.status + " 状态,建议 Restart All",
39
+ action: "/api/restart-all",
40
+ });
41
+ }
42
+ if (s.tasks.stale > 0) {
43
+ out.push({
44
+ code: "stale_task",
45
+ severity: "warning",
46
+ message: "存在 " + s.tasks.stale + " 个 stale 任务,建议查看并 reconcile",
47
+ link: "/pages/tasks.html?filter=stale",
48
+ });
49
+ }
50
+ const coreTunnelReady = !!(s.tunnel.core && s.tunnel.core.ready);
51
+ const directTunnelReady = !!(s.tunnel.direct && s.tunnel.direct.ready);
52
+ if (!coreTunnelReady || !directTunnelReady) {
53
+ out.push({
54
+ code: "tunnel_not_ready",
55
+ severity: "warning",
56
+ message: "Tunnel 未就绪,建议重启 profile 或检查代理",
57
+ action: "/api/restart-all",
58
+ });
59
+ }
60
+ const missingAgents = s.agents.filter((a) => !a.available);
61
+ if (missingAgents.length > 0) {
62
+ out.push({
63
+ code: "agent_missing",
64
+ severity: "info",
65
+ message: "Agent 未就绪:" + missingAgents.map((a) => a.name).join(", ") + "(请检查 opencode/claude 路径)",
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+ let lastStatusDigest = null;
71
+ function buildStatusDigest(s) {
72
+ const task_statuses = {};
73
+ for (const t of s.tasks.tasks) {
74
+ const entry = t;
75
+ task_statuses[entry.task_id] = entry.status;
76
+ }
77
+ return {
78
+ core_available: s.core.available,
79
+ direct_available: s.direct.available,
80
+ watcher_status: s.watcher.status,
81
+ task_statuses,
82
+ };
83
+ }
84
+ function diffAndRecordEvents(prev, curr) {
85
+ if (prev.core_available !== curr.core_available) {
86
+ recordEvent("core.status_changed", { from: prev.core_available, to: curr.core_available });
87
+ }
88
+ if (prev.direct_available !== curr.direct_available) {
89
+ recordEvent("direct.status_changed", { from: prev.direct_available, to: curr.direct_available });
90
+ }
91
+ if (prev.watcher_status !== curr.watcher_status) {
92
+ recordEvent("watcher.status_changed", { from: prev.watcher_status, to: curr.watcher_status });
93
+ }
94
+ for (const [taskId, newStatus] of Object.entries(curr.task_statuses)) {
95
+ const oldStatus = prev.task_statuses[taskId];
96
+ if (oldStatus && oldStatus !== newStatus) {
97
+ recordEvent("task.status_changed", { task_id: taskId, from: oldStatus, to: newStatus });
98
+ }
99
+ }
100
+ }
101
+ // ── Handlers ──────────────────────────────────────────────────────
102
+ export async function handleStatus(res) {
103
+ try {
104
+ const [coreHealth, directHealth, watcher, tunnelCore, tunnelDirect, toolsCore, toolsDirect, agents, workspaceRoot, tasks] = await Promise.all([
105
+ probeRuntimeHealth(CORE_BASE_URL).catch((err) => ({
106
+ available: false,
107
+ reason: errorMessage(err),
108
+ healthz: null,
109
+ readyz: null,
110
+ })),
111
+ probeRuntimeHealth(DIRECT_BASE_URL).catch((err) => ({
112
+ available: false,
113
+ reason: errorMessage(err),
114
+ healthz: null,
115
+ readyz: null,
116
+ })),
117
+ Promise.resolve(readWatcherStatusSafe()),
118
+ Promise.resolve(readTunnelStatus(false)),
119
+ Promise.resolve(readTunnelStatus(true)),
120
+ Promise.resolve(readToolManifest(false)),
121
+ Promise.resolve(readToolManifest(true)),
122
+ Promise.resolve(listAgentsSafe()),
123
+ Promise.resolve(resolveWorkspaceRootSafe()),
124
+ Promise.resolve(listTasksForStatus()),
125
+ ]);
126
+ const snapshotForSuggestions = {
127
+ core: coreHealth,
128
+ direct: directHealth,
129
+ watcher,
130
+ tunnel: { core: tunnelCore, direct: tunnelDirect },
131
+ agents,
132
+ tasks,
133
+ };
134
+ const suggestions = buildSuggestions(snapshotForSuggestions);
135
+ const tunnelClientExe = findTunnelClientExecutable();
136
+ // Diff against the previous poll to record observed state-change events.
137
+ // This is the only place that observes Core/Direct/watcher/task transitions
138
+ // (the control center is otherwise stateless and pull-driven).
139
+ const digest = buildStatusDigest(snapshotForSuggestions);
140
+ if (lastStatusDigest) {
141
+ diffAndRecordEvents(lastStatusDigest, digest);
142
+ }
143
+ lastStatusDigest = digest;
144
+ sendJson(res, 200, {
145
+ core: coreHealth,
146
+ direct: directHealth,
147
+ watcher,
148
+ tunnel: { core: tunnelCore, direct: tunnelDirect },
149
+ tools: { core: toolsCore, direct: toolsDirect },
150
+ agents,
151
+ workspace_root: workspaceRoot,
152
+ tasks,
153
+ suggestions,
154
+ setup: {
155
+ tunnel_client: {
156
+ available: tunnelClientExe !== null,
157
+ path: tunnelClientExe,
158
+ default_path: DEFAULT_TUNNEL_CLIENT_EXE,
159
+ },
160
+ workspace_root: workspaceRoot,
161
+ watcher: {
162
+ status: watcher.status,
163
+ available: watcher.available,
164
+ reason: watcher.reason,
165
+ },
166
+ },
167
+ });
168
+ }
169
+ catch (err) {
170
+ sendJson(res, 200, { error: errorMessage(err), partial: true });
171
+ }
172
+ }
173
+ export function handleControlCenterStatus(res) {
174
+ // Public read of the status file (used by tray/launcher to confirm identity).
175
+ if (!existsSync(controlCenterStatusPath)) {
176
+ sendJson(res, 200, { running: false });
177
+ return;
178
+ }
179
+ const data = readJsonFileSafe(controlCenterStatusPath);
180
+ if (!data) {
181
+ sendJson(res, 200, { running: false });
182
+ return;
183
+ }
184
+ sendJson(res, 200, { running: true, ...data });
185
+ }
186
+ export function handleEvents(res, limit) {
187
+ const events = readEvents(limit);
188
+ sendJson(res, 200, {
189
+ events,
190
+ total: events.length,
191
+ limit,
192
+ });
193
+ }
194
+ export function handleTunnelUiUrl(res) {
195
+ sendJson(res, 200, {
196
+ core: readTunnelUrl(false),
197
+ direct: readTunnelUrl(true),
198
+ });
199
+ }
200
+ export function handleDiagnostics(res) {
201
+ try {
202
+ const watcher = readWatcherStatusSafe();
203
+ const tunnelCore = readTunnelStatus(false);
204
+ const tunnelDirect = readTunnelStatus(true);
205
+ const toolsCore = readToolManifest(false);
206
+ const toolsDirect = readToolManifest(true);
207
+ const agents = listAgentsSafe();
208
+ let workspaceRoot = null;
209
+ try {
210
+ workspaceRoot = resolveWorkspaceRootSafe();
211
+ }
212
+ catch {
213
+ workspaceRoot = null;
214
+ }
215
+ // Recent failures: last 5 failed tasks (status contains "failed")
216
+ let recentFailures = [];
217
+ try {
218
+ const result = listTasksForStatus();
219
+ recentFailures = result.tasks
220
+ .filter((t) => typeof t.status === "string" && t.status.includes("failed"))
221
+ .slice(0, 5)
222
+ .map((t) => ({ task_id: t.task_id, status: t.status }));
223
+ }
224
+ catch {
225
+ recentFailures = [];
226
+ }
227
+ const coreReady = !!(tunnelCore && tunnelCore.ready);
228
+ const directReady = !!(tunnelDirect && tunnelDirect.ready);
229
+ const diagnostics = {
230
+ server_version: PATCHWARDEN_VERSION,
231
+ schema_epoch: TOOL_SCHEMA_EPOCH,
232
+ tool_manifest_sha256: toolsCore.tool_manifest_sha256,
233
+ watcher_status: watcher.status,
234
+ tunnel_core_ready: coreReady,
235
+ tunnel_direct_ready: directReady,
236
+ core_tool_count: toolsCore.tool_count,
237
+ direct_tool_count: toolsDirect.tool_count,
238
+ agent_status: agents.map((a) => ({ name: a.name, available: a.available })),
239
+ workspace_root: workspaceRoot,
240
+ recent_failures: recentFailures,
241
+ direct_profile_enabled: config.enableDirectProfile ?? false,
242
+ };
243
+ // Redact any sensitive content that may have leaked into string fields.
244
+ const redacted = redactSensitiveValue(diagnostics);
245
+ sendJson(res, 200, redacted.value);
246
+ }
247
+ catch (err) {
248
+ sendJson(res, 200, { error: errorMessage(err) });
249
+ }
250
+ }
@@ -0,0 +1,21 @@
1
+ import { type ServerResponse } from "node:http";
2
+ /**
3
+ * Reconcile a stale task. Does NOT delete the task. Reads the task files,
4
+ * decides whether it is safe to mark the task as stale/archived, writes a
5
+ * reconcile record, and (when safe) annotates status.json with reconcile
6
+ * metadata. The task status enum is never changed — only metadata is added.
7
+ */
8
+ export declare function handleReconcile(res: ServerResponse, taskId: string): void;
9
+ /**
10
+ * Run audit_task for a task. Only safe to delegate when the task directory
11
+ * exists and the task is in a terminal state (auditing a running task mid-flight
12
+ * would race with the watcher writing artifacts).
13
+ */
14
+ export declare function handleTaskAudit(res: ServerResponse, taskId: string): void;
15
+ export declare function handleOpenTaskFolder(res: ServerResponse, taskId: string): void;
16
+ /**
17
+ * Hide a stale task from the dashboard's stale-task view. The task itself is
18
+ * NOT deleted or modified — only the control-center's local hidden-stale-ids
19
+ * state file is updated. Requires control token (enforced by the POST router).
20
+ */
21
+ export declare function handleHideStale(res: ServerResponse, taskId: string): void;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Control Center routes — mutating task actions (POST /api/tasks/:id/*).
3
+ *
4
+ * These handlers are invoked only after the server router has validated the
5
+ * control token. They cover reconcile, on-demand audit_task, opening the task
6
+ * folder in the host file explorer, and hiding a stale task from the
7
+ * dashboard. None of them delete task files; they annotate or launch external
8
+ * viewers.
9
+ */
10
+ import { existsSync, statSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { auditTask } from "../../tools/auditTask.js";
14
+ import { getTasksDir } from "../../config.js";
15
+ import { classifyStaleTask, isValidTaskId, readHiddenStaleIds, readWatcherStatusSafe, recordEvent, TERMINAL_TASK_STATUSES, writeHiddenStaleIds, } from "../runtime.js";
16
+ import { config, errorMessage, readJsonFileSafe, sendJson } from "../shared.js";
17
+ /**
18
+ * Reconcile a stale task. Does NOT delete the task. Reads the task files,
19
+ * decides whether it is safe to mark the task as stale/archived, writes a
20
+ * reconcile record, and (when safe) annotates status.json with reconcile
21
+ * metadata. The task status enum is never changed — only metadata is added.
22
+ */
23
+ export function handleReconcile(res, taskId) {
24
+ try {
25
+ if (!isValidTaskId(taskId)) {
26
+ sendJson(res, 400, { error: "Invalid task id" });
27
+ return;
28
+ }
29
+ const tasksDir = getTasksDir(config);
30
+ const taskDir = join(tasksDir, taskId);
31
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
32
+ sendJson(res, 404, { error: "Task not found" });
33
+ return;
34
+ }
35
+ const statusPath = join(taskDir, "status.json");
36
+ const runtimePath = join(taskDir, "runtime.json");
37
+ const statusData = readJsonFileSafe(statusPath) ?? {};
38
+ const runtimeData = readJsonFileSafe(runtimePath) ?? {};
39
+ const watcher = readWatcherStatusSafe();
40
+ const VALID_ACCEPTANCE = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
41
+ const taskStatus = String(statusData.status || "pending");
42
+ const taskAcceptanceStatus = taskStatus === "done_by_agent"
43
+ ? (typeof statusData.acceptance_status === "string" && VALID_ACCEPTANCE.includes(statusData.acceptance_status) ? statusData.acceptance_status : "pending")
44
+ : null;
45
+ const taskEntry = {
46
+ task_id: taskId,
47
+ plan_id: String(statusData.plan_id || ""),
48
+ title: "",
49
+ agent: String(statusData.agent || ""),
50
+ status: taskStatus,
51
+ phase: String(runtimeData.phase || statusData.phase || "queued"),
52
+ acceptance_status: taskAcceptanceStatus,
53
+ created_at: String(statusData.created_at || ""),
54
+ updated_at: String(statusData.updated_at || ""),
55
+ workspace_root: String(statusData.workspace_root || config.workspaceRoot),
56
+ repo_path: String(statusData.repo_path || "."),
57
+ resolved_repo_path: String(statusData.resolved_repo_path || statusData.repo_path || config.workspaceRoot),
58
+ test_command: String(statusData.test_command || ""),
59
+ verify_commands: Array.isArray(statusData.verify_commands) ? statusData.verify_commands : [],
60
+ error: statusData.error ? String(statusData.error) : null,
61
+ last_heartbeat_at: String(runtimeData.last_heartbeat_at || statusData.last_heartbeat_at || statusData.updated_at || ""),
62
+ current_command: runtimeData.current_command === undefined ? null : String(runtimeData.current_command || "") || null,
63
+ timeout_seconds: Number(statusData.timeout_seconds) || config.defaultTaskTimeoutSeconds,
64
+ pending_reason: null,
65
+ watcher_status: watcher.status,
66
+ };
67
+ const cls = classifyStaleTask(taskEntry, watcher);
68
+ const isTerminal = TERMINAL_TASK_STATUSES.has(taskEntry.status);
69
+ // Safe to mark stale/archived when:
70
+ // - terminal status -> archive (already finished)
71
+ // - stale AND watcher is not actively driving it (no current_command OR watcher not healthy)
72
+ let decision;
73
+ let safe = false;
74
+ if (isTerminal) {
75
+ decision = "marked_archived";
76
+ safe = true;
77
+ }
78
+ else if (cls.is_stale &&
79
+ (taskEntry.current_command === null || taskEntry.current_command === "" || watcher.status !== "healthy")) {
80
+ decision = "marked_stale";
81
+ safe = true;
82
+ }
83
+ else {
84
+ decision = "no_action";
85
+ safe = false;
86
+ }
87
+ const reconciledAt = new Date().toISOString();
88
+ const reconcileRecord = {
89
+ task_id: taskId,
90
+ reconciled_at: reconciledAt,
91
+ decision,
92
+ safe,
93
+ previous_status: taskEntry.status,
94
+ previous_phase: taskEntry.phase,
95
+ is_stale: cls.is_stale,
96
+ stale_reasons: cls.stale_reasons,
97
+ watcher_status: watcher.status,
98
+ watcher_last_heartbeat_at: watcher.last_heartbeat_at,
99
+ task_last_heartbeat_at: taskEntry.last_heartbeat_at || null,
100
+ task_current_command: taskEntry.current_command,
101
+ notes: decision === "no_action"
102
+ ? "Task does not currently qualify for safe reconcile (still actively running or watcher is healthy)."
103
+ : "Task annotated with reconcile metadata; original status preserved. No files were deleted.",
104
+ };
105
+ // Write the reconcile record artifact.
106
+ try {
107
+ writeFileSync(join(taskDir, "reconcile.json"), JSON.stringify(reconcileRecord, null, 2), "utf-8");
108
+ }
109
+ catch (writeErr) {
110
+ sendJson(res, 500, { error: `Failed to write reconcile record: ${errorMessage(writeErr)}` });
111
+ return;
112
+ }
113
+ // Annotate status.json with reconcile metadata (do not mutate status enum).
114
+ if (safe) {
115
+ const annotated = {
116
+ ...statusData,
117
+ reconcile_state: decision === "marked_archived" ? "archived" : "stale",
118
+ reconciled_at: reconciledAt,
119
+ };
120
+ try {
121
+ writeFileSync(statusPath, JSON.stringify(annotated, null, 2), "utf-8");
122
+ }
123
+ catch (writeErr) {
124
+ sendJson(res, 500, { error: `Failed to annotate status.json: ${errorMessage(writeErr)}` });
125
+ return;
126
+ }
127
+ }
128
+ recordEvent("task.reconciled", {
129
+ task_id: taskId,
130
+ decision,
131
+ safe,
132
+ previous_status: taskEntry.status,
133
+ is_stale: cls.is_stale,
134
+ });
135
+ sendJson(res, 200, reconcileRecord);
136
+ }
137
+ catch (err) {
138
+ sendJson(res, 500, { error: errorMessage(err) });
139
+ }
140
+ }
141
+ /**
142
+ * Run audit_task for a task. Only safe to delegate when the task directory
143
+ * exists and the task is in a terminal state (auditing a running task mid-flight
144
+ * would race with the watcher writing artifacts).
145
+ */
146
+ export function handleTaskAudit(res, taskId) {
147
+ try {
148
+ if (!isValidTaskId(taskId)) {
149
+ sendJson(res, 400, { error: "Invalid task id" });
150
+ return;
151
+ }
152
+ const tasksDir = getTasksDir(config);
153
+ const taskDir = join(tasksDir, taskId);
154
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
155
+ sendJson(res, 404, { error: "Task not found" });
156
+ return;
157
+ }
158
+ const statusData = readJsonFileSafe(join(taskDir, "status.json"));
159
+ const taskStatus = statusData ? String(statusData.status || "") : "";
160
+ if (!TERMINAL_TASK_STATUSES.has(taskStatus)) {
161
+ sendJson(res, 409, {
162
+ error: "Task is not in a terminal state; audit_task can only run safely after completion.",
163
+ status: taskStatus || "unknown",
164
+ });
165
+ return;
166
+ }
167
+ const output = auditTask(taskId);
168
+ recordEvent("task.audited", { task_id: taskId, previous_status: taskStatus });
169
+ sendJson(res, 200, { ok: true, audit: output });
170
+ }
171
+ catch (err) {
172
+ sendJson(res, 500, { error: errorMessage(err) });
173
+ }
174
+ }
175
+ export function handleOpenTaskFolder(res, taskId) {
176
+ try {
177
+ if (!isValidTaskId(taskId)) {
178
+ sendJson(res, 400, { error: "Invalid task id" });
179
+ return;
180
+ }
181
+ const tasksDir = getTasksDir(config);
182
+ const taskDir = join(tasksDir, taskId);
183
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
184
+ sendJson(res, 404, { error: "Task not found" });
185
+ return;
186
+ }
187
+ let cmd;
188
+ if (process.platform === "win32") {
189
+ cmd = "explorer.exe";
190
+ }
191
+ else if (process.platform === "darwin") {
192
+ cmd = "open";
193
+ }
194
+ else {
195
+ cmd = "xdg-open";
196
+ }
197
+ try {
198
+ const child = spawn(cmd, [taskDir], { detached: true, stdio: "ignore" });
199
+ child.on("error", () => { });
200
+ child.unref();
201
+ }
202
+ catch {
203
+ /* ignore */
204
+ }
205
+ sendJson(res, 200, { ok: true, path: taskDir });
206
+ }
207
+ catch (err) {
208
+ sendJson(res, 500, { error: errorMessage(err) });
209
+ }
210
+ }
211
+ /**
212
+ * Hide a stale task from the dashboard's stale-task view. The task itself is
213
+ * NOT deleted or modified — only the control-center's local hidden-stale-ids
214
+ * state file is updated. Requires control token (enforced by the POST router).
215
+ */
216
+ export function handleHideStale(res, taskId) {
217
+ try {
218
+ if (!isValidTaskId(taskId)) {
219
+ sendJson(res, 400, { error: "Invalid task id" });
220
+ return;
221
+ }
222
+ const ids = readHiddenStaleIds();
223
+ if (!ids.includes(taskId)) {
224
+ ids.push(taskId);
225
+ writeHiddenStaleIds(ids);
226
+ }
227
+ recordEvent("task.hide_stale", { task_id: taskId });
228
+ sendJson(res, 200, { ok: true, hidden: taskId });
229
+ }
230
+ catch (err) {
231
+ sendJson(res, 500, { error: errorMessage(err) });
232
+ }
233
+ }
@@ -0,0 +1,20 @@
1
+ import { type ServerResponse } from "node:http";
2
+ export interface TaskFilters {
3
+ repo_path?: string;
4
+ status?: string;
5
+ acceptance_status?: string;
6
+ agent?: string;
7
+ warning_type?: string;
8
+ }
9
+ export declare function handleTasks(res: ServerResponse, filters?: TaskFilters): void;
10
+ export declare function deriveStaleReasonCode(staleReasons: string[]): string;
11
+ export declare function staleExplanationFor(reasonCode: string): {
12
+ explanation: string;
13
+ next_action: string;
14
+ };
15
+ export declare function handleStaleTasks(res: ServerResponse): void;
16
+ export declare function handleTaskDetail(res: ServerResponse, taskId: string): void;
17
+ export declare function handleTaskSafeResult(res: ServerResponse, taskId: string): void;
18
+ export declare function handleTaskSafeAudit(res: ServerResponse, taskId: string): void;
19
+ export declare function handleTaskSafeTestSummary(res: ServerResponse, taskId: string): void;
20
+ export declare function handleTaskSafeDiffSummary(res: ServerResponse, taskId: string): void;