specrails-desktop 2.2.0 → 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 (63) 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-D6LE6wG2.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
  4. package/client/dist/assets/{BarChart-B366kDEj.js → BarChart-CMdLa6Es.js} +2 -2
  5. package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-DG5LA_WO.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-ChQ1oXLC.js → DocsDialog-D8yoyZDD.js} +2 -2
  8. package/client/dist/assets/{DocsPage-BfGH8NUf.js → DocsPage-CeO-fAxy.js} +2 -2
  9. package/client/dist/assets/{ExportDropdown-9tRrlfM7.js → ExportDropdown-DuoZcdYN.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-DANIzihd.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-BvQ52Q67.js → dist-js-4UEGaKhD.js} +1 -1
  22. package/client/dist/assets/{dist-js-XEilFTNz.js → dist-js-H6hyhSuv.js} +1 -1
  23. package/client/dist/assets/{index-CNiaj7Sj.js → index-CGHKpC-N.js} +13 -13
  24. package/client/dist/assets/index-D17R4Cjc.css +2 -0
  25. package/client/dist/assets/{lib-DZJmnErt.js → lib-Cs5FrUJI.js} +1 -1
  26. package/client/dist/assets/{useProjectCache-H0T8Ot9j.js → useProjectCache-BZWYV-w-.js} +1 -1
  27. package/client/dist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/server/dist/agent-refine-manager.js +128 -153
  30. package/server/dist/chat-manager.js +246 -0
  31. package/server/dist/code-explorer-router.js +78 -0
  32. package/server/dist/command-resolver.js +17 -0
  33. package/server/dist/contract-refine-runner.js +42 -10
  34. package/server/dist/db.js +6 -0
  35. package/server/dist/desktop-db.js +3 -0
  36. package/server/dist/explore-stdin-session.js +129 -0
  37. package/server/dist/mobile/mobile-auth.js +16 -0
  38. package/server/dist/project-router-chat.js +218 -0
  39. package/server/dist/project-router-helpers.js +275 -0
  40. package/server/dist/project-router-jobs.js +389 -0
  41. package/server/dist/project-router-settings.js +312 -0
  42. package/server/dist/project-router-setup.js +456 -0
  43. package/server/dist/project-router-spending.js +320 -0
  44. package/server/dist/project-router-terminals.js +312 -0
  45. package/server/dist/project-router-tickets.js +1767 -0
  46. package/server/dist/project-router.js +27 -3943
  47. package/server/dist/providers/claude-adapter.js +58 -17
  48. package/server/dist/providers/codex-adapter.js +6 -0
  49. package/server/dist/spawn-lifecycle.js +117 -0
  50. package/client/dist/assets/ActivityFeedPage-BupGdGjj.js +0 -1
  51. package/client/dist/assets/AgentsPage-F3xksiLd.js +0 -86
  52. package/client/dist/assets/CodePage-DLwCJgQ0.js +0 -2
  53. package/client/dist/assets/JobDetailPage-1RtejIOB.js +0 -16
  54. package/client/dist/assets/JobsPage-NuDf5Zbx.js +0 -1
  55. package/client/dist/assets/code-AL1rVIMb.js +0 -1
  56. package/client/dist/assets/code-C0BKpkht.js +0 -1
  57. package/client/dist/assets/code-C0FTS3ew.js +0 -1
  58. package/client/dist/assets/code-CPcHxzxw.js +0 -1
  59. package/client/dist/assets/code-D3ryDniw.js +0 -1
  60. package/client/dist/assets/code-D3zVVQTj.js +0 -1
  61. package/client/dist/assets/code-PCmfS3dn.js +0 -1
  62. package/client/dist/assets/code-exI0G5Wd.js +0 -1
  63. package/client/dist/assets/index-DgFfrrTX.css +0 -2
