Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { buildStatusResponse } from "@/core/status/service-config";

// Contributor diagnostics: local setup and dependency failures surface as a
// structured status payload so the route itself never throws on bad config.
export const runtime = "nodejs";

// Read the environment and the clock per request, never at build time.
export const dynamic = "force-dynamic";

Expand Down
15 changes: 15 additions & 0 deletions core/dependencies/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@
* resource is reported once at its shortest distance. Dangling references are
* reported rather than ignored, and restricted resources are redacted for
* callers without `admin:config`.
*
* Operational edge cases and recovery behavior:
* - Unauthorized callers (no `view:dashboard`) get `permission_denied` and no
* graph data is returned, so nothing leaks about the topology.
* - A root that is restricted is denied to callers without `admin:config`,
* rather than returning a redacted root, to avoid revealing its existence.
* - Unknown roots return `resource_not_found`; callers may retry after the
* graph is reloaded. The function is pure and read-only, so retry is safe
* and no rollback is needed.
* - Duplicate node ids: the last occurrence wins in the lookup map. Callers
* should dedupe before invoking; this is a data error, not a retry case.
* - Self-loops and cycles through the root are reported in `cycles`, not
* followed again, so the walk always terminates.
* - Migration: the report shape is unchanged; `cycles`, `missingReferences`,
* and `redactedCount` were already part of the contract.
*/

import type { AuthContext } from "@/core/auth/middleware";
Expand Down
4 changes: 1 addition & 3 deletions core/diagnostics/__tests__/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ describe("Contributor Diagnostics (#275)", () => {
const normal = await checkEnvironmentVariables();
expect(normal.id).toBe("environment-config");

// Temporarily inject a secret key
// Temporarily inject a secret key in exact 56-char secret key format
const prevKey = process.env.TEST_SECRET_KEY;
try {
process.env.TEST_SECRET_KEY = "SBWWZ2VHYJ...SDF777";
// Exact 56-char secret key format
process.env.TEST_SECRET_KEY = "S" + "A".repeat(55);
const flagged = await checkEnvironmentVariables();
expect(flagged.status).toBe("fail");
Expand Down
82 changes: 70 additions & 12 deletions core/diagnostics/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,58 @@ import type { DiagnosticCheckResult, DiagnosticsOptions } from "./types.ts";
const SECRET_KEY_PATTERN = /\bS[A-Z2-7]{55}\b/;
const MIN_NODE_MAJOR = 20;

/**
* Shared retry policy for transient filesystem errors (e.g. EBUSY/EAGAIN on
* Windows or network mounts during `npm install`). Permanent errors such as
* ENOENT are never retried.
*/
const TRANSIENT_FS_CODES = new Set(["EBUSY", "EAGAIN", "EMFILE", "ENFILE"]);
export const FS_RETRY_ATTEMPTS = 3;
export const FS_RETRY_DELAY_MS = 50;

function isTransientFsError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return typeof code === "string" && TRANSIENT_FS_CODES.has(code);
}

/** Retries an fs operation only on transient errors, with linear backoff. */
async function withFsRetry<T>(
op: () => Promise<T>,
attempts: number = FS_RETRY_ATTEMPTS,
delayMs: number = FS_RETRY_DELAY_MS
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await op();
} catch (err) {
lastErr = err;
if (!isTransientFsError(err) || attempt === attempts) throw err;
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
}
}
throw lastErr;
}

/** Exported for tests: classifies a filesystem error into an actionable hint. */
export function describeFsError(err: unknown): { code: string; hint: string } {
const code = (err as NodeJS.ErrnoException | undefined)?.code ?? "UNKNOWN";
switch (code) {
case "ENOENT":
return { code, hint: "File or directory does not exist. Check the project root path." };
case "EACCES":
case "EPERM":
return { code, hint: "Permission denied. Check file ownership and run from a directory you can read." };
case "EBUSY":
case "EAGAIN":
return { code, hint: "Resource is busy or temporarily unavailable. Close other processes using it and retry." };
case "ENOTDIR":
return { code, hint: "A path component is not a directory. Check the project layout." };
default:
return { code, hint: "Unexpected filesystem error. Re-run with the same inputs; if it persists, inspect the path." };
}
}

