• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / aiforge / 21570337701

01 Feb 2026 09:11PM UTC coverage: 81.026% (-2.9%) from 83.954%
21570337701

push

github

apowers313
test: increase coverage to 80%+

2049 of 2382 branches covered (86.02%)

Branch coverage included in aggregate %.

1849 of 2529 new or added lines in 25 files covered. (73.11%)

681 existing lines in 21 files now uncovered.

9861 of 12317 relevant lines covered (80.06%)

26.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

58.02
/src/server/services/shell/ShellService.ts
1
/**
2
 * ShellService - Shell business logic
3
 */
4
import { randomUUID } from 'node:crypto';
1✔
5
import type { Shell, ShellType } from '@shared/types/index.js';
6
import type { ShellStore } from '../../storage/stores/ShellStore.js';
7
import type { ProjectStore } from '../../storage/stores/ProjectStore.js';
8
import { PtyPool, type ShellStoreInterface } from '../pty/index.js';
9

10
export interface ShellServiceOptions {
11
  shellStore: ShellStore;
12
  projectStore: ProjectStore;
13
  ptyPool?: PtyPool;
14
}
15

16
export class ShellService {
1✔
17
  private readonly shellStore: ShellStore;
30✔
18
  private readonly projectStore: ProjectStore;
30✔
19
  private readonly ptyPool: PtyPool | null;
30✔
20
  private readonly lastActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
30✔
21
  private readonly lastActivityDebounceMs = 1000; // Debounce lastActivityAt updates by 1 second
30✔
22

23
  // Track last input time per shell for input-aware activity tracking
24
  // Output only counts as activity if there was recent input (within this window)
25
  private readonly lastInputTimes = new Map<string, number>();
30✔
26
  private readonly inputActivityWindowMs = 30000; // 30 seconds - output after input counts as activity
30✔
27

28
  constructor(options: ShellServiceOptions) {
30✔
29
    this.shellStore = options.shellStore;
30✔
30
    this.projectStore = options.projectStore;
30✔
31
    this.ptyPool = options.ptyPool ?? null;
30✔
32

33
    // Set up event handlers if ptyPool is provided
34
    if (this.ptyPool) {
30!
35
      this.ptyPool.on('session:exited', (shellId: string, exitCode: number) => {
×
36
        this._handleSessionExit(shellId, exitCode).catch((err: unknown) => {
×
37
          // Log but don't crash on lock contention or other storage errors
38
          // This can happen during shell deletion when concurrent updates race
39
          console.error(`[ShellService] Failed to handle session exit for ${shellId}:`, err);
×
40
        });
×
41
      });
×
42

43
      // Track lastActivityAt for AI shells with input-aware logic:
44
      // - Input always counts as activity
45
      // - Output only counts if there was recent input (within 30s)
46
      // This prevents prompt refresh after reconnection from counting as activity
47
      this.ptyPool.on('session:input', (shellId: string) => {
×
48
        this.lastInputTimes.set(shellId, Date.now());
×
49
        this._handleSessionActivity(shellId).catch((err: unknown) => {
×
50
          // Log but don't crash on storage errors
51
          console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
52
        });
×
53
      });
×
54

55
      this.ptyPool.on('session:output', (shellId: string) => {
×
56
        // Only count output as activity if there was recent input
57
        const lastInputTime = this.lastInputTimes.get(shellId);
×
58
        if (lastInputTime && Date.now() - lastInputTime < this.inputActivityWindowMs) {
×
59
          this._handleSessionActivity(shellId).catch((err: unknown) => {
×
60
            // Log but don't crash on storage errors
61
            console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
62
          });
×
63
        }
×
64
        // Otherwise, output is ignored (likely prompt refresh or other housekeeping)
65
      });
×
66
    }
×
67
  }
30✔
68

69
  /**
70
   * Handle PTY session activity (update lastActivityAt for AI shells)
71
   */
72
  private async _handleSessionActivity(shellId: string): Promise<void> {
30✔
73
    // Check if shell is an AI shell (only track lastActivityAt for AI shells)
74
    const shell = await this.shellStore.getById(shellId);
×
75
    if (shell?.type !== 'ai') {
×
76
      return;
×
77
    }
×
78

79
    // Debounce the update to reduce disk writes
80
    const existingTimer = this.lastActivityTimers.get(shellId);
×
81
    if (existingTimer) {
×
82
      clearTimeout(existingTimer);
×
83
    }
×
84

85
    const timer = setTimeout(() => {
×
86
      this.lastActivityTimers.delete(shellId);
×
87
      void this.shellStore.update(shellId, {
×
88
        lastActivityAt: new Date().toISOString(),
×
89
      });
×
90
    }, this.lastActivityDebounceMs);
×
91

92
    this.lastActivityTimers.set(shellId, timer);
×
93
  }
×
94

95
  /**
96
   * Handle PTY session exit
97
   */
98
  private async _handleSessionExit(shellId: string, _exitCode: number): Promise<void> {
30✔
99
    await this.shellStore.update(shellId, {
×
100
      status: 'inactive',
×
101
      pid: null,
×
102
    });
×
103
  }
×
104

105
  /**
106
   * Get all shells for a project
107
   */
108
  async getByProjectId(projectId: string): Promise<Shell[]> {
30✔
109
    return this.shellStore.getByProjectId(projectId);
3✔
110
  }
3✔
111

112
  /**
113
   * Get a shell by ID
114
   */
115
  async getById(id: string): Promise<Shell | null> {
30✔
116
    return this.shellStore.getById(id);
3✔
117
  }
3✔
118

119
  /**
120
   * Create a new shell for a project
121
   * @param projectId - The project ID
122
   * @param name - Optional name for the shell
123
   * @param type - Shell type (bash or ai)
124
   * @param worktreePath - Optional worktree path to associate with the shell (Phase 6)
125
   */
126
  async create(projectId: string, name?: string, type: ShellType = 'bash', worktreePath?: string): Promise<Shell> {
30✔
127
    // Verify project exists
128
    const project = await this.projectStore.getById(projectId);
36✔
129
    if (!project) {
36✔
130
      throw new Error('Project not found');
2✔
131
    }
2✔
132

133
    // Auto-generate name if not provided
134
    let shellName = name;
34✔
135
    if (!shellName) {
36✔
136
      const shellType = type === 'ai' ? 'ai' : 'bash';
12✔
137
      const shellNumber = await this.shellStore.getNextShellNumber(shellType);
12✔
138
      shellName = `${shellType}-${String(shellNumber)}`;
12✔
139
    }
12✔
140

141
    // Phase 6: Use worktreePath as cwd if provided, otherwise use project path
142
    const cwd = worktreePath ?? project.path;
35✔
143

144
    const now = new Date().toISOString();
36✔
145
    const shell: Shell = {
36✔
146
      id: randomUUID(),
36✔
147
      projectId,
36✔
148
      name: shellName,
36✔
149
      cwd,
36✔
150
      status: 'inactive',
36✔
151
      type,
36✔
152
      pid: null,
36✔
153
      socketPath: null,
36✔
154
      lastActivityAt: null,
36✔
155
      done: false,
36✔
156
      worktreePath: worktreePath ?? null,
36✔
157
      createdAt: now,
36✔
158
      updatedAt: now,
36✔
159
    };
36✔
160

161
    await this.shellStore.create(shell);
36✔
162
    return shell;
34✔
163
  }
36✔
164

165
  /**
166
   * Get all shells for a worktree
167
   * Phase 6: Returns shells associated with a specific worktree path
168
   */
169
  async getByWorktreePath(worktreePath: string): Promise<Shell[]> {
30✔
170
    return this.shellStore.getByWorktreePath(worktreePath);
4✔
171
  }
4✔
172

173
  /**
174
   * Update a shell's properties
175
   */
176
  async update(id: string, updates: Partial<Pick<Shell, 'name' | 'status' | 'pid' | 'cwd' | 'lastActivityAt' | 'done' | 'socketPath'>>): Promise<Shell | null> {
30✔
177
    return this.shellStore.update(id, updates);
4✔
178
  }
4✔
179

180
  /**
181
   * Delete a shell
182
   */
183
  async delete(id: string): Promise<boolean> {
30✔
184
    // Kill the PTY session if running
185
    if (this.ptyPool) {
5!
186
      this.ptyPool.kill(id);
×
187
    }
×
188
    return this.shellStore.delete(id);
5✔
189
  }
5✔
190

191
  /**
192
   * Stop a shell (kill PTY process)
193
   */
194
  async stop(shellId: string): Promise<Shell | null> {
30✔
195
    if (!this.ptyPool) {
1✔
196
      throw new Error('PTY pool not configured');
1✔
197
    }
1!
198

UNCOV
199
    this.ptyPool.kill(shellId);
×
200

UNCOV
201
    return this.shellStore.update(shellId, {
×
202
      status: 'inactive',
×
203
      pid: null,
×
204
    });
×
205
  }
1✔
206

207
  /**
208
   * Get the PTY pool (for WebSocket handler access)
209
   */
210
  getPtyPool(): PtyPool | null {
30✔
UNCOV
211
    return this.ptyPool;
×
212
  }
×
213

214
  /**
215
   * Clean up orphaned sessions
216
   */
217
  async cleanupOrphans(): Promise<void> {
30✔
UNCOV
218
    if (this.ptyPool) {
×
UNCOV
219
      await this.ptyPool.cleanupOrphans(this.shellStore as unknown as ShellStoreInterface);
×
UNCOV
220
    }
×
UNCOV
221
  }
×
222

223
  /**
224
   * Reconnect to all persistent daemon sessions
225
   * Call this on server startup to restore sessions that survived restart
226
   */
227
  async reconnectDaemons(): Promise<number> {
30✔
UNCOV
228
    if (!this.ptyPool) {
×
229
      return 0;
×
230
    }
×
231
    return this.ptyPool.reconnectDaemons(this.shellStore as unknown as ShellStoreInterface);
×
232
  }
×
233

234
  /**
235
   * Check if persistent daemon mode is enabled
236
   */
237
  get usePersistentDaemons(): boolean {
30✔
UNCOV
238
    return this.ptyPool?.usePersistentDaemons ?? false;
×
UNCOV
239
  }
×
240

241
  /**
242
   * Shutdown all PTY sessions
243
   */
244
  shutdown(): void {
30✔
UNCOV
245
    if (this.ptyPool) {
×
UNCOV
246
      this.ptyPool.shutdown();
×
UNCOV
247
    }
×
UNCOV
248
  }
×
249
}
30✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc