mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
updated readme
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
{
|
||||
"timestamp": "2026-03-31T09:23:48.747Z",
|
||||
"sessions": [
|
||||
{
|
||||
"date": "2025-03-24",
|
||||
"summary": "Comprehensive file reference documentation review — 28 bugs fixed, all docs updated to match code reality",
|
||||
"tasksCompleted": 28,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"stores",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"protocol",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2025-03-24-file-reference-review.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"summary": "Completed all 13 CEO review fixes and raised server test coverage to 80%+",
|
||||
"tasksCompleted": 14,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"config",
|
||||
"stores",
|
||||
"tauri-rust",
|
||||
"tests",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-17-ceo-fixes-and-coverage.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"summary": "CEO plan review sections 1-8 (HOLD SCOPE) for tauri-migration branch",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"storage",
|
||||
"config",
|
||||
"stores",
|
||||
"pages",
|
||||
"tauri-rust",
|
||||
"tests",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-17-ceo-review-sections-1-8.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"summary": "CEO plan review of tauri-migration branch — HOLD SCOPE",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"permissions",
|
||||
"storage",
|
||||
"components",
|
||||
"pages",
|
||||
"tauri-rust",
|
||||
"protocol",
|
||||
"tests",
|
||||
"security"
|
||||
],
|
||||
"fileName": "2026-03-17-ceo-review.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"summary": "Fixed all golangci-lint issues blocking CI, set up project brain vault",
|
||||
"tasksCompleted": 2,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"ws",
|
||||
"voice",
|
||||
"tauri-rust",
|
||||
"tests",
|
||||
"docs",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-17-lint-fixes-and-vault-setup.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"summary": "Pre-landing review of tauri-migration, commit, push, and PR #15 to dev",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"auth",
|
||||
"db",
|
||||
"config",
|
||||
"tauri-rust",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-17-merge-review-and-pr.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"summary": "Channel management, file uploads, URL previews, voice fixes, UX polish",
|
||||
"tasksCompleted": 15,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"voice",
|
||||
"storage",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-18-features.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"summary": "Added native E2E testing via WebView2 CDP",
|
||||
"tasksCompleted": 1,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"voice",
|
||||
"storage",
|
||||
"config",
|
||||
"lib",
|
||||
"tauri-rust",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-18-native-e2e.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"summary": "Voice chat NAT fix, audio pipeline overhaul, debugging infrastructure",
|
||||
"tasksCompleted": 12,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"voice",
|
||||
"stores",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"tests",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-18-voice-audio-fixes.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-19",
|
||||
"summary": "Go code review (4 passes, 19 fixes), admin panel redesign, live server logs, audit log filters, console output cleanup",
|
||||
"tasksCompleted": 7,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"permissions",
|
||||
"storage",
|
||||
"config",
|
||||
"lib",
|
||||
"components",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-19-admin-panel-and-server-review.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-20",
|
||||
"summary": "Camera button delay fix, video feed flicker fix, security hardening, documentation sync",
|
||||
"tasksCompleted": 4,
|
||||
"modulesTouched": [
|
||||
"auth",
|
||||
"db",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"stores",
|
||||
"pages",
|
||||
"tauri-rust",
|
||||
"protocol",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-20-livekit-camera-fixes.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-20",
|
||||
"summary": "",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"stores",
|
||||
"protocol",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-20-research-report.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-21",
|
||||
"summary": "Implemented 30 actionable fixes from code review audit across server and client",
|
||||
"tasksCompleted": 30,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"storage",
|
||||
"stores",
|
||||
"components",
|
||||
"pages",
|
||||
"tauri-rust",
|
||||
"e2e",
|
||||
"tests",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-21-code-review-fixes.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-22",
|
||||
"summary": "Competitive research, feature roadmap, GitHub issues, vault build-out",
|
||||
"tasksCompleted": 10,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"lib",
|
||||
"stores",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"protocol",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-22-competitive-research.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-26",
|
||||
"summary": "Implemented client-side fixes from Codex/code review findings and added targeted regression coverage.",
|
||||
"tasksCompleted": 1,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"ws",
|
||||
"voice",
|
||||
"config",
|
||||
"stores",
|
||||
"components",
|
||||
"tests",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-26-client-review-fixes.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-27",
|
||||
"summary": "Code review (27 fixes), connection quality indicator, remember-password fix, login redesign with OC branding, new app icon.",
|
||||
"tasksCompleted": 38,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"storage",
|
||||
"config",
|
||||
"lib",
|
||||
"stores",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"protocol",
|
||||
"tests",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-27-dual-model-code-review.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-28",
|
||||
"summary": "Spec audit (18 files, 50 fixes), 143 unit tests, E2E overhaul, CSS injection fix",
|
||||
"tasksCompleted": 8,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"storage",
|
||||
"lib",
|
||||
"stores",
|
||||
"components",
|
||||
"tauri-rust",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-28-specs-tests-e2e.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-29",
|
||||
"summary": "Client-side 2FA integration — TOTP enrollment/disable UI, api.ts fixes, documentation sync",
|
||||
"tasksCompleted": 5,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"stores",
|
||||
"components",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-29-2fa-client-integration.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-29",
|
||||
"summary": "Full-project security + code quality audit, 30 issues fixed",
|
||||
"tasksCompleted": 27,
|
||||
"modulesTouched": [
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"ws",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"lib",
|
||||
"tests",
|
||||
"security",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-29-security-audit-fixes.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"summary": "Fixed 10 test quality bugs (BUG-058 through BUG-067)",
|
||||
"tasksCompleted": 10,
|
||||
"modulesTouched": [
|
||||
"auth",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"tauri-rust",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-30-bug-remediation.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"summary": "Full documentation update — README, Changelog, Dashboard, task tracking",
|
||||
"tasksCompleted": 5,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"db",
|
||||
"voice",
|
||||
"permissions",
|
||||
"config",
|
||||
"components",
|
||||
"e2e",
|
||||
"tests",
|
||||
"docs",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-30-documentation-update.md"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"summary": "Sidebar stream preview + screenshare focus fix",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"voice",
|
||||
"components",
|
||||
"ci"
|
||||
],
|
||||
"fileName": "2026-03-30-stream-preview.md"
|
||||
}
|
||||
],
|
||||
"progressOverTime": [
|
||||
{
|
||||
"date": "2025-03-24",
|
||||
"cumulativeDone": 28,
|
||||
"sessionDone": 28
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"cumulativeDone": 42,
|
||||
"sessionDone": 14
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"cumulativeDone": 42,
|
||||
"sessionDone": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"cumulativeDone": 42,
|
||||
"sessionDone": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"cumulativeDone": 44,
|
||||
"sessionDone": 2
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"cumulativeDone": 44,
|
||||
"sessionDone": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"cumulativeDone": 59,
|
||||
"sessionDone": 15
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"cumulativeDone": 60,
|
||||
"sessionDone": 1
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"cumulativeDone": 72,
|
||||
"sessionDone": 12
|
||||
},
|
||||
{
|
||||
"date": "2026-03-19",
|
||||
"cumulativeDone": 79,
|
||||
"sessionDone": 7
|
||||
},
|
||||
{
|
||||
"date": "2026-03-20",
|
||||
"cumulativeDone": 83,
|
||||
"sessionDone": 4
|
||||
},
|
||||
{
|
||||
"date": "2026-03-20",
|
||||
"cumulativeDone": 83,
|
||||
"sessionDone": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-21",
|
||||
"cumulativeDone": 113,
|
||||
"sessionDone": 30
|
||||
},
|
||||
{
|
||||
"date": "2026-03-22",
|
||||
"cumulativeDone": 123,
|
||||
"sessionDone": 10
|
||||
},
|
||||
{
|
||||
"date": "2026-03-26",
|
||||
"cumulativeDone": 124,
|
||||
"sessionDone": 1
|
||||
},
|
||||
{
|
||||
"date": "2026-03-27",
|
||||
"cumulativeDone": 162,
|
||||
"sessionDone": 38
|
||||
},
|
||||
{
|
||||
"date": "2026-03-28",
|
||||
"cumulativeDone": 170,
|
||||
"sessionDone": 8
|
||||
},
|
||||
{
|
||||
"date": "2026-03-29",
|
||||
"cumulativeDone": 175,
|
||||
"sessionDone": 5
|
||||
},
|
||||
{
|
||||
"date": "2026-03-29",
|
||||
"cumulativeDone": 202,
|
||||
"sessionDone": 27
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"cumulativeDone": 212,
|
||||
"sessionDone": 10
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"cumulativeDone": 217,
|
||||
"sessionDone": 5
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"cumulativeDone": 217,
|
||||
"sessionDone": 0
|
||||
}
|
||||
],
|
||||
"streaks": {
|
||||
"current": 5,
|
||||
"longest": 6,
|
||||
"totalSessions": 22
|
||||
},
|
||||
"lastSession": {
|
||||
"date": "2026-03-30",
|
||||
"summary": "Sidebar stream preview + screenshare focus fix",
|
||||
"tasksCompleted": 0,
|
||||
"modulesTouched": [
|
||||
"api",
|
||||
"auth",
|
||||
"voice",
|
||||
"components",
|
||||
"ci"
|
||||
]
|
||||
},
|
||||
"inProgress": [],
|
||||
"recentlyDone": [
|
||||
{
|
||||
"id": "T-197",
|
||||
"description": "Discord-style video grid with fixed 16:9 aspect ratio",
|
||||
"date": "2026-03-30"
|
||||
},
|
||||
{
|
||||
"id": "T-198",
|
||||
"description": "Sidebar stream preview + screenshare focus fix",
|
||||
"date": "2026-03-30"
|
||||
},
|
||||
{
|
||||
"id": "T-199",
|
||||
"description": "Fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors",
|
||||
"date": "2026-03-30"
|
||||
},
|
||||
{
|
||||
"id": "T-200",
|
||||
"description": "Regenerate codemaps from current codebase",
|
||||
"date": "2026-03-30"
|
||||
},
|
||||
{
|
||||
"id": "T-201",
|
||||
"description": "Full documentation update (README, Changelog, Dashboard, all docs)",
|
||||
"date": "2026-03-30"
|
||||
},
|
||||
{
|
||||
"id": "T-192",
|
||||
"description": "Client 2FA enrollment/disable settings UI",
|
||||
"date": "2026-03-29"
|
||||
},
|
||||
{
|
||||
"id": "T-193",
|
||||
"description": "Client 2FA test coverage — 27 new tests",
|
||||
"date": "2026-03-29"
|
||||
},
|
||||
{
|
||||
"id": "T-194",
|
||||
"description": "Full regression validation pass — all green",
|
||||
"date": "2026-03-29"
|
||||
},
|
||||
{
|
||||
"id": "T-023",
|
||||
"description": "Add TOTP 2FA support — Login challenge: DONE; Server endpoints: DONE; Client enrollment UI: DONE; Client tests: DONE",
|
||||
"date": "2026-03-29"
|
||||
},
|
||||
{
|
||||
"id": "T-190",
|
||||
"description": "Propagate `context.Context` from WS upgrade through all handlers — added `ctx context.Context` field to Client struct (set from `r.Context()` on WS upgrade), updated `MessageHandler` type signature, threaded ctx through all 17 WS handlers across 9 files (chat, presence, reaction, voice, ping). Added `ExecContext`/`QueryRowContext`/`QueryContext`/`BeginTx` context-accepting methods to DB wrapper. Go build + all ws/api/auth/db tests pass",
|
||||
"date": "2026-03-29"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"commands_hash": "ca3b770e3d69abf7",
|
||||
"structs_hash": "2c0574a96a92e42f",
|
||||
"config_hash": "c72a07caa5bc6ed4",
|
||||
"combined_hash": "6a107ade235e2401"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.628896400+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as types from "./types";
|
||||
|
||||
export async function startLivekitProxy(
|
||||
params: types.StartLivekitProxyParams,
|
||||
): Promise<number> {
|
||||
return invoke("start_livekit_proxy", params);
|
||||
}
|
||||
|
||||
export async function stopLivekitProxy(): Promise<void> {
|
||||
return invoke("stop_livekit_proxy");
|
||||
}
|
||||
|
||||
export async function checkClientUpdate(
|
||||
params: types.CheckClientUpdateParams,
|
||||
): Promise<types.UpdateCheckResult> {
|
||||
return invoke("check_client_update", params);
|
||||
}
|
||||
|
||||
export async function downloadAndInstallUpdate(
|
||||
params: types.DownloadAndInstallUpdateParams,
|
||||
): Promise<void> {
|
||||
return invoke("download_and_install_update", params);
|
||||
}
|
||||
|
||||
export async function pttStart(): Promise<void> {
|
||||
return invoke("ptt_start");
|
||||
}
|
||||
|
||||
export async function pttStop(): Promise<void> {
|
||||
return invoke("ptt_stop");
|
||||
}
|
||||
|
||||
export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> {
|
||||
return invoke("ptt_set_key", params);
|
||||
}
|
||||
|
||||
export async function pttGetKey(): Promise<number> {
|
||||
return invoke("ptt_get_key");
|
||||
}
|
||||
|
||||
export async function pttListenForKey(): Promise<number> {
|
||||
return invoke("ptt_listen_for_key");
|
||||
}
|
||||
|
||||
export async function saveCredential(
|
||||
params: types.SaveCredentialParams,
|
||||
): Promise<void> {
|
||||
return invoke("save_credential", params);
|
||||
}
|
||||
|
||||
export async function loadCredential(
|
||||
params: types.LoadCredentialParams,
|
||||
): Promise<types.CredentialData | null> {
|
||||
return invoke("load_credential", params);
|
||||
}
|
||||
|
||||
export async function deleteCredential(
|
||||
params: types.DeleteCredentialParams,
|
||||
): Promise<void> {
|
||||
return invoke("delete_credential", params);
|
||||
}
|
||||
|
||||
export async function wsConnect(params: types.WsConnectParams): Promise<void> {
|
||||
return invoke("ws_connect", params);
|
||||
}
|
||||
|
||||
export async function wsSend(params: types.WsSendParams): Promise<void> {
|
||||
return invoke("ws_send", params);
|
||||
}
|
||||
|
||||
export async function wsDisconnect(): Promise<void> {
|
||||
return invoke("ws_disconnect");
|
||||
}
|
||||
|
||||
export async function acceptCertFingerprint(
|
||||
params: types.AcceptCertFingerprintParams,
|
||||
): Promise<void> {
|
||||
return invoke("accept_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<types.Value> {
|
||||
return invoke("get_settings");
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
params: types.SaveSettingsParams,
|
||||
): Promise<void> {
|
||||
return invoke("save_settings", params);
|
||||
}
|
||||
|
||||
export async function storeCertFingerprint(
|
||||
params: types.StoreCertFingerprintParams,
|
||||
): Promise<void> {
|
||||
return invoke("store_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function getCertFingerprint(
|
||||
params: types.GetCertFingerprintParams,
|
||||
): Promise<string | null> {
|
||||
return invoke("get_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function openDevtools(): Promise<void> {
|
||||
return invoke("open_devtools");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.629251800+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event Listeners
|
||||
* Type-safe event listener helpers for Tauri events
|
||||
*/
|
||||
import { listen, type UnlistenFn, type Event } from "@tauri-apps/api/event";
|
||||
import * as types from "./types";
|
||||
|
||||
/**
|
||||
* Listen for 'status-change' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onStatusChange(
|
||||
handler: (payload: string) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<string>("status-change", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for 'ws-state' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onWsState(
|
||||
handler: (payload: string) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<string>("ws-state", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for 'cert-tofu' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onCertTofu(
|
||||
handler: (payload: types.Value) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<types.Value>("cert-tofu", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.629428700+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./commands";
|
||||
export * from "./events";
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.628377200+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
available: boolean;
|
||||
version?: string | null;
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
export type Value = unknown;
|
||||
|
||||
export interface CredentialData {
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface StartLivekitProxyParams {
|
||||
remoteHost: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CheckClientUpdateParams {
|
||||
serverUrl: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DownloadAndInstallUpdateParams {
|
||||
serverUrl: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PttSetKeyParams {
|
||||
vkCode: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SaveCredentialParams {
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
password?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LoadCredentialParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeleteCredentialParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WsConnectParams {
|
||||
url: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WsSendParams {
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AcceptCertFingerprintParams {
|
||||
host: string;
|
||||
fingerprint: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SaveSettingsParams {
|
||||
key: string;
|
||||
value: Value;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StoreCertFingerprintParams {
|
||||
host: string;
|
||||
fingerprint: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GetCertFingerprintParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -8,13 +8,15 @@
|
||||
|
||||
*The gaming chat platform you actually own.*
|
||||
|
||||
> **Early Alpha — Building in the Open**
|
||||
> OwnCord is under active development and is not production-ready. Do not use it for sensitive communications. Security hardening is in progress. Contributions and [security reports](https://github.com/J3vb/OwnCord/issues) are welcome.
|
||||
## Development Model
|
||||
OwnCord follows an AI-assisted, iteration-focused development workflow.
|
||||
A significant portion of the implementation is generated with AI tooling, with quality validated through automated checks (CI, tests, linting) and real-world usage during alpha.
|
||||
|
||||
A self-hosted Windows chat platform with real-time messaging,
|
||||
voice/video, file sharing, and a web admin panel. Run your own
|
||||
server and keep everything under your control — zero cloud
|
||||
dependencies, works fully on LAN.
|
||||
This approach enables rapid iteration and experimentation, but it also means behavior and internal structure may evolve quickly between releases.
|
||||
|
||||
|
||||
OwnCord is a self-hosted chat stack with a Go server and a Tauri desktop client.
|
||||
It includes real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/Client.png" alt="OwnCord Client" width="700">
|
||||
@@ -25,155 +27,73 @@ dependencies, works fully on LAN.
|
||||
<img src=".github/images/Admin_Panel.png" alt="Admin Panel" width="340">
|
||||
</p>
|
||||
|
||||
## Current Project Status
|
||||
|
||||
| Area | Status |
|
||||
| ---- | ------ |
|
||||
| Core chat flow | Working in alpha |
|
||||
| Voice/video | Working in alpha |
|
||||
| Admin panel | Working in alpha |
|
||||
| Security hardening | In progress |
|
||||
|
||||
## Platform Support (Current Releases)
|
||||
|
||||
| Component | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
| --------- | ----------- | --------- | ----------- |
|
||||
| Server binary | Yes | Not published yet | N/A |
|
||||
| Desktop client | Yes | Not published yet | Not published yet |
|
||||
| Docker server | N/A | Not published yet | N/A |
|
||||
|
||||
## Start Here
|
||||
|
||||
- New user quick path: [docs/quick-start.md](docs/quick-start.md)
|
||||
- Linux Docker deployment: [docs/deployment.md](docs/deployment.md)
|
||||
- Remote access without router config: [docs/tailscale.md](docs/tailscale.md)
|
||||
- Manual router/network setup: [docs/port-forwarding.md](docs/port-forwarding.md)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Download `chatserver.exe` and the OwnCord installer from
|
||||
[GitHub Releases](https://github.com/J3vb/OwnCord/releases)
|
||||
2. Run `chatserver.exe` — generates `config.yaml` and a `data/`
|
||||
directory (database, TLS certs, uploads, backups) on first run
|
||||
3. Open `https://localhost:8443/admin` to create the Owner account
|
||||
4. Generate an invite code in the admin panel and share it
|
||||
5. Friends install the client, enter your server address
|
||||
(`ip:8443`), and register with the invite code
|
||||
> Note: You should locate your active IPv4 via `ipconfig` for Win or `ip a` for Linux, since OwnCord server runs on `0.0.0.0`.
|
||||
>
|
||||
> Example: 192.168.1.2:8443
|
||||
### Option A: Prebuilt binaries
|
||||
|
||||
The client uses TOFU (Trust On First Use) for self-signed
|
||||
certificates — it prompts to trust the server on first
|
||||
connection, then pins it for future sessions.
|
||||
1. Download assets from [GitHub Releases](https://github.com/J3vb/OwnCord/releases).
|
||||
2. Run the server binary:
|
||||
- Windows: `chatserver.exe`
|
||||
- Linux: `./chatserver`
|
||||
3. Open `https://localhost:8443/admin` and create your Owner account.
|
||||
4. Generate invite codes in the admin panel and share them with friends.
|
||||
|
||||
### Option B: Docker (Linux server)
|
||||
|
||||
## Features
|
||||
```bash
|
||||
cd Server
|
||||
cp .env.example .env
|
||||
cp livekit.yaml.example livekit.yaml
|
||||
# Edit both files before starting
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Chat
|
||||
See the full setup guide in [docs/deployment.md](docs/deployment.md).
|
||||
|
||||
- Real-time text messaging over WebSocket
|
||||
- Message editing, deletion, and replies
|
||||
- Emoji reactions with per-message counts
|
||||
- Typing indicators
|
||||
- Full-text message search (SQLite FTS5)
|
||||
- Pinned messages per channel
|
||||
- Rich link previews with Open Graph metadata
|
||||
- YouTube embed support with cached titles
|
||||
- GIF picker powered by Tenor with inline rendering
|
||||
- Inline image previews with lightbox viewer
|
||||
The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections.
|
||||
|
||||
### Voice & Video
|
||||
## What OwnCord Already Has
|
||||
|
||||
- Voice channels powered by LiveKit SFU
|
||||
- Webcam video chat with Discord-style grid layout (fixed 16:9 aspect ratio)
|
||||
- Sidebar stream preview (hover to see live video thumbnail)
|
||||
- Mute, deafen, camera, and screenshare controls
|
||||
- Push-to-talk with global hotkey (non-consuming, works while unfocused)
|
||||
- Per-user volume control (right-click user in voice channel)
|
||||
- RNNoise ML noise suppression
|
||||
- Voice activity detection with speaker indicators (pulsing green glow)
|
||||
- Connection quality indicator with expandable transport stats
|
||||
- Voice call duration timer (MM:SS / HH:MM:SS elapsed)
|
||||
- LiveKit server runs as a companion process alongside `chatserver.exe`
|
||||
- Real-time channels and direct messages over WebSocket
|
||||
- Voice/video channels via LiveKit
|
||||
- Invite-only registration and role-based permissions
|
||||
- Web admin panel with logs, backups, and update tooling
|
||||
- File uploads and inline media rendering
|
||||
- TOTP 2FA support and API rate limiting
|
||||
- Desktop client auto-update with signature verification
|
||||
|
||||
### Direct Messages
|
||||
|
||||
- One-on-one DM conversations with any server member
|
||||
- DM preview section in sidebar with unread bubble indicators
|
||||
- Auto-reopen DM channels on incoming message
|
||||
- DM header shows `@ username` with live online status
|
||||
|
||||
### Channels & Organization
|
||||
|
||||
- Text and voice channels organized by categories
|
||||
- Create, edit, delete, and reorder channels
|
||||
- Unread message indicators
|
||||
- Quick channel switcher (Ctrl+K)
|
||||
|
||||
### File Sharing
|
||||
|
||||
- Drag-and-drop and clipboard paste uploads
|
||||
- Inline image previews with persistent caching (IndexedDB)
|
||||
- File download with native save dialog
|
||||
- Configurable max upload size
|
||||
|
||||
### Users & Permissions
|
||||
|
||||
- Invite-only registration with invite codes
|
||||
- Role-based permissions with custom roles
|
||||
- Member list with online/offline presence
|
||||
- User profiles with status (online, idle, dnd, offline)
|
||||
|
||||
### Administration
|
||||
|
||||
- Web-based admin panel at `/admin` (IP-restricted to private networks by default)
|
||||
- Dashboard with server stats and recent activity
|
||||
- User management (ban, kick, role assignment) with modals
|
||||
- Channel management (create, edit, delete)
|
||||
- Settings management (server name, MOTD, limits, security)
|
||||
- Live server log streaming via SSE with level filters,
|
||||
search, auto-scroll, pause/resume, copy, and clear
|
||||
- Audit log with search, action type filter, copy, and CSV export
|
||||
- Database backup and restore with pre-restore safety backups
|
||||
- Server update checker and one-click apply (GitHub Releases)
|
||||
- Metrics endpoint with uptime, goroutines, heap, connected users
|
||||
- Diagnostics endpoint for connectivity checks
|
||||
|
||||
### Security
|
||||
|
||||
- TLS encryption (self-signed, Let's Encrypt, or custom cert)
|
||||
- Trust-on-first-use certificate pinning in the client
|
||||
- Two-factor authentication (TOTP) with QR enrollment and backup codes
|
||||
- Ed25519-signed client auto-updates
|
||||
- Rate limiting on all endpoints
|
||||
- CSRF protection and security headers
|
||||
- Account deletion with password confirmation and data anonymization
|
||||
|
||||
### Desktop Client
|
||||
|
||||
- Native Windows app built with Tauri v2
|
||||
- System tray integration
|
||||
- Desktop notifications with taskbar flash and sound
|
||||
- In-app auto-update with progress notification
|
||||
- Credential storage via Windows Credential Manager
|
||||
- Auto-login with saved credentials (one-click connect)
|
||||
- Custom emoji picker
|
||||
- Compact mode for information-dense layouts
|
||||
- Discord-style settings panel with blurred backdrop
|
||||
- OC Neon Glow theme with custom theming system (JSON import/export)
|
||||
- Accent color picker
|
||||
- Quick-switch server overlay for multi-server users
|
||||
- Structured logging with JSONL persistence (5-day rotation)
|
||||
|
||||
### Voice & Video Setup (Optional)
|
||||
|
||||
Voice and video require [LiveKit Server](https://github.com/livekit/livekit/releases):
|
||||
|
||||
1. Download `livekit-server` from the LiveKit releases page
|
||||
2. Edit `config.yaml` and set:
|
||||
```yaml
|
||||
voice:
|
||||
livekit_api_key: "devkey" # any string
|
||||
livekit_api_secret: "secret-min-32-characters-long!!" # min 32 chars
|
||||
livekit_binary: "C:/path/to/livekit-server.exe"
|
||||
```
|
||||
3. Restart `chatserver.exe` — it auto-starts LiveKit as a
|
||||
companion process
|
||||
|
||||
### Networking
|
||||
|
||||
For friends outside your LAN, you need to forward these ports:
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `8443` | TCP | HTTPS, WebSocket, REST API |
|
||||
| `7881` | TCP | LiveKit signaling (voice/video) |
|
||||
| `50000-60000` | UDP | LiveKit WebRTC media (voice/video) |
|
||||
|
||||
Alternatively, use Tailscale for zero-config networking
|
||||
with no port forwarding.
|
||||
See deeper feature and architecture docs in [docs/client-architecture.md](docs/client-architecture.md) and [docs/protocol.md](docs/protocol.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
Two components: a **Go server** and a **Tauri v2 client**
|
||||
(Rust + TypeScript).
|
||||
Two main components:
|
||||
|
||||
- Go server (REST API, WebSocket hub, SQLite, admin panel)
|
||||
- Tauri v2 desktop client (Rust backend + TypeScript frontend)
|
||||
|
||||
```text
|
||||
+---------------------+ +---------------------+
|
||||
@@ -195,161 +115,103 @@ Two components: a **Go server** and a **Tauri v2 client**
|
||||
+---------------------+
|
||||
```
|
||||
|
||||
- **WebSocket** — chat messages, typing, presence, voice signaling
|
||||
- **REST API** — message history, file uploads, channel management, auth
|
||||
- **LiveKit** — voice and video via LiveKit SFU (companion process)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
OwnCord/
|
||||
├── Server/ # Go server
|
||||
│ ├── api/ # REST handlers + middleware
|
||||
│ ├── ws/ # WebSocket hub + handlers
|
||||
│ ├── db/ # SQLite queries + migrations
|
||||
│ ├── auth/ # Authentication + rate limiting
|
||||
│ ├── config/ # YAML config loading
|
||||
│ ├── updater/ # GitHub Releases update checker
|
||||
│ ├── admin/ # Web admin panel (static SPA)
|
||||
│ ├── storage/ # File upload storage
|
||||
│ ├── permissions/ # Role-based permission system
|
||||
│ └── migrations/ # Database migration files
|
||||
├── Client/
|
||||
│ └── tauri-client/ # Tauri v2 desktop client
|
||||
│ ├── src-tauri/ # Rust backend (plugins, commands)
|
||||
│ ├── src/ # TypeScript frontend
|
||||
│ │ ├── lib/ # Core services (API, WS, LiveKit, updater)
|
||||
│ │ ├── stores/ # Reactive state (auth, channels, messages, voice)
|
||||
│ │ ├── components/ # UI components (28 modules)
|
||||
│ │ ├── pages/ # Page layouts
|
||||
│ │ └── styles/ # CSS
|
||||
│ └── tests/ # Unit, integration, and E2E tests
|
||||
└── docs/ # Project documentation (Obsidian vault)
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
## Build and Test
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- Rust (stable)
|
||||
- Windows 10/11
|
||||
- Rust stable (client builds)
|
||||
|
||||
### Server
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
# Server (Windows)
|
||||
cd Server
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
|
||||
```
|
||||
|
||||
### Client
|
||||
# Server (Linux)
|
||||
cd Server
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" .
|
||||
|
||||
```bash
|
||||
# Client
|
||||
cd Client/tauri-client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The installer is output to
|
||||
`Client/tauri-client/src-tauri/target/release/bundle/nsis/`.
|
||||
|
||||
### Running Tests
|
||||
### Core verification commands
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd Server && go test ./...
|
||||
cd Server && go test ./... -cover # with coverage
|
||||
cd Server
|
||||
go test ./...
|
||||
|
||||
# Client
|
||||
cd Client/tauri-client
|
||||
npm test # all tests (vitest)
|
||||
npm run test:unit # unit tests only
|
||||
npm run test:integration # integration tests
|
||||
npm run test:e2e # Playwright E2E (mocked Tauri)
|
||||
npm run test:e2e:native # Playwright E2E (real Tauri exe + CDP)
|
||||
npm run test:coverage # coverage report
|
||||
|
||||
# Type checking & linting
|
||||
npm run typecheck # full typecheck
|
||||
npm run lint # ESLint check
|
||||
npm run lint:fix # ESLint auto-fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
For the full command set, use [docs/contributing.md](docs/contributing.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
The server generates a `config.yaml` on first run. All runtime data
|
||||
is stored in a `data/` directory alongside the executable:
|
||||
On first run, the server generates `config.yaml` and a local `data/` directory:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── owncord.db # SQLite database
|
||||
├── certs/ # TLS certificates (auto-generated if self_signed)
|
||||
├── uploads/ # User-uploaded files
|
||||
└── backups/ # Database backups
|
||||
├── chatserver.db
|
||||
├── certs/
|
||||
├── uploads/
|
||||
└── backups/
|
||||
```
|
||||
|
||||
Key settings:
|
||||
Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions.
|
||||
See [docs/server-configuration.md](docs/server-configuration.md).
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------- | ------- | ----------- |
|
||||
| `server.port` | `8443` | HTTPS port |
|
||||
| `server.name` | `OwnCord Server` | Display name |
|
||||
| `tls.mode` | `self_signed` | TLS mode (self_signed, acme, manual, off) |
|
||||
| `upload.max_size_mb` | `100` | Max upload size |
|
||||
| `voice.livekit_url` | `ws://localhost:7880` | LiveKit server WebSocket URL |
|
||||
| `voice.livekit_api_key` | — | LiveKit API key (required for voice) |
|
||||
| `voice.livekit_api_secret` | — | LiveKit API secret (min 32 chars, required for voice) |
|
||||
| `voice.livekit_binary` | — | Path to `livekit-server` binary (empty = don't auto-start) |
|
||||
| `voice.quality` | `medium` | Voice quality (low, medium, high) |
|
||||
| `server.admin_allowed_cidrs` | private nets | CIDRs allowed to access `/admin` |
|
||||
| `github.token` | — | Token for update checks (optional, for higher rate limits) |
|
||||
## Security and Vulnerability Reporting
|
||||
|
||||
## Auto-Updates
|
||||
- For vulnerabilities, use GitHub Security Advisories (private disclosure flow).
|
||||
- Do not open public issues for security bugs.
|
||||
- Read full policy and hardening notes in [docs/security.md](docs/security.md).
|
||||
|
||||
The client checks for updates after connecting to the server.
|
||||
Updates are Ed25519-signed and verified before install.
|
||||
## Update Signing Notes (Maintainers)
|
||||
|
||||
To enable signed releases in CI, add these GitHub repository secrets:
|
||||
Client and server update signing keys are intentionally separate.
|
||||
|
||||
- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key
|
||||
(via `npx tauri signer generate`)
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password
|
||||
Required Actions secrets for release signing:
|
||||
|
||||
## Documentation
|
||||
- `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY`
|
||||
- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
|
||||
- [Quick Start Guide](docs/quick-start.md)
|
||||
- [Server Configuration](docs/server-configuration.md)
|
||||
- [LiveKit Setup (Voice/Video)](docs/livekit-setup.md)
|
||||
- [Deployment Guide](docs/deployment.md)
|
||||
- [Port Forwarding](docs/port-forwarding.md)
|
||||
- [Tailscale Guide](docs/tailscale.md)
|
||||
- [REST API Reference](docs/api.md)
|
||||
- [WebSocket Protocol](docs/protocol.md)
|
||||
- [Database Schema](docs/schema.md)
|
||||
- [Client Architecture](docs/client-architecture.md)
|
||||
- [Contributing](docs/contributing.md)
|
||||
- [Security Policy](docs/security.md)
|
||||
When rotating the server updater key, update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and use staged rollover for live fleets.
|
||||
|
||||
## Docs Index
|
||||
|
||||
- [docs/quick-start.md](docs/quick-start.md)
|
||||
- [docs/deployment.md](docs/deployment.md)
|
||||
- [docs/livekit-setup.md](docs/livekit-setup.md)
|
||||
- [docs/port-forwarding.md](docs/port-forwarding.md)
|
||||
- [docs/tailscale.md](docs/tailscale.md)
|
||||
- [docs/api.md](docs/api.md)
|
||||
- [docs/protocol.md](docs/protocol.md)
|
||||
- [docs/schema.md](docs/schema.md)
|
||||
- [docs/client-architecture.md](docs/client-architecture.md)
|
||||
- [docs/contributing.md](docs/contributing.md)
|
||||
- [docs/security.md](docs/security.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repo and create a feature branch from `dev`
|
||||
2. Follow existing code style and conventions
|
||||
3. Write tests for new functionality
|
||||
4. Open a PR against `dev` with a clear description
|
||||
1. Create a branch from `dev`.
|
||||
2. Keep changes focused and tested.
|
||||
3. Open a PR targeting `dev`.
|
||||
|
||||
See [Contributing Guide](docs/contributing.md) for details.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| --------- | --------- |
|
||||
| Server | Go, chi router, LiveKit server SDK |
|
||||
| Database | SQLite (pure Go, embedded) |
|
||||
| Client | Tauri v2 (Rust + TypeScript) |
|
||||
| Voice/Video | LiveKit SFU (companion process) |
|
||||
| Build | NSIS installer, GitHub Actions CI |
|
||||
See [docs/contributing.md](docs/contributing.md) for the full process.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
AGPL-3.0
|
||||
@@ -0,0 +1 @@
|
||||
.cache/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OwnCord Project Map Generator
|
||||
*
|
||||
* Scans the repository, collects test coverage, parses the backlog,
|
||||
* scores priorities, and outputs a markdown report + terminal summary.
|
||||
*
|
||||
* Usage:
|
||||
* node index.mjs # full run (runs tests for coverage)
|
||||
* node index.mjs --quick # skip test runs, use cached coverage
|
||||
* node index.mjs --research # interactive research launcher
|
||||
* node index.mjs --serve # launch web dashboard
|
||||
*/
|
||||
|
||||
import { scanModules } from './lib/scanner.mjs';
|
||||
import { collectGoCoverage } from './lib/go-coverage.mjs';
|
||||
import { collectVitestCoverage } from './lib/vitest-coverage.mjs';
|
||||
import { parseBacklog } from './lib/backlog-parser.mjs';
|
||||
import { scorePriorities } from './lib/priority-engine.mjs';
|
||||
import { generateReport } from './lib/report-generator.mjs';
|
||||
import { printTerminalSummary } from './lib/terminal-summary.mjs';
|
||||
import { launchResearchAgent } from './lib/research-agent.mjs';
|
||||
import { scanGitHistory } from './lib/git-scanner.mjs';
|
||||
import { parseSessionHistory } from './lib/session-parser.mjs';
|
||||
import { scanTechnicalDebt } from './lib/debt-scanner.mjs';
|
||||
import { buildImportGraph } from './lib/import-graph.mjs';
|
||||
import { generateSuggestions } from './lib/suggestion-engine.mjs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const CACHE_DIR = resolve(__dirname, '.cache');
|
||||
const REPORT_PATH = resolve(ROOT, 'docs/brain/00-Overview/Project-Map.md');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const quick = args.includes('--quick');
|
||||
const research = args.includes('--research');
|
||||
const serve = args.includes('--serve');
|
||||
|
||||
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
async function main() {
|
||||
if (research) {
|
||||
await launchResearchAgent(ROOT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
// Dynamic import to launch the server
|
||||
await import('./server.mjs');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(quick
|
||||
? '\n Project Map (quick mode — using cached data)\n'
|
||||
: '\n Project Map (full mode — running tests for coverage)\n');
|
||||
|
||||
// Core data
|
||||
const modules = await scanModules(ROOT);
|
||||
const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick);
|
||||
const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick);
|
||||
const backlog = await parseBacklog(ROOT);
|
||||
const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog);
|
||||
|
||||
// Enhanced data (graceful failures)
|
||||
let gitData = null, sessionData = null, debtData = null, importGraph = null, suggestions = null;
|
||||
try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Git] ${e.message}`); }
|
||||
try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Session] ${e.message}`); }
|
||||
try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Debt] ${e.message}`); }
|
||||
try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Graph] ${e.message}`); }
|
||||
try {
|
||||
suggestions = await generateSuggestions(CACHE_DIR, {
|
||||
priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData,
|
||||
});
|
||||
} catch (e) { console.error(` [Suggestions] ${e.message}`); }
|
||||
|
||||
// Generate markdown report
|
||||
await generateReport(REPORT_PATH, modules, goCoverage, vitestCoverage, backlog, priorities);
|
||||
|
||||
// Print terminal summary
|
||||
printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities);
|
||||
|
||||
// Print top suggestions
|
||||
if (suggestions?.suggestions?.length) {
|
||||
const CYAN = '\x1b[36m', BOLD = '\x1b[1m', DIM = '\x1b[2m', RESET = '\x1b[0m';
|
||||
console.log(`\n ${CYAN}${BOLD}SMART SUGGESTIONS (${suggestions.strategy})${RESET}`);
|
||||
console.log(` ${DIM}${'─'.repeat(60)}${RESET}`);
|
||||
for (const s of suggestions.suggestions.slice(0, 5)) {
|
||||
console.log(` ${CYAN}${s.rank}.${RESET} ${BOLD}${s.module}${RESET} (score: ${s.score}) — ${DIM}${s.rationale}${RESET}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Project map failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,988 @@
|
||||
/**
|
||||
* Fleet Agent Manager — concurrent job queue with worktree isolation.
|
||||
*
|
||||
* Reworked from single-job queue to support parallel agent execution.
|
||||
* Each agent runs in its own git worktree via worktree-manager.
|
||||
*
|
||||
* Key changes from v1:
|
||||
* - activeAgents Map replaces queueLocked boolean
|
||||
* - QueueStore provides atomic read-modify-write for queue file
|
||||
* - Ring buffer caps live output at 500KB per agent
|
||||
* - Provisioning state for worktree creation phase
|
||||
* - MAX_CONCURRENT configurable parallel limit
|
||||
* - Timeout escalation: 80% warning → SIGTERM → 5s → SIGKILL
|
||||
*/
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
import {
|
||||
createWorktree,
|
||||
destroyWorktree,
|
||||
cleanupStaleWorktrees,
|
||||
} from './worktree-manager.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const QUEUE_FILE = 'agent-queue.json';
|
||||
const RESULTS_DIR = 'agent-results';
|
||||
const PROMPTS_DIR = 'agent-prompts';
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
'research',
|
||||
'write-tests',
|
||||
'code-review',
|
||||
'security-audit',
|
||||
'fix-debt',
|
||||
'custom',
|
||||
]);
|
||||
|
||||
const TIMEOUTS = {
|
||||
research: 600_000,
|
||||
'write-tests': 1_200_000,
|
||||
'code-review': 900_000,
|
||||
'security-audit': 1_200_000,
|
||||
'fix-debt': 900_000,
|
||||
custom: 1_800_000,
|
||||
};
|
||||
|
||||
/** Default max concurrent agents */
|
||||
let MAX_CONCURRENT = 4;
|
||||
|
||||
/** Ring buffer cap per agent (bytes) */
|
||||
const RING_BUFFER_MAX = 512_000; // 500KB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueueStore — centralized queue I/O with atomic updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class QueueStore {
|
||||
#cacheDir;
|
||||
#updateLock = false;
|
||||
|
||||
constructor(cacheDir) {
|
||||
this.#cacheDir = cacheDir;
|
||||
}
|
||||
|
||||
/** Read current queue state */
|
||||
get() {
|
||||
const file = resolve(this.#cacheDir, QUEUE_FILE);
|
||||
if (!existsSync(file)) return [];
|
||||
try {
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a single job by ID */
|
||||
getJob(jobId) {
|
||||
return this.get().find(j => j.id === jobId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write. The updater function receives the current
|
||||
* jobs array and must return the new jobs array.
|
||||
* Prevents concurrent reads from overwriting each other's changes.
|
||||
*/
|
||||
update(updaterFn) {
|
||||
if (this.#updateLock) {
|
||||
throw new Error('QueueStore: concurrent update detected');
|
||||
}
|
||||
this.#updateLock = true;
|
||||
try {
|
||||
const jobs = this.get();
|
||||
const updated = updaterFn(jobs);
|
||||
writeFileSync(
|
||||
resolve(this.#cacheDir, QUEUE_FILE),
|
||||
JSON.stringify(updated, null, 2),
|
||||
);
|
||||
return updated;
|
||||
} finally {
|
||||
this.#updateLock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level store instance — set via init() or createJob() */
|
||||
let store = null;
|
||||
|
||||
function ensureStore(cacheDir) {
|
||||
if (!store || store._dir !== cacheDir) {
|
||||
store = new QueueStore(cacheDir);
|
||||
store._dir = cacheDir;
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — directory setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDirs(cacheDir) {
|
||||
mkdirSync(resolve(cacheDir, RESULTS_DIR), { recursive: true });
|
||||
mkdirSync(resolve(cacheDir, PROMPTS_DIR), { recursive: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — scope path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GO_PACKAGES = new Set([
|
||||
'api', 'ws', 'db', 'auth', 'config', 'admin', 'migrations', 'scripts',
|
||||
]);
|
||||
|
||||
const TS_AREAS = new Set([
|
||||
'lib', 'stores', 'components', 'pages',
|
||||
]);
|
||||
|
||||
function resolveScopePath(target) {
|
||||
if (GO_PACKAGES.has(target)) return `Server/${target}/`;
|
||||
if (TS_AREAS.has(target)) return `Client/tauri-client/src/${target}/`;
|
||||
if (target === 'tauri-rust') return 'Client/tauri-client/src-tauri/src/';
|
||||
return target;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — process checking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
const safePid = parseInt(pid, 10);
|
||||
if (!Number.isInteger(safePid) || safePid <= 0) return false;
|
||||
try {
|
||||
process.kill(safePid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isClaudeProcess(pid) {
|
||||
const safePid = parseInt(pid, 10);
|
||||
if (!Number.isInteger(safePid) || safePid <= 0) return false;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const out = execFileSync(
|
||||
'tasklist',
|
||||
['/FI', `PID eq ${safePid}`, '/FO', 'CSV', '/NH'],
|
||||
{ stdio: 'pipe', timeout: 5_000 },
|
||||
).toString();
|
||||
const lower = out.toLowerCase();
|
||||
return lower.includes('claude') || lower.includes('node');
|
||||
}
|
||||
const out = execFileSync('ps', ['-p', String(safePid), '-o', 'comm='], {
|
||||
stdio: 'pipe',
|
||||
timeout: 5_000,
|
||||
}).toString().trim().toLowerCase();
|
||||
return out.includes('claude') || out.includes('node');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function killProcess(pid) {
|
||||
const safePid = parseInt(pid, 10);
|
||||
if (!Number.isInteger(safePid) || safePid <= 0) return;
|
||||
try {
|
||||
process.kill(safePid, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(safePid, 0);
|
||||
process.kill(safePid, 'SIGKILL');
|
||||
} catch { /* already dead */ }
|
||||
}, 5_000);
|
||||
} catch { /* already dead */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active agents — tracks running processes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map<jobId, { pid, worktreePath, branchName, child, timer, warningTimer }> */
|
||||
const activeAgents = new Map();
|
||||
|
||||
/** Processing flag — guards the auto-process interval from double-spawn */
|
||||
let isProcessing = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ring buffer for live output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const liveOutputBuffers = new Map();
|
||||
|
||||
function appendToRingBuffer(jobId, text) {
|
||||
const current = liveOutputBuffers.get(jobId) || '';
|
||||
let updated = current + text;
|
||||
|
||||
if (updated.length > RING_BUFFER_MAX) {
|
||||
const truncateMarker = '\n[... output truncated — showing last 500KB ...]\n';
|
||||
updated = truncateMarker + updated.slice(updated.length - RING_BUFFER_MAX + truncateMarker.length);
|
||||
}
|
||||
|
||||
liveOutputBuffers.set(jobId, updated);
|
||||
}
|
||||
|
||||
export function getLiveOutput(jobId) {
|
||||
return liveOutputBuffers.get(jobId) || '';
|
||||
}
|
||||
|
||||
export function clearLiveOutput(jobId) {
|
||||
liveOutputBuffers.delete(jobId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPrompt(job, _root) {
|
||||
const { type, target, customPrompt } = job;
|
||||
const scopePath = resolveScopePath(target);
|
||||
|
||||
switch (type) {
|
||||
case 'research':
|
||||
return [
|
||||
`You are a research agent for OwnCord. Investigate the ${target} module.`,
|
||||
'Focus on: coverage gaps, potential bugs, edge cases, security concerns, missing functionality.',
|
||||
`Scope: ${scopePath}`,
|
||||
'Read relevant source and test files. Prioritize findings as CRITICAL/HIGH/MEDIUM/LOW.',
|
||||
'Output a structured markdown report.',
|
||||
].join('\n');
|
||||
|
||||
case 'write-tests':
|
||||
return [
|
||||
'You are a test-writing agent for OwnCord.',
|
||||
`Module: ${target}`,
|
||||
`Read the source files in ${scopePath}.`,
|
||||
'Read docs/brain/06-Specs/TESTING-STRATEGY.md for test patterns.',
|
||||
'Write tests targeting untested functions and branches. Aim for 80%+ coverage.',
|
||||
'Save tests to the appropriate test directory.',
|
||||
].join('\n');
|
||||
|
||||
case 'code-review':
|
||||
return [
|
||||
'You are a code review agent for OwnCord.',
|
||||
`Review recent changes in the ${target} module for bugs, security issues, code quality.`,
|
||||
`Scope: ${scopePath}`,
|
||||
'Output findings with file:line references and severity ratings.',
|
||||
].join('\n');
|
||||
|
||||
case 'security-audit':
|
||||
return [
|
||||
'You are a security audit agent for OwnCord.',
|
||||
`Perform an OWASP Top 10 review of the ${target} module.`,
|
||||
`Scope: ${scopePath}`,
|
||||
'Check for: injection, auth bypass, XSS, CSRF, path traversal, hardcoded secrets.',
|
||||
'Output findings with severity, file:line, and remediation steps.',
|
||||
].join('\n');
|
||||
|
||||
case 'fix-debt':
|
||||
return [
|
||||
'You are a technical debt agent for OwnCord.',
|
||||
`Address TODO/FIXME/HACK items in the ${target} module.`,
|
||||
`Scope: ${scopePath}`,
|
||||
'For each debt marker, either fix it or explain why it should remain.',
|
||||
].join('\n');
|
||||
|
||||
case 'custom':
|
||||
return customPrompt;
|
||||
|
||||
default:
|
||||
return `Investigate ${target} in OwnCord.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity hint parsing — extract file paths from agent stdout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILE_PATH_PATTERNS = [
|
||||
/Server\/[\w/.-]+\.go/g,
|
||||
/Client\/[\w/.-]+\.tsx?/g,
|
||||
/(?<!\/)src\/[\w/.-]+\.tsx?/g,
|
||||
/src-tauri\/[\w/.-]+\.rs/g,
|
||||
/docs\/[\w/.-]+\.md/g,
|
||||
];
|
||||
|
||||
export function parseActivityHints(chunk) {
|
||||
if (!chunk || typeof chunk !== 'string') return [];
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
for (const pattern of FILE_PATH_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(chunk)) !== null) {
|
||||
const file = match[0];
|
||||
if (!seen.has(file)) {
|
||||
seen.add(file);
|
||||
results.push({ hint: `Accessing ${file}`, file });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git diff for running jobs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function gitArgs(args, root) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current git diff for a job.
|
||||
* For fleet jobs, reads diff from the worktree directory.
|
||||
*/
|
||||
export function getJobDiff(root, baselineSha, preExistingDirtyFiles, worktreePath) {
|
||||
const diffRoot = worktreePath || root;
|
||||
const excludeSet = new Set(preExistingDirtyFiles || []);
|
||||
const freshness = new Date().toISOString();
|
||||
|
||||
let numstatRaw = '';
|
||||
try {
|
||||
const args = baselineSha
|
||||
? ['diff', baselineSha, '--numstat']
|
||||
: ['diff', '--numstat'];
|
||||
numstatRaw = gitArgs(args, diffRoot);
|
||||
} catch { /* empty diff */ }
|
||||
|
||||
let untrackedRaw = '';
|
||||
try {
|
||||
untrackedRaw = gitArgs(['ls-files', '--others', '--exclude-standard'], diffRoot);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const files = [];
|
||||
const trackedPaths = new Set();
|
||||
|
||||
for (const line of numstatRaw.split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
const additions = parseInt(parts[0], 10) || 0;
|
||||
const deletions = parseInt(parts[1], 10) || 0;
|
||||
const path = parts[2];
|
||||
if (excludeSet.has(path)) continue;
|
||||
trackedPaths.add(path);
|
||||
files.push({ path, status: 'M', additions, deletions });
|
||||
}
|
||||
|
||||
for (const path of untrackedRaw.split('\n').filter(Boolean)) {
|
||||
if (excludeSet.has(path)) continue;
|
||||
if (trackedPaths.has(path)) continue;
|
||||
files.push({ path, status: 'A', additions: 0, deletions: 0 });
|
||||
}
|
||||
|
||||
const diffs = {};
|
||||
for (const f of files) {
|
||||
try {
|
||||
if (f.status === 'A') {
|
||||
const fullPath = resolve(diffRoot, f.path);
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
f.additions = lines.length;
|
||||
diffs[f.path] = lines.map(l => '+' + l).join('\n');
|
||||
} else {
|
||||
const args = baselineSha
|
||||
? ['diff', baselineSha, '--', f.path]
|
||||
: ['diff', '--', f.path];
|
||||
const content = gitArgs(args, diffRoot);
|
||||
diffs[f.path] = content;
|
||||
}
|
||||
} catch {
|
||||
diffs[f.path] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return { files, diffs, freshness };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setMaxConcurrent(n) {
|
||||
const val = parseInt(n, 10);
|
||||
if (Number.isInteger(val) && val >= 1 && val <= 8) {
|
||||
MAX_CONCURRENT = val;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaxConcurrent() {
|
||||
return MAX_CONCURRENT;
|
||||
}
|
||||
|
||||
export function getActiveCount() {
|
||||
return activeAgents.size;
|
||||
}
|
||||
|
||||
export function getActiveAgentIds() {
|
||||
return [...activeAgents.keys()];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — health check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function healthCheck() {
|
||||
try {
|
||||
const version = execFileSync('claude', ['--version'], { stdio: 'pipe' })
|
||||
.toString()
|
||||
.trim();
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return { available: false, error: err.message || 'Claude CLI not found' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — job CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getJobs(cacheDir) {
|
||||
const s = ensureStore(cacheDir);
|
||||
const jobs = s.get();
|
||||
const sorted = [...jobs].sort((a, b) => {
|
||||
const priDiff = (b.priority ?? 1) - (a.priority ?? 1);
|
||||
if (priDiff !== 0) return priDiff;
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
});
|
||||
return { jobs: sorted };
|
||||
}
|
||||
|
||||
export function createJob(cacheDir, { type, target, priority, customPrompt }) {
|
||||
if (!ALLOWED_TYPES.has(type)) {
|
||||
throw new Error(`Invalid job type "${type}". Allowed: ${[...ALLOWED_TYPES].join(', ')}`);
|
||||
}
|
||||
if (!target || typeof target !== 'string' || target.trim().length === 0) {
|
||||
throw new Error('Job target must be a non-empty string');
|
||||
}
|
||||
if (type === 'custom' && (!customPrompt || typeof customPrompt !== 'string' || customPrompt.trim().length === 0)) {
|
||||
throw new Error('Custom jobs require a non-empty customPrompt');
|
||||
}
|
||||
|
||||
ensureDirs(cacheDir);
|
||||
const s = ensureStore(cacheDir);
|
||||
|
||||
const job = {
|
||||
id: `job-${Date.now()}-${randomBytes(4).toString('hex')}`,
|
||||
type,
|
||||
target: target.trim(),
|
||||
status: 'queued',
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
resultPath: null,
|
||||
error: null,
|
||||
priority: typeof priority === 'number' ? priority : 1,
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
pid: null,
|
||||
customPrompt: customPrompt ?? null,
|
||||
// Fleet fields
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
configMethod: null,
|
||||
};
|
||||
|
||||
s.update(jobs => [...jobs, job]);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function cancelJob(cacheDir, jobId) {
|
||||
const s = ensureStore(cacheDir);
|
||||
const job = s.getJob(jobId);
|
||||
if (!job) throw new Error(`Job "${jobId}" not found`);
|
||||
|
||||
if (job.status === 'queued') {
|
||||
s.update(jobs => jobs.filter(j => j.id !== jobId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (job.status === 'provisioning' || job.status === 'running') {
|
||||
// Kill process if running
|
||||
const agent = activeAgents.get(jobId);
|
||||
if (agent?.pid) killProcess(agent.pid);
|
||||
if (agent?.timer) clearTimeout(agent.timer);
|
||||
if (agent?.warningTimer) clearTimeout(agent.warningTimer);
|
||||
activeAgents.delete(jobId);
|
||||
|
||||
// Destroy worktree
|
||||
if (job.worktreePath) {
|
||||
try {
|
||||
const root = resolve(job.worktreePath, '..', '..');
|
||||
destroyWorktree(root, jobId);
|
||||
} catch (err) {
|
||||
console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === jobId
|
||||
? { ...j, status: 'cancelled', completedAt: new Date().toISOString(), pid: null }
|
||||
: j,
|
||||
));
|
||||
setTimeout(() => clearLiveOutput(jobId), 5000);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Terminal statuses (review, done, failed, cancelled) — just remove from list
|
||||
if (['review', 'dead', 'cancelled'].includes(job.status)) {
|
||||
if (job.worktreePath) {
|
||||
try {
|
||||
const root = resolve(job.worktreePath, '..', '..');
|
||||
destroyWorktree(root, jobId);
|
||||
} catch (err) {
|
||||
console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
s.update(jobs => jobs.filter(j => j.id !== jobId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
throw new Error(`Cannot cancel job "${jobId}" with status "${job.status}"`);
|
||||
}
|
||||
|
||||
export function getJobResult(cacheDir, jobId) {
|
||||
const resultFile = resolve(cacheDir, RESULTS_DIR, `${jobId}.md`);
|
||||
if (!existsSync(resultFile)) return { content: null };
|
||||
return { content: readFileSync(resultFile, 'utf8') };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — fleet queue processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Override CLI command for testing */
|
||||
let _spawnCommand = 'claude';
|
||||
export function setSpawnCommand(cmd) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('setSpawnCommand is only available in test environments');
|
||||
}
|
||||
if (typeof cmd !== 'string' || cmd.length === 0 || cmd.includes('/') || cmd.includes('\\')) {
|
||||
throw new Error('setSpawnCommand: cmd must be a simple command name');
|
||||
}
|
||||
_spawnCommand = cmd;
|
||||
}
|
||||
|
||||
/** Check processing state (for diagnostics) */
|
||||
export function getIsProcessing() { return isProcessing; }
|
||||
|
||||
/**
|
||||
* Process the queue — spawn agents up to MAX_CONCURRENT.
|
||||
* Returns after spawning; agents run asynchronously.
|
||||
*
|
||||
* @param {string} root — project root
|
||||
* @param {string} cacheDir — cache directory
|
||||
* @param {function} [onOutput] — callback (jobId, chunk) for streaming
|
||||
* @param {function} [onWarning] — callback (jobId, message) for timeout warnings
|
||||
* @returns {Promise<{ launched: number, skipped: string[] }>}
|
||||
*/
|
||||
export async function processQueue(root, cacheDir, onOutput, onWarning) {
|
||||
if (isProcessing) return { launched: 0, skipped: ['locked'] };
|
||||
isProcessing = true;
|
||||
|
||||
try {
|
||||
ensureDirs(cacheDir);
|
||||
const s = ensureStore(cacheDir);
|
||||
const jobs = s.get();
|
||||
|
||||
// How many slots available?
|
||||
const slotsAvailable = MAX_CONCURRENT - activeAgents.size;
|
||||
if (slotsAvailable <= 0) {
|
||||
return { launched: 0, skipped: ['at_capacity'] };
|
||||
}
|
||||
|
||||
// Pick highest-priority queued jobs
|
||||
const queued = jobs
|
||||
.filter(j => j.status === 'queued')
|
||||
.sort((a, b) => {
|
||||
const priDiff = (b.priority ?? 1) - (a.priority ?? 1);
|
||||
if (priDiff !== 0) return priDiff;
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
})
|
||||
.slice(0, slotsAvailable);
|
||||
|
||||
if (queued.length === 0) {
|
||||
return { launched: 0, skipped: [] };
|
||||
}
|
||||
|
||||
let launched = 0;
|
||||
const skipped = [];
|
||||
|
||||
for (const job of queued) {
|
||||
try {
|
||||
// Mark provisioning
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id ? { ...j, status: 'provisioning' } : j,
|
||||
));
|
||||
|
||||
// Create worktree
|
||||
let worktreeInfo;
|
||||
try {
|
||||
worktreeInfo = createWorktree(root, job.id);
|
||||
} catch (wtErr) {
|
||||
console.error(` [Agent] Worktree creation failed for ${job.id}: ${wtErr.message}`);
|
||||
// Re-queue or mark dead
|
||||
const newRetry = (job.retryCount ?? 0) + 1;
|
||||
const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead';
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? { ...j, status: nextStatus, error: `Worktree failed: ${wtErr.message}`, retryCount: newRetry, pid: null }
|
||||
: j,
|
||||
));
|
||||
skipped.push(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build prompt and spawn agent
|
||||
const prompt = buildPrompt(job, root);
|
||||
const timeout = TIMEOUTS[job.type] ?? TIMEOUTS.custom;
|
||||
const resultPath = resolve(cacheDir, RESULTS_DIR, `${job.id}.md`);
|
||||
|
||||
// Save prompt for debugging
|
||||
const promptFile = resolve(cacheDir, PROMPTS_DIR, `${job.id}.txt`);
|
||||
try { writeFileSync(promptFile, prompt, 'utf8'); } catch { /* non-critical */ }
|
||||
|
||||
// Spawn claude in the worktree
|
||||
const child = spawn(_spawnCommand, [
|
||||
'-p', prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'text',
|
||||
], {
|
||||
cwd: worktreeInfo.worktreePath,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const pid = child.pid ?? null;
|
||||
if (pid === null) {
|
||||
console.error(` [Agent] Failed to get PID for ${job.id}`);
|
||||
destroyWorktree(root, job.id);
|
||||
const newRetry = (job.retryCount ?? 0) + 1;
|
||||
const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead';
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? { ...j, status: nextStatus, error: 'Failed to start process', retryCount: newRetry, pid: null, worktreePath: null }
|
||||
: j,
|
||||
));
|
||||
skipped.push(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Capture baseline SHA for diff tracking
|
||||
let baselineSha = null;
|
||||
try {
|
||||
baselineSha = gitArgs(['rev-parse', 'HEAD'], worktreeInfo.worktreePath);
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// Mark running
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? {
|
||||
...j,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
pid,
|
||||
worktreePath: worktreeInfo.worktreePath,
|
||||
branchName: worktreeInfo.branchName,
|
||||
configMethod: worktreeInfo.configMethod,
|
||||
baselineSha,
|
||||
preExistingDirtyFiles: [],
|
||||
}
|
||||
: j,
|
||||
));
|
||||
|
||||
// Initialize live output
|
||||
liveOutputBuffers.set(job.id, '');
|
||||
let stdout = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
appendToRingBuffer(job.id, text);
|
||||
if (typeof onOutput === 'function') {
|
||||
try { onOutput(job.id, text); } catch { /* non-critical */ }
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
appendToRingBuffer(job.id, `[stderr] ${text}`);
|
||||
});
|
||||
|
||||
// Timeout warning at 80%
|
||||
const warningTimer = setTimeout(() => {
|
||||
if (typeof onWarning === 'function') {
|
||||
try { onWarning(job.id, `Agent approaching timeout (80% of ${timeout / 1000}s)`); } catch { /* */ }
|
||||
}
|
||||
}, timeout * 0.8);
|
||||
|
||||
// Hard timeout — SIGTERM then SIGKILL
|
||||
const timer = setTimeout(() => {
|
||||
killProcess(pid);
|
||||
}, timeout);
|
||||
|
||||
// Track active agent
|
||||
activeAgents.set(job.id, {
|
||||
pid,
|
||||
worktreePath: worktreeInfo.worktreePath,
|
||||
branchName: worktreeInfo.branchName,
|
||||
child,
|
||||
timer,
|
||||
warningTimer,
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(warningTimer);
|
||||
activeAgents.delete(job.id);
|
||||
|
||||
const currentJob = ensureStore(cacheDir).getJob(job.id);
|
||||
if (!currentJob) return;
|
||||
|
||||
// If cancelled while running, don't overwrite
|
||||
if (currentJob.status === 'cancelled') {
|
||||
setTimeout(() => clearLiveOutput(job.id), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const timedOut = !isProcessAlive(pid) && code !== 0;
|
||||
|
||||
if (code === 0) {
|
||||
// Success — save result, mark as review (worktree kept for merge)
|
||||
writeFileSync(resultPath, stdout, 'utf8');
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? { ...j, status: 'review', completedAt: new Date().toISOString(), resultPath, pid: null }
|
||||
: j,
|
||||
));
|
||||
} else {
|
||||
// Failure
|
||||
const newRetry = (currentJob.retryCount ?? 0) + 1;
|
||||
const maxRetries = currentJob.maxRetries ?? 2;
|
||||
const errorMsg = code === null
|
||||
? `Timed out after ${timeout / 1000}s`
|
||||
: `Exited with code ${code}`;
|
||||
const nextStatus = newRetry < maxRetries ? 'queued' : 'dead';
|
||||
|
||||
// Save partial output
|
||||
if (stdout.length > 0) {
|
||||
writeFileSync(resultPath, stdout, 'utf8');
|
||||
}
|
||||
|
||||
// Destroy worktree on failure
|
||||
try { destroyWorktree(root, job.id); } catch { /* best effort */ }
|
||||
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? {
|
||||
...j,
|
||||
status: nextStatus,
|
||||
completedAt: nextStatus === 'dead' ? new Date().toISOString() : null,
|
||||
startedAt: nextStatus === 'queued' ? null : currentJob.startedAt,
|
||||
error: errorMsg,
|
||||
retryCount: newRetry,
|
||||
pid: null,
|
||||
resultPath: stdout.length > 0 ? resultPath : null,
|
||||
worktreePath: null,
|
||||
branchName: nextStatus === 'queued' ? null : currentJob.branchName,
|
||||
}
|
||||
: j,
|
||||
));
|
||||
}
|
||||
|
||||
setTimeout(() => clearLiveOutput(job.id), 5000);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(warningTimer);
|
||||
activeAgents.delete(job.id);
|
||||
|
||||
try { destroyWorktree(root, job.id); } catch { /* best effort */ }
|
||||
|
||||
const newRetry = (job.retryCount ?? 0) + 1;
|
||||
const maxRetries = job.maxRetries ?? 2;
|
||||
const nextStatus = newRetry < maxRetries ? 'queued' : 'dead';
|
||||
|
||||
s.update(jobs => jobs.map(j =>
|
||||
j.id === job.id
|
||||
? {
|
||||
...j,
|
||||
status: nextStatus,
|
||||
completedAt: nextStatus === 'dead' ? new Date().toISOString() : null,
|
||||
error: err.message,
|
||||
retryCount: newRetry,
|
||||
pid: null,
|
||||
worktreePath: null,
|
||||
}
|
||||
: j,
|
||||
));
|
||||
|
||||
setTimeout(() => clearLiveOutput(job.id), 5000);
|
||||
});
|
||||
|
||||
launched += 1;
|
||||
} catch (err) {
|
||||
console.error(` [Agent] Unexpected error launching ${job.id}: ${err.message}`);
|
||||
skipped.push(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { launched, skipped };
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — orphan recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function recoverOrphans(cacheDir, root) {
|
||||
const s = ensureStore(cacheDir);
|
||||
const jobs = s.get();
|
||||
let recovered = 0;
|
||||
|
||||
const updated = jobs.map((job) => {
|
||||
if (job.status !== 'running' && job.status !== 'provisioning') return job;
|
||||
|
||||
const pid = job.pid;
|
||||
const alive = pid && isProcessAlive(pid);
|
||||
const isClaude = alive && isClaudeProcess(pid);
|
||||
|
||||
if (alive && isClaude) return job;
|
||||
|
||||
// Orphan detected
|
||||
recovered += 1;
|
||||
|
||||
// Destroy worktree if it exists
|
||||
if (job.worktreePath && root) {
|
||||
try { destroyWorktree(root, job.id); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
const newRetry = (job.retryCount ?? 0) + 1;
|
||||
const maxRetries = job.maxRetries ?? 2;
|
||||
|
||||
if (newRetry < maxRetries) {
|
||||
return {
|
||||
...job,
|
||||
status: 'queued',
|
||||
startedAt: null,
|
||||
error: 'Orphaned process — re-queued',
|
||||
retryCount: newRetry,
|
||||
pid: null,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...job,
|
||||
status: 'dead',
|
||||
completedAt: new Date().toISOString(),
|
||||
error: 'Orphaned process — max retries exceeded',
|
||||
retryCount: newRetry,
|
||||
pid: null,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
};
|
||||
});
|
||||
|
||||
if (recovered > 0) {
|
||||
s.update(() => updated);
|
||||
}
|
||||
|
||||
// Also clean stale worktree directories
|
||||
if (root) {
|
||||
try {
|
||||
cleanupStaleWorktrees(root, (jobId) => {
|
||||
const job = jobs.find(j => j.id === jobId);
|
||||
return job?.status === 'running' && job?.pid && isProcessAlive(job.pid);
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
return { recovered };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — result pruning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function pruneResults(cacheDir) {
|
||||
const dir = resolve(cacheDir, RESULTS_DIR);
|
||||
if (!existsSync(dir)) return { pruned: 0 };
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
.map((name) => {
|
||||
const full = join(dir, name);
|
||||
try {
|
||||
const stat = statSync(full);
|
||||
return { name, path: full, mtime: stat.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return { pruned: 0 };
|
||||
}
|
||||
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - thirtyDaysMs;
|
||||
let pruned = 0;
|
||||
|
||||
const remaining = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.mtime < cutoff) {
|
||||
try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ }
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining.length > 50) {
|
||||
remaining.sort((a, b) => a.mtime - b.mtime);
|
||||
const excess = remaining.slice(0, remaining.length - 50);
|
||||
for (const entry of excess) {
|
||||
try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
return { pruned };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy exports for backward compatibility (used by existing tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @deprecated Use getIsProcessing() instead */
|
||||
export function isQueueLocked() { return isProcessing; }
|
||||
|
||||
/** @deprecated Use internal reset instead */
|
||||
export function resetQueueLock() { isProcessing = false; }
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Backlog parser — reads Backlog.md and extracts open tasks,
|
||||
* mapping them to project modules by keyword matching.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Keywords that map tasks to modules
|
||||
const MODULE_KEYWORDS = {
|
||||
// Server Go packages
|
||||
'admin': ['admin', 'admin panel', 'admin api'],
|
||||
'api': ['api', 'REST', 'endpoint', 'handler', 'middleware'],
|
||||
'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'],
|
||||
'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'],
|
||||
'ws': ['websocket', 'WS', 'hub', 'voice', 'broadcast', 'ringbuffer', 'reconnect'],
|
||||
'permissions': ['permission', 'role', 'RBAC'],
|
||||
'storage': ['upload', 'file storage', 'attachment'],
|
||||
'config': ['config', 'settings'],
|
||||
// Client areas
|
||||
'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'],
|
||||
'stores': ['store', 'state management'],
|
||||
'components': ['component', 'UI', 'sidebar', 'overlay', 'widget', 'picker', 'modal'],
|
||||
'pages': ['page', 'ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'],
|
||||
// Rust
|
||||
'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater', 'ptt.rs'],
|
||||
// Cross-cutting
|
||||
'protocol': ['protocol', 'message type', 'payload'],
|
||||
'e2e': ['E2E', 'Playwright', 'end-to-end'],
|
||||
'livekit': ['LiveKit', 'voice', 'video', 'spatial audio', 'whisper', 'screen sharing', 'streaming'],
|
||||
// Feature areas
|
||||
'gaming': ['game detection', 'game time', 'LAN', 'tournament', 'leaderboard', 'seat map', 'Xfire'],
|
||||
'community': ['poll', 'gallery', 'scheduler', 'activity feed', 'pinned notes'],
|
||||
'platform': ['theme engine', 'webhook', 'bot', 'plugin', 'backup', 'monitoring'],
|
||||
'ai': ['AI', 'noise cancellation', 'translation', 'summarization', 'overlay'],
|
||||
};
|
||||
|
||||
function matchModule(description) {
|
||||
const lower = description.toLowerCase();
|
||||
const matches = [];
|
||||
|
||||
for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) {
|
||||
for (const kw of keywords) {
|
||||
const kwLower = kw.toLowerCase();
|
||||
const re = new RegExp(`\\b${kwLower}\\b`);
|
||||
if (re.test(lower)) {
|
||||
matches.push(mod);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches.length > 0 ? matches : ['unclassified'];
|
||||
}
|
||||
|
||||
function extractPhase(sectionHeader) {
|
||||
if (sectionHeader.includes('Bug')) return 'bug';
|
||||
if (sectionHeader.includes('Code Review')) return 'code-review';
|
||||
if (sectionHeader.includes('Medium Priority') || sectionHeader.includes('P2')) return 'medium';
|
||||
if (sectionHeader.includes('High Priority') || sectionHeader.includes('P0') || sectionHeader.includes('P1')) return 'high';
|
||||
if (sectionHeader.includes('Deferred')) return 'deferred';
|
||||
if (/\bR1\b/.test(sectionHeader)) return 'roadmap-r1';
|
||||
if (/\bR2\b/.test(sectionHeader)) return 'roadmap-r2';
|
||||
if (/\bR3\b/.test(sectionHeader)) return 'roadmap-r3';
|
||||
if (/\bR4\b/.test(sectionHeader)) return 'roadmap-r4';
|
||||
if (/\bR5\b/.test(sectionHeader)) return 'roadmap-r5';
|
||||
if (/\bR6\b/.test(sectionHeader)) return 'roadmap-r6';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export async function parseBacklog(root) {
|
||||
const backlogPath = resolve(root, 'docs/brain/02-Tasks/Backlog.md');
|
||||
if (!existsSync(backlogPath)) {
|
||||
console.log(' [Backlog] File not found');
|
||||
return { tasks: [], openCount: 0, doneCount: 0, byModule: {}, byPhase: {} };
|
||||
}
|
||||
|
||||
const content = readFileSync(backlogPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
const tasks = [];
|
||||
let currentSection = '';
|
||||
|
||||
for (const line of lines) {
|
||||
// Track section headers
|
||||
if (line.startsWith('## ') || line.startsWith('### ')) {
|
||||
currentSection = line.replace(/^#+\s*/, '');
|
||||
}
|
||||
|
||||
// Match task lines in two formats:
|
||||
// - [ ] **T-XXX:** description (colon inside bold)
|
||||
// - [ ] **T-XXX**: description (colon after bold)
|
||||
const taskMatch = line.match(/^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/);
|
||||
if (taskMatch) {
|
||||
const done = taskMatch[1] === 'x';
|
||||
const id = `T-${taskMatch[2]}`;
|
||||
const description = taskMatch[3].replace(/\s*—\s*\d{4}-\d{2}-\d{2}$/, '').trim();
|
||||
const modules = matchModule(description);
|
||||
const phase = extractPhase(currentSection);
|
||||
|
||||
tasks.push({ id, description, done, modules, phase, section: currentSection });
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate
|
||||
const openTasks = tasks.filter(t => !t.done);
|
||||
const doneTasks = tasks.filter(t => t.done);
|
||||
|
||||
const byModule = {};
|
||||
for (const task of openTasks) {
|
||||
for (const mod of task.modules) {
|
||||
if (!byModule[mod]) byModule[mod] = [];
|
||||
byModule[mod].push(task);
|
||||
}
|
||||
}
|
||||
|
||||
const byPhase = {};
|
||||
for (const task of openTasks) {
|
||||
if (!byPhase[task.phase]) byPhase[task.phase] = [];
|
||||
byPhase[task.phase].push(task);
|
||||
}
|
||||
|
||||
console.log(` [Backlog] ${openTasks.length} open tasks, ${doneTasks.length} done`);
|
||||
return { tasks, openCount: openTasks.length, doneCount: doneTasks.length, byModule, byPhase };
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join, relative, sep, posix } from 'node:path';
|
||||
|
||||
const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i;
|
||||
|
||||
const SCAN_DIRS = [
|
||||
{ dir: 'Server', ext: '.go', skipFile: '_test.go', skipDirs: ['vendor'] },
|
||||
{ dir: 'Client/tauri-client/src', ext: '.ts', skipFile: null, skipDirs: ['node_modules', 'dist'] },
|
||||
{ dir: 'Client/tauri-client/src-tauri/src', ext: '.rs', skipFile: null, skipDirs: ['target'] },
|
||||
];
|
||||
|
||||
const LARGE_FILE_WARNING = 400;
|
||||
const LARGE_FILE_CRITICAL = 800;
|
||||
const LONG_FUNCTION_LINES = 50;
|
||||
const DEEP_NESTING_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* Recursively collect files matching an extension, skipping specified directories.
|
||||
*/
|
||||
async function collectFiles(base, ext, skipFile, skipDirs) {
|
||||
const results = [];
|
||||
|
||||
async function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (skipDirs.includes(entry.name)) continue;
|
||||
await walk(join(dir, entry.name));
|
||||
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
||||
if (skipFile && entry.name.endsWith(skipFile)) continue;
|
||||
results.push(join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(base);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the module name from a file path relative to root.
|
||||
*/
|
||||
function getModule(relPath) {
|
||||
const parts = relPath.split(/[/\\]/);
|
||||
|
||||
// Server/{package}/file.go -> package name
|
||||
if (parts[0] === 'Server' && parts.length >= 2) {
|
||||
return parts.length >= 3 ? parts[1] : 'server-root';
|
||||
}
|
||||
|
||||
// Client/tauri-client/src-tauri/src/file.rs -> "tauri-rust"
|
||||
if (
|
||||
parts[0] === 'Client' &&
|
||||
parts[1] === 'tauri-client' &&
|
||||
parts[2] === 'src-tauri'
|
||||
) {
|
||||
return 'tauri-rust';
|
||||
}
|
||||
|
||||
// Client/tauri-client/src/{area}/file.ts -> area name
|
||||
if (
|
||||
parts[0] === 'Client' &&
|
||||
parts[1] === 'tauri-client' &&
|
||||
parts[2] === 'src'
|
||||
) {
|
||||
return parts.length >= 5 ? parts[3] : 'client-root';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path to forward slashes for consistent output.
|
||||
*/
|
||||
function normalizePath(p) {
|
||||
return p.split(sep).join(posix.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect long functions via brace-depth tracking.
|
||||
* Returns array of { line, name, lines }.
|
||||
*/
|
||||
function detectLongFunctions(content, ext) {
|
||||
const lines = content.split('\n');
|
||||
const functions = [];
|
||||
|
||||
// Stack: { name, startLine, depth }
|
||||
let current = null;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
// Detect function start
|
||||
let funcName = null;
|
||||
|
||||
if (ext === '.go') {
|
||||
const match = trimmed.match(/^func\s+(?:\([^)]*\)\s*)?(\w+)/);
|
||||
if (match) funcName = match[1];
|
||||
} else if (ext === '.ts') {
|
||||
// Named function declaration
|
||||
const fnMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/);
|
||||
if (fnMatch) {
|
||||
funcName = fnMatch[1];
|
||||
} else {
|
||||
// Arrow function: const name = (...) => {
|
||||
const arrowMatch = trimmed.match(/^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])*=>\s*\{/);
|
||||
if (arrowMatch) {
|
||||
funcName = arrowMatch[1];
|
||||
}
|
||||
}
|
||||
} else if (ext === '.rs') {
|
||||
const match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/);
|
||||
if (match) funcName = match[1];
|
||||
}
|
||||
|
||||
if (funcName && current === null) {
|
||||
current = { name: funcName, startLine: i + 1, depth: braceDepth };
|
||||
}
|
||||
|
||||
// Track braces
|
||||
for (const ch of line) {
|
||||
if (ch === '{') braceDepth++;
|
||||
if (ch === '}') braceDepth--;
|
||||
}
|
||||
if (braceDepth < 0) braceDepth = 0;
|
||||
|
||||
// Check if current function has ended
|
||||
if (current !== null && braceDepth <= current.depth) {
|
||||
const funcLines = (i + 1) - current.startLine + 1;
|
||||
if (funcLines > LONG_FUNCTION_LINES) {
|
||||
functions.push({
|
||||
line: current.startLine,
|
||||
name: current.name,
|
||||
lines: funcLines,
|
||||
});
|
||||
}
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single file for all debt indicators.
|
||||
*/
|
||||
function scanFile(content, relPath, ext) {
|
||||
const lines = content.split('\n');
|
||||
const mod = getModule(relPath);
|
||||
const file = normalizePath(relPath);
|
||||
|
||||
const markers = [];
|
||||
const deepNesting = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Marker detection
|
||||
const markerMatch = line.match(MARKER_RE);
|
||||
if (markerMatch) {
|
||||
markers.push({
|
||||
type: markerMatch[1].toUpperCase(),
|
||||
file,
|
||||
line: i + 1,
|
||||
text: markerMatch[2].trim(),
|
||||
module: mod,
|
||||
});
|
||||
}
|
||||
|
||||
// Deep nesting detection
|
||||
const leadingSpaces = line.match(/^(\s*)/)[1];
|
||||
const tabCount = (leadingSpaces.match(/\t/g) || []).length;
|
||||
const spaceCount = leadingSpaces.replace(/\t/g, '').length;
|
||||
const depth = tabCount + Math.floor(spaceCount / 4);
|
||||
|
||||
if (depth >= DEEP_NESTING_THRESHOLD && line.trim().length > 0) {
|
||||
deepNesting.push({
|
||||
file,
|
||||
line: i + 1,
|
||||
depth,
|
||||
module: mod,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Large file detection
|
||||
let largeFile = null;
|
||||
if (lines.length > LARGE_FILE_WARNING) {
|
||||
largeFile = {
|
||||
file,
|
||||
lines: lines.length,
|
||||
module: mod,
|
||||
severity: lines.length > LARGE_FILE_CRITICAL ? 'critical' : 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
// Long function detection
|
||||
const longFunctions = detectLongFunctions(content, ext).map((f) => ({
|
||||
file,
|
||||
line: f.line,
|
||||
name: f.name,
|
||||
lines: f.lines,
|
||||
module: mod,
|
||||
}));
|
||||
|
||||
return { markers, largeFile, longFunctions, deepNesting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the codebase for technical debt indicators.
|
||||
*
|
||||
* @param {string} root - Absolute path to the repository root
|
||||
* @param {string} cacheDir - Directory to store cached results
|
||||
* @param {boolean} quick - If true and cache exists, return cached data
|
||||
* @returns {Promise<object>} Technical debt report
|
||||
*/
|
||||
export async function scanTechnicalDebt(root, cacheDir, quick) {
|
||||
const cachePath = join(cacheDir, 'debt-data.json');
|
||||
|
||||
if (quick) {
|
||||
try {
|
||||
const cached = await readFile(cachePath, 'utf-8');
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
// Cache miss, proceed with full scan
|
||||
}
|
||||
}
|
||||
|
||||
const allMarkers = [];
|
||||
const allLargeFiles = [];
|
||||
const allLongFunctions = [];
|
||||
let allDeepNesting = [];
|
||||
|
||||
for (const { dir, ext, skipFile, skipDirs } of SCAN_DIRS) {
|
||||
const baseDir = join(root, ...dir.split('/'));
|
||||
const files = await collectFiles(baseDir, ext, skipFile, skipDirs);
|
||||
|
||||
for (const filePath of files) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relPath = relative(root, filePath);
|
||||
const result = scanFile(content, relPath, ext);
|
||||
|
||||
allMarkers.push(...result.markers);
|
||||
if (result.largeFile) allLargeFiles.push(result.largeFile);
|
||||
allLongFunctions.push(...result.longFunctions);
|
||||
allDeepNesting.push(...result.deepNesting);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only top 20 deepest nesting instances
|
||||
allDeepNesting.sort((a, b) => b.depth - a.depth);
|
||||
allDeepNesting = allDeepNesting.slice(0, 20);
|
||||
|
||||
// Build per-module summary
|
||||
const byModule = {};
|
||||
const ensureModule = (mod) => {
|
||||
if (!byModule[mod]) {
|
||||
byModule[mod] = { markers: 0, largeFiles: 0, longFunctions: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
for (const m of allMarkers) {
|
||||
ensureModule(m.module);
|
||||
byModule[m.module].markers++;
|
||||
}
|
||||
for (const f of allLargeFiles) {
|
||||
ensureModule(f.module);
|
||||
byModule[f.module].largeFiles++;
|
||||
}
|
||||
for (const f of allLongFunctions) {
|
||||
ensureModule(f.module);
|
||||
byModule[f.module].longFunctions++;
|
||||
}
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
markers: allMarkers,
|
||||
largeFiles: allLargeFiles,
|
||||
longFunctions: allLongFunctions,
|
||||
deepNesting: allDeepNesting,
|
||||
summary: {
|
||||
totalMarkers: allMarkers.length,
|
||||
totalLargeFiles: allLargeFiles.length,
|
||||
totalLongFunctions: allLongFunctions.length,
|
||||
byModule,
|
||||
},
|
||||
};
|
||||
|
||||
// Write cache
|
||||
try {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(report, null, 2), 'utf-8');
|
||||
} catch {
|
||||
// Non-fatal: cache write failure is acceptable
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* File watcher + SSE auto-refresh.
|
||||
* Watches key directories and notifies connected SSE clients on changes.
|
||||
*/
|
||||
import { watch } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export function createFileWatcher(root, onChangeCallback) {
|
||||
const watchDirs = [
|
||||
resolve(root, 'Server'),
|
||||
resolve(root, 'Client/tauri-client/src'),
|
||||
resolve(root, 'Client/tauri-client/src-tauri/src'),
|
||||
resolve(root, 'docs/brain'),
|
||||
];
|
||||
|
||||
// Debounce: only fire callback once per 2 seconds
|
||||
let debounceTimer = null;
|
||||
let destroyed = false;
|
||||
const debounceMs = 2000;
|
||||
|
||||
function handleChange(eventType, filename) {
|
||||
if (destroyed) return;
|
||||
|
||||
// Ignore non-source files
|
||||
if (filename && (
|
||||
filename.includes('node_modules') ||
|
||||
filename.includes('.git') ||
|
||||
filename.includes('dist') ||
|
||||
filename.includes('target') ||
|
||||
filename.endsWith('.swp') ||
|
||||
filename.endsWith('~')
|
||||
)) return;
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
onChangeCallback({ eventType, filename, timestamp: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
console.error('[Watch] callback error:', err.message);
|
||||
}
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
const watchers = [];
|
||||
for (const dir of watchDirs) {
|
||||
try {
|
||||
const w = watch(dir, { recursive: true }, handleChange);
|
||||
watchers.push(w);
|
||||
} catch {
|
||||
// Directory might not exist — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
close() {
|
||||
destroyed = true;
|
||||
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||
for (const w of watchers) {
|
||||
try { w.close(); } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE (Server-Sent Events) manager.
|
||||
* Maintains a list of connected clients and broadcasts events to all.
|
||||
*/
|
||||
export function createSSEManager() {
|
||||
const clients = new Set();
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
for (const client of clients) {
|
||||
try { client.write(': ping\n\n'); } catch { clients.delete(client); }
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
function handleConnection(req, res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
// Send initial connected event
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() })}\n\n`);
|
||||
|
||||
clients.add(res);
|
||||
|
||||
req.on('close', () => {
|
||||
clients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(event) {
|
||||
const data = `data: ${JSON.stringify(event)}\n\n`;
|
||||
for (const client of clients) {
|
||||
try { client.write(data); } catch { clients.delete(client); }
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearInterval(heartbeatInterval);
|
||||
for (const client of clients) {
|
||||
try { client.end(); } catch { /* ignore */ }
|
||||
}
|
||||
clients.clear();
|
||||
}
|
||||
|
||||
// Safety net: clean up on process exit to prevent leaked intervals
|
||||
process.on('exit', () => { clearInterval(heartbeatInterval); });
|
||||
|
||||
return { handleConnection, broadcast, close, get clientCount() { return clients.size; } };
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Git history scanner — analyzes recent commits to provide per-module
|
||||
* activity, file churn, staleness, velocity, and commit-type breakdown.
|
||||
*
|
||||
* Zero external dependencies — Node built-ins only.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
const TOP_CHURN = 20;
|
||||
const RECENT_LIMIT = 15;
|
||||
|
||||
const COMMIT_TYPES = ['feat', 'fix', 'test', 'refactor', 'docs', 'chore', 'perf'];
|
||||
|
||||
/**
|
||||
* Map a file path to its logical module name.
|
||||
* Server/{package}/ → package name (e.g. "ws", "db", "auth")
|
||||
* Client/tauri-client/src/{area}/ → area name (e.g. "components", "lib")
|
||||
* Client/tauri-client/src-tauri/src/ → "tauri-rust"
|
||||
*
|
||||
* NOTE: names are unprefixed to match scanner.mjs, backlog-parser.mjs,
|
||||
* debt-scanner.mjs, and suggestion-engine.mjs canonical keys.
|
||||
*/
|
||||
function resolveModule(filePath) {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
if (normalized.startsWith('Server/')) {
|
||||
const parts = normalized.split('/');
|
||||
// parts[1] is a subdirectory only when there are 3+ segments
|
||||
// e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root'
|
||||
return parts.length >= 3 ? parts[1] : 'server-root';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) {
|
||||
return 'tauri-rust';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/src/')) {
|
||||
const parts = normalized.replace('Client/tauri-client/src/', '').split('/');
|
||||
return parts.length >= 1 && parts[0] !== '' ? parts[0] : 'client-root';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/')) {
|
||||
return 'client-config';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/')) {
|
||||
return 'client-other';
|
||||
}
|
||||
|
||||
// Root-level files (README, .gitignore, etc.)
|
||||
return 'root';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a conventional-commit prefix from a message.
|
||||
* Returns one of the known types or "other".
|
||||
*/
|
||||
function parseCommitType(message) {
|
||||
const match = message.match(/^(\w+)(?:\(.+?\))?[!]?:/);
|
||||
if (!match) return 'other';
|
||||
const prefix = match[1].toLowerCase();
|
||||
return COMMIT_TYPES.includes(prefix) ? prefix : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command and return stdout as a string.
|
||||
* Uses execFileSync with argument arrays to avoid shell injection.
|
||||
*/
|
||||
function git(args, root) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw git log output into structured commit objects.
|
||||
*
|
||||
* git log --format="%H|%ai|%s" --name-only produces:
|
||||
* HASH|DATE|SUBJECT
|
||||
* (blank line)
|
||||
* file1
|
||||
* file2
|
||||
* HASH|DATE|SUBJECT
|
||||
* (blank line)
|
||||
* file1
|
||||
* ...
|
||||
*
|
||||
* We detect header lines by the HASH|DATE|SUBJECT pattern and
|
||||
* accumulate file lines until the next header.
|
||||
*/
|
||||
function parseGitLog(raw) {
|
||||
const commits = [];
|
||||
const lines = raw.trim().split('\n');
|
||||
|
||||
let current = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
|
||||
// Detect header: 40-char hex hash, then pipe, then date, then pipe, then subject
|
||||
const pipeIdx1 = trimmed.indexOf('|');
|
||||
const pipeIdx2 = pipeIdx1 !== -1 ? trimmed.indexOf('|', pipeIdx1 + 1) : -1;
|
||||
const isHeader = pipeIdx1 === 40 && pipeIdx2 !== -1;
|
||||
|
||||
if (isHeader) {
|
||||
// Finalize previous commit
|
||||
if (current) {
|
||||
current.modules = [...new Set(current.files.map(resolveModule))];
|
||||
commits.push(current);
|
||||
}
|
||||
|
||||
const hash = trimmed.slice(0, pipeIdx1);
|
||||
const date = trimmed.slice(pipeIdx1 + 1, pipeIdx2);
|
||||
const message = trimmed.slice(pipeIdx2 + 1);
|
||||
const type = parseCommitType(message);
|
||||
|
||||
current = { hash, date, message, type, files: [], modules: [] };
|
||||
} else if (current) {
|
||||
current.files.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize last commit
|
||||
if (current) {
|
||||
current.modules = [...new Set(current.files.map(resolveModule))];
|
||||
commits.push(current);
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-module commit data from the parsed commits.
|
||||
*/
|
||||
function buildCommitsByModule(commits) {
|
||||
const byModule = {};
|
||||
|
||||
for (const commit of commits) {
|
||||
for (const mod of commit.modules) {
|
||||
if (!byModule[mod]) {
|
||||
byModule[mod] = { count: 0, lastCommit: commit.date, commits: [] };
|
||||
}
|
||||
byModule[mod].count += 1;
|
||||
byModule[mod].commits.push({
|
||||
hash: commit.hash,
|
||||
date: commit.date,
|
||||
message: commit.message,
|
||||
type: commit.type,
|
||||
});
|
||||
// Keep the most recent date
|
||||
if (commit.date > byModule[mod].lastCommit) {
|
||||
byModule[mod].lastCommit = commit.date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return byModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute file churn — number of commits touching each file.
|
||||
* Returns top N most churned files.
|
||||
*/
|
||||
function buildFileChurn(commits) {
|
||||
const churnMap = {};
|
||||
|
||||
for (const commit of commits) {
|
||||
for (const file of commit.files) {
|
||||
if (!churnMap[file]) {
|
||||
churnMap[file] = { file, commits: 0, module: resolveModule(file) };
|
||||
}
|
||||
churnMap[file].commits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(churnMap)
|
||||
.sort((a, b) => b.commits - a.commits)
|
||||
.slice(0, TOP_CHURN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate staleness — days since last commit per module.
|
||||
*/
|
||||
function buildStaleness(commitsByModule) {
|
||||
const now = Date.now();
|
||||
const staleness = {};
|
||||
|
||||
for (const [mod, data] of Object.entries(commitsByModule)) {
|
||||
const lastDate = new Date(data.lastCommit);
|
||||
const daysSince = Math.floor((now - lastDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
staleness[mod] = {
|
||||
daysSinceLastCommit: daysSince,
|
||||
lastCommitDate: data.lastCommit,
|
||||
};
|
||||
}
|
||||
|
||||
return staleness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build daily velocity and rolling 7-day average.
|
||||
*/
|
||||
function buildVelocity(commits) {
|
||||
// Build a map of date → commit count
|
||||
const dailyMap = {};
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < WINDOW_DAYS; i++) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
dailyMap[key] = 0;
|
||||
}
|
||||
|
||||
for (const commit of commits) {
|
||||
const key = commit.date.slice(0, 10);
|
||||
if (key in dailyMap) {
|
||||
dailyMap[key] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const daily = Object.entries(dailyMap)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
|
||||
// Rolling 7-day average (use last 7 days)
|
||||
const last7 = daily.slice(-7);
|
||||
const weeklyAvg = last7.length > 0
|
||||
? Math.round((last7.reduce((s, d) => s + d.count, 0) / last7.length) * 100) / 100
|
||||
: 0;
|
||||
|
||||
// Trend: compare first half vs second half of the window
|
||||
const mid = Math.floor(daily.length / 2);
|
||||
const firstHalf = daily.slice(0, mid);
|
||||
const secondHalf = daily.slice(mid);
|
||||
|
||||
const avgFirst = firstHalf.length > 0
|
||||
? firstHalf.reduce((s, d) => s + d.count, 0) / firstHalf.length
|
||||
: 0;
|
||||
const avgSecond = secondHalf.length > 0
|
||||
? secondHalf.reduce((s, d) => s + d.count, 0) / secondHalf.length
|
||||
: 0;
|
||||
|
||||
let trend = 'stable';
|
||||
const delta = avgSecond - avgFirst;
|
||||
if (delta > 0.3) trend = 'accelerating';
|
||||
else if (delta < -0.3) trend = 'decelerating';
|
||||
|
||||
return { daily, weeklyAvg, trend };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build commit type breakdown.
|
||||
*/
|
||||
function buildCommitTypes(commits) {
|
||||
const types = { feat: 0, fix: 0, test: 0, refactor: 0, docs: 0, chore: 0, other: 0 };
|
||||
|
||||
for (const commit of commits) {
|
||||
const bucket = commit.type in types ? commit.type : 'other';
|
||||
types[bucket] += 1;
|
||||
}
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point. Scans git history for the last 30 days and returns
|
||||
* structured data about module activity, churn, staleness, and velocity.
|
||||
*
|
||||
* @param {string} root - Repository root directory
|
||||
* @param {string} cacheDir - Directory to store cached results
|
||||
* @param {boolean} quick - If true and cache exists, return cached data
|
||||
* @returns {Promise<object>} Structured git history data
|
||||
*/
|
||||
// Exported for testing
|
||||
export { resolveModule };
|
||||
|
||||
export async function scanGitHistory(root, cacheDir, quick = false) {
|
||||
const cacheFile = join(cacheDir, 'git-data.json');
|
||||
|
||||
// Quick mode: return cache if available
|
||||
if (quick && existsSync(cacheFile)) {
|
||||
try {
|
||||
const cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
||||
return cached;
|
||||
} catch {
|
||||
// Cache corrupt — fall through to fresh scan
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch git log with file names
|
||||
const raw = git(
|
||||
['log', `--since=${WINDOW_DAYS} days ago`, '--format=%H|%ai|%s', '--name-only'],
|
||||
root,
|
||||
);
|
||||
|
||||
const commits = parseGitLog(raw);
|
||||
const commitsByModule = buildCommitsByModule(commits);
|
||||
const fileChurn = buildFileChurn(commits);
|
||||
const staleness = buildStaleness(commitsByModule);
|
||||
const velocity = buildVelocity(commits);
|
||||
const commitTypes = buildCommitTypes(commits);
|
||||
|
||||
const recentCommits = commits.slice(0, RECENT_LIMIT).map(c => ({
|
||||
hash: c.hash,
|
||||
date: c.date,
|
||||
message: c.message,
|
||||
type: c.type,
|
||||
modules: c.modules,
|
||||
}));
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
commitsByModule,
|
||||
fileChurn,
|
||||
staleness,
|
||||
velocity,
|
||||
commitTypes,
|
||||
recentCommits,
|
||||
totalCommits30d: commits.length,
|
||||
};
|
||||
|
||||
// Persist cache
|
||||
try {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8');
|
||||
} catch {
|
||||
// Non-fatal — scanning still succeeds without cache write
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Go test coverage collector.
|
||||
* Runs `go test ./... -cover -short -json` and parses per-package coverage.
|
||||
* Caches results to .cache/go-coverage.json.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CACHE_FILE = 'go-coverage.json';
|
||||
|
||||
// Extract short package name from full Go module path
|
||||
// e.g. "github.com/owncord/server/admin" -> "admin"
|
||||
// e.g. "github.com/owncord/server" -> "root"
|
||||
function extractPkgName(fullPath) {
|
||||
const parts = fullPath.split('/');
|
||||
const last = parts[parts.length - 1];
|
||||
// If the package ends with "server" it's the root package
|
||||
if (last === 'server') return 'server-root';
|
||||
return last;
|
||||
}
|
||||
|
||||
function parseGoCoverage(jsonLines) {
|
||||
const coverage = {};
|
||||
|
||||
for (const line of jsonLines.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Look for coverage output lines
|
||||
if (entry.Action === 'output' && entry.Output) {
|
||||
// Match: "coverage: 67.3% of statements"
|
||||
const coverMatch = entry.Output.match(/coverage:\s+([\d.]+)%\s+of\s+statements/);
|
||||
if (coverMatch && entry.Package) {
|
||||
const pkg = extractPkgName(entry.Package);
|
||||
coverage[pkg] = {
|
||||
percentage: parseFloat(coverMatch[1]),
|
||||
package: pkg,
|
||||
};
|
||||
}
|
||||
// Match: "[no test files]"
|
||||
if (entry.Output.includes('[no test files]') && entry.Package) {
|
||||
const pkg = extractPkgName(entry.Package);
|
||||
coverage[pkg] = {
|
||||
percentage: null,
|
||||
package: pkg,
|
||||
noTests: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Also check for pass/fail
|
||||
if (entry.Action === 'pass' && entry.Package) {
|
||||
const pkg = extractPkgName(entry.Package);
|
||||
if (!coverage[pkg]) {
|
||||
coverage[pkg] = { percentage: 0, package: pkg };
|
||||
}
|
||||
coverage[pkg].passed = true;
|
||||
}
|
||||
if (entry.Action === 'fail' && entry.Package) {
|
||||
const pkg = extractPkgName(entry.Package);
|
||||
if (!coverage[pkg]) {
|
||||
coverage[pkg] = { percentage: 0, package: pkg };
|
||||
}
|
||||
coverage[pkg].failed = true;
|
||||
}
|
||||
} catch { /* skip non-JSON lines */ }
|
||||
}
|
||||
|
||||
return coverage;
|
||||
}
|
||||
|
||||
export async function collectGoCoverage(root, cacheDir, quick) {
|
||||
const cacheFile = resolve(cacheDir, CACHE_FILE);
|
||||
|
||||
if (quick && existsSync(cacheFile)) {
|
||||
console.log(' [Go] Using cached coverage data');
|
||||
try {
|
||||
const cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
||||
if (cached.hasFailures) {
|
||||
console.warn(' [Go Coverage] Cached data has test failures — re-running tests');
|
||||
// Fall through to full collection instead of returning cached
|
||||
} else {
|
||||
return cached;
|
||||
}
|
||||
} catch {
|
||||
console.warn(' [Go] Corrupt cache — re-running tests');
|
||||
}
|
||||
}
|
||||
|
||||
const serverDir = resolve(root, 'Server');
|
||||
if (!existsSync(serverDir)) {
|
||||
console.log(' [Go] Server directory not found, skipping');
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log(' [Go] Running tests with coverage (this may take a minute)...');
|
||||
try {
|
||||
const output = execFileSync('go', ['test', './...', '-cover', '-short', '-json', '-count=1'], {
|
||||
cwd: serverDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000, // 5 min
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const coverage = parseGoCoverage(output);
|
||||
|
||||
// Cache results
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
packages: coverage,
|
||||
};
|
||||
writeFileSync(cacheFile, JSON.stringify(result, null, 2));
|
||||
console.log(` [Go] Coverage collected for ${Object.keys(coverage).length} packages`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// go test returns non-zero on test failure but still produces output
|
||||
if (err.stdout) {
|
||||
const coverage = parseGoCoverage(err.stdout);
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
packages: coverage,
|
||||
hasFailures: true,
|
||||
};
|
||||
writeFileSync(cacheFile, JSON.stringify(result, null, 2));
|
||||
console.log(` [Go] Coverage collected (some tests failed)`);
|
||||
return result;
|
||||
}
|
||||
console.error(` [Go] Failed to collect coverage: ${err.message}`);
|
||||
return { timestamp: new Date().toISOString(), packages: {}, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Import-graph builder — parses Go, TypeScript, and Rust source files to
|
||||
* construct a dependency graph with fan-in/fan-out metrics and cycle detection.
|
||||
* Zero external dependencies — Node built-ins only.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, basename, relative } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursively collect files matching `extensions` under `dir`, skipping
|
||||
* common non-source directories.
|
||||
*/
|
||||
function collectFiles(dir, extensions, skipSuffix = []) {
|
||||
const results = [];
|
||||
if (!existsSync(dir)) return results;
|
||||
|
||||
function walk(d) {
|
||||
let entries;
|
||||
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || ['node_modules', 'vendor', 'dist', 'target'].includes(entry.name)) continue;
|
||||
const full = join(d, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile()
|
||||
&& extensions.some(ext => entry.name.endsWith(ext))
|
||||
&& !skipSuffix.some(suf => entry.name.endsWith(suf))) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go import parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GO_MODULE_PREFIX = 'github.com/owncord/server/';
|
||||
|
||||
/**
|
||||
* Parse a single Go file and return the set of internal package short-names
|
||||
* it imports (e.g. "db", "auth", "ws").
|
||||
*/
|
||||
function parseGoImports(content) {
|
||||
const deps = new Set();
|
||||
// Match import blocks: import ( ... )
|
||||
const blockRe = /import\s*\(([^)]*)\)/gs;
|
||||
let blockMatch;
|
||||
while ((blockMatch = blockRe.exec(content)) !== null) {
|
||||
const block = blockMatch[1];
|
||||
const lineRe = /["']([^"']+)["']/g;
|
||||
let lineMatch;
|
||||
while ((lineMatch = lineRe.exec(block)) !== null) {
|
||||
const path = lineMatch[1];
|
||||
if (path.startsWith(GO_MODULE_PREFIX)) {
|
||||
deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match single-line imports: import "..."
|
||||
const singleRe = /import\s+["']([^"']+)["']/g;
|
||||
let singleMatch;
|
||||
while ((singleMatch = singleRe.exec(content)) !== null) {
|
||||
const path = singleMatch[1];
|
||||
if (path.startsWith(GO_MODULE_PREFIX)) {
|
||||
deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]);
|
||||
}
|
||||
}
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Go source files under `serverDir` and return a map of
|
||||
* package-name -> Set<dependency-package-name>.
|
||||
*/
|
||||
function scanGoImports(serverDir) {
|
||||
const graph = new Map();
|
||||
const files = collectFiles(serverDir, ['.go'], ['_test.go']);
|
||||
|
||||
for (const file of files) {
|
||||
// Determine package from directory name relative to server root
|
||||
const rel = relative(serverDir, file);
|
||||
const pkg = rel.includes('/') || rel.includes('\\')
|
||||
? rel.split(/[/\\]/)[0]
|
||||
: '.'; // root-level files (main.go etc.)
|
||||
|
||||
if (pkg === '.' || pkg === 'scripts' || pkg === 'migrations') continue;
|
||||
|
||||
if (!graph.has(pkg)) graph.set(pkg, new Set());
|
||||
|
||||
let content;
|
||||
try { content = readFileSync(file, 'utf8'); } catch { continue; }
|
||||
|
||||
const deps = parseGoImports(content);
|
||||
const existing = graph.get(pkg);
|
||||
for (const dep of deps) {
|
||||
if (dep !== pkg) existing.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TypeScript import parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Known TS path-alias prefixes that map to source directories. */
|
||||
const TS_ALIAS_MAP = {
|
||||
'@lib': 'lib',
|
||||
'@stores': 'stores',
|
||||
'@components': 'components',
|
||||
'@pages': 'pages',
|
||||
'@styles': 'styles',
|
||||
'@types': 'types',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a TypeScript file and return the set of internal module names it
|
||||
* imports (e.g. "lib", "stores", "components").
|
||||
*/
|
||||
function parseTsImports(content) {
|
||||
const deps = new Set();
|
||||
|
||||
// Match: import ... from '...' / import '...' (including type imports)
|
||||
const re = /import\s+(?:type\s+)?(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const specifier = m[1];
|
||||
|
||||
// Check @alias paths: @lib/foo -> "lib"
|
||||
for (const [alias, dir] of Object.entries(TS_ALIAS_MAP)) {
|
||||
if (specifier.startsWith(alias + '/') || specifier === alias) {
|
||||
deps.add(dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check relative paths: ../lib/foo -> "lib", ./sibling stays in same dir
|
||||
if (specifier.startsWith('../')) {
|
||||
const segment = specifier.slice(3).split('/')[0];
|
||||
if (segment && Object.values(TS_ALIAS_MAP).includes(segment)) {
|
||||
deps.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which TS "module" a file belongs to based on its directory.
|
||||
* Files directly in src/ are grouped as "main".
|
||||
*/
|
||||
function tsModuleName(file, srcDir) {
|
||||
const rel = relative(srcDir, file).replace(/\\/g, '/');
|
||||
const parts = rel.split('/');
|
||||
if (parts.length === 1) return basename(parts[0], '.ts'); // top-level file -> its name
|
||||
return parts[0]; // first directory segment
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan TypeScript files under `srcDir` and return a dependency map.
|
||||
*/
|
||||
function scanTsImports(srcDir) {
|
||||
const graph = new Map();
|
||||
const files = collectFiles(srcDir, ['.ts']);
|
||||
|
||||
for (const file of files) {
|
||||
const mod = tsModuleName(file, srcDir);
|
||||
if (!graph.has(mod)) graph.set(mod, new Set());
|
||||
|
||||
let content;
|
||||
try { content = readFileSync(file, 'utf8'); } catch { continue; }
|
||||
|
||||
const deps = parseTsImports(content);
|
||||
const existing = graph.get(mod);
|
||||
for (const dep of deps) {
|
||||
if (dep !== mod) existing.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rust import parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a Rust file and return module names referenced via `mod` declarations
|
||||
* and `use crate::` paths.
|
||||
*/
|
||||
function parseRustImports(content) {
|
||||
const deps = new Set();
|
||||
|
||||
// mod declarations: mod foo;
|
||||
const modRe = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm;
|
||||
let m;
|
||||
while ((m = modRe.exec(content)) !== null) {
|
||||
deps.add(m[1]);
|
||||
}
|
||||
|
||||
// use crate::foo (possibly ::bar::baz)
|
||||
const useRe = /use\s+crate::(\w+)/g;
|
||||
while ((m = useRe.exec(content)) !== null) {
|
||||
deps.add(m[1]);
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine Rust module name from filename.
|
||||
* lib.rs and main.rs -> "lib" / "main"; others -> stem.
|
||||
*/
|
||||
function rustModuleName(file) {
|
||||
const name = basename(file, '.rs');
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Rust files and return a dependency map.
|
||||
*/
|
||||
function scanRustImports(rustDir) {
|
||||
const graph = new Map();
|
||||
const files = collectFiles(rustDir, ['.rs']);
|
||||
|
||||
for (const file of files) {
|
||||
const mod = rustModuleName(file);
|
||||
if (!graph.has(mod)) graph.set(mod, new Set());
|
||||
|
||||
let content;
|
||||
try { content = readFileSync(file, 'utf8'); } catch { continue; }
|
||||
|
||||
const deps = parseRustImports(content);
|
||||
const existing = graph.get(mod);
|
||||
for (const dep of deps) {
|
||||
if (dep !== mod) existing.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge multiple per-language graphs into a single adjacency list (object).
|
||||
* Each value is a Map<moduleName, Set<dep>>; we also track which language
|
||||
* each module belongs to.
|
||||
*/
|
||||
function mergeGraphs(goGraph, tsGraph, rustGraph) {
|
||||
const merged = {}; // module -> string[]
|
||||
const types = {}; // module -> 'go' | 'typescript' | 'rust'
|
||||
|
||||
for (const [mod, deps] of goGraph) {
|
||||
const key = `go/${mod}`;
|
||||
merged[key] = [...deps].map(d => `go/${d}`);
|
||||
types[key] = 'go';
|
||||
}
|
||||
for (const [mod, deps] of tsGraph) {
|
||||
const key = `ts/${mod}`;
|
||||
merged[key] = [...deps].map(d => `ts/${d}`);
|
||||
types[key] = 'typescript';
|
||||
}
|
||||
for (const [mod, deps] of rustGraph) {
|
||||
const key = `rs/${mod}`;
|
||||
merged[key] = [...deps].map(d => `rs/${d}`);
|
||||
types[key] = 'rust';
|
||||
}
|
||||
|
||||
return { merged, types };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute fan-in (how many modules depend on this one) and fan-out (how many
|
||||
* modules this one depends on).
|
||||
*/
|
||||
function computeMetrics(graph) {
|
||||
const fanIn = {};
|
||||
const fanOut = {};
|
||||
|
||||
for (const mod of Object.keys(graph)) {
|
||||
fanOut[mod] = graph[mod].length;
|
||||
if (!(mod in fanIn)) fanIn[mod] = 0;
|
||||
for (const dep of graph[mod]) {
|
||||
fanIn[dep] = (fanIn[dep] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { fanIn, fanOut };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all cycles in the directed graph using iterative DFS with an
|
||||
* explicit stack to avoid call-stack overflow on large graphs.
|
||||
* Returns an array of cycles, each cycle being an array of module names.
|
||||
*/
|
||||
function detectCycles(graph) {
|
||||
const WHITE = 0; // unvisited
|
||||
const GRAY = 1; // in current path
|
||||
const BLACK = 2; // fully explored
|
||||
|
||||
const color = {};
|
||||
for (const node of Object.keys(graph)) {
|
||||
color[node] = WHITE;
|
||||
}
|
||||
|
||||
const cycles = [];
|
||||
|
||||
for (const start of Object.keys(graph)) {
|
||||
if (color[start] !== WHITE) continue;
|
||||
|
||||
// Stack entries: [node, neighborIndex, pathSoFar]
|
||||
const stack = [[start, 0, [start]]];
|
||||
color[start] = GRAY;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1];
|
||||
const node = top[0];
|
||||
const neighbors = graph[node] || [];
|
||||
|
||||
if (top[1] >= neighbors.length) {
|
||||
// All neighbors explored — backtrack
|
||||
color[node] = BLACK;
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
const neighbor = neighbors[top[1]];
|
||||
top[1]++;
|
||||
|
||||
if (color[neighbor] === GRAY) {
|
||||
// Found a cycle — extract the cycle portion from the current path
|
||||
const fullPath = [...top[2], neighbor];
|
||||
const cycleStart = fullPath.indexOf(neighbor);
|
||||
if (cycleStart !== -1 && cycleStart < fullPath.length - 1) {
|
||||
cycles.push(fullPath.slice(cycleStart));
|
||||
}
|
||||
} else if (color[neighbor] === WHITE) {
|
||||
color[neighbor] = GRAY;
|
||||
stack.push([neighbor, 0, [...top[2], neighbor]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full import graph for the OwnCord project.
|
||||
*
|
||||
* @param {string} root — project root directory
|
||||
* @param {string} cacheDir — directory for caching results
|
||||
* @param {boolean} quick — if true, return cached results when available
|
||||
* @returns {Promise<object>} — import graph data
|
||||
*/
|
||||
export async function buildImportGraph(root, cacheDir, quick) {
|
||||
const cacheFile = join(cacheDir, 'import-graph.json');
|
||||
|
||||
// Quick mode: return cache if it exists
|
||||
if (quick && existsSync(cacheFile)) {
|
||||
try {
|
||||
const cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
||||
return cached;
|
||||
} catch {
|
||||
// Cache corrupt — rebuild
|
||||
}
|
||||
}
|
||||
|
||||
// Scan each language
|
||||
const serverDir = resolve(root, 'Server');
|
||||
const tsSrcDir = resolve(root, 'Client', 'tauri-client', 'src');
|
||||
const rustDir = resolve(root, 'Client', 'tauri-client', 'src-tauri', 'src');
|
||||
|
||||
const goGraph = scanGoImports(serverDir);
|
||||
const tsGraph = scanTsImports(tsSrcDir);
|
||||
const rustGraph = scanRustImports(rustDir);
|
||||
|
||||
// Merge into unified adjacency list
|
||||
const { merged, types } = mergeGraphs(goGraph, tsGraph, rustGraph);
|
||||
|
||||
// Compute metrics
|
||||
const { fanIn, fanOut } = computeMetrics(merged);
|
||||
|
||||
// Build nodes array
|
||||
const nodes = Object.keys(merged).map(mod => {
|
||||
const fi = fanIn[mod] || 0;
|
||||
const fo = fanOut[mod] || 0;
|
||||
return {
|
||||
name: mod,
|
||||
type: types[mod] || 'go',
|
||||
fanIn: fi,
|
||||
fanOut: fo,
|
||||
coupling: fi * fo,
|
||||
};
|
||||
});
|
||||
|
||||
// Also include nodes that appear only as dependencies (no outgoing edges)
|
||||
for (const mod of Object.keys(fanIn)) {
|
||||
if (!(mod in merged)) {
|
||||
const fi = fanIn[mod] || 0;
|
||||
nodes.push({
|
||||
name: mod,
|
||||
type: types[mod] || inferType(mod),
|
||||
fanIn: fi,
|
||||
fanOut: 0,
|
||||
coupling: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges array
|
||||
const edges = [];
|
||||
for (const [from, deps] of Object.entries(merged)) {
|
||||
for (const to of deps) {
|
||||
edges.push({ from, to });
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
const cycles = detectCycles(merged);
|
||||
|
||||
// Top 5 most coupled
|
||||
const mostCoupled = [...nodes]
|
||||
.sort((a, b) => b.coupling - a.coupling)
|
||||
.slice(0, 5)
|
||||
.map(({ name, coupling, fanIn, fanOut }) => ({ name, coupling, fanIn, fanOut }));
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
graph: merged,
|
||||
nodes,
|
||||
edges,
|
||||
cycles,
|
||||
mostCoupled,
|
||||
};
|
||||
|
||||
// Write cache
|
||||
try {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8');
|
||||
} catch {
|
||||
// Non-fatal — proceed without caching
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer language type from a prefixed module name.
|
||||
*/
|
||||
function inferType(mod) {
|
||||
if (mod.startsWith('go/')) return 'go';
|
||||
if (mod.startsWith('ts/')) return 'typescript';
|
||||
if (mod.startsWith('rs/')) return 'rust';
|
||||
return 'go';
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Morning Briefing — digest of overnight agent results + session suggestions.
|
||||
* Composes data from session-parser, agent-manager, suggestion-engine, and backlog-parser.
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Generate a morning briefing digest.
|
||||
* Shows what happened since the last session (agent results, coverage changes, etc.)
|
||||
* and suggests what to work on next.
|
||||
*/
|
||||
export async function generateBriefing(root, cacheDir, {
|
||||
sessionData = null,
|
||||
suggestions = null,
|
||||
backlog = null,
|
||||
agentJobs = null,
|
||||
} = {}) {
|
||||
const briefing = {
|
||||
timestamp: new Date().toISOString(),
|
||||
greeting: '',
|
||||
agentResults: [],
|
||||
deadJobs: [],
|
||||
coverageChanges: [],
|
||||
suggestedTasks: [],
|
||||
autoQueueSuggestions: [],
|
||||
stats: { completedJobs: 0, failedJobs: 0, deadJobs: 0 },
|
||||
};
|
||||
|
||||
// Determine last session date for "since last session" filtering
|
||||
const lastSessionDate = sessionData?.lastSession?.date
|
||||
? new Date(sessionData.lastSession.date)
|
||||
: null;
|
||||
|
||||
// Greeting
|
||||
if (lastSessionDate) {
|
||||
const daysSince = Math.floor((Date.now() - lastSessionDate.getTime()) / 86400000);
|
||||
if (daysSince === 0) {
|
||||
briefing.greeting = 'Welcome back! You had a session earlier today.';
|
||||
} else if (daysSince === 1) {
|
||||
briefing.greeting = 'Good morning! Last session was yesterday.';
|
||||
} else {
|
||||
briefing.greeting = `Welcome back! It's been ${daysSince} days since your last session.`;
|
||||
}
|
||||
} else {
|
||||
briefing.greeting = 'Welcome! This is your first time opening the briefing.';
|
||||
}
|
||||
|
||||
// Agent results since last session
|
||||
if (agentJobs?.jobs) {
|
||||
for (const job of agentJobs.jobs) {
|
||||
if (job.status === 'completed') {
|
||||
const completedAt = job.completedAt ? new Date(job.completedAt) : null;
|
||||
if (!lastSessionDate || (completedAt && completedAt > lastSessionDate)) {
|
||||
// Read result summary (first 500 chars)
|
||||
let summary = '';
|
||||
const resultPath = resolve(cacheDir, `agent-results/${job.id}.md`);
|
||||
if (existsSync(resultPath)) {
|
||||
try {
|
||||
const content = readFileSync(resultPath, 'utf8');
|
||||
summary = content.slice(0, 500).split('\n').slice(0, 10).join('\n');
|
||||
if (content.length > 500) summary += '\n...';
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
briefing.agentResults.push({
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
target: job.target,
|
||||
completedAt: job.completedAt,
|
||||
summary,
|
||||
});
|
||||
briefing.stats.completedJobs++;
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === 'failed') briefing.stats.failedJobs++;
|
||||
|
||||
if (job.status === 'dead') {
|
||||
briefing.deadJobs.push({
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
target: job.target,
|
||||
error: job.error,
|
||||
retryCount: job.retryCount,
|
||||
});
|
||||
briefing.stats.deadJobs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top suggested tasks from suggestion engine
|
||||
if (suggestions?.suggestions) {
|
||||
briefing.suggestedTasks = suggestions.suggestions.slice(0, 5).map(s => ({
|
||||
module: s.module,
|
||||
score: s.score,
|
||||
rationale: s.rationale,
|
||||
breakdown: s.breakdown,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-queue suggestions — only when agent jobs context is available
|
||||
briefing.autoQueueSuggestions = agentJobs
|
||||
? generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs)
|
||||
: [];
|
||||
|
||||
return briefing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smart auto-queue suggestions.
|
||||
* Recommends agent jobs based on current project state.
|
||||
*/
|
||||
function generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) {
|
||||
const existing = new Set(
|
||||
(agentJobs?.jobs || [])
|
||||
.filter(j => ['queued', 'running', 'completed'].includes(j.status))
|
||||
.map(j => `${j.type}:${j.target}`)
|
||||
);
|
||||
|
||||
const suggestions_list = [];
|
||||
|
||||
if (!suggestions?.suggestions) return suggestions_list;
|
||||
|
||||
for (const s of suggestions.suggestions.slice(0, 10)) {
|
||||
// Coverage gap → suggest write-tests
|
||||
if (s.breakdown?.coverage > 20) {
|
||||
const key = `write-tests:${s.module}`;
|
||||
if (!existing.has(key)) {
|
||||
suggestions_list.push({
|
||||
type: 'write-tests',
|
||||
target: s.module,
|
||||
rationale: `Coverage gap on ${s.module} (score: ${s.breakdown.coverage})`,
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// High debt → suggest fix-debt
|
||||
if (s.breakdown?.debt > 15) {
|
||||
const key = `fix-debt:${s.module}`;
|
||||
if (!existing.has(key)) {
|
||||
suggestions_list.push({
|
||||
type: 'fix-debt',
|
||||
target: s.module,
|
||||
rationale: `Tech debt markers in ${s.module} (score: ${s.breakdown.debt})`,
|
||||
priority: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Open bugs → suggest code-review
|
||||
if (s.breakdown?.bugs > 10) {
|
||||
const key = `code-review:${s.module}`;
|
||||
if (!existing.has(key)) {
|
||||
suggestions_list.push({
|
||||
type: 'code-review',
|
||||
target: s.module,
|
||||
rationale: `Open bugs/review items in ${s.module} (score: ${s.breakdown.bugs})`,
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stale modules → suggest research (limit to top 2)
|
||||
const staleModules = suggestions.suggestions
|
||||
.filter(s => s.signals?.some(sig => sig.signal === 'stale'))
|
||||
.slice(0, 2);
|
||||
|
||||
for (const s of staleModules) {
|
||||
const key = `research:${s.module}`;
|
||||
if (!existing.has(key)) {
|
||||
suggestions_list.push({
|
||||
type: 'research',
|
||||
target: s.module,
|
||||
rationale: `Module ${s.module} has been stale — investigate status`,
|
||||
priority: 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and limit to 5
|
||||
const seen = new Set();
|
||||
return suggestions_list.filter(s => {
|
||||
const key = `${s.type}:${s.target}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Priority scoring engine.
|
||||
* Ranks modules by where work is most needed based on:
|
||||
* - Coverage gap (distance from 80% target)
|
||||
* - Module size (larger modules = higher impact)
|
||||
* - Open bug/task count
|
||||
* - Roadmap position (earlier phases = higher priority)
|
||||
*/
|
||||
|
||||
const COVERAGE_TARGET = 80;
|
||||
|
||||
const PHASE_WEIGHTS = {
|
||||
'bug': 10,
|
||||
'high': 8,
|
||||
'code-review': 7,
|
||||
'medium': 5,
|
||||
'deferred': 3,
|
||||
'roadmap-r1': 4,
|
||||
'roadmap-r2': 3,
|
||||
'roadmap-r3': 2,
|
||||
'roadmap-r4': 1.5,
|
||||
'roadmap-r5': 1,
|
||||
'roadmap-r6': 0.5,
|
||||
'other': 2,
|
||||
};
|
||||
|
||||
function coverageGapScore(coveragePct) {
|
||||
if (coveragePct === null || coveragePct === undefined) return 50; // no tests = high gap
|
||||
const gap = Math.max(0, COVERAGE_TARGET - coveragePct);
|
||||
return gap * 1.5; // 1.5 points per percent below target
|
||||
}
|
||||
|
||||
function sizeScore(sourceFiles, sourceLines) {
|
||||
// Larger modules are higher impact
|
||||
return Math.min(30, sourceFiles * 2 + (sourceLines / 200));
|
||||
}
|
||||
|
||||
function taskScore(openTasks) {
|
||||
if (!openTasks || openTasks.length === 0) return 0;
|
||||
let score = 0;
|
||||
for (const task of openTasks) {
|
||||
score += PHASE_WEIGHTS[task.phase] || 2;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export function scorePriorities(modules, goCoverage, vitestCoverage, backlog) {
|
||||
const scores = [];
|
||||
|
||||
// Score Go packages
|
||||
for (const pkg of modules.go) {
|
||||
const covData = goCoverage.packages?.[pkg.name];
|
||||
const coverage = covData?.percentage ?? null;
|
||||
const openTasks = backlog.byModule[pkg.name] || [];
|
||||
|
||||
const cScore = coverageGapScore(coverage);
|
||||
const sScore = sizeScore(pkg.sourceFiles, pkg.sourceLines);
|
||||
const tScore = taskScore(openTasks);
|
||||
const total = cScore + sScore + tScore;
|
||||
|
||||
scores.push({
|
||||
name: pkg.name,
|
||||
type: 'go',
|
||||
path: pkg.path,
|
||||
coverage,
|
||||
coverageGap: cScore,
|
||||
sizeImpact: sScore,
|
||||
taskWeight: tScore,
|
||||
totalScore: total,
|
||||
openTaskCount: openTasks.length,
|
||||
openTasks: openTasks.map(t => t.id),
|
||||
recommendation: getRecommendation(coverage, openTasks, pkg),
|
||||
});
|
||||
}
|
||||
|
||||
// Score TypeScript areas
|
||||
const tsAreas = vitestCoverage.areas || {};
|
||||
for (const dir of modules.typescript.filter(d => d.type === 'typescript')) {
|
||||
const areaCov = tsAreas[dir.name];
|
||||
const coverage = areaCov?.statements ?? (tsAreas._total?.statements ?? null);
|
||||
const openTasks = backlog.byModule[dir.name] || [];
|
||||
|
||||
const cScore = coverageGapScore(coverage);
|
||||
const sScore = sizeScore(dir.sourceFiles, dir.sourceLines);
|
||||
const tScore = taskScore(openTasks);
|
||||
const total = cScore + sScore + tScore;
|
||||
|
||||
scores.push({
|
||||
name: dir.name,
|
||||
type: 'typescript',
|
||||
path: dir.path,
|
||||
coverage,
|
||||
coverageGap: cScore,
|
||||
sizeImpact: sScore,
|
||||
taskWeight: tScore,
|
||||
totalScore: total,
|
||||
openTaskCount: openTasks.length,
|
||||
openTasks: openTasks.map(t => t.id),
|
||||
recommendation: getRecommendation(coverage, openTasks, dir),
|
||||
});
|
||||
}
|
||||
|
||||
// Score Rust
|
||||
for (const rust of modules.rust) {
|
||||
const openTasks = backlog.byModule['tauri-rust'] || [];
|
||||
const cScore = coverageGapScore(null); // no Rust tests
|
||||
const sScore = sizeScore(rust.sourceFiles, rust.sourceLines);
|
||||
const tScore = taskScore(openTasks);
|
||||
const total = cScore + sScore + tScore;
|
||||
|
||||
scores.push({
|
||||
name: rust.name,
|
||||
type: 'rust',
|
||||
path: rust.path,
|
||||
coverage: null,
|
||||
coverageGap: cScore,
|
||||
sizeImpact: sScore,
|
||||
taskWeight: tScore,
|
||||
totalScore: total,
|
||||
openTaskCount: openTasks.length,
|
||||
openTasks: openTasks.map(t => t.id),
|
||||
recommendation: 'Add Rust unit tests — currently zero test coverage',
|
||||
});
|
||||
}
|
||||
|
||||
// Score cross-cutting areas from backlog
|
||||
const crossCutting = ['livekit', 'gaming', 'community', 'platform', 'ai', 'protocol'];
|
||||
for (const area of crossCutting) {
|
||||
const openTasks = backlog.byModule[area] || [];
|
||||
if (openTasks.length === 0) continue;
|
||||
|
||||
const tScore = taskScore(openTasks);
|
||||
scores.push({
|
||||
name: area,
|
||||
type: 'feature-area',
|
||||
path: '',
|
||||
coverage: null,
|
||||
coverageGap: 0,
|
||||
sizeImpact: 0,
|
||||
taskWeight: tScore,
|
||||
totalScore: tScore,
|
||||
openTaskCount: openTasks.length,
|
||||
openTasks: openTasks.map(t => t.id),
|
||||
recommendation: `${openTasks.length} open task(s) in backlog`,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by total score descending
|
||||
scores.sort((a, b) => b.totalScore - a.totalScore);
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
function getRecommendation(coverage, openTasks, module) {
|
||||
const parts = [];
|
||||
|
||||
if (coverage === null) {
|
||||
parts.push('No test coverage');
|
||||
} else if (coverage < 60) {
|
||||
parts.push(`Coverage critically low (${coverage.toFixed(1)}%)`);
|
||||
} else if (coverage < COVERAGE_TARGET) {
|
||||
parts.push(`Coverage below target (${coverage.toFixed(1)}% < ${COVERAGE_TARGET}%)`);
|
||||
}
|
||||
|
||||
if (openTasks.length > 0) {
|
||||
const bugs = openTasks.filter(t => t.phase === 'bug');
|
||||
const reviews = openTasks.filter(t => t.phase === 'code-review');
|
||||
if (bugs.length > 0) parts.push(`${bugs.length} open bug(s)`);
|
||||
if (reviews.length > 0) parts.push(`${reviews.length} code review fix(es)`);
|
||||
if (parts.length === 0 || (bugs.length === 0 && reviews.length === 0)) {
|
||||
parts.push(`${openTasks.length} open task(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (module.sourceFiles > 15) {
|
||||
parts.push('Large module — high impact');
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('; ') : 'Good shape';
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Markdown report generator.
|
||||
* Writes the full project map to docs/brain/00-Overview/Project-Map.md.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
function badge(coverage) {
|
||||
if (coverage === null || coverage === undefined) return '---';
|
||||
return `${coverage.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function statusIcon(coverage) {
|
||||
if (coverage === null || coverage === undefined) return 'No tests';
|
||||
if (coverage >= 80) return 'Above target';
|
||||
if (coverage >= 70) return 'Near target';
|
||||
if (coverage >= 50) return 'Below target';
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
export async function generateReport(reportPath, modules, goCoverage, vitestCoverage, backlog, priorities) {
|
||||
mkdirSync(dirname(reportPath), { recursive: true });
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
const lines = [];
|
||||
|
||||
lines.push('# OwnCord Project Map');
|
||||
lines.push('');
|
||||
lines.push(`> Auto-generated on ${now} by \`tools/project-map\``);
|
||||
lines.push(`> Run \`node tools/project-map/index.mjs\` to regenerate`);
|
||||
lines.push('');
|
||||
|
||||
// --- Summary ---
|
||||
lines.push('## Summary');
|
||||
lines.push('');
|
||||
lines.push(`| Metric | Value |`);
|
||||
lines.push(`|--------|-------|`);
|
||||
lines.push(`| Go packages | ${modules.summary.goPackages} |`);
|
||||
lines.push(`| Go source files | ${modules.summary.goSourceFiles} |`);
|
||||
lines.push(`| Go test files | ${modules.summary.goTestFiles} |`);
|
||||
lines.push(`| TypeScript source files | ${modules.summary.tsSourceFiles} |`);
|
||||
lines.push(`| TypeScript test files | ${modules.summary.tsTestFiles} |`);
|
||||
lines.push(`| Rust source files | ${modules.summary.rustSourceFiles} |`);
|
||||
lines.push(`| Rust test files | ${modules.summary.rustTestFiles} |`);
|
||||
lines.push(`| Open backlog tasks | ${backlog.openCount} |`);
|
||||
lines.push(`| Completed tasks | ${backlog.doneCount} |`);
|
||||
lines.push(`| Completion rate | ${backlog.doneCount > 0 ? ((backlog.doneCount / (backlog.openCount + backlog.doneCount)) * 100).toFixed(1) : 0}% |`);
|
||||
lines.push('');
|
||||
|
||||
// --- Server Coverage ---
|
||||
lines.push('## Server (Go) — Test Coverage');
|
||||
lines.push('');
|
||||
lines.push('| Package | Source Files | Test Files | Coverage | Status |');
|
||||
lines.push('|---------|-------------|------------|----------|--------|');
|
||||
|
||||
for (const pkg of modules.go) {
|
||||
const covData = goCoverage.packages?.[pkg.name];
|
||||
const cov = covData?.percentage ?? null;
|
||||
const status = covData?.noTests ? 'No tests' : statusIcon(cov);
|
||||
const failed = covData?.failed ? ' (FAILING)' : '';
|
||||
lines.push(`| \`${pkg.name}/\` | ${pkg.sourceFiles} | ${pkg.testFiles} | ${badge(cov)} | ${status}${failed} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// --- Client Coverage ---
|
||||
lines.push('## Client (TypeScript) — Test Coverage');
|
||||
lines.push('');
|
||||
const tsAreas = vitestCoverage.areas || {};
|
||||
const totalCov = tsAreas._total;
|
||||
if (totalCov) {
|
||||
lines.push(`**Overall:** ${totalCov.statements.toFixed(1)}% statements, ${totalCov.branches.toFixed(1)}% branches, ${totalCov.functions.toFixed(1)}% functions`);
|
||||
lines.push('');
|
||||
}
|
||||
if (vitestCoverage.testCount) {
|
||||
lines.push(`**Total tests:** ${vitestCoverage.testCount}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('| Area | Source Files | Coverage (stmts) | Status |');
|
||||
lines.push('|------|-------------|------------------|--------|');
|
||||
for (const dir of modules.typescript.filter(d => d.type === 'typescript')) {
|
||||
const areaCov = tsAreas[dir.name];
|
||||
const cov = areaCov?.statements ?? null;
|
||||
lines.push(`| \`${dir.name}/\` | ${dir.sourceFiles} | ${badge(cov)} | ${statusIcon(cov)} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Test file counts
|
||||
lines.push('| Test Suite | Files |');
|
||||
lines.push('|-----------|-------|');
|
||||
for (const dir of modules.typescript.filter(d => d.type !== 'typescript')) {
|
||||
lines.push(`| \`${dir.name}\` | ${dir.testFiles} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// --- Rust ---
|
||||
lines.push('## Client (Rust/Tauri) — Status');
|
||||
lines.push('');
|
||||
for (const rust of modules.rust) {
|
||||
lines.push(`| Metric | Value |`);
|
||||
lines.push(`|--------|-------|`);
|
||||
lines.push(`| Source files | ${rust.sourceFiles} |`);
|
||||
lines.push(`| Lines of code | ${rust.sourceLines} |`);
|
||||
lines.push(`| Test files | ${rust.testFiles} |`);
|
||||
lines.push(`| Coverage | No test infrastructure |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// --- Backlog by Phase ---
|
||||
lines.push('## Open Work — By Phase');
|
||||
lines.push('');
|
||||
const phaseOrder = ['bug', 'high', 'code-review', 'medium', 'deferred', 'roadmap-r1', 'roadmap-r2', 'roadmap-r3', 'roadmap-r4', 'roadmap-r5', 'roadmap-r6', 'other'];
|
||||
const phaseLabels = {
|
||||
'bug': 'Bugs', 'high': 'High Priority', 'code-review': 'Code Review',
|
||||
'medium': 'Medium Priority', 'deferred': 'Deferred Features',
|
||||
'roadmap-r1': 'R1: Community Essentials', 'roadmap-r2': 'R2: Gaming DNA',
|
||||
'roadmap-r3': 'R3: Voice Power', 'roadmap-r4': 'R4: LAN Party Toolkit',
|
||||
'roadmap-r5': 'R5: Platform & Extensibility', 'roadmap-r6': 'R6: Future Vision',
|
||||
'other': 'Other',
|
||||
};
|
||||
|
||||
for (const phase of phaseOrder) {
|
||||
const tasks = backlog.byPhase[phase];
|
||||
if (!tasks || tasks.length === 0) continue;
|
||||
lines.push(`### ${phaseLabels[phase] || phase} (${tasks.length})`);
|
||||
lines.push('');
|
||||
for (const task of tasks) {
|
||||
lines.push(`- **${task.id}:** ${task.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Where to Work Next ---
|
||||
lines.push('## Where to Work Next');
|
||||
lines.push('');
|
||||
lines.push('Ranked by priority score (coverage gap + module size + open tasks):');
|
||||
lines.push('');
|
||||
lines.push('| # | Module | Type | Score | Coverage | Open Tasks | Recommendation |');
|
||||
lines.push('|---|--------|------|-------|----------|------------|----------------|');
|
||||
|
||||
const top = priorities.slice(0, 15);
|
||||
top.forEach((p, i) => {
|
||||
const cov = p.coverage !== null ? `${p.coverage.toFixed(1)}%` : '---';
|
||||
lines.push(`| ${i + 1} | \`${p.name}\` | ${p.type} | ${p.totalScore.toFixed(0)} | ${cov} | ${p.openTaskCount} | ${p.recommendation} |`);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// --- Research ---
|
||||
lines.push('## Research');
|
||||
lines.push('');
|
||||
lines.push('Use `node tools/project-map/index.mjs --research` to launch a Claude Code agent');
|
||||
lines.push('that investigates a specific area and saves findings to `docs/brain/00-Overview/Research/`.');
|
||||
lines.push('');
|
||||
|
||||
// --- Staleness ---
|
||||
lines.push('---');
|
||||
lines.push(`*Last generated: ${now}*`);
|
||||
lines.push('');
|
||||
|
||||
writeFileSync(reportPath, lines.join('\n'), 'utf8');
|
||||
console.log(`\n Report written to: ${reportPath}`);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Research agent launcher.
|
||||
* Spawns a Claude Code subprocess to investigate a specific area
|
||||
* and saves findings to docs/brain/00-Overview/Research/.
|
||||
*/
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
const RESEARCH_DIR = 'docs/brain/00-Overview/Research';
|
||||
|
||||
const RESEARCH_AREAS = {
|
||||
'server-api': {
|
||||
label: 'Server API package',
|
||||
description: 'Analyze Server/api/ for coverage gaps, missing error handling, untested endpoints',
|
||||
scope: 'Server/api/',
|
||||
},
|
||||
'server-ws': {
|
||||
label: 'Server WebSocket package',
|
||||
description: 'Analyze Server/ws/ for coverage gaps, race conditions, edge cases in voice/chat handlers',
|
||||
scope: 'Server/ws/',
|
||||
},
|
||||
'server-auth': {
|
||||
label: 'Server auth package',
|
||||
description: 'Analyze Server/auth/ for security gaps, missing test cases, TOTP edge cases',
|
||||
scope: 'Server/auth/',
|
||||
},
|
||||
'server-db': {
|
||||
label: 'Server database package',
|
||||
description: 'Analyze Server/db/ for missing indexes, query performance, untested queries',
|
||||
scope: 'Server/db/',
|
||||
},
|
||||
'server-admin': {
|
||||
label: 'Server admin package',
|
||||
description: 'Analyze Server/admin/ for test coverage issues, build-tag gating, missing functionality',
|
||||
scope: 'Server/admin/',
|
||||
},
|
||||
'client-livekit': {
|
||||
label: 'Client LiveKit session',
|
||||
description: 'Analyze livekitSession.ts and related audio/video code for coverage gaps and edge cases',
|
||||
scope: 'Client/tauri-client/src/lib/livekitSession.ts',
|
||||
},
|
||||
'client-stores': {
|
||||
label: 'Client stores',
|
||||
description: 'Analyze reactive stores for state management edge cases, race conditions, memory leaks',
|
||||
scope: 'Client/tauri-client/src/stores/',
|
||||
},
|
||||
'rust-backend': {
|
||||
label: 'Tauri Rust backend',
|
||||
description: 'Analyze Rust backend for missing tests, security gaps in proxy/credential code',
|
||||
scope: 'Client/tauri-client/src-tauri/src/',
|
||||
},
|
||||
'e2e-coverage': {
|
||||
label: 'E2E test coverage',
|
||||
description: 'Analyze E2E test suite for missing critical user flows, flaky tests, gaps',
|
||||
scope: 'Client/tauri-client/tests/e2e/',
|
||||
},
|
||||
'security': {
|
||||
label: 'Security audit',
|
||||
description: 'Review codebase for OWASP Top 10 vulnerabilities, auth bypass, injection, XSS',
|
||||
scope: 'Server/ Client/tauri-client/src/',
|
||||
},
|
||||
'protocol': {
|
||||
label: 'Protocol compliance',
|
||||
description: 'Check server and client code against docs/brain/06-Specs/PROTOCOL.md for drift',
|
||||
scope: 'Server/ws/ Client/tauri-client/src/lib/dispatcher.ts',
|
||||
},
|
||||
};
|
||||
|
||||
function buildPrompt(areaKey, area, root) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const outputFile = `${RESEARCH_DIR}/${areaKey}-${date}.md`;
|
||||
|
||||
return `You are a research agent investigating the OwnCord project.
|
||||
|
||||
## Task
|
||||
${area.description}
|
||||
|
||||
## Scope
|
||||
Focus on: ${area.scope}
|
||||
|
||||
## Instructions
|
||||
1. Read the relevant source files and test files
|
||||
2. Identify:
|
||||
- Coverage gaps (functions/branches not tested)
|
||||
- Potential bugs or edge cases
|
||||
- Security concerns
|
||||
- Code quality issues
|
||||
- Missing functionality vs specs
|
||||
3. For each finding, note the file path and line number
|
||||
4. Prioritize findings as CRITICAL, HIGH, MEDIUM, or LOW
|
||||
|
||||
## Output
|
||||
Write your findings to: ${outputFile}
|
||||
|
||||
Use this format:
|
||||
---
|
||||
# Research: ${area.label}
|
||||
Date: ${date}
|
||||
Scope: ${area.scope}
|
||||
|
||||
## Summary
|
||||
[2-3 sentence overview]
|
||||
|
||||
## Findings
|
||||
|
||||
### CRITICAL
|
||||
- [finding with file:line reference]
|
||||
|
||||
### HIGH
|
||||
- [finding with file:line reference]
|
||||
|
||||
### MEDIUM
|
||||
- [finding with file:line reference]
|
||||
|
||||
### LOW
|
||||
- [finding with file:line reference]
|
||||
|
||||
## Recommendations
|
||||
[Prioritized list of what to fix/improve next]
|
||||
---
|
||||
|
||||
After writing the file, print a brief summary of what you found.`;
|
||||
}
|
||||
|
||||
async function promptUser(question) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchResearchAgent(root) {
|
||||
console.log('\n Research Agent Launcher\n');
|
||||
console.log(' Available research areas:\n');
|
||||
|
||||
const keys = Object.keys(RESEARCH_AREAS);
|
||||
keys.forEach((key, i) => {
|
||||
const area = RESEARCH_AREAS[key];
|
||||
console.log(` ${i + 1}. ${area.label} — ${area.description.slice(0, 70)}...`);
|
||||
});
|
||||
|
||||
console.log(`\n ${keys.length + 1}. Custom (enter your own research prompt)`);
|
||||
|
||||
const choice = await promptUser('\n Enter number (or "q" to quit): ');
|
||||
|
||||
if (choice === 'q' || choice === '') {
|
||||
console.log(' Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const num = parseInt(choice, 10);
|
||||
if (isNaN(num) || num < 1 || num > keys.length + 1) {
|
||||
console.log(' Invalid choice.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure research directory exists
|
||||
const researchDir = resolve(root, RESEARCH_DIR);
|
||||
mkdirSync(researchDir, { recursive: true });
|
||||
|
||||
let prompt;
|
||||
let areaKey;
|
||||
|
||||
if (num <= keys.length) {
|
||||
areaKey = keys[num - 1];
|
||||
const area = RESEARCH_AREAS[areaKey];
|
||||
prompt = buildPrompt(areaKey, area, root);
|
||||
console.log(`\n Launching research on: ${area.label}`);
|
||||
} else {
|
||||
const customPrompt = await promptUser(' Enter your research prompt: ');
|
||||
if (!customPrompt) {
|
||||
console.log(' Cancelled.');
|
||||
return;
|
||||
}
|
||||
areaKey = 'custom';
|
||||
prompt = customPrompt;
|
||||
console.log(`\n Launching custom research...`);
|
||||
}
|
||||
|
||||
// Save the prompt for reference
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const promptFile = resolve(researchDir, `${areaKey}-${date}-prompt.txt`);
|
||||
// Boundary check: ensure promptFile stays within researchDir
|
||||
if (!promptFile.startsWith(resolve(researchDir))) {
|
||||
console.error(' Error: prompt file path escapes research directory');
|
||||
return;
|
||||
}
|
||||
writeFileSync(promptFile, prompt, 'utf8');
|
||||
|
||||
console.log(` Prompt saved to: ${promptFile}`);
|
||||
console.log(`\n To run the research agent, execute:\n`);
|
||||
console.log(` claude --print "${promptFile.replace(/\\/g, '/')}"`);
|
||||
console.log(`\n Or copy the prompt and paste it into a Claude Code session.`);
|
||||
console.log(` The agent will save findings to: ${RESEARCH_DIR}/${areaKey}-${date}.md\n`);
|
||||
|
||||
// Try to launch claude directly if available
|
||||
try {
|
||||
execFileSync('claude', ['--version'], { stdio: 'pipe' });
|
||||
const launch = await promptUser(' Claude CLI detected. Launch now? (y/n): ');
|
||||
if (launch.toLowerCase() === 'y') {
|
||||
console.log('\n Spawning Claude Code agent...\n');
|
||||
// Use prompt file instead of inline prompt to avoid shell injection
|
||||
const child = spawn('claude', ['--print', promptFile], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
console.log(`\n Research agent exited with code ${code}`);
|
||||
console.log(` Check ${RESEARCH_DIR}/${areaKey}-${date}.md for findings.\n`);
|
||||
});
|
||||
// Wait for completion
|
||||
await new Promise(resolve => child.on('close', resolve));
|
||||
}
|
||||
} catch {
|
||||
// Claude CLI not available — just show instructions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Module scanner — discovers Go packages, TypeScript directories, and Rust files.
|
||||
* Returns a structured inventory of the project.
|
||||
*/
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
function scanDir(dir, extensions, recursive = true) {
|
||||
let fileCount = 0;
|
||||
let lineCount = 0;
|
||||
|
||||
function walk(d) {
|
||||
if (!existsSync(d)) return;
|
||||
let entries;
|
||||
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === 'dist' || entry.name === 'target') continue;
|
||||
const full = join(d, entry.name);
|
||||
if (entry.isDirectory() && recursive) {
|
||||
walk(full);
|
||||
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
|
||||
fileCount++;
|
||||
try {
|
||||
lineCount += readFileSync(full, 'utf8').split('\n').length;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return { fileCount, lineCount };
|
||||
}
|
||||
|
||||
function scanGoDir(dir, name, pathPrefix) {
|
||||
const source = scanDir(dir, ['.go'], false);
|
||||
const testSource = { fileCount: 0, lineCount: 0 };
|
||||
|
||||
try {
|
||||
for (const f of readdirSync(dir)) {
|
||||
if (f.endsWith('_test.go')) {
|
||||
testSource.fileCount++;
|
||||
try {
|
||||
testSource.lineCount += readFileSync(join(dir, f), 'utf8').split('\n').length;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const srcFiles = source.fileCount - testSource.fileCount;
|
||||
const srcLines = source.lineCount - testSource.lineCount;
|
||||
|
||||
if (srcFiles > 0 || testSource.fileCount > 0) {
|
||||
return {
|
||||
name,
|
||||
type: 'go',
|
||||
path: pathPrefix,
|
||||
sourceFiles: srcFiles,
|
||||
sourceLines: srcLines,
|
||||
testFiles: testSource.fileCount,
|
||||
testLines: testSource.lineCount,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanGoPackages(serverDir) {
|
||||
const packages = [];
|
||||
if (!existsSync(serverDir)) return packages;
|
||||
|
||||
// Scan root-level Go files (main.go, etc.)
|
||||
const rootPkg = scanGoDir(serverDir, 'server-root', 'Server');
|
||||
if (rootPkg) packages.push(rootPkg);
|
||||
|
||||
// Scan subdirectory packages
|
||||
for (const entry of readdirSync(serverDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'vendor') continue;
|
||||
const pkg = scanGoDir(join(serverDir, entry.name), entry.name, `Server/${entry.name}`);
|
||||
if (pkg) packages.push(pkg);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
function scanTSDirectories(clientSrcDir) {
|
||||
const dirs = [];
|
||||
if (!existsSync(clientSrcDir)) return dirs;
|
||||
|
||||
const scanAreas = ['lib', 'stores', 'components', 'pages', 'styles'];
|
||||
for (const area of scanAreas) {
|
||||
const areaDir = join(clientSrcDir, area);
|
||||
if (!existsSync(areaDir)) continue;
|
||||
const source = scanDir(areaDir, ['.ts', '.tsx', '.js', '.jsx'], true);
|
||||
dirs.push({
|
||||
name: area,
|
||||
type: 'typescript',
|
||||
path: `Client/tauri-client/src/${area}`,
|
||||
sourceFiles: source.fileCount,
|
||||
sourceLines: source.lineCount,
|
||||
testFiles: 0, // tests are in a separate dir
|
||||
testLines: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Count test files
|
||||
const testDir = resolve(clientSrcDir, '../tests');
|
||||
if (existsSync(testDir)) {
|
||||
for (const subdir of ['unit', 'integration']) {
|
||||
const td = join(testDir, subdir);
|
||||
if (!existsSync(td)) continue;
|
||||
const tests = scanDir(td, ['.ts', '.tsx', '.test.ts', '.test.tsx', '.spec.ts'], true);
|
||||
dirs.push({
|
||||
name: `tests/${subdir}`,
|
||||
type: 'typescript-tests',
|
||||
path: `Client/tauri-client/tests/${subdir}`,
|
||||
sourceFiles: 0,
|
||||
sourceLines: 0,
|
||||
testFiles: tests.fileCount,
|
||||
testLines: tests.lineCount,
|
||||
});
|
||||
}
|
||||
|
||||
// E2E tests
|
||||
const e2eDir = join(testDir, 'e2e');
|
||||
if (existsSync(e2eDir)) {
|
||||
const e2e = scanDir(e2eDir, ['.ts', '.spec.ts'], true);
|
||||
dirs.push({
|
||||
name: 'tests/e2e',
|
||||
type: 'e2e-tests',
|
||||
path: `Client/tauri-client/tests/e2e`,
|
||||
sourceFiles: 0,
|
||||
sourceLines: 0,
|
||||
testFiles: e2e.fileCount,
|
||||
testLines: e2e.lineCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function scanRustFiles(rustDir) {
|
||||
if (!existsSync(rustDir)) return [];
|
||||
const source = scanDir(rustDir, ['.rs'], true);
|
||||
const testCount = (() => {
|
||||
let count = 0;
|
||||
function walk(d) {
|
||||
try {
|
||||
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
||||
const full = join(d, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('_test.rs') || entry.name === 'tests.rs') count++;
|
||||
else if (entry.name.endsWith('.rs')) {
|
||||
// Check for #[cfg(test)] inside the file
|
||||
try {
|
||||
const content = readFileSync(full, 'utf8');
|
||||
if (content.includes('#[cfg(test)]')) count++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
walk(rustDir);
|
||||
return count;
|
||||
})();
|
||||
|
||||
return [{
|
||||
name: 'tauri-rust',
|
||||
type: 'rust',
|
||||
path: 'Client/tauri-client/src-tauri/src',
|
||||
sourceFiles: source.fileCount,
|
||||
sourceLines: source.lineCount,
|
||||
testFiles: testCount,
|
||||
testLines: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
export async function scanModules(root) {
|
||||
const serverDir = resolve(root, 'Server');
|
||||
const clientSrcDir = resolve(root, 'Client/tauri-client/src');
|
||||
const rustDir = resolve(root, 'Client/tauri-client/src-tauri/src');
|
||||
|
||||
const goPackages = scanGoPackages(serverDir);
|
||||
const tsDirectories = scanTSDirectories(clientSrcDir);
|
||||
const rustFiles = scanRustFiles(rustDir);
|
||||
|
||||
return {
|
||||
go: goPackages,
|
||||
typescript: tsDirectories,
|
||||
rust: rustFiles,
|
||||
summary: {
|
||||
goPackages: goPackages.length,
|
||||
goSourceFiles: goPackages.reduce((s, p) => s + p.sourceFiles, 0),
|
||||
goTestFiles: goPackages.reduce((s, p) => s + p.testFiles, 0),
|
||||
tsSourceFiles: tsDirectories.filter(d => d.type === 'typescript').reduce((s, d) => s + d.sourceFiles, 0),
|
||||
tsTestFiles: tsDirectories.filter(d => d.type !== 'typescript').reduce((s, d) => s + d.testFiles, 0),
|
||||
rustSourceFiles: rustFiles.reduce((s, r) => s + r.sourceFiles, 0),
|
||||
rustTestFiles: rustFiles.reduce((s, r) => s + r.testFiles, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Session manager — consolidates session planning, tracking, and vault writing.
|
||||
*
|
||||
* Manages the full session lifecycle: plan → start → poll → end, with
|
||||
* automatic vault integration (session logs, task file updates).
|
||||
*
|
||||
* Zero external dependencies — Node built-ins only.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
import { generateSuggestions } from './suggestion-engine.mjs';
|
||||
import { parseSessionHistory } from './session-parser.mjs';
|
||||
import { parseBacklog } from './backlog-parser.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_STATE_FILE = 'session-state.json';
|
||||
const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const TASK_ID_REGEX = /\b(?:fix|close|resolve|implement|complete)\s+T-(\d+)/gi;
|
||||
const SHA_RE = /^[0-9a-f]{40}$/i;
|
||||
|
||||
function validateSha(sha) {
|
||||
if (!SHA_RE.test(sha)) throw new Error(`Invalid git SHA: "${sha}"`);
|
||||
return sha;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function git(args, root) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function getHeadSha(root) {
|
||||
return git(['rev-parse', 'HEAD'], root);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File path to module mapping (mirrors git-scanner.mjs logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fileToModule(filePath) {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
if (normalized.startsWith('Server/')) {
|
||||
const parts = normalized.split('/');
|
||||
// parts[1] is a subdirectory only when there are 3+ segments
|
||||
// e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root'
|
||||
return parts.length >= 3 ? parts[1] : 'server-root';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) {
|
||||
return 'tauri-rust';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/src/')) {
|
||||
const area = normalized.replace('Client/tauri-client/src/', '').split('/')[0];
|
||||
return area || 'client-root';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('Client/tauri-client/')) {
|
||||
return 'client-config';
|
||||
}
|
||||
|
||||
return 'root';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session state I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stateFilePath(cacheDir) {
|
||||
return resolve(cacheDir, SESSION_STATE_FILE);
|
||||
}
|
||||
|
||||
function readSessionState(cacheDir) {
|
||||
const fp = stateFilePath(cacheDir);
|
||||
if (!existsSync(fp)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(fp, 'utf8'));
|
||||
} catch {
|
||||
console.warn(' [Session] Corrupt session-state.json — renaming and treating as inactive');
|
||||
try {
|
||||
const corruptPath = `${fp}.corrupt.${Date.now()}`;
|
||||
renameSync(fp, corruptPath);
|
||||
} catch { /* best effort */ }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionState(cacheDir, state) {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(stateFilePath(cacheDir), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe vault write — temp file → validate → atomic rename → rollback copy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function safeWriteFile(targetPath, content) {
|
||||
const dir = resolve(targetPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
const backupPath = targetPath + '.bak';
|
||||
|
||||
// Write to temp file
|
||||
writeFileSync(tmpPath, content, 'utf8');
|
||||
|
||||
// Validate: temp file content must match what was written
|
||||
const written = readFileSync(tmpPath, 'utf8');
|
||||
if (written !== content) {
|
||||
unlinkSync(tmpPath);
|
||||
throw new Error(`Safe write validation failed: content mismatch at ${tmpPath}`);
|
||||
}
|
||||
|
||||
// Keep one rollback copy of existing file
|
||||
if (existsSync(targetPath)) {
|
||||
try {
|
||||
if (existsSync(backupPath)) unlinkSync(backupPath);
|
||||
renameSync(targetPath, backupPath);
|
||||
} catch {
|
||||
// Non-fatal — proceed without backup
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
renameSync(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readVaultFile(filePath) {
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(` [Session] Failed to read vault file ${filePath}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(startIso) {
|
||||
const ms = Date.now() - new Date(startIso).getTime();
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function generateSessionLogPath(root, summary) {
|
||||
const sessionsDir = resolve(root, 'docs/brain/03-Sessions');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const slug = summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40);
|
||||
|
||||
let baseName = `${today}-${slug || 'session'}`;
|
||||
let filePath = join(sessionsDir, `${baseName}.md`);
|
||||
let counter = 1;
|
||||
|
||||
while (existsSync(filePath)) {
|
||||
filePath = join(sessionsDir, `${baseName}-${counter}.md`);
|
||||
counter++;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function buildSessionLogContent(state, autoMarker = '') {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tasksCompleted = state.commits
|
||||
.flatMap(c => extractTaskIds(c))
|
||||
.filter((id, i, arr) => arr.indexOf(id) === i);
|
||||
|
||||
const modulesStr = state.modulesTouched.length > 0
|
||||
? state.modulesTouched.join(', ')
|
||||
: 'none';
|
||||
|
||||
const commitsList = state.commits.length > 0
|
||||
? state.commits.map(c => `- ${c}`).join('\n')
|
||||
: '- (no commits)';
|
||||
|
||||
const filesList = state.filesChanged.length > 0
|
||||
? state.filesChanged.slice(0, 20).map(f => `- ${f}`).join('\n')
|
||||
: '- (no files changed)';
|
||||
|
||||
const tasksTable = tasksCompleted.length > 0
|
||||
? tasksCompleted.map(id => `| ${id} | completed | Done |`).join('\n')
|
||||
: '| — | — | — |';
|
||||
|
||||
const marker = autoMarker ? ` ${autoMarker}` : '';
|
||||
|
||||
return `---
|
||||
date: ${today}
|
||||
summary: "Session${marker} — ${state.modulesTouched.slice(0, 3).join(', ') || 'general'}"
|
||||
tasks-completed: ${tasksCompleted.length}
|
||||
---
|
||||
|
||||
# Session — ${today}${marker}
|
||||
|
||||
## Goal
|
||||
|
||||
*Auto-generated session log from session manager*
|
||||
|
||||
## What Was Done
|
||||
|
||||
### Commits
|
||||
${commitsList}
|
||||
|
||||
### Files Changed (${state.filesChanged.length} total)
|
||||
${filesList}${state.filesChanged.length > 20 ? `\n- ...and ${state.filesChanged.length - 20} more` : ''}
|
||||
|
||||
### Modules Touched
|
||||
${modulesStr}
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- *See commit messages for details*
|
||||
|
||||
## Blockers / Issues
|
||||
|
||||
-
|
||||
|
||||
## Next Steps
|
||||
|
||||
-
|
||||
|
||||
## Tasks Touched
|
||||
|
||||
| Task | Action | Status |
|
||||
| ---- | ------ | ------ |
|
||||
${tasksTable}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task ID extraction from commit messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractTaskIds(commitMessage) {
|
||||
const ids = [];
|
||||
let match;
|
||||
const regex = new RegExp(TASK_ID_REGEX.source, TASK_ID_REGEX.flags);
|
||||
while ((match = regex.exec(commitMessage)) !== null) {
|
||||
ids.push(`T-${match[1]}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault task file updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateVaultTasks(root, completedTaskIds, preloadedInProgressContent) {
|
||||
const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md');
|
||||
const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const updated = [];
|
||||
|
||||
const inProgressContent = preloadedInProgressContent ?? readVaultFile(inProgressPath);
|
||||
const doneContent = readVaultFile(donePath);
|
||||
|
||||
if (!inProgressContent || !doneContent) {
|
||||
console.warn(' [Session] Cannot update vault tasks — file read failed');
|
||||
return updated;
|
||||
}
|
||||
|
||||
const idSet = new Set(completedTaskIds);
|
||||
const movedTasks = [];
|
||||
const remainingLines = [];
|
||||
|
||||
// Parse In Progress.md, extract completed tasks
|
||||
for (const line of inProgressContent.split('\n')) {
|
||||
const taskMatch = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/);
|
||||
if (taskMatch && idSet.has(`T-${taskMatch[1]}`)) {
|
||||
movedTasks.push({
|
||||
id: `T-${taskMatch[1]}`,
|
||||
description: taskMatch[2].trim(),
|
||||
});
|
||||
} else {
|
||||
remainingLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (movedTasks.length === 0) return updated;
|
||||
|
||||
// Write updated In Progress.md
|
||||
safeWriteFile(inProgressPath, remainingLines.join('\n'));
|
||||
|
||||
// Append to Done.md
|
||||
const doneEntries = movedTasks
|
||||
.map(t => `- [x] **${t.id}:** ${t.description} — completed ${today}`)
|
||||
.join('\n');
|
||||
|
||||
const sectionHeader = `\n## Session (${today})\n\n`;
|
||||
const insertionPoint = doneContent.indexOf('\n## ');
|
||||
let newDoneContent;
|
||||
|
||||
if (insertionPoint !== -1) {
|
||||
// Insert after the main heading, before the first section
|
||||
newDoneContent = doneContent.slice(0, insertionPoint) +
|
||||
sectionHeader + doneEntries + '\n' +
|
||||
doneContent.slice(insertionPoint);
|
||||
} else {
|
||||
newDoneContent = doneContent + sectionHeader + doneEntries + '\n';
|
||||
}
|
||||
|
||||
safeWriteFile(donePath, newDoneContent);
|
||||
|
||||
for (const t of movedTasks) {
|
||||
updated.push(t.id);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export { fileToModule };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a session plan combining suggestions, last session, and backlog.
|
||||
*/
|
||||
export async function generatePlan(root, cacheDir) {
|
||||
// Gather data from existing engines
|
||||
const [sessionData, backlog] = await Promise.all([
|
||||
parseSessionHistory(root, cacheDir, true),
|
||||
parseBacklog(root),
|
||||
]);
|
||||
|
||||
const suggestions = await generateSuggestions(cacheDir, {
|
||||
backlog,
|
||||
sessionData,
|
||||
});
|
||||
|
||||
// Build greeting
|
||||
const last = sessionData.lastSession;
|
||||
const greeting = last.date
|
||||
? `Welcome back! Last session: ${last.date} — ${last.summary || 'no summary'}`
|
||||
: 'Welcome! This appears to be the first session.';
|
||||
|
||||
// Map suggestions to plan tasks
|
||||
const tasks = suggestions.suggestions.map((s, i) => {
|
||||
const topSignals = s.signals || [];
|
||||
const relatedFiles = topSignals
|
||||
.filter(sig => sig.signal === 'open-bug' || sig.signal === 'open-task')
|
||||
.map(sig => sig.value)
|
||||
.slice(0, 5);
|
||||
|
||||
const score = s.score || 0;
|
||||
let estimatedFocus = 'small';
|
||||
if (score > 100) estimatedFocus = 'large';
|
||||
else if (score > 40) estimatedFocus = 'medium';
|
||||
|
||||
return {
|
||||
priority: Math.min(i + 1, 5),
|
||||
title: `Focus on ${s.module}`,
|
||||
rationale: s.rationale,
|
||||
estimatedFocus,
|
||||
relatedFiles,
|
||||
module: s.module,
|
||||
};
|
||||
});
|
||||
|
||||
// In-progress tasks
|
||||
const inProgress = sessionData.inProgress.map(t => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
greeting,
|
||||
tasks,
|
||||
lastSession: {
|
||||
date: last.date,
|
||||
summary: last.summary,
|
||||
tasksCompleted: last.tasksCompleted,
|
||||
},
|
||||
inProgress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new session. Records HEAD SHA and creates session state.
|
||||
* Rejects if a session is already active.
|
||||
*/
|
||||
export function startSession(root, cacheDir) {
|
||||
const existing = readSessionState(cacheDir);
|
||||
if (existing && existing.active) {
|
||||
throw new Error(
|
||||
'Session already active (started at ' + existing.startedAt + '). ' +
|
||||
'End or recover it before starting a new one.'
|
||||
);
|
||||
}
|
||||
|
||||
const baselineSha = getHeadSha(root);
|
||||
const state = {
|
||||
active: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
baselineSha,
|
||||
filesChanged: [],
|
||||
commits: [],
|
||||
modulesTouched: [],
|
||||
};
|
||||
|
||||
writeSessionState(cacheDir, state);
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session status with live diff stats.
|
||||
*/
|
||||
export function getSessionStatus(cacheDir) {
|
||||
const state = readSessionState(cacheDir);
|
||||
if (!state || !state.active) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for changes since session baseline. Updates session state with
|
||||
* current file changes, commits, and modules touched.
|
||||
*/
|
||||
export function pollChanges(root, cacheDir) {
|
||||
const state = readSessionState(cacheDir);
|
||||
if (!state || !state.active) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
let filesChanged = [];
|
||||
let newCommits = [];
|
||||
|
||||
try {
|
||||
const diffOutput = git(['diff', '--name-only', validateSha(state.baselineSha)], root);
|
||||
filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : [];
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get git diff: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const logOutput = git(['log', '--oneline', `${validateSha(state.baselineSha)}..HEAD`], root);
|
||||
newCommits = logOutput ? logOutput.split('\n').filter(Boolean) : [];
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get git log: ${err.message}`);
|
||||
}
|
||||
|
||||
// Derive modules from changed files
|
||||
const moduleSet = new Set();
|
||||
for (const file of filesChanged) {
|
||||
moduleSet.add(fileToModule(file));
|
||||
}
|
||||
|
||||
const updatedState = {
|
||||
...state,
|
||||
filesChanged,
|
||||
commits: newCommits,
|
||||
modulesTouched: [...moduleSet],
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeSessionState(cacheDir, updatedState);
|
||||
return { ...updatedState };
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current session. Generates a vault session log, updates task
|
||||
* files, and clears session state.
|
||||
*/
|
||||
export function endSession(root, cacheDir) {
|
||||
const state = readSessionState(cacheDir);
|
||||
if (!state || !state.active) {
|
||||
throw new Error('No active session to end.');
|
||||
}
|
||||
|
||||
// Final poll to capture latest changes
|
||||
const finalState = pollChanges(root, cacheDir);
|
||||
|
||||
// Generate session log
|
||||
const logContent = buildSessionLogContent(finalState);
|
||||
const logPath = generateSessionLogPath(
|
||||
root,
|
||||
finalState.modulesTouched.slice(0, 3).join('-') || 'session'
|
||||
);
|
||||
|
||||
safeWriteFile(logPath, logContent);
|
||||
|
||||
// Extract task IDs from commit messages and update vault
|
||||
const allTaskIds = finalState.commits
|
||||
.flatMap(c => extractTaskIds(c))
|
||||
.filter((id, i, arr) => arr.indexOf(id) === i);
|
||||
|
||||
let tasksUpdated = [];
|
||||
if (allTaskIds.length > 0) {
|
||||
// Verify task IDs exist in vault before updating
|
||||
const inProgressContent = readVaultFile(
|
||||
resolve(root, 'docs/brain/02-Tasks/In Progress.md')
|
||||
);
|
||||
if (inProgressContent) {
|
||||
const knownIds = allTaskIds.filter(id => inProgressContent.includes(id));
|
||||
const unknownIds = allTaskIds.filter(id => !inProgressContent.includes(id));
|
||||
|
||||
for (const uid of unknownIds) {
|
||||
console.warn(` [Session] Task ${uid} not found in In Progress — skipping`);
|
||||
}
|
||||
|
||||
if (knownIds.length > 0) {
|
||||
tasksUpdated = updateVaultTasks(root, knownIds, inProgressContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session state
|
||||
const closedState = {
|
||||
active: false,
|
||||
closedAt: new Date().toISOString(),
|
||||
startedAt: finalState.startedAt,
|
||||
baselineSha: finalState.baselineSha,
|
||||
};
|
||||
writeSessionState(cacheDir, closedState);
|
||||
|
||||
return {
|
||||
sessionLog: logPath,
|
||||
tasksUpdated,
|
||||
filesChanged: finalState.filesChanged.length,
|
||||
duration: formatDuration(finalState.startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a stale session on dashboard startup.
|
||||
* If session-state.json shows active=true but last git activity was
|
||||
* >30 minutes ago, auto-close it with a partial log.
|
||||
*/
|
||||
export function recoverStaleSession(root, cacheDir) {
|
||||
const state = readSessionState(cacheDir);
|
||||
if (!state || !state.active) {
|
||||
return { recovered: false };
|
||||
}
|
||||
|
||||
// Use session-specific timestamps: lastHeartbeat > startedAt
|
||||
// Do NOT use repo-wide git log — that reflects any commit, not this session's activity
|
||||
const lastActivityTime = new Date(state.lastHeartbeat || state.startedAt).getTime();
|
||||
|
||||
const elapsed = Date.now() - lastActivityTime;
|
||||
if (elapsed < STALE_THRESHOLD_MS) {
|
||||
return { recovered: false };
|
||||
}
|
||||
|
||||
// Auto-close the stale session
|
||||
let finalState;
|
||||
try {
|
||||
finalState = pollChanges(root, cacheDir);
|
||||
} catch {
|
||||
// If poll fails, use whatever state we have
|
||||
finalState = { ...state };
|
||||
}
|
||||
|
||||
const logContent = buildSessionLogContent(finalState, '[auto-closed]');
|
||||
const logPath = generateSessionLogPath(root, 'auto-closed');
|
||||
|
||||
try {
|
||||
safeWriteFile(logPath, logContent);
|
||||
} catch (err) {
|
||||
console.warn(` [Session] Failed to write auto-close log: ${err.message}`);
|
||||
}
|
||||
|
||||
// Clear session state
|
||||
const closedState = {
|
||||
active: false,
|
||||
closedAt: new Date().toISOString(),
|
||||
startedAt: state.startedAt,
|
||||
baselineSha: state.baselineSha,
|
||||
autoRecovered: true,
|
||||
};
|
||||
writeSessionState(cacheDir, closedState);
|
||||
|
||||
return {
|
||||
recovered: true,
|
||||
log: logPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Session parser — reads session log files from docs/brain/03-Sessions/,
|
||||
* builds a timeline, tracks progress over time, calculates streaks,
|
||||
* and extracts last-session and task-status info.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module keyword map — maps session content to touched modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODULE_KEYWORDS = {
|
||||
'admin': ['admin panel', 'admin api', 'admin'],
|
||||
'api': ['REST', 'endpoint', 'handler', 'middleware', 'api'],
|
||||
'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'],
|
||||
'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'],
|
||||
'ws': ['websocket', 'hub', 'broadcast', 'ringbuffer', 'reconnect'],
|
||||
'voice': ['voice', 'audio', 'LiveKit', 'livekit', 'WebRTC', 'SFU', 'RTP', 'VAD'],
|
||||
'permissions': ['permission', 'role', 'RBAC'],
|
||||
'storage': ['upload', 'file storage', 'attachment'],
|
||||
'config': ['config', 'settings'],
|
||||
'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'],
|
||||
'stores': ['store', 'state management'],
|
||||
'components': ['component', 'sidebar', 'overlay', 'widget', 'picker', 'modal'],
|
||||
'pages': ['ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'],
|
||||
'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater'],
|
||||
'protocol': ['protocol', 'message type', 'payload'],
|
||||
'e2e': ['E2E', 'Playwright', 'end-to-end'],
|
||||
'tests': ['test', 'coverage', 'vitest', 'unit test', 'integration test'],
|
||||
'docs': ['documentation', 'README', 'CLAUDE.md', 'spec', 'docs'],
|
||||
'security': ['security', 'XSS', 'CSRF', 'injection', 'audit', 'vulnerability'],
|
||||
'ci': ['CI', 'GitHub Actions', 'lint', 'golangci'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse YAML-like frontmatter delimited by --- lines.
|
||||
* Returns { meta: { key: value }, body: string }.
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const lines = content.split('\n').map(l => l.replace(/\r$/, ''));
|
||||
if (lines[0].trim() !== '---') {
|
||||
return { meta: {}, body: content };
|
||||
}
|
||||
|
||||
const meta = {};
|
||||
let endIdx = -1;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') {
|
||||
endIdx = i;
|
||||
break;
|
||||
}
|
||||
const colonIdx = lines[i].indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = lines[i].slice(0, colonIdx).trim();
|
||||
let val = lines[i].slice(colonIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
meta[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIdx === -1) {
|
||||
return { meta: {}, body: content };
|
||||
}
|
||||
|
||||
const body = lines.slice(endIdx + 1).join('\n');
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module detection from session body text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectModules(body) {
|
||||
const lower = body.toLowerCase();
|
||||
const found = [];
|
||||
|
||||
for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) {
|
||||
for (const kw of keywords) {
|
||||
if (lower.includes(kw.toLowerCase())) {
|
||||
found.push(mod);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found.length > 0 ? found : ['general'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task file parsers (Done.md, In Progress.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDoneFile(filePath) {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').map(l => l.replace(/\r$/, ''));
|
||||
const tasks = [];
|
||||
let currentSection = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('## ')) {
|
||||
currentSection = line.replace(/^##\s*/, '');
|
||||
}
|
||||
|
||||
// Match both formats:
|
||||
// - [x] **T-XXX:** description — completed YYYY-MM-DD
|
||||
// - [x] **T-XXX**: description — YYYY-MM-DD
|
||||
const match = line.match(
|
||||
/^- \[x\] \*\*T-(\d+)[:\*]*\*?\*?\s*(.+?)(?:\s*—\s*(?:completed\s+)?(\d{4}-\d{2}-\d{2}))?$/
|
||||
);
|
||||
if (match) {
|
||||
const id = `T-${match[1]}`;
|
||||
const description = match[2]
|
||||
.replace(/\s*—\s*(?:completed\s+)?\d{4}-\d{2}-\d{2}$/, '')
|
||||
.trim();
|
||||
// Date from the line itself, or try to extract from section header
|
||||
const date = match[3] || extractDateFromSection(currentSection) || '';
|
||||
tasks.push({ id, description, date });
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function extractDateFromSection(section) {
|
||||
const match = section.match(/\((\d{4}-\d{2}-\d{2})\)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function parseInProgressFile(filePath) {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').map(l => l.replace(/\r$/, ''));
|
||||
const tasks = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Match: - [ ] **T-XXX:** description
|
||||
const match = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/);
|
||||
if (match) {
|
||||
const id = `T-${match[1]}`;
|
||||
const description = match[2].trim();
|
||||
tasks.push({ id, description });
|
||||
}
|
||||
|
||||
// Also match lines without checkbox but with task ID
|
||||
const altMatch = line.match(/^\*\*T-(\d+):\*\*\s*(.+)/);
|
||||
if (!match && altMatch) {
|
||||
const id = `T-${altMatch[1]}`;
|
||||
const description = altMatch[2].trim();
|
||||
tasks.push({ id, description });
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streak calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function calculateStreaks(sortedDates) {
|
||||
if (sortedDates.length === 0) {
|
||||
return { current: 0, longest: 0, totalSessions: 0 };
|
||||
}
|
||||
|
||||
// Deduplicate dates (multiple sessions on same day count as 1)
|
||||
const uniqueDates = [...new Set(sortedDates)].sort();
|
||||
const totalSessions = sortedDates.length;
|
||||
|
||||
let longest = 1;
|
||||
let currentStreak = 1;
|
||||
let streakAtEnd = 1;
|
||||
|
||||
for (let i = 1; i < uniqueDates.length; i++) {
|
||||
const prev = new Date(uniqueDates[i - 1] + 'T00:00:00Z');
|
||||
const curr = new Date(uniqueDates[i] + 'T00:00:00Z');
|
||||
const diffMs = curr.getTime() - prev.getTime();
|
||||
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
currentStreak = 1;
|
||||
}
|
||||
|
||||
if (currentStreak > longest) {
|
||||
longest = currentStreak;
|
||||
}
|
||||
|
||||
streakAtEnd = currentStreak;
|
||||
}
|
||||
|
||||
// Check if the most recent session date is today or yesterday
|
||||
// to determine if the current streak is still "active"
|
||||
const lastDate = new Date(uniqueDates[uniqueDates.length - 1] + 'T00:00:00Z');
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const daysSinceLast = Math.round((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const current = daysSinceLast <= 1 ? streakAtEnd : 0;
|
||||
|
||||
return { current, longest, totalSessions };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse all session logs and task files, returning a structured timeline.
|
||||
*
|
||||
* @param {string} root - Repository root directory
|
||||
* @param {string} cacheDir - Directory to store cached results
|
||||
* @param {boolean} quick - If true and cache exists, return cached data
|
||||
* @returns {Promise<Object>} - Parsed session history
|
||||
*/
|
||||
export async function parseSessionHistory(root, cacheDir, quick) {
|
||||
const cachePath = join(cacheDir, 'session-data.json');
|
||||
|
||||
// Quick mode: return cached data if available
|
||||
if (quick && existsSync(cachePath)) {
|
||||
try {
|
||||
const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
|
||||
console.log(' [Sessions] Returning cached session data');
|
||||
return cached;
|
||||
} catch {
|
||||
// Cache corrupt — fall through to fresh parse
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsDir = resolve(root, 'docs/brain/03-Sessions');
|
||||
const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md');
|
||||
const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Parse all session files
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const sessions = [];
|
||||
|
||||
if (existsSync(sessionsDir)) {
|
||||
const files = readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith('.md') && f !== 'index.md')
|
||||
.sort();
|
||||
|
||||
for (const fileName of files) {
|
||||
const filePath = join(sessionsDir, fileName);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
|
||||
const date = meta.date || fileName.slice(0, 10);
|
||||
const summary = meta.summary || '';
|
||||
const tasksCompleted = parseInt(meta['tasks-completed'] || '0', 10);
|
||||
const modulesTouched = detectModules(body);
|
||||
|
||||
sessions.push({
|
||||
date,
|
||||
summary,
|
||||
tasksCompleted,
|
||||
modulesTouched,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date ascending
|
||||
sessions.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
console.log(` [Sessions] Parsed ${sessions.length} session files`);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Build progress over time (cumulative tasks completed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const progressOverTime = [];
|
||||
let cumulative = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
cumulative += session.tasksCompleted;
|
||||
progressOverTime.push({
|
||||
date: session.date,
|
||||
cumulativeDone: cumulative,
|
||||
sessionDone: session.tasksCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Calculate streaks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const sessionDates = sessions.map(s => s.date);
|
||||
const streaks = calculateStreaks(sessionDates);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Extract last session info
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const lastSessionRaw = sessions.length > 0 ? sessions[sessions.length - 1] : null;
|
||||
const lastSession = lastSessionRaw
|
||||
? {
|
||||
date: lastSessionRaw.date,
|
||||
summary: lastSessionRaw.summary,
|
||||
tasksCompleted: lastSessionRaw.tasksCompleted,
|
||||
modulesTouched: lastSessionRaw.modulesTouched,
|
||||
}
|
||||
: { date: '', summary: '', tasksCompleted: 0, modulesTouched: [] };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Parse task files
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const allDone = parseDoneFile(donePath);
|
||||
// Last 10 completed tasks (file is ordered newest-first by section)
|
||||
const recentlyDone = allDone.slice(0, 10);
|
||||
|
||||
const inProgress = parseInProgressFile(inProgressPath);
|
||||
|
||||
console.log(` [Sessions] ${allDone.length} done tasks, ${inProgress.length} in-progress`);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Assemble result and cache
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sessions,
|
||||
progressOverTime,
|
||||
streaks,
|
||||
lastSession,
|
||||
inProgress,
|
||||
recentlyDone,
|
||||
};
|
||||
|
||||
// Write cache
|
||||
try {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(cachePath, JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
console.warn(` [Sessions] Failed to write cache: ${err.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Smart suggestion engine.
|
||||
* Combines all signals (coverage, bugs, churn, staleness, coupling, debt, recent work)
|
||||
* into ranked "Focus Next" recommendations.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CACHE_FILE = 'suggestions.json';
|
||||
const PREFS_FILE = 'user-prefs.json';
|
||||
|
||||
// Signal weights (tunable)
|
||||
const WEIGHTS = {
|
||||
coverageGap: 2.0, // per % below 80
|
||||
openBugs: 15, // per bug
|
||||
codeReviewFixes: 10, // per fix
|
||||
openTasks: 3, // per task
|
||||
highChurn: 1.5, // per commit in last 30d
|
||||
staleness: 0.5, // per day since last commit
|
||||
coupling: 2.0, // per coupling score point
|
||||
debtMarkers: 1.0, // per TODO/FIXME/HACK
|
||||
largeFiles: 5, // per large file
|
||||
longFunctions: 3, // per long function
|
||||
recentWorkCooldown: -20, // penalty if worked on in last 2 sessions
|
||||
};
|
||||
|
||||
function loadPrefs(cacheDir) {
|
||||
const prefsFile = resolve(cacheDir, PREFS_FILE);
|
||||
if (existsSync(prefsFile)) {
|
||||
try { return JSON.parse(readFileSync(prefsFile, 'utf8')); } catch { /* skip */ }
|
||||
}
|
||||
return { recentlyWorked: [], strategy: 'balanced' };
|
||||
}
|
||||
|
||||
function savePrefs(cacheDir, prefs) {
|
||||
try {
|
||||
writeFileSync(resolve(cacheDir, PREFS_FILE), JSON.stringify(prefs, null, 2));
|
||||
} catch { /* non-fatal — prefs write failure should not crash the server */ }
|
||||
}
|
||||
|
||||
export function markWorkedOn(cacheDir, moduleName) {
|
||||
const prefs = loadPrefs(cacheDir);
|
||||
prefs.recentlyWorked = [moduleName, ...prefs.recentlyWorked.filter(m => m !== moduleName)].slice(0, 5);
|
||||
savePrefs(cacheDir, prefs);
|
||||
}
|
||||
|
||||
export function setStrategy(cacheDir, strategy) {
|
||||
const prefs = loadPrefs(cacheDir);
|
||||
prefs.strategy = strategy;
|
||||
savePrefs(cacheDir, prefs);
|
||||
}
|
||||
|
||||
export async function generateSuggestions(cacheDir, {
|
||||
priorities = [],
|
||||
goCoverage = {},
|
||||
vitestCoverage = {},
|
||||
backlog = {},
|
||||
gitData = null,
|
||||
debtData = null,
|
||||
importGraph = null,
|
||||
sessionData = null,
|
||||
} = {}) {
|
||||
const prefs = loadPrefs(cacheDir);
|
||||
const strategy = prefs.strategy || 'balanced';
|
||||
const recentlyWorked = new Set(prefs.recentlyWorked || []);
|
||||
|
||||
// Strategy multipliers
|
||||
const strategyMult = {
|
||||
balanced: { coverage: 1, bugs: 1, momentum: 1, debt: 1 },
|
||||
'bugs-first': { coverage: 0.5, bugs: 2.5, momentum: 0.5, debt: 0.5 },
|
||||
'coverage-first': { coverage: 2.5, bugs: 0.5, momentum: 0.5, debt: 0.5 },
|
||||
'momentum-first': { coverage: 0.5, bugs: 0.5, momentum: 2.5, debt: 0.5 },
|
||||
'debt-first': { coverage: 0.5, bugs: 0.5, momentum: 0.5, debt: 2.5 },
|
||||
};
|
||||
const mult = strategyMult[strategy] || strategyMult.balanced;
|
||||
|
||||
// Build per-module signal aggregation
|
||||
const moduleSignals = {};
|
||||
|
||||
function ensureModule(name) {
|
||||
if (!moduleSignals[name]) {
|
||||
moduleSignals[name] = {
|
||||
name,
|
||||
signals: [],
|
||||
score: 0,
|
||||
coverageScore: 0,
|
||||
bugScore: 0,
|
||||
momentumScore: 0,
|
||||
debtScore: 0,
|
||||
};
|
||||
}
|
||||
return moduleSignals[name];
|
||||
}
|
||||
|
||||
// 1. Coverage gaps (from priorities which already have this data)
|
||||
for (const p of priorities) {
|
||||
const m = ensureModule(p.name);
|
||||
if (p.coverage !== null && p.coverage < 80) {
|
||||
const gap = 80 - p.coverage;
|
||||
const pts = gap * WEIGHTS.coverageGap * mult.coverage;
|
||||
m.coverageScore += pts;
|
||||
m.signals.push({ signal: 'coverage-gap', value: `${p.coverage.toFixed(1)}% (${gap.toFixed(0)}% below target)`, points: pts });
|
||||
} else if (p.coverage === null && p.type !== 'feature-area') {
|
||||
const pts = 80 * WEIGHTS.coverageGap * 0.5 * mult.coverage; // unknown coverage = assume 50% gap
|
||||
m.coverageScore += pts;
|
||||
m.signals.push({ signal: 'no-coverage-data', value: 'No test coverage', points: pts });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Open bugs and tasks
|
||||
for (const [phase, tasks] of Object.entries(backlog.byPhase || {})) {
|
||||
for (const task of tasks) {
|
||||
for (const mod of task.modules) {
|
||||
const m = ensureModule(mod);
|
||||
if (phase === 'bug') {
|
||||
const pts = WEIGHTS.openBugs * mult.bugs;
|
||||
m.bugScore += pts;
|
||||
m.signals.push({ signal: 'open-bug', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts });
|
||||
} else if (phase === 'code-review') {
|
||||
const pts = WEIGHTS.codeReviewFixes * mult.bugs;
|
||||
m.bugScore += pts;
|
||||
m.signals.push({ signal: 'code-review-fix', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts });
|
||||
} else {
|
||||
const pts = WEIGHTS.openTasks * mult.momentum;
|
||||
m.momentumScore += pts;
|
||||
m.signals.push({ signal: 'open-task', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Git signals (churn, staleness)
|
||||
if (gitData) {
|
||||
for (const [mod, data] of Object.entries(gitData.commitsByModule || {})) {
|
||||
const m = ensureModule(mod);
|
||||
// High churn = needs attention
|
||||
if (data.count > 10) {
|
||||
const pts = (data.count - 10) * WEIGHTS.highChurn * mult.momentum;
|
||||
m.momentumScore += pts;
|
||||
m.signals.push({ signal: 'high-churn', value: `${data.count} commits in 30d`, points: pts });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [mod, data] of Object.entries(gitData.staleness || {})) {
|
||||
const m = ensureModule(mod);
|
||||
if (data.daysSinceLastCommit > 14) {
|
||||
const pts = data.daysSinceLastCommit * WEIGHTS.staleness * mult.momentum;
|
||||
m.momentumScore += pts;
|
||||
m.signals.push({ signal: 'stale', value: `${data.daysSinceLastCommit} days since last commit`, points: pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Debt signals
|
||||
if (debtData) {
|
||||
for (const [mod, data] of Object.entries(debtData.summary?.byModule || {})) {
|
||||
const m = ensureModule(mod);
|
||||
if (data.markers > 0) {
|
||||
const pts = data.markers * WEIGHTS.debtMarkers * mult.debt;
|
||||
m.debtScore += pts;
|
||||
m.signals.push({ signal: 'debt-markers', value: `${data.markers} TODO/FIXME/HACK`, points: pts });
|
||||
}
|
||||
if (data.largeFiles > 0) {
|
||||
const pts = data.largeFiles * WEIGHTS.largeFiles * mult.debt;
|
||||
m.debtScore += pts;
|
||||
m.signals.push({ signal: 'large-files', value: `${data.largeFiles} oversized file(s)`, points: pts });
|
||||
}
|
||||
if (data.longFunctions > 0) {
|
||||
const pts = data.longFunctions * WEIGHTS.longFunctions * mult.debt;
|
||||
m.debtScore += pts;
|
||||
m.signals.push({ signal: 'long-functions', value: `${data.longFunctions} long function(s)`, points: pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Coupling signals
|
||||
if (importGraph) {
|
||||
for (const node of importGraph.nodes || []) {
|
||||
const m = ensureModule(node.name);
|
||||
if (node.coupling > 10) {
|
||||
const pts = node.coupling * WEIGHTS.coupling * mult.debt;
|
||||
m.debtScore += pts;
|
||||
m.signals.push({ signal: 'high-coupling', value: `Coupling score ${node.coupling} (fan-in ${node.fanIn}, fan-out ${node.fanOut})`, points: pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Recent work cooldown
|
||||
for (const [name, m] of Object.entries(moduleSignals)) {
|
||||
if (recentlyWorked.has(name)) {
|
||||
const pts = WEIGHTS.recentWorkCooldown;
|
||||
m.momentumScore += pts;
|
||||
m.signals.push({ signal: 'recently-worked', value: 'Worked on recently — cooling down', points: pts });
|
||||
}
|
||||
}
|
||||
|
||||
// Compute total scores
|
||||
for (const m of Object.values(moduleSignals)) {
|
||||
m.score = m.coverageScore + m.bugScore + m.momentumScore + m.debtScore;
|
||||
}
|
||||
|
||||
// Sort and take top suggestions
|
||||
const sorted = Object.values(moduleSignals)
|
||||
.filter(m => m.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const suggestions = sorted.slice(0, 10).map((m, i) => {
|
||||
// Build a human-readable rationale from top 3 signals
|
||||
const topSignals = [...m.signals].sort((a, b) => b.points - a.points).slice(0, 3);
|
||||
const rationale = topSignals.map(s => s.value).join('; ');
|
||||
|
||||
return {
|
||||
rank: i + 1,
|
||||
module: m.name,
|
||||
score: Math.round(m.score),
|
||||
rationale,
|
||||
breakdown: {
|
||||
coverage: Math.round(m.coverageScore),
|
||||
bugs: Math.round(m.bugScore),
|
||||
momentum: Math.round(m.momentumScore),
|
||||
debt: Math.round(m.debtScore),
|
||||
},
|
||||
signals: m.signals,
|
||||
};
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
strategy,
|
||||
suggestions,
|
||||
recentlyWorked: [...recentlyWorked],
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(resolve(cacheDir, CACHE_FILE), JSON.stringify(result, null, 2));
|
||||
} catch { /* non-fatal */ }
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Terminal summary — prints a color-coded overview to stdout.
|
||||
*/
|
||||
|
||||
const RESET = '\x1b[0m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const DIM = '\x1b[2m';
|
||||
const RED = '\x1b[31m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const CYAN = '\x1b[36m';
|
||||
const WHITE = '\x1b[37m';
|
||||
|
||||
function covColor(pct) {
|
||||
if (pct === null || pct === undefined) return RED;
|
||||
if (pct >= 80) return GREEN;
|
||||
if (pct >= 60) return YELLOW;
|
||||
return RED;
|
||||
}
|
||||
|
||||
function bar(pct, width = 20) {
|
||||
if (pct === null || pct === undefined) return `${RED}${'░'.repeat(width)}${RESET} ---`;
|
||||
const filled = Math.round((pct / 100) * width);
|
||||
const empty = width - filled;
|
||||
const color = covColor(pct);
|
||||
return `${color}${'█'.repeat(filled)}${'░'.repeat(empty)}${RESET} ${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function divider(title) {
|
||||
const line = '─'.repeat(60);
|
||||
return `\n ${CYAN}${BOLD}${title}${RESET}\n ${DIM}${line}${RESET}`;
|
||||
}
|
||||
|
||||
export function printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities) {
|
||||
console.log(divider('PROJECT OVERVIEW'));
|
||||
|
||||
const total = backlog.openCount + backlog.doneCount;
|
||||
const pct = total > 0 ? ((backlog.doneCount / total) * 100).toFixed(1) : '0.0';
|
||||
console.log(` ${WHITE}Tasks: ${GREEN}${backlog.doneCount} done${RESET} / ${YELLOW}${backlog.openCount} open${RESET} (${pct}% complete)`);
|
||||
console.log(` ${WHITE}Files: ${modules.summary.goSourceFiles} Go + ${modules.summary.tsSourceFiles} TS + ${modules.summary.rustSourceFiles} Rust${RESET}`);
|
||||
console.log(` ${WHITE}Tests: ${modules.summary.goTestFiles} Go + ${modules.summary.tsTestFiles} TS + ${modules.summary.rustTestFiles} Rust${RESET}`);
|
||||
|
||||
// Go coverage
|
||||
console.log(divider('SERVER (GO) COVERAGE'));
|
||||
for (const pkg of modules.go) {
|
||||
const covData = goCoverage.packages?.[pkg.name];
|
||||
const cov = covData?.percentage ?? null;
|
||||
const name = `${pkg.name}/`.padEnd(16);
|
||||
const failed = covData?.failed ? ` ${RED}FAIL${RESET}` : '';
|
||||
console.log(` ${WHITE}${name}${RESET} ${bar(cov)}${failed}`);
|
||||
}
|
||||
|
||||
// TS coverage
|
||||
console.log(divider('CLIENT (TYPESCRIPT) COVERAGE'));
|
||||
const tsAreas = vitestCoverage.areas || {};
|
||||
const totalCov = tsAreas._total;
|
||||
if (totalCov) {
|
||||
console.log(` ${WHITE}${'Overall'.padEnd(16)}${RESET} ${bar(totalCov.statements)}`);
|
||||
}
|
||||
for (const dir of modules.typescript.filter(d => d.type === 'typescript')) {
|
||||
const areaCov = tsAreas[dir.name];
|
||||
const cov = areaCov?.statements ?? null;
|
||||
const name = `${dir.name}/`.padEnd(16);
|
||||
console.log(` ${WHITE}${name}${RESET} ${bar(cov)}`);
|
||||
}
|
||||
|
||||
// Rust
|
||||
console.log(divider('CLIENT (RUST) STATUS'));
|
||||
for (const rust of modules.rust) {
|
||||
console.log(` ${WHITE}${rust.sourceFiles} files, ${rust.sourceLines} lines${RESET} — ${RED}No test infrastructure${RESET}`);
|
||||
}
|
||||
|
||||
// Where to work next
|
||||
console.log(divider('WHERE TO WORK NEXT (TOP 5)'));
|
||||
const top5 = priorities.slice(0, 5);
|
||||
top5.forEach((p, i) => {
|
||||
const num = `${i + 1}.`.padEnd(3);
|
||||
const name = p.name.padEnd(16);
|
||||
const cov = p.coverage !== null ? `${p.coverage.toFixed(0)}%`.padEnd(5) : '--- ';
|
||||
const tasks = p.openTaskCount > 0 ? `${YELLOW}${p.openTaskCount} task(s)${RESET}` : `${GREEN}0 tasks${RESET}`;
|
||||
console.log(` ${CYAN}${num}${RESET} ${BOLD}${name}${RESET} ${covColor(p.coverage)}${cov}${RESET} ${tasks} — ${DIM}${p.recommendation}${RESET}`);
|
||||
});
|
||||
|
||||
// Open bugs
|
||||
const bugs = backlog.byPhase['bug'] || [];
|
||||
if (bugs.length > 0) {
|
||||
console.log(divider('OPEN BUGS'));
|
||||
for (const bug of bugs) {
|
||||
console.log(` ${RED}${bug.id}${RESET}: ${bug.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Code review items
|
||||
const reviews = backlog.byPhase['code-review'] || [];
|
||||
if (reviews.length > 0) {
|
||||
console.log(divider('CODE REVIEW FIXES'));
|
||||
for (const r of reviews) {
|
||||
console.log(` ${YELLOW}${r.id}${RESET}: ${r.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${DIM}Full report: docs/brain/00-Overview/Project-Map.md${RESET}\n`);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Vitest coverage collector.
|
||||
* Runs vitest with coverage and parses the Istanbul coverage-final.json.
|
||||
* Caches results to .cache/vitest-coverage.json.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Coverage data older than this is considered stale and gets a warning flag
|
||||
const FRESHNESS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const CACHE_FILE = 'vitest-coverage.json';
|
||||
|
||||
function computeFileCoverage(fileData) {
|
||||
const s = fileData.s || {};
|
||||
const b = fileData.b || {};
|
||||
const f = fileData.f || {};
|
||||
|
||||
const stmtTotal = Object.keys(s).length;
|
||||
const stmtCovered = Object.values(s).filter(v => v > 0).length;
|
||||
|
||||
let branchTotal = 0;
|
||||
let branchCovered = 0;
|
||||
for (const branches of Object.values(b)) {
|
||||
for (const count of branches) {
|
||||
branchTotal++;
|
||||
if (count > 0) branchCovered++;
|
||||
}
|
||||
}
|
||||
|
||||
const fnTotal = Object.keys(f).length;
|
||||
const fnCovered = Object.values(f).filter(v => v > 0).length;
|
||||
|
||||
return {
|
||||
statements: stmtTotal > 0 ? (stmtCovered / stmtTotal) * 100 : 100,
|
||||
branches: branchTotal > 0 ? (branchCovered / branchTotal) * 100 : 100,
|
||||
functions: fnTotal > 0 ? (fnCovered / fnTotal) * 100 : 100,
|
||||
stmtTotal,
|
||||
stmtCovered,
|
||||
branchTotal,
|
||||
branchCovered,
|
||||
fnTotal,
|
||||
fnCovered,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCoverageFinal(coverageJsonPath) {
|
||||
if (!existsSync(coverageJsonPath)) return {};
|
||||
|
||||
const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8'));
|
||||
const byArea = {};
|
||||
let totalStmt = 0, totalStmtCov = 0;
|
||||
let totalBranch = 0, totalBranchCov = 0;
|
||||
let totalFn = 0, totalFnCov = 0;
|
||||
|
||||
for (const [filePath, fileData] of Object.entries(raw)) {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const srcMatch = normalized.match(/src\/(\w+)\//);
|
||||
const area = srcMatch ? srcMatch[1] : 'other';
|
||||
|
||||
const cov = computeFileCoverage(fileData);
|
||||
|
||||
if (!byArea[area]) {
|
||||
byArea[area] = {
|
||||
files: 0,
|
||||
stmtTotal: 0, stmtCovered: 0,
|
||||
branchTotal: 0, branchCovered: 0,
|
||||
fnTotal: 0, fnCovered: 0,
|
||||
};
|
||||
}
|
||||
|
||||
byArea[area].files++;
|
||||
byArea[area].stmtTotal += cov.stmtTotal;
|
||||
byArea[area].stmtCovered += cov.stmtCovered;
|
||||
byArea[area].branchTotal += cov.branchTotal;
|
||||
byArea[area].branchCovered += cov.branchCovered;
|
||||
byArea[area].fnTotal += cov.fnTotal;
|
||||
byArea[area].fnCovered += cov.fnCovered;
|
||||
|
||||
totalStmt += cov.stmtTotal;
|
||||
totalStmtCov += cov.stmtCovered;
|
||||
totalBranch += cov.branchTotal;
|
||||
totalBranchCov += cov.branchCovered;
|
||||
totalFn += cov.fnTotal;
|
||||
totalFnCov += cov.fnCovered;
|
||||
}
|
||||
|
||||
// Compute percentages
|
||||
const result = {};
|
||||
for (const [area, data] of Object.entries(byArea)) {
|
||||
result[area] = {
|
||||
files: data.files,
|
||||
statements: data.stmtTotal > 0 ? (data.stmtCovered / data.stmtTotal) * 100 : 100,
|
||||
branches: data.branchTotal > 0 ? (data.branchCovered / data.branchTotal) * 100 : 100,
|
||||
functions: data.fnTotal > 0 ? (data.fnCovered / data.fnTotal) * 100 : 100,
|
||||
};
|
||||
}
|
||||
|
||||
result._total = {
|
||||
statements: totalStmt > 0 ? (totalStmtCov / totalStmt) * 100 : 0,
|
||||
branches: totalBranch > 0 ? (totalBranchCov / totalBranch) * 100 : 0,
|
||||
functions: totalFn > 0 ? (totalFnCov / totalFn) * 100 : 0,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function collectVitestCoverage(root, cacheDir, quick) {
|
||||
const cacheFile = resolve(cacheDir, CACHE_FILE);
|
||||
|
||||
const clientDir = resolve(root, 'Client/tauri-client');
|
||||
|
||||
if (quick && existsSync(cacheFile)) {
|
||||
console.log(' [TS] Using cached coverage data');
|
||||
let cached;
|
||||
try {
|
||||
cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
||||
} catch {
|
||||
console.warn(' [TS] Corrupt cache — falling through to fresh collection');
|
||||
cached = null;
|
||||
}
|
||||
if (cached) {
|
||||
// Still evaluate freshness of the underlying coverage-final.json
|
||||
const coveragePath = resolve(clientDir, 'coverage/coverage-final.json');
|
||||
if (existsSync(coveragePath)) {
|
||||
try {
|
||||
const covStat = statSync(coveragePath);
|
||||
const ageMs = Date.now() - covStat.mtimeMs;
|
||||
if (ageMs > FRESHNESS_MAX_AGE_MS) {
|
||||
cached.stale = true;
|
||||
console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`);
|
||||
}
|
||||
} catch { /* stat failed, leave stale flag as-is */ }
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
if (!existsSync(clientDir)) {
|
||||
console.log(' [TS] Client directory not found, skipping');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Check if coverage-final.json already exists (from a previous test run)
|
||||
const coveragePath = resolve(clientDir, 'coverage/coverage-final.json');
|
||||
let needsRun = !existsSync(coveragePath);
|
||||
let stale = false;
|
||||
|
||||
// Check freshness — don't blindly trust old coverage data
|
||||
if (!needsRun) {
|
||||
try {
|
||||
const covStat = statSync(coveragePath);
|
||||
const ageMs = Date.now() - covStat.mtimeMs;
|
||||
if (ageMs > FRESHNESS_MAX_AGE_MS) {
|
||||
stale = true;
|
||||
console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`);
|
||||
if (!quick) {
|
||||
needsRun = true; // Full mode re-runs stale coverage
|
||||
}
|
||||
}
|
||||
} catch { /* stat failed, treat as needing a run */ needsRun = true; }
|
||||
}
|
||||
|
||||
if (needsRun && !quick) {
|
||||
console.log(' [TS] Running vitest with coverage (this may take a moment)...');
|
||||
try {
|
||||
execFileSync('npx', ['vitest', 'run', '--coverage'], {
|
||||
cwd: clientDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
// vitest may exit non-zero but still produce coverage
|
||||
}
|
||||
} else if (!needsRun) {
|
||||
console.log(' [TS] Using existing coverage-final.json');
|
||||
}
|
||||
|
||||
let coverageData = {};
|
||||
if (existsSync(coveragePath)) {
|
||||
coverageData = parseCoverageFinal(coveragePath);
|
||||
}
|
||||
|
||||
// Get test count from a quick vitest --reporter=json run if available
|
||||
let testCount = 0;
|
||||
const reportPath = resolve(clientDir, 'coverage-report.json');
|
||||
if (existsSync(reportPath)) {
|
||||
try {
|
||||
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||||
testCount = report.numTotalTests ?? 0;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// Instead of running vitest again, count files from coverage data
|
||||
if (testCount === 0 && Object.keys(coverageData).length > 0) {
|
||||
try {
|
||||
const coverageJsonPath = resolve(clientDir, 'coverage', 'coverage-final.json');
|
||||
if (existsSync(coverageJsonPath)) {
|
||||
const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8'));
|
||||
testCount = Object.keys(raw).length;
|
||||
}
|
||||
} catch { /* keep testCount as 0 */ }
|
||||
}
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
areas: coverageData,
|
||||
testCount,
|
||||
stale,
|
||||
};
|
||||
|
||||
writeFileSync(cacheFile, JSON.stringify(result, null, 2));
|
||||
console.log(` [TS] Coverage collected for ${Object.keys(coverageData).length} areas, ${testCount} tests`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Worktree Manager — git worktree lifecycle for isolated agent execution.
|
||||
*
|
||||
* Each agent job gets its own worktree under .worktrees/<jobId>.
|
||||
* Changes stay isolated until explicitly merged back.
|
||||
*
|
||||
* Lifecycle:
|
||||
* createWorktree() → agent runs → mergeWorktree() → destroyWorktree()
|
||||
*
|
||||
* State machine:
|
||||
* queued → provisioning → running → review → merged → archived
|
||||
* → failed → (retry → queued)
|
||||
* → cancelled
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
readdirSync,
|
||||
lstatSync,
|
||||
} from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKTREES_DIR = '.worktrees';
|
||||
const BRANCH_PREFIX = 'agent/';
|
||||
const GIT_TIMEOUT = 30_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function git(args, cwd, opts = {}) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: opts.timeout ?? GIT_TIMEOUT,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Link or copy .claude/ and CLAUDE.md into a worktree so agents
|
||||
* inherit project config, skills, and MCP server settings.
|
||||
*
|
||||
* Strategy: try symlink first (works if Developer Mode enabled on Windows).
|
||||
* If symlink fails (EPERM), fall back to a full copy.
|
||||
*
|
||||
* @returns {'symlink'|'copy'|'none'} — method used
|
||||
*/
|
||||
function inheritConfig(rootDir, worktreePath) {
|
||||
let method = 'none';
|
||||
|
||||
const claudeDir = resolve(rootDir, '.claude');
|
||||
const claudeMd = resolve(rootDir, 'CLAUDE.md');
|
||||
const targetDir = resolve(worktreePath, '.claude');
|
||||
const targetMd = resolve(worktreePath, 'CLAUDE.md');
|
||||
|
||||
if (existsSync(claudeDir) && !existsSync(targetDir)) {
|
||||
// Try symlink
|
||||
try {
|
||||
symlinkSync(claudeDir, targetDir, 'junction'); // junction works without admin on Windows
|
||||
method = 'symlink';
|
||||
} catch {
|
||||
// Fallback to copy
|
||||
try {
|
||||
cpSync(claudeDir, targetDir, { recursive: true });
|
||||
method = 'copy';
|
||||
} catch (copyErr) {
|
||||
console.error(` [Worktree] Failed to copy .claude/: ${copyErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(claudeMd) && !existsSync(targetMd)) {
|
||||
try {
|
||||
symlinkSync(claudeMd, targetMd, 'file');
|
||||
} catch {
|
||||
try {
|
||||
cpSync(claudeMd, targetMd);
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove inherited config from a worktree before destruction.
|
||||
* Must handle both symlinks and copies.
|
||||
*/
|
||||
function removeInheritedConfig(worktreePath) {
|
||||
const targets = [
|
||||
resolve(worktreePath, '.claude'),
|
||||
resolve(worktreePath, 'CLAUDE.md'),
|
||||
];
|
||||
|
||||
for (const target of targets) {
|
||||
if (!existsSync(target)) continue;
|
||||
try {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error(` [Worktree] Failed to remove ${target}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge mutex — only one merge at a time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mergeInProgress = false;
|
||||
|
||||
export function isMergeInProgress() {
|
||||
return mergeInProgress;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an isolated git worktree for an agent job.
|
||||
*
|
||||
* @param {string} root — project root (must be a git repo)
|
||||
* @param {string} jobId — unique job identifier (used for directory + branch name)
|
||||
* @returns {{ worktreePath: string, branchName: string, configMethod: string }}
|
||||
*/
|
||||
export function createWorktree(root, jobId) {
|
||||
const worktreesBase = resolve(root, WORKTREES_DIR);
|
||||
if (!existsSync(worktreesBase)) {
|
||||
mkdirSync(worktreesBase, { recursive: true });
|
||||
}
|
||||
|
||||
const worktreePath = resolve(worktreesBase, jobId);
|
||||
const branchName = `${BRANCH_PREFIX}${jobId}`;
|
||||
|
||||
if (existsSync(worktreePath)) {
|
||||
throw new Error(`Worktree already exists for job ${jobId}`);
|
||||
}
|
||||
|
||||
// Create worktree with a new branch based on current HEAD
|
||||
git(['worktree', 'add', worktreePath, '-b', branchName], root);
|
||||
|
||||
// Inherit project config
|
||||
const configMethod = inheritConfig(root, worktreePath);
|
||||
|
||||
return { worktreePath, branchName, configMethod };
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a worktree and its branch.
|
||||
* Handles the Windows-specific cleanup order: remove config → remove dir → prune → delete branch.
|
||||
*
|
||||
* @param {string} root — project root
|
||||
* @param {string} jobId — job identifier
|
||||
*/
|
||||
export function destroyWorktree(root, jobId) {
|
||||
const worktreePath = resolve(root, WORKTREES_DIR, jobId);
|
||||
const branchName = `${BRANCH_PREFIX}${jobId}`;
|
||||
|
||||
if (existsSync(worktreePath)) {
|
||||
// Step 1: Remove inherited config (symlinks/copies) first
|
||||
removeInheritedConfig(worktreePath);
|
||||
|
||||
// Step 2: Try git worktree remove
|
||||
try {
|
||||
git(['worktree', 'remove', worktreePath, '--force'], root);
|
||||
} catch {
|
||||
// Fallback: force-remove directory + prune
|
||||
try {
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
} catch (rmErr) {
|
||||
console.error(` [Worktree] rm failed for ${jobId}: ${rmErr.message}`);
|
||||
}
|
||||
try {
|
||||
git(['worktree', 'prune'], root);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
} else {
|
||||
// Directory already gone — just prune
|
||||
try {
|
||||
git(['worktree', 'prune'], root);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Step 3: Delete the branch
|
||||
try {
|
||||
git(['branch', '-D', branchName], root);
|
||||
} catch {
|
||||
// Branch may already be deleted or never created
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active worktrees.
|
||||
*
|
||||
* @param {string} root — project root
|
||||
* @returns {Array<{ path: string, branch: string, head: string, jobId: string|null }>}
|
||||
*/
|
||||
export function listWorktrees(root) {
|
||||
let raw;
|
||||
try {
|
||||
raw = git(['worktree', 'list', '--porcelain'], root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const worktrees = [];
|
||||
let current = {};
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current.path) worktrees.push(current);
|
||||
current = { path: line.slice(9) };
|
||||
} else if (line.startsWith('HEAD ')) {
|
||||
current.head = line.slice(5);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.slice(7);
|
||||
} else if (line === '') {
|
||||
if (current.path) worktrees.push(current);
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
if (current.path) worktrees.push(current);
|
||||
|
||||
// Filter to agent worktrees only and extract jobId
|
||||
return worktrees
|
||||
.filter(w => w.branch && w.branch.includes(BRANCH_PREFIX))
|
||||
.map(w => ({
|
||||
...w,
|
||||
jobId: w.branch.replace(`refs/heads/${BRANCH_PREFIX}`, ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a worktree's branch back into the current branch.
|
||||
*
|
||||
* Guards:
|
||||
* - Only one merge at a time (mutex)
|
||||
* - Working tree must be clean
|
||||
*
|
||||
* @param {string} root — project root
|
||||
* @param {string} jobId — job identifier
|
||||
* @param {string} [message] — optional merge commit message
|
||||
* @returns {{ success: boolean, filesChanged: number, commitSha: string|null, conflicts: string[] }}
|
||||
*/
|
||||
export function mergeWorktree(root, jobId, message) {
|
||||
if (mergeInProgress) {
|
||||
return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'merge_in_progress' };
|
||||
}
|
||||
|
||||
mergeInProgress = true;
|
||||
|
||||
try {
|
||||
// Guard: working tree must be clean
|
||||
try {
|
||||
git(['diff', '--quiet'], root);
|
||||
git(['diff', '--quiet', '--cached'], root);
|
||||
} catch {
|
||||
return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'working_tree_dirty' };
|
||||
}
|
||||
|
||||
const branchName = `${BRANCH_PREFIX}${jobId}`;
|
||||
const commitMsg = message || `agent: merge results from ${jobId}`;
|
||||
|
||||
// Guard: branch must exist
|
||||
try {
|
||||
git(['rev-parse', '--verify', branchName], root);
|
||||
} catch {
|
||||
return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'branch_not_found' };
|
||||
}
|
||||
|
||||
// Attempt merge
|
||||
try {
|
||||
git(['merge', branchName, '--no-ff', '-m', commitMsg], root, { timeout: 60_000 });
|
||||
} catch (mergeErr) {
|
||||
// Check if it's a conflict
|
||||
try {
|
||||
const conflictRaw = git(['diff', '--name-only', '--diff-filter=U'], root);
|
||||
const conflicts = conflictRaw.split('\n').filter(Boolean);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
// Abort the merge
|
||||
try { git(['merge', '--abort'], root); } catch { /* may already be clean */ }
|
||||
return { success: false, filesChanged: 0, commitSha: null, conflicts };
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Not a conflict — some other error
|
||||
try { git(['merge', '--abort'], root); } catch { /* best effort */ }
|
||||
throw mergeErr;
|
||||
}
|
||||
|
||||
// Success — get stats
|
||||
const commitSha = git(['rev-parse', '--short', 'HEAD'], root);
|
||||
let filesChanged = 0;
|
||||
try {
|
||||
const stat = git(['diff', '--stat', 'HEAD~1..HEAD', '--numstat'], root);
|
||||
filesChanged = stat.split('\n').filter(Boolean).length;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
return { success: true, filesChanged, commitSha, conflicts: [] };
|
||||
} finally {
|
||||
mergeInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale worktrees — those whose agent process is dead.
|
||||
* Call on server startup.
|
||||
*
|
||||
* @param {string} root — project root
|
||||
* @param {function} isJobAlive — callback (jobId) => boolean, checks if the agent process is still running
|
||||
* @returns {{ cleaned: number, errors: string[] }}
|
||||
*/
|
||||
export function cleanupStaleWorktrees(root, isJobAlive) {
|
||||
const worktreesBase = resolve(root, WORKTREES_DIR);
|
||||
if (!existsSync(worktreesBase)) return { cleaned: 0, errors: [] };
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(worktreesBase);
|
||||
} catch {
|
||||
return { cleaned: 0, errors: [] };
|
||||
}
|
||||
|
||||
let cleaned = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = resolve(worktreesBase, entry);
|
||||
try {
|
||||
const stat = lstatSync(entryPath);
|
||||
if (!stat.isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the agent for this worktree is still alive
|
||||
const alive = typeof isJobAlive === 'function' ? isJobAlive(entry) : false;
|
||||
if (alive) continue;
|
||||
|
||||
// Dead worktree — clean up
|
||||
try {
|
||||
destroyWorktree(root, entry);
|
||||
cleaned += 1;
|
||||
} catch (err) {
|
||||
errors.push(`${entry}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { cleaned, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage estimate for a worktree directory (in bytes).
|
||||
* Best-effort — returns 0 on failure.
|
||||
*/
|
||||
export function getWorktreeDiskUsage(root, jobId) {
|
||||
const worktreePath = resolve(root, WORKTREES_DIR, jobId);
|
||||
if (!existsSync(worktreePath)) return 0;
|
||||
|
||||
try {
|
||||
// Use git to count the worktree overhead (not the full repo — worktrees share objects)
|
||||
const countOutput = git(['count-objects', '-vH'], worktreePath);
|
||||
// Parse "size-pack: 42.00 MiB" or similar
|
||||
const match = countOutput.match(/size-pack:\s*([\d.]+)\s*(\w+)/);
|
||||
if (match) {
|
||||
const size = parseFloat(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
if (unit.startsWith('kib') || unit.startsWith('k')) return Math.round(size * 1024);
|
||||
if (unit.startsWith('mib') || unit.startsWith('m')) return Math.round(size * 1024 * 1024);
|
||||
if (unit.startsWith('gib') || unit.startsWith('g')) return Math.round(size * 1024 * 1024 * 1024);
|
||||
return Math.round(size);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "owncord-project-map",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Project map generator for OwnCord — completeness, testing, priorities",
|
||||
"scripts": {
|
||||
"map": "node index.mjs",
|
||||
"map:quick": "node index.mjs --quick",
|
||||
"map:research": "node index.mjs --research",
|
||||
"test": "node --test --test-concurrency=1 tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Project Map Web Dashboard
|
||||
* Run: node tools/project-map/server.mjs
|
||||
* Open: http://localhost:3333
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync as _writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { scanModules } from './lib/scanner.mjs';
|
||||
import { collectGoCoverage } from './lib/go-coverage.mjs';
|
||||
import { collectVitestCoverage } from './lib/vitest-coverage.mjs';
|
||||
import { parseBacklog } from './lib/backlog-parser.mjs';
|
||||
import { scorePriorities } from './lib/priority-engine.mjs';
|
||||
import { scanGitHistory } from './lib/git-scanner.mjs';
|
||||
import { parseSessionHistory } from './lib/session-parser.mjs';
|
||||
import { scanTechnicalDebt } from './lib/debt-scanner.mjs';
|
||||
import { buildImportGraph } from './lib/import-graph.mjs';
|
||||
import { generateSuggestions, markWorkedOn, setStrategy } from './lib/suggestion-engine.mjs';
|
||||
import { createFileWatcher, createSSEManager } from './lib/file-watcher.mjs';
|
||||
import { generatePlan, startSession, getSessionStatus, pollChanges, endSession, recoverStaleSession } from './lib/session-manager.mjs';
|
||||
import { healthCheck, getJobs, createJob, cancelJob, getJobResult, processQueue, recoverOrphans, pruneResults, getLiveOutput, getJobDiff, parseActivityHints, getActiveCount, getMaxConcurrent, setMaxConcurrent, getActiveAgentIds } from './lib/agent-manager.mjs';
|
||||
import { mergeWorktree, destroyWorktree, listWorktrees, isMergeInProgress, getWorktreeDiskUsage } from './lib/worktree-manager.mjs';
|
||||
import { generateBriefing } from './lib/morning-briefing.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const CACHE_DIR = resolve(__dirname, '.cache');
|
||||
let PORT = parseInt(process.env.PORT || '3333', 10);
|
||||
if (isNaN(PORT) || PORT < 1 || PORT > 65535) { console.error(`Invalid PORT "${process.env.PORT}", using 3333`); PORT = 3333; }
|
||||
|
||||
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
// Auth token — generated per process, required for privileged endpoints
|
||||
const AUTH_TOKEN = process.env.PROJECT_MAP_TOKEN || randomBytes(24).toString('hex');
|
||||
|
||||
// In-memory cache (mode-aware)
|
||||
let cachedData = null;
|
||||
let cachedDataMode = null; // 'quick' or 'full'
|
||||
let collectInflight = null;
|
||||
|
||||
// Valid job types
|
||||
const VALID_JOB_TYPES = new Set(['research', 'write-tests', 'code-review', 'security-audit', 'fix-debt', 'custom']);
|
||||
const JOB_ID_RE = /^job-\d+(-[a-f0-9]+)?$/;
|
||||
|
||||
async function collectData(quick = true) {
|
||||
console.log(` Collecting data (${quick ? 'quick' : 'full'} mode)...`);
|
||||
|
||||
const modules = await scanModules(ROOT);
|
||||
const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick);
|
||||
const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick);
|
||||
const backlog = await parseBacklog(ROOT);
|
||||
const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog);
|
||||
|
||||
let gitData = null, sessionData = null, debtData = null, importGraph = null;
|
||||
try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Git] Error:', e.message); }
|
||||
try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Session] Error:', e.message); }
|
||||
try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Debt] Error:', e.message); }
|
||||
try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Graph] Error:', e.message); }
|
||||
|
||||
let suggestions = null;
|
||||
try {
|
||||
suggestions = await generateSuggestions(CACHE_DIR, {
|
||||
priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData,
|
||||
});
|
||||
} catch (e) { console.error(' [Suggestions] Error:', e.message); }
|
||||
|
||||
// Agent jobs
|
||||
let agentJobs = null;
|
||||
try { agentJobs = getJobs(CACHE_DIR); } catch (e) { console.error(' [Jobs] Error:', e.message); }
|
||||
|
||||
// Morning briefing
|
||||
let briefing = null;
|
||||
try { briefing = await generateBriefing(ROOT, CACHE_DIR, { sessionData, suggestions, backlog, agentJobs }); } catch (e) { console.error(' [Briefing] Error:', e.message); }
|
||||
|
||||
return {
|
||||
modules, goCoverage, vitestCoverage, backlog, priorities,
|
||||
gitData, sessionData, debtData, importGraph, suggestions,
|
||||
agentJobs, briefing,
|
||||
agentHealth: null, // populated on demand
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// SSE manager for live updates
|
||||
const sse = createSSEManager();
|
||||
|
||||
// File watcher for auto-refresh
|
||||
const watcher = createFileWatcher(ROOT, (change) => {
|
||||
console.log(` [Watch] Change detected: ${change.filename}`);
|
||||
cachedData = null;
|
||||
sse.broadcast({ type: 'file-change', ...change });
|
||||
});
|
||||
|
||||
// Valid strategy values
|
||||
const VALID_STRATEGIES = new Set(['balanced', 'bugs-first', 'coverage-first', 'momentum-first', 'debt-first']);
|
||||
|
||||
// Parse JSON body from POST requests (with size limit and error handling)
|
||||
async function parseBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
let destroyed = false;
|
||||
req.on('data', chunk => {
|
||||
if (destroyed) return;
|
||||
body += chunk;
|
||||
if (body.length > 4096) { destroyed = true; req.destroy(); resolve(null); }
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (destroyed) return;
|
||||
try { resolve(JSON.parse(body)); } catch { resolve(null); }
|
||||
});
|
||||
req.on('error', () => { if (!destroyed) resolve(null); });
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, data) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Auto-process agent queue on an interval
|
||||
let processInterval = null;
|
||||
|
||||
// Diff poller — polls git diff every 2s while a job is running
|
||||
let diffPollerInterval = null;
|
||||
|
||||
function startDiffPoller() {
|
||||
if (diffPollerInterval) return;
|
||||
console.log(' [Diff] Starting diff poller (2s interval)');
|
||||
diffPollerInterval = setInterval(() => {
|
||||
try {
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
const running = jobs.filter(j => j.status === 'running' && j.baselineSha);
|
||||
if (running.length === 0) {
|
||||
stopDiffPoller();
|
||||
return;
|
||||
}
|
||||
for (const job of running) {
|
||||
try {
|
||||
const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []);
|
||||
sse.broadcast({
|
||||
type: 'job-diff',
|
||||
jobId: job.id,
|
||||
files: diffData.files,
|
||||
diffs: diffData.diffs,
|
||||
freshness: diffData.freshness,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` [Diff] Error polling job ${job.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopDiffPoller() {
|
||||
if (!diffPollerInterval) return;
|
||||
console.log(' [Diff] Stopping diff poller');
|
||||
clearInterval(diffPollerInterval);
|
||||
diffPollerInterval = null;
|
||||
}
|
||||
|
||||
// Check bearer token for privileged endpoints
|
||||
function isAuthorized(req) {
|
||||
const auth = req.headers['authorization'] || '';
|
||||
return auth === `Bearer ${AUTH_TOKEN}`;
|
||||
}
|
||||
|
||||
function requireAuth(req, res) {
|
||||
if (isAuthorized(req)) return true;
|
||||
json(res, 401, { error: 'Unauthorized — pass Authorization: Bearer <token>' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const BIND_HOST = process.env.PROJECT_MAP_HOST || '127.0.0.1';
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', `http://localhost:${PORT}`);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
try {
|
||||
// === EXISTING ROUTES ===
|
||||
|
||||
if (url.pathname === '/api/data') {
|
||||
const quick = url.searchParams.get('full') !== '1';
|
||||
const requestedMode = quick ? 'quick' : 'full';
|
||||
// Don't reuse quick cache for full requests; always refresh if mode changed or forced
|
||||
const cacheValid = cachedData && cachedDataMode === requestedMode && !url.searchParams.has('refresh');
|
||||
// Also allow full cache to serve quick requests (full is a superset)
|
||||
const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !url.searchParams.has('refresh');
|
||||
if (!cacheValid && !fullCacheForQuick) {
|
||||
if (!collectInflight) {
|
||||
collectInflight = collectData(quick).then(data => {
|
||||
cachedData = data;
|
||||
cachedDataMode = requestedMode;
|
||||
collectInflight = null;
|
||||
return data;
|
||||
}).catch(err => {
|
||||
collectInflight = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
await collectInflight;
|
||||
}
|
||||
// Strip agentJobs and job-derived briefing fields from unauthenticated responses
|
||||
if (!isAuthorized(req)) {
|
||||
const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData;
|
||||
if (fullBriefing) {
|
||||
const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing;
|
||||
publicData.briefing = publicBriefing;
|
||||
}
|
||||
json(res, 200, publicData);
|
||||
} else {
|
||||
json(res, 200, cachedData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/events') {
|
||||
sse.handleConnection(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/worked-on' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const body = await parseBody(req);
|
||||
if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; }
|
||||
if (typeof body.module !== 'string' || !body.module || body.module.length > 100 || !/^[\w:/-]+$/.test(body.module)) {
|
||||
json(res, 400, { error: 'Invalid module name' }); return;
|
||||
}
|
||||
markWorkedOn(CACHE_DIR, body.module);
|
||||
cachedData = null; cachedDataMode = null;
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/strategy' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const body = await parseBody(req);
|
||||
if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; }
|
||||
if (!VALID_STRATEGIES.has(body.strategy)) {
|
||||
json(res, 400, { error: `Invalid strategy. Valid: ${[...VALID_STRATEGIES].join(', ')}` }); return;
|
||||
}
|
||||
setStrategy(CACHE_DIR, body.strategy);
|
||||
cachedData = null; cachedDataMode = null;
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// === SESSION ROUTES ===
|
||||
|
||||
if (url.pathname === '/api/session/plan' && req.method === 'GET') {
|
||||
// Unauthenticated — returns non-sensitive planning suggestions
|
||||
const plan = await generatePlan(ROOT, CACHE_DIR);
|
||||
json(res, 200, plan);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/session/start' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const state = startSession(ROOT, CACHE_DIR);
|
||||
sse.broadcast({ type: 'session-started' });
|
||||
json(res, 200, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/session/status' && req.method === 'GET') {
|
||||
const status = getSessionStatus(CACHE_DIR);
|
||||
// If active, poll for latest changes
|
||||
if (status.active) {
|
||||
try { pollChanges(ROOT, CACHE_DIR); } catch { /* best effort */ }
|
||||
const updated = getSessionStatus(CACHE_DIR);
|
||||
json(res, 200, updated);
|
||||
} else {
|
||||
json(res, 200, status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/session/end' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const result = endSession(ROOT, CACHE_DIR);
|
||||
cachedData = null; // Vault changed
|
||||
sse.broadcast({ type: 'session-ended', ...result });
|
||||
json(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// === AGENT JOB ROUTES ===
|
||||
|
||||
if (url.pathname === '/api/agent/health' && req.method === 'GET') {
|
||||
const health = healthCheck();
|
||||
json(res, 200, health);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/jobs' && req.method === 'GET') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobs = getJobs(CACHE_DIR);
|
||||
json(res, 200, jobs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/jobs' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const body = await parseBody(req);
|
||||
if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; }
|
||||
if (!VALID_JOB_TYPES.has(body.type)) {
|
||||
json(res, 400, { error: `Invalid job type. Valid: ${[...VALID_JOB_TYPES].join(', ')}` }); return;
|
||||
}
|
||||
if (!body.target || typeof body.target !== 'string' || !/^[\w:/-]+$/.test(body.target) || body.target.length > 100) {
|
||||
json(res, 400, { error: 'Invalid target: must be alphanumeric with :/-_ only, max 100 chars' }); return;
|
||||
}
|
||||
if (body.type === 'custom') {
|
||||
if (typeof body.customPrompt !== 'string' || body.customPrompt.trim().length === 0) {
|
||||
json(res, 400, { error: 'customPrompt required for custom jobs' }); return;
|
||||
}
|
||||
if (body.customPrompt.length > 8000) {
|
||||
json(res, 400, { error: 'customPrompt exceeds 8000 char limit' }); return;
|
||||
}
|
||||
}
|
||||
// Health check before allowing job creation
|
||||
const health = healthCheck();
|
||||
if (!health.available) {
|
||||
json(res, 503, { error: 'Claude CLI not available', details: health.error }); return;
|
||||
}
|
||||
const job = createJob(CACHE_DIR, {
|
||||
type: body.type,
|
||||
target: body.target,
|
||||
priority: body.priority,
|
||||
customPrompt: body.customPrompt,
|
||||
});
|
||||
json(res, 201, job);
|
||||
return;
|
||||
}
|
||||
|
||||
// DELETE /api/jobs/:id
|
||||
const deleteMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
||||
if (deleteMatch && req.method === 'DELETE') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobId = deleteMatch[1];
|
||||
if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; }
|
||||
try {
|
||||
cancelJob(CACHE_DIR, jobId);
|
||||
json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, 404, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/jobs/:id/result
|
||||
const resultMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/result$/);
|
||||
if (resultMatch && req.method === 'GET') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobId = resultMatch[1];
|
||||
if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; }
|
||||
const result = getJobResult(CACHE_DIR, jobId);
|
||||
json(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/jobs/:id/output — live output for running jobs
|
||||
const outputMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/output$/);
|
||||
if (outputMatch && req.method === 'GET') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobId = outputMatch[1];
|
||||
if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; }
|
||||
const output = getLiveOutput(jobId);
|
||||
json(res, 200, { output });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/jobs/:id/diff — current git diff for a job
|
||||
const diffMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/diff$/);
|
||||
if (diffMatch && req.method === 'GET') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobId = diffMatch[1];
|
||||
if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; }
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
const job = jobs.find(j => j.id === jobId);
|
||||
if (!job) { json(res, 404, { error: 'Job not found' }); return; }
|
||||
if (!job.baselineSha) { json(res, 200, { files: [], diffs: {}, freshness: new Date().toISOString() }); return; }
|
||||
try {
|
||||
const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []);
|
||||
json(res, 200, diffData);
|
||||
} catch (err) {
|
||||
json(res, 500, { error: 'Failed to compute diff: ' + err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/jobs/process' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
startDiffPoller();
|
||||
const result = await processQueue(ROOT, CACHE_DIR,
|
||||
(jobId, chunk) => {
|
||||
sse.broadcast({ type: 'job-output', jobId, chunk });
|
||||
const hints = parseActivityHints(chunk);
|
||||
for (const hint of hints) {
|
||||
sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file });
|
||||
}
|
||||
},
|
||||
(jobId, message) => {
|
||||
sse.broadcast({ type: 'job-warning', jobId, message });
|
||||
},
|
||||
);
|
||||
if (result.launched > 0) {
|
||||
sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() });
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
if (!jobs.some(j => j.status === 'running')) stopDiffPoller();
|
||||
}
|
||||
json(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/jobs/:id/merge — merge worktree back to current branch
|
||||
const mergeMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/merge$/);
|
||||
if (mergeMatch && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const jobId = mergeMatch[1];
|
||||
if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; }
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
const job = jobs.find(j => j.id === jobId);
|
||||
if (!job) { json(res, 404, { error: 'Job not found' }); return; }
|
||||
if (job.status !== 'review') { json(res, 400, { error: `Cannot merge job with status "${job.status}" — must be in review` }); return; }
|
||||
|
||||
// Broadcast merge lock
|
||||
sse.broadcast({ type: 'merge-lock', locked: true, jobId });
|
||||
const body = await parseBody(req);
|
||||
const message = body?.message || undefined;
|
||||
|
||||
const result = mergeWorktree(ROOT, jobId, message);
|
||||
|
||||
if (result.error === 'merge_in_progress') {
|
||||
sse.broadcast({ type: 'merge-lock', locked: false, jobId });
|
||||
json(res, 423, { error: 'Another merge is in progress' });
|
||||
return;
|
||||
}
|
||||
if (result.error === 'working_tree_dirty') {
|
||||
sse.broadcast({ type: 'merge-lock', locked: false, jobId });
|
||||
json(res, 400, { error: 'Working tree has uncommitted changes — commit or stash first' });
|
||||
return;
|
||||
}
|
||||
if (result.error === 'branch_not_found') {
|
||||
sse.broadcast({ type: 'merge-lock', locked: false, jobId });
|
||||
json(res, 410, { error: 'Branch no longer exists — worktree was already cleaned up. Use Dismiss to clear this job.' });
|
||||
return;
|
||||
}
|
||||
if (!result.success && result.conflicts.length > 0) {
|
||||
sse.broadcast({ type: 'merge-lock', locked: false, jobId });
|
||||
json(res, 409, { error: 'merge_conflict', conflicts: result.conflicts });
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — destroy worktree and update job status
|
||||
try { destroyWorktree(ROOT, jobId); } catch { /* best effort */ }
|
||||
// Update job status to merged
|
||||
const s = getJobs(CACHE_DIR); // refresh
|
||||
// Direct queue file update for status change
|
||||
const allJobs = s.jobs.map(j =>
|
||||
j.id === jobId ? { ...j, status: 'merged', worktreePath: null, branchName: null } : j,
|
||||
);
|
||||
const { writeFileSync: wfs } = await import('node:fs');
|
||||
wfs(resolve(CACHE_DIR, 'agent-queue.json'), JSON.stringify(allJobs, null, 2));
|
||||
|
||||
cachedData = null;
|
||||
sse.broadcast({ type: 'merge-lock', locked: false, jobId });
|
||||
sse.broadcast({ type: 'job-update', jobId, status: 'merged' });
|
||||
json(res, 200, { success: true, filesChanged: result.filesChanged, commitSha: result.commitSha });
|
||||
return;
|
||||
}
|
||||
|
||||
// === FLEET ROUTES ===
|
||||
|
||||
if (url.pathname === '/api/fleet/status' && req.method === 'GET') {
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
const active = jobs.filter(j => j.status === 'running' || j.status === 'provisioning').length;
|
||||
const queued = jobs.filter(j => j.status === 'queued').length;
|
||||
const review = jobs.filter(j => j.status === 'review').length;
|
||||
const worktrees = listWorktrees(ROOT).map(w => ({
|
||||
jobId: w.jobId,
|
||||
path: w.path,
|
||||
branch: w.branch,
|
||||
diskMB: Math.round(getWorktreeDiskUsage(ROOT, w.jobId) / (1024 * 1024)),
|
||||
}));
|
||||
json(res, 200, { active, queued, review, maxConcurrent: getMaxConcurrent(), worktrees });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/fleet/config' && req.method === 'POST') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const body = await parseBody(req);
|
||||
if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; }
|
||||
if (typeof body.maxConcurrent === 'number') {
|
||||
if (body.maxConcurrent < 1 || body.maxConcurrent > 8) {
|
||||
json(res, 400, { error: 'maxConcurrent must be between 1 and 8' }); return;
|
||||
}
|
||||
setMaxConcurrent(body.maxConcurrent);
|
||||
}
|
||||
// Save to user prefs for persistence
|
||||
const prefsPath = resolve(CACHE_DIR, 'user-prefs.json');
|
||||
let prefs = {};
|
||||
try { prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); } catch { /* new file */ }
|
||||
if (body.maxConcurrent) prefs.maxConcurrent = body.maxConcurrent;
|
||||
if (body.autopilot) prefs.autopilot = { ...prefs.autopilot, ...body.autopilot };
|
||||
_writeFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/worktrees' && req.method === 'GET') {
|
||||
if (!requireAuth(req, res)) return;
|
||||
const worktrees = listWorktrees(ROOT);
|
||||
json(res, 200, { worktrees });
|
||||
return;
|
||||
}
|
||||
|
||||
// === BRIEFING ROUTE ===
|
||||
|
||||
if (url.pathname === '/api/briefing' && req.method === 'GET') {
|
||||
// Use cached data if available, otherwise collect fresh
|
||||
if (!cachedData) cachedData = await collectData(true);
|
||||
const authenticated = isAuthorized(req);
|
||||
const briefing = await generateBriefing(ROOT, CACHE_DIR, {
|
||||
sessionData: cachedData.sessionData,
|
||||
suggestions: cachedData.suggestions,
|
||||
backlog: cachedData.backlog,
|
||||
// Only pass agent jobs for authenticated callers
|
||||
agentJobs: authenticated ? getJobs(CACHE_DIR) : null,
|
||||
});
|
||||
if (!authenticated) {
|
||||
// Strip all agent-job-derived fields from unauthenticated responses
|
||||
const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = briefing;
|
||||
json(res, 200, publicBriefing);
|
||||
} else {
|
||||
json(res, 200, briefing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === DASHBOARD HTML ===
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||
const html = readFileSync(resolve(__dirname, 'dashboard.html'), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
} catch (err) {
|
||||
console.error(` [Server] ${req.method} ${url.pathname} error:`, err);
|
||||
json(res, 500, { error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Startup recovery
|
||||
async function startup() {
|
||||
console.log('\n Project Map Dashboard');
|
||||
console.log(` http://${BIND_HOST}:${PORT}?token=${AUTH_TOKEN}`);
|
||||
// Also write token to a file for programmatic access
|
||||
try {
|
||||
_writeFileSync(resolve(CACHE_DIR, 'token.txt'), AUTH_TOKEN, { mode: 0o600 });
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// Recover stale sessions
|
||||
try {
|
||||
const recovery = recoverStaleSession(ROOT, CACHE_DIR);
|
||||
if (recovery.recovered) console.log(' [Session] Recovered stale session');
|
||||
} catch (e) { console.error(' [Session] Recovery error:', e.message); }
|
||||
|
||||
// Recover orphaned agent jobs (fleet-aware — also cleans stale worktrees)
|
||||
try {
|
||||
const orphans = recoverOrphans(CACHE_DIR, ROOT);
|
||||
if (orphans.recovered > 0) console.log(` [Fleet] Recovered ${orphans.recovered} orphaned jobs`);
|
||||
} catch (e) { console.error(' [Fleet] Orphan recovery error:', e.message); }
|
||||
|
||||
// Load saved fleet config
|
||||
try {
|
||||
const prefsPath = resolve(CACHE_DIR, 'user-prefs.json');
|
||||
if (existsSync(prefsPath)) {
|
||||
const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
|
||||
if (prefs.maxConcurrent) setMaxConcurrent(prefs.maxConcurrent);
|
||||
}
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
// Prune old results
|
||||
try {
|
||||
const pruned = pruneResults(CACHE_DIR);
|
||||
if (pruned.pruned > 0) console.log(` [Agent] Pruned ${pruned.pruned} old results`);
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
// Agent health check
|
||||
try {
|
||||
const health = healthCheck();
|
||||
if (health.available) {
|
||||
console.log(` [Agent] Claude CLI available (${health.version})`);
|
||||
} else {
|
||||
console.log(' [Agent] Claude CLI not available — agent jobs disabled');
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
|
||||
// Auto-process agent queue every 30 seconds (fleet-aware)
|
||||
processInterval = setInterval(async () => {
|
||||
try {
|
||||
const { jobs } = getJobs(CACHE_DIR);
|
||||
const hasQueued = jobs.some(j => j.status === 'queued');
|
||||
const activeCount = getActiveCount();
|
||||
if (hasQueued && activeCount < getMaxConcurrent()) {
|
||||
console.log(` [Fleet] Auto-processing queue (${activeCount}/${getMaxConcurrent()} active)...`);
|
||||
startDiffPoller();
|
||||
const result = await processQueue(ROOT, CACHE_DIR,
|
||||
(jobId, chunk) => {
|
||||
sse.broadcast({ type: 'job-output', jobId, chunk });
|
||||
const hints = parseActivityHints(chunk);
|
||||
for (const hint of hints) {
|
||||
sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file });
|
||||
}
|
||||
},
|
||||
(jobId, message) => {
|
||||
sse.broadcast({ type: 'job-warning', jobId, message });
|
||||
},
|
||||
);
|
||||
if (result.launched > 0) {
|
||||
sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() });
|
||||
}
|
||||
const { jobs: currentJobs } = getJobs(CACHE_DIR);
|
||||
if (!currentJobs.some(j => j.status === 'running')) stopDiffPoller();
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, 30000);
|
||||
|
||||
console.log(' File watcher active — dashboard auto-refreshes on changes');
|
||||
console.log(` Fleet: max ${getMaxConcurrent()} concurrent agents, auto-processes every 30s\n`);
|
||||
}
|
||||
|
||||
server.listen(PORT, BIND_HOST, startup);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (processInterval) clearInterval(processInterval);
|
||||
stopDiffPoller();
|
||||
watcher.close();
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Exports for testing — token only available in test environment
|
||||
export function getAuthToken() {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('Token access restricted to test environment');
|
||||
return AUTH_TOKEN;
|
||||
}
|
||||
export { isAuthorized, server, BIND_HOST };
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { parseActivityHints, getJobDiff } from '../lib/agent-manager.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const GIT_TEST_DIR = resolve(__dirname, '.test-git-diff');
|
||||
|
||||
describe('parseActivityHints', () => {
|
||||
it('extracts Go file paths', () => {
|
||||
const chunk = 'Reading Server/ws/handler.go for analysis';
|
||||
const hints = parseActivityHints(chunk);
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].file, 'Server/ws/handler.go');
|
||||
});
|
||||
|
||||
it('extracts TypeScript file paths', () => {
|
||||
const chunk = 'Checking Client/tauri-client/src/stores/voice.ts';
|
||||
const hints = parseActivityHints(chunk);
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].file, 'Client/tauri-client/src/stores/voice.ts');
|
||||
});
|
||||
|
||||
it('extracts Rust file paths', () => {
|
||||
const chunk = 'Found issue in src-tauri/src/commands.rs';
|
||||
const hints = parseActivityHints(chunk);
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].file, 'src-tauri/src/commands.rs');
|
||||
});
|
||||
|
||||
it('extracts multiple paths from one chunk', () => {
|
||||
const chunk = 'Comparing Server/ws/handler.go with Server/ws/conn.go';
|
||||
const hints = parseActivityHints(chunk);
|
||||
assert.equal(hints.length, 2);
|
||||
});
|
||||
|
||||
it('returns empty array when no paths found', () => {
|
||||
const hints = parseActivityHints('Just some regular text here');
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
it('handles null/undefined input', () => {
|
||||
assert.deepEqual(parseActivityHints(null), []);
|
||||
assert.deepEqual(parseActivityHints(undefined), []);
|
||||
assert.deepEqual(parseActivityHints(''), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJobDiff', () => {
|
||||
beforeEach(() => {
|
||||
rmSync(GIT_TEST_DIR, { recursive: true, force: true });
|
||||
mkdirSync(GIT_TEST_DIR, { recursive: true });
|
||||
execSync('git init', { cwd: GIT_TEST_DIR, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: GIT_TEST_DIR, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: GIT_TEST_DIR, stdio: 'pipe' });
|
||||
writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'original\n');
|
||||
execSync('git add . && git commit -m "init"', { cwd: GIT_TEST_DIR, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(GIT_TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns empty files when nothing changed', () => {
|
||||
const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim();
|
||||
const result = getJobDiff(GIT_TEST_DIR, headSha, []);
|
||||
assert.deepEqual(result.files, []);
|
||||
assert.deepEqual(result.diffs, {});
|
||||
});
|
||||
|
||||
it('detects modified files with +/- counts', () => {
|
||||
const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim();
|
||||
writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\nline2\n');
|
||||
const result = getJobDiff(GIT_TEST_DIR, headSha, []);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0].path, 'file.txt');
|
||||
assert.equal(result.files[0].status, 'M');
|
||||
assert.ok(result.files[0].additions >= 1);
|
||||
assert.ok(typeof result.diffs['file.txt'] === 'string');
|
||||
assert.ok(result.diffs['file.txt'].includes('modified'));
|
||||
});
|
||||
|
||||
it('detects new files', () => {
|
||||
const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim();
|
||||
writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'brand new\n');
|
||||
const result = getJobDiff(GIT_TEST_DIR, headSha, []);
|
||||
const newFile = result.files.find(f => f.path === 'newfile.txt');
|
||||
assert.ok(newFile);
|
||||
assert.equal(newFile.status, 'A');
|
||||
});
|
||||
|
||||
it('excludes pre-existing dirty files', () => {
|
||||
const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim();
|
||||
writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\n');
|
||||
writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'new\n');
|
||||
const result = getJobDiff(GIT_TEST_DIR, headSha, ['file.txt']);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0].path, 'newfile.txt');
|
||||
assert.ok(!result.diffs['file.txt']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for agent-manager.mjs — verifies that a spawn failure
|
||||
* does not wedge the queue (lock must be released).
|
||||
*
|
||||
* Uses setSpawnCommand() to inject a guaranteed-nonexistent binary,
|
||||
* making the test deterministic regardless of whether Claude CLI is installed.
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
createJob,
|
||||
processQueue,
|
||||
getJobs,
|
||||
isQueueLocked,
|
||||
resetQueueLock,
|
||||
setSpawnCommand,
|
||||
} from '../lib/agent-manager.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_CACHE = resolve(__dirname, '.test-cache-agent');
|
||||
const FAKE_ROOT = resolve(__dirname, '.test-root-agent');
|
||||
|
||||
// A binary name that will never exist on any platform
|
||||
const NONEXISTENT_CMD = '__owncord_test_no_such_binary_12345__';
|
||||
|
||||
describe('agent queue lock recovery', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_CACHE, { recursive: true });
|
||||
mkdirSync(FAKE_ROOT, { recursive: true });
|
||||
resetQueueLock();
|
||||
setSpawnCommand(NONEXISTENT_CMD);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetQueueLock();
|
||||
setSpawnCommand('claude');
|
||||
rmSync(TEST_CACHE, { recursive: true, force: true });
|
||||
rmSync(FAKE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('releases lock after spawn error (command not found)', async () => {
|
||||
createJob(TEST_CACHE, {
|
||||
type: 'research',
|
||||
target: 'api',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
assert.equal(isQueueLocked(), false, 'lock should be free before processing');
|
||||
|
||||
// processQueue spawns the nonexistent command which will ENOENT
|
||||
const result = await processQueue(FAKE_ROOT, TEST_CACHE);
|
||||
|
||||
assert.equal(result.processed, true, 'should have attempted processing');
|
||||
assert.equal(isQueueLocked(), false, 'lock MUST be released after spawn failure');
|
||||
});
|
||||
|
||||
it('allows subsequent jobs after a failed spawn', async () => {
|
||||
createJob(TEST_CACHE, { type: 'research', target: 'ws', priority: 1 });
|
||||
|
||||
// First job will fail due to nonexistent command
|
||||
await processQueue(FAKE_ROOT, TEST_CACHE);
|
||||
assert.equal(isQueueLocked(), false, 'lock should be free after first failure');
|
||||
|
||||
// Create and process another job — should not be blocked
|
||||
createJob(TEST_CACHE, { type: 'code-review', target: 'auth', priority: 1 });
|
||||
const result2 = await processQueue(FAKE_ROOT, TEST_CACHE);
|
||||
|
||||
// It should attempt processing (not be stuck on 'locked')
|
||||
assert.notEqual(result2.reason, 'locked', 'queue should not be wedged');
|
||||
});
|
||||
|
||||
it('marks job as dead after max retries', async () => {
|
||||
createJob(TEST_CACHE, { type: 'research', target: 'db', priority: 1 });
|
||||
|
||||
// Exhaust retries (maxRetries defaults to 2)
|
||||
await processQueue(FAKE_ROOT, TEST_CACHE);
|
||||
await processQueue(FAKE_ROOT, TEST_CACHE);
|
||||
|
||||
const { jobs } = getJobs(TEST_CACHE);
|
||||
const job = jobs.find(j => j.target === 'db');
|
||||
assert.ok(job, 'job should still exist in queue');
|
||||
assert.equal(job.status, 'dead', 'job should be dead after max retries');
|
||||
assert.ok(job.error, 'job should have an error message');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tests for backlog-parser.mjs — verifies both task syntaxes are parsed
|
||||
* and open/done counts match.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '../../..');
|
||||
|
||||
// Direct regex test (same regex the parser uses)
|
||||
const TASK_RE = /^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/;
|
||||
|
||||
describe('backlog parser regex', () => {
|
||||
it('matches colon-inside-bold format: **T-165:**', () => {
|
||||
const line = '- [x] **T-165:** Fix BUG-046 — wrap voice switchActiveDevice — 2026-03-28';
|
||||
const m = line.match(TASK_RE);
|
||||
assert.ok(m, 'should match');
|
||||
assert.equal(m[1], 'x');
|
||||
assert.equal(m[2], '165');
|
||||
assert.ok(m[3].startsWith('Fix BUG-046'));
|
||||
});
|
||||
|
||||
it('matches colon-after-bold format: **T-033**:', () => {
|
||||
const line = '- [x] **T-033**: Fix voice state broadcast silent DB failures — 2026-03-21';
|
||||
const m = line.match(TASK_RE);
|
||||
assert.ok(m, 'should match');
|
||||
assert.equal(m[1], 'x');
|
||||
assert.equal(m[2], '033');
|
||||
assert.ok(m[3].startsWith('Fix voice state'));
|
||||
});
|
||||
|
||||
it('matches open tasks (unchecked)', () => {
|
||||
const line = '- [ ] **T-195:** User profile/password/session management endpoints';
|
||||
const m = line.match(TASK_RE);
|
||||
assert.ok(m, 'should match');
|
||||
assert.equal(m[1], ' ');
|
||||
assert.equal(m[2], '195');
|
||||
});
|
||||
|
||||
it('does not match non-task lines', () => {
|
||||
assert.equal('## Some Section'.match(TASK_RE), null);
|
||||
assert.equal('- Regular bullet point'.match(TASK_RE), null);
|
||||
assert.equal('- [ ] No bold task id here'.match(TASK_RE), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlog parser integration', () => {
|
||||
it('parses actual Backlog.md and counts match', async () => {
|
||||
const { parseBacklog } = await import('../lib/backlog-parser.mjs');
|
||||
const result = await parseBacklog(ROOT);
|
||||
|
||||
// Read the file directly and count with both regexes
|
||||
const backlogPath = resolve(ROOT, 'docs/brain/02-Tasks/Backlog.md');
|
||||
if (!existsSync(backlogPath)) {
|
||||
// Skip if file doesn't exist in CI
|
||||
return;
|
||||
}
|
||||
const content = readFileSync(backlogPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let expectedOpen = 0;
|
||||
let expectedDone = 0;
|
||||
for (const line of lines) {
|
||||
const m = line.match(TASK_RE);
|
||||
if (m) {
|
||||
if (m[1] === 'x') expectedDone++;
|
||||
else expectedOpen++;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(result.openCount, expectedOpen, `open count mismatch: got ${result.openCount}, expected ${expectedOpen}`);
|
||||
assert.equal(result.doneCount, expectedDone, `done count mismatch: got ${result.doneCount}, expected ${expectedDone}`);
|
||||
assert.ok(result.tasks.length > 0, 'should find tasks');
|
||||
assert.equal(result.tasks.length, expectedOpen + expectedDone, 'total tasks should match');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Behavioral tests for:
|
||||
* 1. Root-level Server/*.go files normalize to 'server-root'
|
||||
* 2. Unauthenticated /api/briefing does not leak agent job data
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Root package normalization — behavioral tests calling the real functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('root package normalization', () => {
|
||||
let resolveModule, fileToModule;
|
||||
|
||||
before(async () => {
|
||||
({ resolveModule } = await import('../lib/git-scanner.mjs'));
|
||||
({ fileToModule } = await import('../lib/session-manager.mjs'));
|
||||
});
|
||||
|
||||
const rootFiles = ['Server/main.go', 'Server/go.mod', 'Server/go.sum', 'Server/Makefile'];
|
||||
const subDirFiles = [
|
||||
{ path: 'Server/ws/handler.go', expected: 'ws' },
|
||||
{ path: 'Server/db/models.go', expected: 'db' },
|
||||
{ path: 'Server/auth/middleware.go', expected: 'auth' },
|
||||
];
|
||||
|
||||
for (const filePath of rootFiles) {
|
||||
it(`git-scanner: ${filePath} → server-root`, () => {
|
||||
assert.equal(resolveModule(filePath), 'server-root');
|
||||
});
|
||||
|
||||
it(`session-manager: ${filePath} → server-root`, () => {
|
||||
assert.equal(fileToModule(filePath), 'server-root');
|
||||
});
|
||||
}
|
||||
|
||||
for (const { path, expected } of subDirFiles) {
|
||||
it(`git-scanner: ${path} → ${expected}`, () => {
|
||||
assert.equal(resolveModule(path), expected);
|
||||
});
|
||||
|
||||
it(`session-manager: ${path} → ${expected}`, () => {
|
||||
assert.equal(fileToModule(path), expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('git-scanner and session-manager agree on all test paths', () => {
|
||||
const all = [...rootFiles, ...subDirFiles.map(s => s.path)];
|
||||
for (const p of all) {
|
||||
assert.equal(resolveModule(p), fileToModule(p),
|
||||
`mismatch on ${p}: git-scanner=${resolveModule(p)}, session-manager=${fileToModule(p)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Briefing leak — real HTTP test against the server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('/api/briefing unauthenticated leak prevention', () => {
|
||||
let generateBriefing;
|
||||
|
||||
before(async () => {
|
||||
({ generateBriefing } = await import('../lib/morning-briefing.mjs'));
|
||||
});
|
||||
|
||||
it('generateBriefing with agentJobs=null produces no job data', async () => {
|
||||
const briefing = await generateBriefing('.', '.', {
|
||||
sessionData: null,
|
||||
suggestions: null,
|
||||
backlog: null,
|
||||
agentJobs: null,
|
||||
});
|
||||
assert.deepEqual(briefing.agentResults, [], 'agentResults should be empty');
|
||||
assert.deepEqual(briefing.deadJobs, [], 'deadJobs should be empty');
|
||||
assert.deepEqual(briefing.autoQueueSuggestions, [], 'autoQueueSuggestions should be empty when agentJobs is null');
|
||||
assert.equal(briefing.stats.completedJobs, 0);
|
||||
assert.equal(briefing.stats.deadJobs, 0);
|
||||
});
|
||||
|
||||
it('generateBriefing with agentJobs includes job data', async () => {
|
||||
const fakeJobs = {
|
||||
jobs: [
|
||||
{ id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() },
|
||||
{ id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 },
|
||||
],
|
||||
};
|
||||
const briefing = await generateBriefing('.', '.', {
|
||||
sessionData: null,
|
||||
suggestions: null,
|
||||
backlog: null,
|
||||
agentJobs: fakeJobs,
|
||||
});
|
||||
assert.ok(briefing.agentResults.length > 0, 'should have agent results');
|
||||
assert.ok(briefing.deadJobs.length > 0, 'should have dead jobs');
|
||||
assert.ok(briefing.agentResults[0].id === 'j-1', 'should contain job id');
|
||||
});
|
||||
|
||||
it('server strips job fields from unauthenticated briefing response', async () => {
|
||||
// Read server source to verify the stripping logic
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8');
|
||||
|
||||
// The /api/briefing route must check auth and strip fields
|
||||
const briefingRoute = source.slice(
|
||||
source.indexOf("url.pathname === '/api/briefing'"),
|
||||
source.indexOf("url.pathname === '/api/briefing'") + 800
|
||||
);
|
||||
assert.ok(briefingRoute.includes('isAuthorized(req)'), 'briefing route should check auth');
|
||||
assert.ok(briefingRoute.includes('agentJobs: authenticated'), 'should only pass agentJobs when authenticated');
|
||||
assert.ok(briefingRoute.includes('agentResults'), 'should strip agentResults');
|
||||
assert.ok(briefingRoute.includes('deadJobs'), 'should strip deadJobs');
|
||||
assert.ok(briefingRoute.includes('autoQueueSuggestions'), 'should strip autoQueueSuggestions');
|
||||
assert.ok(briefingRoute.includes('stats'), 'should strip stats');
|
||||
});
|
||||
|
||||
it('HTTP: unauthenticated briefing has no job fields', async () => {
|
||||
// Spin up a minimal test server that mimics the briefing route logic
|
||||
const { generateBriefing } = await import('../lib/morning-briefing.mjs');
|
||||
|
||||
const fakeJobs = {
|
||||
jobs: [
|
||||
{ id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() },
|
||||
{ id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
const TOKEN = 'test-secret-token';
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`;
|
||||
const briefing = await generateBriefing('.', '.', {
|
||||
sessionData: null,
|
||||
suggestions: null,
|
||||
backlog: null,
|
||||
agentJobs: authenticated ? fakeJobs : null,
|
||||
});
|
||||
let payload;
|
||||
if (!authenticated) {
|
||||
const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...pub } = briefing;
|
||||
payload = pub;
|
||||
} else {
|
||||
payload = briefing;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
||||
const port = srv.address().port;
|
||||
|
||||
try {
|
||||
// Unauthenticated request
|
||||
const unauthRes = await fetch(`http://127.0.0.1:${port}/api/briefing`);
|
||||
const unauthData = await unauthRes.json();
|
||||
|
||||
assert.equal(unauthData.agentResults, undefined, 'agentResults must not be present');
|
||||
assert.equal(unauthData.deadJobs, undefined, 'deadJobs must not be present');
|
||||
assert.equal(unauthData.stats, undefined, 'stats must not be present');
|
||||
assert.equal(unauthData.autoQueueSuggestions, undefined, 'autoQueueSuggestions must not be present');
|
||||
assert.ok(unauthData.greeting, 'greeting should still be present');
|
||||
assert.ok(unauthData.suggestedTasks !== undefined, 'suggestedTasks should still be present');
|
||||
|
||||
// Authenticated request — should have all fields
|
||||
const authRes = await fetch(`http://127.0.0.1:${port}/api/briefing`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
const authData = await authRes.json();
|
||||
|
||||
assert.ok(Array.isArray(authData.agentResults), 'authenticated should have agentResults');
|
||||
assert.ok(Array.isArray(authData.deadJobs), 'authenticated should have deadJobs');
|
||||
assert.ok(authData.stats, 'authenticated should have stats');
|
||||
assert.ok(authData.agentResults.length > 0, 'authenticated should have job data');
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('HTTP: unauthenticated /api/data strips job fields from embedded briefing', async () => {
|
||||
const { generateBriefing } = await import('../lib/morning-briefing.mjs');
|
||||
|
||||
const fakeJobs = {
|
||||
jobs: [
|
||||
{ id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() },
|
||||
{ id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
const TOKEN = 'test-data-token';
|
||||
|
||||
// Simulate the /api/data route logic with an embedded briefing
|
||||
const srv = createServer(async (req, res) => {
|
||||
const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`;
|
||||
|
||||
// Build a cachedData-like object with briefing containing job data
|
||||
const briefing = await generateBriefing('.', '.', {
|
||||
sessionData: null,
|
||||
suggestions: null,
|
||||
backlog: null,
|
||||
agentJobs: fakeJobs,
|
||||
});
|
||||
const cachedData = { modules: [], agentJobs: fakeJobs, briefing, timestamp: new Date().toISOString() };
|
||||
|
||||
let payload;
|
||||
if (!authenticated) {
|
||||
const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData;
|
||||
if (fullBriefing) {
|
||||
const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing;
|
||||
publicData.briefing = publicBriefing;
|
||||
}
|
||||
payload = publicData;
|
||||
} else {
|
||||
payload = cachedData;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
||||
const port = srv.address().port;
|
||||
|
||||
try {
|
||||
// Unauthenticated — briefing should be sanitized
|
||||
const unauthRes = await fetch(`http://127.0.0.1:${port}/api/data`);
|
||||
const unauthData = await unauthRes.json();
|
||||
|
||||
assert.equal(unauthData.agentJobs, undefined, 'agentJobs must not be present');
|
||||
assert.ok(unauthData.briefing, 'briefing should still exist');
|
||||
assert.equal(unauthData.briefing.agentResults, undefined, 'briefing.agentResults must not be present');
|
||||
assert.equal(unauthData.briefing.deadJobs, undefined, 'briefing.deadJobs must not be present');
|
||||
assert.equal(unauthData.briefing.stats, undefined, 'briefing.stats must not be present');
|
||||
assert.equal(unauthData.briefing.autoQueueSuggestions, undefined, 'briefing.autoQueueSuggestions must not be present');
|
||||
assert.ok(unauthData.briefing.greeting, 'briefing.greeting should remain');
|
||||
|
||||
// Authenticated — full data
|
||||
const authRes = await fetch(`http://127.0.0.1:${port}/api/data`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
const authData = await authRes.json();
|
||||
|
||||
assert.ok(authData.agentJobs, 'authenticated should have agentJobs');
|
||||
assert.ok(authData.briefing.agentResults, 'authenticated briefing should have agentResults');
|
||||
assert.ok(authData.briefing.deadJobs, 'authenticated briefing should have deadJobs');
|
||||
assert.ok(authData.briefing.stats, 'authenticated briefing should have stats');
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tests for server cache behavior — verifies that quick-mode cached data
|
||||
* is not incorrectly reused for full-mode requests.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
describe('server cache mode awareness', () => {
|
||||
it('source code has mode-aware cache logic', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8');
|
||||
|
||||
// Should track cache mode separately
|
||||
assert.ok(source.includes('cachedDataMode'), 'should have cachedDataMode variable');
|
||||
|
||||
// The /api/data handler should compare requested mode with cached mode
|
||||
assert.ok(source.includes('requestedMode'), 'should compute requestedMode');
|
||||
|
||||
// Quick cache should not be valid for full requests
|
||||
assert.ok(
|
||||
source.includes('cachedDataMode === requestedMode'),
|
||||
'should compare cached mode with requested mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('cache logic prevents quick data from serving full requests', () => {
|
||||
// Simulate the cache validation logic extracted from server.mjs
|
||||
function isCacheValid(cachedData, cachedDataMode, requestedMode, hasRefresh) {
|
||||
const cacheValid = cachedData && cachedDataMode === requestedMode && !hasRefresh;
|
||||
const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !hasRefresh;
|
||||
return cacheValid || fullCacheForQuick;
|
||||
}
|
||||
|
||||
const data = { some: 'data' };
|
||||
|
||||
// Quick cache should serve quick requests
|
||||
assert.ok(isCacheValid(data, 'quick', 'quick', false), 'quick cache serves quick request');
|
||||
|
||||
// Quick cache should NOT serve full requests
|
||||
assert.ok(!isCacheValid(data, 'quick', 'full', false), 'quick cache must NOT serve full request');
|
||||
|
||||
// Full cache should serve full requests
|
||||
assert.ok(isCacheValid(data, 'full', 'full', false), 'full cache serves full request');
|
||||
|
||||
// Full cache should serve quick requests (superset)
|
||||
assert.ok(isCacheValid(data, 'full', 'quick', false), 'full cache serves quick request');
|
||||
|
||||
// No cached data
|
||||
assert.ok(!isCacheValid(null, null, 'quick', false), 'no cache is not valid');
|
||||
|
||||
// Refresh flag forces re-fetch
|
||||
assert.ok(!isCacheValid(data, 'quick', 'quick', true), 'refresh flag invalidates cache');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests for module name normalization — verifies that git-scanner,
|
||||
* scanner, backlog-parser, and debt-scanner all produce the same
|
||||
* canonical module keys so signals merge correctly.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Import the resolveModule function from git-scanner by reading the source
|
||||
// and extracting the function (git-scanner doesn't export it).
|
||||
// Instead, we test the expected behavior by checking the patterns.
|
||||
describe('module naming consistency', () => {
|
||||
// These are the canonical module names used by scanner.mjs:
|
||||
// Go packages: 'api', 'ws', 'db', 'auth', 'config', 'admin', etc.
|
||||
// TS areas: 'lib', 'stores', 'components', 'pages', 'styles'
|
||||
// Rust: 'tauri-rust'
|
||||
|
||||
// git-scanner resolveModule should produce the SAME names (no prefixes).
|
||||
// We test this by importing the git-scanner module and checking its output.
|
||||
|
||||
it('git-scanner resolveModule produces unprefixed Go module names', async () => {
|
||||
// Read the git-scanner source and check that resolveModule for Server paths
|
||||
// no longer returns "go:" prefixed names
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8');
|
||||
|
||||
// Ensure "go:" prefix is NOT used in resolveModule
|
||||
const resolveModuleFn = source.match(/function resolveModule\([\s\S]*?\n\}/);
|
||||
assert.ok(resolveModuleFn, 'should find resolveModule function');
|
||||
|
||||
const fnBody = resolveModuleFn[0];
|
||||
assert.ok(!fnBody.includes('`go:'), 'resolveModule should not produce go: prefixed names');
|
||||
assert.ok(!fnBody.includes('`ts:'), 'resolveModule should not produce ts: prefixed names');
|
||||
assert.ok(!fnBody.includes("'go:"), 'resolveModule should not produce go: prefixed names (single quotes)');
|
||||
assert.ok(!fnBody.includes("'ts:"), 'resolveModule should not produce ts: prefixed names (single quotes)');
|
||||
});
|
||||
|
||||
it('session-manager fileToModule matches git-scanner convention', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sessionSource = readFileSync(resolve(__dirname, '../lib/session-manager.mjs'), 'utf8');
|
||||
const gitSource = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8');
|
||||
|
||||
// Both should use 'server-root' for Server/ root, not 'go:root'
|
||||
assert.ok(sessionSource.includes("'server-root'"), 'session-manager should use server-root');
|
||||
assert.ok(gitSource.includes("'server-root'"), 'git-scanner should use server-root');
|
||||
|
||||
// Both should use 'client-config' not 'ts:config'
|
||||
assert.ok(sessionSource.includes("'client-config'"), 'session-manager should use client-config');
|
||||
assert.ok(gitSource.includes("'client-config'"), 'git-scanner should use client-config');
|
||||
});
|
||||
|
||||
it('backlog and scanner module names have no type prefixes', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const backlogSource = readFileSync(resolve(__dirname, '../lib/backlog-parser.mjs'), 'utf8');
|
||||
|
||||
// Backlog MODULE_KEYWORDS keys should be plain names
|
||||
const keyMatch = backlogSource.match(/const MODULE_KEYWORDS = \{([\s\S]*?)\n\};/);
|
||||
assert.ok(keyMatch, 'should find MODULE_KEYWORDS');
|
||||
|
||||
// Ensure no prefixed keys
|
||||
assert.ok(!keyMatch[1].includes("'go:"), 'backlog keywords should not have go: prefix');
|
||||
assert.ok(!keyMatch[1].includes("'ts:"), 'backlog keywords should not have ts: prefix');
|
||||
});
|
||||
|
||||
it('suggestion engine merges modules from different sources under same key', async () => {
|
||||
const { generateSuggestions } = await import('../lib/suggestion-engine.mjs');
|
||||
const { mkdirSync, rmSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tmpCache = resolve(__dirname, '.test-cache-naming');
|
||||
mkdirSync(tmpCache, { recursive: true });
|
||||
|
||||
try {
|
||||
// Simulate data from different sources all using 'components' (not 'ts:components')
|
||||
const result = await generateSuggestions(tmpCache, {
|
||||
priorities: [{ name: 'components', coverage: 40, type: 'typescript' }],
|
||||
backlog: {
|
||||
byPhase: {
|
||||
bug: [{ id: 'T-001', description: 'UI button bug', modules: ['components'] }],
|
||||
},
|
||||
},
|
||||
gitData: {
|
||||
commitsByModule: {
|
||||
components: { count: 15, lastCommit: new Date().toISOString() },
|
||||
},
|
||||
staleness: {},
|
||||
},
|
||||
debtData: {
|
||||
summary: {
|
||||
byModule: {
|
||||
components: { markers: 3, largeFiles: 1, longFunctions: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// All signals should merge into a single 'components' entry
|
||||
const compSuggestion = result.suggestions.find(s => s.module === 'components');
|
||||
assert.ok(compSuggestion, 'should have a components suggestion');
|
||||
|
||||
// It should have signals from coverage, bugs, git, AND debt
|
||||
const signalTypes = new Set(compSuggestion.signals.map(s => s.signal));
|
||||
assert.ok(signalTypes.has('coverage-gap'), 'should have coverage signal');
|
||||
assert.ok(signalTypes.has('open-bug'), 'should have bug signal');
|
||||
assert.ok(signalTypes.has('high-churn'), 'should have churn signal');
|
||||
assert.ok(signalTypes.has('debt-markers'), 'should have debt signal');
|
||||
|
||||
// Verify NO 'ts:components' entry exists
|
||||
const prefixed = result.suggestions.find(s => s.module === 'ts:components');
|
||||
assert.equal(prefixed, undefined, 'should NOT have ts:components — should be merged into components');
|
||||
} finally {
|
||||
rmSync(tmpCache, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Tests for server.mjs — verifies that all mutating / job / session
|
||||
* endpoints require auth and that the server binds to localhost.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
describe('server security configuration', () => {
|
||||
// Helper — read server source once per test (stateless, no port conflict)
|
||||
async function readServerSource() {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
return readFileSync(resolve(__dirname, '../server.mjs'), 'utf8');
|
||||
}
|
||||
|
||||
it('binds to localhost by default', async () => {
|
||||
const source = await readServerSource();
|
||||
assert.ok(source.includes("'127.0.0.1'"), 'default bind host should be 127.0.0.1');
|
||||
assert.ok(source.includes('BIND_HOST'), 'should use BIND_HOST variable');
|
||||
assert.ok(source.includes('server.listen(PORT, BIND_HOST'), 'server.listen should include host parameter');
|
||||
});
|
||||
|
||||
it('does not use wildcard CORS', async () => {
|
||||
const source = await readServerSource();
|
||||
const corsLines = source.split('\n').filter(l => l.includes('Access-Control-Allow-Origin'));
|
||||
for (const line of corsLines) {
|
||||
assert.ok(!line.includes("'*'"), `CORS should not be wildcard: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates an auth token on startup', async () => {
|
||||
const source = await readServerSource();
|
||||
assert.ok(source.includes('randomBytes'), 'should use crypto.randomBytes for token generation');
|
||||
assert.ok(source.includes('AUTH_TOKEN'), 'should define AUTH_TOKEN');
|
||||
assert.ok(source.includes('Bearer'), 'should use Bearer token scheme');
|
||||
});
|
||||
|
||||
// ---- Protected route checks ----
|
||||
|
||||
const protectedRoutes = [
|
||||
{ label: 'POST /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'POST'" },
|
||||
{ label: 'DELETE /api/jobs/:id', pattern: "deleteMatch && req.method === 'DELETE'" },
|
||||
{ label: 'GET /api/jobs/:id/result', pattern: "resultMatch && req.method === 'GET'" },
|
||||
{ label: 'POST /api/jobs/process', pattern: "url.pathname === '/api/jobs/process'" },
|
||||
{ label: 'GET /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'GET'" },
|
||||
{ label: 'POST /api/worked-on', pattern: "url.pathname === '/api/worked-on'" },
|
||||
{ label: 'POST /api/strategy', pattern: "url.pathname === '/api/strategy'" },
|
||||
{ label: 'POST /api/session/start', pattern: "url.pathname === '/api/session/start'" },
|
||||
{ label: 'POST /api/session/end', pattern: "url.pathname === '/api/session/end'" },
|
||||
];
|
||||
|
||||
for (const { label, pattern } of protectedRoutes) {
|
||||
it(`protects ${label} with requireAuth`, async () => {
|
||||
const source = await readServerSource();
|
||||
const idx = source.indexOf(pattern);
|
||||
assert.ok(idx !== -1, `should have handler for ${label}`);
|
||||
const routeBlock = source.slice(idx, idx + 300);
|
||||
assert.ok(routeBlock.includes('requireAuth'), `${label} should call requireAuth`);
|
||||
});
|
||||
}
|
||||
|
||||
it('strips agentJobs and briefing job fields from unauthenticated /api/data responses', async () => {
|
||||
const source = await readServerSource();
|
||||
// The /api/data handler should check isAuthorized and strip agentJobs
|
||||
assert.ok(source.includes('isAuthorized(req)'), '/api/data should check isAuthorized');
|
||||
assert.ok(source.includes('agentJobs: _stripped'), 'should destructure out agentJobs for public response');
|
||||
// Should also strip job-derived fields from the embedded briefing
|
||||
assert.ok(source.includes('briefing: fullBriefing'), 'should destructure out briefing for sanitization');
|
||||
assert.ok(source.includes('publicBriefing'), 'should rebuild a public briefing without job fields');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user