@sooneocean/agw 1.6.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/snapshots.ts +23 -0
- package/src/daemon/server.ts +10 -0
- package/src/daemon/services/batch.ts +69 -0
- package/src/daemon/services/capability-discovery.ts +130 -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,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
|
@@ -35,6 +35,11 @@ import { ReplayManager } from './services/replay.js';
|
|
|
35
35
|
import { registerSchedulerRoutes } from './routes/scheduler.js';
|
|
36
36
|
import { registerReplayRoutes } from './routes/replay.js';
|
|
37
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';
|
|
38
43
|
|
|
39
44
|
interface ServerOptions {
|
|
40
45
|
dbPath?: string;
|
|
@@ -72,6 +77,8 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
72
77
|
const webhookManager = new WebhookManager();
|
|
73
78
|
const scheduler = new Scheduler();
|
|
74
79
|
const replayManager = new ReplayManager(taskRepo, comboRepo, executor, comboExecutor, router, agentManager);
|
|
80
|
+
const capDiscovery = new CapabilityDiscovery();
|
|
81
|
+
const snapshotManager = new SnapshotManager(dbPath);
|
|
75
82
|
|
|
76
83
|
const app = Fastify({
|
|
77
84
|
logger: false,
|
|
@@ -92,6 +99,9 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
|
|
|
92
99
|
registerSchedulerRoutes(app, scheduler);
|
|
93
100
|
registerReplayRoutes(app, replayManager);
|
|
94
101
|
registerExportImportRoutes(app, templateEngine, webhookManager, scheduler, memoryRepo);
|
|
102
|
+
registerCapabilityRoutes(app, capDiscovery);
|
|
103
|
+
registerBatchRoutes(app, executor, router, agentManager);
|
|
104
|
+
registerSnapshotRoutes(app, snapshotManager);
|
|
95
105
|
|
|
96
106
|
app.register(import('./routes/ui.js'));
|
|
97
107
|
|
|
@@ -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,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
|
+
}
|