@@ -0,0 +1,389 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerJobsRoutes = registerJobsRoutes;
4
+ const db_1 = require("./db");
5
+ const queue_manager_1 = require("./queue-manager");
6
+ const types_1 = require("./types");
7
+ const hooks_1 = require("./hooks");
8
+ const explore_smash_1 = require("./explore-smash");
9
+ const spec_models_1 = require("./spec-models");
10
+ const provider_selection_1 = require("./provider-selection");
11
+ const metrics_1 = require("./metrics");
12
+ const ticket_store_1 = require("./ticket-store");
13
+ const project_router_helpers_1 = require("./project-router-helpers");
14
+ function registerJobsRoutes(deps) {
15
+ const { router, registry, ctx, ticketPath } = deps;
16
+ // ─── Queue / Spawn routes ────────────────────────────────────────────────────
17
+ router.post('/:projectId/spawn', (req, res) => {
18
+ const { command, priority, dependsOnJobId, pipelineId, profileName, aiEngine } = req.body ?? {};
19
+ if (!command || typeof command !== 'string' || !command.trim()) {
20
+ res.status(400).json({ error: 'command is required' });
21
+ return;
22
+ }
23
+ if (priority !== undefined && !types_1.VALID_PRIORITIES.has(priority)) {
24
+ res.status(400).json({ error: 'priority must be one of: low, normal, high, critical' });
25
+ return;
26
+ }
27
+ // profileName accepts: undefined (default resolution), null (force legacy), string (explicit)
28
+ const normalizedProfileName = profileName === null ? null
29
+ : typeof profileName === 'string' && profileName.trim() ? profileName.trim()
30
+ : undefined;
31
+ // aiEngine: optional per-job provider override; must be installed on the
32
+ // project. Omitting it runs on the project's primary provider.
33
+ const engineCheck = (0, provider_selection_1.validateRequestedProvider)(ctx(req).project, aiEngine);
34
+ if (!engineCheck.ok) {
35
+ res.status(400).json({ error: engineCheck.error });
36
+ return;
37
+ }
38
+ try {
39
+ const job = ctx(req).queueManager.enqueue(command, priority ?? 'normal', {
40
+ dependsOnJobId: dependsOnJobId || undefined,
41
+ pipelineId: pipelineId || undefined,
42
+ profileName: normalizedProfileName,
43
+ provider: aiEngine ? engineCheck.provider : undefined,
44
+ });
45
+ const position = job.queuePosition ?? 0;
46
+ res.status(202).json({ jobId: job.id, position });
47
+ }
48
+ catch (err) {
49
+ if (err instanceof queue_manager_1.ClaudeNotFoundError) {
50
+ res.status(400).json({ error: err.message });
51
+ }
52
+ else {
53
+ console.error('[project-router] spawn error:', err);
54
+ res.status(500).json({ error: 'Internal server error' });
55
+ }
56
+ }
57
+ });
58
+ // ─── Pipeline routes ──────────────────────────────────────────────────────────
59
+ // NOTE: Ad-hoc pipeline creation removed — use rails (templates) instead.
60
+ // The GET route remains for viewing existing pipeline status.
61
+ router.get('/:projectId/pipelines/:pipelineId', (req, res) => {
62
+ const { db } = ctx(req);
63
+ const pipelineId = req.params.pipelineId;
64
+ const jobs = (0, db_1.getPipelineJobs)(db, pipelineId);
65
+ if (jobs.length === 0) {
66
+ res.status(404).json({ error: 'Pipeline not found' });
67
+ return;
68
+ }
69
+ const allCompleted = jobs.every(j => j.status === 'completed');
70
+ const anyFailed = jobs.some(j => ['failed', 'skipped', 'canceled', 'zombie_terminated'].includes(j.status));
71
+ const status = allCompleted ? 'completed' : anyFailed ? 'failed' : 'running';
72
+ res.json({ pipelineId, status, jobs });
73
+ });
74
+ router.get('/:projectId/state', (req, res) => {
75
+ const { queueManager, project } = ctx(req);
76
+ res.json({
77
+ projectName: project.name,
78
+ projectId: project.id,
79
+ phases: (0, hooks_1.getPhaseStates)(),
80
+ busy: queueManager.getActiveJobId() !== null,
81
+ currentJobId: queueManager.getActiveJobId(),
82
+ featureFlags: {
83
+ smash: !(0, explore_smash_1.isSpecsSmashKillSwitchActive)(),
84
+ },
85
+ });
86
+ });
87
+ // Returns the resolved default model for Add Spec + the full provider
88
+ // allow-list so the modal can render its picker without maintaining its
89
+ // own copy of the model lists. Source of truth is `server/spec-models.ts`.
90
+ router.get('/:projectId/default-spec-model', (req, res) => {
91
+ const { project } = ctx(req);
92
+ // Multi-provider: an optional ?provider= query selects which engine's models
93
+ // to return. It must be one the project actually has installed; an invalid
94
+ // or omitted value falls back to the project's primary provider. The
95
+ // response also lists every installed provider so the Add Spec modal can
96
+ // render its AI Engine selector without a second round-trip.
97
+ const provider = (0, provider_selection_1.resolveProvider)(project, typeof req.query.provider === 'string' ? req.query.provider : undefined);
98
+ const model = (0, project_router_helpers_1.resolveDefaultSpecModel)({ projectPath: project.path, provider });
99
+ const allowed = (0, spec_models_1.getModelsForProvider)(provider);
100
+ res.json({ model, provider, allowed, providers: project.providers });
101
+ });
102
+ router.delete('/:projectId/jobs/:id', (req, res) => {
103
+ try {
104
+ const result = ctx(req).queueManager.cancel(req.params.id);
105
+ res.json({ ok: true, status: result });
106
+ }
107
+ catch (err) {
108
+ if (err instanceof queue_manager_1.JobNotFoundError) {
109
+ res.status(404).json({ error: 'Job not found' });
110
+ }
111
+ else if (err instanceof queue_manager_1.JobAlreadyTerminalError) {
112
+ // Job already finished — delete it from the DB
113
+ (0, db_1.deleteJob)(ctx(req).db, req.params.id);
114
+ res.json({ ok: true, status: 'deleted' });
115
+ }
116
+ else {
117
+ res.status(500).json({ error: 'Internal server error' });
118
+ }
119
+ }
120
+ });
121
+ router.patch('/:projectId/jobs/:id/priority', (req, res) => {
122
+ const { priority } = req.body ?? {};
123
+ if (!priority || !types_1.VALID_PRIORITIES.has(priority)) {
124
+ res.status(400).json({ error: 'priority must be one of: low, normal, high, critical' });
125
+ return;
126
+ }
127
+ try {
128
+ ctx(req).queueManager.updatePriority(req.params.id, priority);
129
+ res.json({ ok: true });
130
+ }
131
+ catch (err) {
132
+ if (err instanceof queue_manager_1.JobNotFoundError) {
133
+ res.status(404).json({ error: 'Job not found' });
134
+ }
135
+ else {
136
+ res.status(400).json({ error: err.message });
137
+ }
138
+ }
139
+ });
140
+ router.post('/:projectId/queue/pause', (req, res) => {
141
+ ctx(req).queueManager.pause();
142
+ res.json({ ok: true, paused: true });
143
+ });
144
+ router.post('/:projectId/queue/resume', (req, res) => {
145
+ ctx(req).queueManager.resume();
146
+ res.json({ ok: true, paused: false });
147
+ });
148
+ router.put('/:projectId/queue/reorder', (req, res) => {
149
+ const { jobIds } = req.body ?? {};
150
+ if (!Array.isArray(jobIds)) {
151
+ res.status(400).json({ error: 'jobIds must be an array' });
152
+ return;
153
+ }
154
+ try {
155
+ ctx(req).queueManager.reorder(jobIds);
156
+ res.json({ ok: true, queue: jobIds });
157
+ }
158
+ catch (err) {
159
+ res.status(400).json({ error: err.message });
160
+ }
161
+ });
162
+ router.get('/:projectId/queue', (req, res) => {
163
+ const { queueManager } = ctx(req);
164
+ res.json({
165
+ jobs: queueManager.getJobs(),
166
+ paused: queueManager.isPaused(),
167
+ activeJobId: queueManager.getActiveJobId(),
168
+ });
169
+ });
170
+ router.get('/:projectId/jobs', (req, res) => {
171
+ // Clamp to [1, 200] (H-11): a negative limit is LIMIT -1 in SQLite, which
172
+ // means UNLIMITED — without the lower bound `?limit=-1` dumps the whole table.
173
+ const limit = Math.max(1, Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200));
174
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0;
175
+ const status = req.query.status;
176
+ const from = req.query.from;
177
+ const to = req.query.to;
178
+ const { db } = ctx(req);
179
+ const result = (0, db_1.listJobs)(db, { limit, offset, status, from, to });
180
+ // Merge in-memory queued jobs that haven't been persisted to DB yet
181
+ const { queueManager } = ctx(req);
182
+ const dbIds = new Set(result.jobs.map((j) => j.id));
183
+ const queuedRows = queueManager
184
+ .getJobs()
185
+ .filter((j) => j.status === 'queued' && !dbIds.has(j.id))
186
+ .filter((j) => !status || j.status === status)
187
+ .map((j) => ({
188
+ id: j.id,
189
+ command: j.command,
190
+ started_at: j.startedAt ?? new Date().toISOString(),
191
+ finished_at: j.finishedAt,
192
+ status: j.status,
193
+ exit_code: j.exitCode,
194
+ queue_position: j.queuePosition,
195
+ priority: j.priority,
196
+ tokens_in: null,
197
+ tokens_out: null,
198
+ tokens_cache_read: null,
199
+ tokens_cache_create: null,
200
+ total_cost_usd: null,
201
+ num_turns: null,
202
+ model: null,
203
+ duration_ms: null,
204
+ duration_api_ms: null,
205
+ session_id: null,
206
+ depends_on_job_id: j.dependsOnJobId,
207
+ pipeline_id: j.pipelineId,
208
+ skip_reason: j.skipReason,
209
+ }));
210
+ if (queuedRows.length > 0) {
211
+ result.jobs = [...queuedRows, ...result.jobs];
212
+ result.total += queuedRows.length;
213
+ }
214
+ // Annotate each job with hasTelemetry so the client can show the
215
+ // Export diagnostic button without an extra round trip.
216
+ const jobsWithTelemetry = (0, db_1.getJobsWithTelemetry)(db);
217
+ const annotatedJobs = result.jobs.map((j) => ({
218
+ ...j,
219
+ hasTelemetry: jobsWithTelemetry.has(j.id),
220
+ }));
221
+ res.json({ jobs: annotatedJobs, total: result.total });
222
+ });
223
+ // ─── CSV helper ──────────────────────────────────────────────────────────────
224
+ const toCsv = (headers, rows) => {
225
+ const escape = (v) => {
226
+ const s = v == null ? '' : String(v);
227
+ return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
228
+ };
229
+ const lines = [headers.join(',')];
230
+ for (const row of rows) {
231
+ lines.push(headers.map(h => escape(row[h])).join(','));
232
+ }
233
+ return lines.join('\n');
234
+ };
235
+ // ─── Jobs export (must be before /:projectId/jobs/:id) ─────────────────────
236
+ router.get('/:projectId/jobs/export', (req, res) => {
237
+ const format = req.query.format || 'json';
238
+ if (format !== 'json' && format !== 'csv') {
239
+ res.status(400).json({ error: 'Invalid format. Must be json or csv' });
240
+ return;
241
+ }
242
+ const from = req.query.from;
243
+ const to = req.query.to;
244
+ const { db } = ctx(req);
245
+ const conditions = [];
246
+ const params = [];
247
+ if (from) {
248
+ conditions.push('started_at >= ?');
249
+ params.push(from);
250
+ }
251
+ if (to) {
252
+ conditions.push('started_at <= ?');
253
+ params.push(to);
254
+ }
255
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
256
+ const jobs = db
257
+ .prepare(`SELECT * FROM jobs ${where} ORDER BY started_at DESC LIMIT 10000`)
258
+ .all(...params);
259
+ if (format === 'csv') {
260
+ const headers = ['id', 'command', 'status', 'started_at', 'finished_at', 'duration_ms', 'tokens_in', 'tokens_out', 'tokens_cache_read', 'total_cost_usd', 'model'];
261
+ const csv = toCsv(headers, jobs);
262
+ res.setHeader('Content-Type', 'text/csv');
263
+ res.setHeader('Content-Disposition', 'attachment; filename="jobs-export.csv"');
264
+ res.send(csv);
265
+ }
266
+ else {
267
+ res.json({ jobs });
268
+ }
269
+ });
270
+ // Must be registered BEFORE /:projectId/jobs/:id, otherwise Express matches
271
+ // the parameterized route first with id='compare' and this never runs (the
272
+ // Job Comparison feature would always 404).
273
+ router.get('/:projectId/jobs/compare', (req, res) => {
274
+ const raw = req.query.jobIds;
275
+ if (!raw) {
276
+ res.status(400).json({ error: 'jobIds query param required (comma-separated, exactly 2)' });
277
+ return;
278
+ }
279
+ const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
280
+ if (ids.length !== 2) {
281
+ res.status(400).json({ error: 'Exactly 2 jobIds are required' });
282
+ return;
283
+ }
284
+ const { db } = ctx(req);
285
+ const rows = ids.map((id) => {
286
+ const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
287
+ if (!job)
288
+ return null;
289
+ const phases = db.prepare("SELECT phase FROM job_phases WHERE job_id = ? AND state = 'done' ORDER BY updated_at ASC").all(id);
290
+ return {
291
+ id: job.id,
292
+ command: job.command,
293
+ status: job.status,
294
+ startedAt: job.started_at,
295
+ finishedAt: job.finished_at,
296
+ durationMs: job.duration_ms,
297
+ tokensIn: job.tokens_in,
298
+ tokensOut: job.tokens_out,
299
+ tokensCacheRead: job.tokens_cache_read,
300
+ totalCostUsd: job.total_cost_usd,
301
+ model: job.model,
302
+ phasesCompleted: phases.map((p) => p.phase),
303
+ };
304
+ });
305
+ const missing = ids.filter((_, i) => rows[i] === null);
306
+ if (missing.length > 0) {
307
+ res.status(404).json({ error: `Jobs not found: ${missing.join(', ')}` });
308
+ return;
309
+ }
310
+ res.json({ jobs: rows });
311
+ });
312
+ router.get('/:projectId/jobs/:id', (req, res) => {
313
+ const { db, queueManager, project } = ctx(req);
314
+ const jobId = req.params.id;
315
+ const job = (0, db_1.getJob)(db, jobId);
316
+ if (!job) {
317
+ // Queued jobs live only in memory until spawn time (createJob runs on spawn,
318
+ // not enqueue). Fall back to the in-memory queue so /jobs/:id returns a
319
+ // usable payload instead of 404 — the detail page then renders a "queued"
320
+ // state and flips to live logs via WS once the job starts.
321
+ const inMemory = queueManager.getJobs().find((j) => j.id === jobId);
322
+ if (!inMemory) {
323
+ res.status(404).json({ error: 'Job not found' });
324
+ return;
325
+ }
326
+ const synthetic = {
327
+ id: inMemory.id,
328
+ command: inMemory.command,
329
+ started_at: inMemory.startedAt ?? '',
330
+ finished_at: inMemory.finishedAt,
331
+ status: inMemory.status,
332
+ exit_code: inMemory.exitCode,
333
+ queue_position: inMemory.queuePosition,
334
+ priority: inMemory.priority,
335
+ tokens_in: null,
336
+ tokens_out: null,
337
+ tokens_cache_read: null,
338
+ tokens_cache_create: null,
339
+ total_cost_usd: null,
340
+ num_turns: null,
341
+ model: null,
342
+ duration_ms: null,
343
+ duration_api_ms: null,
344
+ session_id: null,
345
+ depends_on_job_id: inMemory.dependsOnJobId,
346
+ pipeline_id: inMemory.pipelineId,
347
+ skip_reason: inMemory.skipReason,
348
+ };
349
+ const phaseDefinitions = queueManager.phasesForCommand(synthetic.command);
350
+ const tickets = (0, ticket_store_1.resolveTicketsFromCommand)(project.path, synthetic.command);
351
+ res.json({ job: { ...synthetic, hasTelemetry: false, tickets }, events: [], phaseDefinitions });
352
+ return;
353
+ }
354
+ const events = (0, db_1.getJobEvents)(db, jobId);
355
+ const phaseDefinitions = queueManager.phasesForCommand(job.command);
356
+ const tickets = (0, ticket_store_1.resolveTicketsFromCommand)(project.path, job.command);
357
+ const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets };
358
+ res.json({ job: annotated, events, phaseDefinitions });
359
+ });
360
+ router.delete('/:projectId/jobs', (req, res) => {
361
+ try {
362
+ const { from, to } = req.body ?? {};
363
+ const deleted = (0, db_1.purgeJobs)(ctx(req).db, { from, to });
364
+ res.json({ ok: true, deleted });
365
+ }
366
+ catch (err) {
367
+ console.error('[project-router] purge error:', err);
368
+ res.status(500).json({ error: 'Failed to purge jobs' });
369
+ }
370
+ });
371
+ router.get('/:projectId/activity', (req, res) => {
372
+ const limit = Math.min(Math.max(1, parseInt(String(req.query.limit ?? '50'), 10) || 50), 100);
373
+ const before = req.query.before;
374
+ res.json((0, db_1.getProjectActivity)(ctx(req).db, { limit, before }));
375
+ });
376
+ router.get('/:projectId/stats', (req, res) => {
377
+ res.json((0, db_1.getStats)(ctx(req).db));
378
+ });
379
+ router.get('/:projectId/metrics', (req, res) => {
380
+ const { project, db } = ctx(req);
381
+ try {
382
+ res.json((0, metrics_1.getProjectMetrics)(project.path, db));
383
+ }
384
+ catch (err) {
385
+ console.error('[project-router] metrics error:', err);
386
+ res.status(500).json({ error: 'Failed to compute metrics' });
387
+ }
388
+ });
389
+ }