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.
- package/.versionrc.js +32 -0
- package/cli.js +112 -0
- package/footer-status.js +155 -0
- package/harness/agent-handoff.js +123 -0
- package/harness/auto-compact.js +243 -0
- package/harness/auto-quota-resume.js +104 -0
- package/harness/blackboard.js +258 -0
- package/harness/context-compact-orchestrator.js +365 -0
- package/harness/context-window-manager.js +330 -0
- package/harness/continue-prompt.js +164 -0
- package/harness/e2e/minimax-quota-parser.js +52 -0
- package/harness/e2e/minimax-quota-scraper.js +475 -0
- package/harness/e2e/openai-quota-scraper.js +332 -0
- package/harness/e2e/playwright-runner.js +165 -0
- package/harness/e2e/quota-status.js +140 -0
- package/harness/e2e/test-engine.js +290 -0
- package/harness/forked-summarizer.js +212 -0
- package/harness/index.js +54 -0
- package/harness/job-state-machine.js +277 -0
- package/harness/loop-runtime.js +531 -0
- package/harness/master-planner.js +229 -0
- package/harness/notification-events.js +233 -0
- package/harness/output-limit-handler.js +233 -0
- package/harness/partial-recovery.js +413 -0
- package/harness/project-detector/detector.js +283 -0
- package/harness/repair-engine.js +256 -0
- package/harness/session-memory.js +293 -0
- package/harness/task-graph.js +87 -0
- package/index.js +1121 -0
- package/mirror.js +205 -0
- package/package.json +6 -4
- package/proactive-compact.js +42 -0
- package/renderer.js +134 -0
- package/status-parsers.js +63 -0
- package/tracker.js +49 -0
- package/windows.js +90 -0
|
@@ -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
|
+
}
|