@online5880/opensession 0.1.0 → 0.1.3

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/src/supabase.js CHANGED
@@ -1,119 +1,309 @@
1
- import { createClient } from '@supabase/supabase-js';
2
-
3
- export function getClient(config) {
4
- if (!config.supabaseUrl || !config.supabaseAnonKey) {
5
- throw new Error('Supabase is not configured. Run `opensession init --url <url> --anon-key <key>`.');
6
- }
7
-
8
- return createClient(config.supabaseUrl, config.supabaseAnonKey, {
9
- auth: { persistSession: false }
10
- });
11
- }
12
-
13
- export async function ensureProject(client, projectKey, projectName) {
14
- const { data: existing, error: lookupError } = await client
15
- .from('projects')
16
- .select('id,project_key,name')
17
- .eq('project_key', projectKey)
18
- .maybeSingle();
19
-
20
- if (lookupError) {
21
- throw lookupError;
22
- }
23
-
24
- if (existing) {
25
- return existing;
26
- }
27
-
28
- const { data: created, error: insertError } = await client
29
- .from('projects')
30
- .insert({ project_key: projectKey, name: projectName ?? projectKey })
31
- .select('id,project_key,name')
32
- .single();
33
-
34
- if (insertError) {
35
- throw insertError;
36
- }
37
-
38
- return created;
39
- }
40
-
41
- export async function startSession(client, projectId, actor) {
42
- const { data: session, error: sessionError } = await client
43
- .from('sessions')
44
- .insert({ project_id: projectId, actor, status: 'active', started_at: new Date().toISOString() })
45
- .select('id,project_id,actor,status,started_at')
46
- .single();
47
-
48
- if (sessionError) {
49
- throw sessionError;
50
- }
51
-
52
- const { error: eventError } = await client
53
- .from('session_events')
54
- .insert({ session_id: session.id, type: 'started', payload: { actor } });
55
-
56
- if (eventError) {
57
- throw eventError;
58
- }
59
-
60
- return session;
61
- }
62
-
63
- export async function appendEvent(client, sessionId, type, payload = {}) {
64
- const { data, error } = await client
65
- .from('session_events')
66
- .insert({ session_id: sessionId, type, payload })
67
- .select('id,session_id,type,payload,created_at')
68
- .single();
69
-
70
- if (error) {
71
- throw error;
72
- }
73
-
74
- return data;
75
- }
76
-
77
- export async function getSession(client, sessionId) {
78
- const { data, error } = await client
79
- .from('sessions')
80
- .select('id,project_id,actor,status,started_at,ended_at')
81
- .eq('id', sessionId)
82
- .maybeSingle();
83
-
84
- if (error) {
85
- throw error;
86
- }
87
-
88
- return data;
89
- }
90
-
91
- export async function listActiveSessions(client, projectId) {
92
- const { data, error } = await client
93
- .from('sessions')
94
- .select('id,project_id,actor,status,started_at,ended_at')
95
- .eq('project_id', projectId)
96
- .eq('status', 'active')
97
- .order('started_at', { ascending: false });
98
-
99
- if (error) {
100
- throw error;
101
- }
102
-
103
- return data;
104
- }
105
-
106
- export async function getSessionEvents(client, sessionId, limit = 50) {
107
- const { data, error } = await client
108
- .from('session_events')
109
- .select('id,session_id,type,payload,created_at')
110
- .eq('session_id', sessionId)
111
- .order('created_at', { ascending: true })
112
- .limit(limit);
113
-
114
- if (error) {
115
- throw error;
116
- }
117
-
118
- return data;
119
- }
1
+ import { createClient } from '@supabase/supabase-js';
2
+
3
+ const DEFAULT_RETRY_ATTEMPTS = 3;
4
+ const BASE_BACKOFF_MS = 120;
5
+ const MAX_BACKOFF_MS = 1500;
6
+
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => {
9
+ setTimeout(resolve, ms);
10
+ });
11
+ }
12
+
13
+ function shouldRetry(error) {
14
+ if (!error) {
15
+ return false;
16
+ }
17
+ const status = Number(error.status ?? error.statusCode ?? 0);
18
+ const code = String(error.code ?? '').toUpperCase();
19
+ const message = String(error.message ?? '').toLowerCase();
20
+
21
+ if (status >= 500 || status === 408 || status === 429) {
22
+ return true;
23
+ }
24
+ if (code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'EAI_AGAIN') {
25
+ return true;
26
+ }
27
+ if (message.includes('network') || message.includes('timeout') || message.includes('fetch failed')) {
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ function isUniqueViolation(error) {
34
+ if (!error) {
35
+ return false;
36
+ }
37
+ const code = String(error.code ?? '').toUpperCase();
38
+ const status = Number(error.status ?? error.statusCode ?? 0);
39
+ return code === '23505' || status === 409;
40
+ }
41
+
42
+ async function withRetry(taskName, fn, attempts = DEFAULT_RETRY_ATTEMPTS) {
43
+ let attempt = 0;
44
+ let lastError = null;
45
+ while (attempt < attempts) {
46
+ attempt += 1;
47
+ try {
48
+ return await fn();
49
+ } catch (error) {
50
+ lastError = error;
51
+ if (attempt >= attempts || !shouldRetry(error)) {
52
+ throw error;
53
+ }
54
+ const exponential = BASE_BACKOFF_MS * (2 ** (attempt - 1));
55
+ const jitter = Math.floor(Math.random() * 80);
56
+ const backoffMs = Math.min(MAX_BACKOFF_MS, exponential + jitter);
57
+ await sleep(backoffMs);
58
+ }
59
+ }
60
+
61
+ throw lastError ?? new Error(`${taskName} failed`);
62
+ }
63
+
64
+ export function getClient(config) {
65
+ if (!config.supabaseUrl || !config.supabaseAnonKey) {
66
+ throw new Error('Supabase is not configured. Run `opensession init` first.');
67
+ }
68
+
69
+ return createClient(config.supabaseUrl, config.supabaseAnonKey, {
70
+ auth: { persistSession: false }
71
+ });
72
+ }
73
+
74
+ export async function ensureProject(client, projectKey, projectName) {
75
+ const { data: existing, error: lookupError } = await withRetry('ensureProject.lookup', () =>
76
+ client
77
+ .from('projects')
78
+ .select('id,project_key,name')
79
+ .eq('project_key', projectKey)
80
+ .maybeSingle()
81
+ );
82
+
83
+ if (lookupError) {
84
+ throw lookupError;
85
+ }
86
+
87
+ if (existing) {
88
+ return existing;
89
+ }
90
+
91
+ const { data: created, error: insertError } = await withRetry('ensureProject.insert', () =>
92
+ client
93
+ .from('projects')
94
+ .insert({ project_key: projectKey, name: projectName ?? projectKey })
95
+ .select('id,project_key,name')
96
+ .single()
97
+ );
98
+
99
+ if (insertError) {
100
+ throw insertError;
101
+ }
102
+
103
+ return created;
104
+ }
105
+
106
+ export async function startSession(client, projectId, actor, options = {}) {
107
+ const operationId = options.operationId ?? `start-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
108
+ const { data: session, error: sessionError } = await withRetry('startSession.insert', () =>
109
+ client
110
+ .from('sessions')
111
+ .insert({ project_id: projectId, actor, status: 'active', started_at: new Date().toISOString() })
112
+ .select('id,project_id,actor,status,started_at')
113
+ .single()
114
+ );
115
+
116
+ if (sessionError) {
117
+ throw sessionError;
118
+ }
119
+
120
+ const { error: eventError } = await withRetry('startSession.appendStartedEvent', () =>
121
+ client
122
+ .from('session_events')
123
+ .insert({
124
+ session_id: session.id,
125
+ type: 'started',
126
+ payload: {
127
+ actor,
128
+ idempotencyKey: `${operationId}:started`
129
+ }
130
+ })
131
+ );
132
+
133
+ if (eventError) {
134
+ throw eventError;
135
+ }
136
+
137
+ return session;
138
+ }
139
+
140
+ export async function appendEvent(client, sessionId, type, payload = {}, options = {}) {
141
+ const normalizedIdempotencyKey = typeof options.idempotencyKey === 'string' && options.idempotencyKey.trim().length > 0
142
+ ? options.idempotencyKey.trim()
143
+ : null;
144
+ const nextPayload = normalizedIdempotencyKey ? { ...payload, idempotencyKey: normalizedIdempotencyKey } : payload;
145
+ const { data, error } = await withRetry('appendEvent.insert', () =>
146
+ client
147
+ .from('session_events')
148
+ .insert({
149
+ session_id: sessionId,
150
+ type,
151
+ idempotency_key: normalizedIdempotencyKey,
152
+ payload: nextPayload
153
+ })
154
+ .select('id,session_id,type,payload,created_at')
155
+ .single()
156
+ );
157
+
158
+ if (!error) {
159
+ return data;
160
+ }
161
+
162
+ if (!normalizedIdempotencyKey || !isUniqueViolation(error)) {
163
+ throw error;
164
+ }
165
+
166
+ const { data: existing, error: findError } = await withRetry('appendEvent.findExistingAfterConflict', () =>
167
+ client
168
+ .from('session_events')
169
+ .select('id,session_id,type,payload,created_at')
170
+ .eq('session_id', sessionId)
171
+ .eq('type', type)
172
+ .eq('idempotency_key', normalizedIdempotencyKey)
173
+ .limit(1)
174
+ .maybeSingle()
175
+ );
176
+
177
+ if (findError) {
178
+ throw findError;
179
+ }
180
+ if (!existing) {
181
+ throw error;
182
+ }
183
+
184
+ return existing;
185
+ }
186
+
187
+ export async function validateConnection(supabaseUrl, supabaseAnonKey) {
188
+ const client = createClient(supabaseUrl, supabaseAnonKey, {
189
+ auth: { persistSession: false }
190
+ });
191
+ const { error } = await withRetry('validateConnection', () =>
192
+ client.from('projects').select('id').limit(1)
193
+ );
194
+ if (error) {
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ export async function getSession(client, sessionId) {
200
+ const { data, error } = await withRetry('getSession', () =>
201
+ client
202
+ .from('sessions')
203
+ .select('id,project_id,actor,status,started_at,ended_at')
204
+ .eq('id', sessionId)
205
+ .maybeSingle()
206
+ );
207
+
208
+ if (error) {
209
+ throw error;
210
+ }
211
+
212
+ return data;
213
+ }
214
+
215
+ export async function listActiveSessions(client, projectId) {
216
+ const { data, error } = await withRetry('listActiveSessions', () =>
217
+ client
218
+ .from('sessions')
219
+ .select('id,project_id,actor,status,started_at,ended_at')
220
+ .eq('project_id', projectId)
221
+ .eq('status', 'active')
222
+ .order('started_at', { ascending: false })
223
+ );
224
+
225
+ if (error) {
226
+ throw error;
227
+ }
228
+
229
+ return data;
230
+ }
231
+
232
+ export async function getSessionEvents(client, sessionId, limit = 50, options = {}) {
233
+ const ascending = options.ascending !== false;
234
+ const { data, error } = await withRetry('getSessionEvents', () =>
235
+ client
236
+ .from('session_events')
237
+ .select('id,session_id,type,payload,created_at')
238
+ .eq('session_id', sessionId)
239
+ .order('created_at', { ascending })
240
+ .limit(limit)
241
+ );
242
+
243
+ if (error) {
244
+ throw error;
245
+ }
246
+
247
+ return data;
248
+ }
249
+
250
+ export async function listSessionEvents(client, sessionIds, options = {}) {
251
+ if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
252
+ return [];
253
+ }
254
+
255
+ let query = client
256
+ .from('session_events')
257
+ .select('id,session_id,type,payload,created_at')
258
+ .in('session_id', sessionIds)
259
+ .order('created_at', { ascending: options.ascending !== false });
260
+
261
+ if (options.since) {
262
+ query = query.gte('created_at', options.since);
263
+ }
264
+ if (options.until) {
265
+ query = query.lt('created_at', options.until);
266
+ }
267
+ if (Number.isInteger(options.limit) && options.limit > 0) {
268
+ query = query.limit(options.limit);
269
+ }
270
+
271
+ const { data, error } = await query;
272
+ if (error) {
273
+ throw error;
274
+ }
275
+ return data;
276
+ }
277
+
278
+ export async function listProjects(client, limit = 50) {
279
+ const { data, error } = await withRetry('listProjects', () =>
280
+ client
281
+ .from('projects')
282
+ .select('id,project_key,name,created_at')
283
+ .order('created_at', { ascending: false })
284
+ .limit(limit)
285
+ );
286
+
287
+ if (error) {
288
+ throw error;
289
+ }
290
+
291
+ return data;
292
+ }
293
+
294
+ export async function listSessions(client, projectId, limit = 100) {
295
+ const { data, error } = await withRetry('listSessions', () =>
296
+ client
297
+ .from('sessions')
298
+ .select('id,project_id,actor,status,started_at,ended_at')
299
+ .eq('project_id', projectId)
300
+ .order('started_at', { ascending: false })
301
+ .limit(limit)
302
+ );
303
+
304
+ if (error) {
305
+ throw error;
306
+ }
307
+
308
+ return data;
309
+ }
package/src/tui.js ADDED
@@ -0,0 +1,159 @@
1
+ import blessed from 'blessed';
2
+ import { listActiveSessions, getSessionEvents } from './supabase.js';
3
+
4
+ export async function startTui(client, options = {}) {
5
+ const screen = blessed.screen({
6
+ smartCSR: true,
7
+ title: 'OpenSession TUI Dashboard'
8
+ });
9
+
10
+ const layout = blessed.layout({
11
+ parent: screen,
12
+ top: 0,
13
+ left: 0,
14
+ width: '100%',
15
+ height: '100%'
16
+ });
17
+
18
+ const header = blessed.box({
19
+ parent: layout,
20
+ top: 0,
21
+ left: 0,
22
+ width: '100%',
23
+ height: 3,
24
+ content: ' OpenSession TUI Dashboard ',
25
+ style: {
26
+ fg: 'white',
27
+ bg: 'blue',
28
+ bold: true
29
+ },
30
+ border: {
31
+ type: 'line'
32
+ }
33
+ });
34
+
35
+ const sessionList = blessed.list({
36
+ parent: layout,
37
+ top: 3,
38
+ left: 0,
39
+ width: '30%',
40
+ height: '100%-3',
41
+ label: ' Active Sessions ',
42
+ border: {
43
+ type: 'line'
44
+ },
45
+ style: {
46
+ selected: {
47
+ bg: 'magenta'
48
+ },
49
+ border: {
50
+ fg: 'cyan'
51
+ }
52
+ },
53
+ keys: true,
54
+ mouse: true
55
+ });
56
+
57
+ const eventLog = blessed.log({
58
+ parent: layout,
59
+ top: 3,
60
+ left: '30%',
61
+ width: '70%',
62
+ height: '100%-3',
63
+ label: ' Session Events ',
64
+ border: {
65
+ type: 'line'
66
+ },
67
+ style: {
68
+ border: {
69
+ fg: 'yellow'
70
+ }
71
+ },
72
+ scrollable: true,
73
+ alwaysScroll: true,
74
+ mouse: true
75
+ });
76
+
77
+ const footer = blessed.box({
78
+ parent: screen,
79
+ bottom: 0,
80
+ left: 0,
81
+ width: '100%',
82
+ height: 1,
83
+ content: ' [R] Refresh | [Q] Quit | [↑/↓] Select Session ',
84
+ style: {
85
+ fg: 'black',
86
+ bg: 'white'
87
+ }
88
+ });
89
+
90
+ let sessions = [];
91
+ let selectedSessionId = null;
92
+ let refreshInterval = null;
93
+
94
+ async function refreshSessions() {
95
+ try {
96
+ header.setContent(` Loading sessions... `);
97
+ screen.render();
98
+
99
+ const activeSessions = await listActiveSessions(client);
100
+ sessions = activeSessions || [];
101
+
102
+ sessionList.setItems(sessions.map(s => `${s.actor} (${s.id.slice(0, 8)})`));
103
+ header.setContent(` OpenSession TUI Dashboard | Total: ${sessions.length} `);
104
+ screen.render();
105
+ } catch (error) {
106
+ header.setContent(` Error: ${error.message} `);
107
+ screen.render();
108
+ }
109
+ }
110
+
111
+ async function refreshEvents() {
112
+ if (!selectedSessionId) return;
113
+
114
+ try {
115
+ const events = await getSessionEvents(client, selectedSessionId);
116
+ const currentScroll = eventLog.childBase;
117
+
118
+ eventLog.clear();
119
+ if (events && events.length > 0) {
120
+ events.forEach(e => {
121
+ eventLog.log(`[${new Date(e.created_at).toLocaleTimeString()}] ${e.type}: ${JSON.stringify(e.payload)}`);
122
+ });
123
+ } else {
124
+ eventLog.log(' No events found for this session.');
125
+ }
126
+
127
+ // Maintain scroll position if needed or scroll to bottom
128
+ eventLog.setScroll(currentScroll);
129
+ screen.render();
130
+ } catch (error) {
131
+ eventLog.log(` Auto-refresh error: ${error.message}`);
132
+ }
133
+ }
134
+
135
+ sessionList.on('select', async (item, index) => {
136
+ const session = sessions[index];
137
+ if (!session) return;
138
+
139
+ selectedSessionId = session.id;
140
+ eventLog.setContent(` Loading events for ${session.id}... \n`);
141
+ screen.render();
142
+
143
+ if (refreshInterval) clearInterval(refreshInterval);
144
+
145
+ await refreshEvents();
146
+
147
+ // Set up auto-refresh every 5 seconds for the selected session
148
+ refreshInterval = setInterval(() => refreshEvents(), 5000);
149
+ });
150
+
151
+ screen.key(['escape', 'q', 'C-c'], () => {
152
+ if (refreshInterval) clearInterval(refreshInterval);
153
+ process.exit(0);
154
+ });
155
+ screen.key(['r'], () => refreshSessions());
156
+
157
+ await refreshSessions();
158
+ screen.render();
159
+ }