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,310 @@
1
+ /**
2
+ * Control Center routes — task queries (GET /api/tasks/*).
3
+ *
4
+ * Hosts the read-only task endpoints: list/filter tasks, stale-task
5
+ * classification with explanations, task detail, and the safe bounded views
6
+ * (safe-result / safe-audit / safe-test-summary / safe-diff-summary) that
7
+ * never expose full stdout/stderr/diff content. Mutating task actions live in
8
+ * taskActions.ts.
9
+ */
10
+ import { existsSync, statSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { listTasks } from "../../tools/listTasks.js";
13
+ import { safeAudit, safeDiffSummary, safeResult, safeTestSummary } from "../../tools/safeViews.js";
14
+ import { getTasksDir } from "../../config.js";
15
+ import { augmentTaskWithStale, classifyStaleTask, fileMtimeIso, isValidTaskId, parseReviewVerdict, readWatcherStatusSafe, } from "../runtime.js";
16
+ import { config, errorMessage, readJsonFileSafe, readTextFileSafe, sendJson } from "../shared.js";
17
+ export function handleTasks(res, filters) {
18
+ try {
19
+ const result = listTasks({ limit: 100 });
20
+ const watcher = result.watcher;
21
+ const now = Date.now();
22
+ let augmented = result.tasks.map((t) => augmentTaskWithStale(t, watcher, now));
23
+ // Apply optional filters from query params.
24
+ if (filters) {
25
+ if (filters.repo_path) {
26
+ const filterRepo = filters.repo_path.trim().replace(/\\/g, "/");
27
+ augmented = augmented.filter((t) => {
28
+ const taskRepo = String(t.repo_path || ".").replace(/\\/g, "/");
29
+ const taskResolved = String(t.resolved_repo_path || "").replace(/\\/g, "/");
30
+ return taskRepo === filterRepo || taskResolved === filterRepo;
31
+ });
32
+ }
33
+ if (filters.status) {
34
+ augmented = augmented.filter((t) => t.status === filters.status);
35
+ }
36
+ if (filters.acceptance_status) {
37
+ augmented = augmented.filter((t) => {
38
+ // Tasks without acceptance status only match the "pending" filter when
39
+ // status is done_by_agent; null acceptance never matches other values.
40
+ return t.acceptance_status === filters.acceptance_status;
41
+ });
42
+ }
43
+ if (filters.agent) {
44
+ const filterAgent = filters.agent.toLowerCase();
45
+ augmented = augmented.filter((t) => String(t.agent || "").toLowerCase() === filterAgent);
46
+ }
47
+ if (filters.warning_type) {
48
+ const wt = filters.warning_type;
49
+ if (wt === "stale") {
50
+ augmented = augmented.filter((t) => t.is_stale);
51
+ }
52
+ else if (wt === "error") {
53
+ augmented = augmented.filter((t) => t.error !== null && t.error !== "");
54
+ }
55
+ else {
56
+ // Treat as a specific stale_reason token to match against.
57
+ augmented = augmented.filter((t) => Array.isArray(t.stale_reasons) && t.stale_reasons.includes(wt));
58
+ }
59
+ }
60
+ }
61
+ const staleCount = augmented.filter((t) => t.is_stale).length;
62
+ sendJson(res, 200, {
63
+ tasks: augmented,
64
+ total: augmented.length,
65
+ returned: augmented.length,
66
+ watcher,
67
+ stale_count: staleCount,
68
+ });
69
+ }
70
+ catch (err) {
71
+ sendJson(res, 200, {
72
+ tasks: [],
73
+ total: 0,
74
+ returned: 0,
75
+ watcher: null,
76
+ stale_count: 0,
77
+ error: errorMessage(err),
78
+ });
79
+ }
80
+ }
81
+ export function deriveStaleReasonCode(staleReasons) {
82
+ if (staleReasons.length === 0)
83
+ return "unknown";
84
+ if (staleReasons.some((r) => r.includes("heartbeat_stale")))
85
+ return "heartbeat_stale";
86
+ if (staleReasons.some((r) => r.includes("config") || r.includes("assessment")))
87
+ return "assessment_stale_config";
88
+ if (staleReasons.some((r) => r.includes("runtime") || r.includes("missing")))
89
+ return "runtime_missing";
90
+ return staleReasons[0];
91
+ }
92
+ export function staleExplanationFor(reasonCode) {
93
+ switch (reasonCode) {
94
+ case "assessment_stale_config":
95
+ return {
96
+ explanation: "配置或 tool manifest 已变化,旧 assessment 已过期,需要重新评估或重新创建任务。",
97
+ next_action: "reconcile or recreate task",
98
+ };
99
+ case "heartbeat_stale":
100
+ return {
101
+ explanation: "任务心跳已过期,watcher 可能未运行或任务已僵死,建议 reconcile 或 kill。",
102
+ next_action: "reconcile or kill task",
103
+ };
104
+ case "runtime_missing":
105
+ return {
106
+ explanation: "任务运行时文件缺失,可能被外部清理,建议重新创建任务。",
107
+ next_action: "recreate task",
108
+ };
109
+ default:
110
+ return {
111
+ explanation: "任务状态异常",
112
+ next_action: "review task",
113
+ };
114
+ }
115
+ }
116
+ export function handleStaleTasks(res) {
117
+ try {
118
+ const result = listTasks({ limit: 100 });
119
+ const watcher = result.watcher;
120
+ const now = Date.now();
121
+ const staleTasks = result.tasks
122
+ .map((t) => augmentTaskWithStale(t, watcher, now))
123
+ .filter((t) => t.is_stale)
124
+ .map((t) => {
125
+ const reasonCode = deriveStaleReasonCode(t.stale_reasons);
126
+ const { explanation, next_action } = staleExplanationFor(reasonCode);
127
+ return { ...t, reason_code: reasonCode, explanation, next_action };
128
+ });
129
+ sendJson(res, 200, {
130
+ stale_tasks: staleTasks,
131
+ total: staleTasks.length,
132
+ watcher,
133
+ stale_threshold_seconds: config.watcherStaleSeconds,
134
+ reason: null,
135
+ });
136
+ }
137
+ catch (err) {
138
+ sendJson(res, 200, { stale_tasks: [], total: 0, reason: errorMessage(err) });
139
+ }
140
+ }
141
+ export function handleTaskDetail(res, taskId) {
142
+ try {
143
+ const tasksDir = getTasksDir(config);
144
+ const taskDir = join(tasksDir, taskId);
145
+ if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
146
+ sendJson(res, 404, { error: "Task not found" });
147
+ return;
148
+ }
149
+ const statusData = readJsonFileSafe(join(taskDir, "status.json"));
150
+ const runtimeData = readJsonFileSafe(join(taskDir, "runtime.json"));
151
+ const resultData = readJsonFileSafe(join(taskDir, "result.json"));
152
+ const auditData = readJsonFileSafe(join(taskDir, "audit.json"));
153
+ const verifyData = readJsonFileSafe(join(taskDir, "verify.json"));
154
+ const changedFiles = readJsonFileSafe(join(taskDir, "changed-files.json"));
155
+ const fileStats = readJsonFileSafe(join(taskDir, "file-stats.json"));
156
+ const reconcileData = readJsonFileSafe(join(taskDir, "reconcile.json"));
157
+ // independent-review.md is the primary audit artifact (written by audit_task)
158
+ const reviewPath = join(taskDir, "independent-review.md");
159
+ let independentReview = { verdict: null, content: null };
160
+ if (existsSync(reviewPath)) {
161
+ const content = readTextFileSafe(reviewPath) ?? "";
162
+ independentReview = { verdict: parseReviewVerdict(content), content };
163
+ }
164
+ // Verification summary from verify.json
165
+ const verificationSummary = verifyData
166
+ ? {
167
+ status: verifyData.status ?? null,
168
+ commands: Array.isArray(verifyData.commands) ? verifyData.commands : null,
169
+ checked_at: fileMtimeIso(join(taskDir, "verify.json")),
170
+ }
171
+ : null;
172
+ // Warnings / errors collected from status.error, result.warnings, error.log
173
+ const warnings = [];
174
+ const errors = [];
175
+ if (statusData && statusData.error)
176
+ errors.push(String(statusData.error));
177
+ if (resultData && Array.isArray(resultData.warnings)) {
178
+ for (const w of resultData.warnings)
179
+ warnings.push(String(w));
180
+ }
181
+ if (resultData && resultData.error)
182
+ errors.push(String(resultData.error));
183
+ const errorLog = readJsonFileSafe(join(taskDir, "error.log"));
184
+ if (errorLog && errorLog.message)
185
+ errors.push(String(errorLog.message));
186
+ // Stale classification (best-effort, using watcher snapshot)
187
+ let stale = null;
188
+ if (statusData) {
189
+ const watcher = readWatcherStatusSafe();
190
+ const VALID_ACCEPTANCE2 = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
191
+ const taskStatus2 = String(statusData.status || "pending");
192
+ const taskAcceptanceStatus2 = taskStatus2 === "done_by_agent"
193
+ ? (typeof statusData.acceptance_status === "string" && VALID_ACCEPTANCE2.includes(statusData.acceptance_status) ? statusData.acceptance_status : "pending")
194
+ : null;
195
+ const entry = {
196
+ task_id: taskId,
197
+ plan_id: String(statusData.plan_id || ""),
198
+ title: "",
199
+ agent: String(statusData.agent || ""),
200
+ status: taskStatus2,
201
+ phase: String(runtimeData?.phase || statusData.phase || "queued"),
202
+ acceptance_status: taskAcceptanceStatus2,
203
+ created_at: String(statusData.created_at || ""),
204
+ updated_at: String(statusData.updated_at || ""),
205
+ workspace_root: String(statusData.workspace_root || config.workspaceRoot),
206
+ repo_path: String(statusData.repo_path || "."),
207
+ resolved_repo_path: String(statusData.resolved_repo_path || statusData.repo_path || config.workspaceRoot),
208
+ test_command: String(statusData.test_command || ""),
209
+ verify_commands: Array.isArray(statusData.verify_commands) ? statusData.verify_commands : [],
210
+ error: statusData.error ? String(statusData.error) : null,
211
+ last_heartbeat_at: String(runtimeData?.last_heartbeat_at || statusData.last_heartbeat_at || statusData.updated_at || ""),
212
+ current_command: runtimeData?.current_command === undefined ? null : String(runtimeData.current_command || "") || null,
213
+ timeout_seconds: Number(statusData.timeout_seconds) || config.defaultTaskTimeoutSeconds,
214
+ pending_reason: null,
215
+ watcher_status: watcher.status,
216
+ };
217
+ stale = classifyStaleTask(entry, watcher);
218
+ }
219
+ sendJson(res, 200, {
220
+ task_id: taskId,
221
+ status: statusData,
222
+ runtime: runtimeData,
223
+ result: resultData,
224
+ audit: auditData,
225
+ independent_review: independentReview,
226
+ diff_patch: readTextFileSafe(join(taskDir, "diff.patch")),
227
+ test_log: readTextFileSafe(join(taskDir, "test.log")) ?? readTextFileSafe(join(taskDir, "test-log.txt")),
228
+ verify_log: readTextFileSafe(join(taskDir, "verify.log")),
229
+ changed_files: changedFiles,
230
+ file_stats: fileStats,
231
+ verification_summary: verificationSummary,
232
+ warnings,
233
+ errors,
234
+ stale,
235
+ reconcile: reconcileData,
236
+ task_dir: taskDir,
237
+ });
238
+ }
239
+ catch (err) {
240
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
241
+ }
242
+ }
243
+ export function handleTaskSafeResult(res, taskId) {
244
+ try {
245
+ if (!isValidTaskId(taskId)) {
246
+ sendJson(res, 400, { error: "Invalid task id" });
247
+ return;
248
+ }
249
+ try {
250
+ sendJson(res, 200, safeResult(taskId, { max_items: 12 }));
251
+ }
252
+ catch (err) {
253
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
254
+ }
255
+ }
256
+ catch (err) {
257
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
258
+ }
259
+ }
260
+ export function handleTaskSafeAudit(res, taskId) {
261
+ try {
262
+ if (!isValidTaskId(taskId)) {
263
+ sendJson(res, 400, { error: "Invalid task id" });
264
+ return;
265
+ }
266
+ try {
267
+ sendJson(res, 200, safeAudit(taskId, { max_items: 12 }));
268
+ }
269
+ catch (err) {
270
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
271
+ }
272
+ }
273
+ catch (err) {
274
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
275
+ }
276
+ }
277
+ export function handleTaskSafeTestSummary(res, taskId) {
278
+ try {
279
+ if (!isValidTaskId(taskId)) {
280
+ sendJson(res, 400, { error: "Invalid task id" });
281
+ return;
282
+ }
283
+ try {
284
+ sendJson(res, 200, safeTestSummary(taskId));
285
+ }
286
+ catch (err) {
287
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
288
+ }
289
+ }
290
+ catch (err) {
291
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
292
+ }
293
+ }
294
+ export function handleTaskSafeDiffSummary(res, taskId) {
295
+ try {
296
+ if (!isValidTaskId(taskId)) {
297
+ sendJson(res, 400, { error: "Invalid task id" });
298
+ return;
299
+ }
300
+ try {
301
+ sendJson(res, 200, safeDiffSummary(taskId, { max_items: 12 }));
302
+ }
303
+ catch (err) {
304
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
305
+ }
306
+ }
307
+ catch (err) {
308
+ sendJson(res, 200, { task_id: taskId, error: errorMessage(err) });
309
+ }
310
+ }
@@ -0,0 +1,15 @@
1
+ import { type ServerResponse } from "node:http";
2
+ export declare function handleWorkspace(res: ServerResponse): void;
3
+ /**
4
+ * Lists first-level subdirectories of the workspace root and, for each one,
5
+ * reads package.json (if present) to expose name/version. Read-only and
6
+ * path-bounded: only direct children of workspaceRoot are inspected.
7
+ */
8
+ export declare function handleWorkspaceRepos(res: ServerResponse): void;
9
+ /**
10
+ * On-demand `git status --short` for a single repo under workspaceRoot.
11
+ * The repo parameter is resolved against workspaceRoot and must stay inside it;
12
+ * any path traversal attempt is rejected with 400. This intentionally does NOT
13
+ * run a full workspace scan — only the repo the user clicked is inspected.
14
+ */
15
+ export declare function handleWorkspaceRepoStatus(res: ServerResponse, repoParam: string): void;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Control Center routes — workspace (/api/workspace/*).
3
+ *
4
+ * Lists workspace directories + agent/config summary, lists first-level repo
5
+ * subdirectories with package.json metadata, and runs on-demand
6
+ * `git status --short` for a single repo. The git-status endpoint rejects path
7
+ * traversal and is the only place that shells out to git.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { execFile } from "node:child_process";
12
+ import { listAgents } from "../../tools/listAgents.js";
13
+ import { resolveWorkspaceRoot } from "../../config.js";
14
+ import { guardWorkspacePath } from "../../security/pathGuard.js";
15
+ import { config, errorMessage, sendJson } from "../shared.js";
16
+ export function handleWorkspace(res) {
17
+ let workspaceRoot = null;
18
+ let directories = [];
19
+ let agents = [];
20
+ let configSummary = null;
21
+ try {
22
+ workspaceRoot = resolveWorkspaceRoot(config);
23
+ }
24
+ catch {
25
+ workspaceRoot = null;
26
+ }
27
+ if (workspaceRoot) {
28
+ try {
29
+ directories = readdirSync(workspaceRoot, { withFileTypes: true })
30
+ .filter((e) => e.isDirectory())
31
+ .map((e) => e.name)
32
+ .sort();
33
+ }
34
+ catch {
35
+ directories = [];
36
+ }
37
+ }
38
+ try {
39
+ agents = listAgents().agents;
40
+ }
41
+ catch {
42
+ agents = [];
43
+ }
44
+ try {
45
+ configSummary = {
46
+ toolProfile: config.toolProfile ?? null,
47
+ allowedTestCommandsCount: config.allowedTestCommands.length,
48
+ enableDirectProfile: config.enableDirectProfile ?? false,
49
+ };
50
+ }
51
+ catch {
52
+ configSummary = null;
53
+ }
54
+ sendJson(res, 200, { workspace_root: workspaceRoot, directories, agents, config: configSummary });
55
+ }
56
+ /**
57
+ * Lists first-level subdirectories of the workspace root and, for each one,
58
+ * reads package.json (if present) to expose name/version. Read-only and
59
+ * path-bounded: only direct children of workspaceRoot are inspected.
60
+ */
61
+ export function handleWorkspaceRepos(res) {
62
+ let workspaceRoot = null;
63
+ try {
64
+ workspaceRoot = resolveWorkspaceRoot(config);
65
+ }
66
+ catch (err) {
67
+ sendJson(res, 200, { repos: [], workspace_root: null, error: errorMessage(err) });
68
+ return;
69
+ }
70
+ if (!workspaceRoot) {
71
+ sendJson(res, 200, { repos: [], workspace_root: null });
72
+ return;
73
+ }
74
+ let entries = [];
75
+ try {
76
+ entries = readdirSync(workspaceRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
77
+ }
78
+ catch (err) {
79
+ sendJson(res, 200, { repos: [], workspace_root: workspaceRoot, error: errorMessage(err) });
80
+ return;
81
+ }
82
+ const repos = entries.map((entry) => {
83
+ const dirPath = join(workspaceRoot, entry.name);
84
+ const packageJsonPath = join(dirPath, "package.json");
85
+ let packageName = null;
86
+ let version = null;
87
+ let hasPackageJson = false;
88
+ if (existsSync(packageJsonPath)) {
89
+ hasPackageJson = true;
90
+ try {
91
+ const raw = readFileSync(packageJsonPath, "utf-8").replace(/^\uFEFF/, "");
92
+ const data = JSON.parse(raw);
93
+ packageName = typeof data.name === "string" ? data.name : null;
94
+ version = typeof data.version === "string" ? data.version : null;
95
+ }
96
+ catch {
97
+ // package.json exists but is unreadable/invalid; keep nulls.
98
+ }
99
+ }
100
+ return {
101
+ name: entry.name,
102
+ path: dirPath,
103
+ has_package_json: hasPackageJson,
104
+ package_name: packageName,
105
+ version,
106
+ };
107
+ });
108
+ repos.sort((a, b) => a.name.localeCompare(b.name));
109
+ sendJson(res, 200, { repos, workspace_root: workspaceRoot });
110
+ }
111
+ /**
112
+ * On-demand `git status --short` for a single repo under workspaceRoot.
113
+ * The repo parameter is resolved against workspaceRoot and must stay inside it;
114
+ * any path traversal attempt is rejected with 400. This intentionally does NOT
115
+ * run a full workspace scan — only the repo the user clicked is inspected.
116
+ */
117
+ export function handleWorkspaceRepoStatus(res, repoParam) {
118
+ try {
119
+ let workspaceRoot;
120
+ try {
121
+ workspaceRoot = resolveWorkspaceRoot(config);
122
+ }
123
+ catch (err) {
124
+ sendJson(res, 500, { error: `workspace root unavailable: ${errorMessage(err)}` });
125
+ return;
126
+ }
127
+ // Reject obvious traversal in the raw parameter before resolving.
128
+ if (repoParam.includes("\0") || repoParam.includes("..")) {
129
+ sendJson(res, 400, { error: "Invalid repo path: traversal segments are not allowed" });
130
+ return;
131
+ }
132
+ let repoAbs;
133
+ try {
134
+ // guardWorkspacePath rejects absolute paths outside workspace and any
135
+ // resolved path that escapes workspaceRoot.
136
+ repoAbs = guardWorkspacePath(repoParam || ".", workspaceRoot);
137
+ }
138
+ catch (err) {
139
+ sendJson(res, 400, { error: `Invalid repo path: ${errorMessage(err)}` });
140
+ return;
141
+ }
142
+ if (!existsSync(repoAbs) || !statSync(repoAbs).isDirectory()) {
143
+ sendJson(res, 404, { error: "Repo directory not found", repo_path: repoParam });
144
+ return;
145
+ }
146
+ // Only `git status --short` is permitted; no arbitrary git subcommand.
147
+ // Timeout guards against a hung git prompt (e.g. credential dialog).
148
+ execFile("git", ["status", "--short"], { cwd: repoAbs, maxBuffer: 1024 * 1024, timeout: 8000, windowsHide: true, encoding: "utf-8" }, (err, stdout, stderr) => {
149
+ if (err) {
150
+ // Not a git repo, git missing, or git errored — return a structured
151
+ // failure rather than 500 so the UI can render it gracefully.
152
+ sendJson(res, 200, {
153
+ repo_path: repoParam,
154
+ resolved_repo_path: repoAbs,
155
+ is_git_repo: false,
156
+ changed_files_count: 0,
157
+ untracked_count: 0,
158
+ modified_count: 0,
159
+ is_clean: true,
160
+ short_status: "",
161
+ error: errorMessage(err),
162
+ stderr: stderr ? String(stderr).slice(0, 500) : "",
163
+ });
164
+ return;
165
+ }
166
+ const text = String(stdout);
167
+ const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
168
+ let modified = 0;
169
+ let untracked = 0;
170
+ for (const line of lines) {
171
+ const xy = line.slice(0, 2);
172
+ if (xy === "??")
173
+ untracked++;
174
+ else
175
+ modified++;
176
+ }
177
+ sendJson(res, 200, {
178
+ repo_path: repoParam,
179
+ resolved_repo_path: repoAbs,
180
+ is_git_repo: true,
181
+ changed_files_count: lines.length,
182
+ untracked_count: untracked,
183
+ modified_count: modified,
184
+ is_clean: lines.length === 0,
185
+ short_status: text,
186
+ error: null,
187
+ });
188
+ });
189
+ }
190
+ catch (err) {
191
+ sendJson(res, 500, { error: errorMessage(err) });
192
+ }
193
+ }
@@ -0,0 +1,91 @@
1
+ import { type TaskEntry } from "../tools/listTasks.js";
2
+ import { type AgentAvailability } from "../tools/listAgents.js";
3
+ import { type WatcherStatusSnapshot } from "../watcherStatus.js";
4
+ export interface HealthProbe {
5
+ available: boolean;
6
+ status: number | null;
7
+ reason: string | null;
8
+ }
9
+ export declare function probeHealthStatus(targetUrl: string): Promise<HealthProbe>;
10
+ export interface RuntimeHealth {
11
+ available: boolean;
12
+ reason: string | null;
13
+ healthz: {
14
+ status: number;
15
+ } | null;
16
+ readyz: {
17
+ status: number;
18
+ } | null;
19
+ }
20
+ export declare function probeRuntimeHealth(baseUrl: string): Promise<RuntimeHealth>;
21
+ export declare function readTunnelStatus(direct: boolean): Record<string, unknown>;
22
+ export interface ToolManifestSummary {
23
+ tool_profile: string | null;
24
+ tool_count: number | null;
25
+ schema_epoch: string | null;
26
+ tool_manifest_sha256: string | null;
27
+ tool_names: string[] | null;
28
+ }
29
+ export declare function readToolManifest(direct: boolean): ToolManifestSummary;
30
+ export declare function readTunnelUrl(direct: boolean): {
31
+ url: string | null;
32
+ reason: string | null;
33
+ };
34
+ export declare function readWatcherStatusSafe(): WatcherStatusSnapshot;
35
+ export declare function listAgentsSafe(): AgentAvailability[];
36
+ export declare function resolveWorkspaceRootSafe(): string | null;
37
+ export interface StatusTasks {
38
+ tasks: unknown[];
39
+ total: number;
40
+ active: number;
41
+ stale: number;
42
+ stale_task_ids: string[];
43
+ reason: string | null;
44
+ }
45
+ export interface StaleClassification {
46
+ is_stale: boolean;
47
+ stale_reasons: string[];
48
+ }
49
+ export declare const TERMINAL_TASK_STATUSES: Set<string>;
50
+ /**
51
+ * Classify a task as stale based on Phase 2 rules:
52
+ * - status=running but last_heartbeat_at exceeds threshold
53
+ * - phase=collecting_artifacts exceeds threshold
54
+ * - current_command=null AND watcher currently healthy
55
+ * - task last_heartbeat_at significantly earlier than current watcher heartbeat
56
+ *
57
+ * Only pending/running tasks can be stale; terminal tasks are never stale.
58
+ */
59
+ export declare function classifyStaleTask(task: TaskEntry, watcher: WatcherStatusSnapshot, nowMs?: number): StaleClassification;
60
+ export declare function augmentTaskWithStale(task: TaskEntry, watcher: WatcherStatusSnapshot, nowMs?: number): TaskEntry & StaleClassification;
61
+ export declare function listTasksForStatus(): StatusTasks;
62
+ export interface ControlCenterStatusFile {
63
+ pid: number;
64
+ port: number;
65
+ started_at: string;
66
+ url: string;
67
+ version: string;
68
+ }
69
+ export declare function writeStatusFile(): void;
70
+ export declare function removeStatusFile(): void;
71
+ export interface ControlCenterEvent {
72
+ timestamp: string;
73
+ type: string;
74
+ payload?: Record<string, unknown>;
75
+ }
76
+ /**
77
+ * Append a single event line to the JSONL timeline. Best-effort: a write
78
+ * failure is logged but never crashes the server. Trims the file to
79
+ * MAX_EVENT_LINES lazily when it grows past 1.5x the cap, so we don't pay the
80
+ * trim cost on every event.
81
+ */
82
+ export declare function recordEvent(type: string, payload?: Record<string, unknown>): void;
83
+ export declare function readEvents(limit: number): ControlCenterEvent[];
84
+ export declare function readHiddenStaleIds(): string[];
85
+ export declare function writeHiddenStaleIds(ids: string[]): void;
86
+ export declare function readHiddenDirectSessionIds(): string[];
87
+ export declare function writeHiddenDirectSessionIds(ids: string[]): void;
88
+ export declare function isValidDirectSessionId(sessionId: string): boolean;
89
+ export declare function isValidTaskId(taskId: string): boolean;
90
+ export declare function parseReviewVerdict(content: string): string | null;
91
+ export declare function fileMtimeIso(filePath: string): string | null;