- Mobile header row polish: finalize QuickChatFAB CSS for mobile viewport handling, apply visual viewport height to quick chat panel, remove duplicate keyboard overlap subtraction - Add `node-routing-policy.ts` with routing logic for mesh network nodes; expand scheduler node-routing tests with new policy coverage - Improve reviewer decision logic and add reviewer test cases for changed behavior - Refactor triage module (48 lines changed) with updated agent selection logic - Update `AgentsView` component and styles; add `AgentsView.test.tsx` integration test - Simplify `QuickChatFAB.test.tsx` test by removing CSS class permutation coverage - Update architecture docs and settings reference to reflect new routing capabilities Commits merged: - fix(FN-2925): remove duplicate keyboard overlap subtraction - feat(FN-2925): complete Step 5 — finalize mobile header row polish - fix(FN-2925): apply visual viewport height to mobile quick chat panel - feat(FN-2903): merge fusion/fn-2903 - feat(FN-2950): merge fusion/fn-2950 Files changed: docs/architecture.md | 19 +-- docs/settings-reference.md | 2 +- packages/dashboard/app/components/AgentsView.css | 20 +++- packages/dashboard/app/components/AgentsView.tsx | 11 +- packages/dashboard/app/components/QuickChatFAB.css | 32 ++++- .../dashboard/app/components/SettingsModal.tsx | 4 +- .../app/components/__tests__/AgentsView.test.tsx | 37 ++++++ .../app/components/__tests__/QuickChatFAB.test.tsx | 18 +-- .../src/__tests__/node-routing-policy.test.ts | 78 ++++++++++++ packages/engine/src/__tests__/reviewer.test.ts | 19 ++- .../src/__tests__/scheduler-node-routing.test.ts | 131 ++++++++++++++++++++- packages/engine/src/__tests__/triage.test.ts | 16 +-- packages/engine/src/node-routing-policy.ts | 45 +++++++ packages/engine/src/reviewer.ts | 33 ++++-- packages/engine/src/scheduler.ts | 44 ++++++- packages/engine/src/triage.ts | 48 ++++---- 16 files changed, 473 insertions(+), 84 deletions(-) Fusion-Task-Id: FN-2925
205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
import { accessSync, constants as fsConstants } from "node:fs";
|
|
import type {
|
|
PreparedTunnelCommand,
|
|
TunnelOutputStream,
|
|
TunnelProvider,
|
|
TunnelProviderAdapter,
|
|
TunnelProviderConfig,
|
|
} from "./types.js";
|
|
|
|
const DEFAULT_READINESS_TIMEOUT_MS = 20_000;
|
|
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
|
|
|
|
const URL_PATTERN = /(https?:\/\/[^\s]+)/i;
|
|
|
|
function isAbsoluteOrPathLike(input: string): boolean {
|
|
return input.startsWith("/") || input.startsWith("./") || input.startsWith("../");
|
|
}
|
|
|
|
function assertNonEmpty(value: unknown, label: string): asserts value is string {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`invalid_config:${label} must be a non-empty string`);
|
|
}
|
|
}
|
|
|
|
function ensureNumberInRange(value: number | undefined, label: string): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error(`invalid_config:${label} must be a positive number`);
|
|
}
|
|
|
|
return Math.floor(value);
|
|
}
|
|
|
|
function collectSensitiveValues(config: TunnelProviderConfig): string[] {
|
|
const values = new Set<string>();
|
|
const env = config.env ?? {};
|
|
|
|
const tokenEnvVar = config.tokenEnvVar;
|
|
if (typeof tokenEnvVar === "string" && tokenEnvVar.trim().length > 0) {
|
|
const tokenValue = env[tokenEnvVar] ?? process.env[tokenEnvVar];
|
|
if (!tokenValue) {
|
|
throw new Error(`invalid_config:missing credential in env var ${tokenEnvVar}`);
|
|
}
|
|
values.add(tokenValue);
|
|
}
|
|
|
|
for (const envName of config.sensitiveEnvVars ?? []) {
|
|
const envValue = env[envName] ?? process.env[envName];
|
|
if (envValue) {
|
|
values.add(envValue);
|
|
}
|
|
}
|
|
|
|
return [...values].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
function redactValue(input: string, sensitiveValues: string[]): string {
|
|
let redacted = input;
|
|
for (const value of sensitiveValues) {
|
|
redacted = redacted.split(value).join("[REDACTED]");
|
|
}
|
|
return redacted;
|
|
}
|
|
|
|
function redactArgs(args: string[], sensitiveValues: string[]): string[] {
|
|
return args.map((arg) => redactValue(arg, sensitiveValues));
|
|
}
|
|
|
|
function buildCommand(config: TunnelProviderConfig): PreparedTunnelCommand {
|
|
const command = config.executablePath.trim();
|
|
const args = [...config.args];
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
...(config.env ?? {}),
|
|
};
|
|
const sensitiveValues = collectSensitiveValues(config);
|
|
const redactedPreview = [command, ...redactArgs(args, sensitiveValues)].join(" ").trim();
|
|
|
|
return {
|
|
provider: config.provider,
|
|
command,
|
|
args,
|
|
cwd: config.cwd,
|
|
env,
|
|
redactedPreview,
|
|
sensitiveValues,
|
|
readinessTimeoutMs: config.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
|
|
stopTimeoutMs: config.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS,
|
|
};
|
|
}
|
|
|
|
function parseCommonReadiness(line: string): { ready: boolean; url?: string } | null {
|
|
const normalized = line.trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const urlMatch = normalized.match(URL_PATTERN);
|
|
const url = urlMatch?.[1];
|
|
|
|
if (/\b(connected|ready|available|started|serving)\b/i.test(normalized)) {
|
|
return { ready: true, url };
|
|
}
|
|
|
|
if (url && /\b(trycloudflare|tailscale|ts\.net|serve|funnel)\b/i.test(normalized)) {
|
|
return { ready: true, url };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateBaseConfig(config: TunnelProviderConfig, provider: TunnelProvider): void {
|
|
if (config.provider !== provider) {
|
|
throw new Error(`invalid_config:config provider ${config.provider} does not match ${provider}`);
|
|
}
|
|
|
|
assertNonEmpty(config.executablePath, "executablePath");
|
|
|
|
if (!Array.isArray(config.args)) {
|
|
throw new Error("invalid_config:args must be an array");
|
|
}
|
|
|
|
for (const [index, arg] of config.args.entries()) {
|
|
assertNonEmpty(arg, `args[${index}]`);
|
|
}
|
|
|
|
if (config.cwd !== undefined) {
|
|
assertNonEmpty(config.cwd, "cwd");
|
|
}
|
|
|
|
if (config.tokenEnvVar !== undefined) {
|
|
assertNonEmpty(config.tokenEnvVar, "tokenEnvVar");
|
|
}
|
|
|
|
ensureNumberInRange(config.readinessTimeoutMs, "readinessTimeoutMs");
|
|
ensureNumberInRange(config.stopTimeoutMs, "stopTimeoutMs");
|
|
}
|
|
|
|
const tailscaleAdapter: TunnelProviderAdapter = {
|
|
provider: "tailscale",
|
|
validateConfig(config) {
|
|
validateBaseConfig(config, "tailscale");
|
|
},
|
|
buildCommand(config) {
|
|
this.validateConfig(config);
|
|
return buildCommand(config);
|
|
},
|
|
parseReadiness(line: string, _stream: TunnelOutputStream) {
|
|
// tailscale funnel prints "Available on the internet:" first (no URL),
|
|
// then a follow-up line with https://<host>.ts.net/. Don't signal ready
|
|
// until we have the URL itself, otherwise the manager freezes status
|
|
// before the URL line arrives.
|
|
const normalized = line.trim();
|
|
if (!normalized) return null;
|
|
const urlMatch = normalized.match(URL_PATTERN);
|
|
const url = urlMatch?.[1];
|
|
if (!url) return null;
|
|
if (/\bts\.net\b/i.test(url) || /\b(tailscale|funnel|serve)\b/i.test(normalized)) {
|
|
return { ready: true, url };
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const cloudflareAdapter: TunnelProviderAdapter = {
|
|
provider: "cloudflare",
|
|
validateConfig(config) {
|
|
validateBaseConfig(config, "cloudflare");
|
|
|
|
if (config.provider === "cloudflare" && config.quickTunnel === true) {
|
|
return;
|
|
}
|
|
|
|
if ("credentialsPath" in config && config.credentialsPath !== undefined) {
|
|
assertNonEmpty(config.credentialsPath, "credentialsPath");
|
|
if (isAbsoluteOrPathLike(config.credentialsPath)) {
|
|
accessSync(config.credentialsPath, fsConstants.R_OK);
|
|
}
|
|
}
|
|
},
|
|
buildCommand(config) {
|
|
this.validateConfig(config);
|
|
return buildCommand(config);
|
|
},
|
|
parseReadiness(line: string, _stream: TunnelOutputStream) {
|
|
return parseCommonReadiness(line);
|
|
},
|
|
};
|
|
|
|
const ADAPTERS: Record<TunnelProvider, TunnelProviderAdapter> = {
|
|
tailscale: tailscaleAdapter,
|
|
cloudflare: cloudflareAdapter,
|
|
};
|
|
|
|
export function getTunnelProviderAdapter(provider: TunnelProvider): TunnelProviderAdapter {
|
|
return ADAPTERS[provider];
|
|
}
|
|
|
|
export function redactTunnelText(input: string, sensitiveValues: string[]): string {
|
|
return redactValue(input, sensitiveValues);
|
|
}
|