@rse/ase 1.0.6 → 2.0.0

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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +230 -116
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -0,0 +1,1246 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import http from "node:http";
9
+ import zlib from "node:zlib";
10
+ import crypto from "node:crypto";
11
+ import { spawn } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+ import { ofetch } from "ofetch";
14
+ import PQueue from "p-queue";
15
+ import YAML from "yaml";
16
+ import writeFileAtomic from "write-file-atomic";
17
+ import lockfile from "proper-lockfile";
18
+ import { Config, sessionState } from "./ase-config-core.js";
19
+ import { configSchema, parsePrices, modelPrice, tokenCost, parseRates, userRate } from "./ase-config-schema.js";
20
+ import { parseScope, userStateDir } from "./ase-config-scope.js";
21
+ import { Task, UsageUncertain } from "./ase-task.js";
22
+ /* the loopback host of the OTLP ingress and of the project services */
23
+ const HOST = "127.0.0.1";
24
+ /* the default port of the OTLP ingress, the default interval (in minutes) of the interim
25
+ bookings of the open usage windows, the default idle time (in minutes) after which a
26
+ usage window pauses, the length of a minute (in ms), the inactivity (in ms) after which
27
+ an open usage window is closed, the grace period (in ms) for late batches before
28
+ the final booking of a closed usage window, the period (in ms) a closed usage window
29
+ whose task vanished is kept for its task being reported as just renamed, the interval
30
+ (in ms) of the attempts to take over the OTLP ingress and of the booking checks, the
31
+ delay (in ms) of the coalesced persisting of the journal after ingested usage records,
32
+ the maximum number of remembered record keys (for de-duplicating re-sent batches),
33
+ the maximum OTLP request size, and the timeout (in ms) of the detaching of a session by
34
+ the session end hook (as Claude Code kills this hook after 1.5 seconds already) */
35
+ const PORT_DFLT = 44318;
36
+ const INTERVAL_DFLT = 10;
37
+ const IDLE_DFLT = 30;
38
+ const MINUTE_MS = 60 * 1000;
39
+ const PURGE_MS = 7 * 24 * 60 * MINUTE_MS;
40
+ const GRACE_MS = 10 * 1000;
41
+ const RENAME_MS = 5 * MINUTE_MS;
42
+ const BIND_MS = 5 * 1000;
43
+ const TICK_MS = 30 * 1000;
44
+ const PERSIST_MS = 5 * 1000;
45
+ const SEEN_MAX = 10000;
46
+ const BODY_MAX = 32 * 1024 * 1024;
47
+ const DETACH_MS = 500;
48
+ /* the id of the current user, the "oneself" of the task assignments */
49
+ export const userId = () => process.env.USER ?? process.env.LOGNAME ?? "unknown";
50
+ /* whether the AI usage accounting is enabled as a whole ("telemetry.enabled", opt-in), i.e., the
51
+ OTLP ingress, the usage windows of the task assignments, and the bookings of the
52
+ "ase agent" jobs */
53
+ export const telemetryEnabled = (log) => {
54
+ const cfg = new Config("config", configSchema, log);
55
+ cfg.read();
56
+ return String(cfg.get("telemetry.enabled") ?? "false") === "true";
57
+ };
58
+ /* whether the AI usage accounting implicitly accounts the active task of a session while
59
+ the task is unassigned ("telemetry.implicit", opt-out), i.e., without the session
60
+ explicitly assigning it to oneself */
61
+ export const telemetryImplicit = (log) => {
62
+ const cfg = new Config("config", configSchema, log);
63
+ cfg.read();
64
+ return String(cfg.get("telemetry.implicit") ?? "true") === "true";
65
+ };
66
+ /* the OTLP ingress of the user: its base URL and its bearer token, where with <create>
67
+ a missing token is generated and persisted on the user configuration scope
68
+ (and without <create> a missing token is reported as empty) */
69
+ export const telemetryEndpoint = (log, create) => {
70
+ const cfg = new Config("config", configSchema, log);
71
+ cfg.read();
72
+ const port = Number(cfg.get("telemetry.service.port") ?? PORT_DFLT);
73
+ let token = String(cfg.get("telemetry.service.token") ?? "");
74
+ if (token === "" && create) {
75
+ token = crypto.randomBytes(24).toString("hex");
76
+ const user = new Config("config", configSchema, log, parseScope("user"));
77
+ user.lock(() => {
78
+ user.read();
79
+ user.set("telemetry.service.token", token);
80
+ user.write();
81
+ });
82
+ }
83
+ return { url: `http://${HOST}:${port}`, token };
84
+ };
85
+ const protoSchemas = {
86
+ LogsRequest: { 1: ["resourceLogs", "msg", true, "ResourceLogs"] },
87
+ ResourceLogs: { 1: ["resource", "msg", false, "Resource"], 2: ["scopeLogs", "msg", true, "ScopeLogs"] },
88
+ ScopeLogs: { 2: ["logRecords", "msg", true, "LogRecord"] },
89
+ LogRecord: {
90
+ 1: ["timeUnixNano", "fixed64"], 11: ["observedTimeUnixNano", "fixed64"],
91
+ 5: ["body", "msg", false, "AnyValue"], 6: ["attributes", "msg", true, "KeyValue"]
92
+ },
93
+ TracesRequest: { 1: ["resourceSpans", "msg", true, "ResourceSpans"] },
94
+ ResourceSpans: { 1: ["resource", "msg", false, "Resource"], 2: ["scopeSpans", "msg", true, "ScopeSpans"] },
95
+ ScopeSpans: { 2: ["spans", "msg", true, "Span"] },
96
+ Span: {
97
+ 2: ["spanId", "bytes"], 5: ["name", "string"],
98
+ 7: ["startTimeUnixNano", "fixed64"], 8: ["endTimeUnixNano", "fixed64"],
99
+ 9: ["attributes", "msg", true, "KeyValue"]
100
+ },
101
+ Resource: { 1: ["attributes", "msg", true, "KeyValue"] },
102
+ KeyValue: { 1: ["key", "string"], 2: ["value", "msg", false, "AnyValue"] },
103
+ AnyValue: {
104
+ 1: ["stringValue", "string"], 2: ["boolValue", "bool"], 3: ["intValue", "int"],
105
+ 4: ["doubleValue", "double"], 5: ["arrayValue", "msg", false, "ArrayValue"],
106
+ 6: ["kvlistValue", "msg", false, "KeyValueList"]
107
+ },
108
+ ArrayValue: { 1: ["values", "msg", true, "AnyValue"] },
109
+ KeyValueList: { 1: ["values", "msg", true, "KeyValue"] }
110
+ };
111
+ /* decode a protobuf message of a type of the OTLP subset above, skipping all unknown fields */
112
+ const decodeProto = (buf, type) => {
113
+ const schema = protoSchemas[type];
114
+ const obj = {};
115
+ let pos = 0;
116
+ const truncated = () => new Error(`truncated protobuf message "${type}"`);
117
+ const varint = () => {
118
+ let result = 0n;
119
+ for (let shift = 0n;; shift += 7n) {
120
+ if (pos >= buf.length)
121
+ throw truncated();
122
+ const b = buf[pos++];
123
+ result |= BigInt(b & 0x7f) << shift;
124
+ if ((b & 0x80) === 0)
125
+ return result;
126
+ }
127
+ };
128
+ while (pos < buf.length) {
129
+ const tag = Number(varint());
130
+ const wire = tag & 0x07;
131
+ let raw;
132
+ if (wire === 0)
133
+ raw = varint();
134
+ else if (wire === 1 || wire === 5) {
135
+ const len = wire === 1 ? 8 : 4;
136
+ raw = buf.subarray(pos, pos + len);
137
+ pos += len;
138
+ }
139
+ else if (wire === 2) {
140
+ const len = Number(varint());
141
+ raw = buf.subarray(pos, pos + len);
142
+ pos += len;
143
+ }
144
+ else
145
+ throw new Error(`unsupported protobuf wire type ${wire} in message "${type}"`);
146
+ if (pos > buf.length)
147
+ throw truncated();
148
+ const field = schema[tag >>> 3];
149
+ if (field === undefined)
150
+ continue;
151
+ const [name, kind, repeated, sub] = field;
152
+ let value;
153
+ if (kind === "msg" && Buffer.isBuffer(raw))
154
+ value = decodeProto(raw, sub);
155
+ else if (kind === "string" && Buffer.isBuffer(raw))
156
+ value = raw.toString("utf8");
157
+ else if (kind === "bytes" && Buffer.isBuffer(raw))
158
+ value = raw.toString("hex");
159
+ else if (kind === "fixed64" && Buffer.isBuffer(raw) && raw.length === 8)
160
+ value = raw.readBigUInt64LE().toString();
161
+ else if (kind === "double" && Buffer.isBuffer(raw) && raw.length === 8)
162
+ value = raw.readDoubleLE();
163
+ else if (kind === "int" && typeof raw === "bigint")
164
+ value = BigInt.asIntN(64, raw).toString();
165
+ else if (kind === "bool" && typeof raw === "bigint")
166
+ value = raw !== 0n;
167
+ else
168
+ continue;
169
+ if (repeated)
170
+ (obj[name] ??= []).push(value);
171
+ else
172
+ obj[name] = value;
173
+ }
174
+ return obj;
175
+ };
176
+ /* flatten the OTLP key/value attributes into a map of scalar values */
177
+ const attributes = (list) => {
178
+ const map = new Map();
179
+ for (const kv of Array.isArray(list) ? list : []) {
180
+ const val = kv?.value ?? {};
181
+ if (typeof kv?.key !== "string")
182
+ continue;
183
+ if (typeof val.stringValue === "string")
184
+ map.set(kv.key, val.stringValue);
185
+ else if (val.intValue !== undefined)
186
+ map.set(kv.key, Number(val.intValue));
187
+ else if (typeof val.doubleValue === "number")
188
+ map.set(kv.key, val.doubleValue);
189
+ else if (typeof val.boolValue === "boolean")
190
+ map.set(kv.key, val.boolValue);
191
+ }
192
+ return map;
193
+ };
194
+ /* extract the usage records of an OTLP/JSON(-shaped) logs or traces request: the
195
+ "claude_code.api_request" events of Claude Code, the "codex.sse_event" (of kind
196
+ "response.completed") events of OpenAI Codex CLI (with the duration from the preceding
197
+ "codex.websocket_request" or "codex.api_request" event of the conversation, as it reports
198
+ none itself), and the GenAI inference ("chat") spans of GitHub Copilot CLI (and any other
199
+ tool following the GenAI semantic conventions); records without a session are dropped;
200
+ the input tokens of Claude Code exclude the cache tokens, while those of Codex (OpenAI
201
+ style) and of the GenAI semantic conventions include them, so for the latter the cache
202
+ tokens are subtracted */
203
+ export const usageRecords = (signal, req) => {
204
+ const records = [];
205
+ const num = (v) => {
206
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
207
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0;
208
+ };
209
+ const inclusive = (input, cread, cwrite, output) => ({ cread, cwrite, input: Math.max(0, input - cread - cwrite), output });
210
+ const nano = (v) => v !== undefined && /^\d+$/.test(String(v)) ? Number(BigInt(String(v)) / 1000000n) : 0;
211
+ const resources = signal === "logs" ? req.resourceLogs : req.resourceSpans;
212
+ for (const rs of Array.isArray(resources) ? resources : []) {
213
+ const res = attributes(rs?.resource?.attributes);
214
+ const scopes = signal === "logs" ? rs?.scopeLogs : rs?.scopeSpans;
215
+ for (const sc of Array.isArray(scopes) ? scopes : []) {
216
+ const items = signal === "logs" ? sc?.logRecords : sc?.spans;
217
+ for (const item of Array.isArray(items) ? items : []) {
218
+ const attrs = attributes(item?.attributes);
219
+ const attr = (...keys) => {
220
+ for (const key of keys) {
221
+ const val = attrs.get(key) ?? res.get(key);
222
+ if (val !== undefined)
223
+ return val;
224
+ }
225
+ return undefined;
226
+ };
227
+ const session = String(attr("session.id", "conversation.id", "gen_ai.conversation.id") ?? "");
228
+ if (session === "")
229
+ continue;
230
+ const model = String(attr("model", "gen_ai.response.model", "gen_ai.request.model") ?? "");
231
+ if (signal === "logs") {
232
+ const name = String(attr("event.name") ?? item?.body?.stringValue ?? "");
233
+ const stamp = Date.parse(String(attr("event.timestamp") ?? ""));
234
+ const time = Number.isFinite(stamp) ? stamp : nano(item?.timeUnixNano) || nano(item?.observedTimeUnixNano);
235
+ if (name === "api_request" || name === "claude_code.api_request") {
236
+ const cost = attr("cost_usd") ?? (attr("cost_usd_micros") !== undefined ? num(attr("cost_usd_micros")) / 1e6 : undefined);
237
+ records.push({
238
+ session, time, model,
239
+ key: `claude:${attr("request_id") ?? attr("event.sequence") ?? time}`,
240
+ durationMs: num(attr("duration_ms")),
241
+ tokens: {
242
+ cread: num(attr("cache_read_tokens")),
243
+ cwrite: num(attr("cache_creation_tokens")),
244
+ input: num(attr("input_tokens")),
245
+ output: num(attr("output_tokens"))
246
+ },
247
+ costUSD: cost !== undefined ? num(cost) : null
248
+ });
249
+ }
250
+ else if (name === "codex.sse_event" && attr("event.kind") === "response.completed") {
251
+ const duration = num(attr("duration_ms"));
252
+ records.push({
253
+ session, time, model,
254
+ key: `codex-sse:${time}`,
255
+ durationMs: duration,
256
+ tokens: inclusive(num(attr("input_token_count")), num(attr("cached_token_count")), num(attr("cache_write_token_count")), num(attr("output_token_count"))),
257
+ costUSD: null,
258
+ ...(duration === 0 ? { mark: "end" } : {})
259
+ });
260
+ }
261
+ else if (name === "codex.websocket_request" || name === "codex.api_request")
262
+ records.push({
263
+ session, time, model,
264
+ key: `codex-req:${time}:${attr("attempt") ?? 0}`,
265
+ durationMs: 0,
266
+ tokens: { cread: 0, cwrite: 0, input: 0, output: 0 },
267
+ costUSD: null,
268
+ mark: "start"
269
+ });
270
+ }
271
+ else {
272
+ const op = String(attr("gen_ai.operation.name") ?? "");
273
+ if (!(op === "chat" || op === "text_completion" || op === "generate_content"
274
+ || (op === "" && String(item?.name ?? "").startsWith("chat"))))
275
+ continue;
276
+ if (attr("gen_ai.usage.input_tokens") === undefined && attr("gen_ai.usage.output_tokens") === undefined)
277
+ continue;
278
+ /* (the "github.copilot.cost" of Copilot is its premium request multiplier
279
+ and its "github.copilot.nano_aiu" its AI credits, so neither is a cost in USD;
280
+ the cache attributes are taken in their current dotted, their older underscored
281
+ (of earlier Copilot CLI versions), and their Codex "cache_write" form) */
282
+ const start = nano(item?.startTimeUnixNano);
283
+ const end = Math.max(start, nano(item?.endTimeUnixNano));
284
+ records.push({
285
+ session, time: end, model,
286
+ key: `span:${item?.spanId ?? start}`,
287
+ durationMs: end - start,
288
+ tokens: inclusive(num(attr("gen_ai.usage.input_tokens")), num(attr("gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_read_input_tokens")), num(attr("gen_ai.usage.cache_creation.input_tokens", "gen_ai.usage.cache_creation_input_tokens", "gen_ai.usage.cache_write.input_tokens")), num(attr("gen_ai.usage.output_tokens"))),
289
+ costUSD: null
290
+ });
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return records;
296
+ };
297
+ /* an empty usage */
298
+ const usageZero = () => ({ aiMs: 0, hrMs: 0, realMs: 0, cread: 0, cwrite: 0, input: 0, output: 0, costAI: 0, costHR: 0 });
299
+ /* the usage tracker of the project service: it owns the usage windows of the tasks of
300
+ the project, accounts the usage records of their attached sessions, books them onto
301
+ the tasks, and acts as the OTLP ingress of the user (routing the usage records to the
302
+ owning project services) whenever no other project service does */
303
+ export class UsageTracker {
304
+ log;
305
+ root;
306
+ project;
307
+ aseDir;
308
+ activity;
309
+ journal = { windows: [], seen: [] };
310
+ seen = new Set();
311
+ queue = new PQueue({ concurrency: 1 });
312
+ ingress = null;
313
+ timers = [];
314
+ watched = new Set();
315
+ unwatch = null;
316
+ started = new Map();
317
+ saving = Promise.resolve();
318
+ deferred = null;
319
+ sessions = new Map();
320
+ registry = null;
321
+ adopting = new PQueue({ concurrency: 1 });
322
+ adopted = new Map();
323
+ owners = new Map();
324
+ explicit = new Set();
325
+ primed = false;
326
+ constructor(log, root, project, aseDir, activity) {
327
+ this.log = log;
328
+ this.root = root;
329
+ this.project = project;
330
+ this.aseDir = aseDir;
331
+ this.activity = activity;
332
+ }
333
+ /* ---- telemetry state ---- */
334
+ /* the per-user telemetry state file, shared by the services of all projects, holding
335
+ the session registry under its "session" field and the journals of the usage windows
336
+ under its "booked" field */
337
+ static stateFile() {
338
+ return path.join(userStateDir(), "telemetry.json");
339
+ }
340
+ /* read the telemetry state file (an empty or missing file being an empty state) */
341
+ static readState(file = UsageTracker.stateFile()) {
342
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
343
+ return text.trim() !== "" ? JSON.parse(text) : {};
344
+ }
345
+ /* the session registry entry of a session (or undefined if not registered) */
346
+ static sessionEntry(session) {
347
+ return UsageTracker.readState().session?.[session];
348
+ }
349
+ /* register resp. unregister a session as attached to a window of this project
350
+ (applied to the telemetry state file by the next persisting of the journal,
351
+ where an unregistration leaves the registration of another project alone) */
352
+ register(session) {
353
+ Task.validateSession(session);
354
+ this.sessions.set(session, { root: this.root, project: this.project });
355
+ }
356
+ unregister(session) {
357
+ this.sessions.set(session, null);
358
+ }
359
+ /* send a request to the service of the project of a root directory
360
+ (whose port is persisted in its ".ase/service.yaml") */
361
+ static async request(root, endpoint, body, timeout = 30 * 1000) {
362
+ const file = path.join(root, ".ase", "service.yaml");
363
+ const port = Number(YAML.parse(fs.readFileSync(file, "utf8"))?.port);
364
+ if (!Number.isInteger(port))
365
+ throw new Error(`no service port in "${file}"`);
366
+ return ofetch(`http://${HOST}:${port}${endpoint}`, { method: "POST", body: body, timeout });
367
+ }
368
+ /* detach a session from its usage window through the owning project service (used by the
369
+ session end hook, within its tight time limit), if the session is registered at all: for
370
+ a service which is not reachable in time (e.g. as it crashed or got stopped), the session
371
+ end is noted in the session registry instead, and the service is started in the
372
+ background (without awaiting it), which then detaches the session as of its noted end,
373
+ as otherwise the window stays open until it is closed for inactivity */
374
+ static async detachRemote(session) {
375
+ const entry = UsageTracker.sessionEntry(session);
376
+ if (entry === undefined)
377
+ return;
378
+ await UsageTracker.request(entry.root, "/usage/detach", { session }, DETACH_MS).catch(async () => {
379
+ const file = UsageTracker.stateFile();
380
+ const release = await lockfile.lock(file, {
381
+ retries: { retries: 5, minTimeout: 20, maxTimeout: 100 }
382
+ });
383
+ let root;
384
+ try {
385
+ const state = UsageTracker.readState(file);
386
+ const noted = state.session?.[session];
387
+ if (noted === undefined)
388
+ return;
389
+ noted.ended = Date.now();
390
+ root = noted.root;
391
+ writeFileAtomic.sync(file, JSON.stringify(state, null, 4) + "\n");
392
+ }
393
+ finally {
394
+ await release();
395
+ }
396
+ const cli = fileURLToPath(new URL("./ase.js", import.meta.url));
397
+ spawn(process.execPath, [cli, "service", "start"], { cwd: root, detached: true, stdio: "ignore" }).unref();
398
+ });
399
+ }
400
+ /* report the rename of a task to the service of the project of a root directory (used
401
+ by every rename through "Task.rename"), if a service of the project runs at all */
402
+ static async renameRemote(root, oldId, newId) {
403
+ if (!fs.existsSync(path.join(root, ".ase", "service.yaml")))
404
+ return;
405
+ await UsageTracker.request(root, "/usage/rename", { old: oldId, new: newId });
406
+ }
407
+ /* ---- journal ---- */
408
+ /* the key of the journal of this project in the telemetry state file: the project id
409
+ plus the first 12 hex digits of the SHA-1 digest of the project root directory
410
+ (as the project id alone may collide between several checkouts of a project) */
411
+ get journalKey() {
412
+ return `${this.project}-${crypto.createHash("sha1").update(this.root).digest("hex").slice(0, 12)}`;
413
+ }
414
+ /* load the journal of this project */
415
+ load() {
416
+ try {
417
+ const journal = UsageTracker.readState(UsageTracker.stateFile()).booked?.[this.journalKey];
418
+ if (journal === undefined)
419
+ return;
420
+ this.journal = journal;
421
+ this.seen = new Set(this.journal.seen);
422
+ }
423
+ catch (err) {
424
+ this.log.write("error", `usage: failed to load journal from "${UsageTracker.stateFile()}": ` +
425
+ `${err instanceof Error ? err.message : String(err)}`);
426
+ }
427
+ }
428
+ /* persist the journal of this project (serialized, as each save writes the then current
429
+ journal): under the lock of the telemetry state file, re-read it, apply the pending
430
+ session (un)registrations, replace (or, without any windows, remove) the journal of
431
+ this project only, and write it atomically, so the concurrent saves of the services
432
+ of other projects are never lost */
433
+ persist() {
434
+ if (this.deferred !== null) {
435
+ clearTimeout(this.deferred);
436
+ this.deferred = null;
437
+ }
438
+ this.journal.seen = Array.from(this.seen).slice(-SEEN_MAX);
439
+ this.seen = new Set(this.journal.seen);
440
+ const sessions = new Map(this.sessions);
441
+ this.saving = this.saving.then(async () => {
442
+ const file = UsageTracker.stateFile();
443
+ fs.mkdirSync(path.dirname(file), { recursive: true });
444
+ fs.appendFileSync(file, "", "utf8");
445
+ const release = await lockfile.lock(file, {
446
+ retries: { retries: 50, minTimeout: 20, maxTimeout: 200 }
447
+ });
448
+ try {
449
+ let state;
450
+ try {
451
+ state = UsageTracker.readState(file);
452
+ }
453
+ catch (err) {
454
+ this.log.write("warning", `usage: discarding unreadable telemetry state "${file}": ` +
455
+ `${err instanceof Error ? err.message : String(err)}`);
456
+ state = {};
457
+ }
458
+ /* (an unregistration removes only an entry of this project, as the session
459
+ may have moved over to a usage window of another project in the meantime) */
460
+ const registry = Object.fromEntries(Object.entries(state.session ?? {})
461
+ .filter(([session, entry]) => !sessions.has(session) || (sessions.get(session) === null && entry.root !== this.root)));
462
+ for (const [session, entry] of sessions)
463
+ if (entry !== null)
464
+ registry[session] = entry;
465
+ const booked = Object.fromEntries(Object.entries(state.booked ?? {})
466
+ .filter(([key]) => key !== this.journalKey));
467
+ if (this.journal.windows.length > 0)
468
+ booked[this.journalKey] = this.journal;
469
+ const next = {
470
+ ...(Object.keys(registry).length > 0 ? { session: registry } : {}),
471
+ ...(Object.keys(booked).length > 0 ? { booked } : {})
472
+ };
473
+ writeFileAtomic.sync(file, JSON.stringify(next, null, 4) + "\n");
474
+ /* drop the applied session (un)registrations (unless superseded in the meantime) */
475
+ for (const [session, entry] of sessions)
476
+ if (this.sessions.get(session) === entry)
477
+ this.sessions.delete(session);
478
+ }
479
+ finally {
480
+ await release();
481
+ }
482
+ }).catch((err) => {
483
+ this.log.write("error", `usage: failed to save journal into "${UsageTracker.stateFile()}": ` +
484
+ `${err instanceof Error ? err.message : String(err)}`);
485
+ });
486
+ return this.saving;
487
+ }
488
+ /* persist the journal of this project deferred, coalescing the frequent persistings
489
+ after the ingested usage records of all sessions (exported every few seconds each)
490
+ into one per delay, at the price of losing the usage of at most this delay on a
491
+ crash of the service (as any immediate persisting takes the deferred one along) */
492
+ persistDeferred() {
493
+ if (this.deferred !== null)
494
+ return;
495
+ this.deferred = setTimeout(() => {
496
+ this.deferred = null;
497
+ this.persist();
498
+ }, PERSIST_MS);
499
+ }
500
+ /* ---- lifecycle ---- */
501
+ /* start the tracker: load the journal, take part in the OTLP ingress election,
502
+ and check the bookings and the foreign assignee changes periodically */
503
+ start() {
504
+ this.load();
505
+ this.bind();
506
+ this.timers.push(setInterval(() => { this.bind(); }, BIND_MS));
507
+ this.timers.push(setInterval(() => { this.tick(); }, TICK_MS));
508
+ for (const timer of this.timers)
509
+ timer.unref();
510
+ this.tick();
511
+ }
512
+ /* stop the tracker (on the stopping of the service): stop its periodic activities,
513
+ release the OTLP ingress, and complete the persisting of the journal (including
514
+ any still deferred one) */
515
+ async stop() {
516
+ for (const timer of this.timers)
517
+ clearInterval(timer);
518
+ this.timers = [];
519
+ if (this.ingress !== null) {
520
+ this.ingress.close();
521
+ this.ingress = null;
522
+ }
523
+ if (this.deferred !== null)
524
+ await this.persist();
525
+ else
526
+ await this.saving;
527
+ }
528
+ /* whether the tracker holds any usage window (keeping the service alive) */
529
+ active() {
530
+ return this.journal.windows.length > 0;
531
+ }
532
+ /* ---- windows ---- */
533
+ /* the open window of a task */
534
+ openWindow(task) {
535
+ return this.journal.windows.find((w) => w.task === task && w.closed === null);
536
+ }
537
+ /* detach a session from its open window at a time, closing the window
538
+ (and scheduling its final booking) once no session remains attached */
539
+ detachAt(session, now) {
540
+ for (const w of this.journal.windows) {
541
+ const a = w.attach.find((a) => a.session === session && a.until === null);
542
+ if (a === undefined)
543
+ continue;
544
+ a.until = now;
545
+ if (w.closed === null && w.attach.every((a) => a.until !== null))
546
+ this.close(w, now);
547
+ }
548
+ this.release(session);
549
+ }
550
+ /* unregister a session once it is attached to no window any longer, as until the final
551
+ booking of a closed window the late batches of the records of its sessions still
552
+ have to be routed to it (during the grace period) */
553
+ release(session) {
554
+ if (!this.journal.windows.some((w) => w.attach.some((a) => a.session === session)))
555
+ this.unregister(session);
556
+ }
557
+ /* book a window in the background, logging any failure */
558
+ bookAsync(w, final) {
559
+ this.book(w, final).catch((err) => {
560
+ this.log.write("error", `usage: booking onto task "${w.task}" failed: ` +
561
+ `${err instanceof Error ? err.message : String(err)}`);
562
+ });
563
+ }
564
+ /* close a window at a time and (unless booked by the caller itself)
565
+ schedule its final booking after the grace period */
566
+ close(w, now, schedule = true) {
567
+ w.closed = now;
568
+ for (const a of w.attach)
569
+ if (a.until === null)
570
+ a.until = now;
571
+ if (schedule)
572
+ setTimeout(() => { this.bookAsync(w, true); }, GRACE_MS).unref();
573
+ }
574
+ /* assign a task (on behalf of a session): to oneself ("me" or the user id) opens or
575
+ attaches to its usage window, to anybody else (or nobody with "none") closes it and
576
+ books its usage after the grace period (with the AI usage accounting disabled, only
577
+ the task is assigned, as its open windows are closed by the periodic check anyway) */
578
+ async assign(id, assignee, session) {
579
+ Task.validateSession(session);
580
+ const self = userId();
581
+ if (assignee.trim() === "")
582
+ throw new Error("usage: empty assignee (use \"none\" to unassign)");
583
+ const to = assignee === "me" ? self : assignee.trim();
584
+ /* (keep the session out of the adoptions meanwhile, and let an assignment to oneself
585
+ settle the adoption of its active task, as the explicit assignment takes precedence) */
586
+ this.explicit.add(session);
587
+ try {
588
+ return await this.assignSession(id, to, self, session);
589
+ }
590
+ finally {
591
+ this.explicit.delete(session);
592
+ }
593
+ }
594
+ async assignSession(id, to, self, session) {
595
+ const warning = await Task.checkAssignee(this.log, to);
596
+ const from = await Task.assign(this.log, id, to === "none" ? null : to);
597
+ const now = Date.now();
598
+ if (to === self) {
599
+ try {
600
+ this.adopted.set(session, Task.getId(this.log, session));
601
+ }
602
+ catch {
603
+ /* best-effort: an unreadable session state just leaves the adoption alone */
604
+ }
605
+ }
606
+ let action = "none";
607
+ let booked = null;
608
+ let notice = "";
609
+ if (!telemetryEnabled(this.log))
610
+ notice = "AI usage accounting is disabled (\"telemetry.enabled\" is \"false\"), so no usage is accounted";
611
+ else if (to === self) {
612
+ let w = this.openWindow(id);
613
+ if (w === undefined || !w.attach.some((a) => a.session === session && a.until === null)) {
614
+ this.detachAt(session, now);
615
+ action = w === undefined ? "opened" : "attached";
616
+ if (w === undefined) {
617
+ w = {
618
+ task: id, self, opened: now, closed: null, realFrom: now, active: now, attach: [],
619
+ unbooked: usageZero(), booked: now, records: 0
620
+ };
621
+ this.journal.windows.push(w);
622
+ }
623
+ w.attach.push({ session, from: now, until: null });
624
+ this.register(session);
625
+ }
626
+ else {
627
+ /* (an implicit attachment becomes an explicit one) */
628
+ for (const a of w.attach)
629
+ if (a.session === session && a.until === null)
630
+ delete a.implicit;
631
+ }
632
+ if (w !== undefined)
633
+ this.touch(w, now, now, this.idleMs());
634
+ if (telemetryEndpoint(this.log, false).token === "")
635
+ notice = "telemetry is not activated (run \"ase setup telemetry activate\"), so no usage is accounted";
636
+ }
637
+ else {
638
+ const w = this.openWindow(id);
639
+ if (w !== undefined) {
640
+ this.close(w, now, false);
641
+ await this.persist();
642
+ await new Promise((resolve) => setTimeout(resolve, GRACE_MS));
643
+ booked = await this.book(w, true);
644
+ action = "closed";
645
+ if (w.records === 0)
646
+ notice = "the usage window received no telemetry records (is telemetry activated for the agent tool?)";
647
+ }
648
+ }
649
+ await this.persist();
650
+ this.watch();
651
+ notice = [warning, notice].filter((n) => n !== "").join("; ");
652
+ return { from, to: to === "none" ? "" : to, action, booked, notice };
653
+ }
654
+ /* detach a session (on its end) */
655
+ detach(session) {
656
+ this.detachAt(session, Date.now());
657
+ this.persist();
658
+ this.watch();
659
+ }
660
+ /* note the switch of a session to another active task, so its adoption
661
+ happens right away instead of on the next periodic check only, as
662
+ otherwise the usage records of its very first AI requests on this task
663
+ would be dropped */
664
+ activate(session) {
665
+ Task.validateSession(session);
666
+ this.adoptSoon();
667
+ }
668
+ /* follow the rename of a task with its usage windows, so their usage is
669
+ booked onto the task under its new id */
670
+ rename(oldId, newId) {
671
+ Task.validateId(oldId);
672
+ Task.validateId(newId);
673
+ let changed = false;
674
+ for (const w of this.journal.windows) {
675
+ if (w.task !== oldId)
676
+ continue;
677
+ w.task = newId;
678
+ delete w.missing;
679
+ changed = true;
680
+ }
681
+ if (!changed)
682
+ return;
683
+ this.log.write("info", `usage: task "${oldId}" renamed to "${newId}" -- following with its usage windows`);
684
+ this.persist();
685
+ this.watch();
686
+ }
687
+ /* account usage records into the windows their sessions were attached to at their time
688
+ (in time order, so a request completion follows the marked sending of its request) */
689
+ ingest(records) {
690
+ let prices = null;
691
+ let idle = null;
692
+ let changed = false;
693
+ const valid = records.filter((r) => typeof r?.session === "string" && Number.isFinite(r.time) && typeof r.key === "string"
694
+ && Number.isFinite(r.durationMs) && (r.costUSD === null || Number.isFinite(r.costUSD))
695
+ && typeof r.tokens === "object" && r.tokens !== null
696
+ && Number.isFinite(r.tokens.cread + r.tokens.cwrite + r.tokens.input + r.tokens.output));
697
+ for (const r of valid.sort((a, b) => a.time - b.time)) {
698
+ if (r.mark === "start") {
699
+ this.started.set(r.session, Math.max(this.started.get(r.session) ?? 0, r.time));
700
+ continue;
701
+ }
702
+ let duration = r.durationMs;
703
+ if (r.mark === "end") {
704
+ const start = this.started.get(r.session);
705
+ duration = start !== undefined && start <= r.time ? r.time - start : 0;
706
+ this.started.delete(r.session);
707
+ }
708
+ const key = `${r.session}:${r.key}`;
709
+ if (this.seen.has(key))
710
+ continue;
711
+ const w = this.journal.windows.find((w) => w.attach.some((a) => a.session === r.session && r.time >= a.from && (a.until === null || r.time <= a.until)));
712
+ if (w === undefined)
713
+ continue;
714
+ let cost = r.costUSD;
715
+ if (cost === null) {
716
+ if (prices === null) {
717
+ const cfg = new Config("config", configSchema, this.log);
718
+ cfg.read();
719
+ prices = parsePrices(String(cfg.get("telemetry.prices.ai") ?? ""));
720
+ }
721
+ cost = tokenCost(modelPrice(prices, r.model), r.tokens);
722
+ }
723
+ this.seen.add(key);
724
+ idle ??= this.idleMs();
725
+ this.touch(w, r.time - duration, r.time, idle);
726
+ w.records++;
727
+ w.unbooked.aiMs += duration;
728
+ w.unbooked.cread += r.tokens.cread;
729
+ w.unbooked.cwrite += r.tokens.cwrite;
730
+ w.unbooked.input += r.tokens.input;
731
+ w.unbooked.output += r.tokens.output;
732
+ w.unbooked.costAI += cost;
733
+ changed = true;
734
+ }
735
+ if (changed)
736
+ this.persistDeferred();
737
+ }
738
+ /* the idle time (in ms) after the last activity of a usage window, after which the
739
+ window pauses accounting its real time (zero disabling the pausing) */
740
+ idleMs() {
741
+ const cfg = new Config("config", configSchema, this.log);
742
+ cfg.read();
743
+ return Number(cfg.get("telemetry.service.idle") ?? IDLE_DFLT) * MINUTE_MS;
744
+ }
745
+ /* account the real time of a window from where it is accounted up to a time, in full
746
+ also as the human time (costed at the hourly rate of the assignee in
747
+ "telemetry.prices.hr"), independent of the AI request time, which can even
748
+ exceed it with parallel sessions */
749
+ accrue(w, until) {
750
+ if (until <= w.realFrom)
751
+ return;
752
+ const cfg = new Config("config", configSchema, this.log);
753
+ cfg.read();
754
+ const rate = userRate(parseRates(String(cfg.get("telemetry.prices.hr") ?? "")), w.self);
755
+ const real = until - w.realFrom;
756
+ w.unbooked.realMs += real;
757
+ w.unbooked.hrMs += real;
758
+ w.unbooked.costHR += real / (60 * MINUTE_MS) * rate;
759
+ w.realFrom = until;
760
+ }
761
+ /* note an activity of a window from a time to a time: an activity beyond the idle time
762
+ after the last one resumes the paused window, i.e., first its real time is accounted up
763
+ to its pause, and then it is accounted again from the activity on only (skipping the
764
+ paused gap), while an activity within the idle time just keeps the window running */
765
+ touch(w, from, to, idle) {
766
+ if (idle > 0 && from > w.active + idle) {
767
+ this.accrue(w, w.active + idle);
768
+ w.realFrom = Math.max(w.realFrom, from);
769
+ }
770
+ w.active = Math.max(w.active, to);
771
+ }
772
+ /* book the unbooked usage of a window onto its task (serialized): its real time is
773
+ accounted up to its closing (or the current) time, but at most up to the idle time
774
+ after its last activity (as it is paused afterwards); an interim booking books the
775
+ whole units only (carrying the remainder over), the final one books the rounded units
776
+ and drops the window; a failed booking is retried on the next tick, unless it might
777
+ have been done nevertheless (then it is taken as done, so it is never booked twice) */
778
+ book(w, final) {
779
+ return this.queue.add(async () => {
780
+ if (!this.journal.windows.includes(w))
781
+ return null;
782
+ const idle = this.idleMs();
783
+ const end = w.closed ?? Date.now();
784
+ this.accrue(w, idle > 0 ? Math.min(end, w.active + idle) : end);
785
+ const u = w.unbooked;
786
+ const units = (value) => (final ? Math.round(value) : Math.floor(value + 1e-9));
787
+ const delta = {
788
+ "time.ai": units(u.aiMs / MINUTE_MS),
789
+ "time.hr": units(u.hrMs / MINUTE_MS),
790
+ "time.real": units(u.realMs / MINUTE_MS),
791
+ "token.cread": u.cread,
792
+ "token.cwrite": u.cwrite,
793
+ "token.input": u.input,
794
+ "token.output": u.output,
795
+ "cost.ai": units(u.costAI * 1e4) / 1e4,
796
+ "cost.hr": units(u.costHR * 1e4) / 1e4
797
+ };
798
+ if (Object.values(delta).some((amount) => amount > 0)) {
799
+ try {
800
+ if (!await Task.addUsage(this.log, w.task, delta)) {
801
+ /* the task of an open window may still be created (as a session can
802
+ switch to a new task before its first save), so an interim booking
803
+ just keeps its usage until the next interval */
804
+ const now = Date.now();
805
+ if (!final) {
806
+ w.booked = now;
807
+ await this.persist();
808
+ return null;
809
+ }
810
+ /* a vanished task may just have been renamed, with the rename being
811
+ reported a moment later (see "rename"), so the window keeps its
812
+ usage and is retried for a while, before it is dropped */
813
+ if (w.missing === undefined || now - w.missing < RENAME_MS) {
814
+ if (w.missing === undefined)
815
+ this.log.write("warning", `usage: task "${w.task}" not found -- keeping its usage window (retrying later)`);
816
+ w.missing ??= now;
817
+ await this.persist();
818
+ return null;
819
+ }
820
+ this.log.write("warning", `usage: task "${w.task}" vanished -- dropping its usage window ` +
821
+ `with its unbooked usage: ${JSON.stringify(delta)}`);
822
+ if (w.closed === null)
823
+ this.close(w, now, false);
824
+ this.journal.windows = this.journal.windows.filter((x) => x !== w);
825
+ for (const a of w.attach)
826
+ this.release(a.session);
827
+ await this.persist();
828
+ return null;
829
+ }
830
+ this.log.write("info", `usage: booked onto task "${w.task}": ${JSON.stringify(delta)}`);
831
+ }
832
+ catch (err) {
833
+ const reason = err instanceof Error ? err.message : String(err);
834
+ if (!(err instanceof UsageUncertain)) {
835
+ this.log.write("warning", `usage: booking onto task "${w.task}" failed (retrying later): ${reason}`);
836
+ await this.persist();
837
+ return null;
838
+ }
839
+ /* a booking which might have been done nevertheless is taken as done, as
840
+ a retried one would then be booked twice (reporting its usage instead,
841
+ as it might be lost) */
842
+ this.log.write("warning", `usage: booking onto task "${w.task}" failed, but might have been done ` +
843
+ `(not retrying, so it is never booked twice): ${reason} -- possibly unbooked usage: ${JSON.stringify(delta)}`);
844
+ }
845
+ delete w.missing;
846
+ }
847
+ u.aiMs -= delta["time.ai"] * MINUTE_MS;
848
+ u.hrMs -= delta["time.hr"] * MINUTE_MS;
849
+ u.realMs -= delta["time.real"] * MINUTE_MS;
850
+ u.cread -= delta["token.cread"];
851
+ u.cwrite -= delta["token.cwrite"];
852
+ u.input -= delta["token.input"];
853
+ u.output -= delta["token.output"];
854
+ u.costAI -= delta["cost.ai"];
855
+ u.costHR -= delta["cost.hr"];
856
+ w.booked = Date.now();
857
+ if (final) {
858
+ this.journal.windows = this.journal.windows.filter((x) => x !== w);
859
+ for (const a of w.attach)
860
+ this.release(a.session);
861
+ }
862
+ await this.persist();
863
+ return delta;
864
+ });
865
+ }
866
+ /* periodically detach the sessions whose end got noted in the session registry (as it
867
+ could not be reported to this service), and book: the closed windows (whose final
868
+ booking failed or was interrupted) after the grace period, the open windows every
869
+ interim booking interval, and close the open windows without any activity for a long
870
+ time (e.g. of crashed agent tools, whose session end never got reported) or all open
871
+ windows once the AI usage accounting got disabled, so their final booking happens and
872
+ they vanish, and adopt the sessions which switched to (or started with) an active task
873
+ assigned to oneself */
874
+ tick() {
875
+ let registry = {};
876
+ try {
877
+ registry = UsageTracker.readState().session ?? {};
878
+ }
879
+ catch (err) {
880
+ this.log.write("warning", `usage: failed to read session registry: ${err instanceof Error ? err.message : String(err)}`);
881
+ }
882
+ for (const [session, entry] of Object.entries(registry)) {
883
+ /* (the not yet persisted (un)registrations of this project take precedence) */
884
+ if (entry.root !== this.root || entry.ended === undefined || this.sessions.has(session)
885
+ || !this.journal.windows.some((w) => w.attach.some((a) => a.session === session && a.until === null)))
886
+ continue;
887
+ this.log.write("info", `usage: session "${session}" ended at ` +
888
+ `${new Date(entry.ended).toISOString()} -- detaching it from its usage window`);
889
+ this.detachAt(session, entry.ended);
890
+ this.persist();
891
+ }
892
+ const cfg = new Config("config", configSchema, this.log);
893
+ cfg.read();
894
+ const interval = Number(cfg.get("telemetry.service.interval") ?? INTERVAL_DFLT) * MINUTE_MS;
895
+ const enabled = telemetryEnabled(this.log);
896
+ const now = Date.now();
897
+ for (const w of this.journal.windows) {
898
+ if (w.closed !== null && now - w.closed >= GRACE_MS * 2)
899
+ this.bookAsync(w, true);
900
+ else if (w.closed === null && !enabled) {
901
+ this.log.write("info", `usage: telemetry disabled -- closing the usage window of task "${w.task}"`);
902
+ this.close(w, now);
903
+ this.persist();
904
+ }
905
+ else if (w.closed === null && now - w.active >= PURGE_MS) {
906
+ this.log.write("info", `usage: task "${w.task}" without any activity since ` +
907
+ `${new Date(w.active).toISOString()} -- closing its usage window`);
908
+ this.close(w, now);
909
+ this.persist();
910
+ }
911
+ else if (w.closed === null && interval > 0 && now - w.booked >= interval)
912
+ this.bookAsync(w, false);
913
+ }
914
+ this.watch();
915
+ this.adoptSoon();
916
+ }
917
+ /* ---- foreign assignee changes ---- */
918
+ /* observe the task store while any open window has its task active in one of its attached
919
+ sessions, closing the window once the assignee of its task changes to anybody else, and
920
+ while the AI usage accounting is enabled, adopting the sessions of the tasks whose
921
+ assignee changes to oneself */
922
+ watch() {
923
+ this.watched.clear();
924
+ for (const w of this.journal.windows) {
925
+ if (w.closed !== null)
926
+ continue;
927
+ const active = w.attach.some((a) => {
928
+ if (a.until !== null)
929
+ return false;
930
+ try {
931
+ return Task.getId(this.log, a.session) === w.task;
932
+ }
933
+ catch {
934
+ return false;
935
+ }
936
+ });
937
+ if (active)
938
+ this.watched.add(w.task);
939
+ }
940
+ const needed = this.watched.size > 0 || telemetryEnabled(this.log);
941
+ if (needed && this.unwatch === null)
942
+ this.unwatch = Task.subscribe(this.log, () => {
943
+ this.check().catch((err) => {
944
+ this.log.write("warning", "usage: failed to check the assignees of the watched tasks: " +
945
+ `${err instanceof Error ? err.message : String(err)}`);
946
+ });
947
+ this.owners.clear();
948
+ this.adoptSoon();
949
+ });
950
+ else if (!needed && this.unwatch !== null) {
951
+ this.unwatch();
952
+ this.unwatch = null;
953
+ }
954
+ }
955
+ async check() {
956
+ for (const task of Array.from(this.watched)) {
957
+ const w = this.openWindow(task);
958
+ if (w === undefined)
959
+ continue;
960
+ /* (a vanished task may just have been renamed, so it is left to the bookings) */
961
+ const parts = await Task.parts(this.log, task);
962
+ if (parts === null)
963
+ continue;
964
+ const assignee = (parts.keys.get("Assignee") ?? "").trim();
965
+ if (assignee === w.self || w.closed !== null)
966
+ continue;
967
+ if (assignee === "" && this.implicit(task)) {
968
+ /* an unassigned task stays accounted implicitly, i.e., its attached sessions
969
+ stay attached only as long as it remains their active task (see "adopt") */
970
+ const explicit = w.attach.filter((a) => a.until === null && a.implicit !== true);
971
+ if (explicit.length === 0)
972
+ continue;
973
+ this.log.write("info", `usage: task "${task}" unassigned elsewhere -- ` +
974
+ "accounting its usage window implicitly from now on");
975
+ for (const a of explicit)
976
+ a.implicit = true;
977
+ await this.persist();
978
+ this.adoptSoon();
979
+ }
980
+ else {
981
+ this.log.write("info", `usage: task "${task}" re-assigned to "${assignee}" elsewhere -- closing its usage window`);
982
+ this.close(w, Date.now());
983
+ await this.persist();
984
+ }
985
+ }
986
+ this.watch();
987
+ }
988
+ /* ---- self-assignments elsewhere and unassigned active tasks ---- */
989
+ /* whether a task is accounted implicitly while it is unassigned, i.e., unless disabled
990
+ ("telemetry.implicit") or the task is the "default" one (the placeholder task of
991
+ every session without an explicitly chosen task) */
992
+ implicit(task) {
993
+ return task !== "default" && telemetryImplicit(this.log);
994
+ }
995
+ /* adopt the sessions of the self-assigned or unassigned active tasks in the background
996
+ (coalescing the bursts of task store changes and task switches into at most one
997
+ pending adoption), logging any failure */
998
+ adoptSoon() {
999
+ if (this.adopting.size > 0)
1000
+ return;
1001
+ this.adopting.add(() => this.adopt()).catch((err) => {
1002
+ this.log.write("warning", "usage: failed to adopt the sessions of the active tasks: " +
1003
+ `${err instanceof Error ? err.message : String(err)}`);
1004
+ });
1005
+ }
1006
+ /* attach every interactive session of this project whose active task is either assigned
1007
+ to oneself elsewhere (e.g. through a task board, an editor, or Git, i.e., outside of any
1008
+ session) or unassigned (implicitly, see "implicit") to the usage window of that task,
1009
+ opening it if necessary: a session is adopted only once per self-assignment resp.
1010
+ activation of its active task (so it is not taken away again from a window it got
1011
+ explicitly assigned to afterwards, and a window closed for inactivity is not re-opened),
1012
+ a window opened this way starts paused, i.e., accounts its real time only from the
1013
+ first AI request of its sessions on (as a session directory might just be left behind
1014
+ by a crashed agent tool), and an implicit attachment ends as soon as its task is no
1015
+ longer the active task of its session or no longer accounted implicitly; the headless
1016
+ sessions of the agents are never adopted, as the agents book their usage themselves */
1017
+ async adopt() {
1018
+ if (!telemetryEnabled(this.log))
1019
+ return;
1020
+ /* determine the interactive sessions of this project (as recorded by the session start
1021
+ hook) and their active tasks, reading their state without locking (see "ase mcp") */
1022
+ let names = [];
1023
+ try {
1024
+ names = fs.readdirSync(path.join(userStateDir(), "session"));
1025
+ }
1026
+ catch {
1027
+ /* no session directory at all */
1028
+ }
1029
+ const active = new Map();
1030
+ for (const session of names) {
1031
+ try {
1032
+ Task.validateSession(session);
1033
+ const state = sessionState(this.log, session);
1034
+ state.read();
1035
+ const basedir = state.getExplicit("basedir");
1036
+ const task = state.getExplicit("task");
1037
+ const headless = state.getExplicit("headless");
1038
+ if (typeof basedir === "string" && typeof task === "string" && headless !== "true"
1039
+ && path.resolve(basedir) === path.resolve(this.root))
1040
+ active.set(session, task);
1041
+ }
1042
+ catch {
1043
+ /* best-effort: skip foreign or unreadable session directories */
1044
+ }
1045
+ }
1046
+ for (const session of Array.from(this.adopted.keys()))
1047
+ if (!active.has(session))
1048
+ this.adopted.delete(session);
1049
+ /* determine the assignees of their active tasks (cached until the next task store
1050
+ change), where a not (yet) existing task is taken as unassigned, as a session
1051
+ usually switches to a new task before its first save */
1052
+ for (const task of new Set(active.values())) {
1053
+ if (this.owners.has(task))
1054
+ continue;
1055
+ const parts = await Task.parts(this.log, task);
1056
+ this.owners.set(task, (parts?.keys.get("Assignee") ?? "").trim());
1057
+ }
1058
+ /* determine how the active task of a session is accounted: explicitly as
1059
+ assigned to oneself, implicitly as unassigned, or not at all */
1060
+ const self = userId();
1061
+ const mode = (task) => {
1062
+ const owner = this.owners.get(task) ?? "";
1063
+ return owner === self ? "explicit" : owner === "" && this.implicit(task) ? "implicit" : "none";
1064
+ };
1065
+ /* end the implicit attachments whose task is no longer the active task of their
1066
+ session or no longer accounted implicitly, resp. turn them into explicit ones once
1067
+ their task got assigned to oneself elsewhere */
1068
+ const now = Date.now();
1069
+ let changed = false;
1070
+ for (const w of this.journal.windows) {
1071
+ if (w.closed !== null)
1072
+ continue;
1073
+ for (const a of w.attach) {
1074
+ if (a.until !== null || a.implicit !== true || this.explicit.has(a.session))
1075
+ continue;
1076
+ const task = active.get(a.session);
1077
+ if (task === undefined)
1078
+ continue;
1079
+ if (task === w.task && mode(task) === "explicit")
1080
+ delete a.implicit;
1081
+ else if (task !== w.task || mode(task) !== "implicit") {
1082
+ this.log.write("info", `usage: task "${w.task}" no longer implicitly accounted for its session ` +
1083
+ `"${a.session}" -- detaching the session from its usage window`);
1084
+ this.detachAt(a.session, now);
1085
+ }
1086
+ else
1087
+ continue;
1088
+ changed = true;
1089
+ }
1090
+ }
1091
+ /* attach the sessions of the self-assigned or unassigned active tasks, where on the
1092
+ first adoption (after the start of the service) the sessions already attached to any
1093
+ window are taken as adopted, as their adoption state did not survive the restart */
1094
+ for (const [session, task] of active) {
1095
+ const how = mode(task);
1096
+ if (how === "none") {
1097
+ this.adopted.delete(session);
1098
+ continue;
1099
+ }
1100
+ if (this.explicit.has(session) || this.adopted.get(session) === task)
1101
+ continue;
1102
+ this.adopted.set(session, task);
1103
+ const attached = (w) => w.closed === null && w.attach.some((a) => a.session === session && a.until === null);
1104
+ let w = this.openWindow(task);
1105
+ if ((w !== undefined && attached(w)) || (!this.primed && this.journal.windows.some(attached)))
1106
+ continue;
1107
+ this.detachAt(session, now);
1108
+ if (w === undefined) {
1109
+ const idle = this.idleMs();
1110
+ w = {
1111
+ task, self, opened: now, closed: null, realFrom: now, active: idle > 0 ? now - idle : now, attach: [],
1112
+ unbooked: usageZero(), booked: now, records: 0
1113
+ };
1114
+ this.journal.windows.push(w);
1115
+ }
1116
+ w.attach.push({ session, from: now, until: null, ...(how === "implicit" ? { implicit: true } : {}) });
1117
+ this.register(session);
1118
+ if (how === "implicit")
1119
+ this.log.write("info", `usage: task "${task}" unassigned -- implicitly ` +
1120
+ `attaching its active session "${session}" to its usage window`);
1121
+ else
1122
+ this.log.write("info", `usage: task "${task}" assigned to "${self}" elsewhere -- ` +
1123
+ `attaching its active session "${session}" to its usage window`);
1124
+ changed = true;
1125
+ }
1126
+ this.primed = true;
1127
+ if (changed) {
1128
+ await this.persist();
1129
+ this.watch();
1130
+ }
1131
+ }
1132
+ /* ---- OTLP ingress ---- */
1133
+ /* take part in the election of the OTLP ingress of the user: unless already holding it,
1134
+ try to bind its port (only if telemetry is activated, i.e., a token exists) */
1135
+ bind() {
1136
+ if (!telemetryEnabled(this.log)) {
1137
+ if (this.ingress !== null) {
1138
+ this.log.write("info", "usage: telemetry disabled -- releasing the OTLP ingress");
1139
+ this.ingress.close();
1140
+ this.ingress = null;
1141
+ }
1142
+ return;
1143
+ }
1144
+ if (this.ingress !== null)
1145
+ return;
1146
+ const { url, token } = telemetryEndpoint(this.log, false);
1147
+ if (token === "")
1148
+ return;
1149
+ const port = Number(new URL(url).port);
1150
+ const server = http.createServer((req, res) => { this.receive(req, res, port, token); });
1151
+ this.ingress = server;
1152
+ server.once("error", () => {
1153
+ /* port held by another project service (or foreign process): retry later */
1154
+ this.ingress = null;
1155
+ server.close();
1156
+ });
1157
+ server.listen(port, HOST, () => {
1158
+ this.log.write("info", `usage: acting as OTLP ingress on ${url}`);
1159
+ });
1160
+ }
1161
+ /* receive an OTLP/HTTP export request on the ingress */
1162
+ receive(req, res, port, token) {
1163
+ const reply = (code, json = true) => {
1164
+ res.writeHead(code, { "Content-Type": json ? "application/json" : "application/x-protobuf" });
1165
+ res.end(json ? "{}" : undefined);
1166
+ };
1167
+ const signal = req.url === "/v1/logs" ? "logs" : req.url === "/v1/traces" ? "traces" : req.url === "/v1/metrics" ? "metrics" : "";
1168
+ const host = (req.headers.host ?? "").toLowerCase();
1169
+ const auth = Buffer.from(req.headers.authorization ?? "");
1170
+ const wanted = Buffer.from(`Bearer ${token}`);
1171
+ if (req.method !== "POST" || signal === "")
1172
+ return reply(404);
1173
+ if (host !== `${HOST}:${port}` && host !== `localhost:${port}`)
1174
+ return reply(421);
1175
+ if (auth.length !== wanted.length || !crypto.timingSafeEqual(auth, wanted))
1176
+ return reply(401);
1177
+ this.activity();
1178
+ const chunks = [];
1179
+ let size = 0;
1180
+ req.on("data", (chunk) => {
1181
+ size += chunk.length;
1182
+ if (size > BODY_MAX)
1183
+ req.destroy();
1184
+ else
1185
+ chunks.push(chunk);
1186
+ });
1187
+ req.on("end", () => {
1188
+ const json = (req.headers["content-type"] ?? "").includes("json");
1189
+ try {
1190
+ let body = Buffer.concat(chunks);
1191
+ if (req.headers["content-encoding"] === "gzip")
1192
+ body = zlib.gunzipSync(body, { maxOutputLength: BODY_MAX });
1193
+ if (process.env.ASE_OTEL_CAPTURE === "1") {
1194
+ const dir = path.join(this.aseDir, "otel-capture");
1195
+ fs.mkdirSync(dir, { recursive: true });
1196
+ fs.writeFileSync(path.join(dir, `${Date.now()}-${signal}.${json ? "json" : "pb"}`), body);
1197
+ }
1198
+ if (signal !== "metrics") {
1199
+ const request = json ? JSON.parse(body.toString("utf8")) :
1200
+ decodeProto(body, signal === "logs" ? "LogsRequest" : "TracesRequest");
1201
+ this.route(usageRecords(signal, request));
1202
+ }
1203
+ reply(200, json);
1204
+ }
1205
+ catch (err) {
1206
+ this.log.write("warning", `usage: invalid OTLP ${signal} request: ${err instanceof Error ? err.message : String(err)}`);
1207
+ reply(400, json);
1208
+ }
1209
+ });
1210
+ }
1211
+ /* route usage records by their session to the owning project service
1212
+ (dropping the records of unregistered sessions), with the session registry
1213
+ re-read from the telemetry state file only once the file changed */
1214
+ route(records) {
1215
+ const bySession = new Map();
1216
+ for (const r of records)
1217
+ bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
1218
+ let registry;
1219
+ try {
1220
+ const file = UsageTracker.stateFile();
1221
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
1222
+ const mark = stat !== undefined ? `${stat.ino}:${stat.mtimeMs}:${stat.size}` : "";
1223
+ if (this.registry?.mark !== mark)
1224
+ this.registry = { mark, session: stat !== undefined ? (UsageTracker.readState(file).session ?? {}) : {} };
1225
+ registry = this.registry.session;
1226
+ }
1227
+ catch (err) {
1228
+ this.log.write("warning", `usage: failed to read session registry: ${err instanceof Error ? err.message : String(err)}`);
1229
+ return;
1230
+ }
1231
+ for (const [session, list] of bySession) {
1232
+ /* (the not yet persisted (un)registrations of this project take precedence) */
1233
+ const entry = this.sessions.has(session) ? this.sessions.get(session) : registry[session];
1234
+ if (entry === undefined || entry === null)
1235
+ continue;
1236
+ if (entry.root === this.root)
1237
+ this.ingest(list);
1238
+ else {
1239
+ UsageTracker.request(entry.root, "/usage/records", { records: list }).catch((err) => {
1240
+ this.log.write("warning", `usage: forwarding records to project "${entry.project}" failed: ` +
1241
+ `${err instanceof Error ? err.message : String(err)}`);
1242
+ });
1243
+ }
1244
+ }
1245
+ }
1246
+ }