feat(stats): add launcher stats command and build integration

- Launcher stats subcommand with cleanup mode
- Stats frontend build integrated into Makefile
- CI workflow updated for stats package
- Config example updated with stats section
- mpv plugin menu entry for stats toggle
This commit is contained in:
2026-03-14 22:14:46 -07:00
parent 26fb5b4162
commit 950263bd66
20 changed files with 456 additions and 60 deletions

View File

@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runStatsCommand } from './stats-command.js';
class ExitSignal extends Error {
code: number;
@@ -128,3 +129,98 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
/unexpectedly returned/,
);
});
test('stats command launches attached app command with response path', async () => {
const context = createContext();
context.args.stats = true;
context.args.logLevel = 'debug';
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'],
]);
});
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const context = createContext();
context.args.stats = true;
context.args.statsCleanup = true;
context.args.statsCleanupVocab = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-vocab',
],
]);
});
test('stats command throws when stats response reports an error', async () => {
const context = createContext();
context.args.stats = true;
await assert.rejects(
async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => 0,
waitForStatsResponse: async () => ({
ok: false,
error: 'Immersion tracking is disabled in config.',
}),
removeDir: () => {},
});
},
/Immersion tracking is disabled in config\./,
);
});
test('stats command fails if attached app exits before startup response', async () => {
const context = createContext();
context.args.stats = true;
await assert.rejects(
async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => 2,
waitForStatsResponse: async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
return { ok: true, url: 'http://127.0.0.1:5175' };
},
removeDir: () => {},
});
},
/Stats app exited before startup response \(status 2\)\./,
);
});

View File

@@ -0,0 +1,108 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
type StatsCommandResponse = {
ok: boolean;
url?: string;
error?: string;
};
type StatsCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
runAppCommandAttached: (
appPath: string,
appArgs: string[],
logLevel: LauncherCommandContext['args']['logLevel'],
label: string,
) => Promise<number>;
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
removeDir: (targetPath: string) => void;
};
const defaultDeps: StatsCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts),
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
runAppCommandAttached(appPath, appArgs, logLevel, label),
waitForStatsResponse: async (responsePath) => {
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return {
ok: false,
error: 'Timed out waiting for stats dashboard startup response.',
};
},
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
};
export async function runStatsCommand(
context: LauncherCommandContext,
deps: StatsCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath } = context;
if (!args.stats || !appPath) {
return false;
}
const tempDir = deps.createTempDir('subminer-stats-');
const responsePath = deps.joinPath(tempDir, 'response.json');
try {
const forwarded = ['--stats', '--stats-response-path', responsePath];
if (args.statsCleanup) {
forwarded.push('--stats-cleanup');
}
if (args.statsCleanupVocab) {
forwarded.push('--stats-cleanup-vocab');
}
if (args.logLevel !== 'info') {
forwarded.push('--log-level', args.logLevel);
}
const attachedExitPromise = deps.runAppCommandAttached(
appPath,
forwarded,
args.logLevel,
'stats',
);
const startupResult = await Promise.race([
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]);
if (startupResult.kind === 'exit') {
if (startupResult.status !== 0) {
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
}
const response = await deps.waitForStatsResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
return true;
}
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
const exitStatus = await attachedExitPromise;
if (exitStatus !== 0) {
throw new Error(`Stats app exited with status ${exitStatus}.`);
}
return true;
} finally {
deps.removeDir(tempDir);
}
}