@medicine-wheel/mcp 4.5.7 → 4.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/all-tools.d.ts.map +1 -1
  2. package/dist/all-tools.js +2 -0
  3. package/dist/all-tools.js.map +1 -1
  4. package/dist/http-store.d.ts +1 -0
  5. package/dist/http-store.d.ts.map +1 -1
  6. package/dist/http-store.js +3 -0
  7. package/dist/http-store.js.map +1 -1
  8. package/dist/jsonl-store.d.ts +9 -0
  9. package/dist/jsonl-store.d.ts.map +1 -1
  10. package/dist/jsonl-store.js +5 -0
  11. package/dist/jsonl-store.js.map +1 -1
  12. package/dist/tools/ceremony-lifecycle.d.ts.map +1 -1
  13. package/dist/tools/ceremony-lifecycle.js +56 -6
  14. package/dist/tools/ceremony-lifecycle.js.map +1 -1
  15. package/dist/tools/coordination.d.ts.map +1 -1
  16. package/dist/tools/coordination.js +132 -5
  17. package/dist/tools/coordination.js.map +1 -1
  18. package/dist/tools/discovery.d.ts.map +1 -1
  19. package/dist/tools/discovery.js +12 -4
  20. package/dist/tools/discovery.js.map +1 -1
  21. package/dist/tools/epistemic.js +2 -2
  22. package/dist/tools/epistemic.js.map +1 -1
  23. package/dist/tools/governance-transformation.js +2 -2
  24. package/dist/tools/governance-transformation.js.map +1 -1
  25. package/dist/tools/infrastructure.d.ts +42 -0
  26. package/dist/tools/infrastructure.d.ts.map +1 -0
  27. package/dist/tools/infrastructure.js +1066 -0
  28. package/dist/tools/infrastructure.js.map +1 -0
  29. package/dist/tools/integrations.d.ts.map +1 -1
  30. package/dist/tools/integrations.js +119 -17
  31. package/dist/tools/integrations.js.map +1 -1
  32. package/dist/tools/plan-perspectives.d.ts.map +1 -1
  33. package/dist/tools/plan-perspectives.js +1 -10
  34. package/dist/tools/plan-perspectives.js.map +1 -1
  35. package/dist/tools/structural-tension.js +10 -10
  36. package/dist/tools/structural-tension.js.map +1 -1
  37. package/package.json +18 -17
