Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ NDO over a live DHT.
connection mode that takes the conductor client from Moss. The standalone build is
unchanged. See `documentation/specifications/moss-integration.md`.
- **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.
- **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.
- **Lobby DNA** as the entry point. Groups are announced here for discovery, and NDOs are
browsed and filtered across every group the agent belongs to.
- **Group DNA, one cloned cell per group.** Each group is its own DHT with its own
Expand Down
2 changes: 1 addition & 1 deletion documentation/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ Nondominium runs as a Moss tool in `weave` dev mode. Standalone (Lobby, Group DN

- ✅ **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.
- ✅ **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)).
- ❌ **NDOs as assets (#165).** WAL, `getAssetInfo`, `search`, asset view.
- ✅ **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.

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

Expand Down
13 changes: 12 additions & 1 deletion documentation/specifications/moss-integration.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Moss Integration

**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.
**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.

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.

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

**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.

## NDOs as Moss assets (#165)

Each NDO is a Moss asset, so other tools can point at it.

- **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`).
- **`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.
- **`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.
- **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.

**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.

## Open items

- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need (see above and the #162 result).
Expand Down
2 changes: 1 addition & 1 deletion documentation/specifications/ui_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ On top of this hierarchy, the **next** UI iteration adds a cross-cutting **Persp

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)).

**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).
**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).

---

Expand Down
23 changes: 23 additions & 0 deletions ui/src/lib/components/ndo/NdoView.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import { decodeHashFromBase64 } from '@holochain/client';
import { appContext } from '$lib/stores/app.context.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';
import {
createNdoStore,
isTaskInterrupted,
Expand All @@ -24,6 +26,18 @@

let { specHashB64 }: Props = $props();

// Moss: the NDO's asset, put in the pocket so it can be linked from other tools.
let pocketAdded = $state(false);
async function addToPocket() {
const cellId = store?.cellId;
if (!cellId) return;
await mossStore.addToPocket({
dnaHash: cellId[0],
identityActionHash: decodeHashFromBase64(specHashB64)
});
pocketAdded = true;
}

/**
* The store of the NDO open in this view, one per `specHashB64`. Recreated when the
* prop changes and destroyed with the old hash or on teardown, which interrupts every
Expand Down Expand Up @@ -166,6 +180,15 @@
<p class="mt-1 font-mono text-xs text-gray-400">{specHashB64.slice(0, 20)}…</p>
</div>
<div class="flex flex-wrap items-center gap-2 md:ml-4 md:shrink-0 md:flex-nowrap">
{#if mossStore.isMoss && store?.cellId}
<button
type="button"
onclick={() => void addToPocket()}
class="rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
>
{pocketAdded ? 'In your Moss pocket' : 'Add to Moss pocket'}
</button>
{/if}
<button
type="button"
disabled={joinLoading}
Expand Down
6 changes: 5 additions & 1 deletion ui/src/lib/components/shell/AppShell.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import Sidebar from './Sidebar.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';

interface Props {
children?: import('svelte').Snippet;
Expand All @@ -22,6 +23,8 @@
<svelte:window onkeydown={onKeydown} />

<div class="flex min-h-screen bg-gray-100">
<!-- Moss opens an NDO asset in a side panel or window: just the NDO, no navigation. -->
{#if !mossStore.isAssetView}
<header
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"
>
Expand Down Expand Up @@ -61,8 +64,9 @@
>
<Sidebar />
</div>
{/if}

<main class="min-w-0 flex-1 overflow-auto pt-12 md:pt-0">
<main class="min-w-0 flex-1 overflow-auto {mossStore.isAssetView ? '' : 'pt-12 md:pt-0'}">
{@render children?.()}
</main>
</div>
71 changes: 71 additions & 0 deletions ui/src/lib/moss/anchors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
import { CellType, type AppClient } from '@holochain/client';
import { loadGroupAnchors } from './anchors';

const dna = new Uint8Array([1, 2, 3]);
const me = new Uint8Array([9, 9, 9]);
const groupHash = new Uint8Array([5, 5, 5]);

const groupRecord = {
signed_action: { hashed: { hash: groupHash, content: { header: { author: me, timestamp: 1 } } } },
entry: { Present: { entry: { name: 'Pilot' } } }
};

function anchorRecord(name: string, ndoDna: Uint8Array, identity: Uint8Array) {
return {
signed_action: { hashed: { hash: new Uint8Array([7]), content: { header: { author: me, timestamp: 1 } } } },
entry: {
Present: {
entry: { name, ndo_dna_hash: ndoDna, identity_action_hash: identity, network_seed: `seed_${name}` }
}
}
};
}

function client(calls: Record<string, unknown>) {
const callZome = vi.fn(async (request: { fn_name: string }) => calls[request.fn_name]);
const appInfo = vi.fn(async () => ({
cell_info: {
group: [{ type: CellType.Provisioned, value: { cell_id: [dna, me], dna_modifiers: { network_seed: 's' } } }]
}
}));
return { callZome, appInfo, instance: { callZome, appInfo } as unknown as AppClient };
}

describe('loadGroupAnchors', () => {
it('reads the NDO anchors of the instance group cell through the applet client', async () => {
const { instance, callZome } = client({
get_my_group: groupRecord,
get_ndo_anchors: [anchorRecord('Seed library', new Uint8Array([1]), new Uint8Array([2]))]
});
const anchors = await loadGroupAnchors(instance);
expect(anchors.map((a) => a.name)).toEqual(['Seed library']);
const fns = callZome.mock.calls.map(([request]) => (request as { fn_name: string }).fn_name);
expect(fns).toEqual(['get_my_group', 'get_ndo_anchors']);
const first = callZome.mock.calls[0]?.[0] as unknown as { cell_id: unknown; zome_name: string };
expect(first.zome_name).toBe('zome_group');
expect(first.cell_id).toEqual([dna, me]);
});

it('skips anchors that lack clone coordinates', async () => {
const { instance } = client({
get_my_group: groupRecord,
get_ndo_anchors: [
anchorRecord('Good', new Uint8Array([1]), new Uint8Array([2])),
{ signed_action: groupRecord.signed_action, entry: { Present: { entry: { name: 'Broken' } } } }
]
});
expect((await loadGroupAnchors(instance)).map((a) => a.name)).toEqual(['Good']);
});

it('answers nothing before the group profile has synced', async () => {
const { instance, callZome } = client({ get_my_group: null });
expect(await loadGroupAnchors(instance)).toEqual([]);
expect(callZome).toHaveBeenCalledTimes(1);
});

it('answers nothing when the app has no group cell', async () => {
const instance = { appInfo: async () => ({ cell_info: {} }), callZome: vi.fn() } as unknown as AppClient;
expect(await loadGroupAnchors(instance)).toEqual([]);
});
});
28 changes: 28 additions & 0 deletions ui/src/lib/moss/anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { decodeHashFromBase64, type AppClient } from '@holochain/client';
import type { NdoAnchorEntry } from '@nondominium/shared-types';
import {
decodeNdoAnchorRecord,
groupProfileFromRecord,
type GroupHolochainRecord
} from '../services/group-clone.helpers';
import { getMossGroupCell } from './group-cell';

/**
* The NDO anchors of the tool instance's group, read straight through the
* applet client Moss passes to `AppletServices`. `search` and `getAssetInfo`
* run outside the UI's stores, so they cannot use the client service.
* Empty before the group profile has synced into this agent's cell.
*/
export async function loadGroupAnchors(client: AppClient): Promise<NdoAnchorEntry[]> {
const cell = getMossGroupCell(await client.appInfo());
if (!cell) return [];
const call = <T>(fn_name: string, payload: unknown) =>
client.callZome({ cell_id: cell.cellId, zome_name: 'zome_group', fn_name, payload }) as Promise<T>;

const profileRecord = await call<GroupHolochainRecord | null>('get_my_group', null);
const profile = profileRecord ? groupProfileFromRecord(profileRecord) : null;
if (!profile) return [];

const records = await call<GroupHolochainRecord[]>('get_ndo_anchors', decodeHashFromBase64(profile.groupHashB64));
return records.map(decodeNdoAnchorRecord).filter((anchor): anchor is NdoAnchorEntry => anchor !== null);
}
49 changes: 40 additions & 9 deletions ui/src/lib/moss/applet-services.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { AppClient } from '@holochain/client';
import { AppletServices } from '@theweave/api';
import type { NdoAnchorEntry } from '@nondominium/shared-types';
import { createAppletServices } from './applet-services';
import { ndoWal } from './assets';

describe('createAppletServices', () => {
const seedLibrary = {
name: 'Seed library',
ndo_dna_hash: new Uint8Array([1]),
identity_action_hash: new Uint8Array([2])
} as unknown as NdoAnchorEntry;
const toolShed = {
name: 'Tool shed',
ndo_dna_hash: new Uint8Array([3]),
identity_action_hash: new Uint8Array([4])
} as unknown as NdoAnchorEntry;

const client = { appInfo: vi.fn() } as unknown as AppClient;

describe('createAppletServices without an asset source', () => {
it('builds a Weave AppletServices object', () => {
expect(createAppletServices()).toBeInstanceOf(AppletServices);
});
Expand All @@ -11,15 +27,30 @@ describe('createAppletServices', () => {
expect(createAppletServices().creatables).toEqual({});
});

it('answers search with no assets until NDOs are exposed as assets', async () => {
it('has no assets', async () => {
const services = createAppletServices();
const result = await services.search({} as never, new Uint8Array(), {} as never, 'anything');
expect(result).toEqual([]);
expect(await services.search(client, new Uint8Array(), {} as never, 'anything')).toEqual([]);
expect(await services.getAssetInfo(client, ndoWal(seedLibrary))).toBeUndefined();
});
});

it('answers getAssetInfo with nothing until NDOs are exposed as assets', async () => {
const services = createAppletServices();
const result = await services.getAssetInfo({} as never, { hrl: [new Uint8Array(), new Uint8Array()] });
expect(result).toBeUndefined();
describe('createAppletServices with NDOs as assets', () => {
const source = { loadAnchors: vi.fn(async () => [seedLibrary, toolShed]) };

it('finds NDOs by name through the applet client Moss passes', async () => {
const services = createAppletServices(source);
const found = await services.search(client, new Uint8Array(), {} as never, 'tool');
expect(found).toEqual([ndoWal(toolShed)]);
expect(source.loadAnchors).toHaveBeenCalledWith(client);
});

it('names an NDO from its WAL', async () => {
const info = await createAppletServices(source).getAssetInfo(client, ndoWal(seedLibrary));
expect(info?.name).toBe('Seed library');
});

it('answers undefined for a WAL that is not one of the group NDOs', async () => {
const stranger = ndoWal({ ndo_dna_hash: new Uint8Array([9]), identity_action_hash: new Uint8Array([9]) });
expect(await createAppletServices(source).getAssetInfo(client, stranger)).toBeUndefined();
});
});
25 changes: 21 additions & 4 deletions ui/src/lib/moss/applet-services.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import type { AppClient } from '@holochain/client';
import { AppletServices } from '@theweave/api';
import type { NdoAnchorEntry } from '@nondominium/shared-types';
import { ndoAssetInfo, searchNdoAssets } from './assets';

/** Where the asset services read the NDOs of the group from. */
export interface AssetSource {
loadAnchors(client: AppClient): Promise<NdoAnchorEntry[]>;
}

/**
* What Nondominium tells Moss about itself. v0.1.0 scaffold: no creatables and
* no assets. NDOs become assets (`search`, `getAssetInfo`) in the asset slice.
* What Nondominium tells Moss about itself. NDOs are the tool's assets:
* `search` finds them by name and `getAssetInfo` names one. Opening an asset
* is not a method here: Moss renders the tool with an asset view and the UI
* opens the NDO (`view.ts`). Without a source the tool has no assets, which is
* how the unit tests and the bare scaffold use it.
*/
export function createAppletServices(): AppletServices {
return new AppletServices();
export function createAppletServices(source?: AssetSource): AppletServices {
const services = new AppletServices();
if (!source) return services;
services.search = async (appletClient, _appletHash, _weaveServices, searchFilter) =>
searchNdoAssets(await source.loadAnchors(appletClient), searchFilter);
services.getAssetInfo = async (appletClient, wal) =>
ndoAssetInfo(await source.loadAnchors(appletClient), wal);
return services;
}
Loading
Loading