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.
Files changed (115) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.claude/skills/setup-wizard/skill.md +343 -0
  3. package/.github/workflows/ci.yml +78 -0
  4. package/CLAUDE.md +82 -0
  5. package/README.md +194 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.js +141 -0
  8. package/dist/commands/init.d.ts +40 -0
  9. package/dist/commands/init.js +568 -0
  10. package/dist/commands/job.d.ts +20 -0
  11. package/dist/commands/job.js +84 -0
  12. package/dist/commands/server.d.ts +19 -0
  13. package/dist/commands/server.js +273 -0
  14. package/dist/commands/setup.d.ts +24 -0
  15. package/dist/commands/setup.js +865 -0
  16. package/dist/commands/status.d.ts +11 -0
  17. package/dist/commands/status.js +114 -0
  18. package/dist/commands/user.d.ts +17 -0
  19. package/dist/commands/user.js +53 -0
  20. package/dist/core/Config.d.ts +16 -0
  21. package/dist/core/Config.js +144 -0
  22. package/dist/core/Prerequisites.d.ts +28 -0
  23. package/dist/core/Prerequisites.js +159 -0
  24. package/dist/core/RelationshipManager.d.ts +73 -0
  25. package/dist/core/RelationshipManager.js +318 -0
  26. package/dist/core/SessionManager.d.ts +89 -0
  27. package/dist/core/SessionManager.js +326 -0
  28. package/dist/core/StateManager.d.ts +28 -0
  29. package/dist/core/StateManager.js +96 -0
  30. package/dist/core/types.d.ts +279 -0
  31. package/dist/core/types.js +8 -0
  32. package/dist/index.d.ts +18 -0
  33. package/dist/index.js +23 -0
  34. package/dist/messaging/TelegramAdapter.d.ts +73 -0
  35. package/dist/messaging/TelegramAdapter.js +288 -0
  36. package/dist/monitoring/HealthChecker.d.ts +38 -0
  37. package/dist/monitoring/HealthChecker.js +148 -0
  38. package/dist/scaffold/bootstrap.d.ts +21 -0
  39. package/dist/scaffold/bootstrap.js +110 -0
  40. package/dist/scaffold/templates.d.ts +34 -0
  41. package/dist/scaffold/templates.js +187 -0
  42. package/dist/scheduler/JobLoader.d.ts +18 -0
  43. package/dist/scheduler/JobLoader.js +70 -0
  44. package/dist/scheduler/JobScheduler.d.ts +111 -0
  45. package/dist/scheduler/JobScheduler.js +402 -0
  46. package/dist/server/AgentServer.d.ts +40 -0
  47. package/dist/server/AgentServer.js +73 -0
  48. package/dist/server/middleware.d.ts +12 -0
  49. package/dist/server/middleware.js +50 -0
  50. package/dist/server/routes.d.ts +25 -0
  51. package/dist/server/routes.js +224 -0
  52. package/dist/users/UserManager.d.ts +45 -0
  53. package/dist/users/UserManager.js +113 -0
  54. package/docs/dawn-audit-report.md +412 -0
  55. package/docs/positioning-vs-openclaw.md +246 -0
  56. package/package.json +52 -0
  57. package/src/cli.ts +169 -0
  58. package/src/commands/init.ts +654 -0
  59. package/src/commands/job.ts +110 -0
  60. package/src/commands/server.ts +325 -0
  61. package/src/commands/setup.ts +958 -0
  62. package/src/commands/status.ts +125 -0
  63. package/src/commands/user.ts +71 -0
  64. package/src/core/Config.ts +161 -0
  65. package/src/core/Prerequisites.ts +187 -0
  66. package/src/core/RelationshipManager.ts +366 -0
  67. package/src/core/SessionManager.ts +385 -0
  68. package/src/core/StateManager.ts +121 -0
  69. package/src/core/types.ts +320 -0
  70. package/src/index.ts +58 -0
  71. package/src/messaging/TelegramAdapter.ts +365 -0
  72. package/src/monitoring/HealthChecker.ts +172 -0
  73. package/src/scaffold/bootstrap.ts +122 -0
  74. package/src/scaffold/templates.ts +204 -0
  75. package/src/scheduler/JobLoader.ts +85 -0
  76. package/src/scheduler/JobScheduler.ts +476 -0
  77. package/src/server/AgentServer.ts +93 -0
  78. package/src/server/middleware.ts +58 -0
  79. package/src/server/routes.ts +278 -0
  80. package/src/templates/default-jobs.json +47 -0
  81. package/src/templates/hooks/compaction-recovery.sh +23 -0
  82. package/src/templates/hooks/dangerous-command-guard.sh +35 -0
  83. package/src/templates/hooks/grounding-before-messaging.sh +22 -0
  84. package/src/templates/hooks/session-start.sh +37 -0
  85. package/src/templates/hooks/settings-template.json +45 -0
  86. package/src/templates/scripts/health-watchdog.sh +63 -0
  87. package/src/templates/scripts/telegram-reply.sh +54 -0
  88. package/src/users/UserManager.ts +129 -0
  89. package/tests/e2e/lifecycle.test.ts +376 -0
  90. package/tests/fixtures/test-repo/CLAUDE.md +3 -0
  91. package/tests/fixtures/test-repo/README.md +1 -0
  92. package/tests/helpers/setup.ts +209 -0
  93. package/tests/integration/fresh-install.test.ts +218 -0
  94. package/tests/integration/scheduler-basic.test.ts +109 -0
  95. package/tests/integration/server-full.test.ts +284 -0
  96. package/tests/integration/session-lifecycle.test.ts +181 -0
  97. package/tests/unit/Config.test.ts +22 -0
  98. package/tests/unit/HealthChecker.test.ts +168 -0
  99. package/tests/unit/JobLoader.test.ts +151 -0
  100. package/tests/unit/JobScheduler.test.ts +267 -0
  101. package/tests/unit/Prerequisites.test.ts +59 -0
  102. package/tests/unit/RelationshipManager.test.ts +345 -0
  103. package/tests/unit/StateManager.test.ts +143 -0
  104. package/tests/unit/TelegramAdapter.test.ts +165 -0
  105. package/tests/unit/UserManager.test.ts +131 -0
  106. package/tests/unit/bootstrap.test.ts +28 -0
  107. package/tests/unit/commands.test.ts +138 -0
  108. package/tests/unit/middleware.test.ts +92 -0
  109. package/tests/unit/relationship-routes.test.ts +131 -0
  110. package/tests/unit/scaffold-templates.test.ts +132 -0
  111. package/tests/unit/server.test.ts +163 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +9 -0
  114. package/vitest.e2e.config.ts +9 -0
  115. 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
+ });