@agentlip/hub 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/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @agentlip/hub
2
+
3
+ Bun-based HTTP + WebSocket hub daemon for Agentlip local coordination.
4
+
5
+ ## Features
6
+
7
+ ### Phase 0 (Current)
8
+
9
+ - ✅ `GET /health` endpoint (unauthenticated)
10
+ - ✅ Localhost-only bind validation
11
+ - ✅ `startHub()` API with configurable options
12
+
13
+ ## API
14
+
15
+ ### `startHub(options?)`
16
+
17
+ Start the Agentlip hub HTTP server.
18
+
19
+ ```typescript
20
+ import { startHub } from "@agentlip/hub";
21
+
22
+ const hub = await startHub({
23
+ host: "127.0.0.1", // Default: 127.0.0.1
24
+ port: 8080, // Default: 0 (random port)
25
+ instanceId: "...", // Default: auto-generated UUID
26
+ dbId: "...", // Default: "unknown"
27
+ schemaVersion: 1, // Default: 0
28
+ allowUnsafeNetwork: false, // Default: false
29
+ });
30
+
31
+ console.log(`Hub running on ${hub.host}:${hub.port}`);
32
+
33
+ // Stop server
34
+ await hub.stop();
35
+ ```
36
+
37
+ ### `assertLocalhostBind(host, options?)`
38
+
39
+ Validates that a bind host is localhost-only.
40
+
41
+ ```typescript
42
+ import { assertLocalhostBind } from "@agentlip/hub";
43
+
44
+ // These pass:
45
+ assertLocalhostBind("127.0.0.1");
46
+ assertLocalhostBind("::1");
47
+ assertLocalhostBind("localhost");
48
+
49
+ // These throw unless allowUnsafeNetwork is true:
50
+ assertLocalhostBind("0.0.0.0"); // Error
51
+ assertLocalhostBind("0.0.0.0", { allowUnsafeNetwork: true }); // OK
52
+ ```
53
+
54
+ ## Endpoints
55
+
56
+ ### `GET /health`
57
+
58
+ Unauthenticated health check endpoint. Always returns 200 when hub is responsive.
59
+
60
+ **Response:**
61
+ ```json
62
+ {
63
+ "status": "ok",
64
+ "instance_id": "abc123-def456",
65
+ "db_id": "workspace-db-uuid",
66
+ "schema_version": 1,
67
+ "protocol_version": "v1",
68
+ "pid": 12345,
69
+ "uptime_seconds": 3600
70
+ }
71
+ ```
72
+
73
+ ## Security
74
+
75
+ By default, the hub only binds to localhost (`127.0.0.1` or `::1`). Attempts to bind to `0.0.0.0` or other network interfaces will fail unless `allowUnsafeNetwork: true` is explicitly set.
76
+
77
+ This prevents accidental network exposure of the hub.
78
+
79
+ ## Manual Verification
80
+
81
+ Start a test server:
82
+
83
+ ```bash
84
+ cd packages/hub
85
+ bun run verify-health.ts
86
+ ```
87
+
88
+ Or test manually:
89
+
90
+ ```bash
91
+ # Terminal 1: Start hub on random port
92
+ bun -e "import {startHub} from './src/index.ts'; const h = await startHub(); console.log('Port:', h.port); await new Promise(r => setTimeout(r, 60000))"
93
+
94
+ # Terminal 2: Test health endpoint (replace PORT with actual port from Terminal 1)
95
+ curl http://127.0.0.1:PORT/health | jq
96
+
97
+ # Should output:
98
+ # {
99
+ # "status": "ok",
100
+ # "instance_id": "...",
101
+ # "db_id": "unknown",
102
+ # "schema_version": 0,
103
+ # "protocol_version": "v1",
104
+ # "pid": ...,
105
+ # "uptime_seconds": ...
106
+ # }
107
+ ```
108
+
109
+ Test bind validation:
110
+
111
+ ```bash
112
+ # Should fail (0.0.0.0 not allowed by default)
113
+ bun -e "import {startHub} from './src/index.ts'; await startHub({host: '0.0.0.0'})"
114
+
115
+ # Should succeed (explicit unsafe flag)
116
+ bun -e "import {startHub} from './src/index.ts'; const h = await startHub({host: '0.0.0.0', allowUnsafeNetwork: true}); console.log('Port:', h.port)"
117
+ ```
118
+
119
+ ## Future Work
120
+
121
+ - Authentication (auth token validation)
122
+ - WebSocket endpoint (`/ws`)
123
+ - REST API routes (`/api/v1/*`)
124
+ - Writer lock management
125
+ - server.json persistence
126
+ - Database integration (db_id, schema_version from meta table)
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@agentlip/hub",
3
+ "version": "0.1.0",
4
+ "description": "HTTP and WebSocket hub daemon for Agentlip",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/phosphorco/agentlip",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/phosphorco/agentlip.git",
11
+ "directory": "packages/hub"
12
+ },
13
+ "engines": {
14
+ "bun": ">=1.0.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "src/**/*.ts",
21
+ "!src/**/*.test.ts",
22
+ "!src/integrationHarness.ts"
23
+ ],
24
+ "exports": {
25
+ ".": "./src/index.ts",
26
+ "./agentlipd": "./src/agentlipd.ts"
27
+ },
28
+ "bin": {
29
+ "agentlipd": "./src/agentlipd.ts"
30
+ },
31
+ "dependencies": {
32
+ "@agentlip/protocol": "^0.1.0",
33
+ "@agentlip/workspace": "^0.1.0",
34
+ "@agentlip/kernel": "^0.1.0"
35
+ }
36
+ }
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * agentlipd CLI - daemon control utilities
4
+ *
5
+ * Commands:
6
+ * - status: check hub health and validate against on-disk DB
7
+ */
8
+
9
+ // Runtime guard: require Bun
10
+ if (typeof Bun === "undefined") {
11
+ console.error("Error: @agentlip/hub requires Bun runtime (https://bun.sh)");
12
+ process.exit(1);
13
+ }
14
+
15
+ import { readServerJson } from "./serverJson.js";
16
+ import { discoverWorkspaceRoot } from "@agentlip/workspace";
17
+ import { openDb } from "@agentlip/kernel";
18
+ import type { HealthResponse } from "@agentlip/protocol";
19
+
20
+ interface StatusOptions {
21
+ workspace?: string;
22
+ json?: boolean;
23
+ }
24
+
25
+ interface StatusResult {
26
+ status: "running" | "not_running" | "stale" | "unreachable" | "db_mismatch";
27
+ instance_id?: string;
28
+ db_id?: string;
29
+ schema_version?: number;
30
+ protocol_version?: string;
31
+ port?: number;
32
+ pid?: number;
33
+ uptime_seconds?: number;
34
+ error?: string;
35
+ }
36
+
37
+ /**
38
+ * Read db_id from on-disk meta table.
39
+ * Returns null if DB doesn't exist or meta table not initialized.
40
+ */
41
+ async function readDbIdFromDisk(dbPath: string): Promise<string | null> {
42
+ try {
43
+ const db = openDb({ dbPath, readonly: true });
44
+ try {
45
+ const row = db
46
+ .query<{ value: string }, []>(
47
+ "SELECT value FROM meta WHERE key = 'db_id'"
48
+ )
49
+ .get();
50
+ return row?.value ?? null;
51
+ } finally {
52
+ db.close();
53
+ }
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Fetch health endpoint with timeout.
61
+ */
62
+ async function fetchHealthWithTimeout(
63
+ url: string,
64
+ timeoutMs: number = 5000
65
+ ): Promise<HealthResponse> {
66
+ const controller = new AbortController();
67
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
68
+
69
+ try {
70
+ const response = await fetch(url, {
71
+ signal: controller.signal,
72
+ method: "GET",
73
+ });
74
+
75
+ if (!response.ok) {
76
+ throw new Error(`HTTP ${response.status}`);
77
+ }
78
+
79
+ return (await response.json()) as HealthResponse;
80
+ } finally {
81
+ clearTimeout(timeoutId);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Check hub status: read server.json, call /health, validate db_id.
87
+ */
88
+ export async function checkStatus(
89
+ options: StatusOptions = {}
90
+ ): Promise<StatusResult> {
91
+ // Discover workspace root
92
+ const discovered = await discoverWorkspaceRoot(options.workspace);
93
+ if (!discovered) {
94
+ return {
95
+ status: "not_running",
96
+ error: "No workspace found (no .agentlip/db.sqlite3 in current directory tree)",
97
+ };
98
+ }
99
+
100
+ const { root: workspaceRoot, dbPath } = discovered;
101
+
102
+ // Read server.json
103
+ const serverJson = await readServerJson({ workspaceRoot });
104
+ if (!serverJson) {
105
+ return {
106
+ status: "not_running",
107
+ error: "No hub running (server.json not found)",
108
+ };
109
+ }
110
+
111
+ // Read db_id from on-disk DB
112
+ const diskDbId = await readDbIdFromDisk(dbPath);
113
+
114
+ // Call /health endpoint
115
+ const healthUrl = `http://${serverJson.host}:${serverJson.port}/health`;
116
+ let health: HealthResponse;
117
+
118
+ try {
119
+ health = await fetchHealthWithTimeout(healthUrl, 5000);
120
+ } catch (err: any) {
121
+ // Hub unreachable - server.json is stale
122
+ return {
123
+ status: "unreachable",
124
+ port: serverJson.port,
125
+ pid: serverJson.pid,
126
+ error: `Hub unreachable at ${healthUrl} (server.json may be stale): ${err.message}`,
127
+ };
128
+ }
129
+
130
+ // Validate db_id matches
131
+ if (diskDbId && health.db_id !== diskDbId) {
132
+ return {
133
+ status: "db_mismatch",
134
+ instance_id: health.instance_id,
135
+ db_id: health.db_id,
136
+ schema_version: health.schema_version,
137
+ protocol_version: health.protocol_version,
138
+ port: serverJson.port,
139
+ pid: health.pid,
140
+ uptime_seconds: health.uptime_seconds,
141
+ error: `DB ID mismatch: hub reports ${health.db_id}, disk has ${diskDbId}`,
142
+ };
143
+ }
144
+
145
+ // All good!
146
+ return {
147
+ status: "running",
148
+ instance_id: health.instance_id,
149
+ db_id: health.db_id,
150
+ schema_version: health.schema_version,
151
+ protocol_version: health.protocol_version,
152
+ port: serverJson.port,
153
+ pid: health.pid,
154
+ uptime_seconds: health.uptime_seconds,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Print status result in human-readable format.
160
+ */
161
+ function printHumanStatus(result: StatusResult): void {
162
+ switch (result.status) {
163
+ case "running":
164
+ console.log("✓ Hub is running");
165
+ console.log(` Instance ID: ${result.instance_id}`);
166
+ console.log(` Database ID: ${result.db_id}`);
167
+ console.log(` Schema Version: ${result.schema_version}`);
168
+ console.log(` Protocol Version: ${result.protocol_version}`);
169
+ console.log(` Port: ${result.port}`);
170
+ console.log(` PID: ${result.pid}`);
171
+ console.log(` Uptime: ${result.uptime_seconds}s`);
172
+ break;
173
+
174
+ case "not_running":
175
+ console.log("✗ Hub is not running");
176
+ if (result.error) {
177
+ console.log(` ${result.error}`);
178
+ }
179
+ break;
180
+
181
+ case "unreachable":
182
+ console.log("✗ Hub is unreachable (stale server.json?)");
183
+ if (result.port) {
184
+ console.log(` Port: ${result.port}`);
185
+ }
186
+ if (result.pid) {
187
+ console.log(` PID: ${result.pid}`);
188
+ }
189
+ if (result.error) {
190
+ console.log(` Error: ${result.error}`);
191
+ }
192
+ break;
193
+
194
+ case "stale":
195
+ console.log("✗ Hub server.json is stale");
196
+ if (result.error) {
197
+ console.log(` ${result.error}`);
198
+ }
199
+ break;
200
+
201
+ case "db_mismatch":
202
+ console.log("✗ Database ID mismatch");
203
+ console.log(` Hub reports: ${result.db_id}`);
204
+ console.log(` Instance ID: ${result.instance_id}`);
205
+ console.log(` Port: ${result.port}`);
206
+ console.log(` PID: ${result.pid}`);
207
+ if (result.error) {
208
+ console.log(` Error: ${result.error}`);
209
+ }
210
+ break;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Main CLI entry point.
216
+ */
217
+ function printHelp(): void {
218
+ console.log("Usage: agentlipd <command> [options]");
219
+ console.log();
220
+ console.log("Commands:");
221
+ console.log(" status Check hub health using server.json and validate db_id");
222
+ console.log();
223
+ console.log("Run: agentlipd status --help");
224
+ }
225
+
226
+ function printStatusHelp(): void {
227
+ console.log("Usage: agentlipd status [--workspace <path>] [--json]");
228
+ console.log();
229
+ console.log("Check hub health and validate against on-disk database.");
230
+ console.log();
231
+ console.log("Options:");
232
+ console.log(" --workspace <path> Explicit workspace root (default: auto-discover)");
233
+ console.log(" --json Output as JSON");
234
+ console.log(" --help, -h Show this help");
235
+ console.log();
236
+ console.log("Exit codes:");
237
+ console.log(" 0 Running");
238
+ console.log(" 3 Not running / unreachable");
239
+ console.log(" 1 Other errors (DB mismatch, etc.)");
240
+ }
241
+
242
+ export async function main(argv: string[] = process.argv.slice(2)) {
243
+ const [command, ...args] = argv;
244
+
245
+ if (!command || command === "--help" || command === "-h") {
246
+ printHelp();
247
+ process.exit(0);
248
+ }
249
+
250
+ if (command !== "status") {
251
+ console.error(`Unknown command: ${command}`);
252
+ console.error("Use --help for usage information");
253
+ process.exit(1);
254
+ }
255
+
256
+ const options: StatusOptions = {};
257
+
258
+ // Parse status args
259
+ for (let i = 0; i < args.length; i++) {
260
+ const arg = args[i];
261
+ if (arg === "--workspace" || arg === "-w") {
262
+ const value = args[++i];
263
+ if (!value) {
264
+ console.error("--workspace requires a value");
265
+ process.exit(1);
266
+ }
267
+ options.workspace = value;
268
+ } else if (arg === "--json") {
269
+ options.json = true;
270
+ } else if (arg === "--help" || arg === "-h") {
271
+ printStatusHelp();
272
+ process.exit(0);
273
+ } else {
274
+ console.error(`Unknown argument: ${arg}`);
275
+ console.error("Use --help for usage information");
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ const result = await checkStatus(options);
281
+
282
+ if (options.json) {
283
+ console.log(JSON.stringify(result, null, 2));
284
+ } else {
285
+ printHumanStatus(result);
286
+ }
287
+
288
+ if (result.status === "running") {
289
+ process.exit(0);
290
+ }
291
+
292
+ if (
293
+ result.status === "not_running" ||
294
+ result.status === "unreachable" ||
295
+ result.status === "stale"
296
+ ) {
297
+ process.exit(3);
298
+ }
299
+
300
+ process.exit(1);
301
+ }
302
+
303
+ // Run if executed directly
304
+ if (import.meta.main) {
305
+ main().catch((err) => {
306
+ console.error("Fatal error:", err);
307
+ process.exit(1);
308
+ });
309
+ }