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 @@ -26,6 +26,7 @@ NDO over a live DHT.
with two agents and one group (`bun run tool-dev`), `@theweave/api` in the UI, and a
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.
- **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 @@ -296,7 +296,7 @@ There is no `get_all_groups` extern in a Group cell; each cell represents one is
Nondominium runs as a Moss tool in `weave` dev mode. Standalone (Lobby, Group DNA, invite codes) is unchanged.

- ✅ **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).** Profiles from Moss, roles from `myAccountabilitiesPerGroup()`, the instance's `group` cell as the Nondominium group, NDO clones through the `WeaveClient`. Not yet implemented: inside Moss the Lobby and invite-code flows are not adapted.
- ✅ **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.

See [moss-integration.md](specifications/moss-integration.md).
Expand Down
23 changes: 21 additions & 2 deletions 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 are implemented (#163). The Moss group context (#164) and NDOs as assets (#165) follow. Supersedes the "Moss Integration Contract" in [lobby-architecture.md](lobby-architecture.md) §9 where they differ.
**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.

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 @@ -55,7 +55,26 @@ hc-connect.ts connectHolochainClient()

Moss mode is a connection mode (`HolochainConnectionMode` gains `'moss'`). The cross-group view is not implemented: a cross-group render throws.

## The Moss group context (#164)

Inside Moss the standalone Lobby model is replaced behind one seam, `LobbyServiceResolved` in `lobby.service.ts`: it is `MossLobbyServiceLive` when the page is hosted by Moss and `LobbyServiceLive` otherwise. Stores, `NdoService` and components see the same `LobbyService` interface in both.

| Concern | Standalone | Inside Moss |
|---|---|---|
| Groups | Lobby DNA announces clones of the Group DNA | One group: the tool instance's provisioned `group` cell (`MOSS_GROUP_ID = 'moss'`) |
| Joining | Invite code or link | Automatic: the tool installer (`toolInstaller`) creates the `GroupProfile`, named after the Moss group; every other member waits for it to gossip, then calls `join_group` |
| Profile | `LobbyUserProfile` form, localStorage | `profilesClient`, mapped to the same `LobbyUserProfile` shape |
| Role | none | `myAccountabilitiesPerGroup()`, strongest of Member, Steward, Progenitor, shown in the sidebar |
| NDO clone | `AppWebsocket.createCloneCell` | `WeaveClient.createCloneCell(req, PUBLIC_NDO_CLONES)` |
| Discovery of NDOs | `NdoAnchor` in the group cell | same |

Create group, join group and invite links are hidden in Moss (`mossStore.isMoss`), and the service refuses them with `Groups are managed by Moss`. The Lobby DHT calls answer with nothing: every tool instance is its own network, so there is no shared lobby to announce to.

Code: `ui/src/lib/moss/` (`group-cell.ts`, `profile.ts`, `ndo-clone.ts`), `ui/src/lib/services/zomes/moss-lobby.ts` (the service, with injected dependencies so it runs in a unit test against a mocked WeaveClient) and `ui/src/lib/stores/moss.store.svelte.ts`.

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

## Open items

- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need. See the #162 result and #164.
- `weaveClient.createCloneCell(req, true)` fails whenever `req.modifiers.properties` is set, which NDO clones need (see above and the #162 result).
- `AppletServices` in `@theweave/api` `0.7.0-rc.3` has `getAssetInfo`, `search` and `creatables`. Opening an asset is not a method on it: Moss renders the tool with an `asset` view (`renderInfo.view.type === 'asset'`), and the tool calls `weaveClient.openAsset` to open other assets.
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. 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 and role for components. Details in [moss-integration.md](moss-integration.md).

---

Expand Down
20 changes: 12 additions & 8 deletions ui/src/lib/components/group/GroupView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { appContext } from '$lib/stores/app.context.svelte';
import { groupStore } from '$lib/stores/group.store.svelte';
import { lobbyStore } from '$lib/stores/lobby.store.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';
import { devStorageKey } from '$lib/utils/hc-connect';
import type { ActiveFilters } from '$lib/stores/lobby.store.svelte';
import NdoBrowser from '$lib/components/lobby/NdoBrowser.svelte';
Expand Down Expand Up @@ -102,7 +103,8 @@
appContext.currentView = 'group';
appContext.selectedGroupId = groupId;
void groupStore.loadGroupData(groupId);
if (!hasVisited(groupId)) {
// Inside Moss the profile and who sees it are the Moss group's business.
if (!mossStore.isMoss && !hasVisited(groupId)) {
showProfileModal = true;
}
});
Expand Down Expand Up @@ -183,13 +185,15 @@
<p class="mt-1 font-mono text-sm text-gray-400">{groupId}</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={copyInviteLink}
class="rounded border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
{inviteCopied ? 'Invite link copied!' : 'Copy invite link'}
</button>
{#if !mossStore.isMoss}
<button
type="button"
onclick={copyInviteLink}
class="rounded border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
>
{inviteCopied ? 'Invite link copied!' : 'Copy invite link'}
</button>
{/if}
<button
type="button"
onclick={() => {
Expand Down
20 changes: 12 additions & 8 deletions ui/src/lib/components/lobby/LobbyProfileBar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { appContext } from '$lib/stores/app.context.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';

interface Props {
/** Open the profile setup / edit modal */
Expand All @@ -18,14 +19,17 @@
<span class="text-sm text-gray-700">
Signed in as <span class="font-semibold text-gray-900">{profile.nickname}</span>
</span>
<button
type="button"
aria-label="Edit profile"
onclick={() => onOpenProfile()}
class="rounded px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50"
>
Edit
</button>
<!-- Inside Moss the profile belongs to the Moss group and is edited there. -->
{#if !mossStore.isMoss}
<button
type="button"
aria-label="Edit profile"
onclick={() => onOpenProfile()}
class="rounded px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50"
>
Edit
</button>
{/if}
{:else}
<span class="text-sm text-gray-500">No Lobby profile yet</span>
<button
Expand Down
7 changes: 6 additions & 1 deletion ui/src/lib/components/lobby/NdoBrowser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ResourceNature
} from '@nondominium/shared-types';
import type { ActiveFilters } from '$lib/stores/lobby.store.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';
import NdoCard from './NdoCard.svelte';

interface Props {
Expand Down Expand Up @@ -224,7 +225,11 @@
Loading NDOs…
</div>
{:else if descriptors.length === 0 && !errorMessage}
{#if showOnboarding && !hasGroups}
{#if showOnboarding && !hasGroups && mossStore.isMoss}
<p class="text-sm text-gray-500">
Waiting for the Moss group to sync. NDOs appear here once it has.
</p>
{:else if showOnboarding && !hasGroups}
<div class="rounded-lg border border-dashed border-blue-200 bg-blue-50 p-6 text-center">
<p class="text-sm font-medium text-gray-800">Create or join a group to see NDOs</p>
<p class="mt-1 text-sm text-gray-500">
Expand Down
37 changes: 28 additions & 9 deletions ui/src/lib/components/shell/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { lobbyStore } from '$lib/stores/lobby.store.svelte';
import { appContext } from '$lib/stores/app.context.svelte';
import { mossStore } from '$lib/stores/moss.store.svelte';
import UserProfileForm from '$lib/components/lobby/UserProfileForm.svelte';

const isActive = (href: string) =>
Expand Down Expand Up @@ -138,21 +139,27 @@
>
{g.name}
</a>
<button
type="button"
title="Copy invite link"
onclick={(e) => copyInviteLink(g.id, e)}
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-white hover:text-blue-600"
>
{copiedGroupId === g.id ? '✓' : '⎘'}
</button>
{#if !mossStore.isMoss}
<button
type="button"
title="Copy invite link"
onclick={(e) => copyInviteLink(g.id, e)}
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-white hover:text-blue-600"
>
{copiedGroupId === g.id ? '✓' : '⎘'}
</button>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="mb-2 text-xs text-gray-400 italic">No groups yet.</p>
<p class="mb-2 text-xs text-gray-400 italic">
{mossStore.isMoss ? 'Waiting for the Moss group to sync…' : 'No groups yet.'}
</p>
{/if}

<!-- Inside Moss the Moss group is the group: no creating, joining or invite codes. -->
{#if !mossStore.isMoss}
<!-- Create Group -->
{#if showCreateForm}
<div class="mb-2 rounded border border-gray-200 bg-white p-2">
Expand Down Expand Up @@ -255,8 +262,19 @@
</button>
{/if}

{/if}

<!-- My Profile -->
<div class="mt-auto border-t border-gray-200 pt-3">
{#if mossStore.isMoss}
<!-- The profile and the role belong to the Moss group; edit them in Moss. -->
<p class="px-2 py-1.5 text-xs text-gray-500">
<span class="font-medium text-gray-700">{appContext.lobbyUserProfile?.nickname ?? 'Member'}</span>
{#if mossStore.role}
<span class="ml-1 text-gray-400">· {mossStore.role}</span>
{/if}
</p>
{:else}
<button
type="button"
onclick={() => {
Expand All @@ -271,5 +289,6 @@
Set up profile
{/if}
</button>
{/if}
</div>
</nav>
55 changes: 55 additions & 0 deletions ui/src/lib/moss/group-cell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { CellType, type AppInfo } from '@holochain/client';
import { MOSS_GROUP_ID, getMossGroupCell } from './group-cell';

const dna = new Uint8Array([1, 2, 3]);
const agent = new Uint8Array([9, 9, 9]);

function appInfo(cellInfo: Record<string, unknown[]>): AppInfo {
return { cell_info: cellInfo } as unknown as AppInfo;
}

describe('getMossGroupCell', () => {
it('returns the provisioned group cell Moss installs for the tool instance', () => {
const cell = getMossGroupCell(
appInfo({
group: [
{
type: CellType.Provisioned,
value: { cell_id: [dna, agent], dna_modifiers: { network_seed: 'instance-seed' } }
}
]
})
);
expect(cell).toEqual({
networkSeed: 'instance-seed',
dnaHash: dna,
cellId: [dna, agent],
cloneId: 'group',
enabled: true
});
});

it('ignores cloned group cells, which belong to the standalone model', () => {
const cell = getMossGroupCell(
appInfo({
group: [
{ type: CellType.Cloned, value: { cell_id: [dna, agent], dna_modifiers: { network_seed: 'x' } } }
]
})
);
expect(cell).toBeNull();
});

it('falls back to the group id when the cell carries no network seed', () => {
const cell = getMossGroupCell(
appInfo({ group: [{ type: CellType.Provisioned, value: { cell_id: [dna, agent], dna_modifiers: {} } }] })
);
expect(cell?.networkSeed).toBe(MOSS_GROUP_ID);
});

it('returns null when the app has no group role or no app info', () => {
expect(getMossGroupCell(appInfo({}))).toBeNull();
expect(getMossGroupCell(null)).toBeNull();
});
});
31 changes: 31 additions & 0 deletions ui/src/lib/moss/group-cell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CellType, type AppInfo, type CellInfo } from '@holochain/client';
import type { GroupCellInfo } from '../services/group-clone.helpers';

/**
* Inside Moss there is exactly one Nondominium group: the tool instance's own
* `group` cell. This is its id wherever the standalone UI would use the clone's
* network seed.
*/
export const MOSS_GROUP_ID = 'moss';

/**
* The `group` cell Moss installs for the tool instance. Moss provisions every
* role of the manifest, deferred ones included (verified in the #162 spike), so
* unlike the standalone model this cell is provisioned, not cloned.
*/
export function getMossGroupCell(appInfo: AppInfo | null): GroupCellInfo | null {
const cells: CellInfo[] = appInfo?.cell_info?.['group'] ?? [];
for (const cellInfo of cells) {
if (cellInfo.type !== CellType.Provisioned) continue;
const { cell_id, dna_modifiers } = cellInfo.value;
const seed = dna_modifiers?.network_seed;
return {
networkSeed: typeof seed === 'string' && seed ? seed : MOSS_GROUP_ID,
dnaHash: cell_id[0],
cellId: cell_id,
cloneId: 'group',
enabled: true
};
}
return null;
}
Loading
Loading