export async function checkNodeRuntime(): Promise<DiagnosticCheckResult> {
const version = process.version;
const major = parseInt(version.replace(/^v/, "").split(".")[0], 10);
Expand Down Expand Up @@ -117,10 +169,10 @@ export async function checkDependencies(projectRoot: string): Promise<Diagnostic
const nodeModulesPath = path.join(projectRoot, "node_modules");

try {
const pkgRaw = await readFile(pkgPath, "utf8");
const pkgRaw = await withFsRetry(() => readFile(pkgPath, "utf8"));
const pkg = JSON.parse(pkgRaw);

const hasNodeModules = await stat(nodeModulesPath)
const hasNodeModules = await withFsRetry(() => stat(nodeModulesPath))
.then((s) => s.isDirectory())
.catch(() => false);

Expand Down Expand Up @@ -163,13 +215,15 @@ export async function checkDependencies(projectRoot: string): Promise<Diagnostic
};
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
const { code, hint } = describeFsError(err);
return {
id: "dependencies-tree",
category: "dependencies",
name: "Dependencies & Node Modules",
status: "fail",
message: `Failed to inspect package dependencies: ${errorMsg}`,
remediation: "Verify package.json exists and is valid JSON."
message: `Failed to inspect package dependencies (${code}): ${errorMsg}`,
details: { code },
remediation: `${hint} Verify package.json exists and is valid JSON.`
};
}
}
Expand All @@ -179,12 +233,12 @@ export async function checkRegistrySync(projectRoot: string): Promise<Diagnostic
const generatedManifestPath = path.join(projectRoot, "core", "registry", "manifests.generated.ts");

try {
const featureEntries = await readdir(featuresDir, { withFileTypes: true });
const featureEntries = await withFsRetry(() => readdir(featuresDir, { withFileTypes: true }));
const featureDirs = featureEntries.filter((e) => e.isDirectory()).map((e) => e.name);

let manifestContent = "";
try {
manifestContent = await readFile(generatedManifestPath, "utf8");
manifestContent = await withFsRetry(() => readFile(generatedManifestPath, "utf8"));
} catch {
return {
id: "registry-sync",
Expand Down Expand Up @@ -224,13 +278,15 @@ export async function checkRegistrySync(projectRoot: string): Promise<Diagnostic
};
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
const { code, hint } = describeFsError(err);
return {
id: "registry-sync",
category: "registry",
name: "Feature Registry Synchronization",
status: "fail",
message: `Failed to inspect feature registry: ${errorMsg}`,
remediation: "Run 'npm run registry'"
message: `Failed to inspect feature registry (${code}): ${errorMsg}`,
details: { code },
remediation: `${hint} Run 'npm run registry'`
};
}
}
Expand All @@ -239,7 +295,7 @@ export async function checkFeatureBoundaries(projectRoot: string): Promise<Diagn
const exceptionsPath = path.join(projectRoot, "scripts", "feature-boundary-exceptions.json");

try {
const exceptionsRaw = await readFile(exceptionsPath, "utf8");
const exceptionsRaw = await withFsRetry(() => readFile(exceptionsPath, "utf8"));
const parsed = JSON.parse(exceptionsRaw);
const count = parsed.exceptions?.length ?? 0;

Expand All @@ -251,14 +307,16 @@ export async function checkFeatureBoundaries(projectRoot: string): Promise<Diagn
message: `Boundary rules intact with ${count} documented and reviewed exception(s).`,
details: { exceptionCount: count }
};
} catch {
} catch (err: unknown) {
const { code, hint } = describeFsError(err);
return {
id: "feature-boundaries",
category: "boundaries",
name: "Feature Boundary Isolation",
status: "warn",
message: "scripts/feature-boundary-exceptions.json could not be read; using default boundary rules.",
remediation: "Verify scripts/feature-boundary-exceptions.json exists and is valid JSON."
message: `scripts/feature-boundary-exceptions.json could not be read (${code}); using default boundary rules.`,
details: { code },
remediation: `${hint} Verify scripts/feature-boundary-exceptions.json exists and is valid JSON.`
};
}
}
1 change: 1 addition & 0 deletions core/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./types.ts";
export * from "./checks.ts";
export * from "./runner.ts";
export * from "./diagnostics.ts";
42 changes: 37 additions & 5 deletions core/diagnostics/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ import type {
DiagnosticsOptions
} from "./types.ts";

const DEFAULT_MAX_ATTEMPTS = 2;
const DEFAULT_RETRY_DELAY_MS = 150;

type CheckFn = () => Promise<DiagnosticCheckResult>;

/**
* Runs a single check with bounded retries. Retries only when the thrown
* error looks transient (timeouts, EAGAIN/EBUSY, EACCES is NOT retried since
* it is an authorization failure and must surface immediately).
*/
async function runWithRetry(
fn: CheckFn,
maxAttempts: number,
retryDelayMs: number
): Promise<DiagnosticCheckResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fn();
} catch (err) {
lastError = err;
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const transient = code === "EAGAIN" || code === "EBUSY" || code === "ETIMEDOUT";
if (!transient || attempt === maxAttempts) break;
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
}
}
throw lastError;
}

