mstro-app 0.4.38 → 0.4.43

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 (198) hide show
  1. package/bin/commands/login.js +17 -7
  2. package/bin/commands/logout.js +14 -6
  3. package/bin/commands/status.js +9 -3
  4. package/bin/commands/whoami.js +10 -4
  5. package/bin/mstro.js +11 -1
  6. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  8. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  9. package/dist/server/cli/headless/index.d.ts +1 -0
  10. package/dist/server/cli/headless/index.d.ts.map +1 -1
  11. package/dist/server/cli/headless/index.js +2 -0
  12. package/dist/server/cli/headless/index.js.map +1 -1
  13. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  14. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  15. package/dist/server/cli/headless/resilient-runner.js +234 -0
  16. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  17. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  19. package/dist/server/cli/headless/retry-strategies.js +262 -0
  20. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +5 -0
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  28. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-retry.js +1 -30
  30. package/dist/server/cli/improvisation-retry.js.map +1 -1
  31. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  32. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  33. package/dist/server/cli/improvisation-session-manager.js +16 -3
  34. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  35. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  36. package/dist/server/cli/prompt-builders.js +31 -13
  37. package/dist/server/cli/prompt-builders.js.map +1 -1
  38. package/dist/server/index.js +1 -9
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/mcp/bouncer-cli.js +5 -4
  41. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  42. package/dist/server/mcp/bouncer-haiku.js +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  44. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.js +14 -8
  46. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  47. package/dist/server/mcp/security-patterns.js +1 -1
  48. package/dist/server/mcp/security-patterns.js.map +1 -1
  49. package/dist/server/services/plan/composer.d.ts.map +1 -1
  50. package/dist/server/services/plan/composer.js +19 -9
  51. package/dist/server/services/plan/composer.js.map +1 -1
  52. package/dist/server/services/plan/executor.d.ts +6 -1
  53. package/dist/server/services/plan/executor.d.ts.map +1 -1
  54. package/dist/server/services/plan/executor.js +158 -76
  55. package/dist/server/services/plan/executor.js.map +1 -1
  56. package/dist/server/services/plan/front-matter.d.ts +1 -0
  57. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  58. package/dist/server/services/plan/front-matter.js +6 -0
  59. package/dist/server/services/plan/front-matter.js.map +1 -1
  60. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  61. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  62. package/dist/server/services/plan/issue-classification.js +20 -0
  63. package/dist/server/services/plan/issue-classification.js.map +1 -0
  64. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  65. package/dist/server/services/plan/issue-prompt-builder.js +10 -5
  66. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  67. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  68. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  69. package/dist/server/services/plan/issue-retry.js +12 -241
  70. package/dist/server/services/plan/issue-retry.js.map +1 -1
  71. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  72. package/dist/server/services/plan/parser-core.js +1 -0
  73. package/dist/server/services/plan/parser-core.js.map +1 -1
  74. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  75. package/dist/server/services/plan/review-gate.js +9 -6
  76. package/dist/server/services/plan/review-gate.js.map +1 -1
  77. package/dist/server/services/plan/types.d.ts +1 -0
  78. package/dist/server/services/plan/types.d.ts.map +1 -1
  79. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.js +11 -4
  81. package/dist/server/services/platform-credentials.js.map +1 -1
  82. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.js +7 -1
  84. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  85. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  87. package/dist/server/services/websocket/handler.d.ts +2 -0
  88. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  89. package/dist/server/services/websocket/handler.js +18 -7
  90. package/dist/server/services/websocket/handler.js.map +1 -1
  91. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  92. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  93. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  94. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  95. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  96. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  97. package/dist/server/services/websocket/quality-handlers.js +48 -7
  98. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  99. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  100. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-persistence.js +48 -1
  102. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  103. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  104. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  105. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  106. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  107. package/dist/server/services/websocket/quality-tools.js +18 -18
  108. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  109. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  110. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  111. package/dist/server/services/websocket/skill-handlers.js +52 -41
  112. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  113. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  114. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  115. package/dist/server/services/websocket/skill-watcher.js +85 -0
  116. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  117. package/dist/server/services/websocket/types.d.ts +2 -268
  118. package/dist/server/services/websocket/types.d.ts.map +1 -1
  119. package/dist/server/services/websocket/types.js +0 -4
  120. package/dist/server/services/websocket/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  123. package/server/cli/headless/index.ts +2 -0
  124. package/server/cli/headless/resilient-runner.ts +354 -0
  125. package/server/cli/headless/retry-strategies.ts +330 -0
  126. package/server/cli/headless/stall-assessor.ts +5 -0
  127. package/server/cli/headless/tool-watchdog.ts +40 -4
  128. package/server/cli/improvisation-retry.ts +1 -32
  129. package/server/cli/improvisation-session-manager.ts +17 -3
  130. package/server/cli/prompt-builders.ts +33 -12
  131. package/server/index.ts +1 -9
  132. package/server/mcp/bouncer-cli.ts +5 -4
  133. package/server/mcp/bouncer-haiku.ts +1 -1
  134. package/server/mcp/bouncer-integration.ts +15 -8
  135. package/server/mcp/security-patterns.ts +1 -1
  136. package/server/services/plan/agents/code-review.md +109 -0
  137. package/server/services/plan/agents/commit-message.md +26 -0
  138. package/server/services/plan/agents/execute-issue.md +10 -1
  139. package/server/services/plan/agents/fix-quality.md +24 -0
  140. package/server/services/plan/agents/pr-description.md +28 -0
  141. package/server/services/plan/composer.ts +20 -9
  142. package/server/services/plan/executor.ts +160 -76
  143. package/server/services/plan/front-matter.ts +7 -0
  144. package/server/services/plan/issue-classification.ts +21 -0
  145. package/server/services/plan/issue-prompt-builder.ts +11 -5
  146. package/server/services/plan/issue-retry.ts +15 -330
  147. package/server/services/plan/parser-core.ts +1 -0
  148. package/server/services/plan/review-gate.ts +9 -6
  149. package/server/services/plan/types.ts +3 -0
  150. package/server/services/platform-credentials.ts +10 -4
  151. package/server/services/terminal/pty-manager.ts +7 -1
  152. package/server/services/websocket/handler-context.ts +2 -0
  153. package/server/services/websocket/handler.ts +18 -8
  154. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  155. package/server/services/websocket/quality-fix-agent.ts +86 -44
  156. package/server/services/websocket/quality-handlers.ts +48 -7
  157. package/server/services/websocket/quality-persistence.ts +75 -1
  158. package/server/services/websocket/quality-review-agent.ts +70 -31
  159. package/server/services/websocket/quality-tools.ts +16 -14
  160. package/server/services/websocket/skill-handlers.ts +50 -40
  161. package/server/services/websocket/skill-watcher.ts +79 -0
  162. package/server/services/websocket/types.ts +0 -311
  163. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  164. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  165. package/dist/server/services/deploy/ai-broker.js +0 -360
  166. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  167. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  168. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  169. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  170. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  171. package/dist/server/services/deploy/credentials.d.ts +0 -35
  172. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  173. package/dist/server/services/deploy/credentials.js +0 -177
  174. package/dist/server/services/deploy/credentials.js.map +0 -1
  175. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  176. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  177. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  178. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  179. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  180. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  181. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  182. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  183. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  184. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  185. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  186. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  187. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  188. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  189. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  190. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  191. package/server/cli/headless/RESEARCH.md +0 -627
  192. package/server/services/deploy/ai-broker.ts +0 -512
  193. package/server/services/deploy/board-execution-handler.ts +0 -847
  194. package/server/services/deploy/credentials.ts +0 -200
  195. package/server/services/deploy/deploy-ai-service.ts +0 -401
  196. package/server/services/deploy/headless-session-handler.ts +0 -414
  197. package/server/services/websocket/deploy-handlers.ts +0 -544
  198. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -1,512 +0,0 @@
