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

apowers313 / aiforge / 22173138776

19 Feb 2026 07:50AM UTC coverage: 81.939% (+0.9%) from 81.026%
22173138776

push

github

apowers313
fix: status indicators when shell is deselected, new shell death bugs

2162 of 2510 branches covered (86.14%)

Branch coverage included in aggregate %.

50 of 175 new or added lines in 10 files covered. (28.57%)

214 existing lines in 10 files now uncovered.

10269 of 12661 relevant lines covered (81.11%)

27.36 hits per line

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

58.97
/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
  constructor(options: ShellServiceOptions) {
30✔
24
    this.shellStore = options.shellStore;
30✔
25
    this.projectStore = options.projectStore;
30✔
26
    this.ptyPool = options.ptyPool ?? null;
30✔
27

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

UNCOV
38
      this.ptyPool.on('session:input', (shellId: string) => {
×
39
        this._handleSessionActivity(shellId).catch((err: unknown) => {
×
40
          console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
41
        });
×
UNCOV
42
      });
×
43

UNCOV
44
      this.ptyPool.on('session:output', (shellId: string) => {
×
UNCOV
45
        this._handleSessionActivity(shellId).catch((err: unknown) => {
×
UNCOV
46
          console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
47
        });
×
48
      });
×
49
    }
×
50
  }
30✔
51

52
  /**
53
   * Handle PTY session activity (update lastActivityAt for AI shells)
54
   */
55
  private async _handleSessionActivity(shellId: string): Promise<void> {
30✔
56
    // Check if shell is an AI shell (only track lastActivityAt for AI shells)
57
    const shell = await this.shellStore.getById(shellId);
×
58
    if (shell?.type !== 'ai') {
×
59
      return;
×
UNCOV
60
    }
×
61

62
    // Debounce the update to reduce disk writes
63
    const existingTimer = this.lastActivityTimers.get(shellId);
×
UNCOV
64
    if (existingTimer) {
×
65
      clearTimeout(existingTimer);
×
66
    }
×
67

UNCOV
68
    const timer = setTimeout(() => {
×
UNCOV
69
      this.lastActivityTimers.delete(shellId);
×
UNCOV
70
      void this.shellStore.update(shellId, {
×
UNCOV
71
        lastActivityAt: new Date().toISOString(),
×
UNCOV
72
      });
×
UNCOV
73
    }, this.lastActivityDebounceMs);
×
74

75
    this.lastActivityTimers.set(shellId, timer);
×
76
  }
×
77

78
  /**
79
   * Handle PTY session exit
80
   */
81
  private async _handleSessionExit(shellId: string, _exitCode: number): Promise<void> {
30✔
82
    await this.shellStore.update(shellId, {
×
83
      status: 'inactive',
×
UNCOV
84
      pid: null,
×
85
    });
×
86
  }
×
87

88
  /**
89
   * Get all shells for a project
90
   */
91
  async getByProjectId(projectId: string): Promise<Shell[]> {
30✔
92
    return this.shellStore.getByProjectId(projectId);
3✔
93
  }
3✔
94

95
  /**
96
   * Get a shell by ID
97
   */
98
  async getById(id: string): Promise<Shell | null> {
30✔
99
    return this.shellStore.getById(id);
3✔
100
  }
3✔
101

102
  /**
103
   * Create a new shell for a project
104
   * @param projectId - The project ID
105
   * @param name - Optional name for the shell
106
   * @param type - Shell type (bash or ai)
107
   * @param worktreePath - Optional worktree path to associate with the shell (Phase 6)
108
   */
