mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.
Storage (Phase 2)
@logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
model at open, serves reads synchronously — a query evaluator that awaits per
triple pattern is unusable — and buffers mutations as SQL that flush() writes
in one transaction. Versioned idempotent migrations; indexes over subject,
predicate, entity-valued object, status, both time axes, aliases, and external
ids; FTS5 for label/alias search. The append-only status log is replayed on
open, so retractions, supersessions, and merge redirects survive a reopen.
REST + SSE + OpenAPI (Phase 2)
16 paths under /api/ontologies in logicsrc-web, described at
/api/ontologies/openapi and referencing the published JSON Schemas rather
than restating them. No token is read-only; a curator token can apply; an
agent token can propose and cannot apply. Idempotency-Key on mutations,
revision ETags, 409 on a stale base revision, and an SSE stream that emits
the same event objects as the JSON endpoint.
MCP (Phase 2)
OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
proposals, never applies — the denial is the shared policy layer, not a
second rule that could drift.
Interoperability (Phase 3)
RDF/Turtle export and import of the reified profile, plus the plain triple
for asserted relationships so a consumer wanting only the accepted graph gets
one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
unmapped in both the return value and the generated Turtle, because a shape
that quietly means something narrower is worse than no shape.
Source adapters (Phase 3)
CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
PROPOSED change-set operations with source, evidence selector, run id, and
confidence attached; fetch is injected so ingestion is offline and testable.
Each declares its capabilities, so "nothing was deleted upstream" is never
confused with "this adapter cannot see deletions" — none of the seven can.
TUI + explorer
Keyboard-first panels (types, entities, claims, sources, queries, change
sets, validation, audit) as plain strings that survive SSH and 60 columns;
status is a glyph and a word, never colour alone; the key bar wraps rather
than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
at /openontology/explore with entity and claim views showing status, both
clocks, confidence, sources, evidence, and append-only history — plus an
/openprd page for the companion standard.
Bugs found and fixed while testing
- the API built a new engine per request, so `explain` could never find a
resultId from a prior request; engines are now cached per role
- the TUI status bar called engine.validateOntologyPackage(), appending a
package.validated event on every repaint; it now uses the pure validator
Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.
Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".
Refs: prd/0001-add-logicsrc-openontology-spec.md
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
296775e003
commit
da5f6f8381
53 changed files with 6939 additions and 23 deletions
|
|
@ -3,6 +3,7 @@ import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
|
|||
import { emailAccountsPlugin } from "@logicsrc/plugin-email-accounts";
|
||||
import { socialAccountsPlugin } from "@logicsrc/plugin-social-accounts";
|
||||
import { uGigPlugin } from "@logicsrc/plugin-ugig";
|
||||
export { renderOntologyTui, renderOntologyKeyHelp, PANEL_KEYS, type OntologyPanel, type OntologyTuiOptions } from "./openontology.js";
|
||||
export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js";
|
||||
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js";
|
||||
|
||||
|
|
|
|||
94
packages/tui/src/openontology.test.ts
Normal file
94
packages/tui/src/openontology.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createOntologyEngine,
|
||||
initOntologyPackage,
|
||||
loadOntologyPackage,
|
||||
localActor
|
||||
} from "@logicsrc/openontology";
|
||||
import { renderOntologyKeyHelp, renderOntologyTui, PANEL_KEYS } from "./openontology.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function engine() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tui-ontology-"));
|
||||
dirs.push(dir);
|
||||
initOntologyPackage(dir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" });
|
||||
return createOntologyEngine({
|
||||
package: loadOntologyPackage(dir),
|
||||
actor: localActor("curator"),
|
||||
clock: () => "2026-07-26T00:00:00Z"
|
||||
});
|
||||
}
|
||||
|
||||
describe("ontology TUI", () => {
|
||||
it("renders a bordered frame with the key bar and status line", () => {
|
||||
const output = renderOntologyTui(engine());
|
||||
const lines = output.split("\n");
|
||||
expect(lines[0]!.startsWith("┌")).toBe(true);
|
||||
expect(lines.at(-1)!.startsWith("└")).toBe(true);
|
||||
expect(output).toContain("e entities");
|
||||
expect(output).toContain("q quit");
|
||||
expect(output).toContain("asserted claims");
|
||||
});
|
||||
|
||||
it("never exceeds the requested width, and stays usable when narrow", () => {
|
||||
for (const width of [60, 78, 120]) {
|
||||
const lines = renderOntologyTui(engine(), { width }).split("\n");
|
||||
const widths = new Set(lines.map((line) => [...line].length));
|
||||
expect(widths.size).toBe(1);
|
||||
expect([...widths][0]).toBe(Math.max(width, 60));
|
||||
}
|
||||
});
|
||||
|
||||
it("shows every panel", () => {
|
||||
const e = engine();
|
||||
expect(renderOntologyTui(e, { panel: "types" })).toContain("Person");
|
||||
expect(renderOntologyTui(e, { panel: "entities" })).toContain("Alice Reyes");
|
||||
expect(renderOntologyTui(e, { panel: "claims" })).toContain("worksOn");
|
||||
expect(renderOntologyTui(e, { panel: "sources" })).toContain("web-page");
|
||||
expect(renderOntologyTui(e, { panel: "queries" })).toContain("contributors");
|
||||
expect(renderOntologyTui(e, { panel: "changesets" })).toContain("no change sets");
|
||||
expect(renderOntologyTui(e, { panel: "violations" })).toContain("no errors");
|
||||
expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events");
|
||||
// Rendering is read-only: repainting must not append to the audit log.
|
||||
expect(renderOntologyTui(e, { panel: "audit" })).toContain("no events");
|
||||
});
|
||||
|
||||
it("distinguishes claim status by glyph and word, not colour", () => {
|
||||
const claims = renderOntologyTui(engine(), { panel: "claims", rows: 20 });
|
||||
expect(claims).toMatch(/✓ asserted/);
|
||||
// No ANSI escapes: the panel must survive a monochrome terminal.
|
||||
expect(claims.includes("[")).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces proposed change sets and disputes in the status bar", () => {
|
||||
const e = engine();
|
||||
e.createOntologyChangeSet({
|
||||
title: "pending",
|
||||
operations: [
|
||||
{
|
||||
op: "assert-claim",
|
||||
value: {
|
||||
subject: "test:person:alice",
|
||||
predicate: "worksOn",
|
||||
object: { entity: "test:project:docs-portal" },
|
||||
sources: ["test:source:repo"]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(renderOntologyTui(e)).toContain("1 proposed");
|
||||
expect(renderOntologyTui(e, { panel: "changesets" })).toContain("pending");
|
||||
});
|
||||
|
||||
it("lists the keys it binds", () => {
|
||||
expect(renderOntologyKeyHelp()).toContain("v: validate");
|
||||
expect(PANEL_KEYS.map((entry) => entry.key)).toContain("q");
|
||||
});
|
||||
});
|
||||
282
packages/tui/src/openontology.ts
Normal file
282
packages/tui/src/openontology.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { validateOntologyPackage, type OntologyEngine, type ValidationReport } from "@logicsrc/openontology";
|
||||
|
||||
/**
|
||||
* Keyboard-first OpenOntology panels.
|
||||
*
|
||||
* Rendered as plain strings so they work over SSH, in tmux, in a narrow
|
||||
* terminal, and in tests. Graph diagrams are optional in the standard; lists,
|
||||
* trees, and paths are what this shows.
|
||||
*/
|
||||
|
||||
export type OntologyPanel = "types" | "entities" | "claims" | "sources" | "queries" | "changesets" | "violations" | "audit";
|
||||
|
||||
export const PANEL_KEYS: Array<{ key: string; panel: OntologyPanel | "quit" | "search"; label: string }> = [
|
||||
{ key: "/", panel: "search", label: "search" },
|
||||
{ key: "t", panel: "types", label: "types" },
|
||||
{ key: "e", panel: "entities", label: "entities" },
|
||||
{ key: "c", panel: "claims", label: "claims" },
|
||||
{ key: "s", panel: "sources", label: "sources" },
|
||||
{ key: "r", panel: "queries", label: "queries" },
|
||||
{ key: "g", panel: "changesets", label: "change sets" },
|
||||
{ key: "v", panel: "violations", label: "validate" },
|
||||
{ key: "a", panel: "audit", label: "audit" },
|
||||
{ key: "q", panel: "quit", label: "quit" }
|
||||
];
|
||||
|
||||
export interface OntologyTuiOptions {
|
||||
panel?: OntologyPanel;
|
||||
/** Terminal width. Clamped to a usable minimum. */
|
||||
width?: number;
|
||||
/** Rows of detail to show. */
|
||||
rows?: number;
|
||||
selected?: string;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 60;
|
||||
|
||||
export function renderOntologyTui(engine: OntologyEngine, options: OntologyTuiOptions = {}): string {
|
||||
const width = Math.max(options.width ?? 78, MIN_WIDTH);
|
||||
const rows = options.rows ?? 8;
|
||||
const panel = options.panel ?? "entities";
|
||||
const manifest = engine.getOntologyManifest();
|
||||
const schema = engine.getOntologySchema();
|
||||
|
||||
const lines: string[] = [];
|
||||
const inner = width - 2;
|
||||
|
||||
const rule = (left: string, right: string, fill = "─") => `${left}${fill.repeat(inner)}${right}`;
|
||||
const row = (text: string) => `│${clip(` ${text}`, inner)}│`;
|
||||
const heading = (text: string) => `├${clip(`─ ${text} `, inner, "─")}┤`;
|
||||
|
||||
lines.push(rule("┌", "┐"));
|
||||
lines.push(row(`OpenOntology: ${manifest.id}@${manifest.version} rev ${engine.store.revision()}`));
|
||||
// The key bar wraps rather than truncating: a binding you cannot see is a
|
||||
// binding you do not have.
|
||||
for (const line of wrap(PANEL_KEYS.map((entry) => `${entry.key} ${entry.label}`), inner - 1)) {
|
||||
lines.push(row(line));
|
||||
}
|
||||
|
||||
lines.push(heading(panelTitle(panel)));
|
||||
for (const line of panelBody(engine, panel, { rows, selected: options.selected, width: inner })) {
|
||||
lines.push(row(line));
|
||||
}
|
||||
|
||||
// Status bar: what needs a human, always visible.
|
||||
const changeSets = engine.store.listChangeSets();
|
||||
const proposed = changeSets.filter((entry) => entry.status === "proposed").length;
|
||||
const conflicted = changeSets.filter((entry) => entry.status === "conflicted").length;
|
||||
const report = validate(engine);
|
||||
const disputed = engine.store.listClaims({ status: ["disputed"] }).length;
|
||||
|
||||
lines.push(heading("status"));
|
||||
lines.push(
|
||||
row(
|
||||
`${proposed} proposed ${disputed} disputed ${conflicted} conflicts ` +
|
||||
`${report.counts.error} errors ${report.counts.warning} warnings`
|
||||
)
|
||||
);
|
||||
lines.push(row(`${schema.entityTypes.length} types ${engine.store.listEntities().length} entities ${engine.store.listClaims({ status: ["asserted"] }).length} asserted claims`));
|
||||
lines.push(rule("└", "┘"));
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate without emitting an event.
|
||||
*
|
||||
* `engine.validateOntologyPackage()` records a package.validated event, which
|
||||
* is right for a CLI run and wrong for a panel that repaints on every keypress:
|
||||
* rendering a read-only view must not write to the audit log.
|
||||
*/
|
||||
function validate(engine: OntologyEngine): ValidationReport {
|
||||
return validateOntologyPackage({
|
||||
manifest: engine.getOntologyManifest(),
|
||||
schema: engine.getOntologySchema(),
|
||||
data: {
|
||||
entities: engine.store.listEntities(),
|
||||
claims: engine.store.listClaims({
|
||||
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"]
|
||||
}),
|
||||
sources: engine.store.listSources(),
|
||||
evidence: engine.store.listEvidence()
|
||||
},
|
||||
files: []
|
||||
});
|
||||
}
|
||||
|
||||
function panelTitle(panel: OntologyPanel): string {
|
||||
switch (panel) {
|
||||
case "types":
|
||||
return "Types";
|
||||
case "entities":
|
||||
return "Entities";
|
||||
case "claims":
|
||||
return "Claims";
|
||||
case "sources":
|
||||
return "Sources";
|
||||
case "queries":
|
||||
return "Saved queries";
|
||||
case "changesets":
|
||||
return "Change sets";
|
||||
case "violations":
|
||||
return "Validation";
|
||||
default:
|
||||
return "Audit";
|
||||
}
|
||||
}
|
||||
|
||||
function panelBody(
|
||||
engine: OntologyEngine,
|
||||
panel: OntologyPanel,
|
||||
view: { rows: number; selected?: string; width: number }
|
||||
): string[] {
|
||||
const { rows } = view;
|
||||
|
||||
switch (panel) {
|
||||
case "types": {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entity of engine.store.listEntities()) {
|
||||
counts.set(entity.type, (counts.get(entity.type) ?? 0) + 1);
|
||||
}
|
||||
return engine
|
||||
.getOntologySchema()
|
||||
.entityTypes.slice(0, rows)
|
||||
.map((type) => `${pad(type.id, 20)} ${String(counts.get(type.id) ?? 0).padStart(5)} ${type.label}`);
|
||||
}
|
||||
|
||||
case "entities": {
|
||||
const entities = engine.store.listEntities({ limit: rows });
|
||||
return entities.map(
|
||||
(entity) =>
|
||||
`${statusMark(entity.status ?? "active")} ${pad(entity.canonicalName, 26)} ${pad(entity.type, 14)} ${entity.id}`
|
||||
);
|
||||
}
|
||||
|
||||
case "claims": {
|
||||
const claims = engine.store.listClaims({
|
||||
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"],
|
||||
limit: rows
|
||||
});
|
||||
return claims.map((claim) => {
|
||||
const object = "entity" in claim.object ? claim.object.entity : JSON.stringify(claim.object.value);
|
||||
const confidence = claim.confidence === undefined ? " — " : claim.confidence.toFixed(2);
|
||||
const from = claim.validTime?.from?.slice(0, 10) ?? "—";
|
||||
// Status is never conveyed by colour alone: a glyph and the word.
|
||||
return `${statusMark(claim.status)} ${pad(claim.status, 10)} ${pad(shorten(claim.subject), 22)} ${pad(claim.predicate, 14)} ${pad(shorten(object), 22)} ${confidence} ${from} src:${claim.sources?.length ?? 0}`;
|
||||
});
|
||||
}
|
||||
|
||||
case "sources": {
|
||||
return engine.store
|
||||
.listSources()
|
||||
.slice(0, rows)
|
||||
.map(
|
||||
(source) =>
|
||||
`${source.stale ? "!" : "·"} ${pad(source.sourceType, 14)} ${pad(source.license ?? "unknown", 12)} ${source.uri}`
|
||||
);
|
||||
}
|
||||
|
||||
case "queries": {
|
||||
return engine
|
||||
.getOntologySchema()
|
||||
.queries.slice(0, rows)
|
||||
.map((query) => `${pad(query.id, 30)} ${query.description}`);
|
||||
}
|
||||
|
||||
case "changesets": {
|
||||
const changeSets = engine.store.listChangeSets().slice(0, rows);
|
||||
if (changeSets.length === 0) return ["(no change sets in this session)"];
|
||||
return changeSets.map((changeSet) => {
|
||||
const approvals = engine.store.listApprovals(changeSet.id).length;
|
||||
const required = changeSet.requiredApprovals ?? 0;
|
||||
return `${statusMark(changeSet.status)} ${pad(changeSet.status, 10)} ${pad(changeSet.title, 34)} ops:${String(changeSet.operations.length).padStart(3)} appr:${approvals}/${required}`;
|
||||
});
|
||||
}
|
||||
|
||||
case "violations": {
|
||||
const report = validate(engine);
|
||||
const findings = report.findings.filter((finding) => finding.severity !== "info").slice(0, rows);
|
||||
if (findings.length === 0) return ["✓ no errors, warnings, or policy findings"];
|
||||
return findings.map(
|
||||
(finding) => `${severityMark(finding.severity)} ${pad(finding.code, 24)} ${finding.message}`
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
const events = engine.listEvents({ limit: rows });
|
||||
if (events.length === 0) return ["(no events in this session)"];
|
||||
return events.map(
|
||||
(event) => `${event.at.slice(0, 19)} ${pad(event.type, 20)} ${pad(event.actor, 20)} ${event.subject ?? ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Glyphs, not colour, so the status survives a monochrome terminal. */
|
||||
function statusMark(status: string): string {
|
||||
switch (status) {
|
||||
case "asserted":
|
||||
case "active":
|
||||
case "applied":
|
||||
return "✓";
|
||||
case "proposed":
|
||||
return "?";
|
||||
case "disputed":
|
||||
case "conflicted":
|
||||
return "!";
|
||||
case "retracted":
|
||||
case "rejected":
|
||||
return "×";
|
||||
case "superseded":
|
||||
case "merged":
|
||||
return "→";
|
||||
case "derived":
|
||||
return "ƒ";
|
||||
case "archived":
|
||||
return "▪";
|
||||
default:
|
||||
return "·";
|
||||
}
|
||||
}
|
||||
|
||||
function severityMark(severity: string): string {
|
||||
return severity === "error" ? "✗" : severity === "warning" ? "!" : "·";
|
||||
}
|
||||
|
||||
/** Pack items onto as many lines as the width needs. */
|
||||
function wrap(items: string[], width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const item of items) {
|
||||
const candidate = current ? `${current} ${item}` : item;
|
||||
if (candidate.length > width && current) {
|
||||
lines.push(current);
|
||||
current = item;
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function pad(value: string, width: number): string {
|
||||
const text = String(value ?? "");
|
||||
return text.length >= width ? `${text.slice(0, Math.max(width - 1, 1))}…` : text.padEnd(width);
|
||||
}
|
||||
|
||||
function clip(value: string, width: number, fill = " "): string {
|
||||
const text = value.length > width ? `${value.slice(0, width - 1)}…` : value;
|
||||
return text.padEnd(width, fill);
|
||||
}
|
||||
|
||||
/** Compact ids read better in a narrow column without their prefix. */
|
||||
function shorten(id: string): string {
|
||||
const parts = String(id).split(":");
|
||||
return parts.length > 2 ? parts.slice(1).join(":") : String(id);
|
||||
}
|
||||
|
||||
/** One-line help for the panel keys, for a footer or `--help`. */
|
||||
export function renderOntologyKeyHelp(): string {
|
||||
return PANEL_KEYS.map((entry) => `${entry.key}: ${entry.label}`).join(" ");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue