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.
- package/README.en.md +43 -2
- package/README.md +43 -2
- package/dist/assessments/agentAssessor.d.ts +1 -1
- package/dist/assessments/agentAssessor.js +3 -3
- package/dist/assessments/assessmentStore.d.ts +1 -1
- package/dist/assessments/assessmentStore.js +2 -2
- package/dist/assessments/confirmCli.js +5 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +34 -0
- package/dist/control/middleware/auth.d.ts +10 -0
- package/dist/control/middleware/auth.js +8 -0
- package/dist/control/middleware/static.d.ts +3 -0
- package/dist/control/middleware/static.js +65 -0
- package/dist/control/routes/audit.d.ts +15 -0
- package/dist/control/routes/audit.js +277 -0
- package/dist/control/routes/evidence.d.ts +4 -0
- package/dist/control/routes/evidence.js +96 -0
- package/dist/control/routes/lineage.d.ts +3 -0
- package/dist/control/routes/lineage.js +71 -0
- package/dist/control/routes/policy.d.ts +3 -0
- package/dist/control/routes/policy.js +81 -0
- package/dist/control/routes/process.d.ts +5 -0
- package/dist/control/routes/process.js +200 -0
- package/dist/control/routes/sessions.d.ts +22 -0
- package/dist/control/routes/sessions.js +224 -0
- package/dist/control/routes/status.d.ts +6 -0
- package/dist/control/routes/status.js +250 -0
- package/dist/control/routes/taskActions.d.ts +21 -0
- package/dist/control/routes/taskActions.js +233 -0
- package/dist/control/routes/tasks.d.ts +20 -0
- package/dist/control/routes/tasks.js +310 -0
- package/dist/control/routes/workspace.d.ts +15 -0
- package/dist/control/routes/workspace.js +193 -0
- package/dist/control/runtime.d.ts +91 -0
- package/dist/control/runtime.js +392 -0
- package/dist/control/server.d.ts +13 -0
- package/dist/control/server.js +479 -0
- package/dist/control/shared.d.ts +35 -0
- package/dist/control/shared.js +288 -0
- package/dist/controlCenter.d.ts +6 -0
- package/dist/controlCenter.js +7 -2197
- package/dist/direct/directGuards.js +30 -8
- package/dist/doctor.d.ts +18 -1
- package/dist/doctor.js +579 -348
- package/dist/goal/goalReport.d.ts +54 -0
- package/dist/goal/goalReport.js +204 -0
- package/dist/goal/goalStatus.d.ts +6 -0
- package/dist/goal/specKitImport.d.ts +63 -0
- package/dist/goal/specKitImport.js +220 -0
- package/dist/goal/subgoalSync.js +2 -1
- package/dist/httpServer.js +15 -12
- package/dist/index.js +7 -4
- package/dist/logging.d.ts +7 -1
- package/dist/logging.js +8 -0
- package/dist/runner/changeCapture.d.ts +3 -3
- package/dist/runner/changeCapture.js +63 -39
- package/dist/runner/cli.js +7 -6
- package/dist/runner/postTaskCleanup.js +26 -2
- package/dist/runner/runTask.js +245 -221
- package/dist/runner/simpleProcess.js +4 -4
- package/dist/runner/watch.js +17 -14
- package/dist/security/contentRedaction.d.ts +6 -0
- package/dist/security/contentRedaction.js +22 -0
- package/dist/smoke-test.js +257 -251
- package/dist/test/unit/apply-patch.test.d.ts +1 -0
- package/dist/test/unit/apply-patch.test.js +225 -0
- package/dist/test/unit/create-task.test.d.ts +1 -0
- package/dist/test/unit/create-task.test.js +197 -0
- package/dist/test/unit/direct-guards.test.js +124 -9
- package/dist/test/unit/evidence-pack.test.js +95 -1
- package/dist/test/unit/get-task-status.test.d.ts +1 -0
- package/dist/test/unit/get-task-status.test.js +174 -0
- package/dist/test/unit/get-task-summary.test.d.ts +1 -0
- package/dist/test/unit/get-task-summary.test.js +146 -0
- package/dist/test/unit/goal-report.test.d.ts +1 -0
- package/dist/test/unit/goal-report.test.js +159 -0
- package/dist/test/unit/goal-subgoal-task.test.js +6 -6
- package/dist/test/unit/goal-tools-registry.test.js +6 -4
- package/dist/test/unit/path-guard.test.js +24 -0
- package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
- package/dist/test/unit/spec-kit-import.test.js +341 -0
- package/dist/test/unit/wait-for-task.test.d.ts +1 -0
- package/dist/test/unit/wait-for-task.test.js +144 -0
- package/dist/tools/auditTask.d.ts +8 -63
- package/dist/tools/auditTask.js +12 -8
- package/dist/tools/createDirectSession.d.ts +1 -1
- package/dist/tools/createDirectSession.js +2 -2
- package/dist/tools/createTask.d.ts +2 -2
- package/dist/tools/createTask.js +4 -4
- package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
- package/dist/tools/dispatch/coreDispatch.js +282 -0
- package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
- package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
- package/dist/tools/dispatch/directDispatch.d.ts +8 -0
- package/dist/tools/dispatch/directDispatch.js +115 -0
- package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
- package/dist/tools/dispatch/goalDispatch.js +91 -0
- package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
- package/dist/tools/dispatch/releaseDispatch.js +45 -0
- package/dist/tools/dispatch/types.d.ts +23 -0
- package/dist/tools/dispatch/types.js +15 -0
- package/dist/tools/evidencePack.d.ts +6 -0
- package/dist/tools/evidencePack.js +213 -6
- package/dist/tools/finalizeDirectSession.d.ts +1 -1
- package/dist/tools/finalizeDirectSession.js +3 -3
- package/dist/tools/goalSubgoalTask.d.ts +1 -1
- package/dist/tools/goalSubgoalTask.js +2 -2
- package/dist/tools/healthCheck.js +3 -3
- package/dist/tools/registry.d.ts +3 -3
- package/dist/tools/registry.js +60 -503
- package/dist/tools/retryTask.d.ts +2 -2
- package/dist/tools/retryTask.js +2 -2
- package/dist/tools/runTaskLoop.js +4 -4
- package/dist/tools/safeViews.d.ts +2 -2
- package/dist/tools/safeViews.js +2 -2
- package/dist/tools/toolRegistry.js +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CODE_WIKI.md +983 -0
- package/docs/agentseal-integration.md +150 -0
- package/docs/architecture.md +63 -0
- package/docs/assets/patchwarden-oss-demo.gif +0 -0
- package/docs/dashboard-overview.md +86 -0
- package/docs/demo.md +8 -0
- package/docs/direct-session-workflow.md +98 -0
- package/docs/evidence-pack-schema.md +215 -0
- package/docs/execution-plan-2026-07-09.md +315 -0
- package/docs/lineage-evidence-pack-workflow.md +127 -0
- package/docs/mcp-inspector-testing.md +200 -0
- package/docs/open-source-application.md +162 -0
- package/docs/opencode-worker.md +151 -0
- package/docs/openhands-worker.md +181 -0
- package/docs/release-evidence.md +72 -0
- package/docs/roadmap-execution-and-acceptance.md +365 -0
- package/docs/spec-kit-integration.md +131 -0
- package/docs/task-safe-review-workflow.md +98 -0
- package/docs/threat-model.md +79 -0
- package/docs/user-feedback.md +40 -0
- package/docs/why-patchwarden.md +110 -0
- package/package.json +2 -2
- package/scripts/checks/control-center-smoke.js +356 -0
- package/scripts/checks/lifecycle-smoke.js +29 -23
- package/scripts/checks/mcp-smoke.js +3 -1
- package/scripts/generate-demo-gif.py +320 -0
- package/src/assessments/agentAssessor.ts +3 -3
- package/src/assessments/assessmentStore.ts +2 -2
- package/src/assessments/confirmCli.ts +5 -4
- package/src/config.ts +37 -0
- package/src/control/middleware/auth.ts +17 -0
- package/src/control/middleware/static.ts +71 -0
- package/src/control/routes/audit.ts +321 -0
- package/src/control/routes/evidence.ts +107 -0
- package/src/control/routes/lineage.ts +92 -0
- package/src/control/routes/policy.ts +81 -0
- package/src/control/routes/process.ts +204 -0
- package/src/control/routes/sessions.ts +251 -0
- package/src/control/routes/status.ts +325 -0
- package/src/control/routes/taskActions.ts +248 -0
- package/src/control/routes/tasks.ts +323 -0
- package/src/control/routes/workspace.ts +203 -0
- package/src/control/runtime.ts +472 -0
- package/src/control/server.ts +471 -0
- package/src/control/shared.ts +294 -0
- package/src/controlCenter.ts +7 -2347
- package/src/direct/directGuards.ts +28 -7
- package/src/doctor.ts +741 -481
- package/src/goal/goalReport.ts +271 -0
- package/src/goal/goalStatus.ts +6 -0
- package/src/goal/specKitImport.ts +355 -0
- package/src/goal/subgoalSync.ts +4 -2
- package/src/httpServer.ts +17 -14
- package/src/index.ts +7 -4
- package/src/logging.ts +10 -1
- package/src/runner/changeCapture.ts +70 -42
- package/src/runner/cli.ts +7 -6
- package/src/runner/postTaskCleanup.ts +26 -2
- package/src/runner/runTask.ts +325 -223
- package/src/runner/simpleProcess.ts +4 -4
- package/src/runner/watch.ts +17 -14
- package/src/security/contentRedaction.ts +29 -0
- package/src/smoke-test.ts +252 -250
- package/src/test/unit/apply-patch.test.ts +293 -0
- package/src/test/unit/create-task.test.ts +255 -0
- package/src/test/unit/direct-guards.test.ts +178 -8
- package/src/test/unit/evidence-pack.test.ts +110 -1
- package/src/test/unit/get-task-status.test.ts +203 -0
- package/src/test/unit/get-task-summary.test.ts +173 -0
- package/src/test/unit/goal-report.test.ts +189 -0
- package/src/test/unit/goal-subgoal-task.test.ts +6 -6
- package/src/test/unit/goal-tools-registry.test.ts +7 -5
- package/src/test/unit/path-guard.test.ts +30 -0
- package/src/test/unit/spec-kit-import.test.ts +429 -0
- package/src/test/unit/wait-for-task.test.ts +176 -0
- package/src/tools/auditTask.ts +99 -59
- package/src/tools/createDirectSession.ts +3 -3
- package/src/tools/createTask.ts +7 -7
- package/src/tools/dispatch/coreDispatch.ts +374 -0
- package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
- package/src/tools/dispatch/directDispatch.ts +167 -0
- package/src/tools/dispatch/goalDispatch.ts +127 -0
- package/src/tools/dispatch/releaseDispatch.ts +65 -0
- package/src/tools/dispatch/types.ts +24 -0
- package/src/tools/evidencePack.ts +291 -6
- package/src/tools/finalizeDirectSession.ts +4 -4
- package/src/tools/goalSubgoalTask.ts +2 -2
- package/src/tools/healthCheck.ts +3 -3
- package/src/tools/registry.ts +68 -628
- package/src/tools/retryTask.ts +2 -2
- package/src/tools/runTaskLoop.ts +4 -4
- package/src/tools/safeViews.ts +2 -2
- package/src/tools/toolRegistry.ts +22 -0
- package/src/version.ts +1 -1
- package/ui/pages/audit.html +192 -3
- package/ui/pages/dashboard.html +959 -44
- package/ui/pages/direct-sessions.html +505 -53
- package/ui/pages/task-detail.html +456 -438
- 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;
|