@bamptee/aia-code 2.0.12 → 2.0.14

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.
@@ -1,8 +1,25 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import Busboy from 'busboy';
4
- import { AIA_DIR } from '../../constants.js';
5
- import { loadStatus, updateStepStatus, resetStep, updateFlowType } from '../../services/status.js';
4
+ import { AIA_DIR, DELETION_FILTER, QUICK_STEPS } from '../../constants.js';
5
+ import { loadStatus, updateStepStatus, resetStep, updateFlowType, updateType, updateApps, softDeleteFeature, restoreFeature, isFeatureDeleted } from '../../services/status.js';
6
+ import { FEATURE_TYPES } from '../../constants.js';
7
+
8
+ /**
9
+ * Check if a feature is completed (all relevant steps are done)
10
+ * @param {Object} status - Feature status object with steps and flow
11
+ * @returns {boolean}
12
+ */
13
+ function isFeatureCompleted(status) {
14
+ const steps = status.steps || {};
15
+ const isQuickFlow = status.flow === 'quick';
16
+ const relevantSteps = isQuickFlow
17
+ ? Object.entries(steps).filter(([k]) => QUICK_STEPS.includes(k))
18
+ : Object.entries(steps);
19
+
20
+ if (relevantSteps.length === 0) return false;
21
+ return relevantSteps.every(([_, s]) => s === 'done');
22
+ }
6
23
  import { createFeature, validateFeatureName } from '../../services/feature.js';
7
24
  import { runStep } from '../../services/runner.js';
8
25
  import { runQuick } from '../../services/quick.js';
@@ -12,6 +29,7 @@ import { callModel } from '../../services/model-call.js';
12
29
  import { loadConfig } from '../../models.js';
13
30
  import { json, error } from '../router.js';
14
31
  import { isWtInstalled, hasWorktree, getFeatureBranch } from '../../services/worktrunk.js';
32
+ import { getSession, isRunning, addSseClient, removeSseClient } from '../../services/agent-sessions.js';
15
33
 
16
34
  const MAX_DESCRIPTION_LENGTH = 50000; // 50KB
17
35
 
@@ -21,24 +39,15 @@ function sseHeaders(res) {
21
39
  'Cache-Control': 'no-cache',
22
40
  'Connection': 'keep-alive',
23
41
  'Access-Control-Allow-Origin': '*',
42
+ 'X-Accel-Buffering': 'no', // Disable nginx buffering if behind proxy
24
43
  });
44
+ res.flushHeaders(); // Force headers to be sent immediately
25
45
  }
26
46
 
27
47
  function sseSend(res, event, data) {
28
48
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
29
- }
30
-
31
- // F6: Extracted validation helpers to avoid code duplication
32
- function validateHistory(rawHistory) {
33
- return Array.isArray(rawHistory)
34
- ? rawHistory
35
- .filter(msg => msg && typeof msg === 'object' && typeof msg.content === 'string')
36
- .map(msg => ({
37
- role: msg.role === 'agent' ? 'agent' : 'user',
38
- content: String(msg.content).slice(0, 50000),
39
- }))
40
- .slice(-20)
41
- : [];
49
+ // Force flush for SSE streaming
50
+ if (res.flush) res.flush();
42
51
  }
43
52
 
