@mcoda/core 0.1.9 → 0.1.12
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 +2 -2
- package/dist/api/AgentsApi.d.ts +1 -0
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +136 -11
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +6 -0
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +7 -0
- package/dist/services/agents/AgentRatingService.d.ts +19 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -1
- package/dist/services/agents/AgentRatingService.js +66 -2
- package/dist/services/agents/GatewayAgentService.d.ts +8 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +462 -65
- package/dist/services/agents/GatewayHandoff.d.ts +5 -1
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -1
- package/dist/services/agents/GatewayHandoff.js +65 -32
- package/dist/services/agents/RoutingService.d.ts +1 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -1
- package/dist/services/agents/RoutingService.js +4 -4
- package/dist/services/backlog/BacklogService.d.ts +23 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -1
- package/dist/services/backlog/BacklogService.js +62 -7
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +16 -4
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +529 -73
- package/dist/services/docs/DocInventory.d.ts +11 -0
- package/dist/services/docs/DocInventory.d.ts.map +1 -0
- package/dist/services/docs/DocInventory.js +230 -0
- package/dist/services/docs/DocgenRunContext.d.ts +59 -0
- package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
- package/dist/services/docs/DocgenRunContext.js +4 -0
- package/dist/services/docs/DocsService.d.ts +59 -2
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +1701 -48
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
- package/dist/services/docs/patch/DocPatchEngine.js +331 -0
- package/dist/services/docs/review/Glossary.d.ts +16 -0
- package/dist/services/docs/review/Glossary.d.ts.map +1 -0
- package/dist/services/docs/review/Glossary.js +47 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportSchema.js +47 -0
- package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
- package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewTypes.js +94 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
- package/dist/services/docs/review/glossary.json +47 -0
- package/dist/services/estimate/EstimateService.d.ts +2 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -1
- package/dist/services/estimate/EstimateService.js +66 -18
- package/dist/services/estimate/VelocityService.d.ts +4 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -1
- package/dist/services/estimate/VelocityService.js +179 -36
- package/dist/services/estimate/types.d.ts +1 -0
- package/dist/services/estimate/types.d.ts.map +1 -1
- package/dist/services/execution/GatewayTrioService.d.ts +71 -4
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -1
- package/dist/services/execution/GatewayTrioService.js +1695 -328
- package/dist/services/execution/QaApiRunner.d.ts +30 -0
- package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
- package/dist/services/execution/QaApiRunner.js +881 -0
- package/dist/services/execution/QaFollowupService.d.ts +1 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +8 -2
- package/dist/services/execution/QaPlanValidator.d.ts +10 -0
- package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
- package/dist/services/execution/QaPlanValidator.js +128 -0
- package/dist/services/execution/QaProfileService.d.ts +21 -1
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +214 -29
- package/dist/services/execution/QaTasksService.d.ts +41 -1
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +2851 -500
- package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
- package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
- package/dist/services/execution/QaTestCommandBuilder.js +495 -0
- package/dist/services/execution/TaskSelectionService.d.ts +4 -2
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
- package/dist/services/execution/TaskSelectionService.js +144 -28
- package/dist/services/execution/TaskStateService.d.ts +19 -6
- package/dist/services/execution/TaskStateService.d.ts.map +1 -1
- package/dist/services/execution/TaskStateService.js +128 -13
- package/dist/services/execution/WorkOnTasksService.d.ts +19 -2
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +3913 -1225
- package/dist/services/jobs/JobInsightsService.d.ts +4 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
- package/dist/services/jobs/JobInsightsService.js +51 -5
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
- package/dist/services/jobs/JobResumeService.js +23 -10
- package/dist/services/jobs/JobService.d.ts +56 -4
- package/dist/services/jobs/JobService.d.ts.map +1 -1
- package/dist/services/jobs/JobService.js +232 -1
- package/dist/services/openapi/OpenApiService.d.ts +41 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +889 -98
- package/dist/services/planning/CreateTasksService.d.ts +15 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +311 -6
- package/dist/services/planning/RefineTasksService.d.ts +4 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +225 -24
- package/dist/services/review/CodeReviewService.d.ts +4 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +778 -232
- package/dist/services/review/ReviewNormalizer.d.ts +9 -0
- package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
- package/dist/services/review/ReviewNormalizer.js +147 -0
- package/dist/services/shared/AuthErrors.d.ts +3 -0
- package/dist/services/shared/AuthErrors.d.ts.map +1 -0
- package/dist/services/shared/AuthErrors.js +17 -0
- package/dist/services/shared/DocdexGuidance.d.ts +7 -0
- package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
- package/dist/services/shared/DocdexGuidance.js +12 -0
- package/dist/services/shared/ProjectGuidance.d.ts +12 -1
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
- package/dist/services/shared/ProjectGuidance.js +64 -7
- package/dist/services/system/ToolDenylist.d.ts +13 -0
- package/dist/services/system/ToolDenylist.d.ts.map +1 -0
- package/dist/services/system/ToolDenylist.js +85 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/dist/services/telemetry/TelemetryService.js +39 -7
- package/dist/workspace/WorkspaceManager.d.ts +22 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +203 -32
- package/package.json +6 -5
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { execFile, spawn } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { once } from 'node:events';
|
|
10
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
11
|
+
import net from 'node:net';
|
|
4
12
|
import { WorkspaceRepository } from '@mcoda/db';
|
|
5
|
-
import { PathHelper } from '@mcoda/shared';
|
|
13
|
+
import { PathHelper, QA_ALLOWED_STATUSES, filterTaskStatuses } from '@mcoda/shared';
|
|
6
14
|
import { JobService } from '../jobs/JobService.js';
|
|
7
15
|
import { TaskSelectionService } from './TaskSelectionService.js';
|
|
8
16
|
import { TaskStateService } from './TaskStateService.js';
|
|
9
17
|
import { QaProfileService } from './QaProfileService.js';
|
|
10
18
|
import { QaFollowupService } from './QaFollowupService.js';
|
|
11
19
|
import { CliQaAdapter } from '@mcoda/integrations/qa/CliQaAdapter.js';
|
|
12
|
-
import { ChromiumQaAdapter } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
|
|
20
|
+
import { ChromiumQaAdapter, resolveChromiumBinary } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
|
|
13
21
|
import { MaestroQaAdapter } from '@mcoda/integrations/qa/MaestroQaAdapter.js';
|
|
14
22
|
import { VcsClient } from '@mcoda/integrations';
|
|
15
23
|
import readline from 'node:readline/promises';
|
|
@@ -19,18 +27,108 @@ import { GlobalRepository } from '@mcoda/db';
|
|
|
19
27
|
import { DocdexClient } from '@mcoda/integrations';
|
|
20
28
|
import { RoutingService } from '../agents/RoutingService.js';
|
|
21
29
|
import { AgentRatingService } from '../agents/AgentRatingService.js';
|
|
22
|
-
import { loadProjectGuidance } from '../shared/ProjectGuidance.js';
|
|
30
|
+
import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from '../shared/ProjectGuidance.js';
|
|
31
|
+
import { buildDocdexUsageGuidance } from '../shared/DocdexGuidance.js';
|
|
23
32
|
import { createTaskCommentSlug, formatTaskCommentBody } from '../tasks/TaskCommentFormatter.js';
|
|
33
|
+
import { AUTH_ERROR_REASON, isAuthErrorMessage } from '../shared/AuthErrors.js';
|
|
34
|
+
import { normalizeQaPlanOutput } from './QaPlanValidator.js';
|
|
35
|
+
import { QaApiRunner } from './QaApiRunner.js';
|
|
36
|
+
import { QaTestCommandBuilder } from './QaTestCommandBuilder.js';
|
|
37
|
+
const execFileAsync = promisify(execFile);
|
|
24
38
|
const DEFAULT_QA_PROMPT = [
|
|
25
|
-
'You are the QA agent.
|
|
26
|
-
|
|
27
|
-
'
|
|
39
|
+
'You are the QA agent.',
|
|
40
|
+
buildDocdexUsageGuidance({ contextLabel: 'the QA report', includeHeading: false, includeFallback: true }),
|
|
41
|
+
'Use docdex snippets to derive acceptance criteria, data contracts, edge cases, and non-functional requirements (performance, accessibility, offline/online assumptions).',
|
|
42
|
+
'QA policy: always run automated tests. Use browser (Chromium) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage. When test_requirements list unit/component/integration/api, run them in that order using stack-appropriate tools. Prefer tests/all.js or package manager test scripts when no category split is available; do not suggest Jest configs unless the repo explicitly documents them.',
|
|
28
43
|
].join('\n');
|
|
29
|
-
const
|
|
44
|
+
const REPO_PROMPTS_DIR = fileURLToPath(new URL('../../../../../prompts/', import.meta.url));
|
|
45
|
+
const resolveRepoPromptPath = (filename) => path.join(REPO_PROMPTS_DIR, filename);
|
|
46
|
+
const QA_TEST_POLICY = 'QA policy: always run automated tests. Use browser (Chromium) tests only when the project has a web UI; otherwise run API/endpoint/CLI tests that simulate real usage. When test_requirements list unit/component/integration/api, run them in that order using stack-appropriate tools. Prefer tests/all.js or package manager test scripts when no category split is available; do not suggest Jest configs unless the repo explicitly documents them.';
|
|
47
|
+
const QA_ROUTING_PROMPT = [
|
|
48
|
+
'You are the mcoda QA routing agent.',
|
|
49
|
+
'Decide which QA profiles should run for each task in this job.',
|
|
50
|
+
'Return a QA plan that maps tasks to profiles and action lists (cli/api/browser/stress).',
|
|
51
|
+
'Only use the provided profile names.',
|
|
52
|
+
'Always include CLI when tests are available.',
|
|
53
|
+
'When adding CLI commands, cover functional checks: unit -> component -> integration -> api (when test_requirements exist), then tests/all.js or package.json test script, plus build, lint, and CLI smoke commands where relevant.',
|
|
54
|
+
'Prefer category scripts (test:unit/test:component/test:integration/test:api) when they exist; otherwise use stack-appropriate test tools.',
|
|
55
|
+
'UI/front-end tasks must include chromium alongside CLI and include browser actions.',
|
|
56
|
+
'Only include chromium when the task itself is UI/front-end (ui_task=yes). Do not add chromium just because ui_repo=yes.',
|
|
57
|
+
'Include at least one light stress action for UI tasks (repeat navigation or submit) when safe.',
|
|
58
|
+
'Browser actions must use types navigate/click/type/wait_for/snapshot/script; do not emit assertText/assert_text. For text checks, use a script action (optionally preceded by snapshot).',
|
|
59
|
+
'API/back-end tasks (api_task=yes) must include API requests with sample data/auth when available.',
|
|
60
|
+
'Only include API requests when api_task=yes or the plan explicitly defines api base_url/requests.',
|
|
61
|
+
'Do not hardcode ports. If base_url is unknown, omit it and rely on MCODA_QA_API_BASE_URL or detected server ports.',
|
|
62
|
+
'If unsure, choose a safe minimal set (usually [\"cli\"]) and avoid guessing API endpoints.',
|
|
63
|
+
].join('\n');
|
|
64
|
+
const QA_ROUTING_OUTPUT_SCHEMA = [
|
|
65
|
+
'Return strict JSON only with shape:',
|
|
66
|
+
'Browser action types: navigate/click/type/wait_for/snapshot/script. Use script+expect for text checks; never emit assertText/assert_text.',
|
|
67
|
+
'{',
|
|
68
|
+
' \"task_profiles\": { \"TASK_KEY\": [\"profile1\", \"profile2\"] },',
|
|
69
|
+
' \"task_plans\": {',
|
|
70
|
+
' \"TASK_KEY\": {',
|
|
71
|
+
' \"profiles\": [\"cli\", \"chromium\"],',
|
|
72
|
+
' \"cli\": { \"commands\": [\"pnpm test\", \"node tests/all.js\"] },',
|
|
73
|
+
' \"api\": { \"base_url\": \"http://localhost:<PORT>\", \"requests\": [{ \"method\": \"GET\", \"path\": \"/health\", \"expect\": { \"status\": 200 } }] },',
|
|
74
|
+
' \"browser\": { \"base_url\": \"http://localhost:<PORT>\", \"actions\": [{ \"type\": \"navigate\", \"url\": \"/\" }, { \"type\": \"snapshot\", \"name\": \"home\" }, { \"type\": \"script\", \"expression\": \"document.body ? document.body.innerText : \\\"\\\"\", \"expect\": \"Welcome\" }] },',
|
|
75
|
+
' \"stress\": { \"api\": [], \"browser\": [] }',
|
|
76
|
+
' }',
|
|
77
|
+
' },',
|
|
78
|
+
' \"notes\": \"optional\"',
|
|
79
|
+
'}',
|
|
80
|
+
'No markdown or prose. task_profiles is required; task_plans is optional when actions are unknown.',
|
|
81
|
+
].join('\n');
|
|
82
|
+
const QA_AGENT_INTERPRETATION_ENV = 'MCODA_QA_AGENT_INTERPRETATION';
|
|
83
|
+
const QA_REQUIRED_DEPS = ['argon2', 'pg', 'ioredis', '@jest/globals'];
|
|
84
|
+
const QA_REQUIRED_ENV = [
|
|
85
|
+
{ dep: 'pg', env: 'TEST_DB_URL' },
|
|
86
|
+
{ dep: 'ioredis', env: 'TEST_REDIS_URL' },
|
|
87
|
+
];
|
|
30
88
|
const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
|
|
31
89
|
const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
|
|
90
|
+
const GATEWAY_PROMPT_MARKERS = [
|
|
91
|
+
'you are the gateway agent',
|
|
92
|
+
'return json only',
|
|
93
|
+
'output json only',
|
|
94
|
+
'docdexnotes',
|
|
95
|
+
'fileslikelytouched',
|
|
96
|
+
'filestocreate',
|
|
97
|
+
'do not include fields outside the schema',
|
|
98
|
+
];
|
|
99
|
+
const sanitizeNonGatewayPrompt = (value) => {
|
|
100
|
+
if (!value)
|
|
101
|
+
return undefined;
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
if (!trimmed)
|
|
104
|
+
return undefined;
|
|
105
|
+
const lower = trimmed.toLowerCase();
|
|
106
|
+
if (GATEWAY_PROMPT_MARKERS.some((marker) => lower.includes(marker)))
|
|
107
|
+
return undefined;
|
|
108
|
+
return trimmed;
|
|
109
|
+
};
|
|
110
|
+
const readPromptFile = async (promptPath, fallback) => {
|
|
111
|
+
try {
|
|
112
|
+
const content = await fs.readFile(promptPath, 'utf8');
|
|
113
|
+
const trimmed = content.trim();
|
|
114
|
+
if (trimmed)
|
|
115
|
+
return trimmed;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// fall through to fallback
|
|
119
|
+
}
|
|
120
|
+
return fallback;
|
|
121
|
+
};
|
|
32
122
|
const RUN_ALL_TESTS_MARKER = 'mcoda_run_all_tests_complete';
|
|
33
123
|
const RUN_ALL_TESTS_GUIDANCE = 'Run-all tests did not emit the expected marker. Ensure tests/all.js prints "MCODA_RUN_ALL_TESTS_COMPLETE".';
|
|
124
|
+
const QA_CLEAN_IGNORE_DEFAULTS = ['test-results', 'repo_meta.json', 'logs/', '.docdexignore'];
|
|
125
|
+
const DEFAULT_QA_HOST = '127.0.0.1';
|
|
126
|
+
const QA_HOST_ENV_KEYS = ['HOST', 'BIND_ADDR', 'BIND_ADDRESS', 'LISTEN_HOST', 'VITE_HOST', 'NUXT_HOST'];
|
|
127
|
+
const QA_PORT_ENV_KEYS = ['PORT', 'VITE_PORT', 'NUXT_PORT', 'NEXT_PORT'];
|
|
128
|
+
const QA_SERVER_START_ENV = 'MCODA_QA_START_SERVER';
|
|
129
|
+
const QA_SERVER_TIMEOUT_ENV = 'MCODA_QA_SERVER_TIMEOUT_MS';
|
|
130
|
+
const DEFAULT_QA_SERVER_TIMEOUT_MS = 5000;
|
|
131
|
+
const QA_INSTALL_DEPS_ENV = 'MCODA_QA_INSTALL_DEPS';
|
|
34
132
|
const normalizeSlugList = (input) => {
|
|
35
133
|
if (!Array.isArray(input))
|
|
36
134
|
return [];
|
|
@@ -44,6 +142,201 @@ const normalizeSlugList = (input) => {
|
|
|
44
142
|
}
|
|
45
143
|
return Array.from(cleaned);
|
|
46
144
|
};
|
|
145
|
+
const normalizePath = (value) => value
|
|
146
|
+
.replace(/\\/g, '/')
|
|
147
|
+
.replace(/^\.\//, '')
|
|
148
|
+
.replace(/^\/+/, '');
|
|
149
|
+
const normalizeCleanIgnorePaths = (input) => {
|
|
150
|
+
const normalized = new Set();
|
|
151
|
+
for (const entry of input) {
|
|
152
|
+
if (!entry)
|
|
153
|
+
continue;
|
|
154
|
+
const trimmed = entry.trim();
|
|
155
|
+
if (!trimmed)
|
|
156
|
+
continue;
|
|
157
|
+
normalized.add(normalizePath(trimmed));
|
|
158
|
+
}
|
|
159
|
+
return Array.from(normalized).filter(Boolean);
|
|
160
|
+
};
|
|
161
|
+
const normalizeLineNumber = (value) => {
|
|
162
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
163
|
+
return Math.max(1, Math.round(value));
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === 'string') {
|
|
166
|
+
const parsed = Number.parseInt(value, 10);
|
|
167
|
+
if (Number.isFinite(parsed))
|
|
168
|
+
return Math.max(1, parsed);
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
};
|
|
172
|
+
const detectApiTask = (task) => {
|
|
173
|
+
const metadata = task.metadata ?? {};
|
|
174
|
+
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
|
175
|
+
const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
|
|
176
|
+
? metadata.last_review_changed_paths
|
|
177
|
+
: [];
|
|
178
|
+
const combined = [...files, ...reviewFiles].map((file) => String(file).toLowerCase());
|
|
179
|
+
const apiHints = ['/api/', '/routes/', '/controllers/', '/server/', '/backend/', '/services/'];
|
|
180
|
+
if (combined.some((file) => apiHints.some((hint) => file.includes(hint))))
|
|
181
|
+
return true;
|
|
182
|
+
const key = (task.key ?? '').toLowerCase();
|
|
183
|
+
if (key.startsWith('bck-') || key.startsWith('api-'))
|
|
184
|
+
return true;
|
|
185
|
+
const type = String(task.type ?? '').toLowerCase();
|
|
186
|
+
if (type.includes('backend') || type.includes('api'))
|
|
187
|
+
return true;
|
|
188
|
+
const acceptance = Array.isArray(task.acceptanceCriteria)
|
|
189
|
+
? task.acceptanceCriteria
|
|
190
|
+
: [];
|
|
191
|
+
const text = [task.key, task.title, task.description, task.type, ...acceptance]
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join(' ')
|
|
194
|
+
.toLowerCase();
|
|
195
|
+
if (!text)
|
|
196
|
+
return false;
|
|
197
|
+
const apiPhrases = [
|
|
198
|
+
'endpoint',
|
|
199
|
+
'route',
|
|
200
|
+
'router',
|
|
201
|
+
'controller',
|
|
202
|
+
'backend',
|
|
203
|
+
'server',
|
|
204
|
+
'openapi',
|
|
205
|
+
'swagger',
|
|
206
|
+
'graphql',
|
|
207
|
+
'rest',
|
|
208
|
+
];
|
|
209
|
+
return apiPhrases.some((phrase) => text.includes(phrase));
|
|
210
|
+
};
|
|
211
|
+
const applyQaHostDefaults = (env) => {
|
|
212
|
+
const next = { ...env };
|
|
213
|
+
for (const key of QA_HOST_ENV_KEYS) {
|
|
214
|
+
const value = next[key];
|
|
215
|
+
if (!value || value === '0.0.0.0') {
|
|
216
|
+
next[key] = DEFAULT_QA_HOST;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return next;
|
|
220
|
+
};
|
|
221
|
+
const resolveEnvPort = (env) => {
|
|
222
|
+
for (const key of QA_PORT_ENV_KEYS) {
|
|
223
|
+
const raw = env[key];
|
|
224
|
+
if (!raw)
|
|
225
|
+
continue;
|
|
226
|
+
const parsed = Number.parseInt(raw, 10);
|
|
227
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
228
|
+
return parsed;
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
};
|
|
232
|
+
const applyQaPortDefaults = (env, port) => {
|
|
233
|
+
for (const key of QA_PORT_ENV_KEYS) {
|
|
234
|
+
if (!env[key]) {
|
|
235
|
+
env[key] = String(port);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const resolveUrlPort = (value) => {
|
|
240
|
+
try {
|
|
241
|
+
const url = new URL(value);
|
|
242
|
+
const port = url.port !== ''
|
|
243
|
+
? Number.parseInt(url.port, 10)
|
|
244
|
+
: url.protocol === 'https:'
|
|
245
|
+
? 443
|
|
246
|
+
: 80;
|
|
247
|
+
if (!Number.isFinite(port) || port <= 0)
|
|
248
|
+
return undefined;
|
|
249
|
+
return { url, port };
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
const isPortOpen = async (host, port, timeoutMs = 500) => await new Promise((resolve) => {
|
|
256
|
+
const socket = new net.Socket();
|
|
257
|
+
let settled = false;
|
|
258
|
+
const finish = (open) => {
|
|
259
|
+
if (settled)
|
|
260
|
+
return;
|
|
261
|
+
settled = true;
|
|
262
|
+
socket.destroy();
|
|
263
|
+
resolve(open);
|
|
264
|
+
};
|
|
265
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
266
|
+
socket.once('error', () => {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
finish(false);
|
|
269
|
+
});
|
|
270
|
+
socket.once('connect', () => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
finish(true);
|
|
273
|
+
});
|
|
274
|
+
socket.connect(port, host);
|
|
275
|
+
});
|
|
276
|
+
const pickFreePort = async (host) => await new Promise((resolve, reject) => {
|
|
277
|
+
const server = net.createServer();
|
|
278
|
+
server.unref();
|
|
279
|
+
server.on('error', reject);
|
|
280
|
+
server.listen(0, host, () => {
|
|
281
|
+
const address = server.address();
|
|
282
|
+
if (typeof address === 'object' && address?.port) {
|
|
283
|
+
const port = address.port;
|
|
284
|
+
server.close(() => resolve(port));
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
server.close(() => reject(new Error('Failed to acquire free port')));
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
const normalizeQaUrl = (value) => {
|
|
292
|
+
if (!value)
|
|
293
|
+
return undefined;
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(value);
|
|
296
|
+
if (url.hostname === '0.0.0.0') {
|
|
297
|
+
url.hostname = DEFAULT_QA_HOST;
|
|
298
|
+
}
|
|
299
|
+
return url.toString().replace(/\/$/, '');
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const isEnvEnabled = (name) => {
|
|
306
|
+
const raw = process.env[name];
|
|
307
|
+
if (!raw)
|
|
308
|
+
return false;
|
|
309
|
+
const normalized = raw.trim().toLowerCase();
|
|
310
|
+
return !['0', 'false', 'off', 'no'].includes(normalized);
|
|
311
|
+
};
|
|
312
|
+
const shouldAutoStartServer = () => {
|
|
313
|
+
if (process.env[QA_SERVER_START_ENV] === undefined)
|
|
314
|
+
return true;
|
|
315
|
+
return isEnvEnabled(QA_SERVER_START_ENV);
|
|
316
|
+
};
|
|
317
|
+
const resolveServerTimeoutMs = () => {
|
|
318
|
+
const raw = process.env[QA_SERVER_TIMEOUT_ENV];
|
|
319
|
+
if (!raw)
|
|
320
|
+
return DEFAULT_QA_SERVER_TIMEOUT_MS;
|
|
321
|
+
const parsed = Number.parseInt(raw, 10);
|
|
322
|
+
if (Number.isFinite(parsed)) {
|
|
323
|
+
if (parsed <= 0)
|
|
324
|
+
return 0;
|
|
325
|
+
return parsed;
|
|
326
|
+
}
|
|
327
|
+
return DEFAULT_QA_SERVER_TIMEOUT_MS;
|
|
328
|
+
};
|
|
329
|
+
const isLocalBaseUrl = (value) => {
|
|
330
|
+
if (!value)
|
|
331
|
+
return false;
|
|
332
|
+
try {
|
|
333
|
+
const url = new URL(value);
|
|
334
|
+
return ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
47
340
|
const parseCommentBody = (body) => {
|
|
48
341
|
const trimmed = (body ?? '').trim();
|
|
49
342
|
if (!trimmed)
|
|
@@ -78,8 +371,15 @@ const buildCommentBacklog = (comments) => {
|
|
|
78
371
|
const lines = [];
|
|
79
372
|
const toSingleLine = (value) => value.replace(/\s+/g, ' ').trim();
|
|
80
373
|
for (const comment of comments) {
|
|
81
|
-
const slug = comment.slug?.trim() || undefined;
|
|
82
374
|
const details = parseCommentBody(comment.body);
|
|
375
|
+
const slug = comment.slug?.trim() ||
|
|
376
|
+
createTaskCommentSlug({
|
|
377
|
+
source: comment.sourceCommand ?? 'comment',
|
|
378
|
+
message: details.message || comment.body,
|
|
379
|
+
file: comment.file,
|
|
380
|
+
line: comment.line,
|
|
381
|
+
category: comment.category ?? null,
|
|
382
|
+
});
|
|
83
383
|
const key = slug ??
|
|
84
384
|
`${comment.sourceCommand}:${comment.file ?? ''}:${comment.line ?? ''}:${details.message || comment.body}`;
|
|
85
385
|
if (seen.has(key))
|
|
@@ -104,7 +404,6 @@ const formatSlugList = (slugs, limit = 12) => {
|
|
|
104
404
|
return slugs.join(', ');
|
|
105
405
|
return `${slugs.slice(0, limit).join(', ')} (+${slugs.length - limit} more)`;
|
|
106
406
|
};
|
|
107
|
-
const MCODA_GITIGNORE_ENTRY = '.mcoda/\n';
|
|
108
407
|
export class QaTasksService {
|
|
109
408
|
constructor(workspace, deps) {
|
|
110
409
|
this.workspace = workspace;
|
|
@@ -112,7 +411,8 @@ export class QaTasksService {
|
|
|
112
411
|
this.dryRunGuard = false;
|
|
113
412
|
this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
114
413
|
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
115
|
-
this.profileService =
|
|
414
|
+
this.profileService =
|
|
415
|
+
deps.profileService ?? new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
|
|
116
416
|
this.followupService = deps.followupService ?? new QaFollowupService(deps.workspaceRepo, workspace.workspaceRoot);
|
|
117
417
|
this.jobService = deps.jobService;
|
|
118
418
|
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
@@ -125,9 +425,11 @@ export class QaTasksService {
|
|
|
125
425
|
static async create(workspace, options = {}) {
|
|
126
426
|
const repo = await GlobalRepository.create();
|
|
127
427
|
const agentService = new AgentService(repo);
|
|
428
|
+
const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
|
|
128
429
|
const docdex = new DocdexClient({
|
|
129
430
|
workspaceRoot: workspace.workspaceRoot,
|
|
130
431
|
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
432
|
+
repoId: docdexRepoId,
|
|
131
433
|
});
|
|
132
434
|
const routingService = await RoutingService.create();
|
|
133
435
|
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
@@ -136,7 +438,7 @@ export class QaTasksService {
|
|
|
136
438
|
});
|
|
137
439
|
const selectionService = new TaskSelectionService(workspace, workspaceRepo);
|
|
138
440
|
const stateService = new TaskStateService(workspaceRepo);
|
|
139
|
-
const profileService = new QaProfileService(workspace.workspaceRoot);
|
|
441
|
+
const profileService = new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
|
|
140
442
|
const followupService = new QaFollowupService(workspaceRepo, workspace.workspaceRoot);
|
|
141
443
|
const vcsClient = new VcsClient();
|
|
142
444
|
return new QaTasksService(workspace, {
|
|
@@ -172,6 +474,14 @@ export class QaTasksService {
|
|
|
172
474
|
await maybeClose(this.docdex);
|
|
173
475
|
await maybeClose(this.deps.routingService);
|
|
174
476
|
}
|
|
477
|
+
setDocdexAvailability(available, reason) {
|
|
478
|
+
if (available)
|
|
479
|
+
return;
|
|
480
|
+
const docdex = this.docdex;
|
|
481
|
+
if (docdex && typeof docdex.disable === "function") {
|
|
482
|
+
docdex.disable(reason);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
175
485
|
async readPromptFiles(paths) {
|
|
176
486
|
const contents = [];
|
|
177
487
|
const seen = new Set();
|
|
@@ -191,12 +501,62 @@ export class QaTasksService {
|
|
|
191
501
|
return contents;
|
|
192
502
|
}
|
|
193
503
|
async loadPrompts(agentId) {
|
|
194
|
-
const mcodaPromptPath = path.join(this.workspace.
|
|
504
|
+
const mcodaPromptPath = path.join(this.workspace.mcodaDir, 'prompts', 'qa-agent.md');
|
|
195
505
|
const workspacePromptPath = path.join(this.workspace.workspaceRoot, 'prompts', 'qa-agent.md');
|
|
506
|
+
const repoPromptPath = resolveRepoPromptPath('qa-agent.md');
|
|
507
|
+
const isStalePrompt = (value) => {
|
|
508
|
+
if (!value)
|
|
509
|
+
return false;
|
|
510
|
+
return (/playwright/i.test(value) ||
|
|
511
|
+
/legacy/i.test(value) ||
|
|
512
|
+
/MCODA_QA_BROWSER_URL/i.test(value) ||
|
|
513
|
+
/http:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}/i.test(value));
|
|
514
|
+
};
|
|
196
515
|
try {
|
|
197
516
|
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
198
|
-
|
|
199
|
-
|
|
517
|
+
let existingPrompt;
|
|
518
|
+
try {
|
|
519
|
+
existingPrompt = await fs.readFile(mcodaPromptPath, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
existingPrompt = undefined;
|
|
523
|
+
}
|
|
524
|
+
if (existingPrompt && !isStalePrompt(existingPrompt)) {
|
|
525
|
+
console.info(`[qa-tasks] using existing QA prompt at ${mcodaPromptPath}`);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
if (existingPrompt) {
|
|
529
|
+
console.info(`[qa-tasks] refreshing stale QA prompt at ${mcodaPromptPath}`);
|
|
530
|
+
}
|
|
531
|
+
let sourcePrompt;
|
|
532
|
+
try {
|
|
533
|
+
const workspacePrompt = await fs.readFile(workspacePromptPath, 'utf8');
|
|
534
|
+
if (!isStalePrompt(workspacePrompt)) {
|
|
535
|
+
sourcePrompt = workspacePrompt;
|
|
536
|
+
console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// ignore workspace prompt
|
|
541
|
+
}
|
|
542
|
+
if (!sourcePrompt) {
|
|
543
|
+
try {
|
|
544
|
+
const repoPrompt = await fs.readFile(repoPromptPath, 'utf8');
|
|
545
|
+
if (!isStalePrompt(repoPrompt)) {
|
|
546
|
+
sourcePrompt = repoPrompt;
|
|
547
|
+
console.info(`[qa-tasks] copied repo QA prompt to ${mcodaPromptPath}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
// ignore repo prompt
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!sourcePrompt) {
|
|
555
|
+
console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
|
|
556
|
+
sourcePrompt = DEFAULT_QA_PROMPT;
|
|
557
|
+
}
|
|
558
|
+
await fs.writeFile(mcodaPromptPath, sourcePrompt, 'utf8');
|
|
559
|
+
}
|
|
200
560
|
}
|
|
201
561
|
catch {
|
|
202
562
|
try {
|
|
@@ -205,25 +565,24 @@ export class QaTasksService {
|
|
|
205
565
|
console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
|
|
206
566
|
}
|
|
207
567
|
catch {
|
|
208
|
-
|
|
209
|
-
|
|
568
|
+
try {
|
|
569
|
+
await fs.access(repoPromptPath);
|
|
570
|
+
await fs.copyFile(repoPromptPath, mcodaPromptPath);
|
|
571
|
+
console.info(`[qa-tasks] copied repo QA prompt to ${mcodaPromptPath}`);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
console.info(`[qa-tasks] no QA prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
|
|
575
|
+
await fs.writeFile(mcodaPromptPath, DEFAULT_QA_PROMPT, 'utf8');
|
|
576
|
+
}
|
|
210
577
|
}
|
|
211
578
|
}
|
|
212
|
-
const commandPromptFiles = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
213
579
|
const agentPrompts = this.agentService && 'getPrompts' in this.agentService ? await this.agentService.getPrompts(agentId) : undefined;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
if (agentPrompts?.commandPrompts?.['qa-tasks']) {
|
|
217
|
-
parts.push(agentPrompts.commandPrompts['qa-tasks']);
|
|
218
|
-
}
|
|
219
|
-
if (!parts.length)
|
|
220
|
-
parts.push(DEFAULT_QA_PROMPT);
|
|
221
|
-
return parts.filter(Boolean).join('\n\n');
|
|
222
|
-
})();
|
|
580
|
+
const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_QA_PROMPT);
|
|
581
|
+
const commandPrompt = agentPrompts?.commandPrompts?.['qa-tasks']?.trim() || filePrompt;
|
|
223
582
|
return {
|
|
224
|
-
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
225
|
-
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
226
|
-
commandPrompt:
|
|
583
|
+
jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
|
|
584
|
+
characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
|
|
585
|
+
commandPrompt: commandPrompt || undefined,
|
|
227
586
|
};
|
|
228
587
|
}
|
|
229
588
|
async checkpoint(jobId, stage, details) {
|
|
@@ -233,24 +592,71 @@ export class QaTasksService {
|
|
|
233
592
|
details,
|
|
234
593
|
});
|
|
235
594
|
}
|
|
236
|
-
async ensureTaskBranch(task, taskRunId, allowDirty) {
|
|
595
|
+
async ensureTaskBranch(task, taskRunId, jobId, allowDirty, cleanIgnorePaths) {
|
|
237
596
|
try {
|
|
238
|
-
|
|
597
|
+
const repoRoot = this.workspace.workspaceRoot;
|
|
598
|
+
await this.vcs.ensureRepo(repoRoot);
|
|
239
599
|
if (!allowDirty) {
|
|
240
|
-
|
|
600
|
+
const ignorePaths = this.buildCleanIgnorePaths(cleanIgnorePaths);
|
|
601
|
+
if (ignorePaths.length) {
|
|
602
|
+
await this.logTask(taskRunId, `VCS clean ignore paths: ${ignorePaths.join(", ")}`, 'vcs', { ignorePaths });
|
|
603
|
+
}
|
|
604
|
+
await this.vcs.ensureClean(repoRoot, true, ignorePaths);
|
|
241
605
|
}
|
|
242
|
-
|
|
243
|
-
|
|
606
|
+
let branch = task.task.vcsBranch;
|
|
607
|
+
if (branch) {
|
|
608
|
+
const exists = await this.vcs.branchExists(repoRoot, branch);
|
|
244
609
|
if (!exists) {
|
|
245
|
-
return { ok: false, message: `Task branch ${
|
|
610
|
+
return { ok: false, message: `Task branch ${branch} not found` };
|
|
246
611
|
}
|
|
247
|
-
await this.vcs.checkoutBranch(this.workspace.workspaceRoot, task.task.vcsBranch);
|
|
248
612
|
}
|
|
249
613
|
else {
|
|
250
614
|
const base = this.workspace.config?.branch ?? 'mcoda-dev';
|
|
251
|
-
await this.vcs.ensureBaseBranch(
|
|
615
|
+
await this.vcs.ensureBaseBranch(repoRoot, base);
|
|
616
|
+
branch = base;
|
|
252
617
|
}
|
|
253
|
-
|
|
618
|
+
const worktreeRoot = path.join(this.workspace.mcodaDir, 'jobs', jobId, 'qa-worktrees', task.task.key);
|
|
619
|
+
await fs.rm(worktreeRoot, { recursive: true, force: true });
|
|
620
|
+
await PathHelper.ensureDir(path.dirname(worktreeRoot));
|
|
621
|
+
const repoBranch = await this.vcs.currentBranch(repoRoot);
|
|
622
|
+
const preferDetached = repoBranch === branch;
|
|
623
|
+
await this.vcs.addWorktree(repoRoot, worktreeRoot, branch, { detach: preferDetached });
|
|
624
|
+
const reportedBranch = await this.vcs.currentBranch(worktreeRoot);
|
|
625
|
+
let activeBranch = reportedBranch ?? branch;
|
|
626
|
+
if (reportedBranch && reportedBranch !== branch) {
|
|
627
|
+
if (reportedBranch === 'HEAD') {
|
|
628
|
+
await this.logTask(taskRunId, `QA worktree is detached at ${branch}; keeping detached HEAD to avoid branch lock.`, 'vcs', { expected: branch, found: reportedBranch });
|
|
629
|
+
activeBranch = branch;
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
await this.logTask(taskRunId, `QA worktree branch mismatch (${reportedBranch}); switching to ${branch}`, 'vcs', {
|
|
633
|
+
expected: branch,
|
|
634
|
+
found: reportedBranch,
|
|
635
|
+
});
|
|
636
|
+
try {
|
|
637
|
+
await this.vcs.checkoutBranch(worktreeRoot, branch);
|
|
638
|
+
activeBranch = (await this.vcs.currentBranch(worktreeRoot)) ?? branch;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
642
|
+
if (message.includes('already used by worktree')) {
|
|
643
|
+
await this.logTask(taskRunId, `QA worktree branch ${branch} already checked out elsewhere; continuing in detached HEAD.`, 'vcs', { error: message });
|
|
644
|
+
activeBranch = branch;
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
await this.logTask(taskRunId, `QA worktree ready on branch ${activeBranch}`, 'vcs', {
|
|
653
|
+
branch: activeBranch,
|
|
654
|
+
});
|
|
655
|
+
const cleanup = async () => {
|
|
656
|
+
await this.vcs.removeWorktree(repoRoot, worktreeRoot);
|
|
657
|
+
await fs.rm(worktreeRoot, { recursive: true, force: true });
|
|
658
|
+
};
|
|
659
|
+
return { ok: true, workspaceRoot: worktreeRoot, cleanup, branch: activeBranch };
|
|
254
660
|
}
|
|
255
661
|
catch (error) {
|
|
256
662
|
await this.logTask(taskRunId, `VCS check failed: ${error?.message ?? error}`, 'vcs');
|
|
@@ -259,16 +665,19 @@ export class QaTasksService {
|
|
|
259
665
|
}
|
|
260
666
|
async ensureMcoda() {
|
|
261
667
|
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
668
|
+
}
|
|
669
|
+
buildCleanIgnorePaths(extra) {
|
|
670
|
+
const configPaths = this.workspace.config?.qa?.cleanIgnorePaths ?? [];
|
|
671
|
+
const envPaths = (process.env.MCODA_QA_CLEAN_IGNORE ?? '')
|
|
672
|
+
.split(',')
|
|
673
|
+
.map((entry) => entry.trim())
|
|
674
|
+
.filter(Boolean);
|
|
675
|
+
return normalizeCleanIgnorePaths([
|
|
676
|
+
...QA_CLEAN_IGNORE_DEFAULTS,
|
|
677
|
+
...configPaths,
|
|
678
|
+
...envPaths,
|
|
679
|
+
...(extra ?? []),
|
|
680
|
+
]);
|
|
272
681
|
}
|
|
273
682
|
adapterForProfile(profile) {
|
|
274
683
|
const runner = profile?.runner ?? 'cli';
|
|
@@ -287,22 +696,225 @@ export class QaTasksService {
|
|
|
287
696
|
return 'infra_issue';
|
|
288
697
|
return 'fix_required';
|
|
289
698
|
}
|
|
699
|
+
shouldUseAgentInterpretation() {
|
|
700
|
+
if (process.env[QA_AGENT_INTERPRETATION_ENV] === undefined)
|
|
701
|
+
return true;
|
|
702
|
+
return isEnvEnabled(QA_AGENT_INTERPRETATION_ENV);
|
|
703
|
+
}
|
|
704
|
+
buildDeterministicInterpretation(task, profile, result) {
|
|
705
|
+
const recommendation = this.mapOutcome(result);
|
|
706
|
+
const runner = profile.runner ?? 'cli';
|
|
707
|
+
const testedScope = `Ran ${profile.name} (${runner}) QA for ${task.task.key}.`;
|
|
708
|
+
const coverageSummary = recommendation === 'pass'
|
|
709
|
+
? `Automated QA completed successfully with exit code ${result.exitCode}.`
|
|
710
|
+
: `Automated QA reported outcome ${result.outcome} (exit code ${result.exitCode}). Review logs/artifacts for details.`;
|
|
711
|
+
return {
|
|
712
|
+
recommendation,
|
|
713
|
+
testedScope,
|
|
714
|
+
coverageSummary,
|
|
715
|
+
failures: [],
|
|
716
|
+
followUps: [],
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async buildRunnerFailureMessage(params) {
|
|
720
|
+
const { outcome, result, runs, runSummary, workspaceRoot } = params;
|
|
721
|
+
const artifacts = result.artifacts ?? [];
|
|
722
|
+
const lines = [`QA ${outcome} based on runner output.`];
|
|
723
|
+
if (runSummary)
|
|
724
|
+
lines.push(`Runs:\n${runSummary}`);
|
|
725
|
+
const tail = (text, maxLines = 16, maxChars = 2000) => {
|
|
726
|
+
const trimmed = text.trim();
|
|
727
|
+
if (!trimmed)
|
|
728
|
+
return '';
|
|
729
|
+
const lines = trimmed.split(/\r?\n/);
|
|
730
|
+
const slice = lines.slice(-maxLines).join('\n');
|
|
731
|
+
return slice.length > maxChars ? `${slice.slice(0, maxChars)}…` : slice;
|
|
732
|
+
};
|
|
733
|
+
const resolveArtifactPath = (artifact) => path.resolve(workspaceRoot, artifact);
|
|
734
|
+
const loadJson = async (artifact) => {
|
|
735
|
+
try {
|
|
736
|
+
const raw = await fs.readFile(resolveArtifactPath(artifact), 'utf8');
|
|
737
|
+
return JSON.parse(raw);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
const browserArtifact = artifacts.find((artifact) => artifact.endsWith('browser-actions.json'));
|
|
744
|
+
if (browserArtifact) {
|
|
745
|
+
const payload = await loadJson(browserArtifact);
|
|
746
|
+
const failures = (payload?.actions ?? []).filter((action) => action.ok === false).slice(0, 8);
|
|
747
|
+
if (failures.length) {
|
|
748
|
+
lines.push(`Browser action failures:\n${failures
|
|
749
|
+
.map((action) => `- ${action.index}. ${action.type}: ${action.message ?? 'failed'}`)
|
|
750
|
+
.join('\n')}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const apiArtifact = artifacts.find((artifact) => artifact.endsWith('api-results.json'));
|
|
754
|
+
if (apiArtifact) {
|
|
755
|
+
const payload = await loadJson(apiArtifact);
|
|
756
|
+
const failures = (payload?.results ?? []).filter((item) => item.ok === false).slice(0, 8);
|
|
757
|
+
if (failures.length) {
|
|
758
|
+
lines.push(`API request failures:\n${failures
|
|
759
|
+
.map((item) => {
|
|
760
|
+
const parts = [item.method ?? 'GET', item.url ?? '', item.status ? `status=${item.status}` : ''];
|
|
761
|
+
const detail = item.error ?? item.expectations?.[0];
|
|
762
|
+
return `- ${parts.filter(Boolean).join(' ')}${detail ? ` (${detail})` : ''}`;
|
|
763
|
+
})
|
|
764
|
+
.join('\n')}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const cliFailure = runs.find((run) => run.runner === 'cli' && run.result.outcome !== 'pass');
|
|
768
|
+
const stderrSnippet = cliFailure?.result.stderr ?? result.stderr;
|
|
769
|
+
const stderrTail = stderrSnippet ? tail(stderrSnippet) : '';
|
|
770
|
+
if (stderrTail) {
|
|
771
|
+
lines.push(`Runner stderr (tail):\n${stderrTail}`);
|
|
772
|
+
}
|
|
773
|
+
return lines.join('\n\n');
|
|
774
|
+
}
|
|
775
|
+
async createRunnerFailureComments(params) {
|
|
776
|
+
const { task, taskRunId, jobId, result, runs, workspaceRoot, runSummary } = params;
|
|
777
|
+
const existingSlugs = params.existingSlugs ?? new Set();
|
|
778
|
+
const artifacts = result.artifacts ?? [];
|
|
779
|
+
const resolveArtifactPath = (artifact) => path.resolve(workspaceRoot, artifact);
|
|
780
|
+
const loadJson = async (artifact) => {
|
|
781
|
+
try {
|
|
782
|
+
const raw = await fs.readFile(resolveArtifactPath(artifact), 'utf8');
|
|
783
|
+
return JSON.parse(raw);
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
const comments = [];
|
|
790
|
+
const browserArtifact = artifacts.find((artifact) => artifact.endsWith('browser-actions.json'));
|
|
791
|
+
if (browserArtifact) {
|
|
792
|
+
const payload = await loadJson(browserArtifact);
|
|
793
|
+
const failures = (payload?.actions ?? []).filter((action) => action.ok === false).slice(0, 8);
|
|
794
|
+
for (const action of failures) {
|
|
795
|
+
const message = `Browser action ${action.index} (${action.type}) failed${action.message ? `: ${action.message}` : ''}`;
|
|
796
|
+
comments.push({
|
|
797
|
+
message,
|
|
798
|
+
metadata: {
|
|
799
|
+
runner: 'chromium',
|
|
800
|
+
actionIndex: action.index,
|
|
801
|
+
actionType: action.type,
|
|
802
|
+
url: action.url,
|
|
803
|
+
artifact: browserArtifact,
|
|
804
|
+
runSummary,
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const apiArtifact = artifacts.find((artifact) => artifact.endsWith('api-results.json'));
|
|
810
|
+
if (apiArtifact) {
|
|
811
|
+
const payload = await loadJson(apiArtifact);
|
|
812
|
+
const failures = (payload?.results ?? []).filter((item) => item.ok === false).slice(0, 8);
|
|
813
|
+
for (const item of failures) {
|
|
814
|
+
const detail = item.error ?? item.expectations?.[0];
|
|
815
|
+
const parts = [item.method ?? 'GET', item.url ?? '', item.status ? `status=${item.status}` : '']
|
|
816
|
+
.filter(Boolean)
|
|
817
|
+
.join(' ');
|
|
818
|
+
const message = `API ${parts} failed${detail ? `: ${detail}` : ''}`;
|
|
819
|
+
comments.push({
|
|
820
|
+
message,
|
|
821
|
+
metadata: {
|
|
822
|
+
runner: 'api',
|
|
823
|
+
requestId: item.id,
|
|
824
|
+
method: item.method,
|
|
825
|
+
url: item.url,
|
|
826
|
+
status: item.status,
|
|
827
|
+
expectations: item.expectations,
|
|
828
|
+
artifact: apiArtifact,
|
|
829
|
+
runSummary,
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const tail = (text, maxLines = 16, maxChars = 2000) => {
|
|
835
|
+
const trimmed = text.trim();
|
|
836
|
+
if (!trimmed)
|
|
837
|
+
return '';
|
|
838
|
+
const lines = trimmed.split(/\r?\n/);
|
|
839
|
+
const slice = lines.slice(-maxLines).join('\n');
|
|
840
|
+
return slice.length > maxChars ? `${slice.slice(0, maxChars)}…` : slice;
|
|
841
|
+
};
|
|
842
|
+
const cliFailures = runs.filter((run) => run.runner === 'cli' && run.result.outcome !== 'pass');
|
|
843
|
+
for (const run of cliFailures) {
|
|
844
|
+
const stderrTail = tail(run.result.stderr ?? '');
|
|
845
|
+
const message = stderrTail
|
|
846
|
+
? `CLI QA failed${run.command ? ` (${run.command})` : ''}: ${stderrTail}`
|
|
847
|
+
: `CLI QA failed${run.command ? ` (${run.command})` : ''}.`;
|
|
848
|
+
comments.push({
|
|
849
|
+
message,
|
|
850
|
+
metadata: {
|
|
851
|
+
runner: 'cli',
|
|
852
|
+
command: run.command,
|
|
853
|
+
testCommand: run.testCommand,
|
|
854
|
+
runSummary,
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
let created = 0;
|
|
859
|
+
for (const entry of comments) {
|
|
860
|
+
const slug = createTaskCommentSlug({ source: 'qa-tasks', message: entry.message, category: 'qa_issue' });
|
|
861
|
+
if (existingSlugs.has(slug))
|
|
862
|
+
continue;
|
|
863
|
+
existingSlugs.add(slug);
|
|
864
|
+
await this.createQaComment({
|
|
865
|
+
task,
|
|
866
|
+
taskRunId,
|
|
867
|
+
jobId,
|
|
868
|
+
message: entry.message,
|
|
869
|
+
category: 'qa_issue',
|
|
870
|
+
status: 'open',
|
|
871
|
+
metadata: entry.metadata,
|
|
872
|
+
});
|
|
873
|
+
created += 1;
|
|
874
|
+
}
|
|
875
|
+
return created;
|
|
876
|
+
}
|
|
877
|
+
resolveRunAllMarkerPolicy() {
|
|
878
|
+
return this.workspace.config?.qa?.runAllMarkerRequired === false ? 'warn' : 'strict';
|
|
879
|
+
}
|
|
290
880
|
adjustOutcomeForSkippedTests(profile, result, testCommand) {
|
|
291
881
|
if ((profile.runner ?? 'cli') !== 'cli')
|
|
292
|
-
return result;
|
|
882
|
+
return { result };
|
|
293
883
|
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
294
884
|
const outputLower = output.toLowerCase();
|
|
295
885
|
const markers = ['no test script configured', 'skipping tests', 'no tests found'];
|
|
296
886
|
if (markers.some((marker) => outputLower.includes(marker))) {
|
|
297
|
-
return { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 };
|
|
887
|
+
return { result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 } };
|
|
298
888
|
}
|
|
889
|
+
let markerStatus;
|
|
299
890
|
if (testCommand && testCommand.includes('tests/all.js')) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
891
|
+
const present = outputLower.includes(RUN_ALL_TESTS_MARKER);
|
|
892
|
+
const policy = this.resolveRunAllMarkerPolicy();
|
|
893
|
+
markerStatus = {
|
|
894
|
+
policy,
|
|
895
|
+
present,
|
|
896
|
+
action: present ? 'none' : policy === 'strict' ? 'block' : 'warn',
|
|
897
|
+
};
|
|
898
|
+
if (!present) {
|
|
899
|
+
const passed = result.outcome === 'pass' || result.exitCode === 0;
|
|
900
|
+
if (passed) {
|
|
901
|
+
markerStatus.action = 'warn';
|
|
902
|
+
const warning = `Warning: ${RUN_ALL_TESTS_GUIDANCE}`;
|
|
903
|
+
const stderr = result.stderr?.includes(RUN_ALL_TESTS_GUIDANCE)
|
|
904
|
+
? result.stderr
|
|
905
|
+
: [result.stderr, warning].filter(Boolean).join('\n');
|
|
906
|
+
return { result: { ...result, stderr }, markerStatus };
|
|
907
|
+
}
|
|
908
|
+
if (policy === 'strict') {
|
|
909
|
+
const stderr = [result.stderr, RUN_ALL_TESTS_GUIDANCE].filter(Boolean).join('\n');
|
|
910
|
+
return {
|
|
911
|
+
result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1, stderr },
|
|
912
|
+
markerStatus,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
303
915
|
}
|
|
304
916
|
}
|
|
305
|
-
return result;
|
|
917
|
+
return { result, markerStatus };
|
|
306
918
|
}
|
|
307
919
|
combineOutcome(result, recommendation) {
|
|
308
920
|
const base = this.mapOutcome(result);
|
|
@@ -318,10 +930,22 @@ export class QaTasksService {
|
|
|
318
930
|
return 'unclear';
|
|
319
931
|
return 'pass';
|
|
320
932
|
}
|
|
321
|
-
async gatherDocContext(task, taskRunId) {
|
|
933
|
+
async gatherDocContext(task, taskRunId, docLinks = []) {
|
|
322
934
|
if (!this.docdex)
|
|
323
935
|
return '';
|
|
936
|
+
let openApiIncluded = false;
|
|
937
|
+
const shouldIncludeDocType = (docType) => {
|
|
938
|
+
if (docType.toUpperCase() !== 'OPENAPI')
|
|
939
|
+
return true;
|
|
940
|
+
if (openApiIncluded)
|
|
941
|
+
return false;
|
|
942
|
+
openApiIncluded = true;
|
|
943
|
+
return true;
|
|
944
|
+
};
|
|
324
945
|
try {
|
|
946
|
+
if (typeof this.docdex?.ensureRepoScope === 'function') {
|
|
947
|
+
await this.docdex.ensureRepoScope();
|
|
948
|
+
}
|
|
325
949
|
const querySeeds = [task.key, task.title, ...(task.acceptanceCriteria ?? [])]
|
|
326
950
|
.filter(Boolean)
|
|
327
951
|
.join(' ')
|
|
@@ -332,7 +956,21 @@ export class QaTasksService {
|
|
|
332
956
|
query: querySeeds,
|
|
333
957
|
});
|
|
334
958
|
const snippets = [];
|
|
335
|
-
|
|
959
|
+
const resolveDocType = async (doc) => {
|
|
960
|
+
const content = doc.segments?.[0]?.content ?? doc.content ?? '';
|
|
961
|
+
const normalized = normalizeDocType({
|
|
962
|
+
docType: doc.docType,
|
|
963
|
+
path: doc.path,
|
|
964
|
+
title: doc.title,
|
|
965
|
+
content,
|
|
966
|
+
});
|
|
967
|
+
if (normalized.downgraded && taskRunId) {
|
|
968
|
+
await this.logTask(taskRunId, `Docdex docType downgraded from SDS to DOC for ${doc.path ?? doc.title ?? doc.docType ?? 'unknown'}: ${normalized.reason ?? 'not_sds'}`, 'docdex');
|
|
969
|
+
}
|
|
970
|
+
return normalized.docType;
|
|
971
|
+
};
|
|
972
|
+
const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, true));
|
|
973
|
+
for (const doc of filteredDocs.slice(0, 5)) {
|
|
336
974
|
const segments = (doc.segments ?? []).slice(0, 2);
|
|
337
975
|
const body = segments.length
|
|
338
976
|
? segments
|
|
@@ -341,7 +979,47 @@ export class QaTasksService {
|
|
|
341
979
|
: doc.content
|
|
342
980
|
? doc.content.slice(0, 600)
|
|
343
981
|
: '';
|
|
344
|
-
|
|
982
|
+
const docType = await resolveDocType(doc);
|
|
983
|
+
if (!shouldIncludeDocType(docType))
|
|
984
|
+
continue;
|
|
985
|
+
snippets.push(`- [${docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
|
|
986
|
+
}
|
|
987
|
+
const normalizeDocLink = (value) => {
|
|
988
|
+
const trimmed = value.trim();
|
|
989
|
+
const stripped = trimmed.replace(/^docdex:/i, '').replace(/^doc:/i, '');
|
|
990
|
+
const candidate = stripped || trimmed;
|
|
991
|
+
const looksLikePath = candidate.includes('/') ||
|
|
992
|
+
candidate.includes('\\') ||
|
|
993
|
+
/\.(md|markdown|txt|rst|yaml|yml|json)$/i.test(candidate);
|
|
994
|
+
return { type: looksLikePath ? 'path' : 'id', ref: candidate };
|
|
995
|
+
};
|
|
996
|
+
for (const link of docLinks) {
|
|
997
|
+
try {
|
|
998
|
+
const { type, ref } = normalizeDocLink(link);
|
|
999
|
+
if (type === 'path' && isDocContextExcluded(ref, true)) {
|
|
1000
|
+
snippets.push(`- [linked:filtered] ${link} — excluded from context`);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
let doc = undefined;
|
|
1004
|
+
if (type === 'path' && 'findDocumentByPath' in this.docdex) {
|
|
1005
|
+
doc = await this.docdex.findDocumentByPath(ref);
|
|
1006
|
+
}
|
|
1007
|
+
if (!doc) {
|
|
1008
|
+
doc = await this.docdex.fetchDocumentById(ref);
|
|
1009
|
+
}
|
|
1010
|
+
if (!doc) {
|
|
1011
|
+
snippets.push(`- [linked:missing] ${link} — no docdex entry found`);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const body = (doc.segments?.[0]?.content ?? doc.content ?? '').slice(0, 600);
|
|
1015
|
+
const docType = await resolveDocType(doc);
|
|
1016
|
+
if (!shouldIncludeDocType(docType))
|
|
1017
|
+
continue;
|
|
1018
|
+
snippets.push(`- [linked:${docType}] ${doc.title ?? doc.path ?? doc.id}\n${body}`.trim());
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
snippets.push(`- [linked:missing] ${link} — ${error?.message ?? error}`);
|
|
1022
|
+
}
|
|
345
1023
|
}
|
|
346
1024
|
return snippets.join('\n\n');
|
|
347
1025
|
}
|
|
@@ -398,8 +1076,8 @@ export class QaTasksService {
|
|
|
398
1076
|
return false;
|
|
399
1077
|
}
|
|
400
1078
|
}
|
|
401
|
-
async readPackageJson() {
|
|
402
|
-
const pkgPath = path.join(
|
|
1079
|
+
async readPackageJson(root = this.workspace.workspaceRoot) {
|
|
1080
|
+
const pkgPath = path.join(root, 'package.json');
|
|
403
1081
|
try {
|
|
404
1082
|
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
405
1083
|
return JSON.parse(raw);
|
|
@@ -408,8 +1086,7 @@ export class QaTasksService {
|
|
|
408
1086
|
return undefined;
|
|
409
1087
|
}
|
|
410
1088
|
}
|
|
411
|
-
async detectPackageManager() {
|
|
412
|
-
const root = this.workspace.workspaceRoot;
|
|
1089
|
+
async detectPackageManager(root = this.workspace.workspaceRoot) {
|
|
413
1090
|
if (await this.fileExists(path.join(root, 'pnpm-lock.yaml')))
|
|
414
1091
|
return 'pnpm';
|
|
415
1092
|
if (await this.fileExists(path.join(root, 'pnpm-workspace.yaml')))
|
|
@@ -424,143 +1101,928 @@ export class QaTasksService {
|
|
|
424
1101
|
return 'npm';
|
|
425
1102
|
return undefined;
|
|
426
1103
|
}
|
|
427
|
-
async resolveTestCommand(profile, requestTestCommand) {
|
|
1104
|
+
async resolveTestCommand(profile, requestTestCommand, workspaceRoot = this.workspace.workspaceRoot) {
|
|
428
1105
|
if (requestTestCommand)
|
|
429
1106
|
return requestTestCommand;
|
|
430
1107
|
if ((profile.runner ?? 'cli') !== 'cli')
|
|
431
1108
|
return undefined;
|
|
432
1109
|
if (profile.test_command)
|
|
433
1110
|
return profile.test_command;
|
|
434
|
-
if (await this.fileExists(path.join(
|
|
1111
|
+
if (await this.fileExists(path.join(workspaceRoot, 'tests', 'all.js'))) {
|
|
435
1112
|
return 'node tests/all.js';
|
|
436
1113
|
}
|
|
437
|
-
const pkg = await this.readPackageJson();
|
|
1114
|
+
const pkg = await this.readPackageJson(workspaceRoot);
|
|
438
1115
|
if (pkg?.scripts?.test) {
|
|
439
|
-
const pm = (await this.detectPackageManager()) ?? 'npm';
|
|
1116
|
+
const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
|
|
440
1117
|
return `${pm} test`;
|
|
441
1118
|
}
|
|
442
1119
|
return undefined;
|
|
443
1120
|
}
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
1121
|
+
isCliTask(task) {
|
|
1122
|
+
const metadata = task.metadata ?? {};
|
|
1123
|
+
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
|
1124
|
+
const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
|
|
1125
|
+
? metadata.last_review_changed_paths
|
|
1126
|
+
: [];
|
|
1127
|
+
const combined = [...files, ...reviewFiles].map((file) => String(file).toLowerCase());
|
|
1128
|
+
const cliHints = ['/cli/', '/packages/cli/', '/bin/', '/cmd/'];
|
|
1129
|
+
if (combined.some((file) => cliHints.some((hint) => file.includes(hint))))
|
|
1130
|
+
return true;
|
|
1131
|
+
const text = [task.key, task.title, task.description, task.type]
|
|
1132
|
+
.filter(Boolean)
|
|
1133
|
+
.join(' ')
|
|
1134
|
+
.toLowerCase();
|
|
1135
|
+
if (!text)
|
|
1136
|
+
return false;
|
|
1137
|
+
return ['cli', 'command', 'terminal'].some((hint) => text.includes(hint));
|
|
1138
|
+
}
|
|
1139
|
+
async findCliBinCommand(workspaceRoot) {
|
|
1140
|
+
const resolveBin = (pkg) => {
|
|
1141
|
+
if (!pkg)
|
|
1142
|
+
return undefined;
|
|
1143
|
+
const bin = pkg.bin;
|
|
1144
|
+
if (typeof bin === 'string')
|
|
1145
|
+
return bin;
|
|
1146
|
+
if (bin && typeof bin === 'object') {
|
|
1147
|
+
const first = Object.values(bin).find((value) => typeof value === 'string');
|
|
1148
|
+
return typeof first === 'string' ? first : undefined;
|
|
1149
|
+
}
|
|
450
1150
|
return undefined;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const cleanedLines = slice
|
|
457
|
-
.split(/\r?\n/)
|
|
458
|
-
.filter((line) => {
|
|
459
|
-
const trimmed = line.trim();
|
|
460
|
-
if (!trimmed)
|
|
461
|
-
return true;
|
|
462
|
-
if (trimmed.startsWith("{") ||
|
|
463
|
-
trimmed.startsWith("}") ||
|
|
464
|
-
trimmed.startsWith("[") ||
|
|
465
|
-
trimmed.startsWith("]") ||
|
|
466
|
-
trimmed.startsWith("\"")) {
|
|
467
|
-
return true;
|
|
468
|
-
}
|
|
469
|
-
return false;
|
|
470
|
-
})
|
|
471
|
-
.join("\n")
|
|
472
|
-
.replace(/,\s*([}\]])/g, "$1");
|
|
473
|
-
const withQuotedKeys = cleanedLines.replace(/([{,]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":');
|
|
1151
|
+
};
|
|
1152
|
+
const rootPkg = await this.readPackageJson(workspaceRoot);
|
|
1153
|
+
let binPath = resolveBin(rootPkg);
|
|
1154
|
+
if (!binPath) {
|
|
1155
|
+
const cliPkgPath = path.join(workspaceRoot, 'packages', 'cli', 'package.json');
|
|
474
1156
|
try {
|
|
475
|
-
|
|
1157
|
+
const raw = await fs.readFile(cliPkgPath, 'utf8');
|
|
1158
|
+
const cliPkg = JSON.parse(raw);
|
|
1159
|
+
binPath = resolveBin(cliPkg);
|
|
1160
|
+
if (binPath) {
|
|
1161
|
+
binPath = path.join('packages', 'cli', binPath);
|
|
1162
|
+
}
|
|
476
1163
|
}
|
|
477
1164
|
catch {
|
|
478
|
-
|
|
1165
|
+
// ignore
|
|
479
1166
|
}
|
|
480
1167
|
}
|
|
481
|
-
|
|
482
|
-
normalizeAgentOutput(parsed) {
|
|
483
|
-
if (!parsed || typeof parsed !== 'object')
|
|
1168
|
+
if (!binPath)
|
|
484
1169
|
return undefined;
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
const asStringArray = (value) => Array.isArray(value) ? value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) : undefined;
|
|
488
|
-
const recommendation = parsed.recommendation;
|
|
489
|
-
if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
|
|
1170
|
+
const normalized = path.isAbsolute(binPath) ? binPath : path.join(workspaceRoot, binPath);
|
|
1171
|
+
if (!(await this.fileExists(normalized)))
|
|
490
1172
|
return undefined;
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
: Array.isArray(parsed.follow_ups)
|
|
494
|
-
? parsed.follow_ups
|
|
495
|
-
: undefined;
|
|
496
|
-
const followUps = rawFollowUps
|
|
497
|
-
? rawFollowUps.map((item) => ({
|
|
498
|
-
title: asString(item?.title),
|
|
499
|
-
description: asString(item?.description),
|
|
500
|
-
type: asString(item?.type),
|
|
501
|
-
priority: asNumber(item?.priority),
|
|
502
|
-
story_points: asNumber(item?.story_points ?? item?.storyPoints),
|
|
503
|
-
tags: asStringArray(item?.tags),
|
|
504
|
-
related_task_key: asString(item?.related_task_key ?? item?.relatedTaskKey),
|
|
505
|
-
epic_key: asString(item?.epic_key ?? item?.epicKey),
|
|
506
|
-
story_key: asString(item?.story_key ?? item?.storyKey),
|
|
507
|
-
components: asStringArray(item?.components),
|
|
508
|
-
doc_links: asStringArray(item?.doc_links ?? item?.docLinks),
|
|
509
|
-
evidence_url: asString(item?.evidence_url ?? item?.evidenceUrl),
|
|
510
|
-
artifacts: asStringArray(item?.artifacts),
|
|
511
|
-
}))
|
|
512
|
-
: undefined;
|
|
513
|
-
const failures = Array.isArray(parsed.failures)
|
|
514
|
-
? parsed.failures.map((f) => ({ kind: f.kind, message: f.message ?? String(f), evidence: f.evidence }))
|
|
515
|
-
: undefined;
|
|
516
|
-
const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
|
|
517
|
-
const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
|
|
518
|
-
return {
|
|
519
|
-
recommendation,
|
|
520
|
-
testedScope: parsed.tested_scope ?? parsed.scope,
|
|
521
|
-
coverageSummary: parsed.coverage_summary ?? parsed.coverage,
|
|
522
|
-
failures,
|
|
523
|
-
followUps,
|
|
524
|
-
resolvedSlugs,
|
|
525
|
-
unresolvedSlugs,
|
|
526
|
-
};
|
|
1173
|
+
const relative = path.isAbsolute(binPath) ? path.relative(workspaceRoot, binPath) : binPath;
|
|
1174
|
+
return `node ${relative} --help`;
|
|
527
1175
|
}
|
|
528
|
-
async
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1176
|
+
async resolveCliChecklistCommands(params) {
|
|
1177
|
+
const pkg = await this.readPackageJson(params.workspaceRoot);
|
|
1178
|
+
if (!pkg?.scripts)
|
|
1179
|
+
return [];
|
|
1180
|
+
const pm = (await this.detectPackageManager(params.workspaceRoot)) ?? 'npm';
|
|
1181
|
+
const existingLower = params.existing.map((cmd) => cmd.toLowerCase());
|
|
1182
|
+
const addIfScript = (script, matcher) => {
|
|
1183
|
+
if (!pkg.scripts?.[script])
|
|
1184
|
+
return undefined;
|
|
1185
|
+
if (existingLower.some((cmd) => cmd.includes(matcher)))
|
|
1186
|
+
return undefined;
|
|
1187
|
+
return this.buildScriptCommand(pm, script);
|
|
539
1188
|
};
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
1189
|
+
const extras = [];
|
|
1190
|
+
const lintCmd = addIfScript('lint', 'lint');
|
|
1191
|
+
if (lintCmd)
|
|
1192
|
+
extras.push(lintCmd);
|
|
1193
|
+
const typecheckCmd = addIfScript('typecheck', 'typecheck') ??
|
|
1194
|
+
addIfScript('type-check', 'type-check') ??
|
|
1195
|
+
addIfScript('tsc', 'tsc');
|
|
1196
|
+
if (typecheckCmd)
|
|
1197
|
+
extras.push(typecheckCmd);
|
|
1198
|
+
const buildCmd = addIfScript('build', 'build');
|
|
1199
|
+
if (buildCmd)
|
|
1200
|
+
extras.push(buildCmd);
|
|
1201
|
+
if (this.isCliTask(params.task)) {
|
|
1202
|
+
const cliSmoke = await this.findCliBinCommand(params.workspaceRoot);
|
|
1203
|
+
if (cliSmoke && !existingLower.some((cmd) => cmd.includes('--help'))) {
|
|
1204
|
+
extras.push(cliSmoke);
|
|
543
1205
|
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1206
|
+
}
|
|
1207
|
+
return extras;
|
|
1208
|
+
}
|
|
1209
|
+
softenOptionalNpmScripts(commands) {
|
|
1210
|
+
if (!commands.length)
|
|
1211
|
+
return commands;
|
|
1212
|
+
const scripts = ['lint', 'build'];
|
|
1213
|
+
return commands.map((command) => {
|
|
1214
|
+
let next = command;
|
|
1215
|
+
for (const script of scripts) {
|
|
1216
|
+
const pattern = new RegExp(`\\bnpm\\s+run\\s+${script}\\b(?!\\s+--if-present)`, 'i');
|
|
1217
|
+
next = next.replace(pattern, '$& --if-present');
|
|
550
1218
|
}
|
|
551
|
-
return
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1219
|
+
return next;
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
usesCliBrowserTools(commands) {
|
|
1223
|
+
const pattern = /(cypress|puppeteer|selenium|capybara|dusk)/i;
|
|
1224
|
+
return commands.some((command) => pattern.test(command));
|
|
1225
|
+
}
|
|
1226
|
+
ensureCypressChromium(command) {
|
|
1227
|
+
if (!/cypress/i.test(command))
|
|
1228
|
+
return command;
|
|
1229
|
+
const browserMatch = /--browser(\s+|=)(\S+)/i.exec(command);
|
|
1230
|
+
if (browserMatch) {
|
|
1231
|
+
const current = browserMatch[2] ?? "";
|
|
1232
|
+
if (/chromium/i.test(current))
|
|
1233
|
+
return command;
|
|
1234
|
+
return command.replace(browserMatch[0], "--browser chromium");
|
|
1235
|
+
}
|
|
1236
|
+
if (/\bcypress\s+(run|open)\b/i.test(command)) {
|
|
1237
|
+
return `${command} --browser chromium`;
|
|
1238
|
+
}
|
|
1239
|
+
return command;
|
|
1240
|
+
}
|
|
1241
|
+
async applyChromiumForCli(env, commands) {
|
|
1242
|
+
if (!this.usesCliBrowserTools(commands)) {
|
|
1243
|
+
return { ok: true, commands };
|
|
1244
|
+
}
|
|
1245
|
+
const chromiumPath = await resolveChromiumBinary();
|
|
1246
|
+
if (!chromiumPath) {
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
commands,
|
|
1250
|
+
message: 'Chromium binary not found for CLI browser tests. Install Docdex Chromium (docdex setup or MCODA_QA_CHROMIUM_PATH).',
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
env.CHROME_PATH = chromiumPath;
|
|
1254
|
+
env.CHROME_BIN = chromiumPath;
|
|
1255
|
+
env.PUPPETEER_EXECUTABLE_PATH = chromiumPath;
|
|
1256
|
+
env.PUPPETEER_PRODUCT = 'chrome';
|
|
1257
|
+
env.CYPRESS_BROWSER = 'chromium';
|
|
1258
|
+
const updated = commands.map((command) => this.ensureCypressChromium(command));
|
|
1259
|
+
return { ok: true, commands: updated };
|
|
1260
|
+
}
|
|
1261
|
+
buildScriptCommand(pm, script) {
|
|
1262
|
+
if (pm === 'yarn')
|
|
1263
|
+
return `yarn ${script}`;
|
|
1264
|
+
if (pm === 'pnpm')
|
|
1265
|
+
return `pnpm ${script}`;
|
|
1266
|
+
return `npm run ${script}`;
|
|
1267
|
+
}
|
|
1268
|
+
async resolveDevServerCommand(workspaceRoot) {
|
|
1269
|
+
const pkg = await this.readPackageJson(workspaceRoot);
|
|
1270
|
+
if (!pkg?.scripts)
|
|
1271
|
+
return undefined;
|
|
1272
|
+
const script = typeof pkg.scripts.dev === 'string'
|
|
1273
|
+
? 'dev'
|
|
1274
|
+
: typeof pkg.scripts.start === 'string'
|
|
1275
|
+
? 'start'
|
|
1276
|
+
: typeof pkg.scripts.serve === 'string'
|
|
1277
|
+
? 'serve'
|
|
1278
|
+
: undefined;
|
|
1279
|
+
if (!script)
|
|
1280
|
+
return undefined;
|
|
1281
|
+
const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
|
|
1282
|
+
return { script, command: this.buildScriptCommand(pm, script) };
|
|
1283
|
+
}
|
|
1284
|
+
shouldInstallQaDeps() {
|
|
1285
|
+
if (process.env[QA_INSTALL_DEPS_ENV] === undefined)
|
|
1286
|
+
return true;
|
|
1287
|
+
return isEnvEnabled(QA_INSTALL_DEPS_ENV);
|
|
1288
|
+
}
|
|
1289
|
+
async hasFileWithExtension(workspaceRoot, ext) {
|
|
1290
|
+
try {
|
|
1291
|
+
const entries = await fs.readdir(workspaceRoot, { withFileTypes: true });
|
|
1292
|
+
return entries.some((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(ext));
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async resolveInstallCommands(workspaceRoot) {
|
|
1299
|
+
const commands = [];
|
|
1300
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
1301
|
+
if (await this.fileExists(pkgPath)) {
|
|
1302
|
+
const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
|
|
1303
|
+
if (pm === 'pnpm')
|
|
1304
|
+
commands.push('pnpm install');
|
|
1305
|
+
else if (pm === 'yarn')
|
|
1306
|
+
commands.push('yarn install');
|
|
1307
|
+
else
|
|
1308
|
+
commands.push('npm install');
|
|
1309
|
+
}
|
|
1310
|
+
if (await this.fileExists(path.join(workspaceRoot, 'requirements.txt'))) {
|
|
1311
|
+
commands.push('python -m pip install -r requirements.txt');
|
|
1312
|
+
}
|
|
1313
|
+
else if ((await this.fileExists(path.join(workspaceRoot, 'pyproject.toml'))) ||
|
|
1314
|
+
(await this.fileExists(path.join(workspaceRoot, 'setup.py')))) {
|
|
1315
|
+
commands.push('python -m pip install .');
|
|
1316
|
+
}
|
|
1317
|
+
if ((await this.hasFileWithExtension(workspaceRoot, '.sln')) ||
|
|
1318
|
+
(await this.hasFileWithExtension(workspaceRoot, '.csproj'))) {
|
|
1319
|
+
commands.push('dotnet restore');
|
|
1320
|
+
}
|
|
1321
|
+
if (await this.fileExists(path.join(workspaceRoot, 'pom.xml'))) {
|
|
1322
|
+
commands.push('mvn -q -DskipTests dependency:resolve');
|
|
1323
|
+
}
|
|
1324
|
+
else if ((await this.fileExists(path.join(workspaceRoot, 'build.gradle'))) ||
|
|
1325
|
+
(await this.fileExists(path.join(workspaceRoot, 'build.gradle.kts')))) {
|
|
1326
|
+
const gradlew = path.join(workspaceRoot, 'gradlew');
|
|
1327
|
+
if (await this.fileExists(gradlew)) {
|
|
1328
|
+
commands.push('./gradlew --no-daemon dependencies');
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
commands.push('gradle --no-daemon dependencies');
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (await this.fileExists(path.join(workspaceRoot, 'go.mod'))) {
|
|
1335
|
+
commands.push('go mod download');
|
|
1336
|
+
}
|
|
1337
|
+
if (await this.fileExists(path.join(workspaceRoot, 'composer.json'))) {
|
|
1338
|
+
commands.push('composer install');
|
|
1339
|
+
}
|
|
1340
|
+
if (await this.fileExists(path.join(workspaceRoot, 'Gemfile'))) {
|
|
1341
|
+
commands.push('bundle install');
|
|
1342
|
+
}
|
|
1343
|
+
if (await this.fileExists(path.join(workspaceRoot, 'pubspec.yaml'))) {
|
|
1344
|
+
commands.push('flutter pub get');
|
|
1345
|
+
}
|
|
1346
|
+
if (await this.fileExists(path.join(workspaceRoot, 'Podfile'))) {
|
|
1347
|
+
commands.push('pod install');
|
|
1348
|
+
}
|
|
1349
|
+
return Array.from(new Set(commands));
|
|
1350
|
+
}
|
|
1351
|
+
async runShellCommand(params) {
|
|
1352
|
+
return await new Promise((resolve) => {
|
|
1353
|
+
const child = spawn(params.command, {
|
|
1354
|
+
cwd: params.cwd,
|
|
1355
|
+
env: params.env,
|
|
1356
|
+
shell: true,
|
|
1357
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1358
|
+
});
|
|
1359
|
+
let stdout = '';
|
|
1360
|
+
let stderr = '';
|
|
1361
|
+
child.stdout?.on('data', (chunk) => {
|
|
1362
|
+
stdout += chunk.toString();
|
|
1363
|
+
});
|
|
1364
|
+
child.stderr?.on('data', (chunk) => {
|
|
1365
|
+
stderr += chunk.toString();
|
|
1366
|
+
});
|
|
1367
|
+
child.once('close', (code) => {
|
|
1368
|
+
const exitCode = typeof code === 'number' ? code : 0;
|
|
1369
|
+
resolve({ ok: exitCode === 0, exitCode, stdout, stderr });
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
async commitInstallChanges(workspaceRoot, message) {
|
|
1374
|
+
const status = await this.vcs.status(workspaceRoot);
|
|
1375
|
+
if (!status.trim())
|
|
1376
|
+
return null;
|
|
1377
|
+
await execFileAsync('git', ['add', '-A'], { cwd: workspaceRoot });
|
|
1378
|
+
const env = {
|
|
1379
|
+
...process.env,
|
|
1380
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? 'mcoda-qa',
|
|
1381
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? 'qa@mcoda.local',
|
|
1382
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? 'mcoda-qa',
|
|
1383
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? 'qa@mcoda.local',
|
|
1384
|
+
};
|
|
1385
|
+
await execFileAsync('git', ['commit', '-m', message, '--no-verify'], { cwd: workspaceRoot, env });
|
|
1386
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: workspaceRoot });
|
|
1387
|
+
const trimmed = typeof stdout === 'string' ? stdout.trim() : Buffer.from(stdout).toString('utf8').trim();
|
|
1388
|
+
return trimmed || null;
|
|
1389
|
+
}
|
|
1390
|
+
isBranchInUseError(error) {
|
|
1391
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
1392
|
+
return /already used by worktree/i.test(message);
|
|
1393
|
+
}
|
|
1394
|
+
buildQaInstallBranch(taskKey, taskRunId, baseBranch) {
|
|
1395
|
+
const suffix = createHash('sha1').update(`${taskKey}:${taskRunId}`).digest('hex').slice(0, 8);
|
|
1396
|
+
const safeKey = taskKey.replace(/[^a-zA-Z0-9._-]+/g, '-');
|
|
1397
|
+
return `mcoda/qa/${safeKey}-${baseBranch}-${suffix}`;
|
|
1398
|
+
}
|
|
1399
|
+
async prepareQaWorkspace(params) {
|
|
1400
|
+
if (!this.shouldInstallQaDeps()) {
|
|
1401
|
+
await this.logTask(params.taskRunId, 'QA dependency install disabled; skipping.', 'qa-install');
|
|
1402
|
+
return { ok: true };
|
|
1403
|
+
}
|
|
1404
|
+
await this.vcs.ensureBaseBranch(params.workspaceRoot, params.baseBranch);
|
|
1405
|
+
const current = await this.vcs.currentBranch(params.workspaceRoot);
|
|
1406
|
+
let installBranch = params.baseBranch;
|
|
1407
|
+
if (current !== params.baseBranch) {
|
|
1408
|
+
try {
|
|
1409
|
+
await this.vcs.checkoutBranch(params.workspaceRoot, params.baseBranch);
|
|
1410
|
+
}
|
|
1411
|
+
catch (error) {
|
|
1412
|
+
if (this.isBranchInUseError(error)) {
|
|
1413
|
+
installBranch = this.buildQaInstallBranch(params.taskKey, params.taskRunId, params.baseBranch);
|
|
1414
|
+
await this.logTask(params.taskRunId, `Base branch ${params.baseBranch} already used by another worktree; using ${installBranch} for QA install.`, 'qa-install');
|
|
1415
|
+
await this.vcs.createOrCheckoutBranch(params.workspaceRoot, installBranch, params.baseBranch);
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
throw error;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const commands = await this.resolveInstallCommands(params.workspaceRoot);
|
|
1423
|
+
for (const command of commands) {
|
|
1424
|
+
await this.logTask(params.taskRunId, `Installing deps: ${command}`, 'qa-install');
|
|
1425
|
+
const result = await this.runShellCommand({ command, cwd: params.workspaceRoot });
|
|
1426
|
+
if (!result.ok) {
|
|
1427
|
+
const message = `QA dependency install failed (${command}) with exit ${result.exitCode}.`;
|
|
1428
|
+
await this.logTask(params.taskRunId, message, 'qa-install', {
|
|
1429
|
+
stdout: result.stdout.slice(0, 2000),
|
|
1430
|
+
stderr: result.stderr.slice(0, 2000),
|
|
1431
|
+
});
|
|
1432
|
+
return { ok: false, message };
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const installCommit = await this.commitInstallChanges(params.workspaceRoot, 'chore: qa install dependencies');
|
|
1436
|
+
if (installCommit) {
|
|
1437
|
+
await this.logTask(params.taskRunId, 'Committed QA dependency install changes.', 'qa-install');
|
|
1438
|
+
}
|
|
1439
|
+
if (params.taskBranch && params.taskBranch !== installBranch) {
|
|
1440
|
+
try {
|
|
1441
|
+
await this.vcs.checkoutBranch(params.workspaceRoot, params.taskBranch);
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
if (this.isBranchInUseError(error)) {
|
|
1445
|
+
await this.logTask(params.taskRunId, `Task branch ${params.taskBranch} already used by another worktree; continuing on ${installBranch}.`, 'qa-install');
|
|
1446
|
+
}
|
|
1447
|
+
else {
|
|
1448
|
+
throw error;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (installCommit) {
|
|
1452
|
+
try {
|
|
1453
|
+
await this.vcs.cherryPick(params.workspaceRoot, installCommit);
|
|
1454
|
+
}
|
|
1455
|
+
catch (error) {
|
|
1456
|
+
await this.logTask(params.taskRunId, `Warning: failed to cherry-pick QA install commit onto ${params.taskBranch}.`, 'qa-install', { error: error instanceof Error ? error.message : String(error) });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return { ok: true };
|
|
1461
|
+
}
|
|
1462
|
+
async isUrlReachable(url, timeoutMs) {
|
|
1463
|
+
const controller = new AbortController();
|
|
1464
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1465
|
+
try {
|
|
1466
|
+
await fetch(url, { method: 'GET', signal: controller.signal });
|
|
1467
|
+
return true;
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
clearTimeout(timer);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async waitForUrlReady(url, timeoutMs) {
|
|
1477
|
+
if (timeoutMs <= 0)
|
|
1478
|
+
return false;
|
|
1479
|
+
const deadline = Date.now() + timeoutMs;
|
|
1480
|
+
while (Date.now() < deadline) {
|
|
1481
|
+
const ok = await this.isUrlReachable(url, Math.min(2000, timeoutMs));
|
|
1482
|
+
if (ok)
|
|
1483
|
+
return true;
|
|
1484
|
+
await delay(500);
|
|
1485
|
+
}
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
async startQaServer(params) {
|
|
1489
|
+
const command = await this.resolveDevServerCommand(params.workspaceRoot);
|
|
1490
|
+
if (!command)
|
|
1491
|
+
return undefined;
|
|
1492
|
+
const serverDir = path.join(this.workspace.mcodaDir, 'jobs', params.jobId, 'qa', params.taskKey, 'server');
|
|
1493
|
+
await PathHelper.ensureDir(serverDir);
|
|
1494
|
+
const logPath = path.join(serverDir, 'server.log');
|
|
1495
|
+
const stream = fsSync.createWriteStream(logPath, { flags: 'a' });
|
|
1496
|
+
const env = { ...params.env };
|
|
1497
|
+
try {
|
|
1498
|
+
const url = new URL(params.baseUrl);
|
|
1499
|
+
if (!env.HOST)
|
|
1500
|
+
env.HOST = url.hostname;
|
|
1501
|
+
if (!env.PORT && url.port)
|
|
1502
|
+
env.PORT = url.port;
|
|
1503
|
+
}
|
|
1504
|
+
catch {
|
|
1505
|
+
// ignore invalid base URLs
|
|
1506
|
+
}
|
|
1507
|
+
const child = spawn(command.command, {
|
|
1508
|
+
cwd: params.workspaceRoot,
|
|
1509
|
+
env,
|
|
1510
|
+
shell: true,
|
|
1511
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1512
|
+
});
|
|
1513
|
+
child.stdout?.pipe(stream);
|
|
1514
|
+
child.stderr?.pipe(stream);
|
|
1515
|
+
child.on('error', (error) => {
|
|
1516
|
+
stream.write(`\n[qa-server] spawn error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1517
|
+
});
|
|
1518
|
+
const stop = async () => {
|
|
1519
|
+
if (child.exitCode !== null) {
|
|
1520
|
+
stream.end();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
child.kill('SIGTERM');
|
|
1524
|
+
try {
|
|
1525
|
+
await Promise.race([once(child, 'exit'), delay(5000)]);
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
// ignore
|
|
1529
|
+
}
|
|
1530
|
+
if (child.exitCode === null) {
|
|
1531
|
+
child.kill('SIGKILL');
|
|
1532
|
+
}
|
|
1533
|
+
stream.end();
|
|
1534
|
+
};
|
|
1535
|
+
return { baseUrl: params.baseUrl, command: command.command, logPath, process: child, stop };
|
|
1536
|
+
}
|
|
1537
|
+
async checkQaPreflight(testCommand, workspaceRoot = this.workspace.workspaceRoot) {
|
|
1538
|
+
const resolveCommandBinary = (command) => {
|
|
1539
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
1540
|
+
if (!tokens.length)
|
|
1541
|
+
return undefined;
|
|
1542
|
+
let idx = 0;
|
|
1543
|
+
while (idx < tokens.length) {
|
|
1544
|
+
const token = tokens[idx];
|
|
1545
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token) && !token.includes("/") && !token.includes("\\")) {
|
|
1546
|
+
idx += 1;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
return token.replace(/^['"]|['"]$/g, "");
|
|
1550
|
+
}
|
|
1551
|
+
return undefined;
|
|
1552
|
+
};
|
|
1553
|
+
const commandExists = (command) => {
|
|
1554
|
+
if (!command)
|
|
1555
|
+
return false;
|
|
1556
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
1557
|
+
const resolved = path.isAbsolute(command) ? command : path.resolve(workspaceRoot, command);
|
|
1558
|
+
return fsSync.existsSync(resolved);
|
|
1559
|
+
}
|
|
1560
|
+
const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
1561
|
+
const extensions = process.platform === "win32"
|
|
1562
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
|
|
1563
|
+
: [""];
|
|
1564
|
+
return pathEntries.some((entry) => extensions.some((ext) => fsSync.existsSync(path.join(entry, `${command}${ext}`))));
|
|
1565
|
+
};
|
|
1566
|
+
if (testCommand) {
|
|
1567
|
+
const binary = resolveCommandBinary(testCommand);
|
|
1568
|
+
if (binary && !commandExists(binary)) {
|
|
1569
|
+
return {
|
|
1570
|
+
ok: false,
|
|
1571
|
+
missingDeps: [],
|
|
1572
|
+
missingEnv: [],
|
|
1573
|
+
message: `Missing CLI binary for QA command: ${binary}. Ensure it is installed and on PATH.`,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const pkg = await this.readPackageJson(workspaceRoot);
|
|
1578
|
+
const declared = new Set([
|
|
1579
|
+
...Object.keys(pkg?.dependencies ?? {}),
|
|
1580
|
+
...Object.keys(pkg?.devDependencies ?? {}),
|
|
1581
|
+
]);
|
|
1582
|
+
if (declared.size === 0 && !testCommand) {
|
|
1583
|
+
return { ok: true, missingDeps: [], missingEnv: [] };
|
|
1584
|
+
}
|
|
1585
|
+
const usesJest = testCommand?.toLowerCase().includes('jest') ?? false;
|
|
1586
|
+
const depsToCheck = QA_REQUIRED_DEPS.filter((dep) => {
|
|
1587
|
+
if (dep === '@jest/globals') {
|
|
1588
|
+
return usesJest || declared.has('@jest/globals') || declared.has('jest');
|
|
1589
|
+
}
|
|
1590
|
+
return declared.has(dep);
|
|
1591
|
+
});
|
|
1592
|
+
if (depsToCheck.length === 0 && !usesJest) {
|
|
1593
|
+
return { ok: true, missingDeps: [], missingEnv: [] };
|
|
1594
|
+
}
|
|
1595
|
+
const requireFromWorkspace = createRequire(path.join(workspaceRoot, 'package.json'));
|
|
1596
|
+
const missingDeps = depsToCheck.filter((dep) => {
|
|
1597
|
+
try {
|
|
1598
|
+
requireFromWorkspace.resolve(dep);
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
catch {
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
const missingEnv = [];
|
|
1606
|
+
for (const requirement of QA_REQUIRED_ENV) {
|
|
1607
|
+
if (!declared.has(requirement.dep) && !missingDeps.includes(requirement.dep))
|
|
1608
|
+
continue;
|
|
1609
|
+
const value = process.env[requirement.env];
|
|
1610
|
+
if (!value)
|
|
1611
|
+
missingEnv.push(requirement.env);
|
|
1612
|
+
}
|
|
1613
|
+
const messages = [];
|
|
1614
|
+
if (missingDeps.length) {
|
|
1615
|
+
messages.push(`Missing QA dependencies: ${missingDeps.join(', ')}. Install them with your package manager (e.g., pnpm add -D ${missingDeps.join(' ')}).`);
|
|
1616
|
+
}
|
|
1617
|
+
if (missingEnv.length) {
|
|
1618
|
+
messages.push(`Missing QA environment variables: ${missingEnv.join(', ')}. Set them (e.g., in .env.test).`);
|
|
1619
|
+
}
|
|
1620
|
+
return {
|
|
1621
|
+
ok: messages.length === 0,
|
|
1622
|
+
missingDeps,
|
|
1623
|
+
missingEnv,
|
|
1624
|
+
message: messages.join(' '),
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
isHttpUrl(value) {
|
|
1628
|
+
if (!value)
|
|
1629
|
+
return false;
|
|
1630
|
+
try {
|
|
1631
|
+
const parsed = new URL(value);
|
|
1632
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
1633
|
+
}
|
|
1634
|
+
catch {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async loadAvailableProfiles() {
|
|
1639
|
+
const loader = this.profileService?.loadProfiles;
|
|
1640
|
+
if (typeof loader !== 'function')
|
|
1641
|
+
return [];
|
|
1642
|
+
try {
|
|
1643
|
+
const profiles = await loader.call(this.profileService);
|
|
1644
|
+
return Array.isArray(profiles) ? profiles.filter(Boolean) : [];
|
|
1645
|
+
}
|
|
1646
|
+
catch {
|
|
1647
|
+
return [];
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
pickDefaultProfile(profiles) {
|
|
1651
|
+
if (!profiles.length)
|
|
1652
|
+
return undefined;
|
|
1653
|
+
const explicitDefault = profiles.find((profile) => profile.default);
|
|
1654
|
+
if (explicitDefault)
|
|
1655
|
+
return explicitDefault;
|
|
1656
|
+
const cliProfile = profiles.find((profile) => profile.name === 'cli' || (profile.runner ?? 'cli') === 'cli');
|
|
1657
|
+
if (cliProfile)
|
|
1658
|
+
return cliProfile;
|
|
1659
|
+
return profiles[0];
|
|
1660
|
+
}
|
|
1661
|
+
async planProfilesWithAgent(tasks, request, ctx) {
|
|
1662
|
+
const plan = new Map();
|
|
1663
|
+
if (request.profileName)
|
|
1664
|
+
return plan;
|
|
1665
|
+
if (!this.agentService)
|
|
1666
|
+
return plan;
|
|
1667
|
+
const profiles = await this.loadAvailableProfiles();
|
|
1668
|
+
if (!profiles.length)
|
|
1669
|
+
return plan;
|
|
1670
|
+
const defaultProfile = this.pickDefaultProfile(profiles);
|
|
1671
|
+
const profileByName = new Map(profiles.map((profile) => [profile.name, profile]));
|
|
1672
|
+
const runnerPlans = new Map();
|
|
1673
|
+
const resolveProfileForRunner = (runner) => {
|
|
1674
|
+
const normalized = runner ?? 'cli';
|
|
1675
|
+
const matches = profiles.filter((profile) => (profile.runner ?? 'cli') === normalized || profile.name === normalized);
|
|
1676
|
+
if (!matches.length)
|
|
1677
|
+
return undefined;
|
|
1678
|
+
const defaults = matches.filter((profile) => profile.default);
|
|
1679
|
+
if (defaults.length === 1)
|
|
1680
|
+
return defaults[0];
|
|
1681
|
+
return matches[0];
|
|
1682
|
+
};
|
|
1683
|
+
const taskKeys = new Set(tasks.map((task) => task.task.key));
|
|
1684
|
+
const agent = await this.resolveAgent(request.agentName);
|
|
1685
|
+
const prompts = await this.loadPrompts(agent.id);
|
|
1686
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
1687
|
+
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
1688
|
+
const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt]
|
|
1689
|
+
.filter(Boolean)
|
|
1690
|
+
.join('\n\n');
|
|
1691
|
+
const availableProfiles = profiles
|
|
1692
|
+
.map((profile) => `- ${profile.name} (runner=${profile.runner ?? 'cli'})`)
|
|
1693
|
+
.join('\n');
|
|
1694
|
+
const taskLines = [];
|
|
1695
|
+
for (const task of tasks) {
|
|
1696
|
+
const desc = (task.task.description ?? '').replace(/\s+/g, ' ').trim();
|
|
1697
|
+
const shortDesc = desc ? ` — ${desc.slice(0, 240)}` : '';
|
|
1698
|
+
const metadata = task.task.metadata ?? {};
|
|
1699
|
+
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
|
1700
|
+
const reviewFiles = Array.isArray(metadata.last_review_changed_paths)
|
|
1701
|
+
? metadata.last_review_changed_paths
|
|
1702
|
+
: [];
|
|
1703
|
+
const combined = [...files, ...reviewFiles].map((file) => String(file)).filter(Boolean);
|
|
1704
|
+
const changedSummary = combined.length > 0 ? ` | changed: ${combined.slice(0, 8).join(', ')}` : '';
|
|
1705
|
+
const apiTask = detectApiTask(task.task);
|
|
1706
|
+
let runnerHint = ` | api_task=${apiTask ? 'yes' : 'no'}`;
|
|
1707
|
+
if (this.profileService && typeof this.profileService.getRunnerPlan === 'function') {
|
|
1708
|
+
const runnerPlan = await this.profileService.getRunnerPlan(task.task);
|
|
1709
|
+
runnerPlans.set(task.task.key, runnerPlan);
|
|
1710
|
+
runnerHint = ` | ui_repo=${runnerPlan.hasWebInterface ? 'yes' : 'no'} ui_task=${runnerPlan.uiTask ? 'yes' : 'no'} mobile_task=${runnerPlan.mobileTask ? 'yes' : 'no'} api_task=${apiTask ? 'yes' : 'no'}`;
|
|
1711
|
+
}
|
|
1712
|
+
taskLines.push(`- ${task.task.key}: ${task.task.title ?? '(untitled)'}${shortDesc}${changedSummary}${runnerHint}`);
|
|
1713
|
+
}
|
|
1714
|
+
const tasksBlock = taskLines.join('\n');
|
|
1715
|
+
const prompt = [
|
|
1716
|
+
systemPrompt,
|
|
1717
|
+
QA_ROUTING_PROMPT,
|
|
1718
|
+
`Available QA profiles:\n${availableProfiles}`,
|
|
1719
|
+
`Tasks:\n${tasksBlock}`,
|
|
1720
|
+
QA_ROUTING_OUTPUT_SCHEMA,
|
|
1721
|
+
]
|
|
1722
|
+
.filter(Boolean)
|
|
1723
|
+
.join('\n\n');
|
|
1724
|
+
const res = await this.agentService.invoke(agent.id, {
|
|
1725
|
+
input: prompt,
|
|
1726
|
+
metadata: { command: 'qa-tasks', action: 'qa-profile-plan' },
|
|
1727
|
+
});
|
|
1728
|
+
const output = res.output ?? '';
|
|
1729
|
+
const tokensPrompt = this.estimateTokens(prompt);
|
|
1730
|
+
const tokensCompletion = this.estimateTokens(output);
|
|
1731
|
+
if (!this.dryRunGuard) {
|
|
1732
|
+
await this.jobService.recordTokenUsage({
|
|
1733
|
+
workspaceId: this.workspace.workspaceId,
|
|
1734
|
+
agentId: agent.id,
|
|
1735
|
+
modelName: agent.defaultModel,
|
|
1736
|
+
jobId: ctx.jobId ?? 'qa-profile-plan',
|
|
1737
|
+
commandRunId: ctx.commandRunId ?? 'qa-profile-plan',
|
|
1738
|
+
tokensPrompt,
|
|
1739
|
+
tokensCompletion,
|
|
1740
|
+
tokensTotal: tokensPrompt + tokensCompletion,
|
|
1741
|
+
timestamp: new Date().toISOString(),
|
|
1742
|
+
metadata: {
|
|
1743
|
+
commandName: 'qa-tasks',
|
|
1744
|
+
action: 'qa-profile-plan',
|
|
1745
|
+
phase: 'qa-plan',
|
|
1746
|
+
taskCount: tasks.length,
|
|
1747
|
+
},
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
const parsed = this.extractJsonCandidate(output);
|
|
1751
|
+
const normalizedPlan = normalizeQaPlanOutput(parsed);
|
|
1752
|
+
if (normalizedPlan.warnings.length) {
|
|
1753
|
+
ctx.warnings?.push(...normalizedPlan.warnings);
|
|
1754
|
+
}
|
|
1755
|
+
const taskProfiles = normalizedPlan.taskProfiles;
|
|
1756
|
+
const taskPlans = normalizedPlan.taskPlans;
|
|
1757
|
+
if (!Object.keys(taskProfiles).length && !Object.keys(taskPlans).length) {
|
|
1758
|
+
ctx.warnings?.push('QA routing agent output invalid; defaulting to CLI profiles.');
|
|
1759
|
+
}
|
|
1760
|
+
this.qaTaskPlans = new Map(Object.entries(taskPlans));
|
|
1761
|
+
for (const task of tasks) {
|
|
1762
|
+
const planEntry = taskPlans[task.task.key];
|
|
1763
|
+
const selection = taskProfiles[task.task.key] ?? planEntry?.profiles ?? [];
|
|
1764
|
+
const rawList = Array.isArray(selection) ? selection : typeof selection === 'string' ? [selection] : [];
|
|
1765
|
+
const resolved = rawList
|
|
1766
|
+
.map((name) => (typeof name === 'string' ? profileByName.get(name) : undefined))
|
|
1767
|
+
.filter(Boolean);
|
|
1768
|
+
let selected = resolved.length ? [...resolved] : [];
|
|
1769
|
+
if (!selected.length && defaultProfile) {
|
|
1770
|
+
selected = [defaultProfile];
|
|
1771
|
+
}
|
|
1772
|
+
const runnerPlan = runnerPlans.get(task.task.key);
|
|
1773
|
+
const allowChromium = Boolean(runnerPlan?.uiTask && runnerPlan?.hasWebInterface);
|
|
1774
|
+
const allowMaestro = Boolean(runnerPlan?.mobileTask);
|
|
1775
|
+
const planBrowserActions = (planEntry?.browser?.actions ?? []).filter(Boolean);
|
|
1776
|
+
const planWantsChromium = rawList.some((name) => String(name).toLowerCase() === 'chromium') ||
|
|
1777
|
+
(planEntry?.profiles ?? []).some((name) => String(name).toLowerCase() === 'chromium') ||
|
|
1778
|
+
planBrowserActions.length > 0;
|
|
1779
|
+
if (planWantsChromium && allowChromium) {
|
|
1780
|
+
const chromiumProfile = resolveProfileForRunner('chromium');
|
|
1781
|
+
if (chromiumProfile && !selected.some((entry) => entry.name === chromiumProfile.name)) {
|
|
1782
|
+
selected.push(chromiumProfile);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const cliProfile = resolveProfileForRunner('cli');
|
|
1786
|
+
if (cliProfile && !selected.some((entry) => entry.name === cliProfile.name)) {
|
|
1787
|
+
selected.push(cliProfile);
|
|
1788
|
+
}
|
|
1789
|
+
if (runnerPlan) {
|
|
1790
|
+
if (allowChromium) {
|
|
1791
|
+
const chromiumProfile = resolveProfileForRunner('chromium');
|
|
1792
|
+
if (chromiumProfile && !selected.some((entry) => entry.name === chromiumProfile.name)) {
|
|
1793
|
+
selected.push(chromiumProfile);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (allowMaestro) {
|
|
1797
|
+
const maestroProfile = resolveProfileForRunner('maestro');
|
|
1798
|
+
if (maestroProfile && !selected.some((entry) => entry.name === maestroProfile.name)) {
|
|
1799
|
+
selected.push(maestroProfile);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
if (!allowChromium) {
|
|
1803
|
+
selected = selected.filter((entry) => (entry.runner ?? 'cli') !== 'chromium' && entry.name !== 'chromium');
|
|
1804
|
+
}
|
|
1805
|
+
if (!allowMaestro) {
|
|
1806
|
+
selected = selected.filter((entry) => (entry.runner ?? 'cli') !== 'maestro' && entry.name !== 'maestro');
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (selected.length) {
|
|
1810
|
+
plan.set(task.task.key, selected);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const summary = Object.fromEntries(Array.from(plan.entries()).map(([key, entries]) => [key, entries.map((profile) => profile.name)]));
|
|
1814
|
+
if (ctx.jobId) {
|
|
1815
|
+
await this.checkpoint(ctx.jobId, 'qa-profile-plan', { profiles: summary, notes: normalizedPlan.notes });
|
|
1816
|
+
}
|
|
1817
|
+
const unusedProfileKeys = Object.keys(taskProfiles).filter((key) => !taskKeys.has(key));
|
|
1818
|
+
const unusedPlanKeys = Object.keys(taskPlans).filter((key) => !taskKeys.has(key));
|
|
1819
|
+
const unusedKeys = Array.from(new Set([...unusedProfileKeys, ...unusedPlanKeys]));
|
|
1820
|
+
if (unusedKeys.length) {
|
|
1821
|
+
ctx.warnings?.push(`QA routing agent returned unknown task keys: ${unusedKeys.join(', ')}`);
|
|
1822
|
+
}
|
|
1823
|
+
return plan;
|
|
1824
|
+
}
|
|
1825
|
+
async resolveProfilesForRequest(task, request) {
|
|
1826
|
+
const profileName = request.profileName;
|
|
1827
|
+
if (profileName) {
|
|
1828
|
+
const profile = await this.profileService.resolveProfileForTask(task, {
|
|
1829
|
+
profileName,
|
|
1830
|
+
level: request.level,
|
|
1831
|
+
});
|
|
1832
|
+
return profile ? [profile] : [];
|
|
1833
|
+
}
|
|
1834
|
+
const planned = this.qaProfilePlan?.get(task.key) ?? [];
|
|
1835
|
+
const profiles = [...planned];
|
|
1836
|
+
const seen = new Set(profiles.map((profile) => profile.name));
|
|
1837
|
+
const shouldUseRunnerProfiles = profiles.length === 0;
|
|
1838
|
+
if (shouldUseRunnerProfiles &&
|
|
1839
|
+
this.profileService &&
|
|
1840
|
+
typeof this.profileService.resolveProfilesForTask === 'function') {
|
|
1841
|
+
try {
|
|
1842
|
+
const runnerProfiles = await this.profileService.resolveProfilesForTask(task, {
|
|
1843
|
+
level: request.level,
|
|
1844
|
+
});
|
|
1845
|
+
if (Array.isArray(runnerProfiles)) {
|
|
1846
|
+
for (const profile of runnerProfiles) {
|
|
1847
|
+
if (!profile || seen.has(profile.name))
|
|
1848
|
+
continue;
|
|
1849
|
+
profiles.push(profile);
|
|
1850
|
+
seen.add(profile.name);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
// ignore runner plan resolution failures and fall back to planned/default
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (profiles.length)
|
|
1859
|
+
return profiles;
|
|
1860
|
+
const available = await this.loadAvailableProfiles();
|
|
1861
|
+
const fallback = this.pickDefaultProfile(available);
|
|
1862
|
+
return fallback ? [fallback] : [];
|
|
1863
|
+
}
|
|
1864
|
+
isApprovedReviewDecision(decision) {
|
|
1865
|
+
if (!decision)
|
|
1866
|
+
return false;
|
|
1867
|
+
const normalized = decision.toLowerCase();
|
|
1868
|
+
return normalized === 'approve' || normalized === 'info_only';
|
|
1869
|
+
}
|
|
1870
|
+
async shouldSkipQaForNoChanges(task) {
|
|
1871
|
+
const metadata = task.metadata ?? {};
|
|
1872
|
+
const diffEmptyValue = metadata.last_review_diff_empty;
|
|
1873
|
+
const diffEmpty = diffEmptyValue === true ||
|
|
1874
|
+
diffEmptyValue === 'true' ||
|
|
1875
|
+
diffEmptyValue === 1 ||
|
|
1876
|
+
diffEmptyValue === '1';
|
|
1877
|
+
const decision = typeof metadata.last_review_decision === 'string' ? metadata.last_review_decision : undefined;
|
|
1878
|
+
if (diffEmpty && this.isApprovedReviewDecision(decision)) {
|
|
1879
|
+
return {
|
|
1880
|
+
skip: true,
|
|
1881
|
+
decision,
|
|
1882
|
+
reviewId: typeof metadata.last_review_id === 'string' ? metadata.last_review_id : undefined,
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
const latestReview = await this.deps.workspaceRepo.getLatestTaskReview(task.id);
|
|
1886
|
+
if (latestReview?.metadata?.diffEmpty === true && this.isApprovedReviewDecision(latestReview.decision)) {
|
|
1887
|
+
return { skip: true, decision: latestReview.decision, reviewId: latestReview.id };
|
|
1888
|
+
}
|
|
1889
|
+
return { skip: false };
|
|
1890
|
+
}
|
|
1891
|
+
extractJsonCandidate(raw) {
|
|
1892
|
+
const trimmed = raw.trim();
|
|
1893
|
+
if (!trimmed)
|
|
1894
|
+
return undefined;
|
|
1895
|
+
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
|
|
1896
|
+
const candidate = fenceMatch ? fenceMatch[1].trim() : trimmed;
|
|
1897
|
+
if (!candidate.startsWith("{") || !candidate.endsWith("}"))
|
|
1898
|
+
return undefined;
|
|
1899
|
+
try {
|
|
1900
|
+
return JSON.parse(candidate);
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
return undefined;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
normalizeAgentOutput(parsed) {
|
|
1907
|
+
if (!parsed || typeof parsed !== 'object')
|
|
1908
|
+
return undefined;
|
|
1909
|
+
const asString = (value) => (typeof value === 'string' ? value.trim() : undefined);
|
|
1910
|
+
const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1911
|
+
const asLine = (value) => normalizeLineNumber(value);
|
|
1912
|
+
const asFile = (value) => {
|
|
1913
|
+
if (typeof value !== 'string')
|
|
1914
|
+
return undefined;
|
|
1915
|
+
const trimmed = value.trim();
|
|
1916
|
+
return trimmed ? normalizePath(trimmed) : undefined;
|
|
1917
|
+
};
|
|
1918
|
+
const asStringArray = (value) => Array.isArray(value) ? value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) : undefined;
|
|
1919
|
+
const recommendation = parsed.recommendation;
|
|
1920
|
+
if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
|
|
1921
|
+
return undefined;
|
|
1922
|
+
const testedScope = asString(parsed.tested_scope ?? parsed.scope);
|
|
1923
|
+
const coverageSummary = asString(parsed.coverage_summary ?? parsed.coverage);
|
|
1924
|
+
const rawFollowUps = Array.isArray(parsed.follow_up_tasks)
|
|
1925
|
+
? parsed.follow_up_tasks
|
|
1926
|
+
: Array.isArray(parsed.follow_ups)
|
|
1927
|
+
? parsed.follow_ups
|
|
1928
|
+
: undefined;
|
|
1929
|
+
const followUps = rawFollowUps
|
|
1930
|
+
? rawFollowUps.map((item) => ({
|
|
1931
|
+
title: asString(item?.title),
|
|
1932
|
+
description: asString(item?.description),
|
|
1933
|
+
type: asString(item?.type),
|
|
1934
|
+
priority: asNumber(item?.priority),
|
|
1935
|
+
story_points: asNumber(item?.story_points ?? item?.storyPoints),
|
|
1936
|
+
tags: asStringArray(item?.tags),
|
|
1937
|
+
related_task_key: asString(item?.related_task_key ?? item?.relatedTaskKey),
|
|
1938
|
+
epic_key: asString(item?.epic_key ?? item?.epicKey),
|
|
1939
|
+
story_key: asString(item?.story_key ?? item?.storyKey),
|
|
1940
|
+
components: asStringArray(item?.components),
|
|
1941
|
+
doc_links: asStringArray(item?.doc_links ?? item?.docLinks),
|
|
1942
|
+
evidence_url: asString(item?.evidence_url ?? item?.evidenceUrl),
|
|
1943
|
+
artifacts: asStringArray(item?.artifacts),
|
|
1944
|
+
}))
|
|
1945
|
+
: undefined;
|
|
1946
|
+
const failures = Array.isArray(parsed.failures)
|
|
1947
|
+
? parsed.failures.map((f) => ({
|
|
1948
|
+
kind: asString(f?.kind),
|
|
1949
|
+
message: asString(f?.message) ?? String(f),
|
|
1950
|
+
evidence: asString(f?.evidence),
|
|
1951
|
+
file: asFile(f?.file ?? f?.path ?? f?.file_path ?? f?.filePath),
|
|
1952
|
+
line: asLine(f?.line ?? f?.line_number ?? f?.lineNumber),
|
|
1953
|
+
}))
|
|
1954
|
+
: undefined;
|
|
1955
|
+
const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
|
|
1956
|
+
const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
|
|
1957
|
+
return {
|
|
1958
|
+
recommendation,
|
|
1959
|
+
testedScope,
|
|
1960
|
+
coverageSummary,
|
|
1961
|
+
failures,
|
|
1962
|
+
followUps,
|
|
1963
|
+
resolvedSlugs,
|
|
1964
|
+
unresolvedSlugs,
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
validateInterpretation(result, options = {}) {
|
|
1968
|
+
if (typeof result.testedScope !== "string" || !result.testedScope.trim()) {
|
|
1969
|
+
return "tested_scope must be a non-empty string.";
|
|
1970
|
+
}
|
|
1971
|
+
if (typeof result.coverageSummary !== "string" || !result.coverageSummary.trim()) {
|
|
1972
|
+
return "coverage_summary must be a non-empty string.";
|
|
1973
|
+
}
|
|
1974
|
+
if (options.requireCommentSlugs && result.resolvedSlugs === undefined && result.unresolvedSlugs === undefined) {
|
|
1975
|
+
return "resolvedSlugs/unresolvedSlugs required when comment backlog exists.";
|
|
1976
|
+
}
|
|
1977
|
+
if (!result.failures || result.failures.length === 0)
|
|
1978
|
+
return undefined;
|
|
1979
|
+
for (const failure of result.failures) {
|
|
1980
|
+
const file = failure.file?.trim();
|
|
1981
|
+
const line = normalizeLineNumber(failure.line);
|
|
1982
|
+
if (!file || !line) {
|
|
1983
|
+
return "Each QA failure must include file and line.";
|
|
1984
|
+
}
|
|
1985
|
+
failure.file = normalizePath(file);
|
|
1986
|
+
failure.line = line;
|
|
1987
|
+
}
|
|
1988
|
+
return undefined;
|
|
1989
|
+
}
|
|
1990
|
+
async interpretResult(task, profile, result, agentName, stream, jobId, commandRunId, taskRunId, commentBacklog, abortSignal) {
|
|
1991
|
+
if (!this.agentService) {
|
|
1992
|
+
return { recommendation: this.mapOutcome(result) };
|
|
1993
|
+
}
|
|
1994
|
+
const resolveAbortReason = () => {
|
|
1995
|
+
const reason = abortSignal?.reason;
|
|
1996
|
+
if (typeof reason === "string" && reason.trim().length > 0)
|
|
1997
|
+
return reason;
|
|
1998
|
+
if (reason instanceof Error && reason.message)
|
|
1999
|
+
return reason.message;
|
|
2000
|
+
return "qa_tasks_aborted";
|
|
2001
|
+
};
|
|
2002
|
+
const abortIfSignaled = () => {
|
|
2003
|
+
if (abortSignal?.aborted) {
|
|
2004
|
+
throw new Error(resolveAbortReason());
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
const withAbort = async (promise) => {
|
|
2008
|
+
if (!abortSignal)
|
|
2009
|
+
return promise;
|
|
2010
|
+
if (abortSignal.aborted) {
|
|
2011
|
+
throw new Error(resolveAbortReason());
|
|
2012
|
+
}
|
|
2013
|
+
return await new Promise((resolve, reject) => {
|
|
2014
|
+
const onAbort = () => reject(new Error(resolveAbortReason()));
|
|
2015
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
2016
|
+
promise.then(resolve, reject).finally(() => {
|
|
2017
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
2018
|
+
});
|
|
557
2019
|
});
|
|
558
2020
|
};
|
|
559
2021
|
try {
|
|
560
2022
|
abortIfSignaled();
|
|
561
2023
|
const agent = await this.resolveAgent(agentName);
|
|
562
2024
|
const prompts = await this.loadPrompts(agent.id);
|
|
563
|
-
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
|
|
2025
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
564
2026
|
if (projectGuidance && taskRunId) {
|
|
565
2027
|
await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
|
|
566
2028
|
}
|
|
@@ -568,7 +2030,8 @@ export class QaTasksService {
|
|
|
568
2030
|
const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, QA_TEST_POLICY]
|
|
569
2031
|
.filter(Boolean)
|
|
570
2032
|
.join('\n\n');
|
|
571
|
-
const
|
|
2033
|
+
const docLinks = Array.isArray(task.task.metadata?.doc_links) ? task.task.metadata.doc_links : [];
|
|
2034
|
+
const docCtx = await this.gatherDocContext(task.task, taskRunId, docLinks);
|
|
572
2035
|
const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
|
|
573
2036
|
const prompt = [
|
|
574
2037
|
systemPrompt,
|
|
@@ -577,7 +2040,7 @@ export class QaTasksService {
|
|
|
577
2040
|
`Task type: ${task.task.type ?? 'n/a'}, status: ${task.task.status}`,
|
|
578
2041
|
task.task.description ? `Task description:\n${task.task.description}` : '',
|
|
579
2042
|
`Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
|
|
580
|
-
acceptance ? `
|
|
2043
|
+
acceptance ? `Task DoD / acceptance criteria:\n${acceptance}` : 'Task DoD / acceptance criteria: (not provided)',
|
|
581
2044
|
commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : 'Comment backlog: none',
|
|
582
2045
|
`QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
|
|
583
2046
|
`Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
|
|
@@ -588,15 +2051,15 @@ export class QaTasksService {
|
|
|
588
2051
|
[
|
|
589
2052
|
'Return strict JSON with keys:',
|
|
590
2053
|
'{',
|
|
591
|
-
' "tested_scope": string,',
|
|
592
|
-
' "coverage_summary": string,',
|
|
593
|
-
' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "evidence": string }],',
|
|
2054
|
+
' "tested_scope": string (single sentence),',
|
|
2055
|
+
' "coverage_summary": string (single paragraph),',
|
|
2056
|
+
' "failures": [{ "kind": "functional|contract|perf|security|infra", "message": string, "file": string, "line": number, "evidence": string }],',
|
|
594
2057
|
' "recommendation": "pass|fix_required|infra_issue|unclear",',
|
|
595
2058
|
' "follow_up_tasks": [{ "title": string, "description": string, "type": "bug|qa_followup|chore", "priority": number, "story_points": number, "tags": string[], "related_task_key": string, "epic_key": string, "story_key": string, "doc_links": string[], "evidence_url": string, "artifacts": string[] }],',
|
|
596
2059
|
' "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],',
|
|
597
2060
|
' "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]',
|
|
598
2061
|
'}',
|
|
599
|
-
'Do not include prose outside the JSON. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
|
|
2062
|
+
'Do not include prose outside the JSON. No markdown fences or comments. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
|
|
600
2063
|
].join('\n'),
|
|
601
2064
|
]
|
|
602
2065
|
.filter(Boolean)
|
|
@@ -650,6 +2113,8 @@ export class QaTasksService {
|
|
|
650
2113
|
metadata: {
|
|
651
2114
|
commandName: 'qa-tasks',
|
|
652
2115
|
action: 'qa-interpret-results',
|
|
2116
|
+
phase: 'qa-interpret',
|
|
2117
|
+
attempt: 1,
|
|
653
2118
|
taskKey: task.task.key,
|
|
654
2119
|
streaming: stream,
|
|
655
2120
|
streamChunks: chunkCount || undefined,
|
|
@@ -657,7 +2122,15 @@ export class QaTasksService {
|
|
|
657
2122
|
});
|
|
658
2123
|
}
|
|
659
2124
|
const parsed = this.extractJsonCandidate(output);
|
|
660
|
-
|
|
2125
|
+
let normalized = this.normalizeAgentOutput(parsed);
|
|
2126
|
+
const requireCommentSlugs = Boolean(commentBacklog && commentBacklog.trim());
|
|
2127
|
+
let validationError = normalized ? this.validateInterpretation(normalized, { requireCommentSlugs }) : undefined;
|
|
2128
|
+
if (normalized && validationError) {
|
|
2129
|
+
if (taskRunId) {
|
|
2130
|
+
await this.logTask(taskRunId, `QA agent output missing required fields (${validationError}); retrying once.`, 'qa-agent');
|
|
2131
|
+
}
|
|
2132
|
+
normalized = undefined;
|
|
2133
|
+
}
|
|
661
2134
|
if (normalized) {
|
|
662
2135
|
return {
|
|
663
2136
|
...normalized,
|
|
@@ -703,12 +2176,18 @@ export class QaTasksService {
|
|
|
703
2176
|
metadata: {
|
|
704
2177
|
commandName: 'qa-tasks',
|
|
705
2178
|
action: 'qa-interpret-retry',
|
|
2179
|
+
phase: 'qa-interpret-retry',
|
|
2180
|
+
attempt: 2,
|
|
706
2181
|
taskKey: task.task.key,
|
|
707
2182
|
},
|
|
708
2183
|
});
|
|
709
2184
|
}
|
|
710
2185
|
const retryParsed = this.extractJsonCandidate(retryOutput);
|
|
711
|
-
|
|
2186
|
+
let retryNormalized = this.normalizeAgentOutput(retryParsed);
|
|
2187
|
+
validationError = retryNormalized ? this.validateInterpretation(retryNormalized, { requireCommentSlugs }) : undefined;
|
|
2188
|
+
if (retryNormalized && validationError) {
|
|
2189
|
+
retryNormalized = undefined;
|
|
2190
|
+
}
|
|
712
2191
|
if (retryNormalized) {
|
|
713
2192
|
return {
|
|
714
2193
|
...retryNormalized,
|
|
@@ -720,7 +2199,10 @@ export class QaTasksService {
|
|
|
720
2199
|
};
|
|
721
2200
|
}
|
|
722
2201
|
if (taskRunId) {
|
|
723
|
-
|
|
2202
|
+
const message = validationError
|
|
2203
|
+
? `QA agent output missing required fields (${validationError}); falling back to QA outcome.`
|
|
2204
|
+
: "QA agent returned invalid JSON after retry; falling back to QA outcome.";
|
|
2205
|
+
await this.logTask(taskRunId, message, "qa-agent");
|
|
724
2206
|
}
|
|
725
2207
|
return {
|
|
726
2208
|
recommendation: 'unclear',
|
|
@@ -733,8 +2215,12 @@ export class QaTasksService {
|
|
|
733
2215
|
};
|
|
734
2216
|
}
|
|
735
2217
|
catch (error) {
|
|
2218
|
+
const message = error?.message ?? String(error);
|
|
736
2219
|
if (taskRunId) {
|
|
737
|
-
await this.logTask(taskRunId, `QA agent failed: ${
|
|
2220
|
+
await this.logTask(taskRunId, `QA agent failed: ${message}`, 'qa-agent');
|
|
2221
|
+
}
|
|
2222
|
+
if (isAuthErrorMessage(message)) {
|
|
2223
|
+
throw error;
|
|
738
2224
|
}
|
|
739
2225
|
return { recommendation: this.mapOutcome(result) };
|
|
740
2226
|
}
|
|
@@ -753,6 +2239,8 @@ export class QaTasksService {
|
|
|
753
2239
|
source: 'qa-tasks',
|
|
754
2240
|
message,
|
|
755
2241
|
category: failure.kind ?? 'qa_issue',
|
|
2242
|
+
file: failure.file,
|
|
2243
|
+
line: failure.line,
|
|
756
2244
|
});
|
|
757
2245
|
}
|
|
758
2246
|
async applyCommentResolutions(params) {
|
|
@@ -814,6 +2302,8 @@ export class QaTasksService {
|
|
|
814
2302
|
message,
|
|
815
2303
|
status: 'open',
|
|
816
2304
|
category: failure.kind ?? 'qa_issue',
|
|
2305
|
+
file: failure.file ?? null,
|
|
2306
|
+
line: failure.line ?? null,
|
|
817
2307
|
});
|
|
818
2308
|
if (!this.dryRunGuard) {
|
|
819
2309
|
await this.deps.workspaceRepo.createTaskComment({
|
|
@@ -826,6 +2316,9 @@ export class QaTasksService {
|
|
|
826
2316
|
category: failure.kind ?? 'qa_issue',
|
|
827
2317
|
slug,
|
|
828
2318
|
status: 'open',
|
|
2319
|
+
file: failure.file ?? null,
|
|
2320
|
+
line: failure.line ?? null,
|
|
2321
|
+
pathHint: failure.file ?? null,
|
|
829
2322
|
body,
|
|
830
2323
|
createdAt: new Date().toISOString(),
|
|
831
2324
|
metadata: {
|
|
@@ -920,19 +2413,48 @@ export class QaTasksService {
|
|
|
920
2413
|
details: details ?? undefined,
|
|
921
2414
|
});
|
|
922
2415
|
}
|
|
923
|
-
async
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
2416
|
+
async createQaComment(params) {
|
|
2417
|
+
const status = params.status ?? (params.category === 'qa_result' ? 'resolved' : 'open');
|
|
2418
|
+
const slug = createTaskCommentSlug({ source: 'qa-tasks', message: params.message, category: params.category });
|
|
2419
|
+
const body = formatTaskCommentBody({
|
|
2420
|
+
slug,
|
|
2421
|
+
source: 'qa-tasks',
|
|
2422
|
+
message: params.message,
|
|
2423
|
+
status,
|
|
2424
|
+
category: params.category,
|
|
2425
|
+
});
|
|
2426
|
+
const createdAt = new Date().toISOString();
|
|
2427
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2428
|
+
taskId: params.task.id,
|
|
2429
|
+
taskRunId: params.taskRunId,
|
|
2430
|
+
jobId: params.jobId,
|
|
2431
|
+
sourceCommand: 'qa-tasks',
|
|
2432
|
+
authorType: params.authorType ?? 'agent',
|
|
2433
|
+
authorAgentId: params.authorAgentId ?? null,
|
|
2434
|
+
category: params.category,
|
|
2435
|
+
slug,
|
|
2436
|
+
status,
|
|
2437
|
+
body,
|
|
2438
|
+
createdAt,
|
|
2439
|
+
resolvedAt: status === 'resolved' ? createdAt : null,
|
|
2440
|
+
resolvedBy: status === 'resolved' ? params.authorAgentId ?? null : null,
|
|
2441
|
+
metadata: params.metadata ?? undefined,
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
async applyStateTransition(task, outcome, context, metadataPatch) {
|
|
2445
|
+
const baseMetadata = {
|
|
2446
|
+
last_qa: new Date().toISOString(),
|
|
2447
|
+
last_qa_outcome: outcome,
|
|
2448
|
+
};
|
|
2449
|
+
if (outcome !== 'pass') {
|
|
2450
|
+
baseMetadata.qa_failure_reason = `qa_${outcome}`;
|
|
930
2451
|
}
|
|
931
|
-
|
|
932
|
-
|
|
2452
|
+
const mergedMetadata = metadataPatch ? { ...baseMetadata, ...metadataPatch } : baseMetadata;
|
|
2453
|
+
if (outcome === 'pass') {
|
|
2454
|
+
await this.stateService.markCompleted(task, mergedMetadata, context);
|
|
933
2455
|
}
|
|
934
|
-
else
|
|
935
|
-
await this.stateService.
|
|
2456
|
+
else {
|
|
2457
|
+
await this.stateService.markNotStarted(task, mergedMetadata, context);
|
|
936
2458
|
}
|
|
937
2459
|
}
|
|
938
2460
|
buildFollowupSuggestion(task, result, notes) {
|
|
@@ -1008,13 +2530,14 @@ export class QaTasksService {
|
|
|
1008
2530
|
return [];
|
|
1009
2531
|
const agent = await this.resolveAgent(undefined);
|
|
1010
2532
|
const prompts = await this.loadPrompts(agent.id);
|
|
1011
|
-
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
|
|
2533
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
1012
2534
|
if (projectGuidance && taskRunId) {
|
|
1013
2535
|
await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
|
|
1014
2536
|
}
|
|
1015
2537
|
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
1016
2538
|
const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
|
|
1017
|
-
const
|
|
2539
|
+
const docLinks = Array.isArray(task.task.metadata?.doc_links) ? task.task.metadata.doc_links : [];
|
|
2540
|
+
const docCtx = await this.gatherDocContext(task.task, taskRunId, docLinks);
|
|
1018
2541
|
const prompt = [
|
|
1019
2542
|
systemPrompt,
|
|
1020
2543
|
'You are the mcoda QA agent. Given QA notes/evidence, propose structured follow-up tasks as JSON.',
|
|
@@ -1046,7 +2569,11 @@ export class QaTasksService {
|
|
|
1046
2569
|
output = res.output ?? '';
|
|
1047
2570
|
}
|
|
1048
2571
|
}
|
|
1049
|
-
catch {
|
|
2572
|
+
catch (error) {
|
|
2573
|
+
const message = error?.message ?? String(error);
|
|
2574
|
+
if (isAuthErrorMessage(message)) {
|
|
2575
|
+
throw error;
|
|
2576
|
+
}
|
|
1050
2577
|
return [];
|
|
1051
2578
|
}
|
|
1052
2579
|
const tokensPrompt = this.estimateTokens(prompt);
|
|
@@ -1083,6 +2610,12 @@ export class QaTasksService {
|
|
|
1083
2610
|
}
|
|
1084
2611
|
async runAuto(task, ctx) {
|
|
1085
2612
|
const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
|
|
2613
|
+
const statusContextBase = {
|
|
2614
|
+
commandName: 'qa-tasks',
|
|
2615
|
+
jobId: ctx.jobId,
|
|
2616
|
+
taskRunId: taskRun.id,
|
|
2617
|
+
metadata: { lane: 'qa' },
|
|
2618
|
+
};
|
|
1086
2619
|
await this.logTask(taskRun.id, 'Starting QA', 'qa-start');
|
|
1087
2620
|
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
1088
2621
|
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
@@ -1103,13 +2636,70 @@ export class QaTasksService {
|
|
|
1103
2636
|
runner: undefined,
|
|
1104
2637
|
metadata: { reason: 'status_gating' },
|
|
1105
2638
|
});
|
|
2639
|
+
await this.createQaComment({
|
|
2640
|
+
task: task.task,
|
|
2641
|
+
taskRunId: taskRun.id,
|
|
2642
|
+
jobId: ctx.jobId,
|
|
2643
|
+
message,
|
|
2644
|
+
category: 'qa_issue',
|
|
2645
|
+
status: 'open',
|
|
2646
|
+
metadata: {
|
|
2647
|
+
reason: 'status_gating',
|
|
2648
|
+
taskStatus: task.task.status,
|
|
2649
|
+
allowedStatuses: Array.from(allowedStatuses),
|
|
2650
|
+
},
|
|
2651
|
+
});
|
|
1106
2652
|
}
|
|
1107
2653
|
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
1108
2654
|
}
|
|
1109
|
-
const
|
|
2655
|
+
const skipReview = await this.shouldSkipQaForNoChanges(task.task);
|
|
2656
|
+
if (skipReview.skip) {
|
|
2657
|
+
const message = 'QA skipped: code review reported no code changes to validate.';
|
|
2658
|
+
await this.logTask(taskRun.id, message, 'qa-skip', { reason: 'review_no_changes', decision: skipReview.decision });
|
|
2659
|
+
if (!this.dryRunGuard) {
|
|
2660
|
+
await this.applyStateTransition(task.task, 'pass', statusContextBase);
|
|
2661
|
+
await this.finishTaskRun(taskRun, 'succeeded');
|
|
2662
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
2663
|
+
taskId: task.task.id,
|
|
2664
|
+
taskRunId: taskRun.id,
|
|
2665
|
+
jobId: ctx.jobId,
|
|
2666
|
+
commandRunId: ctx.commandRunId,
|
|
2667
|
+
source: 'auto',
|
|
2668
|
+
mode: 'auto',
|
|
2669
|
+
rawOutcome: 'pass',
|
|
2670
|
+
recommendation: 'pass',
|
|
2671
|
+
profileName: undefined,
|
|
2672
|
+
runner: undefined,
|
|
2673
|
+
metadata: { reason: 'review_no_changes', decision: skipReview.decision, reviewId: skipReview.reviewId },
|
|
2674
|
+
});
|
|
2675
|
+
const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_result' });
|
|
2676
|
+
const body = formatTaskCommentBody({
|
|
2677
|
+
slug,
|
|
2678
|
+
source: 'qa-tasks',
|
|
2679
|
+
message,
|
|
2680
|
+
status: 'resolved',
|
|
2681
|
+
category: 'qa_result',
|
|
2682
|
+
});
|
|
2683
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2684
|
+
taskId: task.task.id,
|
|
2685
|
+
taskRunId: taskRun.id,
|
|
2686
|
+
jobId: ctx.jobId,
|
|
2687
|
+
sourceCommand: 'qa-tasks',
|
|
2688
|
+
authorType: 'agent',
|
|
2689
|
+
category: 'qa_result',
|
|
2690
|
+
slug,
|
|
2691
|
+
status: 'resolved',
|
|
2692
|
+
body,
|
|
2693
|
+
createdAt: new Date().toISOString(),
|
|
2694
|
+
resolvedAt: new Date().toISOString(),
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
return { taskKey: task.task.key, outcome: 'pass', notes: 'review_no_changes' };
|
|
2698
|
+
}
|
|
2699
|
+
const branchCheck = await this.ensureTaskBranch(task, taskRun.id, ctx.jobId, ctx.request.allowDirty ?? false, ctx.request.cleanIgnorePaths);
|
|
1110
2700
|
if (!branchCheck.ok) {
|
|
1111
2701
|
if (!this.dryRunGuard) {
|
|
1112
|
-
await this.applyStateTransition(task.task, 'infra_issue');
|
|
2702
|
+
await this.applyStateTransition(task.task, 'infra_issue', statusContextBase);
|
|
1113
2703
|
await this.finishTaskRun(taskRun, 'failed');
|
|
1114
2704
|
await this.deps.workspaceRepo.createTaskQaRun({
|
|
1115
2705
|
taskId: task.task.id,
|
|
@@ -1144,336 +2734,945 @@ export class QaTasksService {
|
|
|
1144
2734
|
createdAt: new Date().toISOString(),
|
|
1145
2735
|
});
|
|
1146
2736
|
}
|
|
1147
|
-
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'vcs_branch_missing' };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
2737
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'vcs_branch_missing' };
|
|
2738
|
+
}
|
|
2739
|
+
const qaWorkspaceRoot = branchCheck.workspaceRoot ?? this.workspace.workspaceRoot;
|
|
2740
|
+
const cleanupWorktree = branchCheck.cleanup;
|
|
2741
|
+
let serverHandle;
|
|
2742
|
+
const baseBranch = this.workspace.config?.branch ?? 'mcoda-dev';
|
|
2743
|
+
const taskBranch = branchCheck.branch ?? baseBranch;
|
|
2744
|
+
let qaPrepared = false;
|
|
2745
|
+
const ensureQaPrepared = async () => {
|
|
2746
|
+
if (qaPrepared)
|
|
2747
|
+
return { ok: true };
|
|
2748
|
+
qaPrepared = true;
|
|
2749
|
+
return await this.prepareQaWorkspace({
|
|
2750
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
2751
|
+
taskRunId: taskRun.id,
|
|
2752
|
+
baseBranch,
|
|
2753
|
+
taskBranch,
|
|
2754
|
+
taskKey: task.task.key,
|
|
2755
|
+
});
|
|
2756
|
+
};
|
|
2757
|
+
try {
|
|
2758
|
+
const prep = await ensureQaPrepared();
|
|
2759
|
+
if (!prep.ok) {
|
|
2760
|
+
const message = prep.message ?? 'QA dependency install failed.';
|
|
2761
|
+
await this.logTask(taskRun.id, message, 'qa-install');
|
|
2762
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
2763
|
+
if (!this.dryRunGuard) {
|
|
2764
|
+
await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, {
|
|
2765
|
+
qa_failure_reason: 'qa_dependency_install_failed',
|
|
2766
|
+
});
|
|
2767
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
2768
|
+
taskId: task.task.id,
|
|
2769
|
+
taskRunId: taskRun.id,
|
|
2770
|
+
jobId: ctx.jobId,
|
|
2771
|
+
commandRunId: ctx.commandRunId,
|
|
2772
|
+
source: 'auto',
|
|
2773
|
+
mode: 'auto',
|
|
2774
|
+
rawOutcome: 'infra_issue',
|
|
2775
|
+
recommendation: 'infra_issue',
|
|
2776
|
+
metadata: { reason: 'qa_dependency_install_failed', message },
|
|
2777
|
+
});
|
|
2778
|
+
await this.createQaComment({
|
|
2779
|
+
task: task.task,
|
|
2780
|
+
taskRunId: taskRun.id,
|
|
2781
|
+
jobId: ctx.jobId,
|
|
2782
|
+
message,
|
|
2783
|
+
category: 'qa_issue',
|
|
2784
|
+
status: 'open',
|
|
2785
|
+
metadata: { reason: 'qa_dependency_install_failed' },
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'qa_dependency_install_failed' };
|
|
2789
|
+
}
|
|
2790
|
+
let profiles = [];
|
|
2791
|
+
try {
|
|
2792
|
+
profiles = await this.resolveProfilesForRequest(task.task, ctx.request);
|
|
2793
|
+
}
|
|
2794
|
+
catch (error) {
|
|
2795
|
+
await this.logTask(taskRun.id, `Profile resolution failed: ${error?.message ?? error}`, 'qa-profile');
|
|
2796
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
2797
|
+
if (!this.dryRunGuard) {
|
|
2798
|
+
await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, {
|
|
2799
|
+
qa_failure_reason: 'qa_profile_resolution_failed',
|
|
2800
|
+
});
|
|
2801
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
2802
|
+
taskId: task.task.id,
|
|
2803
|
+
taskRunId: taskRun.id,
|
|
2804
|
+
jobId: ctx.jobId,
|
|
2805
|
+
commandRunId: ctx.commandRunId,
|
|
2806
|
+
source: 'auto',
|
|
2807
|
+
mode: 'auto',
|
|
2808
|
+
rawOutcome: 'infra_issue',
|
|
2809
|
+
recommendation: 'infra_issue',
|
|
2810
|
+
metadata: { reason: 'profile_resolution_failed', message: error?.message ?? String(error) },
|
|
2811
|
+
});
|
|
2812
|
+
await this.createQaComment({
|
|
2813
|
+
task: task.task,
|
|
2814
|
+
taskRunId: taskRun.id,
|
|
2815
|
+
jobId: ctx.jobId,
|
|
2816
|
+
message: `QA profile resolution failed: ${error?.message ?? String(error)}`,
|
|
2817
|
+
category: 'qa_issue',
|
|
2818
|
+
status: 'open',
|
|
2819
|
+
metadata: { reason: 'profile_resolution_failed' },
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'profile_resolution_failed' };
|
|
2823
|
+
}
|
|
2824
|
+
if (!profiles.length) {
|
|
2825
|
+
await this.logTask(taskRun.id, 'No QA profile available', 'qa-profile');
|
|
2826
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
2827
|
+
if (!this.dryRunGuard) {
|
|
2828
|
+
await this.applyStateTransition(task.task, 'infra_issue', statusContextBase, { qa_failure_reason: 'qa_no_profile' });
|
|
2829
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
2830
|
+
taskId: task.task.id,
|
|
2831
|
+
taskRunId: taskRun.id,
|
|
2832
|
+
jobId: ctx.jobId,
|
|
2833
|
+
commandRunId: ctx.commandRunId,
|
|
2834
|
+
source: 'auto',
|
|
2835
|
+
mode: 'auto',
|
|
2836
|
+
rawOutcome: 'infra_issue',
|
|
2837
|
+
recommendation: 'infra_issue',
|
|
2838
|
+
metadata: { reason: 'no_profile' },
|
|
2839
|
+
});
|
|
2840
|
+
await this.createQaComment({
|
|
2841
|
+
task: task.task,
|
|
2842
|
+
taskRunId: taskRun.id,
|
|
2843
|
+
jobId: ctx.jobId,
|
|
2844
|
+
message: 'QA profile selection returned no profiles. Add or configure QA profiles.',
|
|
2845
|
+
category: 'qa_issue',
|
|
2846
|
+
status: 'open',
|
|
2847
|
+
metadata: { reason: 'no_profile' },
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_profile' };
|
|
2851
|
+
}
|
|
2852
|
+
const taskPlan = this.qaTaskPlans?.get(task.task.key);
|
|
2853
|
+
const requestCommand = ctx.request.testCommand;
|
|
2854
|
+
const requestCommandIsUrl = this.isHttpUrl(requestCommand);
|
|
2855
|
+
const cliOverride = requestCommandIsUrl ? undefined : requestCommand;
|
|
2856
|
+
const browserOverride = requestCommandIsUrl ? normalizeQaUrl(requestCommand) : undefined;
|
|
2857
|
+
const qaEnv = applyQaHostDefaults(process.env);
|
|
2858
|
+
const apiRunner = new QaApiRunner(qaWorkspaceRoot);
|
|
2859
|
+
const explicitBaseUrl = normalizeQaUrl(qaEnv.MCODA_QA_API_BASE_URL ?? qaEnv.MCODA_API_BASE_URL ?? qaEnv.API_BASE_URL ?? qaEnv.BASE_URL);
|
|
2860
|
+
if (explicitBaseUrl && !qaEnv.MCODA_QA_API_BASE_URL) {
|
|
2861
|
+
qaEnv.MCODA_QA_API_BASE_URL = explicitBaseUrl;
|
|
2862
|
+
}
|
|
2863
|
+
const hasOpenApiSpec = await apiRunner.hasOpenApiSpec();
|
|
2864
|
+
const browserActions = [...(taskPlan?.browser?.actions ?? [])];
|
|
2865
|
+
const browserStressEntries = taskPlan?.stress?.browser ?? [];
|
|
2866
|
+
const browserStressConfigured = browserStressEntries.length;
|
|
2867
|
+
if (browserStressConfigured) {
|
|
2868
|
+
for (const stress of browserStressEntries) {
|
|
2869
|
+
if (stress?.type !== 'repeat' || !stress.action)
|
|
2870
|
+
continue;
|
|
2871
|
+
const count = Math.max(1, Math.round(stress.count ?? 1));
|
|
2872
|
+
for (let index = 0; index < count; index += 1) {
|
|
2873
|
+
browserActions.push({ ...stress.action });
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
const wantsChromium = browserActions.length > 0 || profiles.some((profile) => (profile.runner ?? 'cli') === 'chromium');
|
|
2878
|
+
const wantsCli = profiles.some((profile) => (profile.runner ?? 'cli') === 'cli');
|
|
2879
|
+
const explicitProfileName = ctx.request.profileName?.toLowerCase();
|
|
2880
|
+
const explicitApi = explicitProfileName === 'api';
|
|
2881
|
+
const apiProbeEnabled = Boolean(taskPlan?.api || detectApiTask(task.task) || explicitApi);
|
|
2882
|
+
let apiProbeRequests;
|
|
2883
|
+
if (taskPlan?.api?.requests?.length) {
|
|
2884
|
+
apiProbeRequests = [...taskPlan.api.requests];
|
|
2885
|
+
}
|
|
2886
|
+
else if (apiProbeEnabled || hasOpenApiSpec) {
|
|
2887
|
+
apiProbeRequests = await apiRunner.suggestDefaultRequests();
|
|
2888
|
+
}
|
|
2889
|
+
let resolvedApiBaseUrl;
|
|
2890
|
+
const resolveApiBaseUrl = async () => {
|
|
2891
|
+
if (resolvedApiBaseUrl !== undefined)
|
|
2892
|
+
return resolvedApiBaseUrl;
|
|
2893
|
+
if (explicitBaseUrl) {
|
|
2894
|
+
resolvedApiBaseUrl = explicitBaseUrl;
|
|
2895
|
+
return resolvedApiBaseUrl;
|
|
2896
|
+
}
|
|
2897
|
+
const inferred = await apiRunner.resolveBaseUrl({
|
|
2898
|
+
planBaseUrl: taskPlan?.api?.base_url,
|
|
2899
|
+
planBrowserBaseUrl: taskPlan?.browser?.base_url,
|
|
2900
|
+
env: qaEnv,
|
|
2901
|
+
probeRequests: apiProbeRequests,
|
|
2902
|
+
});
|
|
2903
|
+
const normalized = inferred ? normalizeQaUrl(inferred) : undefined;
|
|
2904
|
+
resolvedApiBaseUrl = normalized;
|
|
2905
|
+
return resolvedApiBaseUrl;
|
|
2906
|
+
};
|
|
2907
|
+
let allocatedBaseUrl;
|
|
2908
|
+
const allocateLocalBaseUrl = async (reason, options = {}) => {
|
|
2909
|
+
if (allocatedBaseUrl)
|
|
2910
|
+
return allocatedBaseUrl;
|
|
2911
|
+
const host = qaEnv.MCODA_QA_HOST ?? DEFAULT_QA_HOST;
|
|
2912
|
+
const envPort = options.ignoreEnvPort ? undefined : resolveEnvPort(qaEnv);
|
|
2913
|
+
const port = envPort ?? (await pickFreePort(host));
|
|
2914
|
+
applyQaPortDefaults(qaEnv, port);
|
|
2915
|
+
const baseUrl = `http://${host}:${port}`;
|
|
2916
|
+
qaEnv.MCODA_QA_API_BASE_URL = baseUrl;
|
|
2917
|
+
allocatedBaseUrl = baseUrl;
|
|
2918
|
+
await this.logTask(taskRun.id, `QA base URL set to ${baseUrl} (${reason}).`, 'qa-server', {
|
|
2919
|
+
baseUrl,
|
|
2920
|
+
reason,
|
|
2921
|
+
});
|
|
2922
|
+
return baseUrl;
|
|
2923
|
+
};
|
|
2924
|
+
let browserBaseUrl = normalizeQaUrl(browserOverride ?? taskPlan?.browser?.base_url ?? taskPlan?.api?.base_url);
|
|
2925
|
+
if (wantsChromium && !browserBaseUrl) {
|
|
2926
|
+
browserBaseUrl = (await resolveApiBaseUrl()) ?? (await allocateLocalBaseUrl('browser'));
|
|
2927
|
+
}
|
|
2928
|
+
if (wantsCli) {
|
|
2929
|
+
const baseUrlCandidate = browserBaseUrl ?? (await resolveApiBaseUrl());
|
|
2930
|
+
if (baseUrlCandidate && isLocalBaseUrl(baseUrlCandidate)) {
|
|
2931
|
+
const parsed = resolveUrlPort(baseUrlCandidate);
|
|
2932
|
+
if (parsed) {
|
|
2933
|
+
const envPort = resolveEnvPort(qaEnv);
|
|
2934
|
+
let desiredPort = envPort ?? parsed.port;
|
|
2935
|
+
let adjusted = false;
|
|
2936
|
+
let adjustReason;
|
|
2937
|
+
if (envPort && envPort !== parsed.port) {
|
|
2938
|
+
adjusted = true;
|
|
2939
|
+
adjustReason = 'env';
|
|
2940
|
+
}
|
|
2941
|
+
else {
|
|
2942
|
+
const probeOk = apiProbeRequests?.length
|
|
2943
|
+
? await apiRunner.probeBaseUrl(baseUrlCandidate, apiProbeRequests)
|
|
2944
|
+
: undefined;
|
|
2945
|
+
const open = await isPortOpen(parsed.url.hostname, parsed.port);
|
|
2946
|
+
if (open && (!apiProbeRequests?.length || !probeOk)) {
|
|
2947
|
+
desiredPort = await pickFreePort(parsed.url.hostname);
|
|
2948
|
+
adjusted = true;
|
|
2949
|
+
adjustReason = 'in_use';
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
if (desiredPort !== parsed.port) {
|
|
2953
|
+
parsed.url.port = String(desiredPort);
|
|
2954
|
+
adjusted = true;
|
|
2955
|
+
adjustReason = adjustReason ?? 'env';
|
|
2956
|
+
}
|
|
2957
|
+
applyQaPortDefaults(qaEnv, desiredPort);
|
|
2958
|
+
if (adjusted) {
|
|
2959
|
+
const adjustedBaseUrl = parsed.url.toString().replace(/\/$/, '');
|
|
2960
|
+
if (browserBaseUrl)
|
|
2961
|
+
browserBaseUrl = adjustedBaseUrl;
|
|
2962
|
+
qaEnv.MCODA_QA_API_BASE_URL = adjustedBaseUrl;
|
|
2963
|
+
const reasonLabel = adjustReason === 'env' ? 'env override' : 'port in use';
|
|
2964
|
+
await this.logTask(taskRun.id, `QA port ${parsed.port} -> ${desiredPort} (${reasonLabel}).`, 'qa-server', {
|
|
2965
|
+
baseUrl: baseUrlCandidate,
|
|
2966
|
+
adjustedBaseUrl,
|
|
2967
|
+
reason: adjustReason ?? 'unknown',
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
const adjustBaseUrlForPortConflict = async (baseUrl, reason, probeRequests) => {
|
|
2974
|
+
if (!baseUrl)
|
|
2975
|
+
return baseUrl;
|
|
2976
|
+
if (explicitBaseUrl)
|
|
2977
|
+
return baseUrl;
|
|
2978
|
+
if (reason === 'browser' && !probeRequests?.length)
|
|
2979
|
+
return baseUrl;
|
|
2980
|
+
if (!isLocalBaseUrl(baseUrl))
|
|
2981
|
+
return baseUrl;
|
|
2982
|
+
const parsed = resolveUrlPort(baseUrl);
|
|
2983
|
+
if (!parsed)
|
|
2984
|
+
return baseUrl;
|
|
2985
|
+
const open = await isPortOpen(parsed.url.hostname, parsed.port);
|
|
2986
|
+
if (!open)
|
|
2987
|
+
return baseUrl;
|
|
2988
|
+
if (probeRequests?.length) {
|
|
2989
|
+
const probeOk = await apiRunner.probeBaseUrl(baseUrl, probeRequests);
|
|
2990
|
+
if (probeOk)
|
|
2991
|
+
return baseUrl;
|
|
2992
|
+
}
|
|
2993
|
+
const adjustedBaseUrl = await allocateLocalBaseUrl(reason, { ignoreEnvPort: true });
|
|
2994
|
+
const reasonLabel = probeRequests?.length ? 'probe_mismatch' : 'port_in_use';
|
|
2995
|
+
await this.logTask(taskRun.id, `QA ${reason} base URL ${baseUrl} rejected (${reasonLabel}); using ${adjustedBaseUrl}.`, 'qa-server', { baseUrl, adjustedBaseUrl, reason: reasonLabel });
|
|
2996
|
+
return adjustedBaseUrl;
|
|
2997
|
+
};
|
|
2998
|
+
if (wantsChromium) {
|
|
2999
|
+
browserBaseUrl = await adjustBaseUrlForPortConflict(browserBaseUrl, 'browser', apiProbeRequests);
|
|
3000
|
+
}
|
|
3001
|
+
if (wantsChromium && browserActions.length === 0 && browserBaseUrl) {
|
|
3002
|
+
browserActions.push({ type: 'navigate', url: '/' }, {
|
|
3003
|
+
type: 'script',
|
|
3004
|
+
expression: "document.body ? 'ok' : ''",
|
|
3005
|
+
expect: 'ok',
|
|
3006
|
+
}, { type: 'snapshot', name: 'home' });
|
|
3007
|
+
}
|
|
3008
|
+
if (wantsChromium && browserActions.length > 0 && !browserStressConfigured) {
|
|
3009
|
+
const stressSeed = browserActions[0];
|
|
3010
|
+
browserActions.push({ ...stressSeed }, { ...stressSeed });
|
|
3011
|
+
}
|
|
3012
|
+
const baseCtx = {
|
|
3013
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
3014
|
+
jobId: ctx.jobId,
|
|
3015
|
+
taskKey: task.task.key,
|
|
3016
|
+
env: qaEnv,
|
|
3017
|
+
};
|
|
3018
|
+
let serverBaseUrl;
|
|
3019
|
+
const serverTimeoutMs = resolveServerTimeoutMs();
|
|
3020
|
+
const ensureServerReady = async (baseUrl, reason, options = {}) => {
|
|
3021
|
+
if (!baseUrl)
|
|
3022
|
+
return { ok: true };
|
|
3023
|
+
if (!isLocalBaseUrl(baseUrl))
|
|
3024
|
+
return { ok: true };
|
|
3025
|
+
const reachable = await this.isUrlReachable(baseUrl, 1500);
|
|
3026
|
+
if (reachable)
|
|
3027
|
+
return { ok: true };
|
|
3028
|
+
if (!shouldAutoStartServer()) {
|
|
3029
|
+
const message = `QA ${reason} base URL ${baseUrl} is not reachable and auto-start is disabled.`;
|
|
3030
|
+
await this.logTask(taskRun.id, message, 'qa-server');
|
|
3031
|
+
return { ok: true, message };
|
|
3032
|
+
}
|
|
3033
|
+
if (serverHandle) {
|
|
3034
|
+
const message = `QA server already started for ${serverBaseUrl ?? 'unknown'}; ${baseUrl} is still unreachable.`;
|
|
3035
|
+
await this.logTask(taskRun.id, message, 'qa-server');
|
|
3036
|
+
return options.allowFailure ? { ok: true, message } : { ok: false, message };
|
|
3037
|
+
}
|
|
3038
|
+
const prep = await ensureQaPrepared();
|
|
3039
|
+
if (!prep.ok) {
|
|
3040
|
+
const message = prep.message ?? 'QA dependency install failed.';
|
|
3041
|
+
await this.logTask(taskRun.id, message, 'qa-install');
|
|
3042
|
+
return options.allowFailure ? { ok: true, message } : { ok: false, message };
|
|
3043
|
+
}
|
|
3044
|
+
const handle = await this.startQaServer({
|
|
3045
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
3046
|
+
baseUrl,
|
|
3047
|
+
env: qaEnv,
|
|
3048
|
+
jobId: ctx.jobId,
|
|
3049
|
+
taskKey: task.task.key,
|
|
3050
|
+
});
|
|
3051
|
+
if (!handle) {
|
|
3052
|
+
const message = `QA ${reason} base URL ${baseUrl} is unreachable and no dev server script (dev/start/serve) was found.`;
|
|
3053
|
+
await this.logTask(taskRun.id, message, 'qa-server');
|
|
3054
|
+
return options.allowFailure ? { ok: true, message } : { ok: false, message };
|
|
3055
|
+
}
|
|
3056
|
+
serverHandle = handle;
|
|
3057
|
+
serverBaseUrl = baseUrl;
|
|
3058
|
+
await this.logTask(taskRun.id, `Starting QA server: ${handle.command}`, 'qa-server', {
|
|
3059
|
+
baseUrl,
|
|
3060
|
+
logPath: handle.logPath,
|
|
3061
|
+
});
|
|
3062
|
+
if (serverTimeoutMs <= 0) {
|
|
3063
|
+
const message = `QA server wait disabled; continuing without readiness check for ${baseUrl}.`;
|
|
3064
|
+
await this.logTask(taskRun.id, message, 'qa-server', { baseUrl, timeoutMs: serverTimeoutMs });
|
|
3065
|
+
console.info(`[qa-tasks] ${message}`);
|
|
3066
|
+
return { ok: true, message };
|
|
3067
|
+
}
|
|
3068
|
+
console.info(`[qa-tasks] waiting for QA server at ${baseUrl} (timeout ${serverTimeoutMs}ms).`);
|
|
3069
|
+
const ready = await this.waitForUrlReady(baseUrl, serverTimeoutMs);
|
|
3070
|
+
if (!ready) {
|
|
3071
|
+
const message = `QA server did not become ready at ${baseUrl} after ${serverTimeoutMs}ms.`;
|
|
3072
|
+
await this.logTask(taskRun.id, message, 'qa-server', { baseUrl, timeoutMs: serverTimeoutMs });
|
|
3073
|
+
return options.allowFailure ? { ok: true, message } : { ok: false, message };
|
|
3074
|
+
}
|
|
3075
|
+
return { ok: true };
|
|
3076
|
+
};
|
|
3077
|
+
const runs = [];
|
|
3078
|
+
const runSummaries = [];
|
|
3079
|
+
const artifactSet = new Set();
|
|
3080
|
+
const buildInfraResult = (message) => {
|
|
3081
|
+
const now = new Date().toISOString();
|
|
3082
|
+
return {
|
|
3083
|
+
outcome: 'infra_issue',
|
|
3084
|
+
exitCode: null,
|
|
3085
|
+
stdout: '',
|
|
3086
|
+
stderr: message,
|
|
3087
|
+
artifacts: [],
|
|
3088
|
+
startedAt: now,
|
|
3089
|
+
finishedAt: now,
|
|
3090
|
+
};
|
|
3091
|
+
};
|
|
3092
|
+
for (const profile of profiles) {
|
|
3093
|
+
const runner = profile.runner ?? 'cli';
|
|
3094
|
+
await this.logTask(taskRun.id, `Running QA profile ${profile.name} (${runner})`, 'qa-profile');
|
|
3095
|
+
const adapter = this.adapterForProfile(profile);
|
|
3096
|
+
if (!adapter) {
|
|
3097
|
+
const message = `No QA adapter for profile ${profile.name} (${runner})`;
|
|
3098
|
+
await this.logTask(taskRun.id, message, 'qa-adapter');
|
|
3099
|
+
runs.push({ profile, runner, result: buildInfraResult(message) });
|
|
3100
|
+
runSummaries.push(`- ${profile.name} (${runner}) infra_issue (no_adapter)`);
|
|
3101
|
+
continue;
|
|
3102
|
+
}
|
|
3103
|
+
let testCommand = undefined;
|
|
3104
|
+
let cliCommands = [];
|
|
3105
|
+
if (runner === 'cli') {
|
|
3106
|
+
const planCommands = (taskPlan?.cli?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
|
|
3107
|
+
const commandBuilder = new QaTestCommandBuilder(qaWorkspaceRoot);
|
|
3108
|
+
const commandPlan = await commandBuilder.build({
|
|
3109
|
+
task: task.task,
|
|
3110
|
+
planCommands,
|
|
3111
|
+
cliOverride,
|
|
3112
|
+
profileCommand: profile.test_command,
|
|
3113
|
+
});
|
|
3114
|
+
const dedupedCommands = new Set();
|
|
3115
|
+
cliCommands = commandPlan.commands.filter((cmd) => {
|
|
3116
|
+
const normalized = cmd.trim();
|
|
3117
|
+
if (!normalized)
|
|
3118
|
+
return false;
|
|
3119
|
+
if (dedupedCommands.has(normalized))
|
|
3120
|
+
return false;
|
|
3121
|
+
dedupedCommands.add(normalized);
|
|
3122
|
+
return true;
|
|
3123
|
+
});
|
|
3124
|
+
if (!cliOverride) {
|
|
3125
|
+
const checklist = await this.resolveCliChecklistCommands({
|
|
3126
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
3127
|
+
task: task.task,
|
|
3128
|
+
existing: cliCommands,
|
|
3129
|
+
});
|
|
3130
|
+
if (checklist.length) {
|
|
3131
|
+
cliCommands = [...cliCommands, ...checklist];
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
cliCommands = this.softenOptionalNpmScripts(cliCommands);
|
|
3135
|
+
const chromiumPrep = await this.applyChromiumForCli(qaEnv, cliCommands);
|
|
3136
|
+
if (!chromiumPrep.ok) {
|
|
3137
|
+
const message = chromiumPrep.message ?? 'Chromium preflight failed.';
|
|
3138
|
+
await this.logTask(taskRun.id, message, 'qa-preflight');
|
|
3139
|
+
const failedCommand = cliCommands.length ? cliCommands.join(' && ') : undefined;
|
|
3140
|
+
runs.push({ profile, runner, testCommand: failedCommand, result: buildInfraResult(message) });
|
|
3141
|
+
runSummaries.push(`- ${profile.name} (${runner}) infra_issue (chromium_preflight)`);
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
3144
|
+
cliCommands = chromiumPrep.commands;
|
|
3145
|
+
testCommand = cliCommands.length ? cliCommands.join(' && ') : undefined;
|
|
3146
|
+
const preflight = await this.checkQaPreflight(testCommand, qaWorkspaceRoot);
|
|
3147
|
+
if (!preflight.ok) {
|
|
3148
|
+
const message = preflight.message ?? 'QA preflight failed.';
|
|
3149
|
+
await this.logTask(taskRun.id, message, 'qa-preflight', {
|
|
3150
|
+
missingDeps: preflight.missingDeps,
|
|
3151
|
+
missingEnv: preflight.missingEnv,
|
|
3152
|
+
});
|
|
3153
|
+
runs.push({ profile, runner, testCommand, result: buildInfraResult(message) });
|
|
3154
|
+
runSummaries.push(`- ${profile.name} (${runner}) infra_issue (preflight)`);
|
|
3155
|
+
continue;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
const runCtx = {
|
|
3159
|
+
...baseCtx,
|
|
3160
|
+
commands: runner === 'cli' && cliCommands.length > 1 ? cliCommands : undefined,
|
|
3161
|
+
testCommandOverride: runner === 'cli'
|
|
3162
|
+
? cliCommands.length === 1
|
|
3163
|
+
? cliCommands[0]
|
|
3164
|
+
: undefined
|
|
3165
|
+
: undefined,
|
|
3166
|
+
browserActions: runner === 'chromium' && browserActions.length ? browserActions : undefined,
|
|
3167
|
+
browserBaseUrl: runner === 'chromium' ? browserBaseUrl : undefined,
|
|
3168
|
+
};
|
|
3169
|
+
let ensure;
|
|
3170
|
+
try {
|
|
3171
|
+
ensure = await adapter.ensureInstalled(profile, runCtx);
|
|
3172
|
+
}
|
|
3173
|
+
catch (error) {
|
|
3174
|
+
ensure = { ok: false, message: error?.message ?? String(error) };
|
|
3175
|
+
}
|
|
3176
|
+
if (!ensure.ok) {
|
|
3177
|
+
const installMessage = ensure.message ?? 'QA install failed';
|
|
3178
|
+
const guidance = profile.runner === 'chromium'
|
|
3179
|
+
? 'Install Docdex Chromium (docdex setup or MCODA_QA_CHROMIUM_PATH).'
|
|
3180
|
+
: undefined;
|
|
3181
|
+
const installLower = installMessage.toLowerCase();
|
|
3182
|
+
const alreadyGuided = installLower.includes('chromium') ||
|
|
3183
|
+
installLower.includes('docdex') ||
|
|
3184
|
+
installLower.includes('test_command');
|
|
3185
|
+
const installMessageWithGuidance = guidance && !alreadyGuided ? `${installMessage} ${guidance}` : installMessage;
|
|
3186
|
+
await this.logTask(taskRun.id, installMessageWithGuidance, 'qa-install');
|
|
3187
|
+
runs.push({ profile, runner, testCommand, result: buildInfraResult(installMessageWithGuidance) });
|
|
3188
|
+
runSummaries.push(`- ${profile.name} (${runner}) infra_issue (install)`);
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3191
|
+
if (runner === 'chromium') {
|
|
3192
|
+
const serverCheck = await ensureServerReady(browserBaseUrl, 'browser', { allowFailure: true });
|
|
3193
|
+
if (!serverCheck.ok) {
|
|
3194
|
+
const message = serverCheck.message ?? `QA server not ready for ${browserBaseUrl ?? 'browser QA'}.`;
|
|
3195
|
+
runs.push({ profile, runner, testCommand, result: buildInfraResult(message) });
|
|
3196
|
+
runSummaries.push(`- ${profile.name} (${runner}) infra_issue (server_unavailable)`);
|
|
3197
|
+
continue;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
const profileDir = profile.name.replace(/[\\/]/g, '_');
|
|
3201
|
+
const artifactDir = path.join(this.workspace.mcodaDir, 'jobs', ctx.jobId, 'qa', task.task.key, profileDir);
|
|
3202
|
+
await PathHelper.ensureDir(artifactDir);
|
|
3203
|
+
const runEnv = { ...runCtx.env };
|
|
3204
|
+
let result = await adapter.invoke(profile, { ...runCtx, env: runEnv, artifactDir });
|
|
3205
|
+
let markerStatus;
|
|
3206
|
+
if (runner === 'cli') {
|
|
3207
|
+
const adjusted = this.adjustOutcomeForSkippedTests(profile, result, testCommand);
|
|
3208
|
+
result = adjusted.result;
|
|
3209
|
+
markerStatus = adjusted.markerStatus;
|
|
3210
|
+
if (markerStatus) {
|
|
3211
|
+
const statusLabel = markerStatus.present ? 'present' : 'missing';
|
|
3212
|
+
await this.logTask(taskRun.id, `Run-all marker ${statusLabel} for ${profile.name} (policy=${markerStatus.policy}, action=${markerStatus.action}).`, 'qa-marker', {
|
|
3213
|
+
policy: markerStatus.policy,
|
|
3214
|
+
status: statusLabel,
|
|
3215
|
+
action: markerStatus.action,
|
|
3216
|
+
marker: RUN_ALL_TESTS_MARKER,
|
|
3217
|
+
profile: profile.name,
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
const command = runner === 'chromium'
|
|
3222
|
+
? browserBaseUrl ?? profile.test_command
|
|
3223
|
+
: cliCommands.length > 1
|
|
3224
|
+
? cliCommands.join(' && ')
|
|
3225
|
+
: cliCommands[0] ?? testCommand ?? profile.test_command;
|
|
3226
|
+
runs.push({
|
|
3227
|
+
profile,
|
|
3228
|
+
runner,
|
|
3229
|
+
testCommand,
|
|
3230
|
+
command,
|
|
3231
|
+
result,
|
|
3232
|
+
markerStatus,
|
|
3233
|
+
});
|
|
3234
|
+
for (const artifact of result.artifacts ?? []) {
|
|
3235
|
+
artifactSet.add(artifact);
|
|
3236
|
+
}
|
|
3237
|
+
runSummaries.push(`- ${profile.name} (${runner}) outcome=${result.outcome} exit=${result.exitCode ?? 'null'}${command ? ` cmd=${command}` : ''}`);
|
|
3238
|
+
}
|
|
3239
|
+
let apiRequests = [...(taskPlan?.api?.requests ?? [])];
|
|
3240
|
+
if (taskPlan?.stress?.api?.length) {
|
|
3241
|
+
for (const stress of taskPlan.stress.api) {
|
|
3242
|
+
if (stress?.type !== 'burst' || !stress.request)
|
|
3243
|
+
continue;
|
|
3244
|
+
const count = Math.max(1, Math.round(stress.count ?? 1));
|
|
3245
|
+
for (let index = 0; index < count; index += 1) {
|
|
3246
|
+
apiRequests.push({
|
|
3247
|
+
...stress.request,
|
|
3248
|
+
id: stress.request.id ? `${stress.request.id}-${index + 1}` : `stress-${index + 1}`,
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
1171
3252
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
3253
|
+
const allowApiRunner = !explicitProfileName || explicitApi;
|
|
3254
|
+
const apiPlanRequested = taskPlan?.api !== undefined;
|
|
3255
|
+
const apiTaskHint = detectApiTask(task.task);
|
|
3256
|
+
if (allowApiRunner) {
|
|
3257
|
+
const shouldRunApiFallback = apiRequests.length === 0 && (apiPlanRequested || apiTaskHint || explicitApi);
|
|
3258
|
+
if (shouldRunApiFallback) {
|
|
3259
|
+
if (hasOpenApiSpec || apiPlanRequested || apiTaskHint || explicitApi) {
|
|
3260
|
+
apiRequests = await apiRunner.suggestDefaultRequests();
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
if (allowApiRunner && apiRequests.length) {
|
|
3265
|
+
let baseUrl = await apiRunner.resolveBaseUrl({
|
|
3266
|
+
planBaseUrl: taskPlan?.api?.base_url,
|
|
3267
|
+
planBrowserBaseUrl: taskPlan?.browser?.base_url,
|
|
3268
|
+
env: baseCtx.env,
|
|
3269
|
+
probeRequests: apiRequests,
|
|
1188
3270
|
});
|
|
3271
|
+
if (!baseUrl && shouldAutoStartServer()) {
|
|
3272
|
+
baseUrl = await allocateLocalBaseUrl('api');
|
|
3273
|
+
}
|
|
3274
|
+
if (baseUrl) {
|
|
3275
|
+
baseUrl = await adjustBaseUrlForPortConflict(baseUrl, 'api', apiRequests);
|
|
3276
|
+
}
|
|
3277
|
+
if (!baseUrl) {
|
|
3278
|
+
const message = shouldAutoStartServer()
|
|
3279
|
+
? 'QA API base URL could not be resolved.'
|
|
3280
|
+
: 'QA API base URL is missing and auto-start is disabled.';
|
|
3281
|
+
const apiProfile = { name: 'api', runner: 'api' };
|
|
3282
|
+
runs.push({ profile: apiProfile, runner: 'api', command: 'unknown', result: buildInfraResult(message) });
|
|
3283
|
+
runSummaries.push(`- api (api) infra_issue (no_base_url)`);
|
|
3284
|
+
await this.logTask(taskRun.id, message, 'qa-server');
|
|
3285
|
+
}
|
|
3286
|
+
else {
|
|
3287
|
+
const apiProfile = { name: 'api', runner: 'api' };
|
|
3288
|
+
const serverCheck = await ensureServerReady(baseUrl, 'api');
|
|
3289
|
+
if (!serverCheck.ok) {
|
|
3290
|
+
const message = serverCheck.message ?? `QA server not ready for ${baseUrl}.`;
|
|
3291
|
+
const apiResult = buildInfraResult(message);
|
|
3292
|
+
runs.push({
|
|
3293
|
+
profile: apiProfile,
|
|
3294
|
+
runner: 'api',
|
|
3295
|
+
command: `${baseUrl} (${apiRequests.length} requests)`,
|
|
3296
|
+
result: apiResult,
|
|
3297
|
+
});
|
|
3298
|
+
runSummaries.push(`- api (api) infra_issue (server_unavailable) cmd=${baseUrl}`);
|
|
3299
|
+
}
|
|
3300
|
+
else {
|
|
3301
|
+
const apiArtifactDir = path.join(this.workspace.mcodaDir, 'jobs', ctx.jobId, 'qa', task.task.key, 'api');
|
|
3302
|
+
await PathHelper.ensureDir(apiArtifactDir);
|
|
3303
|
+
const apiResult = await apiRunner.run({
|
|
3304
|
+
baseUrl,
|
|
3305
|
+
requests: apiRequests,
|
|
3306
|
+
env: baseCtx.env,
|
|
3307
|
+
artifactDir: apiArtifactDir,
|
|
3308
|
+
});
|
|
3309
|
+
runs.push({
|
|
3310
|
+
profile: apiProfile,
|
|
3311
|
+
runner: 'api',
|
|
3312
|
+
command: `${baseUrl} (${apiRequests.length} requests)`,
|
|
3313
|
+
result: apiResult,
|
|
3314
|
+
});
|
|
3315
|
+
for (const artifact of apiResult.artifacts ?? []) {
|
|
3316
|
+
artifactSet.add(artifact);
|
|
3317
|
+
}
|
|
3318
|
+
runSummaries.push(`- api (api) outcome=${apiResult.outcome} exit=${apiResult.exitCode ?? 'null'} cmd=${baseUrl}`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
1189
3321
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
3322
|
+
else if (!allowApiRunner && (apiRequests.length || apiPlanRequested || apiTaskHint)) {
|
|
3323
|
+
await this.logTask(taskRun.id, `Skipping API checks because profile=${ctx.request.profileName} was explicitly requested.`, 'qa-api', { profile: ctx.request.profileName });
|
|
3324
|
+
}
|
|
3325
|
+
if (!runs.length) {
|
|
3326
|
+
await this.logTask(taskRun.id, 'No QA runs executed', 'qa-adapter');
|
|
3327
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
3328
|
+
if (!this.dryRunGuard) {
|
|
3329
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
3330
|
+
taskId: task.task.id,
|
|
3331
|
+
taskRunId: taskRun.id,
|
|
3332
|
+
jobId: ctx.jobId,
|
|
3333
|
+
commandRunId: ctx.commandRunId,
|
|
3334
|
+
source: 'auto',
|
|
3335
|
+
mode: 'auto',
|
|
3336
|
+
rawOutcome: 'infra_issue',
|
|
3337
|
+
recommendation: 'infra_issue',
|
|
3338
|
+
metadata: { reason: 'no_runs' },
|
|
3339
|
+
});
|
|
3340
|
+
}
|
|
3341
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'no_runs' };
|
|
3342
|
+
}
|
|
3343
|
+
const combinedOutcome = runs.some((run) => run.result.outcome === 'infra_issue')
|
|
3344
|
+
? 'infra_issue'
|
|
3345
|
+
: runs.some((run) => run.result.outcome === 'fail')
|
|
3346
|
+
? 'fail'
|
|
3347
|
+
: 'pass';
|
|
3348
|
+
const combinedExitCode = combinedOutcome === 'infra_issue'
|
|
3349
|
+
? null
|
|
3350
|
+
: combinedOutcome === 'pass'
|
|
3351
|
+
? 0
|
|
3352
|
+
: runs.find((run) => typeof run.result.exitCode === 'number' && run.result.exitCode !== 0)?.result.exitCode ?? 1;
|
|
3353
|
+
const combineOutput = (field) => runs
|
|
3354
|
+
.map((run) => {
|
|
3355
|
+
const header = `=== ${run.profile.name} (${run.runner}) outcome=${run.result.outcome} exit=${run.result.exitCode ?? 'null'}${run.command ? ` cmd=${run.command}` : ''} ===`;
|
|
3356
|
+
const body = field === 'stdout' ? run.result.stdout : run.result.stderr;
|
|
3357
|
+
return [header, body].filter(Boolean).join('\n');
|
|
3358
|
+
})
|
|
3359
|
+
.join('\n\n');
|
|
3360
|
+
const startedAt = runs.reduce((min, run) => (run.result.startedAt < min ? run.result.startedAt : min), runs[0].result.startedAt);
|
|
3361
|
+
const finishedAt = runs.reduce((max, run) => (run.result.finishedAt > max ? run.result.finishedAt : max), runs[0].result.finishedAt);
|
|
3362
|
+
const result = {
|
|
3363
|
+
outcome: combinedOutcome,
|
|
3364
|
+
exitCode: combinedExitCode,
|
|
3365
|
+
stdout: combineOutput('stdout'),
|
|
3366
|
+
stderr: combineOutput('stderr'),
|
|
3367
|
+
artifacts: Array.from(artifactSet),
|
|
3368
|
+
startedAt,
|
|
3369
|
+
finishedAt,
|
|
3370
|
+
};
|
|
3371
|
+
const profile = runs.length === 1 ? runs[0].profile : { name: 'auto', runner: 'multi' };
|
|
3372
|
+
const runSummary = runSummaries.length ? runSummaries.join('\n') : undefined;
|
|
3373
|
+
await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
|
|
3374
|
+
exitCode: result.exitCode,
|
|
3375
|
+
runs: runs.length,
|
|
3376
|
+
});
|
|
3377
|
+
const commentContext = await this.loadCommentContext(task.task.id);
|
|
3378
|
+
const commentBacklog = buildCommentBacklog(commentContext.unresolved);
|
|
3379
|
+
let interpretation;
|
|
3380
|
+
try {
|
|
3381
|
+
if (this.shouldUseAgentInterpretation()) {
|
|
3382
|
+
interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id, commentBacklog, ctx.request.abortSignal);
|
|
3383
|
+
}
|
|
3384
|
+
else {
|
|
3385
|
+
interpretation = this.buildDeterministicInterpretation(task, profile, result);
|
|
3386
|
+
if (taskRun.id) {
|
|
3387
|
+
await this.logTask(taskRun.id, 'QA agent interpretation disabled; using runner outcome only.', 'qa-agent');
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
catch (error) {
|
|
3392
|
+
const message = error?.message ?? String(error);
|
|
3393
|
+
if (isAuthErrorMessage(message) && !this.dryRunGuard) {
|
|
3394
|
+
await this.stateService.markFailed(task.task, AUTH_ERROR_REASON, statusContextBase);
|
|
3395
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
3396
|
+
}
|
|
3397
|
+
throw error;
|
|
3398
|
+
}
|
|
3399
|
+
const invalidJson = interpretation.invalidJson === true;
|
|
3400
|
+
const outcome = invalidJson ? 'unclear' : this.combineOutcome(result, interpretation.recommendation);
|
|
3401
|
+
const artifacts = result.artifacts ?? [];
|
|
3402
|
+
const commentResolution = await this.applyCommentResolutions({
|
|
3403
|
+
task: task.task,
|
|
3404
|
+
taskRunId: taskRun.id,
|
|
3405
|
+
jobId: ctx.jobId,
|
|
3406
|
+
agentId: interpretation.agentId,
|
|
3407
|
+
failures: interpretation.failures ?? [],
|
|
3408
|
+
resolvedSlugs: interpretation.resolvedSlugs,
|
|
3409
|
+
unresolvedSlugs: interpretation.unresolvedSlugs,
|
|
3410
|
+
existingComments: commentContext.comments,
|
|
3411
|
+
});
|
|
3412
|
+
let qaRun;
|
|
1196
3413
|
if (!this.dryRunGuard) {
|
|
1197
|
-
await this.deps.workspaceRepo.createTaskQaRun({
|
|
3414
|
+
qaRun = await this.deps.workspaceRepo.createTaskQaRun({
|
|
1198
3415
|
taskId: task.task.id,
|
|
1199
3416
|
taskRunId: taskRun.id,
|
|
1200
3417
|
jobId: ctx.jobId,
|
|
1201
3418
|
commandRunId: ctx.commandRunId,
|
|
3419
|
+
agentId: interpretation.agentId,
|
|
3420
|
+
modelName: interpretation.modelName,
|
|
1202
3421
|
source: 'auto',
|
|
1203
3422
|
mode: 'auto',
|
|
1204
3423
|
profileName: profile.name,
|
|
1205
3424
|
runner: profile.runner,
|
|
1206
|
-
rawOutcome:
|
|
1207
|
-
recommendation:
|
|
1208
|
-
|
|
3425
|
+
rawOutcome: result.outcome,
|
|
3426
|
+
recommendation: interpretation.recommendation,
|
|
3427
|
+
artifacts,
|
|
3428
|
+
rawResult: {
|
|
3429
|
+
adapter: result,
|
|
3430
|
+
adapterRuns: runs.map((run) => ({
|
|
3431
|
+
profile: run.profile.name,
|
|
3432
|
+
runner: run.runner,
|
|
3433
|
+
command: run.command,
|
|
3434
|
+
testCommand: run.testCommand,
|
|
3435
|
+
result: run.result,
|
|
3436
|
+
})),
|
|
3437
|
+
agent: interpretation.rawOutput,
|
|
3438
|
+
},
|
|
3439
|
+
startedAt: result.startedAt,
|
|
3440
|
+
finishedAt: result.finishedAt,
|
|
3441
|
+
metadata: {
|
|
3442
|
+
tokensPrompt: interpretation.tokensPrompt,
|
|
3443
|
+
tokensCompletion: interpretation.tokensCompletion,
|
|
3444
|
+
testedScope: interpretation.testedScope,
|
|
3445
|
+
coverageSummary: interpretation.coverageSummary,
|
|
3446
|
+
failures: interpretation.failures,
|
|
3447
|
+
invalidJson: interpretation.invalidJson ?? false,
|
|
3448
|
+
runSummary,
|
|
3449
|
+
runProfiles: runs.map((run) => run.profile.name),
|
|
3450
|
+
runCount: runs.length,
|
|
3451
|
+
},
|
|
1209
3452
|
});
|
|
1210
3453
|
}
|
|
1211
|
-
return { taskKey: task.task.key, outcome: 'infra_issue', profile: profile.name, runner: profile.runner, notes: 'no_adapter' };
|
|
1212
|
-
}
|
|
1213
|
-
const testCommand = await this.resolveTestCommand(profile, ctx.request.testCommand);
|
|
1214
|
-
const qaCtx = {
|
|
1215
|
-
workspaceRoot: this.workspace.workspaceRoot,
|
|
1216
|
-
jobId: ctx.jobId,
|
|
1217
|
-
taskKey: task.task.key,
|
|
1218
|
-
env: process.env,
|
|
1219
|
-
testCommandOverride: testCommand,
|
|
1220
|
-
};
|
|
1221
|
-
const ensure = await adapter.ensureInstalled(profile, qaCtx);
|
|
1222
|
-
if (!ensure.ok) {
|
|
1223
|
-
const guidance = 'Run "docdex setup" to install Playwright and at least one browser.';
|
|
1224
|
-
const installMessage = ensure.message ?? 'QA install failed';
|
|
1225
|
-
const installMessageWithGuidance = installMessage.includes('docdex setup')
|
|
1226
|
-
? installMessage
|
|
1227
|
-
: `${installMessage} ${guidance}`;
|
|
1228
|
-
await this.logTask(taskRun.id, installMessageWithGuidance, 'qa-install');
|
|
1229
3454
|
if (!this.dryRunGuard) {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
3455
|
+
const statusContext = {
|
|
3456
|
+
...statusContextBase,
|
|
3457
|
+
agentId: interpretation.agentId ?? undefined,
|
|
3458
|
+
};
|
|
3459
|
+
await this.applyStateTransition(task.task, outcome, statusContext, invalidJson ? { qa_failure_reason: 'qa_invalid_output', qa_invalid_output: true } : undefined);
|
|
3460
|
+
await this.finishTaskRun(taskRun, invalidJson ? 'failed' : outcome === 'pass' ? 'succeeded' : 'failed');
|
|
3461
|
+
}
|
|
3462
|
+
const existingSlugs = new Set(commentContext.comments.map((comment) => comment.slug).filter((slug) => Boolean(slug)));
|
|
3463
|
+
const fallbackFailures = outcome !== 'pass' && (!interpretation.failures || interpretation.failures.length === 0);
|
|
3464
|
+
if (!this.dryRunGuard && fallbackFailures) {
|
|
3465
|
+
let created = 0;
|
|
3466
|
+
try {
|
|
3467
|
+
created = await this.createRunnerFailureComments({
|
|
3468
|
+
task: task.task,
|
|
3469
|
+
taskRunId: taskRun.id,
|
|
3470
|
+
jobId: ctx.jobId,
|
|
3471
|
+
outcome,
|
|
3472
|
+
result,
|
|
3473
|
+
runs,
|
|
3474
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
3475
|
+
runSummary,
|
|
3476
|
+
existingSlugs,
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
catch {
|
|
3480
|
+
// fall through to summary comment
|
|
3481
|
+
}
|
|
3482
|
+
if (created === 0) {
|
|
3483
|
+
let message = `QA ${outcome} based on runner output. Review QA logs/artifacts for details.`;
|
|
3484
|
+
try {
|
|
3485
|
+
message = await this.buildRunnerFailureMessage({
|
|
3486
|
+
outcome,
|
|
3487
|
+
result,
|
|
3488
|
+
runs,
|
|
3489
|
+
runSummary,
|
|
3490
|
+
workspaceRoot: qaWorkspaceRoot,
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
catch {
|
|
3494
|
+
// fallback to default message if summary build fails
|
|
3495
|
+
}
|
|
3496
|
+
await this.createQaComment({
|
|
3497
|
+
task: task.task,
|
|
3498
|
+
taskRunId: taskRun.id,
|
|
3499
|
+
jobId: ctx.jobId,
|
|
3500
|
+
message,
|
|
3501
|
+
category: 'qa_issue',
|
|
3502
|
+
status: 'open',
|
|
3503
|
+
metadata: { reason: 'qa_runner_failure', outcome, runSummary },
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
const followups = [];
|
|
3508
|
+
const wantsFollowups = ctx.request.createFollowupTasks !== 'none';
|
|
3509
|
+
const needsManualFollowup = interpretation.invalidJson === true;
|
|
3510
|
+
if ((outcome === 'fix_required' || needsManualFollowup) && wantsFollowups) {
|
|
3511
|
+
const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
|
|
3512
|
+
if (needsManualFollowup) {
|
|
3513
|
+
suggestions.unshift(this.buildManualQaFollowup(task.task, interpretation.rawOutput));
|
|
3514
|
+
}
|
|
3515
|
+
else if (suggestions.length === 0) {
|
|
3516
|
+
suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
|
|
3517
|
+
}
|
|
3518
|
+
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
3519
|
+
for (const suggestion of suggestions) {
|
|
3520
|
+
const followupSlug = this.buildFollowupSlug(task.task, suggestion);
|
|
3521
|
+
const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
|
|
3522
|
+
if (existing.length) {
|
|
3523
|
+
await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
|
|
3524
|
+
continue;
|
|
3525
|
+
}
|
|
3526
|
+
let proceed = ctx.request.createFollowupTasks !== 'prompt';
|
|
3527
|
+
if (interactive) {
|
|
3528
|
+
const rl = readline.createInterface({ input, output });
|
|
3529
|
+
const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
|
|
3530
|
+
rl.close();
|
|
3531
|
+
proceed = answer.trim().toLowerCase().startsWith('y');
|
|
3532
|
+
}
|
|
3533
|
+
if (!proceed)
|
|
3534
|
+
continue;
|
|
3535
|
+
try {
|
|
3536
|
+
if (!this.dryRunGuard) {
|
|
3537
|
+
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
|
|
3538
|
+
followups.push(created.task.key);
|
|
3539
|
+
await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
catch (error) {
|
|
3543
|
+
await this.logTask(taskRun.id, `Failed to create follow-up task: ${error?.message ?? error}`, 'qa-followup');
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
const bodyLines = [
|
|
3548
|
+
`QA outcome: ${outcome}`,
|
|
3549
|
+
outcome === 'unclear'
|
|
3550
|
+
? 'QA outcome unclear: provide missing acceptance criteria, reproduction steps, and expected behavior.'
|
|
3551
|
+
: '',
|
|
3552
|
+
profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
|
|
3553
|
+
runSummary ? `Runs:\n${runSummary}` : '',
|
|
3554
|
+
interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
|
|
3555
|
+
interpretation.failures && interpretation.failures.length
|
|
3556
|
+
? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
|
|
3557
|
+
: '',
|
|
3558
|
+
commentResolution
|
|
3559
|
+
? `Comment slugs: resolved ${commentResolution.resolved.length}, reopened ${commentResolution.reopened.length}, open ${commentResolution.open.length}`
|
|
3560
|
+
: '',
|
|
3561
|
+
interpretation.invalidJson
|
|
3562
|
+
? 'QA agent output invalid; task needs follow-up (qa_invalid_output).'
|
|
3563
|
+
: '',
|
|
3564
|
+
interpretation.invalidJson && interpretation.rawOutput
|
|
3565
|
+
? `QA agent output (invalid JSON):\n${interpretation.rawOutput.slice(0, 4000)}`
|
|
3566
|
+
: '',
|
|
3567
|
+
result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
|
|
3568
|
+
result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
|
|
3569
|
+
artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
|
|
3570
|
+
followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
|
|
3571
|
+
].filter(Boolean);
|
|
3572
|
+
if (!this.dryRunGuard) {
|
|
3573
|
+
const category = outcome === 'pass' ? 'qa_result' : 'qa_issue';
|
|
3574
|
+
const summaryMessage = bodyLines.join('\n\n');
|
|
3575
|
+
const summarySlug = createTaskCommentSlug({
|
|
3576
|
+
source: 'qa-tasks',
|
|
3577
|
+
message: summaryMessage,
|
|
3578
|
+
category,
|
|
1244
3579
|
});
|
|
1245
|
-
const
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
slug,
|
|
3580
|
+
const status = outcome === 'pass' ? 'resolved' : 'open';
|
|
3581
|
+
const summaryBody = formatTaskCommentBody({
|
|
3582
|
+
slug: summarySlug,
|
|
1249
3583
|
source: 'qa-tasks',
|
|
1250
|
-
message,
|
|
1251
|
-
status
|
|
1252
|
-
category
|
|
3584
|
+
message: summaryMessage,
|
|
3585
|
+
status,
|
|
3586
|
+
category,
|
|
1253
3587
|
});
|
|
3588
|
+
const createdAt = new Date().toISOString();
|
|
1254
3589
|
await this.deps.workspaceRepo.createTaskComment({
|
|
1255
3590
|
taskId: task.task.id,
|
|
1256
3591
|
taskRunId: taskRun.id,
|
|
1257
3592
|
jobId: ctx.jobId,
|
|
1258
3593
|
sourceCommand: 'qa-tasks',
|
|
1259
3594
|
authorType: 'agent',
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
3595
|
+
authorAgentId: interpretation.agentId ?? null,
|
|
3596
|
+
category,
|
|
3597
|
+
slug: summarySlug,
|
|
3598
|
+
status,
|
|
3599
|
+
body: summaryBody,
|
|
3600
|
+
createdAt,
|
|
3601
|
+
resolvedAt: status === 'resolved' ? createdAt : null,
|
|
3602
|
+
resolvedBy: status === 'resolved' ? interpretation.agentId ?? null : null,
|
|
3603
|
+
metadata: {
|
|
3604
|
+
...(artifacts.length ? { artifacts } : {}),
|
|
3605
|
+
...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
|
|
3606
|
+
},
|
|
1265
3607
|
});
|
|
1266
3608
|
}
|
|
3609
|
+
const ratingTokens = (interpretation.tokensPrompt ?? 0) + (interpretation.tokensCompletion ?? 0);
|
|
3610
|
+
if (ctx.request.rateAgents && interpretation.agentId && ratingTokens > 0) {
|
|
3611
|
+
try {
|
|
3612
|
+
const ratingService = this.ensureRatingService();
|
|
3613
|
+
await ratingService.rate({
|
|
3614
|
+
workspace: this.workspace,
|
|
3615
|
+
agentId: interpretation.agentId,
|
|
3616
|
+
commandName: 'qa-tasks',
|
|
3617
|
+
jobId: ctx.jobId,
|
|
3618
|
+
commandRunId: ctx.commandRunId,
|
|
3619
|
+
taskId: task.task.id,
|
|
3620
|
+
taskKey: task.task.key,
|
|
3621
|
+
discipline: task.task.type ?? undefined,
|
|
3622
|
+
complexity: this.resolveTaskComplexity(task.task),
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
catch (error) {
|
|
3626
|
+
const message = `Agent rating failed for ${task.task.key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
3627
|
+
ctx.warnings?.push(message);
|
|
3628
|
+
try {
|
|
3629
|
+
await this.logTask(taskRun.id, message, 'rating');
|
|
3630
|
+
}
|
|
3631
|
+
catch {
|
|
3632
|
+
/* ignore rating log failures */
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
1267
3636
|
return {
|
|
1268
3637
|
taskKey: task.task.key,
|
|
1269
|
-
outcome
|
|
3638
|
+
outcome,
|
|
1270
3639
|
profile: profile.name,
|
|
1271
3640
|
runner: profile.runner,
|
|
1272
|
-
notes: installMessageWithGuidance,
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
const artifactDir = path.join(this.workspace.workspaceRoot, '.mcoda', 'jobs', ctx.jobId, 'qa', task.task.key);
|
|
1276
|
-
await PathHelper.ensureDir(artifactDir);
|
|
1277
|
-
const qaEnv = { ...qaCtx.env };
|
|
1278
|
-
const browsersPath = ensure.details?.playwrightBrowsersPath;
|
|
1279
|
-
if (typeof browsersPath === 'string' && browsersPath.trim().length > 0) {
|
|
1280
|
-
qaEnv.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
1281
|
-
}
|
|
1282
|
-
let result = await adapter.invoke(profile, { ...qaCtx, env: qaEnv, artifactDir });
|
|
1283
|
-
result = this.adjustOutcomeForSkippedTests(profile, result, testCommand);
|
|
1284
|
-
await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
|
|
1285
|
-
exitCode: result.exitCode,
|
|
1286
|
-
});
|
|
1287
|
-
const commentContext = await this.loadCommentContext(task.task.id);
|
|
1288
|
-
const commentBacklog = buildCommentBacklog(commentContext.unresolved);
|
|
1289
|
-
const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id, commentBacklog, ctx.request.abortSignal);
|
|
1290
|
-
const outcome = this.combineOutcome(result, interpretation.recommendation);
|
|
1291
|
-
const artifacts = result.artifacts ?? [];
|
|
1292
|
-
const commentResolution = await this.applyCommentResolutions({
|
|
1293
|
-
task: task.task,
|
|
1294
|
-
taskRunId: taskRun.id,
|
|
1295
|
-
jobId: ctx.jobId,
|
|
1296
|
-
agentId: interpretation.agentId,
|
|
1297
|
-
failures: interpretation.failures ?? [],
|
|
1298
|
-
resolvedSlugs: interpretation.resolvedSlugs,
|
|
1299
|
-
unresolvedSlugs: interpretation.unresolvedSlugs,
|
|
1300
|
-
existingComments: commentContext.comments,
|
|
1301
|
-
});
|
|
1302
|
-
let qaRun;
|
|
1303
|
-
if (!this.dryRunGuard) {
|
|
1304
|
-
qaRun = await this.deps.workspaceRepo.createTaskQaRun({
|
|
1305
|
-
taskId: task.task.id,
|
|
1306
|
-
taskRunId: taskRun.id,
|
|
1307
|
-
jobId: ctx.jobId,
|
|
1308
|
-
commandRunId: ctx.commandRunId,
|
|
1309
|
-
agentId: interpretation.agentId,
|
|
1310
|
-
modelName: interpretation.modelName,
|
|
1311
|
-
source: 'auto',
|
|
1312
|
-
mode: 'auto',
|
|
1313
|
-
profileName: profile.name,
|
|
1314
|
-
runner: profile.runner,
|
|
1315
|
-
rawOutcome: result.outcome,
|
|
1316
|
-
recommendation: interpretation.recommendation,
|
|
1317
3641
|
artifacts,
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
agent: interpretation.rawOutput,
|
|
1321
|
-
},
|
|
1322
|
-
startedAt: result.startedAt,
|
|
1323
|
-
finishedAt: result.finishedAt,
|
|
1324
|
-
metadata: {
|
|
1325
|
-
tokensPrompt: interpretation.tokensPrompt,
|
|
1326
|
-
tokensCompletion: interpretation.tokensCompletion,
|
|
1327
|
-
testedScope: interpretation.testedScope,
|
|
1328
|
-
coverageSummary: interpretation.coverageSummary,
|
|
1329
|
-
failures: interpretation.failures,
|
|
1330
|
-
invalidJson: interpretation.invalidJson ?? false,
|
|
1331
|
-
},
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
if (!this.dryRunGuard) {
|
|
1335
|
-
await this.applyStateTransition(task.task, outcome);
|
|
1336
|
-
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
3642
|
+
followups,
|
|
3643
|
+
};
|
|
1337
3644
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
const needsManualFollowup = interpretation.invalidJson === true;
|
|
1341
|
-
if ((outcome === 'fix_required' || needsManualFollowup) && wantsFollowups) {
|
|
1342
|
-
const suggestions = interpretation.followUps?.map((f) => this.toFollowupSuggestion(task.task, f, artifacts)) ?? [];
|
|
1343
|
-
if (needsManualFollowup) {
|
|
1344
|
-
suggestions.unshift(this.buildManualQaFollowup(task.task, interpretation.rawOutput));
|
|
1345
|
-
}
|
|
1346
|
-
else if (suggestions.length === 0) {
|
|
1347
|
-
suggestions.push(this.buildFollowupSuggestion(task.task, result, ctx.request.notes));
|
|
1348
|
-
}
|
|
1349
|
-
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
1350
|
-
for (const suggestion of suggestions) {
|
|
1351
|
-
const followupSlug = this.buildFollowupSlug(task.task, suggestion);
|
|
1352
|
-
const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
|
|
1353
|
-
if (existing.length) {
|
|
1354
|
-
await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
|
|
1355
|
-
continue;
|
|
1356
|
-
}
|
|
1357
|
-
let proceed = ctx.request.createFollowupTasks !== 'prompt';
|
|
1358
|
-
if (interactive) {
|
|
1359
|
-
const rl = readline.createInterface({ input, output });
|
|
1360
|
-
const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
|
|
1361
|
-
rl.close();
|
|
1362
|
-
proceed = answer.trim().toLowerCase().startsWith('y');
|
|
1363
|
-
}
|
|
1364
|
-
if (!proceed)
|
|
1365
|
-
continue;
|
|
3645
|
+
finally {
|
|
3646
|
+
if (serverHandle) {
|
|
1366
3647
|
try {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
followups.push(created.task.key);
|
|
1370
|
-
await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
|
|
1371
|
-
}
|
|
3648
|
+
await serverHandle.stop();
|
|
3649
|
+
await this.logTask(taskRun.id, 'QA server stopped.', 'qa-server');
|
|
1372
3650
|
}
|
|
1373
3651
|
catch (error) {
|
|
1374
|
-
await this.logTask(taskRun.id, `
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
const bodyLines = [
|
|
1379
|
-
`QA outcome: ${outcome}`,
|
|
1380
|
-
outcome === 'unclear'
|
|
1381
|
-
? 'QA outcome unclear: provide missing acceptance criteria, reproduction steps, and expected behavior.'
|
|
1382
|
-
: '',
|
|
1383
|
-
profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
|
|
1384
|
-
interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
|
|
1385
|
-
interpretation.failures && interpretation.failures.length
|
|
1386
|
-
? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
|
|
1387
|
-
: '',
|
|
1388
|
-
commentResolution
|
|
1389
|
-
? `Comment slugs: resolved ${commentResolution.resolved.length}, reopened ${commentResolution.reopened.length}, open ${commentResolution.open.length}`
|
|
1390
|
-
: '',
|
|
1391
|
-
interpretation.invalidJson && interpretation.rawOutput
|
|
1392
|
-
? `QA agent output (invalid JSON):\n${interpretation.rawOutput.slice(0, 4000)}`
|
|
1393
|
-
: '',
|
|
1394
|
-
result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
|
|
1395
|
-
result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
|
|
1396
|
-
artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
|
|
1397
|
-
followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
|
|
1398
|
-
].filter(Boolean);
|
|
1399
|
-
if (!this.dryRunGuard) {
|
|
1400
|
-
const category = outcome === 'pass' ? 'qa_result' : 'qa_issue';
|
|
1401
|
-
const summaryMessage = bodyLines.join('\n\n');
|
|
1402
|
-
const summarySlug = createTaskCommentSlug({
|
|
1403
|
-
source: 'qa-tasks',
|
|
1404
|
-
message: summaryMessage,
|
|
1405
|
-
category,
|
|
1406
|
-
});
|
|
1407
|
-
const status = outcome === 'pass' ? 'resolved' : 'open';
|
|
1408
|
-
const summaryBody = formatTaskCommentBody({
|
|
1409
|
-
slug: summarySlug,
|
|
1410
|
-
source: 'qa-tasks',
|
|
1411
|
-
message: summaryMessage,
|
|
1412
|
-
status,
|
|
1413
|
-
category,
|
|
1414
|
-
});
|
|
1415
|
-
const createdAt = new Date().toISOString();
|
|
1416
|
-
await this.deps.workspaceRepo.createTaskComment({
|
|
1417
|
-
taskId: task.task.id,
|
|
1418
|
-
taskRunId: taskRun.id,
|
|
1419
|
-
jobId: ctx.jobId,
|
|
1420
|
-
sourceCommand: 'qa-tasks',
|
|
1421
|
-
authorType: 'agent',
|
|
1422
|
-
authorAgentId: interpretation.agentId ?? null,
|
|
1423
|
-
category,
|
|
1424
|
-
slug: summarySlug,
|
|
1425
|
-
status,
|
|
1426
|
-
body: summaryBody,
|
|
1427
|
-
createdAt,
|
|
1428
|
-
resolvedAt: status === 'resolved' ? createdAt : null,
|
|
1429
|
-
resolvedBy: status === 'resolved' ? interpretation.agentId ?? null : null,
|
|
1430
|
-
metadata: {
|
|
1431
|
-
...(artifacts.length ? { artifacts } : {}),
|
|
1432
|
-
...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
|
|
1433
|
-
},
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
const ratingTokens = (interpretation.tokensPrompt ?? 0) + (interpretation.tokensCompletion ?? 0);
|
|
1437
|
-
if (ctx.request.rateAgents && interpretation.agentId && ratingTokens > 0) {
|
|
1438
|
-
try {
|
|
1439
|
-
const ratingService = this.ensureRatingService();
|
|
1440
|
-
await ratingService.rate({
|
|
1441
|
-
workspace: this.workspace,
|
|
1442
|
-
agentId: interpretation.agentId,
|
|
1443
|
-
commandName: 'qa-tasks',
|
|
1444
|
-
jobId: ctx.jobId,
|
|
1445
|
-
commandRunId: ctx.commandRunId,
|
|
1446
|
-
taskId: task.task.id,
|
|
1447
|
-
taskKey: task.task.key,
|
|
1448
|
-
discipline: task.task.type ?? undefined,
|
|
1449
|
-
complexity: this.resolveTaskComplexity(task.task),
|
|
1450
|
-
});
|
|
3652
|
+
await this.logTask(taskRun.id, `QA server shutdown failed: ${error?.message ?? error}`, 'qa-server');
|
|
3653
|
+
}
|
|
1451
3654
|
}
|
|
1452
|
-
|
|
1453
|
-
const message = `Agent rating failed for ${task.task.key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1454
|
-
ctx.warnings?.push(message);
|
|
3655
|
+
if (cleanupWorktree) {
|
|
1455
3656
|
try {
|
|
1456
|
-
await
|
|
3657
|
+
await cleanupWorktree();
|
|
1457
3658
|
}
|
|
1458
|
-
catch {
|
|
1459
|
-
|
|
3659
|
+
catch (error) {
|
|
3660
|
+
await this.logTask(taskRun.id, `QA cleanup failed: ${error?.message ?? error}`, 'qa-cleanup');
|
|
1460
3661
|
}
|
|
1461
3662
|
}
|
|
1462
3663
|
}
|
|
1463
|
-
return {
|
|
1464
|
-
taskKey: task.task.key,
|
|
1465
|
-
outcome,
|
|
1466
|
-
profile: profile.name,
|
|
1467
|
-
runner: profile.runner,
|
|
1468
|
-
artifacts,
|
|
1469
|
-
followups,
|
|
1470
|
-
};
|
|
1471
3664
|
}
|
|
1472
3665
|
async runManual(task, ctx) {
|
|
1473
3666
|
const taskRun = await this.createTaskRun(task.task, ctx.jobId, ctx.commandRunId);
|
|
3667
|
+
const statusContext = {
|
|
3668
|
+
commandName: 'qa-tasks',
|
|
3669
|
+
jobId: ctx.jobId,
|
|
3670
|
+
taskRunId: taskRun.id,
|
|
3671
|
+
metadata: { lane: 'qa' },
|
|
3672
|
+
};
|
|
1474
3673
|
const result = ctx.request.result ?? 'pass';
|
|
1475
3674
|
const notes = ctx.request.notes;
|
|
1476
|
-
const outcome = result === 'pass' ? 'pass' :
|
|
3675
|
+
const outcome = result === 'pass' ? 'pass' : 'fix_required';
|
|
1477
3676
|
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
1478
3677
|
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
1479
3678
|
const message = `Task status ${task.task.status} not allowed for manual QA`;
|
|
@@ -1482,7 +3681,7 @@ export class QaTasksService {
|
|
|
1482
3681
|
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
1483
3682
|
}
|
|
1484
3683
|
if (!ctx.request.dryRun) {
|
|
1485
|
-
await this.applyStateTransition(task.task, outcome);
|
|
3684
|
+
await this.applyStateTransition(task.task, outcome, statusContext);
|
|
1486
3685
|
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
1487
3686
|
}
|
|
1488
3687
|
const followups = [];
|
|
@@ -1515,7 +3714,9 @@ export class QaTasksService {
|
|
|
1515
3714
|
evidenceUrl: ctx.request.evidenceUrl,
|
|
1516
3715
|
},
|
|
1517
3716
|
];
|
|
1518
|
-
const agentSuggestions =
|
|
3717
|
+
const agentSuggestions = this.shouldUseAgentInterpretation()
|
|
3718
|
+
? await this.suggestFollowupsFromAgent(task, notes, ctx.request.evidenceUrl, 'manual', ctx.jobId, ctx.commandRunId, taskRun.id)
|
|
3719
|
+
: [];
|
|
1519
3720
|
if (agentSuggestions.length) {
|
|
1520
3721
|
suggestions.unshift(...agentSuggestions);
|
|
1521
3722
|
}
|
|
@@ -1594,6 +3795,8 @@ export class QaTasksService {
|
|
|
1594
3795
|
};
|
|
1595
3796
|
}
|
|
1596
3797
|
async run(request) {
|
|
3798
|
+
this.qaProfilePlan = undefined;
|
|
3799
|
+
this.qaTaskPlans = undefined;
|
|
1597
3800
|
const resume = request.resumeJobId ? await this.deps.jobService.getJob(request.resumeJobId) : undefined;
|
|
1598
3801
|
if (request.resumeJobId && !resume) {
|
|
1599
3802
|
throw new Error(`Resume requested but job ${request.resumeJobId} not found`);
|
|
@@ -1603,13 +3806,22 @@ export class QaTasksService {
|
|
|
1603
3806
|
const effectiveStory = request.storyKey ?? resume?.payload?.storyKey;
|
|
1604
3807
|
const effectiveTasks = request.taskKeys?.length ? request.taskKeys : resume?.payload?.tasks;
|
|
1605
3808
|
const effectiveStatus = request.statusFilter ?? resume?.payload?.statusFilter ?? ['ready_to_qa'];
|
|
3809
|
+
const effectiveLimit = request.limit ?? resume?.payload?.limit;
|
|
3810
|
+
const ignoreStatusFilter = Boolean(effectiveTasks?.length) || request.ignoreStatusFilter === true;
|
|
3811
|
+
const { filtered: statusFilter, rejected } = filterTaskStatuses(ignoreStatusFilter ? [] : effectiveStatus, QA_ALLOWED_STATUSES, QA_ALLOWED_STATUSES);
|
|
1606
3812
|
const selection = await this.selectionService.selectTasks({
|
|
1607
3813
|
projectKey: effectiveProject,
|
|
1608
3814
|
epicKey: effectiveEpic,
|
|
1609
3815
|
storyKey: effectiveStory,
|
|
1610
3816
|
taskKeys: effectiveTasks,
|
|
1611
|
-
statusFilter
|
|
3817
|
+
statusFilter,
|
|
3818
|
+
limit: effectiveLimit,
|
|
3819
|
+
ignoreDependencies: true,
|
|
3820
|
+
ignoreStatusFilter,
|
|
1612
3821
|
});
|
|
3822
|
+
if (rejected.length > 0 && !ignoreStatusFilter) {
|
|
3823
|
+
selection.warnings.push(`qa-tasks ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${QA_ALLOWED_STATUSES.join(", ")}.`);
|
|
3824
|
+
}
|
|
1613
3825
|
const abortSignal = request.abortSignal;
|
|
1614
3826
|
const resolveAbortReason = () => {
|
|
1615
3827
|
const reason = abortSignal?.reason;
|
|
@@ -1624,27 +3836,37 @@ export class QaTasksService {
|
|
|
1624
3836
|
throw new Error(resolveAbortReason());
|
|
1625
3837
|
}
|
|
1626
3838
|
};
|
|
3839
|
+
const mode = request.mode ?? 'auto';
|
|
1627
3840
|
this.dryRunGuard = request.dryRun ?? false;
|
|
1628
3841
|
if (request.dryRun) {
|
|
1629
3842
|
const dryResults = [];
|
|
3843
|
+
if (mode !== 'manual') {
|
|
3844
|
+
this.qaProfilePlan = await this.planProfilesWithAgent(selection.ordered, request, {
|
|
3845
|
+
warnings: selection.warnings,
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
else {
|
|
3849
|
+
this.qaProfilePlan = new Map();
|
|
3850
|
+
}
|
|
1630
3851
|
for (const task of selection.ordered) {
|
|
1631
3852
|
abortIfSignaled();
|
|
1632
|
-
let
|
|
3853
|
+
let profiles = [];
|
|
1633
3854
|
try {
|
|
1634
|
-
|
|
1635
|
-
profileName: request.profileName,
|
|
1636
|
-
level: request.level,
|
|
1637
|
-
});
|
|
3855
|
+
profiles = await this.resolveProfilesForRequest(task.task, request);
|
|
1638
3856
|
}
|
|
1639
3857
|
catch {
|
|
1640
|
-
|
|
3858
|
+
profiles = [];
|
|
1641
3859
|
}
|
|
3860
|
+
const profile = profiles[0];
|
|
3861
|
+
const profileNames = profiles.map((entry) => entry.name);
|
|
1642
3862
|
dryResults.push({
|
|
1643
3863
|
taskKey: task.task.key,
|
|
1644
3864
|
outcome: profile ? 'unclear' : 'infra_issue',
|
|
1645
|
-
profile: profile?.name,
|
|
1646
|
-
runner: profile?.runner,
|
|
1647
|
-
notes: profile
|
|
3865
|
+
profile: profileNames.length > 1 ? 'auto' : profile?.name,
|
|
3866
|
+
runner: profileNames.length > 1 ? 'multi' : profile?.runner,
|
|
3867
|
+
notes: profile
|
|
3868
|
+
? `Dry-run: QA planned${profileNames.length > 1 ? ` (${profileNames.join(', ')})` : ''}`
|
|
3869
|
+
: 'Dry-run: no profile available',
|
|
1648
3870
|
});
|
|
1649
3871
|
}
|
|
1650
3872
|
return {
|
|
@@ -1688,8 +3910,9 @@ export class QaTasksService {
|
|
|
1688
3910
|
epicKey: effectiveEpic,
|
|
1689
3911
|
storyKey: effectiveStory,
|
|
1690
3912
|
tasks: effectiveTasks,
|
|
1691
|
-
statusFilter
|
|
1692
|
-
|
|
3913
|
+
statusFilter,
|
|
3914
|
+
limit: effectiveLimit,
|
|
3915
|
+
mode,
|
|
1693
3916
|
profile: request.profileName,
|
|
1694
3917
|
level: request.level,
|
|
1695
3918
|
agent: request.agentName,
|
|
@@ -1700,6 +3923,16 @@ export class QaTasksService {
|
|
|
1700
3923
|
totalItems: selection.ordered.length,
|
|
1701
3924
|
processedItems: completedKeys.size,
|
|
1702
3925
|
});
|
|
3926
|
+
if (mode !== 'manual') {
|
|
3927
|
+
this.qaProfilePlan = await this.planProfilesWithAgent(selection.ordered, request, {
|
|
3928
|
+
jobId: job.id,
|
|
3929
|
+
commandRunId: commandRun.id,
|
|
3930
|
+
warnings: selection.warnings,
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
else {
|
|
3934
|
+
this.qaProfilePlan = new Map();
|
|
3935
|
+
}
|
|
1703
3936
|
if (resume?.id) {
|
|
1704
3937
|
try {
|
|
1705
3938
|
const qaRuns = await this.deps.workspaceRepo.listTaskQaRunsForJob(selection.ordered.map((t) => t.task.id), resume.id);
|
|
@@ -1715,8 +3948,8 @@ export class QaTasksService {
|
|
|
1715
3948
|
}
|
|
1716
3949
|
}
|
|
1717
3950
|
const remaining = selection.ordered.filter((t) => !completedKeys.has(t.task.key));
|
|
1718
|
-
// Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/
|
|
1719
|
-
const terminalStatuses = new Set(['completed', 'in_progress', '
|
|
3951
|
+
// Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/failed)
|
|
3952
|
+
const terminalStatuses = new Set(['completed', 'in_progress', 'failed']);
|
|
1720
3953
|
const skippedTerminal = [];
|
|
1721
3954
|
for (const t of remaining) {
|
|
1722
3955
|
if (terminalStatuses.has(t.task.status?.toLowerCase?.() ?? '')) {
|
|
@@ -1735,11 +3968,60 @@ export class QaTasksService {
|
|
|
1735
3968
|
});
|
|
1736
3969
|
await this.checkpoint(job.id, 'selection', {
|
|
1737
3970
|
ordered: selection.ordered.map((t) => t.task.key),
|
|
1738
|
-
blocked: selection.blocked.map((t) => t.task.key),
|
|
1739
3971
|
completedTaskKeys: Array.from(completedKeys),
|
|
1740
3972
|
});
|
|
1741
3973
|
const warnings = [...selection.warnings];
|
|
1742
3974
|
const results = [];
|
|
3975
|
+
let abortRemainingReason = null;
|
|
3976
|
+
const formatSessionId = (iso) => {
|
|
3977
|
+
const date = new Date(iso);
|
|
3978
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
3979
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
3980
|
+
};
|
|
3981
|
+
const formatDuration = (ms) => {
|
|
3982
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
3983
|
+
const seconds = totalSeconds % 60;
|
|
3984
|
+
const minutesTotal = Math.floor(totalSeconds / 60);
|
|
3985
|
+
const minutes = minutesTotal % 60;
|
|
3986
|
+
const hours = Math.floor(minutesTotal / 60);
|
|
3987
|
+
if (hours > 0)
|
|
3988
|
+
return `${hours}H ${minutes}M ${seconds}S`;
|
|
3989
|
+
return `${minutes}M ${seconds}S`;
|
|
3990
|
+
};
|
|
3991
|
+
const emitLine = (line) => {
|
|
3992
|
+
console.info(line);
|
|
3993
|
+
};
|
|
3994
|
+
const emitBlank = () => emitLine('');
|
|
3995
|
+
const emitQaStart = (details) => {
|
|
3996
|
+
emitLine('╭──────────────────────────────────────────────────────────╮');
|
|
3997
|
+
emitLine('│ START OF QA TASK │');
|
|
3998
|
+
emitLine('╰──────────────────────────────────────────────────────────╯');
|
|
3999
|
+
emitLine(` [🪪] QA Task ID: ${details.taskKey}`);
|
|
4000
|
+
emitLine(` [👹] Alias: ${details.alias}`);
|
|
4001
|
+
emitLine(` [ℹ️] Summary: ${details.summary}`);
|
|
4002
|
+
emitLine(` [🤖] Agent: ${details.agent}`);
|
|
4003
|
+
emitLine(` [🕹️] Provider: ${details.provider}`);
|
|
4004
|
+
emitLine(` [🧩] Step: qa`);
|
|
4005
|
+
emitLine(` [🧪] Mode: ${details.mode}`);
|
|
4006
|
+
emitLine(` [📁] Workdir: ${details.workdir}`);
|
|
4007
|
+
emitLine(` [🔑] Session: ${details.sessionId}`);
|
|
4008
|
+
emitLine(` [🕒] Started: ${details.startedAt}`);
|
|
4009
|
+
emitBlank();
|
|
4010
|
+
emitLine(' ░░░░░ START OF QA TASK ░░░░░');
|
|
4011
|
+
emitBlank();
|
|
4012
|
+
};
|
|
4013
|
+
const emitQaEnd = (details) => {
|
|
4014
|
+
emitLine('╭──────────────────────────────────────────────────────────╮');
|
|
4015
|
+
emitLine('│ END OF QA TASK │');
|
|
4016
|
+
emitLine('╰──────────────────────────────────────────────────────────╯');
|
|
4017
|
+
emitLine(` 🧪 QA TASK ${details.taskKey} | 📜 STATUS ${details.statusLabel} | 🧭 OUTCOME ${details.outcome} | ⌛ TIME ${formatDuration(details.elapsedMs)}`);
|
|
4018
|
+
emitLine(` [🕒] Started: ${details.startedAt}`);
|
|
4019
|
+
emitLine(` [🕒] Ended: ${details.endedAt}`);
|
|
4020
|
+
emitLine(` [🧰] Profile: ${details.profile ?? 'n/a'} (${details.runner ?? 'n/a'})`);
|
|
4021
|
+
emitBlank();
|
|
4022
|
+
emitLine(' ░░░░░ END OF QA TASK ░░░░░');
|
|
4023
|
+
emitBlank();
|
|
4024
|
+
};
|
|
1743
4025
|
for (const task of selection.ordered) {
|
|
1744
4026
|
if (completedKeys.has(task.task.key)) {
|
|
1745
4027
|
results.push(priorResults.get(task.task.key) ?? { taskKey: task.task.key, outcome: 'pass', notes: 'skipped (resume)' });
|
|
@@ -1749,14 +4031,74 @@ export class QaTasksService {
|
|
|
1749
4031
|
try {
|
|
1750
4032
|
let processedCount = completedKeys.size;
|
|
1751
4033
|
for (const [index, task] of filteredRemaining.entries()) {
|
|
4034
|
+
if (abortRemainingReason)
|
|
4035
|
+
break;
|
|
1752
4036
|
abortIfSignaled();
|
|
1753
4037
|
const mode = request.mode ?? 'auto';
|
|
1754
|
-
|
|
1755
|
-
|
|
4038
|
+
const startedAt = new Date().toISOString();
|
|
4039
|
+
const taskStartMs = Date.now();
|
|
4040
|
+
const sessionId = formatSessionId(startedAt);
|
|
4041
|
+
const qaAgentLabel = request.agentName ?? '(auto)';
|
|
4042
|
+
emitQaStart({
|
|
4043
|
+
taskKey: task.task.key,
|
|
4044
|
+
alias: `QA task ${task.task.key}`,
|
|
4045
|
+
summary: task.task.title ?? task.task.description ?? '(none)',
|
|
4046
|
+
agent: qaAgentLabel,
|
|
4047
|
+
provider: qaAgentLabel === '(auto)' ? 'routing' : 'qa',
|
|
4048
|
+
mode,
|
|
4049
|
+
workdir: this.workspace.workspaceRoot,
|
|
4050
|
+
sessionId,
|
|
4051
|
+
startedAt,
|
|
4052
|
+
});
|
|
4053
|
+
let result;
|
|
4054
|
+
try {
|
|
4055
|
+
if (mode === 'manual') {
|
|
4056
|
+
result = await this.runManual(task, { jobId: job.id, commandRunId: commandRun.id, request });
|
|
4057
|
+
}
|
|
4058
|
+
else {
|
|
4059
|
+
result = await this.runAuto(task, { jobId: job.id, commandRunId: commandRun.id, request, warnings });
|
|
4060
|
+
}
|
|
1756
4061
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
4062
|
+
catch (error) {
|
|
4063
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4064
|
+
emitQaEnd({
|
|
4065
|
+
taskKey: task.task.key,
|
|
4066
|
+
statusLabel: 'FAILED',
|
|
4067
|
+
outcome: message,
|
|
4068
|
+
profile: undefined,
|
|
4069
|
+
runner: undefined,
|
|
4070
|
+
elapsedMs: Date.now() - taskStartMs,
|
|
4071
|
+
startedAt,
|
|
4072
|
+
endedAt: new Date().toISOString(),
|
|
4073
|
+
});
|
|
4074
|
+
if (isAuthErrorMessage(message)) {
|
|
4075
|
+
abortRemainingReason = message;
|
|
4076
|
+
warnings.push(`Auth/rate limit error detected; stopping after ${task.task.key}. ${message}`);
|
|
4077
|
+
results.push({ taskKey: task.task.key, outcome: 'infra_issue', notes: AUTH_ERROR_REASON });
|
|
4078
|
+
completedKeys.add(task.task.key);
|
|
4079
|
+
processedCount = completedKeys.size;
|
|
4080
|
+
await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
|
|
4081
|
+
await this.checkpoint(job.id, `task:${task.task.key}:aborted`, {
|
|
4082
|
+
processed: processedCount,
|
|
4083
|
+
completedTaskKeys: Array.from(completedKeys),
|
|
4084
|
+
taskResult: results[results.length - 1],
|
|
4085
|
+
});
|
|
4086
|
+
break;
|
|
4087
|
+
}
|
|
4088
|
+
throw error;
|
|
1759
4089
|
}
|
|
4090
|
+
results.push(result);
|
|
4091
|
+
const statusLabel = result.outcome === 'pass' ? 'COMPLETED' : result.outcome === 'infra_issue' ? 'BLOCKED' : 'FAILED';
|
|
4092
|
+
emitQaEnd({
|
|
4093
|
+
taskKey: task.task.key,
|
|
4094
|
+
statusLabel,
|
|
4095
|
+
outcome: result.outcome,
|
|
4096
|
+
profile: result.profile,
|
|
4097
|
+
runner: result.runner,
|
|
4098
|
+
elapsedMs: Date.now() - taskStartMs,
|
|
4099
|
+
startedAt,
|
|
4100
|
+
endedAt: new Date().toISOString(),
|
|
4101
|
+
});
|
|
1760
4102
|
completedKeys.add(task.task.key);
|
|
1761
4103
|
processedCount = completedKeys.size;
|
|
1762
4104
|
await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
|
|
@@ -1766,9 +4108,18 @@ export class QaTasksService {
|
|
|
1766
4108
|
taskResult: results[results.length - 1],
|
|
1767
4109
|
});
|
|
1768
4110
|
}
|
|
4111
|
+
if (abortRemainingReason) {
|
|
4112
|
+
warnings.push(`Stopped remaining tasks due to auth/rate limit: ${abortRemainingReason}`);
|
|
4113
|
+
}
|
|
1769
4114
|
const failureCount = results.filter((r) => r.outcome !== 'pass').length;
|
|
1770
|
-
const state =
|
|
1771
|
-
|
|
4115
|
+
const state = abortRemainingReason
|
|
4116
|
+
? 'failed'
|
|
4117
|
+
: failureCount === 0
|
|
4118
|
+
? 'completed'
|
|
4119
|
+
: failureCount === results.length
|
|
4120
|
+
? 'failed'
|
|
4121
|
+
: 'partial';
|
|
4122
|
+
const errorSummary = abortRemainingReason ?? (failureCount ? `${failureCount} task(s) not passed QA` : undefined);
|
|
1772
4123
|
await this.deps.jobService.updateJobStatus(job.id, state, { errorSummary });
|
|
1773
4124
|
await this.deps.jobService.finishCommandRun(commandRun.id, state === 'completed' ? 'succeeded' : 'failed', errorSummary);
|
|
1774
4125
|
await this.checkpoint(job.id, 'completed', {
|