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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test utilities for instar tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides temp project creation, mock session managers,
|
|
5
|
+
* mock claude scripts, and async polling helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { StateManager } from '../../src/core/StateManager.js';
|
|
13
|
+
import type { Session, SessionManagerConfig, ModelTier, JobDefinition } from '../../src/core/types.js';
|
|
14
|
+
|
|
15
|
+
// ── Temp Project ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface TempProject {
|
|
18
|
+
dir: string;
|
|
19
|
+
stateDir: string;
|
|
20
|
+
state: StateManager;
|
|
21
|
+
cleanup: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createTempProject(): TempProject {
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-test-'));
|
|
26
|
+
const stateDir = path.join(dir, '.instar');
|
|
27
|
+
|
|
28
|
+
// Create instar directory structure
|
|
29
|
+
fs.mkdirSync(path.join(stateDir, 'state', 'sessions'), { recursive: true });
|
|
30
|
+
fs.mkdirSync(path.join(stateDir, 'state', 'jobs'), { recursive: true });
|
|
31
|
+
fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
|
|
32
|
+
|
|
33
|
+
const state = new StateManager(stateDir);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
dir,
|
|
37
|
+
stateDir,
|
|
38
|
+
state,
|
|
39
|
+
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Mock Session Manager ──────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface MockSessionManager {
|
|
46
|
+
spawnSession: (opts: {
|
|
47
|
+
name: string;
|
|
48
|
+
prompt: string;
|
|
49
|
+
model?: ModelTier;
|
|
50
|
+
jobSlug?: string;
|
|
51
|
+
triggeredBy?: string;
|
|
52
|
+
}) => Promise<Session>;
|
|
53
|
+
isSessionAlive: (tmuxSession: string) => boolean;
|
|
54
|
+
listRunningSessions: () => Session[];
|
|
55
|
+
killSession: (sessionId: string) => boolean;
|
|
56
|
+
captureOutput: (tmuxSession: string, lines?: number) => string | null;
|
|
57
|
+
sendInput: (tmuxSession: string, input: string) => boolean;
|
|
58
|
+
|
|
59
|
+
// Test controls
|
|
60
|
+
_sessions: Session[];
|
|
61
|
+
_aliveSet: Set<string>;
|
|
62
|
+
_spawnCount: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createMockSessionManager(): MockSessionManager {
|
|
66
|
+
const mock: MockSessionManager = {
|
|
67
|
+
_sessions: [],
|
|
68
|
+
_aliveSet: new Set(),
|
|
69
|
+
_spawnCount: 0,
|
|
70
|
+
|
|
71
|
+
spawnSession: async (opts) => {
|
|
72
|
+
mock._spawnCount++;
|
|
73
|
+
const session: Session = {
|
|
74
|
+
id: `mock-${Date.now().toString(36)}-${mock._spawnCount}`,
|
|
75
|
+
name: opts.name,
|
|
76
|
+
status: 'running',
|
|
77
|
+
tmuxSession: `test-${opts.name}`,
|
|
78
|
+
startedAt: new Date().toISOString(),
|
|
79
|
+
jobSlug: opts.jobSlug,
|
|
80
|
+
triggeredBy: opts.triggeredBy,
|
|
81
|
+
model: opts.model,
|
|
82
|
+
prompt: opts.prompt,
|
|
83
|
+
};
|
|
84
|
+
mock._sessions.push(session);
|
|
85
|
+
mock._aliveSet.add(session.tmuxSession);
|
|
86
|
+
return session;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
isSessionAlive: (tmuxSession: string) => mock._aliveSet.has(tmuxSession),
|
|
90
|
+
|
|
91
|
+
listRunningSessions: () => mock._sessions.filter(s => s.status === 'running'),
|
|
92
|
+
|
|
93
|
+
killSession: (sessionId: string) => {
|
|
94
|
+
const session = mock._sessions.find(s => s.id === sessionId);
|
|
95
|
+
if (!session) return false;
|
|
96
|
+
session.status = 'killed';
|
|
97
|
+
session.endedAt = new Date().toISOString();
|
|
98
|
+
mock._aliveSet.delete(session.tmuxSession);
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
captureOutput: () => 'mock output',
|
|
103
|
+
|
|
104
|
+
sendInput: (tmuxSession: string) => mock._aliveSet.has(tmuxSession),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return mock;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Mock Claude Script ────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a shell script that mimics claude CLI — echoes the prompt and exits.
|
|
114
|
+
* Returns the path to the script.
|
|
115
|
+
*/
|
|
116
|
+
export function createMockClaude(dir: string): string {
|
|
117
|
+
const scriptPath = path.join(dir, 'mock-claude.sh');
|
|
118
|
+
fs.writeFileSync(scriptPath, `#!/bin/bash
|
|
119
|
+
echo "Mock Claude session started"
|
|
120
|
+
echo "Prompt: $@"
|
|
121
|
+
# Sleep briefly to simulate work, then exit
|
|
122
|
+
sleep 2
|
|
123
|
+
echo "Session ended"
|
|
124
|
+
`);
|
|
125
|
+
fs.chmodSync(scriptPath, '755');
|
|
126
|
+
return scriptPath;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Sample Jobs ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function createSampleJobsFile(dir: string, jobs?: JobDefinition[]): string {
|
|
132
|
+
const filePath = path.join(dir, 'jobs.json');
|
|
133
|
+
const defaultJobs: JobDefinition[] = jobs ?? [
|
|
134
|
+
{
|
|
135
|
+
slug: 'health-check',
|
|
136
|
+
name: 'Health Check',
|
|
137
|
+
description: 'Run a quick health check',
|
|
138
|
+
schedule: '0 */4 * * *',
|
|
139
|
+
priority: 'high',
|
|
140
|
+
expectedDurationMinutes: 2,
|
|
141
|
+
model: 'haiku',
|
|
142
|
+
enabled: true,
|
|
143
|
+
execute: { type: 'skill', value: 'scan' },
|
|
144
|
+
tags: ['monitoring'],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
slug: 'email-check',
|
|
148
|
+
name: 'Email Check',
|
|
149
|
+
description: 'Check for new emails',
|
|
150
|
+
schedule: '0 */2 * * *',
|
|
151
|
+
priority: 'medium',
|
|
152
|
+
expectedDurationMinutes: 5,
|
|
153
|
+
model: 'sonnet',
|
|
154
|
+
enabled: true,
|
|
155
|
+
execute: { type: 'prompt', value: 'Check for new emails and respond' },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
slug: 'disabled-job',
|
|
159
|
+
name: 'Disabled Job',
|
|
160
|
+
description: 'This one is off',
|
|
161
|
+
schedule: '0 0 * * *',
|
|
162
|
+
priority: 'low',
|
|
163
|
+
expectedDurationMinutes: 10,
|
|
164
|
+
model: 'opus',
|
|
165
|
+
enabled: false,
|
|
166
|
+
execute: { type: 'script', value: './scripts/heavy-task.sh' },
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(filePath, JSON.stringify(defaultJobs, null, 2));
|
|
171
|
+
return filePath;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Async Helpers ─────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Poll a condition until it returns true or timeout.
|
|
178
|
+
*/
|
|
179
|
+
export async function waitFor(
|
|
180
|
+
condition: () => boolean | Promise<boolean>,
|
|
181
|
+
timeoutMs: number = 5000,
|
|
182
|
+
intervalMs: number = 100,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
while (Date.now() - start < timeoutMs) {
|
|
186
|
+
if (await condition()) return;
|
|
187
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Tmux Cleanup ──────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Kill any tmux sessions matching a prefix. Use in afterAll for integration tests.
|
|
196
|
+
*/
|
|
197
|
+
export function cleanupTmuxSessions(prefix: string): void {
|
|
198
|
+
try {
|
|
199
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', { encoding: 'utf-8' });
|
|
200
|
+
const sessions = output.trim().split('\n').filter(s => s.startsWith(prefix));
|
|
201
|
+
for (const session of sessions) {
|
|
202
|
+
try {
|
|
203
|
+
execSync(`tmux kill-session -t '=${session}'`);
|
|
204
|
+
} catch { /* already dead */ }
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// No tmux server running — fine
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — fresh project creation via `instar init <name>`.
|
|
3
|
+
*
|
|
4
|
+
* Tests the complete fresh install journey:
|
|
5
|
+
* init with project name → directory created → all files scaffolded →
|
|
6
|
+
* config valid → identity files present → hooks installed → git initialized
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { initProject } from '../../src/commands/init.js';
|
|
14
|
+
|
|
15
|
+
describe('Fresh install: instar init <project-name>', () => {
|
|
16
|
+
const testBase = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-fresh-'));
|
|
17
|
+
const projectName = 'test-agent';
|
|
18
|
+
const projectDir = path.join(testBase, projectName);
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
fs.rmSync(testBase, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('creates project directory and all required files', async () => {
|
|
25
|
+
// Change cwd temporarily so init creates the project relative to testBase
|
|
26
|
+
const originalCwd = process.cwd();
|
|
27
|
+
process.chdir(testBase);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await initProject({ name: projectName, port: 4444 });
|
|
31
|
+
} finally {
|
|
32
|
+
process.chdir(originalCwd);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Verify project directory exists
|
|
36
|
+
expect(fs.existsSync(projectDir)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates CLAUDE.md at project root', () => {
|
|
40
|
+
const claudeMd = path.join(projectDir, 'CLAUDE.md');
|
|
41
|
+
expect(fs.existsSync(claudeMd)).toBe(true);
|
|
42
|
+
|
|
43
|
+
const content = fs.readFileSync(claudeMd, 'utf-8');
|
|
44
|
+
expect(content).toContain('test-agent');
|
|
45
|
+
expect(content).toContain('Agent Infrastructure');
|
|
46
|
+
expect(content).toContain('Initiative Hierarchy');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('creates .instar directory structure', () => {
|
|
50
|
+
const stateDir = path.join(projectDir, '.instar');
|
|
51
|
+
expect(fs.existsSync(stateDir)).toBe(true);
|
|
52
|
+
expect(fs.existsSync(path.join(stateDir, 'state'))).toBe(true);
|
|
53
|
+
expect(fs.existsSync(path.join(stateDir, 'state', 'sessions'))).toBe(true);
|
|
54
|
+
expect(fs.existsSync(path.join(stateDir, 'state', 'jobs'))).toBe(true);
|
|
55
|
+
expect(fs.existsSync(path.join(stateDir, 'relationships'))).toBe(true);
|
|
56
|
+
expect(fs.existsSync(path.join(stateDir, 'logs'))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('creates AGENT.md with identity', () => {
|
|
60
|
+
const agentMd = path.join(projectDir, '.instar', 'AGENT.md');
|
|
61
|
+
expect(fs.existsSync(agentMd)).toBe(true);
|
|
62
|
+
|
|
63
|
+
const content = fs.readFileSync(agentMd, 'utf-8');
|
|
64
|
+
expect(content).toContain('# Test-agent'); // Capitalized
|
|
65
|
+
expect(content).toContain('Who I Am');
|
|
66
|
+
expect(content).toContain('My Principles');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('creates USER.md', () => {
|
|
70
|
+
const userMd = path.join(projectDir, '.instar', 'USER.md');
|
|
71
|
+
expect(fs.existsSync(userMd)).toBe(true);
|
|
72
|
+
|
|
73
|
+
const content = fs.readFileSync(userMd, 'utf-8');
|
|
74
|
+
expect(content).toContain('Communication Preferences');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('creates MEMORY.md', () => {
|
|
78
|
+
const memoryMd = path.join(projectDir, '.instar', 'MEMORY.md');
|
|
79
|
+
expect(fs.existsSync(memoryMd)).toBe(true);
|
|
80
|
+
|
|
81
|
+
const content = fs.readFileSync(memoryMd, 'utf-8');
|
|
82
|
+
expect(content).toContain('Memory');
|
|
83
|
+
expect(content).toContain('persists across sessions');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('creates valid config.json', () => {
|
|
87
|
+
const configPath = path.join(projectDir, '.instar', 'config.json');
|
|
88
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
91
|
+
expect(config.projectName).toBe('test-agent');
|
|
92
|
+
expect(config.port).toBe(4444);
|
|
93
|
+
expect(config.authToken).toBeTruthy();
|
|
94
|
+
expect(config.sessions.maxSessions).toBe(3);
|
|
95
|
+
expect(config.scheduler.enabled).toBe(true); // Enabled by default for fresh
|
|
96
|
+
expect(config.relationships.maxRecentInteractions).toBe(20);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('creates jobs.json with default coherence jobs', () => {
|
|
100
|
+
const jobsPath = path.join(projectDir, '.instar', 'jobs.json');
|
|
101
|
+
expect(fs.existsSync(jobsPath)).toBe(true);
|
|
102
|
+
|
|
103
|
+
const jobs = JSON.parse(fs.readFileSync(jobsPath, 'utf-8'));
|
|
104
|
+
expect(jobs.length).toBe(3);
|
|
105
|
+
|
|
106
|
+
const slugs = jobs.map((j: any) => j.slug);
|
|
107
|
+
expect(slugs).toContain('health-check');
|
|
108
|
+
expect(slugs).toContain('reflection-trigger');
|
|
109
|
+
expect(slugs).toContain('relationship-maintenance');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('installs behavioral hooks', () => {
|
|
113
|
+
const hooksDir = path.join(projectDir, '.instar', 'hooks');
|
|
114
|
+
expect(fs.existsSync(hooksDir)).toBe(true);
|
|
115
|
+
|
|
116
|
+
const hooks = fs.readdirSync(hooksDir);
|
|
117
|
+
expect(hooks).toContain('session-start.sh');
|
|
118
|
+
expect(hooks).toContain('dangerous-command-guard.sh');
|
|
119
|
+
expect(hooks).toContain('grounding-before-messaging.sh');
|
|
120
|
+
expect(hooks).toContain('compaction-recovery.sh');
|
|
121
|
+
|
|
122
|
+
// Verify hooks are executable
|
|
123
|
+
for (const hook of hooks) {
|
|
124
|
+
const stats = fs.statSync(path.join(hooksDir, hook));
|
|
125
|
+
expect(stats.mode & 0o111).toBeGreaterThan(0); // Has execute bit
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('creates .claude/settings.json with hook config', () => {
|
|
130
|
+
const settingsPath = path.join(projectDir, '.claude', 'settings.json');
|
|
131
|
+
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
132
|
+
|
|
133
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
134
|
+
expect(settings.hooks).toBeDefined();
|
|
135
|
+
expect(settings.hooks.PreToolUse).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('creates .claude/scripts/health-watchdog.sh', () => {
|
|
139
|
+
const watchdogPath = path.join(projectDir, '.claude', 'scripts', 'health-watchdog.sh');
|
|
140
|
+
expect(fs.existsSync(watchdogPath)).toBe(true);
|
|
141
|
+
|
|
142
|
+
const stats = fs.statSync(watchdogPath);
|
|
143
|
+
expect(stats.mode & 0o111).toBeGreaterThan(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('creates .gitignore with state exclusions', () => {
|
|
147
|
+
const gitignorePath = path.join(projectDir, '.gitignore');
|
|
148
|
+
expect(fs.existsSync(gitignorePath)).toBe(true);
|
|
149
|
+
|
|
150
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
151
|
+
expect(content).toContain('.instar/state/');
|
|
152
|
+
expect(content).toContain('.instar/logs/');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('initializes a git repository', () => {
|
|
156
|
+
const gitDir = path.join(projectDir, '.git');
|
|
157
|
+
expect(fs.existsSync(gitDir)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('Existing project: instar init (no project name)', () => {
|
|
162
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-existing-'));
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('adds .instar/ to an existing directory without CLAUDE.md', async () => {
|
|
169
|
+
// Create a minimal existing project
|
|
170
|
+
fs.writeFileSync(path.join(testDir, 'index.ts'), '// existing code');
|
|
171
|
+
|
|
172
|
+
await initProject({ dir: testDir, port: 5555 });
|
|
173
|
+
|
|
174
|
+
// Verify .instar was created
|
|
175
|
+
expect(fs.existsSync(path.join(testDir, '.instar', 'config.json'))).toBe(true);
|
|
176
|
+
expect(fs.existsSync(path.join(testDir, '.instar', 'AGENT.md'))).toBe(true);
|
|
177
|
+
expect(fs.existsSync(path.join(testDir, '.instar', 'USER.md'))).toBe(true);
|
|
178
|
+
expect(fs.existsSync(path.join(testDir, '.instar', 'MEMORY.md'))).toBe(true);
|
|
179
|
+
|
|
180
|
+
// Verify existing file wasn't touched
|
|
181
|
+
const existingContent = fs.readFileSync(path.join(testDir, 'index.ts'), 'utf-8');
|
|
182
|
+
expect(existingContent).toBe('// existing code');
|
|
183
|
+
|
|
184
|
+
// Verify scheduler is disabled for existing projects (conservative default)
|
|
185
|
+
const config = JSON.parse(fs.readFileSync(path.join(testDir, '.instar', 'config.json'), 'utf-8'));
|
|
186
|
+
expect(config.scheduler.enabled).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('appends to existing CLAUDE.md without overwriting', async () => {
|
|
190
|
+
const anotherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-existing2-'));
|
|
191
|
+
const existingContent = '# My Project\n\nThis is my project.\n';
|
|
192
|
+
fs.writeFileSync(path.join(anotherDir, 'CLAUDE.md'), existingContent);
|
|
193
|
+
|
|
194
|
+
await initProject({ dir: anotherDir });
|
|
195
|
+
|
|
196
|
+
const result = fs.readFileSync(path.join(anotherDir, 'CLAUDE.md'), 'utf-8');
|
|
197
|
+
expect(result).toContain('# My Project');
|
|
198
|
+
expect(result).toContain('This is my project.');
|
|
199
|
+
expect(result).toContain('## Agent Infrastructure');
|
|
200
|
+
|
|
201
|
+
fs.rmSync(anotherDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not re-append if already initialized', async () => {
|
|
205
|
+
const anotherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-existing3-'));
|
|
206
|
+
const existingContent = '# My Project\n\n## Agent Infrastructure\n\nAlready here.\n';
|
|
207
|
+
fs.writeFileSync(path.join(anotherDir, 'CLAUDE.md'), existingContent);
|
|
208
|
+
|
|
209
|
+
await initProject({ dir: anotherDir });
|
|
210
|
+
|
|
211
|
+
const result = fs.readFileSync(path.join(anotherDir, 'CLAUDE.md'), 'utf-8');
|
|
212
|
+
// Should only have one instance of Agent Infrastructure
|
|
213
|
+
const count = (result.match(/## Agent Infrastructure/g) || []).length;
|
|
214
|
+
expect(count).toBe(1);
|
|
215
|
+
|
|
216
|
+
fs.rmSync(anotherDir, { recursive: true, force: true });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — scheduler with real cron timing.
|
|
3
|
+
*
|
|
4
|
+
* Uses a fast cron (every second) and mock claude to verify
|
|
5
|
+
* the scheduler triggers jobs and tracks state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
9
|
+
import { JobScheduler } from '../../src/scheduler/JobScheduler.js';
|
|
10
|
+
import { detectTmuxPath } from '../../src/core/Config.js';
|
|
11
|
+
import { SessionManager } from '../../src/core/SessionManager.js';
|
|
12
|
+
import {
|
|
13
|
+
createTempProject,
|
|
14
|
+
createMockClaude,
|
|
15
|
+
cleanupTmuxSessions,
|
|
16
|
+
waitFor,
|
|
17
|
+
} from '../helpers/setup.js';
|
|
18
|
+
import type { TempProject } from '../helpers/setup.js';
|
|
19
|
+
import type { JobDefinition } from '../../src/core/types.js';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const TMUX_PREFIX = 'akit-sched-';
|
|
24
|
+
|
|
25
|
+
const tmuxPath = detectTmuxPath();
|
|
26
|
+
const describeMaybe = tmuxPath ? describe : describe.skip;
|
|
27
|
+
|
|
28
|
+
describeMaybe('JobScheduler (integration)', () => {
|
|
29
|
+
let project: TempProject;
|
|
30
|
+
let mockClaudePath: string;
|
|
31
|
+
let sm: SessionManager;
|
|
32
|
+
let scheduler: JobScheduler;
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
project = createTempProject();
|
|
36
|
+
mockClaudePath = createMockClaude(project.dir);
|
|
37
|
+
|
|
38
|
+
sm = new SessionManager(
|
|
39
|
+
{
|
|
40
|
+
tmuxPath: tmuxPath!,
|
|
41
|
+
claudePath: mockClaudePath,
|
|
42
|
+
projectDir: project.dir,
|
|
43
|
+
maxSessions: 5,
|
|
44
|
+
protectedSessions: [],
|
|
45
|
+
completionPatterns: ['Session ended'],
|
|
46
|
+
},
|
|
47
|
+
project.state,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
scheduler?.stop();
|
|
53
|
+
sm.stopMonitoring();
|
|
54
|
+
cleanupTmuxSessions(TMUX_PREFIX);
|
|
55
|
+
project.cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('triggers a job via cron and spawns a session', async () => {
|
|
59
|
+
// Create a jobs file with a fast-firing cron (every second)
|
|
60
|
+
const fastJob: JobDefinition = {
|
|
61
|
+
slug: 'fast-test',
|
|
62
|
+
name: 'Fast Test',
|
|
63
|
+
description: 'Triggers every second for testing',
|
|
64
|
+
schedule: '* * * * * *', // Every second (croner supports seconds)
|
|
65
|
+
priority: 'medium',
|
|
66
|
+
expectedDurationMinutes: 1,
|
|
67
|
+
model: 'haiku',
|
|
68
|
+
enabled: true,
|
|
69
|
+
execute: { type: 'prompt', value: 'Quick test' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const jobsFile = path.join(project.stateDir, 'jobs.json');
|
|
73
|
+
fs.writeFileSync(jobsFile, JSON.stringify([fastJob]));
|
|
74
|
+
|
|
75
|
+
scheduler = new JobScheduler(
|
|
76
|
+
{
|
|
77
|
+
jobsFile,
|
|
78
|
+
enabled: true,
|
|
79
|
+
maxParallelJobs: 3,
|
|
80
|
+
quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
|
|
81
|
+
},
|
|
82
|
+
sm,
|
|
83
|
+
project.state,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
scheduler.start();
|
|
87
|
+
|
|
88
|
+
// Wait for cron to fire and a session to be spawned
|
|
89
|
+
await waitFor(
|
|
90
|
+
() => {
|
|
91
|
+
const events = project.state.queryEvents({ type: 'job_triggered' });
|
|
92
|
+
return events.length > 0;
|
|
93
|
+
},
|
|
94
|
+
5000,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
scheduler.stop();
|
|
98
|
+
|
|
99
|
+
// Verify a job_triggered event was recorded
|
|
100
|
+
const events = project.state.queryEvents({ type: 'job_triggered' });
|
|
101
|
+
expect(events.length).toBeGreaterThan(0);
|
|
102
|
+
expect(events[0].summary).toContain('fast-test');
|
|
103
|
+
|
|
104
|
+
// Verify job state was saved
|
|
105
|
+
const jobState = project.state.getJobState('fast-test');
|
|
106
|
+
expect(jobState).not.toBeNull();
|
|
107
|
+
expect(jobState!.lastRun).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
});
|