specrails-desktop 2.11.4 → 2.11.6
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-B4O_62Cd.js +1 -0
- package/client/dist/assets/{AgentsPage-5UiKCp_t.js → AgentsPage-CzaYXMfF.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-BwxD58MO.js → AnalyticsPage-CAVRiHEZ.js} +1 -1
- package/client/dist/assets/{BarChart-BaJJVLw4.js → BarChart-Cxmjvzk6.js} +1 -1
- package/client/dist/assets/{CodePage-3tgS4fU2.js → CodePage-3YuHn6oB.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-BWJxqa4T.js → DesktopAnalyticsPage-dn0BKpBG.js} +1 -1
- package/client/dist/assets/{DocsDialog-DF5TF81L.js → DocsDialog-CjT3NrG-.js} +1 -1
- package/client/dist/assets/{DocsPage-DZDZPRLe.js → DocsPage-DB6K87CL.js} +1 -1
- package/client/dist/assets/{ExportDropdown-DFEuLgiK.js → ExportDropdown-DXGqkszG.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-CuAShwlJ.js → IntegrationsPage-B6qF0tqT.js} +1 -1
- package/client/dist/assets/{JobDetailPage-Bx0wRTry.js → JobDetailPage-Dhk31VAB.js} +1 -1
- package/client/dist/assets/{JobsPage-COcbVVcd.js → JobsPage-B6Qn1z5m.js} +1 -1
- package/client/dist/assets/{dist-js-Cbm7C856.js → dist-js-CQtuspP9.js} +1 -1
- package/client/dist/assets/{dist-js-CgFhhWyd.js → dist-js-SJkboL9H.js} +1 -1
- package/client/dist/assets/{index-Wgxn2zBM.js → index-vNp6-zmg.js} +7 -7
- package/client/dist/assets/{lib-BhY8Pq4z.js → lib-C7QskAbA.js} +1 -1
- package/client/dist/assets/{useProjectCache-CDaO4kHu.js → useProjectCache-D3d_Np5F.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/dist/agent-generator.js +32 -12
- package/server/dist/artifact-registry.js +24 -4
- package/server/dist/chat-manager.js +13 -0
- package/server/dist/desktop-db.js +11 -2
- package/server/dist/desktop-router.js +22 -9
- package/server/dist/explore-stdin-session.js +14 -2
- package/server/dist/hooks.js +40 -30
- package/server/dist/index.js +5 -0
- package/server/dist/jira/jira-client.js +35 -8
- package/server/dist/jira/jira-credential-store.js +11 -2
- package/server/dist/jira/jira-materializer.js +8 -2
- package/server/dist/jira/jira-sync-manager.js +11 -2
- package/server/dist/mobile/mobile-datachannel.js +19 -1
- package/server/dist/mobile/mobile-gateway.js +10 -7
- package/server/dist/mobile/mobile-signal-reconnect.js +2 -2
- package/server/dist/mobile/mobile-webrtc-peer.js +28 -18
- package/server/dist/mobile/mobile-ws.js +7 -0
- package/server/dist/path-resolver.js +38 -2
- package/server/dist/plugin-manager.js +17 -2
- package/server/dist/plugins/serena/install.js +9 -8
- package/server/dist/plugins-router.js +2 -2
- package/server/dist/profile-manager.js +30 -0
- package/server/dist/profiles-router.js +5 -1
- package/server/dist/project-registry.js +39 -0
- package/server/dist/project-router-chat.js +18 -2
- package/server/dist/project-router-jobs.js +1 -1
- package/server/dist/project-router-spending.js +7 -3
- package/server/dist/project-router-tickets.js +34 -7
- package/server/dist/project-router.js +1 -1
- package/server/dist/providers/claude-adapter.js +3 -1
- package/server/dist/providers/gemini-adapter.js +7 -2
- package/server/dist/queue-manager.js +68 -3
- package/server/dist/result-event.js +2 -1
- package/server/dist/setup-manager.js +58 -10
- package/server/dist/setup-prerequisites.js +9 -3
- package/server/dist/spawn-lifecycle.js +25 -3
- package/server/dist/spending.js +8 -4
- package/server/dist/telemetry-compactor.js +37 -4
- package/server/dist/telemetry-receiver.js +15 -0
- package/server/dist/terminal-manager.js +10 -4
- package/server/dist/transient-children.js +53 -0
- package/client/dist/assets/ActivityFeedPage-CbOQSZ4L.js +0 -1
|
@@ -124,10 +124,26 @@ function registerChatRoutes(deps) {
|
|
|
124
124
|
}
|
|
125
125
|
const { title, model } = req.body ?? {};
|
|
126
126
|
const patch = {};
|
|
127
|
-
if (title !== undefined)
|
|
127
|
+
if (title !== undefined) {
|
|
128
|
+
if (typeof title !== 'string') {
|
|
129
|
+
res.status(400).json({ error: 'title must be a string' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
128
132
|
patch.title = title;
|
|
129
|
-
|
|
133
|
+
}
|
|
134
|
+
if (model !== undefined) {
|
|
135
|
+
// Validate against the conversation's own provider so a bad value can't be
|
|
136
|
+
// persisted and break the next turn's spawn. (Mirrors the POST handler.)
|
|
137
|
+
const convProvider = conversation.provider ?? 'claude';
|
|
138
|
+
if (typeof model !== 'string' || !(0, spec_models_1.isValidModelForProvider)(model, convProvider)) {
|
|
139
|
+
res.status(400).json({
|
|
140
|
+
error: `Invalid model "${String(model)}" for provider "${convProvider}"`,
|
|
141
|
+
allowed: (0, spec_models_1.getModelsForProvider)(convProvider).map((m) => m.value),
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
130
145
|
patch.model = model;
|
|
146
|
+
}
|
|
131
147
|
(0, db_1.updateConversation)(db, req.params.id, patch);
|
|
132
148
|
const updated = (0, db_1.getConversation)(db, req.params.id);
|
|
133
149
|
res.json({ ok: true, conversation: updated });
|
|
@@ -77,7 +77,7 @@ function registerJobsRoutes(deps) {
|
|
|
77
77
|
res.json({
|
|
78
78
|
projectName: project.name,
|
|
79
79
|
projectId: project.id,
|
|
80
|
-
phases: (0, hooks_1.getPhaseStates)(),
|
|
80
|
+
phases: (0, hooks_1.getPhaseStates)(project.id),
|
|
81
81
|
busy: queueManager.getActiveJobId() !== null,
|
|
82
82
|
currentJobId: queueManager.getActiveJobId(),
|
|
83
83
|
featureFlags: {
|
|
@@ -125,9 +125,9 @@ function registerSpendingRoutes(deps) {
|
|
|
125
125
|
].join(','));
|
|
126
126
|
lines.push('');
|
|
127
127
|
lines.push('# Daily timeline');
|
|
128
|
-
lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,totalCostUsd');
|
|
128
|
+
lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,smashCostUsd,fileSummaryCostUsd,totalCostUsd');
|
|
129
129
|
for (const d of data.dailyTimeline) {
|
|
130
|
-
lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.totalCostUsd}`);
|
|
130
|
+
lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.smashCostUsd},${d.fileSummaryCostUsd},${d.totalCostUsd}`);
|
|
131
131
|
}
|
|
132
132
|
lines.push('');
|
|
133
133
|
lines.push('# By surface');
|
|
@@ -198,7 +198,11 @@ function registerSpendingRoutes(deps) {
|
|
|
198
198
|
}
|
|
199
199
|
});
|
|
200
200
|
function csvEscape(v) {
|
|
201
|
-
|
|
201
|
+
let s = v == null ? '' : String(v);
|
|
202
|
+
// CSV formula injection: a value starting with = + - @ (or tab/CR) is run as
|
|
203
|
+
// a formula by Excel/Sheets. Neutralize by prefixing a single quote.
|
|
204
|
+
if (/^[=+\-@\t\r]/.test(s))
|
|
205
|
+
s = `'${s}`;
|
|
202
206
|
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
203
207
|
? `"${s.replace(/"/g, '""')}"`
|
|
204
208
|
: s;
|
|
@@ -56,6 +56,7 @@ const provider_selection_1 = require("./provider-selection");
|
|
|
56
56
|
const ticket_store_1 = require("./ticket-store");
|
|
57
57
|
const explore_draft_title_1 = require("./explore-draft-title");
|
|
58
58
|
const cli_prompt_1 = require("./util/cli-prompt");
|
|
59
|
+
const transient_children_1 = require("./transient-children");
|
|
59
60
|
const readline_1 = require("readline");
|
|
60
61
|
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
61
62
|
const workspace_resolution_1 = require("./workspace-resolution");
|
|
@@ -307,6 +308,9 @@ function registerTicketsRoutes(deps) {
|
|
|
307
308
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
308
309
|
cwd: specGenExec.cwd,
|
|
309
310
|
});
|
|
311
|
+
// Register so removeProject()/shutdown() can tree-kill this otherwise-untracked
|
|
312
|
+
// fire-and-forget child (auto-unregisters on close/error).
|
|
313
|
+
(0, transient_children_1.trackTransientChild)(projectId, child);
|
|
310
314
|
// Watchdog: unlike ai-edit, generate-spec keeps no cancellable handle, so a
|
|
311
315
|
// hung CLI (network stall, model never emitting a terminating event) would
|
|
312
316
|
// otherwise leak this child + its readline for the app's lifetime. Cap is
|
|
@@ -696,9 +700,18 @@ function registerTicketsRoutes(deps) {
|
|
|
696
700
|
flippedInPlace = true;
|
|
697
701
|
return;
|
|
698
702
|
}
|
|
699
|
-
// Idempotent on conversationId
|
|
700
|
-
//
|
|
701
|
-
|
|
703
|
+
// Idempotent on conversationId. A ticket from this conversation may
|
|
704
|
+
// already exist as a DRAFT (update in place) OR as a COMMITTED ticket
|
|
705
|
+
// (it was already turned into a real spec) — in the latter case do NOT
|
|
706
|
+
// insert a duplicate draft sharing the same origin_conversation_id
|
|
707
|
+
// (which would also break the delete→conversation cascade). Return the
|
|
708
|
+
// committed ticket unchanged instead.
|
|
709
|
+
const anyExisting = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId);
|
|
710
|
+
if (anyExisting && anyExisting.status !== 'draft') {
|
|
711
|
+
saved = anyExisting;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const existing = anyExisting && anyExisting.status === 'draft' ? anyExisting : undefined;
|
|
702
715
|
const title = providedTitle || existing?.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
|
|
703
716
|
if (existing) {
|
|
704
717
|
existing.title = title;
|
|
@@ -829,10 +842,18 @@ function registerTicketsRoutes(deps) {
|
|
|
829
842
|
let flipTarget;
|
|
830
843
|
if (draftTicketId !== null) {
|
|
831
844
|
flipTarget = s.tickets[String(draftTicketId)];
|
|
832
|
-
if (!flipTarget
|
|
845
|
+
if (!flipTarget) {
|
|
833
846
|
explicitDraftMissing = true;
|
|
834
847
|
return;
|
|
835
848
|
}
|
|
849
|
+
if (flipTarget.status !== 'draft') {
|
|
850
|
+
// Already committed (lost-response retry / second tab / watchdog
|
|
851
|
+
// re-fire): return the committed ticket idempotently instead of a
|
|
852
|
+
// spurious 404. The draft was flipped on the first call.
|
|
853
|
+
created = flipTarget;
|
|
854
|
+
wasFlip = true;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
836
857
|
}
|
|
837
858
|
else if (conversationId) {
|
|
838
859
|
flipTarget = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
|
|
@@ -953,7 +974,13 @@ function registerTicketsRoutes(deps) {
|
|
|
953
974
|
// are checked inside runContractRefine. Claude-only today — codex
|
|
954
975
|
// contract refine isn't wired (the spawn hardcodes the `claude`
|
|
955
976
|
// binary). Skip silently on codex projects.
|
|
956
|
-
|
|
977
|
+
// Gate on the CONVERSATION's own engine, not the project's PRIMARY
|
|
978
|
+
// provider: a multi-provider project with a non-claude primary can still
|
|
979
|
+
// run an Explore on the claude engine + opt into Contract Refine.
|
|
980
|
+
const convoProvider = conversationId
|
|
981
|
+
? ((0, db_1.getConversation)(ctx(req).db, conversationId)?.provider ?? 'claude')
|
|
982
|
+
: null;
|
|
983
|
+
if (conversationId && created && convoProvider === 'claude') {
|
|
957
984
|
const createdTicketId = created.id;
|
|
958
985
|
const convoId = conversationId;
|
|
959
986
|
console.log(`[project-router] from-draft hook: scheduling refine ticket=${createdTicketId} conv=${convoId}`);
|
|
@@ -970,8 +997,8 @@ function registerTicketsRoutes(deps) {
|
|
|
970
997
|
});
|
|
971
998
|
});
|
|
972
999
|
}
|
|
973
|
-
else if (conversationId && created &&
|
|
974
|
-
console.log(`[project-router] from-draft contract refine skipped for
|
|
1000
|
+
else if (conversationId && created && convoProvider && convoProvider !== 'claude') {
|
|
1001
|
+
console.log(`[project-router] from-draft contract refine skipped for ${convoProvider} conversation (ticket #${created.id})`);
|
|
975
1002
|
}
|
|
976
1003
|
}
|
|
977
1004
|
catch (err) {
|
|
@@ -61,7 +61,7 @@ function createProjectRouter(registry) {
|
|
|
61
61
|
const hooksRouterByCtx = new WeakMap();
|
|
62
62
|
router.use('/:projectId/hooks', (req, res, next) => {
|
|
63
63
|
const projectCtx = ctx(req);
|
|
64
|
-
const hooksRouter = memoizedSubRouter(hooksRouterByCtx, projectCtx, () => (0, hooks_1.createHooksRouter)(projectCtx.broadcast, projectCtx.db, {
|
|
64
|
+
const hooksRouter = memoizedSubRouter(hooksRouterByCtx, projectCtx, () => (0, hooks_1.createHooksRouter)(projectCtx.project.id, projectCtx.broadcast, projectCtx.db, {
|
|
65
65
|
get current() { return projectCtx.queueManager.getActiveJobId(); },
|
|
66
66
|
set current(_) { },
|
|
67
67
|
}));
|
|
@@ -263,7 +263,9 @@ function extractClaudeResult(events) {
|
|
|
263
263
|
num_turns: resultPayload.num_turns,
|
|
264
264
|
model: resultPayload.model,
|
|
265
265
|
duration_ms: resultPayload.duration_ms,
|
|
266
|
-
duration_api_ms
|
|
266
|
+
// The Claude Code CLI emits `duration_api_ms`; the old `api_duration_ms`
|
|
267
|
+
// lookup always resolved undefined (persisted NULL). Read both, real first.
|
|
268
|
+
duration_api_ms: (resultPayload.duration_api_ms ?? resultPayload.api_duration_ms),
|
|
267
269
|
session_id: finalSessionId,
|
|
268
270
|
};
|
|
269
271
|
}
|
|
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
29
29
|
exports._GEMINI_MIN_VERSION = exports.geminiAdapter = void 0;
|
|
30
30
|
exports._compareSemver = compareSemver;
|
|
31
31
|
const child_process_1 = require("child_process");
|
|
32
|
+
const win_spawn_1 = require("../util/win-spawn");
|
|
32
33
|
const gemini_agent_ack_1 = require("./gemini-agent-ack");
|
|
33
34
|
const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
|
|
34
35
|
// Floor where `--output-format stream-json` + headless `--resume` are available.
|
|
@@ -180,16 +181,20 @@ function compareSemver(a, b) {
|
|
|
180
181
|
}
|
|
181
182
|
async function detectGeminiInstalled() {
|
|
182
183
|
try {
|
|
183
|
-
(0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore' });
|
|
184
|
+
(0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore', env: (0, win_spawn_1.windowsSpawnEnv)() });
|
|
184
185
|
}
|
|
185
186
|
catch {
|
|
186
187
|
return { installed: false, executable: false };
|
|
187
188
|
}
|
|
188
189
|
try {
|
|
190
|
+
// gemini is a Node CLI; its cold `--version` (cmd.exe → node → load bundle,
|
|
191
|
+
// Defender scanning) can exceed a few seconds on Windows. Give it a generous
|
|
192
|
+
// budget + SystemRoot env so it isn't wrongly reported not-executable.
|
|
189
193
|
const raw = (0, child_process_1.execSync)('gemini --version', {
|
|
190
194
|
encoding: 'utf-8',
|
|
191
195
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
192
|
-
timeout:
|
|
196
|
+
timeout: process.platform === 'win32' ? 20_000 : 8_000,
|
|
197
|
+
env: (0, win_spawn_1.windowsSpawnEnv)(),
|
|
193
198
|
}).trim();
|
|
194
199
|
const match = raw.match(/\d+\.\d+\.\d+/);
|
|
195
200
|
const version = match ? match[0] : raw;
|
|
@@ -142,6 +142,7 @@ class QueueManager {
|
|
|
142
142
|
* fallback model name stamped onto the ai_invocations row. */
|
|
143
143
|
_resolvedModel;
|
|
144
144
|
_onJobFinished;
|
|
145
|
+
_onBudgetExceeded;
|
|
145
146
|
/** Project ID used for OTEL resource attributes (Super mode only) */
|
|
146
147
|
_projectId;
|
|
147
148
|
/** Server port used to construct the OTLP endpoint URL for env injection */
|
|
@@ -194,6 +195,7 @@ class QueueManager {
|
|
|
194
195
|
this._adapter = (0, providers_1.getAdapter)(options?.provider ?? 'claude');
|
|
195
196
|
this._resolvedModel = options?.resolvedModel ?? null;
|
|
196
197
|
this._onJobFinished = options?.onJobFinished ?? null;
|
|
198
|
+
this._onBudgetExceeded = options?.onBudgetExceeded ?? null;
|
|
197
199
|
this._projectId = options?.projectId ?? null;
|
|
198
200
|
this._desktopPort = options?.desktopPort ?? 4200;
|
|
199
201
|
this._projectSlug = options?.projectSlug ?? null;
|
|
@@ -625,11 +627,51 @@ class QueueManager {
|
|
|
625
627
|
console.error(`[QueueManager] _startJob(${nextJobId}) threw before spawn: ${err?.message}`);
|
|
626
628
|
// Only release if we never established a child (else _onJobExit owns cleanup).
|
|
627
629
|
if (this._activeJobId === nextJobId && this._activeProcess === null) {
|
|
630
|
+
// Stamp the job terminal — _onJobExit never runs without a child, so the
|
|
631
|
+
// job would otherwise wedge 'running' forever and never fire
|
|
632
|
+
// onJobFinished (rail/webhook never settle) and leak its per-job maps.
|
|
633
|
+
this._failWedgedJob(nextJobId, err?.message ?? 'startup failure');
|
|
628
634
|
this._activeJobId = null;
|
|
629
635
|
this._drainQueue();
|
|
630
636
|
}
|
|
631
637
|
});
|
|
632
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* Stamp a job terminal-failed when `_startJob` threw BEFORE a child was ever
|
|
641
|
+
* established (so no `_onJobExit` will run). Mirrors _onJobExit's terminal
|
|
642
|
+
* bookkeeping: in-memory + DB status, per-job map cleanup, onJobFinished, and
|
|
643
|
+
* a queue-state broadcast. Best-effort and never throws.
|
|
644
|
+
*/
|
|
645
|
+
_failWedgedJob(jobId, reason) {
|
|
646
|
+
const job = this._jobs.get(jobId);
|
|
647
|
+
if (job && job.status === 'running') {
|
|
648
|
+
job.status = 'failed';
|
|
649
|
+
job.finishedAt = new Date().toISOString();
|
|
650
|
+
}
|
|
651
|
+
// Clear per-job selection/snapshot maps (none were consumed by a spawn).
|
|
652
|
+
this._jobExecution.delete(jobId);
|
|
653
|
+
this._snapshotRefs.delete(jobId);
|
|
654
|
+
this._jobModelSelection.delete(jobId);
|
|
655
|
+
this._jobProfileSelection.delete(jobId);
|
|
656
|
+
this._jobInteractiveSelection.delete(jobId);
|
|
657
|
+
if (this._db) {
|
|
658
|
+
try {
|
|
659
|
+
(0, db_1.finishJob)(this._db, jobId, { exit_code: -1, status: 'failed' });
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
/* DB may be closed mid-shutdown — never throw from the drain catch */
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
this._onJobFinished?.(jobId, 'failed', undefined);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
/* onJobFinished is best-effort */
|
|
670
|
+
}
|
|
671
|
+
this._persistQueueState();
|
|
672
|
+
this._broadcastQueueState();
|
|
673
|
+
console.error(`[QueueManager] job ${jobId} failed before spawn: ${reason}`);
|
|
674
|
+
}
|
|
633
675
|
/**
|
|
634
676
|
* Resolve the adapter for a job at spawn time: the per-job provider override
|
|
635
677
|
* (consumed from `_jobProviderSelection`) when present and registered, else
|
|
@@ -858,12 +900,13 @@ class QueueManager {
|
|
|
858
900
|
job.queuePosition = null;
|
|
859
901
|
this._recomputePositions();
|
|
860
902
|
this._persistJob(job);
|
|
903
|
+
const phaseScopeId = this._projectId ?? this._cwd ?? 'default';
|
|
861
904
|
const commandPhases = this._phasesForCommand(job.command);
|
|
862
905
|
if (commandPhases.length > 0) {
|
|
863
|
-
(0, hooks_1.setActivePhases)(commandPhases, this._broadcast);
|
|
906
|
+
(0, hooks_1.setActivePhases)(phaseScopeId, commandPhases, this._broadcast);
|
|
864
907
|
}
|
|
865
908
|
else {
|
|
866
|
-
(0, hooks_1.resetPhases)(this._broadcast);
|
|
909
|
+
(0, hooks_1.resetPhases)(phaseScopeId, this._broadcast);
|
|
867
910
|
}
|
|
868
911
|
const commandToRun = job.command.trim();
|
|
869
912
|
// Build supplementary context (output chaining + headless mode) that goes
|
|
@@ -1177,6 +1220,14 @@ class QueueManager {
|
|
|
1177
1220
|
});
|
|
1178
1221
|
this._activeProcess = child;
|
|
1179
1222
|
this._activeJobId = jobId;
|
|
1223
|
+
// Honour a cancel that arrived during the async pre-spawn window (it recorded
|
|
1224
|
+
// intent in _cancelingJobs via _kill but found no child yet). SIGTERM the
|
|
1225
|
+
// just-spawned child now; the close handler wired below fires _onJobExit,
|
|
1226
|
+
// which reads _cancelingJobs and stamps 'canceled'. No early-return — the
|
|
1227
|
+
// exit handler MUST still be wired so the job settles.
|
|
1228
|
+
if (this._cancelingJobs.has(jobId)) {
|
|
1229
|
+
this._kill(jobId);
|
|
1230
|
+
}
|
|
1180
1231
|
// Without this listener, an ENOENT (e.g. claude not on PATH) propagates
|
|
1181
1232
|
// as an unhandled 'error' event and crashes the entire app. Node still
|
|
1182
1233
|
// emits 'close' afterwards, so the existing close handler fails the job
|
|
@@ -1529,6 +1580,10 @@ class QueueManager {
|
|
|
1529
1580
|
this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
|
|
1530
1581
|
}
|
|
1531
1582
|
this._broadcast({ type: 'daily_budget_exceeded', projectId: '', dailySpend, budget: dailyBudget, queuePaused: true });
|
|
1583
|
+
try {
|
|
1584
|
+
this._onBudgetExceeded?.('daily_budget_exceeded', { dailySpend, budget: dailyBudget, queuePaused: true });
|
|
1585
|
+
}
|
|
1586
|
+
catch { /* webhook delivery is best-effort */ }
|
|
1532
1587
|
}
|
|
1533
1588
|
}
|
|
1534
1589
|
}
|
|
@@ -1542,6 +1597,10 @@ class QueueManager {
|
|
|
1542
1597
|
this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
|
|
1543
1598
|
}
|
|
1544
1599
|
this._broadcast({ type: 'desktop_daily_budget_exceeded', projectId: '', desktopDailySpend: desktopTotalSpend, desktopBudget, queuePaused: true });
|
|
1600
|
+
try {
|
|
1601
|
+
this._onBudgetExceeded?.('desktop_daily_budget_exceeded', { desktopDailySpend: desktopTotalSpend, desktopBudget, queuePaused: true });
|
|
1602
|
+
}
|
|
1603
|
+
catch { /* webhook delivery is best-effort */ }
|
|
1545
1604
|
}
|
|
1546
1605
|
}
|
|
1547
1606
|
}
|
|
@@ -1626,6 +1685,13 @@ class QueueManager {
|
|
|
1626
1685
|
this._kill(jobId);
|
|
1627
1686
|
}
|
|
1628
1687
|
_kill(jobId) {
|
|
1688
|
+
// Record the cancel intent UP FRONT — even when no child exists yet. A
|
|
1689
|
+
// cancel arriving during the async pre-spawn window (plugin verify / profile
|
|
1690
|
+
// snapshot) would otherwise early-return below before recording intent, the
|
|
1691
|
+
// child would later spawn unobserved, and _onJobExit would stamp 'completed'.
|
|
1692
|
+
// With the intent recorded, the post-spawn check in _startJob SIGTERMs the
|
|
1693
|
+
// child once it exists, and _onJobExit reads it to stamp 'canceled'.
|
|
1694
|
+
this._cancelingJobs.add(jobId);
|
|
1629
1695
|
if (!this._activeProcess || !this._activeProcess.pid)
|
|
1630
1696
|
return;
|
|
1631
1697
|
this._clearZombieTimer();
|
|
@@ -1636,7 +1702,6 @@ class QueueManager {
|
|
|
1636
1702
|
clearTimeout(this._killTimer);
|
|
1637
1703
|
this._killTimer = null;
|
|
1638
1704
|
}
|
|
1639
|
-
this._cancelingJobs.add(jobId);
|
|
1640
1705
|
(0, tree_kill_1.default)(this._activeProcess.pid, 'SIGTERM');
|
|
1641
1706
|
const pid = this._activeProcess.pid;
|
|
1642
1707
|
this._killTimer = setTimeout(() => {
|
|
@@ -80,7 +80,8 @@ function normaliseResultEvent(event, provider = 'claude') {
|
|
|
80
80
|
num_turns: event.num_turns,
|
|
81
81
|
model: event.model,
|
|
82
82
|
duration_ms: event.duration_ms,
|
|
83
|
-
duration_api_ms
|
|
83
|
+
// CLI emits `duration_api_ms`; keep the legacy alias as a fallback.
|
|
84
|
+
duration_api_ms: (event.duration_api_ms ?? event.api_duration_ms),
|
|
84
85
|
session_id: event.session_id,
|
|
85
86
|
};
|
|
86
87
|
}
|
|
@@ -26,6 +26,7 @@ const bundled_core_1 = require("./bundled-core");
|
|
|
26
26
|
const bundled_openspec_1 = require("./bundled-openspec");
|
|
27
27
|
const path_resolver_1 = require("./path-resolver");
|
|
28
28
|
const framework_manager_1 = require("./framework-manager");
|
|
29
|
+
const workspace_resolution_1 = require("./workspace-resolution");
|
|
29
30
|
/**
|
|
30
31
|
* specrails-core's installer (Node-native from v4.2.0 onward, bash
|
|
31
32
|
* prior) always scaffolds into `.claude/` regardless of which AI
|
|
@@ -668,6 +669,14 @@ class SetupManager {
|
|
|
668
669
|
_checkpoints;
|
|
669
670
|
// Track checkpoint start times
|
|
670
671
|
_checkpointStart;
|
|
672
|
+
// Project ids whose install/enrich was explicitly aborted — the close handler
|
|
673
|
+
// checks this to suppress a spurious setup_error / double onSetupDone when the
|
|
674
|
+
// SIGTERM'd child closes with a null exit code.
|
|
675
|
+
_abortedProjects = new Set();
|
|
676
|
+
// projectId → registry slug (captured at startInstall) so post-install
|
|
677
|
+
// validation/summary/checkpoint reads resolve the RELOCATED workspace artifact
|
|
678
|
+
// root rather than the (pristine) repo path.
|
|
679
|
+
_projectSlugs = new Map();
|
|
671
680
|
// Ring buffer for install log lines — allows clients to recover log on reconnect
|
|
672
681
|
_installLogBuffer;
|
|
673
682
|
// Track each project's chosen AI provider for binary selection
|
|
@@ -692,6 +701,9 @@ class SetupManager {
|
|
|
692
701
|
}
|
|
693
702
|
// ─── Full Install: TUI installer (npx specrails-core) ────────────────────────
|
|
694
703
|
startInstall(projectId, projectPath, projectSlug, provider) {
|
|
704
|
+
this._abortedProjects.delete(projectId); // fresh run — clear any prior abort flag
|
|
705
|
+
if (projectSlug)
|
|
706
|
+
this._projectSlugs.set(projectId, projectSlug);
|
|
695
707
|
if (this._installProcesses.has(projectId)) {
|
|
696
708
|
console.warn(`[SetupManager] install already running for ${projectId}`);
|
|
697
709
|
return;
|
|
@@ -874,8 +886,15 @@ class SetupManager {
|
|
|
874
886
|
catch { /* non-fatal */ }
|
|
875
887
|
}
|
|
876
888
|
this._installProcesses.delete(projectId);
|
|
889
|
+
// Explicit abort already tore down + fired onSetupDone — don't surface a
|
|
890
|
+
// spurious setup_error for the SIGTERM'd child's null exit.
|
|
891
|
+
if (this._abortedProjects.has(projectId))
|
|
892
|
+
return;
|
|
877
893
|
if (code === 0) {
|
|
878
|
-
|
|
894
|
+
// Relocated installs write artifacts to the workspace, not the repo —
|
|
895
|
+
// validate/sweep/summarize against the resolved artifact root.
|
|
896
|
+
const artifactRoot = this._artifactRoot(projectId, projectPath);
|
|
897
|
+
const validation = validateInstalledCore(artifactRoot);
|
|
879
898
|
if (!validation.ok) {
|
|
880
899
|
this._broadcast({
|
|
881
900
|
type: 'setup_error',
|
|
@@ -886,8 +905,8 @@ class SetupManager {
|
|
|
886
905
|
}
|
|
887
906
|
this._advanceCheckpoint(projectId, 'base_install');
|
|
888
907
|
this._completeCheckpoint(projectId, 'base_install');
|
|
889
|
-
const legacySrRemoved = sweepLegacySrCommands(
|
|
890
|
-
const summary = { ...computeSummary(
|
|
908
|
+
const legacySrRemoved = sweepLegacySrCommands(artifactRoot);
|
|
909
|
+
const summary = { ...computeSummary(artifactRoot, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
|
|
891
910
|
this._broadcast({
|
|
892
911
|
type: 'setup_install_done',
|
|
893
912
|
projectId,
|
|
@@ -909,6 +928,7 @@ class SetupManager {
|
|
|
909
928
|
}
|
|
910
929
|
// ─── Enrich: claude -p "/specrails:enrich --from-config" ────────────────────
|
|
911
930
|
startEnrich(projectId, projectPath, provider, projectName) {
|
|
931
|
+
this._abortedProjects.delete(projectId); // fresh run — clear any prior abort flag
|
|
912
932
|
if (this._setupProcesses.has(projectId)) {
|
|
913
933
|
console.warn(`[SetupManager] enrich already running for ${projectId}`);
|
|
914
934
|
return;
|
|
@@ -1132,21 +1152,28 @@ class SetupManager {
|
|
|
1132
1152
|
child.on('close', (code) => {
|
|
1133
1153
|
this._setupProcesses.delete(projectId);
|
|
1134
1154
|
this._stopFilesystemPoll(projectId);
|
|
1155
|
+
// Explicit abort already tore down + fired onSetupDone — suppress the
|
|
1156
|
+
// failure branch (spurious error + a second onSetupDone) here.
|
|
1157
|
+
if (this._abortedProjects.has(projectId))
|
|
1158
|
+
return;
|
|
1135
1159
|
// Final filesystem sync
|
|
1136
1160
|
this._syncFilesystemCheckpoints(projectId, projectPath);
|
|
1137
1161
|
if (code === 0) {
|
|
1138
1162
|
// Sync filesystem checkpoints
|
|
1139
1163
|
this._syncFilesystemCheckpoints(projectId, projectPath);
|
|
1164
|
+
// Relocated installs write artifacts to the workspace, not the repo —
|
|
1165
|
+
// resolve the has-agents/has-commands gate + summary against it.
|
|
1166
|
+
const artifactRoot = this._artifactRoot(projectId, projectPath);
|
|
1140
1167
|
// Check if setup is truly complete — real artifacts must exist
|
|
1141
|
-
const hasAgents = (0, fs_1.existsSync)((0, path_1.join)(
|
|
1142
|
-
hasFiles((0, path_1.join)(
|
|
1143
|
-
const hasCommands = (((0, fs_1.existsSync)((0, path_1.join)(
|
|
1144
|
-
((0, fs_1.existsSync)((0, path_1.join)(
|
|
1168
|
+
const hasAgents = (0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'agents')) &&
|
|
1169
|
+
hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'agents'), /^sr-.*\.md$/);
|
|
1170
|
+
const hasCommands = (((0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'sr')) && hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'sr'), /\.md$/)) ||
|
|
1171
|
+
((0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'specrails')) && hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'specrails'), /\.md$/)));
|
|
1145
1172
|
const isComplete = hasAgents && hasCommands;
|
|
1146
1173
|
if (isComplete) {
|
|
1147
|
-
const legacySrRemoved = sweepLegacySrCommands(
|
|
1174
|
+
const legacySrRemoved = sweepLegacySrCommands(artifactRoot);
|
|
1148
1175
|
const tier = this._projectTiers.get(projectId) ?? 'full';
|
|
1149
|
-
const summary = { ...computeSummary(
|
|
1176
|
+
const summary = { ...computeSummary(artifactRoot, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
|
|
1150
1177
|
this._onSetupDone?.(projectId);
|
|
1151
1178
|
this._broadcast({
|
|
1152
1179
|
type: 'setup_complete',
|
|
@@ -1229,11 +1256,27 @@ class SetupManager {
|
|
|
1229
1256
|
checkpoint.duration_ms = duration_ms;
|
|
1230
1257
|
this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'done', duration_ms });
|
|
1231
1258
|
}
|
|
1259
|
+
/** Resolve the artifact root for post-install reads. For a RELOCATED project
|
|
1260
|
+
* (registry entry + populated workspace) the `.specrails`/provider dirs live
|
|
1261
|
+
* in the workspace, NOT the pristine repo path — `resolveProjectExecution`
|
|
1262
|
+
* returns that cwd. Falls back to the repo path (legacy) when no slug is
|
|
1263
|
+
* known or the project isn't relocated. */
|
|
1264
|
+
_artifactRoot(projectId, projectPath) {
|
|
1265
|
+
const slug = this._projectSlugs.get(projectId);
|
|
1266
|
+
if (!slug)
|
|
1267
|
+
return projectPath;
|
|
1268
|
+
try {
|
|
1269
|
+
return (0, workspace_resolution_1.resolveProjectExecution)({ slug, path: projectPath }).cwd;
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
return projectPath;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1232
1275
|
_syncFilesystemCheckpoints(projectId, projectPath) {
|
|
1233
1276
|
const statuses = this._checkpoints.get(projectId);
|
|
1234
1277
|
if (!statuses)
|
|
1235
1278
|
return;
|
|
1236
|
-
const fsChecks = checkFilesystem(projectPath);
|
|
1279
|
+
const fsChecks = checkFilesystem(this._artifactRoot(projectId, projectPath));
|
|
1237
1280
|
for (const [key, exists] of Object.entries(fsChecks)) {
|
|
1238
1281
|
if (!exists)
|
|
1239
1282
|
continue;
|
|
@@ -1268,9 +1311,14 @@ class SetupManager {
|
|
|
1268
1311
|
}
|
|
1269
1312
|
// ─── Abort ────────────────────────────────────────────────────────────────────
|
|
1270
1313
|
abort(projectId) {
|
|
1314
|
+
// Mark aborted so the still-registered close handlers suppress their failure
|
|
1315
|
+
// branch (spurious setup_error + a second onSetupDone) when the SIGTERM'd
|
|
1316
|
+
// child closes with a null code. Cleared on the next startInstall/startEnrich.
|
|
1317
|
+
this._abortedProjects.add(projectId);
|
|
1271
1318
|
this._stopFilesystemPoll(projectId);
|
|
1272
1319
|
this._projectProviders.delete(projectId);
|
|
1273
1320
|
this._projectTiers.delete(projectId);
|
|
1321
|
+
this._projectSlugs.delete(projectId);
|
|
1274
1322
|
this._onSetupDone?.(projectId);
|
|
1275
1323
|
const installChild = this._installProcesses.get(projectId);
|
|
1276
1324
|
if (installChild?.pid) {
|
|
@@ -28,13 +28,19 @@ const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
|
|
|
28
28
|
* inherited a stripped env) — without it every bundled probe failed with a bogus
|
|
29
29
|
* "bundle corrupted — reinstall the app".
|
|
30
30
|
*/
|
|
31
|
-
function runVersionSpawn(cmd, args) {
|
|
32
|
-
const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout:
|
|
31
|
+
function runVersionSpawn(cmd, args, timeoutMs = 5_000) {
|
|
32
|
+
const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: timeoutMs };
|
|
33
33
|
if (process.platform === 'win32') {
|
|
34
34
|
return cross_spawn_1.default.sync(cmd, args, opts);
|
|
35
35
|
}
|
|
36
36
|
return (0, child_process_1.spawnSync)(cmd, args, opts);
|
|
37
37
|
}
|
|
38
|
+
// A bundled/system CLI's `--version` cold start can be slow on Windows: a npm
|
|
39
|
+
// `.cmd` shim goes cmd.exe → node.exe → load the CLI bundle, while Defender
|
|
40
|
+
// scans it. The Node-based gemini CLI in particular exceeded the old 5s cap and
|
|
41
|
+
// was wrongly reported not-executable. Give version probes a generous Windows
|
|
42
|
+
// budget; the `where`/`which` LOCATE probe stays fast (5s) since it's a builtin.
|
|
43
|
+
const VERSION_PROBE_TIMEOUT_MS = process.platform === 'win32' ? 20_000 : 8_000;
|
|
38
44
|
exports.MIN_VERSIONS = {
|
|
39
45
|
node: '18.0.0',
|
|
40
46
|
npm: '9.0.0',
|
|
@@ -53,7 +59,7 @@ function locateCommand(command) {
|
|
|
53
59
|
}
|
|
54
60
|
/** Run `<bin> <args>` and interpret the result as a `--version` probe. */
|
|
55
61
|
function runProbe(bin, args) {
|
|
56
|
-
const result = runVersionSpawn(bin, args);
|
|
62
|
+
const result = runVersionSpawn(bin, args, VERSION_PROBE_TIMEOUT_MS);
|
|
57
63
|
if (result.error) {
|
|
58
64
|
const err = result.error;
|
|
59
65
|
return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
// manages crash-respawn / idle-kill) stay with the caller via hooks + the
|
|
12
12
|
// returned result. Adopting it is behaviour-preserving: the caller keeps every
|
|
13
13
|
// post-close decision it had, just without re-implementing the plumbing.
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
14
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
18
|
exports.runAiCliInvocation = runAiCliInvocation;
|
|
16
19
|
const node_readline_1 = require("node:readline");
|
|
20
|
+
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
17
21
|
const cli_prompt_1 = require("./util/cli-prompt");
|
|
18
22
|
const STDERR_TAIL_CAP = 64 * 1024;
|
|
19
23
|
/**
|
|
@@ -61,10 +65,28 @@ function runAiCliInvocation(hooks) {
|
|
|
61
65
|
if (hooks.timeoutMs != null) {
|
|
62
66
|
timer = setTimeout(() => {
|
|
63
67
|
hooks.onTimeout?.();
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
// treeKill the whole subtree (a bare child.kill leaves grandchildren),
|
|
69
|
+
// then escalate to SIGKILL after a grace window if the child ignores
|
|
70
|
+
// SIGTERM — otherwise a hung, signal-swallowing CLI is orphaned for the
|
|
71
|
+
// host's lifetime. (Mirrors QueueManager._kill.)
|
|
72
|
+
if (child.pid) {
|
|
73
|
+
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
74
|
+
const pid = child.pid;
|
|
75
|
+
const killTimer = setTimeout(() => {
|
|
76
|
+
try {
|
|
77
|
+
(0, tree_kill_1.default)(pid, 'SIGKILL', () => { });
|
|
78
|
+
}
|
|
79
|
+
catch { /* gone */ }
|
|
80
|
+
}, 2000);
|
|
81
|
+
killTimer.unref?.();
|
|
82
|
+
child.once('close', () => clearTimeout(killTimer));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
child.kill('SIGTERM');
|
|
87
|
+
}
|
|
88
|
+
catch { /* already gone */ }
|
|
66
89
|
}
|
|
67
|
-
catch { /* already gone */ }
|
|
68
90
|
settle({ code: null, timedOut: true, spawnFailed: false, child });
|
|
69
91
|
}, hooks.timeoutMs);
|
|
70
92
|
}
|
package/server/dist/spending.js
CHANGED
|
@@ -7,10 +7,14 @@ exports.parseSpendingFilters = parseSpendingFilters;
|
|
|
7
7
|
const ALL_SURFACES = ['job', 'quick-spec', 'explore-spec', 'ai-edit', 'smash', 'file-summary'];
|
|
8
8
|
function resolveRange(filters, now = new Date()) {
|
|
9
9
|
const period = filters.period ?? '30d';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Only take the custom branch when BOTH dates parse — otherwise
|
|
11
|
+
// `new Date(NaN).toISOString()` throws a RangeError (surfaced as a 500).
|
|
12
|
+
// Unparseable custom dates fall through to the default 30d range below.
|
|
13
|
+
const customFromMs = filters.from ? new Date(filters.from).getTime() : NaN;
|
|
14
|
+
const customToMs = filters.to ? new Date(filters.to).getTime() : NaN;
|
|
15
|
+
if (period === 'custom' && !Number.isNaN(customFromMs) && !Number.isNaN(customToMs)) {
|
|
16
|
+
const fromMs = customFromMs;
|
|
17
|
+
const span = customToMs - fromMs;
|
|
14
18
|
return {
|
|
15
19
|
from: filters.from,
|
|
16
20
|
to: filters.to,
|