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,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 @@
|
|
|
1
|
+
# Test Repo
|