@openclaw-cloud/agent-controller 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/Dockerfile +9 -0
- package/__tests__/backup.test.ts +145 -0
- package/__tests__/connection.test.ts +111 -0
- package/__tests__/handlers.test.ts +150 -0
- package/__tests__/heartbeat.test.ts +80 -0
- package/bin/agent-controller.js +5 -0
- package/dist/connection.d.ts +11 -0
- package/dist/connection.js +117 -0
- package/dist/connection.js.map +1 -0
- package/dist/handlers/backup.d.ts +2 -0
- package/dist/handlers/backup.js +81 -0
- package/dist/handlers/backup.js.map +1 -0
- package/dist/handlers/config.d.ts +2 -0
- package/dist/handlers/config.js +43 -0
- package/dist/handlers/config.js.map +1 -0
- package/dist/handlers/deploy.d.ts +2 -0
- package/dist/handlers/deploy.js +30 -0
- package/dist/handlers/deploy.js.map +1 -0
- package/dist/handlers/exec.d.ts +2 -0
- package/dist/handlers/exec.js +29 -0
- package/dist/handlers/exec.js.map +1 -0
- package/dist/handlers/pair.d.ts +2 -0
- package/dist/handlers/pair.js +23 -0
- package/dist/handlers/pair.js.map +1 -0
- package/dist/handlers/restart.d.ts +2 -0
- package/dist/handlers/restart.js +18 -0
- package/dist/handlers/restart.js.map +1 -0
- package/dist/handlers/stop.d.ts +2 -0
- package/dist/handlers/stop.js +14 -0
- package/dist/handlers/stop.js.map +1 -0
- package/dist/heartbeat.d.ts +9 -0
- package/dist/heartbeat.js +60 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/jest.config.ts +16 -0
- package/package.json +26 -0
- package/src/connection.ts +135 -0
- package/src/handlers/backup.ts +97 -0
- package/src/handlers/config.ts +48 -0
- package/src/handlers/deploy.ts +32 -0
- package/src/handlers/exec.ts +32 -0
- package/src/handlers/pair.ts +26 -0
- package/src/handlers/restart.ts +19 -0
- package/src/handlers/stop.ts +17 -0
- package/src/heartbeat.ts +73 -0
- package/src/index.ts +47 -0
- package/src/types.ts +32 -0
- package/tsconfig.json +18 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { handleBackup } from '../src/handlers/backup';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
jest.mock('node:child_process');
|
|
7
|
+
jest.mock('node:fs/promises');
|
|
8
|
+
|
|
9
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
10
|
+
const mockStat = fs.stat as jest.Mock;
|
|
11
|
+
const mockReadFile = fs.readFile as jest.Mock;
|
|
12
|
+
const mockUnlink = fs.unlink as jest.Mock;
|
|
13
|
+
|
|
14
|
+
function fakeExecSuccess() {
|
|
15
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
16
|
+
cb(null, Buffer.from(''), Buffer.from(''));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeExecError(message: string, stderr = '') {
|
|
21
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
22
|
+
cb(new Error(message), Buffer.from(''), Buffer.from(stderr));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mockFetch = jest.fn() as jest.Mock;
|
|
27
|
+
(globalThis as any).fetch = mockFetch;
|
|
28
|
+
|
|
29
|
+
describe('handleBackup', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
mockUnlink.mockResolvedValue(undefined);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects missing uploadUrl', async () => {
|
|
36
|
+
const cmd: AgentCommand = { id: '1', type: 'backup', payload: {} };
|
|
37
|
+
const res = await handleBackup(cmd);
|
|
38
|
+
expect(res.success).toBe(false);
|
|
39
|
+
expect(res.error).toContain('Missing "uploadUrl"');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('creates tar, uploads, cleans up on success', async () => {
|
|
43
|
+
fakeExecSuccess();
|
|
44
|
+
mockStat.mockResolvedValue({ size: 1024 });
|
|
45
|
+
mockReadFile.mockResolvedValue(Buffer.from('fake-archive'));
|
|
46
|
+
mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
|
|
47
|
+
|
|
48
|
+
const cmd: AgentCommand = {
|
|
49
|
+
id: '2',
|
|
50
|
+
type: 'backup',
|
|
51
|
+
payload: { uploadUrl: 'https://backend.test/upload' },
|
|
52
|
+
};
|
|
53
|
+
const res = await handleBackup(cmd);
|
|
54
|
+
|
|
55
|
+
expect(res.success).toBe(true);
|
|
56
|
+
expect(res.data?.size).toBe(1024);
|
|
57
|
+
expect(res.data?.filename).toMatch(/^backup-\d+\.tar\.gz$/);
|
|
58
|
+
|
|
59
|
+
// verify tar command
|
|
60
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
61
|
+
expect.stringContaining('tar -czf /tmp/backup-'),
|
|
62
|
+
expect.any(Object),
|
|
63
|
+
expect.any(Function),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// verify upload
|
|
67
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
68
|
+
'https://backend.test/upload',
|
|
69
|
+
expect.objectContaining({ method: 'POST' }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// verify cleanup
|
|
73
|
+
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('/tmp/backup-'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns error when tar fails', async () => {
|
|
77
|
+
fakeExecError('tar failed', 'No such file or directory');
|
|
78
|
+
|
|
79
|
+
const cmd: AgentCommand = {
|
|
80
|
+
id: '3',
|
|
81
|
+
type: 'backup',
|
|
82
|
+
payload: { uploadUrl: 'https://backend.test/upload' },
|
|
83
|
+
};
|
|
84
|
+
const res = await handleBackup(cmd);
|
|
85
|
+
|
|
86
|
+
expect(res.success).toBe(false);
|
|
87
|
+
expect(res.error).toContain('tar failed');
|
|
88
|
+
expect(mockUnlink).toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns error when upload fails', async () => {
|
|
92
|
+
fakeExecSuccess();
|
|
93
|
+
mockStat.mockResolvedValue({ size: 512 });
|
|
94
|
+
mockReadFile.mockResolvedValue(Buffer.from('fake'));
|
|
95
|
+
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
|
|
96
|
+
|
|
97
|
+
const cmd: AgentCommand = {
|
|
98
|
+
id: '4',
|
|
99
|
+
type: 'backup',
|
|
100
|
+
payload: { uploadUrl: 'https://backend.test/upload' },
|
|
101
|
+
};
|
|
102
|
+
const res = await handleBackup(cmd);
|
|
103
|
+
|
|
104
|
+
expect(res.success).toBe(false);
|
|
105
|
+
expect(res.error).toContain('Upload failed');
|
|
106
|
+
expect(res.error).toContain('500');
|
|
107
|
+
expect(mockUnlink).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns error when fetch throws (network error)', async () => {
|
|
111
|
+
fakeExecSuccess();
|
|
112
|
+
mockStat.mockResolvedValue({ size: 256 });
|
|
113
|
+
mockReadFile.mockResolvedValue(Buffer.from('fake'));
|
|
114
|
+
mockFetch.mockRejectedValue(new Error('network error'));
|
|
115
|
+
|
|
116
|
+
const cmd: AgentCommand = {
|
|
117
|
+
id: '5',
|
|
118
|
+
type: 'backup',
|
|
119
|
+
payload: { uploadUrl: 'https://backend.test/upload' },
|
|
120
|
+
};
|
|
121
|
+
const res = await handleBackup(cmd);
|
|
122
|
+
|
|
123
|
+
expect(res.success).toBe(false);
|
|
124
|
+
expect(res.error).toContain('network error');
|
|
125
|
+
expect(mockUnlink).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('cleans up even if unlink fails', async () => {
|
|
129
|
+
fakeExecSuccess();
|
|
130
|
+
mockStat.mockResolvedValue({ size: 100 });
|
|
131
|
+
mockReadFile.mockResolvedValue(Buffer.from('fake'));
|
|
132
|
+
mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
|
|
133
|
+
mockUnlink.mockRejectedValue(new Error('ENOENT'));
|
|
134
|
+
|
|
135
|
+
const cmd: AgentCommand = {
|
|
136
|
+
id: '6',
|
|
137
|
+
type: 'backup',
|
|
138
|
+
payload: { uploadUrl: 'https://backend.test/upload' },
|
|
139
|
+
};
|
|
140
|
+
const res = await handleBackup(cmd);
|
|
141
|
+
|
|
142
|
+
// should still succeed despite cleanup error
|
|
143
|
+
expect(res.success).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Centrifuge } from 'centrifuge';
|
|
2
|
+
|
|
3
|
+
jest.mock('centrifuge');
|
|
4
|
+
jest.mock('ws', () => jest.fn());
|
|
5
|
+
|
|
6
|
+
const MockCentrifuge = Centrifuge as jest.MockedClass<typeof Centrifuge>;
|
|
7
|
+
|
|
8
|
+
// Mock subscription instance
|
|
9
|
+
function createMockSubscription(channel: string) {
|
|
10
|
+
const handlers: Record<string, Function> = {};
|
|
11
|
+
return {
|
|
12
|
+
on: jest.fn((event: string, cb: Function) => { handlers[event] = cb; }),
|
|
13
|
+
subscribe: jest.fn(),
|
|
14
|
+
channel,
|
|
15
|
+
_handlers: handlers,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMockClient(mockSubs: ReturnType<typeof createMockSubscription>[]) {
|
|
20
|
+
let subIndex = 0;
|
|
21
|
+
const clientHandlers: Record<string, Function> = {};
|
|
22
|
+
return {
|
|
23
|
+
on: jest.fn((event: string, cb: Function) => { clientHandlers[event] = cb; }),
|
|
24
|
+
newSubscription: jest.fn(() => mockSubs[subIndex++]),
|
|
25
|
+
connect: jest.fn(),
|
|
26
|
+
disconnect: jest.fn(),
|
|
27
|
+
publish: jest.fn().mockResolvedValue(undefined),
|
|
28
|
+
_handlers: clientHandlers,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('createConnection', () => {
|
|
37
|
+
it('subscribes to agent: command channel only', async () => {
|
|
38
|
+
const commandSub = createMockSubscription('agent:test-1');
|
|
39
|
+
const mockClient = createMockClient([commandSub]);
|
|
40
|
+
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
41
|
+
|
|
42
|
+
const { createConnection } = await import('../src/connection');
|
|
43
|
+
|
|
44
|
+
const result = createConnection({
|
|
45
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
46
|
+
token: 'test-token',
|
|
47
|
+
agentId: 'test-1',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(MockCentrifuge).toHaveBeenCalledWith(
|
|
51
|
+
'ws://localhost:8000/connection/websocket',
|
|
52
|
+
expect.objectContaining({ token: 'test-token' })
|
|
53
|
+
);
|
|
54
|
+
expect(mockClient.newSubscription).toHaveBeenCalledWith('agent:test-1');
|
|
55
|
+
expect(mockClient.newSubscription).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(commandSub.subscribe).toHaveBeenCalled();
|
|
57
|
+
expect(mockClient.connect).toHaveBeenCalled();
|
|
58
|
+
expect(commandSub.on).toHaveBeenCalledWith('publication', expect.any(Function));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('routes exec command to handler', async () => {
|
|
62
|
+
const commandSub = createMockSubscription('agent:test-1');
|
|
63
|
+
const mockClient = createMockClient([commandSub]);
|
|
64
|
+
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
65
|
+
|
|
66
|
+
jest.mock('node:child_process', () => ({
|
|
67
|
+
exec: jest.fn((_cmd: string, _opts: unknown, cb: Function) => {
|
|
68
|
+
cb(null, Buffer.from('output'), Buffer.from(''));
|
|
69
|
+
}),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const { createConnection } = await import('../src/connection');
|
|
73
|
+
createConnection({ url: 'ws://localhost:8000/connection/websocket', token: 'tok', agentId: 'test-1' });
|
|
74
|
+
|
|
75
|
+
// Simulate a publication on the command channel
|
|
76
|
+
const publicationHandler = commandSub._handlers['publication'];
|
|
77
|
+
expect(publicationHandler).toBeDefined();
|
|
78
|
+
|
|
79
|
+
await publicationHandler({
|
|
80
|
+
data: { id: 'cmd-1', type: 'exec', payload: { command: 'echo hi' } },
|
|
81
|
+
channel: 'agent:test-1',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Should have published a response to the agent channel
|
|
85
|
+
expect(mockClient.publish).toHaveBeenCalledWith(
|
|
86
|
+
'agent:test-1',
|
|
87
|
+
expect.objectContaining({ id: 'cmd-1', type: 'exec' })
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('handles unknown command types gracefully', async () => {
|
|
92
|
+
const commandSub = createMockSubscription('agent:test-1');
|
|
93
|
+
const mockClient = createMockClient([commandSub]);
|
|
94
|
+
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
95
|
+
|
|
96
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
97
|
+
|
|
98
|
+
const { createConnection } = await import('../src/connection');
|
|
99
|
+
createConnection({ url: 'ws://localhost:8000/connection/websocket', token: 'tok', agentId: 'test-1' });
|
|
100
|
+
|
|
101
|
+
const publicationHandler = commandSub._handlers['publication'];
|
|
102
|
+
await publicationHandler({
|
|
103
|
+
data: { id: 'cmd-2', type: 'unknown_cmd', payload: {} },
|
|
104
|
+
channel: 'agent:test-1',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown command type'));
|
|
108
|
+
expect(mockClient.publish).not.toHaveBeenCalled();
|
|
109
|
+
consoleSpy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { handleExec } from '../src/handlers/exec';
|
|
2
|
+
import { handleRestart } from '../src/handlers/restart';
|
|
3
|
+
import { handleDeploy } from '../src/handlers/deploy';
|
|
4
|
+
import { handleConfig } from '../src/handlers/config';
|
|
5
|
+
import { handlePair } from '../src/handlers/pair';
|
|
6
|
+
import { handleStop } from '../src/handlers/stop';
|
|
7
|
+
import type { AgentCommand } from '../src/types';
|
|
8
|
+
import childProcess from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
jest.mock('node:child_process');
|
|
12
|
+
jest.mock('node:fs/promises');
|
|
13
|
+
|
|
14
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
15
|
+
|
|
16
|
+
function fakeExec(error: Error | null, stdout: string, stderr: string) {
|
|
17
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
18
|
+
cb(error, Buffer.from(stdout), Buffer.from(stderr));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('handleExec', () => {
|
|
23
|
+
beforeEach(() => jest.clearAllMocks());
|
|
24
|
+
|
|
25
|
+
it('executes a command and returns stdout', async () => {
|
|
26
|
+
fakeExec(null, 'hello\n', '');
|
|
27
|
+
const cmd: AgentCommand = { id: '1', type: 'exec', payload: { command: 'echo hello' } };
|
|
28
|
+
const res = await handleExec(cmd);
|
|
29
|
+
expect(res.success).toBe(true);
|
|
30
|
+
expect(res.data?.stdout).toBe('hello\n');
|
|
31
|
+
expect(res.data?.exitCode).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns error when command fails', async () => {
|
|
35
|
+
const err = new Error('command failed');
|
|
36
|
+
(err as any).code = 127;
|
|
37
|
+
fakeExec(err, '', 'not found');
|
|
38
|
+
const cmd: AgentCommand = { id: '2', type: 'exec', payload: { command: 'bad' } };
|
|
39
|
+
const res = await handleExec(cmd);
|
|
40
|
+
expect(res.success).toBe(false);
|
|
41
|
+
expect(res.error).toBe('command failed');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects missing command payload', async () => {
|
|
45
|
+
const cmd: AgentCommand = { id: '3', type: 'exec', payload: {} };
|
|
46
|
+
const res = await handleExec(cmd);
|
|
47
|
+
expect(res.success).toBe(false);
|
|
48
|
+
expect(res.error).toContain('Missing');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('handleRestart', () => {
|
|
53
|
+
beforeEach(() => jest.clearAllMocks());
|
|
54
|
+
|
|
55
|
+
it('calls openclaw gateway restart', async () => {
|
|
56
|
+
fakeExec(null, 'restarted', '');
|
|
57
|
+
const cmd: AgentCommand = { id: '4', type: 'restart', payload: {} };
|
|
58
|
+
const res = await handleRestart(cmd);
|
|
59
|
+
expect(res.success).toBe(true);
|
|
60
|
+
expect(mockExec).toHaveBeenCalledWith('openclaw gateway restart', expect.any(Object), expect.any(Function));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns error on restart failure', async () => {
|
|
64
|
+
fakeExec(new Error('restart failed'), '', 'err');
|
|
65
|
+
const cmd: AgentCommand = { id: '5', type: 'restart', payload: {} };
|
|
66
|
+
const res = await handleRestart(cmd);
|
|
67
|
+
expect(res.success).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('handleDeploy', () => {
|
|
72
|
+
beforeEach(() => jest.clearAllMocks());
|
|
73
|
+
|
|
74
|
+
it('installs and restarts on success', async () => {
|
|
75
|
+
let callCount = 0;
|
|
76
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
77
|
+
callCount++;
|
|
78
|
+
cb(null, Buffer.from(`step${callCount}`), Buffer.from(''));
|
|
79
|
+
});
|
|
80
|
+
const cmd: AgentCommand = { id: '6', type: 'deploy', payload: {} };
|
|
81
|
+
const res = await handleDeploy(cmd);
|
|
82
|
+
expect(res.success).toBe(true);
|
|
83
|
+
expect(mockExec).toHaveBeenCalledTimes(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails if install fails', async () => {
|
|
87
|
+
fakeExec(new Error('npm error'), '', 'err');
|
|
88
|
+
const cmd: AgentCommand = { id: '7', type: 'deploy', payload: {} };
|
|
89
|
+
const res = await handleDeploy(cmd);
|
|
90
|
+
expect(res.success).toBe(false);
|
|
91
|
+
expect(res.error).toContain('Install failed');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('handleConfig', () => {
|
|
96
|
+
beforeEach(() => jest.clearAllMocks());
|
|
97
|
+
|
|
98
|
+
it('writes config and restarts', async () => {
|
|
99
|
+
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
|
100
|
+
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
|
101
|
+
fakeExec(null, 'ok', '');
|
|
102
|
+
const cmd: AgentCommand = { id: '8', type: 'config', payload: { filename: 'test.yml', content: 'key: val' } };
|
|
103
|
+
const res = await handleConfig(cmd);
|
|
104
|
+
expect(res.success).toBe(true);
|
|
105
|
+
expect(fs.writeFile).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects missing payload', async () => {
|
|
109
|
+
const cmd: AgentCommand = { id: '9', type: 'config', payload: {} };
|
|
110
|
+
const res = await handleConfig(cmd);
|
|
111
|
+
expect(res.success).toBe(false);
|
|
112
|
+
expect(res.error).toContain('Missing');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('handlePair', () => {
|
|
117
|
+
it('returns paired response', async () => {
|
|
118
|
+
const cmd: AgentCommand = { id: '10', type: 'pair', payload: { token: 'abc', targetId: 'target-1' } };
|
|
119
|
+
const res = await handlePair(cmd);
|
|
120
|
+
expect(res.success).toBe(true);
|
|
121
|
+
expect(res.data?.paired).toBe(true);
|
|
122
|
+
expect(res.data?.targetId).toBe('target-1');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects missing pair params', async () => {
|
|
126
|
+
const cmd: AgentCommand = { id: '11', type: 'pair', payload: {} };
|
|
127
|
+
const res = await handlePair(cmd);
|
|
128
|
+
expect(res.success).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('handleStop', () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
jest.useFakeTimers();
|
|
135
|
+
jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
136
|
+
});
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
jest.useRealTimers();
|
|
139
|
+
jest.restoreAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns success and schedules shutdown', async () => {
|
|
143
|
+
const cmd: AgentCommand = { id: '12', type: 'stop', payload: {} };
|
|
144
|
+
const res = await handleStop(cmd);
|
|
145
|
+
expect(res.success).toBe(true);
|
|
146
|
+
expect(res.data?.message).toContain('gracefully');
|
|
147
|
+
jest.advanceTimersByTime(600);
|
|
148
|
+
expect(process.exit).toHaveBeenCalledWith(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
|
|
3
|
+
import { getMetrics, startHeartbeat } from '../src/heartbeat';
|
|
4
|
+
|
|
5
|
+
describe('getMetrics', () => {
|
|
6
|
+
it('returns cpu, mem, uptime', () => {
|
|
7
|
+
const metrics = getMetrics();
|
|
8
|
+
expect(metrics).toHaveProperty('cpu');
|
|
9
|
+
expect(metrics).toHaveProperty('mem');
|
|
10
|
+
expect(metrics).toHaveProperty('uptime');
|
|
11
|
+
expect(typeof metrics.cpu).toBe('number');
|
|
12
|
+
expect(typeof metrics.mem).toBe('number');
|
|
13
|
+
expect(typeof metrics.uptime).toBe('number');
|
|
14
|
+
expect(metrics.cpu).toBeGreaterThanOrEqual(0);
|
|
15
|
+
expect(metrics.cpu).toBeLessThanOrEqual(1);
|
|
16
|
+
expect(metrics.mem).toBeGreaterThanOrEqual(0);
|
|
17
|
+
expect(metrics.mem).toBeLessThanOrEqual(1);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('startHeartbeat', () => {
|
|
22
|
+
let fetchSpy: jest.SpyInstance;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.useFakeTimers();
|
|
26
|
+
fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
27
|
+
new Response('{}', { status: 200 }),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
jest.useRealTimers();
|
|
33
|
+
fetchSpy.mockRestore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const opts = {
|
|
37
|
+
centrifugoInternalUrl: 'http://centrifugo-internal:9000',
|
|
38
|
+
centrifugoApiKey: 'test-key',
|
|
39
|
+
agentId: 'agent-42',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
it('publishes heartbeat immediately and at intervals', () => {
|
|
43
|
+
const timer = startHeartbeat(opts);
|
|
44
|
+
|
|
45
|
+
// Immediate call
|
|
46
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
47
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
48
|
+
'http://centrifugo-internal:9000/api/publish',
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: expect.objectContaining({
|
|
52
|
+
'Authorization': 'apikey test-key',
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Check body contains correct channel and agent data
|
|
58
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
|
59
|
+
expect(body.channel).toBe('heartbeat:data');
|
|
60
|
+
expect(body.data.agentId).toBe('agent-42');
|
|
61
|
+
expect(body.data.type).toBe('heartbeat');
|
|
62
|
+
|
|
63
|
+
// After 30s
|
|
64
|
+
jest.advanceTimersByTime(30_000);
|
|
65
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
66
|
+
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('handles publish errors gracefully', () => {
|
|
71
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
fetchSpy.mockRejectedValue(new Error('network fail'));
|
|
73
|
+
|
|
74
|
+
const timer = startHeartbeat(opts);
|
|
75
|
+
|
|
76
|
+
// Should not throw
|
|
77
|
+
clearInterval(timer);
|
|
78
|
+
consoleSpy.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Centrifuge, Subscription } from 'centrifuge';
|
|
2
|
+
export interface ConnectionOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
token: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
backendUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createConnection(opts: ConnectionOptions): {
|
|
9
|
+
client: Centrifuge;
|
|
10
|
+
commandSub: Subscription;
|
|
11
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Centrifuge } from 'centrifuge';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { handleExec } from './handlers/exec.js';
|
|
4
|
+
import { handleRestart } from './handlers/restart.js';
|
|
5
|
+
import { handleDeploy } from './handlers/deploy.js';
|
|
6
|
+
import { handleConfig } from './handlers/config.js';
|
|
7
|
+
import { handlePair } from './handlers/pair.js';
|
|
8
|
+
import { handleStop } from './handlers/stop.js';
|
|
9
|
+
import { handleBackup } from './handlers/backup.js';
|
|
10
|
+
const handlers = {
|
|
11
|
+
exec: handleExec,
|
|
12
|
+
restart: handleRestart,
|
|
13
|
+
deploy: handleDeploy,
|
|
14
|
+
config: handleConfig,
|
|
15
|
+
pair: handlePair,
|
|
16
|
+
stop: handleStop,
|
|
17
|
+
backup: handleBackup,
|
|
18
|
+
};
|
|
19
|
+
async function fetchCentrifugoToken(backendUrl, agentToken, agentId) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`${backendUrl}/api/internal/centrifugo-token`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
'Authorization': `Bearer ${agentToken}`,
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ agentId }),
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
34
|
+
}
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
return data.token;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function createConnection(opts) {
|
|
43
|
+
const centrifugeOpts = {
|
|
44
|
+
websocket: WebSocket,
|
|
45
|
+
name: 'agent-controller',
|
|
46
|
+
minReconnectDelay: 1000,
|
|
47
|
+
maxReconnectDelay: 30000,
|
|
48
|
+
};
|
|
49
|
+
if (opts.backendUrl) {
|
|
50
|
+
// Dynamic JWT: fetch fresh token on every connect/reconnect
|
|
51
|
+
centrifugeOpts.getToken = async () => {
|
|
52
|
+
console.log('Fetching Centrifugo JWT from backend...');
|
|
53
|
+
try {
|
|
54
|
+
const token = await fetchCentrifugoToken(opts.backendUrl, opts.token, opts.agentId);
|
|
55
|
+
console.log('Got Centrifugo JWT');
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error('Failed to fetch Centrifugo JWT:', err instanceof Error ? err.message : err);
|
|
60
|
+
// Retry once
|
|
61
|
+
try {
|
|
62
|
+
const token = await fetchCentrifugoToken(opts.backendUrl, opts.token, opts.agentId);
|
|
63
|
+
console.log('Got Centrifugo JWT (retry)');
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
catch (retryErr) {
|
|
67
|
+
console.error('JWT fetch retry failed:', retryErr instanceof Error ? retryErr.message : retryErr);
|
|
68
|
+
throw retryErr;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Fallback: use raw token (backward compat, won't work with Centrifugo JWT validation)
|
|
75
|
+
console.warn('BACKEND_INTERNAL_URL not set, using AGENT_TOKEN directly (may not work)');
|
|
76
|
+
centrifugeOpts.token = opts.token;
|
|
77
|
+
}
|
|
78
|
+
const client = new Centrifuge(opts.url, centrifugeOpts);
|
|
79
|
+
const commandChannel = `agent:${opts.agentId}`;
|
|
80
|
+
const commandSub = client.newSubscription(commandChannel);
|
|
81
|
+
commandSub.on('publication', async (ctx) => {
|
|
82
|
+
const command = ctx.data;
|
|
83
|
+
if (!command || !command.type)
|
|
84
|
+
return;
|
|
85
|
+
const handler = handlers[command.type];
|
|
86
|
+
if (!handler) {
|
|
87
|
+
console.error(`Unknown command type: ${command.type}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const response = await handler(command);
|
|
92
|
+
await client.publish(commandChannel, response);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const errorResponse = {
|
|
96
|
+
id: command.id,
|
|
97
|
+
type: command.type,
|
|
98
|
+
success: false,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
};
|
|
101
|
+
await client.publish(commandChannel, errorResponse);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
client.on('connected', (ctx) => {
|
|
105
|
+
console.log(`Connected to Centrifugo via ${ctx.transport}`);
|
|
106
|
+
});
|
|
107
|
+
client.on('disconnected', (ctx) => {
|
|
108
|
+
console.log(`Disconnected: ${ctx.reason} (code ${ctx.code})`);
|
|
109
|
+
});
|
|
110
|
+
client.on('connecting', (ctx) => {
|
|
111
|
+
console.log(`Connecting: ${ctx.reason} (code ${ctx.code})`);
|
|
112
|
+
});
|
|
113
|
+
commandSub.subscribe();
|
|
114
|
+
client.connect();
|
|
115
|
+
return { client, commandSub };
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAgB,MAAM,YAAY,CAAC;AACtD,OAAO,SAAS,MAAM,IAAI,CAAC;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AASpD,MAAM,QAAQ,GAAkE;IAC9E,IAAI,EAAE,UAAU;IAChB,OAAO,EAAE,aAAa;IACtB,MAAM,EAAE,YAAY;IACpB,MAAM,EAAE,YAAY;IACpB,IAAI,EAAE,UAAU;IAChB,IAAI,EAAE,UAAU;IAChB,MAAM,EAAE,YAAY;CACrB,CAAC;AAEF,KAAK,UAAU,oBAAoB,CAAC,UAAkB,EAAE,UAAkB,EAAE,OAAe;IACzF,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC;IAC3D,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,UAAU,gCAAgC,EAAE;YACrE,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,eAAe,EAAE,UAAU,UAAU,EAAE;aACxC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,CAAC;YACjC,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,KAAK,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;QAC7E,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAuB,CAAC;QACnD,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,OAAO,CAAC,CAAC;IACxB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAuB;IAItD,MAAM,cAAc,GAA4B;QAC9C,SAAS,EAAE,SAAS;QACpB,IAAI,EAAE,kBAAkB;QACxB,iBAAiB,EAAE,IAAI;QACvB,iBAAiB,EAAE,KAAK;KACzB,CAAC;IAEF,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;QACpB,4DAA4D;QAC5D,cAAc,CAAC,QAAQ,GAAG,KAAK,IAAI,EAAE;YACnC,OAAO,CAAC,GAAG,CAAC,yCAAyC,CAAC,CAAC;YACvD,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAW,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;gBACrF,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;gBAClC,OAAO,KAAK,CAAC;YACf,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,CAAC,KAAK,CAAC,iCAAiC,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;gBAC3F,aAAa;gBACb,IAAI,CAAC;oBACH,MAAM,KAAK,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAW,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;oBACrF,OAAO,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;oBAC1C,OAAO,KAAK,CAAC;gBACf,CAAC;gBAAC,OAAO,QAAQ,EAAE,CAAC;oBAClB,OAAO,CAAC,KAAK,CAAC,yBAAyB,EAAE,QAAQ,YAAY,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;oBAClG,MAAM,QAAQ,CAAC;gBACjB,CAAC;YACH,CAAC;QACH,CAAC,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,uFAAuF;QACvF,OAAO,CAAC,IAAI,CAAC,yEAAyE,CAAC,CAAC;QACxF,cAAc,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IACpC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;IAExD,MAAM,cAAc,GAAG,SAAS,IAAI,CAAC,OAAO,EAAE,CAAC;IAC/C,MAAM,UAAU,GAAG,MAAM,CAAC,eAAe,CAAC,cAAc,CAAC,CAAC;IAE1D,UAAU,CAAC,EAAE,CAAC,aAAa,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;QACzC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAoB,CAAC;QACzC,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI;YAAE,OAAO;QAEtC,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACvC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,yBAAyB,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;YACvD,OAAO;QACT,CAAC;QAED,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;YACxC,MAAM,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,QAAQ,CAAC,CAAC;QACjD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,aAAa,GAAkB;gBACnC,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,OAAO,EAAE,KAAK;gBACd,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACxD,CAAC;YACF,MAAM,MAAM,CAAC,OAAO,CAAC,cAAc,EAAE,aAAa,CAAC,CAAC;QACtD,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,GAAG,EAAE,EAAE;QAC7B,OAAO,CAAC,GAAG,CAAC,+BAA+B,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,EAAE,CAAC,cAAc,EAAE,CAAC,GAAG,EAAE,EAAE;QAChC,OAAO,CAAC,GAAG,CAAC,iBAAiB,GAAG,CAAC,MAAM,UAAU,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,GAAG,EAAE,EAAE;QAC9B,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,CAAC,MAAM,UAAU,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,UAAU,CAAC,SAAS,EAAE,CAAC;IACvB,MAAM,CAAC,OAAO,EAAE,CAAC;IAEjB,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;AAChC,CAAC"}
|