token-trace-manager 0.7.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/lib/config.mjs ADDED
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { dataDir } from './db.mjs'
4
+
5
+ // Porta padrão deliberadamente fora das "manjadas" (8080/3000/8000/9090).
6
+ // 25519 é memorável (aceno à curva ed25519) e não registrada na IANA.
7
+ export const DEFAULT_PORT = 25519
8
+
9
+ // Protocolos de wire reconhecidos pelos extratores em adapters.mjs.
10
+ // Qualquer outro valor no target.protocol é marcado como _invalid em readConfig
11
+ // e recusado em runtime (400 no proxyRequest) — sem cair silenciosamente no
12
+ // extrator OpenAI default (que produziria 0 tokens sem erro visível).
13
+ export const KNOWN_PROTOCOLS = ['anthropic', 'openai', 'openai-responses', 'gemini']
14
+
15
+ // Targets padrão: o primeiro segmento do path escolhe o upstream.
16
+ // targetUrl = URL base real do provedor que o TUI chamaria sem o proxy.
17
+ const DEFAULT_TARGETS = {
18
+ anthropic: { targetUrl: 'https://api.anthropic.com', protocol: 'anthropic' },
19
+ openai: { targetUrl: 'https://api.openai.com', protocol: 'openai' },
20
+ gemini: { targetUrl: 'https://generativelanguage.googleapis.com', protocol: 'gemini' },
21
+ openrouter: { targetUrl: 'https://openrouter.ai/api', protocol: 'openai' },
22
+ }
23
+
24
+ function configPath() {
25
+ return path.join(dataDir(), 'config.json')
26
+ }
27
+
28
+ function readUserRaw() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(configPath(), 'utf8'))
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ function writeUserRaw(user) {
37
+ // Escrita atômica (tmp+rename): evita config corrompida se o processo for
38
+ // morto no meio da escrita (ex: shutdown entre link/unlink). No Windows,
39
+ // rename sobre arquivo existente pode falhar (EPERM) — aí fallback.
40
+ const tmp = configPath() + '.tmp'
41
+ fs.writeFileSync(tmp, JSON.stringify(user, null, 2))
42
+ try {
43
+ fs.renameSync(tmp, configPath())
44
+ } catch {
45
+ try { fs.unlinkSync(configPath()) } catch {}
46
+ fs.renameSync(tmp, configPath())
47
+ }
48
+ }
49
+
50
+ export function readConfig() {
51
+ const user = readUserRaw()
52
+ const port = user.port ?? DEFAULT_PORT
53
+ const mergedTargets = { ...DEFAULT_TARGETS, ...(user.targets ?? {}) }
54
+ const targets = {}
55
+ for (const [name, t] of Object.entries(mergedTargets)) {
56
+ targets[name] = { ...t, _invalid: !KNOWN_PROTOCOLS.includes(t.protocol) }
57
+ }
58
+ return {
59
+ port,
60
+ proxyUrl: user.proxyUrl ?? `http://127.0.0.1:${port}`,
61
+ injectUsage: user.injectUsage ?? true,
62
+ retentionHours: user.retentionHours ?? 24,
63
+ storePayloads: user.storePayloads ?? true,
64
+ otel: user.otel ?? null,
65
+ targets,
66
+ links: user.links ?? {},
67
+ }
68
+ }
69
+
70
+ export function writeConfig(patch) {
71
+ const user = readUserRaw()
72
+ const next = { ...user, ...patch }
73
+ writeUserRaw(next)
74
+ return readConfig()
75
+ }
76
+
77
+ // Patch de UM único target sem clobberar os demais (writeConfig faz merge raso
78
+ // no nível top-level, então passar {targets: {...}} substituiria o mapa todo).
79
+ export function setTarget(name, spec) {
80
+ const user = readUserRaw()
81
+ user.targets = user.targets ?? {}
82
+ user.targets[name] = spec
83
+ writeUserRaw(user)
84
+ return readConfig()
85
+ }
86
+
87
+ export function removeTarget(name) {
88
+ const user = readUserRaw()
89
+ if (user.targets && user.targets[name]) {
90
+ delete user.targets[name]
91
+ writeUserRaw(user)
92
+ }
93
+ return readConfig()
94
+ }
95
+
96
+ export function setLink(tui, info) {
97
+ const user = readUserRaw()
98
+ user.links = user.links ?? {}
99
+ if (info === null) delete user.links[tui]
100
+ else user.links[tui] = info
101
+ writeUserRaw(user)
102
+ return readConfig()
103
+ }
104
+
105
+ // Validação de config para warnings em boot/CLI (NUNCA chamada por-request:
106
+ // readConfig já marca _invalid nos targets; isso só produz mensagens).
107
+ export function validateConfig(cfg) {
108
+ const warnings = []
109
+ const invalidTargets = []
110
+ for (const [name, t] of Object.entries(cfg.targets)) {
111
+ if (t._invalid) {
112
+ invalidTargets.push(name)
113
+ warnings.push(
114
+ `target "${name}" tem protocolo desconhecido "${t.protocol}" (válidos: ${KNOWN_PROTOCOLS.join(', ')}) — será recusado em runtime`,
115
+ )
116
+ }
117
+ if (t.targetUrl) {
118
+ try {
119
+ const u = new URL(t.targetUrl)
120
+ if (/\/v1$/.test(u.pathname) || /\/v1\//.test(u.pathname)) {
121
+ warnings.push(
122
+ `target "${name}" targetUrl "${t.targetUrl}" inclui sufixo de rota (/v1...) — pode dobrar o path no upstream (o ttm já encadeia o caminho do TUI)`,
123
+ )
124
+ }
125
+ } catch {
126
+ warnings.push(`target "${name}" targetUrl inválida: "${t.targetUrl}"`)
127
+ }
128
+ }
129
+ }
130
+ return { warnings, invalidTargets }
131
+ }
132
+
133
+ export function pidFilePath() {
134
+ return path.join(dataDir(), 'daemon.pid')
135
+ }
@@ -0,0 +1,139 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { readConfig, pidFilePath } from './config.mjs'
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
+ const LOCAL_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
9
+
10
+ export async function fetchDaemon(pathname, { method = 'GET', body = null, timeoutMs = 3000 } = {}) {
11
+ const cfg = readConfig()
12
+ const res = await fetch(`http://127.0.0.1:${cfg.port}${pathname}`, {
13
+ method,
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ headers: body ? { 'content-type': 'application/json' } : undefined,
16
+ signal: AbortSignal.timeout(timeoutMs),
17
+ })
18
+ const json = await res.json()
19
+ if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
20
+ return json
21
+ }
22
+
23
+ export async function daemonHealth() {
24
+ try {
25
+ return await fetchDaemon('/healthz', { timeoutMs: 1500 })
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ async function spawnDaemon() {
32
+ const child = spawn(process.execPath, [path.join(__dirname, 'daemon.mjs')], {
33
+ detached: true,
34
+ stdio: 'ignore',
35
+ })
36
+ child.unref()
37
+
38
+ for (let i = 0; i < 20; i++) {
39
+ await new Promise(r => setTimeout(r, 150))
40
+ const h = await daemonHealth()
41
+ if (h) return h
42
+ }
43
+ throw new Error('daemon não respondeu ao /healthz após o start — verifique a porta com `ttm status`')
44
+ }
45
+
46
+ export async function ensureDaemon() {
47
+ const health = await daemonHealth()
48
+ if (health && health.version === LOCAL_VERSION) return { started: false, health }
49
+
50
+ // Daemon de uma versão antiga ainda de pé após upgrade: template/dados
51
+ // ficariam inconsistentes — reinicia com o código novo automaticamente.
52
+ if (health) {
53
+ await stopDaemon()
54
+ const h = await spawnDaemon()
55
+ return { started: true, restartedFrom: health.version, health: h }
56
+ }
57
+
58
+ return { started: true, health: await spawnDaemon() }
59
+ }
60
+
61
+ // O pid do /healthz é a fonte de verdade (o próprio daemon responde). O pid
62
+ // file é só dica: pode ficar obsoleto após crash/SIGKILL e, com reuso de PID
63
+ // pelo SO, apontar para um processo alheio — por isso, antes de matar por pid
64
+ // file, confirmamos via /proc que o processo é mesmo o nosso daemon.
65
+ function pidLooksLikeDaemon(pid) {
66
+ try {
67
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
68
+ return cmdline.includes('daemon.mjs')
69
+ } catch {
70
+ return null // sem /proc (macOS etc.) — não dá pra confirmar
71
+ }
72
+ }
73
+
74
+ export async function stopDaemon() {
75
+ const pidFile = pidFilePath()
76
+ const clearPidFile = () => {
77
+ try {
78
+ fs.unlinkSync(pidFile)
79
+ } catch {
80
+ /* já removido */
81
+ }
82
+ }
83
+
84
+ // O daemon encerra graciosamente (espera streams em voo, até 15s) —
85
+ // aguardamos o processo sair de fato para liberar a porta antes de retornar.
86
+ const waitExit = async pid => {
87
+ for (let i = 0; i < 170; i++) {
88
+ try {
89
+ process.kill(pid, 0)
90
+ } catch {
91
+ return true // processo saiu
92
+ }
93
+ await new Promise(r => setTimeout(r, 100))
94
+ }
95
+ return false
96
+ }
97
+
98
+ const h = await daemonHealth()
99
+ if (h?.pid) {
100
+ try {
101
+ process.kill(h.pid, 'SIGTERM')
102
+ } catch (err) {
103
+ return { stopped: false, reason: `não consegui matar pid ${h.pid}: ${err.message}` }
104
+ }
105
+ clearPidFile()
106
+ const exited = await waitExit(h.pid)
107
+ return { stopped: true, pid: h.pid, graceful: exited }
108
+ }
109
+
110
+ // Sem healthz: daemon travado ou morto. Só matamos por pid file se
111
+ // conseguirmos confirmar que o processo é o daemon.
112
+ let pid = null
113
+ try {
114
+ pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
115
+ } catch {
116
+ /* sem pid file */
117
+ }
118
+ if (!pid) return { stopped: false, reason: 'daemon não está rodando' }
119
+
120
+ const confirmed = pidLooksLikeDaemon(pid)
121
+ if (confirmed === false) {
122
+ clearPidFile() // pid file obsoleto apontando pra outro processo
123
+ return { stopped: false, reason: `pid file obsoleto (pid ${pid} não é o daemon) — removido` }
124
+ }
125
+ if (confirmed === null) {
126
+ return {
127
+ stopped: false,
128
+ reason: `daemon não responde e não consigo confirmar o pid ${pid} nesta plataforma — mate manualmente se necessário`,
129
+ }
130
+ }
131
+ try {
132
+ process.kill(pid, 'SIGTERM')
133
+ } catch (err) {
134
+ clearPidFile()
135
+ return { stopped: false, reason: `não consegui matar pid ${pid}: ${err.message}` }
136
+ }
137
+ clearPidFile()
138
+ return { stopped: true, pid }
139
+ }