109
  async create(projectId: string, name?: string, type: ShellType = 'bash', worktreePath?: string): Promise<Shell> {
30✔
110
    // Verify project exists
111
    const project = await this.projectStore.getById(projectId);
36✔
112
    if (!project) {
36✔
113
      throw new Error('Project not found');
2✔
114
    }
2✔
115

116
    // Auto-generate name if not provided
117
    let shellName = name;
34✔
118
    if (!shellName) {
36✔
119
      const shellType = type === 'ai' ? 'ai' : 'bash';
12✔
120
      const shellNumber = await this.shellStore.getNextShellNumber(shellType);
12✔
121
      shellName = `${shellType}-${String(shellNumber)}`;
12✔
122
    }
12✔
123

124
    // Phase 6: Use worktreePath as cwd if provided, otherwise use project path
125
    const cwd = worktreePath ?? project.path;
35✔
126

127
    const now = new Date().toISOString();
36✔
128
    const shell: Shell = {
36✔
129
      id: randomUUID(),
36✔
130
      projectId,
36✔
131
      name: shellName,
36✔
132
      cwd,
36✔
133
      status: 'inactive',
36✔
134
      type,
36✔
135
      pid: null,
36✔
136
      socketPath: null,
36✔
137
      lastActivityAt: null,
36✔
138
      done: false,
36✔
139
      worktreePath: worktreePath ?? null,
36✔
140
      createdAt: now,
36✔
141
      updatedAt: now,
36✔
142
    };
36✔
143

144
    await this.shellStore.create(shell);
36✔
145
    return shell;
34✔
146
  }
36✔
147

148
  /**
149
   * Get all shells for a worktree
150
   * Phase 6: Returns shells associated with a specific worktree path
151
   */
152
  async getByWorktreePath(worktreePath: string): Promise<Shell[]> {
30✔
153
    return this.shellStore.getByWorktreePath(worktreePath);
4✔
154
  }
4✔
155

156
  /**
157
   * Update a shell's properties
158
   */
159
  async update(id: string, updates: Partial<Pick<Shell, 'name' | 'status' | 'pid' | 'cwd' | 'lastActivityAt' | 'done' | 'socketPath'>>): Promise<Shell | null> {
30✔
160
    return this.shellStore.update(id, updates);
4✔
161
  }
4✔
162

163
  /**
164
   * Delete a shell
165
   */
166
  async delete(id: string): Promise<boolean> {
30✔
167
    // Kill the PTY session if running
168
    if (this.ptyPool) {
5!
UNCOV
169
      this.ptyPool.kill(id);
×
UNCOV
170
    }
×
171
    return this.shellStore.delete(id);
5✔
172
  }
5✔
173

174
  /**
175
   * Stop a shell (kill PTY process)
176
   */
177
  async stop(shellId: string): Promise<Shell | null> {
30✔
178
    if (!this.ptyPool) {
1✔
179
      throw new Error('PTY pool not configured');
1✔
180
    }
1!
181

UNCOV
182
    this.ptyPool.kill(shellId);
×
183

UNCOV
184
    return this.shellStore.update(shellId, {
×
UNCOV
185
      status: 'inactive',
×
186
      pid: null,
×
187
    });
×
188
  }
1✔
189

190
  /**
191
   * Get the PTY pool (for WebSocket handler access)
192
   */
193
  getPtyPool(): PtyPool | null {
30✔
UNCOV
194
    return this.ptyPool;
×
UNCOV
195
  }
×
196

197
  /**
198
   * Clean up orphaned sessions
199
   */
200
  async cleanupOrphans(): Promise<void> {
30✔
201
    if (this.ptyPool) {
×
202
      await this.ptyPool.cleanupOrphans(this.shellStore as unknown as ShellStoreInterface);
×
203
    }
×
204
  }
×
205

206
  /**
207
   * Reconnect to all persistent daemon sessions
208
   * Call this on server startup to restore sessions that survived restart
209
   */
210
  async reconnectDaemons(): Promise<number> {
30✔
211
    if (!this.ptyPool) {
×
212
      return 0;
×
UNCOV
213
    }
×
UNCOV
214
    return this.ptyPool.reconnectDaemons(this.shellStore as unknown as ShellStoreInterface);
×
UNCOV
215
  }
×
216

217
  /**
218
   * Check if persistent daemon mode is enabled
219
   */
220
  get usePersistentDaemons(): boolean {
30✔
221
    return this.ptyPool?.usePersistentDaemons ?? false;
×
UNCOV
222
  }
×
223

224
  /**
225
   * Shutdown all PTY sessions
226
   */
227
  shutdown(): void {
30✔
228
    if (this.ptyPool) {
×
229
      this.ptyPool.shutdown();
×
230
    }
×
231
  }
×
232
}
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