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,129 @@
1
+ /**
2
+ * User Manager — multi-user identity resolution.
3
+ *
4
+ * Maps incoming messages to known users based on their channels.
5
+ * Same agent, same repo, different relationship per user.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import type { UserProfile, UserChannel, Message } from '../core/types.js';
11
+
12
+ export class UserManager {
13
+ private users: Map<string, UserProfile> = new Map();
14
+ private channelIndex: Map<string, string> = new Map(); // "type:identifier" -> userId
15
+ private usersFile: string;
16
+
17
+ constructor(stateDir: string, initialUsers?: UserProfile[]) {
18
+ this.usersFile = path.join(stateDir, 'users.json');
19
+ this.loadUsers(initialUsers);
20
+ }
21
+
22
+ /**
23
+ * Resolve a user from an incoming message.
24
+ * Returns the user profile if the sender is recognized.
25
+ */
26
+ resolveFromMessage(message: Message): UserProfile | null {
27
+ return this.resolveFromChannel(message.channel);
28
+ }
29
+
30
+ /**
31
+ * Resolve a user from a channel identifier.
32
+ */
33
+ resolveFromChannel(channel: UserChannel): UserProfile | null {
34
+ const key = `${channel.type}:${channel.identifier}`;
35
+ const userId = this.channelIndex.get(key);
36
+ if (!userId) return null;
37
+ return this.users.get(userId) || null;
38
+ }
39
+
40
+ /**
41
+ * Get a user by ID.
42
+ */
43
+ getUser(userId: string): UserProfile | null {
44
+ return this.users.get(userId) || null;
45
+ }
46
+
47
+ /**
48
+ * List all registered users.
49
+ */
50
+ listUsers(): UserProfile[] {
51
+ return Array.from(this.users.values());
52
+ }
53
+
54
+ /**
55
+ * Add or update a user.
56
+ */
57
+ upsertUser(profile: UserProfile): void {
58
+ // Remove old channel index entries
59
+ const existing = this.users.get(profile.id);
60
+ if (existing) {
61
+ for (const channel of existing.channels) {
62
+ this.channelIndex.delete(`${channel.type}:${channel.identifier}`);
63
+ }
64
+ }
65
+
66
+ // Add new entries
67
+ this.users.set(profile.id, profile);
68
+ for (const channel of profile.channels) {
69
+ this.channelIndex.set(`${channel.type}:${channel.identifier}`, profile.id);
70
+ }
71
+
72
+ this.persistUsers();
73
+ }
74
+
75
+ /**
76
+ * Remove a user.
77
+ */
78
+ removeUser(userId: string): boolean {
79
+ const user = this.users.get(userId);
80
+ if (!user) return false;
81
+
82
+ for (const channel of user.channels) {
83
+ this.channelIndex.delete(`${channel.type}:${channel.identifier}`);
84
+ }
85
+ this.users.delete(userId);
86
+ this.persistUsers();
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Check if a user has a specific permission.
92
+ */
93
+ hasPermission(userId: string, permission: string): boolean {
94
+ const user = this.users.get(userId);
95
+ if (!user) return false;
96
+ return user.permissions.includes(permission) || user.permissions.includes('admin');
97
+ }
98
+
99
+ private loadUsers(initialUsers?: UserProfile[]): void {
100
+ // Load from file if exists
101
+ if (fs.existsSync(this.usersFile)) {
102
+ const data: UserProfile[] = JSON.parse(fs.readFileSync(this.usersFile, 'utf-8'));
103
+ for (const user of data) {
104
+ this.users.set(user.id, user);
105
+ for (const channel of user.channels) {
106
+ this.channelIndex.set(`${channel.type}:${channel.identifier}`, user.id);
107
+ }
108
+ }
109
+ }
110
+
111
+ // Merge initial users (config takes precedence for initial setup)
112
+ if (initialUsers) {
113
+ for (const user of initialUsers) {
114
+ if (!this.users.has(user.id)) {
115
+ this.upsertUser(user);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ private persistUsers(): void {
122
+ const dir = path.dirname(this.usersFile);
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ fs.writeFileSync(
125
+ this.usersFile,
126
+ JSON.stringify(Array.from(this.users.values()), null, 2)
127
+ );
128
+ }
129
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * E2E test — full instar lifecycle from init to running server.
3
+ *
4
+ * Tests the complete user journey:
5
+ * instar init → configure → server start → health check →
6
+ * trigger job → relationship tracking → auth enforcement → shutdown
7
+ *
8
+ * Uses real filesystem, real HTTP server (ephemeral port), real scheduler.
9
+ * Mocks tmux sessions via mock-claude script.
10
+ */
11
+
12
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import request from 'supertest';
17
+ import { AgentServer } from '../../src/server/AgentServer.js';
18
+ import { SessionManager } from '../../src/core/SessionManager.js';
19
+ import { StateManager } from '../../src/core/StateManager.js';
20
+ import { RelationshipManager } from '../../src/core/RelationshipManager.js';
21
+ import { JobScheduler } from '../../src/scheduler/JobScheduler.js';
22
+ import { loadConfig, ensureStateDir, detectTmuxPath } from '../../src/core/Config.js';
23
+ import { createMockClaude, createSampleJobsFile, waitFor } from '../helpers/setup.js';
24
+ import type { AgentKitConfig, JobDefinition } from '../../src/core/types.js';
25
+
26
+ const tmuxPath = detectTmuxPath();
27
+ const describeMaybe = tmuxPath ? describe : describe.skip;
28
+
29
+ describeMaybe('E2E: Instar lifecycle', () => {
30
+ let projectDir: string;
31
+ let stateDir: string;
32
+ let mockClaudePath: string;
33
+ let state: StateManager;
34
+ let sessionManager: SessionManager;
35
+ let relationships: RelationshipManager;
36
+ let scheduler: JobScheduler;
37
+ let server: AgentServer;
38
+ let app: ReturnType<AgentServer['getApp']>;
39
+ const AUTH_TOKEN = 'e2e-test-token';
40
+
41
+ beforeAll(async () => {
42
+ // ── Phase 1: Simulate `instar init` ─────────────────
43
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-e2e-'));
44
+ stateDir = path.join(projectDir, '.instar');
45
+
46
+ // Create directory structure (what init creates)
47
+ const dirs = [
48
+ path.join(stateDir, 'state', 'sessions'),
49
+ path.join(stateDir, 'state', 'jobs'),
50
+ path.join(stateDir, 'logs'),
51
+ path.join(stateDir, 'relationships'),
52
+ path.join(stateDir, 'hooks'),
53
+ ];
54
+ for (const dir of dirs) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+
58
+ // Write config.json (what init generates)
59
+ const configJson = {
60
+ projectName: 'e2e-test-project',
61
+ port: 0, // Will use ephemeral port
62
+ authToken: AUTH_TOKEN,
63
+ sessions: {
64
+ maxSessions: 3,
65
+ },
66
+ scheduler: {
67
+ enabled: true,
68
+ maxParallelJobs: 2,
69
+ },
70
+ relationships: {
71
+ maxRecentInteractions: 20,
72
+ },
73
+ };
74
+ fs.writeFileSync(
75
+ path.join(stateDir, 'config.json'),
76
+ JSON.stringify(configJson, null, 2),
77
+ );
78
+
79
+ // Write jobs.json with a fast-firing job
80
+ const jobs: JobDefinition[] = [
81
+ {
82
+ slug: 'e2e-health',
83
+ name: 'E2E Health Check',
84
+ description: 'Fast health check for E2E testing',
85
+ schedule: '* * * * * *', // Every second
86
+ priority: 'high',
87
+ expectedDurationMinutes: 1,
88
+ model: 'haiku',
89
+ enabled: true,
90
+ execute: { type: 'prompt', value: 'Quick health check' },
91
+ tags: ['monitoring'],
92
+ },
93
+ ];
94
+ const jobsFile = path.join(stateDir, 'jobs.json');
95
+ fs.writeFileSync(jobsFile, JSON.stringify(jobs, null, 2));
96
+
97
+ // Create mock claude
98
+ mockClaudePath = createMockClaude(projectDir);
99
+
100
+ // ── Phase 2: Simulate `instar server start` ─────────
101
+ state = new StateManager(stateDir);
102
+
103
+ sessionManager = new SessionManager(
104
+ {
105
+ tmuxPath: tmuxPath!,
106
+ claudePath: mockClaudePath,
107
+ projectDir,
108
+ maxSessions: 3,
109
+ protectedSessions: [],
110
+ completionPatterns: ['Session ended'],
111
+ },
112
+ state,
113
+ );
114
+
115
+ relationships = new RelationshipManager({
116
+ relationshipsDir: path.join(stateDir, 'relationships'),
117
+ maxRecentInteractions: 20,
118
+ });
119
+
120
+ scheduler = new JobScheduler(
121
+ {
122
+ jobsFile,
123
+ enabled: true,
124
+ maxParallelJobs: 2,
125
+ quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
126
+ },
127
+ sessionManager,
128
+ state,
129
+ );
130
+ scheduler.start();
131
+
132
+ sessionManager.startMonitoring(500);
133
+ sessionManager.on('sessionComplete', () => {
134
+ scheduler.processQueue();
135
+ });
136
+
137
+ const config: AgentKitConfig = {
138
+ projectName: 'e2e-test-project',
139
+ projectDir,
140
+ stateDir,
141
+ port: 0,
142
+ authToken: AUTH_TOKEN,
143
+ sessions: {
144
+ tmuxPath: tmuxPath!,
145
+ claudePath: mockClaudePath,
146
+ projectDir,
147
+ maxSessions: 3,
148
+ protectedSessions: [],
149
+ completionPatterns: ['Session ended'],
150
+ },
151
+ scheduler: {
152
+ jobsFile,
153
+ enabled: true,
154
+ maxParallelJobs: 2,
155
+ quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
156
+ },
157
+ users: [],
158
+ messaging: [],
159
+ monitoring: {
160
+ quotaTracking: false,
161
+ memoryMonitoring: false,
162
+ healthCheckIntervalMs: 30000,
163
+ },
164
+ relationships: {
165
+ relationshipsDir: path.join(stateDir, 'relationships'),
166
+ maxRecentInteractions: 20,
167
+ },
168
+ };
169
+
170
+ server = new AgentServer({
171
+ config,
172
+ sessionManager,
173
+ state,
174
+ scheduler,
175
+ relationships,
176
+ });
177
+ app = server.getApp();
178
+ });
179
+
180
+ afterAll(async () => {
181
+ scheduler?.stop();
182
+ sessionManager?.stopMonitoring();
183
+
184
+ // Kill any test tmux sessions
185
+ try {
186
+ const { execSync } = await import('node:child_process');
187
+ const sessions = execSync(`${tmuxPath} list-sessions -F "#{session_name}" 2>/dev/null || true`, {
188
+ encoding: 'utf-8',
189
+ }).trim();
190
+ for (const session of sessions.split('\n').filter(Boolean)) {
191
+ if (session.includes('e2e-') || session.includes('job-e2e-')) {
192
+ try { execSync(`${tmuxPath} kill-session -t '=${session}'`); } catch {}
193
+ }
194
+ }
195
+ } catch {}
196
+
197
+ await server?.stop();
198
+ fs.rmSync(projectDir, { recursive: true, force: true });
199
+ });
200
+
201
+ // ── Phase 3: Verify server health ────────────────────────
202
+
203
+ it('health endpoint returns project info', async () => {
204
+ const res = await request(app).get('/health');
205
+ expect(res.status).toBe(200);
206
+ expect(res.body.status).toBe('ok');
207
+ expect(res.body.project).toBe('e2e-test-project');
208
+ });
209
+
210
+ // ── Phase 4: Auth enforcement ────────────────────────────
211
+
212
+ it('rejects unauthenticated requests', async () => {
213
+ const res = await request(app).get('/status');
214
+ expect(res.status).toBe(401);
215
+ });
216
+
217
+ it('accepts authenticated requests', async () => {
218
+ const res = await request(app)
219
+ .get('/status')
220
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`);
221
+ expect(res.status).toBe(200);
222
+ expect(res.body.sessions.max).toBe(3);
223
+ expect(res.body.scheduler.running).toBe(true);
224
+ });
225
+
226
+ // ── Phase 5: Session spawn via API ───────────────────────
227
+
228
+ it('spawns a session via API', async () => {
229
+ const res = await request(app)
230
+ .post('/sessions/spawn')
231
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`)
232
+ .send({ name: 'e2e-test-session', prompt: 'Hello from E2E test' });
233
+
234
+ expect(res.status).toBe(201);
235
+ expect(res.body.tmuxSession).toContain('e2e-test-session');
236
+ expect(res.body.status).toBe('running');
237
+
238
+ // Verify session appears in list
239
+ await new Promise(r => setTimeout(r, 500));
240
+ const statusRes = await request(app)
241
+ .get('/status')
242
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`);
243
+ expect(statusRes.body.sessions.running).toBeGreaterThanOrEqual(1);
244
+ });
245
+
246
+ // ── Phase 6: Job trigger via API ─────────────────────────
247
+
248
+ it('triggers a job via API and verifies event log', async () => {
249
+ const triggerRes = await request(app)
250
+ .post('/jobs/e2e-health/trigger')
251
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`)
252
+ .send({ reason: 'e2e-test' });
253
+
254
+ expect(triggerRes.status).toBe(200);
255
+ expect(triggerRes.body.result).toBe('triggered');
256
+
257
+ // Wait for spawn to complete
258
+ await new Promise(r => setTimeout(r, 200));
259
+
260
+ // Verify event was logged
261
+ const eventsRes = await request(app)
262
+ .get('/events?since=1')
263
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`);
264
+ expect(eventsRes.status).toBe(200);
265
+ const jobEvents = eventsRes.body.filter((e: any) => e.type === 'job_triggered');
266
+ expect(jobEvents.length).toBeGreaterThanOrEqual(1);
267
+ });
268
+
269
+ // ── Phase 7: Cron-triggered job ──────────────────────────
270
+
271
+ it('cron triggers jobs automatically', async () => {
272
+ // The e2e-health job fires every second — wait for it
273
+ await waitFor(
274
+ () => {
275
+ const events = state.queryEvents({ type: 'job_triggered' });
276
+ return events.filter(e => e.summary?.includes('scheduled')).length > 0;
277
+ },
278
+ 5000,
279
+ );
280
+
281
+ const events = state.queryEvents({ type: 'job_triggered' });
282
+ const scheduledEvents = events.filter(e => e.summary?.includes('scheduled'));
283
+ expect(scheduledEvents.length).toBeGreaterThan(0);
284
+ });
285
+
286
+ // ── Phase 8: Relationship tracking ───────────────────────
287
+
288
+ it('creates and retrieves relationships through the full stack', async () => {
289
+ // Create a relationship programmatically
290
+ const record = relationships.findOrCreate('E2E User', {
291
+ type: 'telegram',
292
+ identifier: 'e2e-999',
293
+ });
294
+ relationships.recordInteraction(record.id, {
295
+ timestamp: new Date().toISOString(),
296
+ channel: 'telegram',
297
+ summary: 'E2E lifecycle test interaction',
298
+ topics: ['e2e', 'testing'],
299
+ });
300
+
301
+ // Verify via API
302
+ const listRes = await request(app)
303
+ .get('/relationships')
304
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`);
305
+ expect(listRes.status).toBe(200);
306
+ expect(listRes.body.relationships.some((r: any) => r.name === 'E2E User')).toBe(true);
307
+
308
+ // Verify context generation
309
+ const ctxRes = await request(app)
310
+ .get(`/relationships/${record.id}/context`)
311
+ .set('Authorization', `Bearer ${AUTH_TOKEN}`);
312
+ expect(ctxRes.status).toBe(200);
313
+ expect(ctxRes.body.context).toContain('<relationship_context person="E2E User">');
314
+ expect(ctxRes.body.context).toContain('e2e');
315
+ expect(ctxRes.body.context).toContain('E2E lifecycle test interaction');
316
+
317
+ // Verify disk persistence
318
+ const filePath = path.join(stateDir, 'relationships', `${record.id}.json`);
319
+ expect(fs.existsSync(filePath)).toBe(true);
320
+ const onDisk = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
321
+ expect(onDisk.name).toBe('E2E User');
322
+ expect(onDisk.interactionCount).toBe(1);
323
+ });
324
+
325
+ // ── Phase 9: Session lifecycle (spawn → complete) ────────
326
+
327
+ it('detects session completion and updates state', async () => {
328
+ // Pause the scheduler so cron jobs don't fill session slots
329
+ scheduler.pause();
330
+
331
+ // Wait for existing job sessions to complete (mock claude exits after ~2s)
332
+ await waitFor(
333
+ () => sessionManager.listRunningSessions().length < 3,
334
+ 8000,
335
+ );
336
+
337
+ const session = await sessionManager.spawnSession({
338
+ name: 'e2e-lifecycle-complete',
339
+ prompt: 'echo done',
340
+ });
341
+
342
+ // Mock claude sleeps 2s then exits — wait for completion
343
+ await waitFor(
344
+ () => {
345
+ const saved = state.getSession(session.id);
346
+ return saved?.status === 'completed';
347
+ },
348
+ 8000,
349
+ );
350
+
351
+ const saved = state.getSession(session.id);
352
+ expect(saved?.status).toBe('completed');
353
+ expect(saved?.endedAt).toBeTruthy();
354
+ });
355
+
356
+ // ── Phase 10: Verify state persistence ───────────────────
357
+
358
+ it('all state survives to disk', () => {
359
+ // Sessions persisted
360
+ const sessionFiles = fs.readdirSync(path.join(stateDir, 'state', 'sessions'));
361
+ expect(sessionFiles.length).toBeGreaterThan(0);
362
+
363
+ // Job state persisted
364
+ const jobState = state.getJobState('e2e-health');
365
+ expect(jobState).not.toBeNull();
366
+ expect(jobState!.lastRun).toBeTruthy();
367
+
368
+ // Relationships persisted
369
+ const relFiles = fs.readdirSync(path.join(stateDir, 'relationships'));
370
+ expect(relFiles.length).toBeGreaterThan(0);
371
+
372
+ // Events persisted
373
+ const events = state.queryEvents({});
374
+ expect(events.length).toBeGreaterThan(0);
375
+ });
376
+ });
@@ -0,0 +1,3 @@
1
+ # Test Project
2
+
3
+ A test project for instar development.
@@ -0,0 +1 @@
1
+ # Test Repo