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