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,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
|
+
});
|