agentdev-webui 1.0.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 (39) hide show
  1. package/lib/agent-api.js +530 -0
  2. package/lib/auth.js +127 -0
  3. package/lib/config.js +53 -0
  4. package/lib/database.js +762 -0
  5. package/lib/device-flow.js +257 -0
  6. package/lib/email.js +420 -0
  7. package/lib/encryption.js +112 -0
  8. package/lib/github.js +339 -0
  9. package/lib/history.js +143 -0
  10. package/lib/pwa.js +107 -0
  11. package/lib/redis-logs.js +226 -0
  12. package/lib/routes.js +680 -0
  13. package/migrations/000_create_database.sql +33 -0
  14. package/migrations/001_create_agentdev_schema.sql +135 -0
  15. package/migrations/001_create_agentdev_schema.sql.old +100 -0
  16. package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
  17. package/migrations/002_add_github_token.sql +17 -0
  18. package/migrations/003_add_agent_logs_table.sql +23 -0
  19. package/migrations/004_remove_oauth_columns.sql +11 -0
  20. package/migrations/005_add_projects.sql +44 -0
  21. package/migrations/006_project_github_token.sql +7 -0
  22. package/migrations/007_project_repositories.sql +12 -0
  23. package/migrations/008_add_notifications.sql +20 -0
  24. package/migrations/009_unified_oauth.sql +153 -0
  25. package/migrations/README.md +97 -0
  26. package/package.json +37 -0
  27. package/public/css/styles.css +1140 -0
  28. package/public/device.html +384 -0
  29. package/public/docs.html +862 -0
  30. package/public/docs.md +697 -0
  31. package/public/favicon.svg +5 -0
  32. package/public/index.html +271 -0
  33. package/public/js/app.js +2379 -0
  34. package/public/login.html +224 -0
  35. package/public/profile.html +394 -0
  36. package/public/register.html +392 -0
  37. package/public/reset-password.html +349 -0
  38. package/public/verify-email.html +177 -0
  39. package/server.js +1450 -0
