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,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PatchWarden Control Center — runtime state readers and domain helpers.
|
|
3
|
+
*
|
|
4
|
+
* Provides health probing, runtime file readers (tunnel status, tool manifest,
|
|
5
|
+
* watcher status), stale-task classification, the control-center status/event
|
|
6
|
+
* timeline, hidden-id UI state, and small shared helpers (verdict parsing,
|
|
7
|
+
* file mtime, id validation). These are pure helpers consumed by the route
|
|
8
|
+
* modules; they hold no HTTP-layer concerns beyond reusing `sendJson`.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { get as httpGet } from "node:http";
|
|
13
|
+
import { listTasks, type TaskEntry } from "../tools/listTasks.js";
|
|
14
|
+
import { listAgents, type AgentAvailability } from "../tools/listAgents.js";
|
|
15
|
+
import { readWatcherStatus, type WatcherStatusSnapshot } from "../watcherStatus.js";
|
|
16
|
+
import { resolveWorkspaceRoot } from "../config.js";
|
|
17
|
+
import { PATCHWARDEN_VERSION } from "../version.js";
|
|
18
|
+
import { logger } from "../logging.js";
|
|
19
|
+
import {
|
|
20
|
+
config,
|
|
21
|
+
controlCenterEventsPath,
|
|
22
|
+
controlCenterStatusPath,
|
|
23
|
+
CORE_BASE_URL,
|
|
24
|
+
DIRECT_BASE_URL,
|
|
25
|
+
errorMessage,
|
|
26
|
+
getControlCenterLogDir,
|
|
27
|
+
getRuntimeRoot,
|
|
28
|
+
host,
|
|
29
|
+
MAX_EVENT_LINES,
|
|
30
|
+
port,
|
|
31
|
+
readJsonFileSafe,
|
|
32
|
+
} from "./shared.js";
|
|
33
|
+
|
|
34
|
+
// ── Health probing ────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface HealthProbe {
|
|
37
|
+
available: boolean;
|
|
38
|
+
status: number | null;
|
|
39
|
+
reason: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function probeHealthStatus(targetUrl: string): Promise<HealthProbe> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
let settled = false;
|
|
46
|
+
const finish = (result: HealthProbe) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve(result);
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
try { controller.abort(); } catch { /* ignore */ }
|
|
54
|
+
finish({ available: false, status: null, reason: "timeout after 2000ms" });
|
|
55
|
+
}, 2000);
|
|
56
|
+
try {
|
|
57
|
+
const req = httpGet(targetUrl, { signal: controller.signal }, (resp) => {
|
|
58
|
+
resp.resume();
|
|
59
|
+
const status = resp.statusCode ?? 0;
|
|
60
|
+
// Only 2xx counts as available. A 404 from an unrelated service (or a
|
|
61
|
+
// 4xx/5xx from the real service) must NOT be treated as healthy.
|
|
62
|
+
if (status >= 200 && status < 300) {
|
|
63
|
+
finish({ available: true, status, reason: null });
|
|
64
|
+
} else {
|
|
65
|
+
finish({ available: false, status, reason: `unexpected status ${status}` });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
req.on("error", (err) => {
|
|
69
|
+
finish({ available: false, status: null, reason: err.message });
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
finish({ available: false, status: null, reason: errorMessage(err) });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RuntimeHealth {
|
|
78
|
+
available: boolean;
|
|
79
|
+
reason: string | null;
|
|
80
|
+
healthz: { status: number } | null;
|
|
81
|
+
readyz: { status: number } | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function probeRuntimeHealth(baseUrl: string): Promise<RuntimeHealth> {
|
|
85
|
+
const [h, r] = await Promise.all([
|
|
86
|
+
probeHealthStatus(`${baseUrl}/healthz`),
|
|
87
|
+
probeHealthStatus(`${baseUrl}/readyz`),
|
|
88
|
+
]);
|
|
89
|
+
if (h.available && r.available && h.status !== null && r.status !== null) {
|
|
90
|
+
return { available: true, reason: null, healthz: { status: h.status }, readyz: { status: r.status } };
|
|
91
|
+
}
|
|
92
|
+
const failed = !h.available ? h : r;
|
|
93
|
+
return { available: false, reason: failed.reason ?? "unavailable", healthz: null, readyz: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Runtime file readers ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function readTunnelStatus(direct: boolean): Record<string, unknown> {
|
|
99
|
+
const filePath = join(getRuntimeRoot(direct), "tunnel-status.json");
|
|
100
|
+
if (!existsSync(filePath)) return { observed: false };
|
|
101
|
+
try {
|
|
102
|
+
const data = readJsonFileSafe<Record<string, unknown>>(filePath);
|
|
103
|
+
if (data === null) return { observed: true, error: "invalid JSON" };
|
|
104
|
+
return { observed: true, ...data };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { observed: true, error: errorMessage(err) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ToolManifestSummary {
|
|
111
|
+
tool_profile: string | null;
|
|
112
|
+
tool_count: number | null;
|
|
113
|
+
schema_epoch: string | null;
|
|
114
|
+
tool_manifest_sha256: string | null;
|
|
115
|
+
tool_names: string[] | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readToolManifest(direct: boolean): ToolManifestSummary {
|
|
119
|
+
const empty: ToolManifestSummary = {
|
|
120
|
+
tool_profile: null,
|
|
121
|
+
tool_count: null,
|
|
122
|
+
schema_epoch: null,
|
|
123
|
+
tool_manifest_sha256: null,
|
|
124
|
+
tool_names: null,
|
|
125
|
+
};
|
|
126
|
+
const filePath = join(getRuntimeRoot(direct), "tool-manifest.json");
|
|
127
|
+
if (!existsSync(filePath)) return empty;
|
|
128
|
+
const data = readJsonFileSafe<Record<string, unknown>>(filePath);
|
|
129
|
+
if (!data) return empty;
|
|
130
|
+
return {
|
|
131
|
+
tool_profile: typeof data.tool_profile === "string" ? data.tool_profile : null,
|
|
132
|
+
tool_count: typeof data.tool_count === "number" ? data.tool_count : null,
|
|
133
|
+
schema_epoch: typeof data.schema_epoch === "string" ? data.schema_epoch : null,
|
|
134
|
+
tool_manifest_sha256: typeof data.tool_manifest_sha256 === "string" ? data.tool_manifest_sha256 : null,
|
|
135
|
+
tool_names: Array.isArray(data.tool_names) ? (data.tool_names as string[]) : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readTunnelUrl(direct: boolean): { url: string | null; reason: string | null } {
|
|
140
|
+
const filePath = join(getRuntimeRoot(direct), "tunnel-health-url.txt");
|
|
141
|
+
if (!existsSync(filePath)) return { url: null, reason: "tunnel-health-url.txt not found" };
|
|
142
|
+
try {
|
|
143
|
+
const content = readFileSync(filePath, "utf-8").trim();
|
|
144
|
+
if (!content) return { url: null, reason: "tunnel-health-url.txt is empty" };
|
|
145
|
+
return { url: content, reason: null };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { url: null, reason: errorMessage(err) };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Safe wrappers around reusable modules ─────────────────────────
|
|
152
|
+
|
|
153
|
+
export function readWatcherStatusSafe(): WatcherStatusSnapshot {
|
|
154
|
+
try {
|
|
155
|
+
return readWatcherStatus(config);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return {
|
|
158
|
+
status: "unreadable",
|
|
159
|
+
available: false,
|
|
160
|
+
stale_after_seconds: config.watcherStaleSeconds,
|
|
161
|
+
last_heartbeat_at: null,
|
|
162
|
+
heartbeat_age_seconds: null,
|
|
163
|
+
heartbeat_pid: null,
|
|
164
|
+
instance_id: null,
|
|
165
|
+
launcher_pid: null,
|
|
166
|
+
reason: errorMessage(err),
|
|
167
|
+
activity: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function listAgentsSafe(): AgentAvailability[] {
|
|
173
|
+
try {
|
|
174
|
+
return listAgents().agents;
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resolveWorkspaceRootSafe(): string | null {
|
|
181
|
+
try {
|
|
182
|
+
return resolveWorkspaceRoot(config);
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Stale task classification ─────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export interface StatusTasks {
|
|
191
|
+
tasks: unknown[];
|
|
192
|
+
total: number;
|
|
193
|
+
active: number;
|
|
194
|
+
stale: number;
|
|
195
|
+
stale_task_ids: string[];
|
|
196
|
+
reason: string | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface StaleClassification {
|
|
200
|
+
is_stale: boolean;
|
|
201
|
+
stale_reasons: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const TERMINAL_TASK_STATUSES = new Set([
|
|
205
|
+
"done",
|
|
206
|
+
"done_by_agent",
|
|
207
|
+
"failed",
|
|
208
|
+
"failed_verification",
|
|
209
|
+
"failed_scope_violation",
|
|
210
|
+
"failed_policy_violation",
|
|
211
|
+
"canceled",
|
|
212
|
+
"timeout",
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Classify a task as stale based on Phase 2 rules:
|
|
217
|
+
* - status=running but last_heartbeat_at exceeds threshold
|
|
218
|
+
* - phase=collecting_artifacts exceeds threshold
|
|
219
|
+
* - current_command=null AND watcher currently healthy
|
|
220
|
+
* - task last_heartbeat_at significantly earlier than current watcher heartbeat
|
|
221
|
+
*
|
|
222
|
+
* Only pending/running tasks can be stale; terminal tasks are never stale.
|
|
223
|
+
*/
|
|
224
|
+
export function classifyStaleTask(
|
|
225
|
+
task: TaskEntry,
|
|
226
|
+
watcher: WatcherStatusSnapshot,
|
|
227
|
+
nowMs = Date.now()
|
|
228
|
+
): StaleClassification {
|
|
229
|
+
const reasons: string[] = [];
|
|
230
|
+
if (TERMINAL_TASK_STATUSES.has(task.status)) {
|
|
231
|
+
return { is_stale: false, stale_reasons: reasons };
|
|
232
|
+
}
|
|
233
|
+
// Only pending/running are candidates for staleness.
|
|
234
|
+
if (task.status !== "pending" && task.status !== "running") {
|
|
235
|
+
return { is_stale: false, stale_reasons: reasons };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const staleThresholdMs = config.watcherStaleSeconds * 1000;
|
|
239
|
+
const hbMs = Date.parse(task.last_heartbeat_at || "");
|
|
240
|
+
const heartbeatAgeMs = Number.isFinite(hbMs) ? Math.max(0, nowMs - hbMs) : null;
|
|
241
|
+
|
|
242
|
+
// Rule 1: running with stale heartbeat
|
|
243
|
+
if (task.status === "running" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
|
|
244
|
+
reasons.push("heartbeat_stale");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Rule 2: collecting_artifacts phase exceeds threshold
|
|
248
|
+
if (task.phase === "collecting_artifacts" && heartbeatAgeMs !== null && heartbeatAgeMs > staleThresholdMs) {
|
|
249
|
+
reasons.push("collecting_artifacts_stale");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Rule 3: running with no current_command while watcher is healthy
|
|
253
|
+
if (
|
|
254
|
+
task.status === "running" &&
|
|
255
|
+
(task.current_command === null || task.current_command === "") &&
|
|
256
|
+
watcher.status === "healthy"
|
|
257
|
+
) {
|
|
258
|
+
reasons.push("running_no_command_watcher_healthy");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Rule 4: task heartbeat significantly earlier than watcher heartbeat
|
|
262
|
+
if (heartbeatAgeMs !== null && watcher.last_heartbeat_at) {
|
|
263
|
+
const watcherHbMs = Date.parse(watcher.last_heartbeat_at);
|
|
264
|
+
if (Number.isFinite(watcherHbMs)) {
|
|
265
|
+
const gapMs = watcherHbMs - hbMs;
|
|
266
|
+
// Task heartbeat is "significantly earlier" than watcher heartbeat when
|
|
267
|
+
// the task has not heartbeat for at least 2x the stale threshold while
|
|
268
|
+
// the watcher is alive.
|
|
269
|
+
if (gapMs > staleThresholdMs * 2 && watcher.status === "healthy") {
|
|
270
|
+
reasons.push("heartbeat_far_behind_watcher");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { is_stale: reasons.length > 0, stale_reasons: reasons };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function augmentTaskWithStale(task: TaskEntry, watcher: WatcherStatusSnapshot, nowMs = Date.now()): TaskEntry & StaleClassification {
|
|
279
|
+
const cls = classifyStaleTask(task, watcher, nowMs);
|
|
280
|
+
return { ...task, is_stale: cls.is_stale, stale_reasons: cls.stale_reasons };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function listTasksForStatus(): StatusTasks {
|
|
284
|
+
try {
|
|
285
|
+
const result = listTasks({ limit: 100 });
|
|
286
|
+
const watcher = result.watcher;
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
let active = 0;
|
|
289
|
+
let stale = 0;
|
|
290
|
+
const staleTaskIds: string[] = [];
|
|
291
|
+
const augmented = result.tasks.map((t) => {
|
|
292
|
+
const a = augmentTaskWithStale(t, watcher, now);
|
|
293
|
+
if (t.status === "pending" || t.status === "running") active++;
|
|
294
|
+
if (a.is_stale) {
|
|
295
|
+
stale++;
|
|
296
|
+
staleTaskIds.push(t.task_id);
|
|
297
|
+
}
|
|
298
|
+
return a;
|
|
299
|
+
});
|
|
300
|
+
return { tasks: augmented, total: result.total, active, stale, stale_task_ids: staleTaskIds, reason: null };
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return { tasks: [], total: 0, active: 0, stale: 0, stale_task_ids: [], reason: errorMessage(err) };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Control Center status file + activity timeline ────────────────
|
|
307
|
+
|
|
308
|
+
export interface ControlCenterStatusFile {
|
|
309
|
+
pid: number;
|
|
310
|
+
port: number;
|
|
311
|
+
started_at: string;
|
|
312
|
+
url: string;
|
|
313
|
+
version: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function writeStatusFile(): void {
|
|
317
|
+
const status: ControlCenterStatusFile = {
|
|
318
|
+
pid: process.pid,
|
|
319
|
+
port,
|
|
320
|
+
started_at: new Date().toISOString(),
|
|
321
|
+
url: `http://${host}:${port}/`,
|
|
322
|
+
version: PATCHWARDEN_VERSION,
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
mkdirSync(getControlCenterLogDir(), { recursive: true });
|
|
326
|
+
writeFileSync(controlCenterStatusPath, JSON.stringify(status, null, 2), "utf-8");
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logger.error("[control-center] Failed to write status file", { error: errorMessage(err) });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function removeStatusFile(): void {
|
|
333
|
+
try {
|
|
334
|
+
if (existsSync(controlCenterStatusPath)) {
|
|
335
|
+
unlinkSync(controlCenterStatusPath);
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
logger.error("[control-center] Failed to remove status file", { error: errorMessage(err) });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface ControlCenterEvent {
|
|
343
|
+
timestamp: string;
|
|
344
|
+
type: string;
|
|
345
|
+
payload?: Record<string, unknown>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Append a single event line to the JSONL timeline. Best-effort: a write
|
|
350
|
+
* failure is logged but never crashes the server. Trims the file to
|
|
351
|
+
* MAX_EVENT_LINES lazily when it grows past 1.5x the cap, so we don't pay the
|
|
352
|
+
* trim cost on every event.
|
|
353
|
+
*/
|
|
354
|
+
export function recordEvent(type: string, payload?: Record<string, unknown>): void {
|
|
355
|
+
const event: ControlCenterEvent = {
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
type,
|
|
358
|
+
payload,
|
|
359
|
+
};
|
|
360
|
+
try {
|
|
361
|
+
mkdirSync(getControlCenterLogDir(), { recursive: true });
|
|
362
|
+
appendFileSync(controlCenterEventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logger.error("[control-center] Failed to write event", { error: errorMessage(err) });
|
|
365
|
+
}
|
|
366
|
+
// Lazy trim: only when the file grows well past the cap.
|
|
367
|
+
try {
|
|
368
|
+
const stat = statSync(controlCenterEventsPath);
|
|
369
|
+
if (stat.size > 512 * 1024) {
|
|
370
|
+
trimEventsFile();
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
/* ignore */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function trimEventsFile(): void {
|
|
378
|
+
try {
|
|
379
|
+
if (!existsSync(controlCenterEventsPath)) return;
|
|
380
|
+
const raw = readFileSync(controlCenterEventsPath, "utf-8");
|
|
381
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
382
|
+
if (lines.length <= MAX_EVENT_LINES) return;
|
|
383
|
+
const trimmed = lines.slice(lines.length - MAX_EVENT_LINES);
|
|
384
|
+
writeFileSync(controlCenterEventsPath, trimmed.join("\n") + "\n", "utf-8");
|
|
385
|
+
} catch (err) {
|
|
386
|
+
logger.error("[control-center] Failed to trim events file", { error: errorMessage(err) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function readEvents(limit: number): ControlCenterEvent[] {
|
|
391
|
+
if (!existsSync(controlCenterEventsPath)) return [];
|
|
392
|
+
try {
|
|
393
|
+
const raw = readFileSync(controlCenterEventsPath, "utf-8");
|
|
394
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
395
|
+
const sliced = lines.slice(Math.max(0, lines.length - limit));
|
|
396
|
+
const events: ControlCenterEvent[] = [];
|
|
397
|
+
for (const line of sliced) {
|
|
398
|
+
try {
|
|
399
|
+
events.push(JSON.parse(line) as ControlCenterEvent);
|
|
400
|
+
} catch {
|
|
401
|
+
/* skip malformed line */
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return events;
|
|
405
|
+
} catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Hidden stale task IDs (local UI state) ────────────────────────
|
|
411
|
+
|
|
412
|
+
export function readHiddenStaleIds(): string[] {
|
|
413
|
+
const p = join(getControlCenterLogDir(), "hidden-stale-ids.json");
|
|
414
|
+
if (!existsSync(p)) return [];
|
|
415
|
+
return readJsonFileSafe<string[]>(p) || [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function writeHiddenStaleIds(ids: string[]): void {
|
|
419
|
+
const dir = getControlCenterLogDir();
|
|
420
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
421
|
+
writeFileSync(join(dir, "hidden-stale-ids.json"), JSON.stringify(ids, null, 2));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Hidden direct session IDs (local UI state) ────────────────────
|
|
425
|
+
|
|
426
|
+
export function readHiddenDirectSessionIds(): string[] {
|
|
427
|
+
const p = join(getControlCenterLogDir(), "hidden-direct-session-ids.json");
|
|
428
|
+
if (!existsSync(p)) return [];
|
|
429
|
+
return readJsonFileSafe<string[]>(p) || [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function writeHiddenDirectSessionIds(ids: string[]): void {
|
|
433
|
+
const dir = getControlCenterLogDir();
|
|
434
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
435
|
+
writeFileSync(join(dir, "hidden-direct-session-ids.json"), JSON.stringify(ids, null, 2));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function isValidDirectSessionId(sessionId: string): boolean {
|
|
439
|
+
// Allow alphanumeric, dash, underscore only — reject path traversal and NUL.
|
|
440
|
+
return (
|
|
441
|
+
sessionId.length > 0 &&
|
|
442
|
+
/^[A-Za-z0-9_-]+$/.test(sessionId) &&
|
|
443
|
+
!sessionId.includes("\0")
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function isValidTaskId(taskId: string): boolean {
|
|
448
|
+
return !(
|
|
449
|
+
taskId === "." ||
|
|
450
|
+
taskId === ".." ||
|
|
451
|
+
taskId.includes("/") ||
|
|
452
|
+
taskId.includes("\\") ||
|
|
453
|
+
taskId.includes("\0")
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Small shared helpers ──────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
export function parseReviewVerdict(content: string): string | null {
|
|
460
|
+
// independent-review.md format: "**Verdict**: PASS" (case-insensitive)
|
|
461
|
+
const m = content.match(/\*\*Verdict\*\*\s*:\s*([A-Za-z]+)/);
|
|
462
|
+
return m ? m[1].toLowerCase() : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function fileMtimeIso(filePath: string): string | null {
|
|
466
|
+
try {
|
|
467
|
+
const m = statSync(filePath).mtime;
|
|
468
|
+
return m ? m.toISOString() : null;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|