@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,208 @@
1
+ import { ISSUE_KEY, ISSUE_TRACKER_PROVIDERS, projectOf, } from "../providers/issue-tracker/types.js";
2
+ import { logger } from "./logger.js";
3
+ const log = logger.child({ module: "attribution" });
4
+ /** A WorkItem snapshotted this recently is reused as-is, without calling the tracker. */
5
+ export const SNAPSHOT_CACHE_MS = 10 * 60 * 1000;
6
+ export const SNAPSHOT_TIMEOUT_MS = 3000;
7
+ /**
8
+ * Snapshot budget for a caller that is answering a request (a webhook, an
9
+ * MCP tool call, a host event): a short timeout and no 429 retry, so a slow
10
+ * or rate-limited tracker degrades to key-only attribution instead of
11
+ * stalling the response.
12
+ */
13
+ export const RESPONSE_PATH_SNAPSHOT_BUDGET = { timeoutMs: 2000, retryOn429: false };
14
+ /**
15
+ * The work item a new attribution points at, snapshotted from the tracker
16
+ * unless a fresh snapshot is already stored. Never throws: a failed lookup or
17
+ * snapshot attributes by key only.
18
+ */
19
+ export async function resolveWorkItem(db, trackers, provider, key, opts = {}) {
20
+ const now = opts.now ?? new Date();
21
+ const keyOnly = (scopeKey = projectOf(key)) => ({ provider, key, scopeKey, snapshot: null });
22
+ let existing;
23
+ try {
24
+ existing = await db.workItem.findUnique({
25
+ where: { provider_key: { provider, key } },
26
+ select: { refreshedAt: true, scopeKey: true },
27
+ });
28
+ }
29
+ catch (err) {
30
+ log.warn({ err, provider, key }, "work item lookup failed; attributing by key only");
31
+ return keyOnly();
32
+ }
33
+ if (existing?.refreshedAt && now.getTime() - existing.refreshedAt.getTime() < SNAPSHOT_CACHE_MS) {
34
+ return keyOnly(existing.scopeKey);
35
+ }
36
+ const tracker = trackers?.[provider];
37
+ if (!tracker)
38
+ return keyOnly(existing?.scopeKey);
39
+ try {
40
+ const snapshot = await tracker.snapshotIssue(key, {
41
+ timeoutMs: opts.timeoutMs ?? SNAPSHOT_TIMEOUT_MS,
42
+ retryOn429: opts.retryOn429,
43
+ });
44
+ return { provider, key, scopeKey: snapshot.scopeKey, snapshot };
45
+ }
46
+ catch (err) {
47
+ log.warn({ err, provider, key }, "issue snapshot failed; attributing by key only");
48
+ return keyOnly(existing?.scopeKey);
49
+ }
50
+ }
51
+ /** Upserts the item (and its parent) and returns the item's id and the parent to freeze. A key-only result never clobbers stored fields. */
52
+ async function upsertWorkItem(tx, item, now) {
53
+ const s = item.snapshot;
54
+ if (s?.parent) {
55
+ // The parent's own type/scope/title are refreshed when it is snapshotted itself; here only make sure it exists
56
+ // and take a title we were handed for free.
57
+ await tx.workItem.upsert({
58
+ where: { provider_key: { provider: item.provider, key: s.parent.key } },
59
+ create: {
60
+ provider: item.provider,
61
+ key: s.parent.key,
62
+ scopeKey: projectOf(s.parent.key),
63
+ ...(s.parent.title ? { title: s.parent.title } : {}),
64
+ },
65
+ update: s.parent.title ? { title: s.parent.title } : {},
66
+ });
67
+ }
68
+ const snapshotFields = s
69
+ ? {
70
+ title: s.title ?? null,
71
+ type: s.type ?? null,
72
+ url: s.url,
73
+ scopeKey: s.scopeKey,
74
+ parentKey: s.parent?.key ?? null,
75
+ parentKind: s.parent?.kind ?? null,
76
+ refreshedAt: now,
77
+ }
78
+ : {};
79
+ const row = await tx.workItem.upsert({
80
+ where: { provider_key: { provider: item.provider, key: item.key } },
81
+ create: { provider: item.provider, key: item.key, scopeKey: item.scopeKey, ...snapshotFields },
82
+ update: snapshotFields,
83
+ select: { id: true, parentKey: true },
84
+ });
85
+ return row;
86
+ }
87
+ /**
88
+ * Covers runs created before attribution existed (no backfill): a continued
89
+ * coding run that already carries CodingRun.issueKey, else a direct parent
90
+ * with a RunIssueStatus. Grandchildren then inherit the child's new
91
+ * RunAttribution, so only the direct parent is checked.
92
+ */
93
+ async function legacyIssue(tx, from) {
94
+ if (from.continuesCodingRunId) {
95
+ const prior = await tx.codingRun.findUnique({
96
+ where: { runId: from.continuesCodingRunId },
97
+ select: { issueProvider: true, issueKey: true },
98
+ });
99
+ if (prior?.issueProvider && prior.issueKey)
100
+ return { provider: prior.issueProvider, key: prior.issueKey };
101
+ }
102
+ if (from.parentRunId) {
103
+ const status = await tx.runIssueStatus.findUnique({
104
+ where: { runId: from.parentRunId },
105
+ select: { provider: true, issueKey: true },
106
+ });
107
+ if (status)
108
+ return { provider: status.provider, key: status.issueKey };
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * Writes the run's attribution inside dispatch's persist transaction.
114
+ * Precedence: the parent run's attribution, then the continued coding run's,
115
+ * then a pre-attribution issue (legacyIssue), then the caller's intent. Returns the item's provider/key (for
116
+ * CodingRun.issueProvider/issueKey), or null when the run is unattributed.
117
+ */
118
+ export async function attributeRun(tx, runId, from, now = new Date()) {
119
+ for (const ancestorId of [from.parentRunId, from.continuesCodingRunId]) {
120
+ if (!ancestorId)
121
+ continue;
122
+ const inherited = await tx.runAttribution.findUnique({
123
+ where: { runId: ancestorId },
124
+ select: { workItemId: true, parentKeyAtRun: true, workItem: { select: { provider: true, key: true } } },
125
+ });
126
+ if (!inherited)
127
+ continue;
128
+ await tx.runAttribution.create({
129
+ data: { runId, workItemId: inherited.workItemId, parentKeyAtRun: inherited.parentKeyAtRun, source: "inherited" },
130
+ });
131
+ return { provider: inherited.workItem.provider, key: inherited.workItem.key };
132
+ }
133
+ const legacy = await legacyIssue(tx, from);
134
+ if (legacy) {
135
+ const item = { ...legacy, scopeKey: projectOf(legacy.key), snapshot: null };
136
+ const { id, parentKey } = await upsertWorkItem(tx, item, now);
137
+ await tx.runAttribution.create({ data: { runId, workItemId: id, parentKeyAtRun: parentKey, source: "inherited" } });
138
+ return legacy;
139
+ }
140
+ if (!from.intent)
141
+ return null;
142
+ const { id, parentKey } = await upsertWorkItem(tx, from.intent.item, now);
143
+ await tx.runAttribution.create({
144
+ data: { runId, workItemId: id, parentKeyAtRun: parentKey, source: from.intent.source },
145
+ });
146
+ return { provider: from.intent.item.provider, key: from.intent.item.key };
147
+ }
148
+ /** The issue a pull request was opened for (phase-3 IssuePullRequest); the earliest link when several exist. */
149
+ export async function linkedPullRequestIssue(db, pr) {
150
+ try {
151
+ const row = await db.issuePullRequest.findFirst({
152
+ where: { codeProvider: pr.codeProvider, repository: pr.repository, number: pr.number },
153
+ orderBy: { createdAt: "asc" },
154
+ select: { issueProvider: true, issueKey: true },
155
+ });
156
+ return row ? { provider: row.issueProvider, key: row.issueKey } : null;
157
+ }
158
+ catch (err) {
159
+ log.warn({ err, ...pr }, "linked issue lookup failed; the run is unattributed");
160
+ return null;
161
+ }
162
+ }
163
+ /** Attribution for a run on a pull request that was opened for an issue; undefined when it has none. */
164
+ export async function linkedPullRequestAttribution(db, trackers, pr, opts) {
165
+ const issue = await linkedPullRequestIssue(db, pr);
166
+ if (!issue)
167
+ return undefined;
168
+ return { source: "linked_pr", item: await resolveWorkItem(db, trackers, issue.provider, issue.key, opts) };
169
+ }
170
+ /** A refusal whose message is safe to return to the caller. */
171
+ export class AttributionError extends Error {
172
+ constructor(message) {
173
+ super(message);
174
+ this.name = "AttributionError";
175
+ }
176
+ }
177
+ /**
178
+ * An issue a caller names on trigger_agent or a webhook. It decides whose
179
+ * cost this run counts toward, so it must be well formed and in a project the
180
+ * agent is linked to (AgentIssueProject) — otherwise the dispatch is refused.
181
+ * Issue keys are uppercase, so a key that differs only by case or surrounding
182
+ * spaces ("pay-241") is corrected rather than refused. `field` is the caller's
183
+ * name for the value (trigger_agent: issue; webhooks: wardbyIssue), so
184
+ * refusals name the field the caller actually sent.
185
+ */
186
+ export async function validateExplicitIssue(db, agentId, issue, field = "issue") {
187
+ const candidate = issue && typeof issue === "object" ? issue : {};
188
+ const provider = candidate.provider;
189
+ const key = typeof candidate.key === "string" ? candidate.key.trim().toUpperCase() : candidate.key;
190
+ if (typeof provider !== "string" || !ISSUE_TRACKER_PROVIDERS.includes(provider)) {
191
+ throw new AttributionError(`${field}.provider must be one of: ${ISSUE_TRACKER_PROVIDERS.join(", ")}.`);
192
+ }
193
+ if (typeof key !== "string" || !ISSUE_KEY.test(key)) {
194
+ throw new AttributionError(`${field}.key must be an issue key such as PROJ-123.`);
195
+ }
196
+ const link = await db.agentIssueProject.findUnique({
197
+ where: { agentId_provider_projectKey: { agentId, provider, projectKey: projectOf(key) } },
198
+ select: { agentId: true },
199
+ });
200
+ if (!link)
201
+ throw new AttributionError(`This agent is not linked to ${provider} project ${projectOf(key)}.`);
202
+ return { provider: provider, key };
203
+ }
204
+ export async function explicitAttribution(db, trackers, agentId, issue, opts) {
205
+ const { field, ...budget } = opts ?? {};
206
+ const { provider, key } = await validateExplicitIssue(db, agentId, issue, field);
207
+ return { source: "explicit", item: await resolveWorkItem(db, trackers, provider, key, budget) };
208
+ }
@@ -69,6 +69,12 @@ export interface SpendOptions {
69
69
  * whose hold already covers it.
70
70
  */
71
71
  excludeReservationRunIds?: readonly string[];
72
+ /**
73
+ * Runs left out of the group's spend entirely, real cost and hold alike:
74
+ * the run tree when computing the tree's own ceiling, whose spend is
75
+ * subtracted from that ceiling separately and must count only once.
76
+ */
77
+ excludeRunIds?: readonly string[];
72
78
  /**
73
79
  * First come, first served: count only the holds of runs ordered strictly
74
80
  * before this one. Unset (a coding dispatch, whose row does not exist yet)
@@ -125,16 +131,17 @@ export interface RunTreeSpend {
125
131
  /**
126
132
  * Real-time shared budget scope for a sub-agent dispatch: the whole run tree
127
133
  * rooted at `parentRunId`'s ultimate ancestor shares one ceiling —
128
- * the root's own effective budget (its own budgetUsd, itself tightened by
129
- * its own BudgetGroup if any) minus everything every run in the tree has
130
- * spent so far. The root's ceiling is recomputed fresh here, not pinned
131
- * from whatever it was when the root run started — consistent with
132
- * BudgetGroup periods already always being live-recomputed rather than
133
- * snapshotted. That group check sees the group as the root did when it
134
- * loaded: the tree's own holds are left out (the tree spends inside the
135
- * root's hold, so counting it again would leave the tree nothing), and so
136
- * are holds of runs that started after the root (they already counted the
137
- * root's hold).
134
+ * the root's own budgetUsd, itself tightened by the root agent's BudgetGroup
135
+ * if any — minus everything every run in the tree has spent so far. The
136
+ * ceiling is recomputed fresh here, not pinned from whatever it was when the
137
+ * root run started — consistent with BudgetGroup periods already always
138
+ * being live-recomputed rather than snapshotted. Its group check leaves the
139
+ * tree's runs out entirely, cost and hold alike: the tree's spend is
140
+ * subtracted once, below, so counting it in the group too would charge it
141
+ * twice, and the tree spends inside the root's hold, so counting that hold
142
+ * would leave the tree nothing. It also leaves out holds of runs that started
143
+ * after the root (they already counted the root's hold). capUsd is therefore
144
+ * the tree's whole ceiling, and remainingUsd = capUsd - spentUsd.
138
145
  */
139
146
  export declare function computeRunTreeSpend(db: BudgetGroupsDb, parentRunId: string, now?: Date): Promise<RunTreeSpend>;
140
147
  export type BudgetConstraint = Period | "run-tree";
@@ -84,6 +84,7 @@ export async function computeGroupSpend(db, group, members, now = new Date(), op
84
84
  const widestStart = periodStart("month", now);
85
85
  const memberBudgets = new Map(members.map((m) => [m.id, Number(m.budgetUsd)]));
86
86
  const excluded = new Set(options.excludeReservationRunIds ?? []);
87
+ const dropped = new Set(options.excludeRunIds ?? []);
87
88
  const queueTimeoutSec = options.codingQueueTimeoutSec ?? loadCodingConcurrencyConfig().queueTimeoutSec;
88
89
  const runs = members.length === 0
89
90
  ? []
@@ -99,7 +100,8 @@ export async function computeGroupSpend(db, group, members, now = new Date(), op
99
100
  codingRun: { select: { budgetReservedUsd: true, timeoutSec: true } },
100
101
  },
101
102
  });
102
- const accounted = runs.map((r) => {
103
+ const counted = runs.filter((r) => !dropped.has(r.id));
104
+ const accounted = counted.map((r) => {
103
105
  const costUsd = Number(r.costUsd);
104
106
  let reservedUsd = 0;
105
107
  const holds = !excluded.has(r.id) &&
@@ -151,16 +153,17 @@ async function collectTreeRunIds(db, rootRunId) {
151
153
  /**
152
154
  * Real-time shared budget scope for a sub-agent dispatch: the whole run tree
153
155
  * rooted at `parentRunId`'s ultimate ancestor shares one ceiling —
154
- * the root's own effective budget (its own budgetUsd, itself tightened by
155
- * its own BudgetGroup if any) minus everything every run in the tree has
156
- * spent so far. The root's ceiling is recomputed fresh here, not pinned
157
- * from whatever it was when the root run started — consistent with
158
- * BudgetGroup periods already always being live-recomputed rather than
159
- * snapshotted. That group check sees the group as the root did when it
160
- * loaded: the tree's own holds are left out (the tree spends inside the
161
- * root's hold, so counting it again would leave the tree nothing), and so
162
- * are holds of runs that started after the root (they already counted the
163
- * root's hold).
156
+ * the root's own budgetUsd, itself tightened by the root agent's BudgetGroup
157
+ * if any — minus everything every run in the tree has spent so far. The
158
+ * ceiling is recomputed fresh here, not pinned from whatever it was when the
159
+ * root run started — consistent with BudgetGroup periods already always
160
+ * being live-recomputed rather than snapshotted. Its group check leaves the
161
+ * tree's runs out entirely, cost and hold alike: the tree's spend is
162
+ * subtracted once, below, so counting it in the group too would charge it
163
+ * twice, and the tree spends inside the root's hold, so counting that hold
164
+ * would leave the tree nothing. It also leaves out holds of runs that started
165
+ * after the root (they already counted the root's hold). capUsd is therefore
166
+ * the tree's whole ceiling, and remainingUsd = capUsd - spentUsd.
164
167
  */
165
168
  export async function computeRunTreeSpend(db, parentRunId, now = new Date()) {
166
169
  const { rootRunId, capUsd, spentUsd, remainingUsd } = await runTreeSpend(db, parentRunId, now);
@@ -179,7 +182,7 @@ async function runTreeSpend(db, parentRunId, now) {
179
182
  // Root has no parentRunId of its own, so this terminates in one level —
180
183
  // no unbounded recursion regardless of how deep `parentRunId` itself was.
181
184
  const rootCeiling = await groupCappedBudget(db, rootAgent, now, {
182
- excludeReservationRunIds: treeRunIds,
185
+ excludeRunIds: treeRunIds,
183
186
  holdsBefore: rootOrder,
184
187
  });
185
188
  const capUsd = rootCeiling.effectiveBudgetUsd;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { PrismaClient } from "#prisma";
18
18
  import type { Executor } from "../providers/executor/types.js";
19
+ import { type SelfDefectSink } from "./self-defects.js";
19
20
  export declare const CODING_QUEUE_TIMEOUT_ERROR = "coding_queue_timeout";
20
21
  export type CodingQueueDb = Pick<PrismaClient, "run" | "codingRun" | "$transaction">;
21
22
  export interface DrainCodingQueueOptions {
@@ -24,6 +25,8 @@ export interface DrainCodingQueueOptions {
24
25
  maxConcurrent: number;
25
26
  queueTimeoutSec: number;
26
27
  now?: () => Date;
28
+ /** Files a self-defect for each run this drain fails (queue timeout or start failure). Optional. */
29
+ selfDefects?: SelfDefectSink;
27
30
  }
28
31
  export interface DrainCodingQueueResult {
29
32
  timedOut: number;
@@ -1,5 +1,6 @@
1
1
  import { markRunFailedFromExecutorError } from "./dispatch.js";
2
2
  import { logger } from "./logger.js";
3
+ import { fileSelfDefectForRun } from "./self-defects.js";
3
4
  const queueLog = logger.child({ module: "coding-queue" });
4
5
  export const CODING_QUEUE_TIMEOUT_ERROR = "coding_queue_timeout";
5
6
  export async function drainCodingQueue(options) {
@@ -25,8 +26,11 @@ export async function drainCodingQueue(options) {
25
26
  await tx.codingRun.update({ where: { runId }, data: { failureCategory: CODING_QUEUE_TIMEOUT_ERROR } });
26
27
  return true;
27
28
  });
28
- if (didTimeOut)
29
+ if (didTimeOut) {
29
30
  timedOut += 1;
31
+ // Only this drain made the row failed (a concurrent drain matched zero rows); bounded, never throws.
32
+ await fileSelfDefectForRun(options.selfDefects, runId);
33
+ }
30
34
  }
31
35
  const active = await db.codingRun.count({
32
36
  where: { jobBackend: { not: null }, run: { status: { in: ["pending", "running"] } } },
@@ -48,7 +52,7 @@ export async function drainCodingQueue(options) {
48
52
  // drainCodingQueue's own call stack.
49
53
  void Promise.resolve()
50
54
  .then(() => executor.start(runId))
51
- .catch((err) => markRunFailedFromExecutorError(db, runId, err).catch((err2) => queueLog.error({ err: err2, runId }, "failed to persist queued-run start failure")));
55
+ .catch((err) => markRunFailedFromExecutorError(db, runId, err, options.selfDefects).catch((err2) => queueLog.error({ err: err2, runId }, "failed to persist queued-run start failure")));
52
56
  }
53
57
  return { timedOut, started: next.map((row) => row.runId) };
54
58
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Persists launcher-observed coding-run service states (CodingRunServiceStatus, one row per
3
+ * run x service) for the viewer. Composed into the job launchers by
4
+ * providers/executor/composition.ts; launchers call it through reportServiceState, which drops
5
+ * its errors.
6
+ */
7
+ import type { PrismaClient } from "#prisma";
8
+ import type { ServiceStateReporter } from "../providers/jobs/service-state.js";
9
+ type ServiceStatusDb = Pick<PrismaClient, "codingRunServiceStatus">;
10
+ export declare function prismaServiceStateReporter(db: ServiceStatusDb, now?: () => Date): ServiceStateReporter;
11
+ export {};
@@ -0,0 +1,17 @@
1
+ export function prismaServiceStateReporter(db, now = () => new Date()) {
2
+ return async ({ runId, name, state, attempts, reason }) => {
3
+ const at = now();
4
+ const data = {
5
+ state,
6
+ attempts: attempts ?? null,
7
+ reason: reason ?? null,
8
+ readyAt: state === "ready" ? at : null,
9
+ failedAt: state === "failed" ? at : null,
10
+ };
11
+ await db.codingRunServiceStatus.upsert({
12
+ where: { runId_name: { runId, name } },
13
+ create: { runId, name, ...data },
14
+ update: data,
15
+ });
16
+ };
17
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Cost by issue / parent / scope / agent / model / run over a time window
3
+ * (docs/private/2026-10-01-issue-cost-attribution-design.md §7). Rows count
4
+ * attributed runs only; unattributed spend is reported separately so the gap
5
+ * is visible. Money is summed as NUMERIC in SQL and returned as strings; tokens
6
+ * are always by priced kind, never one total.
7
+ */
8
+ import { type PrismaClient } from "#prisma";
9
+ export type CostGroupBy = "issue" | "parent" | "scope" | "agent" | "model" | "run";
10
+ export declare class CostReportInputError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ export interface CostReportQuery {
14
+ groupBy: CostGroupBy;
15
+ from: Date;
16
+ to: Date;
17
+ provider?: string;
18
+ scopeKey?: string;
19
+ parentKey?: string;
20
+ issueKey?: string;
21
+ agentId?: string;
22
+ limit: number;
23
+ }
24
+ export declare function parseCostReportQuery(raw: unknown, now?: Date): CostReportQuery;
25
+ /** Which runs the caller may count: null = all (stdio operator). */
26
+ export interface CostVisibility {
27
+ ownedAgentIds: string[];
28
+ principalId: string;
29
+ }
30
+ export interface TokenKinds {
31
+ freshInput: number;
32
+ cachedInput: number;
33
+ cacheWrite: number;
34
+ output: number;
35
+ }
36
+ export interface CostRow {
37
+ key: string;
38
+ title: string | null;
39
+ kind: string | null;
40
+ provider: string | null;
41
+ runs: number;
42
+ inProgressRuns: number;
43
+ costUsd: string;
44
+ tokens: TokenKinds;
45
+ byModel: Array<{
46
+ model: string;
47
+ costUsd: string;
48
+ tokens: TokenKinds;
49
+ }>;
50
+ bySource: {
51
+ issue_event: string;
52
+ linked_pr: string;
53
+ explicit: string;
54
+ inherited: string;
55
+ };
56
+ firstRunAt: string;
57
+ lastRunAt: string;
58
+ }
59
+ export interface CostReport {
60
+ currency: "USD";
61
+ groupBy: CostGroupBy;
62
+ from: string;
63
+ to: string;
64
+ rows: CostRow[];
65
+ truncated: boolean;
66
+ totals: {
67
+ runs: number;
68
+ costUsd: string;
69
+ tokens: TokenKinds;
70
+ };
71
+ unattributed: {
72
+ runs: number;
73
+ costUsd: string;
74
+ };
75
+ }
76
+ /**
77
+ * How long one report may take. Prisma's interactive-transaction default (5 s)
78
+ * is too short for a wide window over a large Run table; the same limit is set
79
+ * as the transaction's statement_timeout so Postgres stops the work too, not
80
+ * only the client.
81
+ */
82
+ export declare const COST_REPORT_TIMEOUT_MS = 30000;
83
+ /**
84
+ * Every query runs in one read-only REPEATABLE READ transaction, so the rows,
85
+ * the totals and the unattributed figure all come from the same snapshot even
86
+ * while runs are finishing.
87
+ */
88
+ export declare function costReport(db: Pick<PrismaClient, "$queryRaw" | "$transaction">, q: CostReportQuery, visibility: CostVisibility | null): Promise<CostReport>;