@newrelic/preflight 1.68.0 → 1.70.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 (118) hide show
  1. package/README.md +1 -1
  2. package/dist/config.d.ts +18 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +76 -6
  5. package/dist/config.js.map +1 -1
  6. package/dist/dashboard/routes/api-handler.d.ts +1 -0
  7. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  8. package/dist/dashboard/routes/api-handler.js +109 -91
  9. package/dist/dashboard/routes/api-handler.js.map +1 -1
  10. package/dist/data/dashboards/ai-coding-assistant-adoption-cost.json +291 -1
  11. package/dist/data/dashboards/ai-coding-assistant-team-view.json +6 -6
  12. package/dist/hooks/billed-turn-ledger.d.ts +46 -0
  13. package/dist/hooks/billed-turn-ledger.d.ts.map +1 -0
  14. package/dist/hooks/billed-turn-ledger.js +274 -0
  15. package/dist/hooks/billed-turn-ledger.js.map +1 -0
  16. package/dist/hooks/collector-script.d.ts.map +1 -1
  17. package/dist/hooks/collector-script.js +5 -1
  18. package/dist/hooks/collector-script.js.map +1 -1
  19. package/dist/hooks/event-processor.d.ts +3 -7
  20. package/dist/hooks/event-processor.d.ts.map +1 -1
  21. package/dist/hooks/event-processor.js +21 -7
  22. package/dist/hooks/event-processor.js.map +1 -1
  23. package/dist/hooks/parent-transcript-watcher.d.ts +6 -5
  24. package/dist/hooks/parent-transcript-watcher.d.ts.map +1 -1
  25. package/dist/hooks/parent-transcript-watcher.js +64 -62
  26. package/dist/hooks/parent-transcript-watcher.js.map +1 -1
  27. package/dist/hooks/throttled-warning.d.ts +16 -0
  28. package/dist/hooks/throttled-warning.d.ts.map +1 -0
  29. package/dist/hooks/throttled-warning.js +32 -0
  30. package/dist/hooks/throttled-warning.js.map +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +26 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/install/cli.d.ts.map +1 -1
  35. package/dist/install/cli.js +53 -0
  36. package/dist/install/cli.js.map +1 -1
  37. package/dist/install/diagnostics.d.ts.map +1 -1
  38. package/dist/install/diagnostics.js +17 -3
  39. package/dist/install/diagnostics.js.map +1 -1
  40. package/dist/install/keychain.d.ts +18 -0
  41. package/dist/install/keychain.d.ts.map +1 -0
  42. package/dist/install/keychain.js +60 -0
  43. package/dist/install/keychain.js.map +1 -0
  44. package/dist/install/kiro-install-helper.d.ts +27 -0
  45. package/dist/install/kiro-install-helper.d.ts.map +1 -0
  46. package/dist/install/kiro-install-helper.js +53 -0
  47. package/dist/install/kiro-install-helper.js.map +1 -0
  48. package/dist/install/setup-wizard.d.ts +4 -0
  49. package/dist/install/setup-wizard.d.ts.map +1 -1
  50. package/dist/install/setup-wizard.js +94 -9
  51. package/dist/install/setup-wizard.js.map +1 -1
  52. package/dist/metrics/cost-tracker-seed.d.ts +16 -0
  53. package/dist/metrics/cost-tracker-seed.d.ts.map +1 -1
  54. package/dist/metrics/cost-tracker-seed.js +42 -7
  55. package/dist/metrics/cost-tracker-seed.js.map +1 -1
  56. package/dist/metrics/cost-tracker.d.ts +11 -3
  57. package/dist/metrics/cost-tracker.d.ts.map +1 -1
  58. package/dist/metrics/cost-tracker.js +4 -1
  59. package/dist/metrics/cost-tracker.js.map +1 -1
  60. package/dist/metrics/git-activity-recorder.d.ts +3 -3
  61. package/dist/metrics/git-activity-recorder.d.ts.map +1 -1
  62. package/dist/metrics/git-activity-recorder.js +3 -3
  63. package/dist/metrics/git-activity-recorder.js.map +1 -1
  64. package/dist/metrics/git-workspace-identity.d.ts +6 -1
  65. package/dist/metrics/git-workspace-identity.d.ts.map +1 -1
  66. package/dist/metrics/git-workspace-identity.js +56 -32
  67. package/dist/metrics/git-workspace-identity.js.map +1 -1
  68. package/dist/metrics/git-workspace-reporter.d.ts +4 -3
  69. package/dist/metrics/git-workspace-reporter.d.ts.map +1 -1
  70. package/dist/metrics/git-workspace-reporter.js +4 -3
  71. package/dist/metrics/git-workspace-reporter.js.map +1 -1
  72. package/dist/metrics/skill-activation-tracker.d.ts +27 -0
  73. package/dist/metrics/skill-activation-tracker.d.ts.map +1 -0
  74. package/dist/metrics/skill-activation-tracker.js +92 -0
  75. package/dist/metrics/skill-activation-tracker.js.map +1 -0
  76. package/dist/metrics/turn-cost-attributor.d.ts +34 -16
  77. package/dist/metrics/turn-cost-attributor.d.ts.map +1 -1
  78. package/dist/metrics/turn-cost-attributor.js +95 -60
  79. package/dist/metrics/turn-cost-attributor.js.map +1 -1
  80. package/dist/secret-command.d.ts +5 -3
  81. package/dist/secret-command.d.ts.map +1 -1
  82. package/dist/secret-command.js +17 -11
  83. package/dist/secret-command.js.map +1 -1
  84. package/dist/shared/pricing-data.d.ts.map +1 -1
  85. package/dist/shared/pricing-data.js +223 -32
  86. package/dist/shared/pricing-data.js.map +1 -1
  87. package/dist/shared/pricing.d.ts +25 -3
  88. package/dist/shared/pricing.d.ts.map +1 -1
  89. package/dist/shared/pricing.js +46 -9
  90. package/dist/shared/pricing.js.map +1 -1
  91. package/dist/shared/tokens.d.ts +16 -0
  92. package/dist/shared/tokens.d.ts.map +1 -1
  93. package/dist/shared/tokens.js +18 -0
  94. package/dist/shared/tokens.js.map +1 -1
  95. package/dist/shared/transport/otlp-shared.d.ts.map +1 -1
  96. package/dist/shared/transport/otlp-shared.js +7 -14
  97. package/dist/shared/transport/otlp-shared.js.map +1 -1
  98. package/dist/storage/session-store.d.ts +24 -1
  99. package/dist/storage/session-store.d.ts.map +1 -1
  100. package/dist/storage/session-store.js +34 -4
  101. package/dist/storage/session-store.js.map +1 -1
  102. package/dist/storage/types.d.ts +13 -0
  103. package/dist/storage/types.d.ts.map +1 -1
  104. package/dist/storage/types.js.map +1 -1
  105. package/dist/tools/session-stats.js +1 -1
  106. package/dist/tools/session-stats.js.map +1 -1
  107. package/dist/transport/nr-ingest.d.ts +4 -0
  108. package/dist/transport/nr-ingest.d.ts.map +1 -1
  109. package/dist/transport/nr-ingest.js +43 -19
  110. package/dist/transport/nr-ingest.js.map +1 -1
  111. package/dist/transport/tier-types.d.ts +11 -1
  112. package/dist/transport/tier-types.d.ts.map +1 -1
  113. package/dist/transport/tier-types.js +28 -6
  114. package/dist/transport/tier-types.js.map +1 -1
  115. package/dist/web/assets/{index-lvdXUntr.js → index-U9DPZ-Tb.js} +3 -3
  116. package/dist/web/index.html +1 -1
  117. package/examples/local-alert-rules.json +28 -0
  118. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "AI Coding Assistant — Adoption & Cost",
