Files
fusion/packages/dashboard/src/knowledge-index.ts
gsxdsm cca13737b6 FN-8603: reduce steady-state diagnostic log noise
Route routine core, engine, and dashboard diagnostics through debug-gated shared loggers.

- Demote steady-state diagnostic sites while preserving warnings and errors for actionable failures.
- Add cross-package severity contracts and manifest coverage for demoted log sites.
- Document logging severity guidance and add a patch changeset.

Files changed:
 .changeset/fn-8603-log-severity.md                 |  7 ++
 docs/diagnostics.md                                | 20 ++++--
 .../__tests__/log-severity-spam-contract.test.ts   | 71 ++++++++++++++++++
 packages/core/src/activity-analytics.ts            |  5 +-
 packages/core/src/ai-summarize.ts                  | 61 +++++++---------
 packages/core/src/async-mission-store.ts           |  5 +-
 packages/core/src/async-secrets-store.ts           |  7 +-
 packages/core/src/central-core.ts                  | 17 ++---
 packages/core/src/docker-provisioning.ts           | 13 ++--
 packages/core/src/index.ts                         |  1 +
 packages/core/src/master-key.ts                    |  9 ++-
 packages/core/src/memory-compaction.ts             | 29 ++++----
 packages/core/src/memory-insights.ts               |  7 +-
 packages/core/src/migration-orchestrator.ts        |  7 +-
 packages/core/src/mission-store.ts                 |  5 +-
 packages/core/src/node-discovery.ts                |  7 +-
 packages/core/src/notification/dispatcher.ts       |  9 ++-
 .../core/src/plugins/bundled-plugin-install.ts     | 11 +--
 packages/core/src/reflection-store.ts              |  5 +-
 packages/core/src/secrets-store.ts                 |  7 +-
 packages/core/src/task-store/agent-logs.ts         | 21 +++---
 packages/core/src/task-store/async-events.ts       |  5 +-
 packages/core/src/task-store/async-maintenance.ts  |  7 +-
 packages/core/src/task-store/comments-ops.ts       |  7 +-
 packages/core/src/task-store/task-mutation-ops.ts  | 11 +--
 packages/core/src/task-store/workflow-integrity.ts |  9 ++-
 packages/core/src/types/merge-policy.ts            |  5 +-
 packages/core/src/usage-events.ts                  |  5 +-
 .../__tests__/log-severity-spam-contract.test.ts   | 48 +++++++++++++
 packages/dashboard/src/ai-refine.ts                |  5 +-
 packages/dashboard/src/ai-session-diagnostics.ts   | 10 +--
 packages/dashboard/src/chat.ts                     |  8 ++-
 packages/dashboard/src/devserver-manager.ts        |  9 ++-
 packages/dashboard/src/file-service.ts             |  5 +-
 packages/dashboard/src/github-tracking-comments.ts |  7 +-
 .../dashboard/src/github-tracking-reconciler.ts    |  5 +-
 packages/dashboard/src/github-tracking-state.ts    |  5 +-
 packages/dashboard/src/gitlab-lifecycle.ts         |  5 +-
 packages/dashboard/src/insights-routes.ts          |  9 ++-
 packages/dashboard/src/issue-image-attachments.ts  |  5 +-
 packages/dashboard/src/knowledge-index.ts          |  5 +-
 packages/dashboard/src/plugin-routes.ts            |  7 +-
 packages/dashboard/src/routes/board-workflows.ts   |  5 +-
 packages/dashboard/src/routes/context.ts           |  5 +-
 .../dashboard/src/routes/register-auth-routes.ts   | 13 ++--
 .../routes/register-docker-provisioning-routes.ts  |  7 +-
 .../dashboard/src/routes/register-git-github.ts    | 21 +++---
 packages/dashboard/src/routes/register-gitlab.ts   |  7 +-
 .../src/routes/register-session-diff-routes.ts     |  9 ++-
 .../src/routes/register-settings-memory-routes.ts  |  7 +-
 .../src/routes/register-setup-activity-routes.ts   |  7 +-
 .../dashboard/src/routes/register-signal-routes.ts |  5 +-
 .../src/routes/register-task-workflow-routes.ts    | 11 +--
 packages/dashboard/src/runtime-logger.ts           | 11 +--
 packages/dashboard/src/server.ts                   |  7 +-
 packages/dashboard/src/sse.ts                      |  8 ++-
 packages/dashboard/src/terminal-service.ts         | 34 ++++-----
 packages/dashboard/src/view-chunk-manifest.ts      |  5 +-
 .../engine/src/__tests__/log-severity-manifest.ts  | 83 ++++++++++++++++++++++
 .../__tests__/log-severity-spam-contract.test.ts   | 40 ++++++++++-
 .../src/__tests__/logger-debug-gating.test.ts      |  7 +-
 packages/engine/src/goal-anchoring-audit.ts        |  5 +-
 packages/engine/src/plugin-runner.ts               | 44 ++++++------
 packages/engine/src/pty-native.ts                  |  9 ++-
 .../engine/src/runtimes/child-process-worker.ts    |  4 +-
 packages/engine/src/self-healing.ts                | 12 ++--
 packages/engine/src/worktree-hooks.ts              | 10 ++-
 67 files changed, 632 insertions(+), 250 deletions(-)

