jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

166 lines
7.0 KiB
TypeScript

/** Schema-aligned normalization and lineage invariants for HUX-03. */
import { isOpaqueId, isUtc, safeTags, safeText } from "./security.ts";
import type {
ArtifactSummary,
AttachmentView,
Conversation,
LineageNode,
OrganizationSnapshot,
Project,
RawConversation,
RawProject,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
export function projectsEnabled(flags?: Iterable<string>): boolean {
const enabled = new Set(flags || []);
return enabled.has(FOUNDATION_FLAG) && enabled.has(PROJECTS_FLAG);
}
export function normalizeProject(raw: RawProject, owner: string): Project | null {
const tags = safeTags(raw.tags);
if (raw.schema !== "hux.project.v1" || !isOpaqueId(raw.id, "prj") ||
raw.owner !== owner || tags === null || typeof raw.pinned !== "boolean" ||
typeof raw.archived !== "boolean" || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.default_mode !== undefined &&
(typeof raw.default_mode !== "string" || !MODES.has(raw.default_mode)))) return null;
const name = safeText(raw.name, "", 120);
if (!name) return null;
return {
id: raw.id, owner, name,
description: safeText(raw.description, "", 2000), tags,
pinned: raw.pinned, archived: raw.archived, updatedAt: raw.updated_at,
};
}
function normalizeBranch(raw: unknown): Conversation["branch"] | undefined {
if (raw === undefined) return null;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
if (!isOpaqueId(value.parent_conversation_id, "conv") ||
typeof value.branch_point_message_id !== "string") return undefined;
const messageId = safeText(value.branch_point_message_id, "", 120);
return messageId ? {
parentConversationId: value.parent_conversation_id,
branchPointMessageId: messageId,
} : undefined;
}
export function normalizeConversation(
raw: RawConversation,
owner: string,
projectIds: ReadonlySet<string>,
): Conversation | null {
const tags = safeTags(raw.tags);
const branch = normalizeBranch(raw.branch);
const artifacts = Array.isArray(raw.artifact_ids) ? raw.artifact_ids : null;
const projectId = raw.project_id === undefined ? null : raw.project_id;
if (raw.schema !== "hux.conversation.v1" || !isOpaqueId(raw.id, "conv") ||
raw.owner !== owner || tags === null || branch === undefined ||
typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" ||
!artifacts || artifacts.length > 500 || new Set(artifacts).size !== artifacts.length ||
artifacts.some((id) => !isOpaqueId(id, "art")) || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.last_message_at !== undefined &&
!isUtc(raw.last_message_at)) || (raw.mode !== undefined &&
(typeof raw.mode !== "string" || !MODES.has(raw.mode))) ||
(projectId !== null && (!isOpaqueId(projectId, "prj") || !projectIds.has(projectId)))) return null;
const title = safeText(raw.title, "", 200);
if (!title || branch?.parentConversationId === raw.id) return null;
return {
id: raw.id, owner, projectId, title, tags, pinned: raw.pinned,
archived: raw.archived, artifactIds: [...artifacts] as string[], branch,
lastMessageAt: raw.last_message_at as string | undefined || null,
updatedAt: raw.updated_at,
};
}
/** Reject cross-owner/project records instead of silently re-homing them. */
export function normalizeSnapshot(
rawProjects: readonly RawProject[],
rawConversations: readonly RawConversation[],
owner: string,
): OrganizationSnapshot {
const seenProjects = new Set<string>();
const projects = rawProjects.slice(0, 100).map((item) => normalizeProject(item, owner))
.filter((item): item is Project => {
if (!item || seenProjects.has(item.id)) return false;
seenProjects.add(item.id);
return true;
});
const projectIds = new Set(projects.map((item) => item.id));
const seenConversations = new Set<string>();
const conversations = rawConversations.slice(0, 500)
.map((item) => normalizeConversation(item, owner, projectIds))
.filter((item): item is Conversation => {
if (!item || seenConversations.has(item.id)) return false;
seenConversations.add(item.id);
return true;
});
return {
projects, conversations,
rejected: Math.max(0, rawProjects.length - projects.length) +
Math.max(0, rawConversations.length - conversations.length),
};
}
/** Missing metadata stays visible as an attachment placeholder. */
export function attachmentViews(
conversation: Conversation,
summaries: readonly ArtifactSummary[],
): AttachmentView[] {
const byId = new Map(summaries.filter((item) =>
isOpaqueId(item.id, "art")).slice(0, 500).map((item) => [item.id, item]));
return conversation.artifactIds.map((id) => {
const found = byId.get(id);
return found ? {
id, title: safeText(found.title, "Untitled artifact", 200),
kind: safeText(found.kind, "artifact", 40), missing: false,
} : {id, title: "Attachment details unavailable", kind: "artifact", missing: true};
});
}
/** Build bounded ancestry with explicit missing/cycle nodes, never a fake root. */
export function branchLineage(
selectedId: string,
conversations: readonly Conversation[],
): LineageNode[] {
if (!isOpaqueId(selectedId, "conv")) return [];
const byId = new Map(conversations.slice(0, 500).map((item) => [item.id, item]));
const result: LineageNode[] = [];
const visited = new Set<string>();
let currentId: string | null = selectedId;
while (currentId && result.length < 64) {
if (visited.has(currentId)) {
result.push({conversationId: currentId, title: "Circular branch reference", parentConversationId: null,
branchPointMessageId: null, depth: result.length, missing: false, cycle: true});
break;
}
visited.add(currentId);
const item = byId.get(currentId);
if (!item) {
result.push({conversationId: currentId, title: "Earlier conversation unavailable",
parentConversationId: null, branchPointMessageId: null, depth: result.length,
missing: true, cycle: false});
break;
}
result.push({conversationId: item.id, title: item.title,
parentConversationId: item.branch?.parentConversationId || null,
branchPointMessageId: item.branch?.branchPointMessageId || null,
depth: result.length, missing: false, cycle: false});
currentId = item.branch?.parentConversationId || null;
}
return result.reverse().map((item, depth) => ({...item, depth}));
}
/** A move/rename may not change stable lineage or orphan any attachment. */
export function preservesConversation(before: Conversation, after: Conversation): boolean {
return before.id === after.id && before.owner === after.owner &&
JSON.stringify(before.branch) === JSON.stringify(after.branch) &&
before.artifactIds.length === after.artifactIds.length &&
before.artifactIds.every((id, index) => after.artifactIds[index] === id);
}