44
53
  function validateAttachments(rawAttachments) {
@@ -51,7 +60,10 @@ function validateAttachments(rawAttachments) {
51
60
 
52
61
  export function registerFeatureRoutes(router) {
53
62
  // List all features
54
- router.get('/api/features', async (req, res, { root }) => {
63
+ // Query params:
64
+ // ?filter=active|deleted|all (default: active)
65
+ // ?minimal=true - Return minimal data for faster initial load (name, type, flow, createdAt, deletedAt only)
66
+ router.get('/api/features', async (req, res, { root, query }) => {
55
67
  const featuresDir = path.join(root, AIA_DIR, 'features');
56
68
  if (!(await fs.pathExists(featuresDir))) {
57
69
  return json(res, []);
@@ -59,14 +71,49 @@ export function registerFeatureRoutes(router) {
59
71
  const entries = await fs.readdir(featuresDir, { withFileTypes: true });
60
72
  const features = [];
61
73
  const wtInstalled = isWtInstalled();
74
+
75
+ // Parse filter from query params (default: active, with validation)
76
+ const validFilters = [DELETION_FILTER.ACTIVE, DELETION_FILTER.DELETED, DELETION_FILTER.ALL];
77
+ const filter = validFilters.includes(query?.filter) ? query.filter : DELETION_FILTER.ACTIVE;
78
+
79
+ // Parse minimal flag for lazy loading support
80
+ const minimal = query?.minimal === 'true';
81
+
62
82
  for (const entry of entries) {
63
83
  if (entry.isDirectory()) {
64
84
  try {
65
85
  const status = await loadStatus(entry.name, root);
66
- const hasWt = wtInstalled && hasWorktree(getFeatureBranch(entry.name), root);
67
- features.push({ name: entry.name, ...status, hasWorktree: hasWt });
86
+ const deleted = isFeatureDeleted(status);
87
+
88
+ // Apply deletion filter
89
+ if (filter === DELETION_FILTER.ACTIVE && deleted) continue;
90
+ if (filter === DELETION_FILTER.DELETED && !deleted) continue;
91
+ // DELETION_FILTER.ALL shows everything
92
+
93
+ if (minimal) {
94
+ // Minimal mode: return basic info + essential computed fields for fast initial render
95
+ // Include hasWorktree and isCompleted since they're needed for filtering
96
+ const hasWt = wtInstalled && hasWorktree(getFeatureBranch(entry.name), root);
97
+ const completed = isFeatureCompleted(status);
98
+ features.push({
99
+ name: entry.name,
100
+ type: status.type,
101
+ flow: status.flow,
102
+ createdAt: status.createdAt,
103
+ deletedAt: status.deletedAt,
104
+ isDeleted: deleted,
105
+ hasWorktree: hasWt,
106
+ isCompleted: completed,
107
+ });
108
+ } else {
109
+ // Full mode: return all data including computed states
110
+ const hasWt = wtInstalled && hasWorktree(getFeatureBranch(entry.name), root);
111
+ const running = isRunning(entry.name);
112
+ const completed = isFeatureCompleted(status);
113
+ features.push({ name: entry.name, ...status, hasWorktree: hasWt, isDeleted: deleted, agentRunning: running, isCompleted: completed });
114
+ }
68
115
  } catch {
69
- features.push({ name: entry.name, error: true, hasWorktree: false });
116
+ features.push({ name: entry.name, error: true, hasWorktree: false, isDeleted: false, agentRunning: false });
70
117
  }
71
118
  }
72
119
  }
@@ -85,6 +132,36 @@ export function registerFeatureRoutes(router) {
85
132
  }
86
133
  });
87
134
 
135
+ // Get agent running status for a feature
136
+ router.get('/api/features/:name/agent-status', (req, res, { params }) => {
137
+ const session = getSession(params.name);
138
+ if (!session) {
139
+ return json(res, { running: false });
140
+ }
141
+ json(res, {
142
+ running: true,
143
+ step: session.step,
144
+ startedAt: session.startedAt,
145
+ logs: session.logs,
146
+ });
147
+ });
148
+
149
+ // SSE stream for agent logs (reconnection endpoint)
150
+ router.get('/api/features/:name/agent-stream', (req, res, { params }) => {
151
+ const session = getSession(params.name);
152
+ if (!session) {
153
+ return error(res, 'No active session', 404);
154
+ }
155
+ sseHeaders(res);
156
+ // Replay buffered logs
157
+ for (const log of session.logs) {
158
+ sseSend(res, 'log', { text: log.text, type: log.type });
159
+ }
160
+ // Register for live updates
161
+ addSseClient(params.name, res);
162
+ req.on('close', () => removeSseClient(params.name, res));
163
+ });
164
+
88
165
  // Read a feature file
89
166
  router.get('/api/features/:name/files/:filename', async (req, res, { params, root }) => {
90
167
  const filePath = path.join(root, AIA_DIR, 'features', params.name, params.filename);
@@ -107,9 +184,10 @@ export function registerFeatureRoutes(router) {
107
184
  router.post('/api/features', async (req, res, { root, parseBody }) => {
108
185
  const body = await parseBody();
109
186
  try {
110
- validateFeatureName(body.name);
111
- await createFeature(body.name, root);
112
- json(res, { ok: true, name: body.name }, 201);
187
+ const { name, type = 'feature', apps = [] } = body;
188
+ validateFeatureName(name);
189
+ await createFeature(name, root, { type, apps });
190
+ json(res, { ok: true, name, type, apps }, 201);
113
191
  } catch (err) {
114
192
  error(res, err.message, 400);
115
193
  }
@@ -126,12 +204,10 @@ export function registerFeatureRoutes(router) {
126
204
  };
127
205
 
128
206
  try {
129
- const validatedHistory = validateHistory(body.history || []);
130
207
  const validatedAttachments = validateAttachments(body.attachments || []);
131
208
 
132
209
  const output = await runStep(params.step, params.name, {
133
210
  description: body.description,
134
- history: validatedHistory,
135
211
  attachments: validatedAttachments,
136
212
  model: body.model || undefined,
137
213
  verbose: body.verbose !== undefined ? body.verbose : true,
@@ -170,30 +246,6 @@ export function registerFeatureRoutes(router) {
170
246
  res.end();
171
247
  });
172
248
 
173
- // Quick ticket with SSE streaming (create + run)
174
- router.post('/api/quick', async (req, res, { root, parseBody }) => {
175
- const body = await parseBody();
176
- sseHeaders(res);
177
- sseSend(res, 'status', { status: 'started', mode: 'quick', name: body.name });
178
-
179
- const onData = ({ type, text }) => {
180
- try { sseSend(res, 'log', { type, text }); } catch {}
181
- };
182
-
183
- try {
184
- await runQuick(body.name, {
185
- description: body.description,
186
- apply: body.apply || false,
187
- root,
188
- onData,
189
- });
190
- sseSend(res, 'done', { status: 'completed', name: body.name });
191
- } catch (err) {
192
- sseSend(res, 'error', { message: err.message });
193
- }
194
- res.end();
195
- });
196
-
197
249
  // Iterate a step with SSE streaming (reset + re-run with instructions)
198
250
  router.post('/api/features/:name/iterate/:step', async (req, res, { params, root, parseBody }) => {
199
251
  const body = await parseBody();
@@ -207,12 +259,10 @@ export function registerFeatureRoutes(router) {
207
259
  try { sseSend(res, 'log', { type, text }); } catch {}
208
260
  };
209
261
 
210
- const validatedHistory = validateHistory(body.history || []);
211
262
  const validatedAttachments = validateAttachments(body.attachments || []);
212
263
 
213
264
  await runStep(params.step, params.name, {
214
265
  instructions: body.instructions,
215
- history: validatedHistory,
216
266
  attachments: validatedAttachments,
217
267
  model: body.model || undefined,
218
268
  verbose: body.verbose !== undefined ? body.verbose : true,
@@ -490,4 +540,64 @@ IMPORTANT:
490
540
  error(res, err.message, 400);
491
541
  }
492
542
  });
543
+
544
+ // Update feature type (feature/bug)
545
+ router.patch('/api/features/:name/type', async (req, res, { params, root, parseBody }) => {
546
+ const body = await parseBody();
547
+ const { type } = body;
548
+
549
+ if (!type || !FEATURE_TYPES.includes(type)) {
550
+ return error(res, `Invalid type. Must be one of: ${FEATURE_TYPES.join(', ')}`, 400);
551
+ }
552
+
553
+ try {
554
+ await updateType(params.name, type, root);
555
+ json(res, { ok: true, type });
556
+ } catch (err) {
557
+ error(res, err.message, 400);
558
+ }
559
+ });
560
+
561
+ // Update feature apps/scope
562
+ router.patch('/api/features/:name/apps', async (req, res, { params, root, parseBody }) => {
563
+ const body = await parseBody();
564
+ const { apps } = body;
565
+
566
+ if (!Array.isArray(apps)) {
567
+ return error(res, 'apps must be an array', 400);
568
+ }
569
+
570
+ // Validate all elements are non-empty strings
571
+ const invalidApps = apps.filter(a => typeof a !== 'string' || !a.trim());
572
+ if (invalidApps.length > 0) {
573
+ return error(res, 'apps must contain only non-empty strings', 400);
574
+ }
575
+
576
+ try {
577
+ await updateApps(params.name, apps, root);
578
+ json(res, { ok: true, apps });
579
+ } catch (err) {
580
+ error(res, err.message, 400);
581
+ }
582
+ });
583
+
584
+ // Soft delete a feature
585
+ router.delete('/api/features/:name', async (req, res, { params, root }) => {
586
+ try {
587
+ await softDeleteFeature(params.name, root);
588
+ json(res, { ok: true, deletedAt: new Date().toISOString() });
589
+ } catch (err) {
590
+ error(res, err.message, 400);
591
+ }
592
+ });
593
+
594
+ // Restore a deleted feature
595
+ router.post('/api/features/:name/restore', async (req, res, { params, root }) => {
596
+ try {
597
+ await restoreFeature(params.name, root);
598
+ json(res, { ok: true });
599
+ } catch (err) {
600
+ error(res, err.message, 400);
601
+ }
602
+ });
493
603
  }
@@ -3,6 +3,7 @@ import { registerConfigRoutes } from './config.js';
3
3
  import { registerLogRoutes } from './logs.js';
4
4
  import { registerConstantsRoutes } from './constants.js';
5
5
  import { registerWorktrunkRoutes } from './worktrunk.js';
6
+ import { registerTestQuickRoutes } from './test-quick.js';
6
7
 
7
8
  export function registerApiRoutes(router, root) {
8
9
  registerFeatureRoutes(router);
@@ -10,4 +11,5 @@ export function registerApiRoutes(router, root) {
10
11
  registerLogRoutes(router);
11
12
  registerConstantsRoutes(router);
12
13
  registerWorktrunkRoutes(router);
14
+ registerTestQuickRoutes(router);
13
15
  }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @fileoverview API routes for test-quick feature
3
+ * Provides REST endpoints for managing test-quick items
4
+ */
5
+
6
+ import { json, error } from '../router.js';
7
+ import {
8
+ loadItems,
9
+ createItem,
10
+ getItem,
11
+ listItems,
12
+ updateItem,
13
+ deleteItem,
14
+ updateStatus,
15
+ clearCompleted,
16
+ } from '../../services/test-quick.js';
17
+ import { TEST_QUICK_STATUS, isValidTestQuickName } from '../../types/test-quick.js';
18
+
19
+ /**
20
+ * Registers test-quick API routes
21
+ * @param {Object} router - Router instance
22
+ */
23
+ export function registerTestQuickRoutes(router) {
24
+ /**
25
+ * GET /api/test-quick/statuses
26
+ * Get available status values
27
+ * Note: Must be registered before :name route to avoid conflict
28
+ */
29
+ router.get('/api/test-quick/statuses', async (req, res) => {
30
+ json(res, { statuses: Object.values(TEST_QUICK_STATUS) });
31
+ });
32
+
33
+ /**
34
+ * GET /api/test-quick
35
+ * List all test-quick items with optional status filter
36
+ */
37
+ router.get('/api/test-quick', async (req, res, { root, query }) => {
38
+ try {
39
+ const options = {};
40
+ if (query?.status) {
41
+ options.status = query.status;
42
+ }
43
+
44
+ const result = await listItems(options, root);
45
+ json(res, result);
46
+ } catch (err) {
47
+ error(res, err.message, 500);
48
+ }
49
+ });
50
+
51
+ /**
52
+ * GET /api/test-quick/:name
53
+ * Get a single test-quick item by name
54
+ */
55
+ router.get('/api/test-quick/:name', async (req, res, { params, root }) => {
56
+ try {
57
+ const result = await getItem(params.name, root);
58
+
59
+ if (!result.success) {
60
+ return error(res, result.error, 404);
61
+ }
62
+
63
+ json(res, result);
64
+ } catch (err) {
65
+ error(res, err.message, 500);
66
+ }
67
+ });
68
+
69
+ /**
70
+ * POST /api/test-quick
71
+ * Create a new test-quick item
72
+ */
73
+ router.post('/api/test-quick', async (req, res, { root, parseBody }) => {
74
+ try {
75
+ const body = await parseBody();
76
+
77
+ if (!body.name) {
78
+ return error(res, 'Name is required', 400);
79
+ }
80
+
81
+ if (!isValidTestQuickName(body.name)) {
82
+ return error(
83
+ res,
84
+ 'Invalid name. Use lowercase alphanumeric with hyphens (e.g., my-item)',
85
+ 400
86
+ );
87
+ }
88
+
89
+ const result = await createItem(
90
+ {
91
+ name: body.name,
92
+ description: body.description,
93
+ },
94
+ root
95
+ );
96
+
97
+ if (!result.success) {
98
+ return error(res, result.error, 400);
99
+ }
100
+
101
+ json(res, result, 201);
102
+ } catch (err) {
103
+ error(res, err.message, 500);
104
+ }
105
+ });
106
+
107
+ /**
108
+ * PUT /api/test-quick/:name
109
+ * Update a test-quick item
110
+ */
111
+ router.put('/api/test-quick/:name', async (req, res, { params, root, parseBody }) => {
112
+ try {
113
+ const body = await parseBody();
114
+ const updates = {};
115
+
116
+ if (body.description !== undefined) {
117
+ updates.description = body.description;
118
+ }
119
+
120
+ if (body.status !== undefined) {
121
+ const validStatuses = Object.values(TEST_QUICK_STATUS);
122
+ if (!validStatuses.includes(body.status)) {
123
+ return error(res, `Invalid status. Valid values: ${validStatuses.join(', ')}`, 400);
124
+ }
125
+ updates.status = body.status;
126
+ }
127
+
128
+ if (body.metadata !== undefined) {
129
+ updates.metadata = body.metadata;
130
+ }
131
+
132
+ if (Object.keys(updates).length === 0) {
133
+ return error(res, 'No valid updates provided', 400);
134
+ }
135
+
136
+ const result = await updateItem(params.name, updates, root);
137
+
138
+ if (!result.success) {
139
+ return error(res, result.error, 404);
140
+ }
141
+
142
+ json(res, result);
143
+ } catch (err) {
144
+ error(res, err.message, 500);
145
+ }
146
+ });
147
+
148
+ /**
149
+ * PATCH /api/test-quick/:name/status
150
+ * Update only the status of a test-quick item
151
+ */
152
+ router.patch('/api/test-quick/:name/status', async (req, res, { params, root, parseBody }) => {
153
+ try {
154
+ const body = await parseBody();
155
+
156
+ if (!body.status) {
157
+ return error(res, 'Status is required', 400);
158
+ }
159
+
160
+ const validStatuses = Object.values(TEST_QUICK_STATUS);
161
+ if (!validStatuses.includes(body.status)) {
162
+ return error(res, `Invalid status. Valid values: ${validStatuses.join(', ')}`, 400);
163
+ }
164
+
165
+ const result = await updateStatus(params.name, body.status, root);
166
+
167
+ if (!result.success) {
168
+ return error(res, result.error, 404);
169
+ }
170
+
171
+ json(res, result);
172
+ } catch (err) {
173
+ error(res, err.message, 500);
174
+ }
175
+ });
176
+
177
+ /**
178
+ * DELETE /api/test-quick/:name
179
+ * Delete a test-quick item
180
+ */
181
+ router.delete('/api/test-quick/:name', async (req, res, { params, root }) => {
182
+ try {
183
+ const result = await deleteItem(params.name, root);
184
+
185
+ if (!result.success) {
186
+ return error(res, result.error, 404);
187
+ }
188
+
189
+ json(res, result);
190
+ } catch (err) {
191
+ error(res, err.message, 500);
192
+ }
193
+ });
194
+
195
+ /**
196
+ * POST /api/test-quick/clear-completed
197
+ * Clear all completed items
198
+ */
199
+ router.post('/api/test-quick/clear-completed', async (req, res, { root }) => {
200
+ try {
201
+ const result = await clearCompleted(root);
202
+ json(res, result);
203
+ } catch (err) {
204
+ error(res, err.message, 500);
205
+ }
206
+ });
207
+ }
@@ -6,10 +6,13 @@ import {
6
6
  hasWorktree,
7
7
  removeWorktree,
8
8
  getFeatureBranch,
9
- startServices,
10
- stopServices,
11
9
  hasDockerServices,
10
+ isDockerRunning,
12
11
  getServicesStatus,
12
+ startComposeService,
13
+ stopComposeService,
14
+ startAllComposeServices,
15
+ stopAllComposeServices,
13
16
  } from '../../services/worktrunk.js';
14
17
  import { json, error } from '../router.js';
15
18
 
@@ -28,21 +31,25 @@ export function registerWorktrunkRoutes(router) {
28
31
  installed: false,
29
32
  hasWorktree: false,
30
33
  path: null,
31
- hasServices: false,
34
+ docker: { available: false, hasComposeFile: false },
32
35
  });
33
36
  }
34
37
 
35
38
  const branch = getFeatureBranch(params.name);
36
39
  const wtPath = getWorktreePath(branch, root);
37
40
  const hasWt = wtPath !== null;
38
- const hasServices = hasWt && hasDockerServices(wtPath);
41
+ const dockerRunning = isDockerRunning();
42
+ const hasComposeFile = hasWt && hasDockerServices(wtPath);
39
43
 
40
44
  json(res, {
41
45
  installed: true,
42
46
  hasWorktree: hasWt,
43
47
  path: wtPath,
44
48
  branch,
45
- hasServices,
49
+ docker: {
50
+ available: dockerRunning,
51
+ hasComposeFile,
52
+ },
46
53
  });
47
54
  });
48
55
 
@@ -94,29 +101,45 @@ export function registerWorktrunkRoutes(router) {
94
101
  }
95
102
  });
96
103
 
97
- // Start docker services in worktree
98
- router.post('/api/features/:name/wt/services/start', async (req, res, { params, root }) => {
104
+ // Get services status (from docker-compose.wt.yml)
105
+ router.get('/api/features/:name/wt/services', async (req, res, { params, root }) => {
99
106
  const branch = getFeatureBranch(params.name);
100
107
  const wtPath = getWorktreePath(branch, root);
101
108
 
102
109
  if (!wtPath) {
103
- return error(res, 'No worktree found for this feature', 404);
110
+ return json(res, { services: [], hasComposeFile: false, dockerAvailable: false });
104
111
  }
105
112
 
106
- if (!hasDockerServices(wtPath)) {
107
- return error(res, 'No docker-compose.wt.yml found in worktree', 404);
113
+ const dockerAvailable = isDockerRunning();
114
+ const hasComposeFile = hasDockerServices(wtPath);
115
+
116
+ if (!dockerAvailable || !hasComposeFile) {
117
+ return json(res, { services: [], hasComposeFile, dockerAvailable });
118
+ }
119
+
120
+ const services = getServicesStatus(wtPath);
121
+ json(res, { services, hasComposeFile, dockerAvailable });
122
+ });
123
+
124
+ // Start a specific service
125
+ router.post('/api/features/:name/wt/services/:service/start', async (req, res, { params, root }) => {
126
+ const branch = getFeatureBranch(params.name);
127
+ const wtPath = getWorktreePath(branch, root);
128
+
129
+ if (!wtPath) {
130
+ return error(res, 'No worktree found for this feature', 404);
108
131
  }
109
132
 
110
133
  try {
111
- startServices(wtPath);
134
+ startComposeService(wtPath, params.service);
112
135
  json(res, { ok: true });
113
136
  } catch (err) {
114
- error(res, `Failed to start services: ${err.message}`, 500);
137
+ error(res, `Failed to start service: ${err.message}`, 500);
115
138
  }
116
139
  });
117
140
 
118
- // Stop docker services in worktree
119
- router.post('/api/features/:name/wt/services/stop', async (req, res, { params, root }) => {
141
+ // Stop a specific service
142
+ router.post('/api/features/:name/wt/services/:service/stop', async (req, res, { params, root }) => {
120
143
  const branch = getFeatureBranch(params.name);
121
144
  const wtPath = getWorktreePath(branch, root);
122
145
 
@@ -124,30 +147,45 @@ export function registerWorktrunkRoutes(router) {
124
147
  return error(res, 'No worktree found for this feature', 404);
125
148
  }
126
149
 
127
- if (!hasDockerServices(wtPath)) {
128
- return error(res, 'No docker-compose.wt.yml found in worktree', 404);
150
+ try {
151
+ stopComposeService(wtPath, params.service);
152
+ json(res, { ok: true });
153
+ } catch (err) {
154
+ error(res, `Failed to stop service: ${err.message}`, 500);
155
+ }
156
+ });
157
+
158
+ // Start all services
159
+ router.post('/api/features/:name/wt/services/start', async (req, res, { params, root }) => {
160
+ const branch = getFeatureBranch(params.name);
161
+ const wtPath = getWorktreePath(branch, root);
162
+
163
+ if (!wtPath) {
164
+ return error(res, 'No worktree found for this feature', 404);
129
165
  }
130
166
 
131
167
  try {
132
- stopServices(wtPath);
168
+ startAllComposeServices(wtPath);
133
169
  json(res, { ok: true });
134
170
  } catch (err) {
135
- error(res, `Failed to stop services: ${err.message}`, 500);
171
+ error(res, `Failed to start services: ${err.message}`, 500);
136
172
  }
137
173
  });
138
174
 
139
- // Get services status
140
- router.get('/api/features/:name/wt/services', async (req, res, { params, root }) => {
175
+ // Stop all services
176
+ router.post('/api/features/:name/wt/services/stop', async (req, res, { params, root }) => {
141
177
  const branch = getFeatureBranch(params.name);
142
178
  const wtPath = getWorktreePath(branch, root);
143
179
 
144
180
  if (!wtPath) {
145
- return json(res, { services: [], hasServices: false });
181
+ return error(res, 'No worktree found for this feature', 404);
146
182
  }
147
183
 
148
- const hasServices = hasDockerServices(wtPath);
149
- const services = hasServices ? getServicesStatus(wtPath) : [];
150
-
151
- json(res, { services, hasServices });
184
+ try {
185
+ stopAllComposeServices(wtPath);
186
+ json(res, { ok: true });
187
+ } catch (err) {
188
+ error(res, `Failed to stop services: ${err.message}`, 500);
189
+ }
152
190
  });
153
191
  }