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,151 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { loadJobs, validateJob } from '../../src/scheduler/JobLoader.js';
6
+
7
+ describe('JobLoader', () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-loader-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ const validJob = {
19
+ slug: 'test-job',
20
+ name: 'Test Job',
21
+ description: 'A test job',
22
+ schedule: '0 */4 * * *',
23
+ priority: 'medium',
24
+ expectedDurationMinutes: 5,
25
+ model: 'sonnet',
26
+ enabled: true,
27
+ execute: { type: 'skill', value: 'scan' },
28
+ };
29
+
30
+ function writeJobsFile(jobs: unknown[]): string {
31
+ const filePath = path.join(tmpDir, 'jobs.json');
32
+ fs.writeFileSync(filePath, JSON.stringify(jobs));
33
+ return filePath;
34
+ }
35
+
36
+ describe('loadJobs', () => {
37
+ it('loads a valid jobs file', () => {
38
+ const file = writeJobsFile([validJob]);
39
+ const jobs = loadJobs(file);
40
+ expect(jobs).toHaveLength(1);
41
+ expect(jobs[0].slug).toBe('test-job');
42
+ });
43
+
44
+ it('loads empty array', () => {
45
+ const file = writeJobsFile([]);
46
+ const jobs = loadJobs(file);
47
+ expect(jobs).toHaveLength(0);
48
+ });
49
+
50
+ it('loads multiple jobs', () => {
51
+ const file = writeJobsFile([
52
+ validJob,
53
+ { ...validJob, slug: 'second-job', name: 'Second' },
54
+ ]);
55
+ const jobs = loadJobs(file);
56
+ expect(jobs).toHaveLength(2);
57
+ });
58
+
59
+ it('includes disabled jobs (filtering is caller responsibility)', () => {
60
+ const file = writeJobsFile([
61
+ validJob,
62
+ { ...validJob, slug: 'off', enabled: false },
63
+ ]);
64
+ const jobs = loadJobs(file);
65
+ expect(jobs).toHaveLength(2);
66
+ expect(jobs[1].enabled).toBe(false);
67
+ });
68
+
69
+ it('throws for missing file', () => {
70
+ expect(() => loadJobs('/nonexistent/jobs.json'))
71
+ .toThrow('Jobs file not found');
72
+ });
73
+
74
+ it('throws for non-array JSON', () => {
75
+ const file = path.join(tmpDir, 'bad.json');
76
+ fs.writeFileSync(file, JSON.stringify({ jobs: [] }));
77
+ expect(() => loadJobs(file))
78
+ .toThrow('must contain a JSON array');
79
+ });
80
+ });
81
+
82
+ describe('validateJob', () => {
83
+ it('accepts a valid job', () => {
84
+ expect(() => validateJob(validJob)).not.toThrow();
85
+ });
86
+
87
+ it('rejects null', () => {
88
+ expect(() => validateJob(null)).toThrow('must be an object');
89
+ });
90
+
91
+ it('rejects missing slug', () => {
92
+ const { slug, ...noSlug } = validJob;
93
+ expect(() => validateJob(noSlug)).toThrow('"slug" is required');
94
+ });
95
+
96
+ it('rejects empty slug', () => {
97
+ expect(() => validateJob({ ...validJob, slug: ' ' }))
98
+ .toThrow('"slug" is required');
99
+ });
100
+
101
+ it('rejects missing name', () => {
102
+ const { name, ...noName } = validJob;
103
+ expect(() => validateJob(noName)).toThrow('"name" is required');
104
+ });
105
+
106
+ it('rejects missing description', () => {
107
+ const { description, ...noDesc } = validJob;
108
+ expect(() => validateJob(noDesc)).toThrow('"description" is required');
109
+ });
110
+
111
+ it('rejects missing schedule', () => {
112
+ const { schedule, ...noSchedule } = validJob;
113
+ expect(() => validateJob(noSchedule)).toThrow('"schedule" is required');
114
+ });
115
+
116
+ it('rejects invalid priority', () => {
117
+ expect(() => validateJob({ ...validJob, priority: 'urgent' }))
118
+ .toThrow('"priority" must be one of');
119
+ });
120
+
121
+ it('rejects invalid cron expression', () => {
122
+ expect(() => validateJob({ ...validJob, schedule: 'not a cron' }))
123
+ .toThrow('invalid cron expression');
124
+ });
125
+
126
+ it('rejects non-boolean enabled', () => {
127
+ expect(() => validateJob({ ...validJob, enabled: 'yes' }))
128
+ .toThrow('"enabled" must be a boolean');
129
+ });
130
+
131
+ it('rejects missing execute', () => {
132
+ const { execute, ...noExec } = validJob;
133
+ expect(() => validateJob(noExec))
134
+ .toThrow('"execute" must be an object');
135
+ });
136
+
137
+ it('rejects invalid execute.type', () => {
138
+ expect(() => validateJob({ ...validJob, execute: { type: 'unknown', value: 'x' } }))
139
+ .toThrow('execute.type must be');
140
+ });
141
+
142
+ it('rejects empty execute.value', () => {
143
+ expect(() => validateJob({ ...validJob, execute: { type: 'skill', value: '' } }))
144
+ .toThrow('execute.value is required');
145
+ });
146
+
147
+ it('includes index in error message', () => {
148
+ expect(() => validateJob(null, 3)).toThrow('Job[3]');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { JobScheduler } from '../../src/scheduler/JobScheduler.js';
3
+ import { createTempProject, createMockSessionManager, createSampleJobsFile } from '../helpers/setup.js';
4
+ import type { TempProject, MockSessionManager } from '../helpers/setup.js';
5
+ import type { JobSchedulerConfig } from '../../src/core/types.js';
6
+
7
+ describe('JobScheduler', () => {
8
+ let project: TempProject;
9
+ let mockSM: MockSessionManager;
10
+ let scheduler: JobScheduler;
11
+ let jobsFile: string;
12
+
13
+ beforeEach(() => {
14
+ project = createTempProject();
15
+ mockSM = createMockSessionManager();
16
+ jobsFile = createSampleJobsFile(project.stateDir);
17
+ });
18
+
19
+ afterEach(() => {
20
+ scheduler?.stop();
21
+ project.cleanup();
22
+ });
23
+
24
+ function makeConfig(overrides?: Partial<JobSchedulerConfig>): JobSchedulerConfig {
25
+ return {
26
+ jobsFile,
27
+ enabled: true,
28
+ maxParallelJobs: 2,
29
+ quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ function createScheduler(configOverrides?: Partial<JobSchedulerConfig>): JobScheduler {
35
+ scheduler = new JobScheduler(
36
+ makeConfig(configOverrides),
37
+ mockSM as any,
38
+ project.state,
39
+ );
40
+ return scheduler;
41
+ }
42
+
43
+ describe('start/stop', () => {
44
+ it('starts and loads jobs', () => {
45
+ createScheduler();
46
+ scheduler.start();
47
+
48
+ const status = scheduler.getStatus();
49
+ expect(status.running).toBe(true);
50
+ expect(status.jobCount).toBe(3); // 2 enabled + 1 disabled
51
+ expect(status.enabledJobs).toBe(2);
52
+ });
53
+
54
+ it('stops cleanly', () => {
55
+ createScheduler();
56
+ scheduler.start();
57
+ scheduler.stop();
58
+
59
+ expect(scheduler.getStatus().running).toBe(false);
60
+ });
61
+
62
+ it('start is idempotent', () => {
63
+ createScheduler();
64
+ scheduler.start();
65
+ scheduler.start(); // Should not throw
66
+ expect(scheduler.getStatus().running).toBe(true);
67
+ });
68
+ });
69
+
70
+ describe('triggerJob', () => {
71
+ it('triggers a known job', () => {
72
+ createScheduler();
73
+ scheduler.start();
74
+
75
+ const result = scheduler.triggerJob('health-check', 'test');
76
+ expect(result).toBe('triggered');
77
+ expect(mockSM._spawnCount).toBe(1);
78
+ });
79
+
80
+ it('throws for unknown job', () => {
81
+ createScheduler();
82
+ scheduler.start();
83
+
84
+ expect(() => scheduler.triggerJob('nonexistent', 'test'))
85
+ .toThrow('Unknown job: nonexistent');
86
+ });
87
+
88
+ it('queues when at max parallel jobs', () => {
89
+ createScheduler({ maxParallelJobs: 1 });
90
+ scheduler.start();
91
+
92
+ // First trigger succeeds
93
+ const r1 = scheduler.triggerJob('health-check', 'test');
94
+ expect(r1).toBe('triggered');
95
+
96
+ // Second trigger gets queued — at capacity
97
+ const r2 = scheduler.triggerJob('email-check', 'test');
98
+ expect(r2).toBe('queued');
99
+
100
+ expect(scheduler.getStatus().queueLength).toBe(1);
101
+ });
102
+
103
+ it('skips when paused', () => {
104
+ createScheduler();
105
+ scheduler.start();
106
+ scheduler.pause();
107
+
108
+ const result = scheduler.triggerJob('health-check', 'test');
109
+ expect(result).toBe('skipped');
110
+ expect(mockSM._spawnCount).toBe(0);
111
+ });
112
+
113
+ it('skips when quota callback returns false', () => {
114
+ createScheduler();
115
+ scheduler.start();
116
+ scheduler.canRunJob = () => false;
117
+
118
+ const result = scheduler.triggerJob('health-check', 'test');
119
+ expect(result).toBe('skipped');
120
+ expect(mockSM._spawnCount).toBe(0);
121
+ });
122
+ });
123
+
124
+ describe('queue processing', () => {
125
+ it('drains queue when slot opens', () => {
126
+ createScheduler({ maxParallelJobs: 1 });
127
+ scheduler.start();
128
+
129
+ // Fill the slot
130
+ scheduler.triggerJob('health-check', 'test');
131
+ expect(mockSM._spawnCount).toBe(1);
132
+
133
+ // Queue a job
134
+ scheduler.triggerJob('email-check', 'test');
135
+ expect(scheduler.getStatus().queueLength).toBe(1);
136
+
137
+ // Simulate session completion — mark the running session as completed
138
+ const session = mockSM._sessions[0];
139
+ session.status = 'completed';
140
+ mockSM._aliveSet.delete(session.tmuxSession);
141
+
142
+ // Process the queue
143
+ scheduler.processQueue();
144
+ expect(mockSM._spawnCount).toBe(2);
145
+ expect(scheduler.getStatus().queueLength).toBe(0);
146
+ });
147
+
148
+ it('does not dequeue duplicates', () => {
149
+ createScheduler({ maxParallelJobs: 1 });
150
+ scheduler.start();
151
+
152
+ scheduler.triggerJob('health-check', 'test');
153
+ scheduler.triggerJob('email-check', 'test-1');
154
+ scheduler.triggerJob('email-check', 'test-2'); // duplicate slug
155
+
156
+ expect(scheduler.getStatus().queueLength).toBe(1);
157
+ });
158
+
159
+ it('does not process queue when paused', () => {
160
+ createScheduler({ maxParallelJobs: 1 });
161
+ scheduler.start();
162
+
163
+ scheduler.triggerJob('health-check', 'test');
164
+ scheduler.triggerJob('email-check', 'test');
165
+
166
+ // Clear the slot
167
+ mockSM._sessions[0].status = 'completed';
168
+ mockSM._aliveSet.delete(mockSM._sessions[0].tmuxSession);
169
+
170
+ scheduler.pause();
171
+ scheduler.processQueue();
172
+ expect(mockSM._spawnCount).toBe(1); // Only the first one
173
+ });
174
+ });
175
+
176
+ describe('pause/resume', () => {
177
+ it('resume processes pending queue', () => {
178
+ createScheduler({ maxParallelJobs: 1 });
179
+ scheduler.start();
180
+
181
+ scheduler.triggerJob('health-check', 'test');
182
+ scheduler.triggerJob('email-check', 'test');
183
+ scheduler.pause();
184
+
185
+ // Clear the slot
186
+ mockSM._sessions[0].status = 'completed';
187
+ mockSM._aliveSet.delete(mockSM._sessions[0].tmuxSession);
188
+
189
+ scheduler.resume();
190
+ expect(mockSM._spawnCount).toBe(2);
191
+ });
192
+
193
+ it('getStatus reflects paused state', () => {
194
+ createScheduler();
195
+ scheduler.start();
196
+
197
+ expect(scheduler.getStatus().paused).toBe(false);
198
+ scheduler.pause();
199
+ expect(scheduler.getStatus().paused).toBe(true);
200
+ scheduler.resume();
201
+ expect(scheduler.getStatus().paused).toBe(false);
202
+ });
203
+ });
204
+
205
+ describe('failure tracking', () => {
206
+ it('increments consecutive failures on spawn error', async () => {
207
+ createScheduler();
208
+ scheduler.start();
209
+
210
+ // Make spawnSession reject
211
+ mockSM.spawnSession = async () => { throw new Error('tmux failed'); };
212
+
213
+ scheduler.triggerJob('health-check', 'test');
214
+
215
+ // Wait for the async rejection to be handled
216
+ await new Promise(r => setTimeout(r, 50));
217
+
218
+ const jobState = project.state.getJobState('health-check');
219
+ expect(jobState?.lastResult).toBe('failure');
220
+ expect(jobState?.consecutiveFailures).toBe(1);
221
+ });
222
+ });
223
+
224
+ describe('getJobs', () => {
225
+ it('returns loaded job definitions', () => {
226
+ createScheduler();
227
+ scheduler.start();
228
+
229
+ const jobs = scheduler.getJobs();
230
+ expect(jobs).toHaveLength(3);
231
+ expect(jobs.map(j => j.slug)).toContain('health-check');
232
+ expect(jobs.map(j => j.slug)).toContain('disabled-job');
233
+ });
234
+ });
235
+
236
+ describe('activity events', () => {
237
+ it('emits scheduler_start event', () => {
238
+ createScheduler();
239
+ scheduler.start();
240
+
241
+ const events = project.state.queryEvents({ type: 'scheduler_start' });
242
+ expect(events).toHaveLength(1);
243
+ });
244
+
245
+ it('emits job_triggered event', async () => {
246
+ createScheduler();
247
+ scheduler.start();
248
+ scheduler.triggerJob('health-check', 'manual');
249
+
250
+ // Wait for async spawn to complete
251
+ await new Promise(r => setTimeout(r, 50));
252
+
253
+ const events = project.state.queryEvents({ type: 'job_triggered' });
254
+ expect(events).toHaveLength(1);
255
+ expect(events[0].summary).toContain('health-check');
256
+ });
257
+
258
+ it('emits scheduler_stop event', () => {
259
+ createScheduler();
260
+ scheduler.start();
261
+ scheduler.stop();
262
+
263
+ const events = project.state.queryEvents({ type: 'scheduler_stop' });
264
+ expect(events).toHaveLength(1);
265
+ });
266
+ });
267
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Unit tests for prerequisite detection.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { checkPrerequisites } from '../../src/core/Prerequisites.js';
7
+
8
+ describe('Prerequisites', () => {
9
+ it('returns structured results for all checks', () => {
10
+ const result = checkPrerequisites();
11
+
12
+ expect(result.results).toHaveLength(3);
13
+ expect(result.results.map(r => r.name)).toEqual(['Node.js', 'tmux', 'Claude CLI']);
14
+
15
+ // Each result has the expected shape
16
+ for (const r of result.results) {
17
+ expect(r).toHaveProperty('name');
18
+ expect(r).toHaveProperty('found');
19
+ expect(r).toHaveProperty('installHint');
20
+ }
21
+ });
22
+
23
+ it('detects Node.js version correctly', () => {
24
+ const result = checkPrerequisites();
25
+ const node = result.results.find(r => r.name === 'Node.js')!;
26
+
27
+ expect(node.found).toBe(true); // We're running tests, so Node must be available
28
+ expect(node.version).toMatch(/^v\d+/);
29
+ });
30
+
31
+ it('reports allMet based on found status', () => {
32
+ const result = checkPrerequisites();
33
+
34
+ // allMet should be true only if everything is found
35
+ const allFound = result.results.every(r => r.found);
36
+ expect(result.allMet).toBe(allFound);
37
+ });
38
+
39
+ it('missing array contains only unfound prerequisites', () => {
40
+ const result = checkPrerequisites();
41
+
42
+ for (const m of result.missing) {
43
+ expect(m.found).toBe(false);
44
+ }
45
+
46
+ // missing.length + found.length should equal total
47
+ const foundCount = result.results.filter(r => r.found).length;
48
+ expect(result.missing.length + foundCount).toBe(result.results.length);
49
+ });
50
+
51
+ it('provides install hints for all results', () => {
52
+ const result = checkPrerequisites();
53
+
54
+ // Missing items should have non-empty install hints
55
+ for (const m of result.missing) {
56
+ expect(m.installHint.length).toBeGreaterThan(0);
57
+ }
58
+ });
59
+ });