@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.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +351 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +230 -116
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +3 -0
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +251 -76
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +225 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +79 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /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
|
+
}
|