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.
Files changed (81) hide show
  1. package/README.md +3 -3
  2. package/dist/doctor.js +1 -1
  3. package/dist/logging.d.ts +52 -0
  4. package/dist/logging.js +123 -0
  5. package/dist/runner/changeCapture.d.ts +41 -0
  6. package/dist/runner/changeCapture.js +171 -1
  7. package/dist/runner/runTask.js +204 -24
  8. package/dist/smoke-test.js +8 -8
  9. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  10. package/dist/test/unit/android-doctor.test.js +118 -0
  11. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  12. package/dist/test/unit/chinese-path.test.js +91 -0
  13. package/dist/test/unit/command-guard.test.d.ts +1 -0
  14. package/dist/test/unit/command-guard.test.js +160 -0
  15. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  16. package/dist/test/unit/direct-guards.test.js +213 -0
  17. package/dist/test/unit/logging.test.d.ts +1 -0
  18. package/dist/test/unit/logging.test.js +275 -0
  19. package/dist/test/unit/path-guard.test.d.ts +1 -0
  20. package/dist/test/unit/path-guard.test.js +109 -0
  21. package/dist/test/unit/safe-status.test.d.ts +1 -0
  22. package/dist/test/unit/safe-status.test.js +165 -0
  23. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  24. package/dist/test/unit/sensitive-guard.test.js +104 -0
  25. package/dist/test/unit/sync-file.test.d.ts +1 -0
  26. package/dist/test/unit/sync-file.test.js +154 -0
  27. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  28. package/dist/test/unit/watcher-status.test.js +169 -0
  29. package/dist/tools/androidDoctor.d.ts +38 -0
  30. package/dist/tools/androidDoctor.js +391 -0
  31. package/dist/tools/auditTask.js +11 -5
  32. package/dist/tools/getTaskSummary.d.ts +3 -0
  33. package/dist/tools/getTaskSummary.js +15 -1
  34. package/dist/tools/healthCheck.d.ts +5 -0
  35. package/dist/tools/healthCheck.js +21 -0
  36. package/dist/tools/registry.js +53 -0
  37. package/dist/tools/safeStatus.d.ts +19 -0
  38. package/dist/tools/safeStatus.js +72 -0
  39. package/dist/tools/syncFile.d.ts +18 -0
  40. package/dist/tools/syncFile.js +65 -0
  41. package/dist/tools/taskOutputs.d.ts +2 -2
  42. package/dist/tools/toolCatalog.d.ts +2 -2
  43. package/dist/tools/toolCatalog.js +2 -0
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +2 -2
  46. package/dist/watcherStatus.d.ts +1 -0
  47. package/dist/watcherStatus.js +96 -4
  48. package/docs/performance-notes.md +55 -0
  49. package/docs/release-v0.6.1.md +75 -0
  50. package/package.json +3 -2
  51. package/scripts/http-mcp-smoke.js +10 -2
  52. package/scripts/lifecycle-smoke.js +336 -2
  53. package/scripts/mcp-manifest-check.js +30 -7
  54. package/scripts/mcp-smoke.js +11 -8
  55. package/scripts/pack-clean.js +157 -1
  56. package/scripts/unit-tests.js +36 -0
  57. package/src/doctor.ts +1 -1
  58. package/src/logging.ts +152 -0
  59. package/src/runner/changeCapture.ts +212 -1
  60. package/src/runner/runTask.ts +220 -22
  61. package/src/smoke-test.ts +5 -5
  62. package/src/test/unit/android-doctor.test.ts +158 -0
  63. package/src/test/unit/chinese-path.test.ts +106 -0
  64. package/src/test/unit/command-guard.test.ts +221 -0
  65. package/src/test/unit/direct-guards.test.ts +297 -0
  66. package/src/test/unit/logging.test.ts +325 -0
  67. package/src/test/unit/path-guard.test.ts +150 -0
  68. package/src/test/unit/safe-status.test.ts +187 -0
  69. package/src/test/unit/sensitive-guard.test.ts +124 -0
  70. package/src/test/unit/sync-file.test.ts +231 -0
  71. package/src/test/unit/watcher-status.test.ts +190 -0
  72. package/src/tools/androidDoctor.ts +424 -0
  73. package/src/tools/auditTask.ts +11 -5
  74. package/src/tools/getTaskSummary.ts +22 -1
  75. package/src/tools/healthCheck.ts +22 -0
  76. package/src/tools/registry.ts +63 -0
  77. package/src/tools/safeStatus.ts +96 -0
  78. package/src/tools/syncFile.ts +122 -0
  79. package/src/tools/toolCatalog.ts +2 -0
  80. package/src/version.ts +2 -2
  81. package/src/watcherStatus.ts +101 -4