@@ -0,0 +1,112 @@
1
+ const crypto = require('crypto');
2
+ const config = require('./config');
3
+
4
+ const ALGORITHM = 'aes-256-gcm';
5
+ const IV_LENGTH = 16;
6
+ const AUTH_TAG_LENGTH = 16;
7
+
8
+ /**
9
+ * Get encryption key from config or environment
10
+ * Must be 32 bytes (64 hex chars)
11
+ */
12
+ function getEncryptionKey() {
13
+ const key = config.ENCRYPTION_KEY;
14
+ if (!key) {
15
+ throw new Error('ENCRYPTION_SECRET_KEY not configured');
16
+ }
17
+
18
+ // If hex string, convert to buffer
19
+ if (key.length === 64) {
20
+ return Buffer.from(key, 'hex');
21
+ }
22
+
23
+ // Otherwise hash it to get 32 bytes
24
+ return crypto.createHash('sha256').update(key).digest();
25
+ }
26
+
27
+ /**
28
+ * Encrypt OAuth secrets
29
+ * Returns: base64 encoded string with format: iv.authTag.encryptedData
30
+ */
31
+ function encrypt(text) {
32
+ if (!text) return null;
33
+
34
+ const key = getEncryptionKey();
35
+ const iv = crypto.randomBytes(IV_LENGTH);
36
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
37
+
38
+ let encrypted = cipher.update(text, 'utf8', 'hex');
39
+ encrypted += cipher.final('hex');
40
+
41
+ const authTag = cipher.getAuthTag();
42
+
43
+ // Combine: iv.authTag.encrypted
44
+ const combined = Buffer.concat([
45
+ iv,
46
+ authTag,
47
+ Buffer.from(encrypted, 'hex')
48
+ ]);
49
+
50
+ return combined.toString('base64');
51
+ }
52
+
53
+ /**
54
+ * Decrypt OAuth secrets
55
+ */
56
+ function decrypt(encryptedData) {
57
+ if (!encryptedData) return null;
58
+
59
+ try {
60
+ const key = getEncryptionKey();
61
+ const combined = Buffer.from(encryptedData, 'base64');
62
+
63
+ // Extract components
64
+ const iv = combined.slice(0, IV_LENGTH);
65
+ const authTag = combined.slice(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
66
+ const encrypted = combined.slice(IV_LENGTH + AUTH_TAG_LENGTH);
67
+
68
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
69
+ decipher.setAuthTag(authTag);
70
+
71
+ let decrypted = decipher.update(encrypted, null, 'utf8');
72
+ decrypted += decipher.final('utf8');
73
+
74
+ return decrypted;
75
+ } catch (error) {
76
+ console.error('Decryption failed:', error.message);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Encrypt OAuth configuration for storage
83
+ */
84
+ function encryptOAuthConfig(config) {
85
+ if (!config) return null;
86
+
87
+ return {
88
+ clientId: config.clientId,
89
+ clientSecretEncrypted: encrypt(config.clientSecret),
90
+ scopes: config.scopes || ['repo', 'read:org', 'project']
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Decrypt OAuth configuration
96
+ */
97
+ function decryptOAuthConfig(encryptedConfig) {
98
+ if (!encryptedConfig) return null;
99
+
100
+ return {
101
+ clientId: encryptedConfig.clientId,
102
+ clientSecret: decrypt(encryptedConfig.clientSecretEncrypted),
103
+ scopes: encryptedConfig.scopes || ['repo', 'read:org', 'project']
104
+ };
105
+ }
106
+
107
+ module.exports = {
108
+ encrypt,
109
+ decrypt,
110
+ encryptOAuthConfig,
111
+ decryptOAuthConfig
112
+ };
package/lib/github.js ADDED
@@ -0,0 +1,339 @@
1
+ const config = require('./config');
2
+
3
+ const ORG = config.GITHUB_ORG; // 'data-tamer'
4
+ const ticketCache = new Map();
5
+
6
+ // Default token management
7
+ let defaultToken = process.env.GITHUB_DEFAULT_TOKEN || process.env.GH_TOKEN || null;
8
+
9
+ function setDefaultToken(t) { defaultToken = t; }
10
+
11
+ function getToken(provided) { return provided || defaultToken; }
12
+
13
+ // --- REST + GraphQL helpers ---
14
+
15
+ async function ghRest(method, path, token, body = null) {
16
+ const opts = {
17
+ method,
18
+ headers: {
19
+ 'Authorization': `Bearer ${token}`,
20
+ 'Accept': 'application/vnd.github+json',
21
+ 'X-GitHub-Api-Version': '2022-11-28'
22
+ }
23
+ };
24
+ if (body) {
25
+ opts.headers['Content-Type'] = 'application/json';
26
+ opts.body = JSON.stringify(body);
27
+ }
28
+ const res = await fetch(`https://api.github.com${path}`, opts);
29
+ if (!res.ok) {
30
+ const text = await res.text();
31
+ throw new Error(`GitHub API ${method} ${path} failed (${res.status}): ${text}`);
32
+ }
33
+ if (res.status === 204) return null;
34
+ return res.json();
35
+ }
36
+
37
+ async function ghGraphQL(query, variables, token) {
38
+ const res = await fetch('https://api.github.com/graphql', {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Authorization': `Bearer ${token}`,
42
+ 'Content-Type': 'application/json'
43
+ },
44
+ body: JSON.stringify({ query, variables })
45
+ });
46
+ if (!res.ok) {
47
+ const text = await res.text();
48
+ throw new Error(`GitHub GraphQL failed (${res.status}): ${text}`);
49
+ }
50
+ const json = await res.json();
51
+ if (json.errors) {
52
+ throw new Error(`GitHub GraphQL errors: ${JSON.stringify(json.errors)}`);
53
+ }
54
+ return json.data;
55
+ }
56
+
57
+ // --- Exported functions ---
58
+
59
+ async function fetchTicketDetails(repo, ticket, projectConfig = null) {
60
+ const org = projectConfig?.github_org || ORG;
61
+ const cacheKey = `${org}/${repo}/${ticket}`;
62
+ const cached = ticketCache.get(cacheKey);
63
+ if (cached && Date.now() - cached.timestamp < config.CACHE_TTL) {
64
+ return cached.data;
65
+ }
66
+
67
+ try {
68
+ const token = getToken(null);
69
+ if (!token) return null;
70
+
71
+ const issue = await ghRest('GET', `/repos/${org}/${repo}/issues/${ticket}`, token);
72
+ const data = {
73
+ title: issue.title,
74
+ body: issue.body,
75
+ state: issue.state.toUpperCase(),
76
+ author: issue.user ? { login: issue.user.login, avatarUrl: issue.user.avatar_url } : null,
77
+ labels: (issue.labels || []).map(l => typeof l === 'string' ? { name: l } : l),
78
+ createdAt: issue.created_at
79
+ };
80
+ ticketCache.set(cacheKey, { data, timestamp: Date.now() });
81
+ return data;
82
+ } catch (e) {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function createTicket(repo, title, body, githubToken = null, projectConfig = null) {
88
+ const token = getToken(githubToken);
89
+ const org = projectConfig?.github_org || ORG;
90
+ const issue = await ghRest('POST', `/repos/${org}/${repo}/issues`, token, { title, body });
91
+ return { number: issue.number, url: issue.html_url, nodeId: issue.node_id };
92
+ }
93
+
94
+ async function addToProject(repo, issueNumber, githubToken = null, projectConfig = null) {
95
+ try {
96
+ const token = getToken(githubToken);
97
+ const org = projectConfig?.github_org || ORG;
98
+ const projectId = projectConfig?.github_project_id || config.PROJECT_ID;
99
+ const statusOptions = projectConfig?.status_options || config.STATUS_OPTIONS;
100
+
101
+ // Get issue node_id via REST
102
+ const issue = await ghRest('GET', `/repos/${org}/${repo}/issues/${issueNumber}`, token);
103
+ const contentId = issue.node_id;
104
+
105
+ // Add to project via GraphQL
106
+ const data = await ghGraphQL(
107
+ `mutation($projectId: ID!, $contentId: ID!) {
108
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
109
+ item { id }
110
+ }
111
+ }`,
112
+ { projectId, contentId },
113
+ token
114
+ );
115
+
116
+ const itemId = data.addProjectV2ItemById.item.id;
117
+
118
+ // Set status to Todo
119
+ const todoOptionId = statusOptions.TODO || statusOptions.todo;
120
+ await moveToStatus(itemId, todoOptionId, githubToken, projectConfig);
121
+ console.log(`Set issue #${issueNumber} to Todo status (item: ${itemId})`);
122
+
123
+ return true;
124
+ } catch (e) {
125
+ console.error('Failed to add to project:', e.message);
126
+ return false;
127
+ }
128
+ }
129
+
130
+ async function getIssueState(repo, issue, githubToken = null, projectConfig = null) {
131
+ try {
132
+ const token = getToken(githubToken);
133
+ const org = projectConfig?.github_org || ORG;
134
+ const data = await ghRest('GET', `/repos/${org}/${repo}/issues/${issue}`, token);
135
+ return data.state.toUpperCase();
136
+ } catch (e) {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ async function reopenIssue(repo, issue, githubToken = null, projectConfig = null) {
142
+ const token = getToken(githubToken);
143
+ const org = projectConfig?.github_org || ORG;
144
+ await ghRest('PATCH', `/repos/${org}/${repo}/issues/${issue}`, token, { state: 'open' });
145
+ }
146
+
147
+ async function addComment(repo, issue, comment, githubToken = null, projectConfig = null) {
148
+ const token = getToken(githubToken);
149
+ const org = projectConfig?.github_org || ORG;
150
+ await ghRest('POST', `/repos/${org}/${repo}/issues/${issue}/comments`, token, { body: comment });
151
+ }
152
+
153
+ async function updateIssue(repo, issue, updates, githubToken = null, projectConfig = null) {
154
+ const token = getToken(githubToken);
155
+ const org = projectConfig?.github_org || ORG;
156
+ return await ghRest('PATCH', `/repos/${org}/${repo}/issues/${issue}`, token, updates);
157
+ }
158
+
159
+ async function getProjectItemId(repo, issue, githubToken = null, projectConfig = null) {
160
+ try {
161
+ const token = getToken(githubToken);
162
+ const org = projectConfig?.github_org || ORG;
163
+ const projectNumber = projectConfig?.project_number || config.PROJECT_NUMBER;
164
+ const data = await ghGraphQL(
165
+ `query {
166
+ organization(login: "${org}") {
167
+ projectV2(number: ${projectNumber}) {
168
+ items(first: 100, orderBy: {field: POSITION, direction: DESC}) {
169
+ nodes {
170
+ id
171
+ content {
172
+ ... on Issue {
173
+ number
174
+ repository { name }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }`,
182
+ {},
183
+ token
184
+ );
185
+
186
+ const nodes = data.organization.projectV2.items.nodes;
187
+ const match = nodes.find(
188
+ n => n.content && n.content.number === issue && n.content.repository?.name === repo
189
+ );
190
+ return match ? match.id : null;
191
+ } catch (e) {
192
+ console.error('Failed to get project item ID:', e.message);
193
+ return null;
194
+ }
195
+ }
196
+
197
+ async function moveToStatus(itemId, statusOptionId, githubToken = null, projectConfig = null) {
198
+ try {
199
+ const token = getToken(githubToken);
200
+ const projectId = projectConfig?.github_project_id || config.PROJECT_ID;
201
+ const fieldId = projectConfig?.status_field_id || config.STATUS_FIELD_ID;
202
+ await ghGraphQL(
203
+ `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
204
+ updateProjectV2ItemFieldValue(input: {
205
+ projectId: $projectId
206
+ itemId: $itemId
207
+ fieldId: $fieldId
208
+ value: { singleSelectOptionId: $optionId }
209
+ }) {
210
+ projectV2Item { id }
211
+ }
212
+ }`,
213
+ {
214
+ projectId,
215
+ itemId,
216
+ fieldId,
217
+ optionId: statusOptionId
218
+ },
219
+ token
220
+ );
221
+ return true;
222
+ } catch (e) {
223
+ console.error('Failed to move status:', e.message);
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // Per-project cache: Map<cacheKey, { data, timestamp }>
229
+ const projectTicketCaches = new Map();
230
+ const TRACKED_STATUSES = ['Todo', 'In Progress', 'test', 'Done'];
231
+
232
+ async function fetchProjectTickets(projectConfig = null, githubToken = null, { claudeOnly = true } = {}) {
233
+ const org = projectConfig?.github_org || ORG;
234
+ const projectNumber = projectConfig?.project_number || config.PROJECT_NUMBER;
235
+ const cacheKey = `${org}/${projectNumber}`;
236
+ const cached = projectTicketCaches.get(cacheKey);
237
+ if (cached && Date.now() - cached.timestamp < 8000) {
238
+ return cached.data;
239
+ }
240
+
241
+ try {
242
+ const token = getToken(githubToken);
243
+ if (!token) return (cached && cached.data) || [];
244
+
245
+ const data = await ghGraphQL(
246
+ `query {
247
+ organization(login: "${org}") {
248
+ projectV2(number: ${projectNumber}) {
249
+ items(first: 100, orderBy: {field: POSITION, direction: DESC}) {
250
+ nodes {
251
+ id
252
+ fieldValueByName(name: "Status") {
253
+ ... on ProjectV2ItemFieldSingleSelectValue { name }
254
+ }
255
+ content {
256
+ ... on Issue {
257
+ number
258
+ title
259
+ body
260
+ state
261
+ repository { name }
262
+ author { login avatarUrl }
263
+ createdAt
264
+ comments(last: 50) {
265
+ nodes { body author { login avatarUrl } createdAt }
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }`,
274
+ {},
275
+ token
276
+ );
277
+
278
+ const nodes = data.organization.projectV2.items.nodes;
279
+ const tickets = nodes
280
+ .filter(n => {
281
+ const status = n.fieldValueByName?.name;
282
+ if (!status || !TRACKED_STATUSES.includes(status)) return false;
283
+ if (!n.content) return false;
284
+ if (claudeOnly) {
285
+ const bodyHasClaude = n.content.body?.includes('@claude');
286
+ const commentHasClaude = n.content.comments?.nodes?.some(c => c.body?.includes('@claude'));
287
+ if (!bodyHasClaude && !commentHasClaude) return false;
288
+ }
289
+ return true;
290
+ })
291
+ .map(n => {
292
+ const bodyHasClaude = n.content.body?.includes('@claude');
293
+ const commentHasClaude = n.content.comments?.nodes?.some(c => c.body?.includes('@claude'));
294
+ return {
295
+ projectItemId: n.id,
296
+ number: n.content.number,
297
+ repo: n.content.repository?.name,
298
+ title: n.content.title,
299
+ body: n.content.body || '',
300
+ state: n.content.state,
301
+ status: n.fieldValueByName.name,
302
+ author: n.content.author?.login,
303
+ authorAvatar: n.content.author?.avatarUrl,
304
+ createdAt: n.content.createdAt,
305
+ hasClaude: bodyHasClaude || commentHasClaude,
306
+ comments: (n.content.comments?.nodes || []).map(c => ({
307
+ body: c.body,
308
+ author: c.author?.login,
309
+ authorAvatar: c.author?.avatarUrl,
310
+ createdAt: c.createdAt
311
+ }))
312
+ };
313
+ });
314
+ projectTicketCaches.set(cacheKey, { data: tickets, timestamp: Date.now() });
315
+ return tickets;
316
+ } catch (e) {
317
+ console.error('Failed to fetch project tickets:', e.message);
318
+ return (cached && cached.data) || [];
319
+ }
320
+ }
321
+
322
+ function clearTicketCache() {
323
+ projectTicketCaches.clear();
324
+ }
325
+
326
+ module.exports = {
327
+ fetchTicketDetails,
328
+ createTicket,
329
+ addToProject,
330
+ getIssueState,
331
+ reopenIssue,
332
+ addComment,
333
+ updateIssue,
334
+ getProjectItemId,
335
+ moveToStatus,
336
+ fetchProjectTickets,
337
+ clearTicketCache,
338
+ setDefaultToken
339
+ };
package/lib/history.js ADDED
@@ -0,0 +1,143 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const config = require('./config');
4
+
5
+ let agentHistory = [];
6
+
7
+ function loadHistory() {
8
+ try {
9
+ if (fs.existsSync(config.HISTORY_FILE)) {
10
+ agentHistory = JSON.parse(fs.readFileSync(config.HISTORY_FILE, 'utf8'));
11
+ console.log(`Loaded ${agentHistory.length} history entries`);
12
+ }
13
+ } catch (e) {
14
+ console.error('Error loading history:', e);
15
+ agentHistory = [];
16
+ }
17
+ }
18
+
19
+ function saveHistory() {
20
+ try {
21
+ fs.writeFileSync(config.HISTORY_FILE, JSON.stringify(agentHistory, null, 2));
22
+ } catch (e) {
23
+ console.error('Error saving history:', e);
24
+ }
25
+ }
26
+
27
+ function addToHistory(agent) {
28
+ const exists = agentHistory.find(a => a.id === agent.id);
29
+ if (exists) return;
30
+
31
+ agentHistory.unshift({
32
+ id: agent.id,
33
+ ticket: agent.ticket,
34
+ repo: agent.repo,
35
+ title: agent.title,
36
+ description: agent.description,
37
+ ticketStatus: agent.ticketStatus,
38
+ authorName: agent.authorName,
39
+ authorAvatar: agent.authorAvatar,
40
+ createdAt: agent.createdAt,
41
+ startTime: agent.startTime,
42
+ endTime: new Date().toISOString(),
43
+ completedAt: new Date().toLocaleString()
44
+ });
45
+
46
+ if (agentHistory.length > config.MAX_HISTORY) {
47
+ agentHistory = agentHistory.slice(0, config.MAX_HISTORY);
48
+ }
49
+
50
+ saveHistory();
51
+ }
52
+
53
+ function getHistory() {
54
+ return agentHistory;
55
+ }
56
+
57
+ function importExistingLogs() {
58
+ if (!fs.existsSync(config.AGENT_LOGS_DIR)) return;
59
+
60
+ const existingIds = new Set(agentHistory.map(a => a.id));
61
+ const locks = fs.existsSync('/tmp/auto-ticket-locks')
62
+ ? fs.readdirSync('/tmp/auto-ticket-locks').filter(f => f.startsWith('ticket-'))
63
+ : [];
64
+ const activeIds = new Set();
65
+
66
+ for (const lock of locks) {
67
+ try {
68
+ const agentId = fs.readFileSync(path.join('/tmp/auto-ticket-locks', lock), 'utf8').trim();
69
+ activeIds.add(agentId);
70
+ } catch(e) {}
71
+ }
72
+
73
+ const logFiles = fs.readdirSync(config.AGENT_LOGS_DIR)
74
+ .filter(f => f.endsWith('.log'))
75
+ .map(f => {
76
+ const filePath = path.join(config.AGENT_LOGS_DIR, f);
77
+ const stat = fs.statSync(filePath);
78
+ return { file: f, filePath, mtime: stat.mtime, birthtime: stat.birthtime };
79
+ })
80
+ .sort((a, b) => b.mtime - a.mtime);
81
+
82
+ let imported = 0;
83
+ for (const { file, filePath, mtime, birthtime } of logFiles) {
84
+ const agentId = file.replace('.log', '');
85
+
86
+ if (existingIds.has(agentId) || activeIds.has(agentId)) continue;
87
+
88
+ try {
89
+ const content = fs.readFileSync(filePath, 'utf8').slice(0, 3000);
90
+ let ticket = null;
91
+ let title = null;
92
+ let repo = null;
93
+
94
+ const claimedMatch = content.match(/Claimed ticket #(\d+):\s*(.+?)(?:\n|$)/i);
95
+ if (claimedMatch) {
96
+ ticket = claimedMatch[1];
97
+ title = claimedMatch[2].trim().slice(0, 80);
98
+ if (claimedMatch[2].trim().length > 80) title += '...';
99
+ } else {
100
+ const ticketMatch = content.match(/ticket #(\d+)/i);
101
+ if (ticketMatch) ticket = ticketMatch[1];
102
+ }
103
+
104
+ const repoMatch = content.match(/\*?\*?Repo\*?\*?:\s*([\w_-]+)/i) || content.match(/-R data-tamer\/([\w_-]+)/i);
105
+ if (repoMatch) repo = repoMatch[1];
106
+ if (!repo) {
107
+ if (content.match(/tamer_service\//)) repo = 'tamer_service';
108
+ else if (content.match(/datatamer\.ai\//)) repo = 'datatamer.ai';
109
+ else if (content.match(/agentdev-webui\//)) repo = 'agentdev-webui';
110
+ else if (content.match(/agentdev-webui\//)) repo = 'agentdev-webui';
111
+ else repo = 'data-tamer-dashboard';
112
+ }
113
+
114
+ agentHistory.push({
115
+ id: agentId,
116
+ ticket,
117
+ repo,
118
+ title,
119
+ startTime: birthtime.toISOString(),
120
+ endTime: mtime.toISOString(),
121
+ completedAt: mtime.toLocaleString()
122
+ });
123
+ imported++;
124
+ } catch(e) {}
125
+ }
126
+
127
+ if (imported > 0) {
128
+ agentHistory.sort((a, b) => new Date(b.endTime || 0) - new Date(a.endTime || 0));
129
+ if (agentHistory.length > config.MAX_HISTORY) {
130
+ agentHistory = agentHistory.slice(0, config.MAX_HISTORY);
131
+ }
132
+ saveHistory();
133
+ console.log(`Imported ${imported} existing log files to history`);
134
+ }
135
+ }
136
+
137
+ module.exports = {
138
+ loadHistory,
139
+ saveHistory,
140
+ addToHistory,
141
+ getHistory,
142
+ importExistingLogs
143
+ };
package/lib/pwa.js ADDED
@@ -0,0 +1,107 @@
1
+ // PWA assets - version changes on server restart to trigger updates
2
+ const SW_VERSION = Date.now();
3
+
4
+ function getManifest() {
5
+ return {
6
+ name: 'Agent Dev',
7
+ short_name: 'AgentDev',
8
+ description: 'Multi-agent development workflow',
9
+ start_url: '/',
10
+ display: 'standalone',
11
+ orientation: 'portrait',
12
+ theme_color: '#1a1a2e',
13
+ background_color: '#1a1a2e',
14
+ icons: [
15
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
16
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
17
+ ]
18
+ };
19
+ }
20
+
21
+ function getServiceWorkerCode() {
22
+ return `
23
+ const CACHE_NAME = 'autoticket-v${SW_VERSION}';
24
+ const STATIC_ASSETS = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png'];
25
+
26
+ self.addEventListener('install', event => {
27
+ console.log('[SW] Installing new version:', CACHE_NAME);
28
+ event.waitUntil(
29
+ caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
30
+ );
31
+ self.skipWaiting();
32
+ });
33
+
34
+ self.addEventListener('activate', event => {
35
+ console.log('[SW] Activating new version:', CACHE_NAME);
36
+ event.waitUntil(
37
+ caches.keys().then(keys => {
38
+ return Promise.all(
39
+ keys.filter(k => k.startsWith('autoticket-') && k !== CACHE_NAME)
40
+ .map(k => {
41
+ console.log('[SW] Deleting old cache:', k);
42
+ return caches.delete(k);
43
+ })
44
+ );
45
+ }).then(() => {
46
+ return self.clients.claim();
47
+ }).then(() => {
48
+ return self.clients.matchAll().then(clients => {
49
+ clients.forEach(client => client.postMessage({ type: 'SW_UPDATED' }));
50
+ });
51
+ })
52
+ );
53
+ });
54
+
55
+ self.addEventListener('fetch', event => {
56
+ if (event.request.url.includes('/logs')) return;
57
+ if (event.request.method !== 'GET') return;
58
+
59
+ if (event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html')) {
60
+ event.respondWith(
61
+ fetch(event.request)
62
+ .then(response => {
63
+ const clone = response.clone();
64
+ caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
65
+ return response;
66
+ })
67
+ .catch(() => caches.match(event.request))
68
+ );
69
+ return;
70
+ }
71
+
72
+ event.respondWith(
73
+ caches.match(event.request).then(cached => cached || fetch(event.request))
74
+ );
75
+ });
76
+
77
+ self.addEventListener('message', event => {
78
+ if (event.data && event.data.type === 'SKIP_WAITING') {
79
+ self.skipWaiting();
80
+ }
81
+ });
82
+ `;
83
+ }
84
+
85
+ function getIconSvg(size) {
86
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
87
+ <rect width="100" height="100" rx="18.75" fill="#1a1a2e"/>
88
+ <path d="M50 18.75 L81.25 75 H18.75 Z" fill="none" stroke="#4ade80" stroke-width="7.8" stroke-linejoin="round"/>
89
+ <circle cx="50" cy="53.125" r="9.375" fill="#4ade80"/>
90
+ </svg>`;
91
+ }
92
+
93
+ function getOgImageSvg() {
94
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
95
+ <rect width="1200" height="630" fill="#1a1a2e"/>
96
+ <path d="M540 140 L680 400 H400 Z" fill="none" stroke="#4ade80" stroke-width="12" stroke-linejoin="round"/>
97
+ <circle cx="540" cy="300" r="18" fill="#4ade80"/>
98
+ <text x="600" y="520" font-family="system-ui,sans-serif" font-size="48" font-weight="bold" fill="#fff" text-anchor="middle">Agent Dev</text>
99
+ </svg>`;
100
+ }
101
+
102
+ module.exports = {
103
+ getManifest,
104
+ getServiceWorkerCode,
105
+ getIconSvg,
106
+ getOgImageSvg
107
+ };