patchwarden 0.4.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/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- 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/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +105 -7
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -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/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- 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/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/unit-tests.js +36 -0
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- 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/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
|
@@ -39,8 +39,8 @@ async function test(name, fn) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function git(args) {
|
|
43
|
-
const result = spawnSync("git", args, { cwd
|
|
42
|
+
function git(args, cwd = repoPath) {
|
|
43
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
44
44
|
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -53,6 +53,18 @@ async function waitForRunning(getTaskStatus, taskId) {
|
|
|
53
53
|
throw new Error(`Task ${taskId} did not enter running state`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
async function raceWithTimeout(promise, ms, msg) {
|
|
57
|
+
let timer;
|
|
58
|
+
const timeout = new Promise((_, reject) => {
|
|
59
|
+
timer = setTimeout(() => reject(new Error(msg)), ms);
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([promise, timeout]);
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
console.log("\n=== PatchWarden Lifecycle Smoke Tests ===\n");
|
|
57
69
|
|
|
58
70
|
try {
|
|
@@ -70,10 +82,14 @@ try {
|
|
|
70
82
|
writeFileSync(join(repoPath, "main.js"), "console.log('fixture');\n", "utf-8");
|
|
71
83
|
writeFileSync(join(repoPath, "second.js"), "console.log('second');\n", "utf-8");
|
|
72
84
|
writeFileSync(join(repoPath, "delete-me.txt"), "delete fixture\n", "utf-8");
|
|
85
|
+
writeFileSync(join(repoPath, ".gitignore"), "dist/\nrelease/\nsync-store.json\n*.log\n", "utf-8");
|
|
86
|
+
mkdirSync(join(repoPath, "release"), { recursive: true });
|
|
87
|
+
writeFileSync(join(repoPath, "release", "tracked.txt"), "tracked artifact fixture\n", "utf-8");
|
|
73
88
|
mkdirSync(plainRepoPath, { recursive: true });
|
|
74
89
|
writeFileSync(join(plainRepoPath, "README.md"), "# Non-Git fixture\n", "utf-8");
|
|
75
90
|
git(["init"]);
|
|
76
|
-
git(["add", "README.md", "main.js", "second.js", "delete-me.txt"]);
|
|
91
|
+
git(["add", "README.md", "main.js", "second.js", "delete-me.txt", ".gitignore"]);
|
|
92
|
+
git(["add", "-f", "release/tracked.txt"]);
|
|
77
93
|
git(["-c", "user.name=PatchWarden Test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"]);
|
|
78
94
|
|
|
79
95
|
writeFileSync(
|
|
@@ -114,6 +130,13 @@ try {
|
|
|
114
130
|
command: process.execPath,
|
|
115
131
|
args: ["-e", "require('fs').writeFileSync('fixture.bin',Buffer.from([0,1,2,3,255,0,10]))"],
|
|
116
132
|
},
|
|
133
|
+
artifactwriter: {
|
|
134
|
+
command: process.execPath,
|
|
135
|
+
args: [
|
|
136
|
+
"-e",
|
|
137
|
+
"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.writeFileSync('dist/app.exe','build');fs.writeFileSync('sync-store.json','{}');fs.appendFileSync('release/tracked.txt','changed\\n');fs.writeFileSync('generated.exe','review')",
|
|
138
|
+
],
|
|
139
|
+
},
|
|
117
140
|
},
|
|
118
141
|
allowedTestCommands: ["node --check main.js", "node --check second.js", "node --check missing.js"],
|
|
119
142
|
maxReadFileBytes: 200000,
|
|
@@ -133,12 +156,14 @@ try {
|
|
|
133
156
|
const { getDiff } = await import("../dist/tools/taskOutputs.js");
|
|
134
157
|
const { listAgents } = await import("../dist/tools/listAgents.js");
|
|
135
158
|
const { getTaskSummary } = await import("../dist/tools/getTaskSummary.js");
|
|
159
|
+
const { auditTask } = await import("../dist/tools/auditTask.js");
|
|
136
160
|
const { waitForTask } = await import("../dist/tools/waitForTask.js");
|
|
137
161
|
const { runTask } = await import("../dist/runner/runTask.js");
|
|
162
|
+
const { reloadConfig } = await import("../dist/config.js");
|
|
138
163
|
|
|
139
164
|
await test("list_agents reports configured executables", async () => {
|
|
140
165
|
const result = listAgents();
|
|
141
|
-
if (result.total !==
|
|
166
|
+
if (result.total !== 8 || result.agents.some((agent) => !agent.available)) {
|
|
142
167
|
throw new Error(`Unexpected agent availability: ${JSON.stringify(result)}`);
|
|
143
168
|
}
|
|
144
169
|
});
|
|
@@ -295,6 +320,35 @@ try {
|
|
|
295
320
|
}
|
|
296
321
|
});
|
|
297
322
|
|
|
323
|
+
await test("artifact hygiene separates source, ignored output, runtime data, and suspicious changes", async () => {
|
|
324
|
+
const plan = savePlan({ title: "Artifact hygiene", content: "Generate representative task outputs." });
|
|
325
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "artifactwriter", repo_path: "repo" });
|
|
326
|
+
const result = await runTask(task.task_id);
|
|
327
|
+
if (result.status !== "done") throw new Error(`Artifact task failed: ${JSON.stringify(result)}`);
|
|
328
|
+
const standard = getTaskSummary(task.task_id);
|
|
329
|
+
const compact = getTaskSummary(task.task_id, { view: "compact", max_items: 1 });
|
|
330
|
+
const counts = standard.artifact_hygiene?.counts || {};
|
|
331
|
+
if (
|
|
332
|
+
counts.tracked_build_artifacts < 1 ||
|
|
333
|
+
counts.ignored_untracked_artifacts < 2 ||
|
|
334
|
+
counts.runtime_generated_files < 1 ||
|
|
335
|
+
counts.suspicious_changes < 2
|
|
336
|
+
) {
|
|
337
|
+
throw new Error(`Artifact classification mismatch: ${JSON.stringify(standard.artifact_hygiene)}`);
|
|
338
|
+
}
|
|
339
|
+
if (compact.view !== "compact" || "log_tails" in compact || compact.artifact_hygiene.max_items !== 1) {
|
|
340
|
+
throw new Error(`Compact summary leaked standard detail: ${JSON.stringify(compact)}`);
|
|
341
|
+
}
|
|
342
|
+
if (!Array.isArray(standard.changed_files) || standard.changed_files.length < 4) {
|
|
343
|
+
throw new Error("Standard summary no longer preserves full changed-file evidence");
|
|
344
|
+
}
|
|
345
|
+
const audit = auditTask(task.task_id);
|
|
346
|
+
const hygieneCheck = audit.checks.find((check) => check.name === "artifact_hygiene");
|
|
347
|
+
if (!hygieneCheck || hygieneCheck.result !== "warn") {
|
|
348
|
+
throw new Error(`Audit did not surface suspicious artifact evidence: ${JSON.stringify(audit)}`);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
298
352
|
await test("deleted tracked files are identified with file stats", async () => {
|
|
299
353
|
const plan = savePlan({ title: "Delete fixture", content: "Delete the designated fixture file." });
|
|
300
354
|
const task = createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
|
|
@@ -311,10 +365,13 @@ try {
|
|
|
311
365
|
const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
|
|
312
366
|
const running = runTask(task.task_id);
|
|
313
367
|
const waited = await waitForTask(task.task_id, 5);
|
|
314
|
-
await running;
|
|
368
|
+
await raceWithTimeout(running, 15000, "wait_for_task loop did not terminate within 15s");
|
|
315
369
|
if (!waited.terminal || waited.continuation_required || !waited.summary) {
|
|
316
370
|
throw new Error(`Unexpected wait response: ${JSON.stringify(waited)}`);
|
|
317
371
|
}
|
|
372
|
+
if (waited.summary.view !== "compact" || "log_tails" in waited.summary) {
|
|
373
|
+
throw new Error(`Terminal wait should embed compact evidence: ${JSON.stringify(waited.summary)}`);
|
|
374
|
+
}
|
|
318
375
|
});
|
|
319
376
|
|
|
320
377
|
await test("wait_for_task explicitly requires another call when the task is not terminal", async () => {
|
|
@@ -338,7 +395,7 @@ try {
|
|
|
338
395
|
throw new Error(`Running summary incomplete: ${JSON.stringify(summary)}`);
|
|
339
396
|
}
|
|
340
397
|
cancelTask(task.task_id);
|
|
341
|
-
await running;
|
|
398
|
+
await raceWithTimeout(running, 15000, "running summary cancel did not terminate within 15s");
|
|
342
399
|
});
|
|
343
400
|
|
|
344
401
|
await test("verification failure produces failed_verification and structured evidence", async () => {
|
|
@@ -398,6 +455,339 @@ try {
|
|
|
398
455
|
}
|
|
399
456
|
});
|
|
400
457
|
|
|
458
|
+
// ═══════════════════════════════════════════════════════════
|
|
459
|
+
// Phase 4: Tracked external dirty tests (separate workspace)
|
|
460
|
+
// ═══════════════════════════════════════════════════════════
|
|
461
|
+
|
|
462
|
+
await test("pre-existing tracked external dirty unchanged → warning only, done", async () => {
|
|
463
|
+
// Create a separate workspace with git at the root
|
|
464
|
+
const ws2 = join(tempRoot, "workspace-tracked-dirty");
|
|
465
|
+
const repo2 = join(ws2, "repo");
|
|
466
|
+
const ext2 = join(ws2, "external");
|
|
467
|
+
mkdirSync(repo2, { recursive: true });
|
|
468
|
+
mkdirSync(ext2, { recursive: true });
|
|
469
|
+
mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
|
|
470
|
+
writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
471
|
+
status: "running", pid: process.pid, instance_id: "td-watcher", launcher_pid: process.pid,
|
|
472
|
+
started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
|
|
473
|
+
}), "utf-8");
|
|
474
|
+
|
|
475
|
+
writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
|
|
476
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
|
|
477
|
+
|
|
478
|
+
git(["init"], ws2);
|
|
479
|
+
git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
|
|
480
|
+
git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
|
|
481
|
+
|
|
482
|
+
// Now modify external file to create pre-existing dirty state
|
|
483
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "pre-existing dirty content\n", "utf-8");
|
|
484
|
+
|
|
485
|
+
const cfg2 = join(tempRoot, "config-tracked-dirty.json");
|
|
486
|
+
writeFileSync(cfg2, JSON.stringify({
|
|
487
|
+
workspaceRoot: ws2,
|
|
488
|
+
plansDir: ".patchwarden/plans",
|
|
489
|
+
tasksDir: ".patchwarden/tasks",
|
|
490
|
+
agents: {
|
|
491
|
+
noop: { command: process.execPath, args: ["-e", "console.log('noop')"] },
|
|
492
|
+
},
|
|
493
|
+
allowedTestCommands: ["node --check main.js"],
|
|
494
|
+
maxReadFileBytes: 200000,
|
|
495
|
+
defaultTaskTimeoutSeconds: 10,
|
|
496
|
+
watcherStaleSeconds: 3600,
|
|
497
|
+
}, null, 2), "utf-8");
|
|
498
|
+
|
|
499
|
+
const oldConfig = process.env.PATCHWARDEN_CONFIG;
|
|
500
|
+
process.env.PATCHWARDEN_CONFIG = cfg2;
|
|
501
|
+
reloadConfig(cfg2);
|
|
502
|
+
|
|
503
|
+
const plan = savePlan({ title: "Noop with external dirty", content: "Do nothing." });
|
|
504
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo", verify_commands: ["node --check main.js"] });
|
|
505
|
+
const result = await runTask(task.task_id);
|
|
506
|
+
|
|
507
|
+
if (result.status !== "done") {
|
|
508
|
+
throw new Error(`Expected done, got: ${JSON.stringify(result)}`);
|
|
509
|
+
}
|
|
510
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
511
|
+
|
|
512
|
+
// Must have pre-existing external dirty warning
|
|
513
|
+
if (!structured.warnings?.some((w) => w.includes("Pre-existing external dirty"))) {
|
|
514
|
+
throw new Error(`Missing pre-existing dirty warning: ${JSON.stringify(structured.warnings)}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// new_out_of_scope_changes must be empty (file didn't change during task)
|
|
518
|
+
if (structured.new_out_of_scope_changes?.length > 0) {
|
|
519
|
+
throw new Error(`Should have no new out-of-scope changes: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// verify_status should NOT be failed
|
|
523
|
+
if (structured.verify_status === "failed") {
|
|
524
|
+
throw new Error(`verify_status should not be failed: ${structured.verify_status}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// get_task_summary acceptance_status should NOT be failed
|
|
528
|
+
const summary = getTaskSummary(task.task_id);
|
|
529
|
+
if (summary.acceptance_status === "failed") {
|
|
530
|
+
throw new Error(`acceptance_status should not be failed: ${summary.acceptance_status}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// audit_task should NOT fail scope_changes
|
|
534
|
+
const audit = auditTask(task.task_id);
|
|
535
|
+
const scopeCheck = audit.checks.find((c) => c.name === "scope_changes");
|
|
536
|
+
if (!scopeCheck || scopeCheck.result !== "pass") {
|
|
537
|
+
throw new Error(`audit scope_changes should pass: ${JSON.stringify(scopeCheck)}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Restore config AFTER all assertions
|
|
541
|
+
process.env.PATCHWARDEN_CONFIG = oldConfig;
|
|
542
|
+
reloadConfig(oldConfig);
|
|
543
|
+
|
|
544
|
+
// Print result.json key fields for reporting
|
|
545
|
+
console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await test("pre-existing tracked external dirty changed during task → failed_scope_violation", async () => {
|
|
549
|
+
const ws2 = join(tempRoot, "workspace-tracked-dirty2");
|
|
550
|
+
const repo2 = join(ws2, "repo");
|
|
551
|
+
const ext2 = join(ws2, "external");
|
|
552
|
+
mkdirSync(repo2, { recursive: true });
|
|
553
|
+
mkdirSync(ext2, { recursive: true });
|
|
554
|
+
mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
|
|
555
|
+
writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
556
|
+
status: "running", pid: process.pid, instance_id: "td-watcher2", launcher_pid: process.pid,
|
|
557
|
+
started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
|
|
558
|
+
}), "utf-8");
|
|
559
|
+
|
|
560
|
+
writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
|
|
561
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
|
|
562
|
+
|
|
563
|
+
git(["init"], ws2);
|
|
564
|
+
git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
|
|
565
|
+
git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
|
|
566
|
+
|
|
567
|
+
// Modify external file BEFORE task (pre-existing dirty)
|
|
568
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "pre-existing dirty content\n", "utf-8");
|
|
569
|
+
|
|
570
|
+
const cfg2 = join(tempRoot, "config-tracked-dirty2.json");
|
|
571
|
+
writeFileSync(cfg2, JSON.stringify({
|
|
572
|
+
workspaceRoot: ws2,
|
|
573
|
+
plansDir: ".patchwarden/plans",
|
|
574
|
+
tasksDir: ".patchwarden/tasks",
|
|
575
|
+
agents: {
|
|
576
|
+
extmod: { command: process.execPath, args: ["-e", "require('fs').appendFileSync('../external/external-tracked.txt',' modified by task\\n')"] },
|
|
577
|
+
},
|
|
578
|
+
allowedTestCommands: ["node --check main.js"],
|
|
579
|
+
maxReadFileBytes: 200000,
|
|
580
|
+
defaultTaskTimeoutSeconds: 10,
|
|
581
|
+
watcherStaleSeconds: 3600,
|
|
582
|
+
}, null, 2), "utf-8");
|
|
583
|
+
|
|
584
|
+
const oldConfig = process.env.PATCHWARDEN_CONFIG;
|
|
585
|
+
process.env.PATCHWARDEN_CONFIG = cfg2;
|
|
586
|
+
reloadConfig(cfg2);
|
|
587
|
+
|
|
588
|
+
const plan = savePlan({ title: "External dirty modifier", content: "Modify external tracked file." });
|
|
589
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
|
|
590
|
+
const result = await runTask(task.task_id);
|
|
591
|
+
|
|
592
|
+
if (result.status !== "failed_scope_violation") {
|
|
593
|
+
throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
|
|
594
|
+
}
|
|
595
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
596
|
+
|
|
597
|
+
// new_out_of_scope_changes must contain the external tracked file
|
|
598
|
+
if (!structured.new_out_of_scope_changes?.some((f) => f.path.includes("external-tracked.txt"))) {
|
|
599
|
+
throw new Error(`Missing new out-of-scope change: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// verify_status must be failed
|
|
603
|
+
if (structured.verify_status !== "failed") {
|
|
604
|
+
throw new Error(`verify_status must be failed: ${structured.verify_status}`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// rollback plan must contain the file
|
|
608
|
+
const rollbackPath = join(task.path, "rollback_scope_violation_plan.md");
|
|
609
|
+
if (!existsSync(rollbackPath)) throw new Error("rollback_scope_violation_plan.md missing");
|
|
610
|
+
const rollback = readFileSync(rollbackPath, "utf-8");
|
|
611
|
+
if (!rollback.includes("external-tracked.txt")) {
|
|
612
|
+
throw new Error(`Rollback plan missing external-tracked.txt: ${rollback}`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// get_task_summary acceptance_status must be failed
|
|
616
|
+
const summary = getTaskSummary(task.task_id);
|
|
617
|
+
if (summary.acceptance_status !== "failed") {
|
|
618
|
+
throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// audit_task verdict must be fail
|
|
622
|
+
const audit = auditTask(task.task_id);
|
|
623
|
+
if (audit.verdict !== "fail") {
|
|
624
|
+
throw new Error(`audit verdict must be fail: ${audit.verdict}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Restore config AFTER all assertions
|
|
628
|
+
process.env.PATCHWARDEN_CONFIG = oldConfig;
|
|
629
|
+
reloadConfig(oldConfig);
|
|
630
|
+
|
|
631
|
+
console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
await test("clean tracked external file changed during task → failed_scope_violation", async () => {
|
|
635
|
+
const ws2 = join(tempRoot, "workspace-tracked-dirty3");
|
|
636
|
+
const repo2 = join(ws2, "repo");
|
|
637
|
+
const ext2 = join(ws2, "external");
|
|
638
|
+
mkdirSync(repo2, { recursive: true });
|
|
639
|
+
mkdirSync(ext2, { recursive: true });
|
|
640
|
+
mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
|
|
641
|
+
writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
642
|
+
status: "running", pid: process.pid, instance_id: "td-watcher3", launcher_pid: process.pid,
|
|
643
|
+
started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
|
|
644
|
+
}), "utf-8");
|
|
645
|
+
|
|
646
|
+
writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
|
|
647
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
|
|
648
|
+
|
|
649
|
+
git(["init"], ws2);
|
|
650
|
+
git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
|
|
651
|
+
git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
|
|
652
|
+
|
|
653
|
+
// External file is clean (committed state) — no pre-existing dirty
|
|
654
|
+
|
|
655
|
+
const cfg2 = join(tempRoot, "config-tracked-dirty3.json");
|
|
656
|
+
writeFileSync(cfg2, JSON.stringify({
|
|
657
|
+
workspaceRoot: ws2,
|
|
658
|
+
plansDir: ".patchwarden/plans",
|
|
659
|
+
tasksDir: ".patchwarden/tasks",
|
|
660
|
+
agents: {
|
|
661
|
+
extmod: { command: process.execPath, args: ["-e", "require('fs').appendFileSync('../external/external-tracked.txt',' modified by task\\n')"] },
|
|
662
|
+
},
|
|
663
|
+
allowedTestCommands: ["node --check main.js"],
|
|
664
|
+
maxReadFileBytes: 200000,
|
|
665
|
+
defaultTaskTimeoutSeconds: 10,
|
|
666
|
+
watcherStaleSeconds: 3600,
|
|
667
|
+
}, null, 2), "utf-8");
|
|
668
|
+
|
|
669
|
+
const oldConfig = process.env.PATCHWARDEN_CONFIG;
|
|
670
|
+
process.env.PATCHWARDEN_CONFIG = cfg2;
|
|
671
|
+
reloadConfig(cfg2);
|
|
672
|
+
|
|
673
|
+
const plan = savePlan({ title: "Clean external modifier", content: "Modify clean external tracked file." });
|
|
674
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
|
|
675
|
+
const result = await runTask(task.task_id);
|
|
676
|
+
|
|
677
|
+
if (result.status !== "failed_scope_violation") {
|
|
678
|
+
throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
|
|
679
|
+
}
|
|
680
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
681
|
+
|
|
682
|
+
// new_out_of_scope_changes must contain the external tracked file (clean→dirty)
|
|
683
|
+
if (!structured.new_out_of_scope_changes?.some((f) => f.path.includes("external-tracked.txt"))) {
|
|
684
|
+
throw new Error(`Missing new out-of-scope change: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// verify_status must be failed
|
|
688
|
+
if (structured.verify_status !== "failed") {
|
|
689
|
+
throw new Error(`verify_status must be failed: ${structured.verify_status}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// get_task_summary acceptance_status must be failed
|
|
693
|
+
const summary = getTaskSummary(task.task_id);
|
|
694
|
+
if (summary.acceptance_status !== "failed") {
|
|
695
|
+
throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// audit_task verdict must be fail
|
|
699
|
+
const audit = auditTask(task.task_id);
|
|
700
|
+
if (audit.verdict !== "fail") {
|
|
701
|
+
throw new Error(`audit verdict must be fail: ${audit.verdict}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Restore config AFTER all assertions
|
|
705
|
+
process.env.PATCHWARDEN_CONFIG = oldConfig;
|
|
706
|
+
reloadConfig(oldConfig);
|
|
707
|
+
|
|
708
|
+
console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await test("tracked external file rename → failed_scope_violation", async () => {
|
|
712
|
+
const ws2 = join(tempRoot, "workspace-tracked-rename");
|
|
713
|
+
const repo2 = join(ws2, "repo");
|
|
714
|
+
const ext2 = join(ws2, "external");
|
|
715
|
+
mkdirSync(repo2, { recursive: true });
|
|
716
|
+
mkdirSync(ext2, { recursive: true });
|
|
717
|
+
mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
|
|
718
|
+
writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
719
|
+
status: "running", pid: process.pid, instance_id: "td-watcher-rename", launcher_pid: process.pid,
|
|
720
|
+
started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
|
|
721
|
+
}), "utf-8");
|
|
722
|
+
|
|
723
|
+
writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
|
|
724
|
+
writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
|
|
725
|
+
|
|
726
|
+
git(["init"], ws2);
|
|
727
|
+
git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
|
|
728
|
+
git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
|
|
729
|
+
|
|
730
|
+
// External file is clean (committed state) — no pre-existing dirty
|
|
731
|
+
|
|
732
|
+
const cfg2 = join(tempRoot, "config-tracked-rename.json");
|
|
733
|
+
writeFileSync(cfg2, JSON.stringify({
|
|
734
|
+
workspaceRoot: ws2,
|
|
735
|
+
plansDir: ".patchwarden/plans",
|
|
736
|
+
tasksDir: ".patchwarden/tasks",
|
|
737
|
+
agents: {
|
|
738
|
+
extrenamer: { command: process.execPath, args: ["-e", "require('fs').renameSync('../external/external-tracked.txt','../external/external-renamed.txt')"] },
|
|
739
|
+
},
|
|
740
|
+
allowedTestCommands: ["node --check main.js"],
|
|
741
|
+
maxReadFileBytes: 200000,
|
|
742
|
+
defaultTaskTimeoutSeconds: 10,
|
|
743
|
+
watcherStaleSeconds: 3600,
|
|
744
|
+
}, null, 2), "utf-8");
|
|
745
|
+
|
|
746
|
+
const oldConfig = process.env.PATCHWARDEN_CONFIG;
|
|
747
|
+
process.env.PATCHWARDEN_CONFIG = cfg2;
|
|
748
|
+
reloadConfig(cfg2);
|
|
749
|
+
|
|
750
|
+
const plan = savePlan({ title: "External rename", content: "Rename external tracked file." });
|
|
751
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "extrenamer", repo_path: "repo", verify_commands: ["node --check main.js"] });
|
|
752
|
+
const result = await runTask(task.task_id);
|
|
753
|
+
|
|
754
|
+
if (result.status !== "failed_scope_violation") {
|
|
755
|
+
throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
|
|
756
|
+
}
|
|
757
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
758
|
+
|
|
759
|
+
// new_out_of_scope_changes must contain rename evidence (old or new path)
|
|
760
|
+
const hasRenameEvidence = structured.new_out_of_scope_changes?.some(
|
|
761
|
+
(f) => f.path.includes("external-renamed.txt") || f.path.includes("external-tracked.txt") || f.change === "renamed"
|
|
762
|
+
);
|
|
763
|
+
if (!hasRenameEvidence) {
|
|
764
|
+
throw new Error(`Missing rename evidence in new_out_of_scope_changes: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// verify_status must be failed
|
|
768
|
+
if (structured.verify_status !== "failed") {
|
|
769
|
+
throw new Error(`verify_status must be failed: ${structured.verify_status}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// get_task_summary acceptance_status must be failed
|
|
773
|
+
const summary = getTaskSummary(task.task_id);
|
|
774
|
+
if (summary.acceptance_status !== "failed") {
|
|
775
|
+
throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// audit_task verdict must be fail
|
|
779
|
+
const audit = auditTask(task.task_id);
|
|
780
|
+
if (audit.verdict !== "fail") {
|
|
781
|
+
throw new Error(`audit verdict must be fail: ${audit.verdict}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Restore config AFTER all assertions
|
|
785
|
+
process.env.PATCHWARDEN_CONFIG = oldConfig;
|
|
786
|
+
reloadConfig(oldConfig);
|
|
787
|
+
|
|
788
|
+
console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
|
|
789
|
+
});
|
|
790
|
+
|
|
401
791
|
await test("timeout terminates a long-running agent", async () => {
|
|
402
792
|
const plan = savePlan({ title: "Timeout", content: "Wait." });
|
|
403
793
|
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
|
|
@@ -423,7 +813,7 @@ try {
|
|
|
423
813
|
if (!request.cancel_requested || request.force_kill_requested) {
|
|
424
814
|
throw new Error(`Unexpected cancel response: ${JSON.stringify(request)}`);
|
|
425
815
|
}
|
|
426
|
-
const result = await running;
|
|
816
|
+
const result = await raceWithTimeout(running, 15000, "cancel_task did not terminate within 15s");
|
|
427
817
|
if (result.status !== "canceled") throw new Error(`Expected canceled, got ${JSON.stringify(result)}`);
|
|
428
818
|
});
|
|
429
819
|
|
|
@@ -434,7 +824,7 @@ try {
|
|
|
434
824
|
await waitForRunning(getTaskStatus, task.task_id);
|
|
435
825
|
const request = killTask(task.task_id);
|
|
436
826
|
if (!request.force_kill_requested) throw new Error(`Unexpected kill response: ${JSON.stringify(request)}`);
|
|
437
|
-
const result = await running;
|
|
827
|
+
const result = await raceWithTimeout(running, 15000, "kill_task did not terminate within 15s");
|
|
438
828
|
if (result.status !== "canceled" || !result.error?.includes("kill_task")) {
|
|
439
829
|
throw new Error(`Expected killed/canceled result, got ${JSON.stringify(result)}`);
|
|
440
830
|
}
|
|
@@ -451,3 +841,7 @@ console.log(`${"=".repeat(50)}\n`);
|
|
|
451
841
|
|
|
452
842
|
if (failed > 0) process.exit(1);
|
|
453
843
|
console.log("ALL LIFECYCLE TESTS PASSED\n");
|
|
844
|
+
|
|
845
|
+
// Safety: force exit after cleanup in case any stray timer/handle keeps the event loop alive.
|
|
846
|
+
// All child processes are killed by the runner; this only guards against OS-level pipe drains.
|
|
847
|
+
process.exit(0);
|