@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.
Files changed (216) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +2 -2
  3. package/dist/api/AgentsApi.d.ts +9 -1
  4. package/dist/api/AgentsApi.d.ts.map +1 -1
  5. package/dist/api/AgentsApi.js +201 -6
  6. package/dist/api/QaTasksApi.d.ts.map +1 -1
  7. package/dist/api/QaTasksApi.js +6 -0
  8. package/dist/api/TasksApi.d.ts.map +1 -1
  9. package/dist/api/TasksApi.js +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -0
  13. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  14. package/dist/prompts/PdrPrompts.js +9 -1
  15. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  16. package/dist/prompts/SdsPrompts.js +9 -0
  17. package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
  18. package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
  19. package/dist/services/agents/AgentRatingFormula.js +45 -0
  20. package/dist/services/agents/AgentRatingService.d.ts +60 -0
  21. package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
  22. package/dist/services/agents/AgentRatingService.js +363 -0
  23. package/dist/services/agents/GatewayAgentService.d.ts +11 -0
  24. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  25. package/dist/services/agents/GatewayAgentService.js +525 -84
  26. package/dist/services/agents/GatewayHandoff.d.ts +11 -0
  27. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
  28. package/dist/services/agents/GatewayHandoff.js +141 -0
  29. package/dist/services/agents/RoutingService.d.ts +1 -0
  30. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  31. package/dist/services/agents/RoutingService.js +4 -4
  32. package/dist/services/backlog/BacklogService.d.ts +23 -0
  33. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  34. package/dist/services/backlog/BacklogService.js +62 -7
  35. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  36. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  37. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  38. package/dist/services/backlog/TaskOrderingService.d.ts +17 -4
  39. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  40. package/dist/services/backlog/TaskOrderingService.js +538 -79
  41. package/dist/services/docs/DocInventory.d.ts +11 -0
  42. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  43. package/dist/services/docs/DocInventory.js +230 -0
  44. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  45. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  46. package/dist/services/docs/DocgenRunContext.js +4 -0
  47. package/dist/services/docs/DocsService.d.ts +70 -3
  48. package/dist/services/docs/DocsService.d.ts.map +1 -1
  49. package/dist/services/docs/DocsService.js +1930 -89
  50. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  51. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  52. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  53. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  54. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  55. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  56. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  57. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  58. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  59. package/dist/services/docs/review/Glossary.d.ts +16 -0
  60. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  61. package/dist/services/docs/review/Glossary.js +47 -0
  62. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  63. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  64. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  65. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  66. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  67. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  68. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  69. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  70. package/dist/services/docs/review/ReviewTypes.js +94 -0
  71. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  72. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  74. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  75. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  77. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  78. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  80. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  81. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  83. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  84. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  86. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  89. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  90. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  92. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  95. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  96. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  98. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  99. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  101. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  104. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  105. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  107. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  108. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  110. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  113. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  116. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  119. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  122. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  123. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  125. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  126. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  127. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  128. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  129. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  130. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  131. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  132. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  133. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  134. package/dist/services/docs/review/glossary.json +47 -0
  135. package/dist/services/estimate/EstimateService.d.ts +2 -0
  136. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  137. package/dist/services/estimate/EstimateService.js +66 -18
  138. package/dist/services/estimate/VelocityService.d.ts +4 -0
  139. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  140. package/dist/services/estimate/VelocityService.js +179 -36
  141. package/dist/services/estimate/types.d.ts +1 -0
  142. package/dist/services/estimate/types.d.ts.map +1 -1
  143. package/dist/services/execution/GatewayTrioService.d.ts +200 -0
  144. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
  145. package/dist/services/execution/GatewayTrioService.js +2492 -0
  146. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  147. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  148. package/dist/services/execution/QaApiRunner.js +881 -0
  149. package/dist/services/execution/QaFollowupService.d.ts +2 -0
  150. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  151. package/dist/services/execution/QaFollowupService.js +9 -2
  152. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  153. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  154. package/dist/services/execution/QaPlanValidator.js +128 -0
  155. package/dist/services/execution/QaProfileService.d.ts +27 -1
  156. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  157. package/dist/services/execution/QaProfileService.js +354 -7
  158. package/dist/services/execution/QaTasksService.d.ts +59 -1
  159. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  160. package/dist/services/execution/QaTasksService.js +3347 -318
  161. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  162. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  163. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  164. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  165. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  166. package/dist/services/execution/TaskSelectionService.js +144 -28
  167. package/dist/services/execution/TaskStateService.d.ts +19 -6
  168. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  169. package/dist/services/execution/TaskStateService.js +128 -13
  170. package/dist/services/execution/WorkOnTasksService.d.ts +32 -1
  171. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  172. package/dist/services/execution/WorkOnTasksService.js +4667 -722
  173. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  174. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  175. package/dist/services/jobs/JobInsightsService.js +51 -5
  176. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  177. package/dist/services/jobs/JobResumeService.js +23 -10
  178. package/dist/services/jobs/JobService.d.ts +56 -4
  179. package/dist/services/jobs/JobService.d.ts.map +1 -1
  180. package/dist/services/jobs/JobService.js +232 -1
  181. package/dist/services/openapi/OpenApiService.d.ts +51 -0
  182. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  183. package/dist/services/openapi/OpenApiService.js +953 -106
  184. package/dist/services/planning/CreateTasksService.d.ts +21 -0
  185. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  186. package/dist/services/planning/CreateTasksService.js +569 -31
  187. package/dist/services/planning/RefineTasksService.d.ts +9 -0
  188. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  189. package/dist/services/planning/RefineTasksService.js +409 -59
  190. package/dist/services/review/CodeReviewService.d.ts +18 -0
  191. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  192. package/dist/services/review/CodeReviewService.js +1309 -167
  193. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  194. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  195. package/dist/services/review/ReviewNormalizer.js +147 -0
  196. package/dist/services/shared/AuthErrors.d.ts +3 -0
  197. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  198. package/dist/services/shared/AuthErrors.js +17 -0
  199. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  200. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  201. package/dist/services/shared/DocdexGuidance.js +12 -0
  202. package/dist/services/shared/ProjectGuidance.d.ts +17 -0
  203. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
  204. package/dist/services/shared/ProjectGuidance.js +78 -0
  205. package/dist/services/system/ToolDenylist.d.ts +13 -0
  206. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  207. package/dist/services/system/ToolDenylist.js +85 -0
  208. package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
  209. package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
  210. package/dist/services/tasks/TaskCommentFormatter.js +54 -0
  211. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  212. package/dist/services/telemetry/TelemetryService.js +39 -7
  213. package/dist/workspace/WorkspaceManager.d.ts +26 -0
  214. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  215. package/dist/workspace/WorkspaceManager.js +206 -32
  216. package/package.json +6 -5
@@ -4,15 +4,267 @@ import YAML from "yaml";
4
4
  import SwaggerParser from "@apidevtools/swagger-parser";
5
5
  import { AgentService } from "@mcoda/agents";
6
6
  import { DocdexClient } from "@mcoda/integrations";
7
- import { GlobalRepository } from "@mcoda/db";
7
+ import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
8
8
  import { JobService } from "../jobs/JobService.js";
9
9
  import { RoutingService } from "../agents/RoutingService.js";
10
+ import { AgentRatingService } from "../agents/AgentRatingService.js";
11
+ import { buildDocInventory } from "../docs/DocInventory.js";
10
12
  const OPENAPI_TAGS = [
11
13
  // For project-specific specs, tags come from context; this is only a fallback.
12
14
  ];
13
15
  const OPENAPI_VERSION = "3.1.0";
16
+ const PRIMARY_OPENAPI_FILENAME = "mcoda.yaml";
17
+ const ADMIN_OPENAPI_FILENAME = "mcoda-admin.yaml";
14
18
  const CONTEXT_TOKEN_BUDGET = 8000;