3
- "description": "Org-wide adoption, spend, tool/MCP usage, and team leaderboard built entirely from Preflight events. Works on every supported platform with no additional telemetry.",
3
+ "description": "Org-wide adoption, spend, tool, skill, and MCP usage, and team leaderboard built entirely from Preflight events. Works on every supported platform with no additional telemetry.",
4
4
  "permissions": "PUBLIC_READ_WRITE",
5
5
  "pages": [
6
6
  {
@@ -930,6 +930,296 @@
930
930
  }
931
931
  ]
932
932
  },
933
+ {
934
+ "name": "Skills",
935
+ "description": null,
936
+ "widgets": [
937
+ {
938
+ "title": "Reading Skill Data",
939
+ "layout": {
940
+ "column": 1,
941
+ "row": 1,
942
+ "width": 12,
943
+ "height": 1
944
+ },
945
+ "visualization": {
946
+ "id": "viz.markdown"
947
+ },
948
+ "rawConfiguration": {
949
+ "text": "A skill run opens on a named `Skill` call or a `/<skill>` prompt and lasts until the next prompt or the next skill. Every tool call inside the run carries `skill_name`, `skill_source` (`tool` or `slash`), and `skill_run_id`, so runs, time, and cost here cover all the work a skill drove, not only the call that loaded it. Cost comes from `AiTurnCost`, which splits each turn evenly across its tool calls; a run whose replies made no tool calls has no rows. These widgets need a Preflight release with #895. Earlier events carry only `skillName` on the Skill call itself."
950
+ }
951
+ },
952
+ {
953
+ "title": "Spend in Skills (7d)",
954
+ "layout": {
955
+ "column": 1,
956
+ "row": 2,
957
+ "width": 3,
958
+ "height": 3
959
+ },
960
+ "visualization": {
961
+ "id": "viz.billboard"
962
+ },
963
+ "rawConfiguration": {
964
+ "facet": {
965
+ "showOtherSeries": false
966
+ },
967
+ "nrqlQueries": [
968
+ {
969
+ "accountIds": [],
970
+ "query": "FROM AiTurnCost SELECT filter(sum(cost_usd), WHERE skill_name IS NOT NULL) / sum(cost_usd) * 100 AS 'Spend in Skills %' SINCE 7 days ago"
971
+ }
972
+ ],
973
+ "platformOptions": {
974
+ "ignoreTimeRange": false
975
+ }
976
+ }
977
+ },
978
+ {
979
+ "title": "Skill Runs (7d)",
980
+ "layout": {
981
+ "column": 4,
982
+ "row": 2,
983
+ "width": 3,
984
+ "height": 3
985
+ },
986
+ "visualization": {
987
+ "id": "viz.billboard"
988
+ },
989
+ "rawConfiguration": {
990
+ "facet": {
991
+ "showOtherSeries": false
992
+ },
993
+ "nrqlQueries": [
994
+ {
995
+ "accountIds": [],
996
+ "query": "FROM AiToolCall SELECT uniqueCount(skill_run_id) AS 'Runs' WHERE skill_name IS NOT NULL SINCE 7 days ago"
997
+ }
998
+ ],
999
+ "platformOptions": {
1000
+ "ignoreTimeRange": false
1001
+ }
1002
+ }
1003
+ },
1004
+ {
1005
+ "title": "Skill Users (7d)",
1006
+ "layout": {
1007
+ "column": 7,
1008
+ "row": 2,
1009
+ "width": 3,
1010
+ "height": 3
1011
+ },
1012
+ "visualization": {
1013
+ "id": "viz.billboard"
1014
+ },
1015
+ "rawConfiguration": {
1016
+ "facet": {
1017
+ "showOtherSeries": false
1018
+ },
1019
+ "nrqlQueries": [
1020
+ {
1021
+ "accountIds": [],
1022
+ "query": "FROM AiToolCall SELECT uniqueCount(developer) AS 'Users' WHERE skill_name IS NOT NULL SINCE 7 days ago"
1023
+ }
1024
+ ],
1025
+ "platformOptions": {
1026
+ "ignoreTimeRange": false
1027
+ }
1028
+ }
1029
+ },
1030
+ {
1031
+ "title": "How Skills Are Invoked (7d)",
1032
+ "layout": {
1033
+ "column": 10,
1034
+ "row": 2,
1035
+ "width": 3,
1036
+ "height": 3
1037
+ },
1038
+ "visualization": {
1039
+ "id": "viz.pie"
1040
+ },
1041
+ "rawConfiguration": {
1042
+ "facet": {
1043
+ "showOtherSeries": false
1044
+ },
1045
+ "legend": {
1046
+ "enabled": true
1047
+ },
1048
+ "nrqlQueries": [
1049
+ {
1050
+ "accountIds": [],
1051
+ "query": "FROM AiToolCall SELECT uniqueCount(skill_run_id) AS 'Runs' WHERE skill_name IS NOT NULL FACET skill_source SINCE 7 days ago"
1052
+ }
1053
+ ],
1054
+ "platformOptions": {
1055
+ "ignoreTimeRange": false
1056
+ }
1057
+ }
1058
+ },
1059
+ {
1060
+ "title": "Top Skills by Runs (7d)",
1061
+ "layout": {
1062
+ "column": 1,
1063
+ "row": 5,
1064
+ "width": 6,
1065
+ "height": 3
1066
+ },
1067
+ "visualization": {
1068
+ "id": "viz.bar"
1069
+ },
1070
+ "rawConfiguration": {
1071
+ "facet": {
1072
+ "showOtherSeries": false
1073
+ },
1074
+ "nrqlQueries": [
1075
+ {
1076
+ "accountIds": [],
1077
+ "query": "FROM AiToolCall SELECT uniqueCount(skill_run_id) AS 'Runs' WHERE skill_name IS NOT NULL FACET skill_name SINCE 7 days ago LIMIT 25"
1078
+ }
1079
+ ],
1080
+ "platformOptions": {
1081
+ "ignoreTimeRange": false
1082
+ }
1083
+ }
1084
+ },
1085
+ {
1086
+ "title": "Daily Skill Runs (7d)",
1087
+ "layout": {
1088
+ "column": 7,
1089
+ "row": 5,
1090
+ "width": 6,
1091
+ "height": 3
1092
+ },
1093
+ "visualization": {
1094
+ "id": "viz.line"
1095
+ },
1096
+ "rawConfiguration": {
1097
+ "facet": {
1098
+ "showOtherSeries": false
1099
+ },
1100
+ "legend": {
1101
+ "enabled": true
1102
+ },
1103
+ "yAxisLeft": {
1104
+ "zero": true
1105
+ },
1106
+ "nrqlQueries": [
1107
+ {
1108
+ "accountIds": [],
1109
+ "query": "FROM AiToolCall SELECT uniqueCount(skill_run_id) AS 'Runs' WHERE skill_name IS NOT NULL FACET skill_name SINCE 7 days ago TIMESERIES 1 day LIMIT 10"
1110
+ }
1111
+ ],
1112
+ "platformOptions": {
1113
+ "ignoreTimeRange": false
1114
+ }
1115
+ }
1116
+ },
1117
+ {
1118
+ "title": "Skill Cost (7d)",
1119
+ "layout": {
1120
+ "column": 1,
1121
+ "row": 8,
1122
+ "width": 6,
1123
+ "height": 4
1124
+ },
1125
+ "visualization": {
1126
+ "id": "viz.table"
1127
+ },
1128
+ "rawConfiguration": {
1129
+ "facet": {
1130
+ "showOtherSeries": false
1131
+ },
1132
+ "nrqlQueries": [
1133
+ {
1134
+ "accountIds": [],
1135
+ "query": "FROM AiToolCall, AiTurnCost SELECT uniqueCount(skill_run_id) AS 'Runs', sum(cost_usd) AS 'Cost (USD)', sum(cost_usd) / uniqueCount(skill_run_id) AS 'USD / Run', sum(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) AS 'Tokens' WHERE skill_name IS NOT NULL FACET skill_name SINCE 7 days ago LIMIT 25"
1136
+ }
1137
+ ],
1138
+ "platformOptions": {
1139
+ "ignoreTimeRange": false
1140
+ }
1141
+ }
1142
+ },
1143
+ {
1144
+ "title": "Skill Adoption (7d)",
1145
+ "layout": {
1146
+ "column": 7,
1147
+ "row": 8,
1148
+ "width": 6,
1149
+ "height": 4
1150
+ },
1151
+ "visualization": {
1152
+ "id": "viz.table"
1153
+ },
1154
+ "rawConfiguration": {
1155
+ "facet": {
1156
+ "showOtherSeries": false
1157
+ },
1158
+ "nrqlQueries": [
1159
+ {
1160
+ "accountIds": [],
1161
+ "query": "FROM AiToolCall SELECT uniqueCount(developer) AS 'Users', uniqueCount(skill_run_id) AS 'Runs', count(*) AS 'Tool Calls', filter(sum(duration_ms), WHERE tool != 'Agent') / 3600000 AS 'Hours', percentage(count(*), WHERE success = false) AS 'Error %' WHERE skill_name IS NOT NULL FACET skill_name SINCE 7 days ago LIMIT 25"
1162
+ }
1163
+ ],
1164
+ "platformOptions": {
1165
+ "ignoreTimeRange": false
1166
+ }
1167
+ }
1168
+ },
1169
+ {
1170
+ "title": "Skills by Developer (7d)",
1171
+ "layout": {
1172
+ "column": 1,
1173
+ "row": 12,
1174
+ "width": 6,
1175
+ "height": 4
1176
+ },
1177
+ "visualization": {
1178
+ "id": "viz.table"
1179
+ },
1180
+ "rawConfiguration": {
1181
+ "facet": {
1182
+ "showOtherSeries": false
1183
+ },
1184
+ "nrqlQueries": [
1185
+ {
1186
+ "accountIds": [],
1187
+ "query": "FROM AiToolCall SELECT uniqueCount(skill_run_id) AS 'Runs', filter(sum(duration_ms), WHERE tool != 'Agent') / 3600000 AS 'Hours' WHERE skill_name IS NOT NULL FACET developer, skill_name SINCE 7 days ago LIMIT 100"
1188
+ }
1189
+ ],
1190
+ "platformOptions": {
1191
+ "ignoreTimeRange": false
1192
+ }
1193
+ }
1194
+ },
1195
+ {
1196
+ "title": "Tools Driven by Skills (7d)",
1197
+ "layout": {
1198
+ "column": 7,
1199
+ "row": 12,
1200
+ "width": 6,
1201
+ "height": 4
1202
+ },
1203
+ "visualization": {
1204
+ "id": "viz.table"
1205
+ },
1206
+ "rawConfiguration": {
1207
+ "facet": {
1208
+ "showOtherSeries": false
1209
+ },
1210
+ "nrqlQueries": [
1211
+ {
1212
+ "accountIds": [],
1213
+ "query": "FROM AiToolCall SELECT count(*) AS 'Calls', percentage(count(*), WHERE success = false) AS 'Error %' WHERE skill_name IS NOT NULL AND tool != 'Skill' FACET skill_name, tool SINCE 7 days ago LIMIT 50"
1214
+ }
1215
+ ],
1216
+ "platformOptions": {
1217
+ "ignoreTimeRange": false
1218
+ }
1219
+ }
1220
+ }
1221
+ ]
1222
+ },
933
1223
  {
934
1224
  "name": "Team Leaderboard",
935
1225
  "description": null,
@@ -1,10 +1,10 @@
1
1
  {
2
- "name": "AI Coding Assistant \u2014 Team View",
2
+ "name": "AI Coding Assistant — Team View",
3
3
  "description": "Team-level dashboard for AI coding assistant observability. Shows cost rollups, efficiency scores by developer, trend lines, and optimization insights across sessions. Requires AiToolCall, AiCodingTask, AiAntiPattern custom events and ai.* metrics.",
4
4
  "permissions": "PUBLIC_READ_WRITE",
5
5
  "pages": [
6
6
  {
7
- "name": "AI Coding Assistant \u2014 Team View",
7
+ "name": "AI Coding Assistant — Team View",
8
8
  "description": "Team summary, developer comparison, trends, and optimization insights",
9
9
  "widgets": [
10
10
  {
@@ -308,7 +308,7 @@
308
308
  }
309
309
  },
310
310
  {
311
- "title": "Skill Calls (7d)",
311
+ "title": "Skill Runs (7d)",
312
312
  "layout": {
313
313
  "column": 1,
314
314
  "row": 13,
@@ -322,7 +322,7 @@
322
322
  "nrqlQueries": [
323
323
  {
324
324
  "accountIds": [],
325
- "query": "SELECT count(*) FROM AiToolCall WHERE tool = 'Skill' AND developer = {{developer}} AND session_id = {{session_id}} FACET skillName SINCE 7 DAYS AGO LIMIT 20"
325
+ "query": "SELECT uniqueCount(skill_run_id) AS 'Runs' FROM AiToolCall WHERE skill_name IS NOT NULL AND developer = {{developer}} AND session_id = {{session_id}} FACET skill_name SINCE 7 DAYS AGO LIMIT 20"
326
326
  }
327
327
  ]
328
328
  }
@@ -342,7 +342,7 @@
342
342
  "nrqlQueries": [
343
343
  {
344
344
  "accountIds": [],
345
- "query": "SELECT sum(duration_ms) / 3600000 AS 'Hours' FROM AiToolCall WHERE tool = 'Skill' AND developer = {{developer}} AND session_id = {{session_id}} FACET skillName SINCE 7 DAYS AGO LIMIT 20"
345
+ "query": "SELECT filter(sum(duration_ms), WHERE tool != 'Agent') / 3600000 AS 'Hours' FROM AiToolCall WHERE skill_name IS NOT NULL AND developer = {{developer}} AND session_id = {{session_id}} FACET skill_name SINCE 7 DAYS AGO LIMIT 20"
346
346
  }
347
347
  ]
348
348
  }
@@ -362,7 +362,7 @@
362
362
  "nrqlQueries": [
363
363
  {
364
364
  "accountIds": [],
365
- "query": "SELECT sum(cost_usd) AS 'USD' FROM AiTurnCost WHERE tool = 'Skill' AND developer = {{developer}} AND session_id = {{session_id}} FACET skillName SINCE 7 DAYS AGO LIMIT 20"
365
+ "query": "SELECT sum(cost_usd) AS 'USD' FROM AiTurnCost WHERE skill_name IS NOT NULL AND developer = {{developer}} AND session_id = {{session_id}} FACET skill_name SINCE 7 DAYS AGO LIMIT 20"
366
366
  }
367
367
  ]
368
368
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Append order in an O_APPEND file is the cross-process arbiter, so two
3
+ * racing claimants both read back the same winner without a lock. A message
4
+ * id maps to one file per generation, chosen by a hash of the id and the
5
+ * claim's wall clock rather than the turn's timestamp, so every line of a
6
+ * message is arbitrated in the same file. Each ledger instance claims under
7
+ * its own random claimant id, so two processes tailing the same session
8
+ * still resolve to one winner.
9
+ */
10
+ export interface BillableTurn {
11
+ readonly messageId: string;
12
+ readonly timestampMs: number;
13
+ /** The source line had no timestamp, so the retention horizon cannot judge the turn. */
14
+ readonly undated?: boolean;
15
+ }
16
+ export interface BilledTurnLedgerOptions {
17
+ readonly storagePath: string;
18
+ readonly retainDays?: number | null;
19
+ readonly now?: () => number;
20
+ }
21
+ export declare class BilledTurnLedger {
22
+ private readonly dir;
23
+ private readonly retainDays;
24
+ private readonly now;
25
+ private readonly claimant;
26
+ private readonly files;
27
+ private readonly undelivered;
28
+ constructor(options: BilledTurnLedgerOptions);
29
+ /**
30
+ * Appends, in order, the turns this session owes: the first line of each
31
+ * message this instance claimed first and has not delivered yet. A turn
32
+ * claimed first by any other instance, of any session, is already billed.
33
+ * When `append` throws, that turn and the rest stay owed to this session,
34
+ * so the next deliver() redoing the read appends them, and the error is
35
+ * rethrown.
36
+ */
37
+ deliver<T extends BillableTurn>(sessionId: string, turns: readonly T[], append: (turn: T) => void): void;
38
+ private claim;
39
+ private claimantOf;
40
+ /** Generations on disk per shard. Drops the index of any file no longer listed. */
41
+ private listGenerations;
42
+ private syncFiles;
43
+ private syncFile;
44
+ }
45
+ export declare function pruneBilledTurns(storagePath: string, retainDays: number, nowMs: number): number;
46
+ //# sourceMappingURL=billed-turn-ledger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billed-turn-ledger.d.ts","sourceRoot":"","sources":["../../src/hooks/billed-turn-ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAmBH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wFAAwF;IACxF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAoBD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAgB;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IAKtD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;gBAE7C,OAAO,EAAE,uBAAuB;IAM5C;;;;;;;OAOG;IACH,OAAO,CAAC,CAAC,SAAS,YAAY,EAC5B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,GACxB,IAAI;IAOP,OAAO,CAAC,KAAK;IAqEb,OAAO,CAAC,UAAU;IAQlB,mFAAmF;IACnF,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,SAAS;IAkBjB,OAAO,CAAC,QAAQ;CAmBjB;AAED,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAiB/F"}
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Append order in an O_APPEND file is the cross-process arbiter, so two
3
+ * racing claimants both read back the same winner without a lock. A message
4
+ * id maps to one file per generation, chosen by a hash of the id and the
5
+ * claim's wall clock rather than the turn's timestamp, so every line of a
6
+ * message is arbitrated in the same file. Each ledger instance claims under
7
+ * its own random claimant id, so two processes tailing the same session
8
+ * still resolve to one winner.
9
+ */
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readdirSync, readSync, unlinkSync, } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { createLogger } from '../shared/index.js';
14
+ const logger = createLogger('billed-turn-ledger');
15
+ const LEDGER_DIR = 'billed-turns';
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const GENERATION_MS = 7 * DAY_MS;
18
+ const LEDGER_FILE = /^g(\d+)-([0-9a-f])\.jsonl$/;
19
+ const TORN_LINE_TERMINATOR = '\n';
20
+ const HEAD_BYTES = 256;
21
+ const NEWLINE = 0x0a;
22
+ export class BilledTurnLedger {
23
+ dir;
24
+ retainDays;
25
+ now;
26
+ claimant = randomUUID();
27
+ files = new Map();
28
+ // The session each message this instance won is still owed to: a claim
29
+ // that threw after some of its lines landed, a turn whose append threw, or
30
+ // one whose claim line vanished before it was read back. Only this map lets
31
+ // the ledger's own claims out, and only to the session they were won for.
32
+ undelivered = new Map();
33
+ constructor(options) {
34
+ this.dir = join(options.storagePath, LEDGER_DIR);
35
+ this.retainDays = options.retainDays ?? null;
36
+ this.now = options.now ?? Date.now;
37
+ }
38
+ /**
39
+ * Appends, in order, the turns this session owes: the first line of each
40
+ * message this instance claimed first and has not delivered yet. A turn
41
+ * claimed first by any other instance, of any session, is already billed.
42
+ * When `append` throws, that turn and the rest stay owed to this session,
43
+ * so the next deliver() redoing the read appends them, and the error is
44
+ * rethrown.
45
+ */
46
+ deliver(sessionId, turns, append) {
47
+ for (const turn of this.claim(sessionId, turns)) {
48
+ append(turn);
49
+ this.undelivered.delete(turn.messageId);
50
+ }
51
+ }
52
+ claim(sessionId, turns) {
53
+ const nowMs = this.now();
54
+ const horizon = horizonOf(nowMs, this.retainDays);
55
+ const firstLines = new Map();
56
+ let refused = 0;
57
+ for (const turn of turns) {
58
+ if (firstLines.has(turn.messageId))
59
+ continue;
60
+ const beyond = !turn.undated && Number.isFinite(turn.timestampMs) && turn.timestampMs < horizon;
61
+ if (beyond)
62
+ refused++;
63
+ firstLines.set(turn.messageId, beyond ? null : turn);
64
+ }
65
+ if (refused > 0) {
66
+ logger.debug('Refused turns older than the retention horizon', {
67
+ refused,
68
+ retainDays: this.retainDays,
69
+ });
70
+ }
71
+ const live = [...firstLines.values()].filter((turn) => turn !== null);
72
+ if (live.length === 0)
73
+ return [];
74
+ // Two processes either side of a generation boundary can both win a
75
+ // brand-new message claimed within the same few milliseconds.
76
+ const home = Math.floor(nowMs / GENERATION_MS);
77
+ const generations = this.listGenerations();
78
+ const filesOf = (messageId) => {
79
+ const shard = shardOf(messageId);
80
+ const gens = new Set([...(generations.get(shard) ?? []), home]);
81
+ return [...gens].sort((a, b) => a - b).map((gen) => fileName(gen, shard));
82
+ };
83
+ this.syncFiles(live.flatMap((turn) => filesOf(turn.messageId)));
84
+ const owed = [];
85
+ const linesByFile = new Map();
86
+ for (const turn of live) {
87
+ const id = turn.messageId;
88
+ const claimant = this.claimantOf(id, filesOf(id));
89
+ if (claimant === undefined) {
90
+ const file = fileName(home, shardOf(id));
91
+ const line = TORN_LINE_TERMINATOR +
92
+ JSON.stringify({ m: id, s: sessionId, c: this.claimant, t: nowMs });
93
+ linesByFile.set(file, (linesByFile.get(file) ?? '') + line);
94
+ this.undelivered.set(id, sessionId);
95
+ }
96
+ else if (claimant !== this.claimant) {
97
+ this.undelivered.delete(id);
98
+ continue;
99
+ }
100
+ else if (this.undelivered.get(id) !== sessionId) {
101
+ continue;
102
+ }
103
+ owed.push(turn);
104
+ }
105
+ if (owed.length === 0)
106
+ return [];
107
+ if (linesByFile.size > 0)
108
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
109
+ for (const [file, lines] of linesByFile) {
110
+ appendFileSync(join(this.dir, file), lines + '\n', { mode: 0o600 });
111
+ }
112
+ this.syncFiles(owed.flatMap((turn) => filesOf(turn.messageId)));
113
+ return owed.filter((turn) => {
114
+ const claimant = this.claimantOf(turn.messageId, filesOf(turn.messageId));
115
+ if (claimant !== undefined && claimant !== this.claimant) {
116
+ this.undelivered.delete(turn.messageId);
117
+ }
118
+ return claimant === this.claimant;
119
+ });
120
+ }
121
+ claimantOf(messageId, files) {
122
+ for (const file of files) {
123
+ const claimant = this.files.get(file)?.claimants.get(messageId);
124
+ if (claimant !== undefined)
125
+ return claimant;
126
+ }
127
+ return undefined;
128
+ }
129
+ /** Generations on disk per shard. Drops the index of any file no longer listed. */
130
+ listGenerations() {
131
+ const listed = new Set();
132
+ const generations = new Map();
133
+ for (const name of readLedgerDir(this.dir)) {
134
+ const parsed = parseFileName(name);
135
+ if (parsed === null)
136
+ continue;
137
+ listed.add(name);
138
+ generations.set(parsed.shard, [...(generations.get(parsed.shard) ?? []), parsed.generation]);
139
+ }
140
+ for (const name of this.files.keys()) {
141
+ if (!listed.has(name))
142
+ this.files.delete(name);
143
+ }
144
+ return generations;
145
+ }
146
+ syncFiles(files) {
147
+ for (const file of new Set(files)) {
148
+ let fd;
149
+ try {
150
+ fd = openSync(join(this.dir, file), 'r');
151
+ }
152
+ catch (err) {
153
+ if (err.code !== 'ENOENT')
154
+ throw err;
155
+ this.files.delete(file);
156
+ continue;
157
+ }
158
+ try {
159
+ this.syncFile(file, fd);
160
+ }
161
+ finally {
162
+ closeSync(fd);
163
+ }
164
+ }
165
+ }
166
+ syncFile(file, fd) {
167
+ const { size } = fstatSync(fd);
168
+ const head = readAt(fd, 0, Math.min(size, HEAD_BYTES));
169
+ let index = this.files.get(file);
170
+ if (index === undefined || size < index.offset || !onePrefixesOther(head, index.head)) {
171
+ index = { head, offset: 0, partial: Buffer.alloc(0), claimants: new Map() };
172
+ this.files.set(file, index);
173
+ }
174
+ else if (head.length > index.head.length) {
175
+ index.head = head;
176
+ }
177
+ if (size <= index.offset)
178
+ return;
179
+ const tail = readAt(fd, index.offset, size);
180
+ const bytes = Buffer.concat([index.partial, tail]);
181
+ const end = bytes.lastIndexOf(NEWLINE) + 1;
182
+ foldLines(bytes.subarray(0, end), index.claimants, file);
183
+ index.partial = Buffer.from(bytes.subarray(end));
184
+ index.offset += tail.length;
185
+ }
186
+ }
187
+ export function pruneBilledTurns(storagePath, retainDays, nowMs) {
188
+ const dir = join(storagePath, LEDGER_DIR);
189
+ const horizon = horizonOf(nowMs, retainDays);
190
+ let deleted = 0;
191
+ for (const name of readLedgerDir(dir)) {
192
+ const parsed = parseFileName(name);
193
+ if (parsed === null || (parsed.generation + 1) * GENERATION_MS > horizon)
194
+ continue;
195
+ try {
196
+ unlinkSync(join(dir, name));
197
+ deleted++;
198
+ }
199
+ catch (err) {
200
+ if (err.code !== 'ENOENT') {
201
+ logger.warn('Failed to prune billed-turn ledger file', { file: name, error: String(err) });
202
+ }
203
+ }
204
+ }
205
+ return deleted;
206
+ }
207
+ /**
208
+ * Turns dated before the horizon are refused and generations ending by it
209
+ * are pruned. A turn is claimed no earlier than its own timestamp, so a
210
+ * pruned generation only ever held claims for turns the horizon refuses.
211
+ */
212
+ function horizonOf(nowMs, retainDays) {
213
+ if (retainDays === null || !(retainDays > 0))
214
+ return -Infinity;
215
+ return nowMs - retainDays * DAY_MS;
216
+ }
217
+ function shardOf(messageId) {
218
+ return createHash('sha256').update(messageId).digest('hex')[0];
219
+ }
220
+ function fileName(generation, shard) {
221
+ return `g${generation}-${shard}.jsonl`;
222
+ }
223
+ function parseFileName(name) {
224
+ const match = LEDGER_FILE.exec(name);
225
+ return match ? { generation: Number(match[1]), shard: match[2] } : null;
226
+ }
227
+ function readLedgerDir(dir) {
228
+ try {
229
+ return readdirSync(dir);
230
+ }
231
+ catch (err) {
232
+ if (err.code === 'ENOENT')
233
+ return [];
234
+ throw err;
235
+ }
236
+ }
237
+ function onePrefixesOther(a, b) {
238
+ const n = Math.min(a.length, b.length);
239
+ return a.subarray(0, n).equals(b.subarray(0, n));
240
+ }
241
+ function readAt(fd, start, end) {
242
+ const buf = Buffer.allocUnsafe(Math.max(0, end - start));
243
+ let read = 0;
244
+ while (read < buf.length) {
245
+ const n = readSync(fd, buf, read, buf.length - read, start + read);
246
+ if (n === 0)
247
+ break;
248
+ read += n;
249
+ }
250
+ return buf.subarray(0, read);
251
+ }
252
+ function foldLines(bytes, claimants, file) {
253
+ let start = 0;
254
+ while (start < bytes.length) {
255
+ const end = bytes.indexOf(NEWLINE, start);
256
+ const line = bytes.subarray(start, end);
257
+ start = end + 1;
258
+ if (line.length === 0)
259
+ continue;
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(line.toString('utf-8'));
263
+ }
264
+ catch {
265
+ logger.debug('Skipped a torn billed-turn ledger line', { file, bytes: line.length });
266
+ continue;
267
+ }
268
+ if (typeof parsed.m !== 'string')
269
+ continue;
270
+ if (!claimants.has(parsed.m))
271
+ claimants.set(parsed.m, typeof parsed.c === 'string' ? parsed.c : '');
272
+ }
273
+ }
274
+ //# sourceMappingURL=billed-turn-ledger.js.map