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.
@@ -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
- async function handleList(query, sendJson) {
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
- const targetPath = await resolveSafePath(query.path);
165
- const entries = await fs.promises.readdir(targetPath, { withFileTypes: true });
166
- const files = await Promise.all(
167
- entries.map(async (entry) => {
168
- const fullPath = path.join(targetPath, entry.name);
169
- const stats = await fs.promises.lstat(fullPath);
170
- return {
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: stats.isDirectory(),
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
- files.sort((a, b) => {
180
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
181
- return a.name.localeCompare(b.name);
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
- sendJson(200, { files, path: targetPath });
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
- const file = readConnectionsFile();
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, { runId, startedAt, status: 'running', prompt: task.prompt });
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();