3 Commits

Author SHA1 Message Date
4c4afc4783 fix(web): fit graph to node bounding box once the simulation settles
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Fresh nodes (no prior x/y) get placed by d3-force's default init, which
spirals out from the ORIGIN — not (width/2, height/2) — while the
centering forces here are deliberately weak (0.04, so they don't fight
the link/collide layout) and alphaDecay stops the sim before a weak force
can always pull a far-off cluster back to center. Net effect: graphs could
settle visibly off-center on load, cramped in a corner of the pane.

Fixed by computing the actual node bounding box once the simulation's
'end' event fires and setting the view transform to fit it, instead of
relying on the force balance to land on center by itself. Gated behind a
`fit` flag so passive background reloads (live entity/relationship
events) don't yank the view out from under someone actively panning or
zoomed in on a specific area — only fresh loads (mount, root/depth
change, reset, re-root) reframe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:56 +02:00
604b608fa8 feat(mcp): expose full knowledge content to the agent, not just snippets
search_knowledge and get_entity_knowledge only ever returned a ts_headline
snippet/short headline — enough to find a note, not enough to act on it.
Add get_knowledge_content(slug), mirroring the web UI's
/api/v1/knowledge/content/{id}, so the agent can read a document/
investigation/runbook's full markdown body once it knows which one it
needs. upsert_knowledge already covered the write side. Cross-referenced
all three tool descriptions so the agent discovers the full-read path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:37 +02:00
62a8ec1d8d fix(mcp): write targets/involves relationship edges when executions are created
Executions were being created with no outgoing edges to what they acted
on or which task/session drove them, silently starving the graph of new
data going forward — found during this session's DB audit, which had to
backfill 245+25 missing targets/involves edges for existing executions.
This closes the gap at the source: every execution now gets a
target-->targets-->execution edge, and (when the caller supplies a
session/task) a task-->involves-->execution edge, both idempotent
(NOT EXISTS guards) so retries/backfills don't duplicate.

Two call sites: the deduped systemctl/apt_upgrade/pct_create fast path
and the general classifyAndGate path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:45:44 +02:00
2 changed files with 84 additions and 5 deletions

View File

@@ -149,7 +149,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
})
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)",
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
@@ -168,7 +168,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
LIMIT 20`, q), nil
})
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity",
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
@@ -196,7 +196,19 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
ORDER BY 1`, slug), nil
})
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.",
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
return queryRows(ctx, pool, `
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1`, slug), nil
})
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
InputSchema: objSchema(
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
@@ -428,6 +440,23 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
id, targetID, action+":"+params, correlationID, agentID)
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
id, targetID)
if sessionID != "" {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
FROM entities t WHERE t.slug = $2
AND NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
}
// Execute reversible actions immediately. restart/pct_exec/systemctl
// (outside enable/disable) never reach here — they're routed through
@@ -1397,6 +1426,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
}
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
id, targetID, actionCol, riskClass, correlationID, agentID)
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
id, targetID)
if sessionID != "" {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
FROM entities t WHERE t.slug = $2
AND NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
}
if riskClass == policy.RiskReadOnly {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)

View File

@@ -120,7 +120,37 @@
return typeCategory.get(type) === category
}
async function load() {
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
// default init spreads those via a spiral centered on the ORIGIN — not
// (width/2, height/2) — while the x/y centering forces below are
// deliberately weak (0.04, so they don't fight the link/collide layout).
// Together that meant the cluster could settle noticeably off-origin
// instead of centered. Fixed by explicitly fitting the viewport to the
// node bounding box once the simulation settles, rather than relying on
// the force balance to land on center by itself.
function fitToView() {
const placed = nodes.filter((n) => n.x != null && n.y != null)
if (!placed.length) return
const xs = placed.map((n) => n.x as number)
const ys = placed.map((n) => n.y as number)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 70
const bw = Math.max(maxX - minX, 1)
const bh = Math.max(maxY - minY, 1)
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
}
// fit=false for passive background reloads (live entity/relationship
// events) — those shouldn't yank the view out from under someone
// actively panning/zooming. Fresh loads (mount, root/depth change,
// reset, re-root) default to fit=true.
async function load(fit = true) {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
@@ -163,6 +193,9 @@
.on('tick', () => {
nodes = [...nodes]
})
.on('end', () => {
if (fit) fitToView()
})
}
onMount(() => {
@@ -191,7 +224,7 @@
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load()
load(false)
}
})