19
+ const OPENAPI_TIMEOUT_ENV = "MCODA_OPENAPI_TIMEOUT_SECONDS";
20
+ const OPENAPI_HEARTBEAT_INTERVAL_MS = 15000;
21
+ const OPENAPI_PRIMARY_DRAFT = "openapi-primary-draft.yaml";
22
+ const OPENAPI_ADMIN_DRAFT = "openapi-admin-draft.yaml";
15
23
  const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));
24
+ const parseTimeoutSeconds = (value) => {
25
+ if (!value)
26
+ return undefined;
27
+ const parsed = Number(value);
28
+ if (!Number.isFinite(parsed) || parsed <= 0)
29
+ return undefined;
30
+ return parsed * 1000;
31
+ };
32
+ const formatIterationLabel = (iteration) => {
33
+ if (!iteration || !Number.isFinite(iteration.current))
34
+ return undefined;
35
+ const current = iteration.current;
36
+ const max = iteration.max;
37
+ if (Number.isFinite(max) && max > 0)
38
+ return `${current}/${max}`;
39
+ return `${current}`;
40
+ };
41
+ const compactPayload = (payload) => Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined));
42
+ export class OpenApiJobError extends Error {
43
+ constructor(code, message, jobId) {
44
+ super(message);
45
+ this.code = code;
46
+ this.jobId = jobId;
47
+ }
48
+ }
49
+ export const normalizeOpenApiPath = (value) => {
50
+ if (!value)
51
+ return "/";
52
+ const trimmed = value.trim();
53
+ const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
54
+ const withoutTrailing = withLeading.length > 1 ? withLeading.replace(/\/+$/, "") : withLeading;
55
+ const segments = withoutTrailing
56
+ .split("/")
57
+ .filter(Boolean)
58
+ .map((segment) => {
59
+ if (segment.startsWith("{") && segment.endsWith("}"))
60
+ return "{param}";
61
+ return segment;
62
+ });
63
+ return segments.length ? `/${segments.join("/")}` : "/";
64
+ };
65
+ export const extractOpenApiPaths = (raw) => {
66
+ const errors = [];
67
+ if (!raw || !raw.trim()) {
68
+ return { paths: [], errors: ["OpenAPI spec is empty."] };
69
+ }
70
+ let parsed;
71
+ try {
72
+ parsed = YAML.parse(raw);
73
+ }
74
+ catch (error) {
75
+ return { paths: [], errors: [`OpenAPI parse failed: ${error.message}`] };
76
+ }
77
+ if (!parsed || typeof parsed !== "object") {
78
+ return { paths: [], errors: ["OpenAPI spec is not a YAML object."] };
79
+ }
80
+ const paths = parsed.paths;
81
+ if (!paths || typeof paths !== "object") {
82
+ return { paths: [], errors: ["OpenAPI spec missing paths section."] };
83
+ }
84
+ return { paths: Object.keys(paths).filter(Boolean), errors };
85
+ };
86
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
+ export const findOpenApiPathLine = (raw, target) => {
88
+ if (!raw || !target)
89
+ return undefined;
90
+ const lines = raw.split(/\r?\n/);
91
+ const escaped = escapeRegExp(target);
92
+ const pattern = new RegExp(`^\\s*['"]?${escaped}['"]?\\s*:`);
93
+ for (let i = 0; i < lines.length; i += 1) {
94
+ if (pattern.test(lines[i] ?? ""))
95
+ return i + 1;
96
+ }
97
+ return undefined;
98
+ };
99
+ const ADMIN_PATH_PATTERN = /\/admin(?:\/|\b)/i;
100
+ const ADMIN_CONTEXT_PATTERN = /\badmin\b.*\b(api|endpoint|console|dashboard|portal|interface|panel|ui)\b/i;
101
+ export const findAdminSurfaceMentions = (raw) => {
102
+ if (!raw || !raw.trim())
103
+ return [];
104
+ const lines = raw.split(/\r?\n/);
105
+ const mentions = [];
106
+ const seen = new Set();
107
+ let inFence = false;
108
+ let currentHeading;
109
+ for (let i = 0; i < lines.length; i += 1) {
110
+ const line = lines[i] ?? "";
111
+ const trimmed = line.trim();
112
+ if (!trimmed)
113
+ continue;
114
+ if (/^```|^~~~/.test(trimmed)) {
115
+ inFence = !inFence;
116
+ continue;
117
+ }
118
+ if (inFence)
119
+ continue;
120
+ const headingMatch = trimmed.match(/^#{1,6}\s+(.*)$/);
121
+ if (headingMatch) {
122
+ currentHeading = headingMatch[1]?.trim() || undefined;
123
+ if (currentHeading && /\badmin\b/i.test(currentHeading)) {
124
+ if (!seen.has(i + 1)) {
125
+ mentions.push({ line: i + 1, excerpt: currentHeading, heading: currentHeading });
126
+ seen.add(i + 1);
127
+ }
128
+ }
129
+ continue;
130
+ }
131
+ if (ADMIN_PATH_PATTERN.test(trimmed) || ADMIN_CONTEXT_PATTERN.test(trimmed)) {
132
+ if (!seen.has(i + 1)) {
133
+ mentions.push({ line: i + 1, excerpt: trimmed, heading: currentHeading });
134
+ seen.add(i + 1);
135
+ }
136
+ }
137
+ }
138
+ return mentions;
139
+ };
140
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
141
+ const OPERATION_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
142
+ const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
143
+ const operationUsesJsonSchema = (operation) => {
144
+ const contentBlocks = [];
145
+ const requestContent = operation.requestBody?.content;
146
+ if (isPlainObject(requestContent))
147
+ contentBlocks.push(requestContent);
148
+ const responses = operation.responses;
149
+ if (isPlainObject(responses)) {
150
+ for (const response of Object.values(responses)) {
151
+ const responseContent = response?.content;
152
+ if (isPlainObject(responseContent))
153
+ contentBlocks.push(responseContent);
154
+ }
155
+ }
156
+ for (const content of contentBlocks) {
157
+ for (const [contentType, media] of Object.entries(content)) {
158
+ if (!contentType.toLowerCase().includes("json"))
159
+ continue;
160
+ if (media?.schema)
161
+ return true;
162
+ }
163
+ }
164
+ return false;
165
+ };
166
+ export const validateOpenApiSchema = (doc) => {
167
+ const errors = [];
168
+ if (!isPlainObject(doc)) {
169
+ errors.push("OpenAPI spec is not an object.");
170
+ return errors;
171
+ }
172
+ const version = doc.openapi;
173
+ if (!version) {
174
+ errors.push("Missing openapi version.");
175
+ }
176
+ else if (typeof version !== "string" || !version.startsWith("3.")) {
177
+ errors.push(`Invalid openapi version: ${String(version)}.`);
178
+ }
179
+ const info = doc.info;
180
+ if (!isPlainObject(info)) {
181
+ errors.push("Missing info section.");
182
+ }
183
+ else {
184
+ if (!info.title)
185
+ errors.push("Missing info.title.");
186
+ if (!info.version)
187
+ errors.push("Missing info.version.");
188
+ }
189
+ const paths = doc.paths;
190
+ if (!isPlainObject(paths)) {
191
+ errors.push("Missing paths section.");
192
+ }
193
+ else if (Object.keys(paths).length === 0) {
194
+ errors.push("paths section is empty.");
195
+ }
196
+ const operationIds = new Map();
197
+ let hasOperations = false;
198
+ let hasJsonSchemaUsage = false;
199
+ if (isPlainObject(paths)) {
200
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
201
+ if (!isPlainObject(pathItem)) {
202
+ errors.push(`Path item for ${pathKey} must be an object.`);
203
+ continue;
204
+ }
205
+ const methods = HTTP_METHODS.filter((method) => method in pathItem);
206
+ if (methods.length === 0) {
207
+ errors.push(`Path ${pathKey} has no operations.`);
208
+ continue;
209
+ }
210
+ for (const method of methods) {
211
+ const operation = pathItem[method];
212
+ if (!isPlainObject(operation)) {
213
+ errors.push(`Operation ${method.toUpperCase()} ${pathKey} must be an object.`);
214
+ continue;
215
+ }
216
+ hasOperations = true;
217
+ if (operationUsesJsonSchema(operation)) {
218
+ hasJsonSchemaUsage = true;
219
+ }
220
+ const operationId = operation.operationId;
221
+ if (!operationId || typeof operationId !== "string") {
222
+ errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}.`);
223
+ }
224
+ else if (/\s/.test(operationId) || !OPERATION_ID_PATTERN.test(operationId)) {
225
+ errors.push(`Invalid operationId "${operationId}" for ${method.toUpperCase()} ${pathKey}.`);
226
+ }
227
+ else if (operationIds.has(operationId)) {
228
+ errors.push(`Duplicate operationId "${operationId}" detected.`);
229
+ }
230
+ else {
231
+ operationIds.set(operationId, `${method.toUpperCase()} ${pathKey}`);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ const components = doc.components;
237
+ const schemas = isPlainObject(components) ? components.schemas : undefined;
238
+ const schemaCount = isPlainObject(schemas) ? Object.keys(schemas).length : 0;
239
+ const hasSchemaRefs = typeof doc === "object" && JSON.stringify(doc).includes("#/components/schemas/");
240
+ if ((hasJsonSchemaUsage || hasSchemaRefs || hasOperations) && schemaCount === 0) {
241
+ errors.push("Missing components.schemas for JSON payloads.");
242
+ }
243
+ return errors;
244
+ };
245
+ export const validateOpenApiSchemaContent = (raw) => {
246
+ if (!raw || !raw.trim()) {
247
+ return { errors: ["OpenAPI spec is empty."] };
248
+ }
249
+ let parsed;
250
+ try {
251
+ parsed = YAML.parse(raw);
252
+ }
253
+ catch (error) {
254
+ try {
255
+ parsed = JSON.parse(raw);
256
+ }
257
+ catch (jsonError) {
258
+ return {
259
+ errors: [
260
+ `OpenAPI parse failed: ${error.message ?? String(error)}`,
261
+ ],
262
+ };
263
+ }
264
+ }
265
+ const errors = validateOpenApiSchema(parsed);
266
+ return { doc: parsed, errors };
267
+ };
16
268
  const fileExists = async (candidate) => {
17
269
  try {
18
270
  await fs.access(candidate);
@@ -47,9 +299,12 @@ class OpenapiContextAssembler {
47
299
  }
48
300
  async findLatestLocalDoc(docType) {
49
301
  const candidates = [];
50
- const dirNames = [".mcoda/docs", "docs"];
51
- for (const dir of dirNames) {
52
- const target = path.join(this.workspace.workspaceRoot, dir, docType.toLowerCase());
302
+ const docDirs = [
303
+ path.join(this.workspace.mcodaDir, "docs"),
304
+ path.join(this.workspace.workspaceRoot, "docs"),
305
+ ];
306
+ for (const dir of docDirs) {
307
+ const target = path.join(dir, docType.toLowerCase());
53
308
  try {
54
309
  const entries = await fs.readdir(target);
55
310
  for (const entry of entries.filter((e) => e.endsWith(".md"))) {
@@ -203,27 +458,42 @@ class OpenapiContextAssembler {
203
458
  export class OpenApiService {
204
459
  constructor(workspace, deps) {
205
460
  this.workspace = workspace;
206
- this.docdex = deps?.docdex ?? new DocdexClient({ workspaceRoot: workspace.workspaceRoot });
461
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
462
+ this.docdex = deps?.docdex ?? new DocdexClient({ workspaceRoot: workspace.workspaceRoot, repoId: docdexRepoId });
207
463
  this.jobService = deps?.jobService ?? new JobService(workspace, undefined, { noTelemetry: deps?.noTelemetry });
208
464
  this.agentService = deps.agentService;
209
465
  this.routingService = deps.routingService;
466
+ this.repo = deps.repo;
467
+ this.workspaceRepo = deps.workspaceRepo;
468
+ this.ratingService = deps.ratingService;
210
469
  }
211
470
  static async create(workspace, options = {}) {
212
471
  const repo = await GlobalRepository.create();
213
472
  const agentService = new AgentService(repo);
214
473
  const routingService = await RoutingService.create();
474
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
215
475
  const docdex = new DocdexClient({
216
476
  workspaceRoot: workspace.workspaceRoot,
217
477
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
478
+ repoId: docdexRepoId,
218
479
  });
219
480
  const jobService = new JobService(workspace, undefined, { noTelemetry: options.noTelemetry });
220
- return new OpenApiService(workspace, { agentService, routingService, docdex, jobService, noTelemetry: options.noTelemetry });
481
+ return new OpenApiService(workspace, { agentService, routingService, docdex, jobService, repo, noTelemetry: options.noTelemetry });
221
482
  }
222
483
  async close() {
223
- if (this.agentService.close)
224
- await this.agentService.close();
225
- if (this.jobService.close)
226
- await this.jobService.close();
484
+ const swallow = async (fn) => {
485
+ try {
486
+ if (fn)
487
+ await fn();
488
+ }
489
+ catch {
490
+ // Best-effort close; ignore errors (including "database is closed").
491
+ }
492
+ };
493
+ await swallow(this.agentService.close?.bind(this.agentService));
494
+ await swallow(this.jobService.close?.bind(this.jobService));
495
+ await swallow(this.repo?.close?.bind(this.repo));
496
+ await swallow(this.workspaceRepo?.close?.bind(this.workspaceRepo));
227
497
  }
228
498
  async resolveAgent(agentName) {
229
499
  const resolved = await this.routingService.resolveAgentForCommand({
@@ -233,6 +503,26 @@ export class OpenApiService {
233
503
  });
234
504
  return resolved.agent;
235
505
  }
506
+ async ensureRatingService() {
507
+ if (this.ratingService)
508
+ return this.ratingService;
509
+ if (process.env.MCODA_DISABLE_DB === "1") {
510
+ throw new Error("Workspace DB disabled; agent rating requires DB access.");
511
+ }
512
+ if (!this.workspaceRepo) {
513
+ this.workspaceRepo = await WorkspaceRepository.create(this.workspace.workspaceRoot);
514
+ }
515
+ if (!this.repo) {
516
+ this.repo = await GlobalRepository.create();
517
+ }
518
+ this.ratingService = new AgentRatingService(this.workspace, {
519
+ workspaceRepo: this.workspaceRepo,
520
+ globalRepo: this.repo,
521
+ agentService: this.agentService,
522
+ routingService: this.routingService,
523
+ });
524
+ return this.ratingService;
525
+ }
236
526
  async invokeAgent(agent, prompt, stream, jobId, onToken) {
237
527
  if (stream) {
238
528
  try {
@@ -262,27 +552,18 @@ export class OpenApiService {
262
552
  }
263
553
  sanitizeOutput(raw) {
264
554
  const trimmed = raw.trim();
265
- if (trimmed.startsWith("```")) {
266
- const withoutFence = trimmed.replace(/^```[a-zA-Z]*\s*/m, "").replace(/```$/, "");
267
- return withoutFence.trim();
555
+ let body = trimmed;
556
+ if (body.startsWith("```")) {
557
+ body = body.replace(/^```[a-zA-Z]*\s*/m, "").replace(/```$/, "");
268
558
  }
269
- return trimmed;
559
+ const openapiIndex = body.search(/^openapi:\s*\d/m);
560
+ if (openapiIndex > 0) {
561
+ body = body.slice(openapiIndex);
562
+ }
563
+ return body.trim();
270
564
  }
271
565
  validateSpec(doc) {
272
- const errors = [];
273
- if (!doc || typeof doc !== "object") {
274
- errors.push("Spec is not a YAML object.");
275
- return errors;
276
- }
277
- if (!doc.openapi)
278
- errors.push("Missing openapi version");
279
- if (!doc.info?.title)
280
- errors.push("Missing info.title");
281
- if (!doc.info?.version)
282
- errors.push("Missing info.version");
283
- if (!doc.paths)
284
- errors.push("paths section is required (can be empty if no HTTP API)");
285
- return errors;
566
+ return validateOpenApiSchema(doc);
286
567
  }
287
568
  async runOpenapiValidator(doc) {
288
569
  try {
@@ -297,19 +578,27 @@ export class OpenApiService {
297
578
  return [error.message];
298
579
  }
299
580
  }
300
- buildPrompt(context, cliVersion, retryReasons) {
581
+ buildPrompt(context, cliVersion, retryReasons, variant = "primary") {
301
582
  const contextBlocks = context.blocks
302
583
  .map((block) => `### ${block.label}\n${block.content}`)
303
584
  .join("\n\n");
304
585
  const retryNote = retryReasons?.length
305
586
  ? `\nPrevious attempt issues:\n${retryReasons.map((r) => `- ${r}`).join("\n")}\nFix them in this draft.\n`
306
587
  : "";
588
+ const adminNote = variant === "admin"
589
+ ? [
590
+ "This is the ADMIN OpenAPI spec. Include only administrative/control-plane endpoints.",
591
+ "Focus on admin consoles, moderation, user management, and internal admin workflows described in docs.",
592
+ "Prefer /admin or /internal/admin style prefixes; omit public/customer-facing APIs.",
593
+ ].join("\n")
594
+ : "";
307
595
  return [
308
596
  "You are generating an OpenAPI 3.1 YAML for THIS workspace/project using only the provided PDR/SDS/RFP context.",
309
597
  "Derive resources, schemas, and HTTP endpoints directly from the product requirements (e.g., todos CRUD, filters, search, bulk actions).",
310
598
  "If the documents describe a frontend-only/localStorage app, design a minimal REST API that could back those features (e.g., /todos, /todos/{id}, bulk operations, search/filter params) instead of returning an empty spec.",
311
599
  "Prefer concise tags derived from domain resources (e.g., Todos). Avoid generic mcoda/system endpoints unless explicitly described in the context.",
312
600
  `Use OpenAPI version ${OPENAPI_VERSION}, set info.title to the project name from context (fallback \"mcoda API\"), and info.version ${cliVersion}.`,
601
+ adminNote,
313
602
  "Return only valid YAML (no Markdown fences, no commentary).",
314
603
  retryNote,
315
604
  "Scope rules:",
@@ -332,7 +621,7 @@ export class OpenApiService {
332
621
  await fs.copyFile(target, backup);
333
622
  return backup;
334
623
  }
335
- async registerOpenapi(outPath, content) {
624
+ async registerOpenapi(outPath, content, variant = "primary") {
336
625
  const branch = this.workspace.config?.branch ?? (await readGitBranch(this.workspace.workspaceRoot));
337
626
  return this.docdex.registerDocument({
338
627
  docType: "OPENAPI",
@@ -343,6 +632,7 @@ export class OpenApiService {
343
632
  branch,
344
633
  status: "canonical",
345
634
  projectKey: this.workspace.config?.projectKey,
635
+ variant,
346
636
  },
347
637
  });
348
638
  }
@@ -352,53 +642,454 @@ export class OpenApiService {
352
642
  const issues = this.validateSpec(parsed);
353
643
  const validatorIssues = await this.runOpenapiValidator(parsed);
354
644
  issues.push(...validatorIssues);
355
- return { spec: content, issues };
645
+ return { spec: content, issues, doc: parsed };
646
+ }
647
+ async collectAdminMentions() {
648
+ const warnings = [];
649
+ let inventory;
650
+ try {
651
+ inventory = await buildDocInventory({ workspace: this.workspace });
652
+ }
653
+ catch (error) {
654
+ warnings.push(`Doc inventory build failed: ${error.message ?? String(error)}`);
655
+ }
656
+ const records = [inventory?.pdr, inventory?.sds].filter((record) => Boolean(record));
657
+ if (records.length === 0) {
658
+ return { required: false, mentions: [], warnings };
659
+ }
660
+ const mentions = [];
661
+ for (const record of records) {
662
+ try {
663
+ const content = await fs.readFile(record.path, "utf8");
664
+ const found = findAdminSurfaceMentions(content);
665
+ for (const mention of found) {
666
+ mentions.push({ record, mention });
667
+ }
668
+ }
669
+ catch (error) {
670
+ warnings.push(`Unable to read doc ${record.path}: ${error.message ?? String(error)}`);
671
+ }
672
+ }
673
+ return { required: mentions.length > 0, mentions, warnings };
674
+ }
675
+ openapiDraftPath(jobId, variant) {
676
+ const filename = variant === "admin" ? OPENAPI_ADMIN_DRAFT : OPENAPI_PRIMARY_DRAFT;
677
+ return path.join(this.workspace.mcodaDir, "jobs", jobId, filename);
678
+ }
679
+ async readOpenapiDraft(jobId, variant, draftPathOverride) {
680
+ const draftPath = draftPathOverride ?? this.openapiDraftPath(jobId, variant);
681
+ try {
682
+ return await fs.readFile(draftPath, "utf8");
683
+ }
684
+ catch {
685
+ return undefined;
686
+ }
687
+ }
688
+ async writeOpenapiDraft(jobId, variant, content) {
689
+ const draftPath = this.openapiDraftPath(jobId, variant);
690
+ await fs.mkdir(path.dirname(draftPath), { recursive: true });
691
+ await fs.writeFile(draftPath, content, "utf8");
692
+ }
693
+ async isJobCancelled(jobId) {
694
+ const job = await this.jobService.getJob(jobId);
695
+ const state = job?.jobState ?? job?.state;
696
+ return state === "cancelled";
697
+ }
698
+ async updateOpenapiJobStatus(jobId, state, options = {}) {
699
+ const iterationLabel = formatIterationLabel(options.iteration);
700
+ const detail = options.jobStateDetail ??
701
+ [
702
+ options.stage ? `openapi:${options.stage}` : undefined,
703
+ options.variant ? `variant:${options.variant}` : undefined,
704
+ iterationLabel ? `iter:${iterationLabel}` : undefined,
705
+ ]
706
+ .filter(Boolean)
707
+ .join(" ");
708
+ const payload = compactPayload({
709
+ ...options.payload,
710
+ openapi_stage: options.stage,
711
+ openapi_variant: options.variant,
712
+ openapi_iteration_current: options.iteration?.current,
713
+ openapi_iteration_max: options.iteration?.max,
714
+ openapi_iteration_label: iterationLabel,
715
+ });
716
+ const totalItems = options.totalUnits;
717
+ const processedItems = options.completedUnits;
718
+ await this.jobService.updateJobStatus(jobId, state, {
719
+ job_state_detail: detail || undefined,
720
+ totalUnits: options.totalUnits,
721
+ completedUnits: options.completedUnits,
722
+ totalItems,
723
+ processedItems,
724
+ payload,
725
+ errorSummary: options.errorSummary,
726
+ });
727
+ }
728
+ async writeOpenapiCheckpoint(jobId, stage, details) {
729
+ await this.jobService.writeCheckpoint(jobId, {
730
+ stage,
731
+ timestamp: new Date().toISOString(),
732
+ details,
733
+ });
734
+ }
735
+ startOpenapiHeartbeat(jobId, getStatus) {
736
+ let stopped = false;
737
+ let error;
738
+ const interval = setInterval(() => {
739
+ void (async () => {
740
+ if (stopped || error)
741
+ return;
742
+ if (await this.isJobCancelled(jobId)) {
743
+ error = new OpenApiJobError("cancelled", `OpenAPI job ${jobId} was cancelled.`, jobId);
744
+ return;
745
+ }
746
+ const status = getStatus();
747
+ if (!status.stage)
748
+ return;
749
+ await this.updateOpenapiJobStatus(jobId, "running", {
750
+ stage: status.stage,
751
+ variant: status.variant,
752
+ iteration: status.iteration,
753
+ totalUnits: status.totalUnits,
754
+ completedUnits: status.completedUnits,
755
+ payload: status.payload,
756
+ });
757
+ })().catch(() => {
758
+ // best-effort heartbeat
759
+ });
760
+ }, OPENAPI_HEARTBEAT_INTERVAL_MS);
761
+ return {
762
+ stop: () => {
763
+ stopped = true;
764
+ clearInterval(interval);
765
+ },
766
+ getError: () => error,
767
+ };
768
+ }
769
+ async tryResumeOpenapi(resumeJobId, warnings) {
770
+ const manifest = await this.jobService.readManifest(resumeJobId);
771
+ if (!manifest) {
772
+ warnings.push(`No resume data found for job ${resumeJobId}; starting a new OpenAPI job.`);
773
+ return undefined;
774
+ }
775
+ const manifestType = manifest.type ?? manifest.job_type ?? manifest.jobType;
776
+ if (manifestType && manifestType !== "openapi_change") {
777
+ throw new Error(`Job ${resumeJobId} is type ${manifestType}, not openapi_change. Use a matching job id or rerun without --resume.`);
778
+ }
779
+ const status = manifest.status ?? manifest.state ?? manifest.jobState;
780
+ if (status === "running" || status === "queued" || status === "checkpointing") {
781
+ throw new Error(`Job ${resumeJobId} is still running; use "mcoda job watch --id ${resumeJobId}" to monitor.`);
782
+ }
783
+ const payload = manifest.payload ?? {};
784
+ const outputPath = payload.outputPath ??
785
+ payload.openapi_primary_output_path ??
786
+ payload.openapi_output_path ??
787
+ payload.output_path;
788
+ const adminOutputPath = payload.adminOutputPath ??
789
+ payload.openapi_admin_output_path ??
790
+ payload.admin_output_path;
791
+ const primaryDraftPath = payload.openapi_primary_draft_path ?? this.openapiDraftPath(resumeJobId, "primary");
792
+ const adminDraftPath = payload.openapi_admin_draft_path ?? this.openapiDraftPath(resumeJobId, "admin");
793
+ let primaryDraft;
794
+ let adminDraft;
795
+ let spec;
796
+ let adminSpec;
797
+ primaryDraft = await this.readOpenapiDraft(resumeJobId, "primary", primaryDraftPath);
798
+ adminDraft = await this.readOpenapiDraft(resumeJobId, "admin", adminDraftPath);
799
+ if (outputPath && (await fileExists(outputPath))) {
800
+ try {
801
+ spec = await fs.readFile(outputPath, "utf8");
802
+ }
803
+ catch {
804
+ // ignore output read failures
805
+ }
806
+ }
807
+ if (adminOutputPath && (await fileExists(adminOutputPath))) {
808
+ try {
809
+ adminSpec = await fs.readFile(adminOutputPath, "utf8");
810
+ }
811
+ catch {
812
+ // ignore output read failures
813
+ }
814
+ }
815
+ const docdexId = payload.docdexId ?? payload.openapi_docdex_id ?? payload.docdex_id;
816
+ const adminDocdexId = payload.adminDocdexId ?? payload.openapi_admin_docdex_id ?? payload.admin_docdex_id;
817
+ const lastStage = payload.openapi_stage ?? manifest.lastCheckpoint ?? manifest.last_checkpoint;
818
+ const jobId = manifest.id ?? manifest.job_id ?? resumeJobId;
819
+ const outputReady = Boolean(spec || primaryDraft);
820
+ if (status === "completed" || status === "succeeded") {
821
+ if (outputReady) {
822
+ warnings.push(`Resume requested; returning completed OpenAPI from job ${resumeJobId}.`);
823
+ return {
824
+ job: { ...manifest, id: jobId },
825
+ completed: true,
826
+ outputPath,
827
+ adminOutputPath,
828
+ spec: spec ?? primaryDraft,
829
+ adminSpec: adminSpec ?? adminDraft,
830
+ primaryDraft,
831
+ adminDraft,
832
+ docdexId,
833
+ adminDocdexId,
834
+ lastStage,
835
+ };
836
+ }
837
+ warnings.push(`Resume requested for job ${resumeJobId}, but output is missing; restarting generation.`);
838
+ }
839
+ if (primaryDraft) {
840
+ warnings.push(`Resuming OpenAPI primary draft from job ${resumeJobId}.`);
841
+ }
842
+ else {
843
+ warnings.push(`Resume requested for ${resumeJobId}; regenerating OpenAPI primary draft.`);
844
+ }
845
+ return {
846
+ job: { ...manifest, id: jobId },
847
+ completed: false,
848
+ outputPath,
849
+ adminOutputPath,
850
+ spec,
851
+ adminSpec,
852
+ primaryDraft,
853
+ adminDraft,
854
+ docdexId,
855
+ adminDocdexId,
856
+ lastStage,
857
+ };
356
858
  }
357
859
  async generateFromDocs(options) {
860
+ const warnings = [];
358
861
  const commandRun = await this.jobService.startCommandRun("openapi-from-docs", options.projectKey);
359
- const job = await this.jobService.startJob("openapi_change", commandRun.id, options.projectKey, {
360
- commandName: commandRun.commandName,
361
- payload: { workspaceRoot: this.workspace.workspaceRoot, projectKey: options.projectKey },
862
+ let job;
863
+ let resumePrimaryDraft;
864
+ let resumeAdminDraft;
865
+ let resumeDocdexId;
866
+ let resumeAdminDocdexId;
867
+ let resumeOutputPath;
868
+ let resumeAdminOutputPath;
869
+ let resumeSpec;
870
+ let resumeAdminSpec;
871
+ if (options.resumeJobId) {
872
+ const resumed = await this.tryResumeOpenapi(options.resumeJobId, warnings);
873
+ if (resumed) {
874
+ job = resumed.job;
875
+ if (resumed.completed) {
876
+ await this.jobService.finishCommandRun(commandRun.id, "succeeded");
877
+ return {
878
+ jobId: job.id,
879
+ commandRunId: commandRun.id,
880
+ outputPath: resumed.outputPath,
881
+ spec: resumed.spec ?? "",
882
+ adminOutputPath: resumed.adminOutputPath,
883
+ adminSpec: resumed.adminSpec,
884
+ docdexId: resumed.docdexId,
885
+ adminDocdexId: resumed.adminDocdexId,
886
+ warnings,
887
+ };
888
+ }
889
+ await this.updateOpenapiJobStatus(job.id, "running", {
890
+ stage: "resuming",
891
+ iteration: options.iteration,
892
+ payload: compactPayload({ resumedBy: commandRun.id }),
893
+ });
894
+ await this.writeOpenapiCheckpoint(job.id, "resume_started", { resumedBy: commandRun.id });
895
+ resumePrimaryDraft = resumed.primaryDraft;
896
+ resumeAdminDraft = resumed.adminDraft;
897
+ resumeDocdexId = resumed.docdexId;
898
+ resumeAdminDocdexId = resumed.adminDocdexId;
899
+ resumeOutputPath = resumed.outputPath;
900
+ resumeAdminOutputPath = resumed.adminOutputPath;
901
+ resumeSpec = resumed.spec;
902
+ resumeAdminSpec = resumed.adminSpec;
903
+ }
904
+ }
905
+ if (!job) {
906
+ job = await this.jobService.startJob("openapi_change", commandRun.id, options.projectKey, {
907
+ commandName: commandRun.commandName,
908
+ payload: {
909
+ workspaceRoot: this.workspace.workspaceRoot,
910
+ projectKey: options.projectKey,
911
+ resumeSupported: true,
912
+ cliVersion: options.cliVersion,
913
+ },
914
+ });
915
+ }
916
+ const timeoutMs = options.timeoutMs ?? parseTimeoutSeconds(process.env[OPENAPI_TIMEOUT_ENV]);
917
+ const timeoutAt = timeoutMs ? Date.now() + timeoutMs : undefined;
918
+ const iteration = options.iteration;
919
+ const iterationLabel = formatIterationLabel(iteration);
920
+ const openapiDir = await this.ensureOpenapiDir();
921
+ const outputPath = resumeOutputPath ?? path.join(openapiDir, PRIMARY_OPENAPI_FILENAME);
922
+ const adminOutputPath = resumeAdminOutputPath ?? path.join(openapiDir, ADMIN_OPENAPI_FILENAME);
923
+ const primaryDraftPath = this.openapiDraftPath(job.id, "primary");
924
+ const adminDraftPath = this.openapiDraftPath(job.id, "admin");
925
+ const basePayload = compactPayload({
926
+ workspaceRoot: this.workspace.workspaceRoot,
927
+ projectKey: options.projectKey,
928
+ resumeSupported: true,
929
+ cliVersion: options.cliVersion,
930
+ openapi_timeout_ms: timeoutMs,
931
+ openapi_primary_output_path: outputPath,
932
+ openapi_admin_output_path: adminOutputPath,
933
+ openapi_primary_draft_path: primaryDraftPath,
934
+ openapi_admin_draft_path: adminDraftPath,
935
+ openapi_iteration_current: iteration?.current,
936
+ openapi_iteration_max: iteration?.max,
937
+ openapi_iteration_label: iterationLabel,
362
938
  });
363
- const warnings = [];
939
+ const buildPayload = (payload) => compactPayload({ ...basePayload, ...payload });
940
+ let totalUnits = 1;
941
+ let completedUnits = 0;
942
+ const perVariantUnits = options.validateOnly ? 1 : 2;
943
+ let currentStage = "starting";
944
+ let currentVariant;
945
+ const setStage = async (stage, variant, payload) => {
946
+ currentStage = stage;
947
+ currentVariant = variant;
948
+ await this.updateOpenapiJobStatus(job.id, "running", {
949
+ stage,
950
+ variant,
951
+ iteration,
952
+ totalUnits,
953
+ completedUnits,
954
+ payload: buildPayload(payload),
955
+ });
956
+ };
957
+ const completeStage = async (stage, variant, payload) => {
958
+ completedUnits += 1;
959
+ await this.updateOpenapiJobStatus(job.id, "running", {
960
+ stage,
961
+ variant,
962
+ iteration,
963
+ totalUnits,
964
+ completedUnits,
965
+ payload: buildPayload(payload),
966
+ });
967
+ };
968
+ const runWithTimeout = async (label, fn) => {
969
+ void label;
970
+ if (await this.isJobCancelled(job.id)) {
971
+ throw new OpenApiJobError("cancelled", `OpenAPI job ${job.id} was cancelled.`, job.id);
972
+ }
973
+ if (!timeoutAt)
974
+ return fn();
975
+ const remaining = timeoutAt - Date.now();
976
+ const timeoutSeconds = Math.max(1, Math.round((timeoutMs ?? 0) / 1000));
977
+ if (remaining <= 0) {
978
+ throw new OpenApiJobError("timeout", `OpenAPI job ${job.id} timed out after ${timeoutSeconds}s.`, job.id);
979
+ }
980
+ let timer;
981
+ try {
982
+ return await Promise.race([
983
+ fn(),
984
+ new Promise((_, reject) => {
985
+ timer = setTimeout(() => {
986
+ reject(new OpenApiJobError("timeout", `OpenAPI job ${job.id} timed out after ${timeoutSeconds}s.`, job.id));
987
+ }, remaining);
988
+ }),
989
+ ]);
990
+ }
991
+ finally {
992
+ if (timer)
993
+ clearTimeout(timer);
994
+ }
995
+ };
996
+ const heartbeat = this.startOpenapiHeartbeat(job.id, () => ({
997
+ stage: currentStage,
998
+ variant: currentVariant,
999
+ iteration,
1000
+ totalUnits,
1001
+ completedUnits,
1002
+ payload: buildPayload({ openapi_last_heartbeat_at: new Date().toISOString() }),
1003
+ }));
1004
+ const assertHeartbeat = () => {
1005
+ const error = heartbeat.getError();
1006
+ if (error)
1007
+ throw error;
1008
+ };
364
1009
  try {
1010
+ await setStage("context");
365
1011
  const projectKey = options.projectKey ?? this.workspace.config?.projectKey;
366
1012
  const assembler = new OpenapiContextAssembler(this.docdex, this.workspace, projectKey);
367
- const context = await assembler.build();
1013
+ const context = await runWithTimeout("context", () => assembler.build());
368
1014
  warnings.push(...context.warnings);
369
- await this.jobService.writeCheckpoint(job.id, {
370
- stage: "context_built",
371
- timestamp: new Date().toISOString(),
372
- details: { docdexAvailable: context.docdexAvailable },
1015
+ await this.writeOpenapiCheckpoint(job.id, "context_built", {
1016
+ docdexAvailable: context.docdexAvailable,
373
1017
  });
374
1018
  await this.jobService.recordTokenUsage({
375
1019
  timestamp: new Date().toISOString(),
376
1020
  workspaceId: this.workspace.workspaceId,
377
1021
  commandName: "openapi-from-docs",
378
1022
  jobId: job.id,
1023
+ commandRunId: commandRun.id,
379
1024
  action: "docdex_context",
380
1025
  promptTokens: 0,
381
1026
  completionTokens: 0,
382
1027
  metadata: { docdexAvailable: context.docdexAvailable },
383
1028
  });
384
- const openapiDir = await this.ensureOpenapiDir();
385
- const outputPath = path.join(openapiDir, "mcoda.yaml");
1029
+ await completeStage("context", undefined, { docdexAvailable: context.docdexAvailable });
1030
+ const adminCheck = await runWithTimeout("admin_check", () => this.collectAdminMentions());
1031
+ warnings.push(...adminCheck.warnings);
1032
+ const adminRequired = adminCheck.required;
1033
+ totalUnits = 1 + perVariantUnits * (adminRequired ? 2 : 1);
1034
+ await this.updateOpenapiJobStatus(job.id, "running", {
1035
+ stage: currentStage,
1036
+ variant: currentVariant,
1037
+ iteration,
1038
+ totalUnits,
1039
+ completedUnits,
1040
+ payload: buildPayload({ openapi_admin_required: adminRequired }),
1041
+ });
1042
+ assertHeartbeat();
386
1043
  if (options.validateOnly) {
1044
+ await setStage("validate", "primary");
387
1045
  if (!(await fileExists(outputPath))) {
388
1046
  throw new Error(`Cannot validate missing spec: ${outputPath}`);
389
1047
  }
390
- const { spec, issues } = await this.validateExistingSpec(outputPath);
1048
+ const primaryResult = await runWithTimeout("validate_primary", () => this.validateExistingSpec(outputPath));
1049
+ const issues = primaryResult.issues.map((issue) => `Primary spec: ${issue}`);
1050
+ let adminSpec;
1051
+ let adminResult;
1052
+ const adminExists = await fileExists(adminOutputPath);
1053
+ if (adminRequired && !adminExists) {
1054
+ throw new Error(`Admin spec required but missing: ${adminOutputPath}`);
1055
+ }
1056
+ if (adminExists) {
1057
+ adminResult = await runWithTimeout("validate_admin", () => this.validateExistingSpec(adminOutputPath));
1058
+ adminSpec = adminResult.spec;
1059
+ issues.push(...adminResult.issues.map((issue) => `Admin spec (${adminOutputPath}): ${issue}`));
1060
+ }
1061
+ if (adminResult?.doc && primaryResult.doc) {
1062
+ const primaryVersion = primaryResult.doc?.info?.version;
1063
+ const adminVersion = adminResult.doc?.info?.version;
1064
+ if (primaryVersion && adminVersion && primaryVersion !== adminVersion) {
1065
+ issues.push(`Admin spec info.version (${adminVersion}) does not match primary spec (${primaryVersion}).`);
1066
+ }
1067
+ const primaryOpenapi = primaryResult.doc?.openapi;
1068
+ const adminOpenapi = adminResult.doc?.openapi;
1069
+ if (primaryOpenapi && adminOpenapi && primaryOpenapi !== adminOpenapi) {
1070
+ issues.push(`Admin spec openapi version (${adminOpenapi}) does not match primary spec (${primaryOpenapi}).`);
1071
+ }
1072
+ }
391
1073
  const validationNote = issues.length ? `Validation issues:\n${issues.join("\n")}` : "Validation passed.";
392
1074
  await this.jobService.appendLog(job.id, `${validationNote}\n`);
1075
+ await completeStage("validate", "primary", { validation: validationNote });
1076
+ if (adminExists) {
1077
+ await completeStage("validate", "admin", { validation: validationNote });
1078
+ }
393
1079
  const jobState = issues.length ? "failed" : "completed";
394
1080
  const commandState = issues.length ? "failed" : "succeeded";
395
- await this.jobService.updateJobStatus(job.id, jobState, { payload: { validation: validationNote } });
1081
+ await this.jobService.updateJobStatus(job.id, jobState, {
1082
+ errorSummary: issues.length ? issues.join("; ") : undefined,
1083
+ payload: buildPayload({ validation: validationNote, openapi_admin_required: adminRequired }),
1084
+ });
396
1085
  await this.jobService.finishCommandRun(commandRun.id, commandState, issues.join("; "));
397
1086
  return {
398
1087
  jobId: job.id,
399
1088
  commandRunId: commandRun.id,
400
1089
  outputPath,
401
- spec,
1090
+ spec: primaryResult.spec,
1091
+ adminOutputPath: adminExists ? adminOutputPath : undefined,
1092
+ adminSpec,
402
1093
  warnings,
403
1094
  };
404
1095
  }
@@ -407,97 +1098,253 @@ export class OpenApiService {
407
1098
  }
408
1099
  const agent = await this.resolveAgent(options.agentName);
409
1100
  const stream = options.agentStream ?? true;
410
- let specYaml = "";
411
- let parsed;
412
- let adapter = agent.adapter;
413
- let agentMetadata;
414
- let lastErrors;
415
- for (let attempt = 0; attempt < 2; attempt += 1) {
416
- const prompt = this.buildPrompt(context, options.cliVersion, lastErrors);
417
- const { output, adapter: usedAdapter, metadata } = await this.invokeAgent(agent, prompt, stream, job.id, options.onToken);
418
- adapter = usedAdapter;
419
- agentMetadata = metadata;
420
- specYaml = this.sanitizeOutput(output);
421
- try {
422
- parsed = YAML.parse(specYaml);
423
- if (!parsed.info)
424
- parsed.info = {};
425
- const projectTitle = options.projectKey ?? this.workspace.config?.projectKey;
426
- parsed.info.title = parsed.info.title ?? projectTitle ?? "mcoda API";
427
- parsed.info.version = options.cliVersion;
428
- parsed.openapi = parsed.openapi ?? OPENAPI_VERSION;
429
- const errors = this.validateSpec(parsed);
430
- const validatorErrors = await this.runOpenapiValidator(parsed);
431
- errors.push(...validatorErrors);
432
- await this.jobService.recordTokenUsage({
433
- timestamp: new Date().toISOString(),
434
- workspaceId: this.workspace.workspaceId,
435
- commandName: "openapi-from-docs",
436
- jobId: job.id,
437
- agentId: agent.id,
438
- modelName: agent.defaultModel,
439
- action: attempt === 0 ? "draft_openapi" : "draft_openapi_retry",
440
- promptTokens: estimateTokens(prompt),
441
- completionTokens: estimateTokens(output),
442
- metadata: { adapter, provider: adapter, attempt },
443
- });
444
- if (errors.length === 0) {
445
- specYaml = YAML.stringify(parsed);
446
- break;
1101
+ let agentUsed = false;
1102
+ const generateVariant = async (variant, resumeDraft) => {
1103
+ const fallbackTitle = variant === "admin"
1104
+ ? `${projectKey ?? "mcoda"} Admin API`
1105
+ : projectKey ?? "mcoda API";
1106
+ if (resumeDraft) {
1107
+ try {
1108
+ const parsed = YAML.parse(resumeDraft);
1109
+ if (!parsed.info)
1110
+ parsed.info = {};
1111
+ parsed.info.title = parsed.info.title ?? fallbackTitle;
1112
+ parsed.info.version = options.cliVersion;
1113
+ parsed.openapi = OPENAPI_VERSION;
1114
+ const errors = this.validateSpec(parsed);
1115
+ const validatorErrors = await runWithTimeout("validate_resume", () => this.runOpenapiValidator(parsed));
1116
+ errors.push(...validatorErrors);
1117
+ if (errors.length === 0) {
1118
+ const specYaml = YAML.stringify(parsed);
1119
+ await this.writeOpenapiDraft(job.id, variant, specYaml);
1120
+ await this.writeOpenapiCheckpoint(job.id, `draft_${variant}_completed`, {
1121
+ variant,
1122
+ draftPath: variant === "admin" ? adminDraftPath : primaryDraftPath,
1123
+ resumed: true,
1124
+ });
1125
+ return { specYaml, parsed, adapter: "resume" };
1126
+ }
1127
+ warnings.push(`Saved ${variant} draft invalid; regenerating: ${errors.join("; ")}`);
447
1128
  }
448
- if (attempt === 1) {
449
- throw new Error(`Generated spec failed validation: ${errors.join("; ")}`);
1129
+ catch (error) {
1130
+ warnings.push(`Saved ${variant} draft could not be parsed; regenerating: ${error.message ?? String(error)}`);
450
1131
  }
451
- lastErrors = errors;
452
1132
  }
453
- catch (error) {
454
- if (attempt === 1) {
455
- throw new Error(error.message || "Failed to parse generated YAML");
1133
+ let specYaml = "";
1134
+ let parsed;
1135
+ let adapter = agent.adapter;
1136
+ let agentMetadata;
1137
+ let lastErrors;
1138
+ await setStage("draft", variant);
1139
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1140
+ const prompt = this.buildPrompt(context, options.cliVersion, lastErrors, variant);
1141
+ agentUsed = true;
1142
+ const { output, adapter: usedAdapter, metadata } = await runWithTimeout("draft_agent", async () => this.invokeAgent(agent, prompt, stream, job.id, options.onToken));
1143
+ adapter = usedAdapter;
1144
+ agentMetadata = metadata;
1145
+ specYaml = this.sanitizeOutput(output);
1146
+ try {
1147
+ parsed = YAML.parse(specYaml);
1148
+ if (!parsed.info)
1149
+ parsed.info = {};
1150
+ parsed.info.title = parsed.info.title ?? fallbackTitle;
1151
+ parsed.info.version = options.cliVersion;
1152
+ parsed.openapi = OPENAPI_VERSION;
1153
+ const errors = this.validateSpec(parsed);
1154
+ const validatorErrors = await runWithTimeout("validate_generated", () => this.runOpenapiValidator(parsed));
1155
+ errors.push(...validatorErrors);
1156
+ const action = variant === "admin"
1157
+ ? attempt === 0
1158
+ ? "draft_openapi_admin"
1159
+ : "draft_openapi_admin_retry"
1160
+ : attempt === 0
1161
+ ? "draft_openapi"
1162
+ : "draft_openapi_retry";
1163
+ await this.jobService.recordTokenUsage({
1164
+ timestamp: new Date().toISOString(),
1165
+ workspaceId: this.workspace.workspaceId,
1166
+ commandName: "openapi-from-docs",
1167
+ jobId: job.id,
1168
+ commandRunId: commandRun.id,
1169
+ agentId: agent.id,
1170
+ modelName: agent.defaultModel,
1171
+ action,
1172
+ promptTokens: estimateTokens(prompt),
1173
+ completionTokens: estimateTokens(output),
1174
+ metadata: {
1175
+ adapter,
1176
+ provider: adapter,
1177
+ attempt: attempt + 1,
1178
+ phase: action,
1179
+ variant,
1180
+ },
1181
+ });
1182
+ if (errors.length === 0) {
1183
+ specYaml = YAML.stringify(parsed);
1184
+ break;
1185
+ }
1186
+ if (attempt === 1) {
1187
+ throw new Error(`Generated ${variant} spec failed validation: ${errors.join("; ")}`);
1188
+ }
1189
+ lastErrors = errors;
456
1190
  }
457
- lastErrors = [error.message ?? "Invalid YAML"];
1191
+ catch (error) {
1192
+ if (attempt === 1) {
1193
+ throw new Error(error.message || `Failed to parse generated ${variant} YAML`);
1194
+ }
1195
+ lastErrors = [error.message ?? "Invalid YAML"];
1196
+ }
1197
+ assertHeartbeat();
458
1198
  }
1199
+ await this.writeOpenapiDraft(job.id, variant, specYaml);
1200
+ await this.writeOpenapiCheckpoint(job.id, `draft_${variant}_completed`, {
1201
+ variant,
1202
+ draftPath: variant === "admin" ? adminDraftPath : primaryDraftPath,
1203
+ });
1204
+ return { specYaml, parsed, adapter, agentMetadata };
1205
+ };
1206
+ const primarySpec = await generateVariant("primary", resumePrimaryDraft ?? resumeSpec);
1207
+ await completeStage("draft", "primary", { openapi_variant: "primary" });
1208
+ const adminSpec = adminRequired
1209
+ ? await generateVariant("admin", resumeAdminDraft ?? resumeAdminSpec)
1210
+ : undefined;
1211
+ if (adminSpec) {
1212
+ await completeStage("draft", "admin", { openapi_variant: "admin" });
459
1213
  }
460
1214
  let backup;
1215
+ let adminBackup;
1216
+ let docdexId = resumeDocdexId;
1217
+ let adminDocdexId = resumeAdminDocdexId;
1218
+ await setStage("write", "primary");
461
1219
  if (!options.dryRun) {
462
1220
  backup = await this.backupIfNeeded(outputPath);
463
- await fs.writeFile(outputPath, specYaml, "utf8");
1221
+ await runWithTimeout("write_primary", async () => fs.writeFile(outputPath, primarySpec.specYaml, "utf8"));
1222
+ if (context.docdexAvailable) {
1223
+ try {
1224
+ const registered = await runWithTimeout("docdex_primary", async () => this.registerOpenapi(outputPath, primarySpec.specYaml, "primary"));
1225
+ docdexId = registered.id;
1226
+ }
1227
+ catch (error) {
1228
+ warnings.push(`Docdex registration skipped: ${error.message}`);
1229
+ }
1230
+ }
464
1231
  }
465
1232
  else {
466
1233
  warnings.push("Dry run enabled; spec not written to disk.");
467
1234
  }
468
- let docdexId;
469
- if (!options.dryRun && context.docdexAvailable) {
470
- try {
471
- const registered = await this.registerOpenapi(outputPath, specYaml);
472
- docdexId = registered.id;
473
- }
474
- catch (error) {
475
- warnings.push(`Docdex registration skipped: ${error.message}`);
1235
+ await completeStage("write", "primary", {
1236
+ outputPath,
1237
+ backupPath: backup,
1238
+ docdexId,
1239
+ adapter: primarySpec.adapter,
1240
+ adminAdapter: adminSpec?.adapter,
1241
+ agentMetadata: primarySpec.agentMetadata,
1242
+ adminAgentMetadata: adminSpec?.agentMetadata,
1243
+ openapi_admin_required: adminRequired,
1244
+ });
1245
+ if (adminSpec) {
1246
+ await setStage("write", "admin");
1247
+ if (!options.dryRun) {
1248
+ adminBackup = await this.backupIfNeeded(adminOutputPath);
1249
+ await runWithTimeout("write_admin", async () => fs.writeFile(adminOutputPath, adminSpec.specYaml, "utf8"));
1250
+ if (context.docdexAvailable) {
1251
+ try {
1252
+ const registered = await runWithTimeout("docdex_admin", async () => this.registerOpenapi(adminOutputPath, adminSpec.specYaml, "admin"));
1253
+ adminDocdexId = registered.id;
1254
+ }
1255
+ catch (error) {
1256
+ warnings.push(`Admin Docdex registration skipped: ${error.message}`);
1257
+ }
1258
+ }
476
1259
  }
1260
+ await completeStage("write", "admin", {
1261
+ adminOutputPath,
1262
+ adminBackupPath: adminBackup,
1263
+ adminDocdexId,
1264
+ openapi_admin_required: adminRequired,
1265
+ });
477
1266
  }
478
- await this.jobService.updateJobStatus(job.id, "completed", {
479
- payload: {
1267
+ await this.updateOpenapiJobStatus(job.id, "completed", {
1268
+ stage: "complete",
1269
+ iteration,
1270
+ totalUnits,
1271
+ completedUnits: totalUnits,
1272
+ payload: buildPayload({
480
1273
  outputPath,
481
1274
  backupPath: backup,
1275
+ adminOutputPath: adminSpec ? adminOutputPath : undefined,
1276
+ adminBackupPath: adminBackup,
482
1277
  docdexId,
483
- adapter,
484
- agentMetadata,
485
- },
1278
+ adminDocdexId,
1279
+ adapter: primarySpec.adapter,
1280
+ adminAdapter: adminSpec?.adapter,
1281
+ agentMetadata: primarySpec.agentMetadata,
1282
+ adminAgentMetadata: adminSpec?.agentMetadata,
1283
+ openapi_admin_required: adminRequired,
1284
+ }),
486
1285
  });
487
1286
  await this.jobService.finishCommandRun(commandRun.id, "succeeded");
1287
+ if (options.rateAgents && agentUsed) {
1288
+ try {
1289
+ const ratingService = await this.ensureRatingService();
1290
+ await ratingService.rate({
1291
+ workspace: this.workspace,
1292
+ agentId: agent.id,
1293
+ commandName: "openapi-from-docs",
1294
+ jobId: job.id,
1295
+ commandRunId: commandRun.id,
1296
+ });
1297
+ }
1298
+ catch (error) {
1299
+ warnings.push(`Agent rating failed: ${error.message ?? String(error)}`);
1300
+ }
1301
+ }
488
1302
  return {
489
1303
  jobId: job.id,
490
1304
  commandRunId: commandRun.id,
491
1305
  outputPath: options.dryRun ? undefined : outputPath,
492
- spec: specYaml,
1306
+ spec: primarySpec.specYaml,
1307
+ adminOutputPath: options.dryRun ? undefined : adminSpec ? adminOutputPath : undefined,
1308
+ adminSpec: adminSpec?.specYaml,
493
1309
  docdexId,
1310
+ adminDocdexId,
494
1311
  warnings,
495
1312
  };
496
1313
  }
497
1314
  catch (error) {
498
- await this.jobService.updateJobStatus(job.id, "failed", { errorSummary: error.message });
499
- await this.jobService.finishCommandRun(commandRun.id, "failed", error.message);
1315
+ const message = error instanceof Error ? error.message : String(error);
1316
+ const isOpenapiError = error instanceof OpenApiJobError;
1317
+ if (job) {
1318
+ if (isOpenapiError && error.code === "cancelled") {
1319
+ await this.updateOpenapiJobStatus(job.id, "cancelled", {
1320
+ stage: "cancelled",
1321
+ iteration: options.iteration,
1322
+ totalUnits,
1323
+ completedUnits,
1324
+ payload: buildPayload({ openapi_admin_required: undefined }),
1325
+ errorSummary: message,
1326
+ });
1327
+ await this.jobService.finishCommandRun(commandRun.id, "cancelled", message);
1328
+ }
1329
+ else {
1330
+ await this.updateOpenapiJobStatus(job.id, "failed", {
1331
+ stage: isOpenapiError && error.code === "timeout" ? "timeout" : "failed",
1332
+ iteration: options.iteration,
1333
+ totalUnits,
1334
+ completedUnits,
1335
+ payload: buildPayload({ openapi_admin_required: undefined }),
1336
+ errorSummary: message,
1337
+ });
1338
+ await this.jobService.finishCommandRun(commandRun.id, "failed", message);
1339
+ }
1340
+ }
1341
+ else {
1342
+ await this.jobService.finishCommandRun(commandRun.id, "failed", message);
1343
+ }
500
1344
  throw error;
501
1345
  }
1346
+ finally {
1347
+ heartbeat.stop();
1348
+ }
502
1349
  }
503
1350
  }