patchwarden 0.6.0 → 0.6.4
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/PatchWarden-Control-Tray.cmd +11 -0
- package/PatchWarden-Control.cmd +6 -0
- package/PatchWarden-Desktop.cmd +5 -0
- package/PatchWarden.cmd +1 -1
- package/README.en.md +106 -18
- package/README.md +30 -19
- package/Restart-PatchWarden-Control.cmd +6 -0
- package/Stop-PatchWarden.cmd +11 -0
- package/dist/controlCenter.d.ts +14 -0
- package/dist/controlCenter.js +2002 -0
- package/dist/doctor.js +4 -4
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/changeCapture.d.ts +41 -0
- package/dist/runner/changeCapture.js +171 -1
- package/dist/runner/runTask.js +204 -24
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/auditTask.js +11 -5
- package/dist/tools/getTaskSummary.d.ts +3 -0
- package/dist/tools/getTaskSummary.js +15 -1
- package/dist/tools/healthCheck.d.ts +5 -0
- package/dist/tools/healthCheck.js +21 -0
- package/dist/tools/registry.js +53 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +2 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/control-center/README.md +33 -0
- package/docs/control-center/control-center-daily-driver.md +211 -0
- package/docs/control-center/control-center-mvp.md +205 -0
- package/docs/control-center/control-center-phase2.md +159 -0
- package/docs/demo.md +3 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-v0.6.1.md +75 -0
- package/docs/release-v0.6.4.md +45 -0
- package/examples/openai-tunnel/README.md +5 -5
- package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
- package/package.json +23 -14
- package/scripts/README.md +47 -0
- package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
- package/scripts/checks/control-center-smoke.js +1098 -0
- package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
- package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
- package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +12 -4
- package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +347 -13
- package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +32 -9
- package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +14 -9
- package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
- package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
- package/scripts/checks/unit-tests.js +36 -0
- package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
- package/scripts/control/control-center-tray.ps1 +281 -0
- package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
- package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
- package/scripts/control/restart-control-center.ps1 +173 -0
- package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
- package/scripts/control/start-control-center.ps1 +263 -0
- package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
- package/scripts/control/stop-patchwarden.ps1 +114 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
- package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
- package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
- package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
- package/scripts/release/pack-clean.js +305 -0
- package/src/controlCenter.ts +2152 -0
- package/src/doctor.ts +5 -5
- package/src/logging.ts +152 -0
- package/src/runner/changeCapture.ts +212 -1
- package/src/runner/runTask.ts +220 -22
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/auditTask.ts +11 -5
- package/src/tools/getTaskSummary.ts +22 -1
- package/src/tools/healthCheck.ts +22 -0
- package/src/tools/registry.ts +63 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/toolCatalog.ts +2 -0
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/ui/colors_and_type.css +141 -0
- package/ui/pages/audit.html +743 -0
- package/ui/pages/dashboard.html +1154 -0
- package/ui/pages/direct-sessions.html +652 -0
- package/ui/pages/logs.html +502 -0
- package/ui/pages/task-detail.html +1229 -0
- package/ui/pages/tasks.html +702 -0
- package/ui/pages/workspace.html +947 -0
- package/ui/partials/project-shell.html +362 -0
- package/ui/vendor/lucide.js +12 -0
- package/ui/vendor/tailwindcss-browser.js +947 -0
- package/scripts/pack-clean.js +0 -141
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { getTasksDir, getConfig, type PatchWardenConfig } from "../config.js";
|
|
4
|
+
import { guardReadPath } from "../security/pathGuard.js";
|
|
5
|
+
import { guardSensitivePath } from "../security/sensitiveGuard.js";
|
|
6
|
+
import type { TaskStatus, TaskPhase } from "./createTask.js";
|
|
7
|
+
import { readTaskRuntime } from "../taskRuntime.js";
|
|
8
|
+
import { readWatcherStatus } from "../watcherStatus.js";
|
|
9
|
+
|
|
10
|
+
export interface SafeStatusOutput {
|
|
11
|
+
task_id: string;
|
|
12
|
+
status: TaskStatus | "not_found";
|
|
13
|
+
phase: TaskPhase | null;
|
|
14
|
+
created_at: string | null;
|
|
15
|
+
started_at: string | null;
|
|
16
|
+
updated_at: string | null;
|
|
17
|
+
finished_at: string | null;
|
|
18
|
+
last_heartbeat_at: string | null;
|
|
19
|
+
current_command: string | null;
|
|
20
|
+
verify_status: "passed" | "failed" | "skipped" | null;
|
|
21
|
+
artifact_status: string | null;
|
|
22
|
+
watcher_state: string | null;
|
|
23
|
+
error_code: string | null;
|
|
24
|
+
error_summary: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStatusOutput {
|
|
28
|
+
const cfg = config || getConfig();
|
|
29
|
+
const tasksDir = getTasksDir(cfg);
|
|
30
|
+
|
|
31
|
+
const taskDir = resolve(tasksDir, taskId);
|
|
32
|
+
const statusFile = join(taskDir, "status.json");
|
|
33
|
+
|
|
34
|
+
// Check existence BEFORE guardReadPath so that a non-existent task
|
|
35
|
+
// returns a structured not_found response instead of throwing.
|
|
36
|
+
if (!existsSync(statusFile)) {
|
|
37
|
+
return {
|
|
38
|
+
task_id: taskId,
|
|
39
|
+
status: "not_found",
|
|
40
|
+
phase: null,
|
|
41
|
+
created_at: null,
|
|
42
|
+
started_at: null,
|
|
43
|
+
updated_at: null,
|
|
44
|
+
finished_at: null,
|
|
45
|
+
last_heartbeat_at: null,
|
|
46
|
+
current_command: null,
|
|
47
|
+
verify_status: null,
|
|
48
|
+
artifact_status: null,
|
|
49
|
+
watcher_state: null,
|
|
50
|
+
error_code: null,
|
|
51
|
+
error_summary: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
guardReadPath(statusFile, cfg.workspaceRoot, cfg.tasksDir);
|
|
56
|
+
guardSensitivePath(statusFile);
|
|
57
|
+
|
|
58
|
+
const raw = readFileSync(statusFile, "utf-8");
|
|
59
|
+
const status = JSON.parse(raw) as Record<string, unknown>;
|
|
60
|
+
const runtime = readTaskRuntime(taskDir);
|
|
61
|
+
const phase = (runtime.phase || status.phase || "queued") as TaskPhase;
|
|
62
|
+
const watcher = readWatcherStatus(cfg);
|
|
63
|
+
|
|
64
|
+
// Extract short error summary — never the full error text
|
|
65
|
+
let errorCode: string | null = null;
|
|
66
|
+
let errorSummary: string | null = null;
|
|
67
|
+
if (status.error && typeof status.error === "string") {
|
|
68
|
+
const errorStr = status.error as string;
|
|
69
|
+
// Keep only the first 200 chars as summary
|
|
70
|
+
errorSummary = errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
|
|
71
|
+
// Try to extract an error code from the status
|
|
72
|
+
if (typeof status.status === "string" && (status.status as string).startsWith("failed")) {
|
|
73
|
+
errorCode = status.status as string;
|
|
74
|
+
}
|
|
75
|
+
} else if (typeof status.status === "string" && (status.status as string).startsWith("failed")) {
|
|
76
|
+
errorCode = status.status as string;
|
|
77
|
+
errorSummary = `Task ended with status: ${status.status}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
task_id: taskId,
|
|
82
|
+
status: (status.status as TaskStatus) || "not_found",
|
|
83
|
+
phase,
|
|
84
|
+
created_at: typeof status.created_at === "string" ? status.created_at : null,
|
|
85
|
+
started_at: typeof status.started_at === "string" ? status.started_at : null,
|
|
86
|
+
updated_at: typeof status.updated_at === "string" ? status.updated_at : null,
|
|
87
|
+
finished_at: typeof status.finished_at === "string" ? status.finished_at : null,
|
|
88
|
+
last_heartbeat_at: runtime.last_heartbeat_at || (typeof status.last_heartbeat_at === "string" ? status.last_heartbeat_at : null),
|
|
89
|
+
current_command: runtime.current_command ?? (typeof status.current_command === "string" ? status.current_command : null) ?? null,
|
|
90
|
+
verify_status: typeof status.verify_status === "string" ? (status.verify_status as "passed" | "failed" | "skipped") : null,
|
|
91
|
+
artifact_status: typeof status.artifact_status === "string" ? status.artifact_status : null,
|
|
92
|
+
watcher_state: watcher.status,
|
|
93
|
+
error_code: errorCode,
|
|
94
|
+
error_summary: errorSummary,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, resolve, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { PatchWardenError } from "../errors.js";
|
|
5
|
+
import { getConfig, type PatchWardenConfig } from "../config.js";
|
|
6
|
+
import { guardDirectPath, guardDirectWritePath } from "../direct/directGuards.js";
|
|
7
|
+
import { guardSensitivePath } from "../security/sensitiveGuard.js";
|
|
8
|
+
import { computeFileSha256 } from "../direct/directPatch.js";
|
|
9
|
+
|
|
10
|
+
export interface SyncFileResult {
|
|
11
|
+
source_path: string;
|
|
12
|
+
target_path: string;
|
|
13
|
+
before_target_sha256: string | null;
|
|
14
|
+
after_target_sha256: string;
|
|
15
|
+
source_sha256: string;
|
|
16
|
+
copied_bytes: number;
|
|
17
|
+
changed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Copy a file from source to target within the same session repo.
|
|
22
|
+
* Both source and target must be inside the session's repo_path.
|
|
23
|
+
*/
|
|
24
|
+
export function syncFile(
|
|
25
|
+
sessionId: string,
|
|
26
|
+
sourcePath: string,
|
|
27
|
+
targetPath: string,
|
|
28
|
+
options?: {
|
|
29
|
+
expected_source_sha256?: string;
|
|
30
|
+
expected_target_sha256?: string;
|
|
31
|
+
},
|
|
32
|
+
config?: PatchWardenConfig
|
|
33
|
+
): SyncFileResult {
|
|
34
|
+
const cfg = config || getConfig();
|
|
35
|
+
const sessionsDir = resolve(cfg.workspaceRoot, cfg.directSessionsDir);
|
|
36
|
+
|
|
37
|
+
// Load session to get repo_path
|
|
38
|
+
const sessionFile = resolve(sessionsDir, sessionId, "session.json");
|
|
39
|
+
if (!existsSync(sessionFile)) {
|
|
40
|
+
throw new PatchWardenError(
|
|
41
|
+
"direct_session_not_found",
|
|
42
|
+
`Direct session "${sessionId}" not found.`,
|
|
43
|
+
"Create a direct session first using direct_start_session.",
|
|
44
|
+
true,
|
|
45
|
+
{ session_id: sessionId }
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
|
|
50
|
+
const repoPath = resolve(session.repo_path);
|
|
51
|
+
const workspaceRoot = cfg.workspaceRoot;
|
|
52
|
+
|
|
53
|
+
// Guard source path — must be inside repo
|
|
54
|
+
const resolvedSource = guardDirectPath(sourcePath, repoPath, workspaceRoot);
|
|
55
|
+
guardSensitivePath(resolvedSource);
|
|
56
|
+
|
|
57
|
+
if (!existsSync(resolvedSource)) {
|
|
58
|
+
throw new PatchWardenError(
|
|
59
|
+
"source_file_not_found",
|
|
60
|
+
`Source file does not exist: "${sourcePath}".`,
|
|
61
|
+
"Ensure the source path is correct.",
|
|
62
|
+
true,
|
|
63
|
+
{ source_path: sourcePath }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Guard target path — must be inside repo, not in blocked dirs
|
|
68
|
+
const resolvedTarget = guardDirectWritePath(targetPath, repoPath, workspaceRoot);
|
|
69
|
+
guardSensitivePath(resolvedTarget);
|
|
70
|
+
|
|
71
|
+
// Verify source sha256 if provided
|
|
72
|
+
const sourceSha256 = computeFileSha256(resolvedSource);
|
|
73
|
+
if (options?.expected_source_sha256 && options.expected_source_sha256 !== sourceSha256) {
|
|
74
|
+
throw new PatchWardenError(
|
|
75
|
+
"source_hash_mismatch",
|
|
76
|
+
`Source file hash mismatch. Expected "${options.expected_source_sha256}" but got "${sourceSha256}".`,
|
|
77
|
+
"Re-read the source file to get the current sha256.",
|
|
78
|
+
true,
|
|
79
|
+
{ expected_sha256: options.expected_source_sha256, actual_sha256: sourceSha256 }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get target sha256 before copy
|
|
84
|
+
let beforeTargetSha256: string | null = null;
|
|
85
|
+
if (existsSync(resolvedTarget)) {
|
|
86
|
+
beforeTargetSha256 = computeFileSha256(resolvedTarget);
|
|
87
|
+
// Verify target sha256 if provided
|
|
88
|
+
if (options?.expected_target_sha256 && options.expected_target_sha256 !== beforeTargetSha256) {
|
|
89
|
+
throw new PatchWardenError(
|
|
90
|
+
"target_hash_mismatch",
|
|
91
|
+
`Target file hash mismatch. Expected "${options.expected_target_sha256}" but got "${beforeTargetSha256}".`,
|
|
92
|
+
"Re-read the target file to get the current sha256.",
|
|
93
|
+
true,
|
|
94
|
+
{ expected_sha256: options.expected_target_sha256, actual_sha256: beforeTargetSha256 }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Read source content
|
|
100
|
+
const sourceContent = readFileSync(resolvedSource);
|
|
101
|
+
const copiedBytes = sourceContent.length;
|
|
102
|
+
|
|
103
|
+
// Create target directory if needed
|
|
104
|
+
mkdirSync(dirname(resolvedTarget), { recursive: true });
|
|
105
|
+
|
|
106
|
+
// Write to target
|
|
107
|
+
writeFileSync(resolvedTarget, sourceContent, "utf-8");
|
|
108
|
+
|
|
109
|
+
// Compute after hash
|
|
110
|
+
const afterTargetSha256 = computeFileSha256(resolvedTarget);
|
|
111
|
+
const changed = beforeTargetSha256 !== afterTargetSha256;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
source_path: sourcePath,
|
|
115
|
+
target_path: targetPath,
|
|
116
|
+
before_target_sha256: beforeTargetSha256,
|
|
117
|
+
after_target_sha256: afterTargetSha256,
|
|
118
|
+
source_sha256: sourceSha256,
|
|
119
|
+
copied_bytes: copiedBytes,
|
|
120
|
+
changed,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/tools/toolCatalog.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const CHATGPT_CORE_TOOL_NAMES = [
|
|
|
35
35
|
"list_tasks",
|
|
36
36
|
"cancel_task",
|
|
37
37
|
"audit_task",
|
|
38
|
+
"safe_status",
|
|
38
39
|
] as const;
|
|
39
40
|
|
|
40
41
|
export const CHATGPT_DIRECT_TOOL_NAMES = [
|
|
@@ -47,6 +48,7 @@ export const CHATGPT_DIRECT_TOOL_NAMES = [
|
|
|
47
48
|
"run_verification",
|
|
48
49
|
"finalize_direct_session",
|
|
49
50
|
"audit_session",
|
|
51
|
+
"sync_file",
|
|
50
52
|
] as const;
|
|
51
53
|
|
|
52
54
|
let lastSnapshot: ToolCatalogSnapshot | null = null;
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const PATCHWARDEN_VERSION = "0.6.
|
|
2
|
-
export const TOOL_SCHEMA_EPOCH = "2026-06-
|
|
1
|
+
export const PATCHWARDEN_VERSION = "0.6.4";
|
|
2
|
+
export const TOOL_SCHEMA_EPOCH = "2026-06-24-v7";
|
package/src/watcherStatus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { getConfig, getTasksDir, type PatchWardenConfig } from "./config.js";
|
|
4
4
|
|
|
@@ -24,6 +24,7 @@ export interface WatcherStatusSnapshot {
|
|
|
24
24
|
instance_id: string | null;
|
|
25
25
|
launcher_pid: number | null;
|
|
26
26
|
reason: string | null;
|
|
27
|
+
activity: string | null;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function getWatcherHeartbeatPath(config: PatchWardenConfig = getConfig()): string {
|
|
@@ -37,6 +38,22 @@ export function readWatcherStatus(
|
|
|
37
38
|
const staleAfterSeconds = config.watcherStaleSeconds;
|
|
38
39
|
const heartbeatPath = getWatcherHeartbeatPath(config);
|
|
39
40
|
if (!existsSync(heartbeatPath)) {
|
|
41
|
+
// Even if watcher heartbeat is missing, check if a task is actively running
|
|
42
|
+
const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
|
|
43
|
+
if (taskFallback) {
|
|
44
|
+
return {
|
|
45
|
+
status: "healthy",
|
|
46
|
+
available: true,
|
|
47
|
+
stale_after_seconds: staleAfterSeconds,
|
|
48
|
+
last_heartbeat_at: taskFallback.heartbeat_at,
|
|
49
|
+
heartbeat_age_seconds: taskFallback.age_seconds,
|
|
50
|
+
heartbeat_pid: null,
|
|
51
|
+
instance_id: null,
|
|
52
|
+
launcher_pid: null,
|
|
53
|
+
reason: null,
|
|
54
|
+
activity: taskFallback.activity,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
40
57
|
return {
|
|
41
58
|
status: "missing",
|
|
42
59
|
available: false,
|
|
@@ -47,6 +64,7 @@ export function readWatcherStatus(
|
|
|
47
64
|
instance_id: null,
|
|
48
65
|
launcher_pid: null,
|
|
49
66
|
reason: "Watcher heartbeat has not been observed. Start or restart the PatchWarden watcher.",
|
|
67
|
+
activity: null,
|
|
50
68
|
};
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -58,16 +76,47 @@ export function readWatcherStatus(
|
|
|
58
76
|
const ageMs = Math.max(0, nowMs - heartbeatMs);
|
|
59
77
|
const ageSeconds = Math.round(ageMs / 1000);
|
|
60
78
|
const healthy = ageMs < staleAfterSeconds * 1000;
|
|
79
|
+
if (healthy) {
|
|
80
|
+
return {
|
|
81
|
+
status: "healthy",
|
|
82
|
+
available: true,
|
|
83
|
+
stale_after_seconds: staleAfterSeconds,
|
|
84
|
+
last_heartbeat_at: String(data.last_heartbeat_at),
|
|
85
|
+
heartbeat_age_seconds: ageSeconds,
|
|
86
|
+
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
87
|
+
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
88
|
+
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
89
|
+
reason: null,
|
|
90
|
+
activity: null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Watcher heartbeat is stale — check if a task is actively running
|
|
94
|
+
const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
|
|
95
|
+
if (taskFallback) {
|
|
96
|
+
return {
|
|
97
|
+
status: "healthy",
|
|
98
|
+
available: true,
|
|
99
|
+
stale_after_seconds: staleAfterSeconds,
|
|
100
|
+
last_heartbeat_at: taskFallback.heartbeat_at,
|
|
101
|
+
heartbeat_age_seconds: taskFallback.age_seconds,
|
|
102
|
+
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
103
|
+
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
104
|
+
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
105
|
+
reason: null,
|
|
106
|
+
activity: taskFallback.activity,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
61
109
|
return {
|
|
62
|
-
status:
|
|
63
|
-
available:
|
|
110
|
+
status: "stale",
|
|
111
|
+
available: false,
|
|
64
112
|
stale_after_seconds: staleAfterSeconds,
|
|
65
113
|
last_heartbeat_at: String(data.last_heartbeat_at),
|
|
66
114
|
heartbeat_age_seconds: ageSeconds,
|
|
67
115
|
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
68
116
|
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
69
117
|
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
70
|
-
reason:
|
|
118
|
+
reason: "Watcher heartbeat is stale. Restart the PatchWarden watcher.",
|
|
119
|
+
activity: null,
|
|
71
120
|
};
|
|
72
121
|
} catch {
|
|
73
122
|
return {
|
|
@@ -80,10 +129,58 @@ export function readWatcherStatus(
|
|
|
80
129
|
instance_id: null,
|
|
81
130
|
launcher_pid: null,
|
|
82
131
|
reason: "Watcher heartbeat file is unreadable.",
|
|
132
|
+
activity: null,
|
|
83
133
|
};
|
|
84
134
|
}
|
|
85
135
|
}
|
|
86
136
|
|
|
137
|
+
interface TaskHeartbeatFallback {
|
|
138
|
+
heartbeat_at: string;
|
|
139
|
+
age_seconds: number;
|
|
140
|
+
activity: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function checkRunningTaskHeartbeat(
|
|
144
|
+
config: PatchWardenConfig,
|
|
145
|
+
nowMs: number,
|
|
146
|
+
staleAfterSeconds: number
|
|
147
|
+
): TaskHeartbeatFallback | null {
|
|
148
|
+
const tasksDir = getTasksDir(config);
|
|
149
|
+
if (!existsSync(tasksDir)) return null;
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (!entry.isDirectory()) continue;
|
|
158
|
+
const taskDir = join(tasksDir, entry.name);
|
|
159
|
+
const statusFile = join(taskDir, "status.json");
|
|
160
|
+
const runtimeFile = join(taskDir, "runtime.json");
|
|
161
|
+
if (!existsSync(statusFile) || !existsSync(runtimeFile)) continue;
|
|
162
|
+
try {
|
|
163
|
+
const status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
164
|
+
if (status.status !== "running") continue;
|
|
165
|
+
const runtime = JSON.parse(readFileSync(runtimeFile, "utf-8"));
|
|
166
|
+
const heartbeatAt = String(runtime.last_heartbeat_at || "");
|
|
167
|
+
const heartbeatMs = Date.parse(heartbeatAt);
|
|
168
|
+
if (!Number.isFinite(heartbeatMs)) continue;
|
|
169
|
+
const ageMs = Math.max(0, nowMs - heartbeatMs);
|
|
170
|
+
if (ageMs < staleAfterSeconds * 1000) {
|
|
171
|
+
return {
|
|
172
|
+
heartbeat_at: heartbeatAt,
|
|
173
|
+
age_seconds: Math.round(ageMs / 1000),
|
|
174
|
+
activity: `watcher busy executing task ${entry.name}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
87
184
|
export function derivePendingReason(
|
|
88
185
|
task: { status?: string; phase?: string },
|
|
89
186
|
watcher: WatcherStatusSnapshot
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* PatchWarden Brand CSS — Dark Security Theme (default) + Light Mode */
|
|
2
|
+
/* Prefix: pw */
|
|
3
|
+
|
|
4
|
+
:root {
|
|
5
|
+
/* ── Primary Palette (Dark — default) ── */
|
|
6
|
+
--pw-bg: #0a0e14;
|
|
7
|
+
--pw-bg-elevated: #111820;
|
|
8
|
+
--pw-bg-surface: #161d27;
|
|
9
|
+
--pw-bg-hover: #1c2533;
|
|
10
|
+
--pw-bg-active: #222d3d;
|
|
11
|
+
--pw-border: #1e2a3a;
|
|
12
|
+
--pw-border-subtle: #172030;
|
|
13
|
+
--pw-border-focus: #2dd4a8;
|
|
14
|
+
|
|
15
|
+
/* ── Text ── */
|
|
16
|
+
--pw-text-primary: #e8edf4;
|
|
17
|
+
--pw-text-secondary: #8b99ad;
|
|
18
|
+
--pw-text-tertiary: #5a6a80;
|
|
19
|
+
--pw-text-inverse: #0a0e14;
|
|
20
|
+
--pw-text-accent: #2dd4a8;
|
|
21
|
+
|
|
22
|
+
/* ── Brand Accent — Security Green ── */
|
|
23
|
+
--pw-accent: #2dd4a8;
|
|
24
|
+
--pw-accent-hover: #34eabc;
|
|
25
|
+
--pw-accent-muted: rgba(45, 212, 168, 0.12);
|
|
26
|
+
--pw-accent-subtle: rgba(45, 212, 168, 0.06);
|
|
27
|
+
|
|
28
|
+
/* ── State Colors ── */
|
|
29
|
+
--pw-state-success: #22c55e;
|
|
30
|
+
--pw-state-success-bg: rgba(34, 197, 94, 0.1);
|
|
31
|
+
--pw-state-warning: #f59e0b;
|
|
32
|
+
--pw-state-warning-bg: rgba(245, 158, 11, 0.1);
|
|
33
|
+
--pw-state-error: #ef4444;
|
|
34
|
+
--pw-state-error-bg: rgba(239, 68, 68, 0.1);
|
|
35
|
+
--pw-state-info: #3b82f6;
|
|
36
|
+
--pw-state-info-bg: rgba(59, 130, 246, 0.1);
|
|
37
|
+
|
|
38
|
+
/* ── Radius ── */
|
|
39
|
+
--pw-radius-sm: 4px;
|
|
40
|
+
--pw-radius-md: 8px;
|
|
41
|
+
--pw-radius-lg: 12px;
|
|
42
|
+
--pw-radius-full: 9999px;
|
|
43
|
+
|
|
44
|
+
/* ── Shadows — static surfaces use border only; floating layers use subtle shadow ── */
|
|
45
|
+
--pw-shadow-float: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
46
|
+
--pw-shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.5);
|
|
47
|
+
|
|
48
|
+
/* ── Typography ── */
|
|
49
|
+
--pw-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
50
|
+
--pw-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
51
|
+
|
|
52
|
+
--pw-text-xs: 0.75rem;
|
|
53
|
+
--pw-text-sm: 0.8125rem;
|
|
54
|
+
--pw-text-base: 0.875rem;
|
|
55
|
+
--pw-text-lg: 1rem;
|
|
56
|
+
--pw-text-xl: 1.25rem;
|
|
57
|
+
--pw-text-2xl: 1.5rem;
|
|
58
|
+
--pw-text-3xl: 2rem;
|
|
59
|
+
|
|
60
|
+
--pw-leading-tight: 1.25;
|
|
61
|
+
--pw-leading-normal: 1.5;
|
|
62
|
+
--pw-leading-relaxed: 1.625;
|
|
63
|
+
|
|
64
|
+
--pw-weight-normal: 400;
|
|
65
|
+
--pw-weight-medium: 500;
|
|
66
|
+
--pw-weight-semibold: 600;
|
|
67
|
+
--pw-weight-bold: 700;
|
|
68
|
+
|
|
69
|
+
/* ── Spacing ── */
|
|
70
|
+
--pw-space-1: 4px;
|
|
71
|
+
--pw-space-2: 8px;
|
|
72
|
+
--pw-space-3: 12px;
|
|
73
|
+
--pw-space-4: 16px;
|
|
74
|
+
--pw-space-5: 20px;
|
|
75
|
+
--pw-space-6: 24px;
|
|
76
|
+
--pw-space-8: 32px;
|
|
77
|
+
--pw-space-10: 40px;
|
|
78
|
+
--pw-space-12: 48px;
|
|
79
|
+
|
|
80
|
+
/* ── Transitions ── */
|
|
81
|
+
--pw-transition-fast: 150ms ease;
|
|
82
|
+
--pw-transition-base: 200ms ease;
|
|
83
|
+
--pw-transition-slow: 300ms ease;
|
|
84
|
+
|
|
85
|
+
/* ── Code block backgrounds ── */
|
|
86
|
+
--pw-code-bg: #0d1117;
|
|
87
|
+
--pw-code-add-bg: rgba(34, 197, 94, 0.1);
|
|
88
|
+
--pw-code-add-text: #3fb950;
|
|
89
|
+
--pw-code-remove-bg: rgba(239, 68, 68, 0.1);
|
|
90
|
+
--pw-code-remove-text: #f85149;
|
|
91
|
+
--pw-code-line-num: #5a6a80;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ── Dark mode (explicit, same as :root defaults) ── */
|
|
95
|
+
html.dark {
|
|
96
|
+
--pw-bg: #0a0e14;
|
|
97
|
+
--pw-bg-elevated: #111820;
|
|
98
|
+
--pw-bg-surface: #161d27;
|
|
99
|
+
--pw-bg-hover: #1c2533;
|
|
100
|
+
--pw-bg-active: #222d3d;
|
|
101
|
+
--pw-border: #1e2a3a;
|
|
102
|
+
--pw-border-subtle: #172030;
|
|
103
|
+
--pw-text-primary: #e8edf4;
|
|
104
|
+
--pw-text-secondary: #8b99ad;
|
|
105
|
+
--pw-text-tertiary: #5a6a80;
|
|
106
|
+
--pw-code-bg: #0d1117;
|
|
107
|
+
--pw-code-add-bg: rgba(34, 197, 94, 0.1);
|
|
108
|
+
--pw-code-add-text: #3fb950;
|
|
109
|
+
--pw-code-remove-bg: rgba(239, 68, 68, 0.1);
|
|
110
|
+
--pw-code-remove-text: #f85149;
|
|
111
|
+
--pw-code-line-num: #5a6a80;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── Light mode ── */
|
|
115
|
+
html.light {
|
|
116
|
+
--pw-bg: #f8fafb;
|
|
117
|
+
--pw-bg-elevated: #ffffff;
|
|
118
|
+
--pw-bg-surface: #f1f5f7;
|
|
119
|
+
--pw-bg-hover: #e8eef2;
|
|
120
|
+
--pw-bg-active: #dde5eb;
|
|
121
|
+
--pw-border: #d1d9e0;
|
|
122
|
+
--pw-border-subtle: #e2e8ee;
|
|
123
|
+
--pw-border-focus: #1a9e7a;
|
|
124
|
+
--pw-text-primary: #1a2332;
|
|
125
|
+
--pw-text-secondary: #5a6a80;
|
|
126
|
+
--pw-text-tertiary: #8b99ad;
|
|
127
|
+
--pw-text-inverse: #ffffff;
|
|
128
|
+
--pw-text-accent: #1a9e7a;
|
|
129
|
+
--pw-accent: #1a9e7a;
|
|
130
|
+
--pw-accent-hover: #158c6c;
|
|
131
|
+
--pw-accent-muted: rgba(26, 158, 122, 0.1);
|
|
132
|
+
--pw-accent-subtle: rgba(26, 158, 122, 0.05);
|
|
133
|
+
--pw-shadow-float: 0 8px 32px rgba(0, 0, 0, 0.08);
|
|
134
|
+
--pw-shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.12);
|
|
135
|
+
--pw-code-bg: #f6f8fa;
|
|
136
|
+
--pw-code-add-bg: rgba(34, 197, 94, 0.08);
|
|
137
|
+
--pw-code-add-text: #1a7f37;
|
|
138
|
+
--pw-code-remove-bg: rgba(239, 68, 68, 0.08);
|
|
139
|
+
--pw-code-remove-text: #cf222e;
|
|
140
|
+
--pw-code-line-num: #8b99ad;
|
|
141
|
+
}
|