amalgm 0.1.49 → 0.1.50
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/config.js +1 -1
- package/runtime/scripts/amalgm-mcp/events/executor.js +31 -0
- package/runtime/scripts/amalgm-mcp/events/store.js +202 -2
- package/runtime/scripts/amalgm-mcp/fs/rest.js +348 -16
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +26 -5
- package/runtime/scripts/amalgm-mcp/server/http.js +2 -1
- package/runtime/scripts/amalgm-mcp/state/db.js +72 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +12 -1
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +13 -4
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +60 -22
- package/runtime/scripts/amalgm-mcp/tasks/store.js +783 -55
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +12 -4
- package/runtime/scripts/amalgm-mcp/tests/tasks-store.test.js +113 -0
- package/runtime/scripts/local-gateway.js +13 -0
|
@@ -7,6 +7,8 @@ const fs = require('fs');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const activeMemory = require('../../chat-core/tooling/active-memory');
|
|
10
|
+
const { AMALGM_DIR } = require('../config');
|
|
11
|
+
const { appendStateEvent } = require('../state/events');
|
|
10
12
|
|
|
11
13
|
const TEXT_EXTENSIONS = new Set([
|
|
12
14
|
'txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
|
|
@@ -18,6 +20,14 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
18
20
|
const IS_LOCAL_MACHINE = process.env.AMALGM_LOCAL_MODE === 'true';
|
|
19
21
|
const CONTAINER_ROOT = process.env.AMALGM_WORKSPACE_ROOT || '/workspace';
|
|
20
22
|
const MAX_READ_BYTES = Number.parseInt(process.env.AMALGM_FS_MAX_READ_BYTES || '', 10) || 25 * 1024 * 1024;
|
|
23
|
+
const FILE_RESOURCE_PREFIX = 'files:';
|
|
24
|
+
const UPLOADS_RESOURCE = 'uploads';
|
|
25
|
+
const UPLOADS_ROOT = path.join(AMALGM_DIR, 'uploads');
|
|
26
|
+
const FS_WATCH_DEBOUNCE_MS = 150;
|
|
27
|
+
|
|
28
|
+
const fileWatchers = new Map();
|
|
29
|
+
const uploadWatchers = new Map();
|
|
30
|
+
let uploadsPublishTimer = null;
|
|
21
31
|
|
|
22
32
|
function expandHome(targetPath) {
|
|
23
33
|
if (targetPath === '~') return os.homedir();
|
|
@@ -63,11 +73,65 @@ async function existingAllowedRoots() {
|
|
|
63
73
|
return Array.from(new Set(roots));
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
function existingAllowedRootsSync() {
|
|
77
|
+
const roots = [];
|
|
78
|
+
for (const root of configuredRootPaths()) {
|
|
79
|
+
try {
|
|
80
|
+
roots.push(fs.realpathSync(root));
|
|
81
|
+
} catch {
|
|
82
|
+
// Ignore configured roots that are not mounted on this computer.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Array.from(new Set(roots));
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
function isWithin(root, target) {
|
|
67
89
|
const relative = path.relative(root, target);
|
|
68
90
|
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
69
91
|
}
|
|
70
92
|
|
|
93
|
+
function comparableRealPathSync(targetPath) {
|
|
94
|
+
const resolved = path.resolve(targetPath);
|
|
95
|
+
try {
|
|
96
|
+
return fs.realpathSync(resolved);
|
|
97
|
+
} catch {
|
|
98
|
+
try {
|
|
99
|
+
const parent = nearestExistingParentSync(path.dirname(resolved));
|
|
100
|
+
const realParent = fs.realpathSync(parent);
|
|
101
|
+
return path.join(realParent, path.relative(parent, resolved));
|
|
102
|
+
} catch {
|
|
103
|
+
return resolved;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function base64UrlEncode(value) {
|
|
109
|
+
return Buffer.from(String(value), 'utf8')
|
|
110
|
+
.toString('base64')
|
|
111
|
+
.replace(/\+/g, '-')
|
|
112
|
+
.replace(/\//g, '_')
|
|
113
|
+
.replace(/=+$/g, '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function base64UrlDecode(value) {
|
|
117
|
+
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
|
118
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
119
|
+
return Buffer.from(padded, 'base64').toString('utf8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function filesResourceName(dirPath) {
|
|
123
|
+
return `${FILE_RESOURCE_PREFIX}${base64UrlEncode(path.resolve(expandHome(dirPath)))}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pathFromFilesResource(resource) {
|
|
127
|
+
if (typeof resource !== 'string' || !resource.startsWith(FILE_RESOURCE_PREFIX)) return null;
|
|
128
|
+
try {
|
|
129
|
+
return base64UrlDecode(resource.slice(FILE_RESOURCE_PREFIX.length));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
71
135
|
function requestPathOrDefault(targetPath) {
|
|
72
136
|
if (typeof targetPath === 'string' && targetPath.trim()) return targetPath;
|
|
73
137
|
return IS_LOCAL_MACHINE ? os.homedir() : CONTAINER_ROOT;
|
|
@@ -86,6 +150,19 @@ async function nearestExistingParent(targetPath) {
|
|
|
86
150
|
return current || path.parse(targetPath).root;
|
|
87
151
|
}
|
|
88
152
|
|
|
153
|
+
function nearestExistingParentSync(targetPath) {
|
|
154
|
+
let current = targetPath;
|
|
155
|
+
while (current && current !== path.dirname(current)) {
|
|
156
|
+
try {
|
|
157
|
+
const stats = fs.lstatSync(current);
|
|
158
|
+
return stats.isDirectory() || stats.isSymbolicLink() ? current : path.dirname(current);
|
|
159
|
+
} catch {
|
|
160
|
+
current = path.dirname(current);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return current || path.parse(targetPath).root;
|
|
164
|
+
}
|
|
165
|
+
|
|
89
166
|
async function assertContained(realPath) {
|
|
90
167
|
const roots = await existingAllowedRoots();
|
|
91
168
|
if (roots.length === 0) {
|
|
@@ -102,6 +179,23 @@ async function assertContained(realPath) {
|
|
|
102
179
|
}
|
|
103
180
|
}
|
|
104
181
|
|
|
182
|
+
function assertContainedSync(realPath) {
|
|
183
|
+
const roots = existingAllowedRootsSync();
|
|
184
|
+
const comparablePath = comparableRealPathSync(realPath);
|
|
185
|
+
if (roots.length === 0) {
|
|
186
|
+
const rootHint = IS_LOCAL_MACHINE ? os.homedir() : CONTAINER_ROOT;
|
|
187
|
+
throw Object.assign(new Error(`No accessible filesystem roots configured; expected ${rootHint}`), {
|
|
188
|
+
statusCode: 500,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!roots.some((root) => isWithin(root, comparablePath))) {
|
|
193
|
+
throw Object.assign(new Error('Filesystem path is outside the approved Amalgm computer roots'), {
|
|
194
|
+
statusCode: 403,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
105
199
|
async function resolveSafePath(targetPath, options = {}) {
|
|
106
200
|
const { forCreate = false } = options;
|
|
107
201
|
const requested = requestPathOrDefault(targetPath);
|
|
@@ -144,6 +238,31 @@ async function resolveSafePath(targetPath, options = {}) {
|
|
|
144
238
|
return normalized;
|
|
145
239
|
}
|
|
146
240
|
|
|
241
|
+
function resolveSafePathSync(targetPath) {
|
|
242
|
+
const requested = requestPathOrDefault(targetPath);
|
|
243
|
+
|
|
244
|
+
if (typeof requested !== 'string' || requested.length === 0) {
|
|
245
|
+
throw Object.assign(new Error('path is required'), { statusCode: 400 });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const expanded = expandHome(requested);
|
|
249
|
+
if (!path.isAbsolute(expanded)) {
|
|
250
|
+
throw Object.assign(new Error('Path must be absolute or use ~'), { statusCode: 400 });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const normalized = path.resolve(expanded);
|
|
254
|
+
try {
|
|
255
|
+
const real = fs.realpathSync(normalized);
|
|
256
|
+
assertContainedSync(real);
|
|
257
|
+
return normalized;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error && error.code === 'ENOENT') {
|
|
260
|
+
throw Object.assign(new Error('Filesystem path not found'), { statusCode: 404 });
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
147
266
|
function statusForError(error, fallbackStatus = 500) {
|
|
148
267
|
if (typeof error?.statusCode === 'number') return error.statusCode;
|
|
149
268
|
if (error?.code === 'ENOENT') return 404;
|
|
@@ -159,29 +278,211 @@ function classifyFile(targetPath) {
|
|
|
159
278
|
};
|
|
160
279
|
}
|
|
161
280
|
|
|
162
|
-
|
|
281
|
+
function directorySnapshot(targetPath) {
|
|
282
|
+
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
|
283
|
+
const files = entries.map((entry) => {
|
|
284
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
285
|
+
const stats = fs.lstatSync(fullPath);
|
|
286
|
+
return {
|
|
287
|
+
name: entry.name,
|
|
288
|
+
isDir: stats.isDirectory(),
|
|
289
|
+
size: stats.size,
|
|
290
|
+
modTime: stats.mtime.toISOString(),
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
files.sort((a, b) => {
|
|
295
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
296
|
+
return a.name.localeCompare(b.name);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return { path: targetPath, files };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function closeFileWatcher(dirPath) {
|
|
303
|
+
const existing = fileWatchers.get(dirPath);
|
|
304
|
+
if (!existing) return;
|
|
305
|
+
if (existing.timer) clearTimeout(existing.timer);
|
|
163
306
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
307
|
+
existing.watcher.close();
|
|
308
|
+
} catch {}
|
|
309
|
+
fileWatchers.delete(dirPath);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function scheduleFilesResourcePublish(dirPath, source = 'fs:watch') {
|
|
313
|
+
const resolved = path.resolve(dirPath);
|
|
314
|
+
const existing = fileWatchers.get(resolved);
|
|
315
|
+
if (existing?.timer) clearTimeout(existing.timer);
|
|
316
|
+
|
|
317
|
+
const timer = setTimeout(() => {
|
|
318
|
+
if (existing) existing.timer = null;
|
|
319
|
+
void publishFilesResourceForPath(resolved, source);
|
|
320
|
+
}, FS_WATCH_DEBOUNCE_MS);
|
|
321
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
322
|
+
if (existing) existing.timer = timer;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function watchFilesDirectory(dirPath) {
|
|
326
|
+
const resolved = path.resolve(dirPath);
|
|
327
|
+
if (fileWatchers.has(resolved)) return;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const watcher = fs.watch(resolved, { persistent: false }, () => {
|
|
331
|
+
scheduleFilesResourcePublish(resolved, 'fs:watch');
|
|
332
|
+
});
|
|
333
|
+
watcher.on('error', () => closeFileWatcher(resolved));
|
|
334
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
335
|
+
fileWatchers.set(resolved, { watcher, timer: null });
|
|
336
|
+
} catch {
|
|
337
|
+
// Watching is best-effort. Mutations through this API still publish events.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildFilesResource(resourceOrPath, options = {}) {
|
|
342
|
+
const rawPath = pathFromFilesResource(resourceOrPath) || resourceOrPath;
|
|
343
|
+
if (!rawPath) return undefined;
|
|
344
|
+
const targetPath = resolveSafePathSync(rawPath);
|
|
345
|
+
const snapshot = directorySnapshot(targetPath);
|
|
346
|
+
if (options.watch !== false) watchFilesDirectory(targetPath);
|
|
347
|
+
return snapshot;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function publishFilesResourceForPath(dirPath, source = 'fs') {
|
|
351
|
+
const resolved = path.resolve(expandHome(dirPath));
|
|
352
|
+
try {
|
|
353
|
+
const value = await buildFilesResource(resolved, { watch: true });
|
|
354
|
+
appendStateEvent({
|
|
355
|
+
resource: filesResourceName(resolved),
|
|
356
|
+
op: 'replace',
|
|
357
|
+
id: resolved,
|
|
358
|
+
value,
|
|
359
|
+
source,
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
appendStateEvent({
|
|
363
|
+
resource: filesResourceName(resolved),
|
|
364
|
+
op: 'replace',
|
|
365
|
+
id: resolved,
|
|
366
|
+
value: { path: resolved, files: [], missing: true },
|
|
367
|
+
source,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isUploadsPath(targetPath) {
|
|
373
|
+
const resolved = path.resolve(expandHome(targetPath));
|
|
374
|
+
return isWithin(UPLOADS_ROOT, resolved);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function closeUploadWatcher(dirPath) {
|
|
378
|
+
const existing = uploadWatchers.get(dirPath);
|
|
379
|
+
if (!existing) return;
|
|
380
|
+
try {
|
|
381
|
+
existing.close();
|
|
382
|
+
} catch {}
|
|
383
|
+
uploadWatchers.delete(dirPath);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function scheduleUploadsPublish(source = 'uploads:watch') {
|
|
387
|
+
if (uploadsPublishTimer) clearTimeout(uploadsPublishTimer);
|
|
388
|
+
uploadsPublishTimer = setTimeout(() => {
|
|
389
|
+
uploadsPublishTimer = null;
|
|
390
|
+
void publishUploadsResource(source);
|
|
391
|
+
}, FS_WATCH_DEBOUNCE_MS);
|
|
392
|
+
if (typeof uploadsPublishTimer.unref === 'function') uploadsPublishTimer.unref();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function watchUploadDirectory(dirPath) {
|
|
396
|
+
const resolved = path.resolve(dirPath);
|
|
397
|
+
if (uploadWatchers.has(resolved)) return;
|
|
398
|
+
try {
|
|
399
|
+
const watcher = fs.watch(resolved, { persistent: false }, () => {
|
|
400
|
+
scheduleUploadsPublish('uploads:watch');
|
|
401
|
+
});
|
|
402
|
+
watcher.on('error', () => closeUploadWatcher(resolved));
|
|
403
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
404
|
+
uploadWatchers.set(resolved, watcher);
|
|
405
|
+
} catch {
|
|
406
|
+
// Best-effort watcher; API writes still publish synchronously.
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function buildUploadsResource(options = {}) {
|
|
411
|
+
const files = [];
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
assertContainedSync(UPLOADS_ROOT);
|
|
415
|
+
} catch {
|
|
416
|
+
return { root: UPLOADS_ROOT, files };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (options.watch !== false) watchUploadDirectory(UPLOADS_ROOT);
|
|
420
|
+
|
|
421
|
+
let sessionDirs = [];
|
|
422
|
+
try {
|
|
423
|
+
sessionDirs = fs.readdirSync(UPLOADS_ROOT, { withFileTypes: true });
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error?.code === 'ENOENT') return { root: UPLOADS_ROOT, files };
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const dir of sessionDirs.filter((entry) => entry.isDirectory())) {
|
|
430
|
+
const sessionPath = path.join(UPLOADS_ROOT, dir.name);
|
|
431
|
+
if (options.watch !== false) watchUploadDirectory(sessionPath);
|
|
432
|
+
try {
|
|
433
|
+
const entries = fs.readdirSync(sessionPath, { withFileTypes: true });
|
|
434
|
+
for (const entry of entries.filter((item) => item.isFile())) {
|
|
435
|
+
const fullPath = path.join(sessionPath, entry.name);
|
|
436
|
+
const stats = fs.lstatSync(fullPath);
|
|
437
|
+
files.push({
|
|
171
438
|
name: entry.name,
|
|
172
|
-
isDir:
|
|
439
|
+
isDir: false,
|
|
173
440
|
size: stats.size,
|
|
174
441
|
modTime: stats.mtime.toISOString(),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
442
|
+
fullPath,
|
|
443
|
+
sessionId: dir.name,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
// Upload session directories can disappear while listing.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
178
450
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
451
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
452
|
+
return { root: UPLOADS_ROOT, files };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function publishUploadsResource(source = 'uploads') {
|
|
456
|
+
try {
|
|
457
|
+
appendStateEvent({
|
|
458
|
+
resource: UPLOADS_RESOURCE,
|
|
459
|
+
op: 'replace',
|
|
460
|
+
id: UPLOADS_ROOT,
|
|
461
|
+
value: buildUploadsResource({ watch: true }),
|
|
462
|
+
source,
|
|
182
463
|
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
console.warn('[fs] Failed to publish uploads resource:', error instanceof Error ? error.message : String(error));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function publishFilesystemChange(paths, source = 'fs') {
|
|
470
|
+
const directories = new Set();
|
|
471
|
+
let touchesUploads = false;
|
|
472
|
+
|
|
473
|
+
for (const item of paths.filter(Boolean)) {
|
|
474
|
+
const resolved = path.resolve(expandHome(item));
|
|
475
|
+
directories.add(path.dirname(resolved));
|
|
476
|
+
if (isUploadsPath(resolved)) touchesUploads = true;
|
|
477
|
+
}
|
|
183
478
|
|
|
184
|
-
|
|
479
|
+
await Promise.all(Array.from(directories).map((dir) => publishFilesResourceForPath(dir, source)));
|
|
480
|
+
if (touchesUploads) await publishUploadsResource(source);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function handleList(query, sendJson) {
|
|
484
|
+
try {
|
|
485
|
+
sendJson(200, await buildFilesResource(query.path, { watch: true }));
|
|
185
486
|
} catch (error) {
|
|
186
487
|
const message = error instanceof Error ? error.message : 'Failed to list files';
|
|
187
488
|
sendJson(statusForError(error), { error: message });
|
|
@@ -238,6 +539,7 @@ async function handleWrite(body, sendJson) {
|
|
|
238
539
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
239
540
|
await fs.promises.writeFile(targetPath, data);
|
|
240
541
|
activeMemory.notifyMemoryPathChanged(targetPath, 'fs:write');
|
|
542
|
+
await publishFilesystemChange([targetPath], 'fs:write');
|
|
241
543
|
|
|
242
544
|
sendJson(200, { success: true, path: targetPath });
|
|
243
545
|
} catch (error) {
|
|
@@ -249,8 +551,19 @@ async function handleWrite(body, sendJson) {
|
|
|
249
551
|
async function handleDelete(body, sendJson) {
|
|
250
552
|
try {
|
|
251
553
|
const targetPath = await resolveSafePath(body.path);
|
|
554
|
+
const stats = await fs.promises.lstat(targetPath).catch(() => null);
|
|
252
555
|
await fs.promises.rm(targetPath, { recursive: true, force: true });
|
|
253
556
|
activeMemory.notifyMemoryPathChanged(targetPath, 'fs:delete');
|
|
557
|
+
await publishFilesystemChange([targetPath], 'fs:delete');
|
|
558
|
+
if (stats?.isDirectory()) {
|
|
559
|
+
appendStateEvent({
|
|
560
|
+
resource: filesResourceName(targetPath),
|
|
561
|
+
op: 'replace',
|
|
562
|
+
id: targetPath,
|
|
563
|
+
value: { path: targetPath, files: [], missing: true },
|
|
564
|
+
source: 'fs:delete',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
254
567
|
sendJson(200, { success: true, path: targetPath });
|
|
255
568
|
} catch (error) {
|
|
256
569
|
const message = error instanceof Error ? error.message : 'Failed to delete';
|
|
@@ -263,6 +576,8 @@ async function handleMkdir(body, sendJson) {
|
|
|
263
576
|
const targetPath = await resolveSafePath(body.path, { forCreate: true });
|
|
264
577
|
await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });
|
|
265
578
|
activeMemory.notifyMemoryPathChanged(targetPath, 'fs:mkdir');
|
|
579
|
+
await publishFilesystemChange([targetPath], 'fs:mkdir');
|
|
580
|
+
await publishFilesResourceForPath(targetPath, 'fs:mkdir');
|
|
266
581
|
sendJson(200, { success: true, path: targetPath });
|
|
267
582
|
} catch (error) {
|
|
268
583
|
const message = error instanceof Error ? error.message : 'Failed to create folder';
|
|
@@ -274,10 +589,22 @@ async function handleRename(body, sendJson) {
|
|
|
274
589
|
try {
|
|
275
590
|
const oldPath = await resolveSafePath(body.oldPath);
|
|
276
591
|
const newPath = await resolveSafePath(body.newPath, { forCreate: true });
|
|
592
|
+
const stats = await fs.promises.lstat(oldPath).catch(() => null);
|
|
277
593
|
await fs.promises.mkdir(path.dirname(newPath), { recursive: true });
|
|
278
594
|
await fs.promises.rename(oldPath, newPath);
|
|
279
595
|
activeMemory.notifyMemoryPathChanged(oldPath, 'fs:rename');
|
|
280
596
|
activeMemory.notifyMemoryPathChanged(newPath, 'fs:rename');
|
|
597
|
+
await publishFilesystemChange([oldPath, newPath], 'fs:rename');
|
|
598
|
+
if (stats?.isDirectory()) {
|
|
599
|
+
appendStateEvent({
|
|
600
|
+
resource: filesResourceName(oldPath),
|
|
601
|
+
op: 'replace',
|
|
602
|
+
id: oldPath,
|
|
603
|
+
value: { path: oldPath, files: [], missing: true },
|
|
604
|
+
source: 'fs:rename',
|
|
605
|
+
});
|
|
606
|
+
await publishFilesResourceForPath(newPath, 'fs:rename');
|
|
607
|
+
}
|
|
281
608
|
sendJson(200, { success: true, oldPath, newPath });
|
|
282
609
|
} catch (error) {
|
|
283
610
|
const message = error instanceof Error ? error.message : 'Failed to rename';
|
|
@@ -293,7 +620,12 @@ module.exports = {
|
|
|
293
620
|
handleRename,
|
|
294
621
|
handleWrite,
|
|
295
622
|
_private: {
|
|
623
|
+
buildFilesResource,
|
|
624
|
+
buildUploadsResource,
|
|
296
625
|
configuredRootPaths,
|
|
626
|
+
filesResourceName,
|
|
627
|
+
pathFromFilesResource,
|
|
628
|
+
publishFilesystemChange,
|
|
297
629
|
resolveSafePath,
|
|
298
630
|
},
|
|
299
631
|
};
|
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { AMALGM_DIR } = require('../config');
|
|
10
|
+
const { appendStateEvent } = require('../state/events');
|
|
10
11
|
const { deleteTool, upsertTool } = require('../toolbox/store');
|
|
11
12
|
|
|
12
13
|
const CONNECTIONS_FILE = path.join(AMALGM_DIR, 'mcp-connections.json');
|
|
@@ -103,6 +104,27 @@ function readNativeMcps() {
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
function buildConnectionsSnapshot() {
|
|
108
|
+
const file = readConnectionsFile();
|
|
109
|
+
return {
|
|
110
|
+
connections: sanitizeConnections(file.connections),
|
|
111
|
+
nativeMcps: readNativeMcps(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function publishMcpConnectionsChange(source = 'mcp-connections') {
|
|
116
|
+
try {
|
|
117
|
+
appendStateEvent({
|
|
118
|
+
resource: 'mcp_connections',
|
|
119
|
+
op: 'replace',
|
|
120
|
+
value: buildConnectionsSnapshot(),
|
|
121
|
+
source,
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn('[mcp-connections] Local Live Store publish failed:', error.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
function mirrorConnectionToToolbox(appId, conn) {
|
|
107
129
|
const registry = getMcpRegistry();
|
|
108
130
|
const app = registry?.getMcpApp?.(appId);
|
|
@@ -151,11 +173,7 @@ function removeConnectionFromToolbox(appId) {
|
|
|
151
173
|
}
|
|
152
174
|
|
|
153
175
|
async function handleList(sendJson) {
|
|
154
|
-
|
|
155
|
-
sendJson(200, {
|
|
156
|
-
connections: sanitizeConnections(file.connections),
|
|
157
|
-
nativeMcps: readNativeMcps(),
|
|
158
|
-
});
|
|
176
|
+
sendJson(200, buildConnectionsSnapshot());
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
async function handleUpsert(body, sendJson) {
|
|
@@ -203,6 +221,7 @@ async function handleUpsert(body, sendJson) {
|
|
|
203
221
|
|
|
204
222
|
writeConnectionsFile(file);
|
|
205
223
|
mirrorConnectionToToolbox(appId, file.connections[appId]);
|
|
224
|
+
publishMcpConnectionsChange('mcp-connections:upsert');
|
|
206
225
|
|
|
207
226
|
sendJson(200, {
|
|
208
227
|
ok: true,
|
|
@@ -223,11 +242,13 @@ async function handleDelete(body, sendJson) {
|
|
|
223
242
|
writeConnectionsFile(file);
|
|
224
243
|
}
|
|
225
244
|
removeConnectionFromToolbox(appId);
|
|
245
|
+
publishMcpConnectionsChange('mcp-connections:delete');
|
|
226
246
|
|
|
227
247
|
sendJson(200, { ok: true });
|
|
228
248
|
}
|
|
229
249
|
|
|
230
250
|
module.exports = {
|
|
251
|
+
buildConnectionsSnapshot,
|
|
231
252
|
handleDelete,
|
|
232
253
|
handleList,
|
|
233
254
|
handleUpsert,
|
|
@@ -62,7 +62,7 @@ const { loadTasks } = require('../tasks/store');
|
|
|
62
62
|
const { loadAgents } = require('../agents/store');
|
|
63
63
|
const { loadArtifacts } = require('../artifacts/store');
|
|
64
64
|
const { activeAgentConversations } = require('../agents/talk');
|
|
65
|
-
const { startScheduler, stopScheduler } = require('../tasks/scheduler');
|
|
65
|
+
const { getSchedulerStatus, startScheduler, stopScheduler } = require('../tasks/scheduler');
|
|
66
66
|
const { createTransport } = require('./mcp');
|
|
67
67
|
const { handleEventsPost } = require('../events/ingress');
|
|
68
68
|
const eventsRing = require('../events/ring-buffer');
|
|
@@ -118,6 +118,7 @@ function createServer() {
|
|
|
118
118
|
status: 'ok',
|
|
119
119
|
tasks: tasks.tasks.length,
|
|
120
120
|
enabledTasks: tasks.tasks.filter((t) => t.enabled).length,
|
|
121
|
+
scheduler: getSchedulerStatus(),
|
|
121
122
|
artifacts: loadArtifacts().artifacts.length,
|
|
122
123
|
customAgents: agents.agents.length,
|
|
123
124
|
activeAgentConversations: activeAgentConversations.size,
|
|
@@ -55,6 +55,78 @@ function migrate(database = openLocalDb()) {
|
|
|
55
55
|
CREATE INDEX IF NOT EXISTS event_log_resource_seq_idx
|
|
56
56
|
ON event_log(resource, seq);
|
|
57
57
|
|
|
58
|
+
CREATE TABLE IF NOT EXISTS local_meta (
|
|
59
|
+
key TEXT PRIMARY KEY,
|
|
60
|
+
value TEXT,
|
|
61
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
68
|
+
schedule_kind TEXT NOT NULL,
|
|
69
|
+
schedule_json TEXT NOT NULL,
|
|
70
|
+
next_run_at TEXT,
|
|
71
|
+
last_run_at TEXT,
|
|
72
|
+
last_status TEXT,
|
|
73
|
+
created_at TEXT NOT NULL,
|
|
74
|
+
updated_at TEXT NOT NULL,
|
|
75
|
+
task_json TEXT NOT NULL
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS scheduled_tasks_due_idx
|
|
79
|
+
ON scheduled_tasks(enabled, next_run_at);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS scheduled_tasks_kind_idx
|
|
82
|
+
ON scheduled_tasks(schedule_kind);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS task_runs (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
task_id TEXT NOT NULL,
|
|
87
|
+
scheduled_for TEXT NOT NULL,
|
|
88
|
+
status TEXT NOT NULL,
|
|
89
|
+
started_at TEXT,
|
|
90
|
+
finished_at TEXT,
|
|
91
|
+
claimed_at TEXT,
|
|
92
|
+
claim_expires_at TEXT,
|
|
93
|
+
runner_id TEXT,
|
|
94
|
+
session_id TEXT,
|
|
95
|
+
error TEXT,
|
|
96
|
+
run_json TEXT NOT NULL,
|
|
97
|
+
created_at TEXT NOT NULL,
|
|
98
|
+
updated_at TEXT NOT NULL,
|
|
99
|
+
UNIQUE(task_id, scheduled_for),
|
|
100
|
+
FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE INDEX IF NOT EXISTS task_runs_task_updated_idx
|
|
104
|
+
ON task_runs(task_id, updated_at DESC);
|
|
105
|
+
|
|
106
|
+
CREATE INDEX IF NOT EXISTS task_runs_status_idx
|
|
107
|
+
ON task_runs(status);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS event_runs (
|
|
110
|
+
id TEXT PRIMARY KEY,
|
|
111
|
+
trigger_id TEXT NOT NULL,
|
|
112
|
+
status TEXT NOT NULL,
|
|
113
|
+
started_at TEXT,
|
|
114
|
+
finished_at TEXT,
|
|
115
|
+
session_id TEXT,
|
|
116
|
+
harness TEXT,
|
|
117
|
+
project_path TEXT,
|
|
118
|
+
error TEXT,
|
|
119
|
+
run_json TEXT NOT NULL,
|
|
120
|
+
created_at TEXT NOT NULL,
|
|
121
|
+
updated_at TEXT NOT NULL
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
CREATE INDEX IF NOT EXISTS event_runs_trigger_updated_idx
|
|
125
|
+
ON event_runs(trigger_id, updated_at DESC);
|
|
126
|
+
|
|
127
|
+
CREATE INDEX IF NOT EXISTS event_runs_status_idx
|
|
128
|
+
ON event_runs(status);
|
|
129
|
+
|
|
58
130
|
CREATE TABLE IF NOT EXISTS tools (
|
|
59
131
|
id TEXT PRIMARY KEY,
|
|
60
132
|
name TEXT NOT NULL,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { currentSeq } = require('./events');
|
|
4
4
|
|
|
5
|
-
const DEFAULT_RESOURCES = ['tasks', 'event_triggers', 'agents', 'artifacts', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'memories'];
|
|
5
|
+
const DEFAULT_RESOURCES = ['tasks', 'task_runs', 'event_runs', 'event_triggers', 'agents', 'artifacts', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'memories', 'mcp_connections'];
|
|
6
6
|
|
|
7
7
|
function normalizeResources(resources) {
|
|
8
8
|
const values = resources ? String(resources).split(',') : DEFAULT_RESOURCES;
|
|
@@ -14,6 +14,10 @@ function readResource(resource, cache) {
|
|
|
14
14
|
switch (resource) {
|
|
15
15
|
case 'tasks':
|
|
16
16
|
return require('../tasks/store').loadTasks().tasks;
|
|
17
|
+
case 'task_runs':
|
|
18
|
+
return require('../tasks/store').listTaskRuns({ limit: 100 });
|
|
19
|
+
case 'event_runs':
|
|
20
|
+
return require('../events/store').listEventRuns({ limit: 100 });
|
|
17
21
|
case 'event_triggers':
|
|
18
22
|
return require('../events/store').loadEventTriggers().triggers;
|
|
19
23
|
case 'agents':
|
|
@@ -38,7 +42,14 @@ function readResource(resource, cache) {
|
|
|
38
42
|
}
|
|
39
43
|
case 'memories':
|
|
40
44
|
return require('../../chat-core/tooling/active-memory').collectActiveMemoryFiles();
|
|
45
|
+
case 'uploads':
|
|
46
|
+
return require('../fs/rest')._private.buildUploadsResource();
|
|
47
|
+
case 'mcp_connections':
|
|
48
|
+
return require('../mcp-connections/rest').buildConnectionsSnapshot();
|
|
41
49
|
default:
|
|
50
|
+
if (String(resource).startsWith('files:')) {
|
|
51
|
+
return require('../fs/rest')._private.buildFilesResource(resource);
|
|
52
|
+
}
|
|
42
53
|
return undefined;
|
|
43
54
|
}
|
|
44
55
|
}
|
|
@@ -143,16 +143,25 @@ function resolveTaskRequest(task) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
async function executeTask(task) {
|
|
147
|
-
const runId = crypto.randomUUID();
|
|
148
|
-
const startedAt = new Date().toISOString();
|
|
146
|
+
async function executeTask(task, claimedRun = null) {
|
|
147
|
+
const runId = claimedRun?.id || claimedRun?.runId || crypto.randomUUID();
|
|
148
|
+
const startedAt = claimedRun?.startedAt || new Date().toISOString();
|
|
149
149
|
const { harness, chatInput, legacy, modelSelection, modelSettings } = resolveTaskRequest(task);
|
|
150
150
|
const projectPath = legacy.cwd || task.projectPath || null;
|
|
151
151
|
const mcpServers = await buildLocalMcpServerConfigs(legacy.mcpAppIds || []);
|
|
152
152
|
const reasoningEffort = modelSettings.effort || modelSelection.reasoningEffort;
|
|
153
153
|
|
|
154
154
|
console.log(`[AmalgmMCP:Exec] Starting task ${task.id} (${task.name}), run ${runId}`);
|
|
155
|
-
appendRunLog(task.id, {
|
|
155
|
+
appendRunLog(task.id, {
|
|
156
|
+
runId,
|
|
157
|
+
startedAt,
|
|
158
|
+
scheduledFor: claimedRun?.scheduledFor || startedAt,
|
|
159
|
+
claimedAt: claimedRun?.claimedAt || startedAt,
|
|
160
|
+
claimExpiresAt: claimedRun?.claimExpiresAt || null,
|
|
161
|
+
runnerId: claimedRun?.runnerId || null,
|
|
162
|
+
status: 'running',
|
|
163
|
+
prompt: task.prompt,
|
|
164
|
+
});
|
|
156
165
|
updateTaskMeta(task.id, { lastRunAt: startedAt, lastStatus: 'running' });
|
|
157
166
|
|
|
158
167
|
const codeSessionId = crypto.randomUUID();
|