@openclaw-cloud/agent-controller 0.2.5 → 0.2.7
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/bin/agent-controller.js +5 -0
- package/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/config-file.d.ts +9 -0
- package/dist/config-file.js +47 -0
- package/dist/config-file.js.map +1 -0
- package/dist/connection.d.ts +1 -0
- package/dist/connection.js +27 -13
- package/dist/connection.js.map +1 -1
- package/dist/handlers/backup.js +7 -2
- package/dist/handlers/backup.js.map +1 -1
- package/dist/handlers/knowledge-sync.d.ts +2 -0
- package/dist/handlers/knowledge-sync.js +51 -0
- package/dist/handlers/knowledge-sync.js.map +1 -0
- package/dist/heartbeat.d.ts +1 -0
- package/dist/heartbeat.js +30 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/package.json +6 -1
- package/.claude/cc-notify.sh +0 -32
- package/.claude/settings.json +0 -31
- package/.husky/pre-commit +0 -1
- package/BIZPLAN.md +0 -530
- package/CLAUDE.md +0 -172
- package/Dockerfile +0 -9
- package/__tests__/api.test.ts +0 -183
- package/__tests__/backup.test.ts +0 -145
- package/__tests__/board-handler.test.ts +0 -323
- package/__tests__/chat.test.ts +0 -191
- package/__tests__/config.test.ts +0 -100
- package/__tests__/connection.test.ts +0 -289
- package/__tests__/file-delete.test.ts +0 -90
- package/__tests__/file-write.test.ts +0 -119
- package/__tests__/gateway-adapter.test.ts +0 -366
- package/__tests__/gateway-client.test.ts +0 -272
- package/__tests__/handlers.test.ts +0 -150
- package/__tests__/heartbeat.test.ts +0 -124
- package/__tests__/onboarding.test.ts +0 -55
- package/__tests__/package-install.test.ts +0 -109
- package/__tests__/pair.test.ts +0 -60
- package/__tests__/self-update.test.ts +0 -123
- package/__tests__/stop.test.ts +0 -38
- package/jest.config.ts +0 -16
- package/src/api.ts +0 -62
- package/src/commands/install.ts +0 -64
- package/src/commands/self-update.ts +0 -43
- package/src/commands/uninstall.ts +0 -19
- package/src/connection.ts +0 -203
- package/src/debug.ts +0 -11
- package/src/handlers/backup.ts +0 -101
- package/src/handlers/board-handler.ts +0 -155
- package/src/handlers/chat.ts +0 -79
- package/src/handlers/config.ts +0 -48
- package/src/handlers/deploy.ts +0 -32
- package/src/handlers/exec.ts +0 -32
- package/src/handlers/file-delete.ts +0 -46
- package/src/handlers/file-write.ts +0 -65
- package/src/handlers/knowledge-sync.ts +0 -53
- package/src/handlers/onboarding.ts +0 -19
- package/src/handlers/package-install.ts +0 -69
- package/src/handlers/pair.ts +0 -26
- package/src/handlers/restart.ts +0 -19
- package/src/handlers/stop.ts +0 -17
- package/src/heartbeat.ts +0 -110
- package/src/index.ts +0 -97
- package/src/openclaw/gateway-adapter.ts +0 -129
- package/src/openclaw/gateway-client.ts +0 -131
- package/src/openclaw/index.ts +0 -17
- package/src/openclaw/types.ts +0 -41
- package/src/platform/linux.ts +0 -108
- package/src/platform/macos.ts +0 -122
- package/src/platform/windows.ts +0 -92
- package/src/types.ts +0 -94
- package/tsconfig.json +0 -18
package/CLAUDE.md
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md — Agent Controller Context
|
|
2
|
-
|
|
3
|
-
Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction.
|
|
4
|
-
|
|
5
|
-
My engineering preferences (use these to guide your recommendations):
|
|
6
|
-
|
|
7
|
-
• DRY is important—flag repetition aggressively.
|
|
8
|
-
• Well-tested code is non-negotiable; I'd rather have too many tests than too few.
|
|
9
|
-
• I want code that's "engineered enough" — not under-engineered (fragile, hacky) and not over-engineered (premature abstraction, unnecessary complexity).
|
|
10
|
-
• I err on the side of handling more edge cases, not fewer; thoughtfulness > speed.
|
|
11
|
-
• Bias toward explicit over clever.
|
|
12
|
-
|
|
13
|
-
1. Architecture review
|
|
14
|
-
Evaluate:
|
|
15
|
-
|
|
16
|
-
• Overall system design and component boundaries.
|
|
17
|
-
• Dependency graph and coupling concerns.
|
|
18
|
-
• Data flow patterns and potential bottlenecks.
|
|
19
|
-
• Scaling characteristics and single points of failure.
|
|
20
|
-
• Security architecture (auth, data access, API boundaries).
|
|
21
|
-
2. Code quality review
|
|
22
|
-
Evaluate:
|
|
23
|
-
|
|
24
|
-
• Code organization and module structure.
|
|
25
|
-
• DRY violations—be aggressive here.
|
|
26
|
-
• Error handling patterns and missing edge cases (call these out explicitly).
|
|
27
|
-
• Technical debt hotspots.
|
|
28
|
-
• Areas that are over-engineered or under-engineered relative to my preferences.
|
|
29
|
-
3. Test review
|
|
30
|
-
Evaluate:
|
|
31
|
-
|
|
32
|
-
• Test coverage gaps (unit, integration, e2e).
|
|
33
|
-
• Test quality and assertion strength.
|
|
34
|
-
• Missing edge case coverage—be thorough.
|
|
35
|
-
• Untested failure modes and error paths.
|
|
36
|
-
4. Performance review
|
|
37
|
-
Evaluate:
|
|
38
|
-
|
|
39
|
-
• N+1 queries and database access patterns.
|
|
40
|
-
• Memory-usage concerns.
|
|
41
|
-
• Caching opportunities.
|
|
42
|
-
• Slow or high-complexity code paths.
|
|
43
|
-
For each issue you find
|
|
44
|
-
For every specific issue (bug, smell, design concern, or risk):
|
|
45
|
-
|
|
46
|
-
• Describe the problem concretely, with file and line references.
|
|
47
|
-
• Present 2–3 options, including "do nothing" where that's reasonable.
|
|
48
|
-
• For each option, specify: implementation effort, risk, impact on other code, and maintenance burden.
|
|
49
|
-
• Give me your recommended option and why, mapped to my preferences above.
|
|
50
|
-
• Then explicitly ask whether I agree or want to choose a different direction before proceeding.
|
|
51
|
-
Workflow and interaction
|
|
52
|
-
|
|
53
|
-
• Do not assume my priorities on timeline or scale.
|
|
54
|
-
• After each section, pause and ask for my feedback before moving on.
|
|
55
|
-
BEFORE YOU START:
|
|
56
|
-
Ask if I want one of two options:
|
|
57
|
-
1/ BIG CHANGE: Work through this interactively, one section at a time (Architecture → Code Quality → Tests → Performance) with at most 4 top issues in each section.
|
|
58
|
-
2/ SMALL CHANGE: Work through interactively ONE question per review section
|
|
59
|
-
|
|
60
|
-
FOR EACH STAGE OF REVIEW: output the explanation and pros and cons of each stage's questions AND your opinionated recommendation and why, and then use AskUserQuestion. Also NUMBER issues and then give LETTERS for options and when using AskUserQuestion make sure each option clearly labels the issue NUMBER and option LETTER so the user doesn't get confused. Make the recommended option always the 1st option.
|
|
61
|
-
|
|
62
|
-
## Codebase Navigation
|
|
63
|
-
|
|
64
|
-
### Project Structure
|
|
65
|
-
```
|
|
66
|
-
src/
|
|
67
|
-
├── index.ts # Entry point — parse args, start connection
|
|
68
|
-
├── connection.ts # Centrifugo WS client, command dispatcher
|
|
69
|
-
├── heartbeat.ts # Periodic heartbeat publisher
|
|
70
|
-
├── api.ts # HTTP client for backend API calls
|
|
71
|
-
├── types.ts # AgentCommand, AgentResponse, CommandType
|
|
72
|
-
├── handlers/ # Command handlers (backend → agent)
|
|
73
|
-
│ ├── file-write.ts # Write file to workspace
|
|
74
|
-
│ ├── file-delete.ts # Delete file from workspace
|
|
75
|
-
│ ├── package-install.ts # Install apt/npm/pip packages
|
|
76
|
-
│ ├── restart.ts # Restart openclaw gateway
|
|
77
|
-
│ ├── stop.ts # Stop agent
|
|
78
|
-
│ ├── backup.ts # Create workspace backup
|
|
79
|
-
│ ├── config.ts # Update config files
|
|
80
|
-
│ ├── deploy.ts # Deploy/redeploy
|
|
81
|
-
│ ├── exec.ts # Execute shell commands
|
|
82
|
-
│ ├── chat.ts # Chat relay
|
|
83
|
-
│ ├── onboarding.ts # Onboarding completion
|
|
84
|
-
│ ├── pair.ts # Node pairing
|
|
85
|
-
│ ├── board-handler.ts # Kanban board integration
|
|
86
|
-
│ └── self-update.ts # Self-update via npm
|
|
87
|
-
├── commands/ # CLI subcommands (agent-controller <cmd>)
|
|
88
|
-
│ └── self-update.ts # `agent-controller self-update`
|
|
89
|
-
└── openclaw/ # OpenClaw gateway process management
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Command dispatch flow
|
|
93
|
-
```
|
|
94
|
-
Centrifugo WS message → connection.ts (handler map) → handlers/<type>.ts → AgentResponse → POST /api/internal/agent-response
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Handler map in `connection.ts`:
|
|
98
|
-
```ts
|
|
99
|
-
const handlers: Record<CommandType, Handler> = {
|
|
100
|
-
package_install: handlePackageInstall,
|
|
101
|
-
file_write: handleFileWrite,
|
|
102
|
-
file_delete: handleFileDelete,
|
|
103
|
-
restart: handleRestart,
|
|
104
|
-
// ...
|
|
105
|
-
};
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### Adding a new command handler
|
|
109
|
-
1. Create `src/handlers/my-handler.ts` — export `async function handleMyHandler(cmd: AgentCommand): Promise<AgentResponse>`
|
|
110
|
-
2. Add `'my_handler'` to `CommandType` union in `src/types.ts`
|
|
111
|
-
3. Register in handler map in `src/connection.ts`
|
|
112
|
-
4. Backend sends via `container.centrifugo.publishCommand(agentId, { type: 'my_handler', id, payload })`
|
|
113
|
-
|
|
114
|
-
### Known Issues & Pitfalls
|
|
115
|
-
- **WORKSPACE_DIR**: Defaults to `/etc/openclaw/workspace`. All file operations are sandboxed here (no `..`, no absolute paths).
|
|
116
|
-
- **Command names must match backend exactly**: e.g. `package_install` not `install_packages`. Mismatches silently drop.
|
|
117
|
-
- **npm publish required**: After changes, bump version in `package.json`, `npm publish`, then update agent Dockerfile + user-data.ts to pull new version.
|
|
118
|
-
- **PID 1 kill**: If agent-controller crashes, it kills PID 1 to force K8s pod restart. Don't swallow fatal errors.
|
|
119
|
-
|
|
120
|
-
## Quick Reference
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
npx jest --forceExit # Run tests
|
|
124
|
-
npx tsc --noEmit # Type check
|
|
125
|
-
npm run build # Build
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
## Architecture
|
|
129
|
-
|
|
130
|
-
Agent-controller is a Node.js process that runs inside every agent container (K8s pod) or VM. It's the single communication interface between the agent and the backend.
|
|
131
|
-
|
|
132
|
-
### Communication Flow
|
|
133
|
-
```
|
|
134
|
-
Backend → Centrifugo WS (port 8000) → agent-controller → local agent process
|
|
135
|
-
agent-controller → Backend HTTP API → /api/internal/agent-response (command responses)
|
|
136
|
-
agent-controller → Backend HTTP API → /api/internal/agent-heartbeat (heartbeat)
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Centrifugo WS is **read-only** from agent-controller's perspective — all outbound traffic goes through the backend HTTP API, not directly to Centrifugo.
|
|
140
|
-
|
|
141
|
-
### Key Components
|
|
142
|
-
- **WebSocket client** — Connects to Centrifugo, subscribes to agent channel (read-only)
|
|
143
|
-
- **Heartbeat publisher** — Sends periodic heartbeats via `POST /api/internal/agent-heartbeat`
|
|
144
|
-
- **Command handlers** — Processes commands from backend, sends responses via `POST /api/internal/agent-response`
|
|
145
|
-
- **Board handler** — Kanban board integration (claim cards, move cards, report status)
|
|
146
|
-
- **JWT auth** — Fetches JWT from backend's `/api/internal/centrifugo-token` endpoint, auto-refreshes before expiry (24h TTL)
|
|
147
|
-
|
|
148
|
-
### Environment Variables
|
|
149
|
-
- `AGENT_ID` — UUID of this agent
|
|
150
|
-
- `AGENT_TOKEN` — Internal auth token
|
|
151
|
-
- `CENTRIFUGO_URL` — WebSocket URL (port 8000, converted from http→ws)
|
|
152
|
-
- `API_BASE_URL` — Backend API URL (JWT fetch, agent responses, heartbeat)
|
|
153
|
-
|
|
154
|
-
### Deployment
|
|
155
|
-
|
|
156
|
-
Agent-controller is delivered via **npm** (not Docker image copy):
|
|
157
|
-
|
|
158
|
-
- **K8s pods**: `npm install -g @openclaw-cloud/agent-controller` in agent Dockerfile (`backend/charts/openclaw-kube/Dockerfile`)
|
|
159
|
-
- **VMs**: `npm install -g @openclaw-cloud/agent-controller` in EC2 user-data script (`backend/src/modules/vm-provisioner/user-data.ts`)
|
|
160
|
-
- Runs in same container/VM as OpenClaw agent (not sidecar)
|
|
161
|
-
- Started via bash wrapper in K8s StatefulSet command
|
|
162
|
-
- PID monitoring: if agent-controller dies, `kill 1` terminates container → K8s restarts pod
|
|
163
|
-
|
|
164
|
-
### Docker
|
|
165
|
-
|
|
166
|
-
The `Dockerfile` in this repo is for **local development and testing only**. It is NOT used in production. Production installs agent-controller from npm registry.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
## Business Context
|
|
170
|
-
|
|
171
|
-
For full understanding of all platform components, architecture, deployment models, roadmap, and unit-economics — see `BIZPLAN.md` in this repo.
|
|
172
|
-
|
package/Dockerfile
DELETED
package/__tests__/api.test.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { createAgentApi } from '../src/api';
|
|
2
|
-
|
|
3
|
-
const mockFetch = jest.fn();
|
|
4
|
-
(global as any).fetch = mockFetch;
|
|
5
|
-
|
|
6
|
-
function makeOkResponse(data: unknown, status = 200): Response {
|
|
7
|
-
return {
|
|
8
|
-
ok: true,
|
|
9
|
-
status,
|
|
10
|
-
json: jest.fn().mockResolvedValue(data),
|
|
11
|
-
text: jest.fn().mockResolvedValue(''),
|
|
12
|
-
} as unknown as Response;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function makeErrorResponse(status: number, body = ''): Response {
|
|
16
|
-
return {
|
|
17
|
-
ok: false,
|
|
18
|
-
status,
|
|
19
|
-
json: jest.fn(),
|
|
20
|
-
text: jest.fn().mockResolvedValue(body),
|
|
21
|
-
} as unknown as Response;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
jest.clearAllMocks();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('createAgentApi – get()', () => {
|
|
29
|
-
it('sends GET request with correct URL, method, and Authorization header', async () => {
|
|
30
|
-
const api = createAgentApi('http://backend', 'my-token');
|
|
31
|
-
mockFetch.mockResolvedValue(makeOkResponse({ ok: true }));
|
|
32
|
-
|
|
33
|
-
await api.get('/api/test');
|
|
34
|
-
|
|
35
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
36
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
37
|
-
expect(url).toBe('http://backend/api/test');
|
|
38
|
-
expect(init.method).toBe('GET');
|
|
39
|
-
expect(init.headers['Authorization']).toBe('Bearer my-token');
|
|
40
|
-
expect(init.headers['Content-Type']).toBe('application/json');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('returns parsed JSON body on success', async () => {
|
|
44
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
45
|
-
const data = { id: 1, name: 'test' };
|
|
46
|
-
mockFetch.mockResolvedValue(makeOkResponse(data));
|
|
47
|
-
|
|
48
|
-
const result = await api.get('/resource');
|
|
49
|
-
expect(result).toEqual(data);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('throws containing HTTP status when response is not ok', async () => {
|
|
53
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
54
|
-
mockFetch.mockResolvedValue(makeErrorResponse(404));
|
|
55
|
-
|
|
56
|
-
await expect(api.get('/missing')).rejects.toThrow('HTTP 404');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('throws containing the path in the error message', async () => {
|
|
60
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
61
|
-
mockFetch.mockResolvedValue(makeErrorResponse(500));
|
|
62
|
-
|
|
63
|
-
await expect(api.get('/crash')).rejects.toThrow('/crash');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('propagates network errors from fetch', async () => {
|
|
67
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
68
|
-
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
69
|
-
|
|
70
|
-
await expect(api.get('/path')).rejects.toThrow('ECONNREFUSED');
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('createAgentApi – post()', () => {
|
|
75
|
-
it('sends POST request with correct URL, method, and Authorization header', async () => {
|
|
76
|
-
const api = createAgentApi('http://backend', 'my-token');
|
|
77
|
-
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
78
|
-
|
|
79
|
-
await api.post('/api/action');
|
|
80
|
-
|
|
81
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
82
|
-
expect(url).toBe('http://backend/api/action');
|
|
83
|
-
expect(init.method).toBe('POST');
|
|
84
|
-
expect(init.headers['Authorization']).toBe('Bearer my-token');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('serializes body as JSON when provided', async () => {
|
|
88
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
89
|
-
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
90
|
-
|
|
91
|
-
await api.post('/endpoint', { key: 'value', num: 42 });
|
|
92
|
-
|
|
93
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
94
|
-
expect(init.body).toBe('{"key":"value","num":42}');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('omits body field when no body argument given', async () => {
|
|
98
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
99
|
-
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
100
|
-
|
|
101
|
-
await api.post('/endpoint');
|
|
102
|
-
|
|
103
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
104
|
-
expect(init.body).toBeUndefined();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('returns the raw Response object without throwing on non-ok', async () => {
|
|
108
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
109
|
-
const fakeRes = makeErrorResponse(400);
|
|
110
|
-
mockFetch.mockResolvedValue(fakeRes);
|
|
111
|
-
|
|
112
|
-
const result = await api.post('/endpoint', {});
|
|
113
|
-
expect(result).toBe(fakeRes);
|
|
114
|
-
expect(result.status).toBe(400);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('propagates network errors from fetch', async () => {
|
|
118
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
119
|
-
mockFetch.mockRejectedValue(new Error('timeout'));
|
|
120
|
-
|
|
121
|
-
await expect(api.post('/endpoint')).rejects.toThrow('timeout');
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('createAgentApi – publishResponse()', () => {
|
|
126
|
-
it('POSTs to /api/internal/agent-response with agentId and data', async () => {
|
|
127
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
128
|
-
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
129
|
-
|
|
130
|
-
await api.publishResponse('agent-1', { id: 'cmd-1', type: 'exec', success: true });
|
|
131
|
-
|
|
132
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
133
|
-
expect(url).toBe('http://backend/api/internal/agent-response');
|
|
134
|
-
expect(init.method).toBe('POST');
|
|
135
|
-
expect(JSON.parse(init.body)).toEqual({
|
|
136
|
-
agentId: 'agent-1',
|
|
137
|
-
data: { id: 'cmd-1', type: 'exec', success: true },
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('throws when response is not ok', async () => {
|
|
142
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
143
|
-
mockFetch.mockResolvedValue(makeErrorResponse(503));
|
|
144
|
-
|
|
145
|
-
await expect(api.publishResponse('agent-1', {})).rejects.toThrow('HTTP 503');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('propagates network errors', async () => {
|
|
149
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
150
|
-
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
151
|
-
|
|
152
|
-
await expect(api.publishResponse('agent-1', {})).rejects.toThrow('ECONNREFUSED');
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('createAgentApi – publishHeartbeat()', () => {
|
|
157
|
-
it('POSTs to /api/internal/agent-heartbeat with agentId and payload', async () => {
|
|
158
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
159
|
-
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
160
|
-
|
|
161
|
-
const payload = { type: 'heartbeat', agentId: 'agent-1', ts: 123, metrics: {} };
|
|
162
|
-
await api.publishHeartbeat('agent-1', payload);
|
|
163
|
-
|
|
164
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
165
|
-
expect(url).toBe('http://backend/api/internal/agent-heartbeat');
|
|
166
|
-
expect(init.method).toBe('POST');
|
|
167
|
-
expect(JSON.parse(init.body)).toEqual({ agentId: 'agent-1', payload });
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('throws when response is not ok', async () => {
|
|
171
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
172
|
-
mockFetch.mockResolvedValue(makeErrorResponse(500));
|
|
173
|
-
|
|
174
|
-
await expect(api.publishHeartbeat('agent-1', {})).rejects.toThrow('HTTP 500');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('propagates network errors', async () => {
|
|
178
|
-
const api = createAgentApi('http://backend', 'tok');
|
|
179
|
-
mockFetch.mockRejectedValue(new Error('timeout'));
|
|
180
|
-
|
|
181
|
-
await expect(api.publishHeartbeat('agent-1', {})).rejects.toThrow('timeout');
|
|
182
|
-
});
|
|
183
|
-
});
|
package/__tests__/backup.test.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
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', text: () => Promise.resolve('') });
|
|
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
|
-
});
|