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

apowers313 / aiforge / 21154573356

19 Jan 2026 11:47PM UTC coverage: 83.954% (-0.1%) from 84.063%
21154573356

push

github

apowers313
ci: fix tests failing in ci

1619 of 1892 branches covered (85.57%)

Branch coverage included in aggregate %.

16 of 41 new or added lines in 5 files covered. (39.02%)

3 existing lines in 1 file now uncovered.

8364 of 9999 relevant lines covered (83.65%)

20.56 hits per line

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

54.3
/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;
12✔
18
  private readonly projectStore: ProjectStore;
12✔
19
  private readonly ptyPool: PtyPool | null;
12✔
20
  private readonly lastActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
12✔
21
  private readonly lastActivityDebounceMs = 1000; // Debounce lastActivityAt updates by 1 second
12✔
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>();
12✔
26
  private readonly inputActivityWindowMs = 30000; // 30 seconds - output after input counts as activity
12✔
27

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

33
    // Set up event handlers if ptyPool is provided
34
    if (this.ptyPool) {
12!
35
      this.ptyPool.on('session:exited', (shellId: string, exitCode: number) => {
×
NEW
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
NEW
39
          console.error(`[ShellService] Failed to handle session exit for ${shellId}:`, err);
×
NEW
40
        });
×
UNCOV
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());
×
NEW
49
        this._handleSessionActivity(shellId).catch((err: unknown) => {
×
50
          // Log but don't crash on storage errors
NEW
51
          console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
NEW
52
        });
×
UNCOV
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) {
×
NEW
59
          this._handleSessionActivity(shellId).catch((err: unknown) => {
×
60
            // Log but don't crash on storage errors
NEW
61
            console.error(`[ShellService] Failed to handle session activity for ${shellId}:`, err);
×
NEW
62
          });
×
UNCOV
63
        }
×
64
        // Otherwise, output is ignored (likely prompt refresh or other housekeeping)
65
      });
×
66
    }
×
67
  }
12✔
68

69
  /**
70
   * Handle PTY session activity (update lastActivityAt for AI shells)
71
   */
72
  private async _handleSessionActivity(shellId: string): Promise<void> {
12✔
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> {
12✔
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[]> {
12✔
109
    return this.shellStore.getByProjectId(projectId);
1✔
110
  }
1✔
111

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

119
  /**
120
   * Create a new shell for a project
121
   */
122
  async create(projectId: string, name?: string, type: ShellType = 'bash'): Promise<Shell> {
12✔
123
    // Verify project exists
124
    const project = await this.projectStore.getById(projectId);
30✔
125
    if (!project) {
30✔
126
      throw new Error('Project not found');
1✔
127
    }
1✔
128

129
    // Auto-generate name if not provided
130
    let shellName = name;
29✔
131
    if (!shellName) {
30✔
132
      const shellNumber = await this.shellStore.getNextShellNumber();
1✔
133
      const prefix = type === 'ai' ? 'ai' : 'shell';
1!
134
      shellName = `${prefix}-${String(shellNumber)}`;
1✔
135
    }
1✔
136

137
    const now = new Date().toISOString();
29✔
138
    const shell: Shell = {
29✔
139
      id: randomUUID(),
29✔
140
      projectId,
29✔
141
      name: shellName,
29✔
142
      cwd: project.path,
29✔
143
      status: 'inactive',
29✔
144
      type,
29✔
145
      pid: null,
29✔
146
      socketPath: null,
29✔
147
      lastActivityAt: null,
29✔
148
      done: false,
29✔
149
      createdAt: now,
29✔
150
      updatedAt: now,
29✔
151
    };
29✔
152

153
    await this.shellStore.create(shell);
29✔
154
    return shell;
29✔
155
  }
30✔
156

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

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

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

183
    this.ptyPool.kill(shellId);
×
184

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

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

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

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

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

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