selftune 0.2.23 → 0.2.24
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/CHANGELOG.md +6 -0
- package/README.md +93 -15
- package/apps/local-dashboard/dist/assets/index-DgY2KGP-.css +1 -0
- package/apps/local-dashboard/dist/assets/index-Dmx7LPVX.js +15 -0
- package/apps/local-dashboard/dist/assets/vendor-react-C5oyHiV1.js +11 -0
- package/apps/local-dashboard/dist/assets/{vendor-table-BIiI3YhS.js → vendor-table-Bc_bbKd8.js} +1 -1
- package/apps/local-dashboard/dist/assets/vendor-ui-B3BPIYy7.js +1 -0
- package/apps/local-dashboard/dist/index.html +5 -5
- package/cli/selftune/adapters/codex/install.ts +310 -78
- package/cli/selftune/adapters/opencode/install.ts +3 -4
- package/cli/selftune/alpha-upload/build-payloads.ts +3 -3
- package/cli/selftune/alpha-upload/stage-canonical.ts +17 -11
- package/cli/selftune/auto-update.ts +200 -8
- package/cli/selftune/canonical-export.ts +55 -25
- package/cli/selftune/command-surface.ts +397 -0
- package/cli/selftune/contribute/contribute.ts +64 -13
- package/cli/selftune/contribution-config.ts +57 -3
- package/cli/selftune/contribution-preferences.ts +117 -0
- package/cli/selftune/contribution-signals.ts +8 -4
- package/cli/selftune/contribution-staging.ts +13 -2
- package/cli/selftune/contributions.ts +55 -121
- package/cli/selftune/creator-contributions.ts +29 -10
- package/cli/selftune/cron/setup.ts +7 -3
- package/cli/selftune/dashboard-contract.ts +73 -0
- package/cli/selftune/dashboard-server.ts +168 -17
- package/cli/selftune/dashboard.ts +350 -17
- package/cli/selftune/eval/baseline.ts +21 -5
- package/cli/selftune/eval/execution-eval.ts +170 -0
- package/cli/selftune/eval/family-overlap.ts +2 -2
- package/cli/selftune/eval/hooks-to-evals.ts +228 -82
- package/cli/selftune/eval/import-skillsbench.ts +2 -2
- package/cli/selftune/eval/invocation-classifier.ts +56 -0
- package/cli/selftune/eval/synthetic-evals.ts +5 -3
- package/cli/selftune/eval/unit-test-cli.ts +7 -4
- package/cli/selftune/evolution/apply-proposal.ts +295 -0
- package/cli/selftune/evolution/engines/replay-engine.ts +79 -57
- package/cli/selftune/evolution/evolve-body.ts +100 -39
- package/cli/selftune/evolution/evolve.ts +244 -52
- package/cli/selftune/evolution/rollback.ts +0 -1
- package/cli/selftune/evolution/validate-body.ts +68 -42
- package/cli/selftune/evolution/validate-host-replay.ts +510 -60
- package/cli/selftune/evolution/validate-proposal.ts +11 -150
- package/cli/selftune/evolution/validate-routing.ts +43 -41
- package/cli/selftune/evolution/validation-contract.ts +91 -0
- package/cli/selftune/grading/auto-grade.ts +11 -7
- package/cli/selftune/grading/grade-session.ts +10 -16
- package/cli/selftune/index.ts +35 -10
- package/cli/selftune/ingestors/claude-replay.ts +15 -10
- package/cli/selftune/ingestors/codex-wrapper.ts +3 -3
- package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
- package/cli/selftune/ingestors/pi-ingest.ts +3 -2
- package/cli/selftune/init.ts +27 -3
- package/cli/selftune/localdb/direct-write.ts +35 -1
- package/cli/selftune/localdb/queries/cron.ts +34 -0
- package/cli/selftune/localdb/queries/dashboard.ts +834 -0
- package/cli/selftune/localdb/queries/evolution.ts +158 -0
- package/cli/selftune/localdb/queries/execution.ts +133 -0
- package/cli/selftune/localdb/queries/json.ts +18 -0
- package/cli/selftune/localdb/queries/monitoring.ts +263 -0
- package/cli/selftune/localdb/queries/raw.ts +95 -0
- package/cli/selftune/localdb/queries/staging.ts +270 -0
- package/cli/selftune/localdb/queries/trust.ts +392 -0
- package/cli/selftune/localdb/queries.ts +60 -2288
- package/cli/selftune/localdb/schema.ts +21 -0
- package/cli/selftune/monitoring/watch.ts +96 -29
- package/cli/selftune/normalization.ts +3 -0
- package/cli/selftune/observability.ts +4 -2
- package/cli/selftune/orchestrate/cli.ts +161 -0
- package/cli/selftune/orchestrate/execute.ts +295 -0
- package/cli/selftune/orchestrate/finalize.ts +157 -0
- package/cli/selftune/orchestrate/locks.ts +40 -0
- package/cli/selftune/orchestrate/plan.ts +131 -0
- package/cli/selftune/orchestrate/post-run.ts +59 -0
- package/cli/selftune/orchestrate/prepare.ts +334 -0
- package/cli/selftune/orchestrate/report.ts +182 -0
- package/cli/selftune/orchestrate/runtime.ts +120 -0
- package/cli/selftune/orchestrate/signals.ts +48 -0
- package/cli/selftune/orchestrate.ts +150 -1173
- package/cli/selftune/repair/skill-usage.ts +5 -2
- package/cli/selftune/routes/overview.ts +5 -2
- package/cli/selftune/routes/skill-report.ts +15 -2
- package/cli/selftune/schedule.ts +5 -5
- package/cli/selftune/status.ts +39 -2
- package/cli/selftune/testing-readiness.ts +597 -0
- package/cli/selftune/types.ts +44 -4
- package/cli/selftune/uninstall.ts +2 -1
- package/cli/selftune/utils/canonical-log.ts +1 -9
- package/cli/selftune/utils/cli-error.ts +9 -0
- package/cli/selftune/utils/llm-call.ts +126 -6
- package/cli/selftune/utils/skill-discovery.ts +2 -0
- package/cli/selftune/workflows/proposals.ts +184 -0
- package/cli/selftune/workflows/skill-scaffold.ts +241 -0
- package/cli/selftune/workflows/workflows.ts +100 -26
- package/node_modules/@selftune/telemetry-contract/fixtures/complete-push.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/src/schemas.ts +41 -1
- package/node_modules/@selftune/telemetry-contract/src/types.ts +103 -2
- package/package.json +25 -9
- package/packages/dashboard-core/AGENTS.md +18 -0
- package/packages/dashboard-core/README.md +30 -0
- package/packages/dashboard-core/index.ts +3 -0
- package/packages/dashboard-core/package.json +39 -0
- package/packages/dashboard-core/src/chrome/DashboardChrome.tsx +74 -0
- package/packages/dashboard-core/src/chrome/DashboardHeader.tsx +200 -0
- package/packages/dashboard-core/src/chrome/DashboardSidebar.tsx +219 -0
- package/packages/dashboard-core/src/chrome/RuntimeBadge.tsx +46 -0
- package/packages/dashboard-core/src/chrome/index.ts +14 -0
- package/packages/dashboard-core/src/chrome/types.ts +81 -0
- package/packages/dashboard-core/src/chrome/utils.ts +23 -0
- package/packages/dashboard-core/src/gates/FeatureGate.tsx +11 -0
- package/packages/dashboard-core/src/gates/LockedRoute.tsx +29 -0
- package/packages/dashboard-core/src/gates/UpgradeCard.tsx +89 -0
- package/packages/dashboard-core/src/gates/index.ts +3 -0
- package/packages/dashboard-core/src/host/DashboardHostProvider.tsx +62 -0
- package/packages/dashboard-core/src/host/adapter.ts +47 -0
- package/packages/dashboard-core/src/host/capabilities.ts +55 -0
- package/packages/dashboard-core/src/host/index.ts +3 -0
- package/packages/dashboard-core/src/models/analytics.ts +39 -0
- package/packages/dashboard-core/src/models/index.ts +4 -0
- package/packages/dashboard-core/src/models/overview.ts +98 -0
- package/packages/dashboard-core/src/models/runtime.ts +7 -0
- package/packages/dashboard-core/src/models/skills.ts +34 -0
- package/packages/dashboard-core/src/routes/index.ts +2 -0
- package/packages/dashboard-core/src/routes/manifest.test.ts +70 -0
- package/packages/dashboard-core/src/routes/manifest.ts +451 -0
- package/packages/dashboard-core/src/routes/types.ts +39 -0
- package/packages/dashboard-core/src/screens/analytics/AnalyticsScreen.tsx +278 -0
- package/packages/dashboard-core/src/screens/analytics/index.ts +1 -0
- package/packages/dashboard-core/src/screens/index.ts +37 -0
- package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts +101 -0
- package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx +393 -0
- package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx +113 -0
- package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.tsx +72 -0
- package/packages/dashboard-core/src/screens/overview/OverviewCoreSurface.tsx +71 -0
- package/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.tsx +90 -0
- package/packages/dashboard-core/src/screens/overview/OverviewRunSummary.tsx +40 -0
- package/packages/dashboard-core/src/screens/overview/index.ts +16 -0
- package/packages/dashboard-core/src/screens/overview/types.ts +13 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportDailyBreakdownSection.tsx +99 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportDataQualityTabContent.tsx +35 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceRail.tsx +71 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceSection.tsx +63 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceTabContent.tsx +25 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportInvocationsSection.tsx +24 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportMissedQueriesSection.tsx +79 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportScaffold.tsx +150 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx +224 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx +76 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.tsx +88 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportTrendSection.tsx +33 -0
- package/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx +67 -0
- package/packages/dashboard-core/src/screens/skill-report/index.ts +45 -0
- package/packages/dashboard-core/src/screens/skills/SkillsLibraryScreen.tsx +162 -0
- package/packages/dashboard-core/src/screens/skills/index.ts +6 -0
- package/packages/telemetry-contract/fixtures/complete-push.ts +1 -1
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/packages/telemetry-contract/src/schemas.ts +41 -1
- package/packages/telemetry-contract/src/types.ts +103 -2
- package/packages/ui/src/components/EvidenceViewer.tsx +80 -25
- package/packages/ui/src/components/OverviewPanels.tsx +67 -26
- package/packages/ui/src/primitives/tabs.tsx +7 -6
- package/packages/ui/src/types.ts +10 -0
- package/skill/SKILL.md +130 -332
- package/skill/agents/diagnosis-analyst.md +3 -3
- package/skill/agents/evolution-reviewer.md +3 -3
- package/skill/agents/integration-guide.md +3 -3
- package/skill/agents/pattern-analyst.md +2 -2
- package/skill/references/cli-quick-reference.md +89 -0
- package/skill/references/creator-playbook.md +131 -0
- package/skill/references/examples.md +48 -0
- package/skill/references/troubleshooting.md +47 -0
- package/skill/references/version-history.md +1 -1
- package/skill/selftune.contribute.json +11 -0
- package/skill/{Workflows → workflows}/Baseline.md +20 -1
- package/skill/{Workflows → workflows}/Contribute.md +23 -10
- package/skill/{Workflows → workflows}/Contributions.md +13 -5
- package/skill/workflows/CreateTestDeploy.md +170 -0
- package/skill/{Workflows → workflows}/CreatorContributions.md +18 -6
- package/skill/{Workflows → workflows}/Cron.md +1 -1
- package/skill/{Workflows → workflows}/Dashboard.md +20 -0
- package/skill/{Workflows → workflows}/Doctor.md +1 -1
- package/skill/{Workflows → workflows}/Evals.md +67 -2
- package/skill/{Workflows → workflows}/Evolve.md +119 -30
- package/skill/{Workflows → workflows}/EvolveBody.md +41 -1
- package/skill/{Workflows → workflows}/Grade.md +1 -1
- package/skill/{Workflows → workflows}/Initialize.md +8 -4
- package/skill/{Workflows → workflows}/Orchestrate.md +13 -3
- package/skill/{Workflows → workflows}/Schedule.md +3 -3
- package/skill/workflows/SignalsDashboard.md +87 -0
- package/skill/{Workflows → workflows}/UnitTest.md +19 -0
- package/skill/{Workflows → workflows}/Watch.md +42 -2
- package/skill/{Workflows → workflows}/Workflows.md +39 -2
- package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +0 -1
- package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +0 -59
- package/apps/local-dashboard/dist/assets/vendor-react-CKkiCskZ.js +0 -11
- package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +0 -12
- /package/skill/{Workflows → workflows}/AlphaUpload.md +0 -0
- /package/skill/{Workflows → workflows}/AutoActivation.md +0 -0
- /package/skill/{Workflows → workflows}/Badge.md +0 -0
- /package/skill/{Workflows → workflows}/Composability.md +0 -0
- /package/skill/{Workflows → workflows}/EvolutionMemory.md +0 -0
- /package/skill/{Workflows → workflows}/ExportCanonical.md +0 -0
- /package/skill/{Workflows → workflows}/Hook.md +0 -0
- /package/skill/{Workflows → workflows}/ImportSkillsBench.md +0 -0
- /package/skill/{Workflows → workflows}/Ingest.md +0 -0
- /package/skill/{Workflows → workflows}/PlatformHooks.md +0 -0
- /package/skill/{Workflows → workflows}/Quickstart.md +0 -0
- /package/skill/{Workflows → workflows}/Recover.md +0 -0
- /package/skill/{Workflows → workflows}/Registry.md +0 -0
- /package/skill/{Workflows → workflows}/RepairSkillUsage.md +0 -0
- /package/skill/{Workflows → workflows}/Replay.md +0 -0
- /package/skill/{Workflows → workflows}/Rollback.md +0 -0
- /package/skill/{Workflows → workflows}/Sync.md +0 -0
- /package/skill/{Workflows → workflows}/Telemetry.md +0 -0
- /package/skill/{Workflows → workflows}/Uninstall.md +0 -0
|
@@ -22,7 +22,7 @@ import type { Database } from "bun:sqlite";
|
|
|
22
22
|
import { existsSync, readFileSync, unwatchFile, watchFile } from "node:fs";
|
|
23
23
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
24
24
|
|
|
25
|
-
import type { BadgeFormat } from "./badge/badge-
|
|
25
|
+
import type { BadgeFormat } from "./badge/badge-data.js";
|
|
26
26
|
import { LOG_DIR, SELFTUNE_CONFIG_DIR } from "./constants.js";
|
|
27
27
|
import type {
|
|
28
28
|
HealthResponse,
|
|
@@ -52,12 +52,13 @@ import {
|
|
|
52
52
|
} from "./routes/index.js";
|
|
53
53
|
import type { StatusResult } from "./status.js";
|
|
54
54
|
import { computeStatus } from "./status.js";
|
|
55
|
-
import type { EvolutionEvidenceEntry } from "./types.js";
|
|
55
|
+
import type { EvolutionAuditEntry, EvolutionEvidenceEntry } from "./types.js";
|
|
56
56
|
|
|
57
57
|
export interface DashboardServerOptions {
|
|
58
58
|
port?: number;
|
|
59
59
|
host?: string;
|
|
60
60
|
spaDir?: string;
|
|
61
|
+
spaProxyUrl?: string;
|
|
61
62
|
openBrowser?: boolean;
|
|
62
63
|
runtimeMode?: HealthResponse["process_mode"];
|
|
63
64
|
statusLoader?: () => StatusResult | Promise<StatusResult>;
|
|
@@ -67,13 +68,18 @@ export interface DashboardServerOptions {
|
|
|
67
68
|
actionRunner?: ActionRunner;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
interface DashboardSocketData {
|
|
72
|
+
upstreamUrl?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read selftune version from package.json (fresh on each call to pick up auto-updates). */
|
|
76
|
+
const VERSION_PKG_PATH = join(import.meta.dir, "..", "..", "package.json");
|
|
77
|
+
function getSelftuneVersion(): string {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(VERSION_PKG_PATH, "utf-8")).version;
|
|
80
|
+
} catch {
|
|
81
|
+
return "unknown";
|
|
82
|
+
}
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
/** Resolve short git SHA once at startup (cached). */
|
|
@@ -89,6 +95,10 @@ function getGitSha(): string {
|
|
|
89
95
|
return cachedGitSha;
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
function getSpaBuildId(): string {
|
|
99
|
+
return process.env.SELFTUNE_SPA_BUILD_ID || getSelftuneVersion();
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
const WORKSPACE_ROOT = resolve(import.meta.dir, "..", "..");
|
|
93
103
|
|
|
94
104
|
function findSpaDir(): string | null {
|
|
@@ -121,6 +131,47 @@ function allowedDashboardOrigins(hostname: string, port: number): Set<string> {
|
|
|
121
131
|
return origins;
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
function normalizeSpaProxyUrl(rawValue: string | undefined): URL | null {
|
|
135
|
+
if (!rawValue) return null;
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(rawValue);
|
|
138
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return url;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shouldProxySpaRequest(pathname: string): boolean {
|
|
148
|
+
return (
|
|
149
|
+
!pathname.startsWith("/api/") &&
|
|
150
|
+
!pathname.startsWith("/badge/") &&
|
|
151
|
+
!pathname.startsWith("/report/")
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function proxySpaRequest(req: Request, proxyBaseUrl: URL, url: URL): Promise<Response> {
|
|
156
|
+
const targetUrl = new URL(`${url.pathname}${url.search}`, proxyBaseUrl);
|
|
157
|
+
const headers = new Headers(req.headers);
|
|
158
|
+
headers.set("host", targetUrl.host);
|
|
159
|
+
const upstreamResponse = await fetch(targetUrl, {
|
|
160
|
+
method: req.method,
|
|
161
|
+
headers,
|
|
162
|
+
redirect: "manual",
|
|
163
|
+
});
|
|
164
|
+
const proxiedHeaders = new Headers(upstreamResponse.headers);
|
|
165
|
+
for (const [key, value] of Object.entries(corsHeaders())) {
|
|
166
|
+
proxiedHeaders.set(key, value);
|
|
167
|
+
}
|
|
168
|
+
return new Response(upstreamResponse.body, {
|
|
169
|
+
status: upstreamResponse.status,
|
|
170
|
+
statusText: upstreamResponse.statusText,
|
|
171
|
+
headers: proxiedHeaders,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
124
175
|
const MIME_TYPES: Record<string, string> = {
|
|
125
176
|
".html": "text/html; charset=utf-8",
|
|
126
177
|
".js": "application/javascript; charset=utf-8",
|
|
@@ -139,7 +190,7 @@ async function computeStatusFromDb(): Promise<StatusResult> {
|
|
|
139
190
|
const telemetry = querySessionTelemetry(db);
|
|
140
191
|
const skillRecords = querySkillUsageRecords(db);
|
|
141
192
|
const queryRecords = queryQueryLog(db);
|
|
142
|
-
const auditEntries = queryEvolutionAudit(db);
|
|
193
|
+
const auditEntries = queryEvolutionAudit(db) as EvolutionAuditEntry[];
|
|
143
194
|
const doctorResult = await doctor();
|
|
144
195
|
return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
|
|
145
196
|
}
|
|
@@ -172,6 +223,7 @@ export async function startDashboardServer(
|
|
|
172
223
|
const hostname = options?.host ?? "localhost";
|
|
173
224
|
const openBrowser = options?.openBrowser ?? true;
|
|
174
225
|
const runtimeMode = options?.runtimeMode ?? (import.meta.main ? "dev-server" : "test");
|
|
226
|
+
const spaProxyUrl = normalizeSpaProxyUrl(options?.spaProxyUrl ?? process.env.SPA_PROXY_URL);
|
|
175
227
|
const getStatusResult = options?.statusLoader ?? computeStatusFromDb;
|
|
176
228
|
const getEvidenceEntries = options?.evidenceLoader ?? readEvidenceTrail;
|
|
177
229
|
const getOverviewResponse = options?.overviewLoader;
|
|
@@ -182,7 +234,14 @@ export async function startDashboardServer(
|
|
|
182
234
|
const requestedSpaDir = options?.spaDir ?? findSpaDir();
|
|
183
235
|
const spaDir =
|
|
184
236
|
requestedSpaDir && existsSync(join(requestedSpaDir, "index.html")) ? requestedSpaDir : null;
|
|
185
|
-
|
|
237
|
+
const spaMode: NonNullable<HealthResponse["spa_mode"]> = spaProxyUrl
|
|
238
|
+
? "proxy"
|
|
239
|
+
: spaDir
|
|
240
|
+
? "dist"
|
|
241
|
+
: "missing";
|
|
242
|
+
if (spaProxyUrl) {
|
|
243
|
+
console.log(`SPA proxy enabled at ${spaProxyUrl.toString()}`);
|
|
244
|
+
} else if (spaDir) {
|
|
186
245
|
console.log(`SPA found at ${spaDir}, serving as default dashboard`);
|
|
187
246
|
} else {
|
|
188
247
|
if (options?.spaDir) {
|
|
@@ -247,6 +306,7 @@ export async function startDashboardServer(
|
|
|
247
306
|
|
|
248
307
|
let fsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
249
308
|
const FS_DEBOUNCE_MS = 500;
|
|
309
|
+
const proxiedSpaSockets = new Map<unknown, WebSocket>();
|
|
250
310
|
|
|
251
311
|
function onWALChange(): void {
|
|
252
312
|
if (fsDebounceTimer) return;
|
|
@@ -298,10 +358,48 @@ export async function startDashboardServer(
|
|
|
298
358
|
}
|
|
299
359
|
|
|
300
360
|
// -- HTTP request handler ---------------------------------------------------
|
|
301
|
-
const server = Bun.serve({
|
|
361
|
+
const server = Bun.serve<DashboardSocketData>({
|
|
302
362
|
port,
|
|
303
363
|
hostname,
|
|
304
364
|
idleTimeout: 255,
|
|
365
|
+
websocket: {
|
|
366
|
+
open(ws) {
|
|
367
|
+
const upstreamUrl = ws.data?.upstreamUrl;
|
|
368
|
+
if (!upstreamUrl) {
|
|
369
|
+
ws.close(1011, "Missing upstream websocket target");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const upstreamSocket = new WebSocket(upstreamUrl);
|
|
373
|
+
proxiedSpaSockets.set(ws, upstreamSocket);
|
|
374
|
+
upstreamSocket.onmessage = (event) => {
|
|
375
|
+
ws.send(event.data);
|
|
376
|
+
};
|
|
377
|
+
upstreamSocket.onclose = (event) => {
|
|
378
|
+
proxiedSpaSockets.delete(ws);
|
|
379
|
+
try {
|
|
380
|
+
ws.close(event.code || 1000, event.reason);
|
|
381
|
+
} catch {
|
|
382
|
+
ws.close();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
upstreamSocket.onerror = () => {
|
|
386
|
+
proxiedSpaSockets.delete(ws);
|
|
387
|
+
ws.close(1011, "Upstream websocket error");
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
message(ws, message) {
|
|
391
|
+
const upstreamSocket = proxiedSpaSockets.get(ws);
|
|
392
|
+
if (!upstreamSocket || upstreamSocket.readyState !== WebSocket.OPEN) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
upstreamSocket.send(message);
|
|
396
|
+
},
|
|
397
|
+
close(ws) {
|
|
398
|
+
const upstreamSocket = proxiedSpaSockets.get(ws);
|
|
399
|
+
proxiedSpaSockets.delete(ws);
|
|
400
|
+
upstreamSocket?.close();
|
|
401
|
+
},
|
|
402
|
+
},
|
|
305
403
|
async fetch(req) {
|
|
306
404
|
const url = new URL(req.url);
|
|
307
405
|
|
|
@@ -315,8 +413,12 @@ export async function startDashboardServer(
|
|
|
315
413
|
const healthResponse: HealthResponse = {
|
|
316
414
|
ok: true,
|
|
317
415
|
service: "selftune-dashboard",
|
|
318
|
-
version:
|
|
319
|
-
|
|
416
|
+
version: getSelftuneVersion(),
|
|
417
|
+
pid: process.pid,
|
|
418
|
+
spa: Boolean(spaDir || spaProxyUrl),
|
|
419
|
+
spa_mode: spaMode,
|
|
420
|
+
spa_build_id: getSpaBuildId(),
|
|
421
|
+
spa_proxy_url: spaProxyUrl?.toString() ?? null,
|
|
320
422
|
v2_data_available: Boolean(getOverviewResponse || db),
|
|
321
423
|
workspace_root: WORKSPACE_ROOT,
|
|
322
424
|
git_sha: getGitSha(),
|
|
@@ -331,6 +433,26 @@ export async function startDashboardServer(
|
|
|
331
433
|
return Response.json(healthResponse, { headers: corsHeaders() });
|
|
332
434
|
}
|
|
333
435
|
|
|
436
|
+
if (
|
|
437
|
+
spaProxyUrl &&
|
|
438
|
+
req.headers.get("upgrade")?.toLowerCase() === "websocket" &&
|
|
439
|
+
shouldProxySpaRequest(url.pathname)
|
|
440
|
+
) {
|
|
441
|
+
const upstreamUrl = new URL(`${url.pathname}${url.search}`, spaProxyUrl);
|
|
442
|
+
upstreamUrl.protocol = spaProxyUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
443
|
+
if (
|
|
444
|
+
server.upgrade(req, {
|
|
445
|
+
data: { upstreamUrl: upstreamUrl.toString() },
|
|
446
|
+
})
|
|
447
|
+
) {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
return new Response("WebSocket upgrade failed", {
|
|
451
|
+
status: 502,
|
|
452
|
+
headers: corsHeaders(),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
334
456
|
// ---- GET /api/v2/events ---- SSE stream for live updates
|
|
335
457
|
if (url.pathname === "/api/v2/events" && req.method === "GET") {
|
|
336
458
|
const stream = new ReadableStream({
|
|
@@ -357,6 +479,22 @@ export async function startDashboardServer(
|
|
|
357
479
|
return withCors(await handleDoctor());
|
|
358
480
|
}
|
|
359
481
|
|
|
482
|
+
// ---- SPA static assets ----
|
|
483
|
+
if (spaProxyUrl && req.method === "GET" && shouldProxySpaRequest(url.pathname)) {
|
|
484
|
+
try {
|
|
485
|
+
return await proxySpaRequest(req, spaProxyUrl, url);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
488
|
+
return new Response(
|
|
489
|
+
`Dashboard SPA proxy unavailable at ${spaProxyUrl.toString()}: ${message}`,
|
|
490
|
+
{
|
|
491
|
+
status: 502,
|
|
492
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() },
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
360
498
|
// ---- SPA static assets ----
|
|
361
499
|
if (spaDir && req.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
362
500
|
const filePath = resolve(spaDir, `.${url.pathname}`);
|
|
@@ -470,7 +608,7 @@ export async function startDashboardServer(
|
|
|
470
608
|
);
|
|
471
609
|
}
|
|
472
610
|
refreshV2Data();
|
|
473
|
-
return withCors(handleOverview(db,
|
|
611
|
+
return withCors(handleOverview(db, getSelftuneVersion(), url.searchParams));
|
|
474
612
|
}
|
|
475
613
|
|
|
476
614
|
// ---- GET /api/v2/orchestrate-runs ----
|
|
@@ -544,7 +682,7 @@ export async function startDashboardServer(
|
|
|
544
682
|
},
|
|
545
683
|
});
|
|
546
684
|
|
|
547
|
-
boundPort = server.port;
|
|
685
|
+
boundPort = server.port ?? port;
|
|
548
686
|
|
|
549
687
|
if (openBrowser) {
|
|
550
688
|
const url = `http://${hostname}:${boundPort}`;
|
|
@@ -575,6 +713,14 @@ export async function startDashboardServer(
|
|
|
575
713
|
}
|
|
576
714
|
}
|
|
577
715
|
sseClients.clear();
|
|
716
|
+
for (const upstreamSocket of proxiedSpaSockets.values()) {
|
|
717
|
+
try {
|
|
718
|
+
upstreamSocket.close();
|
|
719
|
+
} catch {
|
|
720
|
+
/* already closed */
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
proxiedSpaSockets.clear();
|
|
578
724
|
if (fsDebounceTimer) clearTimeout(fsDebounceTimer);
|
|
579
725
|
closeSingleton();
|
|
580
726
|
server.stop();
|
|
@@ -602,5 +748,10 @@ if (import.meta.main) {
|
|
|
602
748
|
runtimeModeArg === "standalone" || runtimeModeArg === "dev-server" || runtimeModeArg === "test"
|
|
603
749
|
? runtimeModeArg
|
|
604
750
|
: "dev-server";
|
|
605
|
-
startDashboardServer({
|
|
751
|
+
startDashboardServer({
|
|
752
|
+
port,
|
|
753
|
+
openBrowser: false,
|
|
754
|
+
runtimeMode,
|
|
755
|
+
spaProxyUrl: process.env.SPA_PROXY_URL,
|
|
756
|
+
});
|
|
606
757
|
}
|
|
@@ -4,11 +4,354 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* selftune dashboard — Start server on port 3141 and open browser
|
|
6
6
|
* selftune dashboard --port 8080 — Start on custom port
|
|
7
|
+
* selftune dashboard --restart — Restart an existing dashboard on the target port
|
|
7
8
|
* selftune dashboard --serve — Deprecated alias for the default behavior
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { HealthResponse } from "./dashboard-contract.js";
|
|
10
15
|
import { CLIError } from "./utils/cli-error.js";
|
|
11
16
|
|
|
17
|
+
const DEFAULT_PORT = 3141;
|
|
18
|
+
const VERSION_PKG_PATH = join(import.meta.dir, "..", "..", "package.json");
|
|
19
|
+
const HEALTHCHECK_TIMEOUT_MS = 1000;
|
|
20
|
+
const RESTART_WAIT_TIMEOUT_MS = 5000;
|
|
21
|
+
const RESTART_POLL_INTERVAL_MS = 250;
|
|
22
|
+
|
|
23
|
+
type DashboardServerHandle = Awaited<
|
|
24
|
+
ReturnType<typeof import("./dashboard-server.js").startDashboardServer>
|
|
25
|
+
>;
|
|
26
|
+
type DashboardStartOptions = Parameters<
|
|
27
|
+
typeof import("./dashboard-server.js").startDashboardServer
|
|
28
|
+
>[0];
|
|
29
|
+
type DashboardKillFn = (pid: number, signal?: string | number) => boolean;
|
|
30
|
+
|
|
31
|
+
type DashboardRuntimeHealth = Partial<HealthResponse> & {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
service: string;
|
|
34
|
+
pid?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface DashboardLaunchOptions {
|
|
38
|
+
openBrowser: boolean;
|
|
39
|
+
port: number;
|
|
40
|
+
restart: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DashboardLaunchResult {
|
|
44
|
+
action: "reused" | "started";
|
|
45
|
+
installedVersion: string;
|
|
46
|
+
serverHandle?: DashboardServerHandle;
|
|
47
|
+
url: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DashboardLaunchDeps {
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
findListeningPids?: (port: number) => number[];
|
|
53
|
+
kill?: DashboardKillFn;
|
|
54
|
+
log?: Pick<typeof console, "log" | "warn">;
|
|
55
|
+
openUrl?: (url: string) => void;
|
|
56
|
+
startDashboardServer?: (options?: DashboardStartOptions) => Promise<DashboardServerHandle>;
|
|
57
|
+
wait?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getInstalledSelftuneVersion(): string {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(VERSION_PKG_PATH, "utf-8")).version;
|
|
63
|
+
} catch {
|
|
64
|
+
return "unknown";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildDashboardUrl(port: number): string {
|
|
69
|
+
return `http://localhost:${port}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function openDashboardUrl(url: string): void {
|
|
73
|
+
try {
|
|
74
|
+
const platform = process.platform;
|
|
75
|
+
if (platform === "darwin") {
|
|
76
|
+
Bun.spawn(["open", url]);
|
|
77
|
+
} else if (platform === "linux") {
|
|
78
|
+
Bun.spawn(["xdg-open", url]);
|
|
79
|
+
} else if (platform === "win32") {
|
|
80
|
+
Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`Open manually: ${url}`);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
console.log(`Open manually: ${url}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isAddressInUseError(error: unknown): boolean {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
return /EADDRINUSE|address already in use|port .* in use|already in use/i.test(message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parsePidOutput(output: string): number[] {
|
|
95
|
+
const pids = new Set<number>();
|
|
96
|
+
for (const line of output.split(/\r?\n/)) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed) continue;
|
|
99
|
+
const pid = Number.parseInt(trimmed, 10);
|
|
100
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
101
|
+
pids.add(pid);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...pids];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseWindowsNetstatListeningPids(output: string, port: number): number[] {
|
|
108
|
+
const pids = new Set<number>();
|
|
109
|
+
const portSuffix = `:${port}`;
|
|
110
|
+
|
|
111
|
+
for (const line of output.split(/\r?\n/)) {
|
|
112
|
+
const trimmed = line.trim();
|
|
113
|
+
if (!trimmed || !trimmed.includes("LISTENING")) continue;
|
|
114
|
+
|
|
115
|
+
const parts = trimmed.split(/\s+/);
|
|
116
|
+
const localAddr = parts[1] ?? "";
|
|
117
|
+
if (!localAddr.endsWith(portSuffix)) continue;
|
|
118
|
+
|
|
119
|
+
const pid = Number.parseInt(parts.at(-1) ?? "", 10);
|
|
120
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
121
|
+
pids.add(pid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [...pids];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findListeningPids(port: number): number[] {
|
|
129
|
+
if (process.platform === "win32") {
|
|
130
|
+
const result = Bun.spawnSync(["cmd", "/c", "netstat -ano -p tcp"], {
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
});
|
|
134
|
+
if (result.exitCode !== 0) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
return parseWindowsNetstatListeningPids(result.stdout.toString(), port);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
141
|
+
stdout: "pipe",
|
|
142
|
+
stderr: "pipe",
|
|
143
|
+
});
|
|
144
|
+
if (result.exitCode !== 0) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
return parsePidOutput(result.stdout.toString());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function probeDashboardHealth(
|
|
151
|
+
port: number,
|
|
152
|
+
fetchImpl: typeof fetch = globalThis.fetch,
|
|
153
|
+
): Promise<DashboardRuntimeHealth | null> {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timeout = setTimeout(() => controller.abort(), HEALTHCHECK_TIMEOUT_MS);
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetchImpl(`${buildDashboardUrl(port)}/api/health`, {
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const payload = (await response.json()) as Partial<DashboardRuntimeHealth>;
|
|
164
|
+
if (payload.service !== "selftune-dashboard" || payload.ok !== true) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return payload as DashboardRuntimeHealth;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function waitForDashboardShutdown(port: number, deps: DashboardLaunchDeps): Promise<void> {
|
|
176
|
+
const wait =
|
|
177
|
+
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
178
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
179
|
+
const deadline = Date.now() + RESTART_WAIT_TIMEOUT_MS;
|
|
180
|
+
|
|
181
|
+
while (Date.now() < deadline) {
|
|
182
|
+
const health = await probeDashboardHealth(port, fetchImpl);
|
|
183
|
+
if (!health) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await wait(RESTART_POLL_INTERVAL_MS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new CLIError(
|
|
190
|
+
`Timed out waiting for the existing dashboard on port ${port} to stop.`,
|
|
191
|
+
"OPERATION_FAILED",
|
|
192
|
+
"Retry `selftune dashboard --restart` or stop the existing dashboard process manually.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function stopExistingDashboard(
|
|
197
|
+
port: number,
|
|
198
|
+
health: DashboardRuntimeHealth,
|
|
199
|
+
deps: DashboardLaunchDeps,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const listeningPids = deps.findListeningPids?.(port) ?? findListeningPids(port);
|
|
202
|
+
const pids = new Set<number>();
|
|
203
|
+
|
|
204
|
+
if (typeof health.pid === "number" && health.pid > 0) {
|
|
205
|
+
pids.add(health.pid);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const pid of listeningPids) {
|
|
209
|
+
if (pid > 0) {
|
|
210
|
+
pids.add(pid);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pids.delete(process.pid);
|
|
215
|
+
|
|
216
|
+
if (pids.size === 0) {
|
|
217
|
+
throw new CLIError(
|
|
218
|
+
`Found a running dashboard on port ${port}, but could not determine its process ID.`,
|
|
219
|
+
"OPERATION_FAILED",
|
|
220
|
+
`Stop the dashboard on port ${port} manually, then rerun \`selftune dashboard --port ${port}\`.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const kill = deps.kill ?? process.kill.bind(process);
|
|
225
|
+
for (const pid of pids) {
|
|
226
|
+
try {
|
|
227
|
+
kill(pid, "SIGTERM");
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
if (!/ESRCH|no such process/i.test(message)) {
|
|
231
|
+
throw new CLIError(
|
|
232
|
+
`Failed to stop dashboard process ${pid}: ${message}`,
|
|
233
|
+
"OPERATION_FAILED",
|
|
234
|
+
`Stop the process on port ${port} manually, then rerun \`selftune dashboard --port ${port}\`.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await waitForDashboardShutdown(port, deps);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseDashboardOptions(
|
|
244
|
+
args: string[] = process.argv.slice(2),
|
|
245
|
+
): DashboardLaunchOptions {
|
|
246
|
+
const portIdx = args.indexOf("--port");
|
|
247
|
+
let port = DEFAULT_PORT;
|
|
248
|
+
|
|
249
|
+
if (portIdx !== -1) {
|
|
250
|
+
const parsed = Number.parseInt(args[portIdx + 1], 10);
|
|
251
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
252
|
+
throw new CLIError(
|
|
253
|
+
`Invalid port "${args[portIdx + 1]}": must be an integer between 1 and 65535.`,
|
|
254
|
+
"INVALID_FLAG",
|
|
255
|
+
"Provide a port number between 1 and 65535 (e.g., --port 3141).",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
port = parsed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
openBrowser: !args.includes("--no-open"),
|
|
263
|
+
port,
|
|
264
|
+
restart: args.includes("--restart"),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function launchDashboard(
|
|
269
|
+
args: string[] = process.argv.slice(2),
|
|
270
|
+
deps: DashboardLaunchDeps = {},
|
|
271
|
+
): Promise<DashboardLaunchResult> {
|
|
272
|
+
const options = parseDashboardOptions(args);
|
|
273
|
+
const log = deps.log ?? console;
|
|
274
|
+
const openUrl = deps.openUrl ?? openDashboardUrl;
|
|
275
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
276
|
+
const installedVersion = getInstalledSelftuneVersion();
|
|
277
|
+
const url = buildDashboardUrl(options.port);
|
|
278
|
+
|
|
279
|
+
const runningDashboard = await probeDashboardHealth(options.port, fetchImpl);
|
|
280
|
+
const versionMismatch =
|
|
281
|
+
runningDashboard?.process_mode === "standalone" &&
|
|
282
|
+
runningDashboard.version !== undefined &&
|
|
283
|
+
runningDashboard.version !== "unknown" &&
|
|
284
|
+
installedVersion !== "unknown" &&
|
|
285
|
+
runningDashboard.version !== installedVersion;
|
|
286
|
+
|
|
287
|
+
if (runningDashboard) {
|
|
288
|
+
if (options.restart || versionMismatch) {
|
|
289
|
+
if (versionMismatch) {
|
|
290
|
+
log.log(
|
|
291
|
+
`Installed selftune ${installedVersion} differs from running dashboard ${runningDashboard.version}. Restarting ${url} to pick up the update.`,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
log.log(`Restarting existing selftune dashboard at ${url}.`);
|
|
295
|
+
}
|
|
296
|
+
await stopExistingDashboard(options.port, runningDashboard, deps);
|
|
297
|
+
} else {
|
|
298
|
+
if (
|
|
299
|
+
runningDashboard.process_mode !== "standalone" &&
|
|
300
|
+
runningDashboard.version !== installedVersion &&
|
|
301
|
+
installedVersion !== "unknown"
|
|
302
|
+
) {
|
|
303
|
+
log.warn(
|
|
304
|
+
`Dashboard already running at ${url} from ${runningDashboard.process_mode} mode (version ${runningDashboard.version}). Reusing it without restart.`,
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
log.log(`Reusing existing selftune dashboard at ${url}.`);
|
|
308
|
+
}
|
|
309
|
+
if (options.openBrowser) {
|
|
310
|
+
openUrl(url);
|
|
311
|
+
}
|
|
312
|
+
return { action: "reused", installedVersion, url };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const startDashboardServer =
|
|
317
|
+
deps.startDashboardServer ?? (await import("./dashboard-server.js")).startDashboardServer;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const serverHandle = await startDashboardServer({
|
|
321
|
+
port: options.port,
|
|
322
|
+
openBrowser: options.openBrowser,
|
|
323
|
+
runtimeMode: "standalone",
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
action: "started",
|
|
327
|
+
installedVersion,
|
|
328
|
+
serverHandle,
|
|
329
|
+
url: buildDashboardUrl(serverHandle.port),
|
|
330
|
+
};
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const liveDashboard = await probeDashboardHealth(options.port, fetchImpl);
|
|
333
|
+
if (liveDashboard && !options.restart) {
|
|
334
|
+
log.log(`Reusing existing selftune dashboard at ${url}.`);
|
|
335
|
+
if (options.openBrowser) {
|
|
336
|
+
openUrl(url);
|
|
337
|
+
}
|
|
338
|
+
return { action: "reused", installedVersion, url };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (isAddressInUseError(error)) {
|
|
342
|
+
throw new CLIError(
|
|
343
|
+
`Port ${options.port} is already in use.`,
|
|
344
|
+
"OPERATION_FAILED",
|
|
345
|
+
liveDashboard
|
|
346
|
+
? `Run \`selftune dashboard --port ${options.port} --restart\` to replace the existing dashboard.`
|
|
347
|
+
: `Use \`selftune dashboard --port <port>\` or stop the process currently listening on ${options.port}.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
12
355
|
export async function cliMain(): Promise<void> {
|
|
13
356
|
const args = process.argv.slice(2);
|
|
14
357
|
|
|
@@ -18,6 +361,7 @@ export async function cliMain(): Promise<void> {
|
|
|
18
361
|
Usage:
|
|
19
362
|
selftune dashboard Start dashboard server (port 3141)
|
|
20
363
|
selftune dashboard --port 8080 Start on custom port
|
|
364
|
+
selftune dashboard --restart Restart existing dashboard on the target port
|
|
21
365
|
selftune dashboard --serve Deprecated alias for default behavior
|
|
22
366
|
selftune dashboard --no-open Start server without opening browser`);
|
|
23
367
|
process.exit(0);
|
|
@@ -31,27 +375,16 @@ Usage:
|
|
|
31
375
|
);
|
|
32
376
|
}
|
|
33
377
|
|
|
34
|
-
const portIdx = args.indexOf("--port");
|
|
35
|
-
let port: number | undefined;
|
|
36
|
-
if (portIdx !== -1) {
|
|
37
|
-
const parsed = Number.parseInt(args[portIdx + 1], 10);
|
|
38
|
-
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
39
|
-
throw new CLIError(
|
|
40
|
-
`Invalid port "${args[portIdx + 1]}": must be an integer between 1 and 65535.`,
|
|
41
|
-
"INVALID_FLAG",
|
|
42
|
-
"Provide a port number between 1 and 65535 (e.g., --port 3141).",
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
port = parsed;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
378
|
if (args.includes("--serve")) {
|
|
49
379
|
console.warn("`selftune dashboard --serve` is deprecated; use `selftune dashboard` instead.");
|
|
50
380
|
}
|
|
51
381
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
382
|
+
const launch = await launchDashboard(args);
|
|
383
|
+
if (launch.action === "reused" || !launch.serverHandle) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const { stop } = launch.serverHandle;
|
|
55
388
|
await new Promise<void>((resolve) => {
|
|
56
389
|
let closed = false;
|
|
57
390
|
const keepAlive = setInterval(() => {}, 1 << 30);
|