@sooneocean/agw 1.5.0 → 1.7.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/package.json +1 -1
- package/src/cli/commands/dashboard.ts +67 -0
- package/src/cli/index.ts +3 -1
- package/src/daemon/routes/batch.ts +45 -0
- package/src/daemon/routes/capabilities.ts +17 -0
- package/src/daemon/routes/export-import.ts +54 -0
- package/src/daemon/routes/replay.ts +22 -0
- package/src/daemon/routes/scheduler.ts +38 -0
- package/src/daemon/routes/snapshots.ts +23 -0
- package/src/daemon/server.ts +21 -0
- package/src/daemon/services/batch.ts +69 -0
- package/src/daemon/services/capability-discovery.ts +130 -0
- package/src/daemon/services/export-import.ts +62 -0
- package/src/daemon/services/replay.ts +53 -0
- package/src/daemon/services/scheduler.ts +115 -0
- package/src/daemon/services/snapshot.ts +82 -0
- package/src/daemon/services/task-chain.ts +93 -0
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { HttpClient } from '../http-client.js';
|
|
3
|
+
|
|
4
|
+
export function registerDashboardCommand(program: Command): void {
|
|
5
|
+
program
|
|
6
|
+
.command('dashboard')
|
|
7
|
+
.alias('dash')
|
|
8
|
+
.description('Live terminal dashboard — refresh every 3 seconds')
|
|
9
|
+
.option('--once', 'Print once and exit')
|
|
10
|
+
.action(async (options: { once?: boolean }) => {
|
|
11
|
+
const client = new HttpClient();
|
|
12
|
+
|
|
13
|
+
const render = async () => {
|
|
14
|
+
try {
|
|
15
|
+
const [metrics, agents, costs] = await Promise.all([
|
|
16
|
+
client.get<any>('/metrics').catch(() => null),
|
|
17
|
+
client.get<any[]>('/agents').catch(() => []),
|
|
18
|
+
client.get<any>('/costs').catch(() => null),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
console.clear();
|
|
22
|
+
console.log('\x1b[36m╔══════════════════════════════════════════════╗\x1b[0m');
|
|
23
|
+
console.log('\x1b[36m║\x1b[0m \x1b[1mAGW Dashboard\x1b[0m \x1b[36m║\x1b[0m');
|
|
24
|
+
console.log('\x1b[36m╚══════════════════════════════════════════════╝\x1b[0m');
|
|
25
|
+
|
|
26
|
+
if (metrics) {
|
|
27
|
+
const up = Math.floor(metrics.uptime / 1000);
|
|
28
|
+
const h = Math.floor(up / 3600);
|
|
29
|
+
const m = Math.floor((up % 3600) / 60);
|
|
30
|
+
const s = up % 60;
|
|
31
|
+
console.log(`\n Uptime: ${h}h ${m}m ${s}s Memory: ${metrics.memory?.heapMB ?? '?'}MB`);
|
|
32
|
+
console.log(`\n \x1b[1mTasks\x1b[0m`);
|
|
33
|
+
console.log(` Total: ${metrics.tasks.total} ✓ ${metrics.tasks.completed} ✗ ${metrics.tasks.failed} ⟳ ${metrics.tasks.running} ⏳ ${metrics.tasks.pending ?? 0}`);
|
|
34
|
+
if (metrics.performance.avgDurationMs > 0) {
|
|
35
|
+
console.log(` Avg: ${(metrics.performance.avgDurationMs / 1000).toFixed(1)}s P95: ${(metrics.performance.p95DurationMs / 1000).toFixed(1)}s`);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
console.log('\n \x1b[31mDaemon not running\x1b[0m');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`\n \x1b[1mAgents\x1b[0m`);
|
|
42
|
+
for (const a of agents) {
|
|
43
|
+
const icon = a.available ? '\x1b[32m●\x1b[0m' : '\x1b[31m●\x1b[0m';
|
|
44
|
+
console.log(` ${icon} ${a.name.padEnd(12)} ${a.available ? 'Ready' : 'Down'}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (costs) {
|
|
48
|
+
console.log(`\n \x1b[1mCosts\x1b[0m`);
|
|
49
|
+
console.log(` Daily: $${costs.daily.toFixed(2)}${costs.dailyLimit ? ` / $${costs.dailyLimit.toFixed(2)}` : ''}`);
|
|
50
|
+
console.log(` Monthly: $${costs.monthly.toFixed(2)}${costs.monthlyLimit ? ` / $${costs.monthlyLimit.toFixed(2)}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!options.once) {
|
|
54
|
+
console.log(`\n \x1b[2mRefreshing every 3s... (Ctrl+C to quit)\x1b[0m`);
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
console.log('\x1b[31m Cannot connect to daemon\x1b[0m');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
await render();
|
|
62
|
+
if (options.once) return;
|
|
63
|
+
|
|
64
|
+
const interval = setInterval(render, 3000);
|
|
65
|
+
process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -7,13 +7,14 @@ import { registerDaemonCommand } from './commands/daemon.js';
|
|
|
7
7
|
import { registerCostsCommand } from './commands/costs.js';
|
|
8
8
|
import { registerWorkflowCommand } from './commands/workflow.js';
|
|
9
9
|
import { registerComboCommand } from './commands/combo.js';
|
|
10
|
+
import { registerDashboardCommand } from './commands/dashboard.js';
|
|
10
11
|
|
|
11
12
|
export function createCli(): Command {
|
|
12
13
|
const program = new Command();
|
|
13
14
|
program
|
|
14
15
|
.name('agw')
|
|
15
16
|
.description('Agent Gateway — route tasks to the best AI agent')
|
|
16
|
-
.version('
|
|
17
|
+
.version('1.7.0');
|
|
17
18
|
|
|
18
19
|
registerRunCommand(program);
|
|
19
20
|
registerStatusCommand(program);
|
|
@@ -23,6 +24,7 @@ export function createCli(): Command {
|
|
|
23
24
|
registerCostsCommand(program);
|
|
24
25
|
registerWorkflowCommand(program);
|
|
25
26
|
registerComboCommand(program);
|
|
27
|
+
registerDashboardCommand(program);
|
|
26
28
|
|
|
27
29
|
return program;
|
|
28
30
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { TaskExecutor } from '../services/task-executor.js';
|
|
3
|
+
import type { LlmRouter } from '../../router/llm-router.js';
|
|
4
|
+
import type { AgentManager } from '../services/agent-manager.js';
|
|
5
|
+
import { BatchExecutor } from '../services/batch.js';
|
|
6
|
+
import type { BatchItem } from '../services/batch.js';
|
|
7
|
+
|
|
8
|
+
export function registerBatchRoutes(
|
|
9
|
+
app: FastifyInstance,
|
|
10
|
+
executor: TaskExecutor,
|
|
11
|
+
router: LlmRouter,
|
|
12
|
+
agentManager: AgentManager,
|
|
13
|
+
): void {
|
|
14
|
+
const batchExecutor = new BatchExecutor();
|
|
15
|
+
|
|
16
|
+
app.post<{ Body: { items: BatchItem[]; concurrency?: number } }>('/batch', async (request, reply) => {
|
|
17
|
+
const { items, concurrency } = request.body;
|
|
18
|
+
if (!items || !Array.isArray(items) || items.length === 0) {
|
|
19
|
+
return reply.status(400).send({ error: 'items array is required' });
|
|
20
|
+
}
|
|
21
|
+
if (items.length > 50) {
|
|
22
|
+
return reply.status(400).send({ error: 'Maximum 50 items per batch' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = await batchExecutor.execute(
|
|
26
|
+
items,
|
|
27
|
+
async (prompt, agent, priority) => {
|
|
28
|
+
const availableAgents = agentManager.getAvailableAgents();
|
|
29
|
+
const task = await executor.execute(
|
|
30
|
+
{ prompt, preferredAgent: agent, priority },
|
|
31
|
+
async (p) => router.route(p, availableAgents, agent),
|
|
32
|
+
);
|
|
33
|
+
return {
|
|
34
|
+
taskId: task.taskId,
|
|
35
|
+
status: task.status,
|
|
36
|
+
stdout: task.result?.stdout,
|
|
37
|
+
error: task.status === 'failed' ? task.result?.stderr : undefined,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
concurrency ?? 5,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return reply.status(200).send(result);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { CapabilityDiscovery } from '../services/capability-discovery.js';
|
|
3
|
+
|
|
4
|
+
export function registerCapabilityRoutes(app: FastifyInstance, discovery: CapabilityDiscovery): void {
|
|
5
|
+
app.get('/capabilities', async () => discovery.getAll());
|
|
6
|
+
|
|
7
|
+
app.get<{ Params: { agentId: string } }>('/capabilities/:agentId', async (request, reply) => {
|
|
8
|
+
const cap = discovery.get(request.params.agentId);
|
|
9
|
+
if (!cap) return reply.status(404).send({ error: 'Agent not found' });
|
|
10
|
+
return cap;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.post<{ Body: { prompt: string; availableAgents?: string[] } }>('/capabilities/match', async (request) => {
|
|
14
|
+
const agents = request.body.availableAgents ?? ['claude', 'codex', 'gemini'];
|
|
15
|
+
return discovery.findBestMatch(request.body.prompt, agents) ?? { agentId: agents[0], score: 0, reason: 'Fallback' };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { TemplateEngine } from '../services/template-engine.js';
|
|
3
|
+
import type { WebhookManager } from '../services/webhook-manager.js';
|
|
4
|
+
import type { Scheduler } from '../services/scheduler.js';
|
|
5
|
+
import type { MemoryRepo } from '../../store/memory-repo.js';
|
|
6
|
+
import { COMBO_PRESETS } from '../services/combo-executor.js';
|
|
7
|
+
import { createExport, validateImport } from '../services/export-import.js';
|
|
8
|
+
|
|
9
|
+
export function registerExportImportRoutes(
|
|
10
|
+
app: FastifyInstance,
|
|
11
|
+
templateEngine: TemplateEngine,
|
|
12
|
+
webhookManager: WebhookManager,
|
|
13
|
+
scheduler: Scheduler,
|
|
14
|
+
memoryRepo: MemoryRepo,
|
|
15
|
+
): void {
|
|
16
|
+
app.get('/export', async () => {
|
|
17
|
+
return createExport({
|
|
18
|
+
templates: templateEngine.list(),
|
|
19
|
+
comboPresets: COMBO_PRESETS,
|
|
20
|
+
webhooks: webhookManager.getWebhooks(),
|
|
21
|
+
memory: memoryRepo.list(1000),
|
|
22
|
+
scheduledJobs: scheduler.listJobs(),
|
|
23
|
+
version: '1.6.0',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.post('/import', async (request, reply) => {
|
|
28
|
+
const data = request.body;
|
|
29
|
+
if (!validateImport(data)) {
|
|
30
|
+
return reply.status(400).send({ error: 'Invalid import format' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let imported = { templates: 0, memory: 0, jobs: 0 };
|
|
34
|
+
|
|
35
|
+
for (const t of data.templates) {
|
|
36
|
+
templateEngine.register(t);
|
|
37
|
+
imported.templates++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const m of data.memory) {
|
|
41
|
+
memoryRepo.set(m.key, m.value, m.scope);
|
|
42
|
+
imported.memory++;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const j of data.scheduledJobs) {
|
|
46
|
+
try {
|
|
47
|
+
scheduler.addJob(j);
|
|
48
|
+
imported.jobs++;
|
|
49
|
+
} catch { /* skip invalid */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { imported };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { ReplayManager } from '../services/replay.js';
|
|
3
|
+
|
|
4
|
+
export function registerReplayRoutes(app: FastifyInstance, replayManager: ReplayManager): void {
|
|
5
|
+
app.post<{ Params: { id: string } }>('/tasks/:id/replay', async (request, reply) => {
|
|
6
|
+
try {
|
|
7
|
+
const task = await replayManager.replayTask(request.params.id);
|
|
8
|
+
return reply.status(201).send(task);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return reply.status(404).send({ error: (err as Error).message });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
app.post<{ Params: { id: string } }>('/combos/:id/replay', async (request, reply) => {
|
|
15
|
+
try {
|
|
16
|
+
const comboId = replayManager.replayCombo(request.params.id);
|
|
17
|
+
return reply.status(202).send({ comboId, status: 'replaying' });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return reply.status(404).send({ error: (err as Error).message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { Scheduler } from '../services/scheduler.js';
|
|
3
|
+
|
|
4
|
+
export function registerSchedulerRoutes(app: FastifyInstance, scheduler: Scheduler): void {
|
|
5
|
+
app.get('/scheduler/jobs', async () => scheduler.listJobs());
|
|
6
|
+
|
|
7
|
+
app.get<{ Params: { id: string } }>('/scheduler/jobs/:id', async (request, reply) => {
|
|
8
|
+
const job = scheduler.getJob(request.params.id);
|
|
9
|
+
if (!job) return reply.status(404).send({ error: 'Job not found' });
|
|
10
|
+
return job;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.post('/scheduler/jobs', async (request, reply) => {
|
|
14
|
+
const body = request.body as any;
|
|
15
|
+
try {
|
|
16
|
+
const job = scheduler.addJob(body);
|
|
17
|
+
return reply.status(201).send(job);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return reply.status(400).send({ error: (err as Error).message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.delete<{ Params: { id: string } }>('/scheduler/jobs/:id', async (request, reply) => {
|
|
24
|
+
const removed = scheduler.removeJob(request.params.id);
|
|
25
|
+
if (!removed) return reply.status(404).send({ error: 'Job not found' });
|
|
26
|
+
return { removed: true };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.post<{ Params: { id: string } }>('/scheduler/jobs/:id/enable', async (request, reply) => {
|
|
30
|
+
if (!scheduler.enableJob(request.params.id)) return reply.status(404).send({ error: 'Job not found' });
|
|
31
|
+
return { enabled: true };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
app.post<{ Params: { id: string } }>('/scheduler/jobs/:id/disable', async (request, reply) => {
|
|
35
|
+
if (!scheduler.disableJob(request.params.id)) return reply.status(404).send({ error: 'Job not found' });
|
|
36
|
+
return { disabled: true };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { SnapshotManager } from '../services/snapshot.js';
|
|
3
|
+
|
|
4
|
+
export function registerSnapshotRoutes(app: FastifyInstance, snapshotManager: SnapshotManager): void {
|
|
5
|
+
app.get('/snapshots', async () => snapshotManager.list());
|
|
6
|
+
|
|
7
|
+
app.post<{ Body: { label?: string } }>('/snapshots', async (request, reply) => {
|
|
8
|
+
const info = snapshotManager.create(request.body?.label);
|
|
9
|
+
return reply.status(201).send(info);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
app.post<{ Params: { id: string } }>('/snapshots/:id/restore', async (request, reply) => {
|
|
13
|
+
const ok = snapshotManager.restore(request.params.id);
|
|
14
|
+
if (!ok) return reply.status(404).send({ error: 'Snapshot not found' });
|
|
15
|
+
return { restored: true, note: 'Restart daemon to use restored data' };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.delete<{ Params: { id: string } }>('/snapshots/:id', async (request, reply) => {
|
|
19
|
+
const ok = snapshotManager.delete(request.params.id);
|
|
20
|
+
if (!ok) return reply.status(404).send({ error: 'Snapshot not found' });
|
|
21
|
+
return { deleted: true };
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -30,6 +30,16 @@ import { registerMemoryRoutes } from './routes/memory.js';
|
|
|
30
30
|
import { registerHealthRoutes } from './routes/health.js';
|
|
31
31
|
import { registerTemplateRoutes } from './routes/templates.js';
|
|
32
32
|
import { registerWebhookRoutes } from './routes/webhooks.js';
|
|
33
|
+
import { Scheduler } from './services/scheduler.js';
|
|
34
|
+
import { ReplayManager } from './services/replay.js';
|
|
35
|
+
import { registerSchedulerRoutes } from './routes/scheduler.js';
|
|
36
|
+
import { registerReplayRoutes } from './routes/replay.js';
|
|
37
|
+
import { registerExportImportRoutes } from './routes/export-import.js';
|
|
38
|
+
import { CapabilityDiscovery } from './services/capability-discovery.js';
|
|
39
|
+
import { SnapshotManager } from './services/snapshot.js';
|
|
40
|
+
import { registerCapabilityRoutes } from './routes/capabilities.js';
|
|
41
|
+
import { registerBatchRoutes } from './routes/batch.js';
|
|
42
|
+
import { registerSnapshotRoutes } from './routes/snapshots.js';
|
|
33
43
|
|
|
34
44
|
interface ServerOptions {
|
|
35
45
|
dbPath?: string;
|
|
@@ -65,6 +75,10 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
65
75
|
const templateEngine = new TemplateEngine();
|
|
66
76
|
templateEngine.seedDefaults();
|
|
67
77
|
const webhookManager = new WebhookManager();
|
|
78
|
+
const scheduler = new Scheduler();
|
|
79
|
+
const replayManager = new ReplayManager(taskRepo, comboRepo, executor, comboExecutor, router, agentManager);
|
|
80
|
+
const capDiscovery = new CapabilityDiscovery();
|
|
81
|
+
const snapshotManager = new SnapshotManager(dbPath);
|
|
68
82
|
|
|
69
83
|
const app = Fastify({
|
|
70
84
|
logger: false,
|
|
@@ -82,6 +96,12 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
82
96
|
registerHealthRoutes(app, metrics, agentManager, cbRegistry, taskRepo, costRepo, config);
|
|
83
97
|
registerTemplateRoutes(app, templateEngine, executor, router, agentManager);
|
|
84
98
|
registerWebhookRoutes(app, webhookManager);
|
|
99
|
+
registerSchedulerRoutes(app, scheduler);
|
|
100
|
+
registerReplayRoutes(app, replayManager);
|
|
101
|
+
registerExportImportRoutes(app, templateEngine, webhookManager, scheduler, memoryRepo);
|
|
102
|
+
registerCapabilityRoutes(app, capDiscovery);
|
|
103
|
+
registerBatchRoutes(app, executor, router, agentManager);
|
|
104
|
+
registerSnapshotRoutes(app, snapshotManager);
|
|
85
105
|
|
|
86
106
|
app.register(import('./routes/ui.js'));
|
|
87
107
|
|
|
@@ -93,6 +113,7 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
93
113
|
taskRepo.updateStatus(t.taskId, 'failed');
|
|
94
114
|
auditRepo.log(t.taskId, 'task.failed', { reason: 'daemon shutdown' });
|
|
95
115
|
}
|
|
116
|
+
scheduler.stopAll();
|
|
96
117
|
db.close();
|
|
97
118
|
});
|
|
98
119
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch Execution — submit multiple tasks at once, get unified results.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { nanoid } from 'nanoid';
|
|
6
|
+
|
|
7
|
+
export interface BatchItem {
|
|
8
|
+
prompt: string;
|
|
9
|
+
agent?: string;
|
|
10
|
+
priority?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BatchResult {
|
|
14
|
+
batchId: string;
|
|
15
|
+
total: number;
|
|
16
|
+
completed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
results: { index: number; taskId: string; status: string; output?: string; error?: string }[];
|
|
19
|
+
totalDurationMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ExecuteFn = (prompt: string, agent?: string, priority?: number) => Promise<{ taskId: string; status: string; stdout?: string; error?: string }>;
|
|
23
|
+
|
|
24
|
+
export class BatchExecutor {
|
|
25
|
+
async execute(items: BatchItem[], executeFn: ExecuteFn, concurrency: number = 5): Promise<BatchResult> {
|
|
26
|
+
const batchId = nanoid(8);
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
const results: BatchResult['results'] = [];
|
|
29
|
+
|
|
30
|
+
// Process in chunks for concurrency control
|
|
31
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
32
|
+
const chunk = items.slice(i, i + concurrency);
|
|
33
|
+
const chunkResults = await Promise.allSettled(
|
|
34
|
+
chunk.map(async (item, offset) => {
|
|
35
|
+
const result = await executeFn(item.prompt, item.agent, item.priority);
|
|
36
|
+
return {
|
|
37
|
+
index: i + offset,
|
|
38
|
+
taskId: result.taskId,
|
|
39
|
+
status: result.status,
|
|
40
|
+
output: result.stdout,
|
|
41
|
+
error: result.error,
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
for (const r of chunkResults) {
|
|
47
|
+
if (r.status === 'fulfilled') {
|
|
48
|
+
results.push(r.value);
|
|
49
|
+
} else {
|
|
50
|
+
results.push({
|
|
51
|
+
index: results.length,
|
|
52
|
+
taskId: '',
|
|
53
|
+
status: 'failed',
|
|
54
|
+
error: r.reason?.message ?? 'Unknown error',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
batchId,
|
|
62
|
+
total: items.length,
|
|
63
|
+
completed: results.filter(r => r.status === 'completed').length,
|
|
64
|
+
failed: results.filter(r => r.status === 'failed').length,
|
|
65
|
+
results,
|
|
66
|
+
totalDurationMs: Date.now() - start,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability Discovery — probes agents to determine what they can do.
|
|
3
|
+
* Each agent is asked to self-describe its capabilities.
|
|
4
|
+
* Results are cached and used for smarter routing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface AgentCapability {
|
|
8
|
+
agentId: string;
|
|
9
|
+
strengths: string[];
|
|
10
|
+
weaknesses: string[];
|
|
11
|
+
maxContextLength?: number;
|
|
12
|
+
supportsStreaming: boolean;
|
|
13
|
+
supportsImages: boolean;
|
|
14
|
+
languages: string[];
|
|
15
|
+
specializations: string[];
|
|
16
|
+
discoveredAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const KNOWN_CAPABILITIES: Record<string, AgentCapability> = {
|
|
20
|
+
claude: {
|
|
21
|
+
agentId: 'claude',
|
|
22
|
+
strengths: ['complex reasoning', 'code review', 'architecture', 'refactoring', 'explanation', 'security analysis', 'long context'],
|
|
23
|
+
weaknesses: ['real-time data', 'running arbitrary shell commands autonomously'],
|
|
24
|
+
maxContextLength: 1_000_000,
|
|
25
|
+
supportsStreaming: true,
|
|
26
|
+
supportsImages: true,
|
|
27
|
+
languages: ['typescript', 'python', 'rust', 'go', 'java', 'c++', 'sql', 'bash'],
|
|
28
|
+
specializations: ['code-review', 'architecture', 'debugging', 'documentation', 'security-audit'],
|
|
29
|
+
discoveredAt: new Date().toISOString(),
|
|
30
|
+
},
|
|
31
|
+
codex: {
|
|
32
|
+
agentId: 'codex',
|
|
33
|
+
strengths: ['code generation', 'file operations', 'shell commands', 'quick edits', 'scripting', 'automation'],
|
|
34
|
+
weaknesses: ['long context reasoning', 'multi-file architecture decisions'],
|
|
35
|
+
maxContextLength: 200_000,
|
|
36
|
+
supportsStreaming: true,
|
|
37
|
+
supportsImages: false,
|
|
38
|
+
languages: ['typescript', 'python', 'rust', 'go', 'bash', 'sql'],
|
|
39
|
+
specializations: ['implementation', 'scripting', 'file-ops', 'testing', 'automation'],
|
|
40
|
+
discoveredAt: new Date().toISOString(),
|
|
41
|
+
},
|
|
42
|
+
gemini: {
|
|
43
|
+
agentId: 'gemini',
|
|
44
|
+
strengths: ['research', 'web search', 'multimodal', 'summarization', 'comparison', 'broad knowledge'],
|
|
45
|
+
weaknesses: ['precise code editing', 'complex refactoring'],
|
|
46
|
+
maxContextLength: 1_000_000,
|
|
47
|
+
supportsStreaming: true,
|
|
48
|
+
supportsImages: true,
|
|
49
|
+
languages: ['typescript', 'python', 'java', 'go', 'kotlin'],
|
|
50
|
+
specializations: ['research', 'summarization', 'comparison', 'multimodal-analysis'],
|
|
51
|
+
discoveredAt: new Date().toISOString(),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class CapabilityDiscovery {
|
|
56
|
+
private capabilities = new Map<string, AgentCapability>();
|
|
57
|
+
|
|
58
|
+
constructor() {
|
|
59
|
+
// Seed known capabilities
|
|
60
|
+
for (const [id, cap] of Object.entries(KNOWN_CAPABILITIES)) {
|
|
61
|
+
this.capabilities.set(id, cap);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(agentId: string): AgentCapability | undefined {
|
|
66
|
+
return this.capabilities.get(agentId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getAll(): AgentCapability[] {
|
|
70
|
+
return Array.from(this.capabilities.values());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Find the best agent for a given task description */
|
|
74
|
+
findBestMatch(taskDescription: string, availableAgentIds: string[]): { agentId: string; score: number; reason: string } | undefined {
|
|
75
|
+
const lower = taskDescription.toLowerCase();
|
|
76
|
+
let bestMatch: { agentId: string; score: number; reason: string } | undefined;
|
|
77
|
+
|
|
78
|
+
for (const agentId of availableAgentIds) {
|
|
79
|
+
const cap = this.capabilities.get(agentId);
|
|
80
|
+
if (!cap) continue;
|
|
81
|
+
|
|
82
|
+
let score = 0;
|
|
83
|
+
const matchedStrengths: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const strength of cap.strengths) {
|
|
86
|
+
const words = strength.toLowerCase().split(/\s+/);
|
|
87
|
+
for (const word of words) {
|
|
88
|
+
if (word.length > 3 && lower.includes(word)) {
|
|
89
|
+
score += 2;
|
|
90
|
+
matchedStrengths.push(strength);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const spec of cap.specializations) {
|
|
97
|
+
if (lower.includes(spec.replace('-', ' ')) || lower.includes(spec)) {
|
|
98
|
+
score += 3;
|
|
99
|
+
matchedStrengths.push(`specialization: ${spec}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Penalty for weaknesses
|
|
104
|
+
for (const weakness of cap.weaknesses) {
|
|
105
|
+
const words = weakness.toLowerCase().split(/\s+/);
|
|
106
|
+
for (const word of words) {
|
|
107
|
+
if (word.length > 4 && lower.includes(word)) {
|
|
108
|
+
score -= 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!bestMatch || score > bestMatch.score) {
|
|
114
|
+
bestMatch = {
|
|
115
|
+
agentId,
|
|
116
|
+
score,
|
|
117
|
+
reason: matchedStrengths.length > 0
|
|
118
|
+
? `Matched: ${matchedStrengths.slice(0, 3).join(', ')}`
|
|
119
|
+
: 'Default selection',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return bestMatch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
register(cap: AgentCapability): void {
|
|
128
|
+
this.capabilities.set(cap.agentId, cap);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export/Import — serialize AGW configuration and data for sharing/backup.
|
|
3
|
+
*
|
|
4
|
+
* Exports: templates, combo presets, webhooks, memory entries, scheduled jobs.
|
|
5
|
+
* Does NOT export: tasks, audit logs, cost records (runtime data).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TaskTemplate } from './template-engine.js';
|
|
9
|
+
import type { WebhookConfig } from './webhook-manager.js';
|
|
10
|
+
import type { ScheduledJob } from './scheduler.js';
|
|
11
|
+
import type { MemoryEntry } from '../../store/memory-repo.js';
|
|
12
|
+
import type { ComboPreset } from '../../types.js';
|
|
13
|
+
|
|
14
|
+
export interface AgwExport {
|
|
15
|
+
version: string;
|
|
16
|
+
exportedAt: string;
|
|
17
|
+
templates: TaskTemplate[];
|
|
18
|
+
comboPresets: ComboPreset[];
|
|
19
|
+
webhooks: WebhookConfig[];
|
|
20
|
+
memory: MemoryEntry[];
|
|
21
|
+
scheduledJobs: Omit<ScheduledJob, 'id' | 'intervalMs' | 'nextRun' | 'runCount' | 'lastRun'>[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createExport(data: {
|
|
25
|
+
templates: TaskTemplate[];
|
|
26
|
+
comboPresets: ComboPreset[];
|
|
27
|
+
webhooks: WebhookConfig[];
|
|
28
|
+
memory: MemoryEntry[];
|
|
29
|
+
scheduledJobs: ScheduledJob[];
|
|
30
|
+
version: string;
|
|
31
|
+
}): AgwExport {
|
|
32
|
+
return {
|
|
33
|
+
version: data.version,
|
|
34
|
+
exportedAt: new Date().toISOString(),
|
|
35
|
+
templates: data.templates,
|
|
36
|
+
comboPresets: data.comboPresets,
|
|
37
|
+
webhooks: data.webhooks.map(w => ({ ...w, secret: undefined })), // Strip secrets
|
|
38
|
+
memory: data.memory,
|
|
39
|
+
scheduledJobs: data.scheduledJobs.map(j => ({
|
|
40
|
+
name: j.name,
|
|
41
|
+
type: j.type,
|
|
42
|
+
target: j.target,
|
|
43
|
+
params: j.params,
|
|
44
|
+
interval: j.interval,
|
|
45
|
+
agent: j.agent,
|
|
46
|
+
priority: j.priority,
|
|
47
|
+
workingDirectory: j.workingDirectory,
|
|
48
|
+
enabled: j.enabled,
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateImport(data: unknown): data is AgwExport {
|
|
54
|
+
if (!data || typeof data !== 'object') return false;
|
|
55
|
+
const d = data as Record<string, unknown>;
|
|
56
|
+
return (
|
|
57
|
+
typeof d.version === 'string' &&
|
|
58
|
+
typeof d.exportedAt === 'string' &&
|
|
59
|
+
Array.isArray(d.templates) &&
|
|
60
|
+
Array.isArray(d.memory)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay — re-run a completed/failed task or combo with the same parameters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TaskDescriptor, ComboDescriptor, CreateComboRequest } from '../../types.js';
|
|
6
|
+
import type { TaskExecutor } from './task-executor.js';
|
|
7
|
+
import type { ComboExecutor } from './combo-executor.js';
|
|
8
|
+
import type { TaskRepo } from '../../store/task-repo.js';
|
|
9
|
+
import type { ComboRepo } from '../../store/combo-repo.js';
|
|
10
|
+
import type { LlmRouter } from '../../router/llm-router.js';
|
|
11
|
+
import type { AgentManager } from './agent-manager.js';
|
|
12
|
+
|
|
13
|
+
export class ReplayManager {
|
|
14
|
+
constructor(
|
|
15
|
+
private taskRepo: TaskRepo,
|
|
16
|
+
private comboRepo: ComboRepo,
|
|
17
|
+
private taskExecutor: TaskExecutor,
|
|
18
|
+
private comboExecutor: ComboExecutor,
|
|
19
|
+
private router: LlmRouter,
|
|
20
|
+
private agentManager: AgentManager,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async replayTask(taskId: string): Promise<TaskDescriptor> {
|
|
24
|
+
const original = this.taskRepo.getById(taskId);
|
|
25
|
+
if (!original) throw new Error(`Task ${taskId} not found`);
|
|
26
|
+
|
|
27
|
+
const availableAgents = this.agentManager.getAvailableAgents();
|
|
28
|
+
return this.taskExecutor.execute(
|
|
29
|
+
{
|
|
30
|
+
prompt: original.prompt,
|
|
31
|
+
preferredAgent: original.assignedAgent,
|
|
32
|
+
workingDirectory: original.workingDirectory,
|
|
33
|
+
priority: original.priority,
|
|
34
|
+
},
|
|
35
|
+
async (p) => this.router.route(p, availableAgents, original.assignedAgent),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
replayCombo(comboId: string): string {
|
|
40
|
+
const original = this.comboRepo.getById(comboId);
|
|
41
|
+
if (!original) throw new Error(`Combo ${comboId} not found`);
|
|
42
|
+
|
|
43
|
+
const request: CreateComboRequest = {
|
|
44
|
+
name: `${original.name} (replay)`,
|
|
45
|
+
pattern: original.pattern,
|
|
46
|
+
steps: original.steps,
|
|
47
|
+
input: original.input,
|
|
48
|
+
maxIterations: original.maxIterations,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return this.comboExecutor.start(request);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — run tasks, combos, or templates on cron-like intervals.
|
|
3
|
+
*
|
|
4
|
+
* Supports: interval-based scheduling (every N minutes/hours).
|
|
5
|
+
* Cron expressions are parsed as simple patterns: "every 5m", "every 1h", "every 30s".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { nanoid } from 'nanoid';
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
|
|
11
|
+
export interface ScheduledJob {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
type: 'task' | 'combo-preset' | 'template';
|
|
15
|
+
target: string; // prompt for task, presetId for combo, templateId for template
|
|
16
|
+
params?: Record<string, string>; // for templates
|
|
17
|
+
interval: string; // "every 5m", "every 1h"
|
|
18
|
+
intervalMs: number; // parsed interval in ms
|
|
19
|
+
agent?: string;
|
|
20
|
+
priority?: number;
|
|
21
|
+
workingDirectory?: string;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
lastRun?: string;
|
|
24
|
+
nextRun: string;
|
|
25
|
+
runCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseInterval(expr: string): number {
|
|
29
|
+
const match = expr.match(/^every\s+(\d+)\s*(s|m|h|d)$/i);
|
|
30
|
+
if (!match) throw new Error(`Invalid interval: "${expr}". Use "every Ns/m/h/d"`);
|
|
31
|
+
|
|
32
|
+
const value = parseInt(match[1], 10);
|
|
33
|
+
const unit = match[2].toLowerCase();
|
|
34
|
+
const multipliers: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
35
|
+
return value * multipliers[unit];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Scheduler extends EventEmitter {
|
|
39
|
+
private jobs = new Map<string, ScheduledJob>();
|
|
40
|
+
private timers = new Map<string, NodeJS.Timeout>();
|
|
41
|
+
|
|
42
|
+
addJob(job: Omit<ScheduledJob, 'id' | 'intervalMs' | 'nextRun' | 'runCount'>): ScheduledJob {
|
|
43
|
+
const id = nanoid(8);
|
|
44
|
+
const intervalMs = parseInterval(job.interval);
|
|
45
|
+
const scheduled: ScheduledJob = {
|
|
46
|
+
...job,
|
|
47
|
+
id,
|
|
48
|
+
intervalMs,
|
|
49
|
+
nextRun: new Date(Date.now() + intervalMs).toISOString(),
|
|
50
|
+
runCount: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
this.jobs.set(id, scheduled);
|
|
54
|
+
|
|
55
|
+
if (scheduled.enabled) {
|
|
56
|
+
this.startTimer(scheduled);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return scheduled;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
removeJob(id: string): boolean {
|
|
63
|
+
const timer = this.timers.get(id);
|
|
64
|
+
if (timer) { clearInterval(timer); this.timers.delete(id); }
|
|
65
|
+
return this.jobs.delete(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
enableJob(id: string): boolean {
|
|
69
|
+
const job = this.jobs.get(id);
|
|
70
|
+
if (!job) return false;
|
|
71
|
+
job.enabled = true;
|
|
72
|
+
this.startTimer(job);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
disableJob(id: string): boolean {
|
|
77
|
+
const job = this.jobs.get(id);
|
|
78
|
+
if (!job) return false;
|
|
79
|
+
job.enabled = false;
|
|
80
|
+
const timer = this.timers.get(id);
|
|
81
|
+
if (timer) { clearInterval(timer); this.timers.delete(id); }
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getJob(id: string): ScheduledJob | undefined {
|
|
86
|
+
return this.jobs.get(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
listJobs(): ScheduledJob[] {
|
|
90
|
+
return Array.from(this.jobs.values());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private startTimer(job: ScheduledJob): void {
|
|
94
|
+
const existing = this.timers.get(job.id);
|
|
95
|
+
if (existing) clearInterval(existing);
|
|
96
|
+
|
|
97
|
+
const timer = setInterval(() => {
|
|
98
|
+
job.runCount++;
|
|
99
|
+
job.lastRun = new Date().toISOString();
|
|
100
|
+
job.nextRun = new Date(Date.now() + job.intervalMs).toISOString();
|
|
101
|
+
this.emit('job:trigger', job);
|
|
102
|
+
}, job.intervalMs);
|
|
103
|
+
|
|
104
|
+
// Don't keep process alive just for scheduling
|
|
105
|
+
timer.unref();
|
|
106
|
+
this.timers.set(job.id, timer);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stopAll(): void {
|
|
110
|
+
for (const [id, timer] of this.timers) {
|
|
111
|
+
clearInterval(timer);
|
|
112
|
+
}
|
|
113
|
+
this.timers.clear();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot — full system state backup and restore.
|
|
3
|
+
* Copies the entire SQLite database file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const SNAPSHOT_DIR = path.join(os.homedir(), '.agw', 'snapshots');
|
|
11
|
+
|
|
12
|
+
export interface SnapshotInfo {
|
|
13
|
+
id: string;
|
|
14
|
+
filename: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SnapshotManager {
|
|
20
|
+
private dbPath: string;
|
|
21
|
+
|
|
22
|
+
constructor(dbPath: string) {
|
|
23
|
+
this.dbPath = dbPath;
|
|
24
|
+
if (!fs.existsSync(SNAPSHOT_DIR)) {
|
|
25
|
+
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
create(label?: string): SnapshotInfo {
|
|
30
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
31
|
+
const id = label ? `${label}-${timestamp}` : timestamp;
|
|
32
|
+
const filename = `snapshot-${id}.db`;
|
|
33
|
+
const destPath = path.join(SNAPSHOT_DIR, filename);
|
|
34
|
+
|
|
35
|
+
fs.copyFileSync(this.dbPath, destPath);
|
|
36
|
+
|
|
37
|
+
const stat = fs.statSync(destPath);
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
filename,
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
sizeBytes: stat.size,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
restore(id: string): boolean {
|
|
47
|
+
const filename = `snapshot-${id}.db`;
|
|
48
|
+
const srcPath = path.join(SNAPSHOT_DIR, filename);
|
|
49
|
+
if (!fs.existsSync(srcPath)) return false;
|
|
50
|
+
|
|
51
|
+
// Backup current before overwriting
|
|
52
|
+
const backupPath = `${this.dbPath}.pre-restore`;
|
|
53
|
+
fs.copyFileSync(this.dbPath, backupPath);
|
|
54
|
+
fs.copyFileSync(srcPath, this.dbPath);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list(): SnapshotInfo[] {
|
|
59
|
+
if (!fs.existsSync(SNAPSHOT_DIR)) return [];
|
|
60
|
+
return fs.readdirSync(SNAPSHOT_DIR)
|
|
61
|
+
.filter(f => f.startsWith('snapshot-') && f.endsWith('.db'))
|
|
62
|
+
.map(f => {
|
|
63
|
+
const stat = fs.statSync(path.join(SNAPSHOT_DIR, f));
|
|
64
|
+
const id = f.replace(/^snapshot-/, '').replace(/\.db$/, '');
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
filename: f,
|
|
68
|
+
createdAt: stat.mtime.toISOString(),
|
|
69
|
+
sizeBytes: stat.size,
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
delete(id: string): boolean {
|
|
76
|
+
const filename = `snapshot-${id}.db`;
|
|
77
|
+
const filePath = path.join(SNAPSHOT_DIR, filename);
|
|
78
|
+
if (!fs.existsSync(filePath)) return false;
|
|
79
|
+
fs.unlinkSync(filePath);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Chain — linked tasks with automatic rollback on failure.
|
|
3
|
+
* Each step can define a rollback action that runs if a later step fails.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { nanoid } from 'nanoid';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
|
|
9
|
+
export interface ChainStep {
|
|
10
|
+
prompt: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
rollbackPrompt?: string; // prompt to execute if this step needs rollback
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChainResult {
|
|
16
|
+
chainId: string;
|
|
17
|
+
status: 'completed' | 'failed' | 'rolled-back';
|
|
18
|
+
completedSteps: number;
|
|
19
|
+
failedAtStep?: number;
|
|
20
|
+
rolledBackSteps: number;
|
|
21
|
+
stepResults: { stepIndex: number; output: string; exitCode: number }[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ExecuteFn = (prompt: string, agent?: string) => Promise<{ stdout: string; exitCode: number }>;
|
|
25
|
+
|
|
26
|
+
export class TaskChain extends EventEmitter {
|
|
27
|
+
async execute(steps: ChainStep[], executeFn: ExecuteFn): Promise<ChainResult> {
|
|
28
|
+
const chainId = nanoid(12);
|
|
29
|
+
const stepResults: ChainResult['stepResults'] = [];
|
|
30
|
+
let failedAtStep: number | undefined;
|
|
31
|
+
|
|
32
|
+
this.emit('chain:start', chainId, steps.length);
|
|
33
|
+
|
|
34
|
+
// Execute forward
|
|
35
|
+
for (let i = 0; i < steps.length; i++) {
|
|
36
|
+
this.emit('chain:step', chainId, i, 'executing');
|
|
37
|
+
try {
|
|
38
|
+
const result = await executeFn(steps[i].prompt, steps[i].agent);
|
|
39
|
+
stepResults.push({ stepIndex: i, output: result.stdout, exitCode: result.exitCode });
|
|
40
|
+
|
|
41
|
+
if (result.exitCode !== 0) {
|
|
42
|
+
failedAtStep = i;
|
|
43
|
+
this.emit('chain:step', chainId, i, 'failed');
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
this.emit('chain:step', chainId, i, 'completed');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
stepResults.push({ stepIndex: i, output: (err as Error).message, exitCode: 1 });
|
|
49
|
+
failedAtStep = i;
|
|
50
|
+
this.emit('chain:step', chainId, i, 'failed');
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If all succeeded
|
|
56
|
+
if (failedAtStep === undefined) {
|
|
57
|
+
this.emit('chain:done', chainId, 'completed');
|
|
58
|
+
return {
|
|
59
|
+
chainId,
|
|
60
|
+
status: 'completed',
|
|
61
|
+
completedSteps: steps.length,
|
|
62
|
+
rolledBackSteps: 0,
|
|
63
|
+
stepResults,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rollback: execute rollback prompts in reverse order for completed steps
|
|
68
|
+
let rolledBack = 0;
|
|
69
|
+
for (let i = failedAtStep - 1; i >= 0; i--) {
|
|
70
|
+
if (!steps[i].rollbackPrompt) continue;
|
|
71
|
+
this.emit('chain:rollback', chainId, i);
|
|
72
|
+
try {
|
|
73
|
+
await executeFn(steps[i].rollbackPrompt!, steps[i].agent);
|
|
74
|
+
rolledBack++;
|
|
75
|
+
} catch {
|
|
76
|
+
// Rollback failure is logged but doesn't stop the rollback process
|
|
77
|
+
this.emit('chain:rollback-failed', chainId, i);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const status = rolledBack > 0 ? 'rolled-back' : 'failed';
|
|
82
|
+
this.emit('chain:done', chainId, status);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
chainId,
|
|
86
|
+
status,
|
|
87
|
+
completedSteps: failedAtStep,
|
|
88
|
+
failedAtStep,
|
|
89
|
+
rolledBackSteps: rolledBack,
|
|
90
|
+
stepResults,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|