igel-qe-core 1.0.16 → 1.0.20
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/dist/cli/index.js +198 -51
- package/package.json +1 -1
- package/src/cli/index.ts +220 -60
- package/src/cli/workflow_cli.py +83 -17
- package/src/db/client.py +20 -3
- package/src/deploy/requirements.txt +2 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { spawn, spawnSync, execSync } from "child_process";
|
|
5
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { setupPlatform } from "./platform.js";
|
|
@@ -220,34 +220,63 @@ function listFrameworkContextFiles(workspaceRoot) {
|
|
|
220
220
|
}
|
|
221
221
|
return Array.from(new Set(found)).sort();
|
|
222
222
|
}
|
|
223
|
-
function
|
|
224
|
-
//
|
|
225
|
-
const
|
|
223
|
+
function detectCopilotVariant() {
|
|
224
|
+
// Try `gh copilot --version` first (modern, official gh extension)
|
|
225
|
+
const ghCheck = spawnSync("gh", ["copilot", "--version"], {
|
|
226
226
|
encoding: "utf-8",
|
|
227
227
|
shell: process.platform === "win32",
|
|
228
|
-
timeout:
|
|
228
|
+
timeout: 8_000,
|
|
229
229
|
});
|
|
230
|
-
|
|
230
|
+
if (ghCheck.status === 0 && !ghCheck.error)
|
|
231
|
+
return "gh";
|
|
232
|
+
// Fallback: legacy standalone `copilot` binary
|
|
233
|
+
const legacyCheck = spawnSync("copilot", ["--version"], {
|
|
234
|
+
encoding: "utf-8",
|
|
235
|
+
shell: process.platform === "win32",
|
|
236
|
+
timeout: 8_000,
|
|
237
|
+
});
|
|
238
|
+
if (legacyCheck.status === 0 && !legacyCheck.error)
|
|
239
|
+
return "legacy";
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function isCopilotCliAvailable() {
|
|
243
|
+
return detectCopilotVariant() !== null;
|
|
231
244
|
}
|
|
232
245
|
function isCopilotAuthenticated() {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
const variant = detectCopilotVariant();
|
|
247
|
+
if (!variant)
|
|
248
|
+
return false;
|
|
249
|
+
if (variant === "gh") {
|
|
250
|
+
// `gh auth status` exits 0 when authenticated
|
|
251
|
+
const probe = spawnSync("gh", ["auth", "status"], {
|
|
252
|
+
encoding: "utf-8",
|
|
253
|
+
shell: process.platform === "win32",
|
|
254
|
+
timeout: 10_000,
|
|
255
|
+
});
|
|
256
|
+
return probe.status === 0 && !probe.error;
|
|
257
|
+
}
|
|
258
|
+
// Legacy variant — try `copilot auth status`
|
|
236
259
|
const probe = spawnSync("copilot", ["auth", "status"], {
|
|
237
260
|
encoding: "utf-8",
|
|
238
261
|
shell: process.platform === "win32",
|
|
239
|
-
timeout: 10_000,
|
|
262
|
+
timeout: 10_000,
|
|
240
263
|
});
|
|
241
264
|
return probe.status === 0 && !probe.error;
|
|
242
265
|
}
|
|
243
266
|
function ensureCopilotLogin(workspaceRoot) {
|
|
244
|
-
|
|
267
|
+
const variant = detectCopilotVariant();
|
|
268
|
+
if (!variant)
|
|
245
269
|
return false;
|
|
246
270
|
if (isCopilotAuthenticated())
|
|
247
271
|
return true;
|
|
248
272
|
console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
|
|
249
273
|
console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
|
|
250
|
-
const
|
|
274
|
+
const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
|
|
275
|
+
const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
|
|
276
|
+
stdio: "inherit",
|
|
277
|
+
encoding: "utf-8",
|
|
278
|
+
shell: process.platform === "win32",
|
|
279
|
+
});
|
|
251
280
|
if (login.status !== 0)
|
|
252
281
|
return false;
|
|
253
282
|
return isCopilotAuthenticated();
|
|
@@ -255,6 +284,59 @@ function ensureCopilotLogin(workspaceRoot) {
|
|
|
255
284
|
// Maximum number of context.md files to enrich per init run.
|
|
256
285
|
// Prevents extremely long waits on repos with many folders.
|
|
257
286
|
const MAX_ENRICHMENT_FILES = 15;
|
|
287
|
+
/**
|
|
288
|
+
* Attempt to enrich a single context.md file using `gh copilot suggest`.
|
|
289
|
+
* Returns the enriched text, or null on failure/empty response.
|
|
290
|
+
*/
|
|
291
|
+
function tryGhCopilotSuggest(workspaceRoot, relPath, currentContent) {
|
|
292
|
+
const prompt = [
|
|
293
|
+
`You are enriching a context.md file for an automation test framework.`,
|
|
294
|
+
`The file is located at: ${relPath} (relative to the workspace root).`,
|
|
295
|
+
``,
|
|
296
|
+
`Current content:`,
|
|
297
|
+
`\`\`\`markdown`,
|
|
298
|
+
currentContent,
|
|
299
|
+
`\`\`\``,
|
|
300
|
+
``,
|
|
301
|
+
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
302
|
+
`Requirements:`,
|
|
303
|
+
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
304
|
+
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
305
|
+
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
306
|
+
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
307
|
+
].join("\n");
|
|
308
|
+
const variant = detectCopilotVariant();
|
|
309
|
+
let result;
|
|
310
|
+
if (variant === "gh") {
|
|
311
|
+
// Modern: `gh copilot suggest -t shell "<prompt>"`
|
|
312
|
+
// We use --no-color so output can be parsed cleanly.
|
|
313
|
+
result = spawnSync("gh", ["copilot", "suggest", "-t", "shell", "--", prompt], {
|
|
314
|
+
cwd: workspaceRoot,
|
|
315
|
+
encoding: "utf-8",
|
|
316
|
+
shell: process.platform === "win32",
|
|
317
|
+
timeout: 120_000,
|
|
318
|
+
input: "\n", // auto-accept any interactive prompt
|
|
319
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else if (variant === "legacy") {
|
|
323
|
+
// Legacy @githubnext/github-copilot-cli binary
|
|
324
|
+
result = spawnSync("copilot", ["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"], {
|
|
325
|
+
encoding: "utf-8",
|
|
326
|
+
shell: process.platform === "win32",
|
|
327
|
+
timeout: 120_000,
|
|
328
|
+
input: "",
|
|
329
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
if (!result || result.status !== 0 || result.error)
|
|
336
|
+
return null;
|
|
337
|
+
const text = (result.stdout || "").trim();
|
|
338
|
+
return text.length > 50 ? text : null;
|
|
339
|
+
}
|
|
258
340
|
function enrichWithCopilotCli(workspaceRoot, contextFiles) {
|
|
259
341
|
let updated = 0;
|
|
260
342
|
let failed = 0;
|
|
@@ -263,38 +345,37 @@ function enrichWithCopilotCli(workspaceRoot, contextFiles) {
|
|
|
263
345
|
const total = filesToProcess.length;
|
|
264
346
|
for (let i = 0; i < filesToProcess.length; i++) {
|
|
265
347
|
const absPath = filesToProcess[i];
|
|
266
|
-
|
|
267
|
-
|
|
348
|
+
// Normalise to forward slashes so the prompt is readable on all platforms.
|
|
349
|
+
const relPath = absPath
|
|
350
|
+
.replace(`${workspaceRoot}\\`, "")
|
|
351
|
+
.replace(`${workspaceRoot}/`, "")
|
|
352
|
+
.replace(/\\/g, "/");
|
|
268
353
|
process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
timeout: 120_000, // 2 min per file — prevents indefinite hangs
|
|
290
|
-
input: "", // close stdin immediately
|
|
291
|
-
});
|
|
292
|
-
if (result.status === 0 && !result.error) {
|
|
293
|
-
process.stdout.write(" ✓\n");
|
|
294
|
-
updated += 1;
|
|
354
|
+
let currentContent = "";
|
|
355
|
+
try {
|
|
356
|
+
currentContent = readFileSync(absPath, "utf-8");
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
process.stdout.write(" ✗ (unreadable)\n");
|
|
360
|
+
failed += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
|
|
364
|
+
if (enriched) {
|
|
365
|
+
try {
|
|
366
|
+
writeFileSync(absPath, enriched, "utf-8");
|
|
367
|
+
process.stdout.write(" ✓\n");
|
|
368
|
+
updated += 1;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
process.stdout.write(" ✗ (write failed)\n");
|
|
372
|
+
failed += 1;
|
|
373
|
+
}
|
|
295
374
|
}
|
|
296
375
|
else {
|
|
297
|
-
|
|
376
|
+
// Copilot returned empty/trivial output — not a hard failure.
|
|
377
|
+
// The Python AST enricher will handle these in the fallback pass.
|
|
378
|
+
process.stdout.write(" ✗ (empty response)\n");
|
|
298
379
|
failed += 1;
|
|
299
380
|
}
|
|
300
381
|
}
|
|
@@ -463,21 +544,71 @@ program
|
|
|
463
544
|
if (allContextFiles.length === 0) {
|
|
464
545
|
console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
|
|
465
546
|
}
|
|
466
|
-
else if (!isCopilotCliAvailable()) {
|
|
467
|
-
console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
|
|
468
|
-
}
|
|
469
|
-
else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
470
|
-
console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
|
|
471
|
-
}
|
|
472
547
|
else {
|
|
473
548
|
const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
|
|
474
549
|
if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
|
|
475
550
|
console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
|
|
476
551
|
}
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
552
|
+
const copilotVariant = detectCopilotVariant();
|
|
553
|
+
let copilotUpdated = 0;
|
|
554
|
+
let copilotFailed = 0;
|
|
555
|
+
if (!copilotVariant) {
|
|
556
|
+
console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
|
|
557
|
+
}
|
|
558
|
+
else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
559
|
+
console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// Primary strategy: Copilot CLI
|
|
563
|
+
const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
|
|
564
|
+
copilotUpdated = run.updated;
|
|
565
|
+
copilotFailed = run.failed;
|
|
566
|
+
if (copilotUpdated > 0) {
|
|
567
|
+
console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Fallback / supplementary: Python AST enricher.
|
|
571
|
+
// Runs when Copilot is unavailable OR Copilot returned empty responses.
|
|
572
|
+
// The Python enricher does not require Copilot — it uses AST analysis
|
|
573
|
+
// and the Azure OpenAI key already configured in the project.
|
|
574
|
+
const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
|
|
575
|
+
if (needsFallback) {
|
|
576
|
+
const skippedFiles = allContextFiles
|
|
577
|
+
.slice(0, MAX_ENRICHMENT_FILES)
|
|
578
|
+
.filter((f) => {
|
|
579
|
+
// Only send files that Copilot did NOT update (i.e. the ones that failed).
|
|
580
|
+
// We approximate this as: all files when copilotUpdated=0,
|
|
581
|
+
// or the trailing copilotFailed files otherwise.
|
|
582
|
+
return true; // enrich_framework_context is idempotent — safe to re-enrich
|
|
583
|
+
});
|
|
584
|
+
if (skippedFiles.length > 0) {
|
|
585
|
+
if (copilotVariant) {
|
|
586
|
+
console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const enrichResult = await runWorkflow("enrich_framework_context", {
|
|
593
|
+
repo_path: workspaceRoot,
|
|
594
|
+
context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
|
|
595
|
+
});
|
|
596
|
+
const pyUpdated = Number(enrichResult.updated ?? 0);
|
|
597
|
+
const pyFailed = Number(enrichResult.failed ?? 0);
|
|
598
|
+
if (pyUpdated > 0) {
|
|
599
|
+
console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
|
|
600
|
+
}
|
|
601
|
+
if (pyFailed > 0) {
|
|
602
|
+
console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (pyErr) {
|
|
606
|
+
// Python enricher failed (likely DB offline) — not fatal.
|
|
607
|
+
// Context files still have the scaffolded template content.
|
|
608
|
+
console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`));
|
|
609
|
+
console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
481
612
|
}
|
|
482
613
|
}
|
|
483
614
|
}
|
|
@@ -660,6 +791,22 @@ program
|
|
|
660
791
|
console.log(JSON.stringify(result, null, 2));
|
|
661
792
|
}
|
|
662
793
|
else {
|
|
794
|
+
// DB-offline / degraded state — return a helpful message rather than nothing
|
|
795
|
+
if (result.db_offline || result.error) {
|
|
796
|
+
const msg = String(result.message || result.error || "Unknown error");
|
|
797
|
+
if (result.db_offline) {
|
|
798
|
+
console.log(chalk.yellow("\n⚠ Database not reachable"));
|
|
799
|
+
console.log(chalk.gray(` ${msg}`));
|
|
800
|
+
console.log(chalk.gray("\n Checklist:"));
|
|
801
|
+
console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
|
|
802
|
+
console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
|
|
803
|
+
console.log(chalk.gray(" 3. Re-run: igel-qe init"));
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
663
810
|
const state = result;
|
|
664
811
|
console.log(chalk.bold("\nProject State"));
|
|
665
812
|
const ps = state.project_state;
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { spawn, spawnSync, execSync } from "child_process";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from "fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
|
|
7
7
|
import { join, dirname } from "path";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import { setupPlatform } from "./platform.js";
|
|
@@ -231,40 +231,75 @@ function listFrameworkContextFiles(workspaceRoot: string): string[] {
|
|
|
231
231
|
return Array.from(new Set(found)).sort();
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
// ── Copilot CLI detection ──────────────────────────────────────────────────────
|
|
235
|
+
// Supports two CLI variants:
|
|
236
|
+
// 1. Modern: `gh copilot suggest "<prompt>"` (gh extension, current standard)
|
|
237
|
+
// 2. Legacy: `copilot --version` (@githubnext/github-copilot-cli, deprecated)
|
|
238
|
+
// Detection tries `gh copilot` first; falls back to bare `copilot` binary.
|
|
239
|
+
|
|
240
|
+
type CopilotVariant = "gh" | "legacy" | null;
|
|
241
|
+
|
|
242
|
+
function detectCopilotVariant(): CopilotVariant {
|
|
243
|
+
// Try `gh copilot --version` first (modern, official gh extension)
|
|
244
|
+
const ghCheck = spawnSync("gh", ["copilot", "--version"], {
|
|
237
245
|
encoding: "utf-8",
|
|
238
246
|
shell: process.platform === "win32",
|
|
239
|
-
timeout:
|
|
247
|
+
timeout: 8_000,
|
|
240
248
|
});
|
|
241
|
-
|
|
249
|
+
if (ghCheck.status === 0 && !ghCheck.error) return "gh";
|
|
250
|
+
|
|
251
|
+
// Fallback: legacy standalone `copilot` binary
|
|
252
|
+
const legacyCheck = spawnSync("copilot", ["--version"], {
|
|
253
|
+
encoding: "utf-8",
|
|
254
|
+
shell: process.platform === "win32",
|
|
255
|
+
timeout: 8_000,
|
|
256
|
+
});
|
|
257
|
+
if (legacyCheck.status === 0 && !legacyCheck.error) return "legacy";
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isCopilotCliAvailable(): boolean {
|
|
263
|
+
return detectCopilotVariant() !== null;
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
function isCopilotAuthenticated(): boolean {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
["auth", "status"],
|
|
251
|
-
{
|
|
267
|
+
const variant = detectCopilotVariant();
|
|
268
|
+
if (!variant) return false;
|
|
269
|
+
|
|
270
|
+
if (variant === "gh") {
|
|
271
|
+
// `gh auth status` exits 0 when authenticated
|
|
272
|
+
const probe = spawnSync("gh", ["auth", "status"], {
|
|
252
273
|
encoding: "utf-8",
|
|
253
274
|
shell: process.platform === "win32",
|
|
254
|
-
timeout: 10_000,
|
|
255
|
-
}
|
|
256
|
-
|
|
275
|
+
timeout: 10_000,
|
|
276
|
+
});
|
|
277
|
+
return probe.status === 0 && !probe.error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Legacy variant — try `copilot auth status`
|
|
281
|
+
const probe = spawnSync("copilot", ["auth", "status"], {
|
|
282
|
+
encoding: "utf-8",
|
|
283
|
+
shell: process.platform === "win32",
|
|
284
|
+
timeout: 10_000,
|
|
285
|
+
});
|
|
257
286
|
return probe.status === 0 && !probe.error;
|
|
258
287
|
}
|
|
259
288
|
|
|
260
289
|
function ensureCopilotLogin(workspaceRoot: string): boolean {
|
|
261
|
-
|
|
290
|
+
const variant = detectCopilotVariant();
|
|
291
|
+
if (!variant) return false;
|
|
262
292
|
if (isCopilotAuthenticated()) return true;
|
|
263
293
|
|
|
264
294
|
console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
|
|
265
295
|
console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
|
|
266
296
|
|
|
267
|
-
const
|
|
297
|
+
const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
|
|
298
|
+
const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
|
|
299
|
+
stdio: "inherit",
|
|
300
|
+
encoding: "utf-8",
|
|
301
|
+
shell: process.platform === "win32",
|
|
302
|
+
});
|
|
268
303
|
if (login.status !== 0) return false;
|
|
269
304
|
return isCopilotAuthenticated();
|
|
270
305
|
}
|
|
@@ -273,6 +308,72 @@ function ensureCopilotLogin(workspaceRoot: string): boolean {
|
|
|
273
308
|
// Prevents extremely long waits on repos with many folders.
|
|
274
309
|
const MAX_ENRICHMENT_FILES = 15;
|
|
275
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Attempt to enrich a single context.md file using `gh copilot suggest`.
|
|
313
|
+
* Returns the enriched text, or null on failure/empty response.
|
|
314
|
+
*/
|
|
315
|
+
function tryGhCopilotSuggest(
|
|
316
|
+
workspaceRoot: string,
|
|
317
|
+
relPath: string,
|
|
318
|
+
currentContent: string,
|
|
319
|
+
): string | null {
|
|
320
|
+
const prompt = [
|
|
321
|
+
`You are enriching a context.md file for an automation test framework.`,
|
|
322
|
+
`The file is located at: ${relPath} (relative to the workspace root).`,
|
|
323
|
+
``,
|
|
324
|
+
`Current content:`,
|
|
325
|
+
`\`\`\`markdown`,
|
|
326
|
+
currentContent,
|
|
327
|
+
`\`\`\``,
|
|
328
|
+
``,
|
|
329
|
+
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
330
|
+
`Requirements:`,
|
|
331
|
+
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
332
|
+
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
333
|
+
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
334
|
+
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
335
|
+
].join("\n");
|
|
336
|
+
|
|
337
|
+
const variant = detectCopilotVariant();
|
|
338
|
+
|
|
339
|
+
let result;
|
|
340
|
+
if (variant === "gh") {
|
|
341
|
+
// Modern: `gh copilot suggest -t shell "<prompt>"`
|
|
342
|
+
// We use --no-color so output can be parsed cleanly.
|
|
343
|
+
result = spawnSync(
|
|
344
|
+
"gh",
|
|
345
|
+
["copilot", "suggest", "-t", "shell", "--", prompt],
|
|
346
|
+
{
|
|
347
|
+
cwd: workspaceRoot,
|
|
348
|
+
encoding: "utf-8",
|
|
349
|
+
shell: process.platform === "win32",
|
|
350
|
+
timeout: 120_000,
|
|
351
|
+
input: "\n", // auto-accept any interactive prompt
|
|
352
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
} else if (variant === "legacy") {
|
|
356
|
+
// Legacy @githubnext/github-copilot-cli binary
|
|
357
|
+
result = spawnSync(
|
|
358
|
+
"copilot",
|
|
359
|
+
["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"],
|
|
360
|
+
{
|
|
361
|
+
encoding: "utf-8",
|
|
362
|
+
shell: process.platform === "win32",
|
|
363
|
+
timeout: 120_000,
|
|
364
|
+
input: "",
|
|
365
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!result || result.status !== 0 || result.error) return null;
|
|
373
|
+
const text = (result.stdout || "").trim();
|
|
374
|
+
return text.length > 50 ? text : null;
|
|
375
|
+
}
|
|
376
|
+
|
|
276
377
|
function enrichWithCopilotCli(
|
|
277
378
|
workspaceRoot: string,
|
|
278
379
|
contextFiles: string[],
|
|
@@ -286,45 +387,37 @@ function enrichWithCopilotCli(
|
|
|
286
387
|
|
|
287
388
|
for (let i = 0; i < filesToProcess.length; i++) {
|
|
288
389
|
const absPath = filesToProcess[i];
|
|
289
|
-
|
|
390
|
+
// Normalise to forward slashes so the prompt is readable on all platforms.
|
|
391
|
+
const relPath = absPath
|
|
392
|
+
.replace(`${workspaceRoot}\\`, "")
|
|
393
|
+
.replace(`${workspaceRoot}/`, "")
|
|
394
|
+
.replace(/\\/g, "/");
|
|
290
395
|
|
|
291
|
-
// Show per-file progress so the user knows work is happening.
|
|
292
396
|
process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
|
|
293
397
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
"- Preserve valid markdown.",
|
|
303
|
-
].join("\n");
|
|
304
|
-
|
|
305
|
-
const result = spawnSync(
|
|
306
|
-
"copilot",
|
|
307
|
-
[
|
|
308
|
-
"-C", workspaceRoot,
|
|
309
|
-
"-p", prompt,
|
|
310
|
-
"--allow-all-tools",
|
|
311
|
-
"--allow-all-paths",
|
|
312
|
-
"--no-color",
|
|
313
|
-
"-s",
|
|
314
|
-
],
|
|
315
|
-
{
|
|
316
|
-
encoding: "utf-8",
|
|
317
|
-
shell: process.platform === "win32",
|
|
318
|
-
timeout: 120_000, // 2 min per file — prevents indefinite hangs
|
|
319
|
-
input: "", // close stdin immediately
|
|
320
|
-
},
|
|
321
|
-
);
|
|
398
|
+
let currentContent = "";
|
|
399
|
+
try {
|
|
400
|
+
currentContent = readFileSync(absPath, "utf-8");
|
|
401
|
+
} catch {
|
|
402
|
+
process.stdout.write(" ✗ (unreadable)\n");
|
|
403
|
+
failed += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
322
406
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
407
|
+
const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
|
|
408
|
+
if (enriched) {
|
|
409
|
+
try {
|
|
410
|
+
writeFileSync(absPath, enriched, "utf-8");
|
|
411
|
+
process.stdout.write(" ✓\n");
|
|
412
|
+
updated += 1;
|
|
413
|
+
} catch {
|
|
414
|
+
process.stdout.write(" ✗ (write failed)\n");
|
|
415
|
+
failed += 1;
|
|
416
|
+
}
|
|
326
417
|
} else {
|
|
327
|
-
|
|
418
|
+
// Copilot returned empty/trivial output — not a hard failure.
|
|
419
|
+
// The Python AST enricher will handle these in the fallback pass.
|
|
420
|
+
process.stdout.write(" ✗ (empty response)\n");
|
|
328
421
|
failed += 1;
|
|
329
422
|
}
|
|
330
423
|
}
|
|
@@ -499,19 +592,70 @@ program
|
|
|
499
592
|
const allContextFiles = listFrameworkContextFiles(workspaceRoot);
|
|
500
593
|
if (allContextFiles.length === 0) {
|
|
501
594
|
console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
|
|
502
|
-
} else if (!isCopilotCliAvailable()) {
|
|
503
|
-
console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
|
|
504
|
-
} else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
505
|
-
console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
|
|
506
595
|
} else {
|
|
507
596
|
const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
|
|
508
597
|
if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
|
|
509
598
|
console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
|
|
510
599
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
600
|
+
|
|
601
|
+
const copilotVariant = detectCopilotVariant();
|
|
602
|
+
let copilotUpdated = 0;
|
|
603
|
+
let copilotFailed = 0;
|
|
604
|
+
|
|
605
|
+
if (!copilotVariant) {
|
|
606
|
+
console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
|
|
607
|
+
} else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
608
|
+
console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
|
|
609
|
+
} else {
|
|
610
|
+
// Primary strategy: Copilot CLI
|
|
611
|
+
const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
|
|
612
|
+
copilotUpdated = run.updated;
|
|
613
|
+
copilotFailed = run.failed;
|
|
614
|
+
if (copilotUpdated > 0) {
|
|
615
|
+
console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Fallback / supplementary: Python AST enricher.
|
|
620
|
+
// Runs when Copilot is unavailable OR Copilot returned empty responses.
|
|
621
|
+
// The Python enricher does not require Copilot — it uses AST analysis
|
|
622
|
+
// and the Azure OpenAI key already configured in the project.
|
|
623
|
+
const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
|
|
624
|
+
if (needsFallback) {
|
|
625
|
+
const skippedFiles = allContextFiles
|
|
626
|
+
.slice(0, MAX_ENRICHMENT_FILES)
|
|
627
|
+
.filter((f) => {
|
|
628
|
+
// Only send files that Copilot did NOT update (i.e. the ones that failed).
|
|
629
|
+
// We approximate this as: all files when copilotUpdated=0,
|
|
630
|
+
// or the trailing copilotFailed files otherwise.
|
|
631
|
+
return true; // enrich_framework_context is idempotent — safe to re-enrich
|
|
632
|
+
});
|
|
633
|
+
if (skippedFiles.length > 0) {
|
|
634
|
+
if (copilotVariant) {
|
|
635
|
+
console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
|
|
636
|
+
} else {
|
|
637
|
+
console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
const enrichResult = await runWorkflow("enrich_framework_context", {
|
|
641
|
+
repo_path: workspaceRoot,
|
|
642
|
+
context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
|
|
643
|
+
}) as Record<string, unknown>;
|
|
644
|
+
const pyUpdated = Number(enrichResult.updated ?? 0);
|
|
645
|
+
const pyFailed = Number(enrichResult.failed ?? 0);
|
|
646
|
+
if (pyUpdated > 0) {
|
|
647
|
+
console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
|
|
648
|
+
}
|
|
649
|
+
if (pyFailed > 0) {
|
|
650
|
+
console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
|
|
651
|
+
}
|
|
652
|
+
} catch (pyErr: unknown) {
|
|
653
|
+
// Python enricher failed (likely DB offline) — not fatal.
|
|
654
|
+
// Context files still have the scaffolded template content.
|
|
655
|
+
console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`) );
|
|
656
|
+
console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
515
659
|
}
|
|
516
660
|
}
|
|
517
661
|
} catch (e: unknown) {
|
|
@@ -697,6 +841,22 @@ program
|
|
|
697
841
|
if (options.json) {
|
|
698
842
|
console.log(JSON.stringify(result, null, 2));
|
|
699
843
|
} else {
|
|
844
|
+
// DB-offline / degraded state — return a helpful message rather than nothing
|
|
845
|
+
if (result.db_offline || result.error) {
|
|
846
|
+
const msg = String(result.message || result.error || "Unknown error");
|
|
847
|
+
if (result.db_offline) {
|
|
848
|
+
console.log(chalk.yellow("\n⚠ Database not reachable"));
|
|
849
|
+
console.log(chalk.gray(` ${msg}`));
|
|
850
|
+
console.log(chalk.gray("\n Checklist:"));
|
|
851
|
+
console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
|
|
852
|
+
console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
|
|
853
|
+
console.log(chalk.gray(" 3. Re-run: igel-qe init"));
|
|
854
|
+
} else {
|
|
855
|
+
console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
700
860
|
const state = (result as Record<string, unknown>);
|
|
701
861
|
console.log(chalk.bold("\nProject State"));
|
|
702
862
|
const ps = state.project_state as Record<string, number> | undefined;
|
package/src/cli/workflow_cli.py
CHANGED
|
@@ -27,18 +27,32 @@ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
|
27
27
|
if str(_PROJECT_ROOT) not in sys.path:
|
|
28
28
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
from src.
|
|
32
|
-
from src.
|
|
33
|
-
from src.
|
|
34
|
-
from src.intelligence.
|
|
35
|
-
from src.
|
|
36
|
-
from src.agents.
|
|
37
|
-
from src.agents.
|
|
38
|
-
from src.
|
|
39
|
-
from src.
|
|
40
|
-
from src.project.
|
|
41
|
-
from src.project.
|
|
30
|
+
try:
|
|
31
|
+
from src.db.igel_schema import ensure_igel_tables
|
|
32
|
+
from src.connectors.jira_connector import JiraConnector
|
|
33
|
+
from src.agents.workflow_engine import WorkflowEngine
|
|
34
|
+
from src.intelligence.diff_analyzer import analyze_diff_scope
|
|
35
|
+
from src.intelligence.routing_engine import RouteInputs, compute_weighted_score, classify_route
|
|
36
|
+
from src.agents.framework_analyzer_agent import FrameworkAnalyzerAgent
|
|
37
|
+
from src.agents.orchestrator.orchestrator import Orchestrator
|
|
38
|
+
from src.agents.internal.approval_manager import ApprovalError
|
|
39
|
+
from src.config import cfg
|
|
40
|
+
from src.project.context_projection_builder import ContextProjectionBuilder
|
|
41
|
+
from src.project.context_requirement_sync import sync_requirement_context
|
|
42
|
+
from src.project.context_enricher import enrich_context_files
|
|
43
|
+
_DEPS_AVAILABLE = True
|
|
44
|
+
_DEPS_ERROR: str | None = None
|
|
45
|
+
except ImportError as _ie:
|
|
46
|
+
_DEPS_AVAILABLE = False
|
|
47
|
+
_DEPS_ERROR = (
|
|
48
|
+
f"Missing Python dependency: {_ie}.\n"
|
|
49
|
+
"Run: igel-qe init --with-copilot to install required packages."
|
|
50
|
+
)
|
|
51
|
+
# Define stubs so references below don't cause NameErrors
|
|
52
|
+
ensure_igel_tables = JiraConnector = WorkflowEngine = None # type: ignore[assignment,misc]
|
|
53
|
+
analyze_diff_scope = RouteInputs = compute_weighted_score = classify_route = None # type: ignore[assignment]
|
|
54
|
+
FrameworkAnalyzerAgent = Orchestrator = ApprovalError = cfg = None # type: ignore[assignment,misc]
|
|
55
|
+
ContextProjectionBuilder = sync_requirement_context = enrich_context_files = None # type: ignore[assignment]
|
|
42
56
|
|
|
43
57
|
logging.basicConfig(level=logging.ERROR) # Only output strict JSON to stdout
|
|
44
58
|
|
|
@@ -99,8 +113,39 @@ def main():
|
|
|
99
113
|
except json.JSONDecodeError:
|
|
100
114
|
_err("Invalid JSON args")
|
|
101
115
|
|
|
116
|
+
# ── Dependency guard ───────────────────────────────────────────────────────
|
|
117
|
+
# If core Python packages (e.g. psycopg2) are missing, return a structured
|
|
118
|
+
# warning for init_check so the CLI can surface a helpful message rather
|
|
119
|
+
# than crashing with a raw traceback.
|
|
120
|
+
if not _DEPS_AVAILABLE:
|
|
121
|
+
if args.action in ("init_project", "init_check"):
|
|
122
|
+
print(json.dumps({
|
|
123
|
+
"status": "success",
|
|
124
|
+
"ready": False,
|
|
125
|
+
"required_failed": ["python_dependencies"],
|
|
126
|
+
"warnings": [],
|
|
127
|
+
"checks": {
|
|
128
|
+
"python_dependencies": {
|
|
129
|
+
"status": "error",
|
|
130
|
+
"message": _DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot",
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}))
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
else:
|
|
136
|
+
_err(_DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot")
|
|
137
|
+
|
|
102
138
|
# ── DB init ────────────────────────────────────────────────────────────────
|
|
103
|
-
|
|
139
|
+
# Skip for init_check / init_project: those actions run run_checks() which
|
|
140
|
+
# already tests DB connectivity and reports a structured "postgres: fail"
|
|
141
|
+
# result instead of a hard crash. Calling ensure_igel_tables() here first
|
|
142
|
+
# would cause a non-zero exit before run_checks() ever runs, swallowing
|
|
143
|
+
# the structured error output that the Node CLI depends on.
|
|
144
|
+
# Skip for get_project_status: it reads from DB internally and returns a
|
|
145
|
+
# graceful "db_offline" response on failure — no schema init needed.
|
|
146
|
+
# Also skip for submit_feedback which manages its own connection.
|
|
147
|
+
_DB_SKIP_ACTIONS = ("submit_feedback", "init_check", "init_project", "get_project_status")
|
|
148
|
+
if args.action not in _DB_SKIP_ACTIONS:
|
|
104
149
|
try:
|
|
105
150
|
ensure_igel_tables()
|
|
106
151
|
except Exception as e:
|
|
@@ -770,7 +815,9 @@ def main():
|
|
|
770
815
|
projection = _best_effort_project_context(repo_path)
|
|
771
816
|
|
|
772
817
|
_ok({
|
|
773
|
-
"
|
|
818
|
+
"updated": len(enriched), # TS reads this for progress display
|
|
819
|
+
"failed": 0, # AST enricher does not partially fail
|
|
820
|
+
"enriched_count": len(enriched), # backward compat alias
|
|
774
821
|
"enriched": [
|
|
775
822
|
{
|
|
776
823
|
"path": r.path,
|
|
@@ -787,7 +834,14 @@ def main():
|
|
|
787
834
|
"context_projection": projection,
|
|
788
835
|
})
|
|
789
836
|
except Exception as e:
|
|
790
|
-
|
|
837
|
+
# Return a partial-success response so TS can show a warning instead of crashing.
|
|
838
|
+
# Common failure: DB offline (context files are still written to disk).
|
|
839
|
+
_ok({
|
|
840
|
+
"updated": 0,
|
|
841
|
+
"failed": -1, # signals error condition to TS
|
|
842
|
+
"error": str(e),
|
|
843
|
+
"enriched_count": 0,
|
|
844
|
+
})
|
|
791
845
|
|
|
792
846
|
# ── update_context_file ─────────────────────────────────────────────────
|
|
793
847
|
# Accepts: {
|
|
@@ -932,9 +986,21 @@ def main():
|
|
|
932
986
|
summary = rebuild_system_state_from_db(root)
|
|
933
987
|
_ok(summary)
|
|
934
988
|
except Exception as e:
|
|
935
|
-
|
|
989
|
+
# Return graceful degraded status so `igel-qe status` doesn't crash
|
|
990
|
+
# when DB is offline — users should still get a useful message.
|
|
991
|
+
err_msg = str(e)
|
|
992
|
+
db_offline = "password" in err_msg.lower() or "connection" in err_msg.lower() or "psycopg" in err_msg.lower()
|
|
993
|
+
_ok({
|
|
994
|
+
"db_offline": db_offline,
|
|
995
|
+
"error": err_msg,
|
|
996
|
+
"project_state": None,
|
|
997
|
+
"sync_state": None,
|
|
998
|
+
"message": (
|
|
999
|
+
"Database is offline or not configured. "
|
|
1000
|
+
"Set DB_PASSWORD / DATABASE_URL in your environment and re-run igel-qe init."
|
|
1001
|
+
) if db_offline else err_msg,
|
|
1002
|
+
})
|
|
936
1003
|
|
|
937
1004
|
|
|
938
1005
|
if __name__ == "__main__":
|
|
939
1006
|
main()
|
|
940
|
-
|
package/src/db/client.py
CHANGED
|
@@ -3,15 +3,31 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
import psycopg2
|
|
6
|
+
try:
|
|
7
|
+
import psycopg2
|
|
8
|
+
import psycopg2.pool
|
|
9
|
+
_PSYCOPG2_AVAILABLE = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
psycopg2 = None # type: ignore[assignment]
|
|
12
|
+
_PSYCOPG2_AVAILABLE = False
|
|
13
|
+
|
|
8
14
|
from contextlib import contextmanager
|
|
9
15
|
|
|
10
16
|
from src.config import cfg
|
|
11
17
|
|
|
12
18
|
logger = logging.getLogger(__name__)
|
|
13
19
|
|
|
14
|
-
_pool: psycopg2.pool.ThreadedConnectionPool | None = None
|
|
20
|
+
_pool: "psycopg2.pool.ThreadedConnectionPool | None" = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_psycopg2() -> None:
|
|
24
|
+
"""Raise a clear error if psycopg2 is not installed."""
|
|
25
|
+
if not _PSYCOPG2_AVAILABLE:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"psycopg2 is not installed. Run: pip install psycopg2-binary\n"
|
|
28
|
+
"or re-run: igel-qe init --with-copilot (which will retry the install)"
|
|
29
|
+
)
|
|
30
|
+
|
|
15
31
|
|
|
16
32
|
|
|
17
33
|
def _pool_kwargs() -> dict:
|
|
@@ -32,6 +48,7 @@ def _pool_kwargs() -> dict:
|
|
|
32
48
|
|
|
33
49
|
|
|
34
50
|
def init_pool(minconn: int = 2, maxconn: int = 10) -> None:
|
|
51
|
+
_require_psycopg2()
|
|
35
52
|
global _pool
|
|
36
53
|
if _pool is not None:
|
|
37
54
|
return
|
|
@@ -49,7 +49,8 @@ beautifulsoup4>=4.12.0
|
|
|
49
49
|
lxml>=5.0.0
|
|
50
50
|
duckduckgo-search>=6.0.0,<7.0.0; python_version < "3.9"
|
|
51
51
|
firecrawl-py>=1.0.0
|
|
52
|
-
playwright>=
|
|
52
|
+
# playwright: no upper bound — crawl4ai>=0.4 requires playwright>=1.49
|
|
53
|
+
playwright>=1.49.0
|
|
53
54
|
crawl4ai>=0.4.0,<0.5.0; python_version >= "3.9"
|
|
54
55
|
|
|
55
56
|
# REST API server (always-running service)
|