instar 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/.claude/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — full server with all Tier 1 systems wired.
|
|
3
|
+
*
|
|
4
|
+
* Tests the server with relationships, auth, scheduler, and
|
|
5
|
+
* mocked Telegram running together. Verifies cross-system interactions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import request from 'supertest';
|
|
12
|
+
import { AgentServer } from '../../src/server/AgentServer.js';
|
|
13
|
+
import { RelationshipManager } from '../../src/core/RelationshipManager.js';
|
|
14
|
+
import { JobScheduler } from '../../src/scheduler/JobScheduler.js';
|
|
15
|
+
import {
|
|
16
|
+
createTempProject,
|
|
17
|
+
createMockSessionManager,
|
|
18
|
+
createSampleJobsFile,
|
|
19
|
+
} from '../helpers/setup.js';
|
|
20
|
+
import type { TempProject, MockSessionManager } from '../helpers/setup.js';
|
|
21
|
+
import type { AgentKitConfig } from '../../src/core/types.js';
|
|
22
|
+
|
|
23
|
+
describe('Full server integration', () => {
|
|
24
|
+
let project: TempProject;
|
|
25
|
+
let relationships: RelationshipManager;
|
|
26
|
+
let scheduler: JobScheduler;
|
|
27
|
+
let mockSM: MockSessionManager;
|
|
28
|
+
let server: AgentServer;
|
|
29
|
+
let app: ReturnType<AgentServer['getApp']>;
|
|
30
|
+
const AUTH_TOKEN = 'test-auth-token-integration';
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
project = createTempProject();
|
|
34
|
+
|
|
35
|
+
// Set up relationships
|
|
36
|
+
const relDir = path.join(project.stateDir, 'relationships');
|
|
37
|
+
fs.mkdirSync(relDir, { recursive: true });
|
|
38
|
+
relationships = new RelationshipManager({
|
|
39
|
+
relationshipsDir: relDir,
|
|
40
|
+
maxRecentInteractions: 20,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Seed relationship data
|
|
44
|
+
const alice = relationships.findOrCreate('Alice', { type: 'telegram', identifier: '111' });
|
|
45
|
+
relationships.recordInteraction(alice.id, {
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
channel: 'telegram',
|
|
48
|
+
summary: 'Integration test chat',
|
|
49
|
+
topics: ['testing', 'CI'],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const bob = relationships.findOrCreate('Bob', { type: 'email', identifier: 'bob@test.com' });
|
|
53
|
+
relationships.recordInteraction(bob.id, {
|
|
54
|
+
timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
|
55
|
+
channel: 'email',
|
|
56
|
+
summary: 'Old email exchange',
|
|
57
|
+
topics: ['philosophy'],
|
|
58
|
+
});
|
|
59
|
+
// Boost Bob's significance enough to appear in stale
|
|
60
|
+
for (let i = 0; i < 5; i++) {
|
|
61
|
+
relationships.recordInteraction(bob.id, {
|
|
62
|
+
timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
63
|
+
channel: 'email',
|
|
64
|
+
summary: `Discussion ${i}`,
|
|
65
|
+
topics: [`topic-${i}`],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Set up scheduler
|
|
70
|
+
mockSM = createMockSessionManager();
|
|
71
|
+
const jobsFile = createSampleJobsFile(project.stateDir);
|
|
72
|
+
scheduler = new JobScheduler(
|
|
73
|
+
{
|
|
74
|
+
jobsFile,
|
|
75
|
+
enabled: true,
|
|
76
|
+
maxParallelJobs: 3,
|
|
77
|
+
quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
|
|
78
|
+
},
|
|
79
|
+
mockSM as any,
|
|
80
|
+
project.state,
|
|
81
|
+
);
|
|
82
|
+
scheduler.start();
|
|
83
|
+
|
|
84
|
+
const config: AgentKitConfig = {
|
|
85
|
+
projectName: 'integration-test',
|
|
86
|
+
projectDir: project.dir,
|
|
87
|
+
stateDir: project.stateDir,
|
|
88
|
+
port: 0,
|
|
89
|
+
authToken: AUTH_TOKEN,
|
|
90
|
+
sessions: {
|
|
91
|
+
tmuxPath: '/usr/bin/tmux',
|
|
92
|
+
claudePath: '/usr/bin/claude',
|
|
93
|
+
projectDir: project.dir,
|
|
94
|
+
maxSessions: 5,
|
|
95
|
+
protectedSessions: [],
|
|
96
|
+
completionPatterns: [],
|
|
97
|
+
},
|
|
98
|
+
scheduler: {
|
|
99
|
+
jobsFile,
|
|
100
|
+
enabled: true,
|
|
101
|
+
maxParallelJobs: 3,
|
|
102
|
+
quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
|
|
103
|
+
},
|
|
104
|
+
users: [],
|
|
105
|
+
messaging: [],
|
|
106
|
+
monitoring: {
|
|
107
|
+
quotaTracking: false,
|
|
108
|
+
memoryMonitoring: false,
|
|
109
|
+
healthCheckIntervalMs: 30000,
|
|
110
|
+
},
|
|
111
|
+
relationships: {
|
|
112
|
+
relationshipsDir: relDir,
|
|
113
|
+
maxRecentInteractions: 20,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
server = new AgentServer({
|
|
118
|
+
config,
|
|
119
|
+
sessionManager: mockSM as any,
|
|
120
|
+
state: project.state,
|
|
121
|
+
scheduler,
|
|
122
|
+
relationships,
|
|
123
|
+
});
|
|
124
|
+
app = server.getApp();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(() => {
|
|
128
|
+
scheduler?.stop();
|
|
129
|
+
project.cleanup();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Auth enforcement ─────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('auth enforcement across all endpoints', () => {
|
|
135
|
+
it('allows /health without auth', async () => {
|
|
136
|
+
const res = await request(app).get('/health');
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('blocks /status without auth', async () => {
|
|
141
|
+
const res = await request(app).get('/status');
|
|
142
|
+
expect(res.status).toBe(401);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('blocks /sessions without auth', async () => {
|
|
146
|
+
const res = await request(app).get('/sessions');
|
|
147
|
+
expect(res.status).toBe(401);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('blocks /jobs without auth', async () => {
|
|
151
|
+
const res = await request(app).get('/jobs');
|
|
152
|
+
expect(res.status).toBe(401);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('blocks /relationships without auth', async () => {
|
|
156
|
+
const res = await request(app).get('/relationships');
|
|
157
|
+
expect(res.status).toBe(401);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('blocks /events without auth', async () => {
|
|
161
|
+
const res = await request(app).get('/events');
|
|
162
|
+
expect(res.status).toBe(401);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('allows all with valid auth', async () => {
|
|
166
|
+
const auth = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
|
167
|
+
|
|
168
|
+
const [health, status, sessions, jobs, rels, events] = await Promise.all([
|
|
169
|
+
request(app).get('/health'),
|
|
170
|
+
request(app).get('/status').set(auth),
|
|
171
|
+
request(app).get('/sessions').set(auth),
|
|
172
|
+
request(app).get('/jobs').set(auth),
|
|
173
|
+
request(app).get('/relationships').set(auth),
|
|
174
|
+
request(app).get('/events').set(auth),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
expect(health.status).toBe(200);
|
|
178
|
+
expect(status.status).toBe(200);
|
|
179
|
+
expect(sessions.status).toBe(200);
|
|
180
|
+
expect(jobs.status).toBe(200);
|
|
181
|
+
expect(rels.status).toBe(200);
|
|
182
|
+
expect(events.status).toBe(200);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Cross-system: status includes scheduler ──────────────────
|
|
187
|
+
|
|
188
|
+
describe('cross-system status', () => {
|
|
189
|
+
it('includes scheduler status with job counts', async () => {
|
|
190
|
+
const res = await request(app)
|
|
191
|
+
.get('/status')
|
|
192
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
expect(res.body.scheduler).not.toBeNull();
|
|
196
|
+
expect(res.body.scheduler.running).toBe(true);
|
|
197
|
+
expect(res.body.scheduler.enabledJobs).toBe(2);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── Cross-system: job trigger + session spawn ────────────────
|
|
202
|
+
|
|
203
|
+
describe('job trigger via API', () => {
|
|
204
|
+
it('triggers a job and creates a session', async () => {
|
|
205
|
+
const res = await request(app)
|
|
206
|
+
.post('/jobs/health-check/trigger')
|
|
207
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
|
|
208
|
+
.send({ reason: 'integration-test' });
|
|
209
|
+
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
expect(res.body.result).toBe('triggered');
|
|
212
|
+
expect(mockSM._spawnCount).toBeGreaterThanOrEqual(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── Cross-system: relationships + API ────────────────────────
|
|
217
|
+
|
|
218
|
+
describe('relationship API with persistence', () => {
|
|
219
|
+
it('GET /relationships returns seeded data with auth', async () => {
|
|
220
|
+
const res = await request(app)
|
|
221
|
+
.get('/relationships')
|
|
222
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
|
|
223
|
+
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
expect(res.body.relationships).toHaveLength(2);
|
|
226
|
+
const names = res.body.relationships.map((r: any) => r.name);
|
|
227
|
+
expect(names).toContain('Alice');
|
|
228
|
+
expect(names).toContain('Bob');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('GET /relationships/:id/context returns enriched context', async () => {
|
|
232
|
+
const alice = relationships.getAll().find(r => r.name === 'Alice')!;
|
|
233
|
+
const res = await request(app)
|
|
234
|
+
.get(`/relationships/${alice.id}/context`)
|
|
235
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
|
|
236
|
+
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(res.body.context).toContain('testing');
|
|
239
|
+
expect(res.body.context).toContain('CI');
|
|
240
|
+
expect(res.body.context).toContain('Integration test chat');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('stale endpoint correctly identifies old relationships', async () => {
|
|
244
|
+
const res = await request(app)
|
|
245
|
+
.get('/relationships/stale?days=14')
|
|
246
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
// Bob's last interaction was 30 days ago and significance >= 3
|
|
250
|
+
expect(res.body.stale.length).toBeGreaterThanOrEqual(1);
|
|
251
|
+
expect(res.body.stale.some((r: any) => r.name === 'Bob')).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('relationship data persists to new manager instance', async () => {
|
|
255
|
+
const relDir = path.join(project.stateDir, 'relationships');
|
|
256
|
+
|
|
257
|
+
// Create a fresh manager from the same directory
|
|
258
|
+
const manager2 = new RelationshipManager({
|
|
259
|
+
relationshipsDir: relDir,
|
|
260
|
+
maxRecentInteractions: 20,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(manager2.getAll()).toHaveLength(2);
|
|
264
|
+
const alice = manager2.resolveByChannel({ type: 'telegram', identifier: '111' });
|
|
265
|
+
expect(alice).not.toBeNull();
|
|
266
|
+
expect(alice!.name).toBe('Alice');
|
|
267
|
+
expect(alice!.recentInteractions).toHaveLength(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Cross-system: events capture scheduler activity ──────────
|
|
272
|
+
|
|
273
|
+
describe('event log captures cross-system activity', () => {
|
|
274
|
+
it('events include scheduler_start and job_triggered', async () => {
|
|
275
|
+
const res = await request(app)
|
|
276
|
+
.get('/events?since=1')
|
|
277
|
+
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
|
|
278
|
+
|
|
279
|
+
expect(res.status).toBe(200);
|
|
280
|
+
const types = res.body.map((e: any) => e.type);
|
|
281
|
+
expect(types).toContain('scheduler_start');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — real tmux sessions with a mock claude script.
|
|
3
|
+
*
|
|
4
|
+
* Requires tmux to be installed. Skips if not available.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { SessionManager } from '../../src/core/SessionManager.js';
|
|
10
|
+
import { detectTmuxPath } from '../../src/core/Config.js';
|
|
11
|
+
import {
|
|
12
|
+
createTempProject,
|
|
13
|
+
createMockClaude,
|
|
14
|
+
cleanupTmuxSessions,
|
|
15
|
+
waitFor,
|
|
16
|
+
} from '../helpers/setup.js';
|
|
17
|
+
import type { TempProject } from '../helpers/setup.js';
|
|
18
|
+
|
|
19
|
+
const TMUX_PREFIX = 'akit-integ-';
|
|
20
|
+
|
|
21
|
+
// Skip entire suite if tmux is not available
|
|
22
|
+
const tmuxPath = detectTmuxPath();
|
|
23
|
+
const describeMaybe = tmuxPath ? describe : describe.skip;
|
|
24
|
+
|
|
25
|
+
describeMaybe('Session Lifecycle (integration)', () => {
|
|
26
|
+
let project: TempProject;
|
|
27
|
+
let mockClaudePath: string;
|
|
28
|
+
let sm: SessionManager;
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
project = createTempProject();
|
|
32
|
+
mockClaudePath = createMockClaude(project.dir);
|
|
33
|
+
|
|
34
|
+
sm = new SessionManager(
|
|
35
|
+
{
|
|
36
|
+
tmuxPath: tmuxPath!,
|
|
37
|
+
claudePath: mockClaudePath,
|
|
38
|
+
projectDir: project.dir,
|
|
39
|
+
maxSessions: 3,
|
|
40
|
+
protectedSessions: [],
|
|
41
|
+
completionPatterns: ['Session ended'],
|
|
42
|
+
},
|
|
43
|
+
project.state,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
sm.stopMonitoring();
|
|
49
|
+
cleanupTmuxSessions(TMUX_PREFIX);
|
|
50
|
+
project.cleanup();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('spawns a session and tracks it', async () => {
|
|
54
|
+
const session = await sm.spawnSession({
|
|
55
|
+
name: `${TMUX_PREFIX}basic`,
|
|
56
|
+
prompt: 'echo hello',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(session.status).toBe('running');
|
|
60
|
+
expect(session.tmuxSession).toContain(TMUX_PREFIX);
|
|
61
|
+
|
|
62
|
+
// Give tmux a moment to start
|
|
63
|
+
await new Promise(r => setTimeout(r, 500));
|
|
64
|
+
|
|
65
|
+
// Session should be alive
|
|
66
|
+
expect(sm.isSessionAlive(session.tmuxSession)).toBe(true);
|
|
67
|
+
|
|
68
|
+
// State should be persisted
|
|
69
|
+
const saved = project.state.getSession(session.id);
|
|
70
|
+
expect(saved).not.toBeNull();
|
|
71
|
+
expect(saved!.status).toBe('running');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('captures output from session', async () => {
|
|
75
|
+
const session = await sm.spawnSession({
|
|
76
|
+
name: `${TMUX_PREFIX}output`,
|
|
77
|
+
prompt: 'echo hello',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
81
|
+
|
|
82
|
+
const output = sm.captureOutput(session.tmuxSession);
|
|
83
|
+
expect(output).not.toBeNull();
|
|
84
|
+
// Mock claude echoes its prompt
|
|
85
|
+
expect(output).toContain('Mock Claude session started');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('detects completed sessions via reaping', async () => {
|
|
89
|
+
const session = await sm.spawnSession({
|
|
90
|
+
name: `${TMUX_PREFIX}complete`,
|
|
91
|
+
prompt: 'echo done',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Mock claude exits after ~2s — wait for tmux session to disappear
|
|
95
|
+
await waitFor(
|
|
96
|
+
() => !sm.isSessionAlive(session.tmuxSession),
|
|
97
|
+
8000,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Reap should detect and mark it completed
|
|
101
|
+
const reaped = sm.reapCompletedSessions();
|
|
102
|
+
// Session was already marked completed by listRunningSessions or monitor
|
|
103
|
+
const saved = project.state.getSession(session.id);
|
|
104
|
+
expect(saved!.status).toBe('completed');
|
|
105
|
+
expect(saved!.endedAt).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('kills a session', async () => {
|
|
109
|
+
const session = await sm.spawnSession({
|
|
110
|
+
name: `${TMUX_PREFIX}kill`,
|
|
111
|
+
prompt: 'sleep 60', // Would run forever
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await new Promise(r => setTimeout(r, 500));
|
|
115
|
+
expect(sm.isSessionAlive(session.tmuxSession)).toBe(true);
|
|
116
|
+
|
|
117
|
+
const killed = sm.killSession(session.id);
|
|
118
|
+
expect(killed).toBe(true);
|
|
119
|
+
|
|
120
|
+
// Give tmux a moment to clean up
|
|
121
|
+
await new Promise(r => setTimeout(r, 200));
|
|
122
|
+
expect(sm.isSessionAlive(session.tmuxSession)).toBe(false);
|
|
123
|
+
|
|
124
|
+
const saved = project.state.getSession(session.id);
|
|
125
|
+
expect(saved!.status).toBe('killed');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('enforces max sessions', async () => {
|
|
129
|
+
// Use a separate project to avoid interference from other tests
|
|
130
|
+
const limitProject = createTempProject();
|
|
131
|
+
const limitSM = new SessionManager(
|
|
132
|
+
{
|
|
133
|
+
tmuxPath: tmuxPath!,
|
|
134
|
+
claudePath: mockClaudePath,
|
|
135
|
+
projectDir: limitProject.dir,
|
|
136
|
+
maxSessions: 1,
|
|
137
|
+
protectedSessions: [],
|
|
138
|
+
completionPatterns: [],
|
|
139
|
+
},
|
|
140
|
+
limitProject.state,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const s1 = await limitSM.spawnSession({
|
|
144
|
+
name: `${TMUX_PREFIX}limit1`,
|
|
145
|
+
prompt: 'sleep 30',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await expect(limitSM.spawnSession({
|
|
149
|
+
name: `${TMUX_PREFIX}limit2`,
|
|
150
|
+
prompt: 'sleep 30',
|
|
151
|
+
})).rejects.toThrow('Max sessions');
|
|
152
|
+
|
|
153
|
+
// Cleanup
|
|
154
|
+
limitSM.killSession(s1.id);
|
|
155
|
+
limitProject.cleanup();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('emits sessionComplete via monitoring', async () => {
|
|
159
|
+
const completedSessions: string[] = [];
|
|
160
|
+
sm.on('sessionComplete', (session) => {
|
|
161
|
+
completedSessions.push(session.id);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const session = await sm.spawnSession({
|
|
165
|
+
name: `${TMUX_PREFIX}monitor`,
|
|
166
|
+
prompt: 'echo test',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Start monitoring with fast interval
|
|
170
|
+
sm.startMonitoring(500);
|
|
171
|
+
|
|
172
|
+
// Mock claude exits after ~2s, monitoring should detect it
|
|
173
|
+
await waitFor(
|
|
174
|
+
() => completedSessions.includes(session.id),
|
|
175
|
+
8000,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(completedSessions).toContain(session.id);
|
|
179
|
+
sm.stopMonitoring();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectTmuxPath, detectClaudePath } from '../../src/core/Config.js';
|
|
3
|
+
|
|
4
|
+
describe('Config', () => {
|
|
5
|
+
describe('detectTmuxPath', () => {
|
|
6
|
+
it('finds tmux on this system', () => {
|
|
7
|
+
const tmuxPath = detectTmuxPath();
|
|
8
|
+
// tmux should be installed on the dev machine
|
|
9
|
+
expect(tmuxPath).toBeTruthy();
|
|
10
|
+
expect(tmuxPath).toContain('tmux');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('detectClaudePath', () => {
|
|
15
|
+
it('finds Claude CLI on this system', () => {
|
|
16
|
+
const claudePath = detectClaudePath();
|
|
17
|
+
// Claude CLI should be installed on the dev machine
|
|
18
|
+
expect(claudePath).toBeTruthy();
|
|
19
|
+
expect(claudePath).toContain('claude');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { HealthChecker } from '../../src/monitoring/HealthChecker.js';
|
|
3
|
+
import { createTempProject, createMockSessionManager } from '../helpers/setup.js';
|
|
4
|
+
import type { TempProject, MockSessionManager } from '../helpers/setup.js';
|
|
5
|
+
import type { AgentKitConfig } from '../../src/core/types.js';
|
|
6
|
+
|
|
7
|
+
describe('HealthChecker', () => {
|
|
8
|
+
let project: TempProject;
|
|
9
|
+
let mockSM: MockSessionManager;
|
|
10
|
+
let checker: HealthChecker;
|
|
11
|
+
|
|
12
|
+
const makeConfig = (overrides?: Partial<AgentKitConfig>): AgentKitConfig => ({
|
|
13
|
+
projectName: 'test-project',
|
|
14
|
+
projectDir: '/tmp/test',
|
|
15
|
+
stateDir: '', // set in beforeEach
|
|
16
|
+
port: 4040,
|
|
17
|
+
sessions: {
|
|
18
|
+
tmuxPath: '/opt/homebrew/bin/tmux',
|
|
19
|
+
claudePath: '/usr/bin/claude',
|
|
20
|
+
projectDir: '/tmp/test',
|
|
21
|
+
maxSessions: 3,
|
|
22
|
+
protectedSessions: [],
|
|
23
|
+
completionPatterns: [],
|
|
24
|
+
},
|
|
25
|
+
scheduler: {
|
|
26
|
+
jobsFile: '',
|
|
27
|
+
enabled: false,
|
|
28
|
+
maxParallelJobs: 2,
|
|
29
|
+
quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
|
|
30
|
+
},
|
|
31
|
+
users: [],
|
|
32
|
+
messaging: [],
|
|
33
|
+
monitoring: {
|
|
34
|
+
quotaTracking: false,
|
|
35
|
+
memoryMonitoring: false,
|
|
36
|
+
healthCheckIntervalMs: 5000,
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
project = createTempProject();
|
|
43
|
+
mockSM = createMockSessionManager();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
checker?.stopPeriodicChecks();
|
|
48
|
+
project.cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns healthy when everything is ok', () => {
|
|
52
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
53
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
54
|
+
|
|
55
|
+
const status = checker.check();
|
|
56
|
+
expect(status.status).toBe('healthy');
|
|
57
|
+
expect(status.components.tmux).toBeDefined();
|
|
58
|
+
expect(status.components.sessions).toBeDefined();
|
|
59
|
+
expect(status.components.stateDir).toBeDefined();
|
|
60
|
+
expect(status.timestamp).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reports degraded when sessions at capacity', async () => {
|
|
64
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
65
|
+
config.sessions.maxSessions = 1;
|
|
66
|
+
|
|
67
|
+
// Add a running session to the mock
|
|
68
|
+
await mockSM.spawnSession({ name: 'fill', prompt: 'test' });
|
|
69
|
+
|
|
70
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
71
|
+
|
|
72
|
+
const status = checker.check();
|
|
73
|
+
expect(status.components.sessions.status).toBe('degraded');
|
|
74
|
+
expect(status.components.sessions.message).toContain('capacity');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports unhealthy when state dir missing', () => {
|
|
78
|
+
const config = makeConfig({ stateDir: '/nonexistent/path' });
|
|
79
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
80
|
+
|
|
81
|
+
const status = checker.check();
|
|
82
|
+
expect(status.components.stateDir.status).toBe('unhealthy');
|
|
83
|
+
expect(status.status).toBe('unhealthy');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not include scheduler when none provided', () => {
|
|
87
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
88
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
89
|
+
|
|
90
|
+
const status = checker.check();
|
|
91
|
+
expect(status.components.scheduler).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('includes scheduler status when provided', () => {
|
|
95
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
96
|
+
const mockScheduler = {
|
|
97
|
+
getStatus: () => ({
|
|
98
|
+
running: true,
|
|
99
|
+
paused: false,
|
|
100
|
+
jobCount: 3,
|
|
101
|
+
enabledJobs: 2,
|
|
102
|
+
queueLength: 0,
|
|
103
|
+
activeJobSessions: 0,
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
checker = new HealthChecker(config, mockSM as any, mockScheduler as any);
|
|
107
|
+
|
|
108
|
+
const status = checker.check();
|
|
109
|
+
expect(status.components.scheduler).toBeDefined();
|
|
110
|
+
expect(status.components.scheduler.status).toBe('healthy');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports degraded scheduler when paused', () => {
|
|
114
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
115
|
+
const mockScheduler = {
|
|
116
|
+
getStatus: () => ({
|
|
117
|
+
running: true,
|
|
118
|
+
paused: true,
|
|
119
|
+
jobCount: 3,
|
|
120
|
+
enabledJobs: 2,
|
|
121
|
+
queueLength: 0,
|
|
122
|
+
activeJobSessions: 0,
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
checker = new HealthChecker(config, mockSM as any, mockScheduler as any);
|
|
126
|
+
|
|
127
|
+
const status = checker.check();
|
|
128
|
+
expect(status.components.scheduler.status).toBe('degraded');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('getLastStatus returns null before first check', () => {
|
|
132
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
133
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
134
|
+
|
|
135
|
+
expect(checker.getLastStatus()).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('getLastStatus returns result after check', () => {
|
|
139
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
140
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
141
|
+
|
|
142
|
+
checker.check();
|
|
143
|
+
expect(checker.getLastStatus()).not.toBeNull();
|
|
144
|
+
expect(checker.getLastStatus()!.status).toBe('healthy');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('periodic checks update status', async () => {
|
|
148
|
+
const config = makeConfig({ stateDir: project.stateDir });
|
|
149
|
+
checker = new HealthChecker(config, mockSM as any);
|
|
150
|
+
|
|
151
|
+
checker.startPeriodicChecks(100);
|
|
152
|
+
|
|
153
|
+
// Should run immediately
|
|
154
|
+
expect(checker.getLastStatus()).not.toBeNull();
|
|
155
|
+
|
|
156
|
+
// Wait for another check
|
|
157
|
+
await new Promise(r => setTimeout(r, 250));
|
|
158
|
+
const ts1 = checker.getLastStatus()!.timestamp;
|
|
159
|
+
|
|
160
|
+
await new Promise(r => setTimeout(r, 200));
|
|
161
|
+
const ts2 = checker.getLastStatus()!.timestamp;
|
|
162
|
+
|
|
163
|
+
// Timestamps should differ (multiple checks ran)
|
|
164
|
+
expect(ts2).not.toBe(ts1);
|
|
165
|
+
|
|
166
|
+
checker.stopPeriodicChecks();
|
|
167
|
+
});
|
|
168
|
+
});
|