1
- // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
- // Licensed under the MIT License. See LICENSE file for details.
3
-
4
- /**
5
- * AI Broker — HTTP endpoint for developer backends to invoke AI execution.
6
- *
7
- * POST /api/deploy/ai/invoke
8
- * Accepts { capability, deploymentId, endUserId, prompt, ... }
9
- * Authorization: Bearer <deploy-token>
10
- *
11
- * GET /api/deploy/ai/jobs/:jobId
12
- * Poll board execution status.
13
- *
14
- * Deploy tokens are per-deployment. The CLI stores the SHA-256 hash; the
15
- * developer's backend sends the raw token. We hash the incoming token and
16
- * compare against the stored hash.
17
- *
18
- * Headless sessions return SSE (text/event-stream).
19
- * Board executions return { jobId, statusUrl } immediately.
20
- */
21
-
22
- import { createHash } from 'node:crypto';
23
- import { type Context, Hono } from 'hono';
24
- import { streamSSE } from 'hono/streaming';
25
- import {
26
- type BoardExecutionConfig,
27
- getBoardExecutionStatus,
28
- startBoardExecution,
29
- } from './board-execution-handler.js';
30
- import {
31
- type DeploymentAiConfig,
32
- type HeadlessSessionStreamCallbacks,
33
- type HealthUpdateData,
34
- handleHeadlessSession,
35
- type UsageReportData,
36
- } from './headless-session-handler.js';
37
-
38
- // ========== Types ==========
39
-
40
- export interface AiBrokerInvokeBody {
41
- capability: 'headless' | 'pm-board';
42
- deploymentId: string;
43
- endUserId: string;
44
- prompt: string;
45
- boardTemplateId?: string;
46
- systemPrompt?: string;
47
- allowedTools?: string[];
48
- model?: string;
49
- }
50
-
51
- interface DeployTokenRecord {
52
- deploymentId: string;
53
- tokenHash: string;
54
- capabilities: ('headless' | 'pm-board')[];
55
- rateLimit: {
56
- maxRequestsPerMinute: number | null;
57
- maxConcurrentSessions: number;
58
- };
59
- aiConfig: {
60
- aiEnabled: boolean;
61
- defaultSystemPrompt: string | null;
62
- defaultModel: string;
63
- maxTokensPerRequest: number | null;
64
- workingDir: string;
65
- allowedBoardTemplateIds: string[];
66
- maxConcurrentBoardExecutions: number;
67
- maxBoardExecutionsPerMinute: number | null;
68
- };
69
- /** When set, the deployment requires payment and this URL is returned on 402 */
70
- paymentUrl?: string;
71
- /** Whether the deployment is currently active */
72
- enabled: boolean;
73
- }
74
-
75
- // ========== Token Store ==========
76
-
77
- /**
78
- * In-memory store for deploy tokens. Populated when deployments are created
79
- * via the WebSocket handlers. Each entry maps a deployment ID to its
80
- * hashed token and configuration.
81
- */
82
- const tokenStore = new Map<string, DeployTokenRecord>();
83
-
84
- export function registerDeployToken(record: DeployTokenRecord): void {
85
- tokenStore.set(record.deploymentId, record);
86
- }
87
-
88
- export function unregisterDeployToken(deploymentId: string): void {
89
- tokenStore.delete(deploymentId);
90
- }
91
-
92
- export function getDeployTokenRecord(deploymentId: string): DeployTokenRecord | undefined {
93
- return tokenStore.get(deploymentId);
94
- }
95
-
96
- /**
97
- * Update rate limit and AI config on an existing deploy token record.
98
- * Called when the server syncs updated deployment config to the CLI.
99
- */
100
- export function updateDeployTokenConfig(
101
- deploymentId: string,
102
- updates: {
103
- maxRequestsPerMinute?: number | null;
104
- maxConcurrentSessions?: number;
105
- maxTokensPerRequest?: number | null;
106
- aiEnabled?: boolean;
107
- },
108
- ): boolean {
109
- const record = tokenStore.get(deploymentId);
110
- if (!record) return false;
111
-
112
- if (updates.maxRequestsPerMinute !== undefined) {
113
- record.rateLimit.maxRequestsPerMinute = updates.maxRequestsPerMinute;
114
- }
115
- if (updates.maxConcurrentSessions !== undefined) {
116
- record.rateLimit.maxConcurrentSessions = updates.maxConcurrentSessions;
117
- }
118
- if (updates.maxTokensPerRequest !== undefined) {
119
- record.aiConfig.maxTokensPerRequest = updates.maxTokensPerRequest;
120
- }
121
- if (updates.aiEnabled !== undefined) {
122
- record.aiConfig.aiEnabled = updates.aiEnabled;
123
- }
124
-
125
- return true;
126
- }
127
-
128
- // ========== Usage & Health Listeners ==========
129
-
130
- type UsageReportListener = (report: UsageReportData) => void;
131
- type HealthUpdateListener = (update: HealthUpdateData) => void;
132
-
133
- let usageReportListener: UsageReportListener | null = null;
134
- let healthUpdateListener: HealthUpdateListener | null = null;
135
-
136
- /**
137
- * Register a listener for deploy usage reports.
138
- * Called from the server setup to wire usage reports to the platform connection.
139
- */
140
- export function setDeployUsageReportListener(listener: UsageReportListener): void {
141
- usageReportListener = listener;
142
- }
143
-
144
- /**
145
- * Register a listener for deploy AI health updates.
146
- * Called from the server setup to wire health updates to the platform connection.
147
- */
148
- export function setDeployHealthUpdateListener(listener: HealthUpdateListener): void {
149
- healthUpdateListener = listener;
150
- }
151
-
152
- // ========== Token Validation ==========
153
-
154
- function hashToken(token: string): string {
155
- return createHash('sha256').update(token).digest('hex');
156
- }
157
-
158
- function extractBearerToken(authHeader: string | undefined): string | null {
159
- if (!authHeader) return null;
160
- const match = authHeader.match(/^Bearer\s+(.+)$/i);
161
- return match ? match[1] : null;
162
- }
163
-
164
- /**
165
- * Validate a deploy token against the stored hash.
166
- * Returns the token record if valid, null otherwise.
167
- */
168
- function validateDeployToken(
169
- rawToken: string,
170
- deploymentId: string,
171
- ): DeployTokenRecord | null {
172
- const record = tokenStore.get(deploymentId);
173
- if (!record) return null;
174
-
175
- const incomingHash = hashToken(rawToken);
176
- if (incomingHash !== record.tokenHash) return null;
177
-
178
- return record;
179
- }
180
-
181
- // ========== Rate Limiter ==========
182
-
183
- interface BrokerRateBucket {
184
- timestamps: number[];
185
- activeSessions: number;
186
- }
187
-
188
- const brokerRateBuckets = new Map<string, BrokerRateBucket>();
189
-
190
- function getBucket(key: string): BrokerRateBucket {
191
- let bucket = brokerRateBuckets.get(key);
192
- if (!bucket) {
193
- bucket = { timestamps: [], activeSessions: 0 };
194
- brokerRateBuckets.set(key, bucket);
195
- }
196
- return bucket;
197
- }
198
-
199
- function pruneTimestamps(bucket: BrokerRateBucket): void {
200
- const oneMinuteAgo = Date.now() - 60_000;
201
- while (bucket.timestamps.length > 0 && bucket.timestamps[0] < oneMinuteAgo) {
202
- bucket.timestamps.shift();
203
- }
204
- }
205
-
206
- function checkBrokerRateLimit(
207
- record: DeployTokenRecord,
208
- ): { limited: boolean; retryAfterMs?: number } {
209
- const bucket = getBucket(record.deploymentId);
210
-
211
- if (bucket.activeSessions >= record.rateLimit.maxConcurrentSessions) {
212
- return { limited: true, retryAfterMs: 5_000 };
213
- }
214
-
215
- if (record.rateLimit.maxRequestsPerMinute !== null) {
216
- pruneTimestamps(bucket);
217
- if (bucket.timestamps.length >= record.rateLimit.maxRequestsPerMinute) {
218
- // Calculate retry-after based on oldest timestamp expiry
219
- const oldestTs = bucket.timestamps[0];
220
- const retryAfterMs = oldestTs + 60_000 - Date.now();
221
- return { limited: true, retryAfterMs: Math.max(1_000, retryAfterMs) };
222
- }
223
- }
224
-
225
- return { limited: false };
226
- }
227
-
228
- function recordBrokerRequestStart(deploymentId: string): void {
229
- const bucket = getBucket(deploymentId);
230
- bucket.timestamps.push(Date.now());
231
- bucket.activeSessions++;
232
- }
233
-
234
- function recordBrokerRequestEnd(deploymentId: string): void {
235
- const bucket = getBucket(deploymentId);
236
- bucket.activeSessions = Math.max(0, bucket.activeSessions - 1);
237
- }
238
-
239
- // ========== Request Validation ==========
240
-
241
- type ValidatedRequest =
242
- | { ok: true; body: AiBrokerInvokeBody; record: DeployTokenRecord }
243
- | { ok: false; error: string; status: number; headers?: Record<string, string> };
244
-
245
- function validateBody(body: AiBrokerInvokeBody): string | null {
246
- if (!body.capability || !body.deploymentId || !body.endUserId || !body.prompt) {
247
- return 'Missing required fields: capability, deploymentId, endUserId, prompt';
248
- }
249
- if (body.capability !== 'headless' && body.capability !== 'pm-board') {
250
- return "Invalid capability. Must be 'headless' or 'pm-board'";
251
- }
252
- if (body.capability === 'pm-board' && !body.boardTemplateId) {
253
- return "boardTemplateId is required when capability is 'pm-board'";
254
- }
255
- return null;
256
- }
257
-
258
- function validateTokenAndConfig(
259
- rawToken: string,
260
- body: AiBrokerInvokeBody,
261
- ): ValidatedRequest {
262
- const record = validateDeployToken(rawToken, body.deploymentId);
263
- if (!record) {
264
- return { ok: false, error: 'Invalid deploy token', status: 401 };
265
- }
266
- if (!record.enabled) {
267
- return { ok: false, error: 'Deployment is disabled', status: 403 };
268
- }
269
- if (!record.aiConfig.aiEnabled) {
270
- return { ok: false, error: 'AI features are not enabled for this deployment', status: 403 };
271
- }
272
- if (!record.capabilities.includes(body.capability)) {
273
- return { ok: false, error: `Capability '${body.capability}' is not enabled for this deployment`, status: 403 };
274
- }
275
-
276
- const rateCheck = checkBrokerRateLimit(record);
277
- if (rateCheck.limited) {
278
- const retryAfterSec = Math.ceil((rateCheck.retryAfterMs ?? 5_000) / 1_000);
279
- return {
280
- ok: false,
281
- error: 'Rate limit exceeded. Try again later.',
282
- status: 429,
283
- headers: { 'Retry-After': String(retryAfterSec) },
284
- };
285
- }
286
-
287
- return { ok: true, body, record };
288
- }
289
-
290
- // ========== Route Factory ==========
291
-
292
- export function createAiBrokerRoutes(): Hono {
293
- const routes = new Hono();
294
-
295
- // ── POST /invoke — trigger AI execution ────────────────────
296
-
297
- routes.post('/invoke', async (c) => {
298
- const rawToken = extractBearerToken(c.req.header('Authorization'));
299
- if (!rawToken) {
300
- return c.json({ error: 'Missing or malformed Authorization header. Expected: Bearer <deploy-token>' }, 401);
301
- }
302
-
303
- let body: AiBrokerInvokeBody;
304
- try {
305
- body = await c.req.json<AiBrokerInvokeBody>();
306
- } catch {
307
- return c.json({ error: 'Invalid JSON body' }, 400);
308
- }
309
-
310
- const bodyError = validateBody(body);
311
- if (bodyError) {
312
- return c.json({ error: bodyError }, 400);
313
- }
314
-
315
- const validation = validateTokenAndConfig(rawToken, body);
316
- if (!validation.ok) {
317
- return c.json(
318
- { error: validation.error },
319
- { status: validation.status as 400, headers: validation.headers },
320
- );
321
- }
322
-
323
- if (body.capability === 'headless') {
324
- return handleHeadlessInvoke(c, body, validation.record);
325
- }
326
- return handleBoardInvoke(c, body, validation.record);
327
- });
328
-
329
- // ── GET /jobs/:jobId — poll board execution status ─────────
330
-
331
- routes.get('/jobs/:jobId', (c) => {
332
- const { jobId } = c.req.param();
333
- const endUserId = c.req.query('endUserId');
334
-
335
- const status = getBoardExecutionStatus(jobId, endUserId ?? undefined);
336
- if (!status) {
337
- return c.json({ error: 'Job not found' }, 404);
338
- }
339
-
340
- return c.json(status);
341
- });
342
-
343
- return routes;
344
- }
345
-
346
- // ========== Headless Dispatch ==========
347
-
348
- async function handleHeadlessInvoke(
349
- c: Context,
350
- body: AiBrokerInvokeBody,
351
- record: DeployTokenRecord,
352
- ) {
353
- const config: DeploymentAiConfig = {
354
- deploymentId: record.deploymentId,
355
- aiEnabled: record.aiConfig.aiEnabled,
356
- allowedAiCapabilities: record.capabilities,
357
- maxTokensPerRequest: record.aiConfig.maxTokensPerRequest,
358
- maxRequestsPerMinute: record.rateLimit.maxRequestsPerMinute,
359
- maxConcurrentSessions: record.rateLimit.maxConcurrentSessions,
360
- defaultSystemPrompt: record.aiConfig.defaultSystemPrompt,
361
- defaultModel: record.aiConfig.defaultModel,
362
- workingDir: record.aiConfig.workingDir,
363
- };
364
-
365
- recordBrokerRequestStart(record.deploymentId);
366
-
367
- // Stream headless session output as SSE
368
- return streamSSE(c, async (stream) => {
369
- let resultSent = false;
370
-
371
- const callbacks: HeadlessSessionStreamCallbacks = {
372
- onOutput: (text) => {
373
- stream.writeSSE({ event: 'output', data: text }).catch(() => {});
374
- },
375
- onThinking: (text) => {
376
- stream.writeSSE({ event: 'thinking', data: text }).catch(() => {});
377
- },
378
- onToolUse: (event) => {
379
- stream.writeSSE({ event: 'tool_use', data: JSON.stringify(event) }).catch(() => {});
380
- },
381
- onUsageReport: (report) => {
382
- usageReportListener?.(report);
383
- },
384
- onHealthUpdate: (update) => {
385
- healthUpdateListener?.(update);
386
- },
387
- };
388
-
389
- try {
390
- const result = await handleHeadlessSession(
391
- {
392
- prompt: body.prompt,
393
- systemPrompt: body.systemPrompt,
394
- allowedTools: body.allowedTools,
395
- model: body.model,
396
- endUserId: body.endUserId,
397
- },
398
- config,
399
- callbacks,
400
- );
401
-
402
- if (result.ok) {
403
- await stream.writeSSE({
404
- event: 'done',
405
- data: JSON.stringify({
406
- sessionId: result.result.sessionId,
407
- completed: result.result.completed,
408
- totalTokens: result.result.totalTokens,
409
- durationMs: result.result.durationMs,
410
- }),
411
- });
412
- } else {
413
- // Map error codes to appropriate SSE error events
414
- const statusCode = mapErrorCodeToStatus(result.error.code);
415
- const errorData: Record<string, unknown> = {
416
- code: result.error.code,
417
- message: result.error.message,
418
- statusCode,
419
- };
420
- if (statusCode === 402 && record.paymentUrl) {
421
- errorData.paymentUrl = record.paymentUrl;
422
- }
423
- await stream.writeSSE({
424
- event: 'error',
425
- data: JSON.stringify(errorData),
426
- });
427
- }
428
- resultSent = true;
429
- } catch (error: unknown) {
430
- if (!resultSent) {
431
- const message = error instanceof Error ? error.message : String(error);
432
- await stream.writeSSE({
433
- event: 'error',
434
- data: JSON.stringify({ code: 'EXECUTION_FAILED', message }),
435
- }).catch(() => {});
436
- }
437
- } finally {
438
- recordBrokerRequestEnd(record.deploymentId);
439
- }
440
- });
441
- }
442
-
443
- // ========== Board Dispatch ==========
444
-
445
- function handleBoardInvoke(
446
- c: Context,
447
- body: AiBrokerInvokeBody,
448
- record: DeployTokenRecord,
449
- ) {
450
- const config: BoardExecutionConfig = {
451
- deploymentId: record.deploymentId,
452
- aiEnabled: record.aiConfig.aiEnabled,
453
- allowedAiCapabilities: record.capabilities.map((cap) =>
454
- cap === 'pm-board' ? 'board-execution' : cap,
455
- ),
456
- allowedBoardTemplateIds: record.aiConfig.allowedBoardTemplateIds,
457
- maxConcurrentBoardExecutions: record.aiConfig.maxConcurrentBoardExecutions,
458
- maxBoardExecutionsPerMinute: record.aiConfig.maxBoardExecutionsPerMinute,
459
- defaultModel: record.aiConfig.defaultModel,
460
- workingDir: record.aiConfig.workingDir,
461
- };
462
-
463
- const result = startBoardExecution(
464
- {
465
- boardTemplateId: body.boardTemplateId!,
466
- endUserPrompt: body.prompt,
467
- endUserId: body.endUserId,
468
- deploymentId: body.deploymentId,
469
- },
470
- config,
471
- );
472
-
473
- if (!result.ok) {
474
- const statusCode = mapErrorCodeToStatus(result.error.code);
475
- const body: Record<string, unknown> = { error: result.error.message, code: result.error.code };
476
- if (statusCode === 402 && record.paymentUrl) {
477
- body.paymentUrl = record.paymentUrl;
478
- }
479
- return c.json(body, statusCode as 400);
480
- }
481
-
482
- // Construct polling URL relative to the request
483
- const host = c.req.header('Host') || 'localhost';
484
- const protocol = c.req.header('X-Forwarded-Proto') || 'http';
485
- const statusUrl = `${protocol}://${host}/api/deploy/ai/jobs/${result.jobId}`;
486
-
487
- return c.json({
488
- jobId: result.jobId,
489
- statusUrl,
490
- }, 202);
491
- }
492
-
493
- // ========== Error Mapping ==========
494
-
495
- function mapErrorCodeToStatus(code: string): number {
496
- switch (code) {
497
- case 'CAPABILITY_DENIED':
498
- case 'AI_DISABLED':
499
- return 403;
500
- case 'RATE_LIMIT_EXCEEDED':
501
- case 'CONCURRENT_LIMIT_EXCEEDED':
502
- return 429;
503
- case 'INVALID_REQUEST':
504
- case 'INVALID_BOARD_TEMPLATE':
505
- case 'BOARD_TEMPLATE_NOT_FOUND':
506
- return 400;
507
- case 'PAYMENT_REQUIRED':
508
- return 402;
509
- default:
510
- return 500;
511
- }
512
- }