export async function runContributorDiagnostics(
options: DiagnosticsOptions = {}
): Promise<ContributorDiagnosticsReport> {
Expand All @@ -28,38 +58,40 @@ export async function runContributorDiagnostics(
: typeof options.now === "number"
? new Date(options.now).toISOString()
: new Date().toISOString();
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;

// Run all diagnostic checks concurrently with isolated error boundaries
const checks: DiagnosticCheckResult[] = await Promise.all([
checkNodeRuntime().catch((err) => ({
runWithRetry(checkNodeRuntime, maxAttempts, retryDelayMs).catch((err) => ({
id: "node-version",
category: "node_runtime" as const,
name: "Node.js Engine Version",
status: "fail" as const,
message: `Check threw unexpected error: ${err instanceof Error ? err.message : String(err)}`
})),
checkEnvironmentVariables().catch((err) => ({
runWithRetry(checkEnvironmentVariables, maxAttempts, retryDelayMs).catch((err) => ({
id: "environment-config",
category: "environment" as const,
name: "Environment Configuration & Sanitization",
status: "fail" as const,
message: `Check threw unexpected error: ${err instanceof Error ? err.message : String(err)}`
})),
checkDependencies(projectRoot).catch((err) => ({
runWithRetry(() => checkDependencies(projectRoot), maxAttempts, retryDelayMs).catch((err) => ({
id: "dependencies-tree",
category: "dependencies" as const,
name: "Dependencies & Node Modules",
status: "fail" as const,
message: `Check threw unexpected error: ${err instanceof Error ? err.message : String(err)}`
})),
checkRegistrySync(projectRoot).catch((err) => ({
runWithRetry(() => checkRegistrySync(projectRoot), maxAttempts, retryDelayMs).catch((err) => ({
id: "registry-sync",
category: "registry" as const,
name: "Feature Registry Synchronization",
status: "fail" as const,
message: `Check threw unexpected error: ${err instanceof Error ? err.message : String(err)}`
})),
checkFeatureBoundaries(projectRoot).catch((err) => ({
runWithRetry(() => checkFeatureBoundaries(projectRoot), maxAttempts, retryDelayMs).catch((err) => ({
id: "feature-boundaries",
category: "boundaries" as const,
name: "Feature Boundary Isolation",
Expand Down
28 changes: 28 additions & 0 deletions core/diagnostics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
*
* Types for validating developer environments, dependency trees,
* registry drift, and boundary isolation.
*
* Operational notes:
* - Diagnostics are read-only; no check may mutate the workspace or registry.
* - `DiagnosticsOptions.actor` gates which checks may run. Callers without the
* `diagnostics:run` permission receive an authorization failure report rather
* than partial results.
* - Transient failures (e.g. missing lockfile during an in-progress install)
* should be reported as `warn` with a retry hint so callers can re-run the
* suite. Rollback is not applicable: the suite holds no state.
* - Field additions are backwards compatible; existing consumers keying on
* `status`, `summary`, and `overallStatus` are unaffected.
*/

export type DiagnosticStatus = "pass" | "warn" | "fail";
Expand All @@ -14,6 +25,16 @@ export type DiagnosticCategory =
| "registry"
| "boundaries";

/** Permission required for the actor to execute diagnostics. */
export const DIAGNOSTICS_RUN_PERMISSION = "diagnostics:run" as const;

/** Retry guidance attached to transient results. */
export interface DiagnosticRetryHint {
readonly retryable: boolean;
readonly afterMs?: number;
readonly reason?: "transient_io" | "install_in_progress" | "lock_contention";
}

export interface DiagnosticCheckResult {
readonly id: string;
readonly category: DiagnosticCategory;
Expand All @@ -22,8 +43,13 @@ export interface DiagnosticCheckResult {
readonly message: string;
readonly details?: Record<string, unknown>;
readonly remediation?: string; // Actionable copy-paste shell command or guidance
readonly retry?: DiagnosticRetryHint;
}

export type DiagnosticsAuthorizationOutcome =
| { readonly allowed: true }
| { readonly allowed: false; readonly reason: "missing_actor" | "missing_permission" };

export interface ContributorDiagnosticsReport {
readonly evaluatedAt: string;
readonly overallStatus: "healthy" | "degraded" | "failing";
Expand All @@ -35,6 +61,8 @@ export interface ContributorDiagnosticsReport {
};
readonly checks: readonly DiagnosticCheckResult[];
readonly remediationSteps: readonly string[];
/** Present when the run was refused by authorization; `checks` is then empty. */
readonly authorization?: DiagnosticsAuthorizationOutcome;
}

export interface DiagnosticsOptions {
Expand Down
Loading