@wardby/cli 0.3.0 → 0.4.1

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 (275) hide show
  1. package/.env.example +22 -3
  2. package/README.md +11 -9
  3. package/dist/cli-help.d.ts +1 -1
  4. package/dist/cli-help.js +1 -0
  5. package/dist/cli.js +68 -10
  6. package/dist/coding/base-commit.d.ts +6 -0
  7. package/dist/coding/base-commit.js +12 -0
  8. package/dist/coding/protocol.d.ts +17 -1
  9. package/dist/coding/protocol.js +17 -6
  10. package/dist/coding/provider.d.ts +6 -1
  11. package/dist/coding/provider.js +16 -9
  12. package/dist/config/providers.d.ts +25 -0
  13. package/dist/config/providers.js +71 -0
  14. package/dist/core/attribution.d.ts +101 -0
  15. package/dist/core/attribution.js +208 -0
  16. package/dist/core/budget-groups.d.ts +17 -10
  17. package/dist/core/budget-groups.js +15 -12
  18. package/dist/core/coding-queue.d.ts +3 -0
  19. package/dist/core/coding-queue.js +6 -2
  20. package/dist/core/coding-service-status.d.ts +11 -0
  21. package/dist/core/coding-service-status.js +17 -0
  22. package/dist/core/cost-report.d.ts +88 -0
  23. package/dist/core/cost-report.js +248 -0
  24. package/dist/core/dispatch.d.ts +42 -2
  25. package/dist/core/dispatch.js +144 -26
  26. package/dist/core/engine-native.js +17 -4
  27. package/dist/core/glob.d.ts +10 -0
  28. package/dist/core/glob.js +33 -0
  29. package/dist/core/host-events.d.ts +24 -1
  30. package/dist/core/host-events.js +341 -1
  31. package/dist/core/host-status.d.ts +19 -2
  32. package/dist/core/host-status.js +47 -30
  33. package/dist/core/issue-bridge.d.ts +60 -0
  34. package/dist/core/issue-bridge.js +189 -0
  35. package/dist/core/issue-dedupe.d.ts +70 -0
  36. package/dist/core/issue-dedupe.js +255 -0
  37. package/dist/core/issue-events.d.ts +42 -0
  38. package/dist/core/issue-events.js +155 -0
  39. package/dist/core/issue-status.d.ts +29 -0
  40. package/dist/core/issue-status.js +241 -0
  41. package/dist/core/issue-tracker-tools.d.ts +64 -0
  42. package/dist/core/issue-tracker-tools.js +850 -0
  43. package/dist/core/model-usage.d.ts +10 -0
  44. package/dist/core/model-usage.js +24 -0
  45. package/dist/core/reconciler.d.ts +8 -4
  46. package/dist/core/reconciler.js +15 -4
  47. package/dist/core/review-host-tools.js +10 -3
  48. package/dist/core/run-pricing.d.ts +61 -0
  49. package/dist/core/run-pricing.js +56 -0
  50. package/dist/core/runner.d.ts +5 -2
  51. package/dist/core/runner.js +178 -27
  52. package/dist/core/scheduler.d.ts +4 -1
  53. package/dist/core/scheduler.js +3 -2
  54. package/dist/core/self-defects.d.ts +80 -0
  55. package/dist/core/self-defects.js +180 -0
  56. package/dist/core/tool-names.js +3 -0
  57. package/dist/core/webhooks.d.ts +9 -1
  58. package/dist/core/webhooks.js +19 -1
  59. package/dist/env.js +6 -1
  60. package/dist/generated/prisma/browser.d.ts +66 -0
  61. package/dist/generated/prisma/client.d.ts +66 -0
  62. package/dist/generated/prisma/commonInputTypes.d.ts +122 -52
  63. package/dist/generated/prisma/enums.d.ts +7 -0
  64. package/dist/generated/prisma/enums.js +6 -0
  65. package/dist/generated/prisma/internal/class.d.ts +99 -0
  66. package/dist/generated/prisma/internal/class.js +4 -4
  67. package/dist/generated/prisma/internal/prismaNamespace.d.ts +826 -1
  68. package/dist/generated/prisma/internal/prismaNamespace.js +135 -2
  69. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +142 -0
  70. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -2
  71. package/dist/generated/prisma/models/Agent.d.ts +389 -1
  72. package/dist/generated/prisma/models/AgentIssueProject.d.ts +1838 -0
  73. package/dist/generated/prisma/models/AgentIssueProject.js +1 -0
  74. package/dist/generated/prisma/models/AgentRepository.d.ts +1 -1
  75. package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
  76. package/dist/generated/prisma/models/CodingProxySession.d.ts +73 -1
  77. package/dist/generated/prisma/models/CodingRun.d.ts +130 -1
  78. package/dist/generated/prisma/models/CodingRunServiceStatus.d.ts +1404 -0
  79. package/dist/generated/prisma/models/CodingRunServiceStatus.js +1 -0
  80. package/dist/generated/prisma/models/IssueFingerprint.d.ts +1183 -0
  81. package/dist/generated/prisma/models/IssueFingerprint.js +1 -0
  82. package/dist/generated/prisma/models/IssuePullRequest.d.ts +1255 -0
  83. package/dist/generated/prisma/models/IssuePullRequest.js +1 -0
  84. package/dist/generated/prisma/models/ModelCatalogEntry.d.ts +1322 -0
  85. package/dist/generated/prisma/models/ModelCatalogEntry.js +1 -0
  86. package/dist/generated/prisma/models/Run.d.ts +933 -1
  87. package/dist/generated/prisma/models/RunAttribution.d.ts +1259 -0
  88. package/dist/generated/prisma/models/RunAttribution.js +1 -0
  89. package/dist/generated/prisma/models/RunIssueStatus.d.ts +1199 -0
  90. package/dist/generated/prisma/models/RunIssueStatus.js +1 -0
  91. package/dist/generated/prisma/models/RunModelUsage.d.ts +1316 -0
  92. package/dist/generated/prisma/models/RunModelUsage.js +1 -0
  93. package/dist/generated/prisma/models/WorkItem.d.ts +1408 -0
  94. package/dist/generated/prisma/models/WorkItem.js +1 -0
  95. package/dist/generated/prisma/models.d.ts +9 -0
  96. package/dist/help-index.json +355 -16
  97. package/dist/import/neutral-schema.d.ts +16 -16
  98. package/dist/knowledge/check.d.ts +13 -0
  99. package/dist/knowledge/check.js +69 -0
  100. package/dist/knowledge/cli.d.ts +14 -0
  101. package/dist/knowledge/cli.js +67 -0
  102. package/dist/knowledge/concept.d.ts +54 -0
  103. package/dist/knowledge/concept.js +78 -0
  104. package/dist/knowledge/note.d.ts +11 -0
  105. package/dist/knowledge/note.js +39 -0
  106. package/dist/knowledge/relevance.d.ts +11 -0
  107. package/dist/knowledge/relevance.js +14 -0
  108. package/dist/knowledge/span-hash.d.ts +3 -0
  109. package/dist/knowledge/span-hash.js +16 -0
  110. package/dist/mcp/auth/access.d.ts +4 -2
  111. package/dist/mcp/auth/ownership.d.ts +9 -9
  112. package/dist/mcp/auth/resource-server.d.ts +3 -1
  113. package/dist/mcp/auth/resource-server.js +18 -3
  114. package/dist/mcp/auth/self-hosted/credentials.d.ts +3 -3
  115. package/dist/mcp/auth/self-hosted/session.d.ts +5 -5
  116. package/dist/mcp/context.d.ts +3 -0
  117. package/dist/mcp/host-events/deliveries.d.ts +9 -0
  118. package/dist/mcp/host-events/deliveries.js +17 -0
  119. package/dist/mcp/host-events/github-ingress.d.ts +4 -2
  120. package/dist/mcp/host-events/github-ingress.js +4 -13
  121. package/dist/mcp/host-events/jira-ingress.d.ts +29 -0
  122. package/dist/mcp/host-events/jira-ingress.js +92 -0
  123. package/dist/mcp/index.d.ts +2 -0
  124. package/dist/mcp/index.js +87 -9
  125. package/dist/mcp/server.js +5 -2
  126. package/dist/mcp/tools/agents.js +74 -3
  127. package/dist/mcp/tools/cost-report.d.ts +8 -0
  128. package/dist/mcp/tools/cost-report.js +60 -0
  129. package/dist/mcp/tools/issue-projects.d.ts +2 -0
  130. package/dist/mcp/tools/issue-projects.js +238 -0
  131. package/dist/mcp/tools/model-catalog.d.ts +22 -0
  132. package/dist/mcp/tools/model-catalog.js +423 -0
  133. package/dist/mcp/tools/repositories.js +2 -1
  134. package/dist/mcp/tools/tools.d.ts +2 -2
  135. package/dist/mcp/tools/trigger.js +33 -5
  136. package/dist/mcp/transport/streamable-http.d.ts +5 -0
  137. package/dist/mcp/transport/streamable-http.js +23 -1
  138. package/dist/mcp/webhooks/ingress.d.ts +2 -1
  139. package/dist/mcp/webhooks/ingress.js +9 -2
  140. package/dist/providers/auth/self-hosted.d.ts +8 -1
  141. package/dist/providers/auth/self-hosted.js +39 -2
  142. package/dist/providers/coding-proxy/memory-ledger.d.ts +1 -1
  143. package/dist/providers/coding-proxy/memory-ledger.js +10 -1
  144. package/dist/providers/coding-proxy/metering.d.ts +2 -1
  145. package/dist/providers/coding-proxy/metering.js +13 -2
  146. package/dist/providers/coding-proxy/prisma-ledger.js +59 -6
  147. package/dist/providers/coding-proxy/proxy.d.ts +12 -2
  148. package/dist/providers/coding-proxy/proxy.js +92 -30
  149. package/dist/providers/coding-proxy/types.d.ts +18 -1
  150. package/dist/providers/coding-proxy/types.js +12 -1
  151. package/dist/providers/engine/types.d.ts +19 -0
  152. package/dist/providers/executor/composition.js +9 -1
  153. package/dist/providers/executor/container.d.ts +30 -2
  154. package/dist/providers/executor/container.js +98 -17
  155. package/dist/providers/executor/dbos.d.ts +2 -0
  156. package/dist/providers/executor/dbos.js +7 -5
  157. package/dist/providers/executor/routing.d.ts +6 -0
  158. package/dist/providers/executor/routing.js +5 -0
  159. package/dist/providers/executor/types.d.ts +12 -0
  160. package/dist/providers/issue-tracker/adf.d.ts +31 -0
  161. package/dist/providers/issue-tracker/adf.js +181 -0
  162. package/dist/providers/issue-tracker/index.d.ts +5 -0
  163. package/dist/providers/issue-tracker/index.js +12 -0
  164. package/dist/providers/issue-tracker/jira-client.d.ts +41 -0
  165. package/dist/providers/issue-tracker/jira-client.js +151 -0
  166. package/dist/providers/issue-tracker/jira-events.d.ts +3 -0
  167. package/dist/providers/issue-tracker/jira-events.js +98 -0
  168. package/dist/providers/issue-tracker/jira.d.ts +116 -0
  169. package/dist/providers/issue-tracker/jira.js +502 -0
  170. package/dist/providers/issue-tracker/types.d.ts +269 -0
  171. package/dist/providers/issue-tracker/types.js +16 -0
  172. package/dist/providers/jobs/docker.d.ts +5 -1
  173. package/dist/providers/jobs/docker.js +61 -33
  174. package/dist/providers/jobs/kubernetes.d.ts +3 -0
  175. package/dist/providers/jobs/kubernetes.js +44 -4
  176. package/dist/providers/jobs/service-state.d.ts +22 -0
  177. package/dist/providers/jobs/service-state.js +17 -0
  178. package/dist/providers/llm/anthropic.d.ts +3 -3
  179. package/dist/providers/llm/anthropic.js +3 -9
  180. package/dist/providers/llm/bedrock.d.ts +3 -3
  181. package/dist/providers/llm/bedrock.js +3 -9
  182. package/dist/providers/llm/catalog-lookup.d.ts +10 -0
  183. package/dist/providers/llm/catalog-lookup.js +15 -0
  184. package/dist/providers/llm/catalog-shipped.d.ts +18 -0
  185. package/dist/providers/llm/catalog-shipped.js +197 -0
  186. package/dist/providers/llm/catalog-store.d.ts +58 -0
  187. package/dist/providers/llm/catalog-store.js +138 -0
  188. package/dist/providers/llm/catalog-types.d.ts +66 -0
  189. package/dist/providers/llm/catalog-types.js +64 -0
  190. package/dist/providers/llm/catalog.d.ts +61 -0
  191. package/dist/providers/llm/catalog.js +147 -0
  192. package/dist/providers/llm/claude-provider.d.ts +10 -14
  193. package/dist/providers/llm/claude-provider.js +11 -6
  194. package/dist/providers/llm/index.d.ts +9 -6
  195. package/dist/providers/llm/index.js +8 -5
  196. package/dist/providers/llm/openai.d.ts +14 -5
  197. package/dist/providers/llm/openai.js +24 -14
  198. package/dist/providers/llm/pricing-core.d.ts +5 -3
  199. package/dist/providers/llm/registration.js +8 -12
  200. package/dist/providers/llm/routing.d.ts +18 -17
  201. package/dist/providers/llm/routing.js +40 -24
  202. package/dist/providers/review-host/github-events.js +47 -1
  203. package/dist/providers/review-host/github.js +7 -6
  204. package/dist/providers/review-host/types.d.ts +25 -0
  205. package/dist/providers/vcs/git.js +2 -22
  206. package/dist/providers/vcs/github.d.ts +20 -0
  207. package/dist/providers/vcs/github.js +28 -2
  208. package/dist/providers/vcs/types.d.ts +6 -0
  209. package/dist/quickstart/index.d.ts +8 -0
  210. package/dist/quickstart/index.js +34 -34
  211. package/dist/serve.js +8 -2
  212. package/dist/viewer/api-schema.d.ts +2757 -0
  213. package/dist/viewer/api-schema.js +165 -0
  214. package/dist/viewer/build-schemas.d.ts +2 -0
  215. package/dist/viewer/build-schemas.js +18 -0
  216. package/dist/viewer/event-bus.d.ts +38 -0
  217. package/dist/viewer/event-bus.js +232 -0
  218. package/dist/viewer/graph.d.ts +40 -0
  219. package/dist/viewer/graph.js +243 -0
  220. package/dist/viewer/http.d.ts +30 -0
  221. package/dist/viewer/http.js +133 -0
  222. package/dist/viewer/run-detail.d.ts +4 -0
  223. package/dist/viewer/run-detail.js +61 -0
  224. package/dist/wardby-bin.js +5 -0
  225. package/docs/README.md +10 -0
  226. package/docs/agent-recipes.md +383 -0
  227. package/docs/code-review-agents.md +29 -2
  228. package/docs/coding-agent-setup.md +3 -0
  229. package/docs/coding-worker-isolation.md +39 -5
  230. package/docs/getting-started-gke.md +28 -11
  231. package/docs/getting-started-identity-provider.md +49 -38
  232. package/docs/getting-started.md +14 -0
  233. package/docs/jira-agents.md +649 -0
  234. package/docs/knowledge.md +387 -0
  235. package/docs/models.md +221 -0
  236. package/docs/security-deployment.md +19 -9
  237. package/docs/viewer-api.md +142 -0
  238. package/help/admin-viewer.md +39 -0
  239. package/help/agent-recipes.md +173 -0
  240. package/help/architecture-agent.md +189 -0
  241. package/help/builder-agent.md +80 -0
  242. package/help/code-review-agents.md +6 -0
  243. package/help/cost-attribution.md +67 -0
  244. package/help/creating-agents.md +22 -0
  245. package/help/deploy-gke.md +6 -0
  246. package/help/errors/model-unavailable.md +63 -0
  247. package/help/getting-started.md +1 -0
  248. package/help/github.md +18 -0
  249. package/help/identity-and-access.md +8 -3
  250. package/help/jira.md +135 -0
  251. package/help/knowledge.md +47 -0
  252. package/help/models.md +90 -0
  253. package/help/operating-agents.md +7 -1
  254. package/help/troubleshooting/budgets.md +6 -0
  255. package/package.json +5 -2
  256. package/prisma/migrations/20260930000000_jira_issue_projects/migration.sql +34 -0
  257. package/prisma/migrations/20261001000000_jira_phase2_allowlists/migration.sql +3 -0
  258. package/prisma/migrations/20261001010000_jira_link_types_allowlist/migration.sql +2 -0
  259. package/prisma/migrations/20261002000000_jira_coding_bridge/migration.sql +28 -0
  260. package/prisma/migrations/20261002010000_jira_issue_creation/migration.sql +25 -0
  261. package/prisma/migrations/20261003000000_issue_cost_attribution/migration.sql +56 -0
  262. package/prisma/migrations/20261003010000_coding_run_service_status/migration.sql +23 -0
  263. package/prisma/migrations/20261003020000_viewer_notify/migration.sql +54 -0
  264. package/prisma/migrations/20261003030000_viewer_notify_fixes/migration.sql +47 -0
  265. package/prisma/migrations/20261003040000_viewer_indexes/migration.sql +12 -0
  266. package/prisma/migrations/20261004000000_model_catalog/migration.sql +26 -0
  267. package/prisma/schema.prisma +258 -2
  268. package/dist/mcp/tools/models.d.ts +0 -8
  269. package/dist/mcp/tools/models.js +0 -15
  270. package/dist/providers/llm/pricing-anthropic.d.ts +0 -14
  271. package/dist/providers/llm/pricing-anthropic.js +0 -48
  272. package/dist/providers/llm/pricing-bedrock-claude.d.ts +0 -20
  273. package/dist/providers/llm/pricing-bedrock-claude.js +0 -46
  274. package/dist/providers/llm/pricing.d.ts +0 -30
  275. package/dist/providers/llm/pricing.js +0 -74
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The model catalog over MCP (docs/models.md). Reading it (list_models,
3
+ * get_model) needs only agents:read: anyone deciding what to put in an
4
+ * Agent.model needs to see what's routable, what it costs, and whether it's
5
+ * shipped or a deployment override — entries hold no secrets. Changing it
6
+ * (set_model, disable_model, reset_model) needs models:admin, a privileged
7
+ * scope honoured only with a role granting it (model-manager or admin;
8
+ * resource-server.ts): an entry decides what every principal's runs on that
9
+ * model id are priced and shaped as. Each change is audit-logged (event
10
+ * models.catalog.<action>). Runs already dispatched keep the entry they
11
+ * started with (CatalogEntry snapshots, not live lookups) — a write here
12
+ * never reprices or reshapes a run in flight.
13
+ *
14
+ * Correctness of a write never depends on the process's installed
15
+ * CatalogStore, which can be stale (it polls on an interval) or briefly
16
+ * unreachable:
17
+ * - `set_model`'s provider-conflict check reads `ctx.db` directly (plus the
18
+ * static shipped catalog, which is never stale), not the in-memory
19
+ * catalog — a conflicting row written by another process/replica and
20
+ * not yet polled in here must still be caught.
21
+ * - Every write's response and audit event are built from the data just
22
+ * written (what we know is true), never from a post-write re-read of the
23
+ * catalog — so a refresh failure can never turn a successful write into
24
+ * an unaudited one, or into an error.
25
+ * - `deps.refresh` is still called best-effort after each write (so this
26
+ * process's own routing picks up the change promptly); a failure is
27
+ * logged and swallowed, never thrown.
28
+ * - `set_model` additionally re-checks ownership after a *successful*
29
+ * refresh: two processes racing to claim the same model id under
30
+ * different providers can both write (the compound key is
31
+ * (provider, modelId), not modelId alone) — `buildCatalog` then silently
32
+ * picks one winner. If the merge didn't pick us, we report an error
33
+ * rather than success, even though our own row did get written.
34
+ */
35
+ import { z } from "zod";
36
+ import { logger } from "../../core/logger.js";
37
+ import { McpError } from "../errors.js";
38
+ import { textResult } from "./text-result.js";
39
+ import { MODEL_PROVIDERS, THINKING_MODES, TOKENIZER_ENCODINGS, entryOf, sameEntry, } from "../../providers/llm/catalog-types.js";
40
+ import { LLM_EFFORT_LEVELS } from "../../providers/llm/types.js";
41
+ import { ownerOf, rowFromRecord } from "../../providers/llm/catalog.js";
42
+ import { currentModelCatalog } from "../../providers/llm/catalog-store.js";
43
+ import { SHIPPED_CATALOG_VERSION } from "../../providers/llm/catalog-shipped.js";
44
+ const modelsLog = logger.child({ module: "mcp-model-catalog" });
45
+ const RATE_FIELDS = ["inputPerMTok", "outputPerMTok", "cachedInputPerMTok", "cacheWritePerMTok"];
46
+ function parse(schema, label, value) {
47
+ const result = schema.safeParse(value);
48
+ if (!result.success) {
49
+ const details = result.error.issues
50
+ .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
51
+ .join("; ");
52
+ throw new McpError(400, `Invalid ${label}: ${details}`);
53
+ }
54
+ return result.data;
55
+ }
56
+ const ModelIdSchema = z
57
+ .string()
58
+ .min(1)
59
+ .max(200)
60
+ .regex(/^[A-Za-z0-9._:/-]+$/, "modelId must match ^[A-Za-z0-9._:/-]+$");
61
+ const ModelIdArgSchema = z.object({ modelId: ModelIdSchema }).strict();
62
+ const ListModelsSchema = z.object({ includeDisabled: z.boolean().optional() }).strict();
63
+ const SetModelSchema = z
64
+ .object({
65
+ provider: z.enum(MODEL_PROVIDERS),
66
+ modelId: ModelIdSchema,
67
+ encoding: z.enum(TOKENIZER_ENCODINGS),
68
+ inputPerMTok: z.number().finite().nonnegative(),
69
+ outputPerMTok: z.number().finite().nonnegative(),
70
+ cachedInputPerMTok: z.number().finite().nonnegative(),
71
+ cacheWritePerMTok: z.number().finite().nonnegative(),
72
+ efforts: z
73
+ .array(z.enum(LLM_EFFORT_LEVELS))
74
+ .refine((efforts) => new Set(efforts).size === efforts.length, "efforts must not repeat a level"),
75
+ thinkingMode: z.enum(THINKING_MODES),
76
+ sourceUrl: z
77
+ .string()
78
+ .url()
79
+ .refine((url) => url.startsWith("https://"), "sourceUrl must start with https://"),
80
+ })
81
+ .strict();
82
+ /** A resolved entry as the tools serve it: the merged fields plus whether this deployment can route to it right now. */
83
+ function view(entry, llm) {
84
+ return { ...entry, routable: llm.hasProvider(entry.provider) };
85
+ }
86
+ function audit(ctx, action, info) {
87
+ modelsLog.info({
88
+ event: `models.catalog.${action}`,
89
+ provider: info.provider,
90
+ ...(info.providers && info.providers.length > 1 ? { providers: info.providers } : {}),
91
+ modelId: info.modelId,
92
+ sourceUrl: info.sourceUrl,
93
+ before: info.before,
94
+ after: info.after,
95
+ by: ctx.principal.id,
96
+ }, "model catalog changed");
97
+ }
98
+ /** Best-effort: logs and swallows a refresh failure rather than letting it fail (or un-audit) an already-committed write. Returns whether it actually ran and succeeded. */
99
+ async function refreshBestEffort(refresh, context) {
100
+ if (!refresh)
101
+ return false;
102
+ try {
103
+ await refresh();
104
+ return true;
105
+ }
106
+ catch (err) {
107
+ modelsLog.warn({ event: "models.catalog.refresh_failed", ...context, err: err instanceof Error ? err.message : String(err) }, "model catalog refresh failed after a write; the write stands, but routing may lag until the next poll");
108
+ return false;
109
+ }
110
+ }
111
+ /**
112
+ * `shipped` says whether `modelId` is a shipped model: if so, `provider` is
113
+ * necessarily its shipped provider (ownerOf), and no action — disabling
114
+ * or resetting the override, or anything else — ever lets a different
115
+ * provider claim it, so the message says that plainly rather than pointing
116
+ * at a remedy that doesn't exist. For a non-shipped id, `reset_model` is the
117
+ * only thing that frees it for another provider: `disable_model` keeps the
118
+ * row (and its provider claim) in place, it just flips `enabled`, so
119
+ * disabling never frees the id either, whether `enabled` is currently true
120
+ * or false.
121
+ */
122
+ function conflictError(modelId, provider, enabled, shipped) {
123
+ if (shipped) {
124
+ return new McpError(409, `Model "${modelId}" is a shipped model: it always belongs to provider "${provider}" and can never be ` +
125
+ `served by another provider — disabling or resetting the override does not change that.`);
126
+ }
127
+ return enabled
128
+ ? new McpError(409, `Model "${modelId}" is already served by provider "${provider}"; reset_model it first to free the id ` +
129
+ `for another provider (disable_model does not free it).`)
130
+ : new McpError(409, `Model "${modelId}" is reserved by a disabled entry for provider "${provider}"; reset_model it first.`);
131
+ }
132
+ /**
133
+ * The post-write ownership-race error: our own row for `inputProvider` was
134
+ * written, but a refresh right after showed `nowOwner` winning the merge
135
+ * instead (the first-claim-wins rule — see `ownerOf` in catalog.ts). Named
136
+ * after the thing that actually fixes it (reset_model, which clears every
137
+ * row for the id, `nowOwner`'s included) rather than telling the caller to
138
+ * "retry set_model", which would just hit the pre-write conflict check again
139
+ * (or, if the race repeats, loop). Exported so its exact wording can be unit
140
+ * tested directly — forcing the genuine two-process write race that triggers
141
+ * this through the public tool-call surface isn't practical: it requires a
142
+ * competing write to land in the narrow window between this handler's own
143
+ * pre-write ownership check and its own write, which a single-process test
144
+ * harness has no way to inject mid-call.
145
+ */
146
+ export function raceLostError(modelId, inputProvider, nowOwner) {
147
+ return new McpError(409, `Model "${modelId}" was written for provider "${inputProvider}" but is not serving it: provider "${nowOwner}" ` +
148
+ `owns it in the merged catalog. reset_model removes every row for "${modelId}" (including "${nowOwner}"'s); ` +
149
+ `set_model can then claim it for "${inputProvider}".`);
150
+ }
151
+ const modelIdProperty = {
152
+ type: "string",
153
+ description: 'Exact string an Agent.model must equal, e.g. "claude-sonnet-5".',
154
+ };
155
+ const setModelProperties = {
156
+ provider: { type: "string", enum: [...MODEL_PROVIDERS], description: "Which adapter routes calls to this model." },
157
+ modelId: modelIdProperty,
158
+ encoding: {
159
+ type: "string",
160
+ enum: [...TOKENIZER_ENCODINGS],
161
+ description: "Tokenizer encoding used to pre-count tokens for budget enforcement before any call.",
162
+ },
163
+ inputPerMTok: {
164
+ type: "number",
165
+ minimum: 0,
166
+ description: "USD per million input tokens, the provider's published rate.",
167
+ },
168
+ outputPerMTok: {
169
+ type: "number",
170
+ minimum: 0,
171
+ description: "USD per million output tokens, the provider's published rate.",
172
+ },
173
+ cachedInputPerMTok: {
174
+ type: "number",
175
+ minimum: 0,
176
+ description: "USD per million cache-read input tokens. The provider's own cache-read rate, never derived from inputPerMTok.",
177
+ },
178
+ cacheWritePerMTok: {
179
+ type: "number",
180
+ minimum: 0,
181
+ description: "USD per million cache-write input tokens. The provider's own cache-write rate, never derived from inputPerMTok.",
182
+ },
183
+ efforts: {
184
+ type: "array",
185
+ items: { type: "string", enum: [...LLM_EFFORT_LEVELS] },
186
+ description: "Reasoning effort levels this model accepts, lowest to highest; empty = never send one. Claude Code coding runs may send only these levels, so include the model's default (usually high; medium on some models).",
187
+ },
188
+ thinkingMode: {
189
+ type: "string",
190
+ enum: [...THINKING_MODES],
191
+ description: '"adaptive" (effort-based), "manual" (fixed budget_tokens, no effort), or "none" (no thinking parameter).',
192
+ },
193
+ sourceUrl: {
194
+ type: "string",
195
+ description: "The provider's own pricing page for this exact model (https). Rates must be its published numbers, never derived.",
196
+ },
197
+ };
198
+ export function registerModelCatalogTools(mcp, deps = {}) {
199
+ mcp.registerTool({
200
+ name: "list_models",
201
+ scope: "agents:read",
202
+ description: "The merged model catalog (shipped models overlaid with this deployment's overrides): id, pricing, encoding, effort levels, and whether this deployment can route to it right now (routable).",
203
+ inputSchema: {
204
+ type: "object",
205
+ additionalProperties: false,
206
+ properties: {
207
+ includeDisabled: { type: "boolean", description: "Also return models an admin has disabled." },
208
+ },
209
+ },
210
+ handler: async (rawArgs, ctx) => {
211
+ const { includeDisabled } = parse(ListModelsSchema, "list_models arguments", rawArgs);
212
+ const catalog = currentModelCatalog();
213
+ const llm = ctx.providers.llm;
214
+ const result = {
215
+ models: catalog.entries().map((entry) => view(entry, llm)),
216
+ };
217
+ if (includeDisabled)
218
+ result.disabled = catalog.disabledEntries().map((entry) => view(entry, llm));
219
+ return textResult(result);
220
+ },
221
+ });
222
+ mcp.registerTool({
223
+ name: "get_model",
224
+ scope: "agents:read",
225
+ description: "One catalog entry in full, active or disabled. For an override of a shipped model, also returns the shipped entry it shadows.",
226
+ inputSchema: {
227
+ type: "object",
228
+ additionalProperties: false,
229
+ required: ["modelId"],
230
+ properties: { modelId: modelIdProperty },
231
+ },
232
+ handler: async (rawArgs, ctx) => {
233
+ const { modelId } = parse(ModelIdArgSchema, "get_model arguments", rawArgs);
234
+ const catalog = currentModelCatalog();
235
+ const entry = catalog.get(modelId) ?? catalog.disabledEntries().find((e) => e.modelId === modelId);
236
+ if (!entry) {
237
+ throw new McpError(404, `Model "${modelId}" is not in the catalog (neither active nor disabled). See list_models.`);
238
+ }
239
+ const llm = ctx.providers.llm;
240
+ const shipped = entry.origin === "override" ? catalog.shippedEntry(modelId) : undefined;
241
+ return textResult({ model: view(entry, llm), ...(shipped ? { shipped } : {}) });
242
+ },
243
+ });
244
+ mcp.registerTool({
245
+ name: "set_model",
246
+ scope: "models:admin",
247
+ description: "Model managers (models:admin): add or override a catalog entry. Every field is required — there is no partial update, so the whole entry is always literal and auditable. Refused (409) when the model id belongs to a different provider: a shipped id always belongs to its shipped provider and can never be reassigned; a non-shipped id already claimed by another provider (its row active or disabled) is freed only by reset_model on that id, never by disable_model. Zero rates come back as warnings, not errors.",
248
+ inputSchema: {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ required: [
252
+ "provider",
253
+ "modelId",
254
+ "encoding",
255
+ "inputPerMTok",
256
+ "outputPerMTok",
257
+ "cachedInputPerMTok",
258
+ "cacheWritePerMTok",
259
+ "efforts",
260
+ "thinkingMode",
261
+ "sourceUrl",
262
+ ],
263
+ properties: setModelProperties,
264
+ },
265
+ handler: async (rawArgs, ctx) => {
266
+ const input = parse(SetModelSchema, "set_model arguments", rawArgs);
267
+ const { sourceUrl, ...entry } = input;
268
+ // Ownership is checked against the database directly, not the process's
269
+ // (possibly stale, poll-interval-old) installed catalog: a row written
270
+ // by another process/replica must be caught even if this process
271
+ // hasn't refreshed since. The static shipped catalog is never stale,
272
+ // so it's safe to read from the in-memory catalog either way. The
273
+ // check is buildCatalog's own ownership function (ownerOf), not "any
274
+ // different-provider row blocks": for a shipped id, an orphaned
275
+ // other-provider row (dead in the merge no matter what) must not
276
+ // falsely block the true (shipped) owner, and must not be let through
277
+ // just because it happens to share its own provider with input.provider.
278
+ // Every raw row counts as a claim here, malformed ones included (unlike
279
+ // the merge, which never sees them): a malformed row still occupies the
280
+ // id, and reset_model is what clears it.
281
+ const existingRows = await ctx.db.modelCatalogEntry.findMany({ where: { modelId: input.modelId } });
282
+ const shippedEntry = currentModelCatalog().shippedEntry(input.modelId);
283
+ const owner = ownerOf(shippedEntry, existingRows);
284
+ if (owner && owner.provider !== input.provider) {
285
+ throw conflictError(input.modelId, owner.provider, owner.enabled, !!shippedEntry);
286
+ }
287
+ const ownRow = existingRows.find((row) => row.provider === input.provider);
288
+ const parsedOwnRow = ownRow ? rowFromRecord(ownRow) : null;
289
+ const before = parsedOwnRow
290
+ ? entryOf(parsedOwnRow)
291
+ : shippedEntry && shippedEntry.provider === input.provider
292
+ ? entryOf(shippedEntry)
293
+ : null;
294
+ const data = { ...entry, efforts: [...entry.efforts], enabled: true, sourceUrl, updatedBy: ctx.principal.id };
295
+ const row = await ctx.db.modelCatalogEntry.upsert({
296
+ where: { provider_modelId: { provider: input.provider, modelId: input.modelId } },
297
+ create: data,
298
+ update: data,
299
+ });
300
+ // Audited from the data we know was written — independent of whether
301
+ // the refresh below succeeds, so a transient refresh failure can
302
+ // never leave a committed write un-audited.
303
+ const after = entryOf(entry);
304
+ audit(ctx, "set", { provider: input.provider, modelId: input.modelId, sourceUrl, before, after });
305
+ const warnings = RATE_FIELDS.filter((field) => input[field] === 0).map((field) => `${field} is 0: confirm against ${sourceUrl}`);
306
+ const refreshed = await refreshBestEffort(deps.refresh, { action: "set", modelId: input.modelId });
307
+ // Two processes racing to claim the same id under different providers
308
+ // can both write (the unique key is (provider, modelId), not modelId
309
+ // alone); buildCatalog then silently picks one winner. If a refresh
310
+ // just told us the merge didn't pick us, our own row is dead on
311
+ // arrival — report an error rather than success.
312
+ if (refreshed) {
313
+ const nowOwner = currentModelCatalog().get(input.modelId)?.provider;
314
+ if (nowOwner && nowOwner !== input.provider) {
315
+ throw raceLostError(input.modelId, input.provider, nowOwner);
316
+ }
317
+ }
318
+ const llm = ctx.providers.llm;
319
+ const resolved = {
320
+ ...entry,
321
+ origin: "override",
322
+ priceVersion: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : new Date().toISOString(),
323
+ sourceUrl,
324
+ updatedBy: ctx.principal.id,
325
+ ...(shippedEntry ? { shippedDiffers: !sameEntry(shippedEntry, entry) } : {}),
326
+ };
327
+ return textResult({ model: view(resolved, llm), warnings });
328
+ },
329
+ });
330
+ mcp.registerTool({
331
+ name: "disable_model",
332
+ scope: "models:admin",
333
+ description: "Model managers (models:admin): remove a model from routing without deleting its history. Keeps a disabled row under its own provider so no other provider can claim the id until reset_model clears it.",
334
+ inputSchema: {
335
+ type: "object",
336
+ additionalProperties: false,
337
+ required: ["modelId"],
338
+ properties: { modelId: modelIdProperty },
339
+ },
340
+ handler: async (rawArgs, ctx) => {
341
+ const { modelId } = parse(ModelIdArgSchema, "disable_model arguments", rawArgs);
342
+ const catalog = currentModelCatalog();
343
+ const active = catalog.get(modelId);
344
+ if (!active) {
345
+ throw new McpError(404, `No active model "${modelId}" to disable (it may already be disabled, or unknown). See list_models.`);
346
+ }
347
+ const before = entryOf(active);
348
+ const sourceUrl = active.sourceUrl ?? `shipped:${SHIPPED_CATALOG_VERSION}`;
349
+ const data = {
350
+ ...before,
351
+ efforts: [...before.efforts],
352
+ enabled: false,
353
+ sourceUrl,
354
+ updatedBy: ctx.principal.id,
355
+ };
356
+ const row = await ctx.db.modelCatalogEntry.upsert({
357
+ where: { provider_modelId: { provider: active.provider, modelId } },
358
+ create: data,
359
+ update: data,
360
+ });
361
+ // Disabling never changes the core CatalogEntry fields, so "after" is
362
+ // "before" — audited from the write we just made, not a post-refresh
363
+ // re-read, same reasoning as set_model.
364
+ const after = before;
365
+ audit(ctx, "disable", { provider: active.provider, modelId, sourceUrl, before, after });
366
+ await refreshBestEffort(deps.refresh, { action: "disable", modelId });
367
+ const llm = ctx.providers.llm;
368
+ const shippedEntry = catalog.shippedEntry(modelId);
369
+ const resolved = {
370
+ ...before,
371
+ origin: "override",
372
+ priceVersion: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : new Date().toISOString(),
373
+ sourceUrl,
374
+ updatedBy: ctx.principal.id,
375
+ ...(shippedEntry ? { shippedDiffers: !sameEntry(shippedEntry, before) } : {}),
376
+ };
377
+ return textResult({ disabled: view(resolved, llm) });
378
+ },
379
+ });
380
+ mcp.registerTool({
381
+ name: "reset_model",
382
+ scope: "models:admin",
383
+ description: "Model managers (models:admin): remove every catalog row for a model id, reverting to the shipped entry (if any) or removing it entirely. 404 if there is no row to remove (an unmodified shipped model, or an unknown id).",
384
+ inputSchema: {
385
+ type: "object",
386
+ additionalProperties: false,
387
+ required: ["modelId"],
388
+ properties: { modelId: modelIdProperty },
389
+ },
390
+ handler: async (rawArgs, ctx) => {
391
+ const { modelId } = parse(ModelIdArgSchema, "reset_model arguments", rawArgs);
392
+ // The 404 decision and the audited `providers` list are both based on
393
+ // the RAW rows, not rows that parse cleanly through rowFromRecord: a
394
+ // row can be malformed (fails parsing) yet still exist and still
395
+ // occupy the id. Deciding "nothing to reset" or omitting a provider
396
+ // from the audit based on parse success would delete a malformed row
397
+ // silently and leave the id permanently stuck — set_model's
398
+ // DB-sourced conflict check would keep seeing it (via ownerOf)
399
+ // and refusing every provider forever, with no tool able to clear it.
400
+ const rawRows = await ctx.db.modelCatalogEntry.findMany({ where: { modelId } });
401
+ if (rawRows.length === 0) {
402
+ throw new McpError(404, `No catalog row for "${modelId}" to reset (it is either unmodified shipped, or unknown).`);
403
+ }
404
+ const providers = [...new Set(rawRows.map((row) => String(row.provider)))];
405
+ const parsedRows = rawRows.map((row) => rowFromRecord(row)).filter((row) => row !== null);
406
+ const before = parsedRows.length > 0 ? entryOf(parsedRows[0]) : null;
407
+ const sourceUrl = parsedRows[0]?.sourceUrl;
408
+ await ctx.db.modelCatalogEntry.deleteMany({ where: { modelId } });
409
+ // The shipped entry is static (compiled-in), never stale, so "after"
410
+ // and the response's "now" can both be built from it directly,
411
+ // without depending on the refresh below at all.
412
+ const shippedEntry = currentModelCatalog().shippedEntry(modelId);
413
+ const after = shippedEntry ? entryOf(shippedEntry) : null;
414
+ audit(ctx, "reset", { provider: providers[0], providers, modelId, sourceUrl, before, after });
415
+ await refreshBestEffort(deps.refresh, { action: "reset", modelId });
416
+ const llm = ctx.providers.llm;
417
+ const now = shippedEntry
418
+ ? view({ ...shippedEntry, origin: "shipped", priceVersion: `shipped:${SHIPPED_CATALOG_VERSION}` }, llm)
419
+ : null;
420
+ return textResult({ reset: modelId, now });
421
+ },
422
+ });
423
+ }
@@ -19,7 +19,7 @@ import { requireScope } from "../auth/resource-server.js";
19
19
  import { McpError } from "../errors.js";
20
20
  import { textResult } from "./text-result.js";
21
21
  const PROVIDERS = ["github"];
22
- const TRIGGERS = ["pull_request", "mention"];
22
+ const TRIGGERS = ["pull_request", "mention", "push"];
23
23
  const CHECK_NAME = /^[A-Za-z0-9][A-Za-z0-9 ._/()-]{0,99}$/;
24
24
  function normalizeRepository(provider, repository) {
25
25
  try {
@@ -54,6 +54,7 @@ export function registerRepositoryTools(mcp) {
54
54
  name: "link_repository",
55
55
  scope: "agents:write",
56
56
  description: "Links a native agent you own to a repository on a code-review host, with its access, event triggers, and checkName. " +
57
+ "Triggers: pull_request (review each PR push), mention (answer @mentions), push (start on merges to the default branch). " +
57
58
  "Your linked GitHub account (link_host_account) must have write access to the repository for a write link, or read for a " +
58
59
  "read link; a wardby admin may instead approve it with adminOverride. checkName is only allowed (and required) with the " +
59
60
  "pull_request trigger, and is unique per repository. Re-linking an already-linked repository replaces its access, " +
@@ -20,10 +20,10 @@ import type { WardbyMcpServer } from "../server.js";
20
20
  export declare function projectTool(tool: Tool, principalId: string): {
21
21
  name: string;
22
22
  code: string;
23
- id: string;
24
- ownerId: string | null;
25
23
  createdAt: Date;
26
24
  updatedAt: Date;
25
+ id: string;
26
+ ownerId: string | null;
27
27
  description: string;
28
28
  paramsZod: string;
29
29
  jsonSchema: import("@prisma/client/runtime/client").JsonValue;
@@ -29,6 +29,7 @@
29
29
  * gets 404, not another principal's final text/cost.
30
30
  */
31
31
  import { dispatchRun } from "../../core/dispatch.js";
32
+ import { AttributionError, explicitAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET, } from "../../core/attribution.js";
32
33
  import { CodingBaseRefSchema, CodingTaskOverrideSchema } from "../../coding/protocol.js";
33
34
  import { z } from "zod";
34
35
  import { McpError } from "../errors.js";
@@ -42,6 +43,7 @@ const TriggerAgentSchema = z
42
43
  agentId: z.string().trim().min(1).max(128),
43
44
  task: CodingTaskOverrideSchema.optional(),
44
45
  baseRef: CodingBaseRefSchema.optional(),
46
+ issue: z.unknown().optional(),
45
47
  })
46
48
  .strict();
47
49
  function parseTriggerArgs(args) {
@@ -81,7 +83,17 @@ export function registerTriggerTool(mcp) {
81
83
  inputSchema: {
82
84
  type: "object",
83
85
  additionalProperties: false,
84
- properties: { agentId: { type: "string" }, task: { type: "string" }, baseRef: { type: "string" } },
86
+ properties: {
87
+ agentId: { type: "string" },
88
+ task: { type: "string" },
89
+ baseRef: { type: "string" },
90
+ issue: {
91
+ type: "object",
92
+ description: "Count this run's cost toward an issue in a project the agent is linked to.",
93
+ properties: { provider: { type: "string", enum: ["jira"] }, key: { type: "string" } },
94
+ required: ["provider", "key"],
95
+ },
96
+ },
85
97
  required: ["agentId"],
86
98
  },
87
99
  handler: async (rawArgs, ctx) => {
@@ -90,13 +102,28 @@ export function registerTriggerTool(mcp) {
90
102
  if (agent.kind !== "coding" && (args.task !== undefined || args.baseRef !== undefined)) {
91
103
  throw new McpError(400, "Task and baseRef overrides are only valid for coding agents.");
92
104
  }
105
+ // Refused before any run exists: an invalid or unlinked issue is never
106
+ // silently dropped into an unattributed run.
107
+ let attribution;
108
+ if (args.issue !== undefined) {
109
+ try {
110
+ attribution = await explicitAttribution(ctx.db, ctx.providers.issueTrackers, agent.id, args.issue, RESPONSE_PATH_SNAPSHOT_BUDGET);
111
+ }
112
+ catch (err) {
113
+ if (err instanceof AttributionError)
114
+ throw new McpError(400, err.message);
115
+ throw err;
116
+ }
117
+ }
93
118
  const dispatched = await dispatchRun({
94
119
  db: ctx.db,
95
120
  executor: ctx.providers.executor,
121
+ selfDefects: { db: ctx.db, issueTrackers: ctx.providers.issueTrackers },
96
122
  agentId: agent.id,
97
123
  trigger: "manual",
98
124
  codingTask: args.task,
99
125
  codingBaseRef: args.baseRef,
126
+ attribution,
100
127
  task: ctx.clientSupportsTasks ? { principalId: ctx.principal.id, ttlMs: DEFAULT_TASK_TTL_MS } : undefined,
101
128
  triggeredById: ctx.principal.id,
102
129
  // Re-checked through the transaction: a concurrent revoke or
@@ -113,10 +140,11 @@ export function registerTriggerTool(mcp) {
113
140
  throw new Error("Run task was not persisted.");
114
141
  return textResult(createTaskResult(dispatched.task, DEFAULT_TASK_TTL_MS));
115
142
  }
116
- // A run refused at dispatch (its budget group or run tree is spent) is
117
- // already terminal: say so now rather than only through get_run.
118
- if (dispatched.run.status === "refused") {
119
- return textResult({ runId: dispatched.run.id, status: "refused", error: dispatched.run.error });
143
+ // A run refused at dispatch (its budget group or run tree is spent), or
144
+ // failed there (a coding agent's model is unavailable), is already
145
+ // terminal: say so now rather than only through get_run.
146
+ if (dispatched.run.status === "refused" || dispatched.run.status === "failed") {
147
+ return textResult({ runId: dispatched.run.id, status: dispatched.run.status, error: dispatched.run.error });
120
148
  }
121
149
  return textResult({ runId: dispatched.run.id });
122
150
  },
@@ -6,7 +6,9 @@ import type { SelfHostedAuthProvider } from "../../providers/auth/self-hosted.js
6
6
  import type { WardbyMcpServer } from "../server.js";
7
7
  import type { McpProviders } from "../context.js";
8
8
  import { type GitHubIngressDeps } from "../host-events/github-ingress.js";
9
+ import { type JiraIngressDeps } from "../host-events/jira-ingress.js";
9
10
  import { type HostUserCallbackDeps } from "../host-events/github-user-callback.js";
11
+ import { type ViewerApi } from "../../viewer/http.js";
10
12
  export interface HttpServerConfig {
11
13
  canonicalUri: string;
12
14
  httpBind: {
@@ -30,11 +32,14 @@ export interface StartHttpServerOptions {
30
32
  /** Code-review host webhooks; absent = the endpoints answer 404. */
31
33
  hostEvents?: {
32
34
  github?: GitHubIngressDeps;
35
+ jira?: JiraIngressDeps;
33
36
  };
34
37
  /** The host identity-link browser callback (link_host_account); absent = 404. */
35
38
  hostUserAuth?: {
36
39
  github?: HostUserCallbackDeps;
37
40
  };
41
+ /** The admin viewer API (/admin/api/*); absent = those paths answer 404. */
42
+ viewer?: ViewerApi;
38
43
  }
39
44
  export interface HttpServerHandle {
40
45
  port: number;
@@ -6,11 +6,13 @@ import { authenticate, protectedResourceMetadata } from "../auth/resource-server
6
6
  import { McpError } from "../errors.js";
7
7
  import { handleWebhookIngress } from "../webhooks/ingress.js";
8
8
  import { handleGitHubEventIngress } from "../host-events/github-ingress.js";
9
+ import { handleJiraEventIngress } from "../host-events/jira-ingress.js";
9
10
  import { handleHostUserCallback } from "../host-events/github-user-callback.js";
10
11
  import { canonicalUrl, HTTP_LIMITS, HttpBoundaryError, parseBody, readBody } from "./http-limits.js";
11
12
  import { browserHandler } from "../auth/self-hosted/browser.js";
12
13
  import { handleSecretElicitationForm, SECRET_ELICITATION_PATH } from "../tools/secret-elicitation-form.js";
13
14
  import { logger } from "../../core/logger.js";
15
+ import { VIEWER_API_PREFIX } from "../../viewer/http.js";
14
16
  const httpLog = logger.child({ module: "streamable-http" });
15
17
  export function sendJson(res, status, body, headers = {}) {
16
18
  res
@@ -119,7 +121,7 @@ export async function startHttpServer(opts) {
119
121
  if (!body || Array.isArray(body) || body instanceof URLSearchParams)
120
122
  throw new HttpBoundaryError(415, "expected_json_object");
121
123
  const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
122
- const result = await handleWebhookIngress(webhook[1], { headers, body: body }, opts.auth.db, opts.auth.providers.executor);
124
+ const result = await handleWebhookIngress(webhook[1], { headers, body: body }, opts.auth.db, opts.auth.providers.executor, opts.auth.providers.issueTrackers);
123
125
  sendJson(res, result.status, result.body);
124
126
  return;
125
127
  }
@@ -136,6 +138,19 @@ export async function startHttpServer(opts) {
136
138
  }
137
139
  return;
138
140
  }
141
+ if (url.pathname === "/hosts/jira/events" && req.method === "POST") {
142
+ if (!opts.hostEvents?.jira) {
143
+ sendJson(res, 404, { error: "not_found" });
144
+ return;
145
+ }
146
+ const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
147
+ const result = await handleJiraEventIngress({ headers, rawBody: rawBody ?? "" }, opts.hostEvents.jira);
148
+ sendJson(res, result.status, result.body);
149
+ if (result.afterResponse) {
150
+ void result.afterResponse().catch((err) => httpLog.warn({ err }, "host event follow-up failed"));
151
+ }
152
+ return;
153
+ }
139
154
  if (url.pathname === "/hosts/github/user-callback" && req.method === "GET") {
140
155
  // Unauthenticated by design (a browser redirect from GitHub): the
141
156
  // single-use, hashed OAuth state is what binds it to a link request,
@@ -147,6 +162,12 @@ export async function startHttpServer(opts) {
147
162
  await handleHostUserCallback(url, res, opts.hostUserAuth.github);
148
163
  return;
149
164
  }
165
+ if (url.pathname.startsWith(VIEWER_API_PREFIX)) {
166
+ if (opts.viewer && (await opts.viewer.handle(req, res, url)))
167
+ return;
168
+ sendJson(res, 404, { error: "not_found" });
169
+ return;
170
+ }
150
171
  if (url.pathname === "/mcp") {
151
172
  if (req.method === "POST" && (!body || body instanceof URLSearchParams))
152
173
  throw new HttpBoundaryError(415, "expected_json");
@@ -181,6 +202,7 @@ export async function startHttpServer(opts) {
181
202
  return {
182
203
  port: typeof address === "object" && address ? address.port : opts.config.httpBind.port,
183
204
  close: () => new Promise((resolve, reject) => {
205
+ opts.viewer?.closeStreams();
184
206
  server.close((err) => (err ? reject(err) : resolve()));
185
207
  server.closeIdleConnections();
186
208
  }),
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { PrismaClient } from "#prisma";
8
8
  import type { Executor } from "../../providers/executor/types.js";
9
+ import type { IssueTrackerRegistry } from "../../providers/issue-tracker/types.js";
9
10
  export interface WebhookIngressRequest {
10
11
  headers: Record<string, string | undefined>;
11
12
  body: Record<string, unknown>;
@@ -14,4 +15,4 @@ export interface WebhookIngressResult {
14
15
  status: number;
15
16
  body: Record<string, unknown>;
16
17
  }
17
- export declare function handleWebhookIngress(webhookId: string, req: WebhookIngressRequest, db: PrismaClient, executor: Executor): Promise<WebhookIngressResult>;
18
+ export declare function handleWebhookIngress(webhookId: string, req: WebhookIngressRequest, db: PrismaClient, executor: Executor, issueTrackers?: IssueTrackerRegistry): Promise<WebhookIngressResult>;