orbital-command 0.1.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/LICENSE +21 -0
- package/README.md +396 -0
- package/bin/orbital.js +362 -0
- package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +84 -0
- package/dist/assets/WorkflowVisualizer-BZV40eAE.css +1 -0
- package/dist/assets/charts-D__PA1zp.js +72 -0
- package/dist/assets/index-D1G6i0nS.css +1 -0
- package/dist/assets/index-DpItvKpf.js +419 -0
- package/dist/assets/ui-BvF022GT.js +53 -0
- package/dist/assets/vendor-Dzv9lrRc.js +59 -0
- package/dist/index.html +19 -0
- package/dist/scanner-sweep.png +0 -0
- package/dist/server/server/adapters/index.js +34 -0
- package/dist/server/server/adapters/iterm2-adapter.js +29 -0
- package/dist/server/server/adapters/subprocess-adapter.js +21 -0
- package/dist/server/server/adapters/terminal-adapter.js +1 -0
- package/dist/server/server/config.js +156 -0
- package/dist/server/server/database.js +90 -0
- package/dist/server/server/index.js +372 -0
- package/dist/server/server/init.js +811 -0
- package/dist/server/server/parsers/event-parser.js +64 -0
- package/dist/server/server/parsers/scope-parser.js +188 -0
- package/dist/server/server/routes/config-routes.js +163 -0
- package/dist/server/server/routes/data-routes.js +461 -0
- package/dist/server/server/routes/dispatch-routes.js +215 -0
- package/dist/server/server/routes/git-routes.js +92 -0
- package/dist/server/server/routes/scope-routes.js +215 -0
- package/dist/server/server/routes/sprint-routes.js +116 -0
- package/dist/server/server/routes/version-routes.js +130 -0
- package/dist/server/server/routes/workflow-routes.js +185 -0
- package/dist/server/server/schema.js +90 -0
- package/dist/server/server/services/batch-orchestrator.js +253 -0
- package/dist/server/server/services/claude-session-service.js +352 -0
- package/dist/server/server/services/config-service.js +132 -0
- package/dist/server/server/services/deploy-service.js +51 -0
- package/dist/server/server/services/event-service.js +63 -0
- package/dist/server/server/services/gate-service.js +83 -0
- package/dist/server/server/services/git-service.js +309 -0
- package/dist/server/server/services/github-service.js +145 -0
- package/dist/server/server/services/readiness-service.js +184 -0
- package/dist/server/server/services/scope-cache.js +72 -0
- package/dist/server/server/services/scope-service.js +424 -0
- package/dist/server/server/services/sprint-orchestrator.js +312 -0
- package/dist/server/server/services/sprint-service.js +293 -0
- package/dist/server/server/services/workflow-service.js +397 -0
- package/dist/server/server/utils/cc-hooks-parser.js +49 -0
- package/dist/server/server/utils/dispatch-utils.js +305 -0
- package/dist/server/server/utils/logger.js +86 -0
- package/dist/server/server/utils/terminal-launcher.js +388 -0
- package/dist/server/server/utils/worktree-manager.js +98 -0
- package/dist/server/server/watchers/event-watcher.js +81 -0
- package/dist/server/server/watchers/scope-watcher.js +33 -0
- package/dist/server/shared/api-types.js +5 -0
- package/dist/server/shared/default-workflow.json +616 -0
- package/dist/server/shared/workflow-config.js +44 -0
- package/dist/server/shared/workflow-engine.js +353 -0
- package/index.html +15 -0
- package/package.json +110 -0
- package/postcss.config.js +6 -0
- package/schemas/orbital.config.schema.json +83 -0
- package/scripts/postinstall.js +24 -0
- package/scripts/start.sh +20 -0
- package/server/adapters/index.ts +41 -0
- package/server/adapters/iterm2-adapter.ts +37 -0
- package/server/adapters/subprocess-adapter.ts +25 -0
- package/server/adapters/terminal-adapter.ts +24 -0
- package/server/config.ts +234 -0
- package/server/database.ts +107 -0
- package/server/index.ts +452 -0
- package/server/init.ts +891 -0
- package/server/parsers/event-parser.ts +74 -0
- package/server/parsers/scope-parser.ts +240 -0
- package/server/routes/config-routes.ts +182 -0
- package/server/routes/data-routes.ts +548 -0
- package/server/routes/dispatch-routes.ts +275 -0
- package/server/routes/git-routes.ts +112 -0
- package/server/routes/scope-routes.ts +262 -0
- package/server/routes/sprint-routes.ts +142 -0
- package/server/routes/version-routes.ts +156 -0
- package/server/routes/workflow-routes.ts +198 -0
- package/server/schema.ts +90 -0
- package/server/services/batch-orchestrator.ts +286 -0
- package/server/services/claude-session-service.ts +441 -0
- package/server/services/config-service.ts +151 -0
- package/server/services/deploy-service.ts +98 -0
- package/server/services/event-service.ts +98 -0
- package/server/services/gate-service.ts +126 -0
- package/server/services/git-service.ts +391 -0
- package/server/services/github-service.ts +183 -0
- package/server/services/readiness-service.ts +250 -0
- package/server/services/scope-cache.ts +81 -0
- package/server/services/scope-service.ts +476 -0
- package/server/services/sprint-orchestrator.ts +361 -0
- package/server/services/sprint-service.ts +415 -0
- package/server/services/workflow-service.ts +461 -0
- package/server/utils/cc-hooks-parser.ts +70 -0
- package/server/utils/dispatch-utils.ts +395 -0
- package/server/utils/logger.ts +109 -0
- package/server/utils/terminal-launcher.ts +462 -0
- package/server/utils/worktree-manager.ts +104 -0
- package/server/watchers/event-watcher.ts +100 -0
- package/server/watchers/scope-watcher.ts +38 -0
- package/shared/api-types.ts +20 -0
- package/shared/default-workflow.json +616 -0
- package/shared/workflow-config.ts +170 -0
- package/shared/workflow-engine.ts +427 -0
- package/src/App.tsx +33 -0
- package/src/components/AgentBadge.tsx +40 -0
- package/src/components/BatchPreflightModal.tsx +115 -0
- package/src/components/CardDisplayToggle.tsx +74 -0
- package/src/components/ColumnHeaderActions.tsx +55 -0
- package/src/components/ColumnMenu.tsx +99 -0
- package/src/components/DeployHistory.tsx +141 -0
- package/src/components/DispatchModal.tsx +164 -0
- package/src/components/DispatchPopover.tsx +139 -0
- package/src/components/DragOverlay.tsx +25 -0
- package/src/components/DriftSidebar.tsx +140 -0
- package/src/components/EnvironmentStrip.tsx +88 -0
- package/src/components/ErrorBoundary.tsx +62 -0
- package/src/components/FilterChip.tsx +105 -0
- package/src/components/GateIndicator.tsx +33 -0
- package/src/components/IdeaDetailModal.tsx +190 -0
- package/src/components/IdeaFormDialog.tsx +113 -0
- package/src/components/KanbanColumn.tsx +201 -0
- package/src/components/MarkdownRenderer.tsx +114 -0
- package/src/components/NeonGrid.tsx +128 -0
- package/src/components/PromotionQueue.tsx +89 -0
- package/src/components/ScopeCard.tsx +234 -0
- package/src/components/ScopeDetailModal.tsx +255 -0
- package/src/components/ScopeFilterBar.tsx +152 -0
- package/src/components/SearchInput.tsx +102 -0
- package/src/components/SessionPanel.tsx +335 -0
- package/src/components/SprintContainer.tsx +303 -0
- package/src/components/SprintDependencyDialog.tsx +78 -0
- package/src/components/SprintPreflightModal.tsx +138 -0
- package/src/components/StatusBar.tsx +168 -0
- package/src/components/SwimCell.tsx +67 -0
- package/src/components/SwimLaneRow.tsx +94 -0
- package/src/components/SwimlaneBoardView.tsx +108 -0
- package/src/components/VersionBadge.tsx +139 -0
- package/src/components/ViewModeSelector.tsx +114 -0
- package/src/components/config/AgentChip.tsx +53 -0
- package/src/components/config/AgentCreateDialog.tsx +321 -0
- package/src/components/config/AgentEditor.tsx +175 -0
- package/src/components/config/DirectoryTree.tsx +582 -0
- package/src/components/config/FileEditor.tsx +550 -0
- package/src/components/config/HookChip.tsx +50 -0
- package/src/components/config/StageCard.tsx +198 -0
- package/src/components/config/TransitionZone.tsx +173 -0
- package/src/components/config/UnifiedWorkflowPipeline.tsx +216 -0
- package/src/components/config/WorkflowPipeline.tsx +161 -0
- package/src/components/source-control/BranchList.tsx +93 -0
- package/src/components/source-control/BranchPanel.tsx +105 -0
- package/src/components/source-control/CommitLog.tsx +100 -0
- package/src/components/source-control/CommitRow.tsx +47 -0
- package/src/components/source-control/GitHubPanel.tsx +110 -0
- package/src/components/source-control/GitHubSetupGuide.tsx +52 -0
- package/src/components/source-control/GitOverviewBar.tsx +101 -0
- package/src/components/source-control/PullRequestList.tsx +69 -0
- package/src/components/source-control/WorktreeList.tsx +80 -0
- package/src/components/ui/badge.tsx +41 -0
- package/src/components/ui/button.tsx +55 -0
- package/src/components/ui/card.tsx +78 -0
- package/src/components/ui/dialog.tsx +94 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/ui/toggle-switch.tsx +35 -0
- package/src/components/ui/tooltip.tsx +27 -0
- package/src/components/workflow/AddEdgeDialog.tsx +217 -0
- package/src/components/workflow/AddListDialog.tsx +201 -0
- package/src/components/workflow/ChecklistEditor.tsx +239 -0
- package/src/components/workflow/CommandPrefixManager.tsx +118 -0
- package/src/components/workflow/ConfigSettingsPanel.tsx +189 -0
- package/src/components/workflow/DirectionSelector.tsx +133 -0
- package/src/components/workflow/DispatchConfigPanel.tsx +180 -0
- package/src/components/workflow/EdgeDetailPanel.tsx +236 -0
- package/src/components/workflow/EdgePropertyEditor.tsx +251 -0
- package/src/components/workflow/EditToolbar.tsx +138 -0
- package/src/components/workflow/HookDetailPanel.tsx +250 -0
- package/src/components/workflow/HookExecutionLog.tsx +24 -0
- package/src/components/workflow/HookSourceModal.tsx +129 -0
- package/src/components/workflow/HooksDashboard.tsx +363 -0
- package/src/components/workflow/ListPropertyEditor.tsx +251 -0
- package/src/components/workflow/MigrationPreviewDialog.tsx +237 -0
- package/src/components/workflow/MovementRulesPanel.tsx +188 -0
- package/src/components/workflow/NodeDetailPanel.tsx +245 -0
- package/src/components/workflow/PresetSelector.tsx +414 -0
- package/src/components/workflow/SkillCommandBuilder.tsx +174 -0
- package/src/components/workflow/WorkflowEdgeComponent.tsx +145 -0
- package/src/components/workflow/WorkflowNode.tsx +147 -0
- package/src/components/workflow/graphLayout.ts +186 -0
- package/src/components/workflow/mergeHooks.ts +85 -0
- package/src/components/workflow/useEditHistory.ts +88 -0
- package/src/components/workflow/useWorkflowEditor.ts +262 -0
- package/src/components/workflow/validateConfig.ts +70 -0
- package/src/hooks/useActiveDispatches.ts +198 -0
- package/src/hooks/useBoardSettings.ts +170 -0
- package/src/hooks/useCardDisplay.ts +57 -0
- package/src/hooks/useCcHooks.ts +24 -0
- package/src/hooks/useConfigTree.ts +51 -0
- package/src/hooks/useEnforcementRules.ts +46 -0
- package/src/hooks/useEvents.ts +59 -0
- package/src/hooks/useFileEditor.ts +165 -0
- package/src/hooks/useGates.ts +57 -0
- package/src/hooks/useIdeaActions.ts +53 -0
- package/src/hooks/useKanbanDnd.ts +410 -0
- package/src/hooks/useOrbitalConfig.ts +54 -0
- package/src/hooks/usePipeline.ts +47 -0
- package/src/hooks/usePipelineData.ts +338 -0
- package/src/hooks/useReconnect.ts +25 -0
- package/src/hooks/useScopeFilters.ts +125 -0
- package/src/hooks/useScopeSessions.ts +44 -0
- package/src/hooks/useScopes.ts +67 -0
- package/src/hooks/useSearch.ts +67 -0
- package/src/hooks/useSettings.tsx +187 -0
- package/src/hooks/useSocket.ts +25 -0
- package/src/hooks/useSourceControl.ts +105 -0
- package/src/hooks/useSprintPreflight.ts +55 -0
- package/src/hooks/useSprints.ts +154 -0
- package/src/hooks/useStatusBarHighlight.ts +18 -0
- package/src/hooks/useSwimlaneBoardSettings.ts +104 -0
- package/src/hooks/useTheme.ts +9 -0
- package/src/hooks/useTransitionReadiness.ts +53 -0
- package/src/hooks/useVersion.ts +155 -0
- package/src/hooks/useViolations.ts +65 -0
- package/src/hooks/useWorkflow.tsx +125 -0
- package/src/hooks/useZoomModifier.ts +19 -0
- package/src/index.css +797 -0
- package/src/layouts/DashboardLayout.tsx +113 -0
- package/src/lib/collisionDetection.ts +20 -0
- package/src/lib/scope-fields.ts +61 -0
- package/src/lib/swimlane.ts +146 -0
- package/src/lib/utils.ts +15 -0
- package/src/main.tsx +19 -0
- package/src/socket.ts +11 -0
- package/src/types/index.ts +497 -0
- package/src/views/AgentFeed.tsx +339 -0
- package/src/views/DeployPipeline.tsx +59 -0
- package/src/views/EnforcementView.tsx +378 -0
- package/src/views/PrimitivesConfig.tsx +500 -0
- package/src/views/QualityGates.tsx +1012 -0
- package/src/views/ScopeBoard.tsx +454 -0
- package/src/views/SessionTimeline.tsx +516 -0
- package/src/views/Settings.tsx +183 -0
- package/src/views/SourceControl.tsx +95 -0
- package/src/views/WorkflowVisualizer.tsx +382 -0
- package/tailwind.config.js +161 -0
- package/templates/agents/AUTO-INVOKE.md +180 -0
- package/templates/agents/CONFLICT-RESOLUTION.md +128 -0
- package/templates/agents/QUICK-REFERENCE.md +122 -0
- package/templates/agents/README.md +188 -0
- package/templates/agents/SKILL-TRIGGERS.md +100 -0
- package/templates/agents/blue-team/frontend-designer.md +424 -0
- package/templates/agents/green-team/architect.md +526 -0
- package/templates/agents/green-team/rules-enforcer.md +131 -0
- package/templates/agents/red-team/attacker-learned.md +24 -0
- package/templates/agents/red-team/attacker.md +486 -0
- package/templates/agents/red-team/chaos.md +548 -0
- package/templates/agents/reference/component-registry.md +82 -0
- package/templates/agents/workflows/full-mode.md +218 -0
- package/templates/agents/workflows/quick-mode.md +118 -0
- package/templates/agents/workflows/security-mode.md +283 -0
- package/templates/anti-patterns/dangerous-shortcuts.md +427 -0
- package/templates/config/agent-triggers.json +92 -0
- package/templates/hooks/agent-team-gate.sh +31 -0
- package/templates/hooks/agent-trigger.sh +97 -0
- package/templates/hooks/block-push.sh +66 -0
- package/templates/hooks/block-workarounds.sh +61 -0
- package/templates/hooks/blocker-check.sh +28 -0
- package/templates/hooks/completion-checklist.sh +28 -0
- package/templates/hooks/decision-capture.sh +15 -0
- package/templates/hooks/dependency-check.sh +27 -0
- package/templates/hooks/end-session.sh +31 -0
- package/templates/hooks/exploration-logger.sh +37 -0
- package/templates/hooks/files-changed-summary.sh +37 -0
- package/templates/hooks/get-session-id.sh +49 -0
- package/templates/hooks/git-commit-guard.sh +34 -0
- package/templates/hooks/init-session.sh +93 -0
- package/templates/hooks/orbital-emit.sh +79 -0
- package/templates/hooks/orbital-report-deploy.sh +78 -0
- package/templates/hooks/orbital-report-gates.sh +40 -0
- package/templates/hooks/orbital-report-violation.sh +36 -0
- package/templates/hooks/orbital-scope-update.sh +53 -0
- package/templates/hooks/phase-verify-reminder.sh +26 -0
- package/templates/hooks/review-gate-check.sh +82 -0
- package/templates/hooks/scope-commit-logger.sh +37 -0
- package/templates/hooks/scope-create-cleanup.sh +36 -0
- package/templates/hooks/scope-create-gate.sh +80 -0
- package/templates/hooks/scope-create-tracker.sh +17 -0
- package/templates/hooks/scope-file-sync.sh +53 -0
- package/templates/hooks/scope-gate.sh +35 -0
- package/templates/hooks/scope-helpers.sh +188 -0
- package/templates/hooks/scope-lifecycle-gate.sh +139 -0
- package/templates/hooks/scope-prepare.sh +244 -0
- package/templates/hooks/scope-transition.sh +172 -0
- package/templates/hooks/session-enforcer.sh +143 -0
- package/templates/hooks/time-tracker.sh +33 -0
- package/templates/lessons-learned.md +15 -0
- package/templates/orbital.config.json +35 -0
- package/templates/presets/development.json +42 -0
- package/templates/presets/gitflow.json +712 -0
- package/templates/presets/minimal.json +23 -0
- package/templates/quick/rules.md +218 -0
- package/templates/scopes/_template.md +255 -0
- package/templates/settings-hooks.json +98 -0
- package/templates/skills/git-commit/SKILL.md +85 -0
- package/templates/skills/git-dev/SKILL.md +99 -0
- package/templates/skills/git-hotfix/SKILL.md +223 -0
- package/templates/skills/git-main/SKILL.md +84 -0
- package/templates/skills/git-production/SKILL.md +165 -0
- package/templates/skills/git-staging/SKILL.md +112 -0
- package/templates/skills/scope-create/SKILL.md +81 -0
- package/templates/skills/scope-fix-review/SKILL.md +168 -0
- package/templates/skills/scope-implement/SKILL.md +110 -0
- package/templates/skills/scope-post-review/SKILL.md +144 -0
- package/templates/skills/scope-pre-review/SKILL.md +211 -0
- package/templates/skills/scope-verify/SKILL.md +201 -0
- package/templates/skills/session-init/SKILL.md +62 -0
- package/templates/skills/session-resume/SKILL.md +201 -0
- package/templates/skills/test-checks/SKILL.md +171 -0
- package/templates/skills/test-code-review/SKILL.md +252 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +38 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
2
|
+
import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
|
|
3
|
+
import { getConfig } from '../config.js';
|
|
4
|
+
import { createLogger } from '../utils/logger.js';
|
|
5
|
+
const log = createLogger('sprint');
|
|
6
|
+
const LAUNCH_STAGGER_MS = 2000;
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
// ─── Orchestrator ───────────────────────────────────────────
|
|
11
|
+
export class SprintOrchestrator {
|
|
12
|
+
db;
|
|
13
|
+
io;
|
|
14
|
+
sprintService;
|
|
15
|
+
scopeService;
|
|
16
|
+
engine;
|
|
17
|
+
constructor(db, io, sprintService, scopeService, engine) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
this.io = io;
|
|
20
|
+
this.sprintService = sprintService;
|
|
21
|
+
this.scopeService = scopeService;
|
|
22
|
+
this.engine = engine;
|
|
23
|
+
}
|
|
24
|
+
/** Build execution layers using Kahn's topological sort */
|
|
25
|
+
buildExecutionLayers(sprintScopeIds) {
|
|
26
|
+
const sprintSet = new Set(sprintScopeIds);
|
|
27
|
+
// Load dependency info for each scope in the sprint
|
|
28
|
+
const scopeDeps = new Map();
|
|
29
|
+
for (const id of sprintScopeIds) {
|
|
30
|
+
const scope = this.scopeService.getById(id);
|
|
31
|
+
if (!scope)
|
|
32
|
+
continue;
|
|
33
|
+
// Only keep deps that are WITHIN the sprint
|
|
34
|
+
scopeDeps.set(id, scope.blocked_by.filter((d) => sprintSet.has(d)));
|
|
35
|
+
}
|
|
36
|
+
// Build in-degree map — in-degree = count of internal deps for each scope
|
|
37
|
+
const inDegree = new Map();
|
|
38
|
+
for (const [id, deps] of scopeDeps) {
|
|
39
|
+
inDegree.set(id, deps.length);
|
|
40
|
+
}
|
|
41
|
+
const layers = [];
|
|
42
|
+
const remaining = new Set(sprintScopeIds);
|
|
43
|
+
while (remaining.size > 0) {
|
|
44
|
+
// Find all nodes with in-degree 0
|
|
45
|
+
const layer = [];
|
|
46
|
+
for (const id of remaining) {
|
|
47
|
+
if ((inDegree.get(id) ?? 0) === 0) {
|
|
48
|
+
layer.push(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (layer.length === 0) {
|
|
52
|
+
// Cycle detected — return remaining as cycle
|
|
53
|
+
return { layers, cycle: [...remaining] };
|
|
54
|
+
}
|
|
55
|
+
// Remove this layer and decrement dependents
|
|
56
|
+
for (const id of layer) {
|
|
57
|
+
remaining.delete(id);
|
|
58
|
+
}
|
|
59
|
+
// Decrement in-degree for scopes that depended on this layer's scopes
|
|
60
|
+
for (const id of remaining) {
|
|
61
|
+
const deps = scopeDeps.get(id) ?? [];
|
|
62
|
+
let newDeg = 0;
|
|
63
|
+
for (const dep of deps) {
|
|
64
|
+
if (remaining.has(dep))
|
|
65
|
+
newDeg++;
|
|
66
|
+
}
|
|
67
|
+
inDegree.set(id, newDeg);
|
|
68
|
+
}
|
|
69
|
+
layers.push(layer.sort((a, b) => a - b));
|
|
70
|
+
}
|
|
71
|
+
return { layers, cycle: [] };
|
|
72
|
+
}
|
|
73
|
+
/** Start sprint dispatch: build layers, persist, launch Layer 0 */
|
|
74
|
+
async startSprint(sprintId) {
|
|
75
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
76
|
+
if (!sprint)
|
|
77
|
+
return { ok: false, error: 'Sprint not found' };
|
|
78
|
+
if (sprint.status !== 'assembling')
|
|
79
|
+
return { ok: false, error: `Sprint status is '${sprint.status}', expected 'assembling'` };
|
|
80
|
+
if (sprint.scope_ids.length === 0)
|
|
81
|
+
return { ok: false, error: 'Sprint has no scopes' };
|
|
82
|
+
// Build dependency graph
|
|
83
|
+
const { layers, cycle } = this.buildExecutionLayers(sprint.scope_ids);
|
|
84
|
+
if (cycle.length > 0) {
|
|
85
|
+
return { ok: false, error: `Dependency cycle detected among scopes: ${cycle.join(', ')}` };
|
|
86
|
+
}
|
|
87
|
+
// Persist layer assignments
|
|
88
|
+
this.sprintService.setLayers(sprintId, layers);
|
|
89
|
+
this.sprintService.updateStatus(sprintId, 'dispatched');
|
|
90
|
+
log.info('Sprint started', { id: sprintId, layers: layers.length, scopes: sprint.scope_ids.length });
|
|
91
|
+
// Dispatch Layer 0
|
|
92
|
+
await this.dispatchLayer(sprintId, layers[0], sprint.concurrency_cap);
|
|
93
|
+
return { ok: true, layers };
|
|
94
|
+
}
|
|
95
|
+
/** Called when a scope reaches 'dev' status — advance the sprint */
|
|
96
|
+
async onScopeReachedDev(scopeId) {
|
|
97
|
+
const match = this.sprintService.findActiveSprintForScope(scopeId);
|
|
98
|
+
if (!match)
|
|
99
|
+
return;
|
|
100
|
+
log.debug('Scope reached dev', { scopeId, sprintId: match.sprint_id });
|
|
101
|
+
const sprintId = match.sprint_id;
|
|
102
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
|
|
103
|
+
// Ensure sprint is in 'in_progress' state
|
|
104
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
105
|
+
if (!sprint)
|
|
106
|
+
return;
|
|
107
|
+
if (sprint.status === 'dispatched') {
|
|
108
|
+
this.sprintService.updateStatus(sprintId, 'in_progress');
|
|
109
|
+
}
|
|
110
|
+
// Check for newly unblocked scopes and dispatch them
|
|
111
|
+
await this.dispatchUnblockedScopes(sprintId);
|
|
112
|
+
this.checkSprintCompletion(sprintId);
|
|
113
|
+
}
|
|
114
|
+
/** Called when a scope fails during sprint execution */
|
|
115
|
+
async onScopeFailed(scopeId, error) {
|
|
116
|
+
const match = this.sprintService.findActiveSprintForScope(scopeId);
|
|
117
|
+
if (!match)
|
|
118
|
+
return;
|
|
119
|
+
const sprintId = match.sprint_id;
|
|
120
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'failed', error);
|
|
121
|
+
// Skip downstream dependents transitively
|
|
122
|
+
this.skipDownstream(sprintId, scopeId);
|
|
123
|
+
// Try dispatching other unblocked parallel paths
|
|
124
|
+
await this.dispatchUnblockedScopes(sprintId);
|
|
125
|
+
this.checkSprintCompletion(sprintId);
|
|
126
|
+
}
|
|
127
|
+
/** Cancel an active sprint */
|
|
128
|
+
cancelSprint(sprintId) {
|
|
129
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
130
|
+
if (!sprint)
|
|
131
|
+
return false;
|
|
132
|
+
if (!['assembling', 'dispatched', 'in_progress'].includes(sprint.status))
|
|
133
|
+
return false;
|
|
134
|
+
// Mark pending/queued scopes as skipped
|
|
135
|
+
const scopes = this.sprintService.getSprintScopes(sprintId);
|
|
136
|
+
for (const ss of scopes) {
|
|
137
|
+
if (ss.dispatch_status === 'pending' || ss.dispatch_status === 'queued') {
|
|
138
|
+
this.sprintService.updateScopeStatus(sprintId, ss.scope_id, 'skipped');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.sprintService.updateStatus(sprintId, 'cancelled');
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/** Recover active sprints after server restart */
|
|
145
|
+
async recoverActiveSprints() {
|
|
146
|
+
const active = this.db.prepare(`SELECT id FROM sprints WHERE group_type = 'sprint' AND status IN ('dispatched', 'in_progress')`).all();
|
|
147
|
+
if (active.length > 0) {
|
|
148
|
+
log.info('Recovering active sprints', { count: active.length });
|
|
149
|
+
}
|
|
150
|
+
for (const { id } of active) {
|
|
151
|
+
// Check if any scopes completed while server was down
|
|
152
|
+
const scopes = this.sprintService.getSprintScopes(id);
|
|
153
|
+
for (const ss of scopes) {
|
|
154
|
+
if (ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'in_progress') {
|
|
155
|
+
// Check actual scope status
|
|
156
|
+
const scope = this.scopeService.getById(ss.scope_id);
|
|
157
|
+
if (scope && this.engine.getStatusOrder(scope.status) >= this.engine.getStatusOrder('dev')) {
|
|
158
|
+
this.sprintService.updateScopeStatus(id, ss.scope_id, 'completed');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
await this.dispatchUnblockedScopes(id);
|
|
163
|
+
this.checkSprintCompletion(id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Get execution graph data for visualization */
|
|
167
|
+
getExecutionGraph(sprintId) {
|
|
168
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
169
|
+
if (!sprint)
|
|
170
|
+
return null;
|
|
171
|
+
const layers = sprint.layers ?? [];
|
|
172
|
+
const sprintSet = new Set(sprint.scope_ids);
|
|
173
|
+
const edges = [];
|
|
174
|
+
for (const scopeId of sprint.scope_ids) {
|
|
175
|
+
const scope = this.scopeService.getById(scopeId);
|
|
176
|
+
if (!scope)
|
|
177
|
+
continue;
|
|
178
|
+
for (const dep of scope.blocked_by) {
|
|
179
|
+
if (sprintSet.has(dep)) {
|
|
180
|
+
edges.push({ from: dep, to: scopeId });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { layers, edges };
|
|
185
|
+
}
|
|
186
|
+
// ─── Private Helpers ────────────────────────────────────────
|
|
187
|
+
async dispatchLayer(sprintId, scopeIds, concurrencyCap) {
|
|
188
|
+
const toDispatch = scopeIds.slice(0, concurrencyCap);
|
|
189
|
+
for (let i = 0; i < toDispatch.length; i++) {
|
|
190
|
+
const scopeId = toDispatch[i];
|
|
191
|
+
// Capture current status before optimistic update (for rollback)
|
|
192
|
+
const currentScope = this.scopeService.getById(scopeId);
|
|
193
|
+
const previousStatus = currentScope?.status ?? 'implementing';
|
|
194
|
+
// Record DISPATCH event
|
|
195
|
+
const eventId = crypto.randomUUID();
|
|
196
|
+
const command = `/scope implement ${scopeId}`;
|
|
197
|
+
this.db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
|
|
198
|
+
VALUES (?, 'DISPATCH', ?, NULL, 'sprint-orchestrator', ?, ?)`).run(eventId, scopeId, JSON.stringify({ command, sprint_id: sprintId, resolved: null }), new Date().toISOString());
|
|
199
|
+
this.io.emit('event:new', {
|
|
200
|
+
id: eventId, type: 'DISPATCH', scope_id: scopeId,
|
|
201
|
+
session_id: null, agent: 'sprint-orchestrator',
|
|
202
|
+
data: { command, sprint_id: sprintId, resolved: null },
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
// Update scope + sprint_scope status
|
|
206
|
+
this.scopeService.updateStatus(scopeId, 'implementing', 'dispatch');
|
|
207
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'dispatched');
|
|
208
|
+
// Build scope-aware session name and snapshot PIDs
|
|
209
|
+
const scopeRow = this.scopeService.getById(scopeId);
|
|
210
|
+
const sessionName = buildSessionName({ scopeId, title: scopeRow?.title, command });
|
|
211
|
+
const beforePids = snapshotSessionPids(getConfig().projectRoot);
|
|
212
|
+
// Launch in iTerm — interactive TUI mode (no -p) for full visibility
|
|
213
|
+
const escaped = escapeForAnsiC(command);
|
|
214
|
+
const fullCmd = `cd '${getConfig().projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
|
|
215
|
+
try {
|
|
216
|
+
await launchInCategorizedTerminal(command, fullCmd, sessionName);
|
|
217
|
+
// Fire-and-forget: discover session PID, link to dispatch, and rename
|
|
218
|
+
discoverNewSession(getConfig().projectRoot, beforePids)
|
|
219
|
+
.then((session) => {
|
|
220
|
+
if (!session)
|
|
221
|
+
return;
|
|
222
|
+
linkPidToDispatch(this.db, eventId, session.pid);
|
|
223
|
+
if (sessionName)
|
|
224
|
+
renameSession(getConfig().projectRoot, session.sessionId, sessionName);
|
|
225
|
+
})
|
|
226
|
+
.catch(err => log.error('PID discovery failed', { error: err.message }));
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
// Rollback scope status to previous value
|
|
230
|
+
this.scopeService.updateStatus(scopeId, previousStatus, 'rollback');
|
|
231
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'failed', `Launch failed: ${err}`);
|
|
232
|
+
resolveDispatchEvent(this.db, this.io, eventId, 'failed', `Launch failed: ${err}`);
|
|
233
|
+
}
|
|
234
|
+
// Stagger launches to prevent AppleScript race conditions
|
|
235
|
+
if (i < toDispatch.length - 1) {
|
|
236
|
+
await sleep(LAUNCH_STAGGER_MS);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async dispatchUnblockedScopes(sprintId) {
|
|
241
|
+
const sprint = this.sprintService.getById(sprintId);
|
|
242
|
+
if (!sprint)
|
|
243
|
+
return;
|
|
244
|
+
const scopes = this.sprintService.getSprintScopes(sprintId);
|
|
245
|
+
const completedSet = new Set(scopes.filter((ss) => ss.dispatch_status === 'completed').map((ss) => ss.scope_id));
|
|
246
|
+
const activeCount = scopes.filter((ss) => ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'in_progress').length;
|
|
247
|
+
const available = sprint.concurrency_cap - activeCount;
|
|
248
|
+
if (available <= 0)
|
|
249
|
+
return;
|
|
250
|
+
// Find pending scopes whose internal deps are all completed
|
|
251
|
+
const ready = [];
|
|
252
|
+
for (const ss of scopes) {
|
|
253
|
+
if (ss.dispatch_status !== 'pending')
|
|
254
|
+
continue;
|
|
255
|
+
const scope = this.scopeService.getById(ss.scope_id);
|
|
256
|
+
if (!scope)
|
|
257
|
+
continue;
|
|
258
|
+
const internalDeps = scope.blocked_by.filter((d) => sprint.scope_ids.includes(d));
|
|
259
|
+
const allMet = internalDeps.every((d) => completedSet.has(d));
|
|
260
|
+
if (allMet)
|
|
261
|
+
ready.push(ss.scope_id);
|
|
262
|
+
if (ready.length >= available)
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (ready.length > 0) {
|
|
266
|
+
await this.dispatchLayer(sprintId, ready, available);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
skipDownstream(sprintId, failedScopeId) {
|
|
270
|
+
const scopes = this.sprintService.getSprintScopes(sprintId);
|
|
271
|
+
const sprintScopeIds = scopes.map((ss) => ss.scope_id);
|
|
272
|
+
// Build reverse dependency map: scope → scopes that depend on it
|
|
273
|
+
const dependents = new Map();
|
|
274
|
+
for (const scopeId of sprintScopeIds) {
|
|
275
|
+
const scope = this.scopeService.getById(scopeId);
|
|
276
|
+
if (!scope)
|
|
277
|
+
continue;
|
|
278
|
+
for (const dep of scope.blocked_by) {
|
|
279
|
+
if (!dependents.has(dep))
|
|
280
|
+
dependents.set(dep, []);
|
|
281
|
+
dependents.get(dep).push(scopeId);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// BFS to find all transitive dependents
|
|
285
|
+
const toSkip = new Set();
|
|
286
|
+
const queue = [failedScopeId];
|
|
287
|
+
while (queue.length > 0) {
|
|
288
|
+
const current = queue.shift();
|
|
289
|
+
const downstream = dependents.get(current) ?? [];
|
|
290
|
+
for (const id of downstream) {
|
|
291
|
+
if (!toSkip.has(id)) {
|
|
292
|
+
toSkip.add(id);
|
|
293
|
+
queue.push(id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const scopeId of toSkip) {
|
|
298
|
+
const ss = scopes.find((s) => s.scope_id === scopeId);
|
|
299
|
+
if (ss && ss.dispatch_status === 'pending') {
|
|
300
|
+
this.sprintService.updateScopeStatus(sprintId, scopeId, 'skipped', `Skipped: dependency ${failedScopeId} failed`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
checkSprintCompletion(sprintId) {
|
|
305
|
+
const scopes = this.sprintService.getSprintScopes(sprintId);
|
|
306
|
+
const allDone = scopes.every((ss) => ss.dispatch_status === 'completed' || ss.dispatch_status === 'failed' || ss.dispatch_status === 'skipped');
|
|
307
|
+
if (!allDone)
|
|
308
|
+
return;
|
|
309
|
+
const anyFailed = scopes.some((ss) => ss.dispatch_status === 'failed');
|
|
310
|
+
this.sprintService.updateStatus(sprintId, anyFailed ? 'failed' : 'completed');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { createLogger } from '../utils/logger.js';
|
|
2
|
+
const log = createLogger('sprint');
|
|
3
|
+
// ─── Service ────────────────────────────────────────────────
|
|
4
|
+
export class SprintService {
|
|
5
|
+
db;
|
|
6
|
+
io;
|
|
7
|
+
scopeService;
|
|
8
|
+
constructor(db, io, scopeService) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.io = io;
|
|
11
|
+
this.scopeService = scopeService;
|
|
12
|
+
}
|
|
13
|
+
/** Create a new sprint or batch in assembling state */
|
|
14
|
+
create(name, options) {
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
const targetColumn = options?.target_column ?? 'backlog';
|
|
17
|
+
const groupType = options?.group_type ?? 'sprint';
|
|
18
|
+
const result = this.db.prepare(`INSERT INTO sprints (name, status, concurrency_cap, created_at, updated_at, target_column, group_type)
|
|
19
|
+
VALUES (?, 'assembling', 5, ?, ?, ?, ?)`).run(name, now, now, targetColumn, groupType);
|
|
20
|
+
const sprint = this.getById(Number(result.lastInsertRowid));
|
|
21
|
+
log.info('Sprint created', { id: sprint.id, name, group_type: groupType, target_column: targetColumn });
|
|
22
|
+
this.io.emit('sprint:created', sprint);
|
|
23
|
+
return sprint;
|
|
24
|
+
}
|
|
25
|
+
/** Rename a sprint/batch (only while assembling) */
|
|
26
|
+
rename(id, name) {
|
|
27
|
+
const result = this.db.prepare(`UPDATE sprints SET name = ?, updated_at = ? WHERE id = ? AND status = 'assembling'`).run(name, new Date().toISOString(), id);
|
|
28
|
+
if (result.changes > 0) {
|
|
29
|
+
this.emitUpdate(id);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** List sprints, optionally filtered by status and/or target column */
|
|
35
|
+
getAll(status, targetColumn) {
|
|
36
|
+
let rows;
|
|
37
|
+
if (status && targetColumn) {
|
|
38
|
+
rows = this.db.prepare('SELECT * FROM sprints WHERE status = ? AND target_column = ? ORDER BY created_at DESC')
|
|
39
|
+
.all(status, targetColumn);
|
|
40
|
+
}
|
|
41
|
+
else if (status) {
|
|
42
|
+
rows = this.db.prepare('SELECT * FROM sprints WHERE status = ? ORDER BY created_at DESC').all(status);
|
|
43
|
+
}
|
|
44
|
+
else if (targetColumn) {
|
|
45
|
+
rows = this.db.prepare('SELECT * FROM sprints WHERE target_column = ? ORDER BY created_at DESC').all(targetColumn);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
rows = this.db.prepare('SELECT * FROM sprints ORDER BY created_at DESC').all();
|
|
49
|
+
}
|
|
50
|
+
return rows.map((row) => this.buildDetail(row));
|
|
51
|
+
}
|
|
52
|
+
/** Get full sprint detail by ID */
|
|
53
|
+
getById(id) {
|
|
54
|
+
const row = this.db.prepare('SELECT * FROM sprints WHERE id = ?').get(id);
|
|
55
|
+
if (!row)
|
|
56
|
+
return null;
|
|
57
|
+
return this.buildDetail(row);
|
|
58
|
+
}
|
|
59
|
+
/** Delete a sprint (only if assembling) */
|
|
60
|
+
delete(id) {
|
|
61
|
+
const row = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(id);
|
|
62
|
+
if (!row || row.status !== 'assembling')
|
|
63
|
+
return false;
|
|
64
|
+
this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ?').run(id);
|
|
65
|
+
this.db.prepare('DELETE FROM sprints WHERE id = ?').run(id);
|
|
66
|
+
this.io.emit('sprint:deleted', { id });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Add scopes to a sprint; returns which were added and any unmet dependencies */
|
|
70
|
+
addScopes(sprintId, scopeIds) {
|
|
71
|
+
const sprint = this.db.prepare('SELECT * FROM sprints WHERE id = ?').get(sprintId);
|
|
72
|
+
if (!sprint || sprint.status !== 'assembling')
|
|
73
|
+
return null;
|
|
74
|
+
// Existing scope IDs already in this sprint
|
|
75
|
+
const existingIds = new Set(this.db.prepare('SELECT scope_id FROM sprint_scopes WHERE sprint_id = ?').all(sprintId)
|
|
76
|
+
.map((r) => r.scope_id));
|
|
77
|
+
const added = [];
|
|
78
|
+
const unmet = [];
|
|
79
|
+
const insert = this.db.prepare(`INSERT OR IGNORE INTO sprint_scopes (sprint_id, scope_id, dispatch_status)
|
|
80
|
+
VALUES (?, ?, 'pending')`);
|
|
81
|
+
for (const scopeId of scopeIds) {
|
|
82
|
+
if (existingIds.has(scopeId))
|
|
83
|
+
continue;
|
|
84
|
+
// Check dependencies via cache
|
|
85
|
+
const scope = this.scopeService.getById(scopeId);
|
|
86
|
+
if (!scope)
|
|
87
|
+
continue;
|
|
88
|
+
// W-8: For batch groups, validate scope status matches target column
|
|
89
|
+
if (sprint.group_type === 'batch' && scope.status !== sprint.target_column) {
|
|
90
|
+
continue; // silently skip — frontend shows toast for rejected drops
|
|
91
|
+
}
|
|
92
|
+
const missing = [];
|
|
93
|
+
for (const depId of scope.blocked_by) {
|
|
94
|
+
if (existingIds.has(depId) || scopeIds.includes(depId))
|
|
95
|
+
continue;
|
|
96
|
+
// Check if dependency is already complete (dev or beyond)
|
|
97
|
+
const dep = this.scopeService.getById(depId);
|
|
98
|
+
if (!dep)
|
|
99
|
+
continue;
|
|
100
|
+
const completedStatuses = ['dev', 'staging', 'production'];
|
|
101
|
+
if (!completedStatuses.includes(dep.status)) {
|
|
102
|
+
missing.push({ scope_id: dep.id, title: dep.title, status: dep.status });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
unmet.push({ scope_id: scopeId, missing });
|
|
107
|
+
}
|
|
108
|
+
insert.run(sprintId, scopeId);
|
|
109
|
+
existingIds.add(scopeId);
|
|
110
|
+
added.push(scopeId);
|
|
111
|
+
}
|
|
112
|
+
this.touchUpdatedAt(sprintId);
|
|
113
|
+
this.emitUpdate(sprintId);
|
|
114
|
+
return { added, unmet_dependencies: unmet };
|
|
115
|
+
}
|
|
116
|
+
/** Remove scopes from a sprint (assembling only) */
|
|
117
|
+
removeScopes(sprintId, scopeIds) {
|
|
118
|
+
const sprint = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(sprintId);
|
|
119
|
+
if (!sprint || sprint.status !== 'assembling')
|
|
120
|
+
return false;
|
|
121
|
+
const remove = this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ? AND scope_id = ?');
|
|
122
|
+
for (const scopeId of scopeIds) {
|
|
123
|
+
remove.run(sprintId, scopeId);
|
|
124
|
+
}
|
|
125
|
+
this.touchUpdatedAt(sprintId);
|
|
126
|
+
this.emitUpdate(sprintId);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** Update sprint status */
|
|
130
|
+
updateStatus(id, status) {
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
const extras = {};
|
|
133
|
+
if (status === 'dispatched')
|
|
134
|
+
extras.dispatched_at = now;
|
|
135
|
+
if (status === 'completed' || status === 'failed' || status === 'cancelled')
|
|
136
|
+
extras.completed_at = now;
|
|
137
|
+
const setClauses = ['status = ?', 'updated_at = ?'];
|
|
138
|
+
const params = [status, now];
|
|
139
|
+
for (const [col, val] of Object.entries(extras)) {
|
|
140
|
+
setClauses.push(`${col} = ?`);
|
|
141
|
+
params.push(val);
|
|
142
|
+
}
|
|
143
|
+
params.push(id);
|
|
144
|
+
const result = this.db.prepare(`UPDATE sprints SET ${setClauses.join(', ')} WHERE id = ?`).run(...params);
|
|
145
|
+
if (result.changes > 0) {
|
|
146
|
+
log.info('Sprint status updated', { id, status });
|
|
147
|
+
this.emitUpdate(id);
|
|
148
|
+
if (status === 'completed') {
|
|
149
|
+
const detail = this.getById(id);
|
|
150
|
+
if (detail)
|
|
151
|
+
this.io.emit('sprint:completed', detail);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return result.changes > 0;
|
|
155
|
+
}
|
|
156
|
+
/** Update a sprint scope's dispatch status */
|
|
157
|
+
updateScopeStatus(sprintId, scopeId, status, error) {
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
const extras = [];
|
|
160
|
+
const params = [status];
|
|
161
|
+
if (status === 'dispatched') {
|
|
162
|
+
extras.push('dispatched_at = ?');
|
|
163
|
+
params.push(now);
|
|
164
|
+
}
|
|
165
|
+
if (status === 'completed' || status === 'failed' || status === 'skipped') {
|
|
166
|
+
extras.push('completed_at = ?');
|
|
167
|
+
params.push(now);
|
|
168
|
+
}
|
|
169
|
+
if (error != null) {
|
|
170
|
+
extras.push('error = ?');
|
|
171
|
+
params.push(error);
|
|
172
|
+
}
|
|
173
|
+
const setClauses = ['dispatch_status = ?', ...extras];
|
|
174
|
+
params.push(sprintId, scopeId);
|
|
175
|
+
this.db.prepare(`UPDATE sprint_scopes SET ${setClauses.join(', ')} WHERE sprint_id = ? AND scope_id = ?`).run(...params);
|
|
176
|
+
this.emitUpdate(sprintId);
|
|
177
|
+
}
|
|
178
|
+
/** Persist layer assignments for all scopes in a sprint */
|
|
179
|
+
setLayers(sprintId, layers) {
|
|
180
|
+
const update = this.db.prepare('UPDATE sprint_scopes SET layer = ? WHERE sprint_id = ? AND scope_id = ?');
|
|
181
|
+
for (let i = 0; i < layers.length; i++) {
|
|
182
|
+
for (const scopeId of layers[i]) {
|
|
183
|
+
update.run(i, sprintId, scopeId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.db.prepare('UPDATE sprints SET dispatch_meta = ?, updated_at = ? WHERE id = ?')
|
|
187
|
+
.run(JSON.stringify({ layers }), new Date().toISOString(), sprintId);
|
|
188
|
+
}
|
|
189
|
+
/** Find the active sprint containing a given scope (for orchestrator callbacks) */
|
|
190
|
+
findActiveSprintForScope(scopeId) {
|
|
191
|
+
return this.db.prepare(`SELECT ss.sprint_id FROM sprint_scopes ss
|
|
192
|
+
JOIN sprints s ON s.id = ss.sprint_id
|
|
193
|
+
WHERE ss.scope_id = ? AND s.status IN ('dispatched', 'in_progress')
|
|
194
|
+
LIMIT 1`).get(scopeId);
|
|
195
|
+
}
|
|
196
|
+
/** Find any active group (assembling/dispatched/in_progress) containing a scope.
|
|
197
|
+
* Used to guard against moving scopes that are part of an active batch/sprint. */
|
|
198
|
+
getActiveGroupForScope(scopeId) {
|
|
199
|
+
return this.db.prepare(`SELECT ss.sprint_id, s.group_type FROM sprint_scopes ss
|
|
200
|
+
JOIN sprints s ON s.id = ss.sprint_id
|
|
201
|
+
WHERE ss.scope_id = ? AND s.status IN ('assembling', 'dispatched', 'in_progress')
|
|
202
|
+
LIMIT 1`).get(scopeId);
|
|
203
|
+
}
|
|
204
|
+
/** Force-remove a scope from a sprint regardless of sprint status.
|
|
205
|
+
* Used for cleanup when a scope's status diverges from the batch target. */
|
|
206
|
+
forceRemoveScope(sprintId, scopeId) {
|
|
207
|
+
this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ? AND scope_id = ?')
|
|
208
|
+
.run(sprintId, scopeId);
|
|
209
|
+
this.touchUpdatedAt(sprintId);
|
|
210
|
+
this.emitUpdate(sprintId);
|
|
211
|
+
}
|
|
212
|
+
/** Get all sprint scopes for a sprint */
|
|
213
|
+
getSprintScopes(sprintId) {
|
|
214
|
+
return this.db.prepare('SELECT * FROM sprint_scopes WHERE sprint_id = ?').all(sprintId);
|
|
215
|
+
}
|
|
216
|
+
/** Store typed dispatch result (commit SHA, PR URL, etc.) for a batch */
|
|
217
|
+
updateDispatchResult(id, result) {
|
|
218
|
+
this.db.prepare('UPDATE sprints SET dispatch_result = ?, updated_at = ? WHERE id = ?')
|
|
219
|
+
.run(JSON.stringify(result), new Date().toISOString(), id);
|
|
220
|
+
this.emitUpdate(id);
|
|
221
|
+
}
|
|
222
|
+
/** Check if there's an active (assembling/dispatched/in_progress) batch in the given column */
|
|
223
|
+
findActiveBatchForColumn(targetColumn) {
|
|
224
|
+
const row = this.db.prepare(`SELECT * FROM sprints WHERE group_type = 'batch' AND target_column = ? AND status IN ('assembling', 'dispatched', 'in_progress')
|
|
225
|
+
ORDER BY created_at DESC LIMIT 1`).get(targetColumn);
|
|
226
|
+
if (!row)
|
|
227
|
+
return null;
|
|
228
|
+
return this.buildDetail(row);
|
|
229
|
+
}
|
|
230
|
+
// ─── Private Helpers ────────────────────────────────────────
|
|
231
|
+
buildDetail(row) {
|
|
232
|
+
const ssRows = this.db.prepare(`SELECT scope_id, layer, dispatch_status FROM sprint_scopes
|
|
233
|
+
WHERE sprint_id = ? ORDER BY layer ASC, scope_id ASC`).all(row.id);
|
|
234
|
+
const progress = { pending: 0, in_progress: 0, completed: 0, failed: 0, skipped: 0 };
|
|
235
|
+
const scopes = [];
|
|
236
|
+
for (const ss of ssRows) {
|
|
237
|
+
const scope = this.scopeService.getById(ss.scope_id);
|
|
238
|
+
scopes.push({
|
|
239
|
+
scope_id: ss.scope_id,
|
|
240
|
+
title: scope?.title ?? `Scope ${ss.scope_id}`,
|
|
241
|
+
scope_status: scope?.status ?? 'unknown',
|
|
242
|
+
effort_estimate: scope?.effort_estimate ?? null,
|
|
243
|
+
layer: ss.layer,
|
|
244
|
+
dispatch_status: ss.dispatch_status,
|
|
245
|
+
});
|
|
246
|
+
const key = ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'queued'
|
|
247
|
+
? 'in_progress' : ss.dispatch_status;
|
|
248
|
+
if (key in progress)
|
|
249
|
+
progress[key]++;
|
|
250
|
+
else
|
|
251
|
+
progress.pending++;
|
|
252
|
+
}
|
|
253
|
+
let layers = null;
|
|
254
|
+
try {
|
|
255
|
+
const meta = JSON.parse(row.dispatch_meta || '{}');
|
|
256
|
+
if (meta.layers)
|
|
257
|
+
layers = meta.layers;
|
|
258
|
+
}
|
|
259
|
+
catch { /* ignore */ }
|
|
260
|
+
let dispatchResult = null;
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(row.dispatch_result || '{}');
|
|
263
|
+
if (Object.keys(parsed).length > 0)
|
|
264
|
+
dispatchResult = parsed;
|
|
265
|
+
}
|
|
266
|
+
catch { /* ignore */ }
|
|
267
|
+
return {
|
|
268
|
+
id: row.id,
|
|
269
|
+
name: row.name,
|
|
270
|
+
status: row.status,
|
|
271
|
+
concurrency_cap: row.concurrency_cap,
|
|
272
|
+
group_type: row.group_type ?? 'sprint',
|
|
273
|
+
target_column: row.target_column ?? 'backlog',
|
|
274
|
+
dispatch_result: dispatchResult,
|
|
275
|
+
scope_ids: ssRows.map((ss) => ss.scope_id),
|
|
276
|
+
scopes,
|
|
277
|
+
layers,
|
|
278
|
+
progress,
|
|
279
|
+
created_at: row.created_at,
|
|
280
|
+
updated_at: row.updated_at,
|
|
281
|
+
dispatched_at: row.dispatched_at,
|
|
282
|
+
completed_at: row.completed_at,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
touchUpdatedAt(id) {
|
|
286
|
+
this.db.prepare('UPDATE sprints SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
|
287
|
+
}
|
|
288
|
+
emitUpdate(id) {
|
|
289
|
+
const detail = this.getById(id);
|
|
290
|
+
if (detail)
|
|
291
|
+
this.io.emit('sprint:updated', detail);
|
|
292
|
+
}
|
|
293
|
+
}
|