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,441 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import readline from 'readline';
|
|
4
|
+
import type Database from 'better-sqlite3';
|
|
5
|
+
import type { ScopeService } from './scope-service.js';
|
|
6
|
+
import { getConfig, getClaudeSessionsDir } from '../config.js';
|
|
7
|
+
|
|
8
|
+
export interface ClaudeSession {
|
|
9
|
+
id: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
lastActiveAt: string;
|
|
14
|
+
summary: string | null;
|
|
15
|
+
fileSize: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionStats {
|
|
19
|
+
/** Count of each JSONL line type */
|
|
20
|
+
typeCounts: Record<string, number>;
|
|
21
|
+
/** Fields extracted from 'user' lines */
|
|
22
|
+
user: {
|
|
23
|
+
totalMessages: number;
|
|
24
|
+
metaMessages: number;
|
|
25
|
+
toolResults: number;
|
|
26
|
+
commands: string[];
|
|
27
|
+
permissionModes: string[];
|
|
28
|
+
cwd: string | null;
|
|
29
|
+
version: string | null;
|
|
30
|
+
};
|
|
31
|
+
/** Fields extracted from 'assistant' lines */
|
|
32
|
+
assistant: {
|
|
33
|
+
totalMessages: number;
|
|
34
|
+
models: string[];
|
|
35
|
+
totalInputTokens: number;
|
|
36
|
+
totalOutputTokens: number;
|
|
37
|
+
totalCacheReadTokens: number;
|
|
38
|
+
totalCacheCreationTokens: number;
|
|
39
|
+
toolsUsed: Record<string, number>;
|
|
40
|
+
};
|
|
41
|
+
/** Fields extracted from 'system' lines */
|
|
42
|
+
system: {
|
|
43
|
+
totalMessages: number;
|
|
44
|
+
subtypes: string[];
|
|
45
|
+
stopReasons: string[];
|
|
46
|
+
totalDurationMs: number;
|
|
47
|
+
hookCount: number;
|
|
48
|
+
hookErrors: number;
|
|
49
|
+
};
|
|
50
|
+
/** Fields extracted from 'progress' lines */
|
|
51
|
+
progress: {
|
|
52
|
+
totalLines: number;
|
|
53
|
+
};
|
|
54
|
+
/** Timing */
|
|
55
|
+
timing: {
|
|
56
|
+
firstTimestamp: string | null;
|
|
57
|
+
lastTimestamp: string | null;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getSessionsDir(): string {
|
|
63
|
+
return getClaudeSessionsDir(getConfig().projectRoot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cache: { sessions: ClaudeSession[]; expiry: number } | null = null;
|
|
67
|
+
const CACHE_TTL_MS = 60_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract metadata from a JSONL session file by reading the first few
|
|
71
|
+
* lines and the last line (avoids parsing the entire file).
|
|
72
|
+
*/
|
|
73
|
+
async function parseSessionFile(filePath: string): Promise<ClaudeSession | null> {
|
|
74
|
+
const stat = fs.statSync(filePath);
|
|
75
|
+
const filename = path.basename(filePath, '.jsonl');
|
|
76
|
+
|
|
77
|
+
let sessionId = filename;
|
|
78
|
+
let slug = '';
|
|
79
|
+
let branch = '';
|
|
80
|
+
let startedAt = '';
|
|
81
|
+
let summary: string | null = null;
|
|
82
|
+
|
|
83
|
+
// Read first 20 lines for metadata
|
|
84
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
85
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
86
|
+
let lineNum = 0;
|
|
87
|
+
|
|
88
|
+
for await (const line of rl) {
|
|
89
|
+
if (lineNum > 20) break;
|
|
90
|
+
lineNum++;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const data = JSON.parse(line);
|
|
94
|
+
|
|
95
|
+
if (data.sessionId && !sessionId) sessionId = data.sessionId;
|
|
96
|
+
if (data.slug && !slug) slug = data.slug;
|
|
97
|
+
if (data.gitBranch && !branch) branch = data.gitBranch;
|
|
98
|
+
|
|
99
|
+
// Capture the earliest timestamp
|
|
100
|
+
if (!startedAt) {
|
|
101
|
+
const ts =
|
|
102
|
+
data.timestamp ??
|
|
103
|
+
data.snapshot?.timestamp;
|
|
104
|
+
if (ts) startedAt = ts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If we have all fields, stop early
|
|
108
|
+
if (sessionId && slug && branch && startedAt) break;
|
|
109
|
+
} catch {
|
|
110
|
+
// skip unparseable lines
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
rl.close();
|
|
115
|
+
stream.destroy();
|
|
116
|
+
|
|
117
|
+
if (!sessionId) return null;
|
|
118
|
+
|
|
119
|
+
// Read file content for summary extraction
|
|
120
|
+
try {
|
|
121
|
+
const fullContent = fs.readFileSync(filePath, 'utf-8');
|
|
122
|
+
const lines = fullContent.trimEnd().split('\n');
|
|
123
|
+
|
|
124
|
+
// Prefer explicit summary line from Claude
|
|
125
|
+
const lastLine = JSON.parse(lines[lines.length - 1]);
|
|
126
|
+
if (lastLine.type === 'summary' && lastLine.summary) {
|
|
127
|
+
summary = lastLine.summary;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fall back to first user message
|
|
131
|
+
if (!summary) {
|
|
132
|
+
summary = extractFirstUserMessage(lines, 120);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// ignore
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
id: sessionId,
|
|
140
|
+
slug: slug || filename,
|
|
141
|
+
branch: branch || 'unknown',
|
|
142
|
+
startedAt: startedAt || stat.birthtime.toISOString(),
|
|
143
|
+
lastActiveAt: stat.mtime.toISOString(),
|
|
144
|
+
summary,
|
|
145
|
+
fileSize: stat.size,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function getClaudeSessions(since?: string): Promise<ClaudeSession[]> {
|
|
150
|
+
if (cache && Date.now() < cache.expiry) {
|
|
151
|
+
return filterSince(cache.sessions, since);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sessionsDir = getSessionsDir();
|
|
155
|
+
if (!fs.existsSync(sessionsDir)) return [];
|
|
156
|
+
|
|
157
|
+
const files = fs
|
|
158
|
+
.readdirSync(sessionsDir)
|
|
159
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
160
|
+
.map((f) => path.join(sessionsDir, f));
|
|
161
|
+
|
|
162
|
+
const sessions: ClaudeSession[] = [];
|
|
163
|
+
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const session = await parseSessionFile(file);
|
|
166
|
+
if (session) sessions.push(session);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Sort by most recent first
|
|
170
|
+
sessions.sort(
|
|
171
|
+
(a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime(),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
cache = { sessions, expiry: Date.now() + CACHE_TTL_MS };
|
|
175
|
+
return filterSince(sessions, since);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function filterSince(sessions: ClaudeSession[], since?: string): ClaudeSession[] {
|
|
179
|
+
if (!since) return sessions;
|
|
180
|
+
const cutoff = new Date(since).getTime();
|
|
181
|
+
return sessions.filter((s) => new Date(s.lastActiveAt).getTime() >= cutoff);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Truncate text to max length with ellipsis */
|
|
185
|
+
function truncate(text: string | null | undefined, max: number): string {
|
|
186
|
+
if (!text) return '';
|
|
187
|
+
return text.length > max ? text.slice(0, max) + '...' : text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract a meaningful session name from JSONL lines.
|
|
192
|
+
*
|
|
193
|
+
* Priority:
|
|
194
|
+
* 1. First non-meta user message (what the user actually typed)
|
|
195
|
+
* 2. Slash command name from the first command-message (e.g. "/scope review 1")
|
|
196
|
+
* 3. null if nothing useful found
|
|
197
|
+
*
|
|
198
|
+
* Skips isMeta messages (skill prompts injected by the system),
|
|
199
|
+
* tool_result lines, and raw command XML.
|
|
200
|
+
*/
|
|
201
|
+
function extractFirstUserMessage(lines: string[], max: number): string | null {
|
|
202
|
+
let slashCommand: string | null = null;
|
|
203
|
+
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
try {
|
|
206
|
+
const data = JSON.parse(line);
|
|
207
|
+
if (data.type !== 'user') continue;
|
|
208
|
+
|
|
209
|
+
const content = data.message?.content;
|
|
210
|
+
let text = '';
|
|
211
|
+
|
|
212
|
+
if (typeof content === 'string') {
|
|
213
|
+
text = content;
|
|
214
|
+
} else if (Array.isArray(content)) {
|
|
215
|
+
for (const block of content) {
|
|
216
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
217
|
+
text = block.text;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!text) continue;
|
|
224
|
+
|
|
225
|
+
// Capture slash command as fallback (e.g. "/scope review 1")
|
|
226
|
+
if (!slashCommand && text.includes('<command-name>')) {
|
|
227
|
+
const cmdMatch = text.match(/<command-name>\/?(.+?)<\/command-name>/);
|
|
228
|
+
const argsMatch = text.match(/<command-args>(.+?)<\/command-args>/);
|
|
229
|
+
if (cmdMatch) {
|
|
230
|
+
slashCommand = '/' + cmdMatch[1] + (argsMatch ? ' ' + argsMatch[1] : '');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Skip system-injected lines: commands, meta/skill prompts, tool results
|
|
235
|
+
if (text.startsWith('<command') || text.startsWith('<tool_result')) continue;
|
|
236
|
+
if (data.isMeta) continue;
|
|
237
|
+
|
|
238
|
+
return truncate(text.trim(), max);
|
|
239
|
+
} catch {
|
|
240
|
+
// skip unparseable lines
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return slashCommand ?? null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a full JSONL file and return detailed stats grouped by line type.
|
|
249
|
+
* This is heavier than parseSessionFile — only called for the detail view.
|
|
250
|
+
*/
|
|
251
|
+
export function getSessionStats(claudeSessionId: string): SessionStats | null {
|
|
252
|
+
const filePath = path.join(getSessionsDir(), `${claudeSessionId}.jsonl`);
|
|
253
|
+
if (!fs.existsSync(filePath)) return null;
|
|
254
|
+
|
|
255
|
+
const stats: SessionStats = {
|
|
256
|
+
typeCounts: {},
|
|
257
|
+
user: { totalMessages: 0, metaMessages: 0, toolResults: 0, commands: [], permissionModes: [], cwd: null, version: null },
|
|
258
|
+
assistant: { totalMessages: 0, models: [], totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheCreationTokens: 0, toolsUsed: {} },
|
|
259
|
+
system: { totalMessages: 0, subtypes: [], stopReasons: [], totalDurationMs: 0, hookCount: 0, hookErrors: 0 },
|
|
260
|
+
progress: { totalLines: 0 },
|
|
261
|
+
timing: { firstTimestamp: null, lastTimestamp: null, durationMs: 0 },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
let content: string;
|
|
265
|
+
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
|
266
|
+
|
|
267
|
+
const lines = content.trimEnd().split('\n');
|
|
268
|
+
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
let data: Record<string, unknown>;
|
|
271
|
+
try { data = JSON.parse(line); } catch { continue; }
|
|
272
|
+
|
|
273
|
+
const type = (data.type as string) ?? 'unknown';
|
|
274
|
+
stats.typeCounts[type] = (stats.typeCounts[type] ?? 0) + 1;
|
|
275
|
+
|
|
276
|
+
// Track timestamps
|
|
277
|
+
const ts = (data.timestamp as string) ?? null;
|
|
278
|
+
if (ts) {
|
|
279
|
+
if (!stats.timing.firstTimestamp) stats.timing.firstTimestamp = ts;
|
|
280
|
+
stats.timing.lastTimestamp = ts;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (type === 'user') {
|
|
284
|
+
stats.user.totalMessages++;
|
|
285
|
+
if (data.isMeta) stats.user.metaMessages++;
|
|
286
|
+
if (data.toolUseResult) stats.user.toolResults++;
|
|
287
|
+
if (!stats.user.cwd && data.cwd) stats.user.cwd = data.cwd as string;
|
|
288
|
+
if (!stats.user.version && data.version) stats.user.version = data.version as string;
|
|
289
|
+
|
|
290
|
+
const pm = data.permissionMode as string | undefined;
|
|
291
|
+
if (pm && !stats.user.permissionModes.includes(pm)) stats.user.permissionModes.push(pm);
|
|
292
|
+
|
|
293
|
+
// Extract slash commands
|
|
294
|
+
const content = (data.message as Record<string, unknown>)?.content;
|
|
295
|
+
const text = typeof content === 'string' ? content : '';
|
|
296
|
+
const cmdMatch = text.match(/<command-name>\/?(.+?)<\/command-name>/);
|
|
297
|
+
if (cmdMatch) {
|
|
298
|
+
const cmd = '/' + cmdMatch[1];
|
|
299
|
+
if (!stats.user.commands.includes(cmd)) stats.user.commands.push(cmd);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (type === 'assistant') {
|
|
304
|
+
stats.assistant.totalMessages++;
|
|
305
|
+
const msg = data.message as Record<string, unknown> | undefined;
|
|
306
|
+
if (msg) {
|
|
307
|
+
const model = msg.model as string | undefined;
|
|
308
|
+
if (model && !stats.assistant.models.includes(model)) stats.assistant.models.push(model);
|
|
309
|
+
|
|
310
|
+
const usage = msg.usage as Record<string, unknown> | undefined;
|
|
311
|
+
if (usage) {
|
|
312
|
+
stats.assistant.totalInputTokens += Number(usage.input_tokens) || 0;
|
|
313
|
+
stats.assistant.totalOutputTokens += Number(usage.output_tokens) || 0;
|
|
314
|
+
stats.assistant.totalCacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
|
|
315
|
+
stats.assistant.totalCacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Track tool usage
|
|
319
|
+
const msgContent = msg.content;
|
|
320
|
+
if (Array.isArray(msgContent)) {
|
|
321
|
+
for (const block of msgContent) {
|
|
322
|
+
if (block?.type === 'tool_use' && block.name) {
|
|
323
|
+
const name = block.name as string;
|
|
324
|
+
stats.assistant.toolsUsed[name] = (stats.assistant.toolsUsed[name] ?? 0) + 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (type === 'system') {
|
|
332
|
+
stats.system.totalMessages++;
|
|
333
|
+
const subtype = data.subtype as string | undefined;
|
|
334
|
+
if (subtype && !stats.system.subtypes.includes(subtype)) stats.system.subtypes.push(subtype);
|
|
335
|
+
const stopReason = data.stopReason as string | undefined;
|
|
336
|
+
if (stopReason && !stats.system.stopReasons.includes(stopReason)) stats.system.stopReasons.push(stopReason);
|
|
337
|
+
stats.system.totalDurationMs += Number(data.durationMs) || 0;
|
|
338
|
+
stats.system.hookCount += Number(data.hookCount) || 0;
|
|
339
|
+
stats.system.hookErrors += Number(data.hookErrors) || 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (type === 'progress') {
|
|
343
|
+
stats.progress.totalLines++;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Compute session duration
|
|
348
|
+
if (stats.timing.firstTimestamp && stats.timing.lastTimestamp) {
|
|
349
|
+
stats.timing.durationMs = new Date(stats.timing.lastTimestamp).getTime() - new Date(stats.timing.firstTimestamp).getTime();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return stats;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Sync sessions into the DB from scope frontmatter.
|
|
357
|
+
*
|
|
358
|
+
* Algorithm:
|
|
359
|
+
* 1. Read scopes with non-empty sessions JSON from DB
|
|
360
|
+
* 2. For each scope, parse the sessions JSON: Record<phase, uuid[]>
|
|
361
|
+
* 3. For each (phase, uuid), UPSERT into sessions table with JSONL metadata if available
|
|
362
|
+
*/
|
|
363
|
+
export async function syncClaudeSessionsToDB(db: Database.Database, scopeService: ScopeService): Promise<number> {
|
|
364
|
+
cache = null; // Force fresh read from filesystem
|
|
365
|
+
|
|
366
|
+
const scopeRows = scopeService.getAll()
|
|
367
|
+
.filter(s => Object.keys(s.sessions).length > 0)
|
|
368
|
+
.map(s => ({ id: s.id, sessions: s.sessions }));
|
|
369
|
+
|
|
370
|
+
const upsert = db.prepare(`
|
|
371
|
+
INSERT INTO sessions (id, scope_id, claude_session_id, action, started_at, ended_at, summary, handoff_file, discoveries, next_steps)
|
|
372
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, '[]', '[]')
|
|
373
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
374
|
+
started_at = excluded.started_at,
|
|
375
|
+
ended_at = excluded.ended_at,
|
|
376
|
+
summary = excluded.summary,
|
|
377
|
+
claude_session_id = excluded.claude_session_id,
|
|
378
|
+
action = excluded.action
|
|
379
|
+
`);
|
|
380
|
+
|
|
381
|
+
let count = 0;
|
|
382
|
+
|
|
383
|
+
const insertAll = db.transaction(() => {
|
|
384
|
+
for (const row of scopeRows) {
|
|
385
|
+
for (const [phase, uuids] of Object.entries(row.sessions)) {
|
|
386
|
+
if (!Array.isArray(uuids)) continue;
|
|
387
|
+
|
|
388
|
+
for (const uuid of uuids) {
|
|
389
|
+
if (typeof uuid !== 'string' || !uuid) continue;
|
|
390
|
+
|
|
391
|
+
// Check if JSONL file exists for metadata enrichment
|
|
392
|
+
const jsonlPath = path.join(getSessionsDir(), `${uuid}.jsonl`);
|
|
393
|
+
let startedAt: string | null = null;
|
|
394
|
+
let endedAt: string | null = null;
|
|
395
|
+
let summary: string | null = null;
|
|
396
|
+
|
|
397
|
+
if (fs.existsSync(jsonlPath)) {
|
|
398
|
+
try {
|
|
399
|
+
const stat = fs.statSync(jsonlPath);
|
|
400
|
+
startedAt = stat.birthtime.toISOString();
|
|
401
|
+
endedAt = stat.mtime.toISOString();
|
|
402
|
+
|
|
403
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
404
|
+
const lines = content.trimEnd().split('\n');
|
|
405
|
+
|
|
406
|
+
// Prefer explicit summary line from Claude
|
|
407
|
+
const lastLine = JSON.parse(lines[lines.length - 1]);
|
|
408
|
+
if (lastLine.type === 'summary' && lastLine.summary) {
|
|
409
|
+
summary = truncate(lastLine.summary, 200);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Fall back to first user message
|
|
413
|
+
if (!summary) {
|
|
414
|
+
summary = extractFirstUserMessage(lines, 200);
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
// Metadata unavailable — row still created with nulls
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Composite key includes phase so same UUID under different phases creates distinct rows
|
|
422
|
+
const compositeId = `${uuid}-scope-${row.id}-${phase}`;
|
|
423
|
+
|
|
424
|
+
upsert.run(
|
|
425
|
+
compositeId,
|
|
426
|
+
row.id,
|
|
427
|
+
uuid,
|
|
428
|
+
phase,
|
|
429
|
+
startedAt,
|
|
430
|
+
endedAt,
|
|
431
|
+
summary,
|
|
432
|
+
);
|
|
433
|
+
count++;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
insertAll();
|
|
440
|
+
return count;
|
|
441
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
export interface ConfigFileNode {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string; // relative path from base
|
|
8
|
+
type: 'file' | 'folder';
|
|
9
|
+
children?: ConfigFileNode[];
|
|
10
|
+
frontmatter?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ConfigPrimitiveType = 'agents' | 'skills' | 'hooks';
|
|
14
|
+
|
|
15
|
+
const VALID_TYPES = new Set<ConfigPrimitiveType>(['agents', 'skills', 'hooks']);
|
|
16
|
+
|
|
17
|
+
export function isValidPrimitiveType(type: string): type is ConfigPrimitiveType {
|
|
18
|
+
return VALID_TYPES.has(type as ConfigPrimitiveType);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ConfigService {
|
|
22
|
+
constructor(private projectRoot: string) {}
|
|
23
|
+
|
|
24
|
+
/** Resolve the base directory for a primitive type */
|
|
25
|
+
getBasePath(type: ConfigPrimitiveType): string {
|
|
26
|
+
switch (type) {
|
|
27
|
+
case 'agents': return path.join(this.projectRoot, '.claude', 'agents');
|
|
28
|
+
case 'skills': return path.join(this.projectRoot, '.claude', 'skills');
|
|
29
|
+
case 'hooks': return path.join(this.projectRoot, '.claude', 'hooks');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Scan a directory tree and parse SKILL.md / agent frontmatter */
|
|
34
|
+
scanDirectory(basePath: string, parseFrontmatter = true): ConfigFileNode[] {
|
|
35
|
+
if (!fs.existsSync(basePath)) return [];
|
|
36
|
+
return this.walkDir(basePath, basePath, parseFrontmatter);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
readFile(basePath: string, relativePath: string): string {
|
|
40
|
+
const resolved = this.validatePath(basePath, relativePath);
|
|
41
|
+
return fs.readFileSync(resolved, 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
writeFile(basePath: string, relativePath: string, content: string): void {
|
|
45
|
+
const resolved = this.validatePath(basePath, relativePath);
|
|
46
|
+
if (!fs.existsSync(resolved)) {
|
|
47
|
+
throw new Error('File not found');
|
|
48
|
+
}
|
|
49
|
+
// Atomic write: write to .tmp, then rename
|
|
50
|
+
const tmpPath = resolved + '.tmp';
|
|
51
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
52
|
+
fs.renameSync(tmpPath, resolved);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
createFile(basePath: string, relativePath: string, content: string): void {
|
|
56
|
+
const resolved = this.validatePath(basePath, relativePath);
|
|
57
|
+
if (fs.existsSync(resolved)) {
|
|
58
|
+
throw new Error('File already exists');
|
|
59
|
+
}
|
|
60
|
+
const dir = path.dirname(resolved);
|
|
61
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(resolved, content, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
deleteFile(basePath: string, relativePath: string): void {
|
|
66
|
+
const resolved = this.validatePath(basePath, relativePath);
|
|
67
|
+
if (!fs.existsSync(resolved)) {
|
|
68
|
+
throw new Error('File not found');
|
|
69
|
+
}
|
|
70
|
+
const stat = fs.statSync(resolved);
|
|
71
|
+
if (stat.isDirectory()) {
|
|
72
|
+
throw new Error('Cannot delete a directory');
|
|
73
|
+
}
|
|
74
|
+
fs.unlinkSync(resolved);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
renameFile(basePath: string, oldPath: string, newPath: string): void {
|
|
78
|
+
const resolvedOld = this.validatePath(basePath, oldPath);
|
|
79
|
+
const resolvedNew = this.validatePath(basePath, newPath);
|
|
80
|
+
if (!fs.existsSync(resolvedOld)) {
|
|
81
|
+
throw new Error('Source file not found');
|
|
82
|
+
}
|
|
83
|
+
if (fs.existsSync(resolvedNew)) {
|
|
84
|
+
throw new Error('Destination already exists');
|
|
85
|
+
}
|
|
86
|
+
const destDir = path.dirname(resolvedNew);
|
|
87
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
88
|
+
fs.renameSync(resolvedOld, resolvedNew);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
createFolder(basePath: string, relativePath: string): void {
|
|
92
|
+
const resolved = this.validatePath(basePath, relativePath);
|
|
93
|
+
if (fs.existsSync(resolved)) {
|
|
94
|
+
throw new Error('Folder already exists');
|
|
95
|
+
}
|
|
96
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Private Helpers ────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Path traversal validation — ensure resolved path is within basePath */
|
|
102
|
+
private validatePath(basePath: string, relativePath: string): string {
|
|
103
|
+
const resolvedBase = path.resolve(basePath);
|
|
104
|
+
const resolved = path.resolve(basePath, relativePath);
|
|
105
|
+
if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + path.sep)) {
|
|
106
|
+
throw new Error('Path traversal detected');
|
|
107
|
+
}
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Recursively walk a directory and build a file tree */
|
|
112
|
+
private walkDir(currentPath: string, basePath: string, parseFrontmatter: boolean): ConfigFileNode[] {
|
|
113
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
114
|
+
const nodes: ConfigFileNode[] = [];
|
|
115
|
+
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
// Skip hidden files/dirs (e.g. .DS_Store)
|
|
118
|
+
if (entry.name.startsWith('.')) continue;
|
|
119
|
+
|
|
120
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
121
|
+
const relPath = path.relative(basePath, fullPath);
|
|
122
|
+
|
|
123
|
+
if (entry.isDirectory()) {
|
|
124
|
+
const children = this.walkDir(fullPath, basePath, parseFrontmatter);
|
|
125
|
+
nodes.push({ name: entry.name, path: relPath, type: 'folder', children });
|
|
126
|
+
} else {
|
|
127
|
+
const node: ConfigFileNode = { name: entry.name, path: relPath, type: 'file' };
|
|
128
|
+
if (parseFrontmatter && entry.name.endsWith('.md')) {
|
|
129
|
+
try {
|
|
130
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
131
|
+
const parsed = matter(content);
|
|
132
|
+
if (Object.keys(parsed.data).length > 0) {
|
|
133
|
+
node.frontmatter = parsed.data;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Skip frontmatter parsing errors
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
nodes.push(node);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sort: folders first, then files, alphabetical within each group
|
|
144
|
+
nodes.sort((a, b) => {
|
|
145
|
+
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
|
146
|
+
return a.name.localeCompare(b.name);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return nodes;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { Server } from 'socket.io';
|
|
3
|
+
import type { DeployStatus, DeployEnvironment } from '../../shared/api-types.js';
|
|
4
|
+
import { createLogger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('deploy');
|
|
7
|
+
|
|
8
|
+
export interface DeployRecord {
|
|
9
|
+
environment: DeployEnvironment;
|
|
10
|
+
status: DeployStatus;
|
|
11
|
+
commit_sha: string | null;
|
|
12
|
+
branch: string | null;
|
|
13
|
+
pr_number: number | null;
|
|
14
|
+
health_check_url: string | null;
|
|
15
|
+
details: Record<string, unknown> | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DeployRow {
|
|
19
|
+
id: number;
|
|
20
|
+
environment: DeployEnvironment;
|
|
21
|
+
status: DeployStatus;
|
|
22
|
+
commit_sha: string | null;
|
|
23
|
+
branch: string | null;
|
|
24
|
+
pr_number: number | null;
|
|
25
|
+
health_check_url: string | null;
|
|
26
|
+
started_at: string | null;
|
|
27
|
+
completed_at: string | null;
|
|
28
|
+
details: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DeployService {
|
|
32
|
+
constructor(
|
|
33
|
+
private db: Database.Database,
|
|
34
|
+
private io: Server
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/** Record a deployment event */
|
|
38
|
+
record(deploy: DeployRecord): number {
|
|
39
|
+
const now = new Date().toISOString();
|
|
40
|
+
const result = this.db.prepare(
|
|
41
|
+
`INSERT INTO deployments (environment, status, commit_sha, branch, pr_number, health_check_url, started_at, details)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
43
|
+
).run(
|
|
44
|
+
deploy.environment,
|
|
45
|
+
deploy.status,
|
|
46
|
+
deploy.commit_sha,
|
|
47
|
+
deploy.branch,
|
|
48
|
+
deploy.pr_number,
|
|
49
|
+
deploy.health_check_url,
|
|
50
|
+
now,
|
|
51
|
+
JSON.stringify(deploy.details ?? {})
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const id = result.lastInsertRowid as number;
|
|
55
|
+
log.info('Deploy recorded', { id, env: deploy.environment, status: deploy.status, commit_sha: deploy.commit_sha, branch: deploy.branch });
|
|
56
|
+
const inserted = this.db.prepare('SELECT * FROM deployments WHERE id = ?').get(id);
|
|
57
|
+
if (inserted) {
|
|
58
|
+
this.io.emit('deploy:updated', inserted);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Update deployment status */
|
|
65
|
+
updateStatus(id: number, status: DeployStatus, details?: string): void {
|
|
66
|
+
const completedAt = (status === 'healthy' || status === 'failed' || status === 'rolled-back')
|
|
67
|
+
? new Date().toISOString()
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
this.db.prepare(
|
|
71
|
+
`UPDATE deployments SET status = ?, completed_at = COALESCE(?, completed_at), details = COALESCE(?, details) WHERE id = ?`
|
|
72
|
+
).run(status, completedAt, details, id);
|
|
73
|
+
|
|
74
|
+
log.info('Deploy status updated', { id, status });
|
|
75
|
+
const updated = this.db.prepare('SELECT * FROM deployments WHERE id = ?').get(id);
|
|
76
|
+
if (updated) {
|
|
77
|
+
this.io.emit('deploy:updated', updated);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get recent deployments */
|
|
82
|
+
getRecent(limit: number = 20): DeployRow[] {
|
|
83
|
+
return this.db
|
|
84
|
+
.prepare('SELECT * FROM deployments ORDER BY started_at DESC LIMIT ?')
|
|
85
|
+
.all(limit) as DeployRow[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get latest deployment per environment */
|
|
89
|
+
getLatestPerEnv(): DeployRow[] {
|
|
90
|
+
return this.db.prepare(`
|
|
91
|
+
SELECT * FROM deployments
|
|
92
|
+
WHERE id IN (
|
|
93
|
+
SELECT MAX(id) FROM deployments GROUP BY environment
|
|
94
|
+
)
|
|
95
|
+
ORDER BY environment
|
|
96
|
+
`).all() as DeployRow[];
|
|
97
|
+
}
|
|
98
|
+
}
|