design phase 8 c3 - sinrimin/harness-nexus GitHub Wiki
Status: implemented (branch
phase-8-c3). Parent design:design-phase-8-client.mdยง "Inventory, diff & import (C3)". PRD:prd-phase-8-client.md("่ฏปๅๅฝๅAgent็ๅ็งๆไปถใๅทฅๅ ท โฆ ไธ้ฎๅฏผๅ ฅๅนถๅๅปบ profile"). C2:design-phase-8-c2.md(shipped โ client MCP serving). Verification: shared 41 (snapshot/event schemas + pure diff matrix), cli 7 (fixture-HOME scanners incl. the env/header redaction assertion), server 33 (fake-daemon integration: scan round-trip, import โ reuse-or-create โ profile, idempotent re-import, all gates), smoke[8 C3]โ the REAL daemon dist against a fixture HOME (scan โ import โ re-import reuse โ diff โ 409s) โ 220/220 overall; SQLite migration0008upsert/cascade verified.
C3 answers "what does this machine actually have installed, how does it compare to a profile, and can I pull it into the platform?" โ the management half of the vision (the deploy half is C4):
-
Daemon scanners read the same ground-truth locations the 3.3/C2 adapters
write and report a normalized per-target snapshot:
- claude-code:
~/.claude/skills|commands|agents+mcpServersin~/.claude.json - codex:
~/.codex/skills|prompts+[mcp_servers.*]in~/.codex/config.toml - hermes:
~/.hermes/plugins/*/skills+mcp_serversin~/.hermes/config.yaml-
~/.hermes/AGENTS.md(rules) Each item carries{kind, name, origin: 'platform'|'local', path, summary, contentPreview, importable, meta?}.origin: 'platform'is derived from markers we own (install-state ledger,harness-nexus[-*]MCP entry names) โ never guessed from content. Absent home dir โ target absent from the report (honest: "not installed here").
-
- claude-code:
-
Direct request/response over
/ctl(noJobrows in C3 โ C4 formalizes the queue/replay semantics and absorbs these flows;jobTypeSchemaalready reserves'scan' | 'import'):- SโD
inventory:scan{requestId, targets[]}โ daemon scans, replies oneinventory:report{target, snapshot}per target. - SโD
inventory:collect{requestId, target, items:[{kind,name}]}โ daemon re-scans that target (the server never dictates file paths) and repliesinventory:payloadwith artifact bodies. - DโS
inventory:reportis validated, stored (latest per machine+target), resolves any pending scan waiter, and pushesinventory:updated{machineId, target, reportedAt}to the owner (+admins) on/app. - The daemon also auto-reports every target after each successful
machine:helloโ inventory is fresh whenever the daemon comes up.
- SโD
-
Storage:
machine_inventorytable (migration0008),UNIQUE(machine_id, target), report stored as a JSON document. NewMachineInventorySnapshotdomain type +InventoryRepositoryport (findLatest,list,saveupsert,deleteByMachine) + both drivers. Machine deletion clears its rows. -
REST (
modules/inventory.ts, all under the machine owner-or-admin + 404-hiding guard):-
GET /api/machines/:id/inventoryโ latest snapshot per target. -
POST /api/machines/:id/inventory/scan{targets?}โ gates: offline โ409 MACHINE_OFFLINE; daemon without theinventorycapability โ409 DAEMON_NO_INVENTORY; then awaits one report per requested target (INVENTORY_REQUEST_TIMEOUT_MS, default 60s; a concurrent scan โ409 SCAN_IN_PROGRESS). Timeout โ504 INVENTORY_SCAN_TIMEOUTwith partials. -
GET /api/machines/:id/inventory/diff?profile=<id>โ the snapshot for the profile'stargetdiffed against the profile (404 if profile/snapshot missing/not visible). -
POST /api/machines/:id/inventory/import{target, profileName, items:[{kind,name}]}โ collect โ create โ bundle; synchronous (bounded by the same timeout); responds{profile, created[], reused[], warnings[]}.
-
-
Diff is pure (
shared/src/diff-inventory.ts): profile entries are resolved server-side to{kind, name}pairs, then matched against snapshot items by(kind, name)โ adapters write artifacts named after the resource, so the round-trip is name-stable. The MCP arm is coarse by design: one profile install emits ONE shim entry (harness-nexus[-<slug>]), so the profile's whole MCP arm matches a singleorigin:'platform'mcp item. Output:{upToDate[], missingOnMachine[], notInProfile[], summary}โnotInProfile(originlocal) is exactly the import-candidate list. -
Import = reuse-or-create, then bundle:
- Non-MCP items โ
Resourcerows (key = '<kind>:<slug(name)>',targets: [target],source: inlinesingle-file /inline-bundlemulti-file skill). If a resource with the same key exists AND the content is identical โ reuse (re-import is idempotent); different content โ suffix-<n>on the key. This mirrors the "plans are idempotent overwrites" doctrine. - MCP items โ
McpServerrows, never Resources (4.2'smcpkind rule), with the profile-entry arm{mcpServerId}.dialSite: 'auto'. - Everything bundles into a new personal
Profileowned by the machine's owner (the artifacts are that user's setup; an admin importing still works because admin sees all). Creation is best-effort sequential โ not transactional; a failure surfaces which items made it.
- Non-MCP items โ
-
Secret redaction happens in the daemon, before upload (load-bearing):
MCP
envvalues andheadervalues are replaced by${cred:<KEY>}placeholders (the key name survives, the plaintext never crosses the wire);command/args/urlpass verbatim (same trust level as typing them into the platform). The import response carries a warning per placeholder whose credential does not exist yet, so the user knows to fill them in. -
Daemon capability:
machine:hellonow reportscapabilities: ['inventory']; the scan route checks it for a precise error instead of a timeout against an old daemon. -
Web:
MachineDetailpage at/machines/:id(linked from Machines rows, no sidebar entry โ drill-down, not a top-level surface): header (online badge, daemon version, hostname/os), per-target inventory tables, Scan button, per-profile diff view (missing / candidates / up-to-date), and the import wizard (select candidates โ profile name โ result links to the profile).inventory:updatedon/apprefreshes the view live. -
SDK:
getMachineInventory/scanMachineInventory/diffMachineInventory/importMachineInventory+ view types.
InventorySnapshot = {
target: AgentTarget;
scannedAt: string; // ISO, daemon clock
agents: InventoryAgent[]; // C3: exactly one (the default home)
};
InventoryAgent = {
name: string; // e.g. '~/.claude'
directory: string; // absolute home dir
profileApplied: boolean | null; // null = unknown
items: InventoryItem[];
};
InventoryItem = {
kind: 'skill' | 'command' | 'sub_agent' | 'rule' | 'mcp';
name: string;
origin: 'platform' | 'local';
path: string; // relative to `directory`, display only
summary?: string; // first meaningful line, โค120 chars
contentPreview?: string; // โค200 chars, metadata-only report
importable: boolean; // false: binary/oversized/mcp-with-issues
note?: string; // why not importable, or origin evidence
meta?: { multi?: boolean; transport?: 'stdio'|'sse'|'http'; command?: string; url?: string };
};Caps (validated): โค500 items per target, โค200-char previews, collect bodies
โค256 KiB per item (beyond โ importable: false at scan time by size class
where cheap, else ok: false, error: 'too-large' in the payload).
-
shared โ
schemas/inventory.ts(snapshot/item/artifact/import request + diff response) + realtime event schemas (inventory:scan|report|collect| payload,/appinventory:updated) +diff-inventory.tspure function. Unit tests: schema round-trips, diff matrix (applied / missing / candidates / coarse-MCP). -
core โ
domain/inventory.ts(MachineInventorySnapshot, plain item/ agent interfaces kept in sync with shared) +InventoryRepositoryport +UnitOfWork.inventories. -
server storage โ migration
0008+ SQLite repo + memory repo; machine delete cascades todeleteByMachine. -
server realtime โ
InventoryCoordinator(src/realtime/inventory.ts): pending scan/collect waiters keyed by machine (+target), timeout + one- in-flight-scan guard;/ctlhandlersinventory:report|payload;/appinventory:updatedpush. -
server routes โ
modules/inventory.ts(4 endpoints above) + configINVENTORY_REQUEST_TIMEOUT_MS; import handler with reuse-or-create + McpServer arm + warnings. - sdk โ views + 4 methods.
-
cli โ
src/inventory/(scanner registry + 3 scanners + collect readers with redaction); daemon wiring (scan/collect handlers, capability, auto- report after hello). Scanner tests run against fixture HOMEs (tmp dirs); redaction assertion: an env/header plaintext value never appears in any emitted payload. -
web โ
MachineDetail.tsx+ route + Machines row link +inventory: updatedsubscription. -
verify โ server integration tests (fake daemon socket: report ingest,
scan round-trip, import round-trip incl. idempotent re-import + redaction +
409/404 gates); smoke
[8 C3]block: enroll โ spawn the REAL daemon dist against a fixture HOME โ scan โ diff vs an empty profile โ import โ assert resources + profile + redacted McpServer โ re-import is a full reuse.
- The daemon never trusts server-supplied paths โ collect re-derives every path from its own scan.
- Secret redaction is daemon-side and structural (env/header values), not a server-side scrub of arbitrary text; anything we did not classify (args, URLs) is treated as user-entered config, identical to the existing MCP form.
- Machine PAT blast radius is unchanged: inventory rides
/ctlonly; no new REST exception is opened (the REST endpoints authenticate the OWNER, never the machine token). - Snapshots store previews only โ full bodies exist transiently in the import request, never persisted as inventory.
- Queued/offline-replay semantics (C4's job system absorbs
scan/import). - Hook scanning for any target (CC's matcher format is lossy vs the 4.5 event map; Hermes hooks are Python plugins) โ items are simply not reported.
- CC
~/.claude/pluginsmarketplace installs and project-scoped (.claude/) artifacts โ global homes only. - Rules for claude-code (no established global rules dir) and codex (project-root AGENTS.md only).
- Importing INTO an existing profile (C3 always creates a new one โ simpler, reversible via profile delete).
- Multi-agent-per-target snapshots (the
agents[]array is the forward-compat shape; C3 fills exactly one entry).