Skip to content

Commit b0837b7

Browse files
committed
feat(ui): expose NDOs as Moss assets
Each NDO gets a WAL (its clone DNA and identity record). getAssetInfo and search answer from the group's NDO anchors through the applet client Moss passes, an asset view or a main view on a WAL opens the NDO, and the NDO view can add it to the Moss pocket. Moss maps a WAL to a tool through the DNAs of the agent's own conductor, and NDO clones are not public to the Moss group yet, so the tool joins every anchored NDO clone in the background. The service layer builds AppletServices and hands it to connectMoss, keeping lib/moss/connect.ts free of $lib/services (invariant I1).
1 parent 41d60ff commit b0837b7

24 files changed

Lines changed: 574 additions & 25 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ NDO over a live DHT.
2727
connection mode that takes the conductor client from Moss. The standalone build is
2828
unchanged. See `documentation/specifications/moss-integration.md`.
2929
- **Moss group context.** Inside Moss the Moss group is the group: the tool instance's `group` cell is the one Nondominium group, joined automatically with no invite code, the profile comes from Moss, the role from the Moss accountabilities, and NDO clones are created through the `WeaveClient`. Standalone keeps the Lobby, the Group DNA and invite links.
30+
- **NDOs as Moss assets.** Each NDO has a Weave Asset Locator, `getAssetInfo` and `search` answer for it, an NDO linked from another tool opens in Nondominium, and the NDO view can add it to the Moss pocket.
3031
- **Lobby DNA** as the entry point. Groups are announced here for discovery, and NDOs are
3132
browsed and filtered across every group the agent belongs to.
3233
- **Group DNA, one cloned cell per group.** Each group is its own DHT with its own

‎documentation/IMPLEMENTATION_STATUS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ Nondominium runs as a Moss tool in `weave` dev mode. Standalone (Lobby, Group DN
297297

298298
- ✅ **Scaffold (#163).** `weave/weave.dev.config.ts` (two agents, one group, tool served from `localhost:8888`), `bun run tool-dev`, `@theweave/api` in `ui/`, host detection and the Moss connection mode in `ui/src/lib/moss/`, an empty `AppletServices`. The UI connects through the applet client Moss hands it.
299299
- ✅ **Moss group context (#164).** Inside Moss the profile comes from `profilesClient`, the role from `myAccountabilitiesPerGroup()`, the tool instance's provisioned `group` cell is the one Nondominium group (the tool installer creates its profile, every other member joins it automatically, no invite code), and NDO clones are created through the `WeaveClient`. Standalone keeps the Lobby DNA, Group DNA and invite codes. Known gap: NDO clones are not registered as public to the Moss group, because Moss rc.5 cannot register a clone that carries DNA properties (see [moss-integration.md](specifications/moss-integration.md)).
300-
- ❌ **NDOs as assets (#165).** WAL, `getAssetInfo`, `search`, asset view.
300+
- ✅ **NDOs as assets (#165).** Each NDO has a WAL (its clone DNA and identity record); `getAssetInfo` and `search` answer from the group's NDO anchors; an asset view or a main view on a WAL opens the NDO; an `Add to Moss pocket` button puts it in the pocket. An asset resolves on a member's machine once that member has joined the NDO's clone, which the tool does in the background on load because NDO clones are not public to the Moss group yet.
301301

302302
See [moss-integration.md](specifications/moss-integration.md).
303303

‎documentation/specifications/moss-integration.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Moss Integration
22

3-
**Status:** v0.1.0 pilot, in progress. The tool scaffold and dev config (#163) and the Moss group context (#164) are implemented. NDOs as assets (#165) follows. Supersedes the "Moss Integration Contract" in [lobby-architecture.md](lobby-architecture.md) §9 where they differ.
3+
**Status:** v0.1.0 pilot. The tool scaffold and dev config (#163), the Moss group context (#164) and NDOs as assets (#165) are implemented. Supersedes the "Moss Integration Contract" in [lobby-architecture.md](lobby-architecture.md) §9 where they differ.
44

55
Moss is the Weave host (lightningrodlabs/moss). In the v0.1.0 pilot Nondominium runs as a **tool** inside Moss, started with the `weave` CLI in dev mode. The standalone build (Lobby DNA, Group DNA, invite codes) keeps compiling and running unchanged; the pilot ships Moss only.
66

@@ -74,6 +74,17 @@ Code: `ui/src/lib/moss/` (`group-cell.ts`, `profile.ts`, `ndo-clone.ts`), `ui/sr
7474

7575
**NDO clones are not public to the Moss group (`PUBLIC_NDO_CLONES = false`).** `createCloneCell(req, true)` registers the clone in the Moss group DNA so other members and always-online nodes can install it. Moss `0.16.0-rc.5` cannot register a clone that carries DNA properties: `join_cloned_cell` declares `properties: Option<SerializedBytes>` and the renderer forwards the properties object untouched, so the call fails with a Deserialize error and leaves an orphan cell. NDO clones need their properties (ADR-013). Discovery does not depend on the registration, since peers re-derive the clone from the `NdoAnchor` in the group cell, but an always-online node (wdocker) will not host the NDO cells until this flips. Flip `PUBLIC_NDO_CLONES` once Moss accepts properties.
7676

77+
## NDOs as Moss assets (#165)
78+
79+
Each NDO is a Moss asset, so other tools can point at it.
80+
81+
- **The WAL.** `ndoWal(anchor)` is `{ hrl: [ndo_dna_hash, identity_action_hash] }`: the NDO's own clone DNA, its permanent identity (ADR-010), and its `NondominiumIdentity` record inside it (`moss/assets.ts`).
82+
- **`getAssetInfo` and `search`.** `AppletServices` in `@theweave/api` `0.7.0-rc.3` offers `getAssetInfo(appletClient, wal, recordInfo)`, `search(appletClient, appletHash, weaveServices, filter)` and `creatables`. Both read the NDO anchors of the instance's `group` cell through the applet client Moss passes (`moss/anchors.ts`), so they run outside the UI's stores. `getAssetInfo` answers the NDO name and an inline icon; `search` returns the WALs of the NDOs whose name contains the filter, ignoring case.
83+
- **`openAsset`.** It is not a method on `AppletServices`. A tool calls `weaveClient.openAsset(wal, mode)` to open another tool's asset, and Moss opens ours by rendering the tool with an `asset` view, or with the main view on a WAL (`openAppletMain(hash, wal)`). `moss/view.ts` turns either into the NDO route and the layout navigates there; in an asset view the shell shows the NDO alone, without the sidebar.
84+
- **The pocket.** The NDO detail view has an `Add to Moss pocket` button (`weaveClient.assets.assetToPocket`), from where the WAL can be linked in other tools.
85+
86+
**An asset resolves only where the NDO's clone is joined.** Moss maps a WAL to a tool through the DNAs of the cells in the agent's own conductor. NDO clones are not registered as public to the Moss group (see above), so on load each member joins the clone of every anchored NDO in the background, one at a time (`joinAnchoredNdoCells`, `moss/ndo-cells.ts`). Until that has run, an NDO created by someone else does not resolve as an asset on this member's machine. This is the cost of `PUBLIC_NDO_CLONES = false`: with public clones Moss would register them in the group DNA, though the renderer in rc.5 never joins them on its own either.
87+
7788
## Open items
7889

7990
- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need (see above and the #162 result).

‎documentation/specifications/ui_architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ On top of this hierarchy, the **next** UI iteration adds a cross-cutting **Persp
102102

103103
Dependencies only point down: routes render components, components read stores, stores call services, services call the Holochain client. The domain layer sits beside the services and is imported by stores and components. The three rules that keep it that way are enforced by a script (see [§17](#17-architecture-invariants)).
104104

105-
**Moss host (`lib/moss/`).** When the UI runs inside Moss it is a tool, not a page that owns its conductor connection. `lib/moss/` holds that seam as plain TypeScript beside the service layer: `host.ts` (pure host detection), `connect.ts` (the `WeaveClient` connection, injectable for tests) and `applet-services.ts` (what the tool tells Moss about itself). Only `utils/hc-connect.ts` imports it, as one more connection mode (§15.4), so components, stores and services see the same `AppClient` whether the host is Moss or a standalone conductor. The group context has one more seam: `LobbyServiceResolved` selects `MossLobbyServiceLive` (one group, the tool instance's `group` cell) or the Lobby and Group DNA implementation, behind the same `LobbyService` interface, and `moss.store.svelte.ts` holds the Moss profile and role for components. Details in [moss-integration.md](moss-integration.md).
105+
**Moss host (`lib/moss/`).** When the UI runs inside Moss it is a tool, not a page that owns its conductor connection. `lib/moss/` holds that seam as plain TypeScript beside the service layer: `host.ts` (pure host detection), `connect.ts` (the `WeaveClient` connection, injectable for tests) and `applet-services.ts` (what the tool tells Moss about itself). Only `utils/hc-connect.ts` imports it, as one more connection mode (§15.4), so components, stores and services see the same `AppClient` whether the host is Moss or a standalone conductor. The group context has one more seam: `LobbyServiceResolved` selects `MossLobbyServiceLive` (one group, the tool instance's `group` cell) or the Lobby and Group DNA implementation, behind the same `LobbyService` interface, and `moss.store.svelte.ts` holds the Moss profile, role and view for components. `AppletServices` (`search`, `getAssetInfo`) is built by the service layer, which passes it down to `connectMoss`, because it reads the group's NDO anchors and `lib/moss/connect.ts` is reachable from components (invariant I1). Details in [moss-integration.md](moss-integration.md).
106106

107107
---
108108

‎ui/src/lib/components/ndo/NdoView.svelte‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts">
22
import { untrack } from 'svelte';
3+
import { decodeHashFromBase64 } from '@holochain/client';
34
import { appContext } from '$lib/stores/app.context.svelte';
5+
import { mossStore } from '$lib/stores/moss.store.svelte';
46
import {
57
createNdoStore,
68
isTaskInterrupted,
@@ -24,6 +26,18 @@
2426
2527
let { specHashB64 }: Props = $props();
2628
29+
// Moss: the NDO's asset, put in the pocket so it can be linked from other tools.
30+
let pocketAdded = $state(false);
31+
async function addToPocket() {
32+
const cellId = store?.cellId;
33+
if (!cellId) return;
34+
await mossStore.addToPocket({
35+
dnaHash: cellId[0],
36+
identityActionHash: decodeHashFromBase64(specHashB64)
37+
});
38+
pocketAdded = true;
39+
}
40+
2741
/**
2842
* The store of the NDO open in this view, one per `specHashB64`. Recreated when the
2943
* prop changes and destroyed with the old hash or on teardown, which interrupts every
@@ -166,6 +180,15 @@
166180
<p class="mt-1 font-mono text-xs text-gray-400">{specHashB64.slice(0, 20)}…</p>
167181
</div>
168182
<div class="flex flex-wrap items-center gap-2 md:ml-4 md:shrink-0 md:flex-nowrap">
183+
{#if mossStore.isMoss && store?.cellId}
184+
<button
185+
type="button"
186+
onclick={() => void addToPocket()}
187+
class="rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
188+
>
189+
{pocketAdded ? 'In your Moss pocket' : 'Add to Moss pocket'}
190+
</button>
191+
{/if}
169192
<button
170193
type="button"
171194
disabled={joinLoading}

‎ui/src/lib/components/shell/AppShell.svelte‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { afterNavigate } from '$app/navigation';
33
import Sidebar from './Sidebar.svelte';
4+
import { mossStore } from '$lib/stores/moss.store.svelte';
45
56
interface Props {
67
children?: import('svelte').Snippet;
@@ -22,6 +23,8 @@
2223
<svelte:window onkeydown={onKeydown} />
2324

2425
<div class="flex min-h-screen bg-gray-100">
26+
<!-- Moss opens an NDO asset in a side panel or window: just the NDO, no navigation. -->
27+
{#if !mossStore.isAssetView}
2528
<header
2629
class="fixed inset-x-0 top-0 z-30 flex h-12 items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 md:hidden"
2730
>
@@ -61,8 +64,9 @@
6164
>
6265
<Sidebar />
6366
</div>
67+
{/if}
6468

65-
<main class="min-w-0 flex-1 overflow-auto pt-12 md:pt-0">
69+
<main class="min-w-0 flex-1 overflow-auto {mossStore.isAssetView ? '' : 'pt-12 md:pt-0'}">
6670
{@render children?.()}
6771
</main>
6872
</div>

‎ui/src/lib/moss/anchors.spec.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { CellType, type AppClient } from '@holochain/client';
3+
import { loadGroupAnchors } from './anchors';
4+
5+
const dna = new Uint8Array([1, 2, 3]);
6+
const me = new Uint8Array([9, 9, 9]);
7+
const groupHash = new Uint8Array([5, 5, 5]);
8+
9+
const groupRecord = {
10+
signed_action: { hashed: { hash: groupHash, content: { header: { author: me, timestamp: 1 } } } },
11+
entry: { Present: { entry: { name: 'Pilot' } } }
12+
};
13+
14+
function anchorRecord(name: string, ndoDna: Uint8Array, identity: Uint8Array) {
15+
return {
16+
signed_action: { hashed: { hash: new Uint8Array([7]), content: { header: { author: me, timestamp: 1 } } } },
17+
entry: {
18+
Present: {
19+
entry: { name, ndo_dna_hash: ndoDna, identity_action_hash: identity, network_seed: `seed_${name}` }
20+
}
21+
}
22+
};
23+
}
24+
25+
function client(calls: Record<string, unknown>) {
26+
const callZome = vi.fn(async (request: { fn_name: string }) => calls[request.fn_name]);
27+
const appInfo = vi.fn(async () => ({
28+
cell_info: {
29+
group: [{ type: CellType.Provisioned, value: { cell_id: [dna, me], dna_modifiers: { network_seed: 's' } } }]
30+
}
31+
}));
32+
return { callZome, appInfo, instance: { callZome, appInfo } as unknown as AppClient };
33+
}
34+
35+
describe('loadGroupAnchors', () => {
36+
it('reads the NDO anchors of the instance group cell through the applet client', async () => {
37+
const { instance, callZome } = client({
38+
get_my_group: groupRecord,
39+
get_ndo_anchors: [anchorRecord('Seed library', new Uint8Array([1]), new Uint8Array([2]))]
40+
});
41+
const anchors = await loadGroupAnchors(instance);
42+
expect(anchors.map((a) => a.name)).toEqual(['Seed library']);
43+
const fns = callZome.mock.calls.map(([request]) => (request as { fn_name: string }).fn_name);
44+
expect(fns).toEqual(['get_my_group', 'get_ndo_anchors']);
45+
const first = callZome.mock.calls[0]?.[0] as unknown as { cell_id: unknown; zome_name: string };
46+
expect(first.zome_name).toBe('zome_group');
47+
expect(first.cell_id).toEqual([dna, me]);
48+
});
49+
50+
it('skips anchors that lack clone coordinates', async () => {
51+
const { instance } = client({
52+
get_my_group: groupRecord,
53+
get_ndo_anchors: [
54+
anchorRecord('Good', new Uint8Array([1]), new Uint8Array([2])),
55+
{ signed_action: groupRecord.signed_action, entry: { Present: { entry: { name: 'Broken' } } } }
56+
]
57+
});
58+
expect((await loadGroupAnchors(instance)).map((a) => a.name)).toEqual(['Good']);
59+
});
60+
61+
it('answers nothing before the group profile has synced', async () => {
62+
const { instance, callZome } = client({ get_my_group: null });
63+
expect(await loadGroupAnchors(instance)).toEqual([]);
64+
expect(callZome).toHaveBeenCalledTimes(1);
65+
});
66+
67+
it('answers nothing when the app has no group cell', async () => {
68+
const instance = { appInfo: async () => ({ cell_info: {} }), callZome: vi.fn() } as unknown as AppClient;
69+
expect(await loadGroupAnchors(instance)).toEqual([]);
70+
});
71+
});

‎ui/src/lib/moss/anchors.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { decodeHashFromBase64, type AppClient } from '@holochain/client';
2+
import type { NdoAnchorEntry } from '@nondominium/shared-types';
3+
import {
4+
decodeNdoAnchorRecord,
5+
groupProfileFromRecord,
6+
type GroupHolochainRecord
7+
} from '../services/group-clone.helpers';
8+
import { getMossGroupCell } from './group-cell';
9+
10+
/**
11+
* The NDO anchors of the tool instance's group, read straight through the
12+
* applet client Moss passes to `AppletServices`. `search` and `getAssetInfo`
13+
* run outside the UI's stores, so they cannot use the client service.
14+
* Empty before the group profile has synced into this agent's cell.
15+
*/
16+
export async function loadGroupAnchors(client: AppClient): Promise<NdoAnchorEntry[]> {
17+
const cell = getMossGroupCell(await client.appInfo());
18+
if (!cell) return [];
19+
const call = <T>(fn_name: string, payload: unknown) =>
20+
client.callZome({ cell_id: cell.cellId, zome_name: 'zome_group', fn_name, payload }) as Promise<T>;
21+
22+
const profileRecord = await call<GroupHolochainRecord | null>('get_my_group', null);
23+
const profile = profileRecord ? groupProfileFromRecord(profileRecord) : null;
24+
if (!profile) return [];
25+
26+
const records = await call<GroupHolochainRecord[]>('get_ndo_anchors', decodeHashFromBase64(profile.groupHashB64));
27+
return records.map(decodeNdoAnchorRecord).filter((anchor): anchor is NdoAnchorEntry => anchor !== null);
28+
}
Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { AppClient } from '@holochain/client';
23
import { AppletServices } from '@theweave/api';
4+
import type { NdoAnchorEntry } from '@nondominium/shared-types';
35
import { createAppletServices } from './applet-services';
6+
import { ndoWal } from './assets';
47

5-
describe('createAppletServices', () => {
8+
const seedLibrary = {
9+
name: 'Seed library',
10+
ndo_dna_hash: new Uint8Array([1]),
11+
identity_action_hash: new Uint8Array([2])
12+
} as unknown as NdoAnchorEntry;
13+
const toolShed = {
14+
name: 'Tool shed',
15+
ndo_dna_hash: new Uint8Array([3]),
16+
identity_action_hash: new Uint8Array([4])
17+
} as unknown as NdoAnchorEntry;
18+
19+
const client = { appInfo: vi.fn() } as unknown as AppClient;
20+
21+
describe('createAppletServices without an asset source', () => {
622
it('builds a Weave AppletServices object', () => {
723
expect(createAppletServices()).toBeInstanceOf(AppletServices);
824
});
@@ -11,15 +27,30 @@ describe('createAppletServices', () => {
1127
expect(createAppletServices().creatables).toEqual({});
1228
});
1329

14-
it('answers search with no assets until NDOs are exposed as assets', async () => {
30+
it('has no assets', async () => {
1531
const services = createAppletServices();
16-
const result = await services.search({} as never, new Uint8Array(), {} as never, 'anything');
17-
expect(result).toEqual([]);
32+
expect(await services.search(client, new Uint8Array(), {} as never, 'anything')).toEqual([]);
33+
expect(await services.getAssetInfo(client, ndoWal(seedLibrary))).toBeUndefined();
1834
});
35+
});
1936

20-
it('answers getAssetInfo with nothing until NDOs are exposed as assets', async () => {
21-
const services = createAppletServices();
22-
const result = await services.getAssetInfo({} as never, { hrl: [new Uint8Array(), new Uint8Array()] });
23-
expect(result).toBeUndefined();
37+
describe('createAppletServices with NDOs as assets', () => {
38+
const source = { loadAnchors: vi.fn(async () => [seedLibrary, toolShed]) };
39+
40+
it('finds NDOs by name through the applet client Moss passes', async () => {
41+
const services = createAppletServices(source);
42+
const found = await services.search(client, new Uint8Array(), {} as never, 'tool');
43+
expect(found).toEqual([ndoWal(toolShed)]);
44+
expect(source.loadAnchors).toHaveBeenCalledWith(client);
45+
});
46+
47+
it('names an NDO from its WAL', async () => {
48+
const info = await createAppletServices(source).getAssetInfo(client, ndoWal(seedLibrary));
49+
expect(info?.name).toBe('Seed library');
50+
});
51+
52+
it('answers undefined for a WAL that is not one of the group NDOs', async () => {
53+
const stranger = ndoWal({ ndo_dna_hash: new Uint8Array([9]), identity_action_hash: new Uint8Array([9]) });
54+
expect(await createAppletServices(source).getAssetInfo(client, stranger)).toBeUndefined();
2455
});
2556
});

‎ui/src/lib/moss/applet-services.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
1+
import type { AppClient } from '@holochain/client';
12
import { AppletServices } from '@theweave/api';
3+
import type { NdoAnchorEntry } from '@nondominium/shared-types';
4+
import { ndoAssetInfo, searchNdoAssets } from './assets';
5+
6+
/** Where the asset services read the NDOs of the group from. */
7+
export interface AssetSource {
8+
loadAnchors(client: AppClient): Promise<NdoAnchorEntry[]>;
9+
}
210

311
/**
4-
* What Nondominium tells Moss about itself. v0.1.0 scaffold: no creatables and
5-
* no assets. NDOs become assets (`search`, `getAssetInfo`) in the asset slice.
12+
* What Nondominium tells Moss about itself. NDOs are the tool's assets:
13+
* `search` finds them by name and `getAssetInfo` names one. Opening an asset
14+
* is not a method here: Moss renders the tool with an asset view and the UI
15+
* opens the NDO (`view.ts`). Without a source the tool has no assets, which is
16+
* how the unit tests and the bare scaffold use it.
617
*/
7-
export function createAppletServices(): AppletServices {
8-
return new AppletServices();
18+
export function createAppletServices(source?: AssetSource): AppletServices {
19+
const services = new AppletServices();
20+
if (!source) return services;
21+
services.search = async (appletClient, _appletHash, _weaveServices, searchFilter) =>
22+
searchNdoAssets(await source.loadAnchors(appletClient), searchFilter);
23+
services.getAssetInfo = async (appletClient, wal) =>
24+
ndoAssetInfo(await source.loadAnchors(appletClient), wal);
25+
return services;
926
}

0 commit comments

Comments
 (0)