orbital-command 0.3.0 → 1.0.1
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/README.md +67 -42
- package/bin/commands/config.js +19 -0
- package/bin/commands/events.js +40 -0
- package/bin/commands/launch.js +126 -0
- package/bin/commands/manifest.js +283 -0
- package/bin/commands/registry.js +104 -0
- package/bin/commands/update.js +24 -0
- package/bin/lib/helpers.js +229 -0
- package/bin/orbital.js +90 -873
- package/dist/assets/Landing-CfQdHR0N.js +11 -0
- package/dist/assets/PrimitivesConfig-DThSipFy.js +32 -0
- package/dist/assets/QualityGates-B4kxM5UU.js +26 -0
- package/dist/assets/SessionTimeline-Bz1iZnmg.js +1 -0
- package/dist/assets/Settings-DLcZwbCT.js +12 -0
- package/dist/assets/SourceControl-BMNIz7Lt.js +36 -0
- package/dist/assets/WorkflowVisualizer-CxuSBOYu.js +69 -0
- package/dist/assets/{arrow-down-CPy85_J6.js → arrow-down-DVPp6_qp.js} +1 -1
- package/dist/assets/bot-NFaJBDn_.js +6 -0
- package/dist/assets/{charts-DbDg0Psc.js → charts-LGLb8hyU.js} +1 -1
- package/dist/assets/{circle-x-Cwz6ZQDV.js → circle-x-IsFCkBZu.js} +1 -1
- package/dist/assets/{file-text-C46Xr65c.js → file-text-J1cebZXF.js} +1 -1
- package/dist/assets/{globe-Cn2yNZUD.js → globe-WzeyHsUc.js} +1 -1
- package/dist/assets/index-BdJ57EhC.css +1 -0
- package/dist/assets/index-o4ScMAuR.js +349 -0
- package/dist/assets/{key-OPaNTWJ5.js → key-CKR8JJSj.js} +1 -1
- package/dist/assets/{minus-GMsbpKym.js → minus-CHBsJyjp.js} +1 -1
- package/dist/assets/radio-xqZaR-Uk.js +6 -0
- package/dist/assets/rocket-D_xvvNG6.js +6 -0
- package/dist/assets/{shield-DwAFkDYI.js → shield-TdB1yv_a.js} +1 -1
- package/dist/assets/useSocketListener-0L5yiN5i.js +1 -0
- package/dist/assets/useWorkflowEditor-CqeRWVQX.js +11 -0
- package/dist/assets/workflow-constants-Rw-GmgHZ.js +6 -0
- package/dist/assets/zap-C9wqYMpl.js +6 -0
- package/dist/index.html +3 -3
- package/dist/server/server/__tests__/data-routes.test.js +2 -0
- package/dist/server/server/__tests__/scope-routes.test.js +1 -0
- package/dist/server/server/config-migrator.js +0 -3
- package/dist/server/server/config.js +35 -6
- package/dist/server/server/database.js +0 -22
- package/dist/server/server/index.js +28 -816
- package/dist/server/server/init.js +32 -399
- package/dist/server/server/launch.js +1 -1
- package/dist/server/server/parsers/event-parser.js +4 -1
- package/dist/server/server/project-context.js +19 -9
- package/dist/server/server/project-manager.js +6 -6
- package/dist/server/server/routes/aggregate-routes.js +871 -0
- package/dist/server/server/routes/config-routes.js +41 -88
- package/dist/server/server/routes/data-routes.js +5 -15
- package/dist/server/server/routes/dispatch-routes.js +24 -8
- package/dist/server/server/routes/manifest-routes.js +1 -1
- package/dist/server/server/routes/scope-routes.js +10 -7
- package/dist/server/server/schema.js +1 -0
- package/dist/server/server/services/batch-orchestrator.js +17 -3
- package/dist/server/server/services/config-service.js +10 -1
- package/dist/server/server/services/scope-service.js +7 -7
- package/dist/server/server/services/sprint-orchestrator.js +24 -11
- package/dist/server/server/services/sprint-service.js +2 -2
- package/dist/server/server/uninstall.js +195 -0
- package/dist/server/server/update.js +212 -0
- package/dist/server/server/utils/dispatch-utils.js +8 -6
- package/dist/server/server/utils/flag-builder.js +54 -0
- package/dist/server/server/utils/json-fields.js +14 -0
- package/dist/server/server/utils/json-fields.test.js +73 -0
- package/dist/server/server/utils/route-helpers.js +37 -0
- package/dist/server/server/utils/route-helpers.test.js +115 -0
- package/dist/server/server/watchers/event-watcher.js +28 -13
- package/dist/server/server/wizard/config-editor.js +4 -4
- package/dist/server/server/wizard/doctor.js +2 -2
- package/dist/server/server/wizard/index.js +224 -39
- package/dist/server/server/wizard/phases/welcome.js +1 -4
- package/dist/server/server/wizard/ui.js +6 -7
- package/dist/server/shared/api-types.js +80 -1
- package/dist/server/shared/workflow-engine.js +1 -1
- package/package.json +20 -20
- package/schemas/orbital.config.schema.json +1 -19
- package/scripts/postinstall.js +6 -42
- package/scripts/release.sh +53 -0
- package/server/__tests__/data-routes.test.ts +2 -0
- package/server/__tests__/scope-routes.test.ts +1 -0
- package/server/config-migrator.ts +0 -3
- package/server/config.ts +39 -11
- package/server/database.ts +0 -26
- package/server/global-config.ts +4 -0
- package/server/index.ts +31 -896
- package/server/init.ts +32 -443
- package/server/launch.ts +1 -1
- package/server/parsers/event-parser.ts +4 -1
- package/server/project-context.ts +26 -10
- package/server/project-manager.ts +5 -6
- package/server/routes/aggregate-routes.ts +968 -0
- package/server/routes/config-routes.ts +41 -81
- package/server/routes/data-routes.ts +7 -16
- package/server/routes/dispatch-routes.ts +29 -8
- package/server/routes/manifest-routes.ts +1 -1
- package/server/routes/scope-routes.ts +12 -7
- package/server/schema.ts +1 -0
- package/server/services/batch-orchestrator.ts +18 -2
- package/server/services/config-service.ts +10 -1
- package/server/services/scope-service.ts +6 -6
- package/server/services/sprint-orchestrator.ts +24 -9
- package/server/services/sprint-service.ts +2 -2
- package/server/uninstall.ts +214 -0
- package/server/update.ts +263 -0
- package/server/utils/dispatch-utils.ts +8 -6
- package/server/utils/flag-builder.ts +56 -0
- package/server/utils/json-fields.test.ts +83 -0
- package/server/utils/json-fields.ts +14 -0
- package/server/utils/route-helpers.test.ts +144 -0
- package/server/utils/route-helpers.ts +38 -0
- package/server/watchers/event-watcher.ts +24 -12
- package/server/wizard/config-editor.ts +4 -4
- package/server/wizard/doctor.ts +2 -2
- package/server/wizard/index.ts +291 -40
- package/server/wizard/phases/welcome.ts +1 -5
- package/server/wizard/ui.ts +6 -7
- package/shared/api-types.ts +106 -0
- package/shared/workflow-engine.ts +1 -1
- package/templates/agents/QUICK-REFERENCE.md +1 -0
- package/templates/agents/README.md +1 -0
- package/templates/agents/SKILL-TRIGGERS.md +11 -0
- package/templates/agents/green-team/deep-dive.md +361 -0
- package/templates/hooks/end-session.sh +1 -0
- package/templates/hooks/init-session.sh +1 -0
- package/templates/hooks/scope-commit-logger.sh +2 -2
- package/templates/hooks/scope-create-gate.sh +2 -4
- package/templates/hooks/scope-gate.sh +4 -6
- package/templates/hooks/scope-helpers.sh +10 -1
- package/templates/hooks/scope-lifecycle-gate.sh +14 -5
- package/templates/hooks/scope-prepare.sh +1 -1
- package/templates/hooks/scope-transition.sh +14 -6
- package/templates/hooks/time-tracker.sh +2 -5
- package/templates/orbital.config.json +1 -4
- package/templates/presets/development.json +4 -4
- package/templates/presets/gitflow.json +7 -0
- package/templates/prompts/README.md +23 -0
- package/templates/prompts/deep-dive-audit.md +94 -0
- package/templates/quick/rules.md +56 -5
- package/templates/skills/git-commit/SKILL.md +21 -6
- package/templates/skills/git-dev/SKILL.md +8 -4
- package/templates/skills/git-main/SKILL.md +8 -4
- package/templates/skills/git-production/SKILL.md +6 -3
- package/templates/skills/git-staging/SKILL.md +6 -3
- package/templates/skills/scope-fix-review/SKILL.md +8 -4
- package/templates/skills/scope-implement/SKILL.md +13 -5
- package/templates/skills/scope-post-review/SKILL.md +16 -4
- package/templates/skills/scope-pre-review/SKILL.md +6 -2
- package/dist/assets/PrimitivesConfig-CrmQXYh4.js +0 -32
- package/dist/assets/QualityGates-BbasOsF3.js +0 -21
- package/dist/assets/SessionTimeline-CGeJsVvy.js +0 -1
- package/dist/assets/Settings-oiM496mc.js +0 -12
- package/dist/assets/SourceControl-B1fP2nJL.js +0 -41
- package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +0 -74
- package/dist/assets/formatDistanceToNow-BMqsSP44.js +0 -1
- package/dist/assets/index-Aj4sV8Al.css +0 -1
- package/dist/assets/index-Bc9dK3MW.js +0 -354
- package/dist/assets/useWorkflowEditor-BJkTX_NR.js +0 -16
- package/dist/assets/zap-DfbUoOty.js +0 -11
- package/dist/server/server/services/telemetry-service.js +0 -143
- package/server/services/telemetry-service.ts +0 -195
- /package/{shared/default-workflow.json → templates/presets/default.json} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { ConfigService, isValidPrimitiveType } from '../services/config-service.js';
|
|
3
|
-
import {
|
|
3
|
+
import { catchRoute, inferErrorStatus } from '../utils/route-helpers.js';
|
|
4
4
|
export function createConfigRoutes({ projectRoot, workflowService: _workflowService, io }) {
|
|
5
5
|
const router = Router();
|
|
6
6
|
const configService = new ConfigService(projectRoot);
|
|
@@ -13,21 +13,16 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
13
13
|
return typeParam;
|
|
14
14
|
}
|
|
15
15
|
// GET /config/:type/tree — directory tree with frontmatter
|
|
16
|
-
router.get('/config/:type/tree', (req, res) => {
|
|
16
|
+
router.get('/config/:type/tree', catchRoute((req, res) => {
|
|
17
17
|
const type = parseType(req.params.type, res);
|
|
18
18
|
if (!type)
|
|
19
19
|
return;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
catch (err) {
|
|
26
|
-
res.status(500).json({ success: false, error: errMsg(err) });
|
|
27
|
-
}
|
|
28
|
-
});
|
|
20
|
+
const basePath = configService.getBasePath(type);
|
|
21
|
+
const tree = configService.scanDirectory(basePath);
|
|
22
|
+
res.json({ success: true, data: tree });
|
|
23
|
+
}));
|
|
29
24
|
// GET /config/:type/file?path=<relative> — file content
|
|
30
|
-
router.get('/config/:type/file', (req, res) => {
|
|
25
|
+
router.get('/config/:type/file', catchRoute((req, res) => {
|
|
31
26
|
const type = parseType(req.params.type, res);
|
|
32
27
|
if (!type)
|
|
33
28
|
return;
|
|
@@ -36,19 +31,12 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
36
31
|
res.status(400).json({ success: false, error: 'path query parameter is required' });
|
|
37
32
|
return;
|
|
38
33
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
const msg = errMsg(err);
|
|
46
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('ENOENT') || msg.includes('not found') ? 404 : 500;
|
|
47
|
-
res.status(status).json({ success: false, error: msg });
|
|
48
|
-
}
|
|
49
|
-
});
|
|
34
|
+
const basePath = configService.getBasePath(type);
|
|
35
|
+
const content = configService.readFile(basePath, filePath);
|
|
36
|
+
res.json({ success: true, data: { path: filePath, content } });
|
|
37
|
+
}, inferErrorStatus));
|
|
50
38
|
// PUT /config/:type/file — save file { path, content }
|
|
51
|
-
router.put('/config/:type/file', (req, res) => {
|
|
39
|
+
router.put('/config/:type/file', catchRoute((req, res) => {
|
|
52
40
|
const type = parseType(req.params.type, res);
|
|
53
41
|
if (!type)
|
|
54
42
|
return;
|
|
@@ -57,20 +45,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
57
45
|
res.status(400).json({ success: false, error: 'path and content are required' });
|
|
58
46
|
return;
|
|
59
47
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
const msg = errMsg(err);
|
|
68
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : 500;
|
|
69
|
-
res.status(status).json({ success: false, error: msg });
|
|
70
|
-
}
|
|
71
|
-
});
|
|
48
|
+
const basePath = configService.getBasePath(type);
|
|
49
|
+
configService.writeFile(basePath, filePath, content);
|
|
50
|
+
io.emit(`config:${type}:changed`, { action: 'updated', path: filePath });
|
|
51
|
+
res.json({ success: true });
|
|
52
|
+
}, inferErrorStatus));
|
|
72
53
|
// POST /config/:type/file — create file { path, content }
|
|
73
|
-
router.post('/config/:type/file', (req, res) => {
|
|
54
|
+
router.post('/config/:type/file', catchRoute((req, res) => {
|
|
74
55
|
const type = parseType(req.params.type, res);
|
|
75
56
|
if (!type)
|
|
76
57
|
return;
|
|
@@ -79,20 +60,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
79
60
|
res.status(400).json({ success: false, error: 'path and content are required' });
|
|
80
61
|
return;
|
|
81
62
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
catch (err) {
|
|
89
|
-
const msg = errMsg(err);
|
|
90
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
|
|
91
|
-
res.status(status).json({ success: false, error: msg });
|
|
92
|
-
}
|
|
93
|
-
});
|
|
63
|
+
const basePath = configService.getBasePath(type);
|
|
64
|
+
configService.createFile(basePath, filePath, content);
|
|
65
|
+
io.emit(`config:${type}:changed`, { action: 'created', path: filePath });
|
|
66
|
+
res.status(201).json({ success: true });
|
|
67
|
+
}, inferErrorStatus));
|
|
94
68
|
// DELETE /config/:type/file?path=<relative> — delete file
|
|
95
|
-
router.delete('/config/:type/file', (req, res) => {
|
|
69
|
+
router.delete('/config/:type/file', catchRoute((req, res) => {
|
|
96
70
|
const type = parseType(req.params.type, res);
|
|
97
71
|
if (!type)
|
|
98
72
|
return;
|
|
@@ -101,20 +75,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
101
75
|
res.status(400).json({ success: false, error: 'path query parameter is required' });
|
|
102
76
|
return;
|
|
103
77
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
catch (err) {
|
|
111
|
-
const msg = errMsg(err);
|
|
112
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('directory') ? 400 : 500;
|
|
113
|
-
res.status(status).json({ success: false, error: msg });
|
|
114
|
-
}
|
|
115
|
-
});
|
|
78
|
+
const basePath = configService.getBasePath(type);
|
|
79
|
+
configService.deleteFile(basePath, filePath);
|
|
80
|
+
io.emit(`config:${type}:changed`, { action: 'deleted', path: filePath });
|
|
81
|
+
res.json({ success: true });
|
|
82
|
+
}, inferErrorStatus));
|
|
116
83
|
// POST /config/:type/rename — rename { oldPath, newPath }
|
|
117
|
-
router.post('/config/:type/rename', (req, res) => {
|
|
84
|
+
router.post('/config/:type/rename', catchRoute((req, res) => {
|
|
118
85
|
const type = parseType(req.params.type, res);
|
|
119
86
|
if (!type)
|
|
120
87
|
return;
|
|
@@ -123,20 +90,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
123
90
|
res.status(400).json({ success: false, error: 'oldPath and newPath are required' });
|
|
124
91
|
return;
|
|
125
92
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
catch (err) {
|
|
133
|
-
const msg = errMsg(err);
|
|
134
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('already exists') ? 409 : 500;
|
|
135
|
-
res.status(status).json({ success: false, error: msg });
|
|
136
|
-
}
|
|
137
|
-
});
|
|
93
|
+
const basePath = configService.getBasePath(type);
|
|
94
|
+
configService.renameFile(basePath, oldPath, newPath);
|
|
95
|
+
io.emit(`config:${type}:changed`, { action: 'renamed', oldPath, newPath });
|
|
96
|
+
res.json({ success: true });
|
|
97
|
+
}, inferErrorStatus));
|
|
138
98
|
// POST /config/:type/folder — create folder { path }
|
|
139
|
-
router.post('/config/:type/folder', (req, res) => {
|
|
99
|
+
router.post('/config/:type/folder', catchRoute((req, res) => {
|
|
140
100
|
const type = parseType(req.params.type, res);
|
|
141
101
|
if (!type)
|
|
142
102
|
return;
|
|
@@ -145,17 +105,10 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
|
|
|
145
105
|
res.status(400).json({ success: false, error: 'path is required' });
|
|
146
106
|
return;
|
|
147
107
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
catch (err) {
|
|
155
|
-
const msg = errMsg(err);
|
|
156
|
-
const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
|
|
157
|
-
res.status(status).json({ success: false, error: msg });
|
|
158
|
-
}
|
|
159
|
-
});
|
|
108
|
+
const basePath = configService.getBasePath(type);
|
|
109
|
+
configService.createFolder(basePath, folderPath);
|
|
110
|
+
io.emit(`config:${type}:changed`, { action: 'folder-created', path: folderPath });
|
|
111
|
+
res.status(201).json({ success: true });
|
|
112
|
+
}, inferErrorStatus));
|
|
160
113
|
return router;
|
|
161
114
|
}
|
|
@@ -5,23 +5,12 @@ import { promisify } from 'util';
|
|
|
5
5
|
import { getHookEnforcement } from '../../shared/workflow-config.js';
|
|
6
6
|
import { getClaudeSessions, getSessionStats } from '../services/claude-session-service.js';
|
|
7
7
|
import { launchInTerminal } from '../utils/terminal-launcher.js';
|
|
8
|
+
import { buildClaudeFlags } from '../utils/flag-builder.js';
|
|
8
9
|
import { createLogger } from '../utils/logger.js';
|
|
10
|
+
import { parseJsonFields } from '../utils/json-fields.js';
|
|
9
11
|
const log = createLogger('server');
|
|
10
12
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
|
|
12
|
-
function parseJsonFields(row) {
|
|
13
|
-
const parsed = { ...row };
|
|
14
|
-
for (const field of JSON_FIELDS) {
|
|
15
|
-
if (typeof parsed[field] === 'string') {
|
|
16
|
-
try {
|
|
17
|
-
parsed[field] = JSON.parse(parsed[field]);
|
|
18
|
-
}
|
|
19
|
-
catch { /* keep string */ }
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return parsed;
|
|
23
|
-
}
|
|
24
|
-
export function createDataRoutes({ db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus, }) {
|
|
13
|
+
export function createDataRoutes({ db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus, config, }) {
|
|
25
14
|
const router = Router();
|
|
26
15
|
// ─── Event Routes ──────────────────────────────────────────
|
|
27
16
|
router.get('/events', (req, res) => {
|
|
@@ -289,7 +278,8 @@ export function createDataRoutes({ db, io, eventService, gateService, deployServ
|
|
|
289
278
|
res.status(400).json({ error: 'Valid claude_session_id (UUID) required' });
|
|
290
279
|
return;
|
|
291
280
|
}
|
|
292
|
-
const
|
|
281
|
+
const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
|
|
282
|
+
const resumeCmd = `cd '${projectRoot}' && claude ${flagsStr} --resume '${claude_session_id}'`;
|
|
293
283
|
try {
|
|
294
284
|
await launchInTerminal(resumeCmd);
|
|
295
285
|
res.json({ ok: true, session_id: claude_session_id });
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
3
3
|
import { resolveDispatchEvent, resolveAbandonedDispatchesForScope, getActiveScopeIds, getAbandonedScopeIds, linkPidToDispatch } from '../utils/dispatch-utils.js';
|
|
4
|
+
import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
|
|
4
5
|
import { createLogger } from '../utils/logger.js';
|
|
5
6
|
const log = createLogger('dispatch');
|
|
6
|
-
const
|
|
7
|
-
export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine }) {
|
|
7
|
+
const DEFAULT_MAX_BATCH_SIZE = 20;
|
|
8
|
+
export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine, config }) {
|
|
8
9
|
const router = Router();
|
|
9
10
|
router.get('/dispatch/active-scopes', (_req, res) => {
|
|
10
11
|
const scope_ids = getActiveScopeIds(db, scopeService, engine);
|
|
@@ -44,6 +45,16 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
|
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
// Max concurrent dispatches guard
|
|
49
|
+
const maxConcurrent = config.dispatch.maxConcurrent;
|
|
50
|
+
if (maxConcurrent > 0) {
|
|
51
|
+
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM events
|
|
52
|
+
WHERE type = 'DISPATCH' AND JSON_EXTRACT(data, '$.resolved') IS NULL`).get().count;
|
|
53
|
+
if (activeCount >= maxConcurrent) {
|
|
54
|
+
res.status(429).json({ error: `Max concurrent dispatches reached (${maxConcurrent})` });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
47
58
|
// Update scope status if transition provided
|
|
48
59
|
if (scope_id != null && transition?.to) {
|
|
49
60
|
const result = scopeService.updateStatus(scope_id, transition.to, 'dispatch');
|
|
@@ -66,10 +77,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
|
|
|
66
77
|
const scope = scope_id != null ? scopeService.getById(scope_id) : undefined;
|
|
67
78
|
const sessionName = buildSessionName({ scopeId: scope_id ?? undefined, title: scope?.title, command });
|
|
68
79
|
const beforePids = snapshotSessionPids(projectRoot);
|
|
69
|
-
// Launch in iTerm — interactive TUI mode (no -p) for full visibility
|
|
80
|
+
// Launch in iTerm — interactive TUI mode (no -p unless printMode) for full visibility
|
|
70
81
|
const promptText = prompt ?? command;
|
|
71
82
|
const escaped = escapeForAnsiC(promptText);
|
|
72
|
-
const
|
|
83
|
+
const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
|
|
84
|
+
const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
|
|
85
|
+
const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
|
|
73
86
|
try {
|
|
74
87
|
await launchInCategorizedTerminal(command, fullCmd, sessionName);
|
|
75
88
|
res.json({ ok: true, dispatch_id: eventId, scope_id: scope_id ?? null });
|
|
@@ -160,8 +173,9 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
|
|
|
160
173
|
return;
|
|
161
174
|
}
|
|
162
175
|
// W-12: Validate batch size and scope ID types
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
const maxBatch = config.dispatch.maxBatchSize || DEFAULT_MAX_BATCH_SIZE;
|
|
177
|
+
if (scope_ids.length > maxBatch) {
|
|
178
|
+
res.status(400).json({ error: `Maximum batch size is ${maxBatch}` });
|
|
165
179
|
return;
|
|
166
180
|
}
|
|
167
181
|
if (!scope_ids.every(id => Number.isInteger(id) && id > 0)) {
|
|
@@ -188,10 +202,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
|
|
|
188
202
|
session_id: null, agent: 'dashboard', data: eventData,
|
|
189
203
|
timestamp: new Date().toISOString(),
|
|
190
204
|
});
|
|
191
|
-
// Launch single CLI session
|
|
205
|
+
// Launch single CLI session with batch env vars
|
|
192
206
|
const batchEscaped = escapeForAnsiC(command);
|
|
193
207
|
const beforePids = snapshotSessionPids(projectRoot);
|
|
194
|
-
const
|
|
208
|
+
const batchFlags = buildClaudeFlags(config.claude.dispatchFlags);
|
|
209
|
+
const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
|
|
210
|
+
const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${batchFlags} $'${batchEscaped}'`;
|
|
195
211
|
try {
|
|
196
212
|
await launchInCategorizedTerminal(command, fullCmd);
|
|
197
213
|
res.json({ ok: true, dispatch_id: eventId, scope_ids });
|
|
@@ -120,7 +120,7 @@ export function createManifestRoutes({ projectRoot, templatesDir, packageVersion
|
|
|
120
120
|
manifest = loadManifest(projectRoot);
|
|
121
121
|
}
|
|
122
122
|
if (!manifest) {
|
|
123
|
-
return res.status(400).json({ success: false, error: 'No manifest. Run orbital
|
|
123
|
+
return res.status(400).json({ success: false, error: 'No manifest. Run orbital first.' });
|
|
124
124
|
}
|
|
125
125
|
if (dryRun) {
|
|
126
126
|
refreshFileStatuses(manifest, claudeDir);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import { launchInTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
3
|
+
import { launchInTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
4
4
|
import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
|
|
5
|
+
import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
|
|
5
6
|
import { createLogger } from '../utils/logger.js';
|
|
6
7
|
const log = createLogger('dispatch');
|
|
7
8
|
function isValidSlug(slug) {
|
|
8
9
|
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug) && slug.length <= 80;
|
|
9
10
|
}
|
|
10
|
-
export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine }) {
|
|
11
|
+
export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine, config }) {
|
|
11
12
|
const router = Router();
|
|
12
13
|
// ─── Scope CRUD ──────────────────────────────────────────
|
|
13
14
|
router.get('/scopes', (_req, res) => {
|
|
@@ -103,16 +104,16 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
|
|
|
103
104
|
res.status(400).json({ error: 'Invalid slug' });
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
|
-
const
|
|
107
|
+
const entryPoint = engine.getEntryPoint();
|
|
108
|
+
const targets = engine.getValidTargets(entryPoint.id);
|
|
109
|
+
const promoteTarget = targets[0] ?? 'planning';
|
|
110
|
+
const result = scopeService.promoteIdea(slug, promoteTarget);
|
|
107
111
|
if (!result) {
|
|
108
112
|
res.status(404).json({ error: 'Idea not found' });
|
|
109
113
|
return;
|
|
110
114
|
}
|
|
111
115
|
const scopeId = result.id;
|
|
112
116
|
// Read command from workflow edge config (user-overridable)
|
|
113
|
-
const entryPoint = engine.getEntryPoint();
|
|
114
|
-
const targets = engine.getValidTargets(entryPoint.id);
|
|
115
|
-
const promoteTarget = targets[0] ?? 'planning';
|
|
116
117
|
const edge = engine.findEdge(entryPoint.id, promoteTarget);
|
|
117
118
|
const edgeCommand = edge ? engine.buildCommand(edge, scopeId) : null;
|
|
118
119
|
const command = edgeCommand ?? `/scope-create ${String(scopeId).padStart(3, '0')}`;
|
|
@@ -131,7 +132,9 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
|
|
|
131
132
|
timestamp: new Date().toISOString(),
|
|
132
133
|
});
|
|
133
134
|
const escaped = escapeForAnsiC(command);
|
|
134
|
-
const
|
|
135
|
+
const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
|
|
136
|
+
const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
|
|
137
|
+
const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
|
|
135
138
|
const promoteSessionName = buildSessionName({ scopeId, title: result.title, command });
|
|
136
139
|
const promoteBeforePids = snapshotSessionPids(projectRoot);
|
|
137
140
|
try {
|
|
@@ -89,4 +89,5 @@ CREATE INDEX IF NOT EXISTS idx_sessions_scope ON sessions(scope_id);
|
|
|
89
89
|
CREATE INDEX IF NOT EXISTS idx_sessions_claude_id ON sessions(claude_session_id);
|
|
90
90
|
CREATE INDEX IF NOT EXISTS idx_sprints_status ON sprints(status);
|
|
91
91
|
CREATE INDEX IF NOT EXISTS idx_sprint_scopes_sprint ON sprint_scopes(sprint_id);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_events_dispatch_unresolved ON events(type, scope_id) WHERE type = 'DISPATCH' AND JSON_EXTRACT(data, '$.resolved') IS NULL;
|
|
92
93
|
`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { launchInCategorizedTerminal, escapeForAnsiC, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
|
|
1
|
+
import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
|
|
2
2
|
import { linkPidToDispatch, resolveDispatchEvent } from '../utils/dispatch-utils.js';
|
|
3
|
+
import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
|
|
3
4
|
import { createLogger } from '../utils/logger.js';
|
|
4
5
|
const log = createLogger('batch');
|
|
5
6
|
const VALID_MERGE_MODES = ['push', 'pr'];
|
|
@@ -11,13 +12,15 @@ export class BatchOrchestrator {
|
|
|
11
12
|
scopeService;
|
|
12
13
|
engine;
|
|
13
14
|
projectRoot;
|
|
14
|
-
|
|
15
|
+
config;
|
|
16
|
+
constructor(db, io, sprintService, scopeService, engine, projectRoot, config) {
|
|
15
17
|
this.db = db;
|
|
16
18
|
this.io = io;
|
|
17
19
|
this.sprintService = sprintService;
|
|
18
20
|
this.scopeService = scopeService;
|
|
19
21
|
this.engine = engine;
|
|
20
22
|
this.projectRoot = projectRoot;
|
|
23
|
+
this.config = config;
|
|
21
24
|
}
|
|
22
25
|
/** Dispatch a batch — validates constraints and routes to column-specific handler */
|
|
23
26
|
async dispatch(batchId, mergeMode) {
|
|
@@ -63,7 +66,9 @@ export class BatchOrchestrator {
|
|
|
63
66
|
});
|
|
64
67
|
// Launch single CLI session with BATCH_SCOPE_IDS prepended to command
|
|
65
68
|
const escaped = escapeForAnsiC(command);
|
|
66
|
-
const
|
|
69
|
+
const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
|
|
70
|
+
const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
|
|
71
|
+
const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' BATCH_SCOPE_IDS='${scopeIdsStr}' MERGE_MODE='${mergeModeStr}' claude ${flagsStr} $'${escaped}'`;
|
|
67
72
|
const beforePids = snapshotSessionPids(this.projectRoot);
|
|
68
73
|
try {
|
|
69
74
|
await launchInCategorizedTerminal(command, fullCmd);
|
|
@@ -137,6 +142,15 @@ export class BatchOrchestrator {
|
|
|
137
142
|
if (batch.status !== 'dispatched' && batch.status !== 'in_progress')
|
|
138
143
|
return;
|
|
139
144
|
const scopes = this.sprintService.getSprintScopes(batchId);
|
|
145
|
+
// If batch never reached 'in_progress', the session never started —
|
|
146
|
+
// don't credit any scope regardless of their current workflow status
|
|
147
|
+
if (batch.status === 'dispatched') {
|
|
148
|
+
this.sprintService.updateStatus(batchId, 'failed');
|
|
149
|
+
for (const ss of scopes) {
|
|
150
|
+
this.sprintService.updateScopeStatus(batchId, ss.scope_id, 'failed', 'Session never started');
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
140
154
|
const allTransitioned = scopes.every((ss) => ss.dispatch_status === 'completed');
|
|
141
155
|
if (allTransitioned) {
|
|
142
156
|
this.sprintService.updateStatus(batchId, 'completed');
|
|
@@ -100,7 +100,16 @@ export class ConfigService {
|
|
|
100
100
|
continue;
|
|
101
101
|
const fullPath = path.join(currentPath, entry.name);
|
|
102
102
|
const relPath = path.relative(basePath, fullPath);
|
|
103
|
-
|
|
103
|
+
// Resolve symlinks: Dirent.isDirectory() returns false for symlinks-to-dirs.
|
|
104
|
+
// Self-hosted projects symlink .claude/agents/*, .claude/hooks/*, etc. into templates/.
|
|
105
|
+
let stat;
|
|
106
|
+
try {
|
|
107
|
+
stat = fs.statSync(fullPath);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue; // broken symlink — skip silently
|
|
111
|
+
}
|
|
112
|
+
if (stat.isDirectory()) {
|
|
104
113
|
const children = this.walkDir(fullPath, basePath, parseFrontmatter);
|
|
105
114
|
nodes.push({ name: entry.name, path: relPath, type: 'folder', children });
|
|
106
115
|
}
|
|
@@ -112,7 +112,7 @@ export class ScopeService {
|
|
|
112
112
|
if (id !== undefined) {
|
|
113
113
|
if (previous)
|
|
114
114
|
this.recentlyRemoved.set(id, previous.status);
|
|
115
|
-
this.io.emit('scope:deleted', id);
|
|
115
|
+
this.io.emit('scope:deleted', { id });
|
|
116
116
|
// Clean up stash after a short window (if add never fires, this was a real delete)
|
|
117
117
|
setTimeout(() => this.recentlyRemoved.delete(id), 5000);
|
|
118
118
|
}
|
|
@@ -346,7 +346,7 @@ export class ScopeService {
|
|
|
346
346
|
}
|
|
347
347
|
/** Promote an icebox idea to planning — assigns a proper sequential scope ID,
|
|
348
348
|
* moves the file, and syncs cache. Returns the new scope ID. */
|
|
349
|
-
promoteIdea(slug) {
|
|
349
|
+
promoteIdea(slug, targetStatus = 'planning') {
|
|
350
350
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
351
351
|
const oldPath = this.findIdeaFile(iceboxDir, slug);
|
|
352
352
|
if (!oldPath)
|
|
@@ -362,15 +362,15 @@ export class ScopeService {
|
|
|
362
362
|
const paddedId = String(newId).padStart(3, '0');
|
|
363
363
|
// Build new path
|
|
364
364
|
const titleSlug = this.slugify(title);
|
|
365
|
-
const
|
|
366
|
-
if (!fs.existsSync(
|
|
367
|
-
fs.mkdirSync(
|
|
365
|
+
const targetDir = path.join(this.scopesDir, targetStatus);
|
|
366
|
+
if (!fs.existsSync(targetDir))
|
|
367
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
368
368
|
const newFileName = `${paddedId}-${titleSlug}.md`;
|
|
369
|
-
const newPath = path.join(
|
|
369
|
+
const newPath = path.join(targetDir, newFileName);
|
|
370
370
|
const now = new Date().toISOString().split('T')[0];
|
|
371
371
|
// Update frontmatter in-place: assign ID and change status (preserve other fields)
|
|
372
372
|
parsed.data.id = newId;
|
|
373
|
-
parsed.data.status =
|
|
373
|
+
parsed.data.status = targetStatus;
|
|
374
374
|
parsed.data.updated = now;
|
|
375
375
|
parsed.data.created = created;
|
|
376
376
|
delete parsed.data.ghost;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
1
|
+
import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
2
2
|
import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
|
|
3
|
+
import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
|
|
3
4
|
import { createLogger } from '../utils/logger.js';
|
|
4
5
|
const log = createLogger('sprint');
|
|
5
6
|
const LAUNCH_STAGGER_MS = 2000;
|
|
@@ -14,13 +15,15 @@ export class SprintOrchestrator {
|
|
|
14
15
|
scopeService;
|
|
15
16
|
engine;
|
|
16
17
|
projectRoot;
|
|
17
|
-
|
|
18
|
+
config;
|
|
19
|
+
constructor(db, io, sprintService, scopeService, engine, projectRoot, config) {
|
|
18
20
|
this.db = db;
|
|
19
21
|
this.io = io;
|
|
20
22
|
this.sprintService = sprintService;
|
|
21
23
|
this.scopeService = scopeService;
|
|
22
24
|
this.engine = engine;
|
|
23
25
|
this.projectRoot = projectRoot;
|
|
26
|
+
this.config = config;
|
|
24
27
|
}
|
|
25
28
|
/** Build execution layers using Kahn's topological sort */
|
|
26
29
|
buildExecutionLayers(sprintScopeIds) {
|
|
@@ -98,13 +101,14 @@ export class SprintOrchestrator {
|
|
|
98
101
|
const match = this.sprintService.findActiveSprintForScope(scopeId);
|
|
99
102
|
if (!match)
|
|
100
103
|
return;
|
|
101
|
-
|
|
104
|
+
// Batches are managed by BatchOrchestrator — don't dispatch individual scopes
|
|
102
105
|
const sprintId = match.sprint_id;
|
|
103
|
-
this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
|
|
104
|
-
// Ensure sprint is in 'in_progress' state
|
|
105
106
|
const sprint = this.sprintService.getById(sprintId);
|
|
106
|
-
if (!sprint)
|
|
107
|
+
if (!sprint || sprint.group_type === 'batch')
|
|
107
108
|
return;
|
|
109
|
+
log.debug('Scope reached dev', { scopeId, sprintId });
|
|
110
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
|
|
111
|
+
// Ensure sprint is in 'in_progress' state
|
|
108
112
|
if (sprint.status === 'dispatched') {
|
|
109
113
|
this.sprintService.updateStatus(sprintId, 'in_progress');
|
|
110
114
|
}
|
|
@@ -169,7 +173,7 @@ export class SprintOrchestrator {
|
|
|
169
173
|
const sprint = this.sprintService.getById(sprintId);
|
|
170
174
|
if (!sprint)
|
|
171
175
|
return null;
|
|
172
|
-
const layers = sprint.layers ??
|
|
176
|
+
const layers = sprint.layers ?? this.buildExecutionLayers(sprint.scope_ids).layers;
|
|
173
177
|
const sprintSet = new Set(sprint.scope_ids);
|
|
174
178
|
const edges = [];
|
|
175
179
|
for (const scopeId of sprint.scope_ids) {
|
|
@@ -192,9 +196,14 @@ export class SprintOrchestrator {
|
|
|
192
196
|
// Capture current status before optimistic update (for rollback)
|
|
193
197
|
const currentScope = this.scopeService.getById(scopeId);
|
|
194
198
|
const previousStatus = currentScope?.status ?? 'implementing';
|
|
199
|
+
// Resolve command and target status from workflow engine
|
|
200
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
201
|
+
const targetColumn = sprint?.target_column ?? 'backlog';
|
|
202
|
+
const edgeCommand = this.engine.getBatchCommand(targetColumn);
|
|
203
|
+
const targetStatus = this.engine.getBatchTargetStatus(targetColumn);
|
|
195
204
|
// Record DISPATCH event
|
|
196
205
|
const eventId = crypto.randomUUID();
|
|
197
|
-
const command = `/scope
|
|
206
|
+
const command = edgeCommand ?? `/scope-implement ${scopeId}`;
|
|
198
207
|
this.db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
|
|
199
208
|
VALUES (?, 'DISPATCH', ?, NULL, 'sprint-orchestrator', ?, ?)`).run(eventId, scopeId, JSON.stringify({ command, sprint_id: sprintId, resolved: null }), new Date().toISOString());
|
|
200
209
|
this.io.emit('event:new', {
|
|
@@ -204,15 +213,19 @@ export class SprintOrchestrator {
|
|
|
204
213
|
timestamp: new Date().toISOString(),
|
|
205
214
|
});
|
|
206
215
|
// Update scope + sprint_scope status
|
|
207
|
-
|
|
216
|
+
if (targetStatus) {
|
|
217
|
+
this.scopeService.updateStatus(scopeId, targetStatus, 'dispatch');
|
|
218
|
+
}
|
|
208
219
|
this.sprintService.updateScopeStatus(sprintId, scopeId, 'dispatched');
|
|
209
220
|
// Build scope-aware session name and snapshot PIDs
|
|
210
221
|
const scopeRow = this.scopeService.getById(scopeId);
|
|
211
222
|
const sessionName = buildSessionName({ scopeId, title: scopeRow?.title, command });
|
|
212
223
|
const beforePids = snapshotSessionPids(this.projectRoot);
|
|
213
|
-
// Launch in iTerm — interactive TUI mode
|
|
224
|
+
// Launch in iTerm — interactive TUI mode for full visibility
|
|
214
225
|
const escaped = escapeForAnsiC(command);
|
|
215
|
-
const
|
|
226
|
+
const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
|
|
227
|
+
const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
|
|
228
|
+
const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
|
|
216
229
|
try {
|
|
217
230
|
await launchInCategorizedTerminal(command, fullCmd, sessionName);
|
|
218
231
|
// Fire-and-forget: discover session PID, link to dispatch, and rename
|
|
@@ -73,10 +73,10 @@ export class SprintService {
|
|
|
73
73
|
return null;
|
|
74
74
|
return this.buildDetail(row);
|
|
75
75
|
}
|
|
76
|
-
/** Delete a sprint (
|
|
76
|
+
/** Delete a sprint/batch (assembling, failed, or cancelled) */
|
|
77
77
|
delete(id) {
|
|
78
78
|
const row = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(id);
|
|
79
|
-
if (!row ||
|
|
79
|
+
if (!row || !['assembling', 'failed', 'cancelled', 'completed'].includes(row.status))
|
|
80
80
|
return false;
|
|
81
81
|
this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ?').run(id);
|
|
82
82
|
this.db.prepare('DELETE FROM sprints WHERE id = ?').run(id);
|