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.
- package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
- package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
- package/client/dist/assets/{AnalyticsPage-D6LE6wG2.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
- package/client/dist/assets/{BarChart-B366kDEj.js → BarChart-CMdLa6Es.js} +2 -2
- package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-DG5LA_WO.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
- package/client/dist/assets/{DocsDialog-ChQ1oXLC.js → DocsDialog-D8yoyZDD.js} +2 -2
- package/client/dist/assets/{DocsPage-BfGH8NUf.js → DocsPage-CeO-fAxy.js} +2 -2
- package/client/dist/assets/{ExportDropdown-9tRrlfM7.js → ExportDropdown-DuoZcdYN.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-DANIzihd.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
- package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
- package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
- package/client/dist/assets/code-BtsmPQLV.js +1 -0
- package/client/dist/assets/code-CY85RXZU.js +1 -0
- package/client/dist/assets/code-Coa8f2Sh.js +1 -0
- package/client/dist/assets/code-D1z-YDt-.js +1 -0
- package/client/dist/assets/code-DDU0CRS0.js +1 -0
- package/client/dist/assets/code-L35Loak_.js +1 -0
- package/client/dist/assets/code-g0qFMzyg.js +1 -0
- package/client/dist/assets/code-zCwBt3Uu.js +1 -0
- package/client/dist/assets/{dist-js-BvQ52Q67.js → dist-js-4UEGaKhD.js} +1 -1
- package/client/dist/assets/{dist-js-XEilFTNz.js → dist-js-H6hyhSuv.js} +1 -1
- package/client/dist/assets/{index-CNiaj7Sj.js → index-CGHKpC-N.js} +13 -13
- package/client/dist/assets/index-D17R4Cjc.css +2 -0
- package/client/dist/assets/{lib-DZJmnErt.js → lib-Cs5FrUJI.js} +1 -1
- package/client/dist/assets/{useProjectCache-H0T8Ot9j.js → useProjectCache-BZWYV-w-.js} +1 -1
- package/client/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/dist/agent-refine-manager.js +128 -153
- package/server/dist/chat-manager.js +246 -0
- package/server/dist/code-explorer-router.js +78 -0
- package/server/dist/command-resolver.js +17 -0
- package/server/dist/contract-refine-runner.js +42 -10
- package/server/dist/db.js +6 -0
- package/server/dist/desktop-db.js +3 -0
- package/server/dist/explore-stdin-session.js +129 -0
- package/server/dist/mobile/mobile-auth.js +16 -0
- package/server/dist/project-router-chat.js +218 -0
- package/server/dist/project-router-helpers.js +275 -0
- package/server/dist/project-router-jobs.js +389 -0
- package/server/dist/project-router-settings.js +312 -0
- package/server/dist/project-router-setup.js +456 -0
- package/server/dist/project-router-spending.js +320 -0
- package/server/dist/project-router-terminals.js +312 -0
- package/server/dist/project-router-tickets.js +1767 -0
- package/server/dist/project-router.js +27 -3943
- package/server/dist/providers/claude-adapter.js +58 -17
- package/server/dist/providers/codex-adapter.js +6 -0
- package/server/dist/spawn-lifecycle.js +117 -0
- package/client/dist/assets/ActivityFeedPage-BupGdGjj.js +0 -1
- package/client/dist/assets/AgentsPage-F3xksiLd.js +0 -86
- package/client/dist/assets/CodePage-DLwCJgQ0.js +0 -2
- package/client/dist/assets/JobDetailPage-1RtejIOB.js +0 -16
- package/client/dist/assets/JobsPage-NuDf5Zbx.js +0 -1
- package/client/dist/assets/code-AL1rVIMb.js +0 -1
- package/client/dist/assets/code-C0BKpkht.js +0 -1
- package/client/dist/assets/code-C0FTS3ew.js +0 -1
- package/client/dist/assets/code-CPcHxzxw.js +0 -1
- package/client/dist/assets/code-D3ryDniw.js +0 -1
- package/client/dist/assets/code-D3zVVQTj.js +0 -1
- package/client/dist/assets/code-PCmfS3dn.js +0 -1
- package/client/dist/assets/code-exI0G5Wd.js +0 -1
- package/client/dist/assets/index-DgFfrrTX.css +0 -2
|
@@ -1,342 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.stripSpecMetadataSections =
|
|
40
|
-
exports.extractShortSummary = extractShortSummary;
|
|
41
|
-
exports.deriveFallbackShortSummary = deriveFallbackShortSummary;
|
|
42
|
-
exports.lightlyStructurePrompt = lightlyStructurePrompt;
|
|
43
|
-
exports.formatDescriptionWithCriteria = formatDescriptionWithCriteria;
|
|
44
|
-
exports.resolveDefaultSpecModel = resolveDefaultSpecModel;
|
|
3
|
+
exports.resolveDefaultSpecModel = exports.formatDescriptionWithCriteria = exports.lightlyStructurePrompt = exports.deriveFallbackShortSummary = exports.extractShortSummary = exports.stripSpecMetadataSections = void 0;
|
|
45
4
|
exports.createProjectRouter = createProjectRouter;
|
|
46
|
-
const fs_1 = __importDefault(require("fs"));
|
|
47
|
-
const path_1 = __importDefault(require("path"));
|
|
48
5
|
const express_1 = require("express");
|
|
49
|
-
const ids_1 = require("./ids");
|
|
50
|
-
const db_1 = require("./db");
|
|
51
|
-
const telemetry_export_1 = require("./telemetry-export");
|
|
52
|
-
const desktop_db_1 = require("./desktop-db");
|
|
53
|
-
const queue_manager_1 = require("./queue-manager");
|
|
54
|
-
const types_1 = require("./types");
|
|
55
|
-
const command_resolver_1 = require("./command-resolver");
|
|
56
|
-
const providers_1 = require("./providers");
|
|
57
6
|
const hooks_1 = require("./hooks");
|
|
58
|
-
const config_1 = require("./config");
|
|
59
|
-
const contract_refine_runner_1 = require("./contract-refine-runner");
|
|
60
|
-
const explore_contract_refine_1 = require("./explore-contract-refine");
|
|
61
|
-
const smash_runner_1 = require("./smash-runner");
|
|
62
|
-
const explore_smash_1 = require("./explore-smash");
|
|
63
|
-
const ai_invocations_1 = require("./ai-invocations");
|
|
64
|
-
const context_budget_1 = require("./context-budget");
|
|
65
|
-
const context_scope_1 = require("./context-scope");
|
|
66
|
-
const result_event_1 = require("./result-event");
|
|
67
|
-
const core_package_1 = require("./core-package");
|
|
68
|
-
const spending_1 = require("./spending");
|
|
69
|
-
const crypto_1 = require("crypto");
|
|
70
|
-
const spec_models_1 = require("./spec-models");
|
|
71
|
-
const provider_selection_1 = require("./provider-selection");
|
|
72
|
-
const changes_reader_1 = require("./changes-reader");
|
|
73
|
-
const metrics_1 = require("./metrics");
|
|
74
|
-
const ticket_store_1 = require("./ticket-store");
|
|
75
|
-
const explore_draft_title_1 = require("./explore-draft-title");
|
|
76
|
-
const cli_prompt_1 = require("./util/cli-prompt");
|
|
77
|
-
const readline_1 = require("readline");
|
|
78
|
-
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
79
|
-
const multer_1 = __importDefault(require("multer"));
|
|
80
7
|
const rails_router_1 = require("./rails-router");
|
|
81
8
|
const profiles_router_1 = require("./profiles-router");
|
|
82
9
|
const plugins_router_1 = require("./plugins-router");
|
|
83
10
|
const code_explorer_router_1 = require("./code-explorer-router");
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: ' {}';
|
|
102
|
-
const lines = [
|
|
103
|
-
'# specrails install config — generated by Specrails',
|
|
104
|
-
`# Re-run: npx ${core_package_1.CORE_PACKAGE_SPEC} init to regenerate`,
|
|
105
|
-
`version: ${c.version ?? 1}`,
|
|
106
|
-
`provider: ${c.provider ?? 'claude'}`,
|
|
107
|
-
`tier: ${c.tier ?? 'quick'}`,
|
|
108
|
-
`agents:`,
|
|
109
|
-
` selected: [${(c.agents?.selected ?? []).join(', ')}]`,
|
|
110
|
-
` excluded: [${(c.agents?.excluded ?? []).join(', ')}]`,
|
|
111
|
-
`models:`,
|
|
112
|
-
` preset: ${c.models?.preset ?? 'balanced'}`,
|
|
113
|
-
` defaults: { model: ${c.models?.defaults?.model ?? 'sonnet'} }`,
|
|
114
|
-
` overrides:${overridesYaml}`,
|
|
115
|
-
`agent_teams: ${c.agent_teams ?? false}`,
|
|
116
|
-
'',
|
|
117
|
-
];
|
|
118
|
-
return lines.join('\n');
|
|
119
|
-
}
|
|
120
|
-
// ─── Agent model helpers ──────────────────────────────────────────────────────
|
|
121
|
-
const VALID_MODEL_ALIASES = ['sonnet', 'opus', 'haiku'];
|
|
122
|
-
/**
|
|
123
|
-
* Read installed agents from `.claude/agents/*.md` (top-level only, no subdirs).
|
|
124
|
-
* Extracts the `model:` field from YAML frontmatter.
|
|
125
|
-
*/
|
|
126
|
-
function readAgentModels(projectPath) {
|
|
127
|
-
const agentsDir = path_1.default.join(projectPath, '.claude', 'agents');
|
|
128
|
-
if (!fs_1.default.existsSync(agentsDir))
|
|
129
|
-
return [];
|
|
130
|
-
let entries;
|
|
131
|
-
try {
|
|
132
|
-
entries = fs_1.default.readdirSync(agentsDir);
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
return [];
|
|
136
|
-
}
|
|
137
|
-
const agents = [];
|
|
138
|
-
for (const entry of entries) {
|
|
139
|
-
// Top-level .md files only — skip subdirs
|
|
140
|
-
if (!entry.endsWith('.md'))
|
|
141
|
-
continue;
|
|
142
|
-
const filePath = path_1.default.join(agentsDir, entry);
|
|
143
|
-
try {
|
|
144
|
-
const stat = fs_1.default.statSync(filePath);
|
|
145
|
-
if (!stat.isFile())
|
|
146
|
-
continue;
|
|
147
|
-
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
148
|
-
// Extract model from YAML frontmatter between --- markers
|
|
149
|
-
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
150
|
-
if (!frontmatterMatch)
|
|
151
|
-
continue;
|
|
152
|
-
const frontmatter = frontmatterMatch[1];
|
|
153
|
-
const modelMatch = frontmatter.match(/^model:\s*(.+)$/m);
|
|
154
|
-
const model = modelMatch ? modelMatch[1].trim() : 'sonnet';
|
|
155
|
-
agents.push({ name: entry.slice(0, -3), model });
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
// skip unreadable files
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return agents;
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Read `.specrails/install-config.yaml` and patch the `model:` line in each
|
|
165
|
-
* `.claude/agents/*.md` frontmatter to match the config's defaults/overrides.
|
|
166
|
-
* No-op if the config file does not exist.
|
|
167
|
-
*/
|
|
168
|
-
function applyModelConfig(projectPath) {
|
|
169
|
-
const configPath = path_1.default.join(projectPath, '.specrails', 'install-config.yaml');
|
|
170
|
-
if (!fs_1.default.existsSync(configPath))
|
|
171
|
-
return;
|
|
172
|
-
let configText;
|
|
173
|
-
try {
|
|
174
|
-
configText = fs_1.default.readFileSync(configPath, 'utf-8');
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
// Parse defaults.model
|
|
180
|
-
const defaultsMatch = configText.match(/defaults:\s*\{\s*model:\s*(\S+)\s*\}/);
|
|
181
|
-
const defaultModel = defaultsMatch ? defaultsMatch[1] : 'sonnet';
|
|
182
|
-
// Parse overrides block — lines like ` agentname: alias`
|
|
183
|
-
const overrides = {};
|
|
184
|
-
const overridesBlockMatch = configText.match(/overrides:([\s\S]*?)(?:\n\S|$)/);
|
|
185
|
-
if (overridesBlockMatch) {
|
|
186
|
-
const block = overridesBlockMatch[1];
|
|
187
|
-
const overrideLines = block.match(/^ {2,}(\S+):\s*(\S+)/gm) ?? [];
|
|
188
|
-
for (const line of overrideLines) {
|
|
189
|
-
const m = line.match(/^\s+(\S+):\s*(\S+)/);
|
|
190
|
-
if (m)
|
|
191
|
-
overrides[m[1]] = m[2];
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
const agentsDir = path_1.default.join(projectPath, '.claude', 'agents');
|
|
195
|
-
if (!fs_1.default.existsSync(agentsDir))
|
|
196
|
-
return;
|
|
197
|
-
let entries;
|
|
198
|
-
try {
|
|
199
|
-
entries = fs_1.default.readdirSync(agentsDir);
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
for (const entry of entries) {
|
|
205
|
-
if (!entry.endsWith('.md'))
|
|
206
|
-
continue;
|
|
207
|
-
const filePath = path_1.default.join(agentsDir, entry);
|
|
208
|
-
try {
|
|
209
|
-
const stat = fs_1.default.statSync(filePath);
|
|
210
|
-
if (!stat.isFile())
|
|
211
|
-
continue;
|
|
212
|
-
const agentName = entry.slice(0, -3);
|
|
213
|
-
const targetModel = overrides[agentName] ?? defaultModel;
|
|
214
|
-
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
215
|
-
const patched = content.replace(/^model: .+$/m, `model: ${targetModel}`);
|
|
216
|
-
if (patched !== content) {
|
|
217
|
-
fs_1.default.writeFileSync(filePath, patched, 'utf-8');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// skip unwritable files
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Strip the metadata sections (Spec Title, Labels, Estimated Complexity,
|
|
227
|
-
* Short Summary) from a generate-spec LLM response so they don't end up
|
|
228
|
-
* duplicated inside the ticket's description — they're already parsed into
|
|
229
|
-
* dedicated fields and re-rendered by the UI.
|
|
230
|
-
*/
|
|
231
|
-
function stripSpecMetadataSections(buffer) {
|
|
232
|
-
return buffer
|
|
233
|
-
.replace(/##\s*Spec Title\s*\n+[^\n]*\n*/i, '')
|
|
234
|
-
.replace(/##\s*Labels\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
|
|
235
|
-
.replace(/##\s*Estimated Complexity\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
|
|
236
|
-
.replace(/##\s*Short Summary\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
|
|
237
|
-
.trim();
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Extract the `## Short Summary` section body from a generate-spec response.
|
|
241
|
-
* Returns the raw multi-line string (the ticket-store `clampShortSummary`
|
|
242
|
-
* helper applies trim, control-strip, and the 240-char hard cap before
|
|
243
|
-
* persistence). Returns `null` when the section is missing or empty.
|
|
244
|
-
*/
|
|
245
|
-
function extractShortSummary(buffer) {
|
|
246
|
-
const m = buffer.match(/##\s*Short Summary\s*\n+((?:(?!##)[^\n]+(?:\n(?!##)[^\n]+)*))/i);
|
|
247
|
-
if (!m)
|
|
248
|
-
return null;
|
|
249
|
-
const body = m[1].trim();
|
|
250
|
-
return body.length > 0 ? body : null;
|
|
251
|
-
}
|
|
252
|
-
function deriveFallbackShortSummary(title, description) {
|
|
253
|
-
const plain = description
|
|
254
|
-
.replace(/```[\s\S]*?```/g, ' ')
|
|
255
|
-
.replace(/^#{1,6}\s+.*$/gm, ' ')
|
|
256
|
-
.replace(/^\s*[-*]\s+/gm, '')
|
|
257
|
-
.replace(/\[[^\]]+\]\([^)]+\)/g, (m) => m.match(/\[([^\]]+)\]/)?.[1] ?? '')
|
|
258
|
-
.replace(/[`*_>]/g, '')
|
|
259
|
-
.replace(/\s+/g, ' ')
|
|
260
|
-
.trim();
|
|
261
|
-
const source = plain || title.trim();
|
|
262
|
-
if (!source)
|
|
263
|
-
return null;
|
|
264
|
-
const sentence = source.match(/^(.{24,}?[.!?])(?:\s|$)/)?.[1] ?? source;
|
|
265
|
-
const capped = sentence.length > 160 ? `${sentence.slice(0, 157).trimEnd()}...` : sentence;
|
|
266
|
-
return (0, ticket_store_1.clampShortSummary)(capped);
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Lightly structure a raw free-form prompt (the "Raw" Add-Spec mode) when the
|
|
270
|
-
* user opts in. We deliberately do NOT fabricate spec sections — a raw prompt
|
|
271
|
-
* stays the user's own words. The only transform: when the body has no leading
|
|
272
|
-
* markdown heading, prefix a single neutral `## Overview` heading so the spec
|
|
273
|
-
* renders with a section title downstream. Returns the (trimmed) input
|
|
274
|
-
* unchanged when it already starts with a heading or is empty.
|
|
275
|
-
*/
|
|
276
|
-
function lightlyStructurePrompt(text) {
|
|
277
|
-
const trimmed = text.trim();
|
|
278
|
-
if (!trimmed)
|
|
279
|
-
return trimmed;
|
|
280
|
-
if (/^#{1,6}\s+/.test(trimmed))
|
|
281
|
-
return trimmed;
|
|
282
|
-
return `## Overview\n\n${trimmed}`;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Fold an `acceptanceCriteria` array into a ticket description body, writing
|
|
286
|
-
* (or replacing) a `## Acceptance Criteria` section.
|
|
287
|
-
*
|
|
288
|
-
* - `criteria.length > 0` → append/replace the section with one `- bullet` per item
|
|
289
|
-
* - `criteria.length === 0` → strip any existing `## Acceptance Criteria` section
|
|
290
|
-
*
|
|
291
|
-
* The match is case-insensitive on the heading text but requires `##` exactly
|
|
292
|
-
* to avoid matching other heading levels.
|
|
293
|
-
*
|
|
294
|
-
* Shared by `POST /tickets/from-draft` and `PATCH /tickets/:id`. See
|
|
295
|
-
* openspec/changes/replace-ai-edit-with-continue-editing/design.md D3+D4.
|
|
296
|
-
*/
|
|
297
|
-
function formatDescriptionWithCriteria(body, criteria) {
|
|
298
|
-
const sectionRegex = /\n*##\s*Acceptance Criteria\s*\n[\s\S]*?(?=\n##\s|\n*$)/i;
|
|
299
|
-
const withoutExisting = body.replace(sectionRegex, '').replace(/\s+$/, '');
|
|
300
|
-
if (criteria.length === 0)
|
|
301
|
-
return withoutExisting;
|
|
302
|
-
const section = `## Acceptance Criteria\n\n${criteria.map((c) => `- ${c}`).join('\n')}`;
|
|
303
|
-
if (withoutExisting === '')
|
|
304
|
-
return section;
|
|
305
|
-
return `${withoutExisting}\n\n${section}`;
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Resolve the default model used by Add Spec for a project.
|
|
309
|
-
*
|
|
310
|
-
* Order:
|
|
311
|
-
* 1. `models.defaults.model` from `<project>/.specrails/install-config.yaml`,
|
|
312
|
-
* if it parses AND is in the provider allow-list.
|
|
313
|
-
* 2. Provider default from `PROVIDER_DEFAULT_MODEL` (`sonnet` / `gpt-5.5`).
|
|
314
|
-
*
|
|
315
|
-
* Logs a warning when the configured value exists but is not valid for the
|
|
316
|
-
* project's provider.
|
|
317
|
-
*/
|
|
318
|
-
function resolveDefaultSpecModel(args) {
|
|
319
|
-
const { projectPath, provider } = args;
|
|
320
|
-
const configPath = path_1.default.join(projectPath, '.specrails', 'install-config.yaml');
|
|
321
|
-
if (!fs_1.default.existsSync(configPath))
|
|
322
|
-
return (0, spec_models_1.getProviderDefault)(provider);
|
|
323
|
-
let configText;
|
|
324
|
-
try {
|
|
325
|
-
configText = fs_1.default.readFileSync(configPath, 'utf-8');
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
return (0, spec_models_1.getProviderDefault)(provider);
|
|
329
|
-
}
|
|
330
|
-
const defaultsMatch = configText.match(/defaults:\s*\{\s*model:\s*(\S+?)\s*\}/);
|
|
331
|
-
const configured = defaultsMatch ? defaultsMatch[1] : null;
|
|
332
|
-
if (!configured)
|
|
333
|
-
return (0, spec_models_1.getProviderDefault)(provider);
|
|
334
|
-
if (!(0, spec_models_1.isValidModelForProvider)(configured, provider)) {
|
|
335
|
-
console.warn(`[project-router] resolveDefaultSpecModel: configured model "${configured}" is not valid for provider "${provider}" — falling back to provider default`);
|
|
336
|
-
return (0, spec_models_1.getProviderDefault)(provider);
|
|
337
|
-
}
|
|
338
|
-
return configured;
|
|
339
|
-
}
|
|
11
|
+
const ticket_store_1 = require("./ticket-store");
|
|
12
|
+
const project_router_jobs_1 = require("./project-router-jobs");
|
|
13
|
+
const project_router_spending_1 = require("./project-router-spending");
|
|
14
|
+
const project_router_chat_1 = require("./project-router-chat");
|
|
15
|
+
const project_router_setup_1 = require("./project-router-setup");
|
|
16
|
+
const project_router_tickets_1 = require("./project-router-tickets");
|
|
17
|
+
const project_router_terminals_1 = require("./project-router-terminals");
|
|
18
|
+
const project_router_settings_1 = require("./project-router-settings");
|
|
19
|
+
// Re-export the spec helpers from their new home so existing importers
|
|
20
|
+
// (`import { ... } from './project-router'`) keep working unchanged.
|
|
21
|
+
var project_router_helpers_1 = require("./project-router-helpers");
|
|
22
|
+
Object.defineProperty(exports, "stripSpecMetadataSections", { enumerable: true, get: function () { return project_router_helpers_1.stripSpecMetadataSections; } });
|
|
23
|
+
Object.defineProperty(exports, "extractShortSummary", { enumerable: true, get: function () { return project_router_helpers_1.extractShortSummary; } });
|
|
24
|
+
Object.defineProperty(exports, "deriveFallbackShortSummary", { enumerable: true, get: function () { return project_router_helpers_1.deriveFallbackShortSummary; } });
|
|
25
|
+
Object.defineProperty(exports, "lightlyStructurePrompt", { enumerable: true, get: function () { return project_router_helpers_1.lightlyStructurePrompt; } });
|
|
26
|
+
Object.defineProperty(exports, "formatDescriptionWithCriteria", { enumerable: true, get: function () { return project_router_helpers_1.formatDescriptionWithCriteria; } });
|
|
27
|
+
Object.defineProperty(exports, "resolveDefaultSpecModel", { enumerable: true, get: function () { return project_router_helpers_1.resolveDefaultSpecModel; } });
|
|
340
28
|
function createProjectRouter(registry) {
|
|
341
29
|
const router = (0, express_1.Router)({ mergeParams: true });
|
|
342
30
|
// Middleware: resolve project from :projectId param
|
|
@@ -399,3618 +87,14 @@ function createProjectRouter(registry) {
|
|
|
399
87
|
}));
|
|
400
88
|
codeRouter(req, res, next);
|
|
401
89
|
});
|
|
402
|
-
|
|
403
|
-
router
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
// profileName accepts: undefined (default resolution), null (force legacy), string (explicit)
|
|
414
|
-
const normalizedProfileName = profileName === null ? null
|
|
415
|
-
: typeof profileName === 'string' && profileName.trim() ? profileName.trim()
|
|
416
|
-
: undefined;
|
|
417
|
-
// aiEngine: optional per-job provider override; must be installed on the
|
|
418
|
-
// project. Omitting it runs on the project's primary provider.
|
|
419
|
-
const engineCheck = (0, provider_selection_1.validateRequestedProvider)(ctx(req).project, aiEngine);
|
|
420
|
-
if (!engineCheck.ok) {
|
|
421
|
-
res.status(400).json({ error: engineCheck.error });
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
try {
|
|
425
|
-
const job = ctx(req).queueManager.enqueue(command, priority ?? 'normal', {
|
|
426
|
-
dependsOnJobId: dependsOnJobId || undefined,
|
|
427
|
-
pipelineId: pipelineId || undefined,
|
|
428
|
-
profileName: normalizedProfileName,
|
|
429
|
-
provider: aiEngine ? engineCheck.provider : undefined,
|
|
430
|
-
});
|
|
431
|
-
const position = job.queuePosition ?? 0;
|
|
432
|
-
res.status(202).json({ jobId: job.id, position });
|
|
433
|
-
}
|
|
434
|
-
catch (err) {
|
|
435
|
-
if (err instanceof queue_manager_1.ClaudeNotFoundError) {
|
|
436
|
-
res.status(400).json({ error: err.message });
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
console.error('[project-router] spawn error:', err);
|
|
440
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
// ─── Pipeline routes ──────────────────────────────────────────────────────────
|
|
445
|
-
// NOTE: Ad-hoc pipeline creation removed — use rails (templates) instead.
|
|
446
|
-
// The GET route remains for viewing existing pipeline status.
|
|
447
|
-
router.get('/:projectId/pipelines/:pipelineId', (req, res) => {
|
|
448
|
-
const { db } = ctx(req);
|
|
449
|
-
const pipelineId = req.params.pipelineId;
|
|
450
|
-
const jobs = (0, db_1.getPipelineJobs)(db, pipelineId);
|
|
451
|
-
if (jobs.length === 0) {
|
|
452
|
-
res.status(404).json({ error: 'Pipeline not found' });
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
const allCompleted = jobs.every(j => j.status === 'completed');
|
|
456
|
-
const anyFailed = jobs.some(j => ['failed', 'skipped', 'canceled', 'zombie_terminated'].includes(j.status));
|
|
457
|
-
const status = allCompleted ? 'completed' : anyFailed ? 'failed' : 'running';
|
|
458
|
-
res.json({ pipelineId, status, jobs });
|
|
459
|
-
});
|
|
460
|
-
router.get('/:projectId/state', (req, res) => {
|
|
461
|
-
const { queueManager, project } = ctx(req);
|
|
462
|
-
res.json({
|
|
463
|
-
projectName: project.name,
|
|
464
|
-
projectId: project.id,
|
|
465
|
-
phases: (0, hooks_1.getPhaseStates)(),
|
|
466
|
-
busy: queueManager.getActiveJobId() !== null,
|
|
467
|
-
currentJobId: queueManager.getActiveJobId(),
|
|
468
|
-
featureFlags: {
|
|
469
|
-
smash: !(0, explore_smash_1.isSpecsSmashKillSwitchActive)(),
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
// Returns the resolved default model for Add Spec + the full provider
|
|
474
|
-
// allow-list so the modal can render its picker without maintaining its
|
|
475
|
-
// own copy of the model lists. Source of truth is `server/spec-models.ts`.
|
|
476
|
-
router.get('/:projectId/default-spec-model', (req, res) => {
|
|
477
|
-
const { project } = ctx(req);
|
|
478
|
-
// Multi-provider: an optional ?provider= query selects which engine's models
|
|
479
|
-
// to return. It must be one the project actually has installed; an invalid
|
|
480
|
-
// or omitted value falls back to the project's primary provider. The
|
|
481
|
-
// response also lists every installed provider so the Add Spec modal can
|
|
482
|
-
// render its AI Engine selector without a second round-trip.
|
|
483
|
-
const provider = (0, provider_selection_1.resolveProvider)(project, typeof req.query.provider === 'string' ? req.query.provider : undefined);
|
|
484
|
-
const model = resolveDefaultSpecModel({ projectPath: project.path, provider });
|
|
485
|
-
const allowed = (0, spec_models_1.getModelsForProvider)(provider);
|
|
486
|
-
res.json({ model, provider, allowed, providers: project.providers });
|
|
487
|
-
});
|
|
488
|
-
router.delete('/:projectId/jobs/:id', (req, res) => {
|
|
489
|
-
try {
|
|
490
|
-
const result = ctx(req).queueManager.cancel(req.params.id);
|
|
491
|
-
res.json({ ok: true, status: result });
|
|
492
|
-
}
|
|
493
|
-
catch (err) {
|
|
494
|
-
if (err instanceof queue_manager_1.JobNotFoundError) {
|
|
495
|
-
res.status(404).json({ error: 'Job not found' });
|
|
496
|
-
}
|
|
497
|
-
else if (err instanceof queue_manager_1.JobAlreadyTerminalError) {
|
|
498
|
-
// Job already finished — delete it from the DB
|
|
499
|
-
(0, db_1.deleteJob)(ctx(req).db, req.params.id);
|
|
500
|
-
res.json({ ok: true, status: 'deleted' });
|
|
501
|
-
}
|
|
502
|
-
else {
|
|
503
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
router.patch('/:projectId/jobs/:id/priority', (req, res) => {
|
|
508
|
-
const { priority } = req.body ?? {};
|
|
509
|
-
if (!priority || !types_1.VALID_PRIORITIES.has(priority)) {
|
|
510
|
-
res.status(400).json({ error: 'priority must be one of: low, normal, high, critical' });
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
try {
|
|
514
|
-
ctx(req).queueManager.updatePriority(req.params.id, priority);
|
|
515
|
-
res.json({ ok: true });
|
|
516
|
-
}
|
|
517
|
-
catch (err) {
|
|
518
|
-
if (err instanceof queue_manager_1.JobNotFoundError) {
|
|
519
|
-
res.status(404).json({ error: 'Job not found' });
|
|
520
|
-
}
|
|
521
|
-
else {
|
|
522
|
-
res.status(400).json({ error: err.message });
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
router.post('/:projectId/queue/pause', (req, res) => {
|
|
527
|
-
ctx(req).queueManager.pause();
|
|
528
|
-
res.json({ ok: true, paused: true });
|
|
529
|
-
});
|
|
530
|
-
router.post('/:projectId/queue/resume', (req, res) => {
|
|
531
|
-
ctx(req).queueManager.resume();
|
|
532
|
-
res.json({ ok: true, paused: false });
|
|
533
|
-
});
|
|
534
|
-
router.put('/:projectId/queue/reorder', (req, res) => {
|
|
535
|
-
const { jobIds } = req.body ?? {};
|
|
536
|
-
if (!Array.isArray(jobIds)) {
|
|
537
|
-
res.status(400).json({ error: 'jobIds must be an array' });
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
try {
|
|
541
|
-
ctx(req).queueManager.reorder(jobIds);
|
|
542
|
-
res.json({ ok: true, queue: jobIds });
|
|
543
|
-
}
|
|
544
|
-
catch (err) {
|
|
545
|
-
res.status(400).json({ error: err.message });
|
|
546
|
-
}
|
|
547
|
-
});
|
|
548
|
-
router.get('/:projectId/queue', (req, res) => {
|
|
549
|
-
const { queueManager } = ctx(req);
|
|
550
|
-
res.json({
|
|
551
|
-
jobs: queueManager.getJobs(),
|
|
552
|
-
paused: queueManager.isPaused(),
|
|
553
|
-
activeJobId: queueManager.getActiveJobId(),
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
router.get('/:projectId/jobs', (req, res) => {
|
|
557
|
-
// Clamp to [1, 200] (H-11): a negative limit is LIMIT -1 in SQLite, which
|
|
558
|
-
// means UNLIMITED — without the lower bound `?limit=-1` dumps the whole table.
|
|
559
|
-
const limit = Math.max(1, Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200));
|
|
560
|
-
const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0;
|
|
561
|
-
const status = req.query.status;
|
|
562
|
-
const from = req.query.from;
|
|
563
|
-
const to = req.query.to;
|
|
564
|
-
const { db } = ctx(req);
|
|
565
|
-
const result = (0, db_1.listJobs)(db, { limit, offset, status, from, to });
|
|
566
|
-
// Merge in-memory queued jobs that haven't been persisted to DB yet
|
|
567
|
-
const { queueManager } = ctx(req);
|
|
568
|
-
const dbIds = new Set(result.jobs.map((j) => j.id));
|
|
569
|
-
const queuedRows = queueManager
|
|
570
|
-
.getJobs()
|
|
571
|
-
.filter((j) => j.status === 'queued' && !dbIds.has(j.id))
|
|
572
|
-
.filter((j) => !status || j.status === status)
|
|
573
|
-
.map((j) => ({
|
|
574
|
-
id: j.id,
|
|
575
|
-
command: j.command,
|
|
576
|
-
started_at: j.startedAt ?? new Date().toISOString(),
|
|
577
|
-
finished_at: j.finishedAt,
|
|
578
|
-
status: j.status,
|
|
579
|
-
exit_code: j.exitCode,
|
|
580
|
-
queue_position: j.queuePosition,
|
|
581
|
-
priority: j.priority,
|
|
582
|
-
tokens_in: null,
|
|
583
|
-
tokens_out: null,
|
|
584
|
-
tokens_cache_read: null,
|
|
585
|
-
tokens_cache_create: null,
|
|
586
|
-
total_cost_usd: null,
|
|
587
|
-
num_turns: null,
|
|
588
|
-
model: null,
|
|
589
|
-
duration_ms: null,
|
|
590
|
-
duration_api_ms: null,
|
|
591
|
-
session_id: null,
|
|
592
|
-
depends_on_job_id: j.dependsOnJobId,
|
|
593
|
-
pipeline_id: j.pipelineId,
|
|
594
|
-
skip_reason: j.skipReason,
|
|
595
|
-
}));
|
|
596
|
-
if (queuedRows.length > 0) {
|
|
597
|
-
result.jobs = [...queuedRows, ...result.jobs];
|
|
598
|
-
result.total += queuedRows.length;
|
|
599
|
-
}
|
|
600
|
-
// Annotate each job with hasTelemetry so the client can show the
|
|
601
|
-
// Export diagnostic button without an extra round trip.
|
|
602
|
-
const jobsWithTelemetry = (0, db_1.getJobsWithTelemetry)(db);
|
|
603
|
-
const annotatedJobs = result.jobs.map((j) => ({
|
|
604
|
-
...j,
|
|
605
|
-
hasTelemetry: jobsWithTelemetry.has(j.id),
|
|
606
|
-
}));
|
|
607
|
-
res.json({ jobs: annotatedJobs, total: result.total });
|
|
608
|
-
});
|
|
609
|
-
// ─── CSV helper ──────────────────────────────────────────────────────────────
|
|
610
|
-
const toCsv = (headers, rows) => {
|
|
611
|
-
const escape = (v) => {
|
|
612
|
-
const s = v == null ? '' : String(v);
|
|
613
|
-
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
614
|
-
};
|
|
615
|
-
const lines = [headers.join(',')];
|
|
616
|
-
for (const row of rows) {
|
|
617
|
-
lines.push(headers.map(h => escape(row[h])).join(','));
|
|
618
|
-
}
|
|
619
|
-
return lines.join('\n');
|
|
620
|
-
};
|
|
621
|
-
// ─── Jobs export (must be before /:projectId/jobs/:id) ─────────────────────
|
|
622
|
-
router.get('/:projectId/jobs/export', (req, res) => {
|
|
623
|
-
const format = req.query.format || 'json';
|
|
624
|
-
if (format !== 'json' && format !== 'csv') {
|
|
625
|
-
res.status(400).json({ error: 'Invalid format. Must be json or csv' });
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
const from = req.query.from;
|
|
629
|
-
const to = req.query.to;
|
|
630
|
-
const { db } = ctx(req);
|
|
631
|
-
const conditions = [];
|
|
632
|
-
const params = [];
|
|
633
|
-
if (from) {
|
|
634
|
-
conditions.push('started_at >= ?');
|
|
635
|
-
params.push(from);
|
|
636
|
-
}
|
|
637
|
-
if (to) {
|
|
638
|
-
conditions.push('started_at <= ?');
|
|
639
|
-
params.push(to);
|
|
640
|
-
}
|
|
641
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
642
|
-
const jobs = db
|
|
643
|
-
.prepare(`SELECT * FROM jobs ${where} ORDER BY started_at DESC LIMIT 10000`)
|
|
644
|
-
.all(...params);
|
|
645
|
-
if (format === 'csv') {
|
|
646
|
-
const headers = ['id', 'command', 'status', 'started_at', 'finished_at', 'duration_ms', 'tokens_in', 'tokens_out', 'tokens_cache_read', 'total_cost_usd', 'model'];
|
|
647
|
-
const csv = toCsv(headers, jobs);
|
|
648
|
-
res.setHeader('Content-Type', 'text/csv');
|
|
649
|
-
res.setHeader('Content-Disposition', 'attachment; filename="jobs-export.csv"');
|
|
650
|
-
res.send(csv);
|
|
651
|
-
}
|
|
652
|
-
else {
|
|
653
|
-
res.json({ jobs });
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
// Must be registered BEFORE /:projectId/jobs/:id, otherwise Express matches
|
|
657
|
-
// the parameterized route first with id='compare' and this never runs (the
|
|
658
|
-
// Job Comparison feature would always 404).
|
|
659
|
-
router.get('/:projectId/jobs/compare', (req, res) => {
|
|
660
|
-
const raw = req.query.jobIds;
|
|
661
|
-
if (!raw) {
|
|
662
|
-
res.status(400).json({ error: 'jobIds query param required (comma-separated, exactly 2)' });
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
666
|
-
if (ids.length !== 2) {
|
|
667
|
-
res.status(400).json({ error: 'Exactly 2 jobIds are required' });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
const { db } = ctx(req);
|
|
671
|
-
const rows = ids.map((id) => {
|
|
672
|
-
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
|
|
673
|
-
if (!job)
|
|
674
|
-
return null;
|
|
675
|
-
const phases = db.prepare("SELECT phase FROM job_phases WHERE job_id = ? AND state = 'done' ORDER BY updated_at ASC").all(id);
|
|
676
|
-
return {
|
|
677
|
-
id: job.id,
|
|
678
|
-
command: job.command,
|
|
679
|
-
status: job.status,
|
|
680
|
-
startedAt: job.started_at,
|
|
681
|
-
finishedAt: job.finished_at,
|
|
682
|
-
durationMs: job.duration_ms,
|
|
683
|
-
tokensIn: job.tokens_in,
|
|
684
|
-
tokensOut: job.tokens_out,
|
|
685
|
-
tokensCacheRead: job.tokens_cache_read,
|
|
686
|
-
totalCostUsd: job.total_cost_usd,
|
|
687
|
-
model: job.model,
|
|
688
|
-
phasesCompleted: phases.map((p) => p.phase),
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
const missing = ids.filter((_, i) => rows[i] === null);
|
|
692
|
-
if (missing.length > 0) {
|
|
693
|
-
res.status(404).json({ error: `Jobs not found: ${missing.join(', ')}` });
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
res.json({ jobs: rows });
|
|
697
|
-
});
|
|
698
|
-
router.get('/:projectId/jobs/:id', (req, res) => {
|
|
699
|
-
const { db, queueManager, project } = ctx(req);
|
|
700
|
-
const jobId = req.params.id;
|
|
701
|
-
const job = (0, db_1.getJob)(db, jobId);
|
|
702
|
-
if (!job) {
|
|
703
|
-
// Queued jobs live only in memory until spawn time (createJob runs on spawn,
|
|
704
|
-
// not enqueue). Fall back to the in-memory queue so /jobs/:id returns a
|
|
705
|
-
// usable payload instead of 404 — the detail page then renders a "queued"
|
|
706
|
-
// state and flips to live logs via WS once the job starts.
|
|
707
|
-
const inMemory = queueManager.getJobs().find((j) => j.id === jobId);
|
|
708
|
-
if (!inMemory) {
|
|
709
|
-
res.status(404).json({ error: 'Job not found' });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
const synthetic = {
|
|
713
|
-
id: inMemory.id,
|
|
714
|
-
command: inMemory.command,
|
|
715
|
-
started_at: inMemory.startedAt ?? '',
|
|
716
|
-
finished_at: inMemory.finishedAt,
|
|
717
|
-
status: inMemory.status,
|
|
718
|
-
exit_code: inMemory.exitCode,
|
|
719
|
-
queue_position: inMemory.queuePosition,
|
|
720
|
-
priority: inMemory.priority,
|
|
721
|
-
tokens_in: null,
|
|
722
|
-
tokens_out: null,
|
|
723
|
-
tokens_cache_read: null,
|
|
724
|
-
tokens_cache_create: null,
|
|
725
|
-
total_cost_usd: null,
|
|
726
|
-
num_turns: null,
|
|
727
|
-
model: null,
|
|
728
|
-
duration_ms: null,
|
|
729
|
-
duration_api_ms: null,
|
|
730
|
-
session_id: null,
|
|
731
|
-
depends_on_job_id: inMemory.dependsOnJobId,
|
|
732
|
-
pipeline_id: inMemory.pipelineId,
|
|
733
|
-
skip_reason: inMemory.skipReason,
|
|
734
|
-
};
|
|
735
|
-
const phaseDefinitions = queueManager.phasesForCommand(synthetic.command);
|
|
736
|
-
const tickets = (0, ticket_store_1.resolveTicketsFromCommand)(project.path, synthetic.command);
|
|
737
|
-
res.json({ job: { ...synthetic, hasTelemetry: false, tickets }, events: [], phaseDefinitions });
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
const events = (0, db_1.getJobEvents)(db, jobId);
|
|
741
|
-
const phaseDefinitions = queueManager.phasesForCommand(job.command);
|
|
742
|
-
const tickets = (0, ticket_store_1.resolveTicketsFromCommand)(project.path, job.command);
|
|
743
|
-
const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets };
|
|
744
|
-
res.json({ job: annotated, events, phaseDefinitions });
|
|
745
|
-
});
|
|
746
|
-
router.delete('/:projectId/jobs', (req, res) => {
|
|
747
|
-
try {
|
|
748
|
-
const { from, to } = req.body ?? {};
|
|
749
|
-
const deleted = (0, db_1.purgeJobs)(ctx(req).db, { from, to });
|
|
750
|
-
res.json({ ok: true, deleted });
|
|
751
|
-
}
|
|
752
|
-
catch (err) {
|
|
753
|
-
console.error('[project-router] purge error:', err);
|
|
754
|
-
res.status(500).json({ error: 'Failed to purge jobs' });
|
|
755
|
-
}
|
|
756
|
-
});
|
|
757
|
-
router.get('/:projectId/activity', (req, res) => {
|
|
758
|
-
const limit = Math.min(Math.max(1, parseInt(String(req.query.limit ?? '50'), 10) || 50), 100);
|
|
759
|
-
const before = req.query.before;
|
|
760
|
-
res.json((0, db_1.getProjectActivity)(ctx(req).db, { limit, before }));
|
|
761
|
-
});
|
|
762
|
-
router.get('/:projectId/stats', (req, res) => {
|
|
763
|
-
res.json((0, db_1.getStats)(ctx(req).db));
|
|
764
|
-
});
|
|
765
|
-
router.get('/:projectId/metrics', (req, res) => {
|
|
766
|
-
const { project, db } = ctx(req);
|
|
767
|
-
try {
|
|
768
|
-
res.json((0, metrics_1.getProjectMetrics)(project.path, db));
|
|
769
|
-
}
|
|
770
|
-
catch (err) {
|
|
771
|
-
console.error('[project-router] metrics error:', err);
|
|
772
|
-
res.status(500).json({ error: 'Failed to compute metrics' });
|
|
773
|
-
}
|
|
774
|
-
});
|
|
775
|
-
// ─── Spending dashboard ──────────────────────────────────────────────────────
|
|
776
|
-
router.get('/:projectId/spending', (req, res) => {
|
|
777
|
-
const filters = (0, spending_1.parseSpendingFilters)(req.query);
|
|
778
|
-
if (filters.period === 'custom' && (!filters.from || !filters.to)) {
|
|
779
|
-
res.status(400).json({ error: 'from and to are required for custom period' });
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
try {
|
|
783
|
-
res.json((0, spending_1.getSpending)(ctx(req).db, ctx(req).project.id, filters));
|
|
784
|
-
}
|
|
785
|
-
catch (err) {
|
|
786
|
-
console.error('[project-router] spending error:', err);
|
|
787
|
-
res.status(500).json({ error: 'Failed to compute spending' });
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
// ─── Raw invocations table ───────────────────────────────────────────────────
|
|
791
|
-
router.get('/:projectId/invocations', (req, res) => {
|
|
792
|
-
const filters = (0, spending_1.parseSpendingFilters)(req.query);
|
|
793
|
-
// Clamp to [1, 10000] when provided (H-11): a negative/NaN limit becomes
|
|
794
|
-
// SQLite LIMIT -1 (unlimited) and dumps the entire invocations table.
|
|
795
|
-
const rawLimit = req.query.limit ? parseInt(req.query.limit, 10) : undefined;
|
|
796
|
-
const limit = rawLimit !== undefined && Number.isFinite(rawLimit)
|
|
797
|
-
? Math.max(1, Math.min(rawLimit, 10_000))
|
|
798
|
-
: undefined;
|
|
799
|
-
const offset = req.query.offset ? parseInt(req.query.offset, 10) : undefined;
|
|
800
|
-
try {
|
|
801
|
-
const result = (0, spending_1.getInvocations)(ctx(req).db, ctx(req).project.id, {
|
|
802
|
-
...filters,
|
|
803
|
-
...(limit ? { limit } : {}),
|
|
804
|
-
...(offset ? { offset } : {}),
|
|
805
|
-
});
|
|
806
|
-
// Enrich with ticket titles from YAML store.
|
|
807
|
-
try {
|
|
808
|
-
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
809
|
-
for (const r of result.rows) {
|
|
810
|
-
if (r.ticket_id != null) {
|
|
811
|
-
const t = store.tickets[String(r.ticket_id)];
|
|
812
|
-
r.ticket_title = t?.title ?? null;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
catch { /* tickets store may not exist yet */ }
|
|
817
|
-
res.json(result);
|
|
818
|
-
}
|
|
819
|
-
catch (err) {
|
|
820
|
-
console.error('[project-router] invocations error:', err);
|
|
821
|
-
res.status(500).json({ error: 'Failed to list invocations' });
|
|
822
|
-
}
|
|
823
|
-
});
|
|
824
|
-
// ─── Per-ticket spending summary (used by TicketDetailModal) ─────────────────
|
|
825
|
-
router.get('/:projectId/tickets/:id/spending-summary', (req, res) => {
|
|
826
|
-
const ticketId = parseInt(req.params.id, 10);
|
|
827
|
-
if (Number.isNaN(ticketId)) {
|
|
828
|
-
res.status(400).json({ error: 'Invalid ticket id' });
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
try {
|
|
832
|
-
res.json((0, ai_invocations_1.getTicketSpendingSummary)(ctx(req).db, ticketId));
|
|
833
|
-
}
|
|
834
|
-
catch (err) {
|
|
835
|
-
console.error('[project-router] ticket spending summary error:', err);
|
|
836
|
-
res.status(500).json({ error: 'Failed to compute ticket spending' });
|
|
837
|
-
}
|
|
838
|
-
});
|
|
839
|
-
// ─── Spending / analytics export (Summary + Raw, CSV or JSON) ────────────────
|
|
840
|
-
router.get('/:projectId/analytics/export', async (req, res) => {
|
|
841
|
-
const format = req.query.format || 'json';
|
|
842
|
-
const mode = req.query.mode || 'summary';
|
|
843
|
-
if (format !== 'json' && format !== 'csv') {
|
|
844
|
-
res.status(400).json({ error: 'Invalid format. Must be json or csv' });
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (mode !== 'summary' && mode !== 'raw') {
|
|
848
|
-
res.status(400).json({ error: 'Invalid mode. Must be summary or raw' });
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
const periodRaw = req.query.period ?? '30d';
|
|
852
|
-
const validPeriods = ['7d', '30d', '90d', 'all', 'custom'];
|
|
853
|
-
if (!validPeriods.includes(periodRaw)) {
|
|
854
|
-
res.status(400).json({ error: 'Invalid period. Must be one of: 7d, 30d, 90d, all, custom' });
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
const filters = (0, spending_1.parseSpendingFilters)(req.query);
|
|
858
|
-
if (filters.period === 'custom' && (!filters.from || !filters.to)) {
|
|
859
|
-
res.status(400).json({ error: 'from and to are required for custom period' });
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
const { project } = ctx(req);
|
|
863
|
-
const projectId = project.id;
|
|
864
|
-
const dateStamp = new Date().toISOString().slice(0, 10);
|
|
865
|
-
const periodTag = filters.period ?? '30d';
|
|
866
|
-
const surfaceTag = (filters.surface && filters.surface.length === 1)
|
|
867
|
-
? `-${filters.surface[0].replace('-spec', '').replace('-', '')}`
|
|
868
|
-
: '';
|
|
869
|
-
try {
|
|
870
|
-
if (mode === 'summary') {
|
|
871
|
-
const data = (0, spending_1.getSpending)(ctx(req).db, projectId, filters);
|
|
872
|
-
if (format === 'json') {
|
|
873
|
-
res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-analytics-${periodTag}-${dateStamp}.json"`);
|
|
874
|
-
res.json(data);
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
// CSV summary: multi-section composite
|
|
878
|
-
const lines = [];
|
|
879
|
-
lines.push('# Totals');
|
|
880
|
-
lines.push('totalCostUsd,totalRuns,prevTotalCostUsd,deltaPct,avgCostPerRun,failureRate');
|
|
881
|
-
lines.push([
|
|
882
|
-
data.summary.totalCostUsd,
|
|
883
|
-
data.summary.totalRuns,
|
|
884
|
-
data.summary.prevTotalCostUsd,
|
|
885
|
-
data.summary.deltaPct ?? '',
|
|
886
|
-
data.summary.avgCostPerRun ?? '',
|
|
887
|
-
data.summary.failureRate,
|
|
888
|
-
].join(','));
|
|
889
|
-
lines.push('');
|
|
890
|
-
lines.push('# Daily timeline');
|
|
891
|
-
lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,totalCostUsd');
|
|
892
|
-
for (const d of data.dailyTimeline) {
|
|
893
|
-
lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.totalCostUsd}`);
|
|
894
|
-
}
|
|
895
|
-
lines.push('');
|
|
896
|
-
lines.push('# By surface');
|
|
897
|
-
lines.push('surface,count,costUsd');
|
|
898
|
-
for (const s of data.bySurface)
|
|
899
|
-
lines.push(`${s.surface},${s.count},${s.costUsd}`);
|
|
900
|
-
lines.push('');
|
|
901
|
-
lines.push('# By model');
|
|
902
|
-
lines.push('model,count,costUsd');
|
|
903
|
-
for (const m of data.byModel)
|
|
904
|
-
lines.push(`${csvEscape(m.model)},${m.count},${m.costUsd}`);
|
|
905
|
-
lines.push('');
|
|
906
|
-
lines.push('# Top tickets');
|
|
907
|
-
lines.push('ticketId,totalCostUsd,totalRuns,jobCost,quickCost,exploreCost,aiEditCost');
|
|
908
|
-
for (const t of data.topTickets) {
|
|
909
|
-
lines.push([
|
|
910
|
-
t.ticketId ?? '(unattributed)',
|
|
911
|
-
t.totalCostUsd,
|
|
912
|
-
t.totalRuns,
|
|
913
|
-
t.bySurface.job.costUsd,
|
|
914
|
-
t.bySurface['quick-spec'].costUsd,
|
|
915
|
-
t.bySurface['explore-spec'].costUsd,
|
|
916
|
-
t.bySurface['ai-edit'].costUsd,
|
|
917
|
-
].join(','));
|
|
918
|
-
}
|
|
919
|
-
res.setHeader('Content-Type', 'text/csv');
|
|
920
|
-
res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-analytics-${periodTag}-${dateStamp}.csv"`);
|
|
921
|
-
res.send(lines.join('\n'));
|
|
922
|
-
}
|
|
923
|
-
else {
|
|
924
|
-
// raw mode: capped invocations
|
|
925
|
-
const result = (0, spending_1.getInvocations)(ctx(req).db, projectId, { ...filters, cap: 10000 });
|
|
926
|
-
// Enrich titles
|
|
927
|
-
try {
|
|
928
|
-
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
929
|
-
for (const r of result.rows) {
|
|
930
|
-
if (r.ticket_id != null)
|
|
931
|
-
r.ticket_title = store.tickets[String(r.ticket_id)]?.title ?? null;
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
catch { /* no tickets yet */ }
|
|
935
|
-
if (format === 'json') {
|
|
936
|
-
res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-invocations-${periodTag}${surfaceTag}-${dateStamp}.json"`);
|
|
937
|
-
res.json(result);
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
const headers = [
|
|
941
|
-
'id', 'surface', 'surface_ref_id', 'ticket_id', 'ticket_title', 'conversation_id',
|
|
942
|
-
'model', 'status', 'started_at', 'finished_at', 'duration_ms', 'duration_api_ms',
|
|
943
|
-
'tokens_in', 'tokens_out', 'tokens_cache_read', 'tokens_cache_create',
|
|
944
|
-
'total_cost_usd', 'num_turns', 'session_id'
|
|
945
|
-
];
|
|
946
|
-
const lines = [headers.join(',')];
|
|
947
|
-
for (const r of result.rows) {
|
|
948
|
-
lines.push(headers.map((h) => csvEscape(r[h])).join(','));
|
|
949
|
-
}
|
|
950
|
-
if (result.truncated) {
|
|
951
|
-
lines.push(`# truncated_at=${result.rows.length} of ${result.totalAvailable}`);
|
|
952
|
-
}
|
|
953
|
-
res.setHeader('Content-Type', 'text/csv');
|
|
954
|
-
res.setHeader('Content-Disposition', `attachment; filename="${project.slug}-invocations-${periodTag}${surfaceTag}-${dateStamp}.csv"`);
|
|
955
|
-
res.send(lines.join('\n'));
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
catch (err) {
|
|
959
|
-
console.error('[project-router] export error:', err);
|
|
960
|
-
res.status(500).json({ error: 'Failed to export' });
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
function csvEscape(v) {
|
|
964
|
-
const s = v == null ? '' : String(v);
|
|
965
|
-
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
966
|
-
? `"${s.replace(/"/g, '""')}"`
|
|
967
|
-
: s;
|
|
968
|
-
}
|
|
969
|
-
router.get('/:projectId/config', (req, res) => {
|
|
970
|
-
const { project, db } = ctx(req);
|
|
971
|
-
try {
|
|
972
|
-
const config = (0, config_1.getConfig)(project.path, db, project.name);
|
|
973
|
-
const dailyBudgetRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.daily_budget_usd'`).get()?.value;
|
|
974
|
-
const dailyBudgetUsd = dailyBudgetRaw != null ? parseFloat(dailyBudgetRaw) : null;
|
|
975
|
-
const zombieTimeoutRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.zombie_timeout_ms'`).get()?.value;
|
|
976
|
-
const zombieTimeoutMs = zombieTimeoutRaw != null ? parseInt(zombieTimeoutRaw, 10) : null;
|
|
977
|
-
res.json({ ...config, dailyBudgetUsd, zombieTimeoutMs });
|
|
978
|
-
}
|
|
979
|
-
catch (err) {
|
|
980
|
-
console.error('[project-router] config error:', err);
|
|
981
|
-
res.status(500).json({ error: 'Failed to read config' });
|
|
982
|
-
}
|
|
983
|
-
});
|
|
984
|
-
router.post('/:projectId/config', (req, res) => {
|
|
985
|
-
const { active, labelFilter, dailyBudgetUsd, zombieTimeoutMs } = req.body ?? {};
|
|
986
|
-
const { db, queueManager } = ctx(req);
|
|
987
|
-
try {
|
|
988
|
-
if (active !== undefined) {
|
|
989
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.active_tracker', ?)`).run(active ?? '');
|
|
990
|
-
}
|
|
991
|
-
if (labelFilter !== undefined) {
|
|
992
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.label_filter', ?)`).run(labelFilter ?? '');
|
|
993
|
-
}
|
|
994
|
-
if (dailyBudgetUsd !== undefined) {
|
|
995
|
-
if (dailyBudgetUsd === null) {
|
|
996
|
-
db.prepare(`DELETE FROM queue_state WHERE key = 'config.daily_budget_usd'`).run();
|
|
997
|
-
}
|
|
998
|
-
else if (typeof dailyBudgetUsd === 'number' && dailyBudgetUsd > 0) {
|
|
999
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.daily_budget_usd', ?)`).run(String(dailyBudgetUsd));
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
if (zombieTimeoutMs !== undefined) {
|
|
1003
|
-
if (zombieTimeoutMs === null) {
|
|
1004
|
-
db.prepare(`DELETE FROM queue_state WHERE key = 'config.zombie_timeout_ms'`).run();
|
|
1005
|
-
}
|
|
1006
|
-
else if (typeof zombieTimeoutMs === 'number' && zombieTimeoutMs > 0) {
|
|
1007
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.zombie_timeout_ms', ?)`).run(String(zombieTimeoutMs));
|
|
1008
|
-
}
|
|
1009
|
-
queueManager.setZombieTimeout(typeof zombieTimeoutMs === 'number' && zombieTimeoutMs > 0 ? zombieTimeoutMs : queue_manager_1.DEFAULT_ZOMBIE_TIMEOUT_MS);
|
|
1010
|
-
}
|
|
1011
|
-
res.json({ ok: true });
|
|
1012
|
-
}
|
|
1013
|
-
catch (err) {
|
|
1014
|
-
console.error('[project-router] config persist error:', err);
|
|
1015
|
-
res.status(500).json({ error: 'Failed to persist config' });
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
// ─── Budget routes ────────────────────────────────────────────────────────────
|
|
1019
|
-
router.get('/:projectId/budget', (req, res) => {
|
|
1020
|
-
const { db } = ctx(req);
|
|
1021
|
-
try {
|
|
1022
|
-
const dailyBudgetRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.daily_budget_usd'`).get()?.value;
|
|
1023
|
-
const dailyBudgetUsd = dailyBudgetRaw != null ? parseFloat(dailyBudgetRaw) : null;
|
|
1024
|
-
const jobThresholdRaw = db.prepare(`SELECT value FROM queue_state WHERE key = 'config.job_cost_threshold_usd'`).get()?.value;
|
|
1025
|
-
const jobCostThresholdUsd = jobThresholdRaw != null ? parseFloat(jobThresholdRaw) : null;
|
|
1026
|
-
const costRow = db.prepare(`SELECT COALESCE(SUM(total_cost_usd), 0) as costToday FROM jobs WHERE started_at >= date('now')`).get();
|
|
1027
|
-
const costToday = costRow.costToday;
|
|
1028
|
-
const budgetUtilizationPct = dailyBudgetUsd != null && dailyBudgetUsd > 0
|
|
1029
|
-
? (costToday / dailyBudgetUsd) * 100
|
|
1030
|
-
: null;
|
|
1031
|
-
res.json({ dailyBudgetUsd, jobCostThresholdUsd, costToday, budgetUtilizationPct });
|
|
1032
|
-
}
|
|
1033
|
-
catch (err) {
|
|
1034
|
-
console.error('[project-router] budget get error:', err);
|
|
1035
|
-
res.status(500).json({ error: 'Failed to read budget' });
|
|
1036
|
-
}
|
|
1037
|
-
});
|
|
1038
|
-
router.patch('/:projectId/budget', (req, res) => {
|
|
1039
|
-
const { dailyBudgetUsd, jobCostThresholdUsd } = req.body ?? {};
|
|
1040
|
-
const { db } = ctx(req);
|
|
1041
|
-
try {
|
|
1042
|
-
if (dailyBudgetUsd !== undefined) {
|
|
1043
|
-
if (dailyBudgetUsd === null) {
|
|
1044
|
-
db.prepare(`DELETE FROM queue_state WHERE key = 'config.daily_budget_usd'`).run();
|
|
1045
|
-
}
|
|
1046
|
-
else if (typeof dailyBudgetUsd === 'number' && dailyBudgetUsd > 0) {
|
|
1047
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.daily_budget_usd', ?)`).run(String(dailyBudgetUsd));
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
if (jobCostThresholdUsd !== undefined) {
|
|
1051
|
-
if (jobCostThresholdUsd === null) {
|
|
1052
|
-
db.prepare(`DELETE FROM queue_state WHERE key = 'config.job_cost_threshold_usd'`).run();
|
|
1053
|
-
}
|
|
1054
|
-
else if (typeof jobCostThresholdUsd === 'number' && jobCostThresholdUsd > 0) {
|
|
1055
|
-
db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.job_cost_threshold_usd', ?)`).run(String(jobCostThresholdUsd));
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
res.json({ ok: true });
|
|
1059
|
-
}
|
|
1060
|
-
catch (err) {
|
|
1061
|
-
console.error('[project-router] budget patch error:', err);
|
|
1062
|
-
res.status(500).json({ error: 'Failed to update budget' });
|
|
1063
|
-
}
|
|
1064
|
-
});
|
|
1065
|
-
router.get('/:projectId/issues', (req, res) => {
|
|
1066
|
-
const { project, db } = ctx(req);
|
|
1067
|
-
try {
|
|
1068
|
-
const config = (0, config_1.getConfig)(project.path, db, project.name);
|
|
1069
|
-
const tracker = config.issueTracker.active;
|
|
1070
|
-
if (!tracker) {
|
|
1071
|
-
res.status(503).json({ error: 'No issue tracker configured', trackers: config.issueTracker });
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
const search = req.query.search;
|
|
1075
|
-
const label = req.query.label;
|
|
1076
|
-
const issues = (0, config_1.fetchIssues)(tracker, { search, label, repo: config.project.repo, cwd: project.path });
|
|
1077
|
-
res.json(issues);
|
|
1078
|
-
}
|
|
1079
|
-
catch (err) {
|
|
1080
|
-
console.error('[project-router] issues error:', err);
|
|
1081
|
-
res.status(500).json({ error: 'Failed to fetch issues' });
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
// ─── Chat routes ─────────────────────────────────────────────────────────────
|
|
1085
|
-
router.get('/:projectId/chat/conversations', (req, res) => {
|
|
1086
|
-
const conversations = (0, db_1.listConversations)(ctx(req).db);
|
|
1087
|
-
res.json({ conversations });
|
|
1088
|
-
});
|
|
1089
|
-
router.post('/:projectId/chat/conversations', (req, res) => {
|
|
1090
|
-
const { db, project } = ctx(req);
|
|
1091
|
-
// Multi-provider: an optional aiEngine (alias: provider) picks which engine
|
|
1092
|
-
// this conversation runs on. It must be installed on the project; omitting
|
|
1093
|
-
// it uses the project's primary provider. The chosen provider drives model
|
|
1094
|
-
// validation and is persisted on the conversation so resume turns and
|
|
1095
|
-
// ai_invocations attribute to the right engine.
|
|
1096
|
-
const requestedEngine = req.body?.aiEngine ?? req.body?.provider;
|
|
1097
|
-
const engineCheck = (0, provider_selection_1.validateRequestedProvider)(project, requestedEngine);
|
|
1098
|
-
if (!engineCheck.ok) {
|
|
1099
|
-
res.status(400).json({ error: engineCheck.error });
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
const provider = engineCheck.provider;
|
|
1103
|
-
const rawModel = req.body?.model;
|
|
1104
|
-
let model;
|
|
1105
|
-
if (rawModel === undefined || rawModel === null || rawModel === '') {
|
|
1106
|
-
model = resolveDefaultSpecModel({ projectPath: project.path, provider });
|
|
1107
|
-
}
|
|
1108
|
-
else if ((0, spec_models_1.isValidModelForProvider)(rawModel, provider)) {
|
|
1109
|
-
model = rawModel;
|
|
1110
|
-
}
|
|
1111
|
-
else {
|
|
1112
|
-
res.status(400).json({
|
|
1113
|
-
error: `Invalid model "${String(rawModel)}" for provider "${provider}"`,
|
|
1114
|
-
allowed: (0, spec_models_1.getModelsForProvider)(provider).map((m) => m.value),
|
|
1115
|
-
});
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
const rawKind = req.body?.kind;
|
|
1119
|
-
const kind = rawKind === 'explore' ? 'explore' : 'sidebar';
|
|
1120
|
-
const id = (0, ids_1.newId)();
|
|
1121
|
-
const rawScope = req.body?.contextScope;
|
|
1122
|
-
if (rawScope !== undefined && kind !== 'explore') {
|
|
1123
|
-
res.status(400).json({ error: 'contextScope is only allowed for kind=explore' });
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
let scope;
|
|
1127
|
-
if (kind === 'explore') {
|
|
1128
|
-
const fallback = (0, context_scope_1.getLastContextScope)(db, 'explore');
|
|
1129
|
-
// Defence-in-depth: SMASH / Contract Layer is Claude-only. Strip
|
|
1130
|
-
// contractRefine from the scope when the conversation's resolved provider
|
|
1131
|
-
// is non-Claude so no downstream code (Contract Refine Runner, SMASH
|
|
1132
|
-
// eligibility) ever sees a mismatched flag.
|
|
1133
|
-
const safeRawScope = provider !== 'claude' && rawScope != null
|
|
1134
|
-
? { ...rawScope, contractRefine: false }
|
|
1135
|
-
: rawScope;
|
|
1136
|
-
scope = (0, context_scope_1.normalizeContextScope)(safeRawScope ?? fallback, fallback);
|
|
1137
|
-
(0, context_scope_1.setLastContextScope)(db, scope);
|
|
1138
|
-
console.log(`[project-router] new explore conv ${id} provider=${provider} scope=${JSON.stringify(scope)} rawScope=${JSON.stringify(rawScope)}`);
|
|
1139
|
-
}
|
|
1140
|
-
// Only persist provider when the project is multi-provider; single-provider
|
|
1141
|
-
// projects leave it NULL so behaviour is byte-identical to before.
|
|
1142
|
-
const persistProvider = (0, provider_selection_1.isMultiProvider)(project) ? provider : null;
|
|
1143
|
-
(0, db_1.createConversation)(db, { id, model, kind, contextScope: scope, provider: persistProvider });
|
|
1144
|
-
const conversation = (0, db_1.getConversation)(db, id);
|
|
1145
|
-
res.status(201).json({ conversation });
|
|
1146
|
-
});
|
|
1147
|
-
router.get('/:projectId/chat/conversations/:id', (req, res) => {
|
|
1148
|
-
const { db } = ctx(req);
|
|
1149
|
-
const conversation = (0, db_1.getConversation)(db, req.params.id);
|
|
1150
|
-
if (!conversation) {
|
|
1151
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
const messages = (0, db_1.getMessages)(db, req.params.id);
|
|
1155
|
-
res.json({ conversation, messages });
|
|
1156
|
-
});
|
|
1157
|
-
router.delete('/:projectId/chat/conversations/:id', (req, res) => {
|
|
1158
|
-
const { db, chatManager, broadcast, project, ticketWatcher } = ctx(req);
|
|
1159
|
-
const convId = req.params.id;
|
|
1160
|
-
const conversation = (0, db_1.getConversation)(db, convId);
|
|
1161
|
-
if (!conversation) {
|
|
1162
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1163
|
-
return;
|
|
1164
|
-
}
|
|
1165
|
-
(0, db_1.deleteConversation)(db, convId);
|
|
1166
|
-
chatManager?.forgetSpecDraft(convId);
|
|
1167
|
-
chatManager?.forgetExploreLifecycle(convId);
|
|
1168
|
-
// Cascade-clear origin_conversation_id on any ticket that referenced this
|
|
1169
|
-
// conversation (application-level "ON DELETE SET NULL").
|
|
1170
|
-
try {
|
|
1171
|
-
const filePath = ticketPath(req);
|
|
1172
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
1173
|
-
for (const id of Object.keys(s.tickets)) {
|
|
1174
|
-
if (s.tickets[id].origin_conversation_id === convId) {
|
|
1175
|
-
s.tickets[id].origin_conversation_id = null;
|
|
1176
|
-
s.tickets[id].updated_at = new Date().toISOString();
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
});
|
|
1180
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
1181
|
-
// No per-ticket broadcast: the cleared field is metadata-only and the
|
|
1182
|
-
// board card visual treatment doesn't depend on it.
|
|
1183
|
-
}
|
|
1184
|
-
catch (err) {
|
|
1185
|
-
console.error('[project-router] conversation-cascade ticket update error:', err);
|
|
1186
|
-
}
|
|
1187
|
-
res.json({ ok: true });
|
|
1188
|
-
});
|
|
1189
|
-
router.patch('/:projectId/chat/conversations/:id', (req, res) => {
|
|
1190
|
-
const { db } = ctx(req);
|
|
1191
|
-
const conversation = (0, db_1.getConversation)(db, req.params.id);
|
|
1192
|
-
if (!conversation) {
|
|
1193
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
const { title, model } = req.body ?? {};
|
|
1197
|
-
const patch = {};
|
|
1198
|
-
if (title !== undefined)
|
|
1199
|
-
patch.title = title;
|
|
1200
|
-
if (model !== undefined)
|
|
1201
|
-
patch.model = model;
|
|
1202
|
-
(0, db_1.updateConversation)(db, req.params.id, patch);
|
|
1203
|
-
const updated = (0, db_1.getConversation)(db, req.params.id);
|
|
1204
|
-
res.json({ ok: true, conversation: updated });
|
|
1205
|
-
});
|
|
1206
|
-
router.get('/:projectId/chat/conversations/:id/messages', (req, res) => {
|
|
1207
|
-
const { db } = ctx(req);
|
|
1208
|
-
const conversation = (0, db_1.getConversation)(db, req.params.id);
|
|
1209
|
-
if (!conversation) {
|
|
1210
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
const messages = (0, db_1.getMessages)(db, req.params.id);
|
|
1214
|
-
res.json({ messages });
|
|
1215
|
-
});
|
|
1216
|
-
// Returns the in-memory spec-draft state Claude has accumulated for this
|
|
1217
|
-
// conversation. Used by useSpecDraftStream on mount to rehydrate updates
|
|
1218
|
-
// that were broadcast while the client wasn't subscribed (refresh /
|
|
1219
|
-
// minimize-and-restore). Returns 200 with `null` draft when no state yet.
|
|
1220
|
-
router.get('/:projectId/chat/conversations/:id/spec-draft', (req, res) => {
|
|
1221
|
-
const { db, chatManager } = ctx(req);
|
|
1222
|
-
const conversation = (0, db_1.getConversation)(db, req.params.id);
|
|
1223
|
-
if (!conversation) {
|
|
1224
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
const state = chatManager.getSpecDraftState(req.params.id);
|
|
1228
|
-
if (!state) {
|
|
1229
|
-
res.json({ draft: null, ready: false, chips: [] });
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
res.json({
|
|
1233
|
-
draft: state.draft,
|
|
1234
|
-
ready: state.ready,
|
|
1235
|
-
chips: state.chips,
|
|
1236
|
-
});
|
|
1237
|
-
});
|
|
1238
|
-
router.post('/:projectId/chat/conversations/:id/messages', async (req, res) => {
|
|
1239
|
-
const { db, chatManager, project } = ctx(req);
|
|
1240
|
-
const conversation = (0, db_1.getConversation)(db, req.params.id);
|
|
1241
|
-
if (!conversation) {
|
|
1242
|
-
res.status(404).json({ error: 'Conversation not found' });
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
const text = req.body?.text;
|
|
1246
|
-
if (!text || !text.trim()) {
|
|
1247
|
-
res.status(400).json({ error: 'text is required' });
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
if (chatManager.isActive(req.params.id)) {
|
|
1251
|
-
res.status(409).json({ error: 'CONVERSATION_BUSY' });
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
const lightweight = req.body?.lightweight === true;
|
|
1255
|
-
const maxTurns = typeof req.body?.maxTurns === 'number' ? req.body.maxTurns : undefined;
|
|
1256
|
-
let attachments;
|
|
1257
|
-
const rawAtt = req.body?.attachments;
|
|
1258
|
-
if (rawAtt && typeof rawAtt === 'object' && typeof rawAtt.ticketKey === 'string'
|
|
1259
|
-
&& Array.isArray(rawAtt.ids)) {
|
|
1260
|
-
const ids = rawAtt.ids.filter((x) => typeof x === 'string');
|
|
1261
|
-
if (ids.length > 0) {
|
|
1262
|
-
attachments = { slug: project.slug, ticketKey: rawAtt.ticketKey, ids };
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
res.status(202).json({ ok: true });
|
|
1266
|
-
chatManager.sendMessage(req.params.id, text.trim(), { lightweight, maxTurns, attachments }).catch((err) => {
|
|
1267
|
-
console.error('[project-router] chat sendMessage error:', err);
|
|
1268
|
-
});
|
|
1269
|
-
});
|
|
1270
|
-
router.delete('/:projectId/chat/conversations/:id/messages/stream', (req, res) => {
|
|
1271
|
-
const { chatManager } = ctx(req);
|
|
1272
|
-
if (!chatManager.isActive(req.params.id)) {
|
|
1273
|
-
res.status(404).json({ error: 'No active stream for this conversation' });
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
chatManager.abort(req.params.id);
|
|
1277
|
-
res.json({ ok: true });
|
|
1278
|
-
});
|
|
1279
|
-
// Explore Spec lifecycle: minimize-to-toast hint and restore-from-toast hint.
|
|
1280
|
-
// Idempotent; does not mutate persistent state. See design.md D7.
|
|
1281
|
-
router.post('/:projectId/chat/conversations/:id/minimize', (req, res) => {
|
|
1282
|
-
ctx(req).chatManager.notifyMinimized(req.params.id);
|
|
1283
|
-
res.json({ ok: true });
|
|
1284
|
-
});
|
|
1285
|
-
router.post('/:projectId/chat/conversations/:id/restore', (req, res) => {
|
|
1286
|
-
ctx(req).chatManager.notifyRestored(req.params.id);
|
|
1287
|
-
res.json({ ok: true });
|
|
1288
|
-
});
|
|
1289
|
-
// ─── Install-config route ─────────────────────────────────────────────────────
|
|
1290
|
-
router.post('/:projectId/setup/install-config', (req, res) => {
|
|
1291
|
-
const { project } = ctx(req);
|
|
1292
|
-
const config = req.body ?? {};
|
|
1293
|
-
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
1294
|
-
res.status(400).json({ error: 'Request body must be a config object' });
|
|
1295
|
-
return;
|
|
1296
|
-
}
|
|
1297
|
-
const configDir = path_1.default.join(project.path, '.specrails');
|
|
1298
|
-
const configPath = path_1.default.join(configDir, 'install-config.yaml');
|
|
1299
|
-
try {
|
|
1300
|
-
fs_1.default.mkdirSync(configDir, { recursive: true });
|
|
1301
|
-
const yaml = serializeInstallConfigYaml(config);
|
|
1302
|
-
fs_1.default.writeFileSync(configPath, yaml, 'utf-8');
|
|
1303
|
-
res.json({ ok: true, path: configPath });
|
|
1304
|
-
}
|
|
1305
|
-
catch (err) {
|
|
1306
|
-
res.status(500).json({ error: `Failed to write install-config.yaml: ${err}` });
|
|
1307
|
-
}
|
|
1308
|
-
});
|
|
1309
|
-
// ─── Enrich routes (v3) + Setup aliases (v1/v2 backward compat) ──────────────
|
|
1310
|
-
router.post('/:projectId/setup/install', (req, res) => {
|
|
1311
|
-
const { project, setupManager } = ctx(req);
|
|
1312
|
-
if (setupManager.isInstalling(project.id)) {
|
|
1313
|
-
res.status(409).json({ error: 'Install already in progress' });
|
|
1314
|
-
return;
|
|
1315
|
-
}
|
|
1316
|
-
res.status(202).json({ ok: true });
|
|
1317
|
-
setupManager.startInstall(project.id, project.path);
|
|
1318
|
-
});
|
|
1319
|
-
router.post('/:projectId/enrich/start', (req, res) => {
|
|
1320
|
-
const { project, setupManager } = ctx(req);
|
|
1321
|
-
if (setupManager.isEnriching(project.id)) {
|
|
1322
|
-
res.status(409).json({ error: 'Enrich already in progress' });
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
res.status(202).json({ ok: true });
|
|
1326
|
-
setupManager.startEnrich(project.id, project.path, project.provider);
|
|
1327
|
-
});
|
|
1328
|
-
// Legacy alias: /setup/start → /enrich/start
|
|
1329
|
-
router.post('/:projectId/setup/start', (req, res) => {
|
|
1330
|
-
const { project, setupManager } = ctx(req);
|
|
1331
|
-
if (setupManager.isEnriching(project.id)) {
|
|
1332
|
-
res.status(409).json({ error: 'Setup already in progress' });
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
res.status(202).json({ ok: true });
|
|
1336
|
-
setupManager.startEnrich(project.id, project.path, project.provider);
|
|
1337
|
-
});
|
|
1338
|
-
router.post('/:projectId/enrich/message', (req, res) => {
|
|
1339
|
-
const { project, setupManager } = ctx(req);
|
|
1340
|
-
const { sessionId, message } = req.body ?? {};
|
|
1341
|
-
if (!sessionId || typeof sessionId !== 'string') {
|
|
1342
|
-
res.status(400).json({ error: 'sessionId is required' });
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
1346
|
-
res.status(400).json({ error: 'message is required' });
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
if (setupManager.isEnriching(project.id)) {
|
|
1350
|
-
res.status(409).json({ error: 'Enrich already in progress' });
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
res.status(202).json({ ok: true });
|
|
1354
|
-
setupManager.resumeEnrich(project.id, project.path, sessionId, message.trim(), project.provider);
|
|
1355
|
-
});
|
|
1356
|
-
// Legacy alias: /setup/message → /enrich/message
|
|
1357
|
-
router.post('/:projectId/setup/message', (req, res) => {
|
|
1358
|
-
const { project, setupManager } = ctx(req);
|
|
1359
|
-
const { sessionId, message } = req.body ?? {};
|
|
1360
|
-
if (!sessionId || typeof sessionId !== 'string') {
|
|
1361
|
-
res.status(400).json({ error: 'sessionId is required' });
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
1365
|
-
res.status(400).json({ error: 'message is required' });
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
if (setupManager.isEnriching(project.id)) {
|
|
1369
|
-
res.status(409).json({ error: 'Setup already in progress' });
|
|
1370
|
-
return;
|
|
1371
|
-
}
|
|
1372
|
-
res.status(202).json({ ok: true });
|
|
1373
|
-
setupManager.resumeEnrich(project.id, project.path, sessionId, message.trim(), project.provider);
|
|
1374
|
-
});
|
|
1375
|
-
router.get('/:projectId/setup/checkpoints', (req, res) => {
|
|
1376
|
-
const { project, setupManager } = ctx(req);
|
|
1377
|
-
const checkpoints = setupManager.getCheckpointStatus(project.id, project.path);
|
|
1378
|
-
const savedSessionId = (0, desktop_db_1.getProjectSetupSession)(registry.desktopDb, project.id);
|
|
1379
|
-
res.json({
|
|
1380
|
-
checkpoints,
|
|
1381
|
-
isInstalling: setupManager.isInstalling(project.id),
|
|
1382
|
-
isSettingUp: setupManager.isEnriching(project.id),
|
|
1383
|
-
isEnriching: setupManager.isEnriching(project.id),
|
|
1384
|
-
tier: setupManager.getInstallTier(project.id) ?? null,
|
|
1385
|
-
savedSessionId: savedSessionId ?? null,
|
|
1386
|
-
logLines: setupManager.getInstallLog(project.id),
|
|
1387
|
-
summary: setupManager.getSummary(project.path),
|
|
1388
|
-
});
|
|
1389
|
-
});
|
|
1390
|
-
router.post('/:projectId/setup/abort', (req, res) => {
|
|
1391
|
-
const { project, setupManager } = ctx(req);
|
|
1392
|
-
setupManager.abort(project.id);
|
|
1393
|
-
res.json({ ok: true });
|
|
1394
|
-
});
|
|
1395
|
-
// ─── Proposal routes ──────────────────────────────────────────────────────
|
|
1396
|
-
router.get('/:projectId/propose', (req, res) => {
|
|
1397
|
-
const limit = Math.min(parseInt(String(req.query.limit ?? '20'), 10) || 20, 100);
|
|
1398
|
-
const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0;
|
|
1399
|
-
const result = (0, db_1.listProposals)(ctx(req).db, { limit, offset });
|
|
1400
|
-
res.json(result);
|
|
1401
|
-
});
|
|
1402
|
-
router.post('/:projectId/propose', async (req, res) => {
|
|
1403
|
-
const { idea } = req.body ?? {};
|
|
1404
|
-
if (!idea || typeof idea !== 'string' || !idea.trim()) {
|
|
1405
|
-
res.status(400).json({ error: 'idea is required' });
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
// Pre-check: does the propose-feature command exist in this project?
|
|
1409
|
-
const testCmd = `/specrails:propose-feature test`;
|
|
1410
|
-
const resolved = (0, command_resolver_1.resolveCommand)(testCmd, ctx(req).project.path);
|
|
1411
|
-
if (resolved === testCmd) {
|
|
1412
|
-
res.status(400).json({ error: 'This project does not have the /specrails:propose-feature command installed. Run "npx specrails-core@latest" to update.' });
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
const id = (0, ids_1.newId)();
|
|
1416
|
-
(0, db_1.createProposal)(ctx(req).db, { id, idea: idea.trim() });
|
|
1417
|
-
res.status(202).json({ proposalId: id });
|
|
1418
|
-
ctx(req).proposalManager.startExploration(id, idea.trim()).catch((err) => {
|
|
1419
|
-
console.error('[project-router] proposal startExploration error:', err);
|
|
1420
|
-
});
|
|
1421
|
-
});
|
|
1422
|
-
router.get('/:projectId/propose/:id', (req, res) => {
|
|
1423
|
-
const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
|
|
1424
|
-
if (!proposal) {
|
|
1425
|
-
res.status(404).json({ error: 'Proposal not found' });
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
res.json({ proposal });
|
|
1429
|
-
});
|
|
1430
|
-
router.post('/:projectId/propose/:id/refine', async (req, res) => {
|
|
1431
|
-
const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
|
|
1432
|
-
if (!proposal) {
|
|
1433
|
-
res.status(404).json({ error: 'Proposal not found' });
|
|
1434
|
-
return;
|
|
1435
|
-
}
|
|
1436
|
-
const { feedback } = req.body ?? {};
|
|
1437
|
-
if (!feedback || typeof feedback !== 'string' || !feedback.trim()) {
|
|
1438
|
-
res.status(400).json({ error: 'feedback is required' });
|
|
1439
|
-
return;
|
|
1440
|
-
}
|
|
1441
|
-
if (ctx(req).proposalManager.isActive(req.params.id)) {
|
|
1442
|
-
res.status(409).json({ error: 'PROPOSAL_BUSY' });
|
|
1443
|
-
return;
|
|
1444
|
-
}
|
|
1445
|
-
if (proposal.status !== 'review') {
|
|
1446
|
-
res.status(409).json({ error: 'Proposal is not in review state' });
|
|
1447
|
-
return;
|
|
1448
|
-
}
|
|
1449
|
-
res.status(202).json({ ok: true });
|
|
1450
|
-
ctx(req).proposalManager.sendRefinement(req.params.id, feedback.trim()).catch((err) => {
|
|
1451
|
-
console.error('[project-router] proposal sendRefinement error:', err);
|
|
1452
|
-
});
|
|
1453
|
-
});
|
|
1454
|
-
router.post('/:projectId/propose/:id/create-issue', async (req, res) => {
|
|
1455
|
-
const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
|
|
1456
|
-
if (!proposal) {
|
|
1457
|
-
res.status(404).json({ error: 'Proposal not found' });
|
|
1458
|
-
return;
|
|
1459
|
-
}
|
|
1460
|
-
if (ctx(req).proposalManager.isActive(req.params.id)) {
|
|
1461
|
-
res.status(409).json({ error: 'PROPOSAL_BUSY' });
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1464
|
-
if (proposal.status !== 'review') {
|
|
1465
|
-
res.status(409).json({ error: 'Proposal is not in review state' });
|
|
1466
|
-
return;
|
|
1467
|
-
}
|
|
1468
|
-
res.status(202).json({ ok: true });
|
|
1469
|
-
ctx(req).proposalManager.createIssue(req.params.id).catch((err) => {
|
|
1470
|
-
console.error('[project-router] proposal createIssue error:', err);
|
|
1471
|
-
});
|
|
1472
|
-
});
|
|
1473
|
-
router.delete('/:projectId/propose/:id', (req, res) => {
|
|
1474
|
-
const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
|
|
1475
|
-
if (!proposal) {
|
|
1476
|
-
res.status(404).json({ error: 'Proposal not found' });
|
|
1477
|
-
return;
|
|
1478
|
-
}
|
|
1479
|
-
ctx(req).proposalManager.cancel(req.params.id);
|
|
1480
|
-
res.json({ ok: true });
|
|
1481
|
-
});
|
|
1482
|
-
// ─── Feature Funnel ─────────────────────────────────────────────────────────
|
|
1483
|
-
router.get('/:projectId/changes', (req, res) => {
|
|
1484
|
-
const { project, queueManager } = ctx(req);
|
|
1485
|
-
const activeCommands = queueManager.getJobs()
|
|
1486
|
-
.filter((j) => j.status === 'running' || j.status === 'queued')
|
|
1487
|
-
.map((j) => j.command);
|
|
1488
|
-
const changes = (0, changes_reader_1.readChanges)(project.path, activeCommands);
|
|
1489
|
-
res.json({ changes });
|
|
1490
|
-
});
|
|
1491
|
-
// ─── Change Artifact Browser ─────────────────────────────────────────────────
|
|
1492
|
-
const ALLOWED_ARTIFACTS = new Set(['proposal.md', 'design.md', 'tasks.md', 'delta-spec.md', 'context-bundle.md']);
|
|
1493
|
-
router.get('/:projectId/changes/:changeId/artifacts/:artifact', (req, res) => {
|
|
1494
|
-
const changeId = req.params.changeId;
|
|
1495
|
-
const artifact = req.params.artifact;
|
|
1496
|
-
if (!ALLOWED_ARTIFACTS.has(artifact)) {
|
|
1497
|
-
res.status(400).json({ error: 'Invalid artifact name' });
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
// Sanitize changeId to prevent path traversal
|
|
1501
|
-
if (!/^[\w-]+$/.test(changeId)) {
|
|
1502
|
-
res.status(400).json({ error: 'Invalid change ID' });
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
const { project } = ctx(req);
|
|
1506
|
-
const changesRoot = path_1.default.join(project.path, 'openspec', 'changes');
|
|
1507
|
-
// Check active dir first, then archive
|
|
1508
|
-
let filePath = path_1.default.join(changesRoot, changeId, artifact);
|
|
1509
|
-
if (!fs_1.default.existsSync(filePath)) {
|
|
1510
|
-
filePath = path_1.default.join(changesRoot, 'archive', changeId, artifact);
|
|
1511
|
-
}
|
|
1512
|
-
if (!fs_1.default.existsSync(filePath)) {
|
|
1513
|
-
res.status(404).json({ error: 'Artifact not found' });
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
try {
|
|
1517
|
-
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
1518
|
-
res.json({ content, artifact, changeId });
|
|
1519
|
-
}
|
|
1520
|
-
catch {
|
|
1521
|
-
res.status(500).json({ error: 'Failed to read artifact' });
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1524
|
-
// ─── Spec Launcher ───────────────────────────────────────────────────────────
|
|
1525
|
-
router.post('/:projectId/spec-launcher/start', (req, res) => {
|
|
1526
|
-
const { description } = req.body ?? {};
|
|
1527
|
-
if (!description || typeof description !== 'string' || !description.trim()) {
|
|
1528
|
-
res.status(400).json({ error: 'description is required' });
|
|
1529
|
-
return;
|
|
1530
|
-
}
|
|
1531
|
-
const launchId = (0, ids_1.newId)();
|
|
1532
|
-
res.status(202).json({ launchId });
|
|
1533
|
-
ctx(req).specLauncherManager.launch(launchId, description.trim()).catch((err) => {
|
|
1534
|
-
console.error('[project-router] spec-launcher error:', err);
|
|
1535
|
-
});
|
|
1536
|
-
});
|
|
1537
|
-
router.delete('/:projectId/spec-launcher/:launchId', (req, res) => {
|
|
1538
|
-
const { specLauncherManager } = ctx(req);
|
|
1539
|
-
if (!specLauncherManager.isActive(req.params.launchId)) {
|
|
1540
|
-
res.status(404).json({ error: 'No active launch with that ID' });
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
specLauncherManager.cancel(req.params.launchId);
|
|
1544
|
-
res.json({ ok: true });
|
|
1545
|
-
});
|
|
1546
|
-
// ─── Job Templates ────────────────────────────────────────────────────────
|
|
1547
|
-
function templateToPublic(row) {
|
|
1548
|
-
if (!row)
|
|
1549
|
-
return null;
|
|
1550
|
-
return {
|
|
1551
|
-
id: row.id,
|
|
1552
|
-
name: row.name,
|
|
1553
|
-
description: row.description,
|
|
1554
|
-
commands: JSON.parse(row.commands),
|
|
1555
|
-
created_at: row.created_at,
|
|
1556
|
-
updated_at: row.updated_at,
|
|
1557
|
-
};
|
|
1558
|
-
}
|
|
1559
|
-
router.get('/:projectId/templates', (req, res) => {
|
|
1560
|
-
const rows = (0, db_1.listTemplates)(ctx(req).db);
|
|
1561
|
-
const templates = rows.map((r) => templateToPublic(r));
|
|
1562
|
-
res.json({ templates });
|
|
1563
|
-
});
|
|
1564
|
-
router.post('/:projectId/templates', (req, res) => {
|
|
1565
|
-
const { name, description, commands } = req.body ?? {};
|
|
1566
|
-
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
1567
|
-
res.status(400).json({ error: 'name is required' });
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
if (!Array.isArray(commands) || commands.length === 0) {
|
|
1571
|
-
res.status(400).json({ error: 'commands must be a non-empty array' });
|
|
1572
|
-
return;
|
|
1573
|
-
}
|
|
1574
|
-
if (commands.some((c) => typeof c !== 'string' || !String(c).trim())) {
|
|
1575
|
-
res.status(400).json({ error: 'each command must be a non-empty string' });
|
|
1576
|
-
return;
|
|
1577
|
-
}
|
|
1578
|
-
const id = (0, ids_1.newId)();
|
|
1579
|
-
try {
|
|
1580
|
-
(0, db_1.createTemplate)(ctx(req).db, {
|
|
1581
|
-
id,
|
|
1582
|
-
name: name.trim(),
|
|
1583
|
-
description: description && typeof description === 'string' ? description.trim() : undefined,
|
|
1584
|
-
commands: commands.map((c) => c.trim()),
|
|
1585
|
-
});
|
|
1586
|
-
}
|
|
1587
|
-
catch (err) {
|
|
1588
|
-
const msg = err instanceof Error ? err.message : '';
|
|
1589
|
-
if (msg.includes('UNIQUE constraint failed')) {
|
|
1590
|
-
res.status(409).json({ error: 'A template with that name already exists' });
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
console.error('[project-router] create template error:', err);
|
|
1594
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1595
|
-
return;
|
|
1596
|
-
}
|
|
1597
|
-
const created = templateToPublic((0, db_1.getTemplate)(ctx(req).db, id));
|
|
1598
|
-
res.status(201).json({ template: created });
|
|
1599
|
-
});
|
|
1600
|
-
router.get('/:projectId/templates/:templateId', (req, res) => {
|
|
1601
|
-
const row = (0, db_1.getTemplate)(ctx(req).db, req.params.templateId);
|
|
1602
|
-
if (!row) {
|
|
1603
|
-
res.status(404).json({ error: 'Template not found' });
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
res.json({ template: templateToPublic(row) });
|
|
1607
|
-
});
|
|
1608
|
-
router.patch('/:projectId/templates/:templateId', (req, res) => {
|
|
1609
|
-
const { db } = ctx(req);
|
|
1610
|
-
const templateId = req.params.templateId;
|
|
1611
|
-
const row = (0, db_1.getTemplate)(db, templateId);
|
|
1612
|
-
if (!row) {
|
|
1613
|
-
res.status(404).json({ error: 'Template not found' });
|
|
1614
|
-
return;
|
|
1615
|
-
}
|
|
1616
|
-
const { name, description, commands } = req.body ?? {};
|
|
1617
|
-
const patch = {};
|
|
1618
|
-
if (name !== undefined) {
|
|
1619
|
-
if (typeof name !== 'string' || !name.trim()) {
|
|
1620
|
-
res.status(400).json({ error: 'name must be a non-empty string' });
|
|
1621
|
-
return;
|
|
1622
|
-
}
|
|
1623
|
-
patch.name = name.trim();
|
|
1624
|
-
}
|
|
1625
|
-
if (description !== undefined) {
|
|
1626
|
-
patch.description = description === null ? null : String(description).trim() || null;
|
|
1627
|
-
}
|
|
1628
|
-
if (commands !== undefined) {
|
|
1629
|
-
if (!Array.isArray(commands) || commands.length === 0) {
|
|
1630
|
-
res.status(400).json({ error: 'commands must be a non-empty array' });
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
if (commands.some((c) => typeof c !== 'string' || !String(c).trim())) {
|
|
1634
|
-
res.status(400).json({ error: 'each command must be a non-empty string' });
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
patch.commands = commands.map((c) => c.trim());
|
|
1638
|
-
}
|
|
1639
|
-
try {
|
|
1640
|
-
(0, db_1.updateTemplate)(db, templateId, patch);
|
|
1641
|
-
}
|
|
1642
|
-
catch (err) {
|
|
1643
|
-
const msg = err instanceof Error ? err.message : '';
|
|
1644
|
-
if (msg.includes('UNIQUE constraint failed')) {
|
|
1645
|
-
res.status(409).json({ error: 'A template with that name already exists' });
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
console.error('[project-router] update template error:', err);
|
|
1649
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1650
|
-
return;
|
|
1651
|
-
}
|
|
1652
|
-
const updated = templateToPublic((0, db_1.getTemplate)(db, templateId));
|
|
1653
|
-
res.json({ ok: true, template: updated });
|
|
1654
|
-
});
|
|
1655
|
-
router.delete('/:projectId/templates/:templateId', (req, res) => {
|
|
1656
|
-
const { db } = ctx(req);
|
|
1657
|
-
const row = (0, db_1.getTemplate)(db, req.params.templateId);
|
|
1658
|
-
if (!row) {
|
|
1659
|
-
res.status(404).json({ error: 'Template not found' });
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
(0, db_1.deleteTemplate)(db, req.params.templateId);
|
|
1663
|
-
res.json({ ok: true });
|
|
1664
|
-
});
|
|
1665
|
-
router.post('/:projectId/templates/:templateId/run', (req, res) => {
|
|
1666
|
-
const { db, queueManager } = ctx(req);
|
|
1667
|
-
const row = (0, db_1.getTemplate)(db, req.params.templateId);
|
|
1668
|
-
if (!row) {
|
|
1669
|
-
res.status(404).json({ error: 'Template not found' });
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
const commands = JSON.parse(row.commands);
|
|
1673
|
-
const chain = req.body?.chain !== false; // default: chain jobs as pipeline
|
|
1674
|
-
const jobIds = [];
|
|
1675
|
-
try {
|
|
1676
|
-
const pipelineId = chain && commands.length > 1 ? (0, ids_1.newId)() : undefined;
|
|
1677
|
-
let prevJobId = null;
|
|
1678
|
-
for (const command of commands) {
|
|
1679
|
-
const job = queueManager.enqueue(command, 'normal', {
|
|
1680
|
-
dependsOnJobId: chain ? (prevJobId ?? undefined) : undefined,
|
|
1681
|
-
pipelineId,
|
|
1682
|
-
});
|
|
1683
|
-
jobIds.push(job.id);
|
|
1684
|
-
prevJobId = job.id;
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
catch (err) {
|
|
1688
|
-
if (err instanceof queue_manager_1.ClaudeNotFoundError) {
|
|
1689
|
-
res.status(400).json({ error: err.message });
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
console.error('[project-router] template run error:', err);
|
|
1693
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1694
|
-
return;
|
|
1695
|
-
}
|
|
1696
|
-
res.status(202).json({ ok: true, jobIds, templateId: row.id, templateName: row.name });
|
|
1697
|
-
});
|
|
1698
|
-
// ─── Integration contract ──────────────────────────────────────────────────
|
|
1699
|
-
const DEFAULT_TICKET_CAPABILITIES = ['crud', 'labels', 'status', 'priorities', 'dependencies'];
|
|
1700
|
-
const DEFAULT_TICKET_STORAGE_PATH = '.specrails/local-tickets.json';
|
|
1701
|
-
// GET /:projectId/integration-contract — Return the project's integration contract with ticketProvider
|
|
1702
|
-
router.get('/:projectId/integration-contract', (req, res) => {
|
|
1703
|
-
const projectPath = ctx(req).project.path;
|
|
1704
|
-
const contractFile = path_1.default.join(projectPath, '.claude', 'integration-contract.json');
|
|
1705
|
-
let rawContract = {};
|
|
1706
|
-
let source = 'default';
|
|
1707
|
-
if (fs_1.default.existsSync(contractFile)) {
|
|
1708
|
-
try {
|
|
1709
|
-
rawContract = JSON.parse(fs_1.default.readFileSync(contractFile, 'utf-8'));
|
|
1710
|
-
source = 'contract';
|
|
1711
|
-
}
|
|
1712
|
-
catch {
|
|
1713
|
-
// malformed contract — fall back to defaults
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
const rawProvider = rawContract.ticketProvider;
|
|
1717
|
-
const storagePath = rawProvider?.storagePath ?? DEFAULT_TICKET_STORAGE_PATH;
|
|
1718
|
-
const ticketProvider = {
|
|
1719
|
-
type: rawProvider?.type ?? 'local',
|
|
1720
|
-
storagePath: path_1.default.resolve(projectPath, storagePath),
|
|
1721
|
-
capabilities: rawProvider?.capabilities ?? DEFAULT_TICKET_CAPABILITIES,
|
|
1722
|
-
};
|
|
1723
|
-
res.json({ ticketProvider, source });
|
|
1724
|
-
});
|
|
1725
|
-
// ─── Tickets ──────────────────────────────────────────────────────────────────
|
|
1726
|
-
/** Resolve the ticket storage file path for a project */
|
|
1727
|
-
function ticketPath(req) {
|
|
1728
|
-
return (0, ticket_store_1.resolveTicketStoragePath)(ctx(req).project.path);
|
|
1729
|
-
}
|
|
1730
|
-
// GET /:projectId/tickets — List all tickets with optional filters
|
|
1731
|
-
router.get('/:projectId/tickets', (req, res) => {
|
|
1732
|
-
try {
|
|
1733
|
-
const filePath = ticketPath(req);
|
|
1734
|
-
const store = (0, ticket_store_1.readStore)(filePath);
|
|
1735
|
-
const allTickets = Object.values(store.tickets);
|
|
1736
|
-
const filtered = (0, ticket_store_1.filterTickets)(allTickets, {
|
|
1737
|
-
status: req.query.status,
|
|
1738
|
-
label: req.query.label,
|
|
1739
|
-
q: req.query.q,
|
|
1740
|
-
});
|
|
1741
|
-
// Sort by updated_at descending
|
|
1742
|
-
filtered.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
|
1743
|
-
res.json({ tickets: filtered, revision: store.revision, total: allTickets.length });
|
|
1744
|
-
}
|
|
1745
|
-
catch (err) {
|
|
1746
|
-
console.error('[project-router] ticket list error:', err);
|
|
1747
|
-
res.status(500).json({ error: 'Failed to read tickets' });
|
|
1748
|
-
}
|
|
1749
|
-
});
|
|
1750
|
-
// GET /:projectId/tickets/:id — Get single ticket
|
|
1751
|
-
router.get('/:projectId/tickets/:id', (req, res) => {
|
|
1752
|
-
const ticketId = req.params.id;
|
|
1753
|
-
if (!/^\d+$/.test(ticketId)) {
|
|
1754
|
-
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
try {
|
|
1758
|
-
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
1759
|
-
const ticket = store.tickets[ticketId];
|
|
1760
|
-
if (!ticket) {
|
|
1761
|
-
res.status(404).json({ error: 'Ticket not found' });
|
|
1762
|
-
return;
|
|
1763
|
-
}
|
|
1764
|
-
res.json({ ticket, revision: store.revision });
|
|
1765
|
-
}
|
|
1766
|
-
catch (err) {
|
|
1767
|
-
console.error('[project-router] ticket get error:', err);
|
|
1768
|
-
res.status(500).json({ error: 'Failed to read ticket' });
|
|
1769
|
-
}
|
|
1770
|
-
});
|
|
1771
|
-
// POST /:projectId/tickets/generate-spec — Fast AI spec generation (no codebase exploration)
|
|
1772
|
-
router.post('/:projectId/tickets/generate-spec', async (req, res) => {
|
|
1773
|
-
const idea = req.body?.idea;
|
|
1774
|
-
if (!idea?.trim()) {
|
|
1775
|
-
res.status(400).json({ error: 'idea is required' });
|
|
1776
|
-
return;
|
|
1777
|
-
}
|
|
1778
|
-
const attachmentIds = Array.isArray(req.body?.attachmentIds)
|
|
1779
|
-
? req.body.attachmentIds.filter((x) => typeof x === 'string')
|
|
1780
|
-
: [];
|
|
1781
|
-
const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId : null;
|
|
1782
|
-
if (attachmentIds.length > 0 && !pendingSpecId) {
|
|
1783
|
-
res.status(400).json({ error: 'pendingSpecId is required when attachmentIds are provided' });
|
|
1784
|
-
return;
|
|
1785
|
-
}
|
|
1786
|
-
const { project, broadcast, ticketWatcher } = ctx(req);
|
|
1787
|
-
// Multi-provider: optional aiEngine (alias provider) picks the engine for
|
|
1788
|
-
// this Quick spec; must be installed on the project. Omitting it uses the
|
|
1789
|
-
// primary provider.
|
|
1790
|
-
const requestedEngine = req.body?.aiEngine ?? req.body?.provider;
|
|
1791
|
-
const engineCheck = (0, provider_selection_1.validateRequestedProvider)(project, requestedEngine);
|
|
1792
|
-
if (!engineCheck.ok) {
|
|
1793
|
-
res.status(400).json({ error: engineCheck.error });
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
const provider = engineCheck.provider;
|
|
1797
|
-
// Resolve and validate the model. Order:
|
|
1798
|
-
// - Body had a `model` and it's valid → use it.
|
|
1799
|
-
// - Body had a `model` and it's invalid → 400 with the allow-list.
|
|
1800
|
-
// - Body had no `model` → fall back to project default.
|
|
1801
|
-
const rawModel = req.body?.model;
|
|
1802
|
-
let resolvedModel;
|
|
1803
|
-
if (rawModel === undefined || rawModel === null || rawModel === '') {
|
|
1804
|
-
resolvedModel = resolveDefaultSpecModel({ projectPath: project.path, provider });
|
|
1805
|
-
}
|
|
1806
|
-
else if ((0, spec_models_1.isValidModelForProvider)(rawModel, provider)) {
|
|
1807
|
-
resolvedModel = rawModel;
|
|
1808
|
-
}
|
|
1809
|
-
else {
|
|
1810
|
-
res.status(400).json({
|
|
1811
|
-
error: `Invalid model "${String(rawModel)}" for provider "${provider}"`,
|
|
1812
|
-
allowed: (0, spec_models_1.getModelsForProvider)(provider).map((m) => m.value),
|
|
1813
|
-
});
|
|
1814
|
-
return;
|
|
1815
|
-
}
|
|
1816
|
-
const requestId = (0, ids_1.newId)();
|
|
1817
|
-
const projectId = project.id;
|
|
1818
|
-
const filePath = ticketPath(req);
|
|
1819
|
-
let hasAttachments = false;
|
|
1820
|
-
let baseUserPrompt = `Generate a spec for the following idea:\n\n${idea.trim()}`;
|
|
1821
|
-
let imageFlags = [];
|
|
1822
|
-
if (attachmentIds.length > 0 && pendingSpecId) {
|
|
1823
|
-
try {
|
|
1824
|
-
const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, pendingSpecId, attachmentIds);
|
|
1825
|
-
imageFlags = extracted.imageFlags;
|
|
1826
|
-
if (extracted.textBlocks.length > 0) {
|
|
1827
|
-
hasAttachments = true;
|
|
1828
|
-
baseUserPrompt = `${baseUserPrompt}\n\n## Attached Resources\n\n${extracted.textBlocks.join('\n\n')}`;
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
catch (err) {
|
|
1832
|
-
console.error('[project-router] generate-spec attachment extraction error:', err);
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
// Parse contextScope from body. Quick and Explore share the same Context
|
|
1836
|
-
// Awareness controls; Quick still keeps Contract Refine as a top-level
|
|
1837
|
-
// field for the refine scheduler.
|
|
1838
|
-
const rawScope = req.body?.contextScope;
|
|
1839
|
-
// Contract Layer is Claude-only — force it off for any non-claude engine
|
|
1840
|
-
// (defence-in-depth; the Quick UI hides the toggle for those).
|
|
1841
|
-
const quickContractRefine = provider !== 'claude'
|
|
1842
|
-
? false
|
|
1843
|
-
: typeof req.body?.contractRefine === 'boolean'
|
|
1844
|
-
? req.body.contractRefine
|
|
1845
|
-
: typeof rawScope?.contractRefine === 'boolean'
|
|
1846
|
-
? rawScope.contractRefine
|
|
1847
|
-
: false;
|
|
1848
|
-
const quickScope = {
|
|
1849
|
-
specrails: typeof rawScope?.specrails === 'boolean' ? rawScope.specrails : false,
|
|
1850
|
-
openspec: typeof rawScope?.openspec === 'boolean' ? rawScope.openspec : false,
|
|
1851
|
-
full: typeof rawScope?.full === 'boolean' ? rawScope.full : false,
|
|
1852
|
-
mcp: false,
|
|
1853
|
-
contractRefine: quickContractRefine,
|
|
1854
|
-
// Quick mode never injects MCPs (user MCP is an Explore-only toggle).
|
|
1855
|
-
userMcp: false,
|
|
1856
|
-
};
|
|
1857
|
-
// Persist Quick mode Contract Refine choice (per-project last value).
|
|
1858
|
-
(0, db_1.setQuickContractRefineLast)(ctx(req).db, quickContractRefine);
|
|
1859
|
-
const specsPrefix = (0, context_scope_1.buildScopedSystemPromptPrefix)(quickScope, project.path);
|
|
1860
|
-
const codebaseRule = quickScope.full
|
|
1861
|
-
? `- You MAY use Read, Grep, and Glob to inspect the project codebase. Bash is not available.`
|
|
1862
|
-
: hasAttachments
|
|
1863
|
-
? `- Do NOT explore the project codebase. The resources inside <user-attachment> blocks below are pre-loaded context the user intentionally provided — read and use them freely.`
|
|
1864
|
-
: `- Do NOT read any files or explore the codebase. Work purely from the user's description.`;
|
|
1865
|
-
// The specrails-tickets prefix (when scope.specrails is toggled on)
|
|
1866
|
-
// dumps every ticket into the prompt as informational context. Without
|
|
1867
|
-
// an explicit dedup instruction the model treats it as background and
|
|
1868
|
-
// still proposes a near-duplicate of something already in the backlog.
|
|
1869
|
-
// Adding the rule here, gated on `quickScope.specrails`, keeps the
|
|
1870
|
-
// "toggle is the only gate" contract the user asked for.
|
|
1871
|
-
const dedupRule = quickScope.specrails
|
|
1872
|
-
? `- The "Specrails Tickets" section above lists every ticket already in the backlog. Do NOT propose a duplicate or a near-duplicate of any of them. If the user's idea is already covered by an existing ticket, say so in "Problem Statement" and pick a *different* angle / sub-feature / next step that builds on the existing one — do not repeat it.\n`
|
|
1873
|
-
: '';
|
|
1874
|
-
const backlogRecommendationRule = quickScope.specrails
|
|
1875
|
-
? `- If the user's idea asks for the "next best spec" or a backlog recommendation, use the existing tickets and OpenSpec context to choose one concrete next spec. Do not respond with generic product directions.\n`
|
|
1876
|
-
: '';
|
|
1877
|
-
let baseSystemPrompt = `You are a senior product engineer generating a structured spec proposal.\n\n` +
|
|
1878
|
-
(specsPrefix ? `${specsPrefix}\n\n` : '') +
|
|
1879
|
-
`RULES:\n` +
|
|
1880
|
-
`${codebaseRule}\n` +
|
|
1881
|
-
dedupRule +
|
|
1882
|
-
backlogRecommendationRule +
|
|
1883
|
-
`- Do NOT create files, tickets, or issues.\n` +
|
|
1884
|
-
`- Output ONLY the structured markdown below. No preamble, no explanation.\n\n` +
|
|
1885
|
-
`REQUIRED FORMAT:\n` +
|
|
1886
|
-
`## Spec Title\n[Concise, action-oriented title]\n\n` +
|
|
1887
|
-
`## Labels\n[2-4 short kebab-case tags categorising the spec — comma-separated on one line, e.g. "ui, settings, dark-mode". Lowercase, no spaces inside a tag.]\n\n` +
|
|
1888
|
-
`## Problem Statement\n[2-3 sentences]\n\n` +
|
|
1889
|
-
`## Proposed Solution\n[3-5 sentences]\n\n` +
|
|
1890
|
-
`## Out of Scope\n[Bullet list]\n\n` +
|
|
1891
|
-
`## Acceptance Criteria\n[Numbered list of testable outcomes]\n\n` +
|
|
1892
|
-
`## Technical Considerations\n[Bullet list]\n\n` +
|
|
1893
|
-
`## Estimated Complexity\n[Low/Medium/High/Very High + one sentence justification]\n\n` +
|
|
1894
|
-
`## Short Summary\n[One or two plain-language sentences, max 120 characters total, that capture the essence of this spec for a dashboard postit. No markdown, no bullets, no headings.]`;
|
|
1895
|
-
if (hasAttachments)
|
|
1896
|
-
baseSystemPrompt = `${baseSystemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
|
|
1897
|
-
const systemPrompt = baseSystemPrompt;
|
|
1898
|
-
const userPrompt = baseUserPrompt;
|
|
1899
|
-
// Generate-spec spawn args are adapter-driven. For Claude the `--tools`
|
|
1900
|
-
// flag set comes from `toolFlagsForScope(quickScope)` which the adapter
|
|
1901
|
-
// doesn't model — pass them through `extraArgs` so they slot in after
|
|
1902
|
-
// the standard COMMON_FLAGS. `imageFlags` (also Claude-only) goes the
|
|
1903
|
-
// same way. For codex the system prompt folds into the user prompt
|
|
1904
|
-
// (no --system-prompt flag) and the extra Claude-only flags are ignored
|
|
1905
|
-
// by the codex adapter (it doesn't read extraArgs that don't apply).
|
|
1906
|
-
const adapter = (0, providers_1.getAdapter)(provider);
|
|
1907
|
-
const toolFlags = provider === 'claude' ? (0, context_scope_1.toolFlagsForScope)(quickScope) : { args: [] };
|
|
1908
|
-
// Full scope grants Read/Grep/Glob. The model spends turns exploring the
|
|
1909
|
-
// repo before it writes the spec; 6 was too tight (a few tool calls on a
|
|
1910
|
-
// sparse/empty repo hit error_max_turns → exit 1 → opaque failure). 15
|
|
1911
|
-
// leaves comfortable headroom while --max-turns still bounds runaway loops.
|
|
1912
|
-
const claudeMaxTurns = quickScope.full ? 15 : (hasAttachments ? 3 : 1);
|
|
1913
|
-
const args = adapter.buildArgs('spec-gen', {
|
|
1914
|
-
prompt: userPrompt,
|
|
1915
|
-
systemPrompt,
|
|
1916
|
-
model: resolvedModel,
|
|
1917
|
-
maxTurns: provider === 'claude' ? claudeMaxTurns : undefined,
|
|
1918
|
-
extraArgs: provider === 'claude' ? [...toolFlags.args, ...imageFlags] : undefined,
|
|
1919
|
-
});
|
|
1920
|
-
const binary = adapter.binary;
|
|
1921
|
-
// spawnAiCli reroutes multi-line argv values through stdin on Windows;
|
|
1922
|
-
// POSIX argv path unchanged.
|
|
1923
|
-
console.log(`[project-router] spec-gen spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
|
|
1924
|
-
const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
|
|
1925
|
-
env: process.env,
|
|
1926
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1927
|
-
cwd: project.path,
|
|
1928
|
-
});
|
|
1929
|
-
// Watchdog: unlike ai-edit, generate-spec keeps no cancellable handle, so a
|
|
1930
|
-
// hung CLI (network stall, model never emitting a terminating event) would
|
|
1931
|
-
// otherwise leak this child + its readline for the app's lifetime. Cap is
|
|
1932
|
-
// generous — the 'full' scope can legitimately run minutes and --max-turns
|
|
1933
|
-
// bounds turns, not wall-clock. Cleared on close/error.
|
|
1934
|
-
const GENERATE_SPEC_TIMEOUT_MS = 8 * 60 * 1000;
|
|
1935
|
-
let specGenWatchdog = setTimeout(() => {
|
|
1936
|
-
specGenWatchdog = null;
|
|
1937
|
-
if (child.pid) {
|
|
1938
|
-
try {
|
|
1939
|
-
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
1940
|
-
}
|
|
1941
|
-
catch { /* best-effort */ }
|
|
1942
|
-
}
|
|
1943
|
-
broadcast({
|
|
1944
|
-
type: 'spec_gen_error', projectId, requestId,
|
|
1945
|
-
error: `Spec generation timed out after ${Math.round(GENERATE_SPEC_TIMEOUT_MS / 1000)}s`,
|
|
1946
|
-
timestamp: new Date().toISOString(),
|
|
1947
|
-
});
|
|
1948
|
-
}, GENERATE_SPEC_TIMEOUT_MS);
|
|
1949
|
-
if (typeof specGenWatchdog.unref === 'function')
|
|
1950
|
-
specGenWatchdog.unref();
|
|
1951
|
-
const clearSpecGenWatchdog = () => {
|
|
1952
|
-
if (specGenWatchdog) {
|
|
1953
|
-
clearTimeout(specGenWatchdog);
|
|
1954
|
-
specGenWatchdog = null;
|
|
1955
|
-
}
|
|
1956
|
-
};
|
|
1957
|
-
// Capture stderr so failures (auth missing, model errors, etc.) surface
|
|
1958
|
-
// in the server log instead of being swallowed.
|
|
1959
|
-
let stderrBuf = '';
|
|
1960
|
-
/* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
|
|
1961
|
-
child.stderr?.on('data', (chunk) => {
|
|
1962
|
-
const s = chunk.toString();
|
|
1963
|
-
stderrBuf += s;
|
|
1964
|
-
console.error(`[project-router] spec-gen stderr (${requestId}): ${s.trimEnd()}`);
|
|
1965
|
-
});
|
|
1966
|
-
/* c8 ignore stop */
|
|
1967
|
-
// Without this listener, ENOENT (binary missing on PATH) propagates as
|
|
1968
|
-
// an unhandled 'error' event and crashes the entire app process.
|
|
1969
|
-
/* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
|
|
1970
|
-
child.on('error', (err) => {
|
|
1971
|
-
clearSpecGenWatchdog();
|
|
1972
|
-
console.error(`[project-router] spec-gen spawn failed (${binary}): ${err.message}`);
|
|
1973
|
-
const errMsg = {
|
|
1974
|
-
type: 'spec_gen_error', projectId, requestId,
|
|
1975
|
-
error: `Failed to launch ${binary}: ${err.message}`,
|
|
1976
|
-
timestamp: new Date().toISOString(),
|
|
1977
|
-
};
|
|
1978
|
-
broadcast(errMsg);
|
|
1979
|
-
});
|
|
1980
|
-
/* c8 ignore stop */
|
|
1981
|
-
res.status(202).json({ requestId });
|
|
1982
|
-
let buffer = '';
|
|
1983
|
-
let lastResultEvent = null;
|
|
1984
|
-
// Canonical adapter events feed finaliseInvocationResult on close, giving
|
|
1985
|
-
// codex a real pricing-table cost estimate (+ estimated flag) and tokens,
|
|
1986
|
-
// instead of the legacy hardcoded $0. Accumulated ALONGSIDE the existing
|
|
1987
|
-
// buffer/delta plumbing below — never in place of it.
|
|
1988
|
-
const adapterEvents = [];
|
|
1989
|
-
const turnStartedAt = new Date().toISOString();
|
|
1990
|
-
const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
1991
|
-
stdoutReader.on('line', (line) => {
|
|
1992
|
-
const adapterEv = adapter.parseStreamLine(line);
|
|
1993
|
-
if (adapterEv)
|
|
1994
|
-
adapterEvents.push(adapterEv);
|
|
1995
|
-
let parsed = null;
|
|
1996
|
-
try {
|
|
1997
|
-
parsed = JSON.parse(line);
|
|
1998
|
-
}
|
|
1999
|
-
catch { /* skip */ }
|
|
2000
|
-
if (!parsed)
|
|
2001
|
-
return;
|
|
2002
|
-
if (provider === 'codex') {
|
|
2003
|
-
// Codex `exec --json` emits one event per line. Capture the final
|
|
2004
|
-
// `turn.completed` for usage extraction, and accumulate ONLY the
|
|
2005
|
-
// assistant_message text — never the command_execution items or
|
|
2006
|
-
// wrapper events, otherwise the raw JSONL ends up in the ticket
|
|
2007
|
-
// description.
|
|
2008
|
-
if (parsed.type === 'turn.completed') {
|
|
2009
|
-
lastResultEvent = parsed;
|
|
2010
|
-
return;
|
|
2011
|
-
}
|
|
2012
|
-
if (parsed.type !== 'item.completed')
|
|
2013
|
-
return;
|
|
2014
|
-
const item = parsed.item;
|
|
2015
|
-
if (!item || item.type !== 'agent_message')
|
|
2016
|
-
return;
|
|
2017
|
-
const newText = (item.text ?? '').trim();
|
|
2018
|
-
if (!newText)
|
|
2019
|
-
return;
|
|
2020
|
-
// Each agent_message is a complete chunk — separate with a blank
|
|
2021
|
-
// line so the parser regexes match cleanly across chunks.
|
|
2022
|
-
buffer += (buffer.endsWith('\n') || buffer.length === 0 ? '' : '\n') + newText + '\n';
|
|
2023
|
-
const msg = {
|
|
2024
|
-
type: 'spec_gen_stream', projectId, requestId,
|
|
2025
|
-
delta: newText + '\n', timestamp: new Date().toISOString(),
|
|
2026
|
-
};
|
|
2027
|
-
broadcast(msg);
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
// Claude path.
|
|
2031
|
-
if (parsed.type === 'result') {
|
|
2032
|
-
lastResultEvent = parsed;
|
|
2033
|
-
}
|
|
2034
|
-
if (parsed.type === 'assistant') {
|
|
2035
|
-
const msg = parsed.message;
|
|
2036
|
-
const texts = (msg?.content ?? [])
|
|
2037
|
-
.filter((c) => c.type === 'text')
|
|
2038
|
-
.map((c) => c.text ?? '');
|
|
2039
|
-
const newText = texts.join('');
|
|
2040
|
-
if (newText) {
|
|
2041
|
-
buffer += newText;
|
|
2042
|
-
const wsMsg = {
|
|
2043
|
-
type: 'spec_gen_stream', projectId, requestId,
|
|
2044
|
-
delta: newText, timestamp: new Date().toISOString(),
|
|
2045
|
-
};
|
|
2046
|
-
broadcast(wsMsg);
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
});
|
|
2050
|
-
child.on('close', async (code) => {
|
|
2051
|
-
clearSpecGenWatchdog();
|
|
2052
|
-
let createdTicketId = null;
|
|
2053
|
-
// When claude burns its whole --max-turns budget it exits non-zero with
|
|
2054
|
-
// a result event of subtype:error_max_turns — but it may already have
|
|
2055
|
-
// emitted a complete spec. Salvage that usable output instead of failing
|
|
2056
|
-
// the whole request on an exit code.
|
|
2057
|
-
const resultSubtype = lastResultEvent?.subtype ?? null;
|
|
2058
|
-
const hasUsableSpec = buffer.trim().length > 0 && /##\s*Spec Title/i.test(buffer);
|
|
2059
|
-
const salvageMaxTurns = code !== 0 && resultSubtype === 'error_max_turns' && hasUsableSpec;
|
|
2060
|
-
if ((code === 0 && buffer.trim()) || salvageMaxTurns) {
|
|
2061
|
-
if (salvageMaxTurns) {
|
|
2062
|
-
console.warn(`[project-router] spec-gen salvaged partial output after error_max_turns (${requestId}); ` +
|
|
2063
|
-
`consider raising --max-turns if this recurs`);
|
|
2064
|
-
}
|
|
2065
|
-
// Extract title from generated spec
|
|
2066
|
-
const titleMatch = buffer.match(/##\s*Spec Title\s*\n+(.+)/);
|
|
2067
|
-
const specTitle = titleMatch ? titleMatch[1].trim() : idea.trim().slice(0, 80);
|
|
2068
|
-
// Extract complexity for priority mapping
|
|
2069
|
-
const complexityMatch = buffer.match(/##\s*Estimated Complexity\s*\n+(\w+)/);
|
|
2070
|
-
const complexity = complexityMatch ? complexityMatch[1].toLowerCase() : 'medium';
|
|
2071
|
-
const priority = complexity === 'low' ? 'low' : complexity === 'high' || complexity === 'very' ? 'high' : 'medium';
|
|
2072
|
-
// Extract labels from the `## Labels` section. Comma- or
|
|
2073
|
-
// newline-separated tags, normalised to lowercase kebab-case.
|
|
2074
|
-
// `spec-proposal` is always retained as the marker label.
|
|
2075
|
-
const labelsMatch = buffer.match(/##\s*Labels\s*\n+([^\n]+(?:\n(?!##)[^\n]+)*)/);
|
|
2076
|
-
const claudeLabels = labelsMatch
|
|
2077
|
-
? labelsMatch[1]
|
|
2078
|
-
.replace(/[\[\]]/g, '')
|
|
2079
|
-
.split(/[,\n]/)
|
|
2080
|
-
.map((s) => s.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''))
|
|
2081
|
-
.filter((s) => s.length > 0 && s.length <= 32)
|
|
2082
|
-
.slice(0, 6)
|
|
2083
|
-
: [];
|
|
2084
|
-
const finalLabels = Array.from(new Set(['spec-proposal', ...claudeLabels]));
|
|
2085
|
-
const shortSummary = (0, ticket_store_1.clampShortSummary)(extractShortSummary(buffer));
|
|
2086
|
-
const description = stripSpecMetadataSections(buffer);
|
|
2087
|
-
// Create ticket directly
|
|
2088
|
-
try {
|
|
2089
|
-
const now = new Date().toISOString();
|
|
2090
|
-
let created;
|
|
2091
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2092
|
-
const id = s.next_id++;
|
|
2093
|
-
const ticket = {
|
|
2094
|
-
id,
|
|
2095
|
-
title: specTitle,
|
|
2096
|
-
description,
|
|
2097
|
-
status: 'todo',
|
|
2098
|
-
priority: priority,
|
|
2099
|
-
labels: finalLabels,
|
|
2100
|
-
assignee: null,
|
|
2101
|
-
prerequisites: [],
|
|
2102
|
-
metadata: {},
|
|
2103
|
-
comments: [],
|
|
2104
|
-
origin_conversation_id: null,
|
|
2105
|
-
is_epic: false,
|
|
2106
|
-
parent_epic_id: null,
|
|
2107
|
-
execution_order: null,
|
|
2108
|
-
short_summary: shortSummary,
|
|
2109
|
-
created_at: now,
|
|
2110
|
-
updated_at: now,
|
|
2111
|
-
created_by: 'sr-product-engineer',
|
|
2112
|
-
source: 'propose-spec',
|
|
2113
|
-
};
|
|
2114
|
-
s.tickets[String(id)] = ticket;
|
|
2115
|
-
created = ticket;
|
|
2116
|
-
});
|
|
2117
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2118
|
-
if (created)
|
|
2119
|
-
createdTicketId = created.id;
|
|
2120
|
-
// Migrate attachments from pendingSpecId → real ticket id (if any were uploaded).
|
|
2121
|
-
// Must complete BEFORE broadcasting ticket_created so WS listeners see the populated attachments[].
|
|
2122
|
-
if (pendingSpecId && created) {
|
|
2123
|
-
try {
|
|
2124
|
-
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
2125
|
-
slug: project.slug,
|
|
2126
|
-
pendingId: pendingSpecId,
|
|
2127
|
-
realTicketId: created.id,
|
|
2128
|
-
projectPath: project.path,
|
|
2129
|
-
});
|
|
2130
|
-
if (migrated.length > 0) {
|
|
2131
|
-
created.attachments = migrated;
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
catch (err) {
|
|
2135
|
-
console.error('[project-router] generate-spec attachment migration error:', err);
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
const ticketMsg = {
|
|
2139
|
-
type: 'ticket_created', ticket: created,
|
|
2140
|
-
projectId, timestamp: new Date().toISOString(),
|
|
2141
|
-
};
|
|
2142
|
-
broadcast(ticketMsg);
|
|
2143
|
-
const doneMsg = {
|
|
2144
|
-
type: 'spec_gen_done', projectId, requestId,
|
|
2145
|
-
ticket: created, timestamp: new Date().toISOString(),
|
|
2146
|
-
};
|
|
2147
|
-
broadcast(doneMsg);
|
|
2148
|
-
// Quick mode Contract Refine: when toggle is on in the request body
|
|
2149
|
-
// AND the project setting + kill switch permit it, fire the no-resume
|
|
2150
|
-
// Quick refine path asynchronously. Claude-only today — codex
|
|
2151
|
-
// contract refine isn't wired (the spawn hardcodes the `claude`
|
|
2152
|
-
// binary). Skip silently on codex projects so the ticket lands
|
|
2153
|
-
// without the misleading "Contract layer skipped — model_error"
|
|
2154
|
-
// toast that the refine kill-switch would otherwise emit.
|
|
2155
|
-
if (quickContractRefine && created && provider === 'claude') {
|
|
2156
|
-
const refineTicketId = created.id;
|
|
2157
|
-
const refineTitle = created.title;
|
|
2158
|
-
const refineDescription = created.description;
|
|
2159
|
-
const refineModel = req.body?.model ?? null;
|
|
2160
|
-
process.nextTick(() => {
|
|
2161
|
-
void (0, contract_refine_runner_1.runContractRefineForQuick)({
|
|
2162
|
-
db: ctx(req).db,
|
|
2163
|
-
projectId: project.id,
|
|
2164
|
-
projectSlug: project.slug,
|
|
2165
|
-
projectPath: project.path,
|
|
2166
|
-
projectName: project.name,
|
|
2167
|
-
broadcast: broadcast,
|
|
2168
|
-
}, refineTicketId, refineTitle, refineDescription, refineModel).catch((err) => {
|
|
2169
|
-
console.error('[project-router] runContractRefineForQuick error:', err);
|
|
2170
|
-
});
|
|
2171
|
-
});
|
|
2172
|
-
}
|
|
2173
|
-
else if (quickContractRefine && created && provider === 'codex') {
|
|
2174
|
-
console.log(`[project-router] quick contract refine skipped for codex project (ticket #${created.id}); ` +
|
|
2175
|
-
`feature is claude-only today`);
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
catch (err) {
|
|
2179
|
-
console.error('[project-router] generate-spec ticket creation error:', err);
|
|
2180
|
-
const errMsg = {
|
|
2181
|
-
type: 'spec_gen_error', projectId, requestId,
|
|
2182
|
-
error: 'Failed to create ticket', timestamp: new Date().toISOString(),
|
|
2183
|
-
};
|
|
2184
|
-
broadcast(errMsg);
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
else {
|
|
2188
|
-
const reason = code === 0
|
|
2189
|
-
? 'Empty response from AI'
|
|
2190
|
-
: resultSubtype === 'error_max_turns'
|
|
2191
|
-
? 'AI hit its turn limit before finishing the spec. Try again, or narrow the idea / turn off full-codebase context.'
|
|
2192
|
-
: `Process exited with code ${code}`;
|
|
2193
|
-
console.error(`[project-router] spec-gen failed (${requestId}): ${reason}` +
|
|
2194
|
-
(stderrBuf.trim() ? `\n stderr: ${stderrBuf.trim()}` : '') +
|
|
2195
|
-
(buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
|
|
2196
|
-
const msg = {
|
|
2197
|
-
type: 'spec_gen_error', projectId, requestId,
|
|
2198
|
-
error: reason,
|
|
2199
|
-
timestamp: new Date().toISOString(),
|
|
2200
|
-
};
|
|
2201
|
-
broadcast(msg);
|
|
2202
|
-
}
|
|
2203
|
-
// ai_invocations capture (surface='quick-spec'). Always emit a row, success or fail.
|
|
2204
|
-
try {
|
|
2205
|
-
// Adapter-driven finalisation: claude passes its native total_cost_usd
|
|
2206
|
-
// through untouched; codex (nativeCostUsd:false) gets a pricing-table
|
|
2207
|
-
// estimate from its captured token usage + estimated=true. This
|
|
2208
|
-
// replaces the legacy normaliseResultEvent path that hardcoded codex
|
|
2209
|
-
// cost to $0 and never set the estimated flag.
|
|
2210
|
-
const { result: normalised, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, { fallbackModel: resolvedModel });
|
|
2211
|
-
// extractCodexResult does not surface duration (codex stream carries
|
|
2212
|
-
// none); stamp wall-clock so the row's duration isn't lost. Claude's
|
|
2213
|
-
// result event already provides duration_ms, so prefer it.
|
|
2214
|
-
const wallMs = Date.now() - new Date(turnStartedAt).getTime();
|
|
2215
|
-
(0, ai_invocations_1.recordInvocation)(ctx(req).db, {
|
|
2216
|
-
id: (0, crypto_1.randomUUID)(),
|
|
2217
|
-
project_id: projectId,
|
|
2218
|
-
provider: adapter.id,
|
|
2219
|
-
surface: 'quick-spec',
|
|
2220
|
-
surface_ref_id: requestId,
|
|
2221
|
-
ticket_id: createdTicketId,
|
|
2222
|
-
status: code === 0 && buffer.trim() ? 'success' : 'failed',
|
|
2223
|
-
started_at: turnStartedAt,
|
|
2224
|
-
finished_at: new Date().toISOString(),
|
|
2225
|
-
total_cost_usd_estimated: estimated,
|
|
2226
|
-
...normalised,
|
|
2227
|
-
duration_ms: normalised.duration_ms ?? wallMs,
|
|
2228
|
-
});
|
|
2229
|
-
broadcast({ type: 'spending.invalidated', projectId });
|
|
2230
|
-
}
|
|
2231
|
-
catch (err) {
|
|
2232
|
-
console.error('[project-router] generate-spec recordInvocation failed:', err);
|
|
2233
|
-
}
|
|
2234
|
-
});
|
|
2235
|
-
});
|
|
2236
|
-
// POST /:projectId/tickets/save-as-draft — Persist an in-progress Explore session as a draft ticket
|
|
2237
|
-
router.post('/:projectId/tickets/save-as-draft', (req, res) => {
|
|
2238
|
-
const body = req.body ?? {};
|
|
2239
|
-
const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : '';
|
|
2240
|
-
if (!conversationId) {
|
|
2241
|
-
res.status(400).json({ error: 'conversationId is required' });
|
|
2242
|
-
return;
|
|
2243
|
-
}
|
|
2244
|
-
const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
2245
|
-
const labels = Array.isArray(body.labels)
|
|
2246
|
-
? body.labels.filter((l) => typeof l === 'string')
|
|
2247
|
-
: [];
|
|
2248
|
-
const description = typeof body.description === 'string' ? body.description : '';
|
|
2249
|
-
// Optional editTicketId — when present, demote that specific ticket in
|
|
2250
|
-
// place instead of looking up by conversationId. Drives the
|
|
2251
|
-
// Continue-Editing-on-non-draft flow.
|
|
2252
|
-
let editTicketId;
|
|
2253
|
-
if (body.editTicketId !== undefined && body.editTicketId !== null) {
|
|
2254
|
-
if (typeof body.editTicketId !== 'number' || !Number.isFinite(body.editTicketId)) {
|
|
2255
|
-
res.status(400).json({ error: 'editTicketId must be a number' });
|
|
2256
|
-
return;
|
|
2257
|
-
}
|
|
2258
|
-
editTicketId = body.editTicketId;
|
|
2259
|
-
}
|
|
2260
|
-
try {
|
|
2261
|
-
const { db, project, broadcast, ticketWatcher } = ctx(req);
|
|
2262
|
-
// Require at least one user-submitted turn before accepting a save
|
|
2263
|
-
const messages = (0, db_1.getMessages)(db, conversationId);
|
|
2264
|
-
const hasUserTurn = messages.some((m) => m.role === 'user' && (m.content ?? '').trim().length > 0);
|
|
2265
|
-
if (!hasUserTurn) {
|
|
2266
|
-
res.status(400).json({ error: 'conversation has no user-submitted turn yet' });
|
|
2267
|
-
return;
|
|
2268
|
-
}
|
|
2269
|
-
const filePath = ticketPath(req);
|
|
2270
|
-
const now = new Date().toISOString();
|
|
2271
|
-
let saved;
|
|
2272
|
-
let flippedInPlace = false;
|
|
2273
|
-
let notFound = false;
|
|
2274
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2275
|
-
if (editTicketId !== undefined) {
|
|
2276
|
-
const target = s.tickets[String(editTicketId)];
|
|
2277
|
-
if (!target) {
|
|
2278
|
-
notFound = true;
|
|
2279
|
-
return;
|
|
2280
|
-
}
|
|
2281
|
-
const title = providedTitle || target.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
|
|
2282
|
-
target.title = title;
|
|
2283
|
-
if (description)
|
|
2284
|
-
target.description = description;
|
|
2285
|
-
if (labels.length > 0)
|
|
2286
|
-
target.labels = labels;
|
|
2287
|
-
target.status = 'draft';
|
|
2288
|
-
target.priority = null;
|
|
2289
|
-
target.origin_conversation_id = conversationId;
|
|
2290
|
-
target.updated_at = now;
|
|
2291
|
-
saved = target;
|
|
2292
|
-
flippedInPlace = true;
|
|
2293
|
-
return;
|
|
2294
|
-
}
|
|
2295
|
-
// Idempotent on conversationId: if a draft ticket already references this
|
|
2296
|
-
// conversation, update in place rather than create a second one.
|
|
2297
|
-
const existing = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
|
|
2298
|
-
const title = providedTitle || existing?.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
|
|
2299
|
-
if (existing) {
|
|
2300
|
-
existing.title = title;
|
|
2301
|
-
if (description)
|
|
2302
|
-
existing.description = description;
|
|
2303
|
-
if (labels.length > 0)
|
|
2304
|
-
existing.labels = labels;
|
|
2305
|
-
existing.updated_at = now;
|
|
2306
|
-
saved = existing;
|
|
2307
|
-
return;
|
|
2308
|
-
}
|
|
2309
|
-
const id = s.next_id++;
|
|
2310
|
-
const ticket = {
|
|
2311
|
-
id,
|
|
2312
|
-
title,
|
|
2313
|
-
description,
|
|
2314
|
-
status: 'draft',
|
|
2315
|
-
priority: null,
|
|
2316
|
-
labels,
|
|
2317
|
-
assignee: null,
|
|
2318
|
-
prerequisites: [],
|
|
2319
|
-
metadata: {},
|
|
2320
|
-
comments: [],
|
|
2321
|
-
origin_conversation_id: conversationId,
|
|
2322
|
-
is_epic: false,
|
|
2323
|
-
parent_epic_id: null,
|
|
2324
|
-
execution_order: null,
|
|
2325
|
-
short_summary: null,
|
|
2326
|
-
created_at: now,
|
|
2327
|
-
updated_at: now,
|
|
2328
|
-
created_by: 'sr-explore-spec',
|
|
2329
|
-
source: 'explore-draft',
|
|
2330
|
-
};
|
|
2331
|
-
s.tickets[String(id)] = ticket;
|
|
2332
|
-
saved = ticket;
|
|
2333
|
-
});
|
|
2334
|
-
if (notFound) {
|
|
2335
|
-
res.status(404).json({ error: 'ticket not found' });
|
|
2336
|
-
return;
|
|
2337
|
-
}
|
|
2338
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2339
|
-
if (flippedInPlace) {
|
|
2340
|
-
const msg = {
|
|
2341
|
-
type: 'ticket_updated',
|
|
2342
|
-
ticket: saved,
|
|
2343
|
-
projectId: project.id,
|
|
2344
|
-
timestamp: now,
|
|
2345
|
-
};
|
|
2346
|
-
broadcast(msg);
|
|
2347
|
-
res.status(200).json({ ticket: saved, revision: store.revision });
|
|
2348
|
-
return;
|
|
2349
|
-
}
|
|
2350
|
-
const msg = saved.created_at === saved.updated_at
|
|
2351
|
-
? { type: 'ticket_created', ticket: saved, projectId: project.id, timestamp: now }
|
|
2352
|
-
: { type: 'ticket_updated', ticket: saved, projectId: project.id, timestamp: now };
|
|
2353
|
-
broadcast(msg);
|
|
2354
|
-
res.status(201).json({ ticket: saved, revision: store.revision });
|
|
2355
|
-
}
|
|
2356
|
-
catch (err) {
|
|
2357
|
-
console.error('[project-router] save-as-draft error:', err);
|
|
2358
|
-
res.status(500).json({ error: 'Failed to save draft' });
|
|
2359
|
-
}
|
|
2360
|
-
});
|
|
2361
|
-
// POST /:projectId/tickets/from-draft — Commit an Explore Spec draft as a real ticket
|
|
2362
|
-
// Two paths:
|
|
2363
|
-
// (1) Legacy: payload has no `draftTicketId` → create a brand-new ticket (status='todo').
|
|
2364
|
-
// (2) Flip in place: payload has `draftTicketId` referencing an existing
|
|
2365
|
-
// status='draft' ticket → update that ticket in place to status='todo',
|
|
2366
|
-
// set priority, replace title/description, preserve origin_conversation_id.
|
|
2367
|
-
router.post('/:projectId/tickets/from-draft', async (req, res) => {
|
|
2368
|
-
const body = req.body ?? {};
|
|
2369
|
-
const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
2370
|
-
if (!rawTitle) {
|
|
2371
|
-
res.status(400).json({ error: 'title is required' });
|
|
2372
|
-
return;
|
|
2373
|
-
}
|
|
2374
|
-
const draftTicketId = typeof body.draftTicketId === 'number' ? body.draftTicketId : null;
|
|
2375
|
-
const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
|
|
2376
|
-
const conversationId = typeof body.conversationId === 'string' ? body.conversationId : null;
|
|
2377
|
-
const baseDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
|
2378
|
-
const labels = Array.isArray(body.labels)
|
|
2379
|
-
? body.labels.filter((l) => typeof l === 'string')
|
|
2380
|
-
: [];
|
|
2381
|
-
const acceptanceCriteria = Array.isArray(body.acceptanceCriteria)
|
|
2382
|
-
? body.acceptanceCriteria
|
|
2383
|
-
.filter((c) => typeof c === 'string')
|
|
2384
|
-
.map((c) => c.trim())
|
|
2385
|
-
.filter((c) => c.length > 0)
|
|
2386
|
-
: [];
|
|
2387
|
-
const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
|
|
2388
|
-
// Compose the final ticket body. The title is already its own ticket
|
|
2389
|
-
// field, so we deliberately do NOT echo it as a `## Spec Title` heading
|
|
2390
|
-
// inside the description. The body is just the structured sections from
|
|
2391
|
-
// Claude (Problem Statement / Proposed Solution / Out of Scope /
|
|
2392
|
-
// Technical Considerations / Estimated Complexity) followed by the
|
|
2393
|
-
// Acceptance Criteria bullets.
|
|
2394
|
-
const description = formatDescriptionWithCriteria(baseDescription, acceptanceCriteria);
|
|
2395
|
-
// Short summary: explicit body field wins; otherwise try extracting a
|
|
2396
|
-
// `## Short Summary` section from the description and stripping it.
|
|
2397
|
-
let bodyShortSummary = null;
|
|
2398
|
-
let descriptionForStore = description;
|
|
2399
|
-
if (typeof body.shortSummary === 'string') {
|
|
2400
|
-
bodyShortSummary = (0, ticket_store_1.clampShortSummary)(body.shortSummary);
|
|
2401
|
-
}
|
|
2402
|
-
else {
|
|
2403
|
-
const extracted = extractShortSummary(description);
|
|
2404
|
-
if (extracted !== null) {
|
|
2405
|
-
bodyShortSummary = (0, ticket_store_1.clampShortSummary)(extracted);
|
|
2406
|
-
descriptionForStore = description
|
|
2407
|
-
.replace(/##\s*Short Summary\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
|
|
2408
|
-
.trim();
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
if (bodyShortSummary === null) {
|
|
2412
|
-
bodyShortSummary = deriveFallbackShortSummary(rawTitle, descriptionForStore);
|
|
2413
|
-
}
|
|
2414
|
-
try {
|
|
2415
|
-
const filePath = ticketPath(req);
|
|
2416
|
-
const now = new Date().toISOString();
|
|
2417
|
-
let created;
|
|
2418
|
-
let wasFlip = false;
|
|
2419
|
-
let explicitDraftMissing = false;
|
|
2420
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2421
|
-
// Resolve flip target: explicit `draftTicketId` wins; otherwise look up
|
|
2422
|
-
// an existing draft ticket whose origin_conversation_id matches the
|
|
2423
|
-
// current conversation so a resumed session commits in place even when
|
|
2424
|
-
// the client doesn't track the draft id explicitly.
|
|
2425
|
-
let flipTarget;
|
|
2426
|
-
if (draftTicketId !== null) {
|
|
2427
|
-
flipTarget = s.tickets[String(draftTicketId)];
|
|
2428
|
-
if (!flipTarget || flipTarget.status !== 'draft') {
|
|
2429
|
-
explicitDraftMissing = true;
|
|
2430
|
-
return;
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
else if (conversationId) {
|
|
2434
|
-
flipTarget = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
|
|
2435
|
-
}
|
|
2436
|
-
if (flipTarget) {
|
|
2437
|
-
flipTarget.status = 'todo';
|
|
2438
|
-
flipTarget.priority = priority;
|
|
2439
|
-
flipTarget.title = rawTitle;
|
|
2440
|
-
flipTarget.description = descriptionForStore;
|
|
2441
|
-
if (labels.length > 0)
|
|
2442
|
-
flipTarget.labels = labels;
|
|
2443
|
-
flipTarget.updated_at = now;
|
|
2444
|
-
// Preserve prior short_summary on flip when the model/body omits one;
|
|
2445
|
-
// overwrite only when a non-null value is provided.
|
|
2446
|
-
if (bodyShortSummary !== null) {
|
|
2447
|
-
flipTarget.short_summary = bodyShortSummary;
|
|
2448
|
-
}
|
|
2449
|
-
// origin_conversation_id is intentionally preserved
|
|
2450
|
-
created = flipTarget;
|
|
2451
|
-
wasFlip = true;
|
|
2452
|
-
return;
|
|
2453
|
-
}
|
|
2454
|
-
// B62: from-draft is idempotent only while the ticket is still a draft.
|
|
2455
|
-
// After a successful commit the draft is 'todo', so the draft lookup above
|
|
2456
|
-
// no longer matches and a second from-draft for the same conversation
|
|
2457
|
-
// would insert a DUPLICATE ticket. If a (now non-draft) ticket already
|
|
2458
|
-
// originates from this conversation, return it instead of re-inserting.
|
|
2459
|
-
if (conversationId) {
|
|
2460
|
-
const alreadyCommitted = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId);
|
|
2461
|
-
if (alreadyCommitted) {
|
|
2462
|
-
created = alreadyCommitted;
|
|
2463
|
-
wasFlip = true; // treat as in-place: broadcast ticket_updated, not created
|
|
2464
|
-
return;
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
// Legacy: insert new ticket
|
|
2468
|
-
const id = s.next_id++;
|
|
2469
|
-
const ticket = {
|
|
2470
|
-
id,
|
|
2471
|
-
title: rawTitle,
|
|
2472
|
-
description: descriptionForStore,
|
|
2473
|
-
status: 'todo',
|
|
2474
|
-
priority,
|
|
2475
|
-
labels,
|
|
2476
|
-
assignee: null,
|
|
2477
|
-
prerequisites: [],
|
|
2478
|
-
metadata: {},
|
|
2479
|
-
comments: [],
|
|
2480
|
-
origin_conversation_id: conversationId,
|
|
2481
|
-
is_epic: false,
|
|
2482
|
-
parent_epic_id: null,
|
|
2483
|
-
execution_order: null,
|
|
2484
|
-
short_summary: bodyShortSummary,
|
|
2485
|
-
created_at: now,
|
|
2486
|
-
updated_at: now,
|
|
2487
|
-
created_by: 'sr-explore-spec',
|
|
2488
|
-
source: 'propose-spec',
|
|
2489
|
-
};
|
|
2490
|
-
s.tickets[String(id)] = ticket;
|
|
2491
|
-
created = ticket;
|
|
2492
|
-
});
|
|
2493
|
-
if (explicitDraftMissing) {
|
|
2494
|
-
res.status(404).json({ error: 'Draft ticket not found or not in draft status' });
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
const { broadcast, ticketWatcher, project } = ctx(req);
|
|
2498
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2499
|
-
// Migrate attachments from pendingSpecId → real ticket id (mirrors the
|
|
2500
|
-
// generate-spec flow). Must complete before broadcasting ticket_created
|
|
2501
|
-
// so listeners see the populated attachments[].
|
|
2502
|
-
if (pendingSpecId && created) {
|
|
2503
|
-
try {
|
|
2504
|
-
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
2505
|
-
slug: project.slug,
|
|
2506
|
-
pendingId: pendingSpecId,
|
|
2507
|
-
realTicketId: created.id,
|
|
2508
|
-
projectPath: project.path,
|
|
2509
|
-
});
|
|
2510
|
-
if (migrated.length > 0) {
|
|
2511
|
-
created.attachments = migrated;
|
|
2512
|
-
}
|
|
2513
|
-
}
|
|
2514
|
-
catch (err) {
|
|
2515
|
-
console.error('[project-router] from-draft attachment migration error:', err);
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
const msg = wasFlip
|
|
2519
|
-
? {
|
|
2520
|
-
type: 'ticket_updated',
|
|
2521
|
-
ticket: created,
|
|
2522
|
-
projectId: project.id,
|
|
2523
|
-
timestamp: new Date().toISOString(),
|
|
2524
|
-
}
|
|
2525
|
-
: {
|
|
2526
|
-
type: 'ticket_created',
|
|
2527
|
-
ticket: created,
|
|
2528
|
-
projectId: project.id,
|
|
2529
|
-
timestamp: new Date().toISOString(),
|
|
2530
|
-
};
|
|
2531
|
-
broadcast(msg);
|
|
2532
|
-
// Back-fill ticket_id on the conversation's prior ai_invocations rows.
|
|
2533
|
-
if (conversationId && created) {
|
|
2534
|
-
try {
|
|
2535
|
-
const changes = (0, ai_invocations_1.updateTicketIdForConversation)(ctx(req).db, conversationId, created.id);
|
|
2536
|
-
if (changes > 0) {
|
|
2537
|
-
broadcast({ type: 'spending.invalidated', projectId: project.id });
|
|
2538
|
-
}
|
|
2539
|
-
}
|
|
2540
|
-
catch (err) {
|
|
2541
|
-
console.error('[project-router] from-draft ai_invocations back-fill failed:', err);
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
res.status(201).json({ ticket: created, revision: store.revision });
|
|
2545
|
-
// Fire Contract Refine post-commit (fire-and-forget). Toggle + kill-switch
|
|
2546
|
-
// are checked inside runContractRefine. Claude-only today — codex
|
|
2547
|
-
// contract refine isn't wired (the spawn hardcodes the `claude`
|
|
2548
|
-
// binary). Skip silently on codex projects.
|
|
2549
|
-
if (conversationId && created && project.provider === 'claude') {
|
|
2550
|
-
const createdTicketId = created.id;
|
|
2551
|
-
const convoId = conversationId;
|
|
2552
|
-
console.log(`[project-router] from-draft hook: scheduling refine ticket=${createdTicketId} conv=${convoId}`);
|
|
2553
|
-
process.nextTick(() => {
|
|
2554
|
-
void (0, contract_refine_runner_1.runContractRefine)({
|
|
2555
|
-
db: ctx(req).db,
|
|
2556
|
-
projectId: project.id,
|
|
2557
|
-
projectSlug: project.slug,
|
|
2558
|
-
projectPath: project.path,
|
|
2559
|
-
projectName: project.name,
|
|
2560
|
-
broadcast: broadcast,
|
|
2561
|
-
}, convoId, createdTicketId).catch((err) => {
|
|
2562
|
-
console.error('[project-router] runContractRefine error:', err);
|
|
2563
|
-
});
|
|
2564
|
-
});
|
|
2565
|
-
}
|
|
2566
|
-
else if (conversationId && created && project.provider === 'codex') {
|
|
2567
|
-
console.log(`[project-router] from-draft contract refine skipped for codex project (ticket #${created.id})`);
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
catch (err) {
|
|
2571
|
-
console.error('[project-router] from-draft create error:', err);
|
|
2572
|
-
res.status(500).json({ error: 'Failed to create ticket' });
|
|
2573
|
-
}
|
|
2574
|
-
});
|
|
2575
|
-
// POST /:projectId/tickets/from-prompt — Create a spec directly from a
|
|
2576
|
-
// free-form prompt (the "Raw" Add-Spec mode). NO AI is invoked: the user's
|
|
2577
|
-
// text becomes the ticket description verbatim. The ticket lands as
|
|
2578
|
-
// status='todo' (ready for rails) with source='free-prompt'. There is no
|
|
2579
|
-
// ai_invocations row (nothing was billed) and no contract-refine (no origin
|
|
2580
|
-
// conversation, no description format to refine).
|
|
2581
|
-
router.post('/:projectId/tickets/from-prompt', async (req, res) => {
|
|
2582
|
-
const body = req.body ?? {};
|
|
2583
|
-
const rawDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
|
2584
|
-
if (!rawDescription) {
|
|
2585
|
-
res.status(400).json({ error: 'description is required' });
|
|
2586
|
-
return;
|
|
2587
|
-
}
|
|
2588
|
-
// Optional light-structuring (v1: the client always sends `false`; the flag
|
|
2589
|
-
// keeps the contract stable for a future non-generative structuring pass).
|
|
2590
|
-
const structured = body.structured === true;
|
|
2591
|
-
const description = structured ? lightlyStructurePrompt(rawDescription) : rawDescription;
|
|
2592
|
-
// Title: explicit value wins; otherwise derive a single-line summary from
|
|
2593
|
-
// the body (reusing the deterministic Explore-draft summarizer).
|
|
2594
|
-
const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
|
2595
|
-
const title = providedTitle || (0, explore_draft_title_1.generateAutoTitle)([{ role: 'user', content: rawDescription }]);
|
|
2596
|
-
const labels = Array.isArray(body.labels)
|
|
2597
|
-
? body.labels.filter((l) => typeof l === 'string')
|
|
2598
|
-
: [];
|
|
2599
|
-
// Priority: validate against the allowed set; default 'medium'. A
|
|
2600
|
-
// status='todo' ticket MUST carry a non-null priority (see
|
|
2601
|
-
// validatePriorityForStatus), so we never accept null here.
|
|
2602
|
-
const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
|
|
2603
|
-
const validationError = (0, ticket_store_1.validatePriorityForStatus)('todo', priority);
|
|
2604
|
-
if (validationError) {
|
|
2605
|
-
res.status(400).json({ error: validationError });
|
|
2606
|
-
return;
|
|
2607
|
-
}
|
|
2608
|
-
const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
|
|
2609
|
-
const shortSummary = deriveFallbackShortSummary(title, description);
|
|
2610
|
-
try {
|
|
2611
|
-
const filePath = ticketPath(req);
|
|
2612
|
-
const now = new Date().toISOString();
|
|
2613
|
-
let created;
|
|
2614
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2615
|
-
const id = s.next_id++;
|
|
2616
|
-
const ticket = {
|
|
2617
|
-
id,
|
|
2618
|
-
title,
|
|
2619
|
-
description,
|
|
2620
|
-
status: 'todo',
|
|
2621
|
-
priority,
|
|
2622
|
-
labels,
|
|
2623
|
-
assignee: null,
|
|
2624
|
-
prerequisites: [],
|
|
2625
|
-
metadata: {},
|
|
2626
|
-
comments: [],
|
|
2627
|
-
origin_conversation_id: null,
|
|
2628
|
-
is_epic: false,
|
|
2629
|
-
parent_epic_id: null,
|
|
2630
|
-
execution_order: null,
|
|
2631
|
-
short_summary: shortSummary,
|
|
2632
|
-
created_at: now,
|
|
2633
|
-
updated_at: now,
|
|
2634
|
-
created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
|
|
2635
|
-
source: 'free-prompt',
|
|
2636
|
-
};
|
|
2637
|
-
s.tickets[String(id)] = ticket;
|
|
2638
|
-
created = ticket;
|
|
2639
|
-
});
|
|
2640
|
-
const { broadcast, ticketWatcher, project } = ctx(req);
|
|
2641
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2642
|
-
// Migrate attachments from pendingSpecId → real ticket id (mirrors the
|
|
2643
|
-
// generate-spec / from-draft flow). Must complete before broadcasting so
|
|
2644
|
-
// listeners see the populated attachments[].
|
|
2645
|
-
if (pendingSpecId && created) {
|
|
2646
|
-
try {
|
|
2647
|
-
const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
|
|
2648
|
-
slug: project.slug,
|
|
2649
|
-
pendingId: pendingSpecId,
|
|
2650
|
-
realTicketId: created.id,
|
|
2651
|
-
projectPath: project.path,
|
|
2652
|
-
});
|
|
2653
|
-
if (migrated.length > 0) {
|
|
2654
|
-
created.attachments = migrated;
|
|
2655
|
-
}
|
|
2656
|
-
}
|
|
2657
|
-
catch (err) {
|
|
2658
|
-
console.error('[project-router] from-prompt attachment migration error:', err);
|
|
2659
|
-
}
|
|
2660
|
-
}
|
|
2661
|
-
const msg = {
|
|
2662
|
-
type: 'ticket_created',
|
|
2663
|
-
ticket: created,
|
|
2664
|
-
projectId: project.id,
|
|
2665
|
-
timestamp: new Date().toISOString(),
|
|
2666
|
-
};
|
|
2667
|
-
broadcast(msg);
|
|
2668
|
-
res.status(201).json({ ticket: created, revision: store.revision });
|
|
2669
|
-
}
|
|
2670
|
-
catch (err) {
|
|
2671
|
-
console.error('[project-router] from-prompt create error:', err);
|
|
2672
|
-
res.status(500).json({ error: 'Failed to create ticket' });
|
|
2673
|
-
}
|
|
2674
|
-
});
|
|
2675
|
-
// POST /:projectId/tickets/:id/contract-refine — Manually re-fire refine
|
|
2676
|
-
router.post('/:projectId/tickets/:id/contract-refine', async (req, res) => {
|
|
2677
|
-
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
2678
|
-
if (!Number.isFinite(ticketId)) {
|
|
2679
|
-
res.status(400).json({ error: 'invalid ticket id' });
|
|
2680
|
-
return;
|
|
2681
|
-
}
|
|
2682
|
-
const { project, db, broadcast } = ctx(req);
|
|
2683
|
-
if ((0, explore_contract_refine_1.isExploreContractRefineKillSwitchActive)()) {
|
|
2684
|
-
res.status(409).json({ error: 'feature_disabled_by_env' });
|
|
2685
|
-
return;
|
|
2686
|
-
}
|
|
2687
|
-
if (project.provider === 'codex') {
|
|
2688
|
-
res.status(409).json({ error: 'contract_refine_unsupported_for_codex' });
|
|
2689
|
-
return;
|
|
2690
|
-
}
|
|
2691
|
-
// Validate the ticket exists.
|
|
2692
|
-
try {
|
|
2693
|
-
const filePath = ticketPath(req);
|
|
2694
|
-
const { withLock } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
|
|
2695
|
-
const ticket = withLock(filePath, (s) => s.tickets[String(ticketId)]);
|
|
2696
|
-
if (!ticket) {
|
|
2697
|
-
res.status(404).json({ error: 'ticket not found' });
|
|
2698
|
-
return;
|
|
2699
|
-
}
|
|
2700
|
-
if (!ticket.origin_conversation_id) {
|
|
2701
|
-
res.status(409).json({ error: 'ticket has no origin conversation' });
|
|
2702
|
-
return;
|
|
2703
|
-
}
|
|
2704
|
-
const convoId = ticket.origin_conversation_id;
|
|
2705
|
-
res.status(202).json({ scheduled: true });
|
|
2706
|
-
process.nextTick(() => {
|
|
2707
|
-
void (0, contract_refine_runner_1.runContractRefine)({
|
|
2708
|
-
db,
|
|
2709
|
-
projectId: project.id,
|
|
2710
|
-
projectSlug: project.slug,
|
|
2711
|
-
projectPath: project.path,
|
|
2712
|
-
projectName: project.name,
|
|
2713
|
-
broadcast: broadcast,
|
|
2714
|
-
ignoreConversationScope: true,
|
|
2715
|
-
}, convoId, ticketId).catch((err) => {
|
|
2716
|
-
console.error('[project-router] retry runContractRefine error:', err);
|
|
2717
|
-
});
|
|
2718
|
-
});
|
|
2719
|
-
}
|
|
2720
|
-
catch (err) {
|
|
2721
|
-
console.error('[project-router] retry endpoint error:', err);
|
|
2722
|
-
res.status(500).json({ error: 'Failed to schedule retry' });
|
|
2723
|
-
}
|
|
2724
|
-
});
|
|
2725
|
-
// POST /:projectId/tickets/:id/smash — Decompose ticket into N children
|
|
2726
|
-
router.post('/:projectId/tickets/:id/smash', async (req, res) => {
|
|
2727
|
-
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
2728
|
-
if (!Number.isFinite(ticketId)) {
|
|
2729
|
-
res.status(400).json({ error: 'invalid ticket id' });
|
|
2730
|
-
return;
|
|
2731
|
-
}
|
|
2732
|
-
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
2733
|
-
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
2734
|
-
return;
|
|
2735
|
-
}
|
|
2736
|
-
const { project, db, broadcast } = ctx(req);
|
|
2737
|
-
try {
|
|
2738
|
-
const filePath = ticketPath(req);
|
|
2739
|
-
const { readStore } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
|
|
2740
|
-
const store = readStore(filePath);
|
|
2741
|
-
const gate = (0, smash_runner_1.checkSmashEligibility)(store, ticketId);
|
|
2742
|
-
if (!gate.ok) {
|
|
2743
|
-
const statusCode = gate.reason === 'ticket-not-found' ? 404 : 409;
|
|
2744
|
-
res.status(statusCode).json({ error: 'ineligible', reason: gate.reason });
|
|
2745
|
-
return;
|
|
2746
|
-
}
|
|
2747
|
-
const rawMode = typeof req.body?.mode === 'string' ? req.body.mode : 'simple';
|
|
2748
|
-
const mode = rawMode === 'full' ? 'full' : 'simple';
|
|
2749
|
-
const model = typeof req.body?.model === 'string' && req.body.model.length > 0 ? req.body.model : null;
|
|
2750
|
-
res.status(202).json({ scheduled: true, mode });
|
|
2751
|
-
process.nextTick(() => {
|
|
2752
|
-
void (0, smash_runner_1.runSmash)({
|
|
2753
|
-
db,
|
|
2754
|
-
projectId: project.id,
|
|
2755
|
-
projectSlug: project.slug,
|
|
2756
|
-
projectPath: project.path,
|
|
2757
|
-
projectName: project.name,
|
|
2758
|
-
broadcast: broadcast,
|
|
2759
|
-
mode,
|
|
2760
|
-
model,
|
|
2761
|
-
}, ticketId).catch((err) => {
|
|
2762
|
-
console.error('[project-router] runSmash error:', err);
|
|
2763
|
-
});
|
|
2764
|
-
});
|
|
2765
|
-
}
|
|
2766
|
-
catch (err) {
|
|
2767
|
-
console.error('[project-router] smash endpoint error:', err);
|
|
2768
|
-
res.status(500).json({ error: 'Failed to schedule SMASH' });
|
|
2769
|
-
}
|
|
2770
|
-
});
|
|
2771
|
-
// POST /:projectId/tickets/:id/smash/undo — Reverse a prior SMASH
|
|
2772
|
-
router.post('/:projectId/tickets/:id/smash/undo', async (req, res) => {
|
|
2773
|
-
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
2774
|
-
if (!Number.isFinite(ticketId)) {
|
|
2775
|
-
res.status(400).json({ error: 'invalid ticket id' });
|
|
2776
|
-
return;
|
|
2777
|
-
}
|
|
2778
|
-
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
2779
|
-
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
2780
|
-
return;
|
|
2781
|
-
}
|
|
2782
|
-
const smashedAt = typeof req.body?.smashedAt === 'string' ? req.body.smashedAt : null;
|
|
2783
|
-
if (!smashedAt) {
|
|
2784
|
-
res.status(400).json({ error: 'smashedAt timestamp required' });
|
|
2785
|
-
return;
|
|
2786
|
-
}
|
|
2787
|
-
const { project, db, broadcast } = ctx(req);
|
|
2788
|
-
try {
|
|
2789
|
-
const result = await (0, smash_runner_1.runSmashUndo)({
|
|
2790
|
-
db,
|
|
2791
|
-
projectId: project.id,
|
|
2792
|
-
projectSlug: project.slug,
|
|
2793
|
-
projectPath: project.path,
|
|
2794
|
-
projectName: project.name,
|
|
2795
|
-
broadcast: broadcast,
|
|
2796
|
-
}, ticketId, smashedAt);
|
|
2797
|
-
if (!result.ok) {
|
|
2798
|
-
const statusCode = result.reason === 'ticket-not-found' ? 404 : 409;
|
|
2799
|
-
res.status(statusCode).json({ error: 'undo_failed', reason: result.reason });
|
|
2800
|
-
return;
|
|
2801
|
-
}
|
|
2802
|
-
res.json({ ok: true, deletedChildren: result.deletedChildren });
|
|
2803
|
-
}
|
|
2804
|
-
catch (err) {
|
|
2805
|
-
console.error('[project-router] smash/undo endpoint error:', err);
|
|
2806
|
-
res.status(500).json({ error: 'Failed to undo SMASH' });
|
|
2807
|
-
}
|
|
2808
|
-
});
|
|
2809
|
-
// DELETE /:projectId/tickets/:id/children — Delete all children of an épica
|
|
2810
|
-
router.delete('/:projectId/tickets/:id/children', (req, res) => {
|
|
2811
|
-
const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
|
|
2812
|
-
if (!Number.isFinite(ticketId)) {
|
|
2813
|
-
res.status(400).json({ error: 'invalid ticket id' });
|
|
2814
|
-
return;
|
|
2815
|
-
}
|
|
2816
|
-
if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
|
|
2817
|
-
res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
|
|
2818
|
-
return;
|
|
2819
|
-
}
|
|
2820
|
-
const { project, broadcast, ticketWatcher } = ctx(req);
|
|
2821
|
-
try {
|
|
2822
|
-
const filePath = ticketPath(req);
|
|
2823
|
-
const result = (0, smash_runner_1.applyDeleteEpicChildren)(filePath, ticketId);
|
|
2824
|
-
// Pass the real post-write revision (not 0) so the chokidar echo is
|
|
2825
|
-
// suppressed; a hardcoded 0 never matches the on-disk revision and
|
|
2826
|
-
// triggers a spurious full-refresh broadcast to every client.
|
|
2827
|
-
ticketWatcher.notifyDesktopWrite(result.revision);
|
|
2828
|
-
const now = new Date().toISOString();
|
|
2829
|
-
for (const id of result.deletedChildren) {
|
|
2830
|
-
broadcast({
|
|
2831
|
-
type: 'ticket_deleted',
|
|
2832
|
-
ticketId: id,
|
|
2833
|
-
projectId: project.id,
|
|
2834
|
-
timestamp: now,
|
|
2835
|
-
});
|
|
2836
|
-
}
|
|
2837
|
-
res.json({ ok: true, deletedChildren: result.deletedChildren });
|
|
2838
|
-
}
|
|
2839
|
-
catch (err) {
|
|
2840
|
-
console.error('[project-router] delete-children error:', err);
|
|
2841
|
-
res.status(500).json({ error: 'Failed to delete children' });
|
|
2842
|
-
}
|
|
2843
|
-
});
|
|
2844
|
-
// POST /:projectId/tickets — Create new ticket
|
|
2845
|
-
router.post('/:projectId/tickets', (req, res) => {
|
|
2846
|
-
const { title, description, status, priority, labels, assignee, prerequisites, metadata, source } = req.body ?? {};
|
|
2847
|
-
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
2848
|
-
res.status(400).json({ error: 'title is required' });
|
|
2849
|
-
return;
|
|
2850
|
-
}
|
|
2851
|
-
if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
|
|
2852
|
-
res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
|
|
2853
|
-
return;
|
|
2854
|
-
}
|
|
2855
|
-
const finalStatus = (status ?? 'todo');
|
|
2856
|
-
const finalPriority = priority === undefined ? (finalStatus === 'draft' ? null : 'medium') : (priority === null ? null : priority);
|
|
2857
|
-
const priorityError = (0, ticket_store_1.validatePriorityForStatus)(finalStatus, finalPriority);
|
|
2858
|
-
if (priorityError) {
|
|
2859
|
-
res.status(400).json({ error: priorityError });
|
|
2860
|
-
return;
|
|
2861
|
-
}
|
|
2862
|
-
try {
|
|
2863
|
-
const filePath = ticketPath(req);
|
|
2864
|
-
const now = new Date().toISOString();
|
|
2865
|
-
let created;
|
|
2866
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2867
|
-
const id = s.next_id++;
|
|
2868
|
-
const ticket = {
|
|
2869
|
-
id,
|
|
2870
|
-
title: title.trim(),
|
|
2871
|
-
description: typeof description === 'string' ? description : '',
|
|
2872
|
-
status: finalStatus,
|
|
2873
|
-
priority: finalPriority,
|
|
2874
|
-
labels: Array.isArray(labels) ? labels.filter((l) => typeof l === 'string') : [],
|
|
2875
|
-
assignee: typeof assignee === 'string' ? assignee : null,
|
|
2876
|
-
prerequisites: Array.isArray(prerequisites) ? prerequisites.filter((p) => typeof p === 'number') : [],
|
|
2877
|
-
metadata: typeof metadata === 'object' && metadata !== null ? metadata : {},
|
|
2878
|
-
comments: [],
|
|
2879
|
-
origin_conversation_id: null,
|
|
2880
|
-
is_epic: false,
|
|
2881
|
-
parent_epic_id: null,
|
|
2882
|
-
execution_order: null,
|
|
2883
|
-
short_summary: null,
|
|
2884
|
-
created_at: now,
|
|
2885
|
-
updated_at: now,
|
|
2886
|
-
created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
|
|
2887
|
-
source: source === 'product-backlog' || source === 'propose-spec' || source === 'manual' ? source : 'hub', // legacy on-disk wire value — do not rename
|
|
2888
|
-
};
|
|
2889
|
-
s.tickets[String(id)] = ticket;
|
|
2890
|
-
created = ticket;
|
|
2891
|
-
});
|
|
2892
|
-
const { broadcast, ticketWatcher } = ctx(req);
|
|
2893
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2894
|
-
const msg = { type: 'ticket_created', ticket: created, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
2895
|
-
broadcast(msg);
|
|
2896
|
-
res.status(201).json({ ticket: created, revision: store.revision });
|
|
2897
|
-
}
|
|
2898
|
-
catch (err) {
|
|
2899
|
-
console.error('[project-router] ticket create error:', err);
|
|
2900
|
-
res.status(500).json({ error: 'Failed to create ticket' });
|
|
2901
|
-
}
|
|
2902
|
-
});
|
|
2903
|
-
// PATCH /:projectId/tickets/:id — Update ticket fields
|
|
2904
|
-
router.patch('/:projectId/tickets/:id', (req, res) => {
|
|
2905
|
-
const ticketId = req.params.id;
|
|
2906
|
-
if (!/^\d+$/.test(ticketId)) {
|
|
2907
|
-
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
2908
|
-
return;
|
|
2909
|
-
}
|
|
2910
|
-
const { title, description, status, priority, labels, assignee, prerequisites, metadata, acceptanceCriteria, short_summary } = req.body ?? {};
|
|
2911
|
-
if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
|
|
2912
|
-
res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
|
|
2913
|
-
return;
|
|
2914
|
-
}
|
|
2915
|
-
if (priority !== undefined && priority !== null && !(0, ticket_store_1.isValidPriority)(priority)) {
|
|
2916
|
-
res.status(400).json({ error: 'priority must be one of: critical, high, medium, low' });
|
|
2917
|
-
return;
|
|
2918
|
-
}
|
|
2919
|
-
if (title !== undefined && (typeof title !== 'string' || !title.trim())) {
|
|
2920
|
-
res.status(400).json({ error: 'title cannot be empty' });
|
|
2921
|
-
return;
|
|
2922
|
-
}
|
|
2923
|
-
if (acceptanceCriteria !== undefined) {
|
|
2924
|
-
if (!Array.isArray(acceptanceCriteria) || !acceptanceCriteria.every((c) => typeof c === 'string')) {
|
|
2925
|
-
res.status(400).json({ error: 'acceptanceCriteria must be an array of strings' });
|
|
2926
|
-
return;
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2929
|
-
try {
|
|
2930
|
-
const filePath = ticketPath(req);
|
|
2931
|
-
let updated;
|
|
2932
|
-
let validationError = null;
|
|
2933
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
2934
|
-
const ticket = s.tickets[ticketId];
|
|
2935
|
-
if (!ticket)
|
|
2936
|
-
return;
|
|
2937
|
-
const nextStatus = (status ?? ticket.status);
|
|
2938
|
-
const nextPriority = priority === undefined ? ticket.priority : (priority === null ? null : priority);
|
|
2939
|
-
const err = (0, ticket_store_1.validatePriorityForStatus)(nextStatus, nextPriority);
|
|
2940
|
-
if (err) {
|
|
2941
|
-
validationError = err;
|
|
2942
|
-
return;
|
|
2943
|
-
}
|
|
2944
|
-
if (title !== undefined)
|
|
2945
|
-
ticket.title = title.trim();
|
|
2946
|
-
if (description !== undefined)
|
|
2947
|
-
ticket.description = description;
|
|
2948
|
-
if (acceptanceCriteria !== undefined) {
|
|
2949
|
-
// Fold criteria into the description body under a `## Acceptance Criteria`
|
|
2950
|
-
// section, replacing any existing one. Use the just-set description if
|
|
2951
|
-
// present, otherwise the ticket's current description.
|
|
2952
|
-
ticket.description = formatDescriptionWithCriteria(ticket.description ?? '', acceptanceCriteria);
|
|
2953
|
-
}
|
|
2954
|
-
if (status !== undefined)
|
|
2955
|
-
ticket.status = status;
|
|
2956
|
-
if (priority !== undefined)
|
|
2957
|
-
ticket.priority = nextPriority;
|
|
2958
|
-
if (labels !== undefined && Array.isArray(labels))
|
|
2959
|
-
ticket.labels = labels.filter((l) => typeof l === 'string');
|
|
2960
|
-
if (assignee !== undefined)
|
|
2961
|
-
ticket.assignee = typeof assignee === 'string' ? assignee : null;
|
|
2962
|
-
if (prerequisites !== undefined && Array.isArray(prerequisites))
|
|
2963
|
-
ticket.prerequisites = prerequisites.filter((p) => typeof p === 'number');
|
|
2964
|
-
if (metadata !== undefined && typeof metadata === 'object' && metadata !== null) {
|
|
2965
|
-
ticket.metadata = { ...ticket.metadata, ...metadata };
|
|
2966
|
-
}
|
|
2967
|
-
// Short summary: explicit non-empty overwrites; explicit null clears;
|
|
2968
|
-
// omitted leaves the existing value untouched (preserves prior summary
|
|
2969
|
-
// when AI Refine omits it for a partial edit).
|
|
2970
|
-
if (short_summary === null) {
|
|
2971
|
-
ticket.short_summary = null;
|
|
2972
|
-
}
|
|
2973
|
-
else if (typeof short_summary === 'string') {
|
|
2974
|
-
ticket.short_summary = (0, ticket_store_1.clampShortSummary)(short_summary);
|
|
2975
|
-
}
|
|
2976
|
-
ticket.updated_at = new Date().toISOString();
|
|
2977
|
-
updated = ticket;
|
|
2978
|
-
});
|
|
2979
|
-
if (validationError) {
|
|
2980
|
-
res.status(400).json({ error: validationError });
|
|
2981
|
-
return;
|
|
2982
|
-
}
|
|
2983
|
-
if (!updated) {
|
|
2984
|
-
res.status(404).json({ error: 'Ticket not found' });
|
|
2985
|
-
return;
|
|
2986
|
-
}
|
|
2987
|
-
const { broadcast, ticketWatcher } = ctx(req);
|
|
2988
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
2989
|
-
const msg = { type: 'ticket_updated', ticket: updated, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
2990
|
-
broadcast(msg);
|
|
2991
|
-
res.json({ ticket: updated, revision: store.revision });
|
|
2992
|
-
}
|
|
2993
|
-
catch (err) {
|
|
2994
|
-
console.error('[project-router] ticket update error:', err);
|
|
2995
|
-
res.status(500).json({ error: 'Failed to update ticket' });
|
|
2996
|
-
}
|
|
2997
|
-
});
|
|
2998
|
-
// POST /:projectId/tickets/:id/ai-edit — AI-powered description editing
|
|
2999
|
-
const _aiEditProcesses = new Map();
|
|
3000
|
-
router.post('/:projectId/tickets/:id/ai-edit', async (req, res) => {
|
|
3001
|
-
const ticketId = req.params.id;
|
|
3002
|
-
if (!/^\d+$/.test(ticketId)) {
|
|
3003
|
-
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
3004
|
-
return;
|
|
3005
|
-
}
|
|
3006
|
-
const instructions = req.body?.instructions;
|
|
3007
|
-
const currentDescription = req.body?.description;
|
|
3008
|
-
const currentTitle = typeof req.body?.title === 'string' ? req.body.title : '';
|
|
3009
|
-
if (!instructions?.trim()) {
|
|
3010
|
-
res.status(400).json({ error: 'instructions is required' });
|
|
3011
|
-
return;
|
|
3012
|
-
}
|
|
3013
|
-
if (!currentDescription) {
|
|
3014
|
-
res.status(400).json({ error: 'description is required' });
|
|
3015
|
-
return;
|
|
3016
|
-
}
|
|
3017
|
-
const attachmentIds = Array.isArray(req.body?.attachmentIds)
|
|
3018
|
-
? req.body.attachmentIds.filter((x) => typeof x === 'string')
|
|
3019
|
-
: [];
|
|
3020
|
-
const priorInstructions = Array.isArray(req.body?.priorInstructions)
|
|
3021
|
-
? req.body.priorInstructions.filter((x) => typeof x === 'string')
|
|
3022
|
-
: [];
|
|
3023
|
-
const priorProposalRaw = req.body?.priorProposal;
|
|
3024
|
-
const priorProposal = typeof priorProposalRaw === 'string' && priorProposalRaw.length > 0 ? priorProposalRaw : null;
|
|
3025
|
-
const isRefinement = priorProposal !== null;
|
|
3026
|
-
const { project, broadcast } = ctx(req);
|
|
3027
|
-
const provider = project.provider ?? 'claude';
|
|
3028
|
-
const requestId = (0, ids_1.newId)();
|
|
3029
|
-
const projectId = project.id;
|
|
3030
|
-
// Build the focused pre-prompt
|
|
3031
|
-
const baseRules = `- Output format MUST be exactly:\n` +
|
|
3032
|
-
` TITLE: <one-line spec title>\n` +
|
|
3033
|
-
` SHORT-SUMMARY: <one or two plain-language sentences, max 120 chars, summarising the spec for a dashboard postit. No markdown, no bullets.>\n` +
|
|
3034
|
-
` \n` +
|
|
3035
|
-
` <markdown description body>\n` +
|
|
3036
|
-
` The first line MUST start with "TITLE: " followed by the refined title.\n` +
|
|
3037
|
-
` The second line MUST start with "SHORT-SUMMARY: " followed by the summary.\n` +
|
|
3038
|
-
` Then exactly one blank line. Then the markdown description.\n` +
|
|
3039
|
-
`- Keep the title concise (under 80 characters) and reflective of the latest description.\n` +
|
|
3040
|
-
` If the user's refinement does not affect the title's intent, you may keep it unchanged — but always emit the TITLE line.\n` +
|
|
3041
|
-
`- The SHORT-SUMMARY line MUST always be present. If the user's refinement does not change what the spec is about, keep the previous summary verbatim. Never omit the line.\n` +
|
|
3042
|
-
`- After the SHORT-SUMMARY line and blank line, output ONLY the modified description in markdown. No preamble, no explanation, no wrapping.\n` +
|
|
3043
|
-
`- Preserve the existing markdown structure and section headings in the description.\n` +
|
|
3044
|
-
`- If the user asks to add technical details, briefly check CLAUDE.md and the project directory structure (ls, not deep reads) to ground your edits.\n` +
|
|
3045
|
-
`- Keep it concise and actionable.\n` +
|
|
3046
|
-
`- Do NOT create files, tickets, or issues. Only output text.`;
|
|
3047
|
-
const refinementRule = isRefinement
|
|
3048
|
-
? `\n- You are editing an in-progress draft, not the saved description. Apply the new refinement to the Latest Draft below.`
|
|
3049
|
-
: '';
|
|
3050
|
-
let systemPrompt = `You are a spec editor. You will receive a ticket title and description plus user instructions for how to modify them. ` +
|
|
3051
|
-
`Your job is to produce an improved version of BOTH the title and the description.\n\n` +
|
|
3052
|
-
`RULES:\n` +
|
|
3053
|
-
`${baseRules}${refinementRule}`;
|
|
3054
|
-
let userPrompt = isRefinement
|
|
3055
|
-
? `## Current Title (saved baseline)\n\n${currentTitle}\n\n` +
|
|
3056
|
-
`## Current Description (saved baseline — do not rewrite)\n\n${currentDescription}\n\n` +
|
|
3057
|
-
`## Prior Refinement Turns\n\n${priorInstructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n` +
|
|
3058
|
-
`## Latest Draft (from previous turn — apply the new refinement to this; the draft already includes a TITLE: line)\n\n${priorProposal}\n\n` +
|
|
3059
|
-
`## New Refinement\n\n${instructions.trim()}\n\n` +
|
|
3060
|
-
`Output the updated TITLE line followed by the updated description now.`
|
|
3061
|
-
: `## Current Title\n\n${currentTitle}\n\n` +
|
|
3062
|
-
`## Current Description\n\n${currentDescription}\n\n` +
|
|
3063
|
-
`## User Instructions\n\n${instructions.trim()}\n\n` +
|
|
3064
|
-
`Output the modified TITLE line followed by the modified description now.`;
|
|
3065
|
-
let imageFlags = [];
|
|
3066
|
-
if (attachmentIds.length > 0) {
|
|
3067
|
-
try {
|
|
3068
|
-
const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, ticketId, attachmentIds);
|
|
3069
|
-
imageFlags = extracted.imageFlags;
|
|
3070
|
-
if (extracted.textBlocks.length > 0) {
|
|
3071
|
-
systemPrompt = `${systemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
|
|
3072
|
-
userPrompt = `${userPrompt}\n\n## Attached Files\n\n${extracted.textBlocks.join('\n\n')}`;
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
catch (err) {
|
|
3076
|
-
console.error('[project-router] ai-edit attachment extraction error:', err);
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
let binary;
|
|
3080
|
-
let args;
|
|
3081
|
-
if (provider === 'codex') {
|
|
3082
|
-
binary = 'codex';
|
|
3083
|
-
// Use gpt-5.5 (default for Codex per CODEX_MODELS/PRESET_DEFAULTS in ModelSelector); never hardcode o4-mini
|
|
3084
|
-
args = ['exec', `${systemPrompt}\n\n${userPrompt}`, '--model', 'gpt-5.5'];
|
|
3085
|
-
}
|
|
3086
|
-
else {
|
|
3087
|
-
binary = 'claude';
|
|
3088
|
-
args = [
|
|
3089
|
-
'--dangerously-skip-permissions',
|
|
3090
|
-
'--tools', 'default',
|
|
3091
|
-
'--output-format', 'stream-json',
|
|
3092
|
-
'--verbose',
|
|
3093
|
-
'--max-turns', '4',
|
|
3094
|
-
...imageFlags,
|
|
3095
|
-
'--system-prompt', systemPrompt,
|
|
3096
|
-
'-p', userPrompt,
|
|
3097
|
-
];
|
|
3098
|
-
}
|
|
3099
|
-
// spawnAiCli reroutes multi-line argv values through stdin on Windows.
|
|
3100
|
-
console.log(`[project-router] ai-edit spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
|
|
3101
|
-
const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
|
|
3102
|
-
env: process.env,
|
|
3103
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3104
|
-
cwd: project.path,
|
|
3105
|
-
});
|
|
3106
|
-
_aiEditProcesses.set(requestId, child);
|
|
3107
|
-
// Pipe stderr to server log so failures surface for debugging.
|
|
3108
|
-
let aiEditStderrBuf = '';
|
|
3109
|
-
/* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
|
|
3110
|
-
child.stderr?.on('data', (chunk) => {
|
|
3111
|
-
const s = chunk.toString();
|
|
3112
|
-
aiEditStderrBuf += s;
|
|
3113
|
-
console.error(`[project-router] ai-edit stderr (${requestId}): ${s.trimEnd()}`);
|
|
3114
|
-
});
|
|
3115
|
-
/* c8 ignore stop */
|
|
3116
|
-
// Without this listener, ENOENT (binary missing on PATH) propagates as
|
|
3117
|
-
// an unhandled 'error' event and crashes the entire app process.
|
|
3118
|
-
/* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
|
|
3119
|
-
child.on('error', (err) => {
|
|
3120
|
-
console.error(`[project-router] ai-edit spawn failed (${binary}): ${err.message}`);
|
|
3121
|
-
_aiEditProcesses.delete(requestId);
|
|
3122
|
-
const errMsg = {
|
|
3123
|
-
type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
|
|
3124
|
-
requestId, error: `Failed to launch ${binary}: ${err.message}`,
|
|
3125
|
-
timestamp: new Date().toISOString(),
|
|
3126
|
-
};
|
|
3127
|
-
broadcast(errMsg);
|
|
3128
|
-
});
|
|
3129
|
-
/* c8 ignore stop */
|
|
3130
|
-
res.status(202).json({ requestId });
|
|
3131
|
-
let buffer = '';
|
|
3132
|
-
const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
|
|
3133
|
-
stdoutReader.on('line', (line) => {
|
|
3134
|
-
if (provider === 'codex') {
|
|
3135
|
-
if (line) {
|
|
3136
|
-
buffer += line + '\n';
|
|
3137
|
-
const msg = {
|
|
3138
|
-
type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
|
|
3139
|
-
requestId, delta: line + '\n', timestamp: new Date().toISOString(),
|
|
3140
|
-
};
|
|
3141
|
-
broadcast(msg);
|
|
3142
|
-
}
|
|
3143
|
-
}
|
|
3144
|
-
else {
|
|
3145
|
-
let parsed = null;
|
|
3146
|
-
try {
|
|
3147
|
-
parsed = JSON.parse(line);
|
|
3148
|
-
}
|
|
3149
|
-
catch { /* skip */ }
|
|
3150
|
-
if (!parsed)
|
|
3151
|
-
return;
|
|
3152
|
-
if (parsed.type === 'assistant') {
|
|
3153
|
-
const msg = parsed.message;
|
|
3154
|
-
const texts = (msg?.content ?? [])
|
|
3155
|
-
.filter((c) => c.type === 'text')
|
|
3156
|
-
.map((c) => c.text ?? '');
|
|
3157
|
-
const newText = texts.join('');
|
|
3158
|
-
if (newText) {
|
|
3159
|
-
buffer += newText;
|
|
3160
|
-
const wsMsg = {
|
|
3161
|
-
type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
|
|
3162
|
-
requestId, delta: newText, timestamp: new Date().toISOString(),
|
|
3163
|
-
};
|
|
3164
|
-
broadcast(wsMsg);
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
}
|
|
3168
|
-
});
|
|
3169
|
-
child.on('close', (code) => {
|
|
3170
|
-
_aiEditProcesses.delete(requestId);
|
|
3171
|
-
if (code === 0 && buffer.trim()) {
|
|
3172
|
-
const msg = {
|
|
3173
|
-
type: 'ticket_ai_edit_done', projectId, ticketId: Number(ticketId),
|
|
3174
|
-
requestId, fullText: buffer.trim(), timestamp: new Date().toISOString(),
|
|
3175
|
-
};
|
|
3176
|
-
broadcast(msg);
|
|
3177
|
-
}
|
|
3178
|
-
else {
|
|
3179
|
-
const reason = code === 0 ? 'Empty response from AI' : `Process exited with code ${code}`;
|
|
3180
|
-
console.error(`[project-router] ai-edit failed (${requestId}): ${reason}` +
|
|
3181
|
-
(aiEditStderrBuf.trim() ? `\n stderr: ${aiEditStderrBuf.trim()}` : '') +
|
|
3182
|
-
(buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
|
|
3183
|
-
const msg = {
|
|
3184
|
-
type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
|
|
3185
|
-
requestId, error: reason,
|
|
3186
|
-
timestamp: new Date().toISOString(),
|
|
3187
|
-
};
|
|
3188
|
-
broadcast(msg);
|
|
3189
|
-
}
|
|
3190
|
-
});
|
|
3191
|
-
});
|
|
3192
|
-
router.delete('/:projectId/tickets/:id/ai-edit', (req, res) => {
|
|
3193
|
-
const requestId = req.query.requestId;
|
|
3194
|
-
if (!requestId) {
|
|
3195
|
-
res.status(400).json({ error: 'requestId query param required' });
|
|
3196
|
-
return;
|
|
3197
|
-
}
|
|
3198
|
-
const child = _aiEditProcesses.get(requestId);
|
|
3199
|
-
if (!child?.pid) {
|
|
3200
|
-
res.status(404).json({ error: 'No active AI edit for this request' });
|
|
3201
|
-
return;
|
|
3202
|
-
}
|
|
3203
|
-
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
3204
|
-
_aiEditProcesses.delete(requestId);
|
|
3205
|
-
res.json({ ok: true });
|
|
3206
|
-
});
|
|
3207
|
-
// DELETE /:projectId/tickets/:id — Delete ticket
|
|
3208
|
-
router.delete('/:projectId/tickets/:id', (req, res) => {
|
|
3209
|
-
const ticketId = req.params.id;
|
|
3210
|
-
if (!/^\d+$/.test(ticketId)) {
|
|
3211
|
-
res.status(400).json({ error: 'Invalid ticket ID' });
|
|
3212
|
-
return;
|
|
3213
|
-
}
|
|
3214
|
-
try {
|
|
3215
|
-
const filePath = ticketPath(req);
|
|
3216
|
-
let found = false;
|
|
3217
|
-
let orphanedConversationId = null;
|
|
3218
|
-
const orphanedChildren = [];
|
|
3219
|
-
const numericId = Number(ticketId);
|
|
3220
|
-
const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
|
|
3221
|
-
const t = s.tickets[ticketId];
|
|
3222
|
-
if (!t)
|
|
3223
|
-
return;
|
|
3224
|
-
// If this is a draft and no other ticket references the same
|
|
3225
|
-
// origin_conversation_id, mark it for cascade delete.
|
|
3226
|
-
if (t.status === 'draft' && t.origin_conversation_id) {
|
|
3227
|
-
const otherRefs = Object.values(s.tickets).some((other) => other.id !== t.id && other.origin_conversation_id === t.origin_conversation_id);
|
|
3228
|
-
if (!otherRefs)
|
|
3229
|
-
orphanedConversationId = t.origin_conversation_id;
|
|
3230
|
-
}
|
|
3231
|
-
// SMASH: when deleting an épica, orphan its children (set
|
|
3232
|
-
// parent_epic_id/execution_order to null) rather than cascade-delete.
|
|
3233
|
-
if (t.is_epic) {
|
|
3234
|
-
const now = new Date().toISOString();
|
|
3235
|
-
for (const childId of Object.keys(s.tickets)) {
|
|
3236
|
-
const child = s.tickets[childId];
|
|
3237
|
-
if (child.parent_epic_id === numericId) {
|
|
3238
|
-
child.parent_epic_id = null;
|
|
3239
|
-
child.execution_order = null;
|
|
3240
|
-
child.updated_at = now;
|
|
3241
|
-
orphanedChildren.push(child);
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
delete s.tickets[ticketId];
|
|
3246
|
-
found = true;
|
|
3247
|
-
});
|
|
3248
|
-
if (!found) {
|
|
3249
|
-
res.status(404).json({ error: 'Ticket not found' });
|
|
3250
|
-
return;
|
|
3251
|
-
}
|
|
3252
|
-
const { broadcast, ticketWatcher, db, chatManager } = ctx(req);
|
|
3253
|
-
ticketWatcher.notifyDesktopWrite(store.revision);
|
|
3254
|
-
// Cascade-delete attachments for this ticket
|
|
3255
|
-
attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, ticketId).catch((e) => {
|
|
3256
|
-
console.error('[project-router] attachment cascade delete failed:', e);
|
|
3257
|
-
});
|
|
3258
|
-
// Cascade-delete the orphaned Explore conversation, if any.
|
|
3259
|
-
if (orphanedConversationId) {
|
|
3260
|
-
try {
|
|
3261
|
-
const conv = (0, db_1.getConversation)(db, orphanedConversationId);
|
|
3262
|
-
if (conv && conv.kind === 'explore') {
|
|
3263
|
-
(0, db_1.deleteConversation)(db, orphanedConversationId);
|
|
3264
|
-
chatManager?.forgetSpecDraft(orphanedConversationId);
|
|
3265
|
-
chatManager?.forgetExploreLifecycle(orphanedConversationId);
|
|
3266
|
-
}
|
|
3267
|
-
}
|
|
3268
|
-
catch (err) {
|
|
3269
|
-
console.error('[project-router] orphan conversation cleanup failed:', err);
|
|
3270
|
-
}
|
|
3271
|
-
}
|
|
3272
|
-
// Broadcast ticket_updated for each orphaned child so observers see them
|
|
3273
|
-
// as regular tickets (no longer attached to the deleted épica).
|
|
3274
|
-
for (const child of orphanedChildren) {
|
|
3275
|
-
broadcast({
|
|
3276
|
-
type: 'ticket_updated',
|
|
3277
|
-
ticket: child,
|
|
3278
|
-
projectId: ctx(req).project.id,
|
|
3279
|
-
timestamp: new Date().toISOString(),
|
|
3280
|
-
});
|
|
3281
|
-
}
|
|
3282
|
-
const msg = { type: 'ticket_deleted', ticketId: Number(ticketId), projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
|
|
3283
|
-
broadcast(msg);
|
|
3284
|
-
res.json({ ok: true, revision: store.revision });
|
|
3285
|
-
}
|
|
3286
|
-
catch (err) {
|
|
3287
|
-
console.error('[project-router] ticket delete error:', err);
|
|
3288
|
-
res.status(500).json({ error: 'Failed to delete ticket' });
|
|
3289
|
-
}
|
|
3290
|
-
});
|
|
3291
|
-
// ─── Ticket attachments ─────────────────────────────────────────────────────
|
|
3292
|
-
const attachmentUpload = (0, multer_1.default)({
|
|
3293
|
-
storage: multer_1.default.memoryStorage(),
|
|
3294
|
-
limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB per file
|
|
3295
|
-
fileFilter: (_req, file, cb) => {
|
|
3296
|
-
if ((0, attachment_manager_1.isSupportedUploadedFile)({ mimetype: file.mimetype, originalname: file.originalname }))
|
|
3297
|
-
cb(null, true);
|
|
3298
|
-
else
|
|
3299
|
-
cb(null, false);
|
|
3300
|
-
},
|
|
3301
|
-
});
|
|
3302
|
-
/** A ticket key is either a numeric real id or a UUID (pendingSpecId). */
|
|
3303
|
-
function parseTicketKey(raw) {
|
|
3304
|
-
if (/^\d+$/.test(raw))
|
|
3305
|
-
return { key: raw, isPending: false };
|
|
3306
|
-
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
|
3307
|
-
return { key: raw, isPending: true };
|
|
3308
|
-
}
|
|
3309
|
-
return null;
|
|
3310
|
-
}
|
|
3311
|
-
router.post('/:projectId/tickets/:ticketId/attachments', attachmentUpload.single('file'), async (req, res) => {
|
|
3312
|
-
const parsed = parseTicketKey(req.params.ticketId);
|
|
3313
|
-
if (!parsed) {
|
|
3314
|
-
res.status(400).json({ error: 'Invalid ticketId (must be numeric id or UUID)' });
|
|
3315
|
-
return;
|
|
3316
|
-
}
|
|
3317
|
-
const file = req.file;
|
|
3318
|
-
if (!file) {
|
|
3319
|
-
res.status(400).json({ error: 'No file uploaded or file type unsupported' });
|
|
3320
|
-
return;
|
|
3321
|
-
}
|
|
3322
|
-
if (!parsed.isPending) {
|
|
3323
|
-
const store = (0, ticket_store_1.readStore)(ticketPath(req));
|
|
3324
|
-
if (!store.tickets[parsed.key]) {
|
|
3325
|
-
res.status(404).json({ error: 'Ticket not found' });
|
|
3326
|
-
return;
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
try {
|
|
3330
|
-
const attachment = await attachment_manager_1.attachmentManager.upload({
|
|
3331
|
-
slug: ctx(req).project.slug,
|
|
3332
|
-
ticketKey: parsed.key,
|
|
3333
|
-
projectPath: parsed.isPending ? null : ctx(req).project.path,
|
|
3334
|
-
file: {
|
|
3335
|
-
buffer: file.buffer,
|
|
3336
|
-
originalname: file.originalname,
|
|
3337
|
-
mimetype: file.mimetype,
|
|
3338
|
-
size: file.size,
|
|
3339
|
-
},
|
|
3340
|
-
});
|
|
3341
|
-
res.status(201).json({ attachment });
|
|
3342
|
-
}
|
|
3343
|
-
catch (err) {
|
|
3344
|
-
const status = err.status ?? 500;
|
|
3345
|
-
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
3346
|
-
console.error('[project-router] attachment upload error:', err);
|
|
3347
|
-
res.status(status).json({ error: message });
|
|
3348
|
-
}
|
|
3349
|
-
});
|
|
3350
|
-
router.get('/:projectId/tickets/:ticketId/attachments', (req, res) => {
|
|
3351
|
-
const parsed = parseTicketKey(req.params.ticketId);
|
|
3352
|
-
if (!parsed) {
|
|
3353
|
-
res.status(400).json({ error: 'Invalid ticketId' });
|
|
3354
|
-
return;
|
|
3355
|
-
}
|
|
3356
|
-
const attachments = attachment_manager_1.attachmentManager.list(ctx(req).project.slug, parsed.key);
|
|
3357
|
-
res.json({ attachments });
|
|
3358
|
-
});
|
|
3359
|
-
router.get('/:projectId/tickets/:ticketId/attachments/:attachmentId', (req, res) => {
|
|
3360
|
-
const parsed = parseTicketKey(req.params.ticketId);
|
|
3361
|
-
if (!parsed) {
|
|
3362
|
-
res.status(400).json({ error: 'Invalid ticketId' });
|
|
3363
|
-
return;
|
|
3364
|
-
}
|
|
3365
|
-
const attachmentId = req.params.attachmentId;
|
|
3366
|
-
const slug = ctx(req).project.slug;
|
|
3367
|
-
const meta = attachment_manager_1.attachmentManager.getMeta(slug, parsed.key, attachmentId);
|
|
3368
|
-
const abs = meta ? attachment_manager_1.attachmentManager.getFilePath(slug, parsed.key, attachmentId) : null;
|
|
3369
|
-
if (!meta || !abs) {
|
|
3370
|
-
res.status(404).json({ error: 'Attachment not found' });
|
|
3371
|
-
return;
|
|
3372
|
-
}
|
|
3373
|
-
res.setHeader('Content-Type', meta.mimeType);
|
|
3374
|
-
// Strip quotes AND CR/LF: a newline in the stored (raw) original filename
|
|
3375
|
-
// makes Node's setHeader throw ERR_INVALID_CHAR after Content-Type is
|
|
3376
|
-
// already set, 500-ing the download. Also emit an RFC 5987 filename* so
|
|
3377
|
-
// non-ASCII names survive.
|
|
3378
|
-
const asciiName = meta.filename.replace(/[\r\n"]/g, '_');
|
|
3379
|
-
res.setHeader('Content-Disposition', `inline; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(meta.filename)}`);
|
|
3380
|
-
fs_1.default.createReadStream(abs).pipe(res);
|
|
3381
|
-
});
|
|
3382
|
-
router.delete('/:projectId/tickets/:ticketId/attachments/:attachmentId', async (req, res) => {
|
|
3383
|
-
const parsed = parseTicketKey(req.params.ticketId);
|
|
3384
|
-
if (!parsed) {
|
|
3385
|
-
res.status(400).json({ error: 'Invalid ticketId' });
|
|
3386
|
-
return;
|
|
3387
|
-
}
|
|
3388
|
-
const attachmentId = req.params.attachmentId;
|
|
3389
|
-
try {
|
|
3390
|
-
const ok = await attachment_manager_1.attachmentManager.delete({
|
|
3391
|
-
slug: ctx(req).project.slug,
|
|
3392
|
-
ticketKey: parsed.key,
|
|
3393
|
-
attachmentId,
|
|
3394
|
-
projectPath: parsed.isPending ? null : ctx(req).project.path,
|
|
3395
|
-
});
|
|
3396
|
-
if (!ok) {
|
|
3397
|
-
res.status(404).json({ error: 'Attachment not found' });
|
|
3398
|
-
return;
|
|
3399
|
-
}
|
|
3400
|
-
res.status(204).end();
|
|
3401
|
-
}
|
|
3402
|
-
catch (err) {
|
|
3403
|
-
console.error('[project-router] attachment delete error:', err);
|
|
3404
|
-
res.status(500).json({ error: 'Delete failed' });
|
|
3405
|
-
}
|
|
3406
|
-
});
|
|
3407
|
-
router.delete('/:projectId/tickets/:ticketId/attachments', async (req, res) => {
|
|
3408
|
-
const parsed = parseTicketKey(req.params.ticketId);
|
|
3409
|
-
if (!parsed) {
|
|
3410
|
-
res.status(400).json({ error: 'Invalid ticketId' });
|
|
3411
|
-
return;
|
|
3412
|
-
}
|
|
3413
|
-
try {
|
|
3414
|
-
await attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, parsed.key);
|
|
3415
|
-
res.status(204).end();
|
|
3416
|
-
}
|
|
3417
|
-
catch (err) {
|
|
3418
|
-
console.error('[project-router] attachment bulk delete error:', err);
|
|
3419
|
-
res.status(500).json({ error: 'Bulk delete failed' });
|
|
3420
|
-
}
|
|
3421
|
-
});
|
|
3422
|
-
// ─── Terminals ───────────────────────────────────────────────────────────────
|
|
3423
|
-
function requireTerminalsEnabled(_req, res, next) {
|
|
3424
|
-
if (!TERMINAL_PANEL_ENABLED) {
|
|
3425
|
-
res.status(404).json({ error: 'Terminal panel disabled' });
|
|
3426
|
-
return;
|
|
3427
|
-
}
|
|
3428
|
-
next();
|
|
3429
|
-
}
|
|
3430
|
-
router.get('/:projectId/terminals', requireTerminalsEnabled, (req, res) => {
|
|
3431
|
-
const projectId = ctx(req).project.id;
|
|
3432
|
-
const sessions = (0, terminal_manager_1.getTerminalManager)().listForProject(projectId);
|
|
3433
|
-
res.json({ sessions, limit: terminal_manager_1.TERMINAL_MAX_PER_PROJECT });
|
|
3434
|
-
});
|
|
3435
|
-
router.post('/:projectId/terminals', requireTerminalsEnabled, (req, res) => {
|
|
3436
|
-
const { cols, rows, name } = req.body ?? {};
|
|
3437
|
-
const projectCtx = ctx(req);
|
|
3438
|
-
const project = projectCtx.project;
|
|
3439
|
-
const settings = (0, terminal_settings_1.resolveTerminalSettings)(registry.desktopDb, projectCtx.db);
|
|
3440
|
-
try {
|
|
3441
|
-
const meta = (0, terminal_manager_1.getTerminalManager)().create(project.id, {
|
|
3442
|
-
cwd: project.path,
|
|
3443
|
-
cols: typeof cols === 'number' ? cols : undefined,
|
|
3444
|
-
rows: typeof rows === 'number' ? rows : undefined,
|
|
3445
|
-
name: typeof name === 'string' ? name : undefined,
|
|
3446
|
-
projectSlug: project.slug,
|
|
3447
|
-
projectDb: projectCtx.db,
|
|
3448
|
-
settings,
|
|
3449
|
-
});
|
|
3450
|
-
res.status(201).json({ session: meta });
|
|
3451
|
-
}
|
|
3452
|
-
catch (err) {
|
|
3453
|
-
if (err instanceof terminal_manager_1.TerminalLimitExceededError) {
|
|
3454
|
-
res.status(409).json({ error: 'terminal_limit_exceeded', limit: terminal_manager_1.TERMINAL_MAX_PER_PROJECT });
|
|
3455
|
-
return;
|
|
3456
|
-
}
|
|
3457
|
-
if (err instanceof terminal_manager_1.TerminalNameInvalidError) {
|
|
3458
|
-
res.status(400).json({ error: 'terminal_name_invalid' });
|
|
3459
|
-
return;
|
|
3460
|
-
}
|
|
3461
|
-
if (err instanceof terminal_manager_1.TerminalSpawnError) {
|
|
3462
|
-
// The shell failed to spawn (commonly the host running out of file
|
|
3463
|
-
// descriptors). Surface a concrete, actionable reason — a bare 500 hid
|
|
3464
|
-
// this and made the "+" button look like it did nothing.
|
|
3465
|
-
console.error('[project-router] terminal spawn failed:', err.reason, err.message);
|
|
3466
|
-
res.status(502).json({ error: 'terminal_spawn_failed', reason: err.reason });
|
|
3467
|
-
return;
|
|
3468
|
-
}
|
|
3469
|
-
console.error('[project-router] terminal create error:', err);
|
|
3470
|
-
res.status(500).json({ error: 'Failed to create terminal' });
|
|
3471
|
-
}
|
|
3472
|
-
});
|
|
3473
|
-
router.patch('/:projectId/terminals/:id', requireTerminalsEnabled, (req, res) => {
|
|
3474
|
-
const { name } = req.body ?? {};
|
|
3475
|
-
if (typeof name !== 'string') {
|
|
3476
|
-
res.status(400).json({ error: 'name is required' });
|
|
3477
|
-
return;
|
|
3478
|
-
}
|
|
3479
|
-
const projectId = ctx(req).project.id;
|
|
3480
|
-
try {
|
|
3481
|
-
const meta = (0, terminal_manager_1.getTerminalManager)().rename(projectId, req.params.id, name);
|
|
3482
|
-
res.json({ session: meta });
|
|
3483
|
-
}
|
|
3484
|
-
catch (err) {
|
|
3485
|
-
if (err instanceof terminal_manager_1.TerminalNotFoundError) {
|
|
3486
|
-
res.status(404).json({ error: 'terminal_not_found' });
|
|
3487
|
-
return;
|
|
3488
|
-
}
|
|
3489
|
-
if (err instanceof terminal_manager_1.TerminalNameInvalidError) {
|
|
3490
|
-
res.status(400).json({ error: 'terminal_name_invalid' });
|
|
3491
|
-
return;
|
|
3492
|
-
}
|
|
3493
|
-
console.error('[project-router] terminal rename error:', err);
|
|
3494
|
-
res.status(500).json({ error: 'Failed to rename terminal' });
|
|
3495
|
-
}
|
|
3496
|
-
});
|
|
3497
|
-
router.delete('/:projectId/terminals/:id', requireTerminalsEnabled, (req, res) => {
|
|
3498
|
-
const projectId = ctx(req).project.id;
|
|
3499
|
-
const ok = (0, terminal_manager_1.getTerminalManager)().kill(projectId, req.params.id);
|
|
3500
|
-
if (!ok) {
|
|
3501
|
-
res.status(404).json({ error: 'terminal_not_found' });
|
|
3502
|
-
return;
|
|
3503
|
-
}
|
|
3504
|
-
res.json({ ok: true });
|
|
3505
|
-
});
|
|
3506
|
-
// ─── Embedded browser ("Add Spec from browser") ──────────────────────────────
|
|
3507
|
-
function requireBrowserCaptureEnabled(_req, res, next) {
|
|
3508
|
-
if (!(0, feature_flags_1.isBrowserCaptureEnabled)()) {
|
|
3509
|
-
res.status(404).json({ error: 'browser_capture_disabled' });
|
|
3510
|
-
return;
|
|
3511
|
-
}
|
|
3512
|
-
next();
|
|
3513
|
-
}
|
|
3514
|
-
function parseRect(raw) {
|
|
3515
|
-
if (!raw || typeof raw !== 'object')
|
|
3516
|
-
return null;
|
|
3517
|
-
const r = raw;
|
|
3518
|
-
const nums = [r.x, r.y, r.width, r.height];
|
|
3519
|
-
if (!nums.every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
3520
|
-
return null;
|
|
3521
|
-
const x = r.x, y = r.y, width = r.width, height = r.height;
|
|
3522
|
-
if (width <= 0 || height <= 0)
|
|
3523
|
-
return null;
|
|
3524
|
-
if (x < 0 || y < 0)
|
|
3525
|
-
return null;
|
|
3526
|
-
// Upper bound guards against an over-read request far past any real viewport.
|
|
3527
|
-
if (x + width > 20000 || y + height > 20000)
|
|
3528
|
-
return null;
|
|
3529
|
-
return { x, y, width, height };
|
|
3530
|
-
}
|
|
3531
|
-
// pendingSpecId becomes a filesystem path segment inside attachmentManager
|
|
3532
|
-
// (~/.specrails/projects/<slug>/attachments/<pendingSpecId>/). Reject anything
|
|
3533
|
-
// that isn't a safe opaque token so a crafted value can't traverse the tree.
|
|
3534
|
-
const SAFE_PENDING_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
3535
|
-
router.get('/:projectId/browser/sessions', requireBrowserCaptureEnabled, (req, res) => {
|
|
3536
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3537
|
-
res.json({ sessions: mgr.listSessions(), lastUrl: mgr.getLastUrl() });
|
|
3538
|
-
});
|
|
3539
|
-
router.post('/:projectId/browser/sessions', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3540
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3541
|
-
const initialUrl = typeof req.body?.initialUrl === 'string' ? req.body.initialUrl : undefined;
|
|
3542
|
-
try {
|
|
3543
|
-
const session = await mgr.create({ initialUrl });
|
|
3544
|
-
res.status(201).json({ session });
|
|
3545
|
-
}
|
|
3546
|
-
catch (err) {
|
|
3547
|
-
if (err instanceof browser_capture_types_1.BrowserLimitExceededError) {
|
|
3548
|
-
res.status(409).json({ error: 'browser_session_limit_exceeded', limit: err.limit });
|
|
3549
|
-
return;
|
|
3550
|
-
}
|
|
3551
|
-
if (err instanceof browser_capture_types_1.BrowserLaunchError) {
|
|
3552
|
-
console.error('[project-router] browser launch failed:', err.cause?.message ?? err.message);
|
|
3553
|
-
res.status(502).json({ error: 'browser_launch_failed' });
|
|
3554
|
-
return;
|
|
3555
|
-
}
|
|
3556
|
-
console.error('[project-router] browser session create error:', err);
|
|
3557
|
-
res.status(500).json({ error: 'Failed to create browser session' });
|
|
3558
|
-
}
|
|
3559
|
-
});
|
|
3560
|
-
router.post('/:projectId/browser/sessions/:id/navigate', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3561
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3562
|
-
const action = req.body?.action ?? 'goto';
|
|
3563
|
-
const validActions = new Set(['goto', 'back', 'forward', 'reload']);
|
|
3564
|
-
if (!validActions.has(action)) {
|
|
3565
|
-
res.status(400).json({ error: 'action must be one of: goto, back, forward, reload' });
|
|
3566
|
-
return;
|
|
3567
|
-
}
|
|
3568
|
-
let url;
|
|
3569
|
-
if (action === 'goto') {
|
|
3570
|
-
url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
|
|
3571
|
-
if (!url) {
|
|
3572
|
-
res.status(400).json({ error: 'url is required for goto' });
|
|
3573
|
-
return;
|
|
3574
|
-
}
|
|
3575
|
-
// Only allow web schemes (or bare hosts the manager will https-prefix).
|
|
3576
|
-
// Blocks file://, data:, javascript: etc. from reaching the embedded browser.
|
|
3577
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^https?:\/\//i.test(url)) {
|
|
3578
|
-
res.status(400).json({ error: 'only http(s) URLs are allowed' });
|
|
3579
|
-
return;
|
|
3580
|
-
}
|
|
3581
|
-
}
|
|
3582
|
-
try {
|
|
3583
|
-
const result = await mgr.navigate(req.params.id, action, url);
|
|
3584
|
-
if (!result) {
|
|
3585
|
-
res.status(404).json({ error: 'browser_session_not_found' });
|
|
3586
|
-
return;
|
|
3587
|
-
}
|
|
3588
|
-
res.json(result);
|
|
3589
|
-
}
|
|
3590
|
-
catch (err) {
|
|
3591
|
-
console.error('[project-router] browser navigate error:', err);
|
|
3592
|
-
res.status(500).json({ error: 'Failed to navigate' });
|
|
3593
|
-
}
|
|
3594
|
-
});
|
|
3595
|
-
router.post('/:projectId/browser/sessions/:id/capture', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3596
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3597
|
-
const rect = parseRect(req.body?.rect);
|
|
3598
|
-
if (!rect) {
|
|
3599
|
-
res.status(400).json({ error: 'rect {x,y,width,height} with positive size is required' });
|
|
3600
|
-
return;
|
|
3601
|
-
}
|
|
3602
|
-
const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId.trim() : '';
|
|
3603
|
-
if (!pendingSpecId) {
|
|
3604
|
-
res.status(400).json({ error: 'pendingSpecId is required' });
|
|
3605
|
-
return;
|
|
3606
|
-
}
|
|
3607
|
-
if (!SAFE_PENDING_ID.test(pendingSpecId)) {
|
|
3608
|
-
res.status(400).json({ error: 'pendingSpecId has an invalid format' });
|
|
3609
|
-
return;
|
|
3610
|
-
}
|
|
3611
|
-
const captureNetwork = req.body?.captureNetwork !== false;
|
|
3612
|
-
try {
|
|
3613
|
-
const result = await mgr.capture(req.params.id, rect, pendingSpecId, { captureNetwork });
|
|
3614
|
-
if (!result) {
|
|
3615
|
-
res.status(404).json({ error: 'browser_session_not_found' });
|
|
3616
|
-
return;
|
|
3617
|
-
}
|
|
3618
|
-
res.json(result);
|
|
3619
|
-
}
|
|
3620
|
-
catch (err) {
|
|
3621
|
-
console.error('[project-router] browser capture error:', err);
|
|
3622
|
-
res.status(500).json({ error: 'Failed to capture' });
|
|
3623
|
-
}
|
|
3624
|
-
});
|
|
3625
|
-
router.post('/:projectId/browser/sessions/:id/capture-breakpoints', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3626
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3627
|
-
const rect = parseRect(req.body?.rect);
|
|
3628
|
-
if (!rect) {
|
|
3629
|
-
res.status(400).json({ error: 'rect {x,y,width,height} with positive size is required' });
|
|
3630
|
-
return;
|
|
3631
|
-
}
|
|
3632
|
-
const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId.trim() : '';
|
|
3633
|
-
if (!pendingSpecId || !SAFE_PENDING_ID.test(pendingSpecId)) {
|
|
3634
|
-
res.status(400).json({ error: 'pendingSpecId is required and must be well-formed' });
|
|
3635
|
-
return;
|
|
3636
|
-
}
|
|
3637
|
-
// Validate the per-breakpoint viewport dims (client-supplied; single source).
|
|
3638
|
-
const rawDims = req.body?.breakpoints;
|
|
3639
|
-
const dims = {};
|
|
3640
|
-
if (rawDims && typeof rawDims === 'object') {
|
|
3641
|
-
for (const [k, v] of Object.entries(rawDims)) {
|
|
3642
|
-
if (Object.keys(dims).length >= 4)
|
|
3643
|
-
break;
|
|
3644
|
-
if (!/^[a-z0-9_-]{1,20}$/i.test(k))
|
|
3645
|
-
continue;
|
|
3646
|
-
const d = v;
|
|
3647
|
-
const w = Math.round(Number(d?.width));
|
|
3648
|
-
const h = Math.round(Number(d?.height));
|
|
3649
|
-
if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1 && w <= 4000 && h <= 4000) {
|
|
3650
|
-
dims[k] = { width: w, height: h };
|
|
3651
|
-
}
|
|
3652
|
-
}
|
|
3653
|
-
}
|
|
3654
|
-
if (Object.keys(dims).length === 0) {
|
|
3655
|
-
res.status(400).json({ error: 'breakpoints {name:{width,height}} is required' });
|
|
3656
|
-
return;
|
|
3657
|
-
}
|
|
3658
|
-
const a = req.body?.anchorPoint;
|
|
3659
|
-
const anchorPoint = a && typeof a.x === 'number' && typeof a.y === 'number'
|
|
3660
|
-
? { x: a.x, y: a.y }
|
|
3661
|
-
: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
3662
|
-
try {
|
|
3663
|
-
const result = await mgr.captureBreakpoints(req.params.id, rect, anchorPoint, pendingSpecId, dims);
|
|
3664
|
-
if (!result) {
|
|
3665
|
-
res.status(404).json({ error: 'browser_session_not_found' });
|
|
3666
|
-
return;
|
|
3667
|
-
}
|
|
3668
|
-
res.json(result);
|
|
3669
|
-
}
|
|
3670
|
-
catch (err) {
|
|
3671
|
-
console.error('[project-router] browser capture-breakpoints error:', err);
|
|
3672
|
-
res.status(500).json({ error: 'Failed to capture' });
|
|
3673
|
-
}
|
|
3674
|
-
});
|
|
3675
|
-
router.post('/:projectId/browser/sessions/:id/element', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3676
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3677
|
-
const selector = typeof req.body?.selector === 'string' ? req.body.selector : '';
|
|
3678
|
-
const direction = req.body?.direction;
|
|
3679
|
-
if (!selector || (direction !== 'parent' && direction !== 'child' && direction !== 'self')) {
|
|
3680
|
-
res.status(400).json({ error: 'selector and direction (parent|child|self) are required' });
|
|
3681
|
-
return;
|
|
3682
|
-
}
|
|
3683
|
-
try {
|
|
3684
|
-
// probe may be null (can't step further / element gone) — still 200.
|
|
3685
|
-
const probe = await mgr.navigateElement(req.params.id, selector.slice(0, 4000), direction);
|
|
3686
|
-
res.json({ probe });
|
|
3687
|
-
}
|
|
3688
|
-
catch (err) {
|
|
3689
|
-
console.error('[project-router] browser element navigate error:', err);
|
|
3690
|
-
res.status(500).json({ error: 'Failed to resolve element' });
|
|
3691
|
-
}
|
|
3692
|
-
});
|
|
3693
|
-
router.post('/:projectId/browser/sessions/:id/clipboard', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3694
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3695
|
-
const action = req.body?.action;
|
|
3696
|
-
if (action !== 'copy' && action !== 'paste' && action !== 'cut') {
|
|
3697
|
-
res.status(400).json({ error: 'action must be copy | paste | cut' });
|
|
3698
|
-
return;
|
|
3699
|
-
}
|
|
3700
|
-
const text = typeof req.body?.text === 'string' ? req.body.text.slice(0, 100_000) : undefined;
|
|
3701
|
-
try {
|
|
3702
|
-
const out = await mgr.clipboard(req.params.id, action, text);
|
|
3703
|
-
if (!out) {
|
|
3704
|
-
res.status(404).json({ error: 'browser_session_not_found' });
|
|
3705
|
-
return;
|
|
3706
|
-
}
|
|
3707
|
-
res.json(out);
|
|
3708
|
-
}
|
|
3709
|
-
catch (err) {
|
|
3710
|
-
console.error('[project-router] browser clipboard error:', err);
|
|
3711
|
-
res.status(500).json({ error: 'Failed clipboard op' });
|
|
3712
|
-
}
|
|
3713
|
-
});
|
|
3714
|
-
router.delete('/:projectId/browser/sessions/:id', requireBrowserCaptureEnabled, async (req, res) => {
|
|
3715
|
-
const mgr = ctx(req).browserCaptureManager;
|
|
3716
|
-
const ok = await mgr.kill(req.params.id);
|
|
3717
|
-
if (!ok) {
|
|
3718
|
-
res.status(404).json({ error: 'browser_session_not_found' });
|
|
3719
|
-
return;
|
|
3720
|
-
}
|
|
3721
|
-
res.json({ ok: true });
|
|
3722
|
-
});
|
|
3723
|
-
// ─── Project settings (pipeline telemetry) ───────────────────────────────────
|
|
3724
|
-
router.get('/:projectId/settings', (req, res) => {
|
|
3725
|
-
const settings = (0, db_1.getProjectSettings)(ctx(req).db);
|
|
3726
|
-
res.json(settings);
|
|
3727
|
-
});
|
|
3728
|
-
// ─── Per-project Quick mode Contract Refine last-used value ─────────────────
|
|
3729
|
-
router.get('/:projectId/add-spec-quick-contract-refine-last', (req, res) => {
|
|
3730
|
-
res.json({
|
|
3731
|
-
enabled: (0, db_1.getQuickContractRefineLast)(ctx(req).db),
|
|
3732
|
-
configured: (0, db_1.hasQuickContractRefineLast)(ctx(req).db),
|
|
3733
|
-
});
|
|
3734
|
-
});
|
|
3735
|
-
router.patch('/:projectId/add-spec-quick-contract-refine-last', (req, res) => {
|
|
3736
|
-
const enabled = req.body?.enabled;
|
|
3737
|
-
if (typeof enabled !== 'boolean') {
|
|
3738
|
-
res.status(400).json({ error: 'enabled must be a boolean' });
|
|
3739
|
-
return;
|
|
3740
|
-
}
|
|
3741
|
-
(0, db_1.setQuickContractRefineLast)(ctx(req).db, enabled);
|
|
3742
|
-
res.json({ enabled: (0, db_1.getQuickContractRefineLast)(ctx(req).db) });
|
|
3743
|
-
});
|
|
3744
|
-
// ─── Add Spec context scope ────────────────────────────────────────────────
|
|
3745
|
-
router.get('/:projectId/context-budget', (req, res) => {
|
|
3746
|
-
const { project } = ctx(req);
|
|
3747
|
-
try {
|
|
3748
|
-
const budget = (0, context_budget_1.getContextBudget)(project.id, project.path);
|
|
3749
|
-
res.json(budget);
|
|
3750
|
-
}
|
|
3751
|
-
catch (err) {
|
|
3752
|
-
console.error('[project-router] context-budget failed:', err);
|
|
3753
|
-
res.status(500).json({ error: 'failed to compute context budget' });
|
|
3754
|
-
}
|
|
3755
|
-
});
|
|
3756
|
-
router.get('/:projectId/context-scope-last', (req, res) => {
|
|
3757
|
-
const scope = (0, context_scope_1.getLastContextScope)(ctx(req).db, 'explore');
|
|
3758
|
-
res.json({ scope });
|
|
3759
|
-
});
|
|
3760
|
-
router.patch('/:projectId/context-scope-last', (req, res) => {
|
|
3761
|
-
const body = req.body;
|
|
3762
|
-
if (!body || typeof body !== 'object') {
|
|
3763
|
-
res.status(400).json({ error: 'body must be an object' });
|
|
3764
|
-
return;
|
|
3765
|
-
}
|
|
3766
|
-
// Validate booleans-only for any provided key.
|
|
3767
|
-
for (const key of ['specrails', 'openspec', 'full', 'mcp', 'contractRefine', 'userMcp']) {
|
|
3768
|
-
if (body[key] !== undefined && typeof body[key] !== 'boolean') {
|
|
3769
|
-
res.status(400).json({ error: `${key} must be a boolean` });
|
|
3770
|
-
return;
|
|
3771
|
-
}
|
|
3772
|
-
}
|
|
3773
|
-
const current = (0, context_scope_1.getLastContextScope)(ctx(req).db, 'explore');
|
|
3774
|
-
const merged = (0, context_scope_1.normalizeContextScope)({ ...current, ...body }, current);
|
|
3775
|
-
(0, context_scope_1.setLastContextScope)(ctx(req).db, merged);
|
|
3776
|
-
res.json({ scope: merged });
|
|
3777
|
-
});
|
|
3778
|
-
router.patch('/:projectId/settings', (req, res) => {
|
|
3779
|
-
const { pipelineTelemetryEnabled, orchestratorModel, prePrompt, ultraPrePrompt } = req.body ?? {};
|
|
3780
|
-
const patch = {};
|
|
3781
|
-
if (pipelineTelemetryEnabled !== undefined) {
|
|
3782
|
-
patch.pipelineTelemetryEnabled = Boolean(pipelineTelemetryEnabled);
|
|
3783
|
-
}
|
|
3784
|
-
const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
3785
|
-
if (orchestratorModel !== undefined) {
|
|
3786
|
-
if (typeof orchestratorModel !== 'string' || !VALID_MODELS.includes(orchestratorModel)) {
|
|
3787
|
-
res.status(400).json({ error: `orchestratorModel must be one of: ${VALID_MODELS.join(', ')}` });
|
|
3788
|
-
return;
|
|
3789
|
-
}
|
|
3790
|
-
patch.orchestratorModel = orchestratorModel;
|
|
3791
|
-
}
|
|
3792
|
-
if (prePrompt !== undefined) {
|
|
3793
|
-
if (typeof prePrompt !== 'string') {
|
|
3794
|
-
res.status(400).json({ error: 'prePrompt must be a string' });
|
|
3795
|
-
return;
|
|
3796
|
-
}
|
|
3797
|
-
patch.prePrompt = prePrompt;
|
|
3798
|
-
}
|
|
3799
|
-
if (ultraPrePrompt !== undefined) {
|
|
3800
|
-
if (typeof ultraPrePrompt !== 'string') {
|
|
3801
|
-
res.status(400).json({ error: 'ultraPrePrompt must be a string' });
|
|
3802
|
-
return;
|
|
3803
|
-
}
|
|
3804
|
-
patch.ultraPrePrompt = ultraPrePrompt;
|
|
3805
|
-
}
|
|
3806
|
-
try {
|
|
3807
|
-
(0, db_1.updateProjectSettings)(ctx(req).db, patch);
|
|
3808
|
-
res.json({ ok: true, settings: (0, db_1.getProjectSettings)(ctx(req).db) });
|
|
3809
|
-
}
|
|
3810
|
-
catch (err) {
|
|
3811
|
-
console.error('[project-router] settings patch error:', err);
|
|
3812
|
-
res.status(500).json({ error: 'Failed to update settings' });
|
|
3813
|
-
}
|
|
3814
|
-
});
|
|
3815
|
-
// ─── Agent models ────────────────────────────────────────────────────────────
|
|
3816
|
-
router.get('/:projectId/agent-models', (req, res) => {
|
|
3817
|
-
const { project } = ctx(req);
|
|
3818
|
-
const agents = readAgentModels(project.path);
|
|
3819
|
-
res.json({ agents });
|
|
3820
|
-
});
|
|
3821
|
-
router.patch('/:projectId/agent-models', (req, res) => {
|
|
3822
|
-
const { project } = ctx(req);
|
|
3823
|
-
const { defaultModel, overrides } = req.body ?? {};
|
|
3824
|
-
// Validate defaultModel if provided
|
|
3825
|
-
if (defaultModel !== undefined) {
|
|
3826
|
-
if (typeof defaultModel !== 'string' || !VALID_MODEL_ALIASES.includes(defaultModel)) {
|
|
3827
|
-
res.status(400).json({ error: `Invalid model alias. Must be one of: ${VALID_MODEL_ALIASES.join(', ')}` });
|
|
3828
|
-
return;
|
|
3829
|
-
}
|
|
3830
|
-
}
|
|
3831
|
-
// Validate overrides map if provided
|
|
3832
|
-
if (overrides !== undefined) {
|
|
3833
|
-
if (typeof overrides !== 'object' || Array.isArray(overrides) || overrides === null) {
|
|
3834
|
-
res.status(400).json({ error: 'overrides must be an object' });
|
|
3835
|
-
return;
|
|
3836
|
-
}
|
|
3837
|
-
for (const [agentName, modelValue] of Object.entries(overrides)) {
|
|
3838
|
-
if (typeof modelValue !== 'string' || !VALID_MODEL_ALIASES.includes(modelValue)) {
|
|
3839
|
-
res.status(400).json({ error: `Invalid model alias for agent "${agentName}". Must be one of: ${VALID_MODEL_ALIASES.join(', ')}` });
|
|
3840
|
-
return;
|
|
3841
|
-
}
|
|
3842
|
-
}
|
|
3843
|
-
}
|
|
3844
|
-
const configDir = path_1.default.join(project.path, '.specrails');
|
|
3845
|
-
const configPath = path_1.default.join(configDir, 'install-config.yaml');
|
|
3846
|
-
// Read existing config or build default shape
|
|
3847
|
-
let existingConfig = {
|
|
3848
|
-
version: 1,
|
|
3849
|
-
provider: 'claude',
|
|
3850
|
-
tier: 'quick',
|
|
3851
|
-
agents: { selected: [], excluded: [] },
|
|
3852
|
-
models: { preset: 'balanced', defaults: { model: 'sonnet' }, overrides: {} },
|
|
3853
|
-
agent_teams: false,
|
|
3854
|
-
};
|
|
3855
|
-
if (fs_1.default.existsSync(configPath)) {
|
|
3856
|
-
try {
|
|
3857
|
-
const text = fs_1.default.readFileSync(configPath, 'utf-8');
|
|
3858
|
-
// Parse fields we care about from the existing config text
|
|
3859
|
-
const versionMatch = text.match(/^version:\s*(\d+)/m);
|
|
3860
|
-
const providerMatch = text.match(/^provider:\s*(\S+)/m);
|
|
3861
|
-
const tierMatch = text.match(/^tier:\s*(\S+)/m);
|
|
3862
|
-
const presetMatch = text.match(/preset:\s*(\S+)/);
|
|
3863
|
-
const agentTeamsMatch = text.match(/^agent_teams:\s*(\S+)/m);
|
|
3864
|
-
// Parse selected agents list
|
|
3865
|
-
const selectedMatch = text.match(/selected:\s*\[([^\]]*)\]/);
|
|
3866
|
-
const excludedMatch = text.match(/excluded:\s*\[([^\]]*)\]/);
|
|
3867
|
-
const parsedSelected = selectedMatch
|
|
3868
|
-
? selectedMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
|
3869
|
-
: [];
|
|
3870
|
-
const parsedExcluded = excludedMatch
|
|
3871
|
-
? excludedMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
|
3872
|
-
: [];
|
|
3873
|
-
// Parse existing overrides to merge
|
|
3874
|
-
const existingOverrides = {};
|
|
3875
|
-
const overridesBlockMatch = text.match(/overrides:([\s\S]*?)(?:\n\S|$)/);
|
|
3876
|
-
if (overridesBlockMatch) {
|
|
3877
|
-
const block = overridesBlockMatch[1];
|
|
3878
|
-
const overrideLines = block.match(/^ {2,}(\S+):\s*(\S+)/gm) ?? [];
|
|
3879
|
-
for (const line of overrideLines) {
|
|
3880
|
-
const m = line.match(/^\s+(\S+):\s*(\S+)/);
|
|
3881
|
-
if (m)
|
|
3882
|
-
existingOverrides[m[1]] = m[2];
|
|
3883
|
-
}
|
|
3884
|
-
}
|
|
3885
|
-
existingConfig = {
|
|
3886
|
-
version: versionMatch ? parseInt(versionMatch[1], 10) : 1,
|
|
3887
|
-
provider: providerMatch ? providerMatch[1] : 'claude',
|
|
3888
|
-
tier: tierMatch ? tierMatch[1] : 'quick',
|
|
3889
|
-
agents: { selected: parsedSelected, excluded: parsedExcluded },
|
|
3890
|
-
models: {
|
|
3891
|
-
preset: presetMatch ? presetMatch[1] : 'balanced',
|
|
3892
|
-
defaults: { model: 'sonnet' },
|
|
3893
|
-
overrides: existingOverrides,
|
|
3894
|
-
},
|
|
3895
|
-
agent_teams: agentTeamsMatch ? agentTeamsMatch[1] === 'true' : false,
|
|
3896
|
-
};
|
|
3897
|
-
}
|
|
3898
|
-
catch {
|
|
3899
|
-
// use defaults
|
|
3900
|
-
}
|
|
3901
|
-
}
|
|
3902
|
-
// Merge new values into config
|
|
3903
|
-
const mergedModels = existingConfig.models;
|
|
3904
|
-
if (defaultModel !== undefined) {
|
|
3905
|
-
mergedModels.defaults = { model: defaultModel };
|
|
3906
|
-
}
|
|
3907
|
-
if (overrides !== undefined) {
|
|
3908
|
-
mergedModels.overrides = overrides;
|
|
3909
|
-
}
|
|
3910
|
-
existingConfig.models = mergedModels;
|
|
3911
|
-
try {
|
|
3912
|
-
fs_1.default.mkdirSync(configDir, { recursive: true });
|
|
3913
|
-
const yaml = serializeInstallConfigYaml(existingConfig);
|
|
3914
|
-
fs_1.default.writeFileSync(configPath, yaml, 'utf-8');
|
|
3915
|
-
applyModelConfig(project.path);
|
|
3916
|
-
const agents = readAgentModels(project.path);
|
|
3917
|
-
res.json({ agents });
|
|
3918
|
-
}
|
|
3919
|
-
catch (err) {
|
|
3920
|
-
console.error('[project-router] agent-models patch error:', err);
|
|
3921
|
-
res.status(500).json({ error: `Failed to apply model config: ${err}` });
|
|
3922
|
-
}
|
|
3923
|
-
});
|
|
3924
|
-
// ─── Diagnostic export ───────────────────────────────────────────────────────
|
|
3925
|
-
router.get('/:projectId/jobs/:jobId/diagnostic', async (req, res) => {
|
|
3926
|
-
const { db } = ctx(req);
|
|
3927
|
-
const jobId = req.params.jobId;
|
|
3928
|
-
const blob = (0, db_1.getTelemetryBlob)(db, jobId);
|
|
3929
|
-
if (!blob) {
|
|
3930
|
-
res.status(404).json({ error: 'No telemetry data for this job' });
|
|
3931
|
-
return;
|
|
3932
|
-
}
|
|
3933
|
-
if (blob.state === 'expired') {
|
|
3934
|
-
res.status(410).json({ error: 'Telemetry data has been expired and is no longer available' });
|
|
3935
|
-
return;
|
|
3936
|
-
}
|
|
3937
|
-
const job = (0, db_1.getJob)(db, jobId);
|
|
3938
|
-
if (!job) {
|
|
3939
|
-
res.status(404).json({ error: 'Job not found' });
|
|
3940
|
-
return;
|
|
3941
|
-
}
|
|
3942
|
-
const summaries = (0, db_1.getTelemetrySummaries)(db, jobId);
|
|
3943
|
-
const events = (0, db_1.getJobEvents)(db, jobId);
|
|
3944
|
-
try {
|
|
3945
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
3946
|
-
const filename = `specrails-diagnostic-${jobId}-${dateStr}.zip`;
|
|
3947
|
-
res.setHeader('Content-Type', 'application/zip');
|
|
3948
|
-
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
3949
|
-
const profileRow = db
|
|
3950
|
-
.prepare(`SELECT profile_name, profile_json FROM job_profiles WHERE job_id = ?`)
|
|
3951
|
-
.get(jobId);
|
|
3952
|
-
const { homeJobSnapshotPath } = require('./plugins/paths');
|
|
3953
|
-
const pluginSnap = homeJobSnapshotPath(req.projectCtx.project.slug, jobId);
|
|
3954
|
-
await (0, telemetry_export_1.createDiagnosticZip)(res, {
|
|
3955
|
-
job,
|
|
3956
|
-
blob,
|
|
3957
|
-
summaries,
|
|
3958
|
-
events,
|
|
3959
|
-
profile: profileRow ? { name: profileRow.profile_name, json: profileRow.profile_json } : null,
|
|
3960
|
-
pluginSnapshotPath: pluginSnap,
|
|
3961
|
-
});
|
|
3962
|
-
}
|
|
3963
|
-
catch (err) {
|
|
3964
|
-
if (!res.headersSent) {
|
|
3965
|
-
console.error('[project-router] diagnostic export error:', err);
|
|
3966
|
-
res.status(500).json({ error: 'Failed to create diagnostic export' });
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
});
|
|
3970
|
-
// ─── Terminal command marks ────────────────────────────────────────────────
|
|
3971
|
-
// GET /api/projects/:projectId/terminals/:id/marks?limit=&before=
|
|
3972
|
-
router.get('/:projectId/terminals/:id/marks', (req, res) => {
|
|
3973
|
-
const projectCtx = ctx(req);
|
|
3974
|
-
const sessionId = req.params.id;
|
|
3975
|
-
const limit = parseInt(req.query.limit ?? '100', 10);
|
|
3976
|
-
const before = req.query.before ? parseInt(req.query.before, 10) : undefined;
|
|
3977
|
-
const marks = (0, terminal_marks_store_1.listMarks)(projectCtx.db, sessionId, {
|
|
3978
|
-
limit: Number.isFinite(limit) ? limit : 100,
|
|
3979
|
-
before: typeof before === 'number' && Number.isFinite(before) ? before : undefined,
|
|
3980
|
-
});
|
|
3981
|
-
res.json({ marks });
|
|
3982
|
-
});
|
|
3983
|
-
// ─── Terminal settings (per-project override layer) ────────────────────────
|
|
3984
|
-
// GET /api/projects/:projectId/terminal-settings — returns { resolved, override, desktopDefaults }
|
|
3985
|
-
router.get('/:projectId/terminal-settings', (req, res) => {
|
|
3986
|
-
const projectCtx = ctx(req);
|
|
3987
|
-
const desktopDefaults = (0, terminal_settings_1.getDesktopTerminalSettings)(registry.desktopDb);
|
|
3988
|
-
const override = (0, terminal_settings_1.getProjectOverride)(projectCtx.db);
|
|
3989
|
-
const resolved = (0, terminal_settings_1.resolveTerminalSettings)(registry.desktopDb, projectCtx.db);
|
|
3990
|
-
res.json({ resolved, override, desktopDefaults });
|
|
3991
|
-
});
|
|
3992
|
-
// PATCH /api/projects/:projectId/terminal-settings — partial update of override
|
|
3993
|
-
// (null value for a field clears that override)
|
|
3994
|
-
router.patch('/:projectId/terminal-settings', (req, res) => {
|
|
3995
|
-
const projectCtx = ctx(req);
|
|
3996
|
-
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
|
3997
|
-
res.status(400).json({ error: 'invalid body' });
|
|
3998
|
-
return;
|
|
3999
|
-
}
|
|
4000
|
-
try {
|
|
4001
|
-
(0, terminal_settings_1.patchProjectOverride)(projectCtx.db, req.body);
|
|
4002
|
-
const desktopDefaults = (0, terminal_settings_1.getDesktopTerminalSettings)(registry.desktopDb);
|
|
4003
|
-
const override = (0, terminal_settings_1.getProjectOverride)(projectCtx.db);
|
|
4004
|
-
const resolved = (0, terminal_settings_1.resolveTerminalSettings)(registry.desktopDb, projectCtx.db);
|
|
4005
|
-
res.json({ resolved, override, desktopDefaults });
|
|
4006
|
-
}
|
|
4007
|
-
catch (err) {
|
|
4008
|
-
if (err instanceof terminal_settings_1.TerminalSettingsValidationError) {
|
|
4009
|
-
res.status(400).json({ error: 'validation_failed', field: err.field, message: err.message });
|
|
4010
|
-
return;
|
|
4011
|
-
}
|
|
4012
|
-
throw err;
|
|
4013
|
-
}
|
|
4014
|
-
});
|
|
90
|
+
const ticketPath = (req) => (0, ticket_store_1.resolveTicketStoragePath)(ctx(req).project.path);
|
|
91
|
+
const deps = { router, registry, ctx, ticketPath };
|
|
92
|
+
(0, project_router_jobs_1.registerJobsRoutes)(deps);
|
|
93
|
+
(0, project_router_spending_1.registerSpendingRoutes)(deps);
|
|
94
|
+
(0, project_router_chat_1.registerChatRoutes)(deps);
|
|
95
|
+
(0, project_router_setup_1.registerSetupRoutes)(deps);
|
|
96
|
+
(0, project_router_tickets_1.registerTicketsRoutes)(deps);
|
|
97
|
+
(0, project_router_terminals_1.registerTerminalsRoutes)(deps);
|
|
98
|
+
(0, project_router_settings_1.registerSettingsRoutes)(deps);
|
|
4015
99
|
return router;
|
|
4016
100
|
}
|