@gonzih/agent-ops 0.1.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/.github/workflows/publish.yml +25 -0
- package/ARCHITECTURE.md +40 -0
- package/dist/control.d.ts +8 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/control.js +86 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/ops-bot.d.ts +3 -0
- package/dist/ops-bot.d.ts.map +1 -0
- package/dist/ops-bot.js +166 -0
- package/dist/registry.d.ts +20 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +36 -0
- package/package.json +22 -0
- package/src/control.ts +61 -0
- package/src/index.ts +4 -0
- package/src/ops-bot.ts +127 -0
- package/src/registry.ts +47 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish to GitHub Packages
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
packages: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: '20'
|
|
19
|
+
registry-url: 'https://npm.pkg.github.com'
|
|
20
|
+
scope: '@ecoclaw'
|
|
21
|
+
- run: npm ci
|
|
22
|
+
- run: npm run build
|
|
23
|
+
- run: npm publish --access public
|
|
24
|
+
env:
|
|
25
|
+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# agent-ops Architecture
|
|
2
|
+
|
|
3
|
+
## Control Endpoints (HTTP)
|
|
4
|
+
|
|
5
|
+
Each cc-tg agent exposes a lightweight HTTP server on `CC_AGENT_OPS_PORT`. Endpoints:
|
|
6
|
+
|
|
7
|
+
- `GET /status` — returns JSON with namespace, pid, uptime, version
|
|
8
|
+
- `POST /restart` — triggers `process.exit(0)` after 200ms (supervisor/pm2 restarts the process)
|
|
9
|
+
- `GET /logs?lines=N` — tails the last N lines from `LOG_FILE`
|
|
10
|
+
|
|
11
|
+
**Why HTTP over SSH/pipes:** Works transparently over Tailscale and LAN without additional auth setup. cc-tg already has `process.exit` restart logic, so `/restart` is a thin wrapper. Zero TLS/key management on a trusted local network.
|
|
12
|
+
|
|
13
|
+
## Agent Registry (Redis)
|
|
14
|
+
|
|
15
|
+
Each agent self-registers at startup and refreshes a 90-second TTL key every 60 seconds. If a process dies, its entry expires automatically. The ops-bot queries Redis to discover all live agents.
|
|
16
|
+
|
|
17
|
+
Key schema: `agent-ops:agent:<namespace>` → Redis Hash with fields matching `AgentRecord`.
|
|
18
|
+
|
|
19
|
+
## Integration with cc-tg
|
|
20
|
+
|
|
21
|
+
cc-tg reads two optional env vars:
|
|
22
|
+
|
|
23
|
+
- `CC_AGENT_OPS_PORT` — if set, start the HTTP control server on this port and register with Redis
|
|
24
|
+
- `REDIS_URL` — already used by cc-agent; reused for the agent registry
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { Registry, startControlServer } from '@ecoclaw/agent-ops'
|
|
28
|
+
// on start:
|
|
29
|
+
if (process.env.CC_AGENT_OPS_PORT) {
|
|
30
|
+
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')
|
|
31
|
+
const registry = new Registry(redis)
|
|
32
|
+
await registry.register({ namespace, hostname, pid, version, cwd, control_port, ... })
|
|
33
|
+
setInterval(() => registry.heartbeat(namespace), 60_000)
|
|
34
|
+
startControlServer(Number(process.env.CC_AGENT_OPS_PORT), { logFile: process.env.LOG_FILE })
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## ops-bot
|
|
39
|
+
|
|
40
|
+
Telegram bot that queries Redis for live agents and proxies commands to their control endpoints. Set `OPS_BOT_TOKEN`, `REDIS_URL`, and optionally `ALLOWED_USER_IDS` (comma-separated Telegram user IDs).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
export interface ControlServerOptions {
|
|
3
|
+
namespace?: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
logFile?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function startControlServer(port: number, opts?: ControlServerOptions): http.Server;
|
|
8
|
+
//# sourceMappingURL=control.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;AAG5B,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAAC,MAAM,CAmD7F"}
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.startControlServer = startControlServer;
|
|
37
|
+
const http = __importStar(require("http"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
function startControlServer(port, opts = {}) {
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const server = http.createServer((req, res) => {
|
|
42
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
43
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
44
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
45
|
+
res.end(JSON.stringify({
|
|
46
|
+
namespace: opts.namespace || process.env.NAMESPACE || 'unknown',
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
49
|
+
version: opts.version || process.env.VERSION || '0.0.0',
|
|
50
|
+
}));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (req.method === 'POST' && url.pathname === '/restart') {
|
|
54
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
55
|
+
res.end('restarting');
|
|
56
|
+
setTimeout(() => process.exit(0), 200);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (req.method === 'GET' && url.pathname === '/logs') {
|
|
60
|
+
const lines = parseInt(url.searchParams.get('lines') || '50', 10);
|
|
61
|
+
const logFile = opts.logFile || process.env.LOG_FILE;
|
|
62
|
+
if (!logFile) {
|
|
63
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
64
|
+
res.end('LOG_FILE not configured');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const content = fs.readFileSync(logFile, 'utf8');
|
|
69
|
+
const allLines = content.split('\n');
|
|
70
|
+
const tail = allLines.slice(-lines).join('\n');
|
|
71
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
72
|
+
res.end(tail);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
76
|
+
res.end(`Error reading log file: ${e}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
81
|
+
res.end('Not found');
|
|
82
|
+
});
|
|
83
|
+
server.listen(port);
|
|
84
|
+
return server;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=control.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAC9C,YAAY,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startControlServer = exports.Registry = void 0;
|
|
4
|
+
var registry_1 = require("./registry");
|
|
5
|
+
Object.defineProperty(exports, "Registry", { enumerable: true, get: function () { return registry_1.Registry; } });
|
|
6
|
+
var control_1 = require("./control");
|
|
7
|
+
Object.defineProperty(exports, "startControlServer", { enumerable: true, get: function () { return control_1.startControlServer; } });
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":""}
|
package/dist/ops-bot.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
const node_telegram_bot_api_1 = __importDefault(require("node-telegram-bot-api"));
|
|
41
|
+
const ioredis_1 = __importDefault(require("ioredis"));
|
|
42
|
+
const http = __importStar(require("http"));
|
|
43
|
+
const registry_1 = require("./registry");
|
|
44
|
+
const OPS_BOT_TOKEN = process.env.OPS_BOT_TOKEN;
|
|
45
|
+
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
46
|
+
const ALLOWED_USER_IDS = (process.env.ALLOWED_USER_IDS || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Boolean);
|
|
47
|
+
if (!OPS_BOT_TOKEN) {
|
|
48
|
+
console.error('OPS_BOT_TOKEN env var is required');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const redis = new ioredis_1.default(REDIS_URL);
|
|
52
|
+
const registry = new registry_1.Registry(redis);
|
|
53
|
+
const bot = new node_telegram_bot_api_1.default(OPS_BOT_TOKEN, { polling: true });
|
|
54
|
+
function isAllowed(userId) {
|
|
55
|
+
return ALLOWED_USER_IDS.length === 0 || ALLOWED_USER_IDS.includes(userId);
|
|
56
|
+
}
|
|
57
|
+
function httpGet(url) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
http.get(url, (res) => {
|
|
60
|
+
let data = '';
|
|
61
|
+
res.on('data', (chunk) => data += chunk);
|
|
62
|
+
res.on('end', () => resolve(data));
|
|
63
|
+
}).on('error', reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function httpPost(url) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
const req = http.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname, method: 'POST' }, (res) => {
|
|
70
|
+
let data = '';
|
|
71
|
+
res.on('data', (chunk) => data += chunk);
|
|
72
|
+
res.on('end', () => resolve(data));
|
|
73
|
+
});
|
|
74
|
+
req.on('error', reject);
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
bot.onText(/\/agents/, async (msg) => {
|
|
79
|
+
if (!isAllowed(msg.from?.id || 0))
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
const agents = await registry.listAgents();
|
|
83
|
+
if (agents.length === 0) {
|
|
84
|
+
await bot.sendMessage(msg.chat.id, 'No agents registered.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const lines = agents.map(a => {
|
|
89
|
+
const ageMs = now - parseInt(a.started_at, 10);
|
|
90
|
+
const ageSec = Math.floor(ageMs / 1000);
|
|
91
|
+
const ageStr = ageSec > 3600 ? `${Math.floor(ageSec / 3600)}h` : ageSec > 60 ? `${Math.floor(ageSec / 60)}m` : `${ageSec}s`;
|
|
92
|
+
return `• ${a.namespace} @ ${a.hostname} pid=${a.pid} v${a.version} age=${ageStr}`;
|
|
93
|
+
});
|
|
94
|
+
await bot.sendMessage(msg.chat.id, `Registered agents:\n${lines.join('\n')}`);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
bot.onText(/\/health/, async (msg) => {
|
|
101
|
+
if (!isAllowed(msg.from?.id || 0))
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
const agents = await registry.listAgents();
|
|
105
|
+
if (agents.length === 0) {
|
|
106
|
+
await bot.sendMessage(msg.chat.id, 'No agents registered.');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const results = await Promise.all(agents.map(async (a) => {
|
|
110
|
+
try {
|
|
111
|
+
await httpGet(`http://${a.hostname}:${a.control_port}/status`);
|
|
112
|
+
return `✓ ${a.namespace}`;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return `✗ ${a.namespace}`;
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
await bot.sendMessage(msg.chat.id, results.join('\n'));
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
bot.onText(/\/restart (.+)/, async (msg, match) => {
|
|
125
|
+
if (!isAllowed(msg.from?.id || 0))
|
|
126
|
+
return;
|
|
127
|
+
const namespace = match?.[1]?.trim();
|
|
128
|
+
if (!namespace)
|
|
129
|
+
return;
|
|
130
|
+
try {
|
|
131
|
+
const agents = await registry.listAgents();
|
|
132
|
+
const agent = agents.find(a => a.namespace === namespace);
|
|
133
|
+
if (!agent) {
|
|
134
|
+
await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await httpPost(`http://${agent.hostname}:${agent.control_port}/restart`);
|
|
138
|
+
await bot.sendMessage(msg.chat.id, `Restart signal sent to ${namespace}.`);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
bot.onText(/\/logs (.+)/, async (msg, match) => {
|
|
145
|
+
if (!isAllowed(msg.from?.id || 0))
|
|
146
|
+
return;
|
|
147
|
+
const namespace = match?.[1]?.trim();
|
|
148
|
+
if (!namespace)
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
const agents = await registry.listAgents();
|
|
152
|
+
const agent = agents.find(a => a.namespace === namespace);
|
|
153
|
+
if (!agent) {
|
|
154
|
+
await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const logs = await httpGet(`http://${agent.hostname}:${agent.control_port}/logs?lines=50`);
|
|
158
|
+
const truncated = logs.length > 4000 ? logs.slice(-4000) : logs;
|
|
159
|
+
await bot.sendMessage(msg.chat.id, `Logs for ${namespace}:\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: 'Markdown' });
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
console.log('ops-bot running');
|
|
166
|
+
//# sourceMappingURL=ops-bot.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
export interface AgentRecord {
|
|
3
|
+
hostname: string;
|
|
4
|
+
user: string;
|
|
5
|
+
bot_username: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
namespace: string;
|
|
8
|
+
pid: string;
|
|
9
|
+
version: string;
|
|
10
|
+
started_at: string;
|
|
11
|
+
control_port: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class Registry {
|
|
14
|
+
private redis;
|
|
15
|
+
constructor(redis: Redis);
|
|
16
|
+
register(record: AgentRecord): Promise<void>;
|
|
17
|
+
heartbeat(namespace: string): Promise<void>;
|
|
18
|
+
listAgents(): Promise<AgentRecord[]>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAEpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,MAAM,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,qBAAa,QAAQ;IACP,OAAO,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAE1B,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAM5C,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK3C,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;CAkB3C"}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Registry = void 0;
|
|
4
|
+
class Registry {
|
|
5
|
+
constructor(redis) {
|
|
6
|
+
this.redis = redis;
|
|
7
|
+
}
|
|
8
|
+
async register(record) {
|
|
9
|
+
const key = `agent-ops:agent:${record.namespace}`;
|
|
10
|
+
await this.redis.hset(key, record);
|
|
11
|
+
await this.redis.expire(key, 90);
|
|
12
|
+
}
|
|
13
|
+
async heartbeat(namespace) {
|
|
14
|
+
const key = `agent-ops:agent:${namespace}`;
|
|
15
|
+
await this.redis.expire(key, 90);
|
|
16
|
+
}
|
|
17
|
+
async listAgents() {
|
|
18
|
+
const keys = [];
|
|
19
|
+
let cursor = '0';
|
|
20
|
+
do {
|
|
21
|
+
const [nextCursor, found] = await this.redis.scan(cursor, 'MATCH', 'agent-ops:agent:*', 'COUNT', 100);
|
|
22
|
+
cursor = nextCursor;
|
|
23
|
+
keys.push(...found);
|
|
24
|
+
} while (cursor !== '0');
|
|
25
|
+
const agents = [];
|
|
26
|
+
for (const key of keys) {
|
|
27
|
+
const data = await this.redis.hgetall(key);
|
|
28
|
+
if (data && Object.keys(data).length > 0) {
|
|
29
|
+
agents.push(data);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return agents;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.Registry = Registry;
|
|
36
|
+
//# sourceMappingURL=registry.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gonzih/agent-ops",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ops layer for cc-tg agent fleet — discovery, control, log aggregation",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-ops": "dist/ops-bot.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/ops-bot.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"ioredis": "^5.3.2",
|
|
15
|
+
"node-telegram-bot-api": "^0.66.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.4.0",
|
|
19
|
+
"@types/node": "^20.0.0",
|
|
20
|
+
"@types/node-telegram-bot-api": "^0.64.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/control.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
|
|
4
|
+
export interface ControlServerOptions {
|
|
5
|
+
namespace?: string
|
|
6
|
+
version?: string
|
|
7
|
+
logFile?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function startControlServer(port: number, opts: ControlServerOptions = {}): http.Server {
|
|
11
|
+
const startTime = Date.now()
|
|
12
|
+
|
|
13
|
+
const server = http.createServer((req, res) => {
|
|
14
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`)
|
|
15
|
+
|
|
16
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
17
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
18
|
+
res.end(JSON.stringify({
|
|
19
|
+
namespace: opts.namespace || process.env.NAMESPACE || 'unknown',
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
22
|
+
version: opts.version || process.env.VERSION || '0.0.0',
|
|
23
|
+
}))
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (req.method === 'POST' && url.pathname === '/restart') {
|
|
28
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
29
|
+
res.end('restarting')
|
|
30
|
+
setTimeout(() => process.exit(0), 200)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (req.method === 'GET' && url.pathname === '/logs') {
|
|
35
|
+
const lines = parseInt(url.searchParams.get('lines') || '50', 10)
|
|
36
|
+
const logFile = opts.logFile || process.env.LOG_FILE
|
|
37
|
+
if (!logFile) {
|
|
38
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
39
|
+
res.end('LOG_FILE not configured')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const content = fs.readFileSync(logFile, 'utf8')
|
|
44
|
+
const allLines = content.split('\n')
|
|
45
|
+
const tail = allLines.slice(-lines).join('\n')
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
47
|
+
res.end(tail)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
50
|
+
res.end(`Error reading log file: ${e}`)
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
56
|
+
res.end('Not found')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
server.listen(port)
|
|
60
|
+
return server
|
|
61
|
+
}
|
package/src/index.ts
ADDED
package/src/ops-bot.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import TelegramBot from 'node-telegram-bot-api'
|
|
3
|
+
import Redis from 'ioredis'
|
|
4
|
+
import * as http from 'http'
|
|
5
|
+
import { Registry } from './registry'
|
|
6
|
+
|
|
7
|
+
const OPS_BOT_TOKEN = process.env.OPS_BOT_TOKEN
|
|
8
|
+
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'
|
|
9
|
+
const ALLOWED_USER_IDS = (process.env.ALLOWED_USER_IDS || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Boolean)
|
|
10
|
+
|
|
11
|
+
if (!OPS_BOT_TOKEN) {
|
|
12
|
+
console.error('OPS_BOT_TOKEN env var is required')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const redis = new Redis(REDIS_URL)
|
|
17
|
+
const registry = new Registry(redis)
|
|
18
|
+
const bot = new TelegramBot(OPS_BOT_TOKEN, { polling: true })
|
|
19
|
+
|
|
20
|
+
function isAllowed(userId: number): boolean {
|
|
21
|
+
return ALLOWED_USER_IDS.length === 0 || ALLOWED_USER_IDS.includes(userId)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function httpGet(url: string): Promise<string> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
http.get(url, (res) => {
|
|
27
|
+
let data = ''
|
|
28
|
+
res.on('data', (chunk: string) => data += chunk)
|
|
29
|
+
res.on('end', () => resolve(data))
|
|
30
|
+
}).on('error', reject)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function httpPost(url: string): Promise<string> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const parsed = new URL(url)
|
|
37
|
+
const req = http.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname, method: 'POST' }, (res) => {
|
|
38
|
+
let data = ''
|
|
39
|
+
res.on('data', (chunk: string) => data += chunk)
|
|
40
|
+
res.on('end', () => resolve(data))
|
|
41
|
+
})
|
|
42
|
+
req.on('error', reject)
|
|
43
|
+
req.end()
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
bot.onText(/\/agents/, async (msg) => {
|
|
48
|
+
if (!isAllowed(msg.from?.id || 0)) return
|
|
49
|
+
try {
|
|
50
|
+
const agents = await registry.listAgents()
|
|
51
|
+
if (agents.length === 0) {
|
|
52
|
+
await bot.sendMessage(msg.chat.id, 'No agents registered.')
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const now = Date.now()
|
|
56
|
+
const lines = agents.map(a => {
|
|
57
|
+
const ageMs = now - parseInt(a.started_at, 10)
|
|
58
|
+
const ageSec = Math.floor(ageMs / 1000)
|
|
59
|
+
const ageStr = ageSec > 3600 ? `${Math.floor(ageSec/3600)}h` : ageSec > 60 ? `${Math.floor(ageSec/60)}m` : `${ageSec}s`
|
|
60
|
+
return `• ${a.namespace} @ ${a.hostname} pid=${a.pid} v${a.version} age=${ageStr}`
|
|
61
|
+
})
|
|
62
|
+
await bot.sendMessage(msg.chat.id, `Registered agents:\n${lines.join('\n')}`)
|
|
63
|
+
} catch (e) {
|
|
64
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
bot.onText(/\/health/, async (msg) => {
|
|
69
|
+
if (!isAllowed(msg.from?.id || 0)) return
|
|
70
|
+
try {
|
|
71
|
+
const agents = await registry.listAgents()
|
|
72
|
+
if (agents.length === 0) {
|
|
73
|
+
await bot.sendMessage(msg.chat.id, 'No agents registered.')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const results = await Promise.all(agents.map(async (a) => {
|
|
77
|
+
try {
|
|
78
|
+
await httpGet(`http://${a.hostname}:${a.control_port}/status`)
|
|
79
|
+
return `✓ ${a.namespace}`
|
|
80
|
+
} catch {
|
|
81
|
+
return `✗ ${a.namespace}`
|
|
82
|
+
}
|
|
83
|
+
}))
|
|
84
|
+
await bot.sendMessage(msg.chat.id, results.join('\n'))
|
|
85
|
+
} catch (e) {
|
|
86
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
bot.onText(/\/restart (.+)/, async (msg, match) => {
|
|
91
|
+
if (!isAllowed(msg.from?.id || 0)) return
|
|
92
|
+
const namespace = match?.[1]?.trim()
|
|
93
|
+
if (!namespace) return
|
|
94
|
+
try {
|
|
95
|
+
const agents = await registry.listAgents()
|
|
96
|
+
const agent = agents.find(a => a.namespace === namespace)
|
|
97
|
+
if (!agent) {
|
|
98
|
+
await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
await httpPost(`http://${agent.hostname}:${agent.control_port}/restart`)
|
|
102
|
+
await bot.sendMessage(msg.chat.id, `Restart signal sent to ${namespace}.`)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
bot.onText(/\/logs (.+)/, async (msg, match) => {
|
|
109
|
+
if (!isAllowed(msg.from?.id || 0)) return
|
|
110
|
+
const namespace = match?.[1]?.trim()
|
|
111
|
+
if (!namespace) return
|
|
112
|
+
try {
|
|
113
|
+
const agents = await registry.listAgents()
|
|
114
|
+
const agent = agents.find(a => a.namespace === namespace)
|
|
115
|
+
if (!agent) {
|
|
116
|
+
await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const logs = await httpGet(`http://${agent.hostname}:${agent.control_port}/logs?lines=50`)
|
|
120
|
+
const truncated = logs.length > 4000 ? logs.slice(-4000) : logs
|
|
121
|
+
await bot.sendMessage(msg.chat.id, `Logs for ${namespace}:\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: 'Markdown' })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
await bot.sendMessage(msg.chat.id, `Error: ${e}`)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
console.log('ops-bot running')
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis'
|
|
2
|
+
|
|
3
|
+
export interface AgentRecord {
|
|
4
|
+
hostname: string
|
|
5
|
+
user: string
|
|
6
|
+
bot_username: string
|
|
7
|
+
cwd: string
|
|
8
|
+
namespace: string
|
|
9
|
+
pid: string
|
|
10
|
+
version: string
|
|
11
|
+
started_at: string
|
|
12
|
+
control_port: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Registry {
|
|
16
|
+
constructor(private redis: Redis) {}
|
|
17
|
+
|
|
18
|
+
async register(record: AgentRecord): Promise<void> {
|
|
19
|
+
const key = `agent-ops:agent:${record.namespace}`
|
|
20
|
+
await this.redis.hset(key, record as unknown as Record<string, string>)
|
|
21
|
+
await this.redis.expire(key, 90)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async heartbeat(namespace: string): Promise<void> {
|
|
25
|
+
const key = `agent-ops:agent:${namespace}`
|
|
26
|
+
await this.redis.expire(key, 90)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async listAgents(): Promise<AgentRecord[]> {
|
|
30
|
+
const keys: string[] = []
|
|
31
|
+
let cursor = '0'
|
|
32
|
+
do {
|
|
33
|
+
const [nextCursor, found] = await this.redis.scan(cursor, 'MATCH', 'agent-ops:agent:*', 'COUNT', 100)
|
|
34
|
+
cursor = nextCursor
|
|
35
|
+
keys.push(...found)
|
|
36
|
+
} while (cursor !== '0')
|
|
37
|
+
|
|
38
|
+
const agents: AgentRecord[] = []
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
const data = await this.redis.hgetall(key)
|
|
41
|
+
if (data && Object.keys(data).length > 0) {
|
|
42
|
+
agents.push(data as unknown as AgentRecord)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return agents
|
|
46
|
+
}
|
|
47
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|