tanuki-telemetry 1.1.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 (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,97 @@
1
+ import crypto from "crypto";
2
+ import { Router } from "express";
3
+ import { getDb } from "./db.js";
4
+ import { dashboardAuthMiddleware } from "./middleware.js";
5
+ import type { User } from "./auth.js";
6
+
7
+ export interface ApiKey {
8
+ id: string;
9
+ user_id: string;
10
+ label: string;
11
+ last_used_at: string | null;
12
+ created_at: string;
13
+ // key_hash is never returned
14
+ }
15
+
16
+ function hashKey(rawKey: string): string {
17
+ return crypto.createHash("sha256").update(rawKey).digest("hex");
18
+ }
19
+
20
+ export function generateApiKey(userId: string, label: string = "default"): string {
21
+ const d = getDb();
22
+ const rawKey = "tlm_" + crypto.randomBytes(32).toString("hex");
23
+ const keyHash = hashKey(rawKey);
24
+ const id = crypto.randomUUID();
25
+
26
+ d.prepare(
27
+ "INSERT INTO api_keys (id, user_id, key_hash, label) VALUES (?, ?, ?, ?)"
28
+ ).run(id, userId, keyHash, label);
29
+
30
+ return rawKey;
31
+ }
32
+
33
+ export interface ApiKeyValidation {
34
+ user_id: string;
35
+ email: string;
36
+ }
37
+
38
+ export function validateApiKey(rawKey: string): ApiKeyValidation | null {
39
+ const d = getDb();
40
+ const keyHash = hashKey(rawKey);
41
+ const row = d
42
+ .prepare("SELECT ak.user_id, u.email FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.key_hash = ?")
43
+ .get(keyHash) as { user_id: string; email: string } | undefined;
44
+
45
+ if (!row) return null;
46
+
47
+ // Update last_used_at
48
+ d.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?").run(keyHash);
49
+ return { user_id: row.user_id, email: row.email };
50
+ }
51
+
52
+ export function listApiKeys(userId: string): ApiKey[] {
53
+ const d = getDb();
54
+ return d
55
+ .prepare("SELECT id, user_id, label, last_used_at, created_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC")
56
+ .all(userId) as ApiKey[];
57
+ }
58
+
59
+ export function revokeApiKey(keyId: string, userId: string): boolean {
60
+ const d = getDb();
61
+ const result = d
62
+ .prepare("DELETE FROM api_keys WHERE id = ? AND user_id = ?")
63
+ .run(keyId, userId);
64
+ return result.changes > 0;
65
+ }
66
+
67
+ export function createApiKeyRouter(): Router {
68
+ const router = Router();
69
+
70
+ // All routes require dashboard auth
71
+ router.use(dashboardAuthMiddleware);
72
+
73
+ router.get("/", (req, res) => {
74
+ const user = req.user as User;
75
+ const keys = listApiKeys(user.id);
76
+ res.json(keys);
77
+ });
78
+
79
+ router.post("/", (req, res) => {
80
+ const user = req.user as User;
81
+ const label = (req.body?.label as string) || "default";
82
+ const rawKey = generateApiKey(user.id, label);
83
+ res.json({ key: rawKey, label });
84
+ });
85
+
86
+ router.delete("/:id", (req, res) => {
87
+ const user = req.user as User;
88
+ const deleted = revokeApiKey(req.params.id, user.id);
89
+ if (deleted) {
90
+ res.json({ ok: true });
91
+ } else {
92
+ res.status(404).json({ error: "Key not found" });
93
+ }
94
+ });
95
+
96
+ return router;
97
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { Router } from "express";
2
+ import passport from "passport";
3
+ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
4
+ import { getDb } from "./db.js";
5
+
6
+ export interface User {
7
+ id: string;
8
+ google_id: string;
9
+ email: string;
10
+ name: string;
11
+ avatar_url: string | null;
12
+ }
13
+
14
+ // Ensure users table exists
15
+ export function initAuthTables(): void {
16
+ const d = getDb();
17
+
18
+ d.exec(`
19
+ CREATE TABLE IF NOT EXISTS users (
20
+ id TEXT PRIMARY KEY,
21
+ google_id TEXT UNIQUE NOT NULL,
22
+ email TEXT UNIQUE NOT NULL,
23
+ name TEXT NOT NULL,
24
+ avatar_url TEXT,
25
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS api_keys (
29
+ id TEXT PRIMARY KEY,
30
+ user_id TEXT NOT NULL REFERENCES users(id),
31
+ key_hash TEXT UNIQUE NOT NULL,
32
+ label TEXT NOT NULL DEFAULT 'default',
33
+ last_used_at TEXT,
34
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
38
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
39
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_expired ON user_sessions(expired_at);
40
+ `);
41
+ }
42
+
43
+ function findOrCreateUser(profile: {
44
+ id: string;
45
+ email: string;
46
+ displayName: string;
47
+ photos?: Array<{ value: string }>;
48
+ }): User {
49
+ const d = getDb();
50
+
51
+ const existing = d
52
+ .prepare("SELECT * FROM users WHERE google_id = ?")
53
+ .get(profile.id) as User | undefined;
54
+
55
+ if (existing) {
56
+ // Update name/avatar on each login
57
+ d.prepare("UPDATE users SET name = ?, avatar_url = ? WHERE id = ?").run(
58
+ profile.displayName,
59
+ profile.photos?.[0]?.value ?? null,
60
+ existing.id
61
+ );
62
+ return { ...existing, name: profile.displayName, avatar_url: profile.photos?.[0]?.value ?? null };
63
+ }
64
+
65
+ const id = crypto.randomUUID();
66
+ d.prepare(
67
+ "INSERT INTO users (id, google_id, email, name, avatar_url) VALUES (?, ?, ?, ?, ?)"
68
+ ).run(id, profile.id, profile.email, profile.displayName, profile.photos?.[0]?.value ?? null);
69
+
70
+ return { id, google_id: profile.id, email: profile.email, name: profile.displayName, avatar_url: profile.photos?.[0]?.value ?? null };
71
+ }
72
+
73
+ export function setupPassport(): void {
74
+ const clientID = process.env.GOOGLE_CLIENT_ID;
75
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
76
+ const callbackURL = process.env.GOOGLE_CALLBACK_URL || "/auth/google/callback";
77
+
78
+ if (!clientID || !clientSecret) return;
79
+
80
+ passport.serializeUser((user, done) => {
81
+ done(null, (user as User).id);
82
+ });
83
+
84
+ passport.deserializeUser((id: string, done) => {
85
+ const d = getDb();
86
+ const user = d.prepare("SELECT * FROM users WHERE id = ?").get(id) as User | undefined;
87
+ done(null, user || null);
88
+ });
89
+
90
+ passport.use(
91
+ new GoogleStrategy(
92
+ { clientID, clientSecret, callbackURL },
93
+ (_accessToken, _refreshToken, profile, done) => {
94
+ try {
95
+ const email = profile.emails?.[0]?.value;
96
+ if (!email) {
97
+ return done(new Error("No email in Google profile"));
98
+ }
99
+
100
+ // Check allowed emails
101
+ const allowedEmails = process.env.ALLOWED_EMAILS;
102
+ if (allowedEmails) {
103
+ const allowed = allowedEmails.split(",").map((e) => e.trim().toLowerCase());
104
+ if (!allowed.includes(email.toLowerCase())) {
105
+ return done(new Error("Email not authorized"));
106
+ }
107
+ }
108
+
109
+ const user = findOrCreateUser({
110
+ id: profile.id,
111
+ email,
112
+ displayName: profile.displayName,
113
+ photos: profile.photos,
114
+ });
115
+
116
+ return done(null, user);
117
+ } catch (err) {
118
+ return done(err as Error);
119
+ }
120
+ }
121
+ )
122
+ );
123
+ }
124
+
125
+ export function createAuthRouter(): Router {
126
+ const router = Router();
127
+ const baseUrl = process.env.BASE_URL || "";
128
+
129
+ router.get("/google", passport.authenticate("google", { scope: ["profile", "email"] }));
130
+
131
+ router.get(
132
+ "/google/callback",
133
+ passport.authenticate("google", { failureRedirect: "/auth/login-failed" }),
134
+ (_req, res) => {
135
+ res.redirect(baseUrl ? "/" : "/");
136
+ }
137
+ );
138
+
139
+ router.get("/login-failed", (_req, res) => {
140
+ res.status(403).type("html").send(`<!DOCTYPE html>
141
+ <html><head><title>Access Denied</title></head>
142
+ <body style="background:#0a0a0a;color:#ff4444;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;">
143
+ <h2>ACCESS DENIED</h2>
144
+ <p style="color:#666;">Your email is not authorized to access this dashboard.</p>
145
+ <a href="/auth/google" style="color:#00ff88;margin-top:20px;">Try another account</a>
146
+ </body></html>`);
147
+ });
148
+
149
+ router.get("/logout", (req, res) => {
150
+ req.logout(() => {
151
+ res.redirect("/");
152
+ });
153
+ });
154
+
155
+ router.get("/me", (req, res) => {
156
+ if (req.isAuthenticated()) {
157
+ const user = req.user as User;
158
+ res.json({ id: user.id, email: user.email, name: user.name, avatar_url: user.avatar_url });
159
+ } else {
160
+ res.status(401).json({ error: "Not authenticated" });
161
+ }
162
+ });
163
+
164
+ return router;
165
+ }
@@ -0,0 +1,136 @@
1
+ import { getDb } from "./db.js";
2
+
3
+ export interface WorkspaceState {
4
+ name: string;
5
+ status: "idle" | "working" | "done" | "failed" | "paused";
6
+ last_task?: string;
7
+ pending?: string[];
8
+ session_id?: string;
9
+ last_updated?: string;
10
+ }
11
+
12
+ export interface CoordinatorState {
13
+ session_id: string;
14
+ started_at: string;
15
+ last_updated: string;
16
+ workspaces: Record<string, WorkspaceState>;
17
+ pending_tasks: string[];
18
+ decisions: string[];
19
+ notes: string;
20
+ }
21
+
22
+ export interface ContextSnapshot {
23
+ timestamp: string;
24
+ summary: string;
25
+ key_decisions: string[];
26
+ workspace_states: Record<string, WorkspaceState>;
27
+ pending_work: string[];
28
+ }
29
+
30
+ export function saveCoordinatorState(
31
+ sessionId: string,
32
+ partialState: Partial<Omit<CoordinatorState, "session_id">>
33
+ ): CoordinatorState {
34
+ const d = getDb();
35
+ const existing = getCoordinatorState(sessionId);
36
+ const now = new Date().toISOString();
37
+
38
+ const merged: CoordinatorState = existing
39
+ ? {
40
+ ...existing,
41
+ last_updated: now,
42
+ workspaces: { ...existing.workspaces, ...(partialState.workspaces || {}) },
43
+ pending_tasks: partialState.pending_tasks ?? existing.pending_tasks,
44
+ decisions: partialState.decisions
45
+ ? [...existing.decisions, ...partialState.decisions.filter(d => !existing.decisions.includes(d))]
46
+ : existing.decisions,
47
+ notes: partialState.notes ?? existing.notes,
48
+ }
49
+ : {
50
+ session_id: sessionId,
51
+ started_at: now,
52
+ last_updated: now,
53
+ workspaces: partialState.workspaces || {},
54
+ pending_tasks: partialState.pending_tasks || [],
55
+ decisions: partialState.decisions || [],
56
+ notes: partialState.notes || "",
57
+ };
58
+
59
+ const stateJson = JSON.stringify(merged);
60
+
61
+ if (existing) {
62
+ d.prepare(`
63
+ UPDATE coordinator_state SET state = ?, updated_at = datetime('now') WHERE session_id = ?
64
+ `).run(stateJson, sessionId);
65
+ } else {
66
+ d.prepare(`
67
+ INSERT INTO coordinator_state (session_id, state) VALUES (?, ?)
68
+ `).run(sessionId, stateJson);
69
+ }
70
+
71
+ return merged;
72
+ }
73
+
74
+ export function getCoordinatorState(sessionId: string): CoordinatorState | null {
75
+ const d = getDb();
76
+ const row = d.prepare("SELECT state FROM coordinator_state WHERE session_id = ?").get(sessionId) as { state: string } | undefined;
77
+ if (!row) return null;
78
+ try {
79
+ return JSON.parse(row.state) as CoordinatorState;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ export function getLatestCoordinatorSession(): CoordinatorState | null {
86
+ const d = getDb();
87
+ const row = d.prepare("SELECT state FROM coordinator_state ORDER BY updated_at DESC LIMIT 1").get() as { state: string } | undefined;
88
+ if (!row) return null;
89
+ try {
90
+ return JSON.parse(row.state) as CoordinatorState;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export function listCoordinatorSessions(limit: number = 5): CoordinatorState[] {
97
+ const d = getDb();
98
+ const rows = d.prepare(
99
+ "SELECT state FROM coordinator_state ORDER BY updated_at DESC LIMIT ?"
100
+ ).all(limit) as Array<{ state: string }>;
101
+
102
+ return rows
103
+ .map(r => {
104
+ try { return JSON.parse(r.state) as CoordinatorState; }
105
+ catch { return null; }
106
+ })
107
+ .filter(Boolean) as CoordinatorState[];
108
+ }
109
+
110
+ export function compactCoordinatorContext(
111
+ sessionId: string,
112
+ context: ContextSnapshot
113
+ ): void {
114
+ const d = getDb();
115
+ const entry: ContextSnapshot = {
116
+ ...context,
117
+ timestamp: new Date().toISOString(),
118
+ };
119
+ d.prepare(`
120
+ INSERT INTO coordinator_history (session_id, snapshot) VALUES (?, ?)
121
+ `).run(sessionId, JSON.stringify(entry));
122
+ }
123
+
124
+ export function getCoordinatorHistory(sessionId: string): ContextSnapshot[] {
125
+ const d = getDb();
126
+ const rows = d.prepare(
127
+ "SELECT snapshot FROM coordinator_history WHERE session_id = ? ORDER BY created_at ASC"
128
+ ).all(sessionId) as Array<{ snapshot: string }>;
129
+
130
+ return rows
131
+ .map(r => {
132
+ try { return JSON.parse(r.snapshot) as ContextSnapshot; }
133
+ catch { return null; }
134
+ })
135
+ .filter(Boolean) as ContextSnapshot[];
136
+ }
@@ -0,0 +1,5 @@
1
+ import { startDashboard } from "./dashboard.js";
2
+
3
+ // Standalone dashboard entry point — runs as a persistent container
4
+ startDashboard();
5
+ console.log("Dashboard running at http://localhost:3333");