@@ -0,0 +1,124 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { isSensitivePath, guardSensitivePath } from "../../security/sensitiveGuard.js";
4
+ import { PatchWardenError } from "../../errors.js";
5
+
6
+ describe("isSensitivePath", () => {
7
+ it("blocks .env files", () => {
8
+ assert.equal(isSensitivePath(".env"), true);
9
+ assert.equal(isSensitivePath("path/to/.env"), true);
10
+ assert.equal(isSensitivePath(".env.production"), true);
11
+ assert.equal(isSensitivePath(".env.local"), true);
12
+ });
13
+
14
+ it("blocks .ENV case insensitive", () => {
15
+ assert.equal(isSensitivePath(".ENV"), true);
16
+ assert.equal(isSensitivePath("path/to/.ENV"), true);
17
+ assert.equal(isSensitivePath(".Env.Production"), true);
18
+ });
19
+
20
+ it("blocks config.json case insensitive", () => {
21
+ assert.equal(isSensitivePath("config.json"), true);
22
+ assert.equal(isSensitivePath("Config.json"), true);
23
+ assert.equal(isSensitivePath("CONFIG.JSON"), true);
24
+ assert.equal(isSensitivePath("path/to/Config.json"), true);
25
+ });
26
+
27
+ it("blocks SSH keys", () => {
28
+ assert.equal(isSensitivePath("id_rsa"), true);
29
+ assert.equal(isSensitivePath("id_ed25519"), true);
30
+ assert.equal(isSensitivePath("id_ecdsa"), true);
31
+ assert.equal(isSensitivePath("path/to/id_rsa"), true);
32
+ assert.equal(isSensitivePath(".ssh/id_rsa"), true);
33
+ });
34
+
35
+ it("blocks credentials and tokens", () => {
36
+ assert.equal(isSensitivePath("credentials"), true);
37
+ assert.equal(isSensitivePath("path/to/credentials.json"), true);
38
+ assert.equal(isSensitivePath("token.txt"), true);
39
+ // access_token does NOT match because the pattern requires token at start or after /
40
+ assert.equal(isSensitivePath("access_token"), false);
41
+ assert.equal(isSensitivePath("path/to/token"), true);
42
+ assert.equal(isSensitivePath(".netrc"), true);
43
+ assert.equal(isSensitivePath(".npmrc"), true);
44
+ assert.equal(isSensitivePath(".pypirc"), true);
45
+ });
46
+
47
+ it("blocks private key files", () => {
48
+ assert.equal(isSensitivePath("server.pem"), true);
49
+ assert.equal(isSensitivePath("private.key"), true);
50
+ assert.equal(isSensitivePath("cert.pfx"), true);
51
+ assert.equal(isSensitivePath("cert.p12"), true);
52
+ });
53
+
54
+ it("blocks browser data files", () => {
55
+ assert.equal(isSensitivePath("cookies"), true);
56
+ assert.equal(isSensitivePath("cookies.db"), true);
57
+ assert.equal(isSensitivePath("Web Data"), true);
58
+ assert.equal(isSensitivePath("Login Data"), true);
59
+ assert.equal(isSensitivePath("Local State"), true);
60
+ });
61
+
62
+ it("blocks .git-credentials", () => {
63
+ assert.equal(isSensitivePath(".git-credentials"), true);
64
+ assert.equal(isSensitivePath("path/to/.git-credentials"), true);
65
+ });
66
+
67
+ it("blocks docker and kube config", () => {
68
+ assert.equal(isSensitivePath(".docker/config.json"), true);
69
+ assert.equal(isSensitivePath(".kube/config"), true);
70
+ });
71
+
72
+ it("allows .patchwarden safe prefix", () => {
73
+ assert.equal(isSensitivePath(".patchwarden/tasks/task-001/status.json"), false);
74
+ assert.equal(isSensitivePath(".patchwarden/config.json"), false);
75
+ assert.equal(isSensitivePath(".patchwarden"), false);
76
+ });
77
+
78
+ it("allows non-sensitive files", () => {
79
+ assert.equal(isSensitivePath("src/main.ts"), false);
80
+ assert.equal(isSensitivePath("README.md"), false);
81
+ assert.equal(isSensitivePath("package.json"), false);
82
+ assert.equal(isSensitivePath("docs/guide.md"), false);
83
+ });
84
+
85
+ it("handles Windows backslash paths", () => {
86
+ assert.equal(isSensitivePath("path\\to\\.env"), true);
87
+ assert.equal(isSensitivePath("path\\to\\config.json"), true);
88
+ assert.equal(isSensitivePath(".patchwarden\\tasks\\status.json"), false);
89
+ });
90
+
91
+ it("handles null byte in path", () => {
92
+ // config.json with null byte appended — the $ anchor means this won't match
93
+ // because the string doesn't end with "config.json"
94
+ const nullPath = "config.json\x00.txt";
95
+ assert.equal(isSensitivePath(nullPath), false);
96
+ // But plain config.json still matches
97
+ assert.equal(isSensitivePath("config.json"), true);
98
+ });
99
+
100
+ it("handles Unicode lookalike characters", () => {
101
+ // Full-width dot (U+FF0E) should not match .env pattern
102
+ // This is expected behavior — Unicode lookalikes are NOT matched
103
+ const fullWidthPath = "\uFF0Eenv";
104
+ assert.equal(isSensitivePath(fullWidthPath), false);
105
+ });
106
+ });
107
+
108
+ describe("guardSensitivePath", () => {
109
+ it("throws PatchWardenError for sensitive paths", () => {
110
+ assert.throws(
111
+ () => guardSensitivePath(".env"),
112
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
113
+ );
114
+ });
115
+
116
+ it("does not throw for non-sensitive paths", () => {
117
+ assert.doesNotThrow(() => guardSensitivePath("src/main.ts"));
118
+ assert.doesNotThrow(() => guardSensitivePath("README.md"));
119
+ });
120
+
121
+ it("does not throw for .patchwarden paths", () => {
122
+ assert.doesNotThrow(() => guardSensitivePath(".patchwarden/tasks/status.json"));
123
+ });
124
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { syncFile } from "../../tools/syncFile.js";
7
+ import { computeFileSha256 } from "../../direct/directPatch.js";
8
+ import type { PatchWardenConfig } from "../../config.js";
9
+
10
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
11
+ return {
12
+ workspaceRoot,
13
+ plansDir: ".patchwarden/plans",
14
+ tasksDir: ".patchwarden/tasks",
15
+ assessmentsDir: ".patchwarden/assessments",
16
+ assessmentTtlSeconds: 3600,
17
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
18
+ allowedTestCommands: ["npm test"],
19
+ repoAllowedTestCommands: {},
20
+ maxReadFileBytes: 200_000,
21
+ defaultTaskTimeoutSeconds: 900,
22
+ maxTaskTimeoutSeconds: 3600,
23
+ watcherStaleSeconds: 30,
24
+ directSessionsDir: ".patchwarden/direct-sessions",
25
+ directSessionTtlSeconds: 3600,
26
+ directMaxPatchBytes: 200_000,
27
+ directMaxFileBytes: 500_000,
28
+ };
29
+ }
30
+
31
+ describe("syncFile", () => {
32
+ let tempDir: string;
33
+ let config: PatchWardenConfig;
34
+ let repoPath: string;
35
+ let sessionsDir: string;
36
+ let sessionId: string;
37
+
38
+ beforeEach(() => {
39
+ tempDir = mkdtempSync(join(tmpdir(), "pw-syncfile-"));
40
+ config = makeConfig(tempDir);
41
+ repoPath = join(tempDir, "my-repo");
42
+ sessionsDir = join(tempDir, ".patchwarden", "direct-sessions");
43
+ sessionId = "test-session-001";
44
+
45
+ mkdirSync(repoPath, { recursive: true });
46
+ mkdirSync(join(sessionsDir, sessionId), { recursive: true });
47
+
48
+ writeFileSync(
49
+ join(sessionsDir, sessionId, "session.json"),
50
+ JSON.stringify({
51
+ session_id: sessionId,
52
+ repo_path: repoPath,
53
+ status: "active",
54
+ created_at: new Date().toISOString(),
55
+ }),
56
+ "utf-8"
57
+ );
58
+ });
59
+
60
+ afterEach(() => {
61
+ rmSync(tempDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it("copies a file from source to target", () => {
65
+ const sourceContent = "console.log('hello world');";
66
+ mkdirSync(join(repoPath, "mobile_app"), { recursive: true });
67
+ writeFileSync(join(repoPath, "mobile_app", "app.js"), sourceContent, "utf-8");
68
+
69
+ const result = syncFile(sessionId, "mobile_app/app.js", "windows_app/app/app.js", undefined, config);
70
+
71
+ assert.equal(result.changed, true);
72
+ assert.equal(result.copied_bytes, sourceContent.length);
73
+ assert.equal(result.before_target_sha256, null);
74
+ assert.ok(result.after_target_sha256);
75
+
76
+ const targetContent = readFileSync(join(repoPath, "windows_app", "app", "app.js"), "utf-8");
77
+ assert.equal(targetContent, sourceContent);
78
+ });
79
+
80
+ it("returns changed=false when target already has same content", () => {
81
+ const sourceContent = "same content";
82
+ mkdirSync(join(repoPath, "src"), { recursive: true });
83
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
84
+ writeFileSync(join(repoPath, "src", "file.ts"), sourceContent, "utf-8");
85
+ writeFileSync(join(repoPath, "dst", "file.ts"), sourceContent, "utf-8");
86
+
87
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", undefined, config);
88
+
89
+ assert.equal(result.changed, false);
90
+ assert.ok(result.before_target_sha256);
91
+ assert.equal(result.before_target_sha256, result.after_target_sha256);
92
+ });
93
+
94
+ it("returns changed=true when target has different content", () => {
95
+ mkdirSync(join(repoPath, "src"), { recursive: true });
96
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
97
+ writeFileSync(join(repoPath, "src", "file.ts"), "new content", "utf-8");
98
+ writeFileSync(join(repoPath, "dst", "file.ts"), "old content", "utf-8");
99
+
100
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", undefined, config);
101
+
102
+ assert.equal(result.changed, true);
103
+ assert.notEqual(result.before_target_sha256, result.after_target_sha256);
104
+ });
105
+
106
+ it("rejects source path outside repo", () => {
107
+ mkdirSync(join(repoPath, "src"), { recursive: true });
108
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
109
+
110
+ assert.throws(
111
+ () => syncFile(sessionId, "../../../etc/passwd", "dst/file.ts", undefined, config),
112
+ Error
113
+ );
114
+ });
115
+
116
+ it("rejects target path outside repo", () => {
117
+ mkdirSync(join(repoPath, "src"), { recursive: true });
118
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
119
+
120
+ assert.throws(
121
+ () => syncFile(sessionId, "src/file.ts", "../../../etc/evil", undefined, config),
122
+ Error
123
+ );
124
+ });
125
+
126
+ it("rejects sensitive source files", () => {
127
+ writeFileSync(join(repoPath, ".env"), "SECRET=abc123", "utf-8");
128
+
129
+ assert.throws(
130
+ () => syncFile(sessionId, ".env", "dst/.env", undefined, config),
131
+ Error
132
+ );
133
+ });
134
+
135
+ it("rejects sensitive target files", () => {
136
+ mkdirSync(join(repoPath, "src"), { recursive: true });
137
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
138
+
139
+ assert.throws(
140
+ () => syncFile(sessionId, "src/file.ts", "config.json", undefined, config),
141
+ Error
142
+ );
143
+ });
144
+
145
+ it("rejects writing to node_modules", () => {
146
+ mkdirSync(join(repoPath, "src"), { recursive: true });
147
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
148
+
149
+ assert.throws(
150
+ () => syncFile(sessionId, "src/file.ts", "node_modules/evil/index.js", undefined, config),
151
+ Error
152
+ );
153
+ });
154
+
155
+ it("rejects writing to dist", () => {
156
+ mkdirSync(join(repoPath, "src"), { recursive: true });
157
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
158
+
159
+ assert.throws(
160
+ () => syncFile(sessionId, "src/file.ts", "dist/main.js", undefined, config),
161
+ Error
162
+ );
163
+ });
164
+
165
+ it("rejects non-existent session", () => {
166
+ mkdirSync(join(repoPath, "src"), { recursive: true });
167
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
168
+
169
+ assert.throws(
170
+ () => syncFile("non-existent-session", "src/file.ts", "dst/file.ts", undefined, config),
171
+ Error
172
+ );
173
+ });
174
+
175
+ it("rejects non-existent source file", () => {
176
+ assert.throws(
177
+ () => syncFile(sessionId, "non-existent/file.ts", "dst/file.ts", undefined, config),
178
+ Error
179
+ );
180
+ });
181
+
182
+ it("validates expected_source_sha256", () => {
183
+ mkdirSync(join(repoPath, "src"), { recursive: true });
184
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
185
+
186
+ const correctHash = computeFileSha256(join(repoPath, "src", "file.ts"));
187
+
188
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", {
189
+ expected_source_sha256: correctHash,
190
+ }, config);
191
+ assert.equal(result.source_sha256, correctHash);
192
+
193
+ assert.throws(
194
+ () => syncFile(sessionId, "src/file.ts", "dst2/file.ts", {
195
+ expected_source_sha256: "wronghash",
196
+ }, config),
197
+ Error
198
+ );
199
+ });
200
+
201
+ it("validates expected_target_sha256", () => {
202
+ mkdirSync(join(repoPath, "src"), { recursive: true });
203
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
204
+ writeFileSync(join(repoPath, "src", "file.ts"), "new content", "utf-8");
205
+ writeFileSync(join(repoPath, "dst", "file.ts"), "old content", "utf-8");
206
+
207
+ const correctTargetHash = computeFileSha256(join(repoPath, "dst", "file.ts"));
208
+
209
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", {
210
+ expected_target_sha256: correctTargetHash,
211
+ }, config);
212
+ assert.equal(result.before_target_sha256, correctTargetHash);
213
+
214
+ assert.throws(
215
+ () => syncFile(sessionId, "src/file.ts", "dst/file.ts", {
216
+ expected_target_sha256: "wronghash",
217
+ }, config),
218
+ Error
219
+ );
220
+ });
221
+
222
+ it("creates target directory if it doesn't exist", () => {
223
+ mkdirSync(join(repoPath, "src"), { recursive: true });
224
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
225
+
226
+ const result = syncFile(sessionId, "src/file.ts", "deep/nested/path/file.ts", undefined, config);
227
+
228
+ assert.equal(result.changed, true);
229
+ assert.ok(existsSync(join(repoPath, "deep", "nested", "path", "file.ts")));
230
+ });
231
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, mkdir } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import type { PatchWardenConfig } from "../../config.js";
7
+ import { readWatcherStatus, getWatcherHeartbeatPath } from "../../watcherStatus.js";
8
+
9
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
10
+ return {
11
+ workspaceRoot,
12
+ plansDir: ".patchwarden/plans",
13
+ tasksDir: ".patchwarden/tasks",
14
+ assessmentsDir: ".patchwarden/assessments",
15
+ assessmentTtlSeconds: 3600,
16
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
17
+ allowedTestCommands: ["npm test"],
18
+ repoAllowedTestCommands: {},
19
+ maxReadFileBytes: 200_000,
20
+ defaultTaskTimeoutSeconds: 900,
21
+ maxTaskTimeoutSeconds: 3600,
22
+ watcherStaleSeconds: 30,
23
+ directSessionsDir: ".patchwarden/direct-sessions",
24
+ directSessionTtlSeconds: 3600,
25
+ directMaxPatchBytes: 200_000,
26
+ directMaxFileBytes: 500_000,
27
+ };
28
+ }
29
+
30
+ describe("readWatcherStatus — watcher stale fallback", () => {
31
+ let tempDir: string;
32
+ let config: PatchWardenConfig;
33
+
34
+ beforeEach(() => {
35
+ tempDir = mkdtempSync(join(tmpdir(), "pw-watcher-test-"));
36
+ config = makeConfig(tempDir);
37
+ mkdirSync(join(tempDir, ".patchwarden", "tasks"), { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(tempDir, { recursive: true, force: true });
42
+ });
43
+
44
+ it("returns missing when no heartbeat and no running task", () => {
45
+ const result = readWatcherStatus(config, Date.now());
46
+ assert.equal(result.status, "missing");
47
+ assert.equal(result.available, false);
48
+ assert.equal(result.activity, null);
49
+ });
50
+
51
+ it("returns healthy when watcher heartbeat is fresh", () => {
52
+ const heartbeatPath = getWatcherHeartbeatPath(config);
53
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
54
+ writeFileSync(heartbeatPath, JSON.stringify({
55
+ status: "running",
56
+ pid: process.pid,
57
+ last_heartbeat_at: new Date().toISOString(),
58
+ }), "utf-8");
59
+
60
+ const result = readWatcherStatus(config, Date.now());
61
+ assert.equal(result.status, "healthy");
62
+ assert.equal(result.available, true);
63
+ assert.equal(result.activity, null);
64
+ });
65
+
66
+ it("returns stale when watcher heartbeat is old and no running task", () => {
67
+ const heartbeatPath = getWatcherHeartbeatPath(config);
68
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
69
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
70
+ writeFileSync(heartbeatPath, JSON.stringify({
71
+ status: "running",
72
+ pid: process.pid,
73
+ last_heartbeat_at: oldTime,
74
+ }), "utf-8");
75
+
76
+ const result = readWatcherStatus(config, Date.now());
77
+ assert.equal(result.status, "stale");
78
+ assert.equal(result.available, false);
79
+ assert.equal(result.activity, null);
80
+ });
81
+
82
+ it("returns healthy when watcher heartbeat is stale but a running task has fresh heartbeat", () => {
83
+ // Write stale watcher heartbeat
84
+ const heartbeatPath = getWatcherHeartbeatPath(config);
85
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
86
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
87
+ writeFileSync(heartbeatPath, JSON.stringify({
88
+ status: "running",
89
+ pid: process.pid,
90
+ last_heartbeat_at: oldTime,
91
+ }), "utf-8");
92
+
93
+ // Create a running task with fresh heartbeat
94
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-001");
95
+ mkdirSync(taskDir, { recursive: true });
96
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
97
+ status: "running",
98
+ task_id: "test-task-001",
99
+ }), "utf-8");
100
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
101
+ phase: "executing_agent",
102
+ last_heartbeat_at: new Date().toISOString(),
103
+ current_command: "codex exec",
104
+ }), "utf-8");
105
+
106
+ const result = readWatcherStatus(config, Date.now());
107
+ assert.equal(result.status, "healthy");
108
+ assert.equal(result.available, true);
109
+ assert.ok(result.activity);
110
+ assert.match(result.activity!, /test-task-001/);
111
+ });
112
+
113
+ it("returns healthy when watcher heartbeat is missing but a running task has fresh heartbeat", () => {
114
+ // No watcher heartbeat file at all
115
+ // Create a running task with fresh heartbeat
116
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-002");
117
+ mkdirSync(taskDir, { recursive: true });
118
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
119
+ status: "running",
120
+ task_id: "test-task-002",
121
+ }), "utf-8");
122
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
123
+ phase: "running_tests",
124
+ last_heartbeat_at: new Date().toISOString(),
125
+ current_command: "npm test",
126
+ }), "utf-8");
127
+
128
+ const result = readWatcherStatus(config, Date.now());
129
+ assert.equal(result.status, "healthy");
130
+ assert.equal(result.available, true);
131
+ assert.ok(result.activity);
132
+ assert.match(result.activity!, /test-task-002/);
133
+ });
134
+
135
+ it("returns stale when both watcher heartbeat and task heartbeat are old", () => {
136
+ const heartbeatPath = getWatcherHeartbeatPath(config);
137
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
138
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
139
+ writeFileSync(heartbeatPath, JSON.stringify({
140
+ status: "running",
141
+ pid: process.pid,
142
+ last_heartbeat_at: oldTime,
143
+ }), "utf-8");
144
+
145
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-003");
146
+ mkdirSync(taskDir, { recursive: true });
147
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
148
+ status: "running",
149
+ task_id: "test-task-003",
150
+ }), "utf-8");
151
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
152
+ phase: "executing_agent",
153
+ last_heartbeat_at: oldTime,
154
+ current_command: "codex exec",
155
+ }), "utf-8");
156
+
157
+ const result = readWatcherStatus(config, Date.now());
158
+ assert.equal(result.status, "stale");
159
+ assert.equal(result.available, false);
160
+ assert.equal(result.activity, null);
161
+ });
162
+
163
+ it("does not fall back to non-running tasks", () => {
164
+ const heartbeatPath = getWatcherHeartbeatPath(config);
165
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
166
+ const oldTime = new Date(Date.now() - 120_000).toISOString();
167
+ writeFileSync(heartbeatPath, JSON.stringify({
168
+ status: "running",
169
+ pid: process.pid,
170
+ last_heartbeat_at: oldTime,
171
+ }), "utf-8");
172
+
173
+ // Done task with fresh heartbeat should not trigger fallback
174
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "test-task-004");
175
+ mkdirSync(taskDir, { recursive: true });
176
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({
177
+ status: "done",
178
+ task_id: "test-task-004",
179
+ }), "utf-8");
180
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify({
181
+ phase: "completed",
182
+ last_heartbeat_at: new Date().toISOString(),
183
+ current_command: null,
184
+ }), "utf-8");
185
+
186
+ const result = readWatcherStatus(config, Date.now());
187
+ assert.equal(result.status, "stale");
188
+ assert.equal(result.available, false);
189
+ });
190
+ });