@mcoda/core 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +2 -2
  2. package/dist/api/AgentsApi.d.ts +1 -0
  3. package/dist/api/AgentsApi.d.ts.map +1 -1
  4. package/dist/api/AgentsApi.js +136 -11
  5. package/dist/api/QaTasksApi.d.ts.map +1 -1
  6. package/dist/api/QaTasksApi.js +4 -0
  7. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  8. package/dist/prompts/PdrPrompts.js +6 -0
  9. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  10. package/dist/prompts/SdsPrompts.js +7 -0
  11. package/dist/services/agents/AgentRatingService.d.ts +19 -0
  12. package/dist/services/agents/AgentRatingService.d.ts.map +1 -1
  13. package/dist/services/agents/AgentRatingService.js +66 -2
  14. package/dist/services/agents/GatewayAgentService.d.ts +8 -0
  15. package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
  16. package/dist/services/agents/GatewayAgentService.js +462 -65
  17. package/dist/services/agents/GatewayHandoff.d.ts +5 -1
  18. package/dist/services/agents/GatewayHandoff.d.ts.map +1 -1
  19. package/dist/services/agents/GatewayHandoff.js +65 -32
  20. package/dist/services/agents/RoutingService.d.ts +1 -0
  21. package/dist/services/agents/RoutingService.d.ts.map +1 -1
  22. package/dist/services/agents/RoutingService.js +4 -4
  23. package/dist/services/backlog/BacklogService.d.ts +23 -0
  24. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  25. package/dist/services/backlog/BacklogService.js +62 -7
  26. package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
  27. package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
  28. package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
  29. package/dist/services/backlog/TaskOrderingService.d.ts +16 -4
  30. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  31. package/dist/services/backlog/TaskOrderingService.js +529 -73
  32. package/dist/services/docs/DocInventory.d.ts +11 -0
  33. package/dist/services/docs/DocInventory.d.ts.map +1 -0
  34. package/dist/services/docs/DocInventory.js +230 -0
  35. package/dist/services/docs/DocgenRunContext.d.ts +59 -0
  36. package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
  37. package/dist/services/docs/DocgenRunContext.js +4 -0
  38. package/dist/services/docs/DocsService.d.ts +59 -2
  39. package/dist/services/docs/DocsService.d.ts.map +1 -1
  40. package/dist/services/docs/DocsService.js +1701 -48
  41. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
  42. package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
  43. package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
  44. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
  45. package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
  46. package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
  47. package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
  48. package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
  49. package/dist/services/docs/patch/DocPatchEngine.js +331 -0
  50. package/dist/services/docs/review/Glossary.d.ts +16 -0
  51. package/dist/services/docs/review/Glossary.d.ts.map +1 -0
  52. package/dist/services/docs/review/Glossary.js +47 -0
  53. package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
  54. package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
  55. package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
  56. package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
  57. package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
  58. package/dist/services/docs/review/ReviewReportSchema.js +47 -0
  59. package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
  60. package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
  61. package/dist/services/docs/review/ReviewTypes.js +94 -0
  62. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
  63. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
  64. package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
  65. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
  66. package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
  67. package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
  68. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
  69. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
  70. package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
  71. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
  72. package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
  73. package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
  74. package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
  75. package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
  76. package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
  77. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
  78. package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
  79. package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
  80. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
  81. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
  82. package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
  83. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
  84. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
  85. package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
  86. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
  87. package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
  88. package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
  89. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
  90. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
  91. package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
  92. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
  93. package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
  94. package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
  95. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
  96. package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
  97. package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
  98. package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
  99. package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
  100. package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
  101. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
  102. package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
  103. package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
  104. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
  105. package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
  106. package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
  107. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
  108. package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
  109. package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
  110. package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
  111. package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
  112. package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
  113. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
  114. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
  115. package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
  116. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
  117. package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
  118. package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
  119. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
  120. package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
  121. package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
  122. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
  123. package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
  124. package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
  125. package/dist/services/docs/review/glossary.json +47 -0
  126. package/dist/services/estimate/EstimateService.d.ts +2 -0
  127. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  128. package/dist/services/estimate/EstimateService.js +66 -18
  129. package/dist/services/estimate/VelocityService.d.ts +4 -0
  130. package/dist/services/estimate/VelocityService.d.ts.map +1 -1
  131. package/dist/services/estimate/VelocityService.js +179 -36
  132. package/dist/services/estimate/types.d.ts +1 -0
  133. package/dist/services/estimate/types.d.ts.map +1 -1
  134. package/dist/services/execution/GatewayTrioService.d.ts +71 -4
  135. package/dist/services/execution/GatewayTrioService.d.ts.map +1 -1
  136. package/dist/services/execution/GatewayTrioService.js +1695 -328
  137. package/dist/services/execution/QaApiRunner.d.ts +30 -0
  138. package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
  139. package/dist/services/execution/QaApiRunner.js +881 -0
  140. package/dist/services/execution/QaFollowupService.d.ts +1 -0
  141. package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
  142. package/dist/services/execution/QaFollowupService.js +8 -2
  143. package/dist/services/execution/QaPlanValidator.d.ts +10 -0
  144. package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
  145. package/dist/services/execution/QaPlanValidator.js +128 -0
  146. package/dist/services/execution/QaProfileService.d.ts +21 -1
  147. package/dist/services/execution/QaProfileService.d.ts.map +1 -1
  148. package/dist/services/execution/QaProfileService.js +214 -29
  149. package/dist/services/execution/QaTasksService.d.ts +41 -1
  150. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  151. package/dist/services/execution/QaTasksService.js +2851 -500
  152. package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
  153. package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
  154. package/dist/services/execution/QaTestCommandBuilder.js +495 -0
  155. package/dist/services/execution/TaskSelectionService.d.ts +4 -2
  156. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  157. package/dist/services/execution/TaskSelectionService.js +144 -28
  158. package/dist/services/execution/TaskStateService.d.ts +19 -6
  159. package/dist/services/execution/TaskStateService.d.ts.map +1 -1
  160. package/dist/services/execution/TaskStateService.js +128 -13
  161. package/dist/services/execution/WorkOnTasksService.d.ts +19 -2
  162. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  163. package/dist/services/execution/WorkOnTasksService.js +3913 -1225
  164. package/dist/services/jobs/JobInsightsService.d.ts +4 -0
  165. package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
  166. package/dist/services/jobs/JobInsightsService.js +51 -5
  167. package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
  168. package/dist/services/jobs/JobResumeService.js +23 -10
  169. package/dist/services/jobs/JobService.d.ts +56 -4
  170. package/dist/services/jobs/JobService.d.ts.map +1 -1
  171. package/dist/services/jobs/JobService.js +232 -1
  172. package/dist/services/openapi/OpenApiService.d.ts +41 -0
  173. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  174. package/dist/services/openapi/OpenApiService.js +889 -98
  175. package/dist/services/planning/CreateTasksService.d.ts +15 -0
  176. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  177. package/dist/services/planning/CreateTasksService.js +311 -6
  178. package/dist/services/planning/RefineTasksService.d.ts +4 -0
  179. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  180. package/dist/services/planning/RefineTasksService.js +225 -24
  181. package/dist/services/review/CodeReviewService.d.ts +4 -0
  182. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  183. package/dist/services/review/CodeReviewService.js +778 -232
  184. package/dist/services/review/ReviewNormalizer.d.ts +9 -0
  185. package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
  186. package/dist/services/review/ReviewNormalizer.js +147 -0
  187. package/dist/services/shared/AuthErrors.d.ts +3 -0
  188. package/dist/services/shared/AuthErrors.d.ts.map +1 -0
  189. package/dist/services/shared/AuthErrors.js +17 -0
  190. package/dist/services/shared/DocdexGuidance.d.ts +7 -0
  191. package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
  192. package/dist/services/shared/DocdexGuidance.js +12 -0
  193. package/dist/services/shared/ProjectGuidance.d.ts +12 -1
  194. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  195. package/dist/services/shared/ProjectGuidance.js +64 -7
  196. package/dist/services/system/ToolDenylist.d.ts +13 -0
  197. package/dist/services/system/ToolDenylist.d.ts.map +1 -0
  198. package/dist/services/system/ToolDenylist.js +85 -0
  199. package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
  200. package/dist/services/telemetry/TelemetryService.js +39 -7
  201. package/dist/workspace/WorkspaceManager.d.ts +22 -0
  202. package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
  203. package/dist/workspace/WorkspaceManager.js +203 -32
  204. package/package.json +6 -5
