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,461 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { getHookEnforcement } from '../../shared/workflow-config.js';
|
|
6
|
+
import { getClaudeSessions, getSessionStats } from '../services/claude-session-service.js';
|
|
7
|
+
import { launchInTerminal } from '../utils/terminal-launcher.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
|
|
10
|
+
function parseJsonFields(row) {
|
|
11
|
+
const parsed = { ...row };
|
|
12
|
+
for (const field of JSON_FIELDS) {
|
|
13
|
+
if (typeof parsed[field] === 'string') {
|
|
14
|
+
try {
|
|
15
|
+
parsed[field] = JSON.parse(parsed[field]);
|
|
16
|
+
}
|
|
17
|
+
catch { /* keep string */ }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
function parseDriftCommits(raw) {
|
|
23
|
+
if (!raw)
|
|
24
|
+
return [];
|
|
25
|
+
return raw.split('\n').map((line) => {
|
|
26
|
+
const [sha, date, message, author] = line.split('|');
|
|
27
|
+
return { sha, date, message: message ?? '', author: author ?? '' };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function parseHead(raw) {
|
|
31
|
+
const [sha, date, message] = raw.split('|');
|
|
32
|
+
return { sha: sha ?? '', date: date ?? '', message: message ?? '' };
|
|
33
|
+
}
|
|
34
|
+
export function createDataRoutes({ db, io, gateService, deployService, engine, projectRoot, inferScopeStatus, }) {
|
|
35
|
+
const router = Router();
|
|
36
|
+
// ─── Pipeline Drift (cached) ─────────────────────────────
|
|
37
|
+
let driftCache = null;
|
|
38
|
+
const DRIFT_CACHE_MS = 60_000;
|
|
39
|
+
async function gitLog(args) {
|
|
40
|
+
const { stdout } = await execFileAsync('git', args, { cwd: projectRoot });
|
|
41
|
+
return stdout.trim();
|
|
42
|
+
}
|
|
43
|
+
async function computeDrift() {
|
|
44
|
+
if (driftCache && Date.now() - driftCache.ts < DRIFT_CACHE_MS)
|
|
45
|
+
return driftCache.data;
|
|
46
|
+
const [devToStagingRaw, stagingToMainRaw, devHead, stagingHead, mainHead] = await Promise.all([
|
|
47
|
+
gitLog(['log', 'origin/dev', '--not', 'origin/staging', '--reverse', '--format=%H|%aI|%s|%an']),
|
|
48
|
+
gitLog(['log', 'origin/staging', '--not', 'origin/main', '--reverse', '--format=%H|%aI|%s|%an']),
|
|
49
|
+
gitLog(['log', 'origin/dev', '-1', '--format=%H|%aI|%s']),
|
|
50
|
+
gitLog(['log', 'origin/staging', '-1', '--format=%H|%aI|%s']),
|
|
51
|
+
gitLog(['log', 'origin/main', '-1', '--format=%H|%aI|%s']),
|
|
52
|
+
]);
|
|
53
|
+
const devToStaging = parseDriftCommits(devToStagingRaw);
|
|
54
|
+
const stagingToMain = parseDriftCommits(stagingToMainRaw);
|
|
55
|
+
const data = {
|
|
56
|
+
devToStaging: {
|
|
57
|
+
count: devToStaging.length,
|
|
58
|
+
commits: devToStaging,
|
|
59
|
+
oldestDate: devToStaging[0]?.date ?? null,
|
|
60
|
+
},
|
|
61
|
+
stagingToMain: {
|
|
62
|
+
count: stagingToMain.length,
|
|
63
|
+
commits: stagingToMain,
|
|
64
|
+
oldestDate: stagingToMain[0]?.date ?? null,
|
|
65
|
+
},
|
|
66
|
+
heads: {
|
|
67
|
+
dev: parseHead(devHead),
|
|
68
|
+
staging: parseHead(stagingHead),
|
|
69
|
+
main: parseHead(mainHead),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
driftCache = { data, ts: Date.now() };
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
// ─── Event Routes ──────────────────────────────────────────
|
|
76
|
+
router.get('/events', (req, res) => {
|
|
77
|
+
const limit = Number(req.query.limit) || 50;
|
|
78
|
+
const type = req.query.type;
|
|
79
|
+
const scopeId = req.query.scope_id;
|
|
80
|
+
let query = 'SELECT * FROM events WHERE 1=1';
|
|
81
|
+
const params = [];
|
|
82
|
+
if (type) {
|
|
83
|
+
query += ' AND type = ?';
|
|
84
|
+
params.push(type);
|
|
85
|
+
}
|
|
86
|
+
if (scopeId) {
|
|
87
|
+
query += ' AND scope_id = ?';
|
|
88
|
+
params.push(Number(scopeId));
|
|
89
|
+
}
|
|
90
|
+
query += ' ORDER BY timestamp DESC LIMIT ?';
|
|
91
|
+
params.push(limit);
|
|
92
|
+
const events = db.prepare(query).all(...params);
|
|
93
|
+
res.json(events.map(parseJsonFields));
|
|
94
|
+
});
|
|
95
|
+
router.post('/events', (req, res) => {
|
|
96
|
+
const { id, type, scope_id, session_id, agent, data, timestamp } = req.body;
|
|
97
|
+
if (!type || typeof type !== 'string') {
|
|
98
|
+
res.status(400).json({ error: 'type must be a non-empty string' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (scope_id != null && (!Number.isInteger(scope_id) || scope_id <= 0)) {
|
|
102
|
+
res.status(400).json({ error: 'scope_id must be a positive integer or null' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const eventId = id || crypto.randomUUID();
|
|
106
|
+
const ts = timestamp || new Date().toISOString();
|
|
107
|
+
const eventData = data ?? {};
|
|
108
|
+
db.prepare(`INSERT OR IGNORE INTO events (id, type, scope_id, session_id, agent, data, timestamp)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(eventId, type, scope_id ?? null, session_id ?? null, agent ?? null, JSON.stringify(eventData), ts);
|
|
110
|
+
const event = { id: eventId, type, scope_id, session_id, agent, data: eventData, timestamp: ts };
|
|
111
|
+
io.emit('event:new', event);
|
|
112
|
+
inferScopeStatus(type, scope_id ?? eventData.scope_id, eventData);
|
|
113
|
+
res.status(201).json(event);
|
|
114
|
+
});
|
|
115
|
+
// ─── Violations Summary ──────────────────────────────────
|
|
116
|
+
router.get('/events/violations/summary', (_req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const byRule = db.prepare(`SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count, MAX(timestamp) as last_seen
|
|
119
|
+
FROM events WHERE type = 'VIOLATION' GROUP BY rule ORDER BY count DESC`).all();
|
|
120
|
+
const byFile = db.prepare(`SELECT JSON_EXTRACT(data, '$.file') as file, COUNT(*) as count FROM events
|
|
121
|
+
WHERE type = 'VIOLATION' AND JSON_EXTRACT(data, '$.file') IS NOT NULL AND JSON_EXTRACT(data, '$.file') != ''
|
|
122
|
+
GROUP BY file ORDER BY count DESC LIMIT 20`).all();
|
|
123
|
+
const overrides = db.prepare(`SELECT JSON_EXTRACT(data, '$.rule') as rule, JSON_EXTRACT(data, '$.reason') as reason, timestamp as date
|
|
124
|
+
FROM events WHERE type = 'OVERRIDE' ORDER BY timestamp DESC LIMIT 50`).all();
|
|
125
|
+
const totalViolations = db.prepare(`SELECT COUNT(*) as count FROM events WHERE type = 'VIOLATION'`).get();
|
|
126
|
+
const totalOverrides = db.prepare(`SELECT COUNT(*) as count FROM events WHERE type = 'OVERRIDE'`).get();
|
|
127
|
+
res.json({ byRule, byFile, overrides, totalViolations: totalViolations.count, totalOverrides: totalOverrides.count });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
res.status(500).json({ error: 'Failed to query violations summary' });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// ─── Enforcement Rules ───────────────────────────────────────
|
|
134
|
+
router.get('/enforcement/rules', (_req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const allHooks = engine.getAllHooks();
|
|
137
|
+
const allEdges = engine.getAllEdges();
|
|
138
|
+
// Build edge map: hookId → edges it's attached to
|
|
139
|
+
const hookEdgeMap = new Map();
|
|
140
|
+
for (const edge of allEdges) {
|
|
141
|
+
for (const hookId of edge.hooks ?? []) {
|
|
142
|
+
if (!hookEdgeMap.has(hookId))
|
|
143
|
+
hookEdgeMap.set(hookId, []);
|
|
144
|
+
hookEdgeMap.get(hookId).push({ from: edge.from, to: edge.to, label: edge.label });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Query violation and override stats per rule
|
|
148
|
+
const violationStats = db.prepare(`SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count, MAX(timestamp) as last_seen
|
|
149
|
+
FROM events WHERE type = 'VIOLATION' GROUP BY rule`).all();
|
|
150
|
+
const overrideStats = db.prepare(`SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count
|
|
151
|
+
FROM events WHERE type = 'OVERRIDE' GROUP BY rule`).all();
|
|
152
|
+
const violationMap = new Map(violationStats.map((v) => [v.rule, v]));
|
|
153
|
+
const overrideMap = new Map(overrideStats.map((o) => [o.rule, o]));
|
|
154
|
+
// Build summary counts
|
|
155
|
+
const summary = { guards: 0, gates: 0, lifecycle: 0, observers: 0 };
|
|
156
|
+
for (const hook of allHooks) {
|
|
157
|
+
if (hook.category === 'guard')
|
|
158
|
+
summary.guards++;
|
|
159
|
+
else if (hook.category === 'gate')
|
|
160
|
+
summary.gates++;
|
|
161
|
+
else if (hook.category === 'lifecycle')
|
|
162
|
+
summary.lifecycle++;
|
|
163
|
+
else if (hook.category === 'observer')
|
|
164
|
+
summary.observers++;
|
|
165
|
+
}
|
|
166
|
+
const rules = allHooks.map((hook) => ({
|
|
167
|
+
hook,
|
|
168
|
+
enforcement: getHookEnforcement(hook),
|
|
169
|
+
edges: hookEdgeMap.get(hook.id) ?? [],
|
|
170
|
+
stats: {
|
|
171
|
+
violations: violationMap.get(hook.id)?.count ?? 0,
|
|
172
|
+
overrides: overrideMap.get(hook.id)?.count ?? 0,
|
|
173
|
+
last_triggered: violationMap.get(hook.id)?.last_seen ?? null,
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
res.json({ summary, rules, totalEdges: allEdges.length });
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
res.status(500).json({ error: 'Failed to query enforcement rules' });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// ─── Violation Trends ──────────────────────────────────────
|
|
183
|
+
router.get('/events/violations/trend', (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const days = Number(req.query.days) || 30;
|
|
186
|
+
const trend = db.prepare(`SELECT date(timestamp) as day, JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count
|
|
187
|
+
FROM events WHERE type = 'VIOLATION' AND timestamp >= datetime('now', ? || ' days')
|
|
188
|
+
GROUP BY day, rule ORDER BY day ASC`).all(`-${days}`);
|
|
189
|
+
res.json(trend);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
res.status(500).json({ error: 'Failed to query violation trends' });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
// ─── Gate Routes ───────────────────────────────────────────
|
|
196
|
+
router.get('/gates', (req, res) => {
|
|
197
|
+
const scopeId = req.query.scope_id;
|
|
198
|
+
if (scopeId) {
|
|
199
|
+
res.json(gateService.getLatestForScope(Number(scopeId)));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
res.json(gateService.getLatestRun());
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
router.get('/gates/trend', (req, res) => {
|
|
206
|
+
const limit = Number(req.query.limit) || 30;
|
|
207
|
+
res.json(gateService.getTrend(limit));
|
|
208
|
+
});
|
|
209
|
+
router.get('/gates/stats', (_req, res) => {
|
|
210
|
+
res.json(gateService.getStats());
|
|
211
|
+
});
|
|
212
|
+
router.post('/gates', (req, res) => {
|
|
213
|
+
const { scope_id, gate_name, status, details, duration_ms, commit_sha } = req.body;
|
|
214
|
+
const VALID_GATE_STATUSES = ['pass', 'fail', 'running', 'skipped'];
|
|
215
|
+
if (!gate_name || typeof gate_name !== 'string') {
|
|
216
|
+
res.status(400).json({ error: 'gate_name must be a non-empty string' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (status && !VALID_GATE_STATUSES.includes(status)) {
|
|
220
|
+
res.status(400).json({ error: `status must be one of: ${VALID_GATE_STATUSES.join(', ')}` });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
gateService.record({ scope_id, gate_name, status, details, duration_ms, commit_sha });
|
|
224
|
+
res.status(201).json({ ok: true });
|
|
225
|
+
});
|
|
226
|
+
// ─── Deployment Routes ─────────────────────────────────────
|
|
227
|
+
router.get('/deployments', (_req, res) => {
|
|
228
|
+
res.json(deployService.getRecent().map(parseJsonFields));
|
|
229
|
+
});
|
|
230
|
+
router.get('/deployments/latest', (_req, res) => {
|
|
231
|
+
res.json(deployService.getLatestPerEnv().map(parseJsonFields));
|
|
232
|
+
});
|
|
233
|
+
router.post('/deployments', (req, res) => {
|
|
234
|
+
const VALID_ENVIRONMENTS = ['staging', 'production'];
|
|
235
|
+
if (req.body.environment && !VALID_ENVIRONMENTS.includes(req.body.environment)) {
|
|
236
|
+
res.status(400).json({ error: `environment must be one of: ${VALID_ENVIRONMENTS.join(', ')}` });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const id = deployService.record(req.body);
|
|
240
|
+
res.status(201).json({ id });
|
|
241
|
+
});
|
|
242
|
+
router.patch('/deployments/:id', (req, res) => {
|
|
243
|
+
const id = Number(req.params.id);
|
|
244
|
+
const { status, details } = req.body;
|
|
245
|
+
if (!status) {
|
|
246
|
+
res.status(400).json({ error: 'status is required' });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
deployService.updateStatus(id, status, details);
|
|
250
|
+
res.json({ ok: true });
|
|
251
|
+
});
|
|
252
|
+
router.get('/pipeline/drift', async (_req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const drift = await computeDrift();
|
|
255
|
+
res.json(drift);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
res.status(500).json({ error: 'Failed to compute drift', details: String(err) });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
router.get('/deployments/frequency', (_req, res) => {
|
|
262
|
+
try {
|
|
263
|
+
const rows = db.prepare(`SELECT environment, strftime('%Y-W%W', started_at) as week, COUNT(*) as count
|
|
264
|
+
FROM deployments WHERE started_at > datetime('now', '-56 days') GROUP BY environment, week ORDER BY week ASC`).all();
|
|
265
|
+
const weekMap = new Map();
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
if (!weekMap.has(row.week))
|
|
268
|
+
weekMap.set(row.week, { week: row.week, staging: 0, production: 0 });
|
|
269
|
+
const entry = weekMap.get(row.week);
|
|
270
|
+
if (row.environment === 'staging')
|
|
271
|
+
entry.staging = row.count;
|
|
272
|
+
if (row.environment === 'production')
|
|
273
|
+
entry.production = row.count;
|
|
274
|
+
}
|
|
275
|
+
res.json([...weekMap.values()]);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
res.status(500).json({ error: 'Failed to query deployment frequency' });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
// ─── Session Routes ────────────────────────────────────────
|
|
282
|
+
router.get('/sessions', (_req, res) => {
|
|
283
|
+
const rows = db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all()
|
|
284
|
+
.map(parseJsonFields);
|
|
285
|
+
const seen = new Map();
|
|
286
|
+
const scopeMap = new Map();
|
|
287
|
+
const actionMap = new Map();
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
const key = row.claude_session_id ?? row.id;
|
|
290
|
+
if (!seen.has(key)) {
|
|
291
|
+
seen.set(key, row);
|
|
292
|
+
scopeMap.set(key, []);
|
|
293
|
+
actionMap.set(key, []);
|
|
294
|
+
}
|
|
295
|
+
const sid = row.scope_id;
|
|
296
|
+
if (sid != null) {
|
|
297
|
+
const arr = scopeMap.get(key);
|
|
298
|
+
if (!arr.includes(sid))
|
|
299
|
+
arr.push(sid);
|
|
300
|
+
}
|
|
301
|
+
const action = row.action;
|
|
302
|
+
if (action) {
|
|
303
|
+
const actions = actionMap.get(key);
|
|
304
|
+
if (!actions.includes(action))
|
|
305
|
+
actions.push(action);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const results = [...seen.values()].map((row) => {
|
|
309
|
+
const key = row.claude_session_id ?? row.id;
|
|
310
|
+
return { ...row, scope_ids: scopeMap.get(key) ?? [], actions: actionMap.get(key) ?? [] };
|
|
311
|
+
});
|
|
312
|
+
res.json(results.slice(0, 50));
|
|
313
|
+
});
|
|
314
|
+
// ─── Scope Sessions ───────────────────────────────────────
|
|
315
|
+
router.get('/scopes/:id/sessions', (req, res) => {
|
|
316
|
+
const scopeId = Number(req.params.id);
|
|
317
|
+
const sessions = db.prepare('SELECT * FROM sessions WHERE scope_id = ? ORDER BY started_at DESC')
|
|
318
|
+
.all(scopeId);
|
|
319
|
+
res.json(sessions.map(parseJsonFields));
|
|
320
|
+
});
|
|
321
|
+
router.get('/sessions/:id/content', async (req, res) => {
|
|
322
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?')
|
|
323
|
+
.get(req.params.id);
|
|
324
|
+
if (!session) {
|
|
325
|
+
res.status(404).json({ error: 'Session not found' });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const parsed = parseJsonFields(session);
|
|
329
|
+
let content = '';
|
|
330
|
+
let meta = null;
|
|
331
|
+
let stats = null;
|
|
332
|
+
if (parsed.claude_session_id && typeof parsed.claude_session_id === 'string') {
|
|
333
|
+
const claudeSessions = await getClaudeSessions();
|
|
334
|
+
const match = claudeSessions.find(s => s.id === parsed.claude_session_id);
|
|
335
|
+
if (match) {
|
|
336
|
+
meta = {
|
|
337
|
+
slug: match.slug,
|
|
338
|
+
branch: match.branch,
|
|
339
|
+
fileSize: match.fileSize,
|
|
340
|
+
summary: match.summary,
|
|
341
|
+
startedAt: match.startedAt,
|
|
342
|
+
lastActiveAt: match.lastActiveAt,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
stats = getSessionStats(parsed.claude_session_id);
|
|
346
|
+
}
|
|
347
|
+
if (!content) {
|
|
348
|
+
const parts = [];
|
|
349
|
+
if (parsed.summary)
|
|
350
|
+
parts.push(`# ${parsed.summary}\n`);
|
|
351
|
+
const discoveries = Array.isArray(parsed.discoveries) ? parsed.discoveries : [];
|
|
352
|
+
if (discoveries.length > 0) {
|
|
353
|
+
parts.push('## Completed\n');
|
|
354
|
+
for (const d of discoveries)
|
|
355
|
+
parts.push(`- ${d}`);
|
|
356
|
+
parts.push('');
|
|
357
|
+
}
|
|
358
|
+
const nextSteps = Array.isArray(parsed.next_steps) ? parsed.next_steps : [];
|
|
359
|
+
if (nextSteps.length > 0) {
|
|
360
|
+
parts.push('## Next Steps\n');
|
|
361
|
+
for (const n of nextSteps)
|
|
362
|
+
parts.push(`- ${n}`);
|
|
363
|
+
}
|
|
364
|
+
content = parts.join('\n');
|
|
365
|
+
}
|
|
366
|
+
res.json({
|
|
367
|
+
id: parsed.id,
|
|
368
|
+
content,
|
|
369
|
+
claude_session_id: parsed.claude_session_id ?? null,
|
|
370
|
+
meta,
|
|
371
|
+
stats,
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
router.post('/sessions/:id/resume', async (req, res) => {
|
|
375
|
+
const { claude_session_id } = req.body;
|
|
376
|
+
if (!claude_session_id || !/^[0-9a-f-]{36}$/i.test(claude_session_id)) {
|
|
377
|
+
res.status(400).json({ error: 'Valid claude_session_id (UUID) required' });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const resumeCmd = `cd '${projectRoot}' && claude --dangerously-skip-permissions --resume '${claude_session_id}'`;
|
|
381
|
+
try {
|
|
382
|
+
await launchInTerminal(resumeCmd);
|
|
383
|
+
res.json({ ok: true, session_id: claude_session_id });
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
res.status(500).json({ error: 'Failed to launch terminal', details: String(err) });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// ─── Git Status ────────────────────────────────────────────
|
|
390
|
+
router.get('/git/status', async (_req, res) => {
|
|
391
|
+
try {
|
|
392
|
+
const [branchResult, statusResult] = await Promise.all([
|
|
393
|
+
execFileAsync('git', ['branch', '--show-current'], { cwd: projectRoot }),
|
|
394
|
+
execFileAsync('git', ['status', '--porcelain'], { cwd: projectRoot }),
|
|
395
|
+
]);
|
|
396
|
+
const branch = branchResult.stdout.trim();
|
|
397
|
+
const dirty = statusResult.stdout.trim().length > 0;
|
|
398
|
+
let detached = false;
|
|
399
|
+
if (!branch) {
|
|
400
|
+
detached = true;
|
|
401
|
+
}
|
|
402
|
+
res.json({ branch: branch || '(detached)', dirty, detached });
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
res.status(500).json({ error: 'Failed to get git status', details: String(err) });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
router.get('/worktrees', async (_req, res) => {
|
|
409
|
+
try {
|
|
410
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: projectRoot });
|
|
411
|
+
const worktrees = [];
|
|
412
|
+
let current = { path: '', branch: '', head: '' };
|
|
413
|
+
for (const line of stdout.split('\n')) {
|
|
414
|
+
if (line.startsWith('worktree ')) {
|
|
415
|
+
if (current.path)
|
|
416
|
+
worktrees.push(current);
|
|
417
|
+
current = { path: line.slice(9), branch: '', head: '' };
|
|
418
|
+
}
|
|
419
|
+
else if (line.startsWith('HEAD ')) {
|
|
420
|
+
current.head = line.slice(5);
|
|
421
|
+
}
|
|
422
|
+
else if (line.startsWith('branch ')) {
|
|
423
|
+
current.branch = line.slice(7);
|
|
424
|
+
}
|
|
425
|
+
else if (line === '') {
|
|
426
|
+
if (current.path)
|
|
427
|
+
worktrees.push(current);
|
|
428
|
+
current = { path: '', branch: '', head: '' };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (current.path)
|
|
432
|
+
worktrees.push(current);
|
|
433
|
+
res.json(worktrees);
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
res.status(500).json({ error: 'Failed to list worktrees', details: String(err) });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
// ─── Open File ──────────────────────────────────────────────
|
|
440
|
+
router.post('/open-file', (req, res) => {
|
|
441
|
+
const filePath = req.query.path || '';
|
|
442
|
+
if (!filePath || filePath.includes('..')) {
|
|
443
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const absolute = path.resolve(projectRoot, filePath);
|
|
447
|
+
const resolvedRoot = path.resolve(projectRoot) + path.sep;
|
|
448
|
+
if (!absolute.startsWith(resolvedRoot)) {
|
|
449
|
+
res.status(400).json({ error: 'Path escapes project root' });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
execFile('open', [absolute], (err) => {
|
|
453
|
+
if (err) {
|
|
454
|
+
res.status(500).json({ error: 'Failed to open file' });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
res.json({ ok: true });
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
return router;
|
|
461
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
|
|
3
|
+
import { resolveDispatchEvent, resolveAbandonedDispatchesForScope, getActiveScopeIds, getAbandonedScopeIds, linkPidToDispatch } from '../utils/dispatch-utils.js';
|
|
4
|
+
import { createLogger } from '../utils/logger.js';
|
|
5
|
+
const log = createLogger('dispatch');
|
|
6
|
+
const MAX_BATCH_SIZE = 20;
|
|
7
|
+
export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine }) {
|
|
8
|
+
const router = Router();
|
|
9
|
+
router.get('/dispatch/active-scopes', (_req, res) => {
|
|
10
|
+
const scope_ids = getActiveScopeIds(db, scopeService, engine);
|
|
11
|
+
const abandoned_scopes = getAbandonedScopeIds(db, scopeService, engine, scope_ids);
|
|
12
|
+
res.json({ scope_ids, abandoned_scopes });
|
|
13
|
+
});
|
|
14
|
+
router.get('/dispatch/active', (req, res) => {
|
|
15
|
+
const scopeId = Number(req.query.scope_id);
|
|
16
|
+
if (isNaN(scopeId) || scopeId <= 0) {
|
|
17
|
+
res.status(400).json({ error: 'Valid scope_id query param required' });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const active = db.prepare(`SELECT id, timestamp, JSON_EXTRACT(data, '$.command') as command
|
|
21
|
+
FROM events
|
|
22
|
+
WHERE type = 'DISPATCH' AND scope_id = ? AND JSON_EXTRACT(data, '$.resolved') IS NULL
|
|
23
|
+
ORDER BY timestamp DESC LIMIT 1`).get(scopeId);
|
|
24
|
+
res.json({ active: active ?? null });
|
|
25
|
+
});
|
|
26
|
+
router.post('/dispatch', async (req, res) => {
|
|
27
|
+
const { scope_id, command, prompt, transition } = req.body;
|
|
28
|
+
if (!command || !engine.isAllowedCommand(command)) {
|
|
29
|
+
res.status(400).json({ error: 'Command must start with /scope-, /git-, /test-, or /session-' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// W-11: Validate prompt field against allowed command prefixes
|
|
33
|
+
if (prompt && !engine.isAllowedCommand(prompt)) {
|
|
34
|
+
res.status(400).json({ error: 'Prompt must start with /scope-, /git-, /test-, or /session-' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Active session guard
|
|
38
|
+
if (scope_id != null) {
|
|
39
|
+
const active = db.prepare(`SELECT id FROM events
|
|
40
|
+
WHERE type = 'DISPATCH' AND scope_id = ? AND JSON_EXTRACT(data, '$.resolved') IS NULL
|
|
41
|
+
ORDER BY timestamp DESC LIMIT 1`).get(scope_id);
|
|
42
|
+
if (active) {
|
|
43
|
+
res.status(409).json({ error: 'Active dispatch exists', dispatch_id: active.id });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Update scope status if transition provided
|
|
48
|
+
if (scope_id != null && transition?.to) {
|
|
49
|
+
const result = scopeService.updateStatus(scope_id, transition.to, 'dispatch');
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
res.status(400).json({ error: result.error });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Record DISPATCH event
|
|
56
|
+
const eventId = crypto.randomUUID();
|
|
57
|
+
const eventData = { command, transition: transition ?? null, resolved: null };
|
|
58
|
+
db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
|
|
59
|
+
VALUES (?, 'DISPATCH', ?, NULL, 'dashboard', ?, ?)`).run(eventId, scope_id ?? null, JSON.stringify(eventData), new Date().toISOString());
|
|
60
|
+
io.emit('event:new', {
|
|
61
|
+
id: eventId, type: 'DISPATCH', scope_id: scope_id ?? null,
|
|
62
|
+
session_id: null, agent: 'dashboard', data: eventData,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
// Build scope-aware session name before launch
|
|
66
|
+
const scope = scope_id != null ? scopeService.getById(scope_id) : undefined;
|
|
67
|
+
const sessionName = buildSessionName({ scopeId: scope_id ?? undefined, title: scope?.title, command });
|
|
68
|
+
const beforePids = snapshotSessionPids(projectRoot);
|
|
69
|
+
// Launch in iTerm — interactive TUI mode (no -p) for full visibility
|
|
70
|
+
const promptText = prompt ?? command;
|
|
71
|
+
const escaped = escapeForAnsiC(promptText);
|
|
72
|
+
const fullCmd = `cd '${projectRoot}' && ORBITAL_DISPATCH_ID='${eventId}' claude --dangerously-skip-permissions $'${escaped}'`;
|
|
73
|
+
try {
|
|
74
|
+
await launchInCategorizedTerminal(command, fullCmd, sessionName);
|
|
75
|
+
res.json({ ok: true, dispatch_id: eventId, scope_id: scope_id ?? null });
|
|
76
|
+
// Fire-and-forget: discover session PID, link to dispatch, and rename
|
|
77
|
+
discoverNewSession(projectRoot, beforePids)
|
|
78
|
+
.then((session) => {
|
|
79
|
+
if (!session)
|
|
80
|
+
return;
|
|
81
|
+
linkPidToDispatch(db, eventId, session.pid);
|
|
82
|
+
if (sessionName)
|
|
83
|
+
renameSession(projectRoot, session.sessionId, sessionName);
|
|
84
|
+
})
|
|
85
|
+
.catch(err => log.error('PID discovery failed', { error: err.message }));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (scope_id != null && transition?.from) {
|
|
89
|
+
scopeService.updateStatus(scope_id, transition.from, 'rollback');
|
|
90
|
+
}
|
|
91
|
+
resolveDispatchEvent(db, io, eventId, 'failed', String(err));
|
|
92
|
+
res.status(500).json({ error: 'Failed to launch terminal', details: String(err) });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
router.post('/dispatch/:id/resolve', (req, res) => {
|
|
96
|
+
const eventId = req.params.id;
|
|
97
|
+
const row = db.prepare('SELECT id FROM events WHERE id = ? AND type = ?')
|
|
98
|
+
.get(eventId, 'DISPATCH');
|
|
99
|
+
if (!row) {
|
|
100
|
+
res.status(404).json({ error: 'Dispatch event not found' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
resolveDispatchEvent(db, io, eventId, 'completed');
|
|
104
|
+
res.json({ ok: true, dispatch_id: eventId });
|
|
105
|
+
});
|
|
106
|
+
/** Recover an abandoned scope by reverting it to its pre-dispatch status. */
|
|
107
|
+
router.post('/dispatch/recover/:scopeId', (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
const scopeId = Number(req.params.scopeId);
|
|
110
|
+
if (isNaN(scopeId) || scopeId <= 0) {
|
|
111
|
+
res.status(400).json({ error: 'Valid scopeId required' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { from_status } = req.body;
|
|
115
|
+
if (!from_status) {
|
|
116
|
+
res.status(400).json({ error: 'from_status is required' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Revert scope to its pre-dispatch status
|
|
120
|
+
const result = scopeService.updateStatus(scopeId, from_status, 'rollback');
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
res.status(400).json({ error: result.error });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
resolveAbandonedDispatchesForScope(db, io, scopeId);
|
|
126
|
+
res.json({ ok: true, scope_id: scopeId, reverted_to: from_status });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
log.error('Error recovering scope', { error: String(err) });
|
|
130
|
+
res.status(500).json({ error: 'Internal server error', details: String(err) });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
/** Dismiss abandoned state without reverting scope status. */
|
|
134
|
+
router.post('/dispatch/dismiss-abandoned/:scopeId', (req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const scopeId = Number(req.params.scopeId);
|
|
137
|
+
if (isNaN(scopeId) || scopeId <= 0) {
|
|
138
|
+
res.status(400).json({ error: 'Valid scopeId required' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const dismissed = resolveAbandonedDispatchesForScope(db, io, scopeId);
|
|
142
|
+
res.json({ ok: true, scope_id: scopeId, dismissed });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
log.error('Error dismissing abandoned dispatches', { error: String(err) });
|
|
146
|
+
res.status(500).json({ error: 'Internal server error', details: String(err) });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
router.post('/dispatch/batch', async (req, res) => {
|
|
150
|
+
const { scope_ids, command, transition } = req.body;
|
|
151
|
+
if (!command || !engine.isAllowedCommand(command)) {
|
|
152
|
+
res.status(400).json({ error: 'Command must start with /scope-, /git-, /test-, or /session-' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(scope_ids) || scope_ids.length === 0) {
|
|
156
|
+
res.status(400).json({ error: 'scope_ids must be a non-empty array' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// W-12: Validate batch size and scope ID types
|
|
160
|
+
if (scope_ids.length > MAX_BATCH_SIZE) {
|
|
161
|
+
res.status(400).json({ error: `Maximum batch size is ${MAX_BATCH_SIZE}` });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!scope_ids.every(id => Number.isInteger(id) && id > 0)) {
|
|
165
|
+
res.status(400).json({ error: 'scope_ids must contain positive integers' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Update all scope statuses
|
|
169
|
+
if (transition?.to) {
|
|
170
|
+
for (const id of scope_ids) {
|
|
171
|
+
const result = scopeService.updateStatus(id, transition.to, 'dispatch');
|
|
172
|
+
if (!result.ok) {
|
|
173
|
+
res.status(400).json({ error: `Scope ${id}: ${result.error}` });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Record single DISPATCH event for the batch
|
|
179
|
+
const eventId = crypto.randomUUID();
|
|
180
|
+
const eventData = { command, transition: transition ?? null, scope_ids, batch: true, resolved: null };
|
|
181
|
+
db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
|
|
182
|
+
VALUES (?, 'DISPATCH', NULL, NULL, 'dashboard', ?, ?)`).run(eventId, JSON.stringify(eventData), new Date().toISOString());
|
|
183
|
+
io.emit('event:new', {
|
|
184
|
+
id: eventId, type: 'DISPATCH', scope_id: null,
|
|
185
|
+
session_id: null, agent: 'dashboard', data: eventData,
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
// Launch single CLI session
|
|
189
|
+
const batchEscaped = escapeForAnsiC(command);
|
|
190
|
+
const beforePids = snapshotSessionPids(projectRoot);
|
|
191
|
+
const fullCmd = `cd '${projectRoot}' && ORBITAL_DISPATCH_ID='${eventId}' claude --dangerously-skip-permissions -p $'${batchEscaped}'`;
|
|
192
|
+
try {
|
|
193
|
+
await launchInCategorizedTerminal(command, fullCmd);
|
|
194
|
+
res.json({ ok: true, dispatch_id: eventId, scope_ids });
|
|
195
|
+
// Fire-and-forget: discover session PID and link to dispatch
|
|
196
|
+
discoverNewSession(projectRoot, beforePids)
|
|
197
|
+
.then((session) => {
|
|
198
|
+
if (!session)
|
|
199
|
+
return;
|
|
200
|
+
linkPidToDispatch(db, eventId, session.pid);
|
|
201
|
+
})
|
|
202
|
+
.catch(err => log.error('Batch PID discovery failed', { error: err.message }));
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (transition?.from) {
|
|
206
|
+
for (const id of scope_ids) {
|
|
207
|
+
scopeService.updateStatus(id, transition.from, 'rollback');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
resolveDispatchEvent(db, io, eventId, 'failed', String(err));
|
|
211
|
+
res.status(500).json({ error: 'Failed to launch terminal', details: String(err) });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
return router;
|
|
215
|
+
}
|