@gonzih/agent-ops 0.1.0 → 0.2.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/README.md +67 -0
- package/dist/control.d.ts +16 -4
- package/dist/control.d.ts.map +1 -1
- package/dist/control.js +62 -75
- package/dist/control.js.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -7
- package/dist/index.js.map +1 -0
- package/dist/ops-bot.d.ts +11 -1
- package/dist/ops-bot.d.ts.map +1 -1
- package/dist/ops-bot.js +99 -141
- package/dist/ops-bot.js.map +1 -0
- package/dist/registry.d.ts +23 -7
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +52 -31
- package/dist/registry.js.map +1 -0
- package/package.json +15 -10
- package/.github/workflows/publish.yml +0 -25
- package/ARCHITECTURE.md +0 -40
- package/src/control.ts +0 -61
- package/src/index.ts +0 -4
- package/src/ops-bot.ts +0 -127
- package/src/registry.ts +0 -47
- package/tsconfig.json +0 -17
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @ecoclaw/agent-ops
|
|
2
|
+
|
|
3
|
+
Ops layer for the cc-tg agent fleet — discovery, control, and log aggregation across machines.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────┐
|
|
9
|
+
│ Telegram Ops Bot │
|
|
10
|
+
│ /agents /health /restart /logs /update /broadcast │
|
|
11
|
+
└───────────────────┬─────────────────────────────────┘
|
|
12
|
+
│ HTTP control calls
|
|
13
|
+
┌───────────┴──────────┐
|
|
14
|
+
│ Redis Registry │ TTL-based liveness
|
|
15
|
+
│ agent-ops:agent:* │ heartbeat every 60s
|
|
16
|
+
└───────────┬──────────┘
|
|
17
|
+
│ self-registers
|
|
18
|
+
┌────────────────┼────────────────┐
|
|
19
|
+
▼ ▼ ▼
|
|
20
|
+
cc-tg A cc-tg B cc-tg C
|
|
21
|
+
money-brain simorgh-app ...
|
|
22
|
+
:8080/status :8081/status
|
|
23
|
+
:8080/restart :8081/restart
|
|
24
|
+
:8080/logs :8081/logs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
### 1. Run the ops bot
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
OPS_BOT_TOKEN=<your-bot-token> \
|
|
33
|
+
REDIS_URL=redis://localhost:6379 \
|
|
34
|
+
CONTROL_AUTH_TOKEN=secret123 \
|
|
35
|
+
ALLOWED_CHAT_IDS=123456789 \
|
|
36
|
+
npx @ecoclaw/agent-ops
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Integrate into cc-tg
|
|
40
|
+
|
|
41
|
+
Each cc-tg instance needs to:
|
|
42
|
+
|
|
43
|
+
1. Call `registry.register(record)` on startup
|
|
44
|
+
2. Call `registry.heartbeat(id)` every 60s
|
|
45
|
+
3. Start `createControlServer({ port, agentRecord, authToken })` on a free port
|
|
46
|
+
|
|
47
|
+
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full integration spec.
|
|
48
|
+
|
|
49
|
+
## Environment Variables
|
|
50
|
+
|
|
51
|
+
| Variable | Required | Description |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `OPS_BOT_TOKEN` | Yes | Telegram bot token for the ops bot |
|
|
54
|
+
| `REDIS_URL` | No | Redis connection URL (default: `redis://localhost:6379`) |
|
|
55
|
+
| `CONTROL_AUTH_TOKEN` | No | Bearer token sent to control endpoints |
|
|
56
|
+
| `ALLOWED_CHAT_IDS` | No | Comma-separated Telegram chat IDs allowed to use ops bot |
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `/agents` | List all registered agents with liveness status |
|
|
63
|
+
| `/health` | Fleet health summary |
|
|
64
|
+
| `/restart <id>` | Restart a specific agent (launchd respawns = auto-update) |
|
|
65
|
+
| `/logs <id>` | Tail last 50 lines from agent log file |
|
|
66
|
+
| `/update all` | Restart all agents |
|
|
67
|
+
| `/broadcast <msg>` | Broadcast message guidance |
|
package/dist/control.d.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTTP control endpoint for a single cc-tg instance.
|
|
3
|
+
*
|
|
4
|
+
* Exposes:
|
|
5
|
+
* GET /status — current agent info + uptime
|
|
6
|
+
* POST /restart — graceful exit (launchd respawns = auto-update)
|
|
7
|
+
* GET /logs — last N lines of the log file (?lines=100)
|
|
8
|
+
*
|
|
9
|
+
* No framework deps — plain Node http module.
|
|
10
|
+
*/
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { AgentRecord } from "./registry.js";
|
|
2
13
|
export interface ControlServerOptions {
|
|
3
|
-
|
|
4
|
-
version?: string;
|
|
14
|
+
port: number;
|
|
5
15
|
logFile?: string;
|
|
16
|
+
agentRecord: Omit<AgentRecord, "last_seen">;
|
|
17
|
+
authToken?: string;
|
|
6
18
|
}
|
|
7
|
-
export declare function
|
|
19
|
+
export declare function createControlServer(opts: ControlServerOptions): http.Server;
|
|
8
20
|
//# sourceMappingURL=control.d.ts.map
|
package/dist/control.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAqBD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,IAAI,CAAC,MAAM,CAgD3E"}
|
package/dist/control.js
CHANGED
|
@@ -1,86 +1,73 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTTP control endpoint for a single cc-tg instance.
|
|
3
|
+
*
|
|
4
|
+
* Exposes:
|
|
5
|
+
* GET /status — current agent info + uptime
|
|
6
|
+
* POST /restart — graceful exit (launchd respawns = auto-update)
|
|
7
|
+
* GET /logs — last N lines of the log file (?lines=100)
|
|
8
|
+
*
|
|
9
|
+
* No framework deps — plain Node http module.
|
|
10
|
+
*/
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
function json(res, status, body) {
|
|
15
|
+
const payload = JSON.stringify(body);
|
|
16
|
+
res.writeHead(status, {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
19
|
+
});
|
|
20
|
+
res.end(payload);
|
|
21
|
+
}
|
|
22
|
+
function tailFile(filePath, lines) {
|
|
23
|
+
try {
|
|
24
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
25
|
+
const all = content.split("\n");
|
|
26
|
+
return all.slice(Math.max(0, all.length - lines)).join("\n");
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return `(could not read log file: ${err.message})`;
|
|
7
30
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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();
|
|
31
|
+
}
|
|
32
|
+
export function createControlServer(opts) {
|
|
33
|
+
const startedAt = new Date().toISOString();
|
|
41
34
|
const server = http.createServer((req, res) => {
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
49
|
-
version: opts.version || process.env.VERSION || '0.0.0',
|
|
50
|
-
}));
|
|
51
|
-
return;
|
|
35
|
+
// Optional auth
|
|
36
|
+
if (opts.authToken) {
|
|
37
|
+
const auth = req.headers["authorization"] ?? "";
|
|
38
|
+
if (auth !== `Bearer ${opts.authToken}`) {
|
|
39
|
+
return json(res, 401, { error: "unauthorized" });
|
|
40
|
+
}
|
|
52
41
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
res
|
|
42
|
+
const url = new URL(req.url ?? "/", `http://localhost:${opts.port}`);
|
|
43
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
44
|
+
return json(res, 200, {
|
|
45
|
+
...opts.agentRecord,
|
|
46
|
+
started_at: startedAt,
|
|
47
|
+
last_seen: new Date().toISOString(),
|
|
48
|
+
uptime_seconds: Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (req.method === "POST" && url.pathname === "/restart") {
|
|
52
|
+
json(res, 200, { ok: true, message: "restarting" });
|
|
53
|
+
// Give the response time to flush before exiting
|
|
56
54
|
setTimeout(() => process.exit(0), 200);
|
|
57
55
|
return;
|
|
58
56
|
}
|
|
59
|
-
if (req.method ===
|
|
60
|
-
const lines = parseInt(url.searchParams.get(
|
|
61
|
-
const logFile = opts.logFile
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
57
|
+
if (req.method === "GET" && url.pathname === "/logs") {
|
|
58
|
+
const lines = parseInt(url.searchParams.get("lines") ?? "100", 10);
|
|
59
|
+
const logFile = opts.logFile ?? path.join("/tmp", `cc-tg-${opts.agentRecord.namespace}.log`);
|
|
60
|
+
return json(res, 200, {
|
|
61
|
+
file: logFile,
|
|
62
|
+
lines,
|
|
63
|
+
content: tailFile(logFile, lines),
|
|
64
|
+
});
|
|
79
65
|
}
|
|
80
|
-
res
|
|
81
|
-
|
|
66
|
+
return json(res, 404, { error: "not found" });
|
|
67
|
+
});
|
|
68
|
+
server.listen(opts.port, () => {
|
|
69
|
+
console.log(`[agent-ops/control] listening on :${opts.port}`);
|
|
82
70
|
});
|
|
83
|
-
server.listen(port);
|
|
84
71
|
return server;
|
|
85
72
|
}
|
|
86
73
|
//# sourceMappingURL=control.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control.js","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type { AgentRecord } from
|
|
3
|
-
export {
|
|
4
|
-
export type { ControlServerOptions } from
|
|
1
|
+
export { AgentRegistry } from "./registry.js";
|
|
2
|
+
export type { AgentRecord } from "./registry.js";
|
|
3
|
+
export { createControlServer } from "./control.js";
|
|
4
|
+
export type { ControlServerOptions } from "./control.js";
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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; } });
|
|
1
|
+
export { AgentRegistry } from "./registry.js";
|
|
2
|
+
export { createControlServer } from "./control.js";
|
|
8
3
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/ops-bot.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Telegram ops bot — single bot that manages the entire cc-tg agent fleet.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /agents — list all registered agents with liveness status
|
|
6
|
+
* /health — summary health view
|
|
7
|
+
* /restart <id> — POST /restart to named agent's control endpoint
|
|
8
|
+
* /logs <id> — tail logs from named agent
|
|
9
|
+
* /update all — restart every agent (launchd respawn = auto-update)
|
|
10
|
+
* /broadcast <msg> — send a message via each agent's Telegram bot token
|
|
11
|
+
*/
|
|
2
12
|
export {};
|
|
3
13
|
//# sourceMappingURL=ops-bot.d.ts.map
|
package/dist/ops-bot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":""}
|
|
1
|
+
{"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
|
package/dist/ops-bot.js
CHANGED
|
@@ -1,166 +1,124 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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');
|
|
1
|
+
/**
|
|
2
|
+
* Telegram ops bot — single bot that manages the entire cc-tg agent fleet.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /agents — list all registered agents with liveness status
|
|
6
|
+
* /health — summary health view
|
|
7
|
+
* /restart <id> — POST /restart to named agent's control endpoint
|
|
8
|
+
* /logs <id> — tail logs from named agent
|
|
9
|
+
* /update all — restart every agent (launchd respawn = auto-update)
|
|
10
|
+
* /broadcast <msg> — send a message via each agent's Telegram bot token
|
|
11
|
+
*/
|
|
12
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
13
|
+
import { AgentRegistry } from "./registry.js";
|
|
14
|
+
const BOT_TOKEN = process.env.OPS_BOT_TOKEN;
|
|
15
|
+
const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379";
|
|
16
|
+
const CONTROL_AUTH_TOKEN = process.env.CONTROL_AUTH_TOKEN;
|
|
17
|
+
const ALLOWED_CHAT_IDS = (process.env.ALLOWED_CHAT_IDS ?? "")
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((s) => s.trim())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
if (!BOT_TOKEN) {
|
|
22
|
+
console.error("OPS_BOT_TOKEN env var is required");
|
|
49
23
|
process.exit(1);
|
|
50
24
|
}
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
25
|
+
const registry = new AgentRegistry(REDIS_URL);
|
|
26
|
+
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
|
27
|
+
async function controlFetch(agent, path, method = "GET") {
|
|
28
|
+
const headers = { "Content-Type": "application/json" };
|
|
29
|
+
if (CONTROL_AUTH_TOKEN)
|
|
30
|
+
headers["Authorization"] = `Bearer ${CONTROL_AUTH_TOKEN}`;
|
|
31
|
+
return fetch(`${agent.control_url}${path}`, { method, headers });
|
|
56
32
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
res.on('data', (chunk) => data += chunk);
|
|
62
|
-
res.on('end', () => resolve(data));
|
|
63
|
-
}).on('error', reject);
|
|
64
|
-
});
|
|
33
|
+
function agentLine(a) {
|
|
34
|
+
const ago = Math.floor((Date.now() - new Date(a.last_seen).getTime()) / 1000);
|
|
35
|
+
const status = ago < 90 ? "✅" : "❌";
|
|
36
|
+
return `${status} \`${a.id}\` — ${a.bot_username} @ ${a.hostname} (${ago}s ago)`;
|
|
65
37
|
}
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
});
|
|
38
|
+
function isAllowed(chatId) {
|
|
39
|
+
if (ALLOWED_CHAT_IDS.length === 0)
|
|
40
|
+
return true; // open if not configured
|
|
41
|
+
return ALLOWED_CHAT_IDS.includes(String(chatId));
|
|
77
42
|
}
|
|
78
43
|
bot.onText(/\/agents/, async (msg) => {
|
|
79
|
-
if (!isAllowed(msg.
|
|
44
|
+
if (!isAllowed(msg.chat.id))
|
|
80
45
|
return;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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}`);
|
|
46
|
+
const agents = await registry.list();
|
|
47
|
+
if (agents.length === 0) {
|
|
48
|
+
return bot.sendMessage(msg.chat.id, "No agents registered.");
|
|
98
49
|
}
|
|
50
|
+
const lines = agents.map(agentLine).join("\n");
|
|
51
|
+
bot.sendMessage(msg.chat.id, `*Registered agents (${agents.length}):*\n${lines}`, {
|
|
52
|
+
parse_mode: "Markdown",
|
|
53
|
+
});
|
|
99
54
|
});
|
|
100
55
|
bot.onText(/\/health/, async (msg) => {
|
|
101
|
-
if (!isAllowed(msg.
|
|
56
|
+
if (!isAllowed(msg.chat.id))
|
|
102
57
|
return;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
}
|
|
58
|
+
const agents = await registry.list();
|
|
59
|
+
const alive = agents.filter((a) => (Date.now() - new Date(a.last_seen).getTime()) / 1000 < 90);
|
|
60
|
+
const dead = agents.length - alive.length;
|
|
61
|
+
bot.sendMessage(msg.chat.id, `*Fleet health:*\n✅ Alive: ${alive.length}\n❌ Dead/stale: ${dead}\nTotal: ${agents.length}`, { parse_mode: "Markdown" });
|
|
123
62
|
});
|
|
124
63
|
bot.onText(/\/restart (.+)/, async (msg, match) => {
|
|
125
|
-
if (!isAllowed(msg.
|
|
126
|
-
return;
|
|
127
|
-
const namespace = match?.[1]?.trim();
|
|
128
|
-
if (!namespace)
|
|
64
|
+
if (!isAllowed(msg.chat.id))
|
|
129
65
|
return;
|
|
66
|
+
const id = match?.[1]?.trim();
|
|
67
|
+
if (!id)
|
|
68
|
+
return bot.sendMessage(msg.chat.id, "Usage: /restart <agent-id>");
|
|
69
|
+
const agent = await registry.get(id);
|
|
70
|
+
if (!agent)
|
|
71
|
+
return bot.sendMessage(msg.chat.id, `Agent \`${id}\` not found.`, { parse_mode: "Markdown" });
|
|
130
72
|
try {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
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}.`);
|
|
73
|
+
const res = await controlFetch(agent, "/restart", "POST");
|
|
74
|
+
const body = await res.json();
|
|
75
|
+
bot.sendMessage(msg.chat.id, `Restarting \`${id}\`… ${JSON.stringify(body)}`, { parse_mode: "Markdown" });
|
|
139
76
|
}
|
|
140
|
-
catch (
|
|
141
|
-
|
|
77
|
+
catch (err) {
|
|
78
|
+
bot.sendMessage(msg.chat.id, `Failed to reach \`${id}\`: ${err.message}`, { parse_mode: "Markdown" });
|
|
142
79
|
}
|
|
143
80
|
});
|
|
144
81
|
bot.onText(/\/logs (.+)/, async (msg, match) => {
|
|
145
|
-
if (!isAllowed(msg.
|
|
146
|
-
return;
|
|
147
|
-
const namespace = match?.[1]?.trim();
|
|
148
|
-
if (!namespace)
|
|
82
|
+
if (!isAllowed(msg.chat.id))
|
|
149
83
|
return;
|
|
84
|
+
const id = match?.[1]?.trim();
|
|
85
|
+
if (!id)
|
|
86
|
+
return bot.sendMessage(msg.chat.id, "Usage: /logs <agent-id>");
|
|
87
|
+
const agent = await registry.get(id);
|
|
88
|
+
if (!agent)
|
|
89
|
+
return bot.sendMessage(msg.chat.id, `Agent \`${id}\` not found.`, { parse_mode: "Markdown" });
|
|
150
90
|
try {
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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' });
|
|
91
|
+
const res = await controlFetch(agent, "/logs?lines=50");
|
|
92
|
+
const body = await res.json();
|
|
93
|
+
const snippet = body.content.slice(-3000); // Telegram message limit
|
|
94
|
+
bot.sendMessage(msg.chat.id, `*Logs for \`${id}\`:*\n\`\`\`\n${snippet}\n\`\`\``, { parse_mode: "Markdown" });
|
|
160
95
|
}
|
|
161
|
-
catch (
|
|
162
|
-
|
|
96
|
+
catch (err) {
|
|
97
|
+
bot.sendMessage(msg.chat.id, `Failed to reach \`${id}\`: ${err.message}`, { parse_mode: "Markdown" });
|
|
163
98
|
}
|
|
164
99
|
});
|
|
165
|
-
|
|
100
|
+
bot.onText(/\/update all/, async (msg) => {
|
|
101
|
+
if (!isAllowed(msg.chat.id))
|
|
102
|
+
return;
|
|
103
|
+
const agents = await registry.list();
|
|
104
|
+
if (agents.length === 0)
|
|
105
|
+
return bot.sendMessage(msg.chat.id, "No agents to update.");
|
|
106
|
+
bot.sendMessage(msg.chat.id, `Triggering restart on ${agents.length} agent(s)…`);
|
|
107
|
+
const results = await Promise.allSettled(agents.map((a) => controlFetch(a, "/restart", "POST")));
|
|
108
|
+
const ok = results.filter((r) => r.status === "fulfilled").length;
|
|
109
|
+
bot.sendMessage(msg.chat.id, `Done. ${ok}/${agents.length} responded to restart.`);
|
|
110
|
+
});
|
|
111
|
+
bot.onText(/\/broadcast (.+)/, async (msg, match) => {
|
|
112
|
+
if (!isAllowed(msg.chat.id))
|
|
113
|
+
return;
|
|
114
|
+
const message = match?.[1]?.trim();
|
|
115
|
+
if (!message)
|
|
116
|
+
return bot.sendMessage(msg.chat.id, "Usage: /broadcast <message>");
|
|
117
|
+
// Broadcast is a no-op in the ops bot itself — it's a reminder / template
|
|
118
|
+
// that each cc-tg instance handles /broadcast via its own bot token.
|
|
119
|
+
bot.sendMessage(msg.chat.id, `Broadcast "\`${message}\`" — send this command to each agent's chat directly, or integrate with the control endpoint.`, { parse_mode: "Markdown" });
|
|
120
|
+
});
|
|
121
|
+
registry.connect().then(() => {
|
|
122
|
+
console.log("[agent-ops/ops-bot] connected to Redis, polling Telegram…");
|
|
123
|
+
});
|
|
166
124
|
//# sourceMappingURL=ops-bot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ops-bot.js","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":"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"}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed agent registry with heartbeat + TTL liveness.
|
|
3
|
+
*
|
|
4
|
+
* Each cc-tg instance self-registers on start and sends heartbeats every 60s.
|
|
5
|
+
* Keys expire after 120s — any agent that misses 2 heartbeats is considered dead.
|
|
6
|
+
*/
|
|
2
7
|
export interface AgentRecord {
|
|
8
|
+
id: string;
|
|
3
9
|
hostname: string;
|
|
4
10
|
user: string;
|
|
5
11
|
bot_username: string;
|
|
6
12
|
cwd: string;
|
|
7
13
|
namespace: string;
|
|
8
|
-
pid:
|
|
14
|
+
pid: number;
|
|
9
15
|
version: string;
|
|
16
|
+
control_url: string;
|
|
10
17
|
started_at: string;
|
|
11
|
-
|
|
18
|
+
last_seen: string;
|
|
12
19
|
}
|
|
13
|
-
export declare class
|
|
20
|
+
export declare class AgentRegistry {
|
|
14
21
|
private redis;
|
|
15
|
-
constructor(
|
|
22
|
+
constructor(redisUrl?: string);
|
|
23
|
+
connect(): Promise<void>;
|
|
24
|
+
disconnect(): Promise<void>;
|
|
25
|
+
/** Register or update an agent record. Call on start and every heartbeat. */
|
|
16
26
|
register(record: AgentRecord): Promise<void>;
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
/** Refresh TTL for a live agent — call every 60s from cc-tg. */
|
|
28
|
+
heartbeat(id: string): Promise<void>;
|
|
29
|
+
/** Deregister an agent on clean shutdown. */
|
|
30
|
+
deregister(id: string): Promise<void>;
|
|
31
|
+
/** List all currently-live agents. */
|
|
32
|
+
list(): Promise<AgentRecord[]>;
|
|
33
|
+
/** Get a single agent by id. Returns null if not found / expired. */
|
|
34
|
+
get(id: string): Promise<AgentRecord | null>;
|
|
19
35
|
}
|
|
20
36
|
//# sourceMappingURL=registry.d.ts.map
|
package/dist/registry.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAKD,qBAAa,aAAa;IACxB,OAAO,CAAC,KAAK,CAA6B;gBAE9B,QAAQ,GAAE,MAA0D;IAI1E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAIjC,6EAA6E;IACvE,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAMlD,gEAAgE;IAC1D,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS1C,6CAA6C;IACvC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,sCAAsC;IAChC,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IASpC,qEAAqE;IAC/D,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;CAInD"}
|
package/dist/registry.js
CHANGED
|
@@ -1,36 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Redis-backed agent registry with heartbeat + TTL liveness.
|
|
3
|
+
*
|
|
4
|
+
* Each cc-tg instance self-registers on start and sends heartbeats every 60s.
|
|
5
|
+
* Keys expire after 120s — any agent that misses 2 heartbeats is considered dead.
|
|
6
|
+
*/
|
|
7
|
+
import { Redis } from "ioredis";
|
|
8
|
+
const REGISTRY_PREFIX = "agent-ops:agent:";
|
|
9
|
+
const TTL_SECONDS = 120;
|
|
10
|
+
export class AgentRegistry {
|
|
11
|
+
redis;
|
|
12
|
+
constructor(redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379") {
|
|
13
|
+
this.redis = new Redis(redisUrl, { lazyConnect: true });
|
|
7
14
|
}
|
|
15
|
+
async connect() {
|
|
16
|
+
await this.redis.connect();
|
|
17
|
+
}
|
|
18
|
+
async disconnect() {
|
|
19
|
+
await this.redis.quit();
|
|
20
|
+
}
|
|
21
|
+
/** Register or update an agent record. Call on start and every heartbeat. */
|
|
8
22
|
async register(record) {
|
|
9
|
-
const key =
|
|
10
|
-
|
|
11
|
-
await this.redis.
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
const key = `${REGISTRY_PREFIX}${record.id}`;
|
|
24
|
+
const value = JSON.stringify({ ...record, last_seen: new Date().toISOString() });
|
|
25
|
+
await this.redis.set(key, value, "EX", TTL_SECONDS);
|
|
26
|
+
}
|
|
27
|
+
/** Refresh TTL for a live agent — call every 60s from cc-tg. */
|
|
28
|
+
async heartbeat(id) {
|
|
29
|
+
const key = `${REGISTRY_PREFIX}${id}`;
|
|
30
|
+
const raw = await this.redis.get(key);
|
|
31
|
+
if (!raw)
|
|
32
|
+
return; // never registered or already expired
|
|
33
|
+
const record = JSON.parse(raw);
|
|
34
|
+
record.last_seen = new Date().toISOString();
|
|
35
|
+
await this.redis.set(key, JSON.stringify(record), "EX", TTL_SECONDS);
|
|
36
|
+
}
|
|
37
|
+
/** Deregister an agent on clean shutdown. */
|
|
38
|
+
async deregister(id) {
|
|
39
|
+
await this.redis.del(`${REGISTRY_PREFIX}${id}`);
|
|
40
|
+
}
|
|
41
|
+
/** List all currently-live agents. */
|
|
42
|
+
async list() {
|
|
43
|
+
const keys = await this.redis.keys(`${REGISTRY_PREFIX}*`);
|
|
44
|
+
if (keys.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const values = await this.redis.mget(...keys);
|
|
47
|
+
return values
|
|
48
|
+
.filter((v) => v !== null)
|
|
49
|
+
.map((v) => JSON.parse(v));
|
|
50
|
+
}
|
|
51
|
+
/** Get a single agent by id. Returns null if not found / expired. */
|
|
52
|
+
async get(id) {
|
|
53
|
+
const raw = await this.redis.get(`${REGISTRY_PREFIX}${id}`);
|
|
54
|
+
return raw ? JSON.parse(raw) : null;
|
|
33
55
|
}
|
|
34
56
|
}
|
|
35
|
-
exports.Registry = Registry;
|
|
36
57
|
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/agent-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Ops layer for cc-tg agent fleet — discovery, control, log aggregation",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
},
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
9
8
|
"scripts": {
|
|
10
9
|
"build": "tsc",
|
|
11
|
-
"start": "node dist/ops-bot.js"
|
|
10
|
+
"start": "node dist/ops-bot.js",
|
|
11
|
+
"dev": "tsx src/ops-bot.ts"
|
|
12
12
|
},
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"keywords": ["claude", "agents", "ops", "telegram", "cc-tg"],
|
|
15
|
+
"author": "ecoclaw",
|
|
16
|
+
"license": "MIT",
|
|
13
17
|
"dependencies": {
|
|
14
|
-
"ioredis": "
|
|
15
|
-
"node-telegram-bot-api": "
|
|
18
|
+
"ioredis": "5.3.2",
|
|
19
|
+
"node-telegram-bot-api": "0.66.0"
|
|
16
20
|
},
|
|
17
21
|
"devDependencies": {
|
|
18
|
-
"
|
|
19
|
-
"@types/node": "
|
|
20
|
-
"
|
|
22
|
+
"@types/node": "22.10.0",
|
|
23
|
+
"@types/node-telegram-bot-api": "0.64.7",
|
|
24
|
+
"tsx": "4.19.2",
|
|
25
|
+
"typescript": "5.7.2"
|
|
21
26
|
}
|
|
22
27
|
}
|
|
@@ -1,25 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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).
|
package/src/control.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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
DELETED
package/src/ops-bot.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|