snow-flow 10.0.1-dev.394 → 10.0.1-dev.396

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "10.0.1-dev.394",
3
+ "version": "10.0.1-dev.396",
4
4
  "name": "snow-flow",
5
5
  "description": "Snow-Flow - ServiceNow Multi-Agent Development Framework powered by AI",
6
6
  "license": "Elastic-2.0",
@@ -407,17 +407,32 @@ async function probeFlowFactoryNamespace(
407
407
  }
408
408
  }
409
409
 
410
- // 5. Probe each candidate — GET on a POST-only endpoint
410
+ // 5. Probe each candidate — GET the /discover endpoint (a real GET handler)
411
+ // If /discover doesn't exist yet, fall back to /create (expects 405)
411
412
  for (var j = 0; j < unique.length; j++) {
413
+ // Try /discover first (GET endpoint, returns 200 when namespace is correct)
414
+ try {
415
+ var discoverResp = await client.get('/api/' + unique[j] + '/' + FLOW_FACTORY_API_ID + '/discover');
416
+ if (discoverResp.status === 200 || discoverResp.data) {
417
+ return unique[j]; // Namespace confirmed via /discover
418
+ }
419
+ } catch (discoverErr: any) {
420
+ var ds = discoverErr.response?.status;
421
+ if (ds === 401 || ds === 403) {
422
+ return unique[j]; // Namespace correct but auth issue
423
+ }
424
+ // 404 = wrong namespace OR /discover not deployed yet, try /create
425
+ }
426
+ // Fallback: try /create (POST-only, expect 405 for correct namespace)
412
427
  try {
413
428
  await client.get('/api/' + unique[j] + '/' + FLOW_FACTORY_API_ID + '/create');
414
- return unique[j]; // 200 = endpoint exists (unlikely but valid)
415
- } catch (probeError: any) {
416
- var status = probeError.response?.status;
417
- if (status === 405 || status === 401 || status === 403) {
429
+ return unique[j]; // 200 = unexpected but valid
430
+ } catch (createErr: any) {
431
+ var cs = createErr.response?.status;
432
+ if (cs === 405 || cs === 401 || cs === 403) {
418
433
  return unique[j]; // Namespace correct — method or auth rejected
419
434
  }
420
- // 404 = wrong namespace, try next
435
+ // 404 = wrong namespace, try next candidate
421
436
  }
422
437
  }
423
438
 
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
45
45
  import { iife } from "@/util/iife"
46
46
  import { Shell } from "@/shell/shell"
47
47
  import { Truncate } from "@/tool/truncation"
48
+ import { UsageReporter } from "@/usage"
48
49
 
49
50
  // @ts-ignore
50
51
  globalThis.AI_SDK_LOG_WARNINGS = false
@@ -152,6 +153,9 @@ export namespace SessionPrompt {
152
153
  const session = await Session.get(input.sessionID)
153
154
  await SessionRevert.cleanup(session)
154
155
 
156
+ // Initialize TUI usage reporting (lazy, no-op if no enterprise auth)
157
+ UsageReporter.init()
158
+
155
159
  const message = await createUserMessage(input)
156
160
  await Session.touch(input.sessionID)
157
161
 
@@ -0,0 +1 @@
1
+ export { UsageReporter } from "./reporter"
@@ -0,0 +1,237 @@
1
+ import { Instance } from "@/project/instance"
2
+ import { Bus } from "@/bus"
3
+ import { MessageV2 } from "@/session/message-v2"
4
+ import { Auth } from "@/auth"
5
+ import { Log } from "@/util/log"
6
+
7
+ const log = Log.create({ service: "usage.reporter" })
8
+
9
+ const BUILTIN_TOOLS = new Set([
10
+ "Read",
11
+ "Write",
12
+ "Edit",
13
+ "Bash",
14
+ "Glob",
15
+ "Grep",
16
+ "Task",
17
+ "WebFetch",
18
+ "WebSearch",
19
+ "Skill",
20
+ "TodoRead",
21
+ "TodoWrite",
22
+ "NotebookEdit",
23
+ "AskUserQuestion",
24
+ "EnterPlanMode",
25
+ "ExitPlanMode",
26
+ ])
27
+
28
+ interface UsageEvent {
29
+ eventType: "llm" | "tool"
30
+ sessionId: string
31
+ machineId?: string
32
+ model?: string
33
+ provider?: string
34
+ agent?: string
35
+ tokensInput?: number
36
+ tokensOutput?: number
37
+ tokensReasoning?: number
38
+ tokensCacheRead?: number
39
+ tokensCacheWrite?: number
40
+ costUsd?: number
41
+ toolName?: string
42
+ toolCategory?: string
43
+ durationMs?: number
44
+ success?: boolean
45
+ errorMessage?: string
46
+ timestamp: number
47
+ }
48
+
49
+ interface AuthCache {
50
+ licenseKey: string
51
+ portalUrl: string
52
+ cachedAt: number
53
+ }
54
+
55
+ const AUTH_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
56
+ const FLUSH_INTERVAL = 30_000 // 30 seconds
57
+ const MAX_BATCH = 500
58
+
59
+ export namespace UsageReporter {
60
+ // Message metadata cache: maps messageID -> { modelID, providerID, agent }
61
+ // Populated from Message.Updated events so we can correlate step-finish parts
62
+ const messageMetadata = new Map<string, { modelID: string; providerID: string; agent: string }>()
63
+
64
+ const state = Instance.state(
65
+ () => {
66
+ const queue: UsageEvent[] = []
67
+ let authCache: AuthCache | undefined
68
+ let flushTimer: ReturnType<typeof setInterval> | undefined
69
+
70
+ const unsubs = [
71
+ // Track assistant message metadata for correlating step-finish parts
72
+ Bus.subscribe(MessageV2.Event.Updated, (event) => {
73
+ const info = event.properties.info
74
+ if (info.role !== "assistant") return
75
+ messageMetadata.set(info.id, {
76
+ modelID: info.modelID,
77
+ providerID: info.providerID,
78
+ agent: info.agent,
79
+ })
80
+ }),
81
+
82
+ // Track part updates for usage events
83
+ Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
84
+ const part = event.properties.part
85
+
86
+ if (part.type === "step-finish") {
87
+ const meta = messageMetadata.get(part.messageID)
88
+ queue.push({
89
+ eventType: "llm",
90
+ sessionId: part.sessionID,
91
+ model: meta?.modelID,
92
+ provider: meta?.providerID,
93
+ agent: meta?.agent,
94
+ tokensInput: part.tokens.input,
95
+ tokensOutput: part.tokens.output,
96
+ tokensReasoning: part.tokens.reasoning,
97
+ tokensCacheRead: part.tokens.cache.read,
98
+ tokensCacheWrite: part.tokens.cache.write,
99
+ costUsd: part.cost,
100
+ timestamp: Date.now(),
101
+ })
102
+ if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
103
+ }
104
+
105
+ if (part.type === "tool") {
106
+ if (part.state.status === "completed") {
107
+ queue.push({
108
+ eventType: "tool",
109
+ sessionId: part.sessionID,
110
+ toolName: part.tool,
111
+ toolCategory: BUILTIN_TOOLS.has(part.tool) ? "builtin" : "mcp",
112
+ durationMs: part.state.time.end - part.state.time.start,
113
+ success: true,
114
+ timestamp: Date.now(),
115
+ })
116
+ if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
117
+ }
118
+
119
+ if (part.state.status === "error") {
120
+ queue.push({
121
+ eventType: "tool",
122
+ sessionId: part.sessionID,
123
+ toolName: part.tool,
124
+ toolCategory: BUILTIN_TOOLS.has(part.tool) ? "builtin" : "mcp",
125
+ durationMs: part.state.time.end - part.state.time.start,
126
+ success: false,
127
+ errorMessage: part.state.error,
128
+ timestamp: Date.now(),
129
+ })
130
+ if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
131
+ }
132
+ }
133
+ }),
134
+ ]
135
+
136
+ // Start periodic flush
137
+ resolveAuth().then((auth) => {
138
+ if (!auth) {
139
+ log.info("no enterprise/portal auth found, TUI usage reporting disabled")
140
+ return
141
+ }
142
+ authCache = auth
143
+ flushTimer = setInterval(() => {
144
+ flush(queue, authCache).catch(() => {})
145
+ }, FLUSH_INTERVAL)
146
+ })
147
+
148
+ return { queue, unsubs, flushTimer, authCache }
149
+ },
150
+ async (current) => {
151
+ // Dispose: final flush + cleanup
152
+ if (current.flushTimer) clearInterval(current.flushTimer)
153
+ for (const unsub of current.unsubs) unsub()
154
+ if (current.queue.length > 0 && current.authCache) {
155
+ await flush(current.queue, current.authCache).catch(() => {})
156
+ }
157
+ messageMetadata.clear()
158
+ },
159
+ )
160
+
161
+ /** Initialize the reporter. Call once per Instance lifecycle. */
162
+ export function init() {
163
+ state() // triggers lazy init
164
+ }
165
+ }
166
+
167
+ async function resolveAuth(): Promise<AuthCache | undefined> {
168
+ const all = await Auth.all()
169
+
170
+ // Check enterprise auth first
171
+ for (const entry of Object.values(all)) {
172
+ if (entry.type === "enterprise" && entry.licenseKey) {
173
+ return {
174
+ licenseKey: entry.licenseKey,
175
+ portalUrl: entry.enterpriseUrl || process.env.SNOW_FLOW_PORTAL_URL || "https://portal.snow-flow.dev",
176
+ cachedAt: Date.now(),
177
+ }
178
+ }
179
+ }
180
+
181
+ // Check portal auth (Teams users have organization license key)
182
+ // Portal auth doesn't carry a license key directly, but we can use the token
183
+ // The portal backend also accepts organization license keys
184
+ for (const entry of Object.values(all)) {
185
+ if (entry.type === "portal") {
186
+ // Portal users need their org license key; we don't have it directly
187
+ // They authenticate via token, not license key
188
+ // For now, skip portal-only users (they use the portal chat which already tracks usage)
189
+ return undefined
190
+ }
191
+ }
192
+
193
+ return undefined
194
+ }
195
+
196
+ async function flush(queue: UsageEvent[], authCache: AuthCache | undefined): Promise<void> {
197
+ if (queue.length === 0 || !authCache) return
198
+
199
+ // Drain the queue
200
+ const batch = queue.splice(0, MAX_BATCH)
201
+
202
+ try {
203
+ const response = await fetch(`${authCache.portalUrl}/api/tui/usage/ingest`, {
204
+ method: "POST",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ "X-License-Key": authCache.licenseKey,
208
+ },
209
+ body: JSON.stringify({ events: batch }),
210
+ signal: AbortSignal.timeout(10_000),
211
+ })
212
+
213
+ if (!response.ok) {
214
+ if (response.status === 401) {
215
+ log.warn("TUI usage flush got 401, clearing auth cache")
216
+ // Re-resolve auth on next flush
217
+ const fresh = await resolveAuth()
218
+ if (fresh) {
219
+ authCache.licenseKey = fresh.licenseKey
220
+ authCache.portalUrl = fresh.portalUrl
221
+ authCache.cachedAt = fresh.cachedAt
222
+ }
223
+ }
224
+ // Put events back if flush failed (with limit to avoid unbounded growth)
225
+ if (queue.length + batch.length <= MAX_BATCH * 2) {
226
+ queue.unshift(...batch)
227
+ }
228
+ log.warn("TUI usage flush failed", { status: response.status })
229
+ }
230
+ } catch (error) {
231
+ // Fire-and-forget: network errors are expected when offline
232
+ if (queue.length + batch.length <= MAX_BATCH * 2) {
233
+ queue.unshift(...batch)
234
+ }
235
+ log.warn("TUI usage flush error", { error: String(error) })
236
+ }
237
+ }