patchwarden 0.6.0 → 0.6.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.md +3 -3
- package/dist/doctor.js +1 -1
- 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/performance-notes.md +55 -0
- package/docs/release-v0.6.1.md +75 -0
- package/package.json +3 -2
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/lifecycle-smoke.js +336 -2
- package/scripts/mcp-manifest-check.js +30 -7
- package/scripts/mcp-smoke.js +11 -8
- package/scripts/pack-clean.js +157 -1
- package/scripts/unit-tests.js +36 -0
- package/src/doctor.ts +1 -1
- 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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { getTasksDir, getConfig } from "../config.js";
|
|
4
|
+
import { guardReadPath } from "../security/pathGuard.js";
|
|
5
|
+
import { guardSensitivePath } from "../security/sensitiveGuard.js";
|
|
6
|
+
import { readTaskRuntime } from "../taskRuntime.js";
|
|
7
|
+
import { readWatcherStatus } from "../watcherStatus.js";
|
|
8
|
+
export function safeStatus(taskId, config) {
|
|
9
|
+
const cfg = config || getConfig();
|
|
10
|
+
const tasksDir = getTasksDir(cfg);
|
|
11
|
+
const taskDir = resolve(tasksDir, taskId);
|
|
12
|
+
const statusFile = join(taskDir, "status.json");
|
|
13
|
+
// Check existence BEFORE guardReadPath so that a non-existent task
|
|
14
|
+
// returns a structured not_found response instead of throwing.
|
|
15
|
+
if (!existsSync(statusFile)) {
|
|
16
|
+
return {
|
|
17
|
+
task_id: taskId,
|
|
18
|
+
status: "not_found",
|
|
19
|
+
phase: null,
|
|
20
|
+
created_at: null,
|
|
21
|
+
started_at: null,
|
|
22
|
+
updated_at: null,
|
|
23
|
+
finished_at: null,
|
|
24
|
+
last_heartbeat_at: null,
|
|
25
|
+
current_command: null,
|
|
26
|
+
verify_status: null,
|
|
27
|
+
artifact_status: null,
|
|
28
|
+
watcher_state: null,
|
|
29
|
+
error_code: null,
|
|
30
|
+
error_summary: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
guardReadPath(statusFile, cfg.workspaceRoot, cfg.tasksDir);
|
|
34
|
+
guardSensitivePath(statusFile);
|
|
35
|
+
const raw = readFileSync(statusFile, "utf-8");
|
|
36
|
+
const status = JSON.parse(raw);
|
|
37
|
+
const runtime = readTaskRuntime(taskDir);
|
|
38
|
+
const phase = (runtime.phase || status.phase || "queued");
|
|
39
|
+
const watcher = readWatcherStatus(cfg);
|
|
40
|
+
// Extract short error summary — never the full error text
|
|
41
|
+
let errorCode = null;
|
|
42
|
+
let errorSummary = null;
|
|
43
|
+
if (status.error && typeof status.error === "string") {
|
|
44
|
+
const errorStr = status.error;
|
|
45
|
+
// Keep only the first 200 chars as summary
|
|
46
|
+
errorSummary = errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
|
|
47
|
+
// Try to extract an error code from the status
|
|
48
|
+
if (typeof status.status === "string" && status.status.startsWith("failed")) {
|
|
49
|
+
errorCode = status.status;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (typeof status.status === "string" && status.status.startsWith("failed")) {
|
|
53
|
+
errorCode = status.status;
|
|
54
|
+
errorSummary = `Task ended with status: ${status.status}`;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
task_id: taskId,
|
|
58
|
+
status: status.status || "not_found",
|
|
59
|
+
phase,
|
|
60
|
+
created_at: typeof status.created_at === "string" ? status.created_at : null,
|
|
61
|
+
started_at: typeof status.started_at === "string" ? status.started_at : null,
|
|
62
|
+
updated_at: typeof status.updated_at === "string" ? status.updated_at : null,
|
|
63
|
+
finished_at: typeof status.finished_at === "string" ? status.finished_at : null,
|
|
64
|
+
last_heartbeat_at: runtime.last_heartbeat_at || (typeof status.last_heartbeat_at === "string" ? status.last_heartbeat_at : null),
|
|
65
|
+
current_command: runtime.current_command ?? (typeof status.current_command === "string" ? status.current_command : null) ?? null,
|
|
66
|
+
verify_status: typeof status.verify_status === "string" ? status.verify_status : null,
|
|
67
|
+
artifact_status: typeof status.artifact_status === "string" ? status.artifact_status : null,
|
|
68
|
+
watcher_state: watcher.status,
|
|
69
|
+
error_code: errorCode,
|
|
70
|
+
error_summary: errorSummary,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type PatchWardenConfig } from "../config.js";
|
|
2
|
+
export interface SyncFileResult {
|
|
3
|
+
source_path: string;
|
|
4
|
+
target_path: string;
|
|
5
|
+
before_target_sha256: string | null;
|
|
6
|
+
after_target_sha256: string;
|
|
7
|
+
source_sha256: string;
|
|
8
|
+
copied_bytes: number;
|
|
9
|
+
changed: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Copy a file from source to target within the same session repo.
|
|
13
|
+
* Both source and target must be inside the session's repo_path.
|
|
14
|
+
*/
|
|
15
|
+
export declare function syncFile(sessionId: string, sourcePath: string, targetPath: string, options?: {
|
|
16
|
+
expected_source_sha256?: string;
|
|
17
|
+
expected_target_sha256?: string;
|
|
18
|
+
}, config?: PatchWardenConfig): SyncFileResult;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { PatchWardenError } from "../errors.js";
|
|
4
|
+
import { getConfig } from "../config.js";
|
|
5
|
+
import { guardDirectPath, guardDirectWritePath } from "../direct/directGuards.js";
|
|
6
|
+
import { guardSensitivePath } from "../security/sensitiveGuard.js";
|
|
7
|
+
import { computeFileSha256 } from "../direct/directPatch.js";
|
|
8
|
+
/**
|
|
9
|
+
* Copy a file from source to target within the same session repo.
|
|
10
|
+
* Both source and target must be inside the session's repo_path.
|
|
11
|
+
*/
|
|
12
|
+
export function syncFile(sessionId, sourcePath, targetPath, options, config) {
|
|
13
|
+
const cfg = config || getConfig();
|
|
14
|
+
const sessionsDir = resolve(cfg.workspaceRoot, cfg.directSessionsDir);
|
|
15
|
+
// Load session to get repo_path
|
|
16
|
+
const sessionFile = resolve(sessionsDir, sessionId, "session.json");
|
|
17
|
+
if (!existsSync(sessionFile)) {
|
|
18
|
+
throw new PatchWardenError("direct_session_not_found", `Direct session "${sessionId}" not found.`, "Create a direct session first using direct_start_session.", true, { session_id: sessionId });
|
|
19
|
+
}
|
|
20
|
+
const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
|
|
21
|
+
const repoPath = resolve(session.repo_path);
|
|
22
|
+
const workspaceRoot = cfg.workspaceRoot;
|
|
23
|
+
// Guard source path — must be inside repo
|
|
24
|
+
const resolvedSource = guardDirectPath(sourcePath, repoPath, workspaceRoot);
|
|
25
|
+
guardSensitivePath(resolvedSource);
|
|
26
|
+
if (!existsSync(resolvedSource)) {
|
|
27
|
+
throw new PatchWardenError("source_file_not_found", `Source file does not exist: "${sourcePath}".`, "Ensure the source path is correct.", true, { source_path: sourcePath });
|
|
28
|
+
}
|
|
29
|
+
// Guard target path — must be inside repo, not in blocked dirs
|
|
30
|
+
const resolvedTarget = guardDirectWritePath(targetPath, repoPath, workspaceRoot);
|
|
31
|
+
guardSensitivePath(resolvedTarget);
|
|
32
|
+
// Verify source sha256 if provided
|
|
33
|
+
const sourceSha256 = computeFileSha256(resolvedSource);
|
|
34
|
+
if (options?.expected_source_sha256 && options.expected_source_sha256 !== sourceSha256) {
|
|
35
|
+
throw new PatchWardenError("source_hash_mismatch", `Source file hash mismatch. Expected "${options.expected_source_sha256}" but got "${sourceSha256}".`, "Re-read the source file to get the current sha256.", true, { expected_sha256: options.expected_source_sha256, actual_sha256: sourceSha256 });
|
|
36
|
+
}
|
|
37
|
+
// Get target sha256 before copy
|
|
38
|
+
let beforeTargetSha256 = null;
|
|
39
|
+
if (existsSync(resolvedTarget)) {
|
|
40
|
+
beforeTargetSha256 = computeFileSha256(resolvedTarget);
|
|
41
|
+
// Verify target sha256 if provided
|
|
42
|
+
if (options?.expected_target_sha256 && options.expected_target_sha256 !== beforeTargetSha256) {
|
|
43
|
+
throw new PatchWardenError("target_hash_mismatch", `Target file hash mismatch. Expected "${options.expected_target_sha256}" but got "${beforeTargetSha256}".`, "Re-read the target file to get the current sha256.", true, { expected_sha256: options.expected_target_sha256, actual_sha256: beforeTargetSha256 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Read source content
|
|
47
|
+
const sourceContent = readFileSync(resolvedSource);
|
|
48
|
+
const copiedBytes = sourceContent.length;
|
|
49
|
+
// Create target directory if needed
|
|
50
|
+
mkdirSync(dirname(resolvedTarget), { recursive: true });
|
|
51
|
+
// Write to target
|
|
52
|
+
writeFileSync(resolvedTarget, sourceContent, "utf-8");
|
|
53
|
+
// Compute after hash
|
|
54
|
+
const afterTargetSha256 = computeFileSha256(resolvedTarget);
|
|
55
|
+
const changed = beforeTargetSha256 !== afterTargetSha256;
|
|
56
|
+
return {
|
|
57
|
+
source_path: sourcePath,
|
|
58
|
+
target_path: targetPath,
|
|
59
|
+
before_target_sha256: beforeTargetSha256,
|
|
60
|
+
after_target_sha256: afterTargetSha256,
|
|
61
|
+
source_sha256: sourceSha256,
|
|
62
|
+
copied_bytes: copiedBytes,
|
|
63
|
+
changed,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -14,7 +14,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
|
|
|
14
14
|
lines?: number;
|
|
15
15
|
redact?: boolean;
|
|
16
16
|
}): {
|
|
17
|
-
file: "
|
|
17
|
+
file: "test" | "stdout" | "stderr" | "verify";
|
|
18
18
|
lines: number;
|
|
19
19
|
total_bytes: number;
|
|
20
20
|
task_id: string;
|
|
@@ -37,7 +37,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
|
|
|
37
37
|
truncated?: undefined;
|
|
38
38
|
} | {
|
|
39
39
|
task_id: string;
|
|
40
|
-
file: "
|
|
40
|
+
file: "test" | "stdout" | "stderr" | "verify";
|
|
41
41
|
filename: string;
|
|
42
42
|
content: string;
|
|
43
43
|
available: boolean;
|
|
@@ -12,8 +12,8 @@ export interface ToolCatalogSnapshot {
|
|
|
12
12
|
tool_names: string[];
|
|
13
13
|
tool_manifest_sha256: string;
|
|
14
14
|
}
|
|
15
|
-
export declare const CHATGPT_CORE_TOOL_NAMES: readonly ["health_check", "list_agents", "list_workspace", "read_workspace_file", "save_plan", "create_task", "wait_for_task", "get_task_summary", "get_diff", "get_result", "get_result_json", "get_test_log", "get_task_status", "list_tasks", "cancel_task", "audit_task"];
|
|
16
|
-
export declare const CHATGPT_DIRECT_TOOL_NAMES: readonly ["health_check", "list_workspace", "create_direct_session", "search_workspace", "read_workspace_file", "apply_patch", "run_verification", "finalize_direct_session", "audit_session"];
|
|
15
|
+
export declare const CHATGPT_CORE_TOOL_NAMES: readonly ["health_check", "list_agents", "list_workspace", "read_workspace_file", "save_plan", "create_task", "wait_for_task", "get_task_summary", "get_diff", "get_result", "get_result_json", "get_test_log", "get_task_status", "list_tasks", "cancel_task", "audit_task", "safe_status"];
|
|
16
|
+
export declare const CHATGPT_DIRECT_TOOL_NAMES: readonly ["health_check", "list_workspace", "create_direct_session", "search_workspace", "read_workspace_file", "apply_patch", "run_verification", "finalize_direct_session", "audit_session", "sync_file"];
|
|
17
17
|
export declare function resolveToolProfile(configProfile?: string): ToolProfile;
|
|
18
18
|
export declare function selectToolsForProfile<T extends CatalogTool>(tools: T[], profile: ToolProfile, enableDirectProfile?: boolean): T[];
|
|
19
19
|
export declare function buildToolCatalogSnapshot(tools: CatalogTool[], profile: ToolProfile): ToolCatalogSnapshot;
|
|
@@ -17,6 +17,7 @@ export const CHATGPT_CORE_TOOL_NAMES = [
|
|
|
17
17
|
"list_tasks",
|
|
18
18
|
"cancel_task",
|
|
19
19
|
"audit_task",
|
|
20
|
+
"safe_status",
|
|
20
21
|
];
|
|
21
22
|
export const CHATGPT_DIRECT_TOOL_NAMES = [
|
|
22
23
|
"health_check",
|
|
@@ -28,6 +29,7 @@ export const CHATGPT_DIRECT_TOOL_NAMES = [
|
|
|
28
29
|
"run_verification",
|
|
29
30
|
"finalize_direct_session",
|
|
30
31
|
"audit_session",
|
|
32
|
+
"sync_file",
|
|
31
33
|
];
|
|
32
34
|
let lastSnapshot = null;
|
|
33
35
|
export function resolveToolProfile(configProfile) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const PATCHWARDEN_VERSION = "0.6.
|
|
2
|
-
export declare const TOOL_SCHEMA_EPOCH = "2026-06-
|
|
1
|
+
export declare const PATCHWARDEN_VERSION = "0.6.1";
|
|
2
|
+
export declare const TOOL_SCHEMA_EPOCH = "2026-06-24-v7";
|
package/dist/version.js
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.1";
|
|
2
|
+
export const TOOL_SCHEMA_EPOCH = "2026-06-24-v7";
|
package/dist/watcherStatus.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface WatcherStatusSnapshot {
|
|
|
11
11
|
instance_id: string | null;
|
|
12
12
|
launcher_pid: number | null;
|
|
13
13
|
reason: string | null;
|
|
14
|
+
activity: string | null;
|
|
14
15
|
}
|
|
15
16
|
export declare function getWatcherHeartbeatPath(config?: PatchWardenConfig): string;
|
|
16
17
|
export declare function readWatcherStatus(config?: PatchWardenConfig, nowMs?: number): WatcherStatusSnapshot;
|
package/dist/watcherStatus.js
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 } from "./config.js";
|
|
4
4
|
export function getWatcherHeartbeatPath(config = getConfig()) {
|
|
@@ -8,6 +8,22 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
|
|
|
8
8
|
const staleAfterSeconds = config.watcherStaleSeconds;
|
|
9
9
|
const heartbeatPath = getWatcherHeartbeatPath(config);
|
|
10
10
|
if (!existsSync(heartbeatPath)) {
|
|
11
|
+
// Even if watcher heartbeat is missing, check if a task is actively running
|
|
12
|
+
const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
|
|
13
|
+
if (taskFallback) {
|
|
14
|
+
return {
|
|
15
|
+
status: "healthy",
|
|
16
|
+
available: true,
|
|
17
|
+
stale_after_seconds: staleAfterSeconds,
|
|
18
|
+
last_heartbeat_at: taskFallback.heartbeat_at,
|
|
19
|
+
heartbeat_age_seconds: taskFallback.age_seconds,
|
|
20
|
+
heartbeat_pid: null,
|
|
21
|
+
instance_id: null,
|
|
22
|
+
launcher_pid: null,
|
|
23
|
+
reason: null,
|
|
24
|
+
activity: taskFallback.activity,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
11
27
|
return {
|
|
12
28
|
status: "missing",
|
|
13
29
|
available: false,
|
|
@@ -18,6 +34,7 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
|
|
|
18
34
|
instance_id: null,
|
|
19
35
|
launcher_pid: null,
|
|
20
36
|
reason: "Watcher heartbeat has not been observed. Start or restart the PatchWarden watcher.",
|
|
37
|
+
activity: null,
|
|
21
38
|
};
|
|
22
39
|
}
|
|
23
40
|
try {
|
|
@@ -29,16 +46,47 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
|
|
|
29
46
|
const ageMs = Math.max(0, nowMs - heartbeatMs);
|
|
30
47
|
const ageSeconds = Math.round(ageMs / 1000);
|
|
31
48
|
const healthy = ageMs < staleAfterSeconds * 1000;
|
|
49
|
+
if (healthy) {
|
|
50
|
+
return {
|
|
51
|
+
status: "healthy",
|
|
52
|
+
available: true,
|
|
53
|
+
stale_after_seconds: staleAfterSeconds,
|
|
54
|
+
last_heartbeat_at: String(data.last_heartbeat_at),
|
|
55
|
+
heartbeat_age_seconds: ageSeconds,
|
|
56
|
+
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
57
|
+
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
58
|
+
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
59
|
+
reason: null,
|
|
60
|
+
activity: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Watcher heartbeat is stale — check if a task is actively running
|
|
64
|
+
const taskFallback = checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds);
|
|
65
|
+
if (taskFallback) {
|
|
66
|
+
return {
|
|
67
|
+
status: "healthy",
|
|
68
|
+
available: true,
|
|
69
|
+
stale_after_seconds: staleAfterSeconds,
|
|
70
|
+
last_heartbeat_at: taskFallback.heartbeat_at,
|
|
71
|
+
heartbeat_age_seconds: taskFallback.age_seconds,
|
|
72
|
+
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
73
|
+
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
74
|
+
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
75
|
+
reason: null,
|
|
76
|
+
activity: taskFallback.activity,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
32
79
|
return {
|
|
33
|
-
status:
|
|
34
|
-
available:
|
|
80
|
+
status: "stale",
|
|
81
|
+
available: false,
|
|
35
82
|
stale_after_seconds: staleAfterSeconds,
|
|
36
83
|
last_heartbeat_at: String(data.last_heartbeat_at),
|
|
37
84
|
heartbeat_age_seconds: ageSeconds,
|
|
38
85
|
heartbeat_pid: Number.isInteger(Number(data.pid)) ? Number(data.pid) : null,
|
|
39
86
|
instance_id: typeof data.instance_id === "string" ? data.instance_id : null,
|
|
40
87
|
launcher_pid: Number.isInteger(Number(data.launcher_pid)) ? Number(data.launcher_pid) : null,
|
|
41
|
-
reason:
|
|
88
|
+
reason: "Watcher heartbeat is stale. Restart the PatchWarden watcher.",
|
|
89
|
+
activity: null,
|
|
42
90
|
};
|
|
43
91
|
}
|
|
44
92
|
catch {
|
|
@@ -52,9 +100,53 @@ export function readWatcherStatus(config = getConfig(), nowMs = Date.now()) {
|
|
|
52
100
|
instance_id: null,
|
|
53
101
|
launcher_pid: null,
|
|
54
102
|
reason: "Watcher heartbeat file is unreadable.",
|
|
103
|
+
activity: null,
|
|
55
104
|
};
|
|
56
105
|
}
|
|
57
106
|
}
|
|
107
|
+
function checkRunningTaskHeartbeat(config, nowMs, staleAfterSeconds) {
|
|
108
|
+
const tasksDir = getTasksDir(config);
|
|
109
|
+
if (!existsSync(tasksDir))
|
|
110
|
+
return null;
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (!entry.isDirectory())
|
|
120
|
+
continue;
|
|
121
|
+
const taskDir = join(tasksDir, entry.name);
|
|
122
|
+
const statusFile = join(taskDir, "status.json");
|
|
123
|
+
const runtimeFile = join(taskDir, "runtime.json");
|
|
124
|
+
if (!existsSync(statusFile) || !existsSync(runtimeFile))
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
const status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
128
|
+
if (status.status !== "running")
|
|
129
|
+
continue;
|
|
130
|
+
const runtime = JSON.parse(readFileSync(runtimeFile, "utf-8"));
|
|
131
|
+
const heartbeatAt = String(runtime.last_heartbeat_at || "");
|
|
132
|
+
const heartbeatMs = Date.parse(heartbeatAt);
|
|
133
|
+
if (!Number.isFinite(heartbeatMs))
|
|
134
|
+
continue;
|
|
135
|
+
const ageMs = Math.max(0, nowMs - heartbeatMs);
|
|
136
|
+
if (ageMs < staleAfterSeconds * 1000) {
|
|
137
|
+
return {
|
|
138
|
+
heartbeat_at: heartbeatAt,
|
|
139
|
+
age_seconds: Math.round(ageMs / 1000),
|
|
140
|
+
activity: `watcher busy executing task ${entry.name}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
58
150
|
export function derivePendingReason(task, watcher) {
|
|
59
151
|
if (task.status === "pending") {
|
|
60
152
|
if (watcher.status === "stale")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# PatchWarden Performance Notes
|
|
2
|
+
|
|
3
|
+
## Current State (v0.6.1)
|
|
4
|
+
|
|
5
|
+
### Identified Optimization Opportunities
|
|
6
|
+
|
|
7
|
+
The following performance optimizations have been identified for future implementation. They are documented here rather than implemented in v0.6.1 to avoid introducing risk to the core task execution path.
|
|
8
|
+
|
|
9
|
+
### 1. Async Git Operations in changeCapture.ts
|
|
10
|
+
|
|
11
|
+
**Current**: `runGit()` uses `spawnSync()` which blocks the event loop during git operations.
|
|
12
|
+
|
|
13
|
+
**Proposed**: Replace with `execFile()` (async) and use `Promise.all()` for independent git queries in `captureRepoSnapshot()`.
|
|
14
|
+
|
|
15
|
+
**Risk**: Medium — changes the core change capture path. Requires careful testing of all snapshot/diff/scope scenarios.
|
|
16
|
+
|
|
17
|
+
**Expected Benefit**: ~200-500ms improvement per task for repos with many files, as multiple git queries can run concurrently.
|
|
18
|
+
|
|
19
|
+
### 2. Streaming File Hash
|
|
20
|
+
|
|
21
|
+
**Current**: `computeFileSha256()` reads the entire file into memory with `readFileSync()`.
|
|
22
|
+
|
|
23
|
+
**Proposed**: Use `createReadStream()` + `crypto.createHash()` for streaming hash computation.
|
|
24
|
+
|
|
25
|
+
**Risk**: Low — isolated to hash computation, no semantic change.
|
|
26
|
+
|
|
27
|
+
**Expected Benefit**: Reduces peak memory usage for large files (e.g., release artifacts > 5MB).
|
|
28
|
+
|
|
29
|
+
### 3. Workspace Snapshot Caching
|
|
30
|
+
|
|
31
|
+
**Current**: `walkWorkspace()` traverses the entire workspace directory tree on every snapshot.
|
|
32
|
+
|
|
33
|
+
**Proposed**: Cache the directory listing and invalidate on file modification events.
|
|
34
|
+
|
|
35
|
+
**Risk**: High — requires file system watchers and cache invalidation logic. Could introduce stale data bugs.
|
|
36
|
+
|
|
37
|
+
**Expected Benefit**: ~100-1000ms improvement for large workspaces with many repos.
|
|
38
|
+
|
|
39
|
+
### 4. Parallel Task Status Reads
|
|
40
|
+
|
|
41
|
+
**Current**: `listTasks()` reads each task's `status.json` sequentially.
|
|
42
|
+
|
|
43
|
+
**Proposed**: Use `Promise.all()` to read multiple task status files concurrently.
|
|
44
|
+
|
|
45
|
+
**Risk**: Low — read-only operation, no semantic change.
|
|
46
|
+
|
|
47
|
+
**Expected Benefit**: ~50-200ms improvement when listing 10+ tasks.
|
|
48
|
+
|
|
49
|
+
## Decision for v0.6.1
|
|
50
|
+
|
|
51
|
+
All four optimizations are deferred to a future release. The current synchronous implementation is correct and well-tested. Changing the core paths would require extensive regression testing that is beyond the scope of this stability release.
|
|
52
|
+
|
|
53
|
+
## Monitoring
|
|
54
|
+
|
|
55
|
+
The structured logging module (`src/logging.ts`) added in v0.6.1 provides the infrastructure to measure tool call durations (`duration_ms` in audit logs). Once deployed, real-world timing data can be collected to prioritize which optimization to implement first.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# PatchWarden v0.6.1 Release Notes
|
|
2
|
+
|
|
3
|
+
**Release Date**: 2026-06-25
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
PatchWarden v0.6.1 is a stability and observability release. It addresses watcher heartbeat false positives, adds Chinese path reliability, introduces two new tools (`safe_status` and `sync_file`), and adds comprehensive security guard unit tests.
|
|
8
|
+
|
|
9
|
+
## New Features
|
|
10
|
+
|
|
11
|
+
### safe_status Tool
|
|
12
|
+
|
|
13
|
+
A minimal task lifecycle status tool that returns task state without exposing diff, log content, or file contents. Useful when upper-layer security policies block content-bearing tools.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"task_id": "task-001",
|
|
18
|
+
"status": "running",
|
|
19
|
+
"phase": "executing_agent",
|
|
20
|
+
"last_heartbeat_at": "2026-06-24T10:00:12Z",
|
|
21
|
+
"current_command": "codex exec",
|
|
22
|
+
"watcher_state": "healthy"
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### sync_file Tool
|
|
27
|
+
|
|
28
|
+
Copy a file from source to target within a Direct session repo. Both paths must be inside the session's `repo_path`. Supports optional sha256 verification for both source and target.
|
|
29
|
+
|
|
30
|
+
### Android Build Doctor
|
|
31
|
+
|
|
32
|
+
When a managed project contains an `android_app` directory, the health check now includes Android build environment diagnostics: Java version, JAVA_HOME, ANDROID_HOME, SDK platform, build-tools, Gradle wrapper, and APK output path.
|
|
33
|
+
|
|
34
|
+
## Bug Fixes
|
|
35
|
+
|
|
36
|
+
- **Watcher stale false positive**: Long-running tasks no longer cause the watcher to be incorrectly reported as stale. The watcher status now falls back to checking running task heartbeats.
|
|
37
|
+
- **Chinese path encoding**: All file I/O operations verified to use UTF-8 encoding. Health check now reports `path_encoding` status.
|
|
38
|
+
- **Tracked external dirty detection**: `captureRepoSnapshot` now correctly identifies tracked-but-modified external files via `dirty_paths` field parsed from `git status --porcelain`. The regex now includes `R` (rename) status, fixing dead code that previously never executed for renamed files.
|
|
39
|
+
- **Release zip path separators**: The packaging script (`pack-clean.js`) now creates zip archives with POSIX `/` path separators instead of Windows backslashes. Linux `unzip` no longer reports `warning: appears to use backslashes as path separators`.
|
|
40
|
+
|
|
41
|
+
## Security
|
|
42
|
+
|
|
43
|
+
- 136 unit tests covering all security guards (path guard, sensitive guard, command guard, direct guards)
|
|
44
|
+
- Tests use Node's built-in `node:test` — zero new dependencies
|
|
45
|
+
|
|
46
|
+
## Observability
|
|
47
|
+
|
|
48
|
+
- Structured JSON logging to stderr (never stdout, to avoid polluting MCP JSON-RPC)
|
|
49
|
+
- Tool call audit logs with duration tracking
|
|
50
|
+
- Global `unhandledRejection` and `uncaughtException` handlers
|
|
51
|
+
|
|
52
|
+
## Tool Profile Changes
|
|
53
|
+
|
|
54
|
+
| Profile | v0.6.0 | v0.6.1 |
|
|
55
|
+
|---------|--------|--------|
|
|
56
|
+
| full | 28 | 30 |
|
|
57
|
+
| chatgpt_core | 16 | 17 |
|
|
58
|
+
| chatgpt_direct | 9 | 10 |
|
|
59
|
+
|
|
60
|
+
## Migration
|
|
61
|
+
|
|
62
|
+
No breaking changes. All new fields are optional. New tools are additive and do not affect existing tools.
|
|
63
|
+
|
|
64
|
+
## Verification
|
|
65
|
+
|
|
66
|
+
- TypeScript compilation: PASS
|
|
67
|
+
- Unit tests: 136 pass, 0 fail, 1 skipped (Windows symlink)
|
|
68
|
+
- Smoke tests: 139 pass, 0 fail
|
|
69
|
+
- Lifecycle tests: 22 pass, 0 fail (includes tracked external file rename regression test)
|
|
70
|
+
- Doctor CI: PASS (81 OK, 0 WARN, 0 FAIL on the release check host)
|
|
71
|
+
- Tool manifest check: PASS
|
|
72
|
+
- Package manifest check: PASS (260 files)
|
|
73
|
+
- Brand check: PASS (128 tracked files)
|
|
74
|
+
- MCP HTTP tests: 13 pass, 0 fail
|
|
75
|
+
- Release zip: POSIX path separators verified (261 entries, no backslashes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchwarden",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A secure local MCP bridge: clients save plans, local agents execute tasks, and results are returned through workspace-scoped files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"watch": "node dist/runner/watch.js",
|
|
35
35
|
"doctor": "node dist/doctor.js",
|
|
36
36
|
"doctor:ci": "node dist/doctor.js --allow-default-config",
|
|
37
|
-
"test": "node
|
|
37
|
+
"test:unit": "node scripts/unit-tests.js",
|
|
38
|
+
"test": "node dist/smoke-test.js && node scripts/unit-tests.js && node scripts/lifecycle-smoke.js && node scripts/doctor-smoke.js && node scripts/tunnel-supervisor-smoke.js && node scripts/watcher-supervisor-smoke.js && node scripts/control-smoke.js && node scripts/mcp-manifest-check.js && node scripts/brand-check.js",
|
|
38
39
|
"test:lifecycle": "npm run build && node scripts/lifecycle-smoke.js",
|
|
39
40
|
"test:doctor": "npm run build && node scripts/doctor-smoke.js",
|
|
40
41
|
"test:tunnel-supervisor": "npm run build && node scripts/tunnel-supervisor-smoke.js",
|
|
@@ -139,7 +139,7 @@ try {
|
|
|
139
139
|
serverStderr += chunk.toString();
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
await sleep(
|
|
142
|
+
await sleep(3000);
|
|
143
143
|
if (serverProcess.exitCode !== null) {
|
|
144
144
|
throw new Error(`HTTP server exited early: ${serverStderr}`);
|
|
145
145
|
}
|
|
@@ -274,7 +274,15 @@ try {
|
|
|
274
274
|
stdio: ["ignore", "ignore", "pipe"],
|
|
275
275
|
});
|
|
276
276
|
serverProcess.stderr.on("data", (chunk) => { serverStderr += chunk.toString(); });
|
|
277
|
-
await sleep(
|
|
277
|
+
await sleep(3000);
|
|
278
|
+
// Wait for server to be ready
|
|
279
|
+
for (let i = 0; i < 10; i++) {
|
|
280
|
+
try {
|
|
281
|
+
const r = await fetch(`${mcpUrl}/healthz`);
|
|
282
|
+
if (r.status === 200) break;
|
|
283
|
+
} catch {}
|
|
284
|
+
await sleep(500);
|
|
285
|
+
}
|
|
278
286
|
|
|
279
287
|
await test("token: no token returns 401", async () => {
|
|
280
288
|
try {
|