@@ -8,12 +8,263 @@ import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
8
8
  import { JobService } from "../jobs/JobService.js";
9
9
  import { RoutingService } from "../agents/RoutingService.js";
10
10
  import { AgentRatingService } from "../agents/AgentRatingService.js";
11
+ import { buildDocInventory } from "../docs/DocInventory.js";
11
12
  const OPENAPI_TAGS = [
12
13
  // For project-specific specs, tags come from context; this is only a fallback.
13
14
  ];
14
15
  const OPENAPI_VERSION = "3.1.0";
16
+ const PRIMARY_OPENAPI_FILENAME = "mcoda.yaml";
17
+ const ADMIN_OPENAPI_FILENAME = "mcoda-admin.yaml";
15
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";
16
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
+ };
17
268
  const fileExists = async (candidate) => {
18
269
  try {
19
270
  await fs.access(candidate);
@@ -48,9 +299,12 @@ class OpenapiContextAssembler {
48
299
  }
49
300
  async findLatestLocalDoc(docType) {
50
301
  const candidates = [];
51
- const dirNames = [".mcoda/docs", "docs"];
52
- for (const dir of dirNames) {
53
- 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());
54
308
  try {
55
309
  const entries = await fs.readdir(target);
56
310
  for (const entry of entries.filter((e) => e.endsWith(".md"))) {
@@ -204,7 +458,8 @@ class OpenapiContextAssembler {
204
458
  export class OpenApiService {
205
459
  constructor(workspace, deps) {
206
460
  this.workspace = workspace;
207
- 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 });
208
463
  this.jobService = deps?.jobService ?? new JobService(workspace, undefined, { noTelemetry: deps?.noTelemetry });
209
464
  this.agentService = deps.agentService;
210
465
  this.routingService = deps.routingService;
@@ -216,9 +471,11 @@ export class OpenApiService {
216
471
  const repo = await GlobalRepository.create();
217
472
  const agentService = new AgentService(repo);
218
473
  const routingService = await RoutingService.create();
474
+ const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
219
475
  const docdex = new DocdexClient({
220
476
  workspaceRoot: workspace.workspaceRoot,
221
477
  baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
478
+ repoId: docdexRepoId,
222
479
  });
223
480
  const jobService = new JobService(workspace, undefined, { noTelemetry: options.noTelemetry });
224
481
  return new OpenApiService(workspace, { agentService, routingService, docdex, jobService, repo, noTelemetry: options.noTelemetry });
@@ -306,20 +563,7 @@ export class OpenApiService {
306
563
  return body.trim();
307
564
  }
308
565
  validateSpec(doc) {
309
- const errors = [];
310
- if (!doc || typeof doc !== "object") {
311
- errors.push("Spec is not a YAML object.");
312
- return errors;
313
- }
314
- if (!doc.openapi)
315
- errors.push("Missing openapi version");
316
- if (!doc.info?.title)
317
- errors.push("Missing info.title");
318
- if (!doc.info?.version)
319
- errors.push("Missing info.version");
320
- if (!doc.paths)
321
- errors.push("paths section is required (can be empty if no HTTP API)");
322
- return errors;
566
+ return validateOpenApiSchema(doc);
323
567
  }
324
568
  async runOpenapiValidator(doc) {
325
569
  try {
@@ -334,19 +578,27 @@ export class OpenApiService {
334
578
  return [error.message];
335
579
  }
336
580
  }
337
- buildPrompt(context, cliVersion, retryReasons) {
581
+ buildPrompt(context, cliVersion, retryReasons, variant = "primary") {
338
582
  const contextBlocks = context.blocks
339
583
  .map((block) => `### ${block.label}\n${block.content}`)
340
584
  .join("\n\n");
341
585
  const retryNote = retryReasons?.length
342
586
  ? `\nPrevious attempt issues:\n${retryReasons.map((r) => `- ${r}`).join("\n")}\nFix them in this draft.\n`
343
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
+ : "";
344
595
  return [
345
596
  "You are generating an OpenAPI 3.1 YAML for THIS workspace/project using only the provided PDR/SDS/RFP context.",
346
597
  "Derive resources, schemas, and HTTP endpoints directly from the product requirements (e.g., todos CRUD, filters, search, bulk actions).",
347
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.",
348
599
  "Prefer concise tags derived from domain resources (e.g., Todos). Avoid generic mcoda/system endpoints unless explicitly described in the context.",
349
600
  `Use OpenAPI version ${OPENAPI_VERSION}, set info.title to the project name from context (fallback \"mcoda API\"), and info.version ${cliVersion}.`,
601
+ adminNote,
350
602
  "Return only valid YAML (no Markdown fences, no commentary).",
351
603
  retryNote,
352
604
  "Scope rules:",
@@ -369,7 +621,7 @@ export class OpenApiService {
369
621
  await fs.copyFile(target, backup);
370
622
  return backup;
371
623
  }
372
- async registerOpenapi(outPath, content) {
624
+ async registerOpenapi(outPath, content, variant = "primary") {
373
625
  const branch = this.workspace.config?.branch ?? (await readGitBranch(this.workspace.workspaceRoot));
374
626
  return this.docdex.registerDocument({
375
627
  docType: "OPENAPI",
@@ -380,6 +632,7 @@ export class OpenApiService {
380
632
  branch,
381
633
  status: "canonical",
382
634
  projectKey: this.workspace.config?.projectKey,
635
+ variant,
383
636
  },
384
637
  });
385
638
  }
@@ -389,24 +642,378 @@ export class OpenApiService {
389
642
  const issues = this.validateSpec(parsed);
390
643
  const validatorIssues = await this.runOpenapiValidator(parsed);
391
644
  issues.push(...validatorIssues);
392
- 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
+ };
393
858
  }
394
859
  async generateFromDocs(options) {
860
+ const warnings = [];
395
861
  const commandRun = await this.jobService.startCommandRun("openapi-from-docs", options.projectKey);
396
- const job = await this.jobService.startJob("openapi_change", commandRun.id, options.projectKey, {
397
- commandName: commandRun.commandName,
398
- 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,
399
938
  });
400
- 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
+ };
401
1009
  try {
1010
+ await setStage("context");
402
1011
  const projectKey = options.projectKey ?? this.workspace.config?.projectKey;
403
1012
  const assembler = new OpenapiContextAssembler(this.docdex, this.workspace, projectKey);
404
- const context = await assembler.build();
1013
+ const context = await runWithTimeout("context", () => assembler.build());
405
1014
  warnings.push(...context.warnings);
406
- await this.jobService.writeCheckpoint(job.id, {
407
- stage: "context_built",
408
- timestamp: new Date().toISOString(),
409
- details: { docdexAvailable: context.docdexAvailable },
1015
+ await this.writeOpenapiCheckpoint(job.id, "context_built", {
1016
+ docdexAvailable: context.docdexAvailable,
410
1017
  });
411
1018
  await this.jobService.recordTokenUsage({
412
1019
  timestamp: new Date().toISOString(),
@@ -419,24 +1026,70 @@ export class OpenApiService {
419
1026
  completionTokens: 0,
420
1027
  metadata: { docdexAvailable: context.docdexAvailable },
421
1028
  });
422
- const openapiDir = await this.ensureOpenapiDir();
423
- 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();
424
1043
  if (options.validateOnly) {
1044
+ await setStage("validate", "primary");
425
1045
  if (!(await fileExists(outputPath))) {
426
1046
  throw new Error(`Cannot validate missing spec: ${outputPath}`);
427
1047
  }
428
- 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
+ }
429
1073
  const validationNote = issues.length ? `Validation issues:\n${issues.join("\n")}` : "Validation passed.";
430
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
+ }
431
1079
  const jobState = issues.length ? "failed" : "completed";
432
1080
  const commandState = issues.length ? "failed" : "succeeded";
433
- 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
+ });
434
1085
  await this.jobService.finishCommandRun(commandRun.id, commandState, issues.join("; "));
435
1086
  return {
436
1087
  jobId: job.id,
437
1088
  commandRunId: commandRun.id,
438
1089
  outputPath,
439
- spec,
1090
+ spec: primaryResult.spec,
1091
+ adminOutputPath: adminExists ? adminOutputPath : undefined,
1092
+ adminSpec,
440
1093
  warnings,
441
1094
  };
442
1095
  }
@@ -445,85 +1098,190 @@ export class OpenApiService {
445
1098
  }
446
1099
  const agent = await this.resolveAgent(options.agentName);
447
1100
  const stream = options.agentStream ?? true;
448
- let specYaml = "";
449
- let parsed;
450
- let adapter = agent.adapter;
451
- let agentMetadata;
452
- let lastErrors;
453
1101
  let agentUsed = false;
454
- for (let attempt = 0; attempt < 2; attempt += 1) {
455
- const prompt = this.buildPrompt(context, options.cliVersion, lastErrors);
456
- agentUsed = true;
457
- const { output, adapter: usedAdapter, metadata } = await this.invokeAgent(agent, prompt, stream, job.id, options.onToken);
458
- adapter = usedAdapter;
459
- agentMetadata = metadata;
460
- specYaml = this.sanitizeOutput(output);
461
- try {
462
- parsed = YAML.parse(specYaml);
463
- if (!parsed.info)
464
- parsed.info = {};
465
- const projectTitle = options.projectKey ?? this.workspace.config?.projectKey;
466
- parsed.info.title = parsed.info.title ?? projectTitle ?? "mcoda API";
467
- parsed.info.version = options.cliVersion;
468
- parsed.openapi = parsed.openapi ?? OPENAPI_VERSION;
469
- const errors = this.validateSpec(parsed);
470
- const validatorErrors = await this.runOpenapiValidator(parsed);
471
- errors.push(...validatorErrors);
472
- await this.jobService.recordTokenUsage({
473
- timestamp: new Date().toISOString(),
474
- workspaceId: this.workspace.workspaceId,
475
- commandName: "openapi-from-docs",
476
- jobId: job.id,
477
- commandRunId: commandRun.id,
478
- agentId: agent.id,
479
- modelName: agent.defaultModel,
480
- action: attempt === 0 ? "draft_openapi" : "draft_openapi_retry",
481
- promptTokens: estimateTokens(prompt),
482
- completionTokens: estimateTokens(output),
483
- metadata: { adapter, provider: adapter, attempt },
484
- });
485
- if (errors.length === 0) {
486
- specYaml = YAML.stringify(parsed);
487
- break;
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("; ")}`);
488
1128
  }
489
- if (attempt === 1) {
490
- 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)}`);
491
1131
  }
492
- lastErrors = errors;
493
1132
  }
494
- catch (error) {
495
- if (attempt === 1) {
496
- 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;
1190
+ }
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"];
497
1196
  }
498
- lastErrors = [error.message ?? "Invalid YAML"];
1197
+ assertHeartbeat();
499
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" });
500
1213
  }
501
1214
  let backup;
1215
+ let adminBackup;
1216
+ let docdexId = resumeDocdexId;
1217
+ let adminDocdexId = resumeAdminDocdexId;
1218
+ await setStage("write", "primary");
502
1219
  if (!options.dryRun) {
503
1220
  backup = await this.backupIfNeeded(outputPath);
504
- 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
+ }
505
1231
  }
506
1232
  else {
507
1233
  warnings.push("Dry run enabled; spec not written to disk.");
508
1234
  }
509
- let docdexId;
510
- if (!options.dryRun && context.docdexAvailable) {
511
- try {
512
- const registered = await this.registerOpenapi(outputPath, specYaml);
513
- docdexId = registered.id;
514
- }
515
- catch (error) {
516
- 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
+ }
517
1259
  }
1260
+ await completeStage("write", "admin", {
1261
+ adminOutputPath,
1262
+ adminBackupPath: adminBackup,
1263
+ adminDocdexId,
1264
+ openapi_admin_required: adminRequired,
1265
+ });
518
1266
  }
519
- await this.jobService.updateJobStatus(job.id, "completed", {
520
- payload: {
1267
+ await this.updateOpenapiJobStatus(job.id, "completed", {
1268
+ stage: "complete",
1269
+ iteration,
1270
+ totalUnits,
1271
+ completedUnits: totalUnits,
1272
+ payload: buildPayload({
521
1273
  outputPath,
522
1274
  backupPath: backup,
1275
+ adminOutputPath: adminSpec ? adminOutputPath : undefined,
1276
+ adminBackupPath: adminBackup,
523
1277
  docdexId,
524
- adapter,
525
- agentMetadata,
526
- },
1278
+ adminDocdexId,
1279
+ adapter: primarySpec.adapter,
1280
+ adminAdapter: adminSpec?.adapter,
1281
+ agentMetadata: primarySpec.agentMetadata,
1282
+ adminAgentMetadata: adminSpec?.agentMetadata,
1283
+ openapi_admin_required: adminRequired,
1284
+ }),
527
1285
  });
528
1286
  await this.jobService.finishCommandRun(commandRun.id, "succeeded");
529
1287
  if (options.rateAgents && agentUsed) {
@@ -545,15 +1303,48 @@ export class OpenApiService {
545
1303
  jobId: job.id,
546
1304
  commandRunId: commandRun.id,
547
1305
  outputPath: options.dryRun ? undefined : outputPath,
548
- spec: specYaml,
1306
+ spec: primarySpec.specYaml,
1307
+ adminOutputPath: options.dryRun ? undefined : adminSpec ? adminOutputPath : undefined,
1308
+ adminSpec: adminSpec?.specYaml,
549
1309
  docdexId,
1310
+ adminDocdexId,
550
1311
  warnings,
551
1312
  };
552
1313
  }
553
1314
  catch (error) {
554
- await this.jobService.updateJobStatus(job.id, "failed", { errorSummary: error.message });
555
- 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
+ }
556
1344
  throw error;
557
1345
  }
1346
+ finally {
1347
+ heartbeat.stop();
1348
+ }
558
1349
  }
559
1350
  }