Fusion-Task-Id: FN-8603

Fusion-Task-Lineage: 53901db6-1af2-4bd7-b5ea-49507e048ef2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 12:01:19 -07:00

275 lines
9.6 KiB
TypeScript

import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-knowledge-index");
/**
* Persistent knowledge index (U14).
*
* A persistent, incrementally-refreshed knowledge layer that downstream agents
* can query. Each "page" captures the durable, queryable summary of one source
* (currently one page per completed task; PR-history pages share the same row
* shape). Pages are stored in the project-owned `knowledge_pages` table; the
* runtime uses the project-scoped PostgreSQL persistence API.
*
* ## Delta over `insights` / `memoryView`
*
* This is intentionally NOT a second copy of the existing surfaces:
*
* - `InsightStore` / `insights-routes.ts` / `InsightsView` store **LLM-extracted
* durable project learnings** ("patterns/principles/pitfalls" mined from
* working memory by an agent run). `memoryView` renders the freeform working/
* insights **markdown memory files**. Both are *interpretation* layers and both
* require a model run to populate.
* - The knowledge index is a **deterministic, model-free, keyword-searchable
* index of concrete task/PR history** (title, description, modified files,
* commits, PR links). It is refreshed **incrementally on task completion** —
* one upsert per affected page, never a full re-index — and exposes a plain
* keyword **query API** an agent can call to recall "what work touched X".
*
* So the genuinely new capability is: (1) a persistent per-task/PR page store,
* (2) an incremental refresh hook on task completion, and (3) a keyword query
* API — none of which the insights/memory surfaces provide.
*
* ## Search
*
* Matching is portable keyword `LIKE` over a denormalized lowercased
* `searchText` column (AND-of-terms), deliberately avoiding backend-specific
* full-text extensions and external embedding APIs.
*
* ## Security
*
* The query API is registered as an {@link ApiRouteRegistrar} (see
* `routes/register-knowledge-routes.ts`) so it inherits the dashboard's standard
* session/auth middleware AND resolves the database through `getScopedStore(req)`
* before reading — exactly like U9. The index holds sensitive repo/commit/PR
* content, so it is an information-disclosure surface, never an open endpoint.
*/
import {
countKnowledgePagesInPostgres,
queryKnowledgePagesInPostgres,
upsertKnowledgePageInPostgres,
type AsyncDataLayer,
type TaskStore,
} from "@fusion/core";
/** The kind of source a knowledge page was indexed from. */
export type KnowledgeSourceKind = "task" | "pr";
/** A knowledge page row as stored/read from `knowledge_pages`. */
export interface KnowledgePage {
id: number;
sourceKind: KnowledgeSourceKind;
sourceId: string;
/** Stable dedupe key (`<sourceKind>:<sourceId>`); upserts target this. */
sourceKey: string;
title: string;
summary: string | null;
content: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
/** Input for {@link upsertKnowledgePageAsync}. */
export interface KnowledgePageInput {
sourceKind: KnowledgeSourceKind;
sourceId: string;
title: string;
summary?: string | null;
content: string;
tags?: string[];
/** Injectable clock for deterministic tests. Defaults to now. */
now?: string;
}
/** Maximum number of pages a single keyword query returns. */
export const KNOWLEDGE_QUERY_DEFAULT_LIMIT = 20;
export const KNOWLEDGE_QUERY_MAX_LIMIT = 100;
/**
* Build the denormalized, lowercased search blob a page is matched against.
* Pure so it can be unit-tested independently of the DB.
*/
export function buildSearchText(input: {
title: string;
summary?: string | null;
content: string;
tags?: string[];
}): string {
return [
input.title,
input.summary ?? "",
input.content,
(input.tags ?? []).join(" "),
]
.join(" ")
.toLowerCase();
}
/**
* Tokenize a free-text query into lowercased keyword terms. Empty / whitespace
* input yields no terms (callers treat that as "match nothing", not "match all",
* to avoid returning the whole sensitive index for a blank query).
*/
export function tokenizeQuery(query: string): string[] {
return query
.toLowerCase()
.split(/[^a-z0-9_]+/i)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
/** PostgreSQL implementation of the incremental knowledge-page upsert. */
export async function upsertKnowledgePageAsync(
layer: AsyncDataLayer,
input: KnowledgePageInput,
): Promise<{ page: KnowledgePage; created: boolean }> {
return upsertKnowledgePageInPostgres(layer, input, buildSearchText(input));
}
/** Options for {@link queryKnowledgePagesAsync}. */
export interface KnowledgeQueryOptions {
query: string;
sourceKind?: KnowledgeSourceKind;
limit?: number;
}
/**
* Keyword search the index. Returns pages whose `searchText` contains **all**
* query terms (AND), most-recently-updated first. A blank/termless query returns
* an empty list rather than the whole index.
*/
/** Project-scoped PostgreSQL keyword search with the same AND-of-terms semantics. */
export async function queryKnowledgePagesAsync(
layer: AsyncDataLayer,
options: KnowledgeQueryOptions,
): Promise<KnowledgePage[]> {
const terms = tokenizeQuery(options.query);
if (terms.length === 0) return [];
const limit = Math.min(
Math.max(1, options.limit ?? KNOWLEDGE_QUERY_DEFAULT_LIMIT),
KNOWLEDGE_QUERY_MAX_LIMIT,
);
return queryKnowledgePagesInPostgres(layer, {
terms: terms.map(escapeLike),
sourceKind: options.sourceKind,
limit,
});
}
/** Escape SQL `LIKE` wildcards in a term so user input cannot inject them. */
function escapeLike(term: string): string {
return term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}
/** Total number of knowledge pages in the bound PostgreSQL project. */
export async function countKnowledgePagesAsync(layer: AsyncDataLayer): Promise<number> {
return countKnowledgePagesInPostgres(layer);
}
/**
* Render a completed task into a deterministic knowledge page body. Pure so the
* refresh hook is testable without a real store. Concatenates the durable,
* non-sensitive facts: title, description, modified files, associated commit
* subjects, and PR link if present.
*/
export function renderTaskPage(task: {
id: string;
title?: string;
description?: string;
modifiedFiles?: string[];
commitSubjects?: string[];
prUrl?: string | null;
column?: string;
}): KnowledgePageInput {
const title = (task.title ?? "").trim() || `Task ${task.id}`;
const lines: string[] = [];
if (task.description?.trim()) {
lines.push(task.description.trim());
}
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
lines.push(`Files: ${task.modifiedFiles.join(", ")}`);
}
if (task.commitSubjects && task.commitSubjects.length > 0) {
lines.push(`Commits:\n${task.commitSubjects.map((s) => `- ${s}`).join("\n")}`);
}
if (task.prUrl) {
lines.push(`PR: ${task.prUrl}`);
}
const tags = (task.modifiedFiles ?? [])
.map((f) => f.split("/").pop() ?? f)
.filter((t) => t.length > 0);
return {
sourceKind: "task",
sourceId: task.id,
title,
summary: task.description?.trim().slice(0, 280) || null,
content: lines.join("\n\n") || title,
tags,
};
}
/**
* Incremental refresh hook: index (or re-index) a single task as a knowledge
* page. Intended to be invoked from the task-completion path (or by code that
* observes a task reaching `done`). It reads only the one task and upserts only
* its page, so unaffected pages are never touched.
*
* **Fail-soft:** any read/write error is logged and swallowed so a knowledge
* refresh can never break the task-completion flow that called it.
*
* @returns the upserted page, or `null` if the task could not be loaded/indexed.
*/
export async function refreshKnowledgeForTask(
store: TaskStore,
taskId: string,
options?: { now?: string },
): Promise<KnowledgePage | null> {
try {
const detail = await store.getTask(taskId);
if (!detail) return null;
let commitSubjects: string[] = [];
try {
const lineageId = (detail as { lineageId?: string }).lineageId ?? detail.id;
const rows = await store.getTaskCommitAssociationsByLineageId(lineageId);
commitSubjects = [...rows]
.sort((a, b) => a.authoredAt.localeCompare(b.authoredAt))
.map((row) => row.commitSubject);
} catch {
commitSubjects = [];
}
const prUrl = extractPrUrl(detail);
const input = renderTaskPage({
id: detail.id,
title: detail.title,
description: detail.description,
modifiedFiles: (detail as { modifiedFiles?: string[] }).modifiedFiles,
commitSubjects,
prUrl,
column: detail.column,
});
if (options?.now) input.now = options.now;
/* FNXC:PostgresSatelliteCutover 2026-07-14-17:30: Completed-task knowledge is PostgreSQL-only; missing layer wiring fails this best-effort refresh instead of writing a hidden SQLite index. */
const layer = store.getAsyncLayer();
if (!layer) throw new Error("Knowledge refresh requires the project PostgreSQL AsyncDataLayer");
return (await upsertKnowledgePageAsync(layer, input)).page;
} catch (err) {
severityAuditLog.warn(`[knowledge-index] refresh skipped for task ${taskId}:`, err);
return null;
}
}
/** Best-effort extraction of a PR URL from a task detail, tolerant of shape. */
function extractPrUrl(detail: unknown): string | null {
if (!detail || typeof detail !== "object") return null;
const d = detail as Record<string, unknown>;
if (typeof d.prUrl === "string" && d.prUrl) return d.prUrl;
const pr = d.pullRequest as Record<string, unknown> | undefined;
if (pr && typeof pr.url === "string" && pr.url) return pr.url;
if (pr && typeof pr.htmlUrl === "string" && pr.htmlUrl) return pr.htmlUrl;
return null;
}