@mcoda/core 0.1.8 → 0.1.11
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/CHANGELOG.md +3 -0
- package/README.md +2 -2
- package/dist/api/AgentsApi.d.ts +9 -1
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +201 -6
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +6 -0
- package/dist/api/TasksApi.d.ts.map +1 -1
- package/dist/api/TasksApi.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +9 -1
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +9 -0
- package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
- package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingFormula.js +45 -0
- package/dist/services/agents/AgentRatingService.d.ts +60 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingService.js +363 -0
- package/dist/services/agents/GatewayAgentService.d.ts +11 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +525 -84
- package/dist/services/agents/GatewayHandoff.d.ts +11 -0
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
- package/dist/services/agents/GatewayHandoff.js +141 -0
- 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 +17 -4
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +538 -79
- 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 +70 -3
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +1930 -89
- 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 +200 -0
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
- package/dist/services/execution/GatewayTrioService.js +2492 -0
- 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 +2 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +9 -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 +27 -1
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +354 -7
- package/dist/services/execution/QaTasksService.d.ts +59 -1
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +3347 -318
- 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 +32 -1
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +4667 -722
- 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 +51 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +953 -106
- package/dist/services/planning/CreateTasksService.d.ts +21 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +569 -31
- package/dist/services/planning/RefineTasksService.d.ts +9 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +409 -59
- package/dist/services/review/CodeReviewService.d.ts +18 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +1309 -167
- 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 +17 -0
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
- package/dist/services/shared/ProjectGuidance.js +78 -0
- 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/tasks/TaskCommentFormatter.d.ts +20 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
- package/dist/services/tasks/TaskCommentFormatter.js +54 -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 +26 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +206 -32
- package/package.json +6 -5
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
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';
|
|
3
12
|
import { WorkspaceRepository } from '@mcoda/db';
|
|
4
|
-
import { PathHelper } from '@mcoda/shared';
|
|
13
|
+
import { PathHelper, QA_ALLOWED_STATUSES, filterTaskStatuses } from '@mcoda/shared';
|
|
5
14
|
import { JobService } from '../jobs/JobService.js';
|
|
6
15
|
import { TaskSelectionService } from './TaskSelectionService.js';
|
|
7
16
|
import { TaskStateService } from './TaskStateService.js';
|
|
8
17
|
import { QaProfileService } from './QaProfileService.js';
|
|
9
18
|
import { QaFollowupService } from './QaFollowupService.js';
|
|
10
19
|
import { CliQaAdapter } from '@mcoda/integrations/qa/CliQaAdapter.js';
|
|
11
|
-
import { ChromiumQaAdapter } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
|
|
20
|
+
import { ChromiumQaAdapter, resolveChromiumBinary } from '@mcoda/integrations/qa/ChromiumQaAdapter.js';
|
|
12
21
|
import { MaestroQaAdapter } from '@mcoda/integrations/qa/MaestroQaAdapter.js';
|
|
13
22
|
import { VcsClient } from '@mcoda/integrations';
|
|
14
23
|
import readline from 'node:readline/promises';
|
|
@@ -17,13 +26,384 @@ import { AgentService } from '@mcoda/agents';
|
|
|
17
26
|
import { GlobalRepository } from '@mcoda/db';
|
|
18
27
|
import { DocdexClient } from '@mcoda/integrations';
|
|
19
28
|
import { RoutingService } from '../agents/RoutingService.js';
|
|
29
|
+
import { AgentRatingService } from '../agents/AgentRatingService.js';
|
|
30
|
+
import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from '../shared/ProjectGuidance.js';
|
|
31
|
+
import { buildDocdexUsageGuidance } from '../shared/DocdexGuidance.js';
|
|
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);
|
|
20
38
|
const DEFAULT_QA_PROMPT = [
|
|
21
|
-
'You are the QA agent.
|
|
22
|
-
|
|
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.',
|
|
23
43
|
].join('\n');
|
|
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
|
+
];
|
|
24
88
|
const DEFAULT_JOB_PROMPT = 'You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.';
|
|
25
89
|
const DEFAULT_CHARACTER_PROMPT = 'Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.';
|
|
26
|
-
const
|
|
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
|
+
};
|
|
122
|
+
const RUN_ALL_TESTS_MARKER = 'mcoda_run_all_tests_complete';
|
|
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';
|
|
132
|
+
const normalizeSlugList = (input) => {
|
|
133
|
+
if (!Array.isArray(input))
|
|
134
|
+
return [];
|
|
135
|
+
const cleaned = new Set();
|
|
136
|
+
for (const slug of input) {
|
|
137
|
+
if (typeof slug !== 'string')
|
|
138
|
+
continue;
|
|
139
|
+
const trimmed = slug.trim();
|
|
140
|
+
if (trimmed)
|
|
141
|
+
cleaned.add(trimmed);
|
|
142
|
+
}
|
|
143
|
+
return Array.from(cleaned);
|
|
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
|
+
};
|
|
340
|
+
const parseCommentBody = (body) => {
|
|
341
|
+
const trimmed = (body ?? '').trim();
|
|
342
|
+
if (!trimmed)
|
|
343
|
+
return { message: '(no details provided)' };
|
|
344
|
+
const lines = trimmed.split(/\r?\n/);
|
|
345
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
346
|
+
const messageIndex = lines.findIndex((line) => normalize(line) === 'message:');
|
|
347
|
+
const suggestedIndex = lines.findIndex((line) => {
|
|
348
|
+
const normalized = normalize(line);
|
|
349
|
+
return normalized === 'suggested_fix:' || normalized === 'suggested fix:';
|
|
350
|
+
});
|
|
351
|
+
if (messageIndex >= 0) {
|
|
352
|
+
const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
|
|
353
|
+
const message = messageLines.join('\n').trim();
|
|
354
|
+
const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
|
|
355
|
+
const suggestedFix = suggestedLines.join('\n').trim();
|
|
356
|
+
return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
|
|
357
|
+
}
|
|
358
|
+
if (suggestedIndex >= 0) {
|
|
359
|
+
const message = lines.slice(0, suggestedIndex).join('\n').trim() || trimmed;
|
|
360
|
+
const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
|
|
361
|
+
const suggestedTail = lines.slice(suggestedIndex + 1).join('\n').trim();
|
|
362
|
+
const suggestedFix = inlineFix || suggestedTail || undefined;
|
|
363
|
+
return { message, suggestedFix };
|
|
364
|
+
}
|
|
365
|
+
return { message: trimmed };
|
|
366
|
+
};
|
|
367
|
+
const buildCommentBacklog = (comments) => {
|
|
368
|
+
if (!comments.length)
|
|
369
|
+
return '';
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const lines = [];
|
|
372
|
+
const toSingleLine = (value) => value.replace(/\s+/g, ' ').trim();
|
|
373
|
+
for (const comment of comments) {
|
|
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
|
+
});
|
|
383
|
+
const key = slug ??
|
|
384
|
+
`${comment.sourceCommand}:${comment.file ?? ''}:${comment.line ?? ''}:${details.message || comment.body}`;
|
|
385
|
+
if (seen.has(key))
|
|
386
|
+
continue;
|
|
387
|
+
seen.add(key);
|
|
388
|
+
const location = comment.file
|
|
389
|
+
? `${comment.file}${typeof comment.line === 'number' ? `:${comment.line}` : ''}`
|
|
390
|
+
: '(location not specified)';
|
|
391
|
+
const message = toSingleLine(details.message || comment.body || '(no details provided)');
|
|
392
|
+
lines.push(`- [${slug ?? 'untracked'}] ${location} ${message}`);
|
|
393
|
+
const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
|
|
394
|
+
if (suggestedFix) {
|
|
395
|
+
lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return lines.join('\n');
|
|
399
|
+
};
|
|
400
|
+
const formatSlugList = (slugs, limit = 12) => {
|
|
401
|
+
if (!slugs.length)
|
|
402
|
+
return 'none';
|
|
403
|
+
if (slugs.length <= limit)
|
|
404
|
+
return slugs.join(', ');
|
|
405
|
+
return `${slugs.slice(0, limit).join(', ')} (+${slugs.length - limit} more)`;
|
|
406
|
+
};
|
|
27
407
|
export class QaTasksService {
|
|
28
408
|
constructor(workspace, deps) {
|
|
29
409
|
this.workspace = workspace;
|
|
@@ -31,7 +411,8 @@ export class QaTasksService {
|
|
|
31
411
|
this.dryRunGuard = false;
|
|
32
412
|
this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
33
413
|
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
34
|
-
this.profileService =
|
|
414
|
+
this.profileService =
|
|
415
|
+
deps.profileService ?? new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
|
|
35
416
|
this.followupService = deps.followupService ?? new QaFollowupService(deps.workspaceRepo, workspace.workspaceRoot);
|
|
36
417
|
this.jobService = deps.jobService;
|
|
37
418
|
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
@@ -39,13 +420,16 @@ export class QaTasksService {
|
|
|
39
420
|
this.docdex = deps.docdex;
|
|
40
421
|
this.repo = deps.repo;
|
|
41
422
|
this.routingService = deps.routingService;
|
|
423
|
+
this.ratingService = deps.ratingService;
|
|
42
424
|
}
|
|
43
425
|
static async create(workspace, options = {}) {
|
|
44
426
|
const repo = await GlobalRepository.create();
|
|
45
427
|
const agentService = new AgentService(repo);
|
|
428
|
+
const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
|
|
46
429
|
const docdex = new DocdexClient({
|
|
47
430
|
workspaceRoot: workspace.workspaceRoot,
|
|
48
431
|
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
432
|
+
repoId: docdexRepoId,
|
|
49
433
|
});
|
|
50
434
|
const routingService = await RoutingService.create();
|
|
51
435
|
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
@@ -54,7 +438,7 @@ export class QaTasksService {
|
|
|
54
438
|
});
|
|
55
439
|
const selectionService = new TaskSelectionService(workspace, workspaceRepo);
|
|
56
440
|
const stateService = new TaskStateService(workspaceRepo);
|
|
57
|
-
const profileService = new QaProfileService(workspace.workspaceRoot);
|
|
441
|
+
const profileService = new QaProfileService(workspace.workspaceRoot, { noRepoWrites: workspace.noRepoWrites });
|
|
58
442
|
const followupService = new QaFollowupService(workspaceRepo, workspace.workspaceRoot);
|
|
59
443
|
const vcsClient = new VcsClient();
|
|
60
444
|
return new QaTasksService(workspace, {
|
|
@@ -90,6 +474,14 @@ export class QaTasksService {
|
|
|
90
474
|
await maybeClose(this.docdex);
|
|
91
475
|
await maybeClose(this.deps.routingService);
|
|
92
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
|
+
}
|
|
93
485
|
async readPromptFiles(paths) {
|
|
94
486
|
const contents = [];
|
|
95
487
|
const seen = new Set();
|
|
@@ -109,12 +501,62 @@ export class QaTasksService {
|
|
|
109
501
|
return contents;
|
|
110
502
|
}
|
|
111
503
|
async loadPrompts(agentId) {
|
|
112
|
-
const mcodaPromptPath = path.join(this.workspace.
|
|
504
|
+
const mcodaPromptPath = path.join(this.workspace.mcodaDir, 'prompts', 'qa-agent.md');
|
|
113
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
|
+
};
|
|
114
515
|
try {
|
|
115
516
|
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
560
|
}
|
|
119
561
|
catch {
|
|
120
562
|
try {
|
|
@@ -123,25 +565,24 @@ export class QaTasksService {
|
|
|
123
565
|
console.info(`[qa-tasks] copied QA prompt to ${mcodaPromptPath}`);
|
|
124
566
|
}
|
|
125
567
|
catch {
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
128
577
|
}
|
|
129
578
|
}
|
|
130
|
-
const commandPromptFiles = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
131
579
|
const agentPrompts = this.agentService && 'getPrompts' in this.agentService ? await this.agentService.getPrompts(agentId) : undefined;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
if (agentPrompts?.commandPrompts?.['qa-tasks']) {
|
|
135
|
-
parts.push(agentPrompts.commandPrompts['qa-tasks']);
|
|
136
|
-
}
|
|
137
|
-
if (!parts.length)
|
|
138
|
-
parts.push(DEFAULT_QA_PROMPT);
|
|
139
|
-
return parts.filter(Boolean).join('\n\n');
|
|
140
|
-
})();
|
|
580
|
+
const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_QA_PROMPT);
|
|
581
|
+
const commandPrompt = agentPrompts?.commandPrompts?.['qa-tasks']?.trim() || filePrompt;
|
|
141
582
|
return {
|
|
142
|
-
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
143
|
-
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
144
|
-
commandPrompt:
|
|
583
|
+
jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
|
|
584
|
+
characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
|
|
585
|
+
commandPrompt: commandPrompt || undefined,
|
|
145
586
|
};
|
|
146
587
|
}
|
|
147
588
|
async checkpoint(jobId, stage, details) {
|
|
@@ -151,22 +592,71 @@ export class QaTasksService {
|
|
|
151
592
|
details,
|
|
152
593
|
});
|
|
153
594
|
}
|
|
154
|
-
async ensureTaskBranch(task, taskRunId) {
|
|
595
|
+
async ensureTaskBranch(task, taskRunId, jobId, allowDirty, cleanIgnorePaths) {
|
|
155
596
|
try {
|
|
156
|
-
|
|
157
|
-
await this.vcs.
|
|
158
|
-
if (
|
|
159
|
-
const
|
|
597
|
+
const repoRoot = this.workspace.workspaceRoot;
|
|
598
|
+
await this.vcs.ensureRepo(repoRoot);
|
|
599
|
+
if (!allowDirty) {
|
|
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);
|
|
605
|
+
}
|
|
606
|
+
let branch = task.task.vcsBranch;
|
|
607
|
+
if (branch) {
|
|
608
|
+
const exists = await this.vcs.branchExists(repoRoot, branch);
|
|
160
609
|
if (!exists) {
|
|
161
|
-
return { ok: false, message: `Task branch ${
|
|
610
|
+
return { ok: false, message: `Task branch ${branch} not found` };
|
|
162
611
|
}
|
|
163
|
-
await this.vcs.checkoutBranch(this.workspace.workspaceRoot, task.task.vcsBranch);
|
|
164
612
|
}
|
|
165
613
|
else {
|
|
166
614
|
const base = this.workspace.config?.branch ?? 'mcoda-dev';
|
|
167
|
-
await this.vcs.ensureBaseBranch(
|
|
615
|
+
await this.vcs.ensureBaseBranch(repoRoot, base);
|
|
616
|
+
branch = base;
|
|
168
617
|
}
|
|
169
|
-
|
|
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 };
|
|
170
660
|
}
|
|
171
661
|
catch (error) {
|
|
172
662
|
await this.logTask(taskRunId, `VCS check failed: ${error?.message ?? error}`, 'vcs');
|
|
@@ -175,16 +665,19 @@ export class QaTasksService {
|
|
|
175
665
|
}
|
|
176
666
|
async ensureMcoda() {
|
|
177
667
|
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
]);
|
|
188
681
|
}
|
|
189
682
|
adapterForProfile(profile) {
|
|
190
683
|
const runner = profile?.runner ?? 'cli';
|
|
@@ -203,6 +696,226 @@ export class QaTasksService {
|
|
|
203
696
|
return 'infra_issue';
|
|
204
697
|
return 'fix_required';
|
|
205
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
|
+
}
|
|
880
|
+
adjustOutcomeForSkippedTests(profile, result, testCommand) {
|
|
881
|
+
if ((profile.runner ?? 'cli') !== 'cli')
|
|
882
|
+
return { result };
|
|
883
|
+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
884
|
+
const outputLower = output.toLowerCase();
|
|
885
|
+
const markers = ['no test script configured', 'skipping tests', 'no tests found'];
|
|
886
|
+
if (markers.some((marker) => outputLower.includes(marker))) {
|
|
887
|
+
return { result: { ...result, outcome: 'infra_issue', exitCode: result.exitCode ?? 1 } };
|
|
888
|
+
}
|
|
889
|
+
let markerStatus;
|
|
890
|
+
if (testCommand && testCommand.includes('tests/all.js')) {
|
|
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
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { result, markerStatus };
|
|
918
|
+
}
|
|
206
919
|
combineOutcome(result, recommendation) {
|
|
207
920
|
const base = this.mapOutcome(result);
|
|
208
921
|
if (!recommendation)
|
|
@@ -217,10 +930,22 @@ export class QaTasksService {
|
|
|
217
930
|
return 'unclear';
|
|
218
931
|
return 'pass';
|
|
219
932
|
}
|
|
220
|
-
async gatherDocContext(task, taskRunId) {
|
|
933
|
+
async gatherDocContext(task, taskRunId, docLinks = []) {
|
|
221
934
|
if (!this.docdex)
|
|
222
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
|
+
};
|
|
223
945
|
try {
|
|
946
|
+
if (typeof this.docdex?.ensureRepoScope === 'function') {
|
|
947
|
+
await this.docdex.ensureRepoScope();
|
|
948
|
+
}
|
|
224
949
|
const querySeeds = [task.key, task.title, ...(task.acceptanceCriteria ?? [])]
|
|
225
950
|
.filter(Boolean)
|
|
226
951
|
.join(' ')
|
|
@@ -231,7 +956,21 @@ export class QaTasksService {
|
|
|
231
956
|
query: querySeeds,
|
|
232
957
|
});
|
|
233
958
|
const snippets = [];
|
|
234
|
-
|
|
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)) {
|
|
235
974
|
const segments = (doc.segments ?? []).slice(0, 2);
|
|
236
975
|
const body = segments.length
|
|
237
976
|
? segments
|
|
@@ -240,7 +979,47 @@ export class QaTasksService {
|
|
|
240
979
|
: doc.content
|
|
241
980
|
? doc.content.slice(0, 600)
|
|
242
981
|
: '';
|
|
243
|
-
|
|
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
|
+
}
|
|
244
1023
|
}
|
|
245
1024
|
return snippets.join('\n\n');
|
|
246
1025
|
}
|
|
@@ -262,18 +1041,863 @@ export class QaTasksService {
|
|
|
262
1041
|
});
|
|
263
1042
|
return resolved.agent;
|
|
264
1043
|
}
|
|
265
|
-
|
|
266
|
-
|
|
1044
|
+
ensureRatingService() {
|
|
1045
|
+
if (!this.ratingService) {
|
|
1046
|
+
if (!this.repo || !this.agentService || !this.routingService) {
|
|
1047
|
+
throw new Error('Agent rating requires routing, agent, and repository services.');
|
|
1048
|
+
}
|
|
1049
|
+
this.ratingService = new AgentRatingService(this.workspace, {
|
|
1050
|
+
workspaceRepo: this.deps.workspaceRepo,
|
|
1051
|
+
globalRepo: this.repo,
|
|
1052
|
+
agentService: this.agentService,
|
|
1053
|
+
routingService: this.routingService,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return this.ratingService;
|
|
1057
|
+
}
|
|
1058
|
+
resolveTaskComplexity(task) {
|
|
1059
|
+
const metadata = task.metadata ?? {};
|
|
1060
|
+
const metaComplexity = typeof metadata.complexity === 'number' && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
|
|
1061
|
+
const storyPoints = typeof task.storyPoints === 'number' && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
|
|
1062
|
+
const candidate = metaComplexity ?? storyPoints;
|
|
1063
|
+
if (!Number.isFinite(candidate ?? NaN))
|
|
1064
|
+
return undefined;
|
|
1065
|
+
return Math.min(10, Math.max(1, Math.round(candidate)));
|
|
1066
|
+
}
|
|
1067
|
+
estimateTokens(text) {
|
|
1068
|
+
return Math.max(1, Math.ceil((text?.length ?? 0) / 4));
|
|
1069
|
+
}
|
|
1070
|
+
async fileExists(absolutePath) {
|
|
1071
|
+
try {
|
|
1072
|
+
await fs.access(absolutePath);
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
catch {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async readPackageJson(root = this.workspace.workspaceRoot) {
|
|
1080
|
+
const pkgPath = path.join(root, 'package.json');
|
|
1081
|
+
try {
|
|
1082
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
1083
|
+
return JSON.parse(raw);
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
return undefined;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
async detectPackageManager(root = this.workspace.workspaceRoot) {
|
|
1090
|
+
if (await this.fileExists(path.join(root, 'pnpm-lock.yaml')))
|
|
1091
|
+
return 'pnpm';
|
|
1092
|
+
if (await this.fileExists(path.join(root, 'pnpm-workspace.yaml')))
|
|
1093
|
+
return 'pnpm';
|
|
1094
|
+
if (await this.fileExists(path.join(root, 'yarn.lock')))
|
|
1095
|
+
return 'yarn';
|
|
1096
|
+
if (await this.fileExists(path.join(root, 'package-lock.json')))
|
|
1097
|
+
return 'npm';
|
|
1098
|
+
if (await this.fileExists(path.join(root, 'npm-shrinkwrap.json')))
|
|
1099
|
+
return 'npm';
|
|
1100
|
+
if (await this.fileExists(path.join(root, 'package.json')))
|
|
1101
|
+
return 'npm';
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
async resolveTestCommand(profile, requestTestCommand, workspaceRoot = this.workspace.workspaceRoot) {
|
|
1105
|
+
if (requestTestCommand)
|
|
1106
|
+
return requestTestCommand;
|
|
1107
|
+
if ((profile.runner ?? 'cli') !== 'cli')
|
|
1108
|
+
return undefined;
|
|
1109
|
+
if (profile.test_command)
|
|
1110
|
+
return profile.test_command;
|
|
1111
|
+
if (await this.fileExists(path.join(workspaceRoot, 'tests', 'all.js'))) {
|
|
1112
|
+
return 'node tests/all.js';
|
|
1113
|
+
}
|
|
1114
|
+
const pkg = await this.readPackageJson(workspaceRoot);
|
|
1115
|
+
if (pkg?.scripts?.test) {
|
|
1116
|
+
const pm = (await this.detectPackageManager(workspaceRoot)) ?? 'npm';
|
|
1117
|
+
return `${pm} test`;
|
|
1118
|
+
}
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
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
|
+
}
|
|
1150
|
+
return undefined;
|
|
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');
|
|
1156
|
+
try {
|
|
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
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
// ignore
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (!binPath)
|
|
1169
|
+
return undefined;
|
|
1170
|
+
const normalized = path.isAbsolute(binPath) ? binPath : path.join(workspaceRoot, binPath);
|
|
1171
|
+
if (!(await this.fileExists(normalized)))
|
|
1172
|
+
return undefined;
|
|
1173
|
+
const relative = path.isAbsolute(binPath) ? path.relative(workspaceRoot, binPath) : binPath;
|
|
1174
|
+
return `node ${relative} --help`;
|
|
1175
|
+
}
|
|
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);
|
|
1188
|
+
};
|
|
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);
|
|
1205
|
+
}
|
|
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');
|
|
1218
|
+
}
|
|
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 };
|
|
267
1890
|
}
|
|
268
1891
|
extractJsonCandidate(raw) {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
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("}"))
|
|
274
1898
|
return undefined;
|
|
275
1899
|
try {
|
|
276
|
-
return JSON.parse(candidate
|
|
1900
|
+
return JSON.parse(candidate);
|
|
277
1901
|
}
|
|
278
1902
|
catch {
|
|
279
1903
|
return undefined;
|
|
@@ -282,34 +1906,132 @@ export class QaTasksService {
|
|
|
282
1906
|
normalizeAgentOutput(parsed) {
|
|
283
1907
|
if (!parsed || typeof parsed !== 'object')
|
|
284
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;
|
|
285
1919
|
const recommendation = parsed.recommendation;
|
|
286
1920
|
if (!recommendation || !['pass', 'fix_required', 'infra_issue', 'unclear'].includes(recommendation))
|
|
287
1921
|
return undefined;
|
|
288
|
-
const
|
|
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)
|
|
289
1925
|
? parsed.follow_up_tasks
|
|
290
1926
|
: Array.isArray(parsed.follow_ups)
|
|
291
1927
|
? parsed.follow_ups
|
|
292
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;
|
|
293
1946
|
const failures = Array.isArray(parsed.failures)
|
|
294
|
-
? parsed.failures.map((f) => ({
|
|
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
|
+
}))
|
|
295
1954
|
: undefined;
|
|
1955
|
+
const resolvedSlugs = normalizeSlugList(parsed.resolved_slugs ?? parsed.resolvedSlugs);
|
|
1956
|
+
const unresolvedSlugs = normalizeSlugList(parsed.unresolved_slugs ?? parsed.unresolvedSlugs);
|
|
296
1957
|
return {
|
|
297
1958
|
recommendation,
|
|
298
|
-
testedScope
|
|
299
|
-
coverageSummary
|
|
1959
|
+
testedScope,
|
|
1960
|
+
coverageSummary,
|
|
300
1961
|
failures,
|
|
301
1962
|
followUps,
|
|
1963
|
+
resolvedSlugs,
|
|
1964
|
+
unresolvedSlugs,
|
|
302
1965
|
};
|
|
303
1966
|
}
|
|
304
|
-
|
|
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) {
|
|
305
1991
|
if (!this.agentService) {
|
|
306
1992
|
return { recommendation: this.mapOutcome(result) };
|
|
307
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
|
+
});
|
|
2019
|
+
});
|
|
2020
|
+
};
|
|
308
2021
|
try {
|
|
2022
|
+
abortIfSignaled();
|
|
309
2023
|
const agent = await this.resolveAgent(agentName);
|
|
310
2024
|
const prompts = await this.loadPrompts(agent.id);
|
|
311
|
-
const
|
|
312
|
-
|
|
2025
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
2026
|
+
if (projectGuidance && taskRunId) {
|
|
2027
|
+
await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
|
|
2028
|
+
}
|
|
2029
|
+
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
2030
|
+
const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, QA_TEST_POLICY]
|
|
2031
|
+
.filter(Boolean)
|
|
2032
|
+
.join('\n\n');
|
|
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);
|
|
313
2035
|
const acceptance = (task.task.acceptanceCriteria ?? []).map((line) => `- ${line}`).join('\n');
|
|
314
2036
|
const prompt = [
|
|
315
2037
|
systemPrompt,
|
|
@@ -318,7 +2040,8 @@ export class QaTasksService {
|
|
|
318
2040
|
`Task type: ${task.task.type ?? 'n/a'}, status: ${task.task.status}`,
|
|
319
2041
|
task.task.description ? `Task description:\n${task.task.description}` : '',
|
|
320
2042
|
`Epic/Story: ${task.task.epicKey ?? task.task.epicId} / ${task.task.storyKey ?? task.task.userStoryId}`,
|
|
321
|
-
acceptance ? `
|
|
2043
|
+
acceptance ? `Task DoD / acceptance criteria:\n${acceptance}` : 'Task DoD / acceptance criteria: (not provided)',
|
|
2044
|
+
commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : 'Comment backlog: none',
|
|
322
2045
|
`QA profile: ${profile.name} (${profile.runner ?? 'cli'})`,
|
|
323
2046
|
`Test command / runner outcome: exit=${result.exitCode} outcome=${result.outcome}`,
|
|
324
2047
|
result.stdout ? `Stdout (truncated):\n${result.stdout.slice(0, 3000)}` : '',
|
|
@@ -328,13 +2051,15 @@ export class QaTasksService {
|
|
|
328
2051
|
[
|
|
329
2052
|
'Return strict JSON with keys:',
|
|
330
2053
|
'{',
|
|
331
|
-
' "tested_scope": string,',
|
|
332
|
-
' "coverage_summary": string,',
|
|
333
|
-
' "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 }],',
|
|
334
2057
|
' "recommendation": "pass|fix_required|infra_issue|unclear",',
|
|
335
|
-
' "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[] }]',
|
|
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[] }],',
|
|
2059
|
+
' "resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],',
|
|
2060
|
+
' "unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]',
|
|
336
2061
|
'}',
|
|
337
|
-
'Do not include prose outside the JSON.',
|
|
2062
|
+
'Do not include prose outside the JSON. No markdown fences or comments. Include resolvedSlugs/unresolvedSlugs when reviewing comment backlog.',
|
|
338
2063
|
].join('\n'),
|
|
339
2064
|
]
|
|
340
2065
|
.filter(Boolean)
|
|
@@ -355,14 +2080,19 @@ export class QaTasksService {
|
|
|
355
2080
|
let output = '';
|
|
356
2081
|
let chunkCount = 0;
|
|
357
2082
|
if (stream && this.agentService.invokeStream) {
|
|
358
|
-
const gen = await this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
359
|
-
|
|
2083
|
+
const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
|
|
2084
|
+
while (true) {
|
|
2085
|
+
abortIfSignaled();
|
|
2086
|
+
const { value, done } = await withAbort(gen.next());
|
|
2087
|
+
if (done)
|
|
2088
|
+
break;
|
|
2089
|
+
const chunk = value;
|
|
360
2090
|
output += chunk.output ?? '';
|
|
361
2091
|
chunkCount += 1;
|
|
362
2092
|
}
|
|
363
2093
|
}
|
|
364
2094
|
else {
|
|
365
|
-
const res = await this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } });
|
|
2095
|
+
const res = await withAbort(this.agentService.invoke(agent.id, { input: prompt, metadata: { command: 'qa-tasks' } }));
|
|
366
2096
|
output = res.output ?? '';
|
|
367
2097
|
}
|
|
368
2098
|
const tokensPrompt = this.estimateTokens(prompt);
|
|
@@ -383,6 +2113,8 @@ export class QaTasksService {
|
|
|
383
2113
|
metadata: {
|
|
384
2114
|
commandName: 'qa-tasks',
|
|
385
2115
|
action: 'qa-interpret-results',
|
|
2116
|
+
phase: 'qa-interpret',
|
|
2117
|
+
attempt: 1,
|
|
386
2118
|
taskKey: task.task.key,
|
|
387
2119
|
streaming: stream,
|
|
388
2120
|
streamChunks: chunkCount || undefined,
|
|
@@ -390,7 +2122,15 @@ export class QaTasksService {
|
|
|
390
2122
|
});
|
|
391
2123
|
}
|
|
392
2124
|
const parsed = this.extractJsonCandidate(output);
|
|
393
|
-
|
|
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
|
+
}
|
|
394
2134
|
if (normalized) {
|
|
395
2135
|
return {
|
|
396
2136
|
...normalized,
|
|
@@ -401,15 +2141,243 @@ export class QaTasksService {
|
|
|
401
2141
|
modelName: agent.defaultModel,
|
|
402
2142
|
};
|
|
403
2143
|
}
|
|
404
|
-
|
|
2144
|
+
const retryPrompt = `${prompt}\n\nReturn STRICT JSON only. Do not include prose, markdown fences, or comments.`;
|
|
2145
|
+
let retryOutput = "";
|
|
2146
|
+
if (stream && this.agentService.invokeStream) {
|
|
2147
|
+
const gen = await withAbort(this.agentService.invokeStream(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
|
|
2148
|
+
while (true) {
|
|
2149
|
+
abortIfSignaled();
|
|
2150
|
+
const { value, done } = await withAbort(gen.next());
|
|
2151
|
+
if (done)
|
|
2152
|
+
break;
|
|
2153
|
+
const chunk = value;
|
|
2154
|
+
retryOutput += chunk.output ?? '';
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
else {
|
|
2158
|
+
const res = await withAbort(this.agentService.invoke(agent.id, { input: retryPrompt, metadata: { command: 'qa-tasks' } }));
|
|
2159
|
+
retryOutput = res.output ?? '';
|
|
2160
|
+
}
|
|
2161
|
+
const retryTokensPrompt = this.estimateTokens(retryPrompt);
|
|
2162
|
+
const retryTokensCompletion = this.estimateTokens(retryOutput);
|
|
2163
|
+
if (!this.dryRunGuard) {
|
|
2164
|
+
await this.jobService.recordTokenUsage({
|
|
2165
|
+
workspaceId: this.workspace.workspaceId,
|
|
2166
|
+
agentId: agent.id,
|
|
2167
|
+
modelName: agent.defaultModel,
|
|
2168
|
+
jobId,
|
|
2169
|
+
taskId: task.task.id,
|
|
2170
|
+
commandRunId,
|
|
2171
|
+
taskRunId,
|
|
2172
|
+
tokensPrompt: retryTokensPrompt,
|
|
2173
|
+
tokensCompletion: retryTokensCompletion,
|
|
2174
|
+
tokensTotal: retryTokensPrompt + retryTokensCompletion,
|
|
2175
|
+
timestamp: new Date().toISOString(),
|
|
2176
|
+
metadata: {
|
|
2177
|
+
commandName: 'qa-tasks',
|
|
2178
|
+
action: 'qa-interpret-retry',
|
|
2179
|
+
phase: 'qa-interpret-retry',
|
|
2180
|
+
attempt: 2,
|
|
2181
|
+
taskKey: task.task.key,
|
|
2182
|
+
},
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
const retryParsed = this.extractJsonCandidate(retryOutput);
|
|
2186
|
+
let retryNormalized = this.normalizeAgentOutput(retryParsed);
|
|
2187
|
+
validationError = retryNormalized ? this.validateInterpretation(retryNormalized, { requireCommentSlugs }) : undefined;
|
|
2188
|
+
if (retryNormalized && validationError) {
|
|
2189
|
+
retryNormalized = undefined;
|
|
2190
|
+
}
|
|
2191
|
+
if (retryNormalized) {
|
|
2192
|
+
return {
|
|
2193
|
+
...retryNormalized,
|
|
2194
|
+
rawOutput: retryOutput,
|
|
2195
|
+
tokensPrompt: tokensPrompt + retryTokensPrompt,
|
|
2196
|
+
tokensCompletion: tokensCompletion + retryTokensCompletion,
|
|
2197
|
+
agentId: agent.id,
|
|
2198
|
+
modelName: agent.defaultModel,
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
if (taskRunId) {
|
|
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");
|
|
2206
|
+
}
|
|
2207
|
+
return {
|
|
2208
|
+
recommendation: 'unclear',
|
|
2209
|
+
rawOutput: retryOutput || output,
|
|
2210
|
+
tokensPrompt: tokensPrompt + retryTokensPrompt,
|
|
2211
|
+
tokensCompletion: tokensCompletion + retryTokensCompletion,
|
|
2212
|
+
agentId: agent.id,
|
|
2213
|
+
modelName: agent.defaultModel,
|
|
2214
|
+
invalidJson: true,
|
|
2215
|
+
};
|
|
405
2216
|
}
|
|
406
2217
|
catch (error) {
|
|
2218
|
+
const message = error?.message ?? String(error);
|
|
407
2219
|
if (taskRunId) {
|
|
408
|
-
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;
|
|
409
2224
|
}
|
|
410
2225
|
return { recommendation: this.mapOutcome(result) };
|
|
411
2226
|
}
|
|
412
2227
|
}
|
|
2228
|
+
async loadCommentContext(taskId) {
|
|
2229
|
+
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
2230
|
+
sourceCommands: ['code-review', 'qa-tasks'],
|
|
2231
|
+
limit: 50,
|
|
2232
|
+
});
|
|
2233
|
+
const unresolved = comments.filter((comment) => !comment.resolvedAt);
|
|
2234
|
+
return { comments, unresolved };
|
|
2235
|
+
}
|
|
2236
|
+
resolveFailureSlug(failure) {
|
|
2237
|
+
const message = (failure.message ?? '').trim() || 'QA issue';
|
|
2238
|
+
return createTaskCommentSlug({
|
|
2239
|
+
source: 'qa-tasks',
|
|
2240
|
+
message,
|
|
2241
|
+
category: failure.kind ?? 'qa_issue',
|
|
2242
|
+
file: failure.file,
|
|
2243
|
+
line: failure.line,
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
async applyCommentResolutions(params) {
|
|
2247
|
+
const existingBySlug = new Map();
|
|
2248
|
+
const openBySlug = new Set();
|
|
2249
|
+
const resolvedBySlug = new Set();
|
|
2250
|
+
for (const comment of params.existingComments) {
|
|
2251
|
+
if (!comment.slug)
|
|
2252
|
+
continue;
|
|
2253
|
+
if (!existingBySlug.has(comment.slug)) {
|
|
2254
|
+
existingBySlug.set(comment.slug, comment);
|
|
2255
|
+
}
|
|
2256
|
+
if (comment.resolvedAt) {
|
|
2257
|
+
resolvedBySlug.add(comment.slug);
|
|
2258
|
+
}
|
|
2259
|
+
else {
|
|
2260
|
+
openBySlug.add(comment.slug);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
|
|
2264
|
+
const resolvedSet = new Set(resolvedSlugs);
|
|
2265
|
+
const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
|
|
2266
|
+
const failureSlugs = [];
|
|
2267
|
+
for (const failure of params.failures ?? []) {
|
|
2268
|
+
const slug = this.resolveFailureSlug(failure);
|
|
2269
|
+
failureSlugs.push(slug);
|
|
2270
|
+
if (!resolvedSet.has(slug)) {
|
|
2271
|
+
unresolvedSet.add(slug);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
for (const slug of resolvedSet) {
|
|
2275
|
+
unresolvedSet.delete(slug);
|
|
2276
|
+
}
|
|
2277
|
+
const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
|
|
2278
|
+
const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
|
|
2279
|
+
if (!this.dryRunGuard) {
|
|
2280
|
+
for (const slug of toResolve) {
|
|
2281
|
+
await this.deps.workspaceRepo.resolveTaskComment({
|
|
2282
|
+
taskId: params.task.id,
|
|
2283
|
+
slug,
|
|
2284
|
+
resolvedAt: new Date().toISOString(),
|
|
2285
|
+
resolvedBy: params.agentId ?? null,
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
for (const slug of toReopen) {
|
|
2289
|
+
await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const createdSlugs = new Set();
|
|
2293
|
+
for (const failure of params.failures ?? []) {
|
|
2294
|
+
const slug = this.resolveFailureSlug(failure);
|
|
2295
|
+
if (existingBySlug.has(slug) || createdSlugs.has(slug))
|
|
2296
|
+
continue;
|
|
2297
|
+
const baseMessage = (failure.message ?? '').trim() || '(no details provided)';
|
|
2298
|
+
const message = failure.evidence ? `${baseMessage}\nEvidence: ${failure.evidence}` : baseMessage;
|
|
2299
|
+
const body = formatTaskCommentBody({
|
|
2300
|
+
slug,
|
|
2301
|
+
source: 'qa-tasks',
|
|
2302
|
+
message,
|
|
2303
|
+
status: 'open',
|
|
2304
|
+
category: failure.kind ?? 'qa_issue',
|
|
2305
|
+
file: failure.file ?? null,
|
|
2306
|
+
line: failure.line ?? null,
|
|
2307
|
+
});
|
|
2308
|
+
if (!this.dryRunGuard) {
|
|
2309
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2310
|
+
taskId: params.task.id,
|
|
2311
|
+
taskRunId: params.taskRunId,
|
|
2312
|
+
jobId: params.jobId,
|
|
2313
|
+
sourceCommand: 'qa-tasks',
|
|
2314
|
+
authorType: 'agent',
|
|
2315
|
+
authorAgentId: params.agentId ?? null,
|
|
2316
|
+
category: failure.kind ?? 'qa_issue',
|
|
2317
|
+
slug,
|
|
2318
|
+
status: 'open',
|
|
2319
|
+
file: failure.file ?? null,
|
|
2320
|
+
line: failure.line ?? null,
|
|
2321
|
+
pathHint: failure.file ?? null,
|
|
2322
|
+
body,
|
|
2323
|
+
createdAt: new Date().toISOString(),
|
|
2324
|
+
metadata: {
|
|
2325
|
+
kind: failure.kind,
|
|
2326
|
+
evidence: failure.evidence,
|
|
2327
|
+
},
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
createdSlugs.add(slug);
|
|
2331
|
+
}
|
|
2332
|
+
const openSet = new Set(openBySlug);
|
|
2333
|
+
for (const slug of unresolvedSet) {
|
|
2334
|
+
openSet.add(slug);
|
|
2335
|
+
}
|
|
2336
|
+
for (const slug of resolvedSet) {
|
|
2337
|
+
openSet.delete(slug);
|
|
2338
|
+
}
|
|
2339
|
+
if ((resolvedSlugs.length || toReopen.length || unresolvedSet.size) && !this.dryRunGuard) {
|
|
2340
|
+
const resolutionMessage = [
|
|
2341
|
+
`Resolved slugs: ${formatSlugList(toResolve)}`,
|
|
2342
|
+
`Reopened slugs: ${formatSlugList(toReopen)}`,
|
|
2343
|
+
`Open slugs: ${formatSlugList(Array.from(openSet))}`,
|
|
2344
|
+
].join('\n');
|
|
2345
|
+
const resolutionSlug = createTaskCommentSlug({
|
|
2346
|
+
source: 'qa-tasks',
|
|
2347
|
+
message: resolutionMessage,
|
|
2348
|
+
category: 'comment_resolution',
|
|
2349
|
+
});
|
|
2350
|
+
const resolutionBody = formatTaskCommentBody({
|
|
2351
|
+
slug: resolutionSlug,
|
|
2352
|
+
source: 'qa-tasks',
|
|
2353
|
+
message: resolutionMessage,
|
|
2354
|
+
status: 'resolved',
|
|
2355
|
+
category: 'comment_resolution',
|
|
2356
|
+
});
|
|
2357
|
+
const createdAt = new Date().toISOString();
|
|
2358
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2359
|
+
taskId: params.task.id,
|
|
2360
|
+
taskRunId: params.taskRunId,
|
|
2361
|
+
jobId: params.jobId,
|
|
2362
|
+
sourceCommand: 'qa-tasks',
|
|
2363
|
+
authorType: 'agent',
|
|
2364
|
+
authorAgentId: params.agentId ?? null,
|
|
2365
|
+
category: 'comment_resolution',
|
|
2366
|
+
slug: resolutionSlug,
|
|
2367
|
+
status: 'resolved',
|
|
2368
|
+
body: resolutionBody,
|
|
2369
|
+
createdAt,
|
|
2370
|
+
resolvedAt: createdAt,
|
|
2371
|
+
resolvedBy: params.agentId ?? null,
|
|
2372
|
+
metadata: {
|
|
2373
|
+
resolvedSlugs: toResolve,
|
|
2374
|
+
reopenedSlugs: toReopen,
|
|
2375
|
+
openSlugs: Array.from(openSet),
|
|
2376
|
+
},
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
|
|
2380
|
+
}
|
|
413
2381
|
async createTaskRun(task, jobId, commandRunId) {
|
|
414
2382
|
const startedAt = new Date().toISOString();
|
|
415
2383
|
return this.deps.workspaceRepo.createTaskRun({
|
|
@@ -445,16 +2413,48 @@ export class QaTasksService {
|
|
|
445
2413
|
details: details ?? undefined,
|
|
446
2414
|
});
|
|
447
2415
|
}
|
|
448
|
-
async
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
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}`;
|
|
452
2451
|
}
|
|
453
|
-
|
|
454
|
-
|
|
2452
|
+
const mergedMetadata = metadataPatch ? { ...baseMetadata, ...metadataPatch } : baseMetadata;
|
|
2453
|
+
if (outcome === 'pass') {
|
|
2454
|
+
await this.stateService.markCompleted(task, mergedMetadata, context);
|
|
455
2455
|
}
|
|
456
|
-
else
|
|
457
|
-
await this.stateService.
|
|
2456
|
+
else {
|
|
2457
|
+
await this.stateService.markNotStarted(task, mergedMetadata, context);
|
|
458
2458
|
}
|
|
459
2459
|
}
|
|
460
2460
|
buildFollowupSuggestion(task, result, notes) {
|
|
@@ -474,6 +2474,38 @@ export class QaTasksService {
|
|
|
474
2474
|
testName: tests[0],
|
|
475
2475
|
};
|
|
476
2476
|
}
|
|
2477
|
+
buildManualQaFollowup(task, rawOutput) {
|
|
2478
|
+
const summary = rawOutput ? rawOutput.slice(0, 1000) : 'QA agent returned invalid JSON after retry.';
|
|
2479
|
+
const components = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
|
|
2480
|
+
const docLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
|
|
2481
|
+
const tests = Array.isArray(task.metadata?.tests) ? task.metadata.tests : [];
|
|
2482
|
+
return {
|
|
2483
|
+
title: `Manual QA follow-up for ${task.key}`,
|
|
2484
|
+
description: `QA agent returned invalid JSON after retry. Manual QA required.\n\nRaw output:\n${summary}`.slice(0, 2000),
|
|
2485
|
+
type: 'qa_followup',
|
|
2486
|
+
storyPoints: 1,
|
|
2487
|
+
priority: 90,
|
|
2488
|
+
tags: ['qa', 'manual', ...components],
|
|
2489
|
+
components,
|
|
2490
|
+
docLinks,
|
|
2491
|
+
testName: tests[0],
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
buildFollowupSlug(task, suggestion) {
|
|
2495
|
+
const seedParts = [
|
|
2496
|
+
task.key,
|
|
2497
|
+
suggestion.title ?? '',
|
|
2498
|
+
suggestion.description ?? '',
|
|
2499
|
+
suggestion.type ?? '',
|
|
2500
|
+
suggestion.testName ?? '',
|
|
2501
|
+
suggestion.evidenceUrl ?? '',
|
|
2502
|
+
...(suggestion.tags ?? []),
|
|
2503
|
+
...(suggestion.components ?? []),
|
|
2504
|
+
];
|
|
2505
|
+
const seed = seedParts.join('|').toLowerCase();
|
|
2506
|
+
const digest = createHash('sha1').update(seed).digest('hex').slice(0, 12);
|
|
2507
|
+
return `qa-followup-${task.key}-${digest}`;
|
|
2508
|
+
}
|
|
477
2509
|
toFollowupSuggestion(task, agentFollow, artifacts) {
|
|
478
2510
|
const taskComponents = Array.isArray(task.metadata?.components) ? task.metadata.components : [];
|
|
479
2511
|
const taskDocLinks = Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : [];
|
|
@@ -498,8 +2530,14 @@ export class QaTasksService {
|
|
|
498
2530
|
return [];
|
|
499
2531
|
const agent = await this.resolveAgent(undefined);
|
|
500
2532
|
const prompts = await this.loadPrompts(agent.id);
|
|
501
|
-
const
|
|
502
|
-
|
|
2533
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
2534
|
+
if (projectGuidance && taskRunId) {
|
|
2535
|
+
await this.logTask(taskRunId, `Loaded project guidance from ${projectGuidance.source}`, 'project_guidance');
|
|
2536
|
+
}
|
|
2537
|
+
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
2538
|
+
const systemPrompt = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt].filter(Boolean).join('\n\n');
|
|
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);
|
|
503
2541
|
const prompt = [
|
|
504
2542
|
systemPrompt,
|
|
505
2543
|
'You are the mcoda QA agent. Given QA notes/evidence, propose structured follow-up tasks as JSON.',
|
|
@@ -531,7 +2569,11 @@ export class QaTasksService {
|
|
|
531
2569
|
output = res.output ?? '';
|
|
532
2570
|
}
|
|
533
2571
|
}
|
|
534
|
-
catch {
|
|
2572
|
+
catch (error) {
|
|
2573
|
+
const message = error?.message ?? String(error);
|
|
2574
|
+
if (isAuthErrorMessage(message)) {
|
|
2575
|
+
throw error;
|
|
2576
|
+
}
|
|
535
2577
|
return [];
|
|
536
2578
|
}
|
|
537
2579
|
const tokensPrompt = this.estimateTokens(prompt);
|
|
@@ -568,6 +2610,12 @@ export class QaTasksService {
|
|
|
568
2610
|
}
|
|
569
2611
|
async runAuto(task, ctx) {
|
|
570
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
|
+
};
|
|
571
2619
|
await this.logTask(taskRun.id, 'Starting QA', 'qa-start');
|
|
572
2620
|
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
573
2621
|
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
@@ -588,252 +2636,1043 @@ export class QaTasksService {
|
|
|
588
2636
|
runner: undefined,
|
|
589
2637
|
metadata: { reason: 'status_gating' },
|
|
590
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
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
2654
|
+
}
|
|
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);
|
|
2700
|
+
if (!branchCheck.ok) {
|
|
2701
|
+
if (!this.dryRunGuard) {
|
|
2702
|
+
await this.applyStateTransition(task.task, 'infra_issue', statusContextBase);
|
|
2703
|
+
await this.finishTaskRun(taskRun, 'failed');
|
|
2704
|
+
await this.deps.workspaceRepo.createTaskQaRun({
|
|
2705
|
+
taskId: task.task.id,
|
|
2706
|
+
taskRunId: taskRun.id,
|
|
2707
|
+
jobId: ctx.jobId,
|
|
2708
|
+
commandRunId: ctx.commandRunId,
|
|
2709
|
+
source: 'auto',
|
|
2710
|
+
mode: 'auto',
|
|
2711
|
+
rawOutcome: 'infra_issue',
|
|
2712
|
+
recommendation: 'infra_issue',
|
|
2713
|
+
metadata: { reason: 'vcs_branch_missing', detail: branchCheck.message },
|
|
2714
|
+
});
|
|
2715
|
+
const message = `VCS validation failed: ${branchCheck.message ?? 'unknown error'}`;
|
|
2716
|
+
const slug = createTaskCommentSlug({ source: 'qa-tasks', message, category: 'qa_issue' });
|
|
2717
|
+
const body = formatTaskCommentBody({
|
|
2718
|
+
slug,
|
|
2719
|
+
source: 'qa-tasks',
|
|
2720
|
+
message,
|
|
2721
|
+
status: 'open',
|
|
2722
|
+
category: 'qa_issue',
|
|
2723
|
+
});
|
|
2724
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2725
|
+
taskId: task.task.id,
|
|
2726
|
+
taskRunId: taskRun.id,
|
|
2727
|
+
jobId: ctx.jobId,
|
|
2728
|
+
sourceCommand: 'qa-tasks',
|
|
2729
|
+
authorType: 'agent',
|
|
2730
|
+
category: 'qa_issue',
|
|
2731
|
+
slug,
|
|
2732
|
+
status: 'open',
|
|
2733
|
+
body,
|
|
2734
|
+
createdAt: new Date().toISOString(),
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
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}` : ''}`);
|
|
591
3238
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
}
|
|
3252
|
+
}
|
|
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,
|
|
619
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
|
+
}
|
|
620
3321
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|
|
628
3376
|
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
recommendation: 'infra_issue',
|
|
643
|
-
metadata: { reason: 'profile_resolution_failed', message: error?.message ?? String(error) },
|
|
644
|
-
});
|
|
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
|
+
}
|
|
645
3390
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
taskId: task.task.id,
|
|
654
|
-
taskRunId: taskRun.id,
|
|
655
|
-
jobId: ctx.jobId,
|
|
656
|
-
commandRunId: ctx.commandRunId,
|
|
657
|
-
source: 'auto',
|
|
658
|
-
mode: 'auto',
|
|
659
|
-
rawOutcome: 'infra_issue',
|
|
660
|
-
recommendation: 'infra_issue',
|
|
661
|
-
metadata: { reason: 'no_profile' },
|
|
662
|
-
});
|
|
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;
|
|
663
3398
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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;
|
|
670
3413
|
if (!this.dryRunGuard) {
|
|
671
|
-
await this.deps.workspaceRepo.createTaskQaRun({
|
|
3414
|
+
qaRun = await this.deps.workspaceRepo.createTaskQaRun({
|
|
672
3415
|
taskId: task.task.id,
|
|
673
3416
|
taskRunId: taskRun.id,
|
|
674
3417
|
jobId: ctx.jobId,
|
|
675
3418
|
commandRunId: ctx.commandRunId,
|
|
3419
|
+
agentId: interpretation.agentId,
|
|
3420
|
+
modelName: interpretation.modelName,
|
|
676
3421
|
source: 'auto',
|
|
677
3422
|
mode: 'auto',
|
|
678
3423
|
profileName: profile.name,
|
|
679
3424
|
runner: profile.runner,
|
|
680
|
-
rawOutcome:
|
|
681
|
-
recommendation:
|
|
682
|
-
|
|
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
|
+
},
|
|
683
3452
|
});
|
|
684
3453
|
}
|
|
685
|
-
return { taskKey: task.task.key, outcome: 'infra_issue', profile: profile.name, runner: profile.runner, notes: 'no_adapter' };
|
|
686
|
-
}
|
|
687
|
-
const qaCtx = {
|
|
688
|
-
workspaceRoot: this.workspace.workspaceRoot,
|
|
689
|
-
jobId: ctx.jobId,
|
|
690
|
-
taskKey: task.task.key,
|
|
691
|
-
env: process.env,
|
|
692
|
-
testCommandOverride: ctx.request.testCommand,
|
|
693
|
-
};
|
|
694
|
-
const ensure = await adapter.ensureInstalled(profile, qaCtx);
|
|
695
|
-
if (!ensure.ok) {
|
|
696
|
-
await this.logTask(taskRun.id, ensure.message ?? 'QA install failed', 'qa-install');
|
|
697
3454
|
if (!this.dryRunGuard) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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,
|
|
3579
|
+
});
|
|
3580
|
+
const status = outcome === 'pass' ? 'resolved' : 'open';
|
|
3581
|
+
const summaryBody = formatTaskCommentBody({
|
|
3582
|
+
slug: summarySlug,
|
|
3583
|
+
source: 'qa-tasks',
|
|
3584
|
+
message: summaryMessage,
|
|
3585
|
+
status,
|
|
3586
|
+
category,
|
|
3587
|
+
});
|
|
3588
|
+
const createdAt = new Date().toISOString();
|
|
3589
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
701
3590
|
taskId: task.task.id,
|
|
702
3591
|
taskRunId: taskRun.id,
|
|
703
3592
|
jobId: ctx.jobId,
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
3593
|
+
sourceCommand: 'qa-tasks',
|
|
3594
|
+
authorType: 'agent',
|
|
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
|
+
},
|
|
712
3607
|
});
|
|
713
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
|
+
}
|
|
714
3636
|
return {
|
|
715
3637
|
taskKey: task.task.key,
|
|
716
|
-
outcome
|
|
3638
|
+
outcome,
|
|
717
3639
|
profile: profile.name,
|
|
718
3640
|
runner: profile.runner,
|
|
719
|
-
notes: ensure.message,
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
const artifactDir = path.join(this.workspace.workspaceRoot, '.mcoda', 'jobs', ctx.jobId, 'qa', task.task.key);
|
|
723
|
-
await PathHelper.ensureDir(artifactDir);
|
|
724
|
-
const result = await adapter.invoke(profile, { ...qaCtx, artifactDir });
|
|
725
|
-
await this.logTask(taskRun.id, `QA run completed with outcome ${result.outcome}`, 'qa-exec', {
|
|
726
|
-
exitCode: result.exitCode,
|
|
727
|
-
});
|
|
728
|
-
const interpretation = await this.interpretResult(task, profile, result, ctx.request.agentName, ctx.request.agentStream ?? true, ctx.jobId, ctx.commandRunId, taskRun.id);
|
|
729
|
-
const outcome = this.combineOutcome(result, interpretation.recommendation);
|
|
730
|
-
const artifacts = result.artifacts ?? [];
|
|
731
|
-
let qaRun;
|
|
732
|
-
if (!this.dryRunGuard) {
|
|
733
|
-
qaRun = await this.deps.workspaceRepo.createTaskQaRun({
|
|
734
|
-
taskId: task.task.id,
|
|
735
|
-
taskRunId: taskRun.id,
|
|
736
|
-
jobId: ctx.jobId,
|
|
737
|
-
commandRunId: ctx.commandRunId,
|
|
738
|
-
agentId: interpretation.agentId,
|
|
739
|
-
modelName: interpretation.modelName,
|
|
740
|
-
source: 'auto',
|
|
741
|
-
mode: 'auto',
|
|
742
|
-
profileName: profile.name,
|
|
743
|
-
runner: profile.runner,
|
|
744
|
-
rawOutcome: result.outcome,
|
|
745
|
-
recommendation: interpretation.recommendation,
|
|
746
3641
|
artifacts,
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
agent: interpretation.rawOutput,
|
|
750
|
-
},
|
|
751
|
-
startedAt: result.startedAt,
|
|
752
|
-
finishedAt: result.finishedAt,
|
|
753
|
-
metadata: {
|
|
754
|
-
tokensPrompt: interpretation.tokensPrompt,
|
|
755
|
-
tokensCompletion: interpretation.tokensCompletion,
|
|
756
|
-
testedScope: interpretation.testedScope,
|
|
757
|
-
coverageSummary: interpretation.coverageSummary,
|
|
758
|
-
failures: interpretation.failures,
|
|
759
|
-
},
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
if (!this.dryRunGuard) {
|
|
763
|
-
await this.applyStateTransition(task.task, outcome);
|
|
764
|
-
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
3642
|
+
followups,
|
|
3643
|
+
};
|
|
765
3644
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
772
|
-
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
773
|
-
for (const suggestion of suggestions) {
|
|
774
|
-
let proceed = ctx.request.createFollowupTasks !== 'prompt';
|
|
775
|
-
if (interactive) {
|
|
776
|
-
const rl = readline.createInterface({ input, output });
|
|
777
|
-
const answer = await rl.question(`Create follow-up task "${suggestion.title}" for ${task.task.key}? [y/N]: `);
|
|
778
|
-
rl.close();
|
|
779
|
-
proceed = answer.trim().toLowerCase().startsWith('y');
|
|
3645
|
+
finally {
|
|
3646
|
+
if (serverHandle) {
|
|
3647
|
+
try {
|
|
3648
|
+
await serverHandle.stop();
|
|
3649
|
+
await this.logTask(taskRun.id, 'QA server stopped.', 'qa-server');
|
|
780
3650
|
}
|
|
781
|
-
|
|
782
|
-
|
|
3651
|
+
catch (error) {
|
|
3652
|
+
await this.logTask(taskRun.id, `QA server shutdown failed: ${error?.message ?? error}`, 'qa-server');
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
if (cleanupWorktree) {
|
|
783
3656
|
try {
|
|
784
|
-
|
|
785
|
-
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
|
|
786
|
-
followups.push(created.task.key);
|
|
787
|
-
await this.logTask(taskRun.id, `Created follow-up ${created.task.key}`, 'qa-followup');
|
|
788
|
-
}
|
|
3657
|
+
await cleanupWorktree();
|
|
789
3658
|
}
|
|
790
3659
|
catch (error) {
|
|
791
|
-
await this.logTask(taskRun.id, `
|
|
3660
|
+
await this.logTask(taskRun.id, `QA cleanup failed: ${error?.message ?? error}`, 'qa-cleanup');
|
|
792
3661
|
}
|
|
793
3662
|
}
|
|
794
3663
|
}
|
|
795
|
-
const bodyLines = [
|
|
796
|
-
`QA outcome: ${outcome}`,
|
|
797
|
-
profile ? `Profile: ${profile.name} (${profile.runner ?? 'cli'})` : '',
|
|
798
|
-
interpretation.coverageSummary ? `Coverage: ${interpretation.coverageSummary}` : '',
|
|
799
|
-
interpretation.failures && interpretation.failures.length
|
|
800
|
-
? `Failures:\n${interpretation.failures.map((f) => `- [${f.kind ?? 'issue'}] ${f.message}${f.evidence ? ` (${f.evidence})` : ''}`).join('\n')}`
|
|
801
|
-
: '',
|
|
802
|
-
result.stdout ? `Stdout:\n${result.stdout.slice(0, 4000)}` : '',
|
|
803
|
-
result.stderr ? `Stderr:\n${result.stderr.slice(0, 4000)}` : '',
|
|
804
|
-
artifacts.length ? `Artifacts:\n${artifacts.join('\n')}` : '',
|
|
805
|
-
followups.length ? `Follow-ups: ${followups.join(', ')}` : '',
|
|
806
|
-
].filter(Boolean);
|
|
807
|
-
if (!this.dryRunGuard) {
|
|
808
|
-
await this.deps.workspaceRepo.createTaskComment({
|
|
809
|
-
taskId: task.task.id,
|
|
810
|
-
taskRunId: taskRun.id,
|
|
811
|
-
jobId: ctx.jobId,
|
|
812
|
-
sourceCommand: 'qa-tasks',
|
|
813
|
-
authorType: 'agent',
|
|
814
|
-
category: outcome === 'pass' ? 'qa_result' : 'qa_issue',
|
|
815
|
-
body: bodyLines.join('\n\n'),
|
|
816
|
-
createdAt: new Date().toISOString(),
|
|
817
|
-
metadata: {
|
|
818
|
-
...(artifacts.length ? { artifacts } : {}),
|
|
819
|
-
...(qaRun?.id ? { qaRunId: qaRun.id } : {}),
|
|
820
|
-
},
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
return {
|
|
824
|
-
taskKey: task.task.key,
|
|
825
|
-
outcome,
|
|
826
|
-
profile: profile.name,
|
|
827
|
-
runner: profile.runner,
|
|
828
|
-
artifacts,
|
|
829
|
-
followups,
|
|
830
|
-
};
|
|
831
3664
|
}
|
|
832
3665
|
async runManual(task, ctx) {
|
|
833
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
|
+
};
|
|
834
3673
|
const result = ctx.request.result ?? 'pass';
|
|
835
3674
|
const notes = ctx.request.notes;
|
|
836
|
-
const outcome = result === 'pass' ? 'pass' :
|
|
3675
|
+
const outcome = result === 'pass' ? 'pass' : 'fix_required';
|
|
837
3676
|
const allowedStatuses = new Set(ctx.request.statusFilter ?? ['ready_to_qa']);
|
|
838
3677
|
if (task.task.status && !allowedStatuses.has(task.task.status)) {
|
|
839
3678
|
const message = `Task status ${task.task.status} not allowed for manual QA`;
|
|
@@ -842,7 +3681,7 @@ export class QaTasksService {
|
|
|
842
3681
|
return { taskKey: task.task.key, outcome: 'infra_issue', notes: 'status_gating' };
|
|
843
3682
|
}
|
|
844
3683
|
if (!ctx.request.dryRun) {
|
|
845
|
-
await this.applyStateTransition(task.task, outcome);
|
|
3684
|
+
await this.applyStateTransition(task.task, outcome, statusContext);
|
|
846
3685
|
await this.finishTaskRun(taskRun, outcome === 'pass' ? 'succeeded' : 'failed');
|
|
847
3686
|
}
|
|
848
3687
|
const followups = [];
|
|
@@ -875,12 +3714,20 @@ export class QaTasksService {
|
|
|
875
3714
|
evidenceUrl: ctx.request.evidenceUrl,
|
|
876
3715
|
},
|
|
877
3716
|
];
|
|
878
|
-
const agentSuggestions =
|
|
3717
|
+
const agentSuggestions = this.shouldUseAgentInterpretation()
|
|
3718
|
+
? await this.suggestFollowupsFromAgent(task, notes, ctx.request.evidenceUrl, 'manual', ctx.jobId, ctx.commandRunId, taskRun.id)
|
|
3719
|
+
: [];
|
|
879
3720
|
if (agentSuggestions.length) {
|
|
880
3721
|
suggestions.unshift(...agentSuggestions);
|
|
881
3722
|
}
|
|
882
3723
|
const interactive = ctx.request.createFollowupTasks === 'prompt' && process.stdout.isTTY;
|
|
883
3724
|
for (const suggestion of suggestions) {
|
|
3725
|
+
const followupSlug = this.buildFollowupSlug(task.task, suggestion);
|
|
3726
|
+
const existing = await this.deps.workspaceRepo.listTasksByMetadataValue(task.task.projectId, 'qa_followup_slug', followupSlug);
|
|
3727
|
+
if (existing.length) {
|
|
3728
|
+
await this.logTask(taskRun.id, `Skipped follow-up ${followupSlug}; already exists: ${existing.map((item) => item.key).join(', ')}`, 'qa-followup');
|
|
3729
|
+
continue;
|
|
3730
|
+
}
|
|
884
3731
|
let proceed = ctx.request.createFollowupTasks === 'auto' || ctx.request.createFollowupTasks === undefined;
|
|
885
3732
|
if (interactive) {
|
|
886
3733
|
const rl = readline.createInterface({ input, output });
|
|
@@ -891,7 +3738,7 @@ export class QaTasksService {
|
|
|
891
3738
|
if (!proceed)
|
|
892
3739
|
continue;
|
|
893
3740
|
try {
|
|
894
|
-
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, suggestion);
|
|
3741
|
+
const created = await this.followupService.createFollowupTask({ ...task.task, storyKey: task.task.storyKey, epicKey: task.task.epicKey }, { ...suggestion, followupSlug });
|
|
895
3742
|
followups.push(created.task.key);
|
|
896
3743
|
}
|
|
897
3744
|
catch (error) {
|
|
@@ -909,15 +3756,30 @@ export class QaTasksService {
|
|
|
909
3756
|
.filter(Boolean)
|
|
910
3757
|
.join('\n');
|
|
911
3758
|
if (!ctx.request.dryRun) {
|
|
3759
|
+
const category = result === 'pass' ? 'qa_result' : 'qa_issue';
|
|
3760
|
+
const status = result === 'pass' ? 'resolved' : 'open';
|
|
3761
|
+
const slug = createTaskCommentSlug({ source: 'qa-tasks', message: body, category });
|
|
3762
|
+
const formattedBody = formatTaskCommentBody({
|
|
3763
|
+
slug,
|
|
3764
|
+
source: 'qa-tasks',
|
|
3765
|
+
message: body,
|
|
3766
|
+
status,
|
|
3767
|
+
category,
|
|
3768
|
+
});
|
|
3769
|
+
const createdAt = new Date().toISOString();
|
|
912
3770
|
await this.deps.workspaceRepo.createTaskComment({
|
|
913
3771
|
taskId: task.task.id,
|
|
914
3772
|
taskRunId: taskRun.id,
|
|
915
3773
|
jobId: ctx.jobId,
|
|
916
3774
|
sourceCommand: 'qa-tasks',
|
|
917
3775
|
authorType: 'human',
|
|
918
|
-
category
|
|
919
|
-
|
|
920
|
-
|
|
3776
|
+
category,
|
|
3777
|
+
slug,
|
|
3778
|
+
status,
|
|
3779
|
+
body: formattedBody,
|
|
3780
|
+
createdAt,
|
|
3781
|
+
resolvedAt: status === 'resolved' ? createdAt : null,
|
|
3782
|
+
resolvedBy: status === 'resolved' ? 'human' : null,
|
|
921
3783
|
metadata: {
|
|
922
3784
|
...(ctx.request.evidenceUrl ? { evidence: ctx.request.evidenceUrl } : {}),
|
|
923
3785
|
...(artifacts.length ? { artifacts } : {}),
|
|
@@ -933,6 +3795,8 @@ export class QaTasksService {
|
|
|
933
3795
|
};
|
|
934
3796
|
}
|
|
935
3797
|
async run(request) {
|
|
3798
|
+
this.qaProfilePlan = undefined;
|
|
3799
|
+
this.qaTaskPlans = undefined;
|
|
936
3800
|
const resume = request.resumeJobId ? await this.deps.jobService.getJob(request.resumeJobId) : undefined;
|
|
937
3801
|
if (request.resumeJobId && !resume) {
|
|
938
3802
|
throw new Error(`Resume requested but job ${request.resumeJobId} not found`);
|
|
@@ -942,33 +3806,67 @@ export class QaTasksService {
|
|
|
942
3806
|
const effectiveStory = request.storyKey ?? resume?.payload?.storyKey;
|
|
943
3807
|
const effectiveTasks = request.taskKeys?.length ? request.taskKeys : resume?.payload?.tasks;
|
|
944
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);
|
|
945
3812
|
const selection = await this.selectionService.selectTasks({
|
|
946
3813
|
projectKey: effectiveProject,
|
|
947
3814
|
epicKey: effectiveEpic,
|
|
948
3815
|
storyKey: effectiveStory,
|
|
949
3816
|
taskKeys: effectiveTasks,
|
|
950
|
-
statusFilter
|
|
3817
|
+
statusFilter,
|
|
3818
|
+
limit: effectiveLimit,
|
|
3819
|
+
ignoreDependencies: true,
|
|
3820
|
+
ignoreStatusFilter,
|
|
951
3821
|
});
|
|
3822
|
+
if (rejected.length > 0 && !ignoreStatusFilter) {
|
|
3823
|
+
selection.warnings.push(`qa-tasks ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${QA_ALLOWED_STATUSES.join(", ")}.`);
|
|
3824
|
+
}
|
|
3825
|
+
const abortSignal = request.abortSignal;
|
|
3826
|
+
const resolveAbortReason = () => {
|
|
3827
|
+
const reason = abortSignal?.reason;
|
|
3828
|
+
if (typeof reason === "string" && reason.trim().length > 0)
|
|
3829
|
+
return reason;
|
|
3830
|
+
if (reason instanceof Error && reason.message)
|
|
3831
|
+
return reason.message;
|
|
3832
|
+
return "qa_tasks_aborted";
|
|
3833
|
+
};
|
|
3834
|
+
const abortIfSignaled = () => {
|
|
3835
|
+
if (abortSignal?.aborted) {
|
|
3836
|
+
throw new Error(resolveAbortReason());
|
|
3837
|
+
}
|
|
3838
|
+
};
|
|
3839
|
+
const mode = request.mode ?? 'auto';
|
|
952
3840
|
this.dryRunGuard = request.dryRun ?? false;
|
|
953
3841
|
if (request.dryRun) {
|
|
954
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
|
+
}
|
|
955
3851
|
for (const task of selection.ordered) {
|
|
956
|
-
|
|
3852
|
+
abortIfSignaled();
|
|
3853
|
+
let profiles = [];
|
|
957
3854
|
try {
|
|
958
|
-
|
|
959
|
-
profileName: request.profileName,
|
|
960
|
-
level: request.level,
|
|
961
|
-
});
|
|
3855
|
+
profiles = await this.resolveProfilesForRequest(task.task, request);
|
|
962
3856
|
}
|
|
963
3857
|
catch {
|
|
964
|
-
|
|
3858
|
+
profiles = [];
|
|
965
3859
|
}
|
|
3860
|
+
const profile = profiles[0];
|
|
3861
|
+
const profileNames = profiles.map((entry) => entry.name);
|
|
966
3862
|
dryResults.push({
|
|
967
3863
|
taskKey: task.task.key,
|
|
968
3864
|
outcome: profile ? 'unclear' : 'infra_issue',
|
|
969
|
-
profile: profile?.name,
|
|
970
|
-
runner: profile?.runner,
|
|
971
|
-
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',
|
|
972
3870
|
});
|
|
973
3871
|
}
|
|
974
3872
|
return {
|
|
@@ -1012,8 +3910,9 @@ export class QaTasksService {
|
|
|
1012
3910
|
epicKey: effectiveEpic,
|
|
1013
3911
|
storyKey: effectiveStory,
|
|
1014
3912
|
tasks: effectiveTasks,
|
|
1015
|
-
statusFilter
|
|
1016
|
-
|
|
3913
|
+
statusFilter,
|
|
3914
|
+
limit: effectiveLimit,
|
|
3915
|
+
mode,
|
|
1017
3916
|
profile: request.profileName,
|
|
1018
3917
|
level: request.level,
|
|
1019
3918
|
agent: request.agentName,
|
|
@@ -1024,6 +3923,16 @@ export class QaTasksService {
|
|
|
1024
3923
|
totalItems: selection.ordered.length,
|
|
1025
3924
|
processedItems: completedKeys.size,
|
|
1026
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
|
+
}
|
|
1027
3936
|
if (resume?.id) {
|
|
1028
3937
|
try {
|
|
1029
3938
|
const qaRuns = await this.deps.workspaceRepo.listTaskQaRunsForJob(selection.ordered.map((t) => t.task.id), resume.id);
|
|
@@ -1039,8 +3948,8 @@ export class QaTasksService {
|
|
|
1039
3948
|
}
|
|
1040
3949
|
}
|
|
1041
3950
|
const remaining = selection.ordered.filter((t) => !completedKeys.has(t.task.key));
|
|
1042
|
-
// Skip tasks that are already in a terminal QA state for this job (ready_to_qa -> completed/in_progress/
|
|
1043
|
-
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']);
|
|
1044
3953
|
const skippedTerminal = [];
|
|
1045
3954
|
for (const t of remaining) {
|
|
1046
3955
|
if (terminalStatuses.has(t.task.status?.toLowerCase?.() ?? '')) {
|
|
@@ -1059,10 +3968,60 @@ export class QaTasksService {
|
|
|
1059
3968
|
});
|
|
1060
3969
|
await this.checkpoint(job.id, 'selection', {
|
|
1061
3970
|
ordered: selection.ordered.map((t) => t.task.key),
|
|
1062
|
-
blocked: selection.blocked.map((t) => t.task.key),
|
|
1063
3971
|
completedTaskKeys: Array.from(completedKeys),
|
|
1064
3972
|
});
|
|
3973
|
+
const warnings = [...selection.warnings];
|
|
1065
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
|
+
};
|
|
1066
4025
|
for (const task of selection.ordered) {
|
|
1067
4026
|
if (completedKeys.has(task.task.key)) {
|
|
1068
4027
|
results.push(priorResults.get(task.task.key) ?? { taskKey: task.task.key, outcome: 'pass', notes: 'skipped (resume)' });
|
|
@@ -1072,13 +4031,74 @@ export class QaTasksService {
|
|
|
1072
4031
|
try {
|
|
1073
4032
|
let processedCount = completedKeys.size;
|
|
1074
4033
|
for (const [index, task] of filteredRemaining.entries()) {
|
|
4034
|
+
if (abortRemainingReason)
|
|
4035
|
+
break;
|
|
4036
|
+
abortIfSignaled();
|
|
1075
4037
|
const mode = request.mode ?? 'auto';
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
+
}
|
|
1078
4061
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
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;
|
|
1081
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
|
+
});
|
|
1082
4102
|
completedKeys.add(task.task.key);
|
|
1083
4103
|
processedCount = completedKeys.size;
|
|
1084
4104
|
await this.deps.jobService.updateJobStatus(job.id, 'running', { processedItems: processedCount });
|
|
@@ -1088,9 +4108,18 @@ export class QaTasksService {
|
|
|
1088
4108
|
taskResult: results[results.length - 1],
|
|
1089
4109
|
});
|
|
1090
4110
|
}
|
|
4111
|
+
if (abortRemainingReason) {
|
|
4112
|
+
warnings.push(`Stopped remaining tasks due to auth/rate limit: ${abortRemainingReason}`);
|
|
4113
|
+
}
|
|
1091
4114
|
const failureCount = results.filter((r) => r.outcome !== 'pass').length;
|
|
1092
|
-
const state =
|
|
1093
|
-
|
|
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);
|
|
1094
4123
|
await this.deps.jobService.updateJobStatus(job.id, state, { errorSummary });
|
|
1095
4124
|
await this.deps.jobService.finishCommandRun(commandRun.id, state === 'completed' ? 'succeeded' : 'failed', errorSummary);
|
|
1096
4125
|
await this.checkpoint(job.id, 'completed', {
|
|
@@ -1111,7 +4140,7 @@ export class QaTasksService {
|
|
|
1111
4140
|
commandRunId: commandRun.id,
|
|
1112
4141
|
selection,
|
|
1113
4142
|
results,
|
|
1114
|
-
warnings
|
|
4143
|
+
warnings,
|
|
1115
4144
|
};
|
|
1116
4145
|
}
|
|
1117
4146
|
}
|