pi-harness-runtime 0.10.13 → 0.10.14

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Partial Response Recovery — RFC-0021
3
+ *
4
+ * Persist and recover incomplete agent outputs.
5
+ * Never loses partial response text.
6
+ *
7
+ * Integration with CompactOrchestrator:
8
+ * - Saves partial artifacts during compact
9
+ * - Loads partial artifacts on retry
10
+ * - Merges partials for context injection
11
+ *
12
+ * Artifact Layout:
13
+ * harness/partial/
14
+ * job_xxx/
15
+ * task_004/
16
+ * partial_001.md
17
+ * partial_002.md
18
+ * merged.md
19
+ * recovery_status.json
20
+ * files.json
21
+ */
22
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { continuePromptGenerator } from "./continue-prompt.js";
26
+ export class PartialRecovery {
27
+ rootDir;
28
+ taskId;
29
+ partials = [];
30
+ constructor(jobId, taskId, rootDir) {
31
+ this.rootDir =
32
+ rootDir ?? join(homedir(), ".pi", "harness", jobId, "partial", taskId);
33
+ this.taskId = taskId;
34
+ this.ensureDir();
35
+ this.loadExistingPartials();
36
+ }
37
+ // --- Public API -----------------------------------------------------------
38
+ /**
39
+ * Save a partial response
40
+ */
41
+ savePartial(content, source = "output_limit", metadata) {
42
+ const partial = {
43
+ id: this.generateId(),
44
+ timestamp: new Date().toISOString(),
45
+ taskId: this.taskId,
46
+ content,
47
+ source,
48
+ metadata,
49
+ };
50
+ this.partials.push(partial);
51
+ // Save to disk
52
+ const path = join(this.rootDir, `${partial.id}.md`);
53
+ writeFileSync(path, content, "utf-8");
54
+ // Update files manifest
55
+ this.saveFilesManifest();
56
+ // Update recovery status
57
+ this.updateStatus("continuing");
58
+ return partial;
59
+ }
60
+ /**
61
+ * Save partial from compact result
62
+ */
63
+ saveFromCompact(summary, remainingWork) {
64
+ const content = [
65
+ "## Compaction Summary",
66
+ summary,
67
+ "",
68
+ "## Remaining Work",
69
+ ...remainingWork.map((w) => `- ${w}`),
70
+ ].join("\n");
71
+ this.savePartial(content, "compaction", { type: "compact_summary" });
72
+ }
73
+ /**
74
+ * Get all partial responses
75
+ */
76
+ getPartials() {
77
+ return [...this.partials];
78
+ }
79
+ /**
80
+ * Get the count of partials
81
+ */
82
+ getCount() {
83
+ return this.partials.length;
84
+ }
85
+ /**
86
+ * Merge partials using the specified strategy
87
+ */
88
+ merge(options = { method: "markdown_sections" }) {
89
+ if (this.partials.length === 0) {
90
+ return "";
91
+ }
92
+ let merged;
93
+ switch (options.method) {
94
+ case "markdown_sections":
95
+ merged = this.mergeAsMarkdownSections();
96
+ break;
97
+ case "code_blocks":
98
+ merged = this.mergeCodeBlocks();
99
+ break;
100
+ case "json_concat":
101
+ merged = this.mergeJson();
102
+ break;
103
+ case "patch_merge":
104
+ merged = this.mergePatchBased();
105
+ break;
106
+ default:
107
+ merged = this.mergeAsMarkdownSections();
108
+ }
109
+ // Remove duplicates if requested
110
+ if (options.removeDuplicates) {
111
+ merged = this.removeDuplicates(merged);
112
+ }
113
+ // Save merged output
114
+ const mergedPath = join(this.rootDir, "merged.md");
115
+ writeFileSync(mergedPath, merged, "utf-8");
116
+ return merged;
117
+ }
118
+ /**
119
+ * Generate continue prompt from partials
120
+ */
121
+ generateContinuePrompt() {
122
+ if (this.partials.length === 0) {
123
+ return "continue";
124
+ }
125
+ const merged = this.merge({ method: "markdown_sections" });
126
+ const recentWork = this.extractRecentWork(merged);
127
+ return continuePromptGenerator.generate({
128
+ taskId: this.taskId,
129
+ requirement: "Continue from partial work",
130
+ whatWasCompleted: this.extractCompletedWork(merged),
131
+ whatNeedsToBeDone: recentWork,
132
+ partialFiles: this.extractFileReferences(merged),
133
+ decisions: this.extractDecisions(merged),
134
+ });
135
+ }
136
+ /**
137
+ * Load partials from a continuation prompt
138
+ */
139
+ loadFromContinuationPrompt(prompt) {
140
+ // Extract code blocks and content from continue_prompt.md
141
+ const codeBlockMatch = prompt.match(/```[\s\S]*?```/g);
142
+ if (codeBlockMatch) {
143
+ const content = codeBlockMatch.join("\n\n");
144
+ this.savePartial(content, "output_limit", { fromContinuation: true });
145
+ }
146
+ }
147
+ /**
148
+ * Check if recovery is needed
149
+ */
150
+ hasPartials() {
151
+ return this.partials.length > 0;
152
+ }
153
+ /**
154
+ * Check if we should escalate (too many partials)
155
+ */
156
+ shouldEscalate(maxPartials = 10) {
157
+ return this.partials.length >= maxPartials;
158
+ }
159
+ /**
160
+ * Mark recovery as completed
161
+ */
162
+ markCompleted() {
163
+ this.updateStatus("completed");
164
+ }
165
+ /**
166
+ * Mark recovery as failed
167
+ */
168
+ markFailed(error) {
169
+ this.updateStatus("failed", error);
170
+ }
171
+ /**
172
+ * Mark recovery as escalated
173
+ */
174
+ markEscalated() {
175
+ this.updateStatus("escalated");
176
+ }
177
+ /**
178
+ * Get recovery status
179
+ */
180
+ getStatus() {
181
+ const statusPath = join(this.rootDir, "recovery_status.json");
182
+ if (existsSync(statusPath)) {
183
+ try {
184
+ return JSON.parse(readFileSync(statusPath, "utf-8"));
185
+ }
186
+ catch {
187
+ // Fall through to default
188
+ }
189
+ }
190
+ return {
191
+ taskId: this.taskId,
192
+ status: "pending",
193
+ partials: [],
194
+ attempts: 0,
195
+ };
196
+ }
197
+ /**
198
+ * Clean up partials (after successful completion)
199
+ */
200
+ cleanup(keepMerged = true) {
201
+ if (!existsSync(this.rootDir)) {
202
+ return;
203
+ }
204
+ const files = readdirSync(this.rootDir);
205
+ for (const file of files) {
206
+ if (file === "merged.md" && keepMerged) {
207
+ continue;
208
+ }
209
+ if (file === "recovery_status.json") {
210
+ continue;
211
+ }
212
+ try {
213
+ unlinkSync(join(this.rootDir, file));
214
+ }
215
+ catch {
216
+ // Ignore errors
217
+ }
218
+ }
219
+ }
220
+ // --- Private Methods ------------------------------------------------
221
+ ensureDir() {
222
+ if (!existsSync(this.rootDir)) {
223
+ mkdirSync(this.rootDir, { recursive: true });
224
+ }
225
+ }
226
+ loadExistingPartials() {
227
+ if (!existsSync(this.rootDir)) {
228
+ return;
229
+ }
230
+ const files = readdirSync(this.rootDir);
231
+ for (const file of files) {
232
+ if (file.endsWith(".md") && file !== "merged.md") {
233
+ const path = join(this.rootDir, file);
234
+ const content = readFileSync(path, "utf-8");
235
+ const id = file.replace(".md", "");
236
+ this.partials.push({
237
+ id,
238
+ timestamp: new Date().toISOString(),
239
+ taskId: this.taskId,
240
+ content,
241
+ source: "output_limit",
242
+ });
243
+ }
244
+ }
245
+ // Sort by id
246
+ this.partials.sort((a, b) => a.id.localeCompare(b.id));
247
+ }
248
+ generateId() {
249
+ const count = this.partials.length + 1;
250
+ return `partial_${String(count).padStart(3, "0")}`;
251
+ }
252
+ saveFilesManifest() {
253
+ const manifestPath = join(this.rootDir, "files.json");
254
+ const files = this.partials.map((p) => ({
255
+ id: p.id,
256
+ file: `${p.id}.md`,
257
+ timestamp: p.timestamp,
258
+ source: p.source,
259
+ }));
260
+ writeFileSync(manifestPath, JSON.stringify(files, null, 2), "utf-8");
261
+ }
262
+ updateStatus(status, error) {
263
+ const statusPath = join(this.rootDir, "recovery_status.json");
264
+ const current = this.getStatus();
265
+ const updated = {
266
+ taskId: this.taskId,
267
+ status,
268
+ partials: this.partials.map((p) => `${p.id}.md`),
269
+ mergedOutput: existsSync(join(this.rootDir, "merged.md"))
270
+ ? "merged.md"
271
+ : undefined,
272
+ attempts: current.attempts + 1,
273
+ lastError: error ?? current.lastError,
274
+ completedAt: status === "completed" ? new Date().toISOString() : undefined,
275
+ };
276
+ writeFileSync(statusPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
277
+ }
278
+ mergeAsMarkdownSections() {
279
+ return this.partials
280
+ .map((p, i) => `## Partial ${i + 1} (${p.source})\n\n${p.content}`)
281
+ .join("\n\n---\n\n");
282
+ }
283
+ mergeCodeBlocks() {
284
+ const codeBlocks = [];
285
+ for (const partial of this.partials) {
286
+ const matches = partial.content.match(/```[\s\S]*?```/g);
287
+ if (matches) {
288
+ codeBlocks.push(...matches);
289
+ }
290
+ }
291
+ // Remove duplicates
292
+ const seen = new Set();
293
+ const unique = [];
294
+ for (const block of codeBlocks) {
295
+ if (!seen.has(block)) {
296
+ seen.add(block);
297
+ unique.push(block);
298
+ }
299
+ }
300
+ return unique.join("\n\n");
301
+ }
302
+ mergeJson() {
303
+ const results = [];
304
+ for (const partial of this.partials) {
305
+ try {
306
+ const parsed = JSON.parse(partial.content);
307
+ if (Array.isArray(parsed)) {
308
+ results.push(...parsed);
309
+ }
310
+ else {
311
+ results.push(parsed);
312
+ }
313
+ }
314
+ catch {
315
+ // Not JSON, include as-is
316
+ results.push({ _raw: partial.content });
317
+ }
318
+ }
319
+ return JSON.stringify(results, null, 2);
320
+ }
321
+ mergePatchBased() {
322
+ // Simple approach: concatenate non-overlapping parts
323
+ const parts = [];
324
+ for (const partial of this.partials) {
325
+ // Look for new content after last marker
326
+ const lines = partial.content.split("\n");
327
+ const newLines = [];
328
+ for (const line of lines) {
329
+ // Skip if it looks like a duplicate header
330
+ if (!parts.some((p) => p.includes(line) ||
331
+ line.startsWith("#") ||
332
+ line.startsWith("---"))) {
333
+ newLines.push(line);
334
+ }
335
+ }
336
+ if (newLines.length > 0) {
337
+ parts.push(newLines.join("\n"));
338
+ }
339
+ }
340
+ return parts.join("\n\n---\n\n");
341
+ }
342
+ removeDuplicates(text) {
343
+ const lines = text.split("\n");
344
+ const seen = new Set();
345
+ const unique = [];
346
+ for (const line of lines) {
347
+ const trimmed = line.trim();
348
+ if (!seen.has(trimmed) && trimmed.length > 0) {
349
+ seen.add(trimmed);
350
+ unique.push(line);
351
+ }
352
+ }
353
+ return unique.join("\n");
354
+ }
355
+ extractRecentWork(merged) {
356
+ const work = [];
357
+ // Look for bullet points and remaining work
358
+ const lines = merged.split("\n");
359
+ for (const line of lines) {
360
+ if (line.match(/^[-*]\s/) && !line.includes("[completed]")) {
361
+ work.push(line.replace(/^[-*]\s/, "").trim());
362
+ }
363
+ }
364
+ return work.slice(0, 10);
365
+ }
366
+ extractCompletedWork(merged) {
367
+ const completed = [];
368
+ const lines = merged.split("\n");
369
+ for (const line of lines) {
370
+ if (line.match(/^[-*]\s/) && line.includes("[completed]")) {
371
+ completed.push(line
372
+ .replace(/^[-*]\s/, "")
373
+ .replace("[completed]", "")
374
+ .trim());
375
+ }
376
+ }
377
+ return completed.slice(0, 10);
378
+ }
379
+ extractFileReferences(merged) {
380
+ const files = [];
381
+ // Look for file paths
382
+ const patterns = [
383
+ /[A-Za-z]:\\[\w\\]+(?:\.\w+)?/g, // Windows
384
+ /\/[\w./-]+(?:\.\w+)?/g, // Unix
385
+ ];
386
+ for (const pattern of patterns) {
387
+ for (const match of merged.matchAll(pattern)) {
388
+ if (match[0] && !files.includes(match[0])) {
389
+ files.push(match[0]);
390
+ }
391
+ }
392
+ }
393
+ return files.slice(0, 20);
394
+ }
395
+ extractDecisions(merged) {
396
+ const decisions = [];
397
+ // Look for decision markers
398
+ const lines = merged.split("\n");
399
+ for (const line of lines) {
400
+ if (line.match(/(?:decision|decided|chose|using):/i)) {
401
+ decisions.push(line.replace(/^[-*]\s*/, "").trim());
402
+ }
403
+ }
404
+ return decisions.slice(0, 5);
405
+ }
406
+ }
407
+ // --- Factory ----------------------------------------------------------------
408
+ /**
409
+ * Create a PartialRecovery manager for a task
410
+ */
411
+ export function createPartialRecovery(jobId, taskId, rootDir) {
412
+ return new PartialRecovery(jobId, taskId, rootDir);
413
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Project Detector — RFC-0014
3
+ *
4
+ * Auto-detect project framework and choose suitable dummy data,
5
+ * seed strategy, and E2E approach.
6
+ */
7
+ import { readJson } from "../../cli.ts";
8
+ // @ts-expect-error - Bun has built-in Node.js types
9
+ import { join } from "node:path";
10
+ export class ProjectDetector {
11
+ signals = new Map([
12
+ [
13
+ "frappe_erpnext",
14
+ [
15
+ { pattern: "frappe-bench/sites", weight: 0.3, matched: false },
16
+ { pattern: "frappe-bench/apps", weight: 0.2, matched: false },
17
+ { pattern: "hooks.py", weight: 0.2, matched: false },
18
+ { pattern: "doctype/", weight: 0.2, matched: false },
19
+ { pattern: "bench", weight: 0.1, matched: false },
20
+ ],
21
+ ],
22
+ [
23
+ "frappe_spa",
24
+ [
25
+ {
26
+ pattern: "frappe-bench/apps/*/frontend",
27
+ weight: 0.3,
28
+ matched: false,
29
+ },
30
+ { pattern: "vite.config.", weight: 0.2, matched: false },
31
+ { pattern: ".reactrc", weight: 0.15, matched: false },
32
+ { pattern: "package.json", weight: 0.15, matched: false },
33
+ { pattern: "tsx", weight: 0.2, matched: false },
34
+ ],
35
+ ],
36
+ [
37
+ "nextjs",
38
+ [
39
+ { pattern: "next.config.", weight: 0.3, matched: false },
40
+ { pattern: "package.json", weight: 0.15, matched: false },
41
+ { pattern: "pages/", weight: 0.2, matched: false },
42
+ { pattern: "app/", weight: 0.25, matched: false },
43
+ { pattern: '"next"', weight: 0.1, matched: false },
44
+ ],
45
+ ],
46
+ [
47
+ "react_vite",
48
+ [
49
+ { pattern: "vite.config.", weight: 0.3, matched: false },
50
+ { pattern: "package.json", weight: 0.15, matched: false },
51
+ { pattern: "src/main.tsx", weight: 0.25, matched: false },
52
+ { pattern: "index.html", weight: 0.15, matched: false },
53
+ { pattern: '"vite"', weight: 0.15, matched: false },
54
+ ],
55
+ ],
56
+ [
57
+ "django",
58
+ [
59
+ { pattern: "manage.py", weight: 0.4, matched: false },
60
+ { pattern: "settings.py", weight: 0.25, matched: false },
61
+ { pattern: "wsgi.py", weight: 0.15, matched: false },
62
+ { pattern: "migrations/", weight: 0.2, matched: false },
63
+ ],
64
+ ],
65
+ [
66
+ "laravel",
67
+ [
68
+ { pattern: "artisan", weight: 0.4, matched: false },
69
+ { pattern: "database/seeders", weight: 0.25, matched: false },
70
+ { pattern: "composer.json", weight: 0.15, matched: false },
71
+ { pattern: "routes/", weight: 0.2, matched: false },
72
+ ],
73
+ ],
74
+ ]);
75
+ /**
76
+ * Detect project type from a directory
77
+ */
78
+ async detect(rootDir) {
79
+ const projectFiles = await this.scanDirectory(rootDir);
80
+ // Score each project type
81
+ const scores = {
82
+ frappe_erpnext: 0,
83
+ frappe_spa: 0,
84
+ nextjs: 0,
85
+ react_vite: 0,
86
+ django: 0,
87
+ laravel: 0,
88
+ generic_web: 0,
89
+ unknown: 0,
90
+ };
91
+ const signals = [];
92
+ for (const [projectType, signalList] of this.signals) {
93
+ for (const signal of signalList) {
94
+ signal.matched = false;
95
+ for (const file of projectFiles) {
96
+ if (file.includes(signal.pattern)) {
97
+ signal.matched = true;
98
+ scores[projectType] += signal.weight;
99
+ if (!signals.includes(signal.pattern)) {
100
+ signals.push(signal.pattern);
101
+ }
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // Find the project type with highest score
108
+ let bestType = "unknown";
109
+ let bestScore = 0;
110
+ for (const [projectType, score] of Object.entries(scores)) {
111
+ if (score > bestScore) {
112
+ bestScore = score;
113
+ bestType = projectType;
114
+ }
115
+ }
116
+ // Check for generic web
117
+ if (projectFiles.some((f) => f === "package.json")) {
118
+ if (bestType === "unknown") {
119
+ bestType = "generic_web";
120
+ }
121
+ }
122
+ // Determine confidence based on score
123
+ const confidence = Math.min(1, bestScore);
124
+ return {
125
+ projectType: bestType,
126
+ confidence,
127
+ signals,
128
+ recommendedSeedStrategy: this.getSeedStrategy(bestType),
129
+ recommendedE2EStrategy: this.getE2EStrategy(bestType),
130
+ ...this.getAdditionalInfo(bestType, projectFiles),
131
+ };
132
+ }
133
+ /**
134
+ * Scan directory for relevant files
135
+ */
136
+ async scanDirectory(rootDir) {
137
+ // This is a simplified implementation.
138
+ // In practice, you'd use recursive directory scanning.
139
+ const files = [];
140
+ try {
141
+ // Check for common files
142
+ const checks = [
143
+ "frappe-bench",
144
+ "frappe-bench/sites",
145
+ "frappe-bench/apps",
146
+ "hooks.py",
147
+ "doctype",
148
+ "bench",
149
+ "vite.config.ts",
150
+ "vite.config.js",
151
+ "next.config.js",
152
+ "next.config.ts",
153
+ "package.json",
154
+ "pages",
155
+ "app",
156
+ "src/main.tsx",
157
+ "index.html",
158
+ "manage.py",
159
+ "settings.py",
160
+ "artisan",
161
+ "database/seeders",
162
+ ];
163
+ // @ts-expect-error - Bun has built-in file system access
164
+ const { existsSync } = await import("node:fs");
165
+ // @ts-expect-error - Bun has built-in file system access
166
+ const { readdirSync } = await import("node:fs");
167
+ for (const check of checks) {
168
+ const fullPath = join(rootDir, check);
169
+ if (existsSync(fullPath)) {
170
+ files.push(check);
171
+ }
172
+ }
173
+ // Scan root directory for common files
174
+ try {
175
+ const rootFiles = readdirSync(rootDir);
176
+ for (const file of rootFiles) {
177
+ if (file.includes("package.json"))
178
+ files.push(file);
179
+ if (file.includes("vite.config"))
180
+ files.push(file);
181
+ if (file.includes("next.config"))
182
+ files.push(file);
183
+ }
184
+ }
185
+ catch {
186
+ // Ignore errors
187
+ }
188
+ }
189
+ catch {
190
+ // Return empty on error
191
+ }
192
+ return files;
193
+ }
194
+ /**
195
+ * Get recommended seed strategy
196
+ */
197
+ getSeedStrategy(projectType) {
198
+ const strategies = {
199
+ frappe_erpnext: "frappe_doc_insert",
200
+ frappe_spa: "frappe_site_seed",
201
+ nextjs: "nextjs_factory",
202
+ react_vite: "react_factory",
203
+ django: "django_fixture",
204
+ laravel: "laravel_factory",
205
+ generic_web: "generic_sql",
206
+ unknown: "generic_sql",
207
+ };
208
+ return strategies[projectType];
209
+ }
210
+ /**
211
+ * Get recommended E2E strategy
212
+ */
213
+ getE2EStrategy(projectType) {
214
+ const strategies = {
215
+ frappe_erpnext: "bench_site_browser_flow",
216
+ frappe_spa: "bench_site_browser_flow",
217
+ nextjs: "next_dev_server_flow",
218
+ react_vite: "vite_dev_server_flow",
219
+ django: "django_test_client_flow",
220
+ laravel: "laravel_dusk_flow",
221
+ generic_web: "generic_playwright_flow",
222
+ unknown: "generic_playwright_flow",
223
+ };
224
+ return strategies[projectType];
225
+ }
226
+ /**
227
+ * Get additional project info
228
+ */
229
+ getAdditionalInfo(projectType, files) {
230
+ const info = {};
231
+ // Try to extract version from package.json (if rootDir is available)
232
+ if (files.includes("package.json")) {
233
+ try {
234
+ const pkgPath = join(".", "package.json");
235
+ const pkg = readJson(pkgPath);
236
+ if (pkg?.version) {
237
+ info.version = pkg.version;
238
+ }
239
+ }
240
+ catch {
241
+ // Ignore
242
+ }
243
+ }
244
+ // Set framework name
245
+ const frameworks = {
246
+ frappe_erpnext: "Frappe/ERPNext",
247
+ frappe_spa: "Frappe SPA",
248
+ nextjs: "Next.js",
249
+ react_vite: "React + Vite",
250
+ django: "Django",
251
+ laravel: "Laravel",
252
+ generic_web: "Generic Web",
253
+ };
254
+ info.framework = frameworks[projectType];
255
+ return info;
256
+ }
257
+ /**
258
+ * Generate detection report
259
+ */
260
+ generateReport(detection) {
261
+ const lines = [
262
+ "Project Detection Report",
263
+ "=".repeat(40),
264
+ "",
265
+ `Type: ${detection.projectType}`,
266
+ `Framework: ${detection.framework ?? "Unknown"}`,
267
+ `Confidence: ${(detection.confidence * 100).toFixed(1)}%`,
268
+ "",
269
+ "Signals:",
270
+ ];
271
+ for (const signal of detection.signals) {
272
+ lines.push(` - ${signal}`);
273
+ }
274
+ lines.push("");
275
+ lines.push("Recommendations:");
276
+ lines.push(` Seed Strategy: ${detection.recommendedSeedStrategy}`);
277
+ lines.push(` E2E Strategy: ${detection.recommendedE2EStrategy}`);
278
+ if (detection.version) {
279
+ lines.push(` Version: ${detection.version}`);
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+ }