[1/5] feat(intelligence): surface own tiny.place identity + relay in Orchestration tab (#4588)

This commit is contained in:
oxoxDev
2026-07-06 11:24:13 -07:00
committed by GitHub
parent 0b71713a5b
commit 05a3c97b97
25 changed files with 901 additions and 8 deletions
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import RelayBadge from './RelayBadge';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
describe('RelayBadge', () => {
it('renders nothing when relay info is absent', () => {
const { container } = render(<RelayBadge relay={null} />);
expect(container.firstChild).toBeNull();
});
it('shows the staging label + base url and tags the network', () => {
render(
<RelayBadge relay={{ baseUrl: 'https://staging-api.tiny.place', network: 'staging' }} />
);
const badge = screen.getByTestId('tinyplace-relay-badge');
expect(badge).toHaveAttribute('data-network', 'staging');
expect(badge).toHaveAttribute('title', 'https://staging-api.tiny.place');
expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.staging');
});
it('shows the production label for the prod relay', () => {
render(<RelayBadge relay={{ baseUrl: 'https://api.tiny.place', network: 'prod' }} />);
const badge = screen.getByTestId('tinyplace-relay-badge');
expect(badge).toHaveAttribute('data-network', 'prod');
expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.prod');
});
});
@@ -0,0 +1,44 @@
/**
* RelayBadge — a small always-visible pill naming the tiny.place relay the core
* is talking to (STAGING / PRODUCTION).
*
* Why it exists: during live debugging, claudebot sat on `staging-api.tiny.place`
* while the core silently defaulted to prod `api.tiny.place`, producing a mystery
* 404. Nothing on screen said which relay either side used. Surfacing the target
* relay as a first-class chip makes a mismatch obvious instead of opaque.
*
* Presentational only — the parent fetches {@link RelayInfo} via
* `orchestrationClient.relayInfo()` and passes it down.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { RelayInfo } from '../../lib/orchestration/orchestrationClient';
export interface RelayBadgeProps {
relay: RelayInfo | null;
}
export default function RelayBadge({ relay }: RelayBadgeProps): React.ReactElement | null {
const { t } = useT();
if (!relay) return null;
const isStaging = relay.network === 'staging';
const label = isStaging
? t('tinyplaceOrchestration.relay.staging')
: t('tinyplaceOrchestration.relay.prod');
// Staging is the everyday dev target (amber, "you're on the test relay");
// production is the live one (sage). Colour, not just text, so it's scannable.
const tone = isStaging
? 'border-amber-400 text-amber-700 dark:border-amber-500/60 dark:text-amber-300'
: 'border-sage-400 text-sage-700 dark:border-sage-500/60 dark:text-sage-300';
return (
<span
data-testid="tinyplace-relay-badge"
data-network={relay.network}
title={relay.baseUrl}
className={`inline-flex flex-none items-center rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wide ${tone}`}>
{label}
</span>
);
}
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient';
import SelfIdentityCard from './SelfIdentityCard';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const discoverable: SelfIdentity = {
agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh',
handles: [{ username: 'openhuman', primary: true }],
primaryHandle: 'openhuman',
cardPublished: true,
keyPublished: true,
discoverable: true,
};
describe('SelfIdentityCard', () => {
beforeEach(() => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
});
it('shows a loading state before the identity resolves', () => {
render(<SelfIdentityCard identity={null} loading />);
expect(screen.getByTestId('tinyplace-self-identity')).toHaveTextContent(
'tinyplaceOrchestration.identity.loading'
);
});
it('renders nothing once loaded with no identity', () => {
const { container } = render(<SelfIdentityCard identity={null} loading={false} />);
expect(container.firstChild).toBeNull();
});
it('renders the primary handle, shortened address and discoverable status', () => {
render(<SelfIdentityCard identity={discoverable} loading={false} />);
expect(screen.getByText('@openhuman')).toBeInTheDocument();
// Address is shortened but the full value is preserved in the title.
expect(screen.getByText('6wNaBJ…QbHSh')).toHaveAttribute('title', discoverable.agentId);
const status = screen.getByTestId('tinyplace-self-identity-status');
expect(status).toHaveAttribute('data-discoverable', 'true');
});
it('flags an un-messageable identity and shows the register hint', () => {
const undiscoverable: SelfIdentity = {
agentId: 'addrWithNoCardPublishedYet',
handles: [],
cardPublished: false,
keyPublished: false,
discoverable: false,
};
render(<SelfIdentityCard identity={undiscoverable} loading={false} />);
expect(screen.getByText('tinyplaceOrchestration.identity.noHandle')).toBeInTheDocument();
expect(screen.getByTestId('tinyplace-self-identity-status')).toHaveAttribute(
'data-discoverable',
'false'
);
expect(
screen.getByText('tinyplaceOrchestration.identity.undiscoverableHint')
).toBeInTheDocument();
});
it('copies the address to the clipboard on click', async () => {
render(<SelfIdentityCard identity={discoverable} loading={false} />);
fireEvent.click(screen.getByTestId('tinyplace-self-identity-copy'));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(discoverable.agentId);
await waitFor(() =>
expect(screen.getByTestId('tinyplace-self-identity-copy')).toHaveTextContent(
'tinyplaceOrchestration.identity.copied'
)
);
});
});
@@ -0,0 +1,134 @@
/**
* SelfIdentityCard — pinned at the top of the roster, shows OpenHuman's *own*
* tiny.place identity so the user can hand it to a peer to be messaged inbound.
*
* Why it exists: the tab previously showed peers/contacts but never OpenHuman's
* own agent id, so there was no way to say "here's my address, DM me". Worse, a
* fresh identity can accept contacts yet stay un-messageable until it registers a
* @handle (which is what publishes its directory card + Signal key) — every send
* to it 404s on `/directory/agents/<addr>`. This card surfaces both the address
* and the discoverability gap instead of leaving it a mystery.
*
* Presentational only — the parent fetches {@link SelfIdentity} via
* `orchestrationClient.selfIdentity()` and owns refresh.
*/
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient';
export interface SelfIdentityCardProps {
identity: SelfIdentity | null;
loading: boolean;
}
function shortAddress(address: string): string {
if (address.length <= 14) return address;
return `${address.slice(0, 6)}${address.slice(-5)}`;
}
export default function SelfIdentityCard({
identity,
loading,
}: SelfIdentityCardProps): React.ReactElement {
const { t } = useT();
const [copied, setCopied] = useState(false);
const address = identity?.agentId ?? '';
const onCopy = useCallback(() => {
if (!address) return;
void navigator.clipboard?.writeText(address).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
},
() => setCopied(false)
);
}, [address]);
if (loading && !identity) {
return (
<section
data-testid="tinyplace-self-identity"
className="border-b border-line bg-surface-muted/40 px-4 py-3 text-[11px] text-content-faint">
{t('tinyplaceOrchestration.identity.loading')}
</section>
);
}
if (!identity) return <></>;
const primaryHandle = identity.primaryHandle ?? identity.handles[0]?.username;
return (
<section
data-testid="tinyplace-self-identity"
className="border-b border-line bg-surface-muted/40 px-4 py-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className="flex h-6 w-6 flex-none items-center justify-center rounded-md bg-sage-500 font-mono text-[11px] font-bold text-white">
OH
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-content">
{primaryHandle ? `@${primaryHandle}` : t('tinyplaceOrchestration.identity.noHandle')}
</div>
<div className="truncate font-mono text-[10px] text-content-faint" title={address}>
{shortAddress(address)}
</div>
</div>
<button
type="button"
data-testid="tinyplace-self-identity-copy"
onClick={onCopy}
disabled={!address}
className="flex-none rounded-md px-1.5 py-0.5 text-[11px] font-semibold text-ocean-600 transition hover:bg-ocean-500/10 disabled:opacity-40 dark:text-ocean-300">
{copied
? t('tinyplaceOrchestration.identity.copied')
: t('tinyplaceOrchestration.identity.copy')}
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{identity.discoverable ? (
<span
data-testid="tinyplace-self-identity-status"
data-discoverable="true"
className="inline-flex items-center rounded-full bg-sage-500/10 px-2 py-0.5 text-[10px] font-semibold text-sage-700 dark:text-sage-300">
{t('tinyplaceOrchestration.identity.discoverable')}
</span>
) : (
<span
data-testid="tinyplace-self-identity-status"
data-discoverable="false"
className="inline-flex items-center rounded-full bg-coral-500/10 px-2 py-0.5 text-[10px] font-semibold text-coral-700 dark:text-coral-300">
{t('tinyplaceOrchestration.identity.undiscoverable')}
</span>
)}
<span
className="inline-flex items-center gap-1 rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-content-muted"
title={t('tinyplaceOrchestration.identity.card')}>
{t('tinyplaceOrchestration.identity.card')}:{' '}
{identity.cardPublished
? t('tinyplaceOrchestration.identity.published')
: t('tinyplaceOrchestration.identity.notPublished')}
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-surface-strong px-2 py-0.5 text-[10px] text-content-muted"
title={t('tinyplaceOrchestration.identity.key')}>
{t('tinyplaceOrchestration.identity.key')}:{' '}
{identity.keyPublished
? t('tinyplaceOrchestration.identity.published')
: t('tinyplaceOrchestration.identity.notPublished')}
</span>
</div>
{!identity.discoverable ? (
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1 text-[10px] text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.identity.undiscoverableHint')}
</p>
) : null}
</section>
);
}
@@ -30,6 +30,8 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => {
sendMasterMessage: vi.fn(),
markRead: vi.fn(),
status: vi.fn(),
selfIdentity: vi.fn(),
relayInfo: vi.fn(),
},
};
});
@@ -50,6 +52,8 @@ const messagesListMock = vi.mocked(orchestrationClient.messagesList);
const sendMasterMock = vi.mocked(orchestrationClient.sendMasterMessage);
const markReadMock = vi.mocked(orchestrationClient.markRead);
const statusMock = vi.mocked(orchestrationClient.status);
const selfIdentityMock = vi.mocked(orchestrationClient.selfIdentity);
const relayInfoMock = vi.mocked(orchestrationClient.relayInfo);
const pairingListMock = vi.mocked(apiClient.orchestrationPairing.list);
const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession);
@@ -105,6 +109,18 @@ describe('TinyPlaceOrchestrationTab', () => {
sendMasterMock.mockResolvedValue({ ok: true, messageId: 'm-1' });
markReadMock.mockResolvedValue({ ok: true });
statusMock.mockResolvedValue({});
selfIdentityMock.mockResolvedValue({
agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh',
handles: [{ username: 'openhuman', primary: true }],
primaryHandle: 'openhuman',
cardPublished: true,
keyPublished: true,
discoverable: true,
});
relayInfoMock.mockResolvedValue({
baseUrl: 'https://staging-api.tiny.place',
network: 'staging',
});
pairingListMock.mockResolvedValue({
records: [],
contacts: { contacts: [] },
@@ -191,6 +207,19 @@ describe('TinyPlaceOrchestrationTab', () => {
expect(screen.getByText('OpenHuman app session')).toBeInTheDocument();
});
it('keeps the relay badge visible when identity discovery fails (locked wallet)', async () => {
// selfIdentity() builds the tiny.place client from the wallet and can reject;
// relayInfo() only reads the base URL and must stay visible regardless.
selfIdentityMock.mockRejectedValue(new Error('wallet locked'));
render(<TinyPlaceOrchestrationTab />);
const badge = await screen.findByTestId('tinyplace-relay-badge');
expect(badge).toHaveAttribute('data-network', 'staging');
// Identity read failed → its card must not render.
expect(screen.queryByTestId('tinyplace-self-identity')).not.toBeInTheDocument();
});
it('loads and renders messages for the opened chat', async () => {
sessionsListMock.mockResolvedValue({
sessions: [
@@ -9,6 +9,11 @@ import {
PaymentRequiredError,
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import {
orchestrationClient,
type RelayInfo,
type SelfIdentity,
} from '../../lib/orchestration/orchestrationClient';
import {
type ChatMessage,
type ChatWindow,
@@ -17,6 +22,8 @@ import {
} from '../../lib/orchestration/useOrchestrationChats';
import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious';
import Button from '../ui/Button';
import RelayBadge from './RelayBadge';
import SelfIdentityCard from './SelfIdentityCard';
const debug = debugFactory('brain:tinyplace-orchestration');
@@ -213,6 +220,12 @@ export default function TinyPlaceOrchestrationTab() {
// Which contact rows are expanded to reveal their nested sessions.
const [expandedContacts, setExpandedContacts] = useState<Record<string, boolean>>({});
const [creatingSession, setCreatingSession] = useState<string | null>(null);
// Own tiny.place identity (discoverability) + the relay the core is on. Both
// best-effort: a failed read leaves the card/badge hidden rather than erroring
// the whole tab.
const [selfIdentity, setSelfIdentity] = useState<SelfIdentity | null>(null);
const [identityLoading, setIdentityLoading] = useState(true);
const [relayInfo, setRelayInfo] = useState<RelayInfo | null>(null);
const mountedRef = useRef(true);
const toggleContact = useCallback((address: string) => {
@@ -257,6 +270,40 @@ export default function TinyPlaceOrchestrationTab() {
}
}, []);
const loadIdentity = useCallback(async () => {
debug('[tinyplace-orchestration] identity load entry');
// Identity and relay are independent reads: selfIdentity() builds the
// tiny.place client from the wallet and can reject (locked/unconfigured
// wallet), but relayInfo() only reads the configured base URL and must
// stay visible regardless. Settle them separately so one failure never
// hides the other. Neither failure may break the chat surface.
const [identityResult, relayResult] = await Promise.allSettled([
orchestrationClient.selfIdentity(),
orchestrationClient.relayInfo(),
]);
if (!mountedRef.current) return;
if (identityResult.status === 'fulfilled') {
debug(
'[tinyplace-orchestration] identity load ok discoverable=%s',
identityResult.value.discoverable
);
setSelfIdentity(identityResult.value);
} else {
const reason = identityResult.reason;
const message = reason instanceof Error ? reason.message : String(reason);
debug('[tinyplace-orchestration] identity load error %s', message);
}
if (relayResult.status === 'fulfilled') {
debug('[tinyplace-orchestration] relay load ok network=%s', relayResult.value.network);
setRelayInfo(relayResult.value);
} else {
const reason = relayResult.reason;
const message = reason instanceof Error ? reason.message : String(reason);
debug('[tinyplace-orchestration] relay load error %s', message);
}
setIdentityLoading(false);
}, []);
const runPairingAction = useCallback(
async (actionId: string, action: () => Promise<unknown>) => {
debug('[tinyplace-orchestration] pairing action entry id=%s', actionId);
@@ -297,7 +344,8 @@ export default function TinyPlaceOrchestrationTab() {
const refreshAll = useCallback(() => {
void refresh();
void loadPairing();
}, [refresh, loadPairing]);
void loadIdentity();
}, [refresh, loadPairing, loadIdentity]);
const submitComposer = useCallback(
(event: FormEvent<HTMLFormElement>) => {
@@ -316,12 +364,15 @@ export default function TinyPlaceOrchestrationTab() {
useEffect(() => {
mountedRef.current = true;
const handle = window.setTimeout(() => void loadPairing(), 0);
const handle = window.setTimeout(() => {
void loadPairing();
void loadIdentity();
}, 0);
return () => {
window.clearTimeout(handle);
mountedRef.current = false;
};
}, [loadPairing]);
}, [loadPairing, loadIdentity]);
const pinned = chats.filter(chat => chat.pinned);
const sessions = chats
@@ -416,9 +467,12 @@ export default function TinyPlaceOrchestrationTab() {
<div className="border-b border-line px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-content">
{t('tinyplaceOrchestration.title')}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-semibold text-content">
{t('tinyplaceOrchestration.title')}
</h3>
<RelayBadge relay={relayInfo} />
</div>
<p className="mt-0.5 truncate text-[11px] text-content-muted">
{t('tinyplaceOrchestration.subtitle')}
</p>
@@ -444,6 +498,8 @@ export default function TinyPlaceOrchestrationTab() {
) : null}
</div>
<SelfIdentityCard identity={selfIdentity} loading={identityLoading} />
<section className="border-b border-line px-4 py-3">
<form className="space-y-2" onSubmit={submitLink}>
<label
+14
View File
@@ -288,6 +288,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'إرسال',
'tinyplaceOrchestration.composer.sendFailed': 'فشل إرسال الرسالة',
'tinyplaceOrchestration.steering.label': 'التوجيه',
'tinyplaceOrchestration.relay.staging': 'بيئة التجهيز',
'tinyplaceOrchestration.relay.prod': 'الإنتاج',
'tinyplaceOrchestration.identity.loading': 'جارٍ تحميل الهوية…',
'tinyplaceOrchestration.identity.noHandle': 'لا يوجد @handle بعد',
'tinyplaceOrchestration.identity.copy': 'نسخ',
'tinyplaceOrchestration.identity.copied': 'تم النسخ',
'tinyplaceOrchestration.identity.discoverable': 'قابل للاكتشاف',
'tinyplaceOrchestration.identity.undiscoverable': 'غير قابل للاكتشاف',
'tinyplaceOrchestration.identity.undiscoverableHint':
'سجّل @handle حتى يتمكن الآخرون من مراسلتك.',
'tinyplaceOrchestration.identity.card': 'بطاقة الدليل',
'tinyplaceOrchestration.identity.key': 'مفتاح التشفير',
'tinyplaceOrchestration.identity.published': 'منشور',
'tinyplaceOrchestration.identity.notPublished': 'غير منشور',
'tinyplaceOrchestration.roster.instances': 'مثيلات',
'tinyplaceOrchestration.roster.empty': 'لا توجد مثيلات وكيل بعد',
'tinyplaceOrchestration.roster.other': 'أخرى',
+14
View File
@@ -295,6 +295,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'পাঠান',
'tinyplaceOrchestration.composer.sendFailed': 'বার্তা পাঠাতে ব্যর্থ',
'tinyplaceOrchestration.steering.label': 'নির্দেশনা',
'tinyplaceOrchestration.relay.staging': 'স্টেজিং',
'tinyplaceOrchestration.relay.prod': 'প্রোডাকশন',
'tinyplaceOrchestration.identity.loading': 'পরিচয় লোড হচ্ছে…',
'tinyplaceOrchestration.identity.noHandle': 'এখনও কোনো @handle নেই',
'tinyplaceOrchestration.identity.copy': 'কপি করুন',
'tinyplaceOrchestration.identity.copied': 'কপি হয়েছে',
'tinyplaceOrchestration.identity.discoverable': 'খুঁজে পাওয়া যায়',
'tinyplaceOrchestration.identity.undiscoverable': 'খুঁজে পাওয়া যায় না',
'tinyplaceOrchestration.identity.undiscoverableHint':
'একটি @handle নিবন্ধন করুন যাতে অন্য এজেন্টরা আপনাকে বার্তা পাঠাতে পারে।',
'tinyplaceOrchestration.identity.card': 'ডিরেক্টরি কার্ড',
'tinyplaceOrchestration.identity.key': 'এনক্রিপশন কী',
'tinyplaceOrchestration.identity.published': 'প্রকাশিত',
'tinyplaceOrchestration.identity.notPublished': 'অপ্রকাশিত',
'tinyplaceOrchestration.roster.instances': 'ইনস্ট্যান্স',
'tinyplaceOrchestration.roster.empty': 'এখনও কোনো এজেন্ট ইনস্ট্যান্স নেই',
'tinyplaceOrchestration.roster.other': 'অন্যান্য',
+14
View File
@@ -302,6 +302,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Senden',
'tinyplaceOrchestration.composer.sendFailed': 'Nachricht konnte nicht gesendet werden',
'tinyplaceOrchestration.steering.label': 'Steuerung',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Produktion',
'tinyplaceOrchestration.identity.loading': 'Identität wird geladen…',
'tinyplaceOrchestration.identity.noHandle': 'Noch kein @handle',
'tinyplaceOrchestration.identity.copy': 'Kopieren',
'tinyplaceOrchestration.identity.copied': 'Kopiert',
'tinyplaceOrchestration.identity.discoverable': 'Auffindbar',
'tinyplaceOrchestration.identity.undiscoverable': 'Nicht auffindbar',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Registriere ein @handle, damit Peers dir schreiben können.',
'tinyplaceOrchestration.identity.card': 'Verzeichniskarte',
'tinyplaceOrchestration.identity.key': 'Verschlüsselungsschlüssel',
'tinyplaceOrchestration.identity.published': 'Veröffentlicht',
'tinyplaceOrchestration.identity.notPublished': 'Nicht veröffentlicht',
'tinyplaceOrchestration.roster.instances': 'Instanzen',
'tinyplaceOrchestration.roster.empty': 'Noch keine Agent-Instanzen',
'tinyplaceOrchestration.roster.other': 'Andere',
+14
View File
@@ -4223,6 +4223,20 @@ const en: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Send',
'tinyplaceOrchestration.composer.sendFailed': 'Failed to send message',
'tinyplaceOrchestration.steering.label': 'Steering',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Production',
'tinyplaceOrchestration.identity.loading': 'Loading identity…',
'tinyplaceOrchestration.identity.noHandle': 'No @handle yet',
'tinyplaceOrchestration.identity.copy': 'Copy',
'tinyplaceOrchestration.identity.copied': 'Copied',
'tinyplaceOrchestration.identity.discoverable': 'Discoverable',
'tinyplaceOrchestration.identity.undiscoverable': 'Not discoverable',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Register a @handle so peers can message you.',
'tinyplaceOrchestration.identity.card': 'Directory card',
'tinyplaceOrchestration.identity.key': 'Encryption key',
'tinyplaceOrchestration.identity.published': 'Published',
'tinyplaceOrchestration.identity.notPublished': 'Not published',
'tinyplaceOrchestration.roster.instances': 'Instances',
'tinyplaceOrchestration.roster.empty': 'No agent instances yet',
'tinyplaceOrchestration.roster.other': 'Other',
+14
View File
@@ -299,6 +299,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Enviar',
'tinyplaceOrchestration.composer.sendFailed': 'No se pudo enviar el mensaje',
'tinyplaceOrchestration.steering.label': 'Dirección',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Producción',
'tinyplaceOrchestration.identity.loading': 'Cargando identidad…',
'tinyplaceOrchestration.identity.noHandle': 'Aún sin @handle',
'tinyplaceOrchestration.identity.copy': 'Copiar',
'tinyplaceOrchestration.identity.copied': 'Copiado',
'tinyplaceOrchestration.identity.discoverable': 'Detectable',
'tinyplaceOrchestration.identity.undiscoverable': 'No detectable',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Registra un @handle para que otros agentes puedan escribirte.',
'tinyplaceOrchestration.identity.card': 'Tarjeta de directorio',
'tinyplaceOrchestration.identity.key': 'Clave de cifrado',
'tinyplaceOrchestration.identity.published': 'Publicado',
'tinyplaceOrchestration.identity.notPublished': 'No publicado',
'tinyplaceOrchestration.roster.instances': 'Instancias',
'tinyplaceOrchestration.roster.empty': 'Aún no hay instancias de agente',
'tinyplaceOrchestration.roster.other': 'Otros',
+14
View File
@@ -299,6 +299,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Envoyer',
'tinyplaceOrchestration.composer.sendFailed': 'Échec de lenvoi du message',
'tinyplaceOrchestration.steering.label': 'Pilotage',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Production',
'tinyplaceOrchestration.identity.loading': 'Chargement de lidentité…',
'tinyplaceOrchestration.identity.noHandle': 'Pas encore de @handle',
'tinyplaceOrchestration.identity.copy': 'Copier',
'tinyplaceOrchestration.identity.copied': 'Copié',
'tinyplaceOrchestration.identity.discoverable': 'Découvrable',
'tinyplaceOrchestration.identity.undiscoverable': 'Non découvrable',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Enregistre un @handle pour que les pairs puissent te contacter.',
'tinyplaceOrchestration.identity.card': 'Fiche dannuaire',
'tinyplaceOrchestration.identity.key': 'Clé de chiffrement',
'tinyplaceOrchestration.identity.published': 'Publié',
'tinyplaceOrchestration.identity.notPublished': 'Non publié',
'tinyplaceOrchestration.roster.instances': 'Instances',
'tinyplaceOrchestration.roster.empty': 'Aucune instance dagent',
'tinyplaceOrchestration.roster.other': 'Autre',
+14
View File
@@ -295,6 +295,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'भेजें',
'tinyplaceOrchestration.composer.sendFailed': 'संदेश भेजने में विफल',
'tinyplaceOrchestration.steering.label': 'मार्गदर्शन',
'tinyplaceOrchestration.relay.staging': 'स्टेजिंग',
'tinyplaceOrchestration.relay.prod': 'प्रोडक्शन',
'tinyplaceOrchestration.identity.loading': 'पहचान लोड हो रही है…',
'tinyplaceOrchestration.identity.noHandle': 'अभी तक कोई @handle नहीं',
'tinyplaceOrchestration.identity.copy': 'कॉपी करें',
'tinyplaceOrchestration.identity.copied': 'कॉपी हो गया',
'tinyplaceOrchestration.identity.discoverable': 'खोजने योग्य',
'tinyplaceOrchestration.identity.undiscoverable': 'खोजने योग्य नहीं',
'tinyplaceOrchestration.identity.undiscoverableHint':
'एक @handle पंजीकृत करें ताकि अन्य एजेंट आपको संदेश भेज सकें।',
'tinyplaceOrchestration.identity.card': 'डायरेक्टरी कार्ड',
'tinyplaceOrchestration.identity.key': 'एन्क्रिप्शन कुंजी',
'tinyplaceOrchestration.identity.published': 'प्रकाशित',
'tinyplaceOrchestration.identity.notPublished': 'अप्रकाशित',
'tinyplaceOrchestration.roster.instances': 'इंस्टेंस',
'tinyplaceOrchestration.roster.empty': 'अभी तक कोई एजेंट इंस्टेंस नहीं',
'tinyplaceOrchestration.roster.other': 'अन्य',
+14
View File
@@ -296,6 +296,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Kirim',
'tinyplaceOrchestration.composer.sendFailed': 'Gagal mengirim pesan',
'tinyplaceOrchestration.steering.label': 'Pengarahan',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Produksi',
'tinyplaceOrchestration.identity.loading': 'Memuat identitas…',
'tinyplaceOrchestration.identity.noHandle': 'Belum ada @handle',
'tinyplaceOrchestration.identity.copy': 'Salin',
'tinyplaceOrchestration.identity.copied': 'Tersalin',
'tinyplaceOrchestration.identity.discoverable': 'Dapat ditemukan',
'tinyplaceOrchestration.identity.undiscoverable': 'Tidak dapat ditemukan',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Daftarkan @handle agar rekan dapat mengirimi Anda pesan.',
'tinyplaceOrchestration.identity.card': 'Kartu direktori',
'tinyplaceOrchestration.identity.key': 'Kunci enkripsi',
'tinyplaceOrchestration.identity.published': 'Diterbitkan',
'tinyplaceOrchestration.identity.notPublished': 'Belum diterbitkan',
'tinyplaceOrchestration.roster.instances': 'Instansi',
'tinyplaceOrchestration.roster.empty': 'Belum ada instansi agen',
'tinyplaceOrchestration.roster.other': 'Lainnya',
+14
View File
@@ -299,6 +299,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Invia',
'tinyplaceOrchestration.composer.sendFailed': 'Invio del messaggio non riuscito',
'tinyplaceOrchestration.steering.label': 'Direzione',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Produzione',
'tinyplaceOrchestration.identity.loading': 'Caricamento identità…',
'tinyplaceOrchestration.identity.noHandle': 'Ancora nessun @handle',
'tinyplaceOrchestration.identity.copy': 'Copia',
'tinyplaceOrchestration.identity.copied': 'Copiato',
'tinyplaceOrchestration.identity.discoverable': 'Rilevabile',
'tinyplaceOrchestration.identity.undiscoverable': 'Non rilevabile',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Registra un @handle così altri agenti possono scriverti.',
'tinyplaceOrchestration.identity.card': 'Scheda directory',
'tinyplaceOrchestration.identity.key': 'Chiave di crittografia',
'tinyplaceOrchestration.identity.published': 'Pubblicato',
'tinyplaceOrchestration.identity.notPublished': 'Non pubblicato',
'tinyplaceOrchestration.roster.instances': 'Istanze',
'tinyplaceOrchestration.roster.empty': 'Nessuna istanza agente',
'tinyplaceOrchestration.roster.other': 'Altro',
+14
View File
@@ -292,6 +292,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': '보내기',
'tinyplaceOrchestration.composer.sendFailed': '메시지를 보내지 못했습니다',
'tinyplaceOrchestration.steering.label': '조종',
'tinyplaceOrchestration.relay.staging': '스테이징',
'tinyplaceOrchestration.relay.prod': '프로덕션',
'tinyplaceOrchestration.identity.loading': '신원 불러오는 중…',
'tinyplaceOrchestration.identity.noHandle': '아직 @handle 없음',
'tinyplaceOrchestration.identity.copy': '복사',
'tinyplaceOrchestration.identity.copied': '복사됨',
'tinyplaceOrchestration.identity.discoverable': '검색 가능',
'tinyplaceOrchestration.identity.undiscoverable': '검색 불가',
'tinyplaceOrchestration.identity.undiscoverableHint':
'다른 에이전트가 메시지를 보낼 수 있도록 @handle을 등록하세요.',
'tinyplaceOrchestration.identity.card': '디렉터리 카드',
'tinyplaceOrchestration.identity.key': '암호화 키',
'tinyplaceOrchestration.identity.published': '게시됨',
'tinyplaceOrchestration.identity.notPublished': '게시 안 됨',
'tinyplaceOrchestration.roster.instances': '인스턴스',
'tinyplaceOrchestration.roster.empty': '아직 에이전트 인스턴스가 없습니다',
'tinyplaceOrchestration.roster.other': '기타',
+14
View File
@@ -300,6 +300,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Wyślij',
'tinyplaceOrchestration.composer.sendFailed': 'Nie udało się wysłać wiadomości',
'tinyplaceOrchestration.steering.label': 'Sterowanie',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Produkcja',
'tinyplaceOrchestration.identity.loading': 'Ładowanie tożsamości…',
'tinyplaceOrchestration.identity.noHandle': 'Brak @handle',
'tinyplaceOrchestration.identity.copy': 'Kopiuj',
'tinyplaceOrchestration.identity.copied': 'Skopiowano',
'tinyplaceOrchestration.identity.discoverable': 'Wykrywalny',
'tinyplaceOrchestration.identity.undiscoverable': 'Niewykrywalny',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Zarejestruj @handle, aby inni mogli do Ciebie pisać.',
'tinyplaceOrchestration.identity.card': 'Karta katalogu',
'tinyplaceOrchestration.identity.key': 'Klucz szyfrowania',
'tinyplaceOrchestration.identity.published': 'Opublikowano',
'tinyplaceOrchestration.identity.notPublished': 'Nie opublikowano',
'tinyplaceOrchestration.roster.instances': 'Instancje',
'tinyplaceOrchestration.roster.empty': 'Brak instancji agentów',
'tinyplaceOrchestration.roster.other': 'Inne',
+14
View File
@@ -298,6 +298,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Enviar',
'tinyplaceOrchestration.composer.sendFailed': 'Falha ao enviar mensagem',
'tinyplaceOrchestration.steering.label': 'Direção',
'tinyplaceOrchestration.relay.staging': 'Staging',
'tinyplaceOrchestration.relay.prod': 'Produção',
'tinyplaceOrchestration.identity.loading': 'Carregando identidade…',
'tinyplaceOrchestration.identity.noHandle': 'Ainda sem @handle',
'tinyplaceOrchestration.identity.copy': 'Copiar',
'tinyplaceOrchestration.identity.copied': 'Copiado',
'tinyplaceOrchestration.identity.discoverable': 'Descobrível',
'tinyplaceOrchestration.identity.undiscoverable': 'Não descobrível',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Registre um @handle para que outros agentes possam lhe enviar mensagens.',
'tinyplaceOrchestration.identity.card': 'Cartão de diretório',
'tinyplaceOrchestration.identity.key': 'Chave de criptografia',
'tinyplaceOrchestration.identity.published': 'Publicado',
'tinyplaceOrchestration.identity.notPublished': 'Não publicado',
'tinyplaceOrchestration.roster.instances': 'Instâncias',
'tinyplaceOrchestration.roster.empty': 'Nenhuma instância de agente ainda',
'tinyplaceOrchestration.roster.other': 'Outros',
+14
View File
@@ -300,6 +300,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Отправить',
'tinyplaceOrchestration.composer.sendFailed': 'Не удалось отправить сообщение',
'tinyplaceOrchestration.steering.label': 'Управление',
'tinyplaceOrchestration.relay.staging': 'Стейджинг',
'tinyplaceOrchestration.relay.prod': 'Продакшн',
'tinyplaceOrchestration.identity.loading': 'Загрузка личности…',
'tinyplaceOrchestration.identity.noHandle': 'Пока нет @handle',
'tinyplaceOrchestration.identity.copy': 'Копировать',
'tinyplaceOrchestration.identity.copied': 'Скопировано',
'tinyplaceOrchestration.identity.discoverable': 'Обнаруживаемый',
'tinyplaceOrchestration.identity.undiscoverable': 'Не обнаруживается',
'tinyplaceOrchestration.identity.undiscoverableHint':
'Зарегистрируйте @handle, чтобы другие агенты могли писать вам.',
'tinyplaceOrchestration.identity.card': 'Карточка каталога',
'tinyplaceOrchestration.identity.key': 'Ключ шифрования',
'tinyplaceOrchestration.identity.published': 'Опубликовано',
'tinyplaceOrchestration.identity.notPublished': 'Не опубликовано',
'tinyplaceOrchestration.roster.instances': 'Экземпляры',
'tinyplaceOrchestration.roster.empty': 'Пока нет экземпляров агентов',
'tinyplaceOrchestration.roster.other': 'Другое',
+14
View File
@@ -276,6 +276,20 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': '发送',
'tinyplaceOrchestration.composer.sendFailed': '消息发送失败',
'tinyplaceOrchestration.steering.label': '引导',
'tinyplaceOrchestration.relay.staging': '预发布',
'tinyplaceOrchestration.relay.prod': '生产',
'tinyplaceOrchestration.identity.loading': '正在加载身份…',
'tinyplaceOrchestration.identity.noHandle': '尚无 @handle',
'tinyplaceOrchestration.identity.copy': '复制',
'tinyplaceOrchestration.identity.copied': '已复制',
'tinyplaceOrchestration.identity.discoverable': '可被发现',
'tinyplaceOrchestration.identity.undiscoverable': '不可被发现',
'tinyplaceOrchestration.identity.undiscoverableHint':
'注册一个 @handle,以便其他代理可以给你发消息。',
'tinyplaceOrchestration.identity.card': '目录卡片',
'tinyplaceOrchestration.identity.key': '加密密钥',
'tinyplaceOrchestration.identity.published': '已发布',
'tinyplaceOrchestration.identity.notPublished': '未发布',
'tinyplaceOrchestration.roster.instances': '实例',
'tinyplaceOrchestration.roster.empty': '暂无代理实例',
'tinyplaceOrchestration.roster.other': '其他',
@@ -101,6 +101,35 @@ export interface OrchestrationMessageEvent {
chatKind: string;
}
/** One @handle this agent's wallet holds (reverse-resolved from the directory). */
export interface SelfHandle {
username: string;
primary: boolean;
}
/**
* This agent's own tiny.place identity and whether peers can reach it.
*
* `discoverable` is the bottom line: a peer can DM this agent only when both its
* directory card (`cardPublished`) and its Signal encryption key (`keyPublished`)
* are live. A fresh identity can accept contacts yet stay un-messageable until it
* registers a @handle — the card surfaces that gap instead of a mystery 404.
*/
export interface SelfIdentity {
agentId: string;
handles: SelfHandle[];
primaryHandle?: string;
cardPublished: boolean;
keyPublished: boolean;
discoverable: boolean;
}
/** The relay endpoint the core talks to, plus a coarse network label. */
export interface RelayInfo {
baseUrl: string;
network: 'staging' | 'prod';
}
// ── Internal helper ───────────────────────────────────────────────────────────
function safeParseJson(s: string): unknown {
@@ -172,6 +201,16 @@ export const orchestrationClient = {
/** Current orchestration status (active steering directive, tick timing). */
status: () => call<OrchestrationStatus>('openhuman.orchestration_status', {}),
/**
* This agent's own tiny.place identity + discoverability (agent id, @handles,
* whether its directory card and Signal key are published, whether peers can
* DM it). Powers the SelfIdentityCard.
*/
selfIdentity: () => call<SelfIdentity>('openhuman.orchestration_self_identity', {}),
/** The relay endpoint + network label the core is talking to (RelayBadge). */
relayInfo: () => call<RelayInfo>('openhuman.orchestration_relay_info', {}),
};
export type OrchestrationClient = typeof orchestrationClient;
+123
View File
@@ -834,12 +834,135 @@ fn session_send_plaintext(session_id: &str, body: &str) -> anyhow::Result<String
.map_err(|e| anyhow::anyhow!("envelope encode: {e}"))
}
// ── Self-identity composition (orchestration_self_identity read model) ────────
/// One @handle this agent's wallet holds (reverse-resolved from the directory).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HandleEntry {
pub(crate) username: String,
pub(crate) primary: bool,
}
/// This agent's own tiny.place identity and whether peers can reach it.
///
/// `discoverable` is the bottom line the UI cares about: a peer can DM this
/// agent only if both its directory card AND its Signal encryption key are
/// published. A fresh identity can accept contacts yet still be un-messageable
/// until it registers a @handle (which is what publishes both), so the
/// `SelfIdentityCard` surfaces the gap instead of leaving it a mystery 404.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SelfIdentity {
pub(crate) agent_id: String,
pub(crate) handles: Vec<HandleEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) primary_handle: Option<String>,
pub(crate) card_published: bool,
pub(crate) key_published: bool,
pub(crate) discoverable: bool,
}
/// Pure composition of the three tinyplace reads into the renderer shape. Kept
/// here (business logic) so the parsing/discoverability rules are unit-testable
/// without a live tiny.place client; the `schemas` handler supplies the reads.
///
/// `reverse` is the raw `directory_reverse` JSON (`{ identities: [...] }`), or
/// `None` on a reverse miss. Discoverable = card live AND encryption key
/// published + current — either gap leaves the agent un-messageable.
pub(crate) fn build_self_identity(
agent_id: String,
key_published: bool,
reverse: Option<&Value>,
card_published: bool,
) -> SelfIdentity {
let mut handles: Vec<HandleEntry> = Vec::new();
let mut primary_handle: Option<String> = None;
if let Some(idents) = reverse
.and_then(|r| r.get("identities"))
.and_then(Value::as_array)
{
for ident in idents {
let username = ident
.get("username")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let Some(username) = username else { continue };
let primary = ident
.get("primary")
.and_then(Value::as_bool)
.unwrap_or(false);
if primary && primary_handle.is_none() {
primary_handle = Some(username.to_string());
}
handles.push(HandleEntry {
username: username.to_string(),
primary,
});
}
}
// Fall back to the first handle when none is flagged primary.
if primary_handle.is_none() {
primary_handle = handles.first().map(|h| h.username.clone());
}
SelfIdentity {
agent_id,
handles,
primary_handle,
card_published,
key_published,
discoverable: card_published && key_published,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::orchestration::types::OrchestrationMessage;
use std::sync::atomic::{AtomicUsize, Ordering};
use tinyagents::graph::checkpoint::Checkpointer;
#[test]
fn self_identity_marks_published_identity_discoverable() {
let reverse = serde_json::json!({
"identities": [
{ "username": " ", "primary": false }, // blank → skipped
{ "username": "openhuman", "primary": false },
{ "username": "oh_primary", "primary": true },
]
});
let id = build_self_identity("addr123".to_string(), true, Some(&reverse), true);
assert_eq!(id.agent_id, "addr123");
assert_eq!(id.handles.len(), 2, "blank username skipped");
assert_eq!(id.primary_handle.as_deref(), Some("oh_primary"));
assert!(id.card_published && id.key_published && id.discoverable);
}
#[test]
fn self_identity_primary_falls_back_to_first_handle() {
let reverse = serde_json::json!({
"identities": [ { "username": "solo" } ] // no primary flag
});
let id = build_self_identity("addr".to_string(), true, Some(&reverse), true);
assert_eq!(id.primary_handle.as_deref(), Some("solo"));
}
#[test]
fn self_identity_undiscoverable_when_card_or_key_missing() {
// No reverse (handle-less), card present but key not published → the
// exact un-messageable case the SelfIdentityCard must flag.
let no_key = build_self_identity("addr".to_string(), false, None, true);
assert!(no_key.handles.is_empty());
assert!(no_key.primary_handle.is_none());
assert!(!no_key.discoverable, "key not published → not discoverable");
let no_card = build_self_identity("addr".to_string(), true, None, false);
assert!(
!no_card.discoverable,
"card not published → not discoverable"
);
}
use tinyagents::graph::SqliteCheckpointer;
fn test_config(tmp: &tempfile::TempDir) -> Config {
+130 -1
View File
@@ -28,6 +28,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schema_for("orchestration_send_master_message"),
schema_for("orchestration_mark_read"),
schema_for("orchestration_status"),
schema_for("orchestration_self_identity"),
schema_for("orchestration_relay_info"),
]
}
@@ -57,6 +59,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schema_for("orchestration_status"),
handler: handle_status,
},
RegisteredController {
schema: schema_for("orchestration_self_identity"),
handler: handle_self_identity,
},
RegisteredController {
schema: schema_for("orchestration_relay_info"),
handler: handle_relay_info,
},
]
}
@@ -120,6 +130,23 @@ fn schema_for(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output("result", "OrchestrationStatus.")],
},
"orchestration_self_identity" => ControllerSchema {
namespace: "orchestration",
function: "self_identity",
description: "This agent's own tiny.place identity + discoverability: agent id (address), reverse-resolved @handles, whether its directory card and Signal encryption key are published, and whether peers can therefore DM it. Composes the tinyplace signal/directory reads.",
inputs: vec![],
outputs: vec![json_output(
"result",
"{ agentId, handles: {username, primary}[], primaryHandle?, cardPublished, keyPublished, discoverable }.",
)],
},
"orchestration_relay_info" => ControllerSchema {
namespace: "orchestration",
function: "relay_info",
description: "The tiny.place relay endpoint the core is talking to, plus a coarse network label (staging | prod) for the renderer's relay badge.",
inputs: vec![],
outputs: vec![json_output("result", "{ baseUrl, network }.")],
},
other => unreachable!("unknown orchestration schema: {other}"),
}
}
@@ -177,6 +204,13 @@ struct OrchestrationStatus {
last_error: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RelayInfo {
base_url: String,
network: String,
}
/// Resolve the `chat` param to a store session id. `master` / `subconscious` map
/// to their pinned session ids; anything else is treated as a harness session id.
fn chat_to_session_id(chat: &str) -> &str {
@@ -568,6 +602,93 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
})
}
/// Own tiny.place identity + discoverability, composed from the internal
/// tinyplace signal/directory reads. Delegates like `send_master` does
/// (`crate::openhuman::tinyplace::handle_tinyplace_*`), so there is no new
/// tiny.place logic here — only aggregation into the shape the renderer needs.
fn handle_self_identity(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
// 1. Key status → own agent id + whether the encryption key is published
// to the directory and current. `encryptionKeyPublished` is false when
// the card is missing OR the published key is stale (wrong wallet).
let key_status =
crate::openhuman::tinyplace::handle_tinyplace_signal_key_status(Map::new())
.await
.map_err(|e| format!("self_identity key_status: {e}"))?;
let agent_id = key_status
.get("agentId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let key_published = key_status
.get("encryptionKeyPublished")
.and_then(Value::as_bool)
.unwrap_or(false);
log::debug!(
target: LOG,
"[orchestration_rpc] self_identity agent_id_len={} key_published={key_published}",
agent_id.len()
);
// 2. Reverse-resolve any @handles this wallet holds. Best-effort: a
// handle-less identity is normal (and is exactly the un-messageable
// case the card must flag), so a reverse miss is not an error.
let reverse = if agent_id.is_empty() {
None
} else {
let mut rev_params = Map::new();
rev_params.insert("cryptoId".to_string(), Value::from(agent_id.clone()));
match crate::openhuman::tinyplace::handle_tinyplace_directory_reverse(rev_params).await
{
Ok(reverse) => Some(reverse),
Err(e) => {
log::debug!(target: LOG, "[orchestration_rpc] self_identity reverse miss: {e}");
None
}
}
};
// 3. Directory card presence: get_agent(self) 404s when no card is
// published. Ok → a card is live; Err → treat as unpublished.
let card_published = if agent_id.is_empty() {
false
} else {
let mut card_params = Map::new();
card_params.insert("agentId".to_string(), Value::from(agent_id.clone()));
crate::openhuman::tinyplace::handle_tinyplace_directory_get_agent(card_params)
.await
.is_ok()
};
let identity = super::ops::build_self_identity(
agent_id,
key_published,
reverse.as_ref(),
card_published,
);
log::debug!(
target: LOG,
"[orchestration_rpc] self_identity handles={} card_published={card_published} discoverable={}",
identity.handles.len(),
identity.discoverable
);
to_json(identity)
})
}
/// Relay endpoint + network label for the renderer's relay badge. Reads only the
/// configured base URL (no client build / wallet unlock), so it always resolves.
fn handle_relay_info(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let (base_url, network) = crate::openhuman::tinyplace::ops::relay_endpoint();
log::debug!(target: LOG, "[orchestration_rpc] relay_info network={network}");
to_json(RelayInfo {
base_url,
network: network.to_string(),
})
})
}
// ── helpers ─────────────────────────────────────────────────────────────────
async fn load_config(action: &str) -> Result<Config, String> {
@@ -625,8 +746,16 @@ mod tests {
#[test]
fn schemas_use_orchestration_namespace() {
let schemas = all_controller_schemas();
assert_eq!(schemas.len(), 6);
assert_eq!(schemas.len(), 8);
assert!(schemas.iter().all(|s| s.namespace == "orchestration"));
assert_eq!(
schema_for("orchestration_self_identity").function,
"self_identity"
);
assert_eq!(
schema_for("orchestration_relay_info").function,
"relay_info"
);
assert_eq!(
schema_for("orchestration_messages_list").function,
"messages_list"
+3 -1
View File
@@ -29,7 +29,9 @@ pub(crate) mod agent;
mod agent_tools;
mod manifest;
pub(crate) use manifest::{
acknowledge_message, decrypt_envelope, handle_tinyplace_signal_send_message,
acknowledge_message, decrypt_envelope, handle_tinyplace_directory_get_agent,
handle_tinyplace_directory_reverse, handle_tinyplace_signal_key_status,
handle_tinyplace_signal_send_message,
};
pub(crate) mod ops;
mod payment;
+38
View File
@@ -19,6 +19,29 @@ pub(crate) fn global_state() -> &'static TinyPlaceState {
TINYPLACE_STATE.get_or_init(TinyPlaceState::from_env)
}
/// Active relay endpoint the tiny.place client talks to, plus a coarse network
/// label the renderer surfaces as a badge. A base URL containing `staging`
/// resolves to `"staging"`; anything else is treated as `"prod"`.
///
/// This reads only the configured base URL (no client build, no wallet unlock),
/// so it is safe to call at any time.
pub(crate) fn relay_endpoint() -> (String, &'static str) {
let base_url = global_state().base_url.clone();
let network = relay_network_label(&base_url);
(base_url, network)
}
/// Coarse network label for a relay base URL: `staging` when the host carries a
/// `staging` marker, else `prod`. Kept pure so it is unit-testable without the
/// process-global state.
pub(crate) fn relay_network_label(base_url: &str) -> &'static str {
if base_url.contains("staging") {
"staging"
} else {
"prod"
}
}
// ── Error mapping ─────────────────────────────────────────────────────────────
/// Map a SDK [`tinyplace::Error`] to a [`String`] the controller layer returns.
@@ -69,3 +92,18 @@ fn extract_error_reason(body: &serde_json::Value) -> Option<String> {
other => Some(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_network_label_classifies_staging_and_prod() {
assert_eq!(
relay_network_label("https://staging-api.tiny.place"),
"staging"
);
assert_eq!(relay_network_label("https://api.tiny.place"), "prod");
assert_eq!(relay_network_label(""), "prod");
}
}