specrails-desktop 2.2.1 → 2.3.0

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.
Files changed (61) hide show
  1. package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
  2. package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
  3. package/client/dist/assets/{AnalyticsPage-BD0paa75.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
  4. package/client/dist/assets/{BarChart-D8ZZRab3.js → BarChart-CMdLa6Es.js} +2 -2
  5. package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-mwd8460_.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-D_dyF2h9.js → DocsDialog-D8yoyZDD.js} +2 -2
  8. package/client/dist/assets/{DocsPage-C9-Ru8wE.js → DocsPage-CeO-fAxy.js} +2 -2
  9. package/client/dist/assets/{ExportDropdown-CLYmQhic.js → ExportDropdown-DuoZcdYN.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-3WWtx9hi.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
  11. package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
  12. package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
  13. package/client/dist/assets/code-BtsmPQLV.js +1 -0
  14. package/client/dist/assets/code-CY85RXZU.js +1 -0
  15. package/client/dist/assets/code-Coa8f2Sh.js +1 -0
  16. package/client/dist/assets/code-D1z-YDt-.js +1 -0
  17. package/client/dist/assets/code-DDU0CRS0.js +1 -0
  18. package/client/dist/assets/code-L35Loak_.js +1 -0
  19. package/client/dist/assets/code-g0qFMzyg.js +1 -0
  20. package/client/dist/assets/code-zCwBt3Uu.js +1 -0
  21. package/client/dist/assets/{dist-js-BOu_cXw3.js → dist-js-4UEGaKhD.js} +1 -1
  22. package/client/dist/assets/{dist-js-D3MxtOYa.js → dist-js-H6hyhSuv.js} +1 -1
  23. package/client/dist/assets/{index-D9G_K4L-.js → index-CGHKpC-N.js} +11 -11
  24. package/client/dist/assets/{lib-DQ2hrj8m.js → lib-Cs5FrUJI.js} +1 -1
  25. package/client/dist/assets/{useProjectCache-BxY4aTjd.js → useProjectCache-BZWYV-w-.js} +1 -1
  26. package/client/dist/index.html +2 -2
  27. package/package.json +1 -1
  28. package/server/dist/agent-refine-manager.js +128 -153
  29. package/server/dist/chat-manager.js +242 -0
  30. package/server/dist/code-explorer-router.js +78 -0
  31. package/server/dist/command-resolver.js +17 -0
  32. package/server/dist/contract-refine-runner.js +42 -10
  33. package/server/dist/db.js +6 -0
  34. package/server/dist/desktop-db.js +3 -0
  35. package/server/dist/explore-stdin-session.js +129 -0
  36. package/server/dist/mobile/mobile-auth.js +16 -0
  37. package/server/dist/project-router-chat.js +218 -0
  38. package/server/dist/project-router-helpers.js +275 -0
  39. package/server/dist/project-router-jobs.js +389 -0
  40. package/server/dist/project-router-settings.js +312 -0
  41. package/server/dist/project-router-setup.js +456 -0
  42. package/server/dist/project-router-spending.js +320 -0
  43. package/server/dist/project-router-terminals.js +312 -0
  44. package/server/dist/project-router-tickets.js +1767 -0
  45. package/server/dist/project-router.js +27 -3950
  46. package/server/dist/providers/claude-adapter.js +23 -0
  47. package/server/dist/providers/codex-adapter.js +6 -0
  48. package/server/dist/spawn-lifecycle.js +117 -0
  49. package/client/dist/assets/ActivityFeedPage-BpjXuX2H.js +0 -1
  50. package/client/dist/assets/AgentsPage-D-7fDbTc.js +0 -86
  51. package/client/dist/assets/CodePage-B6q6CiYJ.js +0 -2
  52. package/client/dist/assets/JobDetailPage-DgN-79s-.js +0 -16
  53. package/client/dist/assets/JobsPage-Du8_w1ob.js +0 -1
  54. package/client/dist/assets/code-AL1rVIMb.js +0 -1
  55. package/client/dist/assets/code-C0BKpkht.js +0 -1
  56. package/client/dist/assets/code-C0FTS3ew.js +0 -1
  57. package/client/dist/assets/code-CPcHxzxw.js +0 -1
  58. package/client/dist/assets/code-D3ryDniw.js +0 -1
  59. package/client/dist/assets/code-D3zVVQTj.js +0 -1
  60. package/client/dist/assets/code-PCmfS3dn.js +0 -1
  61. package/client/dist/assets/code-exI0G5Wd.js +0 -1
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerSpendingRoutes = registerSpendingRoutes;
4
+ const queue_manager_1 = require("./queue-manager");
5
+ const config_1 = require("./config");
6
+ const ai_invocations_1 = require("./ai-invocations");
7
+ const spending_1 = require("./spending");
8
+ const ticket_store_1 = require("./ticket-store");
9
+ function registerSpendingRoutes(deps) {
10
+ const { router, registry, ctx, ticketPath } = deps;
11
+ // ─── Spending dashboard ──────────────────────────────────────────────────────
12
+ router.get('/:projectId/spending', (req, res) => {
13
+ const filters = (0, spending_1.parseSpendingFilters)(req.query);
14
+ if (filters.period === 'custom' && (!filters.from || !filters.to)) {
15
+ res.status(400).json({ error: 'from and to are required for custom period' });
16
+ return;
17
+ }
18
+ try {
19
+ res.json((0, spending_1.getSpending)(ctx(req).db, ctx(req).project.id, filters));
20
+ }
21
+ catch (err) {
22
+ console.error('[project-router] spending error:', err);
23
+ res.status(500).json({ error: 'Failed to compute spending' });
24
+ }
25
+ });
26
+ // ─── Raw invocations table ───────────────────────────────────────────────────
27
+ router.get('/:projectId/invocations', (req, res) => {
28
+ const filters = (0, spending_1.parseSpendingFilters)(req.query);
29
+ // Clamp to [1, 10000] when provided (H-11): a negative/NaN limit becomes
30
+ // SQLite LIMIT -1 (unlimited) and dumps the entire invocations table.
31
+ const rawLimit = req.query.limit ? parseInt(req.query.limit, 10) : undefined;
32
+ const limit = rawLimit !== undefined && Number.isFinite(rawLimit)
33
+ ? Math.max(1, Math.min(rawLimit, 10_000))
34
+ : undefined;
35
+ const offset = req.query.offset ? parseInt(req.query.offset, 10) : undefined;
36
+ try {
37
+ const result = (0, spending_1.getInvocations)(ctx(req).db, ctx(req).project.id, {
38
+ ...filters,
39
+ ...(limit ? { limit } : {}),
40
+ ...(offset ? { offset } : {}),
41
+ });
42
+ // Enrich with ticket titles from YAML store.
43
+ try {
44
+ const store = (0, ticket_store_1.readStore)(ticketPath(req));
45
+ for (const r of result.rows) {
46
+ if (r.ticket_id != null) {
47
+ const t = store.tickets[String(r.ticket_id)];
48
+ r.ticket_title = t?.title ?? null;
49
+ }
50
+ }
51
+ }
52
+ catch { /* tickets store may not exist yet */ }
53
+ res.json(result);
54
+ }
55
+ catch (err) {
56
+ console.error('[project-router] invocations error:', err);
57
+ res.status(500).json({ error: 'Failed to list invocations' });
58
+ }
59
+ });
60
+ // ─── Per-ticket spending summary (used by TicketDetailModal) ─────────────────
61
+ router.get('/:projectId/tickets/:id/spending-summary', (req, res) => {
62
+ const ticketId = parseInt(req.params.id, 10);
63
+ if (Number.isNaN(ticketId)) {
64
+ res.status(400).json({ error: 'Invalid ticket id' });
65
+ return;
66
+ }
67
+ try {
68
+ res.json((0, ai_invocations_1.getTicketSpendingSummary)(ctx(req).db, ticketId));
69
+ }
70
+ catch (err) {
71
+ console.error('[project-router] ticket spending summary error:', err);
72
+ res.status(500).json({ error: 'Failed to compute ticket spending' });
73
+ }
74
+ });
75
+ // ─── Spending / analytics export (Summary + Raw, CSV or JSON) ────────────────
76
+ router.get('/:projectId/analytics/export', async (req, res) => {
77
+ const format = req.query.format || 'json';
78
+ const mode = req.query.mode || 'summary';
79
+ if (format !== 'json' && format !== 'csv') {
80
+ res.status(400).json({ error: 'Invalid format. Must be json or csv' });
81
+ return;
82
+ }
83
+ if (mode !== 'summary' && mode !== 'raw') {
84
+ res.status(400).json({ error: 'Invalid mode. Must be summary or raw' });
85
+ return;
86
+ }
87
+ const periodRaw = req.query.period ?? '30d';
88
+ const validPeriods = ['7d', '30d', '90d', 'all', 'custom'];
89
+ if (!validPeriods.includes(periodRaw)) {
90
+ res.status(400).json({ error: 'Invalid period. Must be one of: 7d, 30d, 90d, all, custom' });
91
+ return;
92
+ }
93
+ const filters = (0, spending_1.parseSpendingFilters)(req.query);
94
+ if (filters.period === 'custom' && (!filters.from || !filters.to)) {
95
+ res.status(400).json({ error: 'from and to are required for custom period' });
96
+ return;
97
+ }
98
+ const { project } = ctx(req);
99
+ const projectId = project.id;
100
+ const dateStamp = new Date().toISOString().slice(0, 10);
101
+ const periodTag = filters.period ?? '30d';
102
+ const surfaceTag = (filters.surface && filters.surface.length === 1)
103
+ ? `-${filters.surface[0].replace('-spec', '').replace('-', '')}`
104
+ : '';
105
+ try {
106
+ if (mode === 'summary') {
107
+ const data = (0, spending_1.getSpending)(ctx(req).db, projectId, filters);
108
+ if (format === 'json') {
109
+ res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-analytics-${periodTag}-${dateStamp}.json"`);
110
+ res.json(data);
111
+ return;
112
+ }
113
+ // CSV summary: multi-section composite
114
+ const lines = [];
115
+ lines.push('# Totals');
116
+ lines.push('totalCostUsd,totalRuns,prevTotalCostUsd,deltaPct,avgCostPerRun,failureRate');
117
+ lines.push([
118
+ data.summary.totalCostUsd,
119
+ data.summary.totalRuns,
120
+ data.summary.prevTotalCostUsd,
121
+ data.summary.deltaPct ?? '',
122
+ data.summary.avgCostPerRun ?? '',
123
+ data.summary.failureRate,
124
+ ].join(','));
125
+ lines.push('');
126
+ lines.push('# Daily timeline');
127
+ lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,totalCostUsd');
128
+ for (const d of data.dailyTimeline) {
129
+ lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.totalCostUsd}`);
130
+ }
131
+ lines.push('');
132
+ lines.push('# By surface');
133
+ lines.push('surface,count,costUsd');
134
+ for (const s of data.bySurface)
135
+ lines.push(`${s.surface},${s.count},${s.costUsd}`);
136
+ lines.push('');
137
+ lines.push('# By model');
138
+ lines.push('model,count,costUsd');
139
+ for (const m of data.byModel)
140
+ lines.push(`${csvEscape(m.model)},${m.count},${m.costUsd}`);
141
+ lines.push('');
142
+ lines.push('# Top tickets');
143
+ lines.push('ticketId,totalCostUsd,totalRuns,jobCost,quickCost,exploreCost,aiEditCost');
144
+ for (const t of data.topTickets) {
145
+ lines.push([
146
+ t.ticketId ?? '(unattributed)',
147
+ t.totalCostUsd,
148
+ t.totalRuns,
149
+ t.bySurface.job.costUsd,
150
+ t.bySurface['quick-spec'].costUsd,
151
+ t.bySurface['explore-spec'].costUsd,
152
+ t.bySurface['ai-edit'].costUsd,
153
+ ].join(','));
154
+ }
155
+ res.setHeader('Content-Type', 'text/csv');
156
+ res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-analytics-${periodTag}-${dateStamp}.csv"`);
157
+ res.send(lines.join('\n'));
158
+ }
159
+ else {
160
+ // raw mode: capped invocations
161
+ const result = (0, spending_1.getInvocations)(ctx(req).db, projectId, { ...filters, cap: 10000 });
162
+ // Enrich titles
163
+ try {
164
+ const store = (0, ticket_store_1.readStore)(ticketPath(req));
165
+ for (const r of result.rows) {
166
+ if (r.ticket_id != null)
167
+ r.ticket_title = store.tickets[String(r.ticket_id)]?.title ?? null;
168
+ }
169
+ }
170
+ catch { /* no tickets yet */ }
171
+ if (format === 'json') {
172
+ res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-invocations-${periodTag}${surfaceTag}-${dateStamp}.json"`);
173
+ res.json(result);
174
+ return;
175
+ }
176
+ const headers = [
177
+ 'id', 'surface', 'surface_ref_id', 'ticket_id', 'ticket_title', 'conversation_id',
178
+ 'model', 'status', 'started_at', 'finished_at', 'duration_ms', 'duration_api_ms',
179
+ 'tokens_in', 'tokens_out', 'tokens_cache_read', 'tokens_cache_create',
180
+ 'total_cost_usd', 'num_turns', 'session_id'
181
+ ];
182
+ const lines = [headers.join(',')];
183
+ for (const r of result.rows) {
184
+ lines.push(headers.map((h) => csvEscape(r[h])).join(','));
185
+ }
186
+ if (result.truncated) {
187
+ lines.push(`# truncated_at=${result.rows.length} of ${result.totalAvailable}`);
188
+ }
189
+ res.setHeader('Content-Type', 'text/csv');
190
+ res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-invocations-${periodTag}${surfaceTag}-${dateStamp}.csv"`);
191
+ res.send(lines.join('\n'));
192
+ }
193
+ }
194
+ catch (err) {
195
+ console.error('[project-router] export error:', err);
196
+ res.status(500).json({ error: 'Failed to export' });
197
+ }
198
+ });
199
+ function csvEscape(v) {
200
+ const s = v == null ? '' : String(v);
201
+ return s.includes(',') || s.includes('"') || s.includes('\n')
202
+ ? `"${s.replace(/"/g, '""')}"`
203
+ : s;
204
+ }
205
+ router.get('/:projectId/config', (req, res) => {
206
+ const { project, db } = ctx(req);
207
+ try {
208
+ const config = (0, config_1.getConfig)(project.path, db, project.name);
209
+ const dailyBudgetRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.daily_budget_usd'`).get()?.value;
210
+ const dailyBudgetUsd = dailyBudgetRaw != null ? parseFloat(dailyBudgetRaw) : null;
211
+ const zombieTimeoutRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.zombie_timeout_ms'`).get()?.value;
212
+ const zombieTimeoutMs = zombieTimeoutRaw != null ? parseInt(zombieTimeoutRaw, 10) : null;
213
+ res.json({ ...config, dailyBudgetUsd, zombieTimeoutMs });
214
+ }
215
+ catch (err) {
216
+ console.error('[project-router] config error:', err);
217
+ res.status(500).json({ error: 'Failed to read config' });
218
+ }
219
+ });
220
+ router.post('/:projectId/config', (req, res) => {
221
+ const { active, labelFilter, dailyBudgetUsd, zombieTimeoutMs } = req.body ?? {};
222
+ const { db, queueManager } = ctx(req);
223
+ try {
224
+ if (active !== undefined) {
225
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.active_tracker', ?)`).run(active ?? '');
226
+ }
227
+ if (labelFilter !== undefined) {
228
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.label_filter', ?)`).run(labelFilter ?? '');
229
+ }
230
+ if (dailyBudgetUsd !== undefined) {
231
+ if (dailyBudgetUsd === null) {
232
+ db.prepare(`DELETE FROM queue_state WHERE key = 'config.daily_budget_usd'`).run();
233
+ }
234
+ else if (typeof dailyBudgetUsd === 'number' && dailyBudgetUsd > 0) {
235
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.daily_budget_usd', ?)`).run(String(dailyBudgetUsd));
236
+ }
237
+ }
238
+ if (zombieTimeoutMs !== undefined) {
239
+ if (zombieTimeoutMs === null) {
240
+ db.prepare(`DELETE FROM queue_state WHERE key = 'config.zombie_timeout_ms'`).run();
241
+ }
242
+ else if (typeof zombieTimeoutMs === 'number' && zombieTimeoutMs > 0) {
243
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.zombie_timeout_ms', ?)`).run(String(zombieTimeoutMs));
244
+ }
245
+ queueManager.setZombieTimeout(typeof zombieTimeoutMs === 'number' && zombieTimeoutMs > 0 ? zombieTimeoutMs : queue_manager_1.DEFAULT_ZOMBIE_TIMEOUT_MS);
246
+ }
247
+ res.json({ ok: true });
248
+ }
249
+ catch (err) {
250
+ console.error('[project-router] config persist error:', err);
251
+ res.status(500).json({ error: 'Failed to persist config' });
252
+ }
253
+ });
254
+ // ─── Budget routes ────────────────────────────────────────────────────────────
255
+ router.get('/:projectId/budget', (req, res) => {
256
+ const { db } = ctx(req);
257
+ try {
258
+ const dailyBudgetRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.daily_budget_usd'`).get()?.value;
259
+ const dailyBudgetUsd = dailyBudgetRaw != null ? parseFloat(dailyBudgetRaw) : null;
260
+ const jobThresholdRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.job_cost_threshold_usd'`).get()?.value;
261
+ const jobCostThresholdUsd = jobThresholdRaw != null ? parseFloat(jobThresholdRaw) : null;
262
+ const costRow = db.prepare(`SELECT COALESCE(SUM(total_cost_usd), 0) as costToday FROM jobs WHERE started_at >= date('now')`).get();
263
+ const costToday = costRow.costToday;
264
+ const budgetUtilizationPct = dailyBudgetUsd != null && dailyBudgetUsd > 0
265
+ ? (costToday / dailyBudgetUsd) * 100
266
+ : null;
267
+ res.json({ dailyBudgetUsd, jobCostThresholdUsd, costToday, budgetUtilizationPct });
268
+ }
269
+ catch (err) {
270
+ console.error('[project-router] budget get error:', err);
271
+ res.status(500).json({ error: 'Failed to read budget' });
272
+ }
273
+ });
274
+ router.patch('/:projectId/budget', (req, res) => {
275
+ const { dailyBudgetUsd, jobCostThresholdUsd } = req.body ?? {};
276
+ const { db } = ctx(req);
277
+ try {
278
+ if (dailyBudgetUsd !== undefined) {
279
+ if (dailyBudgetUsd === null) {
280
+ db.prepare(`DELETE FROM queue_state WHERE key = 'config.daily_budget_usd'`).run();
281
+ }
282
+ else if (typeof dailyBudgetUsd === 'number' && dailyBudgetUsd > 0) {
283
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.daily_budget_usd', ?)`).run(String(dailyBudgetUsd));
284
+ }
285
+ }
286
+ if (jobCostThresholdUsd !== undefined) {
287
+ if (jobCostThresholdUsd === null) {
288
+ db.prepare(`DELETE FROM queue_state WHERE key = 'config.job_cost_threshold_usd'`).run();
289
+ }
290
+ else if (typeof jobCostThresholdUsd === 'number' && jobCostThresholdUsd > 0) {
291
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.job_cost_threshold_usd', ?)`).run(String(jobCostThresholdUsd));
292
+ }
293
+ }
294
+ res.json({ ok: true });
295
+ }
296
+ catch (err) {
297
+ console.error('[project-router] budget patch error:', err);
298
+ res.status(500).json({ error: 'Failed to update budget' });
299
+ }
300
+ });
301
+ router.get('/:projectId/issues', (req, res) => {
302
+ const { project, db } = ctx(req);
303
+ try {
304
+ const config = (0, config_1.getConfig)(project.path, db, project.name);
305
+ const tracker = config.issueTracker.active;
306
+ if (!tracker) {
307
+ res.status(503).json({ error: 'No issue tracker configured', trackers: config.issueTracker });
308
+ return;
309
+ }
310
+ const search = req.query.search;
311
+ const label = req.query.label;
312
+ const issues = (0, config_1.fetchIssues)(tracker, { search, label, repo: config.project.repo, cwd: project.path });
313
+ res.json(issues);
314
+ }
315
+ catch (err) {
316
+ console.error('[project-router] issues error:', err);
317
+ res.status(500).json({ error: 'Failed to fetch issues' });
318
+ }
319
+ });
320
+ }
@@ -0,0 +1,312 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerTerminalsRoutes = registerTerminalsRoutes;
4
+ const terminal_settings_1 = require("./terminal-settings");
5
+ const feature_flags_1 = require("./feature-flags");
6
+ const browser_capture_types_1 = require("./browser-capture-types");
7
+ const terminal_manager_1 = require("./terminal-manager");
8
+ const project_router_helpers_1 = require("./project-router-helpers");
9
+ function registerTerminalsRoutes(deps) {
10
+ const { router, registry, ctx, ticketPath } = deps;
11
+ // ─── Terminals ───────────────────────────────────────────────────────────────
12
+ function requireTerminalsEnabled(_req, res, next) {
13
+ if (!project_router_helpers_1.TERMINAL_PANEL_ENABLED) {
14
+ res.status(404).json({ error: 'Terminal panel disabled' });
15
+ return;
16
+ }
17
+ next();
18
+ }
19
+ router.get('/:projectId/terminals', requireTerminalsEnabled, (req, res) => {
20
+ const projectId = ctx(req).project.id;
21
+ const sessions = (0, terminal_manager_1.getTerminalManager)().listForProject(projectId);
22
+ res.json({ sessions, limit: terminal_manager_1.TERMINAL_MAX_PER_PROJECT });
23
+ });
24
+ router.post('/:projectId/terminals', requireTerminalsEnabled, (req, res) => {
25
+ const { cols, rows, name } = req.body ?? {};
26
+ const projectCtx = ctx(req);
27
+ const project = projectCtx.project;
28
+ const settings = (0, terminal_settings_1.resolveTerminalSettings)(registry.desktopDb, projectCtx.db);
29
+ try {
30
+ const meta = (0, terminal_manager_1.getTerminalManager)().create(project.id, {
31
+ cwd: project.path,
32
+ cols: typeof cols === 'number' ? cols : undefined,
33
+ rows: typeof rows === 'number' ? rows : undefined,
34
+ name: typeof name === 'string' ? name : undefined,
35
+ projectSlug: project.slug,
36
+ projectDb: projectCtx.db,
37
+ settings,
38
+ });
39
+ res.status(201).json({ session: meta });
40
+ }
41
+ catch (err) {
42
+ if (err instanceof terminal_manager_1.TerminalLimitExceededError) {
43
+ res.status(409).json({ error: 'terminal_limit_exceeded', limit: terminal_manager_1.TERMINAL_MAX_PER_PROJECT });
44
+ return;
45
+ }
46
+ if (err instanceof terminal_manager_1.TerminalNameInvalidError) {
47
+ res.status(400).json({ error: 'terminal_name_invalid' });
48
+ return;
49
+ }
50
+ if (err instanceof terminal_manager_1.TerminalSpawnError) {
51
+ // The shell failed to spawn (commonly the host running out of file
52
+ // descriptors). Surface a concrete, actionable reason — a bare 500 hid
53
+ // this and made the "+" button look like it did nothing.
54
+ console.error('[project-router] terminal spawn failed:', err.reason, err.message);
55
+ res.status(502).json({ error: 'terminal_spawn_failed', reason: err.reason });
56
+ return;
57
+ }
58
+ console.error('[project-router] terminal create error:', err);
59
+ res.status(500).json({ error: 'Failed to create terminal' });
60
+ }
61
+ });
62
+ router.patch('/:projectId/terminals/:id', requireTerminalsEnabled, (req, res) => {
63
+ const { name } = req.body ?? {};
64
+ if (typeof name !== 'string') {
65
+ res.status(400).json({ error: 'name is required' });
66
+ return;
67
+ }
68
+ const projectId = ctx(req).project.id;
69
+ try {
70
+ const meta = (0, terminal_manager_1.getTerminalManager)().rename(projectId, req.params.id, name);
71
+ res.json({ session: meta });
72
+ }
73
+ catch (err) {
74
+ if (err instanceof terminal_manager_1.TerminalNotFoundError) {
75
+ res.status(404).json({ error: 'terminal_not_found' });
76
+ return;
77
+ }
78
+ if (err instanceof terminal_manager_1.TerminalNameInvalidError) {
79
+ res.status(400).json({ error: 'terminal_name_invalid' });
80
+ return;
81
+ }
82
+ console.error('[project-router] terminal rename error:', err);
83
+ res.status(500).json({ error: 'Failed to rename terminal' });
84
+ }
85
+ });
86
+ router.delete('/:projectId/terminals/:id', requireTerminalsEnabled, (req, res) => {
87
+ const projectId = ctx(req).project.id;
88
+ const ok = (0, terminal_manager_1.getTerminalManager)().kill(projectId, req.params.id);
89
+ if (!ok) {
90
+ res.status(404).json({ error: 'terminal_not_found' });
91
+ return;
92
+ }
93
+ res.json({ ok: true });
94
+ });
95
+ // ─── Embedded browser ("Add Spec from browser") ──────────────────────────────
96
+ function requireBrowserCaptureEnabled(_req, res, next) {
97
+ if (!(0, feature_flags_1.isBrowserCaptureEnabled)()) {
98
+ res.status(404).json({ error: 'browser_capture_disabled' });
99
+ return;
100
+ }
101
+ next();
102
+ }
103
+ function parseRect(raw) {
104
+ if (!raw || typeof raw !== 'object')
105
+ return null;
106
+ const r = raw;
107
+ const nums = [r.x, r.y, r.width, r.height];
108
+ if (!nums.every((n) => typeof n === 'number' && Number.isFinite(n)))
109
+ return null;
110
+ const x = r.x, y = r.y, width = r.width, height = r.height;
111
+ if (width <= 0 || height <= 0)
112
+ return null;
113
+ if (x < 0 || y < 0)
114
+ return null;
115
+ // Upper bound guards against an over-read request far past any real viewport.
116
+ if (x + width > 20000 || y + height > 20000)
117
+ return null;
118
+ return { x, y, width, height };
119
+ }
120
+ // pendingSpecId becomes a filesystem path segment inside attachmentManager
121
+ // (~/.specrails/projects/<slug>/attachments/<pendingSpecId>/). Reject anything
122
+ // that isn't a safe opaque token so a crafted value can't traverse the tree.
123
+ const SAFE_PENDING_ID = /^[A-Za-z0-9_-]{1,128}$/;
124
+ router.get('/:projectId/browser/sessions', requireBrowserCaptureEnabled, (req, res) => {
125
+ const mgr = ctx(req).browserCaptureManager;
126
+ res.json({ sessions: mgr.listSessions(), lastUrl: mgr.getLastUrl() });
127
+ });
128
+ router.post('/:projectId/browser/sessions', requireBrowserCaptureEnabled, async (req, res) => {
129
+ const mgr = ctx(req).browserCaptureManager;
130
+ const initialUrl = typeof req.body?.initialUrl === 'string' ? req.body.initialUrl : undefined;
131
+ try {
132
+ const session = await mgr.create({ initialUrl });
133
+ res.status(201).json({ session });
134
+ }
135
+ catch (err) {
136
+ if (err instanceof browser_capture_types_1.BrowserLimitExceededError) {
137
+ res.status(409).json({ error: 'browser_session_limit_exceeded', limit: err.limit });
138
+ return;
139
+ }
140
+ if (err instanceof browser_capture_types_1.BrowserLaunchError) {
141
+ console.error('[project-router] browser launch failed:', err.cause?.message ?? err.message);
142
+ res.status(502).json({ error: 'browser_launch_failed' });
143
+ return;
144
+ }
145
+ console.error('[project-router] browser session create error:', err);
146
+ res.status(500).json({ error: 'Failed to create browser session' });
147
+ }
148
+ });
149
+ router.post('/:projectId/browser/sessions/:id/navigate', requireBrowserCaptureEnabled, async (req, res) => {
150
+ const mgr = ctx(req).browserCaptureManager;
151
+ const action = req.body?.action ?? 'goto';
152
+ const validActions = new Set(['goto', 'back', 'forward', 'reload']);
153
+ if (!validActions.has(action)) {
154
+ res.status(400).json({ error: 'action must be one of: goto, back, forward, reload' });
155
+ return;
156
+ }
157
+ let url;
158
+ if (action === 'goto') {
159
+ url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
160
+ if (!url) {
161
+ res.status(400).json({ error: 'url is required for goto' });
162
+ return;
163
+ }
164
+ // Only allow web schemes (or bare hosts the manager will https-prefix).
165
+ // Blocks file://, data:, javascript: etc. from reaching the embedded browser.
166
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^https?:\/\//i.test(url)) {
167
+ res.status(400).json({ error: 'only http(s) URLs are allowed' });
168
+ return;
169
+ }
170
+ }
171
+ try {
172
+ const result = await mgr.navigate(req.params.id, action, url);
173
+ if (!result) {
174
+ res.status(404).json({ error: 'browser_session_not_found' });
175
+ return;
176
+ }
177
+ res.json(result);
178
+ }
179
+ catch (err) {
180
+ console.error('[project-router] browser navigate error:', err);
181
+ res.status(500).json({ error: 'Failed to navigate' });
182
+ }
183
+ });
184
+ router.post('/:projectId/browser/sessions/:id/capture', requireBrowserCaptureEnabled, async (req, res) => {
185
+ const mgr = ctx(req).browserCaptureManager;
186
+ const rect = parseRect(req.body?.rect);
187
+ if (!rect) {
188
+ res.status(400).json({ error: 'rect {x,y,width,height} with positive size is required' });
189
+ return;
190
+ }
191
+ const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId.trim() : '';
192
+ if (!pendingSpecId) {
193
+ res.status(400).json({ error: 'pendingSpecId is required' });
194
+ return;
195
+ }
196
+ if (!SAFE_PENDING_ID.test(pendingSpecId)) {
197
+ res.status(400).json({ error: 'pendingSpecId has an invalid format' });
198
+ return;
199
+ }
200
+ const captureNetwork = req.body?.captureNetwork !== false;
201
+ try {
202
+ const result = await mgr.capture(req.params.id, rect, pendingSpecId, { captureNetwork });
203
+ if (!result) {
204
+ res.status(404).json({ error: 'browser_session_not_found' });
205
+ return;
206
+ }
207
+ res.json(result);
208
+ }
209
+ catch (err) {
210
+ console.error('[project-router] browser capture error:', err);
211
+ res.status(500).json({ error: 'Failed to capture' });
212
+ }
213
+ });
214
+ router.post('/:projectId/browser/sessions/:id/capture-breakpoints', requireBrowserCaptureEnabled, async (req, res) => {
215
+ const mgr = ctx(req).browserCaptureManager;
216
+ const rect = parseRect(req.body?.rect);
217
+ if (!rect) {
218
+ res.status(400).json({ error: 'rect {x,y,width,height} with positive size is required' });
219
+ return;
220
+ }
221
+ const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId.trim() : '';
222
+ if (!pendingSpecId || !SAFE_PENDING_ID.test(pendingSpecId)) {
223
+ res.status(400).json({ error: 'pendingSpecId is required and must be well-formed' });
224
+ return;
225
+ }
226
+ // Validate the per-breakpoint viewport dims (client-supplied; single source).
227
+ const rawDims = req.body?.breakpoints;
228
+ const dims = {};
229
+ if (rawDims && typeof rawDims === 'object') {
230
+ for (const [k, v] of Object.entries(rawDims)) {
231
+ if (Object.keys(dims).length >= 4)
232
+ break;
233
+ if (!/^[a-z0-9_-]{1,20}$/i.test(k))
234
+ continue;
235
+ const d = v;
236
+ const w = Math.round(Number(d?.width));
237
+ const h = Math.round(Number(d?.height));
238
+ if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1 && w <= 4000 && h <= 4000) {
239
+ dims[k] = { width: w, height: h };
240
+ }
241
+ }
242
+ }
243
+ if (Object.keys(dims).length === 0) {
244
+ res.status(400).json({ error: 'breakpoints {name:{width,height}} is required' });
245
+ return;
246
+ }
247
+ const a = req.body?.anchorPoint;
248
+ const anchorPoint = a && typeof a.x === 'number' && typeof a.y === 'number'
249
+ ? { x: a.x, y: a.y }
250
+ : { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
251
+ try {
252
+ const result = await mgr.captureBreakpoints(req.params.id, rect, anchorPoint, pendingSpecId, dims);
253
+ if (!result) {
254
+ res.status(404).json({ error: 'browser_session_not_found' });
255
+ return;
256
+ }
257
+ res.json(result);
258
+ }
259
+ catch (err) {
260
+ console.error('[project-router] browser capture-breakpoints error:', err);
261
+ res.status(500).json({ error: 'Failed to capture' });
262
+ }
263
+ });
264
+ router.post('/:projectId/browser/sessions/:id/element', requireBrowserCaptureEnabled, async (req, res) => {
265
+ const mgr = ctx(req).browserCaptureManager;
266
+ const selector = typeof req.body?.selector === 'string' ? req.body.selector : '';
267
+ const direction = req.body?.direction;
268
+ if (!selector || (direction !== 'parent' && direction !== 'child' && direction !== 'self')) {
269
+ res.status(400).json({ error: 'selector and direction (parent|child|self) are required' });
270
+ return;
271
+ }
272
+ try {
273
+ // probe may be null (can't step further / element gone) — still 200.
274
+ const probe = await mgr.navigateElement(req.params.id, selector.slice(0, 4000), direction);
275
+ res.json({ probe });
276
+ }
277
+ catch (err) {
278
+ console.error('[project-router] browser element navigate error:', err);
279
+ res.status(500).json({ error: 'Failed to resolve element' });
280
+ }
281
+ });
282
+ router.post('/:projectId/browser/sessions/:id/clipboard', requireBrowserCaptureEnabled, async (req, res) => {
283
+ const mgr = ctx(req).browserCaptureManager;
284
+ const action = req.body?.action;
285
+ if (action !== 'copy' && action !== 'paste' && action !== 'cut') {
286
+ res.status(400).json({ error: 'action must be copy | paste | cut' });
287
+ return;
288
+ }
289
+ const text = typeof req.body?.text === 'string' ? req.body.text.slice(0, 100_000) : undefined;
290
+ try {
291
+ const out = await mgr.clipboard(req.params.id, action, text);
292
+ if (!out) {
293
+ res.status(404).json({ error: 'browser_session_not_found' });
294
+ return;
295
+ }
296
+ res.json(out);
297
+ }
298
+ catch (err) {
299
+ console.error('[project-router] browser clipboard error:', err);
300
+ res.status(500).json({ error: 'Failed clipboard op' });
301
+ }
302
+ });
303
+ router.delete('/:projectId/browser/sessions/:id', requireBrowserCaptureEnabled, async (req, res) => {
304
+ const mgr = ctx(req).browserCaptureManager;
305
+ const ok = await mgr.kill(req.params.id);
306
+ if (!ok) {
307
+ res.status(404).json({ error: 'browser_session_not_found' });
308
+ return;
309
+ }
310
+ res.json({ ok: true });
311
+ });
312
+ }