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.
- package/lib/agent-api.js +530 -0
- package/lib/auth.js +127 -0
- package/lib/config.js +53 -0
- package/lib/database.js +762 -0
- package/lib/device-flow.js +257 -0
- package/lib/email.js +420 -0
- package/lib/encryption.js +112 -0
- package/lib/github.js +339 -0
- package/lib/history.js +143 -0
- package/lib/pwa.js +107 -0
- package/lib/redis-logs.js +226 -0
- package/lib/routes.js +680 -0
- package/migrations/000_create_database.sql +33 -0
- package/migrations/001_create_agentdev_schema.sql +135 -0
- package/migrations/001_create_agentdev_schema.sql.old +100 -0
- package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
- package/migrations/002_add_github_token.sql +17 -0
- package/migrations/003_add_agent_logs_table.sql +23 -0
- package/migrations/004_remove_oauth_columns.sql +11 -0
- package/migrations/005_add_projects.sql +44 -0
- package/migrations/006_project_github_token.sql +7 -0
- package/migrations/007_project_repositories.sql +12 -0
- package/migrations/008_add_notifications.sql +20 -0
- package/migrations/009_unified_oauth.sql +153 -0
- package/migrations/README.md +97 -0
- package/package.json +37 -0
- package/public/css/styles.css +1140 -0
- package/public/device.html +384 -0
- package/public/docs.html +862 -0
- package/public/docs.md +697 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +271 -0
- package/public/js/app.js +2379 -0
- package/public/login.html +224 -0
- package/public/profile.html +394 -0
- package/public/register.html +392 -0
- package/public/reset-password.html +349 -0
- package/public/verify-email.html +177 -0
- 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
|
+
};
|