@bamptee/aia-code 2.0.12 → 2.0.13

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.
@@ -146,24 +146,15 @@ export function getFeatureBranch(featureName) {
146
146
  }
147
147
 
148
148
  /**
149
- * Start docker-compose services in a worktree
150
- * @param {string} wtPath - Worktree directory path
151
- */
152
- export function startServices(wtPath) {
153
- const composePath = path.join(wtPath, 'docker-compose.wt.yml');
154
- if (fs.existsSync(composePath)) {
155
- execSync('docker-compose -f docker-compose.wt.yml up -d', { cwd: wtPath, stdio: 'inherit' });
156
- }
157
- }
158
-
159
- /**
160
- * Stop docker-compose services in a worktree
161
- * @param {string} wtPath - Worktree directory path
149
+ * Check if Docker daemon is running
150
+ * @returns {boolean}
162
151
  */
163
- export function stopServices(wtPath) {
164
- const composePath = path.join(wtPath, 'docker-compose.wt.yml');
165
- if (fs.existsSync(composePath)) {
166
- execSync('docker-compose -f docker-compose.wt.yml down', { cwd: wtPath, stdio: 'inherit' });
152
+ export function isDockerRunning() {
153
+ try {
154
+ execSync('docker info', { stdio: 'ignore' });
155
+ return true;
156
+ } catch {
157
+ return false;
167
158
  }
168
159
  }
169
160
 
@@ -179,19 +170,142 @@ export function hasDockerServices(wtPath) {
179
170
  }
180
171
 
181
172
  /**
182
- * Get docker services status
173
+ * List services defined in docker-compose.wt.yml
183
174
  * @param {string} wtPath - Worktree directory path
184
- * @returns {Array} List of service objects with name and status
175
+ * @returns {Array<string>} List of service names
176
+ */
177
+ export function listComposeServices(wtPath) {
178
+ if (!hasDockerServices(wtPath)) return [];
179
+ try {
180
+ const output = execSync('docker-compose -f docker-compose.wt.yml config --services', {
181
+ cwd: wtPath,
182
+ encoding: 'utf-8',
183
+ });
184
+ return output.trim().split('\n').filter(Boolean);
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Get status of all services from docker-compose
192
+ * @param {string} wtPath - Worktree directory path
193
+ * @returns {Array} List of service objects with name, state, status
185
194
  */
186
195
  export function getServicesStatus(wtPath) {
187
196
  if (!hasDockerServices(wtPath)) return [];
197
+
198
+ // Get all defined services
199
+ const definedServices = listComposeServices(wtPath);
200
+ if (!definedServices.length) return [];
201
+
202
+ // Get running containers status
203
+ let runningServices = [];
188
204
  try {
189
205
  const output = execSync('docker-compose -f docker-compose.wt.yml ps --format json', {
190
206
  cwd: wtPath,
191
207
  encoding: 'utf-8',
192
208
  });
193
- return JSON.parse(output);
209
+ // docker-compose ps --format json outputs one JSON per line
210
+ runningServices = output.trim().split('\n').filter(Boolean).map(line => {
211
+ try {
212
+ return JSON.parse(line);
213
+ } catch {
214
+ return null;
215
+ }
216
+ }).filter(Boolean);
194
217
  } catch {
195
- return [];
218
+ // docker-compose ps failed, all services are stopped
196
219
  }
220
+
221
+ // Map defined services to their status
222
+ return definedServices.map(serviceName => {
223
+ const running = runningServices.find(s =>
224
+ s.Service === serviceName || s.Name?.includes(serviceName)
225
+ );
226
+
227
+ // Extract published ports from Publishers array
228
+ const ports = (running?.Publishers || [])
229
+ .filter(p => p.PublishedPort)
230
+ .map(p => ({
231
+ published: p.PublishedPort,
232
+ target: p.TargetPort,
233
+ protocol: p.Protocol || 'tcp',
234
+ }));
235
+
236
+ return {
237
+ name: serviceName,
238
+ state: running?.State || 'stopped',
239
+ status: running?.Status || 'Stopped',
240
+ health: running?.Health || null,
241
+ ports,
242
+ };
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Start a docker-compose service
248
+ * @param {string} wtPath - Worktree directory path
249
+ * @param {string} serviceName - Service name to start
250
+ */
251
+ export function startComposeService(wtPath, serviceName) {
252
+ if (!hasDockerServices(wtPath)) {
253
+ throw new Error('No docker-compose.wt.yml found');
254
+ }
255
+ // Validate service name
256
+ if (!/^[a-zA-Z0-9_-]+$/.test(serviceName)) {
257
+ throw new Error(`Invalid service name: "${serviceName}"`);
258
+ }
259
+ execSync(`docker-compose -f docker-compose.wt.yml up -d ${serviceName}`, {
260
+ cwd: wtPath,
261
+ stdio: 'inherit',
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Stop a docker-compose service
267
+ * @param {string} wtPath - Worktree directory path
268
+ * @param {string} serviceName - Service name to stop
269
+ */
270
+ export function stopComposeService(wtPath, serviceName) {
271
+ if (!hasDockerServices(wtPath)) {
272
+ throw new Error('No docker-compose.wt.yml found');
273
+ }
274
+ // Validate service name
275
+ if (!/^[a-zA-Z0-9_-]+$/.test(serviceName)) {
276
+ throw new Error(`Invalid service name: "${serviceName}"`);
277
+ }
278
+ execSync(`docker-compose -f docker-compose.wt.yml stop ${serviceName}`, {
279
+ cwd: wtPath,
280
+ stdio: 'inherit',
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Start all docker-compose services
286
+ * @param {string} wtPath - Worktree directory path
287
+ */
288
+ export function startAllComposeServices(wtPath) {
289
+ if (!hasDockerServices(wtPath)) {
290
+ throw new Error('No docker-compose.wt.yml found');
291
+ }
292
+ execSync('docker-compose -f docker-compose.wt.yml up -d', {
293
+ cwd: wtPath,
294
+ stdio: 'inherit',
295
+ });
197
296
  }
297
+
298
+ /**
299
+ * Stop all docker-compose services
300
+ * @param {string} wtPath - Worktree directory path
301
+ */
302
+ export function stopAllComposeServices(wtPath) {
303
+ if (!hasDockerServices(wtPath)) {
304
+ throw new Error('No docker-compose.wt.yml found');
305
+ }
306
+ execSync('docker-compose -f docker-compose.wt.yml stop', {
307
+ cwd: wtPath,
308
+ stdio: 'inherit',
309
+ });
310
+ }
311
+
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @fileoverview Type definitions for test-quick feature
3
+ * Uses JSDoc for type definitions in this JavaScript codebase
4
+ */
5
+
6
+ /**
7
+ * @typedef {Object} TestQuickConfig
8
+ * @property {string} name - Name of the test-quick item
9
+ * @property {string} [description] - Optional description
10
+ * @property {boolean} [enabled=true] - Whether the item is enabled
11
+ * @property {string} [createdAt] - ISO timestamp of creation
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} TestQuickItem
16
+ * @property {string} id - Unique identifier
17
+ * @property {string} name - Item name
18
+ * @property {string} [description] - Optional description
19
+ * @property {'pending'|'active'|'completed'|'error'} status - Current status
20
+ * @property {Object} [metadata] - Additional metadata
21
+ * @property {string} createdAt - ISO timestamp of creation
22
+ * @property {string} [updatedAt] - ISO timestamp of last update
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} TestQuickResult
27
+ * @property {boolean} success - Whether the operation succeeded
28
+ * @property {string} [message] - Result message
29
+ * @property {TestQuickItem} [data] - Result data
30
+ * @property {string} [error] - Error message if failed
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} TestQuickListResult
35
+ * @property {boolean} success - Whether the operation succeeded
36
+ * @property {TestQuickItem[]} items - List of items
37
+ * @property {number} total - Total count
38
+ */
39
+
40
+ /**
41
+ * Valid status values for test-quick items
42
+ * @type {Object.<string, string>}
43
+ */
44
+ export const TEST_QUICK_STATUS = {
45
+ PENDING: 'pending',
46
+ ACTIVE: 'active',
47
+ COMPLETED: 'completed',
48
+ ERROR: 'error',
49
+ };
50
+
51
+ /**
52
+ * Default configuration for test-quick
53
+ * @type {TestQuickConfig}
54
+ */
55
+ export const DEFAULT_TEST_QUICK_CONFIG = {
56
+ name: '',
57
+ description: '',
58
+ enabled: true,
59
+ createdAt: null,
60
+ };
61
+
62
+ /**
63
+ * Validates a test-quick item name
64
+ * @param {string} name - Name to validate
65
+ * @returns {boolean} Whether the name is valid
66
+ */
67
+ export function isValidTestQuickName(name) {
68
+ if (!name || typeof name !== 'string') return false;
69
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
70
+ }
71
+
72
+ /**
73
+ * Creates a new test-quick item with defaults
74
+ * @param {Partial<TestQuickItem>} data - Partial item data
75
+ * @returns {TestQuickItem} Complete item with defaults
76
+ */
77
+ export function createTestQuickItem(data) {
78
+ const now = new Date().toISOString();
79
+ return {
80
+ id: data.id || `tq-${Date.now()}`,
81
+ name: data.name || '',
82
+ description: data.description || '',
83
+ status: data.status || TEST_QUICK_STATUS.PENDING,
84
+ metadata: data.metadata || {},
85
+ createdAt: data.createdAt || now,
86
+ updatedAt: now,
87
+ };
88
+ }
@@ -2,10 +2,37 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import yaml from 'yaml';
4
4
  import { AIA_DIR } from '../../constants.js';
5
- import { loadGlobalConfig, saveGlobalConfig, getGlobalConfigPath } from '../../services/config.js';
5
+ import { loadGlobalConfig, saveGlobalConfig, getGlobalConfigPath, getApps, setApps, toggleApp } from '../../services/config.js';
6
+ import { scanApps } from '../../services/apps.js';
6
7
  import { json, error } from '../router.js';
7
8
 
8
9
  export function registerConfigRoutes(router) {
10
+ // GET /api/apps - List configured apps
11
+ router.get('/api/apps', async (req, res, { root }) => {
12
+ const apps = await getApps(root);
13
+ json(res, apps);
14
+ });
15
+
16
+ // POST /api/apps/scan - Re-scan and update apps
17
+ router.post('/api/apps/scan', async (req, res, { root }) => {
18
+ const scanned = await scanApps(root);
19
+ const existing = await getApps(root);
20
+ // Merge: keep enabled status from existing apps
21
+ const merged = scanned.map(s => {
22
+ const ex = existing.find(e => e.path === s.path);
23
+ return ex ? { ...s, enabled: ex.enabled } : s;
24
+ });
25
+ await setApps(merged, root);
26
+ json(res, merged);
27
+ });
28
+
29
+ // PATCH /api/apps/:name - Toggle app enabled status
30
+ router.patch('/api/apps/:name', async (req, res, { params, root, parseBody }) => {
31
+ const { enabled } = await parseBody();
32
+ await toggleApp(params.name, enabled, root);
33
+ json(res, { ok: true });
34
+ });
35
+
9
36
  // Get project config
10
37
  router.get('/api/config', async (req, res, { root }) => {
11
38
  const configPath = path.join(root, AIA_DIR, 'config.yaml');
@@ -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
  }