stagent 0.8.0 → 0.9.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/README.md +41 -0
- package/dist/cli.js +19 -2
- package/package.json +2 -1
- package/src/app/analytics/page.tsx +60 -0
- package/src/app/api/license/checkout/route.ts +28 -0
- package/src/app/api/license/portal/route.ts +26 -0
- package/src/app/api/license/route.ts +88 -0
- package/src/app/api/license/usage/route.ts +63 -0
- package/src/app/api/marketplace/browse/route.ts +15 -0
- package/src/app/api/marketplace/import/route.ts +28 -0
- package/src/app/api/marketplace/publish/route.ts +40 -0
- package/src/app/api/memory/route.ts +11 -0
- package/src/app/api/onboarding/email/route.ts +53 -0
- package/src/app/api/onboarding/progress/route.ts +60 -0
- package/src/app/api/schedules/route.ts +11 -0
- package/src/app/api/settings/telemetry/route.ts +14 -0
- package/src/app/api/sync/export/route.ts +54 -0
- package/src/app/api/sync/restore/route.ts +37 -0
- package/src/app/api/sync/sessions/route.ts +24 -0
- package/src/app/api/tasks/[id]/execute/route.ts +21 -0
- package/src/app/auth/callback/route.ts +79 -0
- package/src/app/marketplace/page.tsx +19 -0
- package/src/app/page.tsx +6 -2
- package/src/app/settings/page.tsx +8 -0
- package/src/components/analytics/analytics-dashboard.tsx +200 -0
- package/src/components/analytics/analytics-gate-card.tsx +101 -0
- package/src/components/marketplace/blueprint-card.tsx +61 -0
- package/src/components/marketplace/marketplace-browser.tsx +131 -0
- package/src/components/onboarding/activation-checklist.tsx +64 -0
- package/src/components/onboarding/donut-ring.tsx +52 -0
- package/src/components/onboarding/email-capture-card.tsx +104 -0
- package/src/components/settings/activation-form.tsx +95 -0
- package/src/components/settings/cloud-account-section.tsx +145 -0
- package/src/components/settings/cloud-sync-section.tsx +155 -0
- package/src/components/settings/subscription-section.tsx +410 -0
- package/src/components/settings/telemetry-section.tsx +80 -0
- package/src/components/shared/app-sidebar.tsx +136 -29
- package/src/components/shared/premium-gate-overlay.tsx +50 -0
- package/src/components/shared/schedule-gate-dialog.tsx +64 -0
- package/src/components/shared/upgrade-banner.tsx +112 -0
- package/src/hooks/use-snoozed-banners.ts +73 -0
- package/src/hooks/use-supabase-auth.ts +79 -0
- package/src/instrumentation.ts +34 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +28 -1
- package/src/lib/agents/__tests__/learned-context.test.ts +13 -0
- package/src/lib/agents/execution-manager.ts +35 -0
- package/src/lib/agents/learned-context.ts +13 -0
- package/src/lib/analytics/queries.ts +207 -0
- package/src/lib/billing/email.ts +54 -0
- package/src/lib/billing/products.ts +80 -0
- package/src/lib/billing/stripe.ts +101 -0
- package/src/lib/cloud/supabase-browser.ts +38 -0
- package/src/lib/cloud/supabase-client.ts +49 -0
- package/src/lib/constants/settings.ts +18 -0
- package/src/lib/data/clear.ts +5 -0
- package/src/lib/db/bootstrap.ts +16 -0
- package/src/lib/db/schema.ts +24 -0
- package/src/lib/license/__tests__/features.test.ts +56 -0
- package/src/lib/license/__tests__/key-format.test.ts +88 -0
- package/src/lib/license/__tests__/tier-limits.test.ts +79 -0
- package/src/lib/license/cloud-validation.ts +64 -0
- package/src/lib/license/features.ts +44 -0
- package/src/lib/license/key-format.ts +101 -0
- package/src/lib/license/limit-check.ts +111 -0
- package/src/lib/license/limit-queries.ts +51 -0
- package/src/lib/license/manager.ts +290 -0
- package/src/lib/license/notifications.ts +59 -0
- package/src/lib/license/tier-limits.ts +71 -0
- package/src/lib/marketplace/marketplace-client.ts +107 -0
- package/src/lib/sync/cloud-sync.ts +237 -0
- package/src/lib/telemetry/conversion-events.ts +73 -0
- package/src/lib/telemetry/queue.ts +122 -0
- package/src/lib/usage/ledger.ts +18 -0
- package/src/lib/validators/license.ts +33 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud Sync — encrypted SQLite backup and restore via Supabase Storage.
|
|
3
|
+
*
|
|
4
|
+
* Encryption: AES-256-GCM with HKDF-derived keys.
|
|
5
|
+
* Envelope: [4B version][32B salt][12B IV][N bytes ciphertext][16B auth tag]
|
|
6
|
+
*
|
|
7
|
+
* V1: Full-database export (no incremental). Manual backup/restore only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { getStagentDataDir } from "@/lib/utils/stagent-paths";
|
|
15
|
+
import { getSupabaseClient } from "@/lib/cloud/supabase-client";
|
|
16
|
+
import { sqlite } from "@/lib/db";
|
|
17
|
+
|
|
18
|
+
const SYNC_VERSION = Buffer.from([0, 0, 0, 1]); // Version 1
|
|
19
|
+
const BUCKET_NAME = "stagent-sync";
|
|
20
|
+
|
|
21
|
+
export interface SyncResult {
|
|
22
|
+
success: boolean;
|
|
23
|
+
blobPath?: string;
|
|
24
|
+
sizeBytes?: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Derive an AES-256 key from userId using HKDF-like construction.
|
|
30
|
+
* Uses SHA-256 HMAC with a fixed info string and random salt.
|
|
31
|
+
*/
|
|
32
|
+
function deriveKey(userId: string, salt: Buffer): Buffer {
|
|
33
|
+
const hmac = createHash("sha256");
|
|
34
|
+
hmac.update(salt);
|
|
35
|
+
hmac.update(userId);
|
|
36
|
+
hmac.update("stagent-sync-v1");
|
|
37
|
+
return Buffer.from(hmac.digest());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Encrypt data using AES-256-GCM.
|
|
42
|
+
* Returns envelope: [4B version][32B salt][12B IV][ciphertext + 16B auth tag]
|
|
43
|
+
*/
|
|
44
|
+
function encrypt(data: Buffer, userId: string): Buffer {
|
|
45
|
+
const salt = randomBytes(32);
|
|
46
|
+
const iv = randomBytes(12);
|
|
47
|
+
const key = deriveKey(userId, salt);
|
|
48
|
+
|
|
49
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
50
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
51
|
+
const authTag = cipher.getAuthTag();
|
|
52
|
+
|
|
53
|
+
return Buffer.concat([SYNC_VERSION, salt, iv, encrypted, authTag]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decrypt an envelope back to raw data.
|
|
58
|
+
*/
|
|
59
|
+
function decrypt(envelope: Buffer, userId: string): Buffer {
|
|
60
|
+
// Parse envelope
|
|
61
|
+
const version = envelope.subarray(0, 4);
|
|
62
|
+
if (!version.equals(SYNC_VERSION)) {
|
|
63
|
+
throw new Error(`Unsupported sync version: ${version.toString("hex")}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const salt = envelope.subarray(4, 36);
|
|
67
|
+
const iv = envelope.subarray(36, 48);
|
|
68
|
+
const authTag = envelope.subarray(envelope.length - 16);
|
|
69
|
+
const ciphertext = envelope.subarray(48, envelope.length - 16);
|
|
70
|
+
|
|
71
|
+
const key = deriveKey(userId, salt);
|
|
72
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
73
|
+
decipher.setAuthTag(authTag);
|
|
74
|
+
|
|
75
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Export the SQLite database, encrypt it, and upload to Supabase Storage.
|
|
80
|
+
*/
|
|
81
|
+
export async function exportAndUpload(
|
|
82
|
+
userId: string,
|
|
83
|
+
deviceId: string,
|
|
84
|
+
accessToken?: string
|
|
85
|
+
): Promise<SyncResult> {
|
|
86
|
+
let supabase = getSupabaseClient();
|
|
87
|
+
if (!supabase) return { success: false, error: "Cloud not configured" };
|
|
88
|
+
|
|
89
|
+
// If an access token is provided, create an authenticated client for Storage RLS
|
|
90
|
+
if (accessToken) {
|
|
91
|
+
const { createClient } = await import("@supabase/supabase-js");
|
|
92
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "https://yznantjbmacbllhcyzwc.supabase.co";
|
|
93
|
+
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl6bmFudGpibWFjYmxsaGN5endjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI1MDg1ODMsImV4cCI6MjA4ODA4NDU4M30.i-P7MXpR1_emBjhUkzbFeSX7fgjgPDv90_wkqF7sW3Y";
|
|
94
|
+
supabase = createClient(url, anonKey, {
|
|
95
|
+
global: { headers: { Authorization: `Bearer ${accessToken}` } },
|
|
96
|
+
auth: { persistSession: false },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// 1. Create a consistent backup using better-sqlite3 .backup()
|
|
102
|
+
const tempPath = join(tmpdir(), `stagent-export-${Date.now()}.db`);
|
|
103
|
+
await sqlite.backup(tempPath);
|
|
104
|
+
|
|
105
|
+
// 2. Read and encrypt
|
|
106
|
+
const dbBuffer = readFileSync(tempPath);
|
|
107
|
+
const encrypted = encrypt(dbBuffer, userId);
|
|
108
|
+
|
|
109
|
+
// 3. Upload to Supabase Storage
|
|
110
|
+
const blobPath = `${userId}/${deviceId}/${Date.now()}.enc`;
|
|
111
|
+
const { error: uploadError } = await supabase.storage
|
|
112
|
+
.from(BUCKET_NAME)
|
|
113
|
+
.upload(blobPath, encrypted, {
|
|
114
|
+
contentType: "application/octet-stream",
|
|
115
|
+
upsert: false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (uploadError) {
|
|
119
|
+
return { success: false, error: uploadError.message };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Record sync session
|
|
123
|
+
await supabase.from("sync_sessions").insert({
|
|
124
|
+
user_id: userId,
|
|
125
|
+
device_name: deviceId,
|
|
126
|
+
device_id: deviceId,
|
|
127
|
+
blob_path: blobPath,
|
|
128
|
+
blob_size_bytes: encrypted.length,
|
|
129
|
+
sync_type: "backup",
|
|
130
|
+
status: "completed",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Clean up temp file
|
|
134
|
+
try { require("fs").unlinkSync(tempPath); } catch { /* ignore */ }
|
|
135
|
+
|
|
136
|
+
return { success: true, blobPath, sizeBytes: encrypted.length };
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { success: false, error: err instanceof Error ? err.message : "Export failed" };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Download the latest snapshot and restore it.
|
|
144
|
+
* Always creates a safety backup before restoring.
|
|
145
|
+
*/
|
|
146
|
+
export async function downloadAndRestore(userId: string): Promise<SyncResult> {
|
|
147
|
+
const supabase = getSupabaseClient();
|
|
148
|
+
if (!supabase) return { success: false, error: "Cloud not configured" };
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// 1. Find the latest snapshot
|
|
152
|
+
const { data: sessions, error: listError } = await supabase
|
|
153
|
+
.from("sync_sessions")
|
|
154
|
+
.select("blob_path, blob_size_bytes")
|
|
155
|
+
.eq("user_id", userId)
|
|
156
|
+
.eq("sync_type", "backup")
|
|
157
|
+
.eq("status", "completed")
|
|
158
|
+
.order("created_at", { ascending: false })
|
|
159
|
+
.limit(1);
|
|
160
|
+
|
|
161
|
+
if (listError || !sessions?.length) {
|
|
162
|
+
return { success: false, error: "No backup found" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { blob_path } = sessions[0];
|
|
166
|
+
|
|
167
|
+
// 2. Download encrypted snapshot
|
|
168
|
+
const { data: blob, error: downloadError } = await supabase.storage
|
|
169
|
+
.from(BUCKET_NAME)
|
|
170
|
+
.download(blob_path);
|
|
171
|
+
|
|
172
|
+
if (downloadError || !blob) {
|
|
173
|
+
return { success: false, error: downloadError?.message ?? "Download failed" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const encrypted = Buffer.from(await blob.arrayBuffer());
|
|
177
|
+
|
|
178
|
+
// 3. Decrypt
|
|
179
|
+
const decrypted = decrypt(encrypted, userId);
|
|
180
|
+
|
|
181
|
+
// 4. Create safety backup of current DB
|
|
182
|
+
const dataDir = getStagentDataDir();
|
|
183
|
+
const safetyPath = join(dataDir, `stagent-safety-${Date.now()}.db`);
|
|
184
|
+
const dbPath = join(dataDir, "stagent.db");
|
|
185
|
+
if (existsSync(dbPath)) {
|
|
186
|
+
copyFileSync(dbPath, safetyPath);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 5. Write restored DB to temp location and validate
|
|
190
|
+
const tempRestore = join(tmpdir(), `stagent-restore-${Date.now()}.db`);
|
|
191
|
+
writeFileSync(tempRestore, decrypted);
|
|
192
|
+
|
|
193
|
+
// Basic validation: check it's a valid SQLite file
|
|
194
|
+
const header = decrypted.subarray(0, 16).toString("ascii");
|
|
195
|
+
if (!header.startsWith("SQLite format 3")) {
|
|
196
|
+
return { success: false, error: "Decrypted data is not a valid SQLite database" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 6. Replace current DB (requires app restart)
|
|
200
|
+
copyFileSync(tempRestore, dbPath);
|
|
201
|
+
|
|
202
|
+
// Clean up
|
|
203
|
+
try { require("fs").unlinkSync(tempRestore); } catch { /* ignore */ }
|
|
204
|
+
|
|
205
|
+
// Record restore session
|
|
206
|
+
await supabase.from("sync_sessions").insert({
|
|
207
|
+
user_id: userId,
|
|
208
|
+
device_name: "restore",
|
|
209
|
+
device_id: "restore",
|
|
210
|
+
blob_path,
|
|
211
|
+
blob_size_bytes: decrypted.length,
|
|
212
|
+
sync_type: "restore",
|
|
213
|
+
status: "completed",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { success: true, sizeBytes: decrypted.length };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { success: false, error: err instanceof Error ? err.message : "Restore failed" };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* List recent sync sessions for the user.
|
|
224
|
+
*/
|
|
225
|
+
export async function listSyncSessions(userId: string) {
|
|
226
|
+
const supabase = getSupabaseClient();
|
|
227
|
+
if (!supabase) return [];
|
|
228
|
+
|
|
229
|
+
const { data } = await supabase
|
|
230
|
+
.from("sync_sessions")
|
|
231
|
+
.select("*")
|
|
232
|
+
.eq("user_id", userId)
|
|
233
|
+
.order("created_at", { ascending: false })
|
|
234
|
+
.limit(20);
|
|
235
|
+
|
|
236
|
+
return data ?? [];
|
|
237
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversion funnel tracking — lightweight, anonymous event tracking
|
|
3
|
+
* for Community→Premium conversion optimization.
|
|
4
|
+
*
|
|
5
|
+
* Events: banner_impression, banner_click, checkout_started,
|
|
6
|
+
* checkout_completed, limit_hit
|
|
7
|
+
*
|
|
8
|
+
* Fire-and-forget — never blocks user actions. No PII.
|
|
9
|
+
* No-op if Supabase is not configured.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isCloudConfigured } from "@/lib/cloud/supabase-client";
|
|
13
|
+
import { getSettingSync, setSetting } from "@/lib/settings/helpers";
|
|
14
|
+
|
|
15
|
+
const SESSION_KEY = "conversion.sessionId";
|
|
16
|
+
|
|
17
|
+
export type ConversionEventType =
|
|
18
|
+
| "banner_impression"
|
|
19
|
+
| "banner_click"
|
|
20
|
+
| "checkout_started"
|
|
21
|
+
| "checkout_completed"
|
|
22
|
+
| "limit_hit";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get or create an anonymous session ID for conversion tracking.
|
|
26
|
+
* Stored in settings, not tied to any user identity.
|
|
27
|
+
*/
|
|
28
|
+
function getSessionId(): string {
|
|
29
|
+
let id = getSettingSync(SESSION_KEY);
|
|
30
|
+
if (!id) {
|
|
31
|
+
id = crypto.randomUUID();
|
|
32
|
+
setSetting(SESSION_KEY, id).catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Track a conversion funnel event. Fire-and-forget.
|
|
39
|
+
*
|
|
40
|
+
* @param eventType - The event type
|
|
41
|
+
* @param source - Where the event originated (e.g., "memory_banner", "schedule_gate")
|
|
42
|
+
* @param metadata - Optional additional context
|
|
43
|
+
*/
|
|
44
|
+
export function trackConversionEvent(
|
|
45
|
+
eventType: ConversionEventType,
|
|
46
|
+
source?: string,
|
|
47
|
+
metadata?: Record<string, unknown>
|
|
48
|
+
): void {
|
|
49
|
+
if (!isCloudConfigured()) return;
|
|
50
|
+
|
|
51
|
+
const sessionId = getSessionId();
|
|
52
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
53
|
+
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
54
|
+
|
|
55
|
+
if (!supabaseUrl || !anonKey) return;
|
|
56
|
+
|
|
57
|
+
// Fire-and-forget — don't await, don't block
|
|
58
|
+
fetch(`${supabaseUrl}/functions/v1/conversion-ingest`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
Authorization: `Bearer ${anonKey}`,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
eventType,
|
|
66
|
+
sessionId,
|
|
67
|
+
source: source ?? null,
|
|
68
|
+
metadata: metadata ?? null,
|
|
69
|
+
}),
|
|
70
|
+
}).catch(() => {
|
|
71
|
+
// Silently ignore — conversion tracking is non-critical
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry batch queue — opt-in anonymized usage data.
|
|
3
|
+
*
|
|
4
|
+
* Events are queued in the settings table as a JSON array,
|
|
5
|
+
* flushed every 5 minutes to the Supabase telemetry-ingest Edge Function.
|
|
6
|
+
* Capped at 200 events to prevent unbounded growth.
|
|
7
|
+
*
|
|
8
|
+
* EXPLICITLY ABSENT from events: taskId, projectId, taskTitle,
|
|
9
|
+
* description, result, userId, email (no PII).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getSettingSync, setSetting } from "@/lib/settings/helpers";
|
|
13
|
+
import { SETTINGS_KEYS } from "@/lib/constants/settings";
|
|
14
|
+
import { isCloudConfigured } from "@/lib/cloud/supabase-client";
|
|
15
|
+
|
|
16
|
+
const MAX_BATCH_SIZE = 200;
|
|
17
|
+
const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
|
|
19
|
+
export interface TelemetryEvent {
|
|
20
|
+
runtimeId: string;
|
|
21
|
+
providerId: string;
|
|
22
|
+
modelId: string;
|
|
23
|
+
profileDomain?: string;
|
|
24
|
+
workflowPattern?: string;
|
|
25
|
+
activityType: string;
|
|
26
|
+
outcomeStatus?: string;
|
|
27
|
+
tokenCount?: number;
|
|
28
|
+
costMicros?: number;
|
|
29
|
+
durationMs?: number;
|
|
30
|
+
stepCount?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if telemetry is opt-in enabled.
|
|
35
|
+
*/
|
|
36
|
+
export function isTelemetryEnabled(): boolean {
|
|
37
|
+
return getSettingSync(SETTINGS_KEYS.TELEMETRY_ENABLED) === "true";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get or create the anonymous runtime ID.
|
|
42
|
+
*/
|
|
43
|
+
export function getRuntimeId(): string {
|
|
44
|
+
let id = getSettingSync(SETTINGS_KEYS.TELEMETRY_RUNTIME_ID);
|
|
45
|
+
if (!id) {
|
|
46
|
+
id = crypto.randomUUID();
|
|
47
|
+
setSetting(SETTINGS_KEYS.TELEMETRY_RUNTIME_ID, id).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Queue a telemetry event for batch flush.
|
|
54
|
+
* No-op if telemetry is disabled.
|
|
55
|
+
*/
|
|
56
|
+
export function queueTelemetryEvent(event: TelemetryEvent): void {
|
|
57
|
+
if (!isTelemetryEnabled()) return;
|
|
58
|
+
|
|
59
|
+
const batch = loadBatch();
|
|
60
|
+
if (batch.length >= MAX_BATCH_SIZE) {
|
|
61
|
+
// Drop oldest events when at capacity
|
|
62
|
+
batch.shift();
|
|
63
|
+
}
|
|
64
|
+
batch.push(event);
|
|
65
|
+
saveBatch(batch);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Flush the batch to the cloud telemetry endpoint.
|
|
70
|
+
* Called on an interval from instrumentation.ts.
|
|
71
|
+
*/
|
|
72
|
+
export async function flushTelemetryBatch(): Promise<void> {
|
|
73
|
+
if (!isTelemetryEnabled() || !isCloudConfigured()) return;
|
|
74
|
+
|
|
75
|
+
const batch = loadBatch();
|
|
76
|
+
if (batch.length === 0) return;
|
|
77
|
+
|
|
78
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
79
|
+
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${supabaseUrl}/functions/v1/telemetry-ingest`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
Authorization: `Bearer ${anonKey}`,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ events: batch }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
// Clear the batch on successful flush
|
|
93
|
+
saveBatch([]);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Network failure — retain batch for next flush
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Start the periodic flush timer. Call from instrumentation.ts.
|
|
102
|
+
*/
|
|
103
|
+
export function startTelemetryFlush(): void {
|
|
104
|
+
// Flush once on startup
|
|
105
|
+
flushTelemetryBatch().catch(() => {});
|
|
106
|
+
// Then every 5 minutes
|
|
107
|
+
setInterval(() => flushTelemetryBatch().catch(() => {}), FLUSH_INTERVAL_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadBatch(): TelemetryEvent[] {
|
|
111
|
+
try {
|
|
112
|
+
const raw = getSettingSync(SETTINGS_KEYS.TELEMETRY_BATCH);
|
|
113
|
+
if (!raw) return [];
|
|
114
|
+
return JSON.parse(raw) as TelemetryEvent[];
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function saveBatch(batch: TelemetryEvent[]): void {
|
|
121
|
+
setSetting(SETTINGS_KEYS.TELEMETRY_BATCH, JSON.stringify(batch)).catch(() => {});
|
|
122
|
+
}
|
package/src/lib/usage/ledger.ts
CHANGED
|
@@ -248,6 +248,24 @@ export async function recordUsageLedgerEntry(input: UsageLedgerWriteInput) {
|
|
|
248
248
|
} as const;
|
|
249
249
|
|
|
250
250
|
await db.insert(usageLedger).values(row);
|
|
251
|
+
|
|
252
|
+
// Queue telemetry event (opt-in, fire-and-forget)
|
|
253
|
+
try {
|
|
254
|
+
const { queueTelemetryEvent } = await import("@/lib/telemetry/queue");
|
|
255
|
+
queueTelemetryEvent({
|
|
256
|
+
runtimeId: input.runtimeId,
|
|
257
|
+
providerId: input.providerId,
|
|
258
|
+
modelId: input.modelId ?? "unknown",
|
|
259
|
+
activityType: input.activityType,
|
|
260
|
+
outcomeStatus: status,
|
|
261
|
+
tokenCount: normalizedTotalTokens ?? undefined,
|
|
262
|
+
costMicros: resolvedCostMicros ?? undefined,
|
|
263
|
+
durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
// Telemetry is non-critical
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
return row;
|
|
252
270
|
}
|
|
253
271
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TIERS } from "@/lib/license/tier-limits";
|
|
3
|
+
|
|
4
|
+
export const activateLicenseSchema = z.object({
|
|
5
|
+
key: z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1, "License key is required")
|
|
8
|
+
.regex(
|
|
9
|
+
/^STAG-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/,
|
|
10
|
+
"Invalid license key format (expected STAG-XXXX-XXXX-XXXX-XXXX)"
|
|
11
|
+
)
|
|
12
|
+
.optional(),
|
|
13
|
+
email: z.string().email("Invalid email address").optional(),
|
|
14
|
+
tier: z.enum(TIERS).optional(),
|
|
15
|
+
token: z.string().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type ActivateLicenseInput = z.infer<typeof activateLicenseSchema>;
|
|
19
|
+
|
|
20
|
+
export const licenseStatusSchema = z.object({
|
|
21
|
+
tier: z.enum(TIERS),
|
|
22
|
+
status: z.enum(["active", "inactive", "grace"]),
|
|
23
|
+
email: z.string().nullable(),
|
|
24
|
+
activatedAt: z.string().nullable(),
|
|
25
|
+
expiresAt: z.string().nullable(),
|
|
26
|
+
lastValidatedAt: z.string().nullable(),
|
|
27
|
+
gracePeriodExpiresAt: z.string().nullable(),
|
|
28
|
+
isPremium: z.boolean(),
|
|
29
|
+
features: z.record(z.string(), z.boolean()),
|
|
30
|
+
limits: z.record(z.string(), z.number()),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type LicenseStatusResponse = z.infer<typeof licenseStatusSchema>;
|