@@ -0,0 +1,1066 @@
1
+ /**
2
+ * Infrastructure Tools — hosts, tenants, services and the ports they claim.
3
+ *
4
+ * `@medicine-wheel/infra` shipped its facets, its schemas and `detectPortConflicts`
5
+ * and was imported by nothing: the vocabulary existed and no agent could reach it.
6
+ * This file is the door. Every tool here is backed by a type or a pure function
7
+ * already written in that package; none of them widens the ontology.
8
+ *
9
+ * Three commitments this module makes, each of which is a defect if broken:
10
+ *
11
+ * 1. **The tool emits `metadata.kind`, never the agent.** The precedent is stated
12
+ * in `ontology-core/src/types.ts`: new kinds ride existing `knowledge`/`land`/
13
+ * `human` nodes carrying a discriminator. Hand-typing that discriminator is how
14
+ * it gets misspelled once and the node becomes unfindable forever.
15
+ * 2. **Every facet is `safeParse`d before it is stored.** A legible-but-stale model
16
+ * lies with a straight face; a port of `0` is rejected here rather than silently
17
+ * never colliding with anything downstream.
18
+ * 3. **Writes are awaited.** `HttpStore.createNode` returns a promise precisely so
19
+ * a caller can learn the truth rather than being told "registered" over a
20
+ * request that 404'd.
21
+ *
22
+ * Registration is idempotent by identity, not by id: a host is its hostname, a
23
+ * tenant is its account on its host, a service is its unit under its owner.
24
+ * Re-registering updates in place and says `updated`. Without that, an agent
25
+ * re-running a provisioning step fills the wheel with duplicate services, and the
26
+ * port-conflict traversal then reports every service as colliding with itself.
27
+ *
28
+ * **Update means merge, never replace.** A re-registration that rebuilt the facet
29
+ * from its arguments alone would silently drop every field the caller did not
30
+ * re-supply — including the `metis` some person put there with `hold_metis`. A
31
+ * package whose stated reason to exist is that the grid must not flatten what
32
+ * people know cannot have its most ordinary operation delete it. Prior fields
33
+ * survive unless the caller passes a new value for them.
34
+ *
35
+ * @see src/infra/src/types.ts — the facets
36
+ * @see src/infra/src/ports.ts — detectPortConflicts
37
+ * @see src/infra/src/preconditions.ts — preconditionGuard / readyService
38
+ * @see src/infra/src/reconcile.ts — level-triggered drift
39
+ */
40
+ import { HostFacetSchema, TenantFacetSchema, ServiceFacetSchema, MetisHoldSchema, ObservedStateSchema, detectPortConflicts, reconcile, } from "@medicine-wheel/infra";
41
+ import { INFRA_ENTITY_BINDING } from "@medicine-wheel/ontology-core";
42
+ import { store } from "../store.js";
43
+ /** Every node in the wheel carrying an infrastructure discriminator. */
44
+ async function loadInfraNodes(kind) {
45
+ const all = (await store.getAllNodes());
46
+ return all.filter((n) => {
47
+ const k = n.metadata?.kind;
48
+ if (typeof k !== "string")
49
+ return false;
50
+ return kind ? k === kind : k === "host" || k === "tenant" || k === "service";
51
+ });
52
+ }
53
+ /**
54
+ * Find an already-registered node by the identity its kind is defined on.
55
+ *
56
+ * Identity is deliberately not the node id. An agent provisioning a host does
57
+ * not know the id the wheel minted last time; it knows the hostname. Matching on
58
+ * what the caller actually holds is what makes re-registration an update instead
59
+ * of a duplicate.
60
+ */
61
+ async function findByIdentity(kind, identity) {
62
+ const candidates = await loadInfraNodes(kind);
63
+ return candidates.find((n) => {
64
+ const facet = n.metadata?.facet;
65
+ if (!facet)
66
+ return false;
67
+ return Object.entries(identity).every(([field, value]) => facet[field] === value);
68
+ });
69
+ }
70
+ /** Make an identity component safe to sit inside a colon-delimited node id. */
71
+ function slug(value) {
72
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
73
+ }
74
+ /**
75
+ * Derive a node id **from the identity itself**, not from a clock and a die.
76
+ *
77
+ * The repo-wide shape elsewhere is `node:<type>:<ms>:<random>`. That shape cannot
78
+ * be used here, and the reason is a race rather than a preference:
79
+ * `findByIdentity` then `createNode` is read-then-write. Two concurrent
80
+ * `register_host({hostname:'eury'})` calls each find nothing, each mint a
81
+ * *different* random id, and both succeed — leaving two nodes named eury. A
82
+ * service on :9081 under a tenant on copy A and another on :9081 under a tenant
83
+ * on copy B are then, correctly by the data and catastrophically in fact,
84
+ * **not** in conflict. The traversal this package exists for goes quiet exactly
85
+ * when it matters. An `await` on the synchronous JSONL store still yields a
86
+ * microtask tick, so this is not an HTTP-only hazard.
87
+ *
88
+ * A deterministic id makes the duplicate collapse onto one record instead of
89
+ * racing: both writers compute the same key and the second is an update. It also
90
+ * makes the ids readable, which is its own small mercy.
91
+ *
92
+ * Nodes registered before this existed keep their minted ids — `findByIdentity`
93
+ * runs first and returns them, so nothing needs migrating.
94
+ */
95
+ function identityId(kind, identity) {
96
+ return `node:${INFRA_ENTITY_BINDING[kind].nodeType}:${kind}:${slug(identity)}`;
97
+ }
98
+ /** The shape every register_* tool returns, so a caller can branch on one field. */
99
+ function registered(outcome, node, facet, teaching) {
100
+ return {
101
+ status: outcome,
102
+ node_id: node.id,
103
+ kind: node.metadata?.kind,
104
+ node,
105
+ facet,
106
+ message: `${node.metadata?.kind} ${outcome}: ${node.name}`,
107
+ teaching,
108
+ };
109
+ }
110
+ /** Uniform failure shape — a rejected parse names the fields, never just "invalid". */
111
+ function invalid(kind, error) {
112
+ return {
113
+ status: "error",
114
+ message: `${kind} facet failed validation — nothing was written`,
115
+ issues: error.issues.map((i) => ({ field: i.path.join("."), problem: i.message })),
116
+ teaching: "A legible-but-stale model lies with a straight face. The parse is where that gets caught.",
117
+ };
118
+ }
119
+ function errorOf(error, action) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ return { status: "error", message: `Failed to ${action}: ${message}`, error: message };
122
+ }
123
+ /**
124
+ * The facet already stored on a node, as the base a re-registration merges onto.
125
+ *
126
+ * Returns `{}` for a node that has none, so a first registration and an update
127
+ * take the same code path.
128
+ */
129
+ function priorFacet(existing) {
130
+ return (existing?.metadata?.facet ?? {});
131
+ }
132
+ /**
133
+ * One relational edge, shaped so the store's own contract is satisfied.
134
+ *
135
+ * `ceremony_honored` is set explicitly. `StoredEdge` declares it required and
136
+ * `create_relational_edge` writes `false`; omitting it here would have sorted
137
+ * every infrastructure edge into a third state for any consumer testing
138
+ * `=== false` rather than falsiness.
139
+ *
140
+ * The `id` is what keeps two claims from collapsing into one — see the call site
141
+ * for `binds-port`.
142
+ */
143
+ function infraEdge(id, fromId, toId, relationshipType, obligations, createdAt, metadata) {
144
+ return {
145
+ id,
146
+ from_id: fromId,
147
+ to_id: toId,
148
+ relationship_type: relationshipType,
149
+ strength: 1,
150
+ obligations,
151
+ ceremony_honored: false,
152
+ ...(metadata ? { metadata } : {}),
153
+ created_at: createdAt,
154
+ };
155
+ }
156
+ /**
157
+ * Resolve a host node id or hostname to a registered host node id.
158
+ *
159
+ * There is exactly one of these on purpose. Three call sites previously each
160
+ * wrote their own, two of which fell back to `?? h` — meaning an unregistered
161
+ * hostname silently became its own host key and could therefore never collide
162
+ * with anything. `detect_port_conflicts` would answer "No slot is claimed twice"
163
+ * over a real collision because the observer named a machine the wheel had never
164
+ * been told about. An unresolved host is a fact the caller has to be handed, not
165
+ * a value to invent.
166
+ */
167
+ function makeHostResolver(hosts) {
168
+ return (reference) => {
169
+ if (!reference)
170
+ return undefined;
171
+ if (reference.startsWith("node:")) {
172
+ return hosts.some((n) => n.id === reference)
173
+ ? { id: reference }
174
+ : { unresolved: reference };
175
+ }
176
+ const found = hosts.find((n) => n.metadata?.facet?.hostname === reference);
177
+ return found ? { id: found.id } : { unresolved: reference };
178
+ };
179
+ }
180
+ export const infrastructureTools = [
181
+ // ── register_host ─────────────────────────────────────────────────────────
182
+ {
183
+ name: "register_host",
184
+ description: "Register a machine as a `land` node carrying a typed HostFacet. The tool emits " +
185
+ "metadata.kind='host' rather than the caller hand-typing it, and validates the facet " +
186
+ "with HostFacetSchema before anything is written. Re-registering the same hostname " +
187
+ "MERGES onto what is already stored — fields you do not pass keep their previous value, " +
188
+ "including any métis held on this host. A host carries no direction of its own; it is " +
189
+ "the ground the tenants and services stand on.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ hostname: { type: "string", description: "Short hostname — 'gaia', 'eury', 'ilex'" },
194
+ fqdn: { type: "string", description: "Fully-qualified name (optional)" },
195
+ os: { type: "string", description: "Operating system (optional)" },
196
+ reachable_via: {
197
+ type: "array",
198
+ items: { type: "string", enum: ["lan", "tailnet", "cloudflare", "ngrok"] },
199
+ description: "How this host can be reached (optional)",
200
+ },
201
+ description: { type: "string", description: "What this machine is for (optional)" },
202
+ metis: {
203
+ type: "object",
204
+ description: "Tacit operator knowledge this host holds — exceptions, invisible work, notes, heldBy. " +
205
+ "heldBy should name a person.",
206
+ },
207
+ },
208
+ required: ["hostname"],
209
+ },
210
+ handler: async (args) => {
211
+ try {
212
+ const { hostname, fqdn, os, reachable_via, description, metis } = args;
213
+ const existing = await findByIdentity("host", { hostname });
214
+ const nodeType = INFRA_ENTITY_BINDING.host.nodeType;
215
+ const id = existing?.id ?? identityId("host", hostname);
216
+ const facet = {
217
+ ...priorFacet(existing),
218
+ nodeId: id,
219
+ hostname,
220
+ ...(fqdn ? { fqdn } : {}),
221
+ ...(os ? { os } : {}),
222
+ ...(reachable_via ? { reachableVia: reachable_via } : {}),
223
+ ...(metis ? { metis } : {}),
224
+ };
225
+ const parsed = HostFacetSchema.safeParse(facet);
226
+ if (!parsed.success)
227
+ return invalid("host", parsed.error);
228
+ const now = new Date().toISOString();
229
+ const node = {
230
+ id,
231
+ type: nodeType,
232
+ name: hostname,
233
+ description: description ?? existing?.description ?? `Host ${hostname}`,
234
+ metadata: { ...(existing?.metadata ?? {}), kind: "host", facet: parsed.data },
235
+ created_at: existing?.created_at ?? now,
236
+ updated_at: now,
237
+ };
238
+ await store.createNode(node);
239
+ return registered(existing ? "updated" : "created", node, parsed.data, "A machine is land. It is not a namespace — it is the ground a tenant stands on.");
240
+ }
241
+ catch (error) {
242
+ return errorOf(error, "register host");
243
+ }
244
+ },
245
+ },
246
+ // ── register_tenant ───────────────────────────────────────────────────────
247
+ {
248
+ name: "register_tenant",
249
+ description: "Register a unix account as a `human` node carrying a typed TenantFacet — an account is " +
250
+ "a being, not a namespace. `linger` is recorded as a MACHINE FACT with no authority and " +
251
+ "no ability to withdraw; it is never consent. The human authorization to run the root " +
252
+ "step that set it is a ConsentRecord in consent-lifecycle, referenced separately by " +
253
+ "mw_enforce_gate. Tenants serve South: readiness is a precondition. Re-registering MERGES — " +
254
+ "omitting `linger` keeps the value last observed rather than resetting it to 'unknown'.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ account: { type: "string", description: "Unix account — 'gmusic', 'ava', 'mia'" },
259
+ on_host: {
260
+ type: "string",
261
+ description: "Host node id, or the hostname of an already-registered host",
262
+ },
263
+ uid: { type: "number", description: "Numeric uid (optional)" },
264
+ home: { type: "string", description: "Home directory (optional)" },
265
+ linger: {
266
+ type: "string",
267
+ enum: ["enabled", "disabled", "unknown"],
268
+ description: "Whether user units survive logout. A machine fact read at runtime — NOT consent. " +
269
+ "Default 'unknown', which is honest when nobody has looked.",
270
+ },
271
+ description: { type: "string" },
272
+ metis: { type: "object", description: "Tacit knowledge, with heldBy naming a person" },
273
+ },
274
+ required: ["account", "on_host"],
275
+ },
276
+ handler: async (args) => {
277
+ try {
278
+ const { account, on_host, uid, home, description, metis } = args;
279
+ // Accept a hostname where a node id is expected — the caller knows the
280
+ // machine by its name, and refusing that would make the tool answerable
281
+ // only to something that had already called register_host in this session.
282
+ // A `node:`-prefixed value is checked for existence too: taking it on
283
+ // trust let a tenant be registered onto a host that was never created,
284
+ // which then owns services nobody can see in any per-host reading.
285
+ const resolveHost = makeHostResolver(await loadInfraNodes("host"));
286
+ const resolved = resolveHost(on_host);
287
+ if (!resolved || "unresolved" in resolved) {
288
+ return {
289
+ status: "error",
290
+ message: `No registered host '${on_host}'. Call register_host first, or pass the node id of a ` +
291
+ `host that exists — a tenant on a host the wheel has never been told about is a tenant ` +
292
+ `whose services no per-host reading will ever show.`,
293
+ };
294
+ }
295
+ const hostNodeId = resolved.id;
296
+ const existing = await findByIdentity("tenant", { account, onHost: hostNodeId });
297
+ const nodeType = INFRA_ENTITY_BINDING.tenant.nodeType;
298
+ const id = existing?.id ?? identityId("tenant", `${account}@${hostNodeId}`);
299
+ const prior = priorFacet(existing);
300
+ const facet = {
301
+ ...prior,
302
+ nodeId: id,
303
+ account,
304
+ onHost: hostNodeId,
305
+ // A caller that does not mention linger has not observed it. Keep what
306
+ // was last read rather than overwriting a measured 'enabled' with the
307
+ // schema default 'unknown' — an update must not un-know a fact.
308
+ linger: args.linger ?? prior.linger ?? "unknown",
309
+ ...(uid !== undefined ? { uid } : {}),
310
+ ...(home ? { home } : {}),
311
+ ...(metis ? { metis } : {}),
312
+ };
313
+ const parsed = TenantFacetSchema.safeParse(facet);
314
+ if (!parsed.success)
315
+ return invalid("tenant", parsed.error);
316
+ const now = new Date().toISOString();
317
+ const node = {
318
+ id,
319
+ type: nodeType,
320
+ name: account,
321
+ description: description ?? existing?.description ?? `Tenant ${account} on ${on_host}`,
322
+ direction: INFRA_ENTITY_BINDING.tenant.direction,
323
+ metadata: { ...(existing?.metadata ?? {}), kind: "tenant", facet: parsed.data },
324
+ created_at: existing?.created_at ?? now,
325
+ updated_at: now,
326
+ };
327
+ await store.createNode(node);
328
+ await store.createEdge(infraEdge(`edge:part-of:${id}:${hostNodeId}`, id, hostNodeId, "part-of", ["land"], now));
329
+ return registered(existing ? "updated" : "created", node, parsed.data, "linger is a checkbox. Consent is a relationship that can be withdrawn. Never read one as the other.");
330
+ }
331
+ catch (error) {
332
+ return errorOf(error, "register tenant");
333
+ }
334
+ },
335
+ },
336
+ // ── register_service ──────────────────────────────────────────────────────
337
+ {
338
+ name: "register_service",
339
+ description: "Register a systemd unit (or any running service) as a `knowledge` node carrying a typed " +
340
+ "ServiceFacet, with its port claims. Serves West — the thing that executes. The tool emits " +
341
+ "metadata.kind='service' and validates with ServiceFacetSchema; a port outside 1-65535 is " +
342
+ "rejected here rather than silently never colliding downstream. Re-registering the same " +
343
+ "unit under the same owner MERGES onto what is stored — fields you omit keep their previous " +
344
+ "value, including métis and, when `ports` is omitted entirely, the existing port claims. " +
345
+ "Emits a `part-of` edge to the owning tenant and one `binds-port` edge per declared port.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ unit: { type: "string", description: "Unit name — 'assembly-mux.service'" },
350
+ owned_by: {
351
+ type: "string",
352
+ description: "Tenant node id, or the account name of an already-registered tenant",
353
+ },
354
+ scope: {
355
+ type: "string",
356
+ enum: ["user", "system"],
357
+ description: "Which systemd manager owns the unit. Default 'user'.",
358
+ },
359
+ ports: {
360
+ type: "array",
361
+ description: "Port claims. `host` may be a host node id or a hostname.",
362
+ items: {
363
+ type: "object",
364
+ properties: {
365
+ port: { type: "number" },
366
+ proto: { type: "string", enum: ["tcp", "udp"] },
367
+ host: { type: "string" },
368
+ },
369
+ required: ["port"],
370
+ },
371
+ },
372
+ working_directory: {
373
+ type: "string",
374
+ description: "Where the unit reads its environment from (jgwill/gaia#74)",
375
+ },
376
+ exec_stop: {
377
+ type: "string",
378
+ description: "How to stop it — 'docker compose stop', never 'down' if volumes matter",
379
+ },
380
+ url: { type: "string", description: "Where a person reaches it (optional)" },
381
+ description: { type: "string" },
382
+ metis: { type: "object", description: "Tacit knowledge, with heldBy naming a person" },
383
+ },
384
+ required: ["unit", "owned_by"],
385
+ },
386
+ handler: async (args) => {
387
+ try {
388
+ const { unit, owned_by, working_directory, exec_stop, url, description, metis } = args;
389
+ // NOT defaulted to []. An omitted `ports` means "I am not saying anything
390
+ // about ports", and defaulting would turn that into "this service claims
391
+ // nothing" — silently releasing every slot it holds.
392
+ const ports = args.ports;
393
+ // A `node:`-prefixed owner is checked for existence, not taken on trust.
394
+ // An unchecked one produced services owned by nobody: invisible in every
395
+ // topology reading (no tenant contains them) while their ports still
396
+ // counted in the conflict traversal.
397
+ const tenants = await loadInfraNodes("tenant");
398
+ const tenant = String(owned_by).startsWith("node:")
399
+ ? tenants.find((n) => n.id === owned_by)
400
+ : tenants.find((n) => n.metadata?.facet?.account === owned_by);
401
+ if (!tenant) {
402
+ return {
403
+ status: "error",
404
+ message: `No registered tenant '${owned_by}'. Call register_tenant first, or pass the node id of ` +
405
+ `a tenant that exists — a service owned by nobody appears in no topology reading while ` +
406
+ `its ports still occupy slots.`,
407
+ };
408
+ }
409
+ const tenantNodeId = tenant.id;
410
+ // The host a port is scarce ON. Falls back to the owning tenant's host —
411
+ // the common case is a service claiming a port on the machine its owner
412
+ // lives on, and making the caller repeat that invites them to get it wrong.
413
+ const defaultHost = tenant.metadata?.facet?.onHost;
414
+ const resolveHost = makeHostResolver(await loadInfraNodes("host"));
415
+ const existing = await findByIdentity("service", { unit, ownedBy: tenantNodeId });
416
+ const nodeType = INFRA_ENTITY_BINDING.service.nodeType;
417
+ const id = existing?.id ?? identityId("service", `${unit}@${tenantNodeId}`);
418
+ const prior = priorFacet(existing);
419
+ let bindings;
420
+ if (ports === undefined) {
421
+ // Keep the claims already on record, re-pointed at this id in case the
422
+ // node was minted in this same call.
423
+ bindings = (prior.ports ?? []).map((b) => ({ ...b, boundBy: id }));
424
+ }
425
+ else {
426
+ bindings = [];
427
+ for (const p of ports) {
428
+ const target = p.host ? resolveHost(p.host) : defaultHost ? { id: defaultHost } : undefined;
429
+ if (!target || "unresolved" in target) {
430
+ return {
431
+ status: "error",
432
+ message: `Port ${p.port} names host '${p.host ?? "(none)"}' which is not registered, and the ` +
433
+ `owning tenant declares no host to fall back to. Ports are scarce per host — a claim ` +
434
+ `on a host the wheel does not know cannot be checked against anything, and would ` +
435
+ `read as free forever.`,
436
+ };
437
+ }
438
+ bindings.push({
439
+ port: p.port,
440
+ ...(p.proto ? { proto: p.proto } : {}),
441
+ host: target.id,
442
+ boundBy: id,
443
+ });
444
+ }
445
+ }
446
+ const facet = {
447
+ ...prior,
448
+ nodeId: id,
449
+ unit,
450
+ scope: args.scope ?? prior.scope ?? "user",
451
+ ownedBy: tenantNodeId,
452
+ ports: bindings,
453
+ ...(working_directory ? { workingDirectory: working_directory } : {}),
454
+ ...(exec_stop ? { execStop: exec_stop } : {}),
455
+ ...(metis ? { metis } : {}),
456
+ };
457
+ const parsed = ServiceFacetSchema.safeParse(facet);
458
+ if (!parsed.success)
459
+ return invalid("service", parsed.error);
460
+ const now = new Date().toISOString();
461
+ const node = {
462
+ id,
463
+ type: nodeType,
464
+ name: unit,
465
+ description: description ?? existing?.description ?? `Service ${unit}`,
466
+ direction: INFRA_ENTITY_BINDING.service.direction,
467
+ metadata: {
468
+ ...(existing?.metadata ?? {}),
469
+ kind: "service",
470
+ facet: parsed.data,
471
+ ...(url ? { url } : {}),
472
+ },
473
+ created_at: existing?.created_at ?? now,
474
+ updated_at: now,
475
+ };
476
+ await store.createNode(node);
477
+ await store.createEdge(infraEdge(`edge:part-of:${id}:${tenantNodeId}`, id, tenantNodeId, "part-of", ["land"], now));
478
+ for (const b of parsed.data.ports) {
479
+ // The id must include the SLOT, not just the pair. `EdgeCollection`
480
+ // keys on `id || from_id:to_id`, so a service binding :80 and :443 on
481
+ // one host wrote two edges that collapsed into one — and anything
482
+ // walking the governed vocabulary read a single claim where two exist.
483
+ await store.createEdge(infraEdge(`edge:binds-port:${id}:${b.host}:${b.proto ?? "tcp"}:${b.port}`, id, b.host, "binds-port", ["land", "human"], now, { port: b.port, proto: b.proto ?? "tcp" }));
484
+ }
485
+ // Report the collision at registration, where it is still cheap.
486
+ const all = (await loadInfraNodes("service")).flatMap((n) => (n.metadata?.facet?.ports ?? []));
487
+ const conflicts = detectPortConflicts(all);
488
+ const mine = conflicts.filter((c) => c.claimants.includes(id));
489
+ return {
490
+ ...registered(existing ? "updated" : "created", node, parsed.data, "A service is West — the thing that executes. Its port claim is South — a precondition that must hold first."),
491
+ port_conflicts: mine,
492
+ ...(mine.length > 0
493
+ ? {
494
+ warning: `${mine.length} port conflict(s) involve this service. It was registered — the wheel ` +
495
+ `records what is true, not what is tidy — but do not provision against this.`,
496
+ }
497
+ : {}),
498
+ };
499
+ }
500
+ catch (error) {
501
+ return errorOf(error, "register service");
502
+ }
503
+ },
504
+ },
505
+ // ── detect_port_conflicts ─────────────────────────────────────────────────
506
+ {
507
+ name: "detect_port_conflicts",
508
+ description: "Every port on a host claimed by more than one service, over the union of what is " +
509
+ "registered in the wheel (declared) and any bindings passed in (observed, read live from " +
510
+ "a host). Neither side alone can see a collision. Rows are deterministic — sorted by host, " +
511
+ "then proto, then port — so two runs can be diffed. One service seen twice is not a " +
512
+ "conflict; ports are scarce per host, not globally.",
513
+ inputSchema: {
514
+ type: "object",
515
+ properties: {
516
+ observed: {
517
+ type: "array",
518
+ description: "Bindings read live from a machine, to union with what the wheel already holds. " +
519
+ "Omit to check the registered state against itself.",
520
+ items: {
521
+ type: "object",
522
+ properties: {
523
+ port: { type: "number" },
524
+ proto: { type: "string", enum: ["tcp", "udp"] },
525
+ host: { type: "string", description: "Host node id or hostname" },
526
+ bound_by: { type: "string", description: "Service node id or unit name" },
527
+ },
528
+ required: ["port", "host", "bound_by"],
529
+ },
530
+ },
531
+ host: { type: "string", description: "Restrict to one host — node id or hostname" },
532
+ },
533
+ },
534
+ handler: async (args) => {
535
+ try {
536
+ const { observed = [], host } = args;
537
+ const services = await loadInfraNodes("service");
538
+ const hosts = await loadInfraNodes("host");
539
+ const resolveHost = makeHostResolver(hosts);
540
+ const declared = services.flatMap((n) => (n.metadata?.facet?.ports ?? []));
541
+ // An unresolved host is reported, never invented. Letting an unknown
542
+ // hostname become its own key made every claim on it collide with
543
+ // nothing — so an observer on a machine the wheel had not been told
544
+ // about received "No slot is claimed twice" over a real collision.
545
+ const unresolved = new Set();
546
+ const observedBindings = [];
547
+ for (const o of observed) {
548
+ const target = resolveHost(o.host);
549
+ if (!target || "unresolved" in target) {
550
+ unresolved.add(o.host);
551
+ continue;
552
+ }
553
+ observedBindings.push({
554
+ port: o.port,
555
+ ...(o.proto ? { proto: o.proto } : {}),
556
+ host: target.id,
557
+ boundBy: o.bound_by,
558
+ });
559
+ }
560
+ let scopeHost;
561
+ if (host) {
562
+ const target = resolveHost(host);
563
+ if (!target || "unresolved" in target) {
564
+ return { status: "not_found", message: `No registered host '${host}'` };
565
+ }
566
+ scopeHost = target.id;
567
+ }
568
+ let conflicts = detectPortConflicts([...declared, ...observedBindings]);
569
+ if (scopeHost)
570
+ conflicts = conflicts.filter((c) => c.host === scopeHost);
571
+ // Name the services rather than making a human resolve node ids by hand.
572
+ const nameOf = new Map(services.map((n) => [n.id, n.name]));
573
+ const rows = conflicts.map((c) => ({
574
+ ...c,
575
+ host_name: hosts.find((n) => n.id === c.host)?.name ?? c.host,
576
+ claimant_names: c.claimants.map((id) => nameOf.get(id) ?? id),
577
+ }));
578
+ return {
579
+ status: "ok",
580
+ declared_bindings: declared.length,
581
+ observed_bindings: observedBindings.length,
582
+ conflict_count: rows.length,
583
+ conflicts: rows,
584
+ ...(unresolved.size > 0
585
+ ? {
586
+ unresolved_hosts: [...unresolved].sort(),
587
+ warning: `${unresolved.size} observed binding host(s) are not registered and were NOT checked: ` +
588
+ `${[...unresolved].sort().join(", ")}. Register them with register_host — until then ` +
589
+ `a collision on those machines cannot be seen from here.`,
590
+ }
591
+ : {}),
592
+ teaching:
593
+ // The clean bill of health is withheld whenever something went
594
+ // unchecked. "No slot is claimed twice" over a host nobody resolved
595
+ // is the reassurance that costs an outage.
596
+ unresolved.size > 0
597
+ ? "Some claims could not be placed on a known host. This answer is incomplete, and incomplete is not clean."
598
+ : conflicts.length === 0
599
+ ? "No slot is claimed twice. This is the answer before provisioning, not the table read after the outage."
600
+ : "A collision found before the second tenant is provisioned costs a conversation. Found after, it costs an outage.",
601
+ };
602
+ }
603
+ catch (error) {
604
+ return errorOf(error, "detect port conflicts");
605
+ }
606
+ },
607
+ },
608
+ // ── hold_metis ────────────────────────────────────────────────────────────
609
+ {
610
+ name: "hold_metis",
611
+ description: "Attach tacit operator knowledge to a registered host, tenant or service — the 'restart it " +
612
+ "twice', the load-bearing BSSID lock, the credential path everyone just knows. `held_by` " +
613
+ "names a person on purpose: invisible work is surfaced WITH its carrier, never anonymised " +
614
+ "into the schema. This is the tool whose whole reason to exist is keeping the four-directions " +
615
+ "grid from flattening what a person knows. Appends to what is already held; never replaces it.",
616
+ inputSchema: {
617
+ type: "object",
618
+ properties: {
619
+ node_id: { type: "string", description: "The registered host / tenant / service node" },
620
+ held_by: {
621
+ type: "string",
622
+ description: "Who carries this know-how. A person's name — accountability, not anonymisation.",
623
+ },
624
+ exceptions: {
625
+ type: "array",
626
+ items: { type: "string" },
627
+ description: "'compose stop, never down — down drops the volumes'",
628
+ },
629
+ invisible_work: {
630
+ type: "array",
631
+ items: { type: "string" },
632
+ description: "'BELL917 BSSID lock is load-bearing — do not remove'",
633
+ },
634
+ notes: {
635
+ type: "array",
636
+ items: { type: "string" },
637
+ description: "credential paths, ordering lore, the unwritten",
638
+ },
639
+ },
640
+ required: ["node_id", "held_by"],
641
+ },
642
+ handler: async (args) => {
643
+ try {
644
+ const { node_id, held_by, exceptions = [], invisible_work = [], notes = [] } = args;
645
+ if (exceptions.length === 0 && invisible_work.length === 0 && notes.length === 0) {
646
+ return {
647
+ status: "error",
648
+ message: "Nothing to hold. Naming a carrier without naming what they carry records a person " +
649
+ "and forgets their knowledge — the exact inversion this tool exists to prevent.",
650
+ };
651
+ }
652
+ // An empty carrier passes every other check while anonymising the work.
653
+ if (typeof held_by !== "string" || held_by.trim().length === 0) {
654
+ return {
655
+ status: "error",
656
+ message: "`held_by` must name a person. An empty carrier records the knowledge and erases who " +
657
+ "holds it, which is the anonymisation this field exists to refuse.",
658
+ };
659
+ }
660
+ const node = (await store.getNode(node_id));
661
+ if (!node)
662
+ return { status: "not_found", message: `No node ${node_id}` };
663
+ const kind = node.metadata?.kind;
664
+ if (kind !== "host" && kind !== "tenant" && kind !== "service") {
665
+ return {
666
+ status: "error",
667
+ message: `Node ${node_id} is not infrastructure (metadata.kind=${JSON.stringify(kind)}).`,
668
+ };
669
+ }
670
+ const facet = { ...node.metadata?.facet };
671
+ const prior = (facet.metis ?? {});
672
+ // Appended, never replaced. A second operator's knowledge does not
673
+ // overwrite the first's, and heldBy accumulates rather than reassigning
674
+ // authorship of what someone else wrote down.
675
+ const priorHeld = prior.heldBy ? prior.heldBy.split(", ") : [];
676
+ const heldBy = [...new Set([...priorHeld, held_by.trim()])].join(", ");
677
+ const merged = {
678
+ ...(prior.exceptions || exceptions.length
679
+ ? { exceptions: [...new Set([...(prior.exceptions ?? []), ...exceptions])] }
680
+ : {}),
681
+ ...(prior.invisibleWork || invisible_work.length
682
+ ? { invisibleWork: [...new Set([...(prior.invisibleWork ?? []), ...invisible_work])] }
683
+ : {}),
684
+ ...(prior.notes || notes.length
685
+ ? { notes: [...new Set([...(prior.notes ?? []), ...notes])] }
686
+ : {}),
687
+ heldBy,
688
+ };
689
+ const parsedMetis = MetisHoldSchema.safeParse(merged);
690
+ if (!parsedMetis.success)
691
+ return invalid("metis", parsedMetis.error);
692
+ facet.metis = parsedMetis.data;
693
+ const schema = kind === "host" ? HostFacetSchema : kind === "tenant" ? TenantFacetSchema : ServiceFacetSchema;
694
+ const parsed = schema.safeParse(facet);
695
+ if (!parsed.success)
696
+ return invalid(kind, parsed.error);
697
+ const updated = {
698
+ ...node,
699
+ metadata: { ...node.metadata, facet: parsed.data },
700
+ updated_at: new Date().toISOString(),
701
+ };
702
+ await store.createNode(updated);
703
+ return {
704
+ status: "held",
705
+ node_id,
706
+ kind,
707
+ metis: parsedMetis.data,
708
+ held_by: heldBy,
709
+ message: `Métis held on ${node.name}, carried by ${heldBy}`,
710
+ teaching: "The four-directions ontology is an imposed legibility grid. A grid that becomes the only " +
711
+ "sanctioned way to describe a system erases the métis. This is where it does not.",
712
+ };
713
+ }
714
+ catch (error) {
715
+ return errorOf(error, "hold métis");
716
+ }
717
+ },
718
+ },
719
+ // ── reconcile_infra_state ─────────────────────────────────────────────────
720
+ {
721
+ name: "reconcile_infra_state",
722
+ description: "Compare what the wheel declares against what was observed on a host, right now. " +
723
+ "Level-triggered: it remembers no transitions, so `converged` cannot age into a lie — the " +
724
+ "outage this answers (jgwill/gaia#74) was caused not by a missing fact but by a fact that " +
725
+ "HAD been true. Four drift states: converged, drifted, unrealized (declared, not running), " +
726
+ "and undeclared (running and nobody wrote it down — the service somebody started by hand " +
727
+ "that holds the port the next tenant is about to be given). Port conflicts over declared ∪ " +
728
+ "observed are folded in. Métis is never counted as drift.",
729
+ inputSchema: {
730
+ type: "object",
731
+ properties: {
732
+ observed_by: {
733
+ type: "string",
734
+ description: "Host node id or hostname this reading was taken on. Required — an observation with no reader is a rumour.",
735
+ },
736
+ observed_at: {
737
+ type: "string",
738
+ description: "ISO 8601 timestamp of the reading. Required, for the same reason.",
739
+ },
740
+ services: {
741
+ type: "array",
742
+ description: "Services read live from the machine, as ServiceFacet shapes: " +
743
+ "{ nodeId, unit, scope, ownedBy, ports:[{port,proto?,host,boundBy}], workingDirectory?, execStop? }",
744
+ items: { type: "object" },
745
+ },
746
+ host: {
747
+ type: "string",
748
+ description: "Restrict the declared side to one host — node id or hostname (optional)",
749
+ },
750
+ now: {
751
+ type: "string",
752
+ description: "ISO 8601 'now', to compute how old the reading is. Defaults to the server clock; pass " +
753
+ "it explicitly when you need the answer to be reproducible.",
754
+ },
755
+ },
756
+ required: ["observed_by", "observed_at", "services"],
757
+ },
758
+ handler: async (args) => {
759
+ try {
760
+ const { observed_by, observed_at, services = [], host, now } = args;
761
+ const hosts = await loadInfraNodes("host");
762
+ const resolveHost = makeHostResolver(hosts);
763
+ const observer = resolveHost(observed_by);
764
+ if (!observer || "unresolved" in observer) {
765
+ return {
766
+ status: "not_found",
767
+ message: `No registered host '${observed_by}'. A reading has to be attributable to a machine ` +
768
+ `the wheel knows, or the drift it reports cannot be placed anywhere.`,
769
+ };
770
+ }
771
+ let scopeHost;
772
+ if (host) {
773
+ const target = resolveHost(host);
774
+ if (!target || "unresolved" in target) {
775
+ return { status: "not_found", message: `No registered host '${host}'` };
776
+ }
777
+ scopeHost = target.id;
778
+ }
779
+ const unresolved = new Set();
780
+ const resolveOrRecord = (h) => {
781
+ const target = resolveHost(h);
782
+ if (!target || "unresolved" in target) {
783
+ unresolved.add(h);
784
+ return h;
785
+ }
786
+ return target.id;
787
+ };
788
+ let declaredNodes = await loadInfraNodes("service");
789
+ if (scopeHost) {
790
+ const tenants = await loadInfraNodes("tenant");
791
+ const onHost = new Set(tenants
792
+ .filter((t) => t.metadata?.facet?.onHost === scopeHost)
793
+ .map((t) => t.id));
794
+ // Containment UNION port-locality. A service owned by a tenant on eury
795
+ // may legitimately claim a slot on gaia — `register_service` allows it.
796
+ // Scoping by containment alone made that claim invisible in gaia's own
797
+ // slot map, which is the one place "ports are scarce per host" has to
798
+ // hold.
799
+ declaredNodes = declaredNodes.filter((s) => {
800
+ const facet = s.metadata?.facet;
801
+ return (onHost.has(facet?.ownedBy ?? "") ||
802
+ (facet?.ports ?? []).some((p) => p.host === scopeHost));
803
+ });
804
+ }
805
+ const declared = declaredNodes
806
+ .map((n) => n.metadata?.facet)
807
+ .filter((f) => f !== undefined);
808
+ // A live reader knows units, not the ids the wheel minted. Matching on
809
+ // nodeId alone reported every correctly-running declared service as BOTH
810
+ // `unrealized` and `undeclared`, plus a phantom conflict against itself.
811
+ const declaredByUnit = new Map(declared.map((f) => [`${f.unit}@${f.ownedBy}`, f]));
812
+ const declaredById = new Set(declared.map((f) => f.nodeId));
813
+ const rekeyed = [];
814
+ const observedServices = services.map((s) => {
815
+ let nodeId = s.nodeId;
816
+ if (!declaredById.has(nodeId)) {
817
+ const match = declaredByUnit.get(`${s.unit}@${s.ownedBy}`) ??
818
+ declared.find((f) => f.unit === s.unit);
819
+ if (match) {
820
+ rekeyed.push({ observed: s.nodeId, matched: match.nodeId, by: s.unit });
821
+ nodeId = match.nodeId;
822
+ }
823
+ }
824
+ return {
825
+ ...s,
826
+ nodeId,
827
+ ports: (s.ports ?? []).map((p) => ({
828
+ ...p,
829
+ host: resolveOrRecord(p.host),
830
+ boundBy: p.boundBy === s.nodeId ? nodeId : p.boundBy,
831
+ })),
832
+ };
833
+ });
834
+ const parsed = ObservedStateSchema.safeParse({
835
+ observedBy: observer.id,
836
+ observedAt: observed_at,
837
+ services: observedServices,
838
+ });
839
+ if (!parsed.success)
840
+ return invalid("observed state", parsed.error);
841
+ const result = reconcile(declared, parsed.data, {
842
+ now: now ?? new Date().toISOString(),
843
+ });
844
+ const nameOf = new Map(declaredNodes.map((n) => [n.id, n.name]));
845
+ return {
846
+ status: "ok",
847
+ ...result,
848
+ rows: result.rows.map((r) => ({ ...r, name: nameOf.get(r.nodeId) ?? r.unit })),
849
+ declared_count: declared.length,
850
+ observed_count: parsed.data.services.length,
851
+ // Matching by unit is an inference, so it is reported rather than
852
+ // performed silently. A reader who disagrees can pass real node ids.
853
+ ...(rekeyed.length > 0 ? { matched_by_unit: rekeyed } : {}),
854
+ ...(unresolved.size > 0
855
+ ? {
856
+ unresolved_hosts: [...unresolved].sort(),
857
+ warning: `Observed port claims name host(s) the wheel does not know: ` +
858
+ `${[...unresolved].sort().join(", ")}. Those claims were kept in the comparison but ` +
859
+ `cannot collide with anything registered.`,
860
+ }
861
+ : {}),
862
+ teaching: result.summary.undeclared > 0
863
+ ? "Something is running that nobody wrote down. That is the row worth reading first."
864
+ : result.converged
865
+ ? "Declared and observed agree, as of this reading — and only as of this reading."
866
+ : "Drift is not failure. It is the distance between what was intended and what is, said out loud.",
867
+ };
868
+ }
869
+ catch (error) {
870
+ return errorOf(error, "reconcile infrastructure state");
871
+ }
872
+ },
873
+ },
874
+ // ── list_infra_topology ───────────────────────────────────────────────────
875
+ {
876
+ name: "list_infra_topology",
877
+ description: "The whole registered infrastructure as one traversal — hosts, the tenants upon them, the " +
878
+ "services those tenants own, and the ports those services claim — with port conflicts and " +
879
+ "every held métis surfaced. This is the read side of registration: registering " +
880
+ "infrastructure you cannot walk back out of is not registration. Scope to one host with " +
881
+ "`host`, or ask a single question with `perspective`.",
882
+ inputSchema: {
883
+ type: "object",
884
+ properties: {
885
+ host: { type: "string", description: "Restrict to one host — node id or hostname" },
886
+ perspective: {
887
+ type: "string",
888
+ enum: ["host", "tenant", "service", "port", "metis"],
889
+ description: "Which reading to return. 'host' (default) nests tenants and services under machines. " +
890
+ "'port' returns the slot map. 'metis' returns only what people hold, with carriers.",
891
+ },
892
+ },
893
+ },
894
+ handler: async (args) => {
895
+ try {
896
+ const { host, perspective = "host" } = args;
897
+ const hosts = await loadInfraNodes("host");
898
+ const tenants = await loadInfraNodes("tenant");
899
+ const services = await loadInfraNodes("service");
900
+ const facetOf = (n) => n.metadata?.facet;
901
+ let wanted;
902
+ if (host) {
903
+ const target = makeHostResolver(hosts)(host);
904
+ if (!target || "unresolved" in target) {
905
+ return { status: "not_found", message: `No registered host '${host}'` };
906
+ }
907
+ wanted = target.id;
908
+ }
909
+ const visibleHosts = wanted ? hosts.filter((h) => h.id === wanted) : hosts;
910
+ const visibleHostIds = new Set(visibleHosts.map((h) => h.id));
911
+ const visibleTenants = tenants.filter((t) => visibleHostIds.has(facetOf(t)?.onHost));
912
+ const visibleTenantIds = new Set(visibleTenants.map((t) => t.id));
913
+ // Containment UNION port-locality: a service owned elsewhere that claims
914
+ // a slot on this host belongs in this host's reading. Scoping by
915
+ // containment alone hid exactly the claims that cross a tenancy
916
+ // boundary — from the machine whose slot they occupy.
917
+ const visibleServices = services.filter((s) => {
918
+ const facet = facetOf(s);
919
+ return (visibleTenantIds.has(facet?.ownedBy) ||
920
+ (wanted !== undefined && (facet?.ports ?? []).some((p) => p.host === wanted)));
921
+ });
922
+ const allBindings = visibleServices
923
+ .flatMap((s) => facetOf(s)?.ports ?? [])
924
+ // In a host-scoped reading, a co-owned service's claims on OTHER hosts
925
+ // are not this host's slots.
926
+ .filter((b) => wanted === undefined || b.host === wanted);
927
+ const conflicts = detectPortConflicts(allBindings);
928
+ const metisRows = [...visibleHosts, ...visibleTenants, ...visibleServices]
929
+ .map((n) => ({
930
+ node_id: n.id,
931
+ name: n.name,
932
+ kind: n.metadata?.kind,
933
+ metis: facetOf(n)?.metis,
934
+ }))
935
+ .filter((r) => r.metis !== undefined);
936
+ const base = {
937
+ status: "ok",
938
+ perspective,
939
+ counts: {
940
+ hosts: visibleHosts.length,
941
+ tenants: visibleTenants.length,
942
+ services: visibleServices.length,
943
+ port_bindings: allBindings.length,
944
+ conflicts: conflicts.length,
945
+ metis_held: metisRows.length,
946
+ },
947
+ conflicts,
948
+ };
949
+ if (perspective === "metis") {
950
+ return {
951
+ ...base,
952
+ metis: metisRows,
953
+ teaching: metisRows.length === 0
954
+ ? "Nothing is held yet. An infrastructure with no métis recorded is not an infrastructure without tacit knowledge — it is one whose tacit knowledge is undocumented."
955
+ : "Every row names its carrier. That is the point.",
956
+ };
957
+ }
958
+ if (perspective === "port") {
959
+ const rows = allBindings
960
+ .map((b) => {
961
+ const owner = services.find((s) => s.id === b.boundBy);
962
+ return {
963
+ host: b.host,
964
+ host_name: hosts.find((h) => h.id === b.host)?.name ?? b.host,
965
+ port: b.port,
966
+ proto: b.proto ?? "tcp",
967
+ bound_by: b.boundBy,
968
+ service: owner?.name ?? b.boundBy,
969
+ contested: conflicts.some((c) => c.host === b.host && c.port === b.port && c.proto === (b.proto ?? "tcp")),
970
+ // Carried here too. The slot map is where "restart it twice"
971
+ // matters most, and it was the one perspective that dropped it.
972
+ metis: owner ? facetOf(owner)?.metis : undefined,
973
+ };
974
+ })
975
+ .sort((a, b) => a.host_name.localeCompare(b.host_name) ||
976
+ a.proto.localeCompare(b.proto) ||
977
+ a.port - b.port);
978
+ return { ...base, ports: rows, teaching: "Ports are scarce per host, not globally." };
979
+ }
980
+ if (perspective === "tenant") {
981
+ return {
982
+ ...base,
983
+ tenants: visibleTenants.map((t) => {
984
+ const f = facetOf(t);
985
+ return {
986
+ node_id: t.id,
987
+ account: f?.account,
988
+ on_host: f?.onHost,
989
+ linger: f?.linger,
990
+ linger_is_not_consent: true,
991
+ services: visibleServices
992
+ .filter((s) => facetOf(s)?.ownedBy === t.id)
993
+ .map((s) => ({ node_id: s.id, unit: s.name })),
994
+ metis: f?.metis,
995
+ };
996
+ }),
997
+ teaching: "An account is a being, not a namespace. South: its readiness is a precondition.",
998
+ };
999
+ }
1000
+ if (perspective === "service") {
1001
+ return {
1002
+ ...base,
1003
+ services: visibleServices.map((s) => {
1004
+ const f = facetOf(s);
1005
+ return {
1006
+ node_id: s.id,
1007
+ unit: f?.unit,
1008
+ scope: f?.scope,
1009
+ owned_by: f?.ownedBy,
1010
+ ports: f?.ports,
1011
+ working_directory: f?.workingDirectory,
1012
+ exec_stop: f?.execStop,
1013
+ url: s.metadata?.url,
1014
+ metis: f?.metis,
1015
+ };
1016
+ }),
1017
+ teaching: "A service is West — the thing that executes.",
1018
+ };
1019
+ }
1020
+ return {
1021
+ ...base,
1022
+ hosts: visibleHosts.map((h) => {
1023
+ const hf = facetOf(h);
1024
+ return {
1025
+ node_id: h.id,
1026
+ hostname: hf?.hostname,
1027
+ fqdn: hf?.fqdn,
1028
+ os: hf?.os,
1029
+ reachable_via: hf?.reachableVia,
1030
+ metis: hf?.metis,
1031
+ tenants: visibleTenants
1032
+ .filter((t) => facetOf(t)?.onHost === h.id)
1033
+ .map((t) => {
1034
+ const tf = facetOf(t);
1035
+ return {
1036
+ node_id: t.id,
1037
+ account: tf?.account,
1038
+ linger: tf?.linger,
1039
+ metis: tf?.metis,
1040
+ services: visibleServices
1041
+ .filter((s) => facetOf(s)?.ownedBy === t.id)
1042
+ .map((s) => {
1043
+ const sf = facetOf(s);
1044
+ return {
1045
+ node_id: s.id,
1046
+ unit: sf?.unit,
1047
+ scope: sf?.scope,
1048
+ ports: sf?.ports?.map((p) => `${p.port}/${p.proto ?? "tcp"}`),
1049
+ url: s.metadata?.url,
1050
+ metis: sf?.metis,
1051
+ };
1052
+ }),
1053
+ };
1054
+ }),
1055
+ };
1056
+ }),
1057
+ teaching: "Host, tenant, service — land, human, knowledge. The same six node types that hold a ceremony hold a machine.",
1058
+ };
1059
+ }
1060
+ catch (error) {
1061
+ return errorOf(error, "list infrastructure topology");
1062
+ }
1063
+ },
1064
+ },
1065
+ ];
1066
+ //# sourceMappingURL=infrastructure.js.map