specrails-desktop 2.28.0 → 2.29.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/cli/dist/specrails-desktop.js +13 -3
- package/client/dist/assets/{ActivityFeedPage-DcMmDAP3.js → ActivityFeedPage-Be-6L0Ld.js} +1 -1
- package/client/dist/assets/AgentBrowserCapture-CpZ-dd8O.js +1 -0
- package/client/dist/assets/{AgentIntegrationsModal-DkqooCFn.js → AgentIntegrationsModal-DO1pN_Ez.js} +1 -1
- package/client/dist/assets/{AgentModeCodePane-CM77ljte.js → AgentModeCodePane-DLltTOqp.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-DbZzogbi.js → AgentModeJobsPane-Dp5YvmW0.js} +1 -1
- package/client/dist/assets/AgentsPage-ehHuVUds.js +86 -0
- package/client/dist/assets/{AnalyticsPage-B4qflNxi.js → AnalyticsPage-C4Ik95FX.js} +1 -1
- package/client/dist/assets/{BarChart-Ckstq9fl.js → BarChart-DVZgrhJd.js} +1 -1
- package/client/dist/assets/CodePage-D4OCMfxL.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-DbgMa8OO.js → DesktopAnalyticsPage-2Xf-52x2.js} +1 -1
- package/client/dist/assets/{DocsDialog-BQc9Kwev.js → DocsDialog-CHihLjWd.js} +2 -2
- package/client/dist/assets/{DocsPage-OsV44ECy.js → DocsPage-1vEk7XbH.js} +2 -2
- package/client/dist/assets/{ExportDropdown-a43XHO3Y.js → ExportDropdown-DLHFQxVn.js} +1 -1
- package/client/dist/assets/IntegrationsPage-CCb2YuyJ.js +1 -0
- package/client/dist/assets/InteractiveJobComposer-CxJJEnjw.js +19 -0
- package/client/dist/assets/JobDetailModal-CJI7qRkm.js +1 -0
- package/client/dist/assets/JobDetailPage-DwA9bbwx.js +1 -0
- package/client/dist/assets/JobsPage-BDIyFfd5.js +1 -0
- package/client/dist/assets/LoopBuilderPage-CpBaW59t.js +7 -0
- package/client/dist/assets/{LoopPreviewModal-B2wmifF6.js → LoopPreviewModal-CwpKcv0Y.js} +1 -1
- package/client/dist/assets/LoopsPage-BO0Pa8oz.js +1 -0
- package/client/dist/assets/PluginsPage-BBGTBpGh.js +2 -0
- package/client/dist/assets/ProjectSettingsDialog-M9zGQo0l.js +1 -0
- package/client/dist/assets/{TemplatePreviewModal-CERf1IYB.js → TemplatePreviewModal-eBI0zL5P.js} +1 -1
- package/client/dist/assets/{agent-MaTxBlg5.js → agent-BEXYxDbs.js} +1 -1
- package/client/dist/assets/{agent-ostmMYNz2.js → agent-BoXSzaBk2.js} +1 -1
- package/client/dist/assets/{agent-BRJCESkc2.js → agent-C2-rROL52.js} +1 -1
- package/client/dist/assets/{agent-D4JAuEGN2.js → agent-CeRTdGT12.js} +1 -1
- package/client/dist/assets/{agent-CxnRzL6Z.js → agent-DZ8zTrQT.js} +1 -1
- package/client/dist/assets/{agent-DyzUH89R.js → agent-DqyzT33X.js} +1 -1
- package/client/dist/assets/{agent-DNQlI8EN.js → agent-fQI_6tsA.js} +1 -1
- package/client/dist/assets/{agent-BuYz6fJd.js → agent-tJdAobaM.js} +1 -1
- package/client/dist/assets/{brain-BPbhM1Jx.js → brain-DecvjjPU.js} +1 -1
- package/client/dist/assets/{dist-js-Dh-9lbLD.js → dist-js-BTAcxhPb.js} +1 -1
- package/client/dist/assets/{dist-js-Byjcju1W.js → dist-js-UjXbEWhN.js} +1 -1
- package/client/dist/assets/index-BVbgKXUu.css +2 -0
- package/client/dist/assets/index-OBDI7bHm.js +158 -0
- package/client/dist/assets/{lib-yKyTNJ1c.js → lib-C5ZxrPSR.js} +1 -1
- package/client/dist/assets/{upload-Qh1NE3xi.js → upload-lgjKJgrl.js} +1 -1
- package/client/dist/assets/{value-BMPeyInx.js → value-Bc0_jlJA.js} +1 -1
- package/client/dist/index.html +3 -3
- package/package.json +2 -2
- package/server/dist/agent-operator-prompt.js +33 -1
- package/server/dist/contract-refine-runner.js +30 -9
- package/server/dist/explore-cwd-manager.js +6 -3
- package/server/dist/headroom-manager.js +78 -13
- package/server/dist/loop-factory.js +15 -8
- package/server/dist/loop-graph.js +7 -0
- package/server/dist/loop-run-manager.js +115 -13
- package/server/dist/loop-runs-store.js +27 -8
- package/server/dist/loop-templates.js +10 -2
- package/server/dist/mcp/tools/jobs.js +3 -3
- package/server/dist/mcp/tools/rails.js +2 -1
- package/server/dist/project-registry.js +2 -0
- package/server/dist/project-router-jobs.js +15 -11
- package/server/dist/rails-router.js +1 -1
- package/server/dist/terminal-shell-integration.js +4 -1
- package/server/dist/worktree-manager.js +52 -4
- package/client/dist/assets/AgentBrowserCapture-CZ3oc1UL.js +0 -1
- package/client/dist/assets/AgentsPage-BZIml7eL.js +0 -86
- package/client/dist/assets/CodePage-DFZXLbI1.js +0 -2
- package/client/dist/assets/IntegrationsPage-C3E8hZhB.js +0 -1
- package/client/dist/assets/InteractiveJobComposer-zj8SrOAl.js +0 -19
- package/client/dist/assets/JobDetailModal-Cm6ZeVoh.js +0 -1
- package/client/dist/assets/JobDetailPage-DN67LsdX.js +0 -1
- package/client/dist/assets/JobsPage-DF9wKGbw.js +0 -1
- package/client/dist/assets/LoopBuilderPage-RJ6GGjli.js +0 -7
- package/client/dist/assets/LoopsPage-DdlvH-dJ.js +0 -1
- package/client/dist/assets/PluginsPage-9DI0a6jI.js +0 -2
- package/client/dist/assets/ProjectSettingsDialog-dgy5hqPd.js +0 -1
- package/client/dist/assets/index-89SuhD4Y.js +0 -158
- package/client/dist/assets/index-DOGzWJhO.css +0 -2
|
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.HeadroomManager = void 0;
|
|
6
|
+
exports.HeadroomManager = exports.HEADROOM_MANAGED_PYTHON_VERSION = void 0;
|
|
7
|
+
exports.getHeadroomManagedInstallPlan = getHeadroomManagedInstallPlan;
|
|
7
8
|
const child_process_1 = require("child_process");
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
9
10
|
const os_1 = __importDefault(require("os"));
|
|
@@ -12,11 +13,15 @@ const desktop_db_1 = require("./desktop-db");
|
|
|
12
13
|
const headroom_routing_1 = require("./headroom-routing");
|
|
13
14
|
const STATE_KEY = 'plugins.headroom.state';
|
|
14
15
|
const DEFAULT_PORT = 8787;
|
|
16
|
+
exports.HEADROOM_MANAGED_PYTHON_VERSION = '3.12';
|
|
15
17
|
function nowIso() {
|
|
16
18
|
return new Date().toISOString();
|
|
17
19
|
}
|
|
20
|
+
function specrailsHome() {
|
|
21
|
+
return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
|
|
22
|
+
}
|
|
18
23
|
function toolRoot() {
|
|
19
|
-
return path_1.default.join(
|
|
24
|
+
return path_1.default.join(specrailsHome(), '.specrails', 'tools');
|
|
20
25
|
}
|
|
21
26
|
function uvToolDir() {
|
|
22
27
|
return path_1.default.join(toolRoot(), 'uv', 'tools');
|
|
@@ -24,6 +29,15 @@ function uvToolDir() {
|
|
|
24
29
|
function uvCacheDir() {
|
|
25
30
|
return path_1.default.join(toolRoot(), 'uv', 'cache');
|
|
26
31
|
}
|
|
32
|
+
function uvPythonInstallDir() {
|
|
33
|
+
return path_1.default.join(toolRoot(), 'uv', 'python');
|
|
34
|
+
}
|
|
35
|
+
function uvPythonCacheDir() {
|
|
36
|
+
return path_1.default.join(toolRoot(), 'uv', 'python-cache');
|
|
37
|
+
}
|
|
38
|
+
function uvPythonBinDir() {
|
|
39
|
+
return path_1.default.join(toolRoot(), 'uv', 'python-bin');
|
|
40
|
+
}
|
|
27
41
|
function uvBinDir() {
|
|
28
42
|
return path_1.default.join(toolRoot(), 'bin');
|
|
29
43
|
}
|
|
@@ -152,6 +166,40 @@ function classifyInstallFailure(output, command) {
|
|
|
152
166
|
}
|
|
153
167
|
return issue('unknown', output, command);
|
|
154
168
|
}
|
|
169
|
+
function getHeadroomManagedInstallPlan() {
|
|
170
|
+
return {
|
|
171
|
+
pythonVersion: exports.HEADROOM_MANAGED_PYTHON_VERSION,
|
|
172
|
+
pythonInstallArgs: [
|
|
173
|
+
'python',
|
|
174
|
+
'install',
|
|
175
|
+
exports.HEADROOM_MANAGED_PYTHON_VERSION,
|
|
176
|
+
'--managed-python',
|
|
177
|
+
],
|
|
178
|
+
toolInstallArgs: [
|
|
179
|
+
'tool',
|
|
180
|
+
'install',
|
|
181
|
+
'--python',
|
|
182
|
+
exports.HEADROOM_MANAGED_PYTHON_VERSION,
|
|
183
|
+
'--managed-python',
|
|
184
|
+
'--force',
|
|
185
|
+
'headroom-ai[all]',
|
|
186
|
+
],
|
|
187
|
+
env: {
|
|
188
|
+
UV_TOOL_DIR: uvToolDir(),
|
|
189
|
+
UV_TOOL_BIN_DIR: uvBinDir(),
|
|
190
|
+
UV_CACHE_DIR: uvCacheDir(),
|
|
191
|
+
UV_PYTHON: exports.HEADROOM_MANAGED_PYTHON_VERSION,
|
|
192
|
+
UV_MANAGED_PYTHON: 'true',
|
|
193
|
+
UV_PYTHON_DOWNLOADS: 'automatic',
|
|
194
|
+
UV_PYTHON_INSTALL_DIR: uvPythonInstallDir(),
|
|
195
|
+
UV_PYTHON_CACHE_DIR: uvPythonCacheDir(),
|
|
196
|
+
UV_PYTHON_BIN_DIR: uvPythonBinDir(),
|
|
197
|
+
UV_PYTHON_NO_REGISTRY: 'true',
|
|
198
|
+
UV_PYTHON_INSTALL_REGISTRY: 'false',
|
|
199
|
+
UV_NO_PROGRESS: '1',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
155
203
|
function classifyProxyFailure(output, command) {
|
|
156
204
|
const lower = output.toLowerCase();
|
|
157
205
|
if (lower.includes('address already in use') ||
|
|
@@ -334,18 +382,32 @@ class HeadroomManager {
|
|
|
334
382
|
fs_1.default.mkdirSync(uvToolDir(), { recursive: true });
|
|
335
383
|
fs_1.default.mkdirSync(uvCacheDir(), { recursive: true });
|
|
336
384
|
fs_1.default.mkdirSync(uvBinDir(), { recursive: true });
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
385
|
+
fs_1.default.mkdirSync(uvPythonInstallDir(), { recursive: true });
|
|
386
|
+
fs_1.default.mkdirSync(uvPythonCacheDir(), { recursive: true });
|
|
387
|
+
fs_1.default.mkdirSync(uvPythonBinDir(), { recursive: true });
|
|
388
|
+
const plan = getHeadroomManagedInstallPlan();
|
|
389
|
+
const pythonCommand = `${uv} ${plan.pythonInstallArgs.join(' ')}`;
|
|
390
|
+
const installCommand = `${uv} ${plan.toolInstallArgs.join(' ')}`;
|
|
391
|
+
const logs = [
|
|
392
|
+
`Ensuring Headroom Python runtime (CPython ${plan.pythonVersion})`,
|
|
393
|
+
`Running ${pythonCommand}`,
|
|
394
|
+
];
|
|
340
395
|
this.emit('installing', logs[0]);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
396
|
+
this.emit('installing', logs[1]);
|
|
397
|
+
const pythonResult = await this.runCommand(uv, plan.pythonInstallArgs, plan.env, logs);
|
|
398
|
+
if (pythonResult.code !== 0) {
|
|
399
|
+
const failure = classifyInstallFailure(logs.join('\n'), pythonCommand);
|
|
400
|
+
this.updatePersisted({ lastIssue: failure });
|
|
401
|
+
this.emit('failed', failure.title);
|
|
402
|
+
return { ok: false, state: this.getState(), issue: failure, logs };
|
|
403
|
+
}
|
|
404
|
+
logs.push(`Installing Headroom with managed CPython ${plan.pythonVersion}`);
|
|
405
|
+
logs.push(`Running ${installCommand}`);
|
|
406
|
+
this.emit('installing', logs[logs.length - 2]);
|
|
407
|
+
this.emit('installing', logs[logs.length - 1]);
|
|
408
|
+
const result = await this.runCommand(uv, plan.toolInstallArgs, plan.env, logs);
|
|
347
409
|
if (result.code !== 0) {
|
|
348
|
-
const failure = classifyInstallFailure(logs.join('\n'),
|
|
410
|
+
const failure = classifyInstallFailure(logs.join('\n'), installCommand);
|
|
349
411
|
this.updatePersisted({ lastIssue: failure });
|
|
350
412
|
this.emit('failed', failure.title);
|
|
351
413
|
return { ok: false, state: this.getState(), issue: failure, logs };
|
|
@@ -353,7 +415,7 @@ class HeadroomManager {
|
|
|
353
415
|
const exe = this.resolveHeadroomInstall(null, false)?.path ?? null;
|
|
354
416
|
const version = exe ? this.readHeadroomVersion(exe) : null;
|
|
355
417
|
if (!exe || !version) {
|
|
356
|
-
const failure = issue('headroom_not_found_after_install', logs.join('\n'),
|
|
418
|
+
const failure = issue('headroom_not_found_after_install', logs.join('\n'), installCommand);
|
|
357
419
|
this.updatePersisted({ lastIssue: failure });
|
|
358
420
|
this.emit('failed', failure.title);
|
|
359
421
|
return { ok: false, state: this.getState(), issue: failure, logs };
|
|
@@ -494,6 +556,9 @@ class HeadroomManager {
|
|
|
494
556
|
toolDir: uvToolDir(),
|
|
495
557
|
binDir: uvBinDir(),
|
|
496
558
|
cacheDir: uvCacheDir(),
|
|
559
|
+
pythonInstallDir: uvPythonInstallDir(),
|
|
560
|
+
pythonBinDir: uvPythonBinDir(),
|
|
561
|
+
pythonCacheDir: uvPythonCacheDir(),
|
|
497
562
|
proxyTail: this.proxyTail.slice(-4000),
|
|
498
563
|
installSource: state.installSource,
|
|
499
564
|
detectedRoutes: state.detectedRoutes,
|
|
@@ -14,6 +14,13 @@ const GREEN_GOAL = 'Stop only when the latest verification step reports {{const:
|
|
|
14
14
|
const FACTORY_MAX_ITERATIONS = 12;
|
|
15
15
|
const FACTORY_LOOP_TIMEOUT_MIN = 360;
|
|
16
16
|
const FACTORY_AI_STEP_TIMEOUT_MIN = 60;
|
|
17
|
+
const SDD_QUICK_OPENSPEC_FACTORY = {
|
|
18
|
+
id: 'factory:sdd-quick-openspec',
|
|
19
|
+
name: 'SDD Quick (OpenSpec)',
|
|
20
|
+
description: 'Quick spec-driven OpenSpec lifecycle for small contract-governed changes: amend artifacts, apply, verify, and archive only after PASS.',
|
|
21
|
+
mode: 'loop',
|
|
22
|
+
graph: (0, loop_templates_1.opsxLifecycleGraph)(),
|
|
23
|
+
};
|
|
17
24
|
exports.FACTORY_LOOPS = [
|
|
18
25
|
{
|
|
19
26
|
id: 'factory:implement',
|
|
@@ -40,15 +47,15 @@ exports.FACTORY_LOOPS = [
|
|
|
40
47
|
claudeOnly: true,
|
|
41
48
|
graph: (0, loop_templates_1.fixLoopGraph)(['{{cmd:freestyle}}'], GREEN_GOAL, FACTORY_MAX_ITERATIONS, FACTORY_LOOP_TIMEOUT_MIN, FACTORY_AI_STEP_TIMEOUT_MIN),
|
|
42
49
|
},
|
|
43
|
-
|
|
44
|
-
id: 'factory:openspec',
|
|
45
|
-
name: 'OpenSpec Lifecycle',
|
|
46
|
-
description: 'Ticket-to-archive OpenSpec lifecycle: generate artifacts (opsx:ff) → implement (opsx:apply) → verify (opsx:verify); on FAIL loop back and amend the same change, on PASS archive it unattended.',
|
|
47
|
-
mode: 'loop',
|
|
48
|
-
graph: (0, loop_templates_1.opsxLifecycleGraph)(),
|
|
49
|
-
},
|
|
50
|
+
SDD_QUICK_OPENSPEC_FACTORY,
|
|
50
51
|
];
|
|
51
|
-
const
|
|
52
|
+
const FACTORY_ALIASES = new Map([
|
|
53
|
+
['factory:openspec', SDD_QUICK_OPENSPEC_FACTORY],
|
|
54
|
+
]);
|
|
55
|
+
const FACTORY_BY_ID = new Map([
|
|
56
|
+
...exports.FACTORY_LOOPS.map((f) => [f.id, f]),
|
|
57
|
+
...FACTORY_ALIASES,
|
|
58
|
+
]);
|
|
52
59
|
function getFactoryLoop(id) {
|
|
53
60
|
return FACTORY_BY_ID.get(id);
|
|
54
61
|
}
|
|
@@ -155,6 +155,13 @@ function interpolateSpec(text, spec) {
|
|
|
155
155
|
const ids = spec?.ticketIds ?? (spec?.id != null ? [spec.id] : []);
|
|
156
156
|
return ids.map((id) => `#${id}`).join(' ');
|
|
157
157
|
}
|
|
158
|
+
if (key === 'openspecChangeName') {
|
|
159
|
+
const direct = typeof spec?.openspecChangeName === 'string' ? spec.openspecChangeName.trim() : '';
|
|
160
|
+
const fromMetadata = typeof spec?.metadata?.openspecChangeName === 'string' ? spec.metadata.openspecChangeName.trim() : '';
|
|
161
|
+
return direct || fromMetadata;
|
|
162
|
+
}
|
|
163
|
+
if (!['id', 'title', 'description', 'status', 'priority', 'labels', 'jira_key', 'jira_url'].includes(key))
|
|
164
|
+
return '';
|
|
158
165
|
const value = spec ? spec[key] : undefined;
|
|
159
166
|
if (value == null)
|
|
160
167
|
return '';
|
|
@@ -108,6 +108,7 @@ class LoopRunManager {
|
|
|
108
108
|
* id, which IS the backing job row's id — the manager-agnostic turn routing
|
|
109
109
|
* (project-router-jobs) addresses sessions by that job id. */
|
|
110
110
|
_interactiveSteps = new Map();
|
|
111
|
+
_pausedHumanDecisions = new Map();
|
|
111
112
|
constructor(db, broadcast, executors, now = () => Date.now()) {
|
|
112
113
|
this.db = db;
|
|
113
114
|
this.broadcast = broadcast;
|
|
@@ -122,6 +123,11 @@ class LoopRunManager {
|
|
|
122
123
|
* leave the engine's `await` hanging forever. */
|
|
123
124
|
cancel(runId) {
|
|
124
125
|
this._cancelled.add(runId);
|
|
126
|
+
const paused = this._pausedHumanDecisions.get(runId);
|
|
127
|
+
if (paused) {
|
|
128
|
+
this._pausedHumanDecisions.delete(runId);
|
|
129
|
+
paused.resolve({ action: 'stop' });
|
|
130
|
+
}
|
|
125
131
|
const session = this._interactiveSteps.get(runId);
|
|
126
132
|
if (session) {
|
|
127
133
|
session.abort('■ Run canceled — tearing down the interactive step session.');
|
|
@@ -139,10 +145,23 @@ class LoopRunManager {
|
|
|
139
145
|
isInteractiveJob(jobId) {
|
|
140
146
|
return this._interactiveSteps.has(jobId);
|
|
141
147
|
}
|
|
148
|
+
/** True while the loop is paused between engine steps awaiting a human call. */
|
|
149
|
+
isPaused(jobId) {
|
|
150
|
+
return this._pausedHumanDecisions.has(jobId);
|
|
151
|
+
}
|
|
152
|
+
pausedReason(jobId) {
|
|
153
|
+
return this._pausedHumanDecisions.get(jobId)?.reason ?? null;
|
|
154
|
+
}
|
|
142
155
|
/** Feed one more user prompt to the ACTIVE step session owning this job row
|
|
143
156
|
* (queued behind the active turn — steering; the loop tends to continue its
|
|
144
157
|
* plan). Returns false when no interactive step is live for the id. */
|
|
145
158
|
sendInteractiveTurn(jobId, text) {
|
|
159
|
+
const paused = this._pausedHumanDecisions.get(jobId);
|
|
160
|
+
if (paused) {
|
|
161
|
+
this._pausedHumanDecisions.delete(jobId);
|
|
162
|
+
paused.resolve({ action: 'resume', text });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
146
165
|
const session = this._interactiveSteps.get(jobId);
|
|
147
166
|
if (!session)
|
|
148
167
|
return false;
|
|
@@ -180,6 +199,14 @@ class LoopRunManager {
|
|
|
180
199
|
}
|
|
181
200
|
}
|
|
182
201
|
this._activeChild.clear();
|
|
202
|
+
for (const [runId, paused] of this._pausedHumanDecisions.entries()) {
|
|
203
|
+
this._cancelled.add(runId);
|
|
204
|
+
try {
|
|
205
|
+
paused.resolve({ action: 'stop' });
|
|
206
|
+
}
|
|
207
|
+
catch { /* ignore */ }
|
|
208
|
+
}
|
|
209
|
+
this._pausedHumanDecisions.clear();
|
|
183
210
|
}
|
|
184
211
|
async run(req) {
|
|
185
212
|
const runId = req.runId ?? (0, ids_1.newId)();
|
|
@@ -459,6 +486,65 @@ class LoopRunManager {
|
|
|
459
486
|
// that loop-back so each iteration starts FRESH (re-reading the current code),
|
|
460
487
|
// keeping context bounded regardless of pass count. Within an iteration the
|
|
461
488
|
// steps still resume, so RED→GREEN→REFACTOR share context.
|
|
489
|
+
const awaitHumanDecision = async (reason) => {
|
|
490
|
+
const decisionPromise = new Promise((resolve) => {
|
|
491
|
+
this._pausedHumanDecisions.set(runId, { reason, resolve });
|
|
492
|
+
});
|
|
493
|
+
logLine(`\n■ Loop paused — needs a human decision: ${reason}`, 'stderr');
|
|
494
|
+
try {
|
|
495
|
+
(0, db_1.markJobInteractive)(this.db, runId);
|
|
496
|
+
}
|
|
497
|
+
catch { /* best-effort */ }
|
|
498
|
+
(0, loop_runs_store_1.pauseLoopRun)(this.db, runId, {
|
|
499
|
+
iterationCount: iteration,
|
|
500
|
+
totalCostUsd: totalCost,
|
|
501
|
+
totalTokens,
|
|
502
|
+
totalDurationMs: totalDuration,
|
|
503
|
+
});
|
|
504
|
+
const pausedAt = new Date(this.now()).toISOString();
|
|
505
|
+
this.broadcast({
|
|
506
|
+
type: 'loop.run_paused',
|
|
507
|
+
projectId: req.projectId,
|
|
508
|
+
loopRunId: runId,
|
|
509
|
+
railIndex: req.railIndex ?? null,
|
|
510
|
+
reason,
|
|
511
|
+
ticketIds: req.ticketId != null ? [req.ticketId] : [],
|
|
512
|
+
});
|
|
513
|
+
this.broadcast({
|
|
514
|
+
type: 'job.interactive',
|
|
515
|
+
projectId: req.projectId,
|
|
516
|
+
jobId: runId,
|
|
517
|
+
acceptingTurns: true,
|
|
518
|
+
settleMode: 'auto',
|
|
519
|
+
timestamp: pausedAt,
|
|
520
|
+
});
|
|
521
|
+
const decision = await decisionPromise;
|
|
522
|
+
if (decision.action === 'stop' || this._cancelled.has(runId)) {
|
|
523
|
+
logLine(`\n■ Loop stop requested while paused.`, 'stderr');
|
|
524
|
+
return { action: 'stop' };
|
|
525
|
+
}
|
|
526
|
+
(0, loop_runs_store_1.resumeLoopRun)(this.db, runId);
|
|
527
|
+
const answer = decision.text.trim();
|
|
528
|
+
const resumedAt = new Date(this.now()).toISOString();
|
|
529
|
+
logLine(`\n▶ Loop resumed — human decision received${answer ? `:\n${answer}` : '.'}`);
|
|
530
|
+
history.push(`Human decision: ${truncate(answer)}`);
|
|
531
|
+
this.broadcast({
|
|
532
|
+
type: 'loop.run_resumed',
|
|
533
|
+
projectId: req.projectId,
|
|
534
|
+
loopRunId: runId,
|
|
535
|
+
railIndex: req.railIndex ?? null,
|
|
536
|
+
ticketIds: req.ticketId != null ? [req.ticketId] : [],
|
|
537
|
+
});
|
|
538
|
+
this.broadcast({
|
|
539
|
+
type: 'job.interactive',
|
|
540
|
+
projectId: req.projectId,
|
|
541
|
+
jobId: runId,
|
|
542
|
+
acceptingTurns: false,
|
|
543
|
+
settleMode: 'auto',
|
|
544
|
+
timestamp: resumedAt,
|
|
545
|
+
});
|
|
546
|
+
return decision;
|
|
547
|
+
};
|
|
462
548
|
const firstStepId = start ? req.graph.edges.find((e) => e.source === start.id)?.target : undefined;
|
|
463
549
|
try {
|
|
464
550
|
let nodeId = start?.id;
|
|
@@ -601,15 +687,20 @@ class LoopRunManager {
|
|
|
601
687
|
// Only carry forward the session of a step that actually ran — a
|
|
602
688
|
// hard-failed turn (codex still emits thread.started before its error)
|
|
603
689
|
// would otherwise make the next step `--resume` a dead session.
|
|
604
|
-
if (res.sessionId && !stepFailed)
|
|
690
|
+
if (res.sessionId && (!stepFailed || (blockedReason !== null && !res.failed && !zeroWork)))
|
|
605
691
|
aiSessionId = res.sessionId;
|
|
606
692
|
history.push(`AI Step: ${truncate(res.text)}`);
|
|
607
693
|
record(`loop:${runId}`, { ...res, failed: stepFailed }, aiStepStart);
|
|
608
694
|
if (blockedReason) {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
695
|
+
const decision = await awaitHumanDecision(blockedReason);
|
|
696
|
+
if (decision.action === 'stop') {
|
|
697
|
+
outcome = 'stopped';
|
|
698
|
+
settled = true;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
justContinued = true;
|
|
702
|
+
nodeId = node.id;
|
|
703
|
+
continue;
|
|
613
704
|
}
|
|
614
705
|
// Capture the OpenSpec change id from a step's output the FIRST time it
|
|
615
706
|
// appears (first-match-wins, kept stable across loop-back iterations so
|
|
@@ -754,10 +845,21 @@ class LoopRunManager {
|
|
|
754
845
|
// the fix for the verify→fix cycle spinning on an unanswered scope
|
|
755
846
|
// question the loop can't resolve on its own.
|
|
756
847
|
if (dec.blocked) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
848
|
+
const decision = await awaitHumanDecision(dec.reasoning);
|
|
849
|
+
if (decision.action === 'stop') {
|
|
850
|
+
outcome = 'stopped';
|
|
851
|
+
settled = true;
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
const target = branchTarget('continue') ?? conts[0]?.id ?? firstStepId;
|
|
855
|
+
if (!target) {
|
|
856
|
+
outcome = 'failed';
|
|
857
|
+
settled = true;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
justContinued = true;
|
|
861
|
+
nodeId = target;
|
|
862
|
+
continue;
|
|
761
863
|
}
|
|
762
864
|
// Non-convergence guard: if two consecutive 'continue' iterations
|
|
763
865
|
// leave the working tree byte-identical, the loop isn't progressing
|
|
@@ -846,11 +948,11 @@ class LoopRunManager {
|
|
|
846
948
|
// Settle the backing job so the Jobs list + JobDetail reflect the final
|
|
847
949
|
// status, and emit job.finalized so an open JobDetail re-fetches + stops the
|
|
848
950
|
// live stream (mirrors QueueManager).
|
|
849
|
-
// `
|
|
850
|
-
// surface as `canceled`,
|
|
851
|
-
//
|
|
951
|
+
// `stopped`/`stalled` are controlled halts (not done, not a hard crash) →
|
|
952
|
+
// surface as `canceled`, so tickets revert to todo rather than being marked
|
|
953
|
+
// done/on_review AND the run is never counted as success.
|
|
852
954
|
const jobStatus = outcome === 'success' ? 'completed'
|
|
853
|
-
: outcome === 'stopped' || outcome === '
|
|
955
|
+
: outcome === 'stopped' || outcome === 'stalled' ? 'canceled'
|
|
854
956
|
: 'failed';
|
|
855
957
|
// `≥` when any cost-bearing step ended unpriced (timeout/crash) — the figure
|
|
856
958
|
// is a lower bound, not exact.
|
|
@@ -3,9 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createLoopRun = createLoopRun;
|
|
4
4
|
exports.updateLoopRunCounters = updateLoopRunCounters;
|
|
5
5
|
exports.finishLoopRun = finishLoopRun;
|
|
6
|
+
exports.pauseLoopRun = pauseLoopRun;
|
|
7
|
+
exports.resumeLoopRun = resumeLoopRun;
|
|
6
8
|
exports.getLoopRun = getLoopRun;
|
|
7
9
|
exports.getRunEventCounts = getRunEventCounts;
|
|
8
10
|
exports.listRunningLoopRuns = listRunningLoopRuns;
|
|
11
|
+
exports.listActiveLoopRuns = listActiveLoopRuns;
|
|
9
12
|
exports.listLoopRuns = listLoopRuns;
|
|
10
13
|
exports.countRunningForLoop = countRunningForLoop;
|
|
11
14
|
exports.reconcileOrphanLoopRuns = reconcileOrphanLoopRuns;
|
|
@@ -40,6 +43,16 @@ function finishLoopRun(db, id, opts) {
|
|
|
40
43
|
db.prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = ?, finished_at = ? WHERE id = ?`).run(opts.outcome, opts.finishedAt, id);
|
|
41
44
|
return getLoopRun(db, id);
|
|
42
45
|
}
|
|
46
|
+
function pauseLoopRun(db, id, counters) {
|
|
47
|
+
if (counters)
|
|
48
|
+
updateLoopRunCounters(db, id, counters);
|
|
49
|
+
db.prepare(`UPDATE loop_runs SET status = 'paused' WHERE id = ? AND status = 'running'`).run(id);
|
|
50
|
+
return getLoopRun(db, id);
|
|
51
|
+
}
|
|
52
|
+
function resumeLoopRun(db, id) {
|
|
53
|
+
db.prepare(`UPDATE loop_runs SET status = 'running' WHERE id = ? AND status = 'paused'`).run(id);
|
|
54
|
+
return getLoopRun(db, id);
|
|
55
|
+
}
|
|
43
56
|
function getLoopRun(db, id) {
|
|
44
57
|
return db.prepare('SELECT * FROM loop_runs WHERE id = ?').get(id);
|
|
45
58
|
}
|
|
@@ -92,6 +105,11 @@ function listRunningLoopRuns(db, projectId) {
|
|
|
92
105
|
.prepare("SELECT * FROM loop_runs WHERE project_id = ? AND status = 'running' ORDER BY started_at ASC")
|
|
93
106
|
.all(projectId);
|
|
94
107
|
}
|
|
108
|
+
function listActiveLoopRuns(db, projectId) {
|
|
109
|
+
return db
|
|
110
|
+
.prepare("SELECT * FROM loop_runs WHERE project_id = ? AND status IN ('running','paused') ORDER BY started_at ASC")
|
|
111
|
+
.all(projectId);
|
|
112
|
+
}
|
|
95
113
|
function listLoopRuns(db, projectId, limit = 100) {
|
|
96
114
|
return db
|
|
97
115
|
.prepare('SELECT * FROM loop_runs WHERE project_id = ? ORDER BY started_at DESC LIMIT ?')
|
|
@@ -102,19 +120,20 @@ function listLoopRuns(db, projectId, limit = 100) {
|
|
|
102
120
|
* Running?" guard that blocks edit/delete of a loop while it executes. */
|
|
103
121
|
function countRunningForLoop(db, loopId) {
|
|
104
122
|
const row = db
|
|
105
|
-
.prepare(`SELECT COUNT(*) AS n FROM loop_runs WHERE loop_id = ? AND status
|
|
123
|
+
.prepare(`SELECT COUNT(*) AS n FROM loop_runs WHERE loop_id = ? AND status IN ('running','paused')`)
|
|
106
124
|
.get(loopId);
|
|
107
125
|
return row.n;
|
|
108
126
|
}
|
|
109
|
-
/** Reconcile orphan runs at startup: any row still
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
127
|
+
/** Reconcile orphan runs at startup: any row still active belongs to a process
|
|
128
|
+
* that is now dead (server restart, or a run killed before it could settle), so
|
|
129
|
+
* mark it terminal (`completed` + outcome `failed`). A paused row is active too:
|
|
130
|
+
* its resumable context lived in the old process. Without this an orphan keeps
|
|
131
|
+
* `countRunningForLoop > 0` forever, which wedges the loop as un-editable /
|
|
132
|
+
* un-publishable ("Failed to save loop" via the isRunning 409). Returns the
|
|
133
|
+
* number of rows reconciled. */
|
|
115
134
|
function reconcileOrphanLoopRuns(db, finishedAt) {
|
|
116
135
|
const res = db
|
|
117
|
-
.prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = COALESCE(final_outcome, 'failed'), finished_at = COALESCE(finished_at, ?) WHERE status
|
|
136
|
+
.prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = COALESCE(final_outcome, 'failed'), finished_at = COALESCE(finished_at, ?) WHERE status IN ('running','paused')`)
|
|
118
137
|
.run(finishedAt);
|
|
119
138
|
return res.changes ?? 0;
|
|
120
139
|
}
|
|
@@ -116,7 +116,11 @@ const OPSX_FF_PROMPT = [
|
|
|
116
116
|
'',
|
|
117
117
|
'{{spec.description}}',
|
|
118
118
|
'',
|
|
119
|
-
'
|
|
119
|
+
'Structured OpenSpec target from the local ticket metadata: "{{spec.openspecChangeName}}". If this value is non-blank, CONTINUE that exact OpenSpec change and do NOT create a duplicate change for the same follow-up. If it is blank and no run change id exists, create a new OpenSpec change from this ticket and generate all required artifacts.',
|
|
120
|
+
'',
|
|
121
|
+
'If a run change id appears here — "{{run.changeId}}" — an OpenSpec change for this ticket already exists: CONTINUE that change (do NOT create a new one) and address only what the verification reported as still missing (see the context below).',
|
|
122
|
+
'',
|
|
123
|
+
'OpenSpec artifacts are authoritative. If the requested implementation changes requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants, amend the relevant OpenSpec artifacts before any code changes.',
|
|
120
124
|
'',
|
|
121
125
|
'Run fully unattended: make reasonable decisions to keep momentum and NEVER stop to ask — there is no human to answer. When something is unclear, pick the most sensible option, proceed, and note the assumption.',
|
|
122
126
|
].join('\n');
|
|
@@ -125,6 +129,8 @@ const OPSX_APPLY_PROMPT = [
|
|
|
125
129
|
'',
|
|
126
130
|
'Implement every pending task of the active OpenSpec change for ticket "{{spec.title}}", editing code as needed and marking tasks complete as you finish them.',
|
|
127
131
|
'',
|
|
132
|
+
'Before editing code, confirm the active OpenSpec artifacts already describe the contract being implemented. If implementation requires changing requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants, stop code work and amend the OpenSpec artifacts first.',
|
|
133
|
+
'',
|
|
128
134
|
'Run fully unattended: decide and keep momentum, never pause to ask. If you hit an ambiguity or blocker, make the most reasonable choice, implement it, and continue.',
|
|
129
135
|
].join('\n');
|
|
130
136
|
const OPSX_VERIFY_PROMPT = [
|
|
@@ -132,9 +138,11 @@ const OPSX_VERIFY_PROMPT = [
|
|
|
132
138
|
'',
|
|
133
139
|
'Verify the active OpenSpec change against its specs, design, and tasks for ticket "{{spec.title}}". Be strict and honest — inspect the REAL code and tests, not any step\'s self-report.',
|
|
134
140
|
'',
|
|
141
|
+
'Report FAIL if the implementation diverges from the active OpenSpec artifacts, or if code changed requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants without corresponding artifact amendments.',
|
|
142
|
+
'',
|
|
135
143
|
'Finish with a clear final line: exactly `{{const:VERIFICATION_PASS}}` when the change fully matches the ticket with nothing required missing, or `{{const:VERIFICATION_FAIL}} — <what is still missing>` otherwise.',
|
|
136
144
|
].join('\n');
|
|
137
|
-
const OPSX_DECIDER_GOAL = 'The verify step reported {{const:VERIFICATION_PASS}} — the implementation fully matches ticket "{{spec.title}}" and nothing required is missing.';
|
|
145
|
+
const OPSX_DECIDER_GOAL = 'The verify step reported {{const:VERIFICATION_PASS}} — the implementation fully matches the active OpenSpec artifacts for ticket "{{spec.title}}" and nothing required is missing.';
|
|
138
146
|
/** The OpenSpec-lifecycle graph (see comment above). Exported for unit testing. */
|
|
139
147
|
function opsxLifecycleGraph() {
|
|
140
148
|
return {
|
|
@@ -38,9 +38,9 @@ function backgroundStartBusyReason(projectCtx) {
|
|
|
38
38
|
if (activeJobId)
|
|
39
39
|
return `job ${activeJobId} is still running`;
|
|
40
40
|
try {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
return `loop run ${
|
|
41
|
+
const activeLoops = (0, loop_runs_store_1.listActiveLoopRuns)(projectCtx.db, projectCtx.project.id);
|
|
42
|
+
if (activeLoops.length > 0)
|
|
43
|
+
return `loop run ${activeLoops[0].id} is still running`;
|
|
44
44
|
}
|
|
45
45
|
catch {
|
|
46
46
|
// Some unit harnesses stub the DB narrowly; absence of loop metadata is not a blocker.
|
|
@@ -52,6 +52,7 @@ function railsTools() {
|
|
|
52
52
|
'For on_review tickets with an already-open GitHub PR (including a published pr_ready delivery), launch automatically tries to continue that PR head branch; Jira-linked in_progress tickets can do the same when the PR match is explicit; fresh tickets still start from the project integration branch. ' +
|
|
53
53
|
'When launched from the in-app agent chat without an explicit aiEngine, the engine defaults to your conversation\'s provider (pass aiEngine to override; launch_all always uses each rail\'s stored engine). ' +
|
|
54
54
|
'User-facing naming: call the free-form autonomous mode "Freestyle"; use "freestyle" as the canonical API enum value for that same capability. ' +
|
|
55
|
+
'For small OpenSpec-governed work, recommend "SDD Quick (OpenSpec)" and launch with mode "loop" plus loopId "factory:sdd-quick-openspec"; keep Freestyle for ticket-local implementation-only work. ' +
|
|
55
56
|
'NAMING: railIndex is the 0-BASED internal identity; the dashboard shows rails 1-based ("Rail N" = railIndex N-1). When talking to the user, ALWAYS say "Rail <railIndex + 1>" (or the rail\'s custom name) — results include railLabel with the correct user-facing label.',
|
|
56
57
|
hintTier: 'read',
|
|
57
58
|
tier: (a) => {
|
|
@@ -114,7 +115,7 @@ function railsTools() {
|
|
|
114
115
|
loopId: zod_1.z
|
|
115
116
|
.string()
|
|
116
117
|
.optional()
|
|
117
|
-
.describe('Loop id to run (launch). Factory ids (e.g. "factory:implement") map to a legacy mode; a custom published loop id keeps mode="loop". Browse/author loops with specrails_loops; the loop must be Published.'),
|
|
118
|
+
.describe('Loop id to run (launch). Use "factory:sdd-quick-openspec" for SDD Quick (OpenSpec): small OpenSpec-governed work via mode="loop". Factory ids (e.g. "factory:implement") map to a legacy mode; a custom published loop id keeps mode="loop". Browse/author loops with specrails_loops; the loop must be Published.'),
|
|
118
119
|
reasoning_effort: zod_1.z
|
|
119
120
|
.enum(['low', 'medium', 'high'])
|
|
120
121
|
.optional()
|
|
@@ -712,6 +712,8 @@ class ProjectRegistry {
|
|
|
712
712
|
labels: t.labels,
|
|
713
713
|
jira_key: t.jira_key ?? null,
|
|
714
714
|
jira_url: t.jira_url ?? null,
|
|
715
|
+
openspecChangeName: typeof t.metadata?.openspecChangeName === 'string' ? t.metadata.openspecChangeName : undefined,
|
|
716
|
+
metadata: t.metadata,
|
|
715
717
|
};
|
|
716
718
|
}
|
|
717
719
|
catch {
|
|
@@ -23,9 +23,9 @@ function backgroundStartBusyReason(c) {
|
|
|
23
23
|
if (activeJobId)
|
|
24
24
|
return `job ${activeJobId} is still running`;
|
|
25
25
|
try {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
return `loop run ${
|
|
26
|
+
const activeLoops = (0, loop_runs_store_1.listActiveLoopRuns)(c.db, c.project.id);
|
|
27
|
+
if (activeLoops.length > 0)
|
|
28
|
+
return `loop run ${activeLoops[0].id} is still running`;
|
|
29
29
|
}
|
|
30
30
|
catch {
|
|
31
31
|
// If the loop table is unavailable in a test harness, do not block startup.
|
|
@@ -249,7 +249,7 @@ function registerJobsRoutes(deps) {
|
|
|
249
249
|
// the loop is still executing — cancel through the engine instead of
|
|
250
250
|
// 404ing with a live child (the mission-modal "cancel did nothing" bug).
|
|
251
251
|
const loopRun = (0, loop_runs_store_1.getLoopRun)(c.db, id);
|
|
252
|
-
if (loopRun && loopRun.status === 'running' && c.loopRunManager) {
|
|
252
|
+
if (loopRun && (loopRun.status === 'running' || loopRun.status === 'paused') && c.loopRunManager) {
|
|
253
253
|
c.loopRunManager.cancel(id);
|
|
254
254
|
res.json({ ok: true, status: 'canceling' });
|
|
255
255
|
return;
|
|
@@ -280,7 +280,10 @@ function registerJobsRoutes(deps) {
|
|
|
280
280
|
// 409 = neither manager has an active interactive session for the id
|
|
281
281
|
// (unknown / non-interactive / finalized / between loop steps).
|
|
282
282
|
router.post('/:projectId/jobs/:id/messages', (req, res) => {
|
|
283
|
-
|
|
283
|
+
const c = ctx(req);
|
|
284
|
+
const jobId = req.params.id;
|
|
285
|
+
const loopPaused = c.loopRunManager?.isPaused?.(jobId) ?? false;
|
|
286
|
+
if (!(0, feature_flags_1.isInteractiveJobsEnabled)() && !loopPaused) {
|
|
284
287
|
res.status(403).json({ error: 'Interactive jobs are disabled on this server' });
|
|
285
288
|
return;
|
|
286
289
|
}
|
|
@@ -289,8 +292,6 @@ function registerJobsRoutes(deps) {
|
|
|
289
292
|
res.status(400).json({ error: 'text is required' });
|
|
290
293
|
return;
|
|
291
294
|
}
|
|
292
|
-
const c = ctx(req);
|
|
293
|
-
const jobId = req.params.id;
|
|
294
295
|
const accepted = c.queueManager.sendInteractiveTurn(jobId, text)
|
|
295
296
|
|| (c.loopRunManager?.sendInteractiveTurn(jobId, text) ?? false);
|
|
296
297
|
if (!accepted) {
|
|
@@ -571,13 +572,16 @@ function registerJobsRoutes(deps) {
|
|
|
571
572
|
// composer can phrase the waiting state truthfully instead of guessing.
|
|
572
573
|
let interactiveSettleMode = null;
|
|
573
574
|
let interactiveAcceptingTurns = false;
|
|
574
|
-
|
|
575
|
+
const loopRun = (0, loop_runs_store_1.getLoopRun)(db, jobId);
|
|
576
|
+
const loopPaused = loopRun?.status === 'paused' || (c.loopRunManager?.isPaused?.(jobId) ?? false);
|
|
577
|
+
const loopPauseReason = c.loopRunManager?.pausedReason?.(jobId) ?? null;
|
|
578
|
+
if (job.status === 'running' && (((0, feature_flags_1.isInteractiveJobsEnabled)() && job.interactive) || loopPaused)) {
|
|
575
579
|
const qmMode = queueManager.getInteractiveSettleMode?.(jobId) ?? null;
|
|
576
580
|
const loopStepActive = c.loopRunManager?.isInteractiveJob?.(jobId) ?? false;
|
|
577
|
-
interactiveAcceptingTurns = qmMode !== null || loopStepActive;
|
|
578
|
-
interactiveSettleMode = qmMode ?? ((loopStepActive ||
|
|
581
|
+
interactiveAcceptingTurns = qmMode !== null || loopStepActive || loopPaused;
|
|
582
|
+
interactiveSettleMode = qmMode ?? ((loopStepActive || loopPaused || loopRun) ? 'auto' : null);
|
|
579
583
|
}
|
|
580
|
-
const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets, interactiveSettleMode, interactiveAcceptingTurns };
|
|
584
|
+
const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets, interactiveSettleMode, interactiveAcceptingTurns, loopPaused, loopPauseReason };
|
|
581
585
|
res.json({ job: annotated, events, phaseDefinitions });
|
|
582
586
|
});
|
|
583
587
|
router.delete('/:projectId/jobs', (req, res) => {
|
|
@@ -111,7 +111,7 @@ function createRailsRouter() {
|
|
|
111
111
|
// Also surface DB 'running' runs not tracked in-memory (the rail map is
|
|
112
112
|
// cleared on every server restart) — DB is the authoritative source, so the
|
|
113
113
|
// dashboard metrics survive both a page refresh AND a server restart.
|
|
114
|
-
for (const run of (0, loop_runs_store_1.
|
|
114
|
+
for (const run of (0, loop_runs_store_1.listActiveLoopRuns)(c.db, c.project.id)) {
|
|
115
115
|
if (run.rail_index == null || activeLoopRuns[run.rail_index])
|
|
116
116
|
continue;
|
|
117
117
|
const counts = (0, loop_runs_store_1.getRunEventCounts)(c.db, run.id);
|
|
@@ -62,8 +62,11 @@ function warnShimMissingOnce(name) {
|
|
|
62
62
|
_warnedShims.add(name);
|
|
63
63
|
console.warn(`[terminal-shell-integration] bundled shim not found: ${name} — shell integration disabled for this shell`);
|
|
64
64
|
}
|
|
65
|
+
function specrailsHome() {
|
|
66
|
+
return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
|
|
67
|
+
}
|
|
65
68
|
function projectsRoot() {
|
|
66
|
-
return path_1.default.join(
|
|
69
|
+
return path_1.default.join(specrailsHome(), '.specrails', 'projects');
|
|
67
70
|
}
|
|
68
71
|
function shimDirFor(projectSlug, sessionId) {
|
|
69
72
|
return path_1.default.join(projectsRoot(), projectSlug, 'terminals', sessionId);
|