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
package/dist/runner/runTask.js
CHANGED
|
@@ -8,10 +8,13 @@ import { writeTaskProgress } from "../taskProgress.js";
|
|
|
8
8
|
import { writeTaskRuntime } from "../taskRuntime.js";
|
|
9
9
|
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
10
10
|
import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
|
|
11
|
-
import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, } from "./changeCapture.js";
|
|
11
|
+
import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, extractExternalDirtyFiles, findNewExternalDirtyFiles, buildArtifactManifest, groupChangedFiles, } from "./changeCapture.js";
|
|
12
|
+
import { errorPayload } from "../errors.js";
|
|
13
|
+
import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
|
|
12
14
|
const HEARTBEAT_INTERVAL_MS = 2000;
|
|
13
15
|
const GRACEFUL_KILL_MS = 2000;
|
|
14
16
|
const MAX_CAPTURE_CHARS = 100_000;
|
|
17
|
+
const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
|
|
15
18
|
export async function runTask(taskId) {
|
|
16
19
|
const config = getConfig();
|
|
17
20
|
const tasksDir = getTasksDir(config);
|
|
@@ -34,7 +37,7 @@ export async function runTask(taskId) {
|
|
|
34
37
|
timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
|
|
35
38
|
}
|
|
36
39
|
catch (error) {
|
|
37
|
-
return failBeforeExecution(taskId, taskDir, errorMessage(error));
|
|
40
|
+
return failBeforeExecution(taskId, taskDir, errorMessage(error), error);
|
|
38
41
|
}
|
|
39
42
|
const startedAtMs = Date.now();
|
|
40
43
|
const deadlineMs = startedAtMs + timeoutSeconds * 1000;
|
|
@@ -44,14 +47,14 @@ export async function runTask(taskId) {
|
|
|
44
47
|
}
|
|
45
48
|
catch (error) {
|
|
46
49
|
const message = `repo_path validation failed: ${errorMessage(error)}`;
|
|
47
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
50
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
48
51
|
}
|
|
49
52
|
try {
|
|
50
53
|
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
|
|
51
54
|
}
|
|
52
55
|
catch (error) {
|
|
53
56
|
const message = `verification metadata validation failed: ${errorMessage(error)}`;
|
|
54
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
57
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
55
58
|
}
|
|
56
59
|
// ── Assessment freshness revalidation before execution ──
|
|
57
60
|
const assessmentId = String(initialStatus.assessment_id || "");
|
|
@@ -66,7 +69,7 @@ export async function runTask(taskId) {
|
|
|
66
69
|
}
|
|
67
70
|
catch (error) {
|
|
68
71
|
const message = `assessment validation error: ${errorMessage(error)}`;
|
|
69
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
72
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
updateStatus(taskDir, {
|
|
@@ -81,21 +84,25 @@ export async function runTask(taskId) {
|
|
|
81
84
|
setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
|
|
82
85
|
let beforeSnapshot;
|
|
83
86
|
let beforeWorkspaceSnapshot;
|
|
87
|
+
let externalDirtyBaseline = [];
|
|
84
88
|
try {
|
|
85
89
|
beforeSnapshot = captureRepoSnapshot(repoPath);
|
|
86
90
|
beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
|
|
87
91
|
writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
|
|
88
92
|
writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
|
|
93
|
+
// Phase 4: Record external dirty files as baseline before task execution
|
|
94
|
+
externalDirtyBaseline = extractExternalDirtyFiles(beforeWorkspaceSnapshot, repoPath, wsRoot);
|
|
89
95
|
}
|
|
90
96
|
catch (error) {
|
|
91
97
|
const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
|
|
92
|
-
return failBeforeExecution(taskId, taskDir, message);
|
|
98
|
+
return failBeforeExecution(taskId, taskDir, message, error);
|
|
93
99
|
}
|
|
94
100
|
let agentResult = null;
|
|
95
101
|
let testResult = skippedTest(testCommand, repoPath, "Agent did not complete successfully.");
|
|
96
102
|
const verifyResults = [];
|
|
97
103
|
let finalStatus = "failed";
|
|
98
104
|
let finalError = null;
|
|
105
|
+
let lastCaughtError = null;
|
|
99
106
|
try {
|
|
100
107
|
const planFile = resolve(plansDir, planId, "plan.md");
|
|
101
108
|
if (!existsSync(planFile))
|
|
@@ -171,19 +178,45 @@ export async function runTask(taskId) {
|
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
180
|
catch (error) {
|
|
181
|
+
lastCaughtError = error;
|
|
174
182
|
finalError = errorMessage(error);
|
|
175
183
|
}
|
|
176
184
|
setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
|
|
185
|
+
const artifactCollectionStartedAt = new Date().toISOString();
|
|
177
186
|
let changes;
|
|
187
|
+
let artifactStatus = "collected";
|
|
188
|
+
let artifactCollectionError = null;
|
|
189
|
+
let timeoutHandle;
|
|
178
190
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
// Phase 5: Wrap artifact collection with a timeout
|
|
192
|
+
const collectionResult = await Promise.race([
|
|
193
|
+
Promise.resolve().then(() => {
|
|
194
|
+
const afterSnapshot = captureRepoSnapshot(repoPath);
|
|
195
|
+
writeSnapshot(taskDir, "git-after.json", afterSnapshot);
|
|
196
|
+
return buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
|
|
197
|
+
}),
|
|
198
|
+
new Promise((_, reject) => {
|
|
199
|
+
timeoutHandle = setTimeout(() => reject(new Error("Artifact collection timed out")), ARTIFACT_COLLECTION_TIMEOUT_MS);
|
|
200
|
+
}),
|
|
201
|
+
]);
|
|
202
|
+
if (timeoutHandle)
|
|
203
|
+
clearTimeout(timeoutHandle);
|
|
204
|
+
changes = collectionResult;
|
|
182
205
|
}
|
|
183
206
|
catch (error) {
|
|
207
|
+
if (timeoutHandle)
|
|
208
|
+
clearTimeout(timeoutHandle);
|
|
209
|
+
lastCaughtError = error;
|
|
210
|
+
artifactCollectionError = errorMessage(error);
|
|
211
|
+
if (artifactCollectionError.includes("timed out")) {
|
|
212
|
+
artifactStatus = "timeout";
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
artifactStatus = "failed";
|
|
216
|
+
}
|
|
184
217
|
changes = {
|
|
185
218
|
changed_files: [],
|
|
186
|
-
diff: `(change capture failed: ${
|
|
219
|
+
diff: `(change capture failed: ${artifactCollectionError})\n`,
|
|
187
220
|
diff_available: false,
|
|
188
221
|
diff_truncated: false,
|
|
189
222
|
diff_size_bytes: 0,
|
|
@@ -193,16 +226,50 @@ export async function runTask(taskId) {
|
|
|
193
226
|
workspace_dirty_before: beforeSnapshot.workspace_dirty,
|
|
194
227
|
workspace_dirty_after: beforeSnapshot.workspace_dirty,
|
|
195
228
|
patch_mode: "hash_only",
|
|
196
|
-
unavailable_reason: `Change capture failed: ${
|
|
229
|
+
unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
|
|
197
230
|
artifact_hygiene: emptyArtifactHygiene(),
|
|
198
231
|
};
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
// Phase 5: Write partial_result.md when artifact collection fails
|
|
233
|
+
writeFileSync(join(taskDir, "partial_result.md"), [
|
|
234
|
+
"# PatchWarden Partial Result",
|
|
235
|
+
"",
|
|
236
|
+
`Task: ${taskId}`,
|
|
237
|
+
"",
|
|
238
|
+
"## Artifact Collection Status",
|
|
239
|
+
artifactStatus,
|
|
240
|
+
"",
|
|
241
|
+
"## Error",
|
|
242
|
+
artifactCollectionError,
|
|
243
|
+
"",
|
|
244
|
+
"## Agent Status",
|
|
245
|
+
agentResult ? `Exit code: ${agentResult.exitCode}` : "Agent did not run",
|
|
246
|
+
"",
|
|
247
|
+
"## Verification Status",
|
|
248
|
+
verifyResults.length > 0 ? verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
|
|
249
|
+
"",
|
|
250
|
+
"## Note",
|
|
251
|
+
"Artifact collection did not complete. The task result may be incomplete. Review stdout.log, stderr.log, and verify.json for details.",
|
|
252
|
+
"",
|
|
253
|
+
].join("\n"), "utf-8");
|
|
254
|
+
// Don't override test failure with artifact failure
|
|
255
|
+
if (finalStatus === "done") {
|
|
256
|
+
finalError = `Change capture failed: ${artifactCollectionError}`;
|
|
257
|
+
finalStatus = "failed";
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
finalError ||= `Change capture failed: ${artifactCollectionError}`;
|
|
261
|
+
}
|
|
201
262
|
}
|
|
263
|
+
const artifactCollectionFinishedAt = new Date().toISOString();
|
|
202
264
|
let outOfScopeChanges = [];
|
|
265
|
+
let preexistingExternalDirty = externalDirtyBaseline;
|
|
266
|
+
let newOutOfScopeChanges = [];
|
|
203
267
|
try {
|
|
204
268
|
const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
|
|
205
269
|
writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
|
|
270
|
+
const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, repoPath, wsRoot);
|
|
271
|
+
// Phase 4: Only NEW external dirty files (not in baseline) are scope violations
|
|
272
|
+
newOutOfScopeChanges = findNewExternalDirtyFiles(externalDirtyBaseline, allExternalDirty);
|
|
206
273
|
outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
|
|
207
274
|
.filter((file) => !isPathInside(resolve(wsRoot, file.path), repoPath) ||
|
|
208
275
|
Boolean(file.old_path && !isPathInside(resolve(wsRoot, file.old_path), repoPath)));
|
|
@@ -212,15 +279,20 @@ export async function runTask(taskId) {
|
|
|
212
279
|
if (finalStatus === "done")
|
|
213
280
|
finalStatus = "failed";
|
|
214
281
|
}
|
|
215
|
-
|
|
282
|
+
// Phase 4: Pre-existing external dirty files are warnings, not failures
|
|
283
|
+
const preexistingWarnings = preexistingExternalDirty.length > 0
|
|
284
|
+
? [`Pre-existing external dirty files (not caused by this task): ${preexistingExternalDirty.length} file(s)`]
|
|
285
|
+
: [];
|
|
286
|
+
if (newOutOfScopeChanges.length > 0) {
|
|
216
287
|
finalStatus = "failed_scope_violation";
|
|
217
|
-
finalError = `Detected ${
|
|
288
|
+
finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
|
|
218
289
|
writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
|
|
219
290
|
task_id: taskId,
|
|
220
291
|
status: "review_required",
|
|
221
292
|
automatic_rollback_performed: false,
|
|
222
293
|
warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
|
|
223
|
-
out_of_scope_changes:
|
|
294
|
+
out_of_scope_changes: newOutOfScopeChanges,
|
|
295
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
224
296
|
}, null, 2), "utf-8");
|
|
225
297
|
writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
|
|
226
298
|
"# Scope Violation Rollback Plan",
|
|
@@ -229,8 +301,8 @@ export async function runTask(taskId) {
|
|
|
229
301
|
"",
|
|
230
302
|
"PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
|
|
231
303
|
"",
|
|
232
|
-
"##
|
|
233
|
-
...
|
|
304
|
+
"## New out-of-scope files (caused by this task)",
|
|
305
|
+
...newOutOfScopeChanges.map((file) => `- ${file.change}: ${file.path}`),
|
|
234
306
|
"",
|
|
235
307
|
].join("\n"), "utf-8");
|
|
236
308
|
}
|
|
@@ -241,6 +313,10 @@ export async function runTask(taskId) {
|
|
|
241
313
|
writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
|
|
242
314
|
writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
|
|
243
315
|
writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
|
|
316
|
+
// Phase 6: Generate artifact_manifest.json and group changed files
|
|
317
|
+
const artifactManifest = buildArtifactManifest(changes.changed_files, repoPath, taskId);
|
|
318
|
+
writeFileSync(join(taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
|
|
319
|
+
const changedFileGroups = groupChangedFiles(changes.changed_files);
|
|
244
320
|
writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
|
|
245
321
|
task_id: taskId,
|
|
246
322
|
additions: changes.additions,
|
|
@@ -249,7 +325,10 @@ export async function runTask(taskId) {
|
|
|
249
325
|
}, null, 2), "utf-8");
|
|
250
326
|
writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
|
|
251
327
|
const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
|
|
252
|
-
|
|
328
|
+
// Phase 4: Use newOutOfScopeChanges (not outOfScopeChanges) for verify_status
|
|
329
|
+
// to stay consistent with finalStatus. Pre-existing external dirty files
|
|
330
|
+
// that didn't change during the task should NOT cause verify_status to fail.
|
|
331
|
+
if (newOutOfScopeChanges.length > 0) {
|
|
253
332
|
verifyJson.status = "failed";
|
|
254
333
|
verifyJson.failure_reason = "scope_violation";
|
|
255
334
|
}
|
|
@@ -263,6 +342,20 @@ export async function runTask(taskId) {
|
|
|
263
342
|
finalStatus = "failed";
|
|
264
343
|
const finalPhase = finalStatus === "done" ? "completed" : finalStatus;
|
|
265
344
|
const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
|
|
345
|
+
// Phase 7: Run Android build environment diagnostics if android_app exists
|
|
346
|
+
const androidDiagnostic = diagnoseAndroidBuild(repoPath);
|
|
347
|
+
let androidWarning = null;
|
|
348
|
+
if (androidDiagnostic.status !== "skip") {
|
|
349
|
+
if (androidDiagnostic.status === "fail") {
|
|
350
|
+
androidWarning = "Android project exists, APK not built because Android SDK is missing.";
|
|
351
|
+
}
|
|
352
|
+
else if (androidDiagnostic.status === "warn") {
|
|
353
|
+
const apkCheck = androidDiagnostic.checks.find((c) => c.check === "APK output path");
|
|
354
|
+
if (apkCheck && apkCheck.status !== "ok") {
|
|
355
|
+
androidWarning = `Android build environment has warnings: ${apkCheck.reason}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
266
359
|
const resultMd = buildResultMarkdown({
|
|
267
360
|
taskId,
|
|
268
361
|
planId,
|
|
@@ -274,6 +367,12 @@ export async function runTask(taskId) {
|
|
|
274
367
|
verify: verifyJson,
|
|
275
368
|
changes,
|
|
276
369
|
outOfScopeChanges,
|
|
370
|
+
artifactStatus,
|
|
371
|
+
artifactManifest,
|
|
372
|
+
changedFileGroups,
|
|
373
|
+
androidDiagnostic,
|
|
374
|
+
androidWarning,
|
|
375
|
+
preexistingWarnings,
|
|
277
376
|
});
|
|
278
377
|
writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
|
|
279
378
|
writeFileSync(join(taskDir, "result.json"), JSON.stringify({
|
|
@@ -288,8 +387,26 @@ export async function runTask(taskId) {
|
|
|
288
387
|
change_policy: changePolicy,
|
|
289
388
|
summary: finalError || "Agent execution and configured verification completed successfully.",
|
|
290
389
|
changed_files: changes.changed_files,
|
|
390
|
+
changed_file_groups: {
|
|
391
|
+
source_changes: changedFileGroups.source_changes.length,
|
|
392
|
+
docs_changes: changedFileGroups.docs_changes.length,
|
|
393
|
+
config_changes: changedFileGroups.config_changes.length,
|
|
394
|
+
test_changes: changedFileGroups.test_changes.length,
|
|
395
|
+
release_artifacts: changedFileGroups.release_artifacts.length,
|
|
396
|
+
runtime_generated_files: changedFileGroups.runtime_generated_files.length,
|
|
397
|
+
},
|
|
291
398
|
artifact_hygiene: changes.artifact_hygiene,
|
|
399
|
+
artifact_status: artifactStatus,
|
|
400
|
+
artifact_collection_error: artifactCollectionError,
|
|
401
|
+
artifact_collection_started_at: artifactCollectionStartedAt,
|
|
402
|
+
artifact_collection_finished_at: artifactCollectionFinishedAt,
|
|
403
|
+
artifact_manifest: artifactManifest,
|
|
292
404
|
out_of_scope_changes: outOfScopeChanges,
|
|
405
|
+
new_out_of_scope_changes: newOutOfScopeChanges,
|
|
406
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
407
|
+
target_repo_status: changes.workspace_dirty_after ? "dirty" : "clean",
|
|
408
|
+
workspace_status: changes.workspace_dirty_after ? "dirty" : "clean",
|
|
409
|
+
android_diagnostic: androidDiagnostic,
|
|
293
410
|
verify_status: verifyJson.status,
|
|
294
411
|
verify_commands: verifyJson.commands,
|
|
295
412
|
commands_run: verifyJson.commands.map((command) => ({
|
|
@@ -300,12 +417,15 @@ export async function runTask(taskId) {
|
|
|
300
417
|
commands_observed: [],
|
|
301
418
|
verify: verifyJson,
|
|
302
419
|
artifacts: [
|
|
303
|
-
"result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json",
|
|
420
|
+
"result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json", "artifact_manifest.json",
|
|
304
421
|
...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
|
|
422
|
+
...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
|
|
305
423
|
],
|
|
306
424
|
warnings: [
|
|
307
425
|
...beforeSnapshot.warnings,
|
|
308
426
|
...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
|
|
427
|
+
...preexistingWarnings,
|
|
428
|
+
...(androidWarning ? [androidWarning] : []),
|
|
309
429
|
],
|
|
310
430
|
errors: finalError ? [finalError] : [],
|
|
311
431
|
known_issues: finalError ? [finalError] : [],
|
|
@@ -317,8 +437,14 @@ export async function runTask(taskId) {
|
|
|
317
437
|
? ["Review get_task_summary and audit_task before accepting the work."]
|
|
318
438
|
: ["Resolve the reported failure before accepting the work."],
|
|
319
439
|
}, null, 2), "utf-8");
|
|
320
|
-
if (finalError)
|
|
321
|
-
|
|
440
|
+
if (finalError) {
|
|
441
|
+
// Phase 9: Preserve PatchWardenError structured info in error.log
|
|
442
|
+
const structuredError = errorPayload(lastCaughtError);
|
|
443
|
+
writeFileSync(join(taskDir, "error.log"), JSON.stringify({
|
|
444
|
+
summary: finalError,
|
|
445
|
+
...structuredError,
|
|
446
|
+
}, null, 2), "utf-8");
|
|
447
|
+
}
|
|
322
448
|
const finishedAt = new Date().toISOString();
|
|
323
449
|
updateStatus(taskDir, {
|
|
324
450
|
status: finalStatus,
|
|
@@ -329,7 +455,13 @@ export async function runTask(taskId) {
|
|
|
329
455
|
error: finalError,
|
|
330
456
|
changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
|
|
331
457
|
artifact_hygiene_counts: changes.artifact_hygiene.counts,
|
|
458
|
+
artifact_status: artifactStatus,
|
|
459
|
+
artifact_collection_error: artifactCollectionError,
|
|
460
|
+
artifact_collection_started_at: artifactCollectionStartedAt,
|
|
461
|
+
artifact_collection_finished_at: artifactCollectionFinishedAt,
|
|
332
462
|
out_of_scope_changes: outOfScopeChanges,
|
|
463
|
+
new_out_of_scope_changes: newOutOfScopeChanges,
|
|
464
|
+
preexisting_external_dirty_files: preexistingExternalDirty,
|
|
333
465
|
verify_status: verifyJson.status,
|
|
334
466
|
verify_commands: verifyCommands,
|
|
335
467
|
diff_available: changes.diff_available,
|
|
@@ -586,6 +718,40 @@ function buildResultMarkdown(input) {
|
|
|
586
718
|
const outOfScope = input.outOfScopeChanges.length
|
|
587
719
|
? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
|
|
588
720
|
: "(none)";
|
|
721
|
+
// Phase 6: Artifact manifest summary
|
|
722
|
+
const artifactCount = input.artifactManifest?.artifacts.length || 0;
|
|
723
|
+
const artifactLines = artifactCount > 0
|
|
724
|
+
? input.artifactManifest.artifacts.map((a) => `- ${a.type}: ${a.path} (${a.size} bytes, sha256: ${a.sha256.slice(0, 16)}...)`).join("\n")
|
|
725
|
+
: "(no release artifacts)";
|
|
726
|
+
// Phase 6: Changed file group summary
|
|
727
|
+
const groupLines = input.changedFileGroups
|
|
728
|
+
? [
|
|
729
|
+
`- source_changes: ${input.changedFileGroups.source_changes.length}`,
|
|
730
|
+
`- docs_changes: ${input.changedFileGroups.docs_changes.length}`,
|
|
731
|
+
`- config_changes: ${input.changedFileGroups.config_changes.length}`,
|
|
732
|
+
`- test_changes: ${input.changedFileGroups.test_changes.length}`,
|
|
733
|
+
`- release_artifacts: ${input.changedFileGroups.release_artifacts.length}`,
|
|
734
|
+
`- runtime_generated_files: ${input.changedFileGroups.runtime_generated_files.length}`,
|
|
735
|
+
].join("\n")
|
|
736
|
+
: "";
|
|
737
|
+
// Phase 7: Android diagnostic summary
|
|
738
|
+
let androidSection = "";
|
|
739
|
+
if (input.androidDiagnostic && input.androidDiagnostic.status !== "skip") {
|
|
740
|
+
const checkLines = (input.androidDiagnostic.checks || [])
|
|
741
|
+
.map((c) => `- [${c.status}] ${c.check}: ${c.reason}`)
|
|
742
|
+
.join("\n");
|
|
743
|
+
androidSection = [
|
|
744
|
+
"## Android Build Environment",
|
|
745
|
+
`Status: ${input.androidDiagnostic.status}`,
|
|
746
|
+
...(input.androidWarning ? [`Warning: ${input.androidWarning}`] : []),
|
|
747
|
+
checkLines,
|
|
748
|
+
"",
|
|
749
|
+
].join("\n");
|
|
750
|
+
}
|
|
751
|
+
// Phase 4: Pre-existing warnings
|
|
752
|
+
const preexistingSection = input.preexistingWarnings && input.preexistingWarnings.length > 0
|
|
753
|
+
? ["## Pre-existing Warnings", ...input.preexistingWarnings, ""].join("\n")
|
|
754
|
+
: "";
|
|
589
755
|
return [
|
|
590
756
|
"# PatchWarden Task Result",
|
|
591
757
|
"",
|
|
@@ -604,6 +770,12 @@ function buildResultMarkdown(input) {
|
|
|
604
770
|
"## Files changed",
|
|
605
771
|
changed,
|
|
606
772
|
"",
|
|
773
|
+
"## Changed file groups",
|
|
774
|
+
groupLines,
|
|
775
|
+
"",
|
|
776
|
+
"## Release artifacts",
|
|
777
|
+
artifactLines,
|
|
778
|
+
"",
|
|
607
779
|
"## Verification",
|
|
608
780
|
`- diff_available: ${input.changes.diff_available}`,
|
|
609
781
|
`- diff_truncated: ${input.changes.diff_truncated}`,
|
|
@@ -612,10 +784,13 @@ function buildResultMarkdown(input) {
|
|
|
612
784
|
`- verify_status: ${input.verify.status}`,
|
|
613
785
|
`- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
|
|
614
786
|
`- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
|
|
787
|
+
`- artifact_status: ${input.artifactStatus || "collected"}`,
|
|
615
788
|
"",
|
|
616
789
|
"## Out-of-scope changes",
|
|
617
790
|
outOfScope,
|
|
618
791
|
"",
|
|
792
|
+
preexistingSection,
|
|
793
|
+
androidSection,
|
|
619
794
|
"## Summary",
|
|
620
795
|
input.error || "Agent execution and configured verification completed successfully.",
|
|
621
796
|
"",
|
|
@@ -721,8 +896,13 @@ function normalizeTimeout(value, config) {
|
|
|
721
896
|
}
|
|
722
897
|
return timeout;
|
|
723
898
|
}
|
|
724
|
-
function failBeforeExecution(taskId, taskDir, message) {
|
|
725
|
-
|
|
899
|
+
function failBeforeExecution(taskId, taskDir, message, caughtError) {
|
|
900
|
+
// Phase 9: Preserve PatchWardenError structured info in error.log
|
|
901
|
+
const structuredError = caughtError ? errorPayload(caughtError) : { error: message };
|
|
902
|
+
writeFileSync(join(taskDir, "error.log"), JSON.stringify({
|
|
903
|
+
summary: message,
|
|
904
|
+
...structuredError,
|
|
905
|
+
}, null, 2), "utf-8");
|
|
726
906
|
const current = readStatus(join(taskDir, "status.json"));
|
|
727
907
|
const now = new Date().toISOString();
|
|
728
908
|
const verify = {
|
package/dist/smoke-test.js
CHANGED
|
@@ -533,8 +533,8 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
|
|
|
533
533
|
try {
|
|
534
534
|
process.env.PATCHWARDEN_TOOL_PROFILE = "full";
|
|
535
535
|
const fullTools = getToolDefs();
|
|
536
|
-
if (fullTools.length !==
|
|
537
|
-
throw new Error(`Expected
|
|
536
|
+
if (fullTools.length !== 30)
|
|
537
|
+
throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
|
|
538
538
|
const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
|
|
539
539
|
const names = coreTools.map((tool) => tool.name);
|
|
540
540
|
if (JSON.stringify(names) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
@@ -1866,11 +1866,11 @@ writeFileSync(directConfigPath, JSON.stringify({
|
|
|
1866
1866
|
process.env.PATCHWARDEN_CONFIG = directConfigPath;
|
|
1867
1867
|
reloadConfig();
|
|
1868
1868
|
let directSessionId = "";
|
|
1869
|
-
test("M1. chatgpt_core still has
|
|
1869
|
+
test("M1. chatgpt_core still has 17 tools", () => {
|
|
1870
1870
|
const tools = getToolDefs();
|
|
1871
1871
|
const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
|
|
1872
|
-
if (coreTools.length !==
|
|
1873
|
-
throw new Error(`Expected
|
|
1872
|
+
if (coreTools.length !== 17)
|
|
1873
|
+
throw new Error(`Expected 17, got ${coreTools.length}`);
|
|
1874
1874
|
if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
|
|
1875
1875
|
throw new Error("Tool names mismatch");
|
|
1876
1876
|
}
|
|
@@ -1883,11 +1883,11 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
|
|
|
1883
1883
|
if (disabledTools[0].name !== "health_check")
|
|
1884
1884
|
throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
|
|
1885
1885
|
});
|
|
1886
|
-
test("M3. chatgpt_direct enabled has
|
|
1886
|
+
test("M3. chatgpt_direct enabled has 10 tools", () => {
|
|
1887
1887
|
const tools = getToolDefs();
|
|
1888
1888
|
const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
|
|
1889
|
-
if (directTools.length !==
|
|
1890
|
-
throw new Error(`Expected
|
|
1889
|
+
if (directTools.length !== 10)
|
|
1890
|
+
throw new Error(`Expected 10, got ${directTools.length}`);
|
|
1891
1891
|
if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
|
|
1892
1892
|
throw new Error("Tool names mismatch");
|
|
1893
1893
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { diagnoseAndroidBuild, } from "../../tools/androidDoctor.js";
|
|
7
|
+
const VALID_STATUSES = ["ok", "warn", "fail", "skip"];
|
|
8
|
+
/** Restore an env var to its original value (deleted when undefined). */
|
|
9
|
+
function restoreEnv(key, value) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
delete process.env[key];
|
|
12
|
+
else
|
|
13
|
+
process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
describe("diagnoseAndroidBuild", () => {
|
|
16
|
+
let tempDir;
|
|
17
|
+
let savedJavaHome;
|
|
18
|
+
let savedAndroidHome;
|
|
19
|
+
let savedAndroidSdkRoot;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-android-doctor-"));
|
|
22
|
+
savedJavaHome = process.env.JAVA_HOME;
|
|
23
|
+
savedAndroidHome = process.env.ANDROID_HOME;
|
|
24
|
+
savedAndroidSdkRoot = process.env.ANDROID_SDK_ROOT;
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
+
restoreEnv("JAVA_HOME", savedJavaHome);
|
|
29
|
+
restoreEnv("ANDROID_HOME", savedAndroidHome);
|
|
30
|
+
restoreEnv("ANDROID_SDK_ROOT", savedAndroidSdkRoot);
|
|
31
|
+
});
|
|
32
|
+
it("returns a skip result when no android_app directory exists", () => {
|
|
33
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
34
|
+
assert.equal(result.status, "skip");
|
|
35
|
+
assert.equal(result.reason, "No android_app directory found");
|
|
36
|
+
});
|
|
37
|
+
it("returns a structured diagnostic report when android_app exists", () => {
|
|
38
|
+
const androidApp = join(tempDir, "android_app");
|
|
39
|
+
mkdirSync(join(androidApp, "app"), { recursive: true });
|
|
40
|
+
mkdirSync(join(androidApp, "gradle", "wrapper"), { recursive: true });
|
|
41
|
+
writeFileSync(join(androidApp, "gradlew"), "#!/bin/sh\necho gradle\n");
|
|
42
|
+
writeFileSync(join(androidApp, "settings.gradle.kts"), 'pluginManagement { repositories { google() } }\n');
|
|
43
|
+
writeFileSync(join(androidApp, "app", "build.gradle.kts"), 'plugins { id("com.android.application") }\n');
|
|
44
|
+
writeFileSync(join(androidApp, "gradle", "wrapper", "gradle-wrapper.properties"), "distributionUrl=https://services.gradle.org/distributions/gradle-8.5-bin.zip\n");
|
|
45
|
+
// Make SDK-related checks deterministic regardless of the host environment.
|
|
46
|
+
delete process.env.ANDROID_HOME;
|
|
47
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
48
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
49
|
+
assert.notEqual(result.status, "skip");
|
|
50
|
+
const report = result;
|
|
51
|
+
assert.equal(report.repo_path, tempDir);
|
|
52
|
+
assert.equal(report.android_app_path, join(tempDir, "android_app"));
|
|
53
|
+
assert.ok(Array.isArray(report.checks));
|
|
54
|
+
assert.equal(report.checks.length, 10);
|
|
55
|
+
for (const item of report.checks) {
|
|
56
|
+
assert.equal(typeof item.check, "string");
|
|
57
|
+
assert.ok(item.check.length > 0);
|
|
58
|
+
assert.ok(VALID_STATUSES.includes(item.status));
|
|
59
|
+
assert.equal(typeof item.reason, "string");
|
|
60
|
+
assert.equal(typeof item.suggested_fix, "string");
|
|
61
|
+
}
|
|
62
|
+
// Overall status follows the fail > warn > ok precedence.
|
|
63
|
+
const hasFail = report.checks.some((c) => c.status === "fail");
|
|
64
|
+
const hasWarn = report.checks.some((c) => c.status === "warn");
|
|
65
|
+
const expected = hasFail ? "fail" : hasWarn ? "warn" : "ok";
|
|
66
|
+
assert.equal(report.status, expected);
|
|
67
|
+
// The expected check names are all present.
|
|
68
|
+
const checkNames = report.checks.map((c) => c.check);
|
|
69
|
+
assert.ok(checkNames.includes("java -version"));
|
|
70
|
+
assert.ok(checkNames.includes("JAVA_HOME"));
|
|
71
|
+
assert.ok(checkNames.includes("ANDROID_HOME"));
|
|
72
|
+
assert.ok(checkNames.includes("ANDROID_SDK_ROOT"));
|
|
73
|
+
assert.ok(checkNames.includes("Android SDK platform"));
|
|
74
|
+
assert.ok(checkNames.includes("Android build-tools"));
|
|
75
|
+
assert.ok(checkNames.includes("Gradle wrapper"));
|
|
76
|
+
assert.ok(checkNames.includes("android_app/settings.gradle.kts"));
|
|
77
|
+
assert.ok(checkNames.includes("android_app/app/build.gradle.kts"));
|
|
78
|
+
assert.ok(checkNames.includes("APK output path"));
|
|
79
|
+
// Files we created should be detected as ok.
|
|
80
|
+
const gradleCheck = report.checks.find((c) => c.check === "Gradle wrapper");
|
|
81
|
+
assert.equal(gradleCheck?.status, "ok");
|
|
82
|
+
const settingsCheck = report.checks.find((c) => c.check === "android_app/settings.gradle.kts");
|
|
83
|
+
assert.equal(settingsCheck?.status, "ok");
|
|
84
|
+
const appBuildCheck = report.checks.find((c) => c.check === "android_app/app/build.gradle.kts");
|
|
85
|
+
assert.equal(appBuildCheck?.status, "ok");
|
|
86
|
+
});
|
|
87
|
+
it("reports a missing JAVA_HOME as warn or fail", () => {
|
|
88
|
+
const androidApp = join(tempDir, "android_app");
|
|
89
|
+
mkdirSync(androidApp, { recursive: true });
|
|
90
|
+
delete process.env.JAVA_HOME;
|
|
91
|
+
delete process.env.ANDROID_HOME;
|
|
92
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
93
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
94
|
+
const report = result;
|
|
95
|
+
const javaHomeCheck = report.checks.find((c) => c.check === "JAVA_HOME");
|
|
96
|
+
assert.ok(javaHomeCheck, "JAVA_HOME check should exist");
|
|
97
|
+
assert.ok(javaHomeCheck.status === "warn" || javaHomeCheck.status === "fail", `expected warn or fail, got ${javaHomeCheck.status}`);
|
|
98
|
+
});
|
|
99
|
+
it("reports a missing ANDROID_HOME as fail with the SDK-missing message", () => {
|
|
100
|
+
const androidApp = join(tempDir, "android_app");
|
|
101
|
+
mkdirSync(androidApp, { recursive: true });
|
|
102
|
+
delete process.env.ANDROID_HOME;
|
|
103
|
+
delete process.env.ANDROID_SDK_ROOT;
|
|
104
|
+
const result = diagnoseAndroidBuild(tempDir);
|
|
105
|
+
const report = result;
|
|
106
|
+
const androidHomeCheck = report.checks.find((c) => c.check === "ANDROID_HOME");
|
|
107
|
+
assert.ok(androidHomeCheck, "ANDROID_HOME check should exist");
|
|
108
|
+
assert.equal(androidHomeCheck.status, "fail");
|
|
109
|
+
assert.ok(androidHomeCheck.reason.includes("Android project exists, APK not built because Android SDK is missing."), `reason should mention the SDK-missing sentence, got: ${androidHomeCheck.reason}`);
|
|
110
|
+
// With the SDK missing, the overall status must be fail.
|
|
111
|
+
assert.equal(report.status, "fail");
|
|
112
|
+
// The APK output check should also carry the SDK-missing reason and be skipped.
|
|
113
|
+
const apkCheck = report.checks.find((c) => c.check === "APK output path");
|
|
114
|
+
assert.ok(apkCheck, "APK output path check should exist");
|
|
115
|
+
assert.equal(apkCheck.status, "skip");
|
|
116
|
+
assert.ok(apkCheck.reason.includes("Android project exists, APK not built because Android SDK is missing."));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|