patchwarden 1.1.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +83 -8
- package/README.md +78 -10
- 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 -2001
- package/dist/direct/directGuards.js +30 -8
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- 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/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -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 +261 -255
- 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.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +224 -0
- 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/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/schema-drift-check.test.js +10 -9
- 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 +45 -0
- package/dist/tools/evidencePack.js +375 -0
- 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/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/registry.d.ts +3 -3
- package/dist/tools/registry.js +333 -402
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/retryTask.d.ts +2 -2
- package/dist/tools/retryTask.js +2 -2
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeViews.d.ts +8 -2
- package/dist/tools/safeViews.js +4 -2
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +6 -0
- package/dist/tools/toolRegistry.js +132 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- 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/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -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 +3 -3
- package/scripts/checks/control-center-smoke.js +443 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/lifecycle-smoke.js +29 -23
- package/scripts/checks/mcp-manifest-check.js +12 -0
- package/scripts/checks/mcp-smoke.js +33 -7
- package/scripts/checks/watcher-supervisor-smoke.js +1 -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 -2150
- package/src/direct/directGuards.ts +28 -7
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- 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/policy/projectPolicy.ts +344 -0
- 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 +254 -252
- 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 +251 -0
- 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/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/schema-drift-check.test.ts +11 -9
- 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 +490 -0
- package/src/tools/finalizeDirectSession.ts +4 -4
- package/src/tools/goalSubgoalTask.ts +2 -2
- package/src/tools/healthCheck.ts +3 -3
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/registry.ts +352 -509
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/retryTask.ts +2 -2
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeViews.ts +4 -2
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +6 -0
- package/src/tools/toolRegistry.ts +132 -0
- package/src/version.ts +2 -2
- package/ui/pages/audit.html +192 -3
- package/ui/pages/dashboard.html +1070 -14
- 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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control Center routes — runtime status, events, diagnostics, tunnel URLs.
|
|
3
|
+
*
|
|
4
|
+
* `handleStatus` is the dashboard's primary poll endpoint: it fans out health
|
|
5
|
+
* probes, watcher/task snapshots, and suggestion generation, and records
|
|
6
|
+
* observed state-change events (core/direct/watcher/task transitions) into the
|
|
7
|
+
* activity timeline. The remaining endpoints expose the control-center status
|
|
8
|
+
* file, the event timeline, diagnostics (redacted), and tunnel UI URLs.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { type ServerResponse } from "node:http";
|
|
12
|
+
import { type AgentAvailability } from "../../tools/listAgents.js";
|
|
13
|
+
import { type TaskEntry } from "../../tools/listTasks.js";
|
|
14
|
+
import { type WatcherStatusSnapshot } from "../../watcherStatus.js";
|
|
15
|
+
import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../../version.js";
|
|
16
|
+
import { redactSensitiveValue } from "../../security/contentRedaction.js";
|
|
17
|
+
import {
|
|
18
|
+
type ControlCenterStatusFile,
|
|
19
|
+
type RuntimeHealth,
|
|
20
|
+
type StaleClassification,
|
|
21
|
+
type StatusTasks,
|
|
22
|
+
listAgentsSafe,
|
|
23
|
+
listTasksForStatus,
|
|
24
|
+
probeRuntimeHealth,
|
|
25
|
+
readEvents,
|
|
26
|
+
recordEvent,
|
|
27
|
+
readToolManifest,
|
|
28
|
+
readTunnelStatus,
|
|
29
|
+
readTunnelUrl,
|
|
30
|
+
readWatcherStatusSafe,
|
|
31
|
+
resolveWorkspaceRootSafe,
|
|
32
|
+
} from "../runtime.js";
|
|
33
|
+
import { findTunnelClientExecutable } from "./process.js";
|
|
34
|
+
import {
|
|
35
|
+
config,
|
|
36
|
+
controlCenterStatusPath,
|
|
37
|
+
CORE_BASE_URL,
|
|
38
|
+
DEFAULT_TUNNEL_CLIENT_EXE,
|
|
39
|
+
DIRECT_BASE_URL,
|
|
40
|
+
errorMessage,
|
|
41
|
+
readJsonFileSafe,
|
|
42
|
+
sendJson,
|
|
43
|
+
} from "../shared.js";
|
|
44
|
+
|
|
45
|
+
// ── Health suggestions ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface Suggestion {
|
|
48
|
+
code: string;
|
|
49
|
+
severity: "info" | "warning" | "error";
|
|
50
|
+
message: string;
|
|
51
|
+
action?: string;
|
|
52
|
+
link?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface StatusSnapshotForSuggestions {
|
|
56
|
+
core: RuntimeHealth;
|
|
57
|
+
direct: RuntimeHealth;
|
|
58
|
+
watcher: WatcherStatusSnapshot;
|
|
59
|
+
tunnel: { core: Record<string, unknown>; direct: Record<string, unknown> };
|
|
60
|
+
agents: AgentAvailability[];
|
|
61
|
+
tasks: StatusTasks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildSuggestions(s: StatusSnapshotForSuggestions): Suggestion[] {
|
|
65
|
+
const out: Suggestion[] = [];
|
|
66
|
+
|
|
67
|
+
if (!s.core.available) {
|
|
68
|
+
out.push({
|
|
69
|
+
code: "core_stopped",
|
|
70
|
+
severity: "warning",
|
|
71
|
+
message: "Core 未运行,建议启动 Core profile",
|
|
72
|
+
action: "/api/core/start",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!s.direct.available) {
|
|
76
|
+
out.push({
|
|
77
|
+
code: "direct_stopped",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
message: "Direct 未运行,建议启动 Direct profile",
|
|
80
|
+
action: "/api/direct/start",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (s.watcher.status === "stale" || s.watcher.status === "unreadable") {
|
|
85
|
+
out.push({
|
|
86
|
+
code: "watcher_stale",
|
|
87
|
+
severity: "error",
|
|
88
|
+
message: "Watcher 处于 " + s.watcher.status + " 状态,建议 Restart All",
|
|
89
|
+
action: "/api/restart-all",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (s.tasks.stale > 0) {
|
|
94
|
+
out.push({
|
|
95
|
+
code: "stale_task",
|
|
96
|
+
severity: "warning",
|
|
97
|
+
message: "存在 " + s.tasks.stale + " 个 stale 任务,建议查看并 reconcile",
|
|
98
|
+
link: "/pages/tasks.html?filter=stale",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const coreTunnelReady = !!(s.tunnel.core && s.tunnel.core.ready);
|
|
103
|
+
const directTunnelReady = !!(s.tunnel.direct && s.tunnel.direct.ready);
|
|
104
|
+
if (!coreTunnelReady || !directTunnelReady) {
|
|
105
|
+
out.push({
|
|
106
|
+
code: "tunnel_not_ready",
|
|
107
|
+
severity: "warning",
|
|
108
|
+
message: "Tunnel 未就绪,建议重启 profile 或检查代理",
|
|
109
|
+
action: "/api/restart-all",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const missingAgents = s.agents.filter((a) => !a.available);
|
|
114
|
+
if (missingAgents.length > 0) {
|
|
115
|
+
out.push({
|
|
116
|
+
code: "agent_missing",
|
|
117
|
+
severity: "info",
|
|
118
|
+
message: "Agent 未就绪:" + missingAgents.map((a) => a.name).join(", ") + "(请检查 opencode/claude 路径)",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Observed state-change detection (drives activity timeline) ────
|
|
126
|
+
|
|
127
|
+
interface StatusSnapshotDigest {
|
|
128
|
+
core_available: boolean;
|
|
129
|
+
direct_available: boolean;
|
|
130
|
+
watcher_status: string;
|
|
131
|
+
task_statuses: Record<string, string>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let lastStatusDigest: StatusSnapshotDigest | null = null;
|
|
135
|
+
|
|
136
|
+
function buildStatusDigest(s: StatusSnapshotForSuggestions): StatusSnapshotDigest {
|
|
137
|
+
const task_statuses: Record<string, string> = {};
|
|
138
|
+
for (const t of s.tasks.tasks) {
|
|
139
|
+
const entry = t as TaskEntry & StaleClassification;
|
|
140
|
+
task_statuses[entry.task_id] = entry.status;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
core_available: s.core.available,
|
|
144
|
+
direct_available: s.direct.available,
|
|
145
|
+
watcher_status: s.watcher.status,
|
|
146
|
+
task_statuses,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function diffAndRecordEvents(prev: StatusSnapshotDigest, curr: StatusSnapshotDigest): void {
|
|
151
|
+
if (prev.core_available !== curr.core_available) {
|
|
152
|
+
recordEvent("core.status_changed", { from: prev.core_available, to: curr.core_available });
|
|
153
|
+
}
|
|
154
|
+
if (prev.direct_available !== curr.direct_available) {
|
|
155
|
+
recordEvent("direct.status_changed", { from: prev.direct_available, to: curr.direct_available });
|
|
156
|
+
}
|
|
157
|
+
if (prev.watcher_status !== curr.watcher_status) {
|
|
158
|
+
recordEvent("watcher.status_changed", { from: prev.watcher_status, to: curr.watcher_status });
|
|
159
|
+
}
|
|
160
|
+
for (const [taskId, newStatus] of Object.entries(curr.task_statuses)) {
|
|
161
|
+
const oldStatus = prev.task_statuses[taskId];
|
|
162
|
+
if (oldStatus && oldStatus !== newStatus) {
|
|
163
|
+
recordEvent("task.status_changed", { task_id: taskId, from: oldStatus, to: newStatus });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Handlers ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export async function handleStatus(res: ServerResponse): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
const [coreHealth, directHealth, watcher, tunnelCore, tunnelDirect, toolsCore, toolsDirect, agents, workspaceRoot, tasks] = await Promise.all([
|
|
173
|
+
probeRuntimeHealth(CORE_BASE_URL).catch((err): RuntimeHealth => ({
|
|
174
|
+
available: false,
|
|
175
|
+
reason: errorMessage(err),
|
|
176
|
+
healthz: null,
|
|
177
|
+
readyz: null,
|
|
178
|
+
})),
|
|
179
|
+
probeRuntimeHealth(DIRECT_BASE_URL).catch((err): RuntimeHealth => ({
|
|
180
|
+
available: false,
|
|
181
|
+
reason: errorMessage(err),
|
|
182
|
+
healthz: null,
|
|
183
|
+
readyz: null,
|
|
184
|
+
})),
|
|
185
|
+
Promise.resolve(readWatcherStatusSafe()),
|
|
186
|
+
Promise.resolve(readTunnelStatus(false)),
|
|
187
|
+
Promise.resolve(readTunnelStatus(true)),
|
|
188
|
+
Promise.resolve(readToolManifest(false)),
|
|
189
|
+
Promise.resolve(readToolManifest(true)),
|
|
190
|
+
Promise.resolve(listAgentsSafe()),
|
|
191
|
+
Promise.resolve(resolveWorkspaceRootSafe()),
|
|
192
|
+
Promise.resolve(listTasksForStatus()),
|
|
193
|
+
]);
|
|
194
|
+
const snapshotForSuggestions: StatusSnapshotForSuggestions = {
|
|
195
|
+
core: coreHealth,
|
|
196
|
+
direct: directHealth,
|
|
197
|
+
watcher,
|
|
198
|
+
tunnel: { core: tunnelCore, direct: tunnelDirect },
|
|
199
|
+
agents,
|
|
200
|
+
tasks,
|
|
201
|
+
};
|
|
202
|
+
const suggestions = buildSuggestions(snapshotForSuggestions);
|
|
203
|
+
const tunnelClientExe = findTunnelClientExecutable();
|
|
204
|
+
|
|
205
|
+
// Diff against the previous poll to record observed state-change events.
|
|
206
|
+
// This is the only place that observes Core/Direct/watcher/task transitions
|
|
207
|
+
// (the control center is otherwise stateless and pull-driven).
|
|
208
|
+
const digest = buildStatusDigest(snapshotForSuggestions);
|
|
209
|
+
if (lastStatusDigest) {
|
|
210
|
+
diffAndRecordEvents(lastStatusDigest, digest);
|
|
211
|
+
}
|
|
212
|
+
lastStatusDigest = digest;
|
|
213
|
+
|
|
214
|
+
sendJson(res, 200, {
|
|
215
|
+
core: coreHealth,
|
|
216
|
+
direct: directHealth,
|
|
217
|
+
watcher,
|
|
218
|
+
tunnel: { core: tunnelCore, direct: tunnelDirect },
|
|
219
|
+
tools: { core: toolsCore, direct: toolsDirect },
|
|
220
|
+
agents,
|
|
221
|
+
workspace_root: workspaceRoot,
|
|
222
|
+
tasks,
|
|
223
|
+
suggestions,
|
|
224
|
+
setup: {
|
|
225
|
+
tunnel_client: {
|
|
226
|
+
available: tunnelClientExe !== null,
|
|
227
|
+
path: tunnelClientExe,
|
|
228
|
+
default_path: DEFAULT_TUNNEL_CLIENT_EXE,
|
|
229
|
+
},
|
|
230
|
+
workspace_root: workspaceRoot,
|
|
231
|
+
watcher: {
|
|
232
|
+
status: watcher.status,
|
|
233
|
+
available: watcher.available,
|
|
234
|
+
reason: watcher.reason,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
sendJson(res, 200, { error: errorMessage(err), partial: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function handleControlCenterStatus(res: ServerResponse): void {
|
|
244
|
+
// Public read of the status file (used by tray/launcher to confirm identity).
|
|
245
|
+
if (!existsSync(controlCenterStatusPath)) {
|
|
246
|
+
sendJson(res, 200, { running: false });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const data = readJsonFileSafe<ControlCenterStatusFile>(controlCenterStatusPath);
|
|
250
|
+
if (!data) {
|
|
251
|
+
sendJson(res, 200, { running: false });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
sendJson(res, 200, { running: true, ...data });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function handleEvents(res: ServerResponse, limit: number): void {
|
|
258
|
+
const events = readEvents(limit);
|
|
259
|
+
sendJson(res, 200, {
|
|
260
|
+
events,
|
|
261
|
+
total: events.length,
|
|
262
|
+
limit,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function handleTunnelUiUrl(res: ServerResponse): void {
|
|
267
|
+
sendJson(res, 200, {
|
|
268
|
+
core: readTunnelUrl(false),
|
|
269
|
+
direct: readTunnelUrl(true),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function handleDiagnostics(res: ServerResponse): void {
|
|
274
|
+
try {
|
|
275
|
+
const watcher = readWatcherStatusSafe();
|
|
276
|
+
const tunnelCore = readTunnelStatus(false);
|
|
277
|
+
const tunnelDirect = readTunnelStatus(true);
|
|
278
|
+
const toolsCore = readToolManifest(false);
|
|
279
|
+
const toolsDirect = readToolManifest(true);
|
|
280
|
+
const agents = listAgentsSafe();
|
|
281
|
+
|
|
282
|
+
let workspaceRoot: string | null = null;
|
|
283
|
+
try {
|
|
284
|
+
workspaceRoot = resolveWorkspaceRootSafe();
|
|
285
|
+
} catch {
|
|
286
|
+
workspaceRoot = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Recent failures: last 5 failed tasks (status contains "failed")
|
|
290
|
+
let recentFailures: Array<{ task_id: string; status: string }> = [];
|
|
291
|
+
try {
|
|
292
|
+
const result = listTasksForStatus();
|
|
293
|
+
recentFailures = (result.tasks as Array<{ task_id: string; status: string }>)
|
|
294
|
+
.filter((t) => typeof t.status === "string" && t.status.includes("failed"))
|
|
295
|
+
.slice(0, 5)
|
|
296
|
+
.map((t) => ({ task_id: t.task_id, status: t.status }));
|
|
297
|
+
} catch {
|
|
298
|
+
recentFailures = [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const coreReady = !!(tunnelCore && tunnelCore.ready);
|
|
302
|
+
const directReady = !!(tunnelDirect && tunnelDirect.ready);
|
|
303
|
+
|
|
304
|
+
const diagnostics = {
|
|
305
|
+
server_version: PATCHWARDEN_VERSION,
|
|
306
|
+
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
307
|
+
tool_manifest_sha256: toolsCore.tool_manifest_sha256,
|
|
308
|
+
watcher_status: watcher.status,
|
|
309
|
+
tunnel_core_ready: coreReady,
|
|
310
|
+
tunnel_direct_ready: directReady,
|
|
311
|
+
core_tool_count: toolsCore.tool_count,
|
|
312
|
+
direct_tool_count: toolsDirect.tool_count,
|
|
313
|
+
agent_status: agents.map((a) => ({ name: a.name, available: a.available })),
|
|
314
|
+
workspace_root: workspaceRoot,
|
|
315
|
+
recent_failures: recentFailures,
|
|
316
|
+
direct_profile_enabled: config.enableDirectProfile ?? false,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Redact any sensitive content that may have leaked into string fields.
|
|
320
|
+
const redacted = redactSensitiveValue(diagnostics);
|
|
321
|
+
sendJson(res, 200, redacted.value);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
sendJson(res, 200, { error: errorMessage(err) });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control Center routes — mutating task actions (POST /api/tasks/:id/*).
|
|
3
|
+
*
|
|
4
|
+
* These handlers are invoked only after the server router has validated the
|
|
5
|
+
* control token. They cover reconcile, on-demand audit_task, opening the task
|
|
6
|
+
* folder in the host file explorer, and hiding a stale task from the
|
|
7
|
+
* dashboard. None of them delete task files; they annotate or launch external
|
|
8
|
+
* viewers.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { type ServerResponse } from "node:http";
|
|
14
|
+
import { type TaskEntry } from "../../tools/listTasks.js";
|
|
15
|
+
import type { AcceptanceStatus } from "../../tools/createTask.js";
|
|
16
|
+
import { auditTask } from "../../tools/auditTask.js";
|
|
17
|
+
import { getTasksDir } from "../../config.js";
|
|
18
|
+
import {
|
|
19
|
+
classifyStaleTask,
|
|
20
|
+
isValidTaskId,
|
|
21
|
+
readHiddenStaleIds,
|
|
22
|
+
readWatcherStatusSafe,
|
|
23
|
+
recordEvent,
|
|
24
|
+
TERMINAL_TASK_STATUSES,
|
|
25
|
+
writeHiddenStaleIds,
|
|
26
|
+
} from "../runtime.js";
|
|
27
|
+
import { config, errorMessage, readJsonFileSafe, sendJson } from "../shared.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reconcile a stale task. Does NOT delete the task. Reads the task files,
|
|
31
|
+
* decides whether it is safe to mark the task as stale/archived, writes a
|
|
32
|
+
* reconcile record, and (when safe) annotates status.json with reconcile
|
|
33
|
+
* metadata. The task status enum is never changed — only metadata is added.
|
|
34
|
+
*/
|
|
35
|
+
export function handleReconcile(res: ServerResponse, taskId: string): void {
|
|
36
|
+
try {
|
|
37
|
+
if (!isValidTaskId(taskId)) {
|
|
38
|
+
sendJson(res, 400, { error: "Invalid task id" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const tasksDir = getTasksDir(config);
|
|
42
|
+
const taskDir = join(tasksDir, taskId);
|
|
43
|
+
if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
|
|
44
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusPath = join(taskDir, "status.json");
|
|
49
|
+
const runtimePath = join(taskDir, "runtime.json");
|
|
50
|
+
const statusData = readJsonFileSafe<Record<string, unknown>>(statusPath) ?? {};
|
|
51
|
+
const runtimeData = readJsonFileSafe<Record<string, unknown>>(runtimePath) ?? {};
|
|
52
|
+
|
|
53
|
+
const watcher = readWatcherStatusSafe();
|
|
54
|
+
const VALID_ACCEPTANCE = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
|
|
55
|
+
const taskStatus = String(statusData.status || "pending");
|
|
56
|
+
const taskAcceptanceStatus = taskStatus === "done_by_agent"
|
|
57
|
+
? (typeof statusData.acceptance_status === "string" && VALID_ACCEPTANCE.includes(statusData.acceptance_status) ? (statusData.acceptance_status as AcceptanceStatus) : "pending" as AcceptanceStatus)
|
|
58
|
+
: null;
|
|
59
|
+
const taskEntry: TaskEntry = {
|
|
60
|
+
task_id: taskId,
|
|
61
|
+
plan_id: String(statusData.plan_id || ""),
|
|
62
|
+
title: "",
|
|
63
|
+
agent: String(statusData.agent || ""),
|
|
64
|
+
status: taskStatus as TaskEntry["status"],
|
|
65
|
+
phase: String(runtimeData.phase || statusData.phase || "queued") as TaskEntry["phase"],
|
|
66
|
+
acceptance_status: taskAcceptanceStatus,
|
|
67
|
+
created_at: String(statusData.created_at || ""),
|
|
68
|
+
updated_at: String(statusData.updated_at || ""),
|
|
69
|
+
workspace_root: String(statusData.workspace_root || config.workspaceRoot),
|
|
70
|
+
repo_path: String(statusData.repo_path || "."),
|
|
71
|
+
resolved_repo_path: String(statusData.resolved_repo_path || statusData.repo_path || config.workspaceRoot),
|
|
72
|
+
test_command: String(statusData.test_command || ""),
|
|
73
|
+
verify_commands: Array.isArray(statusData.verify_commands) ? (statusData.verify_commands as string[]) : [],
|
|
74
|
+
error: statusData.error ? String(statusData.error) : null,
|
|
75
|
+
last_heartbeat_at: String(runtimeData.last_heartbeat_at || statusData.last_heartbeat_at || statusData.updated_at || ""),
|
|
76
|
+
current_command: runtimeData.current_command === undefined ? null : String(runtimeData.current_command || "") || null,
|
|
77
|
+
timeout_seconds: Number(statusData.timeout_seconds) || config.defaultTaskTimeoutSeconds,
|
|
78
|
+
pending_reason: null,
|
|
79
|
+
watcher_status: watcher.status,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const cls = classifyStaleTask(taskEntry, watcher);
|
|
83
|
+
const isTerminal = TERMINAL_TASK_STATUSES.has(taskEntry.status);
|
|
84
|
+
|
|
85
|
+
// Safe to mark stale/archived when:
|
|
86
|
+
// - terminal status -> archive (already finished)
|
|
87
|
+
// - stale AND watcher is not actively driving it (no current_command OR watcher not healthy)
|
|
88
|
+
let decision: "marked_stale" | "marked_archived" | "no_action";
|
|
89
|
+
let safe = false;
|
|
90
|
+
if (isTerminal) {
|
|
91
|
+
decision = "marked_archived";
|
|
92
|
+
safe = true;
|
|
93
|
+
} else if (
|
|
94
|
+
cls.is_stale &&
|
|
95
|
+
(taskEntry.current_command === null || taskEntry.current_command === "" || watcher.status !== "healthy")
|
|
96
|
+
) {
|
|
97
|
+
decision = "marked_stale";
|
|
98
|
+
safe = true;
|
|
99
|
+
} else {
|
|
100
|
+
decision = "no_action";
|
|
101
|
+
safe = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reconciledAt = new Date().toISOString();
|
|
105
|
+
const reconcileRecord = {
|
|
106
|
+
task_id: taskId,
|
|
107
|
+
reconciled_at: reconciledAt,
|
|
108
|
+
decision,
|
|
109
|
+
safe,
|
|
110
|
+
previous_status: taskEntry.status,
|
|
111
|
+
previous_phase: taskEntry.phase,
|
|
112
|
+
is_stale: cls.is_stale,
|
|
113
|
+
stale_reasons: cls.stale_reasons,
|
|
114
|
+
watcher_status: watcher.status,
|
|
115
|
+
watcher_last_heartbeat_at: watcher.last_heartbeat_at,
|
|
116
|
+
task_last_heartbeat_at: taskEntry.last_heartbeat_at || null,
|
|
117
|
+
task_current_command: taskEntry.current_command,
|
|
118
|
+
notes:
|
|
119
|
+
decision === "no_action"
|
|
120
|
+
? "Task does not currently qualify for safe reconcile (still actively running or watcher is healthy)."
|
|
121
|
+
: "Task annotated with reconcile metadata; original status preserved. No files were deleted.",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Write the reconcile record artifact.
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(join(taskDir, "reconcile.json"), JSON.stringify(reconcileRecord, null, 2), "utf-8");
|
|
127
|
+
} catch (writeErr) {
|
|
128
|
+
sendJson(res, 500, { error: `Failed to write reconcile record: ${errorMessage(writeErr)}` });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Annotate status.json with reconcile metadata (do not mutate status enum).
|
|
133
|
+
if (safe) {
|
|
134
|
+
const annotated = {
|
|
135
|
+
...statusData,
|
|
136
|
+
reconcile_state: decision === "marked_archived" ? "archived" : "stale",
|
|
137
|
+
reconciled_at: reconciledAt,
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(statusPath, JSON.stringify(annotated, null, 2), "utf-8");
|
|
141
|
+
} catch (writeErr) {
|
|
142
|
+
sendJson(res, 500, { error: `Failed to annotate status.json: ${errorMessage(writeErr)}` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
recordEvent("task.reconciled", {
|
|
148
|
+
task_id: taskId,
|
|
149
|
+
decision,
|
|
150
|
+
safe,
|
|
151
|
+
previous_status: taskEntry.status,
|
|
152
|
+
is_stale: cls.is_stale,
|
|
153
|
+
});
|
|
154
|
+
sendJson(res, 200, reconcileRecord);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run audit_task for a task. Only safe to delegate when the task directory
|
|
162
|
+
* exists and the task is in a terminal state (auditing a running task mid-flight
|
|
163
|
+
* would race with the watcher writing artifacts).
|
|
164
|
+
*/
|
|
165
|
+
export function handleTaskAudit(res: ServerResponse, taskId: string): void {
|
|
166
|
+
try {
|
|
167
|
+
if (!isValidTaskId(taskId)) {
|
|
168
|
+
sendJson(res, 400, { error: "Invalid task id" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const tasksDir = getTasksDir(config);
|
|
172
|
+
const taskDir = join(tasksDir, taskId);
|
|
173
|
+
if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
|
|
174
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const statusData = readJsonFileSafe<Record<string, unknown>>(join(taskDir, "status.json"));
|
|
178
|
+
const taskStatus = statusData ? String(statusData.status || "") : "";
|
|
179
|
+
if (!TERMINAL_TASK_STATUSES.has(taskStatus as string)) {
|
|
180
|
+
sendJson(res, 409, {
|
|
181
|
+
error: "Task is not in a terminal state; audit_task can only run safely after completion.",
|
|
182
|
+
status: taskStatus || "unknown",
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const output = auditTask(taskId);
|
|
187
|
+
recordEvent("task.audited", { task_id: taskId, previous_status: taskStatus });
|
|
188
|
+
sendJson(res, 200, { ok: true, audit: output });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function handleOpenTaskFolder(res: ServerResponse, taskId: string): void {
|
|
195
|
+
try {
|
|
196
|
+
if (!isValidTaskId(taskId)) {
|
|
197
|
+
sendJson(res, 400, { error: "Invalid task id" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const tasksDir = getTasksDir(config);
|
|
201
|
+
const taskDir = join(tasksDir, taskId);
|
|
202
|
+
if (!existsSync(taskDir) || !statSync(taskDir).isDirectory()) {
|
|
203
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let cmd: string;
|
|
207
|
+
if (process.platform === "win32") {
|
|
208
|
+
cmd = "explorer.exe";
|
|
209
|
+
} else if (process.platform === "darwin") {
|
|
210
|
+
cmd = "open";
|
|
211
|
+
} else {
|
|
212
|
+
cmd = "xdg-open";
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const child = spawn(cmd, [taskDir], { detached: true, stdio: "ignore" });
|
|
216
|
+
child.on("error", () => { /* ignore spawn errors */ });
|
|
217
|
+
child.unref();
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
sendJson(res, 200, { ok: true, path: taskDir });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Hide a stale task from the dashboard's stale-task view. The task itself is
|
|
229
|
+
* NOT deleted or modified — only the control-center's local hidden-stale-ids
|
|
230
|
+
* state file is updated. Requires control token (enforced by the POST router).
|
|
231
|
+
*/
|
|
232
|
+
export function handleHideStale(res: ServerResponse, taskId: string): void {
|
|
233
|
+
try {
|
|
234
|
+
if (!isValidTaskId(taskId)) {
|
|
235
|
+
sendJson(res, 400, { error: "Invalid task id" });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const ids = readHiddenStaleIds();
|
|
239
|
+
if (!ids.includes(taskId)) {
|
|
240
|
+
ids.push(taskId);
|
|
241
|
+
writeHiddenStaleIds(ids);
|
|
242
|
+
}
|
|
243
|
+
recordEvent("task.hide_stale", { task_id: taskId });
|
|
244
|
+
sendJson(res, 200, { ok: true, hidden: taskId });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
247
|
+
}
|
|
248
|
+
}
|