patchwarden 0.6.4 → 1.5.0
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.en.md +54 -20
- package/README.md +48 -21
- package/dist/agents/agentRouter.d.ts +40 -0
- package/dist/agents/agentRouter.js +95 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +6 -2
- package/dist/controlCenter.js +212 -3
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.js +32 -1
- package/dist/goal/acceptanceEngine.d.ts +62 -0
- package/dist/goal/acceptanceEngine.js +103 -0
- package/dist/goal/acceptanceTemplate.d.ts +10 -0
- package/dist/goal/acceptanceTemplate.js +104 -0
- package/dist/goal/goalGraph.d.ts +58 -0
- package/dist/goal/goalGraph.js +189 -0
- package/dist/goal/goalProgress.d.ts +81 -0
- package/dist/goal/goalProgress.js +216 -0
- package/dist/goal/goalStatus.d.ts +60 -0
- package/dist/goal/goalStatus.js +137 -0
- package/dist/goal/goalStore.d.ts +79 -0
- package/dist/goal/goalStore.js +211 -0
- package/dist/goal/handoffExport.d.ts +34 -0
- package/dist/goal/handoffExport.js +183 -0
- package/dist/goal/subgoalSync.d.ts +40 -0
- package/dist/goal/subgoalSync.js +72 -0
- package/dist/goal/worktreeManager.d.ts +96 -0
- package/dist/goal/worktreeManager.js +292 -0
- package/dist/logging.d.ts +44 -0
- package/dist/logging.js +68 -0
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/release/releaseGate.d.ts +99 -0
- package/dist/release/releaseGate.js +475 -0
- package/dist/runner/postTaskCleanup.d.ts +13 -0
- package/dist/runner/postTaskCleanup.js +147 -0
- package/dist/runner/runTask.js +50 -10
- package/dist/security/discoveryTokenStore.d.ts +62 -0
- package/dist/security/discoveryTokenStore.js +100 -0
- package/dist/security/toolInvocationGuard.d.ts +35 -0
- package/dist/security/toolInvocationGuard.js +153 -0
- package/dist/smoke-test.js +18 -10
- package/dist/taskRuntime.d.ts +17 -0
- package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
- package/dist/test/unit/acceptance-engine.test.js +228 -0
- package/dist/test/unit/agent-router.test.d.ts +1 -0
- package/dist/test/unit/agent-router.test.js +287 -0
- package/dist/test/unit/audit-checks.test.d.ts +1 -0
- package/dist/test/unit/audit-checks.test.js +350 -0
- package/dist/test/unit/diagnose-task.test.d.ts +1 -0
- package/dist/test/unit/diagnose-task.test.js +457 -0
- package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
- package/dist/test/unit/discovery-token-store.test.js +139 -0
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +130 -0
- package/dist/test/unit/goal-graph.test.d.ts +1 -0
- package/dist/test/unit/goal-graph.test.js +298 -0
- package/dist/test/unit/goal-progress.test.d.ts +1 -0
- package/dist/test/unit/goal-progress.test.js +381 -0
- package/dist/test/unit/goal-status.test.d.ts +1 -0
- package/dist/test/unit/goal-status.test.js +215 -0
- package/dist/test/unit/goal-store.test.d.ts +1 -0
- package/dist/test/unit/goal-store.test.js +253 -0
- package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
- package/dist/test/unit/goal-subgoal-task.test.js +55 -0
- package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
- package/dist/test/unit/goal-tools-registry.test.js +190 -0
- package/dist/test/unit/handoff-export.test.d.ts +1 -0
- package/dist/test/unit/handoff-export.test.js +263 -0
- package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
- package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
- package/dist/test/unit/logging.test.js +127 -5
- package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
- package/dist/test/unit/post-task-cleanup.test.js +48 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
- package/dist/test/unit/reconcile-tasks.test.js +456 -0
- package/dist/test/unit/release-gate.test.d.ts +1 -0
- package/dist/test/unit/release-gate.test.js +242 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/safe-views.test.d.ts +1 -0
- package/dist/test/unit/safe-views.test.js +171 -0
- package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
- package/dist/test/unit/schema-drift-check.test.js +176 -0
- package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
- package/dist/test/unit/subgoal-sync.test.js +183 -0
- package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
- package/dist/test/unit/tool-invocation-guard.test.js +432 -0
- package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
- package/dist/test/unit/tool-usage-stats.test.js +300 -0
- package/dist/test/unit/toolSearch.test.d.ts +1 -0
- package/dist/test/unit/toolSearch.test.js +571 -0
- package/dist/test/unit/worktree-manager.test.d.ts +1 -0
- package/dist/test/unit/worktree-manager.test.js +176 -0
- package/dist/tools/auditTask.d.ts +103 -1
- package/dist/tools/auditTask.js +461 -8
- package/dist/tools/cancelTask.js +1 -1
- package/dist/tools/checkReleaseGate.d.ts +21 -0
- package/dist/tools/checkReleaseGate.js +22 -0
- package/dist/tools/createTask.d.ts +18 -2
- package/dist/tools/createTask.js +29 -1
- package/dist/tools/diagnoseTask.d.ts +45 -0
- package/dist/tools/diagnoseTask.js +347 -0
- package/dist/tools/discardWorktree.d.ts +24 -0
- package/dist/tools/discardWorktree.js +27 -0
- package/dist/tools/discoverTools.d.ts +11 -0
- package/dist/tools/discoverTools.js +39 -0
- package/dist/tools/evidencePack.d.ts +39 -0
- package/dist/tools/evidencePack.js +168 -0
- package/dist/tools/explainTool.d.ts +11 -0
- package/dist/tools/explainTool.js +21 -0
- package/dist/tools/getTaskSummary.js +2 -1
- package/dist/tools/goalSubgoalTask.d.ts +51 -0
- package/dist/tools/goalSubgoalTask.js +110 -0
- package/dist/tools/healthCheck.d.ts +1 -0
- package/dist/tools/healthCheck.js +5 -1
- package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
- package/dist/tools/invokeDiscoveredTool.js +112 -0
- package/dist/tools/listTasks.d.ts +3 -1
- package/dist/tools/listTasks.js +14 -1
- package/dist/tools/mergeWorktree.d.ts +24 -0
- package/dist/tools/mergeWorktree.js +27 -0
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/reconcileTasks.d.ts +41 -0
- package/dist/tools/reconcileTasks.js +352 -0
- package/dist/tools/registry.js +926 -2
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeStatus.d.ts +31 -1
- package/dist/tools/safeStatus.js +43 -1
- package/dist/tools/safeViews.d.ts +262 -0
- package/dist/tools/safeViews.js +252 -0
- package/dist/tools/schemaDriftCheck.d.ts +46 -0
- package/dist/tools/schemaDriftCheck.js +80 -0
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +39 -11
- package/dist/tools/toolRegistry.d.ts +61 -0
- package/dist/tools/toolRegistry.js +834 -0
- package/dist/tools/toolSearch.d.ts +110 -0
- package/dist/tools/toolSearch.js +331 -0
- package/dist/tools/toolUsageStats.d.ts +41 -0
- package/dist/tools/toolUsageStats.js +116 -0
- package/dist/tools/waitForTask.js +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +29 -0
- package/dist/watcherStatus.js +92 -1
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/package.json +3 -2
- package/scripts/checks/control-center-smoke.js +87 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/doctor-smoke.js +1 -0
- package/scripts/checks/lifecycle-smoke.js +13 -9
- package/scripts/checks/mcp-manifest-check.js +65 -6
- package/scripts/checks/mcp-smoke.js +60 -9
- package/scripts/checks/watcher-supervisor-smoke.js +2 -2
- package/src/agents/agentRouter.ts +149 -0
- package/src/config.ts +9 -3
- package/src/controlCenter.ts +215 -4
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +36 -1
- package/src/goal/acceptanceEngine.ts +160 -0
- package/src/goal/acceptanceTemplate.ts +121 -0
- package/src/goal/goalGraph.ts +225 -0
- package/src/goal/goalProgress.ts +301 -0
- package/src/goal/goalStatus.ts +234 -0
- package/src/goal/goalStore.ts +306 -0
- package/src/goal/handoffExport.ts +211 -0
- package/src/goal/subgoalSync.ts +82 -0
- package/src/goal/worktreeManager.ts +404 -0
- package/src/logging.ts +91 -0
- package/src/policy/projectPolicy.ts +344 -0
- package/src/release/releaseGate.ts +567 -0
- package/src/runner/postTaskCleanup.ts +154 -0
- package/src/runner/runTask.ts +49 -10
- package/src/security/discoveryTokenStore.ts +157 -0
- package/src/security/toolInvocationGuard.ts +251 -0
- package/src/smoke-test.ts +15 -7
- package/src/taskRuntime.ts +17 -0
- package/src/test/unit/acceptance-engine.test.ts +261 -0
- package/src/test/unit/agent-router.test.ts +342 -0
- package/src/test/unit/audit-checks.test.ts +567 -0
- package/src/test/unit/diagnose-task.test.ts +544 -0
- package/src/test/unit/discovery-token-store.test.ts +181 -0
- package/src/test/unit/evidence-pack.test.ts +142 -0
- package/src/test/unit/goal-graph.test.ts +347 -0
- package/src/test/unit/goal-progress.test.ts +538 -0
- package/src/test/unit/goal-status.test.ts +270 -0
- package/src/test/unit/goal-store.test.ts +318 -0
- package/src/test/unit/goal-subgoal-task.test.ts +72 -0
- package/src/test/unit/goal-tools-registry.test.ts +243 -0
- package/src/test/unit/handoff-export.test.ts +295 -0
- package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
- package/src/test/unit/logging.test.ts +177 -5
- package/src/test/unit/post-task-cleanup.test.ts +53 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/reconcile-tasks.test.ts +551 -0
- package/src/test/unit/release-gate.test.ts +314 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/safe-views.test.ts +184 -0
- package/src/test/unit/schema-drift-check.test.ts +260 -0
- package/src/test/unit/subgoal-sync.test.ts +236 -0
- package/src/test/unit/tool-invocation-guard.test.ts +542 -0
- package/src/test/unit/tool-usage-stats.test.ts +384 -0
- package/src/test/unit/toolSearch.test.ts +756 -0
- package/src/test/unit/worktree-manager.test.ts +247 -0
- package/src/tools/auditTask.ts +831 -402
- package/src/tools/cancelTask.ts +1 -1
- package/src/tools/checkReleaseGate.ts +45 -0
- package/src/tools/createTask.ts +64 -6
- package/src/tools/diagnoseTask.ts +460 -0
- package/src/tools/discardWorktree.ts +42 -0
- package/src/tools/discoverTools.ts +51 -0
- package/src/tools/evidencePack.ts +205 -0
- package/src/tools/explainTool.ts +26 -0
- package/src/tools/getTaskSummary.ts +2 -1
- package/src/tools/goalSubgoalTask.ts +170 -0
- package/src/tools/healthCheck.ts +4 -1
- package/src/tools/invokeDiscoveredTool.ts +163 -0
- package/src/tools/listTasks.ts +16 -2
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/mergeWorktree.ts +42 -0
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/reconcileTasks.ts +464 -0
- package/src/tools/registry.ts +1093 -73
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeStatus.ts +73 -2
- package/src/tools/safeViews.ts +273 -0
- package/src/tools/schemaDriftCheck.ts +120 -0
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +40 -11
- package/src/tools/toolRegistry.ts +896 -0
- package/src/tools/toolSearch.ts +464 -0
- package/src/tools/toolUsageStats.ts +151 -0
- package/src/tools/waitForTask.ts +1 -0
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +97 -1
- package/ui/pages/dashboard.html +143 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { generateHandoff, exportHandoff } from "../../goal/handoffExport.js";
|
|
7
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
8
|
+
function makeSubgoal(overrides) {
|
|
9
|
+
return {
|
|
10
|
+
title: "Subgoal " + overrides.id,
|
|
11
|
+
status: "ready",
|
|
12
|
+
depends_on: [],
|
|
13
|
+
task_ids: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function makeGoalStatus(subgoals, overrides = {}) {
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
return {
|
|
20
|
+
goal_id: "goal_test_001",
|
|
21
|
+
title: "Test Goal",
|
|
22
|
+
status: "active",
|
|
23
|
+
repo_path: "/repo/test",
|
|
24
|
+
created_at: now,
|
|
25
|
+
updated_at: now,
|
|
26
|
+
subgoals,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// ── Tests ─────────────────────────────────────────────────────────
|
|
31
|
+
describe("handoffExport", () => {
|
|
32
|
+
describe("generateHandoff", () => {
|
|
33
|
+
it("空 subgoals → 文档包含所有章节", () => {
|
|
34
|
+
const goal = makeGoalStatus([]);
|
|
35
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
36
|
+
assert.ok(md.includes("# Goal Session Handoff"));
|
|
37
|
+
assert.ok(md.includes("## 当前 Goal"));
|
|
38
|
+
assert.ok(md.includes("## 版本目标"));
|
|
39
|
+
assert.ok(md.includes("## 已完成子目标"));
|
|
40
|
+
assert.ok(md.includes("## 未完成子目标"));
|
|
41
|
+
assert.ok(md.includes("## 已拒绝子目标"));
|
|
42
|
+
assert.ok(md.includes("## 最近一次 diff 摘要"));
|
|
43
|
+
assert.ok(md.includes("## 最近一次测试结果"));
|
|
44
|
+
assert.ok(md.includes("## 当前阻塞点"));
|
|
45
|
+
assert.ok(md.includes("## 下一步建议"));
|
|
46
|
+
assert.ok(md.includes("## 风险提醒"));
|
|
47
|
+
assert.ok(md.includes("## 接手说明"));
|
|
48
|
+
assert.ok(md.includes("Generated:"));
|
|
49
|
+
});
|
|
50
|
+
it("空 subgoals → 已完成/未完成显示无", () => {
|
|
51
|
+
const goal = makeGoalStatus([]);
|
|
52
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
53
|
+
const completedSection = md.split("## 已完成子目标")[1].split("##")[0];
|
|
54
|
+
assert.ok(completedSection.includes("无"));
|
|
55
|
+
const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
|
|
56
|
+
assert.ok(incompleteSection.includes("无"));
|
|
57
|
+
});
|
|
58
|
+
it("有 accepted subgoal → 出现在已完成子目标", () => {
|
|
59
|
+
const goal = makeGoalStatus([
|
|
60
|
+
makeSubgoal({
|
|
61
|
+
id: "subgoal-001",
|
|
62
|
+
title: "Implement feature A",
|
|
63
|
+
status: "accepted",
|
|
64
|
+
accepted_at: "2026-01-01T00:00:00.000Z",
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
68
|
+
const completedSection = md.split("## 已完成子目标")[1].split("##")[0];
|
|
69
|
+
assert.ok(completedSection.includes("subgoal-001"));
|
|
70
|
+
assert.ok(completedSection.includes("Implement feature A"));
|
|
71
|
+
assert.ok(completedSection.includes("2026-01-01T00:00:00.000Z"));
|
|
72
|
+
});
|
|
73
|
+
it("有 running subgoal → 出现在未完成子目标和风险提醒", () => {
|
|
74
|
+
const goal = makeGoalStatus([
|
|
75
|
+
makeSubgoal({
|
|
76
|
+
id: "subgoal-001",
|
|
77
|
+
title: "WIP task",
|
|
78
|
+
status: "running",
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
82
|
+
const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
|
|
83
|
+
assert.ok(incompleteSection.includes("subgoal-001"));
|
|
84
|
+
assert.ok(incompleteSection.includes("running"));
|
|
85
|
+
const riskSection = md.split("## 风险提醒")[1].split("##")[0];
|
|
86
|
+
assert.ok(riskSection.includes("subgoal-001"));
|
|
87
|
+
assert.ok(riskSection.includes("running"));
|
|
88
|
+
});
|
|
89
|
+
it("有 rejected subgoal → 出现在已拒绝子目标", () => {
|
|
90
|
+
const goal = makeGoalStatus([
|
|
91
|
+
makeSubgoal({
|
|
92
|
+
id: "subgoal-001",
|
|
93
|
+
title: "Failed task",
|
|
94
|
+
status: "rejected",
|
|
95
|
+
rejected_reason: "Tests failed",
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
99
|
+
const rejectedSection = md.split("## 已拒绝子目标")[1].split("##")[0];
|
|
100
|
+
assert.ok(rejectedSection.includes("subgoal-001"));
|
|
101
|
+
assert.ok(rejectedSection.includes("Failed task"));
|
|
102
|
+
assert.ok(rejectedSection.includes("Tests failed"));
|
|
103
|
+
});
|
|
104
|
+
it("无 rejected subgoal → 已拒绝子目标显示无", () => {
|
|
105
|
+
const goal = makeGoalStatus([]);
|
|
106
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
107
|
+
const rejectedSection = md.split("## 已拒绝子目标")[1].split("##")[0];
|
|
108
|
+
assert.ok(rejectedSection.includes("无"));
|
|
109
|
+
});
|
|
110
|
+
it("有 needs_fix subgoal → 出现在当前阻塞点和风险提醒", () => {
|
|
111
|
+
const goal = makeGoalStatus([
|
|
112
|
+
makeSubgoal({
|
|
113
|
+
id: "subgoal-001",
|
|
114
|
+
title: "Needs fix task",
|
|
115
|
+
status: "needs_fix",
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
119
|
+
const blockerSection = md.split("## 当前阻塞点")[1].split("##")[0];
|
|
120
|
+
assert.ok(blockerSection.includes("subgoal-001"));
|
|
121
|
+
assert.ok(blockerSection.includes("Needs fix task"));
|
|
122
|
+
const riskSection = md.split("## 风险提醒")[1].split("##")[0];
|
|
123
|
+
assert.ok(riskSection.includes("subgoal-001"));
|
|
124
|
+
assert.ok(riskSection.includes("needs_fix"));
|
|
125
|
+
});
|
|
126
|
+
it("有 recentDiff → 出现在最近一次 diff 摘要", () => {
|
|
127
|
+
const goal = makeGoalStatus([]);
|
|
128
|
+
const diff = "diff --git a/src/foo.ts b/src/foo.ts\n+console.log('hello')";
|
|
129
|
+
const md = generateHandoff("goal_test_001", goal, diff);
|
|
130
|
+
const diffSection = md.split("## 最近一次 diff 摘要")[1].split("##")[0];
|
|
131
|
+
assert.ok(diffSection.includes("src/foo.ts"));
|
|
132
|
+
assert.ok(diffSection.includes("console.log"));
|
|
133
|
+
});
|
|
134
|
+
it("无 recentDiff → 显示暂无", () => {
|
|
135
|
+
const goal = makeGoalStatus([]);
|
|
136
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
137
|
+
const diffSection = md.split("## 最近一次 diff 摘要")[1].split("##")[0];
|
|
138
|
+
assert.ok(diffSection.includes("暂无"));
|
|
139
|
+
});
|
|
140
|
+
it("有 recentTestResult → 出现在最近一次测试结果", () => {
|
|
141
|
+
const goal = makeGoalStatus([]);
|
|
142
|
+
const testResult = "PASS: 42 tests passed, 0 failed";
|
|
143
|
+
const md = generateHandoff("goal_test_001", goal, undefined, testResult);
|
|
144
|
+
const testSection = md.split("## 最近一次测试结果")[1].split("##")[0];
|
|
145
|
+
assert.ok(testSection.includes("42 tests passed"));
|
|
146
|
+
});
|
|
147
|
+
it("无 recentTestResult → 显示暂无", () => {
|
|
148
|
+
const goal = makeGoalStatus([]);
|
|
149
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
150
|
+
const testSection = md.split("## 最近一次测试结果")[1].split("##")[0];
|
|
151
|
+
assert.ok(testSection.includes("暂无"));
|
|
152
|
+
});
|
|
153
|
+
it("接手说明包含 goalId", () => {
|
|
154
|
+
const goal = makeGoalStatus([]);
|
|
155
|
+
const md = generateHandoff("goal_handoff_999", goal);
|
|
156
|
+
const handoffSection = md.split("## 接手说明")[1];
|
|
157
|
+
assert.ok(handoffSection.includes("goal_handoff_999"));
|
|
158
|
+
assert.ok(handoffSection.includes("read_goal"));
|
|
159
|
+
assert.ok(handoffSection.includes("suggest_next_subgoal"));
|
|
160
|
+
});
|
|
161
|
+
it("当前 Goal 章节包含 goal_id/title/status/repo_path", () => {
|
|
162
|
+
const goal = makeGoalStatus([], {
|
|
163
|
+
goal_id: "goal_abc",
|
|
164
|
+
title: "My Custom Goal",
|
|
165
|
+
status: "active",
|
|
166
|
+
repo_path: "/custom/repo",
|
|
167
|
+
});
|
|
168
|
+
const md = generateHandoff("goal_abc", goal);
|
|
169
|
+
const goalSection = md.split("## 当前 Goal")[1].split("##")[0];
|
|
170
|
+
assert.ok(goalSection.includes("goal_abc"));
|
|
171
|
+
assert.ok(goalSection.includes("My Custom Goal"));
|
|
172
|
+
assert.ok(goalSection.includes("active"));
|
|
173
|
+
assert.ok(goalSection.includes("/custom/repo"));
|
|
174
|
+
});
|
|
175
|
+
it("有 ready subgoal → 下一步建议包含该 subgoal", () => {
|
|
176
|
+
const goal = makeGoalStatus([
|
|
177
|
+
makeSubgoal({ id: "subgoal-001", title: "Ready task", depends_on: [] }),
|
|
178
|
+
]);
|
|
179
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
180
|
+
const suggestSection = md.split("## 下一步建议")[1].split("##")[0];
|
|
181
|
+
assert.ok(suggestSection.includes("subgoal-001"));
|
|
182
|
+
assert.ok(suggestSection.includes("Ready task"));
|
|
183
|
+
});
|
|
184
|
+
it("未完成子目标显示 depends_on", () => {
|
|
185
|
+
const goal = makeGoalStatus([
|
|
186
|
+
makeSubgoal({ id: "A", status: "accepted", accepted_at: "2026-01-01T00:00:00.000Z" }),
|
|
187
|
+
makeSubgoal({ id: "B", title: "Depends on A", status: "ready", depends_on: ["A"] }),
|
|
188
|
+
]);
|
|
189
|
+
const md = generateHandoff("goal_test_001", goal);
|
|
190
|
+
const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
|
|
191
|
+
assert.ok(incompleteSection.includes("B"));
|
|
192
|
+
assert.ok(incompleteSection.includes("depends_on: A"));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe("exportHandoff", () => {
|
|
196
|
+
let tempDir;
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-handoff-"));
|
|
199
|
+
});
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
it("写入 handoff.md 文件到 .patchwarden/goals/{goalId}/", () => {
|
|
204
|
+
const goal = makeGoalStatus([
|
|
205
|
+
makeSubgoal({ id: "subgoal-001", title: "Task A", depends_on: [] }),
|
|
206
|
+
]);
|
|
207
|
+
const result = exportHandoff("goal_export_001", goal, tempDir);
|
|
208
|
+
const expectedPath = resolve(tempDir, ".patchwarden", "goals", "goal_export_001", "handoff.md");
|
|
209
|
+
assert.equal(result.handoff_path, expectedPath);
|
|
210
|
+
assert.ok(existsSync(expectedPath));
|
|
211
|
+
const fileContent = readFileSync(expectedPath, "utf-8");
|
|
212
|
+
assert.ok(fileContent.includes("# Goal Session Handoff"));
|
|
213
|
+
assert.ok(fileContent.includes("goal_export_001"));
|
|
214
|
+
});
|
|
215
|
+
it("返回的 handoff_path 是绝对路径", () => {
|
|
216
|
+
const goal = makeGoalStatus([]);
|
|
217
|
+
const result = exportHandoff("goal_export_002", goal, tempDir);
|
|
218
|
+
assert.ok(result.handoff_path.includes(tempDir));
|
|
219
|
+
assert.ok(result.handoff_path.endsWith("handoff.md"));
|
|
220
|
+
});
|
|
221
|
+
it("content_preview 不超过 500 字符(加省略号)", () => {
|
|
222
|
+
const goal = makeGoalStatus([
|
|
223
|
+
makeSubgoal({ id: "subgoal-001", title: "A".repeat(200), depends_on: [] }),
|
|
224
|
+
makeSubgoal({ id: "subgoal-002", title: "B".repeat(200), depends_on: [] }),
|
|
225
|
+
makeSubgoal({ id: "subgoal-003", title: "C".repeat(200), depends_on: [] }),
|
|
226
|
+
]);
|
|
227
|
+
const result = exportHandoff("goal_export_003", goal, tempDir);
|
|
228
|
+
assert.ok(result.content_preview.length > 0);
|
|
229
|
+
assert.ok(result.content_preview.endsWith("..."));
|
|
230
|
+
assert.ok(result.content_preview.length <= 503); // 500 + "..."
|
|
231
|
+
});
|
|
232
|
+
it("content_preview 是文档前 500 字符的截取", () => {
|
|
233
|
+
const goal = makeGoalStatus([]);
|
|
234
|
+
const result = exportHandoff("goal_export_004", goal, tempDir);
|
|
235
|
+
const fileContent = readFileSync(result.handoff_path, "utf-8");
|
|
236
|
+
if (fileContent.length > 500) {
|
|
237
|
+
assert.equal(result.content_preview, fileContent.slice(0, 500) + "...");
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
assert.equal(result.content_preview, fileContent);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
it("文件内容与 generateHandoff 一致", () => {
|
|
244
|
+
const goal = makeGoalStatus([
|
|
245
|
+
makeSubgoal({ id: "subgoal-001", title: "Verify content", depends_on: [] }),
|
|
246
|
+
]);
|
|
247
|
+
const expected = generateHandoff("goal_export_005", goal);
|
|
248
|
+
const result = exportHandoff("goal_export_005", goal, tempDir);
|
|
249
|
+
const fileContent = readFileSync(result.handoff_path, "utf-8");
|
|
250
|
+
// 排除 Generated 时间戳行(两次调用可能跨越毫秒边界)
|
|
251
|
+
const stripGenerated = (s) => s.replace(/^Generated: .*$/m, "Generated: <ts>");
|
|
252
|
+
assert.equal(stripGenerated(fileContent), stripGenerated(expected));
|
|
253
|
+
});
|
|
254
|
+
it("创建多层目录结构", () => {
|
|
255
|
+
const goal = makeGoalStatus([]);
|
|
256
|
+
const result = exportHandoff("goal_deep_001", goal, tempDir);
|
|
257
|
+
const goalDir = resolve(tempDir, ".patchwarden", "goals", "goal_deep_001");
|
|
258
|
+
const stat = statSync(goalDir);
|
|
259
|
+
assert.ok(stat.isDirectory());
|
|
260
|
+
assert.ok(existsSync(result.handoff_path));
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { invokeDiscoveredTool, } from "../../tools/invokeDiscoveredTool.js";
|
|
4
|
+
import { issueToken, clearAllTokens } from "../../security/discoveryTokenStore.js";
|
|
5
|
+
import { computeSchemaDigest } from "../../tools/toolRegistry.js";
|
|
6
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
7
|
+
function makeToolDef(name) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
description: `Mock ${name} tool`,
|
|
11
|
+
inputSchema: { type: "object", properties: {} },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const MOCK_TOOLS = [
|
|
15
|
+
"safe_status",
|
|
16
|
+
"get_diff",
|
|
17
|
+
"save_plan",
|
|
18
|
+
"invoke_discovered_tool",
|
|
19
|
+
"__test_credential_tool",
|
|
20
|
+
].map(makeToolDef);
|
|
21
|
+
function makeContext(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
tools: MOCK_TOOLS,
|
|
24
|
+
profile: "full",
|
|
25
|
+
dispatch: async () => ({ ok: true }),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function issueTokenFor(toolName, risk) {
|
|
30
|
+
return issueToken({
|
|
31
|
+
toolName,
|
|
32
|
+
risk,
|
|
33
|
+
query: "test query",
|
|
34
|
+
schemaDigest: computeSchemaDigest({}),
|
|
35
|
+
profile: "full",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function makeInput(overrides = {}) {
|
|
39
|
+
return {
|
|
40
|
+
toolName: "safe_status",
|
|
41
|
+
arguments: {},
|
|
42
|
+
discoveryToken: "",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ── Tests ─────────────────────────────────────────────────────────
|
|
47
|
+
describe("invokeDiscoveredTool", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
clearAllTokens();
|
|
50
|
+
});
|
|
51
|
+
describe("合法调用", () => {
|
|
52
|
+
it("readonly 工具 + 有效 token + dispatch 成功 → ok=true", async () => {
|
|
53
|
+
const token = issueTokenFor("safe_status", "readonly");
|
|
54
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
55
|
+
toolName: "safe_status",
|
|
56
|
+
arguments: { task_id: "t-1" },
|
|
57
|
+
discoveryToken: token,
|
|
58
|
+
}), makeContext({
|
|
59
|
+
dispatch: async () => ({ status: "pending" }),
|
|
60
|
+
}));
|
|
61
|
+
assert.equal(result.ok, true);
|
|
62
|
+
assert.ok(result.result, "result should be present");
|
|
63
|
+
assert.deepEqual(result.result, { status: "pending" });
|
|
64
|
+
assert.equal(result.invocation_log_written, true);
|
|
65
|
+
assert.equal(result.error, undefined);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("token 校验", () => {
|
|
69
|
+
it("token 不存在 → ok=false, reason=token_not_found", async () => {
|
|
70
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
71
|
+
toolName: "safe_status",
|
|
72
|
+
arguments: {},
|
|
73
|
+
discoveryToken: "tok",
|
|
74
|
+
}), makeContext());
|
|
75
|
+
assert.equal(result.ok, false);
|
|
76
|
+
assert.equal(result.error?.reason, "token_not_found");
|
|
77
|
+
assert.equal(result.invocation_log_written, true);
|
|
78
|
+
});
|
|
79
|
+
it("token toolName 不匹配 → ok=false, reason=token_tool_mismatch", async () => {
|
|
80
|
+
const token = issueTokenFor("safe_status", "readonly");
|
|
81
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
82
|
+
toolName: "get_diff",
|
|
83
|
+
arguments: { task_id: "t-1" },
|
|
84
|
+
discoveryToken: token,
|
|
85
|
+
}), makeContext());
|
|
86
|
+
assert.equal(result.ok, false);
|
|
87
|
+
assert.equal(result.error?.reason, "token_tool_mismatch");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("toolMeta 查找", () => {
|
|
91
|
+
it("工具未注册 → ok=false, reason=tool_not_registered", async () => {
|
|
92
|
+
const token = issueTokenFor("safe_status", "readonly");
|
|
93
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
94
|
+
toolName: "safe_status",
|
|
95
|
+
arguments: {},
|
|
96
|
+
discoveryToken: token,
|
|
97
|
+
}), makeContext({
|
|
98
|
+
tools: [makeToolDef("get_diff")],
|
|
99
|
+
}));
|
|
100
|
+
assert.equal(result.ok, false);
|
|
101
|
+
assert.equal(result.error?.reason, "tool_not_registered");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe("防递归", () => {
|
|
105
|
+
it("toolName=invoke_discovered_tool → ok=false, reason=recursive_invocation_blocked", async () => {
|
|
106
|
+
const token = issueTokenFor("invoke_discovered_tool", "command");
|
|
107
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
108
|
+
toolName: "invoke_discovered_tool",
|
|
109
|
+
arguments: {},
|
|
110
|
+
discoveryToken: token,
|
|
111
|
+
}), makeContext());
|
|
112
|
+
assert.equal(result.ok, false);
|
|
113
|
+
assert.equal(result.error?.reason, "recursive_invocation_blocked");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("守卫校验", () => {
|
|
117
|
+
it("credential_sensitive 工具 → ok=false, reason=credential_sensitive_blocked", async () => {
|
|
118
|
+
const token = issueTokenFor("__test_credential_tool", "credential_sensitive");
|
|
119
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
120
|
+
toolName: "__test_credential_tool",
|
|
121
|
+
arguments: {},
|
|
122
|
+
discoveryToken: token,
|
|
123
|
+
}), makeContext());
|
|
124
|
+
assert.equal(result.ok, false);
|
|
125
|
+
assert.equal(result.error?.reason, "credential_sensitive_blocked");
|
|
126
|
+
});
|
|
127
|
+
it("workspace_write 缺 assessmentId → ok=false, reason=assessment_required", async () => {
|
|
128
|
+
const token = issueTokenFor("save_plan", "workspace_write");
|
|
129
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
130
|
+
toolName: "save_plan",
|
|
131
|
+
arguments: {},
|
|
132
|
+
discoveryToken: token,
|
|
133
|
+
}), makeContext());
|
|
134
|
+
assert.equal(result.ok, false);
|
|
135
|
+
assert.equal(result.error?.reason, "assessment_required");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("dispatch", () => {
|
|
139
|
+
it("dispatch 抛错 → ok=false, reason=dispatch_error", async () => {
|
|
140
|
+
const token = issueTokenFor("safe_status", "readonly");
|
|
141
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
142
|
+
toolName: "safe_status",
|
|
143
|
+
arguments: {},
|
|
144
|
+
discoveryToken: token,
|
|
145
|
+
}), makeContext({
|
|
146
|
+
dispatch: async () => {
|
|
147
|
+
throw new Error("boom");
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
assert.equal(result.ok, false);
|
|
151
|
+
assert.equal(result.error?.reason, "dispatch_error");
|
|
152
|
+
assert.ok(result.error?.message.includes("boom"), `expected message to contain "boom", got "${result.error?.message}"`);
|
|
153
|
+
});
|
|
154
|
+
it("成功调用 → invocation_log_written=true", async () => {
|
|
155
|
+
const token = issueTokenFor("safe_status", "readonly");
|
|
156
|
+
const result = await invokeDiscoveredTool(makeInput({
|
|
157
|
+
toolName: "safe_status",
|
|
158
|
+
arguments: { task_id: "t-log" },
|
|
159
|
+
discoveryToken: token,
|
|
160
|
+
}), makeContext({
|
|
161
|
+
dispatch: async () => ({ status: "done" }),
|
|
162
|
+
}));
|
|
163
|
+
assert.equal(result.ok, true);
|
|
164
|
+
assert.equal(result.invocation_log_written, true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
2
|
import { strict as assert } from "node:assert";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { Logger, logUnhandledError, installGlobalHandlers, computeArgumentsDigest, logToolInvocation, } from "../../logging.js";
|
|
4
7
|
// ── Test helpers ──────────────────────────────────────────────────
|
|
5
8
|
/**
|
|
6
9
|
* Capture everything written to stderr during `fn` and return it as an
|
|
@@ -140,9 +143,10 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
|
|
|
140
143
|
it("does not log args by default (verbose mode off)", () => {
|
|
141
144
|
delete process.env.PATCHWARDEN_VERBOSE_LOG;
|
|
142
145
|
const log = new Logger();
|
|
146
|
+
const fakeToken = "sk_" + "FAKE_TOKEN_VALUE_123456";
|
|
143
147
|
const sensitiveArgs = {
|
|
144
148
|
prompt: "fix the bug",
|
|
145
|
-
token:
|
|
149
|
+
token: fakeToken,
|
|
146
150
|
};
|
|
147
151
|
const entries = captureJson(() => {
|
|
148
152
|
log.audit("createTask", true, 100, undefined, "task-003", sensitiveArgs);
|
|
@@ -153,14 +157,15 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
|
|
|
153
157
|
assert.equal(entry.args, undefined);
|
|
154
158
|
// The raw token must not appear anywhere in the serialized entry
|
|
155
159
|
const raw = JSON.stringify(entry);
|
|
156
|
-
assert.ok(!raw.includes(
|
|
160
|
+
assert.ok(!raw.includes(fakeToken), "raw token must not leak into log output");
|
|
157
161
|
});
|
|
158
162
|
it("logs sanitized args when PATCHWARDEN_VERBOSE_LOG=true", () => {
|
|
159
163
|
process.env.PATCHWARDEN_VERBOSE_LOG = "true";
|
|
160
164
|
const log = new Logger();
|
|
165
|
+
const fakeToken = "sk_" + "FAKE_TOKEN_VALUE_123456";
|
|
161
166
|
const sensitiveArgs = {
|
|
162
167
|
prompt: "fix the bug",
|
|
163
|
-
token:
|
|
168
|
+
token: fakeToken,
|
|
164
169
|
};
|
|
165
170
|
const entries = captureJson(() => {
|
|
166
171
|
log.audit("createTask", true, 100, undefined, "task-004", sensitiveArgs);
|
|
@@ -171,7 +176,7 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
|
|
|
171
176
|
assert.ok(entry.args !== undefined, "args should be logged in verbose mode");
|
|
172
177
|
const argsStr = String(entry.args);
|
|
173
178
|
assert.ok(argsStr.includes("[REDACTED TOKEN]"), "sensitive token must be redacted");
|
|
174
|
-
assert.ok(!argsStr.includes(
|
|
179
|
+
assert.ok(!argsStr.includes(fakeToken), "raw token must not appear in args");
|
|
175
180
|
// non-sensitive content is preserved
|
|
176
181
|
assert.ok(argsStr.includes("fix the bug"), "non-sensitive args content is preserved");
|
|
177
182
|
});
|
|
@@ -273,3 +278,120 @@ describe("installGlobalHandlers", () => {
|
|
|
273
278
|
}
|
|
274
279
|
});
|
|
275
280
|
});
|
|
281
|
+
// ── computeArgumentsDigest ───────────────────────────────────────
|
|
282
|
+
describe("computeArgumentsDigest", () => {
|
|
283
|
+
it("returns the same digest for identical arguments (stability)", () => {
|
|
284
|
+
const args = { b: 2, a: 1, nested: { y: "yes", x: [1, 2] } };
|
|
285
|
+
const d1 = computeArgumentsDigest(args);
|
|
286
|
+
const d2 = computeArgumentsDigest(args);
|
|
287
|
+
assert.equal(d1, d2);
|
|
288
|
+
});
|
|
289
|
+
it("returns the same digest regardless of key order (stable serialization)", () => {
|
|
290
|
+
const d1 = computeArgumentsDigest({ a: 1, b: 2 });
|
|
291
|
+
const d2 = computeArgumentsDigest({ b: 2, a: 1 });
|
|
292
|
+
assert.equal(d1, d2);
|
|
293
|
+
});
|
|
294
|
+
it("returns different digests for different arguments", () => {
|
|
295
|
+
const d1 = computeArgumentsDigest({ a: 1 });
|
|
296
|
+
const d2 = computeArgumentsDigest({ a: 2 });
|
|
297
|
+
assert.notEqual(d1, d2);
|
|
298
|
+
});
|
|
299
|
+
it("returns a 'sha256:' prefixed digest", () => {
|
|
300
|
+
const digest = computeArgumentsDigest({ foo: "bar" });
|
|
301
|
+
assert.ok(digest.startsWith("sha256:"), `digest should start with "sha256:", got: ${digest}`);
|
|
302
|
+
// full format: sha256: + 64 lowercase hex chars
|
|
303
|
+
assert.match(digest, /^sha256:[a-f0-9]{64}$/);
|
|
304
|
+
});
|
|
305
|
+
it("does not leak raw argument values into the digest string", () => {
|
|
306
|
+
const fakeToken = "tok-value";
|
|
307
|
+
const sensitiveArgs = { token: fakeToken, prompt: "hello-world" };
|
|
308
|
+
const digest = computeArgumentsDigest(sensitiveArgs);
|
|
309
|
+
assert.ok(!digest.includes(fakeToken), "digest must not leak raw token value");
|
|
310
|
+
assert.ok(!digest.includes("hello-world"), "digest must not leak raw prompt value");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
// ── logToolInvocation ────────────────────────────────────────────
|
|
314
|
+
describe("logToolInvocation", () => {
|
|
315
|
+
let tempDir;
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-invlog-"));
|
|
318
|
+
});
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
321
|
+
});
|
|
322
|
+
function sampleEntry(overrides = {}) {
|
|
323
|
+
return {
|
|
324
|
+
timestamp: new Date().toISOString(),
|
|
325
|
+
toolName: "read_workspace_file",
|
|
326
|
+
discoveryToken: "tok",
|
|
327
|
+
risk: "workspace_read_sensitive",
|
|
328
|
+
profile: "full",
|
|
329
|
+
arguments_digest: "sha256:" + "0".repeat(64),
|
|
330
|
+
result: "ok",
|
|
331
|
+
duration_ms: 42,
|
|
332
|
+
...overrides,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
it("writes an entry to invocation.log inside the provided logsDir", () => {
|
|
336
|
+
const logsDir = join(tempDir, ".patchwarden", "logs");
|
|
337
|
+
const entry = sampleEntry();
|
|
338
|
+
logToolInvocation(entry, { logsDir });
|
|
339
|
+
const logPath = join(logsDir, "invocation.log");
|
|
340
|
+
assert.ok(existsSync(logPath), "invocation.log should be created");
|
|
341
|
+
const content = readFileSync(logPath, "utf-8");
|
|
342
|
+
const lines = content.trim().split("\n");
|
|
343
|
+
assert.equal(lines.length, 1, "exactly one JSON line should be written");
|
|
344
|
+
const parsed = JSON.parse(lines[0]);
|
|
345
|
+
assert.equal(parsed.toolName, "read_workspace_file");
|
|
346
|
+
assert.equal(parsed.discoveryToken, "tok");
|
|
347
|
+
assert.equal(parsed.risk, "workspace_read_sensitive");
|
|
348
|
+
assert.equal(parsed.profile, "full");
|
|
349
|
+
assert.equal(parsed.result, "ok");
|
|
350
|
+
assert.equal(parsed.duration_ms, 42);
|
|
351
|
+
assert.match(parsed.arguments_digest, /^sha256:[a-f0-9]{64}$/);
|
|
352
|
+
assert.ok(!Number.isNaN(Date.parse(parsed.timestamp)));
|
|
353
|
+
});
|
|
354
|
+
it("appends multiple entries as JSON Lines", () => {
|
|
355
|
+
const logsDir = join(tempDir, "logs");
|
|
356
|
+
logToolInvocation(sampleEntry({ duration_ms: 1 }), { logsDir });
|
|
357
|
+
logToolInvocation(sampleEntry({ duration_ms: 2, result: "error", error_code: "blocked" }), { logsDir });
|
|
358
|
+
const content = readFileSync(join(logsDir, "invocation.log"), "utf-8");
|
|
359
|
+
const lines = content.trim().split("\n");
|
|
360
|
+
assert.equal(lines.length, 2);
|
|
361
|
+
const first = JSON.parse(lines[0]);
|
|
362
|
+
const second = JSON.parse(lines[1]);
|
|
363
|
+
assert.equal(first.duration_ms, 1);
|
|
364
|
+
assert.equal(first.result, "ok");
|
|
365
|
+
assert.equal(second.duration_ms, 2);
|
|
366
|
+
assert.equal(second.result, "error");
|
|
367
|
+
assert.equal(second.error_code, "blocked");
|
|
368
|
+
});
|
|
369
|
+
it("includes allowedScope and error_code when provided", () => {
|
|
370
|
+
const logsDir = join(tempDir, "logs");
|
|
371
|
+
logToolInvocation(sampleEntry({
|
|
372
|
+
allowedScope: ["src/", "docs/"],
|
|
373
|
+
result: "error",
|
|
374
|
+
error_code: "scope_violation",
|
|
375
|
+
}), { logsDir });
|
|
376
|
+
const parsed = JSON.parse(readFileSync(join(logsDir, "invocation.log"), "utf-8").trim());
|
|
377
|
+
assert.deepEqual(parsed.allowedScope, ["src/", "docs/"]);
|
|
378
|
+
assert.equal(parsed.error_code, "scope_violation");
|
|
379
|
+
});
|
|
380
|
+
it("omits error_code and allowedScope when not provided", () => {
|
|
381
|
+
const logsDir = join(tempDir, "logs");
|
|
382
|
+
logToolInvocation(sampleEntry(), { logsDir });
|
|
383
|
+
const parsed = JSON.parse(readFileSync(join(logsDir, "invocation.log"), "utf-8").trim());
|
|
384
|
+
assert.equal(parsed.error_code, undefined);
|
|
385
|
+
assert.equal(parsed.allowedScope, undefined);
|
|
386
|
+
});
|
|
387
|
+
it("does not throw when the log directory cannot be created", () => {
|
|
388
|
+
// Create a file, then attempt to use a path beneath it as logsDir.
|
|
389
|
+
// mkdirSync will fail with ENOTDIR because the parent is a file.
|
|
390
|
+
const blockingFile = join(tempDir, "blocker");
|
|
391
|
+
writeFileSync(blockingFile, "x", "utf-8");
|
|
392
|
+
const impossibleLogsDir = join(blockingFile, "subdir");
|
|
393
|
+
assert.doesNotThrow(() => {
|
|
394
|
+
logToolInvocation(sampleEntry(), { logsDir: impossibleLogsDir });
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { runPostTaskCleanup } from "../../runner/postTaskCleanup.js";
|
|
8
|
+
describe("postTaskCleanup", () => {
|
|
9
|
+
it("removes untracked low-risk artifacts and skips tracked or excluded paths", () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "pw-cleanup-"));
|
|
11
|
+
try {
|
|
12
|
+
execFileSync("git", ["init"], { cwd: root, stdio: "ignore" });
|
|
13
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root, stdio: "ignore" });
|
|
14
|
+
execFileSync("git", ["config", "user.name", "PatchWarden Test"], { cwd: root, stdio: "ignore" });
|
|
15
|
+
mkdirSync(join(root, "tracked", "__pycache__"), { recursive: true });
|
|
16
|
+
writeFileSync(join(root, "tracked", "__pycache__", "keep.pyc"), "tracked", "utf-8");
|
|
17
|
+
execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" });
|
|
18
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: root, stdio: "ignore" });
|
|
19
|
+
mkdirSync(join(root, "backend", "__pycache__"), { recursive: true });
|
|
20
|
+
mkdirSync(join(root, ".venv", "__pycache__"), { recursive: true });
|
|
21
|
+
mkdirSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__"), { recursive: true });
|
|
22
|
+
mkdirSync(join(root, "node_modules", "pkg", "__pycache__"), { recursive: true });
|
|
23
|
+
mkdirSync(join(root, "docs", "__pycache__"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, "backend", "__pycache__", "drop.pyc"), "drop", "utf-8");
|
|
25
|
+
writeFileSync(join(root, ".venv", "__pycache__", "skip.pyc"), "skip", "utf-8");
|
|
26
|
+
writeFileSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__", "skip.pyc"), "skip", "utf-8");
|
|
27
|
+
writeFileSync(join(root, "node_modules", "pkg", "__pycache__", "skip.pyc"), "skip", "utf-8");
|
|
28
|
+
writeFileSync(join(root, "docs", "__pycache__", "skip.pyc"), "skip", "utf-8");
|
|
29
|
+
const taskDir = join(root, ".patchwarden", "tasks", "task-1");
|
|
30
|
+
mkdirSync(taskDir, { recursive: true });
|
|
31
|
+
const report = runPostTaskCleanup(root, taskDir);
|
|
32
|
+
assert.ok(report.removed.some((entry) => entry.path === "backend/__pycache__"));
|
|
33
|
+
assert.ok(report.skipped.some((entry) => entry.path === "tracked/__pycache__" && entry.skip_reason === "tracked_by_git"));
|
|
34
|
+
assert.equal(report.source_files_touched, 0);
|
|
35
|
+
assert.ok(!existsSync(join(root, "backend", "__pycache__")));
|
|
36
|
+
assert.ok(existsSync(join(root, "tracked", "__pycache__", "keep.pyc")));
|
|
37
|
+
assert.ok(existsSync(join(root, ".venv", "__pycache__", "skip.pyc")));
|
|
38
|
+
assert.ok(existsSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__", "skip.pyc")));
|
|
39
|
+
assert.ok(existsSync(join(root, "node_modules", "pkg", "__pycache__", "skip.pyc")));
|
|
40
|
+
assert.ok(existsSync(join(root, "docs", "__pycache__", "skip.pyc")));
|
|
41
|
+
const written = JSON.parse(readFileSync(join(taskDir, "post-task-cleanup.json"), "utf-8"));
|
|
42
|
+
assert.equal(written.enabled, true);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|