mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(vault): expose approved markdown writes (#2936)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
c56897e0d1
commit
b37bab7134
@@ -41,6 +41,8 @@ function vault(overrides: Record<string, unknown> = {}) {
|
||||
created_at: '2026-05-17T10:00:00Z',
|
||||
last_synced_at: null,
|
||||
file_count: 0,
|
||||
write_state: 'writable',
|
||||
write_state_reason: 'writable',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -111,6 +113,35 @@ describe('<VaultPanel />', () => {
|
||||
expect(screen.getByText('A')).toBeTruthy();
|
||||
expect(screen.getByText(/42 file/)).toBeTruthy();
|
||||
expect(screen.getByText(/synced 30s ago/)).toBeTruthy();
|
||||
expect(screen.getByTestId('vault-write-state-v-A')).toHaveTextContent('Writable');
|
||||
expect(screen.getByText(/Approved markdown/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows read-only and unavailable vault write states with reasons', async () => {
|
||||
mockList.mockResolvedValueOnce({
|
||||
result: [
|
||||
vault({
|
||||
id: 'v-readonly',
|
||||
name: 'Read only',
|
||||
write_state: 'read_only',
|
||||
write_state_reason: 'read_only',
|
||||
}),
|
||||
vault({
|
||||
id: 'v-missing',
|
||||
name: 'Missing',
|
||||
write_state: 'unavailable',
|
||||
write_state_reason: 'unavailable',
|
||||
}),
|
||||
],
|
||||
logs: [],
|
||||
});
|
||||
render(<VaultPanel />);
|
||||
await waitFor(() => screen.getByTestId('vault-list'));
|
||||
|
||||
expect(screen.getByTestId('vault-write-state-v-readonly')).toHaveTextContent('Read-only');
|
||||
expect(screen.getByText('Vault folder is read-only on this device.')).toBeTruthy();
|
||||
expect(screen.getByTestId('vault-write-state-v-missing')).toHaveTextContent('Unavailable');
|
||||
expect(screen.getByText('Vault folder is not available on this device.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles the add form and creates a vault on submit', async () => {
|
||||
|
||||
@@ -359,6 +359,12 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
|
||||
<ul className="divide-y divide-stone-100 dark:divide-neutral-800" data-testid="vault-list">
|
||||
{vaults.map(v => {
|
||||
const state = busy[v.id];
|
||||
const writeStateReason = v.write_state_reason
|
||||
? t(
|
||||
`vault.writeState.reasons.${v.write_state_reason}`,
|
||||
t('vault.writeState.unknownReason')
|
||||
)
|
||||
: t('vault.writeState.unknownReason');
|
||||
return (
|
||||
<li key={v.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -379,6 +385,17 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
|
||||
)
|
||||
: t('vault.neverSynced')}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={writeStateBadgeClass(v.write_state)}
|
||||
title={writeStateReason}
|
||||
data-testid={`vault-write-state-${v.id}`}>
|
||||
{t(`vault.writeState.${v.write_state}`)}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{writeStateReason}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -425,6 +442,19 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function writeStateBadgeClass(state: CoreVault['write_state']): string {
|
||||
const base = 'inline-flex h-5 items-center rounded px-1.5 text-[10px] font-semibold';
|
||||
switch (state) {
|
||||
case 'writable':
|
||||
return `${base} bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300`;
|
||||
case 'read_only':
|
||||
return `${base} bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300`;
|
||||
case 'unavailable':
|
||||
default:
|
||||
return `${base} bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string, translate: (key: string) => string): string {
|
||||
const timestamp = new Date(iso).getTime();
|
||||
if (Number.isNaN(timestamp)) return iso;
|
||||
|
||||
@@ -4041,6 +4041,16 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} ملف (ملفات)',
|
||||
'vault.syncedRelative': 'تمت مزامنته {time}',
|
||||
'vault.neverSynced': 'لم تتم مزامنته أبدًا',
|
||||
'vault.writeState.writable': 'قابل للكتابة',
|
||||
'vault.writeState.read_only': 'للقراءة فقط',
|
||||
'vault.writeState.unavailable': 'غير متاح',
|
||||
'vault.writeState.unknownReason': 'حالة الكتابة غير معروفة.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'يمكن حفظ عمليات كتابة Markdown/wiki الموافق عليها في هذا المخزن.',
|
||||
'vault.writeState.reasons.read_only': 'مجلد المخزن للقراءة فقط على هذا الجهاز.',
|
||||
'vault.writeState.reasons.unavailable': 'مجلد المخزن غير متاح على هذا الجهاز.',
|
||||
'vault.writeState.reasons.not_directory': 'مسار المخزن ليس دليلاً.',
|
||||
'vault.writeState.reasons.empty_path': 'مسار مجلد المخزن فارغ.',
|
||||
'vault.syncingProgress': 'جارٍ المزامنة… {ingested}/{total}',
|
||||
'vault.removing': 'جارٍ الإزالة...',
|
||||
'vault.relative.sec': 'قبل {count}s',
|
||||
|
||||
@@ -4107,6 +4107,15 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} ফাইল(গুলি)',
|
||||
'vault.syncedRelative': 'সিঙ্ক করা হয়েছে {time}',
|
||||
'vault.neverSynced': 'কখনই সিঙ্ক করা হয়নি',
|
||||
'vault.writeState.writable': 'লেখার যোগ্য',
|
||||
'vault.writeState.read_only': 'শুধু-পঠন',
|
||||
'vault.writeState.unavailable': 'অনুপলব্ধ',
|
||||
'vault.writeState.unknownReason': 'লেখার অবস্থা অজানা।',
|
||||
'vault.writeState.reasons.writable': 'অনুমোদিত Markdown/wiki লেখা এই ভল্টে সংরক্ষণ করা যাবে।',
|
||||
'vault.writeState.reasons.read_only': 'এই ডিভাইসে ভল্ট ফোল্ডারটি শুধু-পঠন।',
|
||||
'vault.writeState.reasons.unavailable': 'এই ডিভাইসে ভল্ট ফোল্ডারটি উপলব্ধ নয়।',
|
||||
'vault.writeState.reasons.not_directory': 'ভল্ট পাথটি কোনো ফোল্ডার নয়।',
|
||||
'vault.writeState.reasons.empty_path': 'ভল্ট ফোল্ডার পাথ খালি।',
|
||||
'vault.syncingProgress': 'সিঙ্ক হচ্ছে... {ingested}/{total}',
|
||||
'vault.removing': 'সরানো হচ্ছে...',
|
||||
'vault.relative.sec': '{count}সেকেন্ড আগে',
|
||||
|
||||
@@ -4220,6 +4220,16 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} Datei(en)',
|
||||
'vault.syncedRelative': 'synchronisiert {time}',
|
||||
'vault.neverSynced': 'nie synchronisiert',
|
||||
'vault.writeState.writable': 'Beschreibbar',
|
||||
'vault.writeState.read_only': 'Schreibgeschützt',
|
||||
'vault.writeState.unavailable': 'Nicht verfügbar',
|
||||
'vault.writeState.unknownReason': 'Schreibstatus ist unbekannt.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Genehmigte Markdown-/Wiki-Schreibvorgänge können in diesem Tresor gespeichert werden.',
|
||||
'vault.writeState.reasons.read_only': 'Der Tresorordner ist auf diesem Gerät schreibgeschützt.',
|
||||
'vault.writeState.reasons.unavailable': 'Der Tresorordner ist auf diesem Gerät nicht verfügbar.',
|
||||
'vault.writeState.reasons.not_directory': 'Der Tresorpfad ist kein Ordner.',
|
||||
'vault.writeState.reasons.empty_path': 'Der Tresorordnerpfad ist leer.',
|
||||
'vault.syncingProgress': 'Synchronisierung… {ingested}/{total}',
|
||||
'vault.removing': 'Entfernen…',
|
||||
'vault.relative.sec': 'vor {count}s',
|
||||
|
||||
@@ -4323,6 +4323,15 @@ const en: TranslationMap = {
|
||||
'vault.fileCount': '{count} file(s)',
|
||||
'vault.syncedRelative': 'synced {time}',
|
||||
'vault.neverSynced': 'never synced',
|
||||
'vault.writeState.writable': 'Writable',
|
||||
'vault.writeState.read_only': 'Read-only',
|
||||
'vault.writeState.unavailable': 'Unavailable',
|
||||
'vault.writeState.unknownReason': 'Write state is unknown.',
|
||||
'vault.writeState.reasons.writable': 'Approved markdown/wiki writes can be saved in this vault.',
|
||||
'vault.writeState.reasons.read_only': 'Vault folder is read-only on this device.',
|
||||
'vault.writeState.reasons.unavailable': 'Vault folder is not available on this device.',
|
||||
'vault.writeState.reasons.not_directory': 'Vault path is not a directory.',
|
||||
'vault.writeState.reasons.empty_path': 'Vault folder path is empty.',
|
||||
'vault.syncingProgress': 'Syncing… {ingested}/{total}',
|
||||
'vault.removing': 'Removing…',
|
||||
'vault.relative.sec': '{count}s ago',
|
||||
|
||||
@@ -4185,6 +4185,18 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} archivo(s)',
|
||||
'vault.syncedRelative': 'sincronizado {time}',
|
||||
'vault.neverSynced': 'nunca sincronizado',
|
||||
'vault.writeState.writable': 'Editable',
|
||||
'vault.writeState.read_only': 'Solo lectura',
|
||||
'vault.writeState.unavailable': 'No disponible',
|
||||
'vault.writeState.unknownReason': 'Se desconoce el estado de escritura.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Las escrituras Markdown/wiki aprobadas se pueden guardar en esta bóveda.',
|
||||
'vault.writeState.reasons.read_only':
|
||||
'La carpeta de la bóveda es de solo lectura en este dispositivo.',
|
||||
'vault.writeState.reasons.unavailable':
|
||||
'La carpeta de la bóveda no está disponible en este dispositivo.',
|
||||
'vault.writeState.reasons.not_directory': 'La ruta de la bóveda no es una carpeta.',
|
||||
'vault.writeState.reasons.empty_path': 'La ruta de la carpeta de la bóveda está vacía.',
|
||||
'vault.syncingProgress': 'Sincronizando... {ingested}/{total}',
|
||||
'vault.removing': 'Eliminando…',
|
||||
'vault.relative.sec': 'Hace {count}s',
|
||||
|
||||
@@ -4202,6 +4202,18 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} fichier(s)',
|
||||
'vault.syncedRelative': 'synchronisés {time}',
|
||||
'vault.neverSynced': 'jamais synchronisés',
|
||||
'vault.writeState.writable': 'Modifiable',
|
||||
'vault.writeState.read_only': 'Lecture seule',
|
||||
'vault.writeState.unavailable': 'Indisponible',
|
||||
'vault.writeState.unknownReason': "L'état d'écriture est inconnu.",
|
||||
'vault.writeState.reasons.writable':
|
||||
'Les écritures Markdown/wiki approuvées peuvent être enregistrées dans ce coffre.',
|
||||
'vault.writeState.reasons.read_only':
|
||||
'Le dossier du coffre est en lecture seule sur cet appareil.',
|
||||
'vault.writeState.reasons.unavailable':
|
||||
"Le dossier du coffre n'est pas disponible sur cet appareil.",
|
||||
'vault.writeState.reasons.not_directory': "Le chemin du coffre n'est pas un dossier.",
|
||||
'vault.writeState.reasons.empty_path': 'Le chemin du dossier du coffre est vide.',
|
||||
'vault.syncingProgress': 'Synchronisation… {ingested}/{total}',
|
||||
'vault.removing': 'Suppression…',
|
||||
'vault.relative.sec': 'il y a {count}s',
|
||||
|
||||
@@ -4116,6 +4116,15 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count}फ़ाइलें',
|
||||
'vault.syncedRelative': 'समन्वयित {time}',
|
||||
'vault.neverSynced': 'कभी समन्वयित नहीं किया गया',
|
||||
'vault.writeState.writable': 'लिखने योग्य',
|
||||
'vault.writeState.read_only': 'केवल पढ़ने योग्य',
|
||||
'vault.writeState.unavailable': 'उपलब्ध नहीं',
|
||||
'vault.writeState.unknownReason': 'लिखने की स्थिति अज्ञात है.',
|
||||
'vault.writeState.reasons.writable': 'स्वीकृत Markdown/wiki लेखन इस वॉल्ट में सहेजे जा सकते हैं।',
|
||||
'vault.writeState.reasons.read_only': 'इस डिवाइस पर वॉल्ट फ़ोल्डर केवल पढ़ने योग्य है।',
|
||||
'vault.writeState.reasons.unavailable': 'इस डिवाइस पर वॉल्ट फ़ोल्डर उपलब्ध नहीं है।',
|
||||
'vault.writeState.reasons.not_directory': 'वॉल्ट पथ कोई फ़ोल्डर नहीं है।',
|
||||
'vault.writeState.reasons.empty_path': 'वॉल्ट फ़ोल्डर पथ खाली है।',
|
||||
'vault.syncingProgress': 'सिंक हो रहा है... {ingested}/{total}',
|
||||
'vault.removing': 'हटाया जा रहा है...',
|
||||
'vault.relative.sec': '{count}s पहले',
|
||||
|
||||
@@ -4125,6 +4125,16 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} file',
|
||||
'vault.syncedRelative': 'disinkronkan {time}',
|
||||
'vault.neverSynced': 'tidak pernah disinkronkan',
|
||||
'vault.writeState.writable': 'Dapat ditulis',
|
||||
'vault.writeState.read_only': 'Hanya baca',
|
||||
'vault.writeState.unavailable': 'Tidak tersedia',
|
||||
'vault.writeState.unknownReason': 'Status tulis tidak diketahui.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Penulisan Markdown/wiki yang disetujui dapat disimpan di vault ini.',
|
||||
'vault.writeState.reasons.read_only': 'Folder vault hanya baca di perangkat ini.',
|
||||
'vault.writeState.reasons.unavailable': 'Folder vault tidak tersedia di perangkat ini.',
|
||||
'vault.writeState.reasons.not_directory': 'Jalur vault bukan folder.',
|
||||
'vault.writeState.reasons.empty_path': 'Jalur folder vault kosong.',
|
||||
'vault.syncingProgress': 'Menyinkronkan… {ingested}/{total}',
|
||||
'vault.removing': 'Menghapus…',
|
||||
'vault.relative.sec': '{count}s yang lalu',
|
||||
|
||||
@@ -4178,6 +4178,18 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} file',
|
||||
'vault.syncedRelative': 'sincronizzato {time}',
|
||||
'vault.neverSynced': 'mai sincronizzato',
|
||||
'vault.writeState.writable': 'Scrivibile',
|
||||
'vault.writeState.read_only': 'Sola lettura',
|
||||
'vault.writeState.unavailable': 'Non disponibile',
|
||||
'vault.writeState.unknownReason': 'Stato di scrittura sconosciuto.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Le scritture Markdown/wiki approvate possono essere salvate in questo deposito.',
|
||||
'vault.writeState.reasons.read_only':
|
||||
'La cartella del deposito è in sola lettura su questo dispositivo.',
|
||||
'vault.writeState.reasons.unavailable':
|
||||
'La cartella del deposito non è disponibile su questo dispositivo.',
|
||||
'vault.writeState.reasons.not_directory': 'Il percorso del deposito non è una cartella.',
|
||||
'vault.writeState.reasons.empty_path': 'Il percorso della cartella del deposito è vuoto.',
|
||||
'vault.syncingProgress': 'Sincronizzazione… {ingested}/{total}',
|
||||
'vault.removing': 'Rimozione in corso…',
|
||||
'vault.relative.sec': '{count}s fa',
|
||||
|
||||
@@ -4076,6 +4076,15 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} 파일',
|
||||
'vault.syncedRelative': '동기화됨 {time}',
|
||||
'vault.neverSynced': '동기화되지 않음',
|
||||
'vault.writeState.writable': '쓰기 가능',
|
||||
'vault.writeState.read_only': '읽기 전용',
|
||||
'vault.writeState.unavailable': '사용할 수 없음',
|
||||
'vault.writeState.unknownReason': '쓰기 상태를 알 수 없습니다.',
|
||||
'vault.writeState.reasons.writable': '승인된 Markdown/wiki 쓰기를 이 볼트에 저장할 수 있습니다.',
|
||||
'vault.writeState.reasons.read_only': '이 장치에서 볼트 폴더는 읽기 전용입니다.',
|
||||
'vault.writeState.reasons.unavailable': '이 장치에서 볼트 폴더를 사용할 수 없습니다.',
|
||||
'vault.writeState.reasons.not_directory': '볼트 경로가 폴더가 아닙니다.',
|
||||
'vault.writeState.reasons.empty_path': '볼트 폴더 경로가 비어 있습니다.',
|
||||
'vault.syncingProgress': '동기화 중… {ingested}/{total}',
|
||||
'vault.removing': '제거 중…',
|
||||
'vault.relative.sec': '{count}초 전',
|
||||
|
||||
@@ -4180,6 +4180,16 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': 'Plików: {count}',
|
||||
'vault.syncedRelative': 'zsynchronizowano {time}',
|
||||
'vault.neverSynced': 'nigdy nie synchronizowano',
|
||||
'vault.writeState.writable': 'Zapisywalny',
|
||||
'vault.writeState.read_only': 'Tylko do odczytu',
|
||||
'vault.writeState.unavailable': 'Niedostępny',
|
||||
'vault.writeState.unknownReason': 'Stan zapisu jest nieznany.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Zatwierdzone zapisy Markdown/wiki mogą być zapisane w tym skarbcu.',
|
||||
'vault.writeState.reasons.read_only': 'Folder skarbca jest tylko do odczytu na tym urządzeniu.',
|
||||
'vault.writeState.reasons.unavailable': 'Folder skarbca jest niedostępny na tym urządzeniu.',
|
||||
'vault.writeState.reasons.not_directory': 'Ścieżka skarbca nie jest folderem.',
|
||||
'vault.writeState.reasons.empty_path': 'Ścieżka folderu skarbca jest pusta.',
|
||||
'vault.syncingProgress': 'Synchronizowanie… {ingested}/{total}',
|
||||
'vault.removing': 'Usuwanie…',
|
||||
'vault.relative.sec': '{count} s temu',
|
||||
|
||||
@@ -4179,6 +4179,16 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} arquivo(s)',
|
||||
'vault.syncedRelative': 'sincronizado {time}',
|
||||
'vault.neverSynced': 'nunca sincronizado',
|
||||
'vault.writeState.writable': 'Gravável',
|
||||
'vault.writeState.read_only': 'Somente leitura',
|
||||
'vault.writeState.unavailable': 'Indisponível',
|
||||
'vault.writeState.unknownReason': 'Estado de gravação desconhecido.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Gravações Markdown/wiki aprovadas podem ser salvas neste cofre.',
|
||||
'vault.writeState.reasons.read_only': 'A pasta do cofre é somente leitura neste dispositivo.',
|
||||
'vault.writeState.reasons.unavailable': 'A pasta do cofre não está disponível neste dispositivo.',
|
||||
'vault.writeState.reasons.not_directory': 'O caminho do cofre não é uma pasta.',
|
||||
'vault.writeState.reasons.empty_path': 'O caminho da pasta do cofre está vazio.',
|
||||
'vault.syncingProgress': 'Sincronizando… {ingested}/{total}',
|
||||
'vault.removing': 'Removendo…',
|
||||
'vault.relative.sec': '{count}s atrás',
|
||||
|
||||
@@ -4146,6 +4146,17 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} файлов',
|
||||
'vault.syncedRelative': 'синхронизировано {time}',
|
||||
'vault.neverSynced': 'никогда не синхронизировано',
|
||||
'vault.writeState.writable': 'Доступно для записи',
|
||||
'vault.writeState.read_only': 'Только чтение',
|
||||
'vault.writeState.unavailable': 'Недоступно',
|
||||
'vault.writeState.unknownReason': 'Статус записи неизвестен.',
|
||||
'vault.writeState.reasons.writable':
|
||||
'Одобренные записи Markdown/wiki можно сохранить в этом хранилище.',
|
||||
'vault.writeState.reasons.read_only':
|
||||
'Папка хранилища доступна только для чтения на этом устройстве.',
|
||||
'vault.writeState.reasons.unavailable': 'Папка хранилища недоступна на этом устройстве.',
|
||||
'vault.writeState.reasons.not_directory': 'Путь хранилища не является папкой.',
|
||||
'vault.writeState.reasons.empty_path': 'Путь к папке хранилища пуст.',
|
||||
'vault.syncingProgress': 'Синхронизация… {ingested}/{total}',
|
||||
'vault.removing': 'Удаление…',
|
||||
'vault.relative.sec': '{count}s назад',
|
||||
|
||||
@@ -3915,6 +3915,15 @@ const messages: TranslationMap = {
|
||||
'vault.fileCount': '{count} 文件',
|
||||
'vault.syncedRelative': '已同步 {time}',
|
||||
'vault.neverSynced': '从未同步过',
|
||||
'vault.writeState.writable': '可写',
|
||||
'vault.writeState.read_only': '只读',
|
||||
'vault.writeState.unavailable': '不可用',
|
||||
'vault.writeState.unknownReason': '写入状态未知。',
|
||||
'vault.writeState.reasons.writable': '可将已批准的 Markdown/wiki 写入保存到此保险库。',
|
||||
'vault.writeState.reasons.read_only': '此设备上的保险库文件夹是只读的。',
|
||||
'vault.writeState.reasons.unavailable': '此设备上的保险库文件夹不可用。',
|
||||
'vault.writeState.reasons.not_directory': '保险库路径不是文件夹。',
|
||||
'vault.writeState.reasons.empty_path': '保险库文件夹路径为空。',
|
||||
'vault.syncingProgress': '正在同步... {ingested}/{total}',
|
||||
'vault.removing': '正在删除...',
|
||||
'vault.relative.sec': '{count} 秒前',
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('tauriCommands/vault', () => {
|
||||
let openhumanVaultCreate: typeof import('./vault').openhumanVaultCreate;
|
||||
let openhumanVaultGet: typeof import('./vault').openhumanVaultGet;
|
||||
let openhumanVaultFiles: typeof import('./vault').openhumanVaultFiles;
|
||||
let openhumanVaultWriteMarkdown: typeof import('./vault').openhumanVaultWriteMarkdown;
|
||||
let openhumanVaultRemove: typeof import('./vault').openhumanVaultRemove;
|
||||
let openhumanVaultSync: typeof import('./vault').openhumanVaultSync;
|
||||
let openhumanVaultSyncStatus: typeof import('./vault').openhumanVaultSyncStatus;
|
||||
@@ -33,6 +34,7 @@ describe('tauriCommands/vault', () => {
|
||||
openhumanVaultCreate = actual.openhumanVaultCreate;
|
||||
openhumanVaultGet = actual.openhumanVaultGet;
|
||||
openhumanVaultFiles = actual.openhumanVaultFiles;
|
||||
openhumanVaultWriteMarkdown = actual.openhumanVaultWriteMarkdown;
|
||||
openhumanVaultRemove = actual.openhumanVaultRemove;
|
||||
openhumanVaultSync = actual.openhumanVaultSync;
|
||||
openhumanVaultSyncStatus = actual.openhumanVaultSyncStatus;
|
||||
@@ -127,6 +129,44 @@ describe('tauriCommands/vault', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanVaultWriteMarkdown', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(
|
||||
openhumanVaultWriteMarkdown({
|
||||
vaultId: 'v-1',
|
||||
relPath: 'wiki/a.md',
|
||||
content: '# A',
|
||||
approved: true,
|
||||
})
|
||||
).rejects.toThrow('Not running in Tauri');
|
||||
});
|
||||
|
||||
test('dispatches openhuman.vault_write_markdown with approval state', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { vault_id: 'v-1', rel_path: 'wiki/a.md', bytes_written: 3, created: true },
|
||||
logs: [],
|
||||
});
|
||||
await openhumanVaultWriteMarkdown({
|
||||
vaultId: 'v-1',
|
||||
relPath: 'wiki/a.md',
|
||||
content: '# A',
|
||||
overwrite: true,
|
||||
approved: true,
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.vault_write_markdown',
|
||||
params: {
|
||||
vault_id: 'v-1',
|
||||
rel_path: 'wiki/a.md',
|
||||
content: '# A',
|
||||
overwrite: true,
|
||||
approved: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanVaultRemove', () => {
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
@@ -15,8 +15,12 @@ export interface CoreVault {
|
||||
created_at: string;
|
||||
last_synced_at?: string | null;
|
||||
file_count: number;
|
||||
write_state: CoreVaultWriteState;
|
||||
write_state_reason?: string | null;
|
||||
}
|
||||
|
||||
export type CoreVaultWriteState = 'writable' | 'read_only' | 'unavailable';
|
||||
|
||||
export type CoreVaultFileStatus = 'ok' | 'skipped' | 'failed';
|
||||
|
||||
export interface CoreVaultFile {
|
||||
@@ -98,6 +102,30 @@ export async function openhumanVaultFiles(
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanVaultWriteMarkdown(params: {
|
||||
vaultId: string;
|
||||
relPath: string;
|
||||
content: string;
|
||||
overwrite?: boolean;
|
||||
approved: boolean;
|
||||
}): Promise<
|
||||
CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }>
|
||||
> {
|
||||
ensureTauri();
|
||||
return await callCoreRpc<
|
||||
CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }>
|
||||
>({
|
||||
method: 'openhuman.vault_write_markdown',
|
||||
params: {
|
||||
vault_id: params.vaultId,
|
||||
rel_path: params.relPath,
|
||||
content: params.content,
|
||||
overwrite: params.overwrite ?? false,
|
||||
approved: params.approved,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanVaultRemove(
|
||||
vaultId: string,
|
||||
purgeMemory: boolean
|
||||
|
||||
@@ -422,6 +422,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
|
||||
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
|
||||
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
|
||||
| 11.1.9 | Vault Markdown Writes | RU/VU | `src/openhuman/vault/tests.rs`, `app/src/components/intelligence/VaultPanel.test.tsx`, `app/src/utils/tauriCommands/vault.test.ts` | ✅ | User-added vaults expose writable/read-only/unavailable state; approved markdown writes reject missing approval, path escapes, and non-markdown targets |
|
||||
|
||||
### 11.2 Insights Dashboard
|
||||
|
||||
|
||||
@@ -587,6 +587,16 @@ const result = await callCoreRpc<MyType>({
|
||||
|
||||
Implementation: `invoke('core_rpc_relay', { request: { method, params, serviceManaged } })` → `app/src-tauri/src/commands/core_relay.rs` → HTTP client in `app/src-tauri/src/core_rpc.rs`.
|
||||
|
||||
### Knowledge Vault Write State
|
||||
|
||||
`VaultPanel` in `app/src/components/intelligence/` renders user-added knowledge vaults from the core `vault.*` controller surface. Each vault row must show the core-provided `write_state`:
|
||||
|
||||
- `writable` means approved markdown/wiki writes can be saved under the vault root.
|
||||
- `read_only` means the folder exists but local permissions report it as read-only.
|
||||
- `unavailable` means the folder is missing, no longer a directory, or cannot be inspected on this device.
|
||||
|
||||
The UI should not infer writability from the path string alone. It maps the core-provided `write_state_reason` code through i18n so unsupported write states are visible instead of failing silently. Approved artifact creation uses `openhuman.vault_write_markdown`; callers must pass `approved: true`, a relative `.md` or `.markdown` path, and content. The core rejects absolute paths, `..` traversal, non-markdown files, symlink escapes, and updates without `overwrite: true`.
|
||||
|
||||
### Service integration with providers
|
||||
|
||||
#### SocketProvider
|
||||
|
||||
@@ -310,6 +310,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.vault_markdown_writes",
|
||||
name: "Vault Markdown Writes",
|
||||
domain: "intelligence",
|
||||
category: CapabilityCategory::Intelligence,
|
||||
description: "Show whether a user-added local vault is writable, and write explicitly approved markdown/wiki artifacts back into that vault without leaving the device.",
|
||||
how_to: "Intelligence > Memory > Knowledge vaults",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: LOCAL_RAW,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.embedding_provider_config",
|
||||
name: "Configure Embedding Provider",
|
||||
|
||||
@@ -103,6 +103,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
|
||||
"intelligence.mcp_server",
|
||||
"intelligence.searxng_search",
|
||||
"intelligence.tool_registry",
|
||||
"intelligence.vault_markdown_writes",
|
||||
"intelligence.embedding_provider_config",
|
||||
"intelligence.embedding_provider_test",
|
||||
"conversation.subagent_mascots",
|
||||
|
||||
@@ -11,6 +11,7 @@ mod read_diff;
|
||||
mod run_linter;
|
||||
mod run_tests;
|
||||
mod update_memory_md;
|
||||
mod vault_write_markdown;
|
||||
|
||||
pub use apply_patch::ApplyPatchTool;
|
||||
pub use csv_export::CsvExportTool;
|
||||
@@ -25,3 +26,4 @@ pub use read_diff::ReadDiffTool;
|
||||
pub use run_linter::RunLinterTool;
|
||||
pub use run_tests::RunTestsTool;
|
||||
pub use update_memory_md::UpdateMemoryMdTool;
|
||||
pub use vault_write_markdown::VaultWriteMarkdownTool;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Tool: vault_write_markdown — approved markdown/wiki writes into user vaults.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
pub struct VaultWriteMarkdownTool {
|
||||
config: Arc<Config>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl VaultWriteMarkdownTool {
|
||||
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { config, security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for VaultWriteMarkdownTool {
|
||||
fn name(&self) -> &str {
|
||||
"vault_write_markdown"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Create or update an approved .md/.markdown wiki note inside a registered knowledge vault. \
|
||||
Use only after the user has approved writing generated content into that vault."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["vault_id", "rel_path", "content"],
|
||||
"properties": {
|
||||
"vault_id": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the registered vault to write into."
|
||||
},
|
||||
"rel_path": {
|
||||
"type": "string",
|
||||
"description": "Relative path under the vault root. Must end with .md or .markdown."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Markdown content to write."
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"description": "Set true to update an existing markdown file."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
|
||||
self.security.gate_decision(CommandClass::Write) == GateDecision::Prompt
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
if !self.security.can_act() {
|
||||
return Ok(ToolResult::error(
|
||||
"[policy-blocked] Action blocked: autonomy is read-only",
|
||||
));
|
||||
}
|
||||
if self.security.is_rate_limited() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: too many actions in the last hour",
|
||||
));
|
||||
}
|
||||
|
||||
let vault_id = args
|
||||
.get("vault_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'vault_id' parameter"))?;
|
||||
let rel_path = args
|
||||
.get("rel_path")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'rel_path' parameter"))?;
|
||||
let content = args
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
|
||||
let overwrite = args
|
||||
.get("overwrite")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !self.security.record_action() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: action budget exhausted",
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
vault_id = %vault_id,
|
||||
rel_path = %rel_path,
|
||||
overwrite,
|
||||
content_bytes = content.len(),
|
||||
"[vault_write_markdown] execute"
|
||||
);
|
||||
match crate::openhuman::vault::ops::vault_write_markdown(
|
||||
&self.config,
|
||||
vault_id,
|
||||
rel_path,
|
||||
content,
|
||||
overwrite,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => Ok(ToolResult::json(json!(outcome.value))),
|
||||
Err(err) => Ok(ToolResult::error(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::security::AutonomyLevel;
|
||||
|
||||
fn security(workspace: std::path::PathBuf, autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy,
|
||||
workspace_dir: workspace,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_write_markdown_tool_schema_requires_core_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = Arc::new(Config::default());
|
||||
let tool = VaultWriteMarkdownTool::new(
|
||||
config,
|
||||
security(dir.path().to_path_buf(), AutonomyLevel::Supervised),
|
||||
);
|
||||
|
||||
assert_eq!(tool.name(), "vault_write_markdown");
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::Write);
|
||||
assert!(tool.external_effect_with_args(&json!({})));
|
||||
let schema = tool.parameters_schema();
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.iter().any(|value| value == "vault_id"));
|
||||
assert!(required.iter().any(|value| value == "rel_path"));
|
||||
assert!(required.iter().any(|value| value == "content"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_write_markdown_tool_blocks_read_only_autonomy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = Arc::new(Config::default());
|
||||
let tool = VaultWriteMarkdownTool::new(
|
||||
config,
|
||||
security(dir.path().to_path_buf(), AutonomyLevel::ReadOnly),
|
||||
);
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"vault_id": "v-1",
|
||||
"rel_path": "wiki/a.md",
|
||||
"content": "# A"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_error);
|
||||
assert!(result.text().contains("read-only"));
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,10 @@ pub fn all_tools_with_runtime(
|
||||
shell,
|
||||
Box::new(FileReadTool::new(security.clone())),
|
||||
Box::new(FileWriteTool::new(security.clone())),
|
||||
Box::new(VaultWriteMarkdownTool::new(
|
||||
config.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
// Coding-harness baseline tools (issue #1205): file navigation
|
||||
// + atomic editing primitives. Use these instead of falling
|
||||
// through to `shell` for grep/find/sed work.
|
||||
|
||||
@@ -177,6 +177,43 @@ fn all_tools_includes_spawn_parallel_agents() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_includes_vault_write_markdown() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let mem_cfg = MemoryConfig {
|
||||
backend: "markdown".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory_store::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
let browser = BrowserConfig {
|
||||
enabled: false,
|
||||
allowed_domains: vec![],
|
||||
session_name: None,
|
||||
..BrowserConfig::default()
|
||||
};
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
let cfg = test_config(&tmp);
|
||||
|
||||
let tools = all_tools(
|
||||
Arc::new(cfg.clone()),
|
||||
&security,
|
||||
AuditLogger::disabled(),
|
||||
mem,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(
|
||||
names.contains(&"vault_write_markdown"),
|
||||
"vault_write_markdown must be registered so agents can write approved markdown into user vaults; got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_always_registers_curl() {
|
||||
// Regression guard: `curl` is always registered (gated only by
|
||||
@@ -382,6 +419,7 @@ fn all_tools_default_registry_contains_expected_baseline_surface() {
|
||||
"shell",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"vault_write_markdown",
|
||||
"grep",
|
||||
"glob",
|
||||
"list",
|
||||
|
||||
@@ -9,7 +9,10 @@ const TOOL_ID_TO_RUST_NAMES: &[(&str, &[&str])] = &[
|
||||
("install_tool", &["install_tool"]),
|
||||
("git_operations", &["git_operations"]),
|
||||
("file_read", &["file_read", "read_diff", "csv_export"]),
|
||||
("file_write", &["file_write", "update_memory_md"]),
|
||||
(
|
||||
"file_write",
|
||||
&["file_write", "update_memory_md", "vault_write_markdown"],
|
||||
),
|
||||
("screenshot", &["screenshot"]),
|
||||
("image_info", &["image_info"]),
|
||||
("browser_open", &["browser_open"]),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use schemas::{
|
||||
};
|
||||
pub use types::{
|
||||
Vault, VaultFile, VaultFileStatus, VaultSyncReport, VaultSyncState, VaultSyncStatus,
|
||||
VaultWriteMarkdownReport, VaultWriteState,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+163
-5
@@ -3,6 +3,7 @@
|
||||
use chrono::Utc;
|
||||
use futures::FutureExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
@@ -14,7 +15,9 @@ use crate::rpc::RpcOutcome;
|
||||
use super::state;
|
||||
use super::store;
|
||||
use super::sync;
|
||||
use super::types::{Vault, VaultFile, VaultSyncState, VaultSyncStatus};
|
||||
use super::types::{
|
||||
Vault, VaultFile, VaultSyncState, VaultSyncStatus, VaultWriteMarkdownReport, VaultWriteState,
|
||||
};
|
||||
|
||||
/// Derive a stable memory namespace for a vault without embedding the raw UUID.
|
||||
///
|
||||
@@ -63,13 +66,15 @@ pub async fn vault_create(
|
||||
exclude_globs.len(),
|
||||
);
|
||||
let namespace = vault_namespace_for_id(&id);
|
||||
let canonical_root = root
|
||||
.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| trimmed_root.to_string());
|
||||
let (write_state, write_state_reason) = store::vault_write_state_for_root_path(&canonical_root);
|
||||
let vault = Vault {
|
||||
id: id.clone(),
|
||||
name: trimmed_name.to_string(),
|
||||
root_path: root
|
||||
.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| trimmed_root.to_string()),
|
||||
root_path: canonical_root,
|
||||
host_os: Some(store::current_host_os().to_string()),
|
||||
namespace,
|
||||
include_globs,
|
||||
@@ -77,6 +82,8 @@ pub async fn vault_create(
|
||||
created_at: Utc::now(),
|
||||
last_synced_at: None,
|
||||
file_count: 0,
|
||||
write_state,
|
||||
write_state_reason,
|
||||
};
|
||||
|
||||
store::insert_vault(config, &vault).map_err(|e| e.to_string())?;
|
||||
@@ -104,6 +111,81 @@ pub async fn vault_get(config: &Config, id: &str) -> Result<RpcOutcome<Vault>, S
|
||||
Ok(RpcOutcome::single_log(vault, "vault loaded"))
|
||||
}
|
||||
|
||||
/// Write an explicitly approved markdown/wiki artifact into a registered vault.
|
||||
pub async fn vault_write_markdown(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
rel_path: &str,
|
||||
content: &str,
|
||||
overwrite: bool,
|
||||
approved: bool,
|
||||
) -> Result<RpcOutcome<VaultWriteMarkdownReport>, String> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
return Err("vault_id must not be empty".to_string());
|
||||
}
|
||||
if !approved {
|
||||
log::debug!("[vault] write_markdown: rejected missing approval id={id}");
|
||||
return Err("vault markdown writes require explicit user approval".to_string());
|
||||
}
|
||||
|
||||
let vault = store::get_vault(config, id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("vault not found: {id}"))?;
|
||||
let (write_state, write_reason) = store::vault_write_state_for_root_path(&vault.root_path);
|
||||
if write_state != VaultWriteState::Writable {
|
||||
log::debug!(
|
||||
"[vault] write_markdown: rejected non-writable id={id} state={write_state:?} reason={write_reason:?}"
|
||||
);
|
||||
return Err(store::vault_write_state_reason_message(write_reason.as_deref()).to_string());
|
||||
}
|
||||
|
||||
let rel = validate_markdown_rel_path(rel_path)?;
|
||||
let bytes = content.as_bytes().len() as u64;
|
||||
let root = std::fs::canonicalize(&vault.root_path)
|
||||
.map_err(|err| format!("failed to resolve vault folder: {err}"))?;
|
||||
ensure_existing_ancestors_stay_in_root(&root, &rel)?;
|
||||
let target = root.join(&rel);
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| "target path has no parent directory".to_string())?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("failed to create vault note directory: {err}"))?;
|
||||
let parent_canon = std::fs::canonicalize(parent)
|
||||
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
|
||||
if !parent_canon.starts_with(&root) {
|
||||
return Err("vault note path resolves outside the vault folder".to_string());
|
||||
}
|
||||
if let Ok(meta) = std::fs::symlink_metadata(&target) {
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err("refusing to write through a symlink inside the vault".to_string());
|
||||
}
|
||||
}
|
||||
let created = !target.exists();
|
||||
if !created && !overwrite {
|
||||
return Err(
|
||||
"vault markdown file already exists; set overwrite=true to update it".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[vault] write_markdown: writing id={id} rel_path={} bytes={bytes} overwrite={overwrite} created={created}",
|
||||
rel.display()
|
||||
);
|
||||
std::fs::write(&target, content)
|
||||
.map_err(|err| format!("failed to write vault markdown file: {err}"))?;
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
VaultWriteMarkdownReport {
|
||||
vault_id: id.to_string(),
|
||||
rel_path: rel.to_string_lossy().replace('\\', "/"),
|
||||
bytes_written: bytes,
|
||||
created,
|
||||
},
|
||||
format!("vault markdown written: {id}"),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn vault_files(config: &Config, id: &str) -> Result<RpcOutcome<Vec<VaultFile>>, String> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
@@ -117,6 +199,82 @@ pub async fn vault_files(config: &Config, id: &str) -> Result<RpcOutcome<Vec<Vau
|
||||
Ok(RpcOutcome::single_log(files, "vault files listed"))
|
||||
}
|
||||
|
||||
fn validate_markdown_rel_path(raw: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("rel_path must not be empty".to_string());
|
||||
}
|
||||
let path = Path::new(trimmed);
|
||||
if path.is_absolute() {
|
||||
return Err("rel_path must be relative to the vault folder".to_string());
|
||||
}
|
||||
|
||||
let mut clean = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Normal(part) => clean.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
return Err("rel_path must not contain '..' segments".to_string());
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => {
|
||||
return Err("rel_path must stay inside the vault folder".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if clean.as_os_str().is_empty() {
|
||||
return Err("rel_path must name a markdown file".to_string());
|
||||
}
|
||||
let ext = clean
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default();
|
||||
if !matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown") {
|
||||
return Err("rel_path must end with .md or .markdown".to_string());
|
||||
}
|
||||
Ok(clean)
|
||||
}
|
||||
|
||||
fn ensure_existing_ancestors_stay_in_root(root: &Path, rel_path: &Path) -> Result<(), String> {
|
||||
let Some(parent) = rel_path.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut current = root.to_path_buf();
|
||||
for component in parent.components() {
|
||||
let Component::Normal(part) = component else {
|
||||
continue;
|
||||
};
|
||||
let next = current.join(part);
|
||||
match std::fs::symlink_metadata(&next) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
let resolved = std::fs::canonicalize(&next)
|
||||
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
|
||||
if !resolved.starts_with(root) {
|
||||
return Err(
|
||||
"vault note directory resolves outside the vault folder".to_string()
|
||||
);
|
||||
}
|
||||
current = resolved;
|
||||
}
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
current = next;
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err("vault note parent path is not a directory".to_string());
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(format!("failed to inspect vault note directory: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn vault_remove(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("list"),
|
||||
schemas("get"),
|
||||
schemas("files"),
|
||||
schemas("write_markdown"),
|
||||
schemas("remove"),
|
||||
schemas("sync"),
|
||||
schemas("sync_status"),
|
||||
@@ -45,6 +46,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("files"),
|
||||
handler: handle_files,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("write_markdown"),
|
||||
handler: handle_write_markdown,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("remove"),
|
||||
handler: handle_remove,
|
||||
@@ -139,6 +144,44 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"write_markdown" => ControllerSchema {
|
||||
namespace: "vault",
|
||||
function: "write_markdown",
|
||||
description: "Write an explicitly approved markdown/wiki file inside a writable vault.",
|
||||
inputs: vec![
|
||||
vault_id_input("Identifier of the vault to write into."),
|
||||
FieldSchema {
|
||||
name: "rel_path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Relative .md/.markdown path inside the vault.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "content",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Markdown content to write.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "overwrite",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
|
||||
comment: "When true, update an existing markdown file.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "approved",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Must be true after explicit user approval for arbitrary vault writes.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "report",
|
||||
ty: TypeSchema::Ref("VaultWriteMarkdownReport"),
|
||||
comment: "Write result with relative path and byte count.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"remove" => ControllerSchema {
|
||||
namespace: "vault",
|
||||
function: "remove",
|
||||
@@ -283,6 +326,23 @@ fn handle_files(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_write_markdown(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let vault_id = read_required::<String>(¶ms, "vault_id")?;
|
||||
let rel_path = read_required::<String>(¶ms, "rel_path")?;
|
||||
let content = read_required::<String>(¶ms, "content")?;
|
||||
let overwrite = read_optional::<bool>(¶ms, "overwrite")?.unwrap_or(false);
|
||||
let approved = read_required::<bool>(¶ms, "approved")?;
|
||||
to_json(
|
||||
crate::openhuman::vault::ops::vault_write_markdown(
|
||||
&config, &vault_id, &rel_path, &content, overwrite, approved,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
@@ -8,10 +8,16 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
use super::types::{Vault, VaultFile, VaultFileStatus};
|
||||
use super::types::{Vault, VaultFile, VaultFileStatus, VaultWriteState};
|
||||
|
||||
static MIGRATED_VAULT_DBS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
pub(crate) const VAULT_WRITE_REASON_EMPTY_PATH: &str = "empty_path";
|
||||
pub(crate) const VAULT_WRITE_REASON_UNAVAILABLE: &str = "unavailable";
|
||||
pub(crate) const VAULT_WRITE_REASON_NOT_DIRECTORY: &str = "not_directory";
|
||||
pub(crate) const VAULT_WRITE_REASON_READ_ONLY: &str = "read_only";
|
||||
pub(crate) const VAULT_WRITE_REASON_WRITABLE: &str = "writable";
|
||||
|
||||
pub(crate) fn with_connection<T>(
|
||||
config: &Config,
|
||||
f: impl FnOnce(&Connection) -> Result<T>,
|
||||
@@ -225,10 +231,12 @@ fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result<Vault> {
|
||||
let created_raw: String = row.get(7)?;
|
||||
let last_raw: Option<String> = row.get(8)?;
|
||||
let file_count: i64 = row.get(9)?;
|
||||
let root_path: String = row.get(2)?;
|
||||
let (write_state, write_state_reason) = vault_write_state_for_root_path(&root_path);
|
||||
Ok(Vault {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
root_path: row.get(2)?,
|
||||
root_path,
|
||||
host_os: row.get(3)?,
|
||||
namespace: row.get(4)?,
|
||||
include_globs: serde_json::from_str(&include_raw).unwrap_or_default(),
|
||||
@@ -236,6 +244,8 @@ fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result<Vault> {
|
||||
created_at: parse_dt(&created_raw),
|
||||
last_synced_at: last_raw.as_deref().map(parse_dt),
|
||||
file_count: file_count.max(0) as u64,
|
||||
write_state,
|
||||
write_state_reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,6 +292,61 @@ pub(crate) fn current_host_os() -> &'static str {
|
||||
std::env::consts::OS
|
||||
}
|
||||
|
||||
pub(crate) fn vault_write_state_for_root_path(
|
||||
root_path: &str,
|
||||
) -> (VaultWriteState, Option<String>) {
|
||||
let trimmed = root_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return (
|
||||
VaultWriteState::Unavailable,
|
||||
Some(VAULT_WRITE_REASON_EMPTY_PATH.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let path = std::path::Path::new(trimmed);
|
||||
let metadata = match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
return (
|
||||
VaultWriteState::Unavailable,
|
||||
Some(VAULT_WRITE_REASON_UNAVAILABLE.to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return (
|
||||
VaultWriteState::Unavailable,
|
||||
Some(VAULT_WRITE_REASON_NOT_DIRECTORY.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if metadata.permissions().readonly() {
|
||||
return (
|
||||
VaultWriteState::ReadOnly,
|
||||
Some(VAULT_WRITE_REASON_READ_ONLY.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
VaultWriteState::Writable,
|
||||
Some(VAULT_WRITE_REASON_WRITABLE.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn vault_write_state_reason_message(reason_code: Option<&str>) -> &'static str {
|
||||
match reason_code {
|
||||
Some(VAULT_WRITE_REASON_EMPTY_PATH) => "Vault folder path is empty.",
|
||||
Some(VAULT_WRITE_REASON_UNAVAILABLE) => "Vault folder is not available on this device.",
|
||||
Some(VAULT_WRITE_REASON_NOT_DIRECTORY) => "Vault path is not a directory.",
|
||||
Some(VAULT_WRITE_REASON_READ_ONLY) => "Vault folder is read-only on this device.",
|
||||
Some(VAULT_WRITE_REASON_WRITABLE) => {
|
||||
"Approved markdown/wiki writes can be saved in this vault."
|
||||
}
|
||||
_ => "Vault write state is unknown.",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn path_looks_compatible_with_host_os(raw_path: &str, host_os: &str) -> bool {
|
||||
let path = raw_path.trim();
|
||||
if path.is_empty() {
|
||||
|
||||
@@ -702,6 +702,7 @@ mod sync_tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested};
|
||||
use crate::openhuman::vault::ops;
|
||||
use crate::openhuman::vault::VaultWriteState;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Test-config pattern mirrors `memory::sync_pipeline_e2e_test::test_config`:
|
||||
@@ -730,6 +731,8 @@ mod sync_tests {
|
||||
created_at: Utc::now(),
|
||||
last_synced_at: None,
|
||||
file_count: 0,
|
||||
write_state: VaultWriteState::Writable,
|
||||
write_state_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ use super::ops;
|
||||
use super::state;
|
||||
use super::store;
|
||||
use super::sync::supported_extension;
|
||||
use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus};
|
||||
use super::types::{
|
||||
Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus, VaultWriteState,
|
||||
};
|
||||
|
||||
fn make_config(tmp: &TempDir) -> Config {
|
||||
let mut config = Config::default();
|
||||
@@ -30,6 +32,8 @@ fn sample_vault(root: PathBuf) -> Vault {
|
||||
created_at: chrono::Utc::now(),
|
||||
last_synced_at: None,
|
||||
file_count: 0,
|
||||
write_state: VaultWriteState::Writable,
|
||||
write_state_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +103,31 @@ fn store_stamps_new_vaults_with_current_host_os() {
|
||||
let listed = store::list_vaults(&config).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].host_os.as_deref(), Some(std::env::consts::OS));
|
||||
assert_eq!(listed[0].write_state, VaultWriteState::Writable);
|
||||
assert_eq!(
|
||||
listed[0].write_state_reason.as_deref(),
|
||||
Some(store::VAULT_WRITE_REASON_WRITABLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_marks_missing_vault_folder_unavailable_instead_of_hiding_it() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = make_config(&tmp);
|
||||
let vault_root = tmp.path().join("vault-root");
|
||||
std::fs::create_dir_all(&vault_root).unwrap();
|
||||
let vault = sample_vault(vault_root.clone());
|
||||
|
||||
store::insert_vault(&config, &vault).unwrap();
|
||||
std::fs::remove_dir_all(&vault_root).unwrap();
|
||||
|
||||
let listed = store::list_vaults(&config).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].write_state, VaultWriteState::Unavailable);
|
||||
assert_eq!(
|
||||
listed[0].write_state_reason.as_deref(),
|
||||
Some(store::VAULT_WRITE_REASON_UNAVAILABLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -342,6 +371,11 @@ async fn vault_create_returns_current_host_os() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.value.host_os.as_deref(), Some(std::env::consts::OS));
|
||||
assert_eq!(outcome.value.write_state, VaultWriteState::Writable);
|
||||
assert_eq!(
|
||||
outcome.value.write_state_reason.as_deref(),
|
||||
Some(store::VAULT_WRITE_REASON_WRITABLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -390,6 +424,160 @@ async fn vault_sync_status_returns_idle_for_unknown_vault() {
|
||||
assert_eq!(outcome.value.ingested, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_write_markdown_requires_explicit_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = make_config(&tmp);
|
||||
|
||||
let vault = ops::vault_create(
|
||||
&config,
|
||||
"Test",
|
||||
tmp.path().to_str().unwrap(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let err = ops::vault_write_markdown(
|
||||
&config,
|
||||
&vault.id,
|
||||
"wiki/summary.md",
|
||||
"# Summary\n",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("explicit user approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_write_markdown_creates_and_updates_relative_markdown() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = make_config(&tmp);
|
||||
let vault_root = tmp.path().join("vault-root");
|
||||
std::fs::create_dir_all(&vault_root).unwrap();
|
||||
|
||||
let vault = ops::vault_create(
|
||||
&config,
|
||||
"Test",
|
||||
vault_root.to_str().unwrap(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let first = ops::vault_write_markdown(
|
||||
&config,
|
||||
&vault.id,
|
||||
"wiki/summary.md",
|
||||
"# Summary\n\nInitial.",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
assert!(first.created);
|
||||
assert_eq!(first.rel_path, "wiki/summary.md");
|
||||
assert_eq!(first.bytes_written, "# Summary\n\nInitial.".len() as u64);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
|
||||
"# Summary\n\nInitial."
|
||||
);
|
||||
|
||||
let duplicate = ops::vault_write_markdown(
|
||||
&config,
|
||||
&vault.id,
|
||||
"wiki/summary.md",
|
||||
"# Summary\n\nUpdated.",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(duplicate.contains("already exists"));
|
||||
|
||||
let updated = ops::vault_write_markdown(
|
||||
&config,
|
||||
&vault.id,
|
||||
"wiki/summary.md",
|
||||
"# Summary\n\nUpdated.",
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
assert!(!updated.created);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
|
||||
"# Summary\n\nUpdated."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_write_markdown_rejects_escape_paths_and_non_markdown() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = make_config(&tmp);
|
||||
let vault = ops::vault_create(
|
||||
&config,
|
||||
"Test",
|
||||
tmp.path().to_str().unwrap(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let traversal = ops::vault_write_markdown(&config, &vault.id, "../x.md", "x", false, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(traversal.contains(".."));
|
||||
|
||||
let non_markdown =
|
||||
ops::vault_write_markdown(&config, &vault.id, "notes/out.txt", "x", false, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(non_markdown.contains(".md"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn vault_write_markdown_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = make_config(&tmp);
|
||||
let vault_root = tmp.path().join("vault-root");
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir_all(&vault_root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
symlink(&outside, vault_root.join("linked")).unwrap();
|
||||
|
||||
let vault = ops::vault_create(
|
||||
&config,
|
||||
"Test",
|
||||
vault_root.to_str().unwrap(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.value;
|
||||
|
||||
let err = ops::vault_write_markdown(&config, &vault.id, "linked/escape.md", "x", false, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("outside the vault"));
|
||||
assert!(!outside.join("escape.md").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_sync_status_returns_state_when_present() {
|
||||
let id = "__ops_status_running__";
|
||||
|
||||
@@ -59,6 +59,20 @@ pub struct Vault {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_synced_at: Option<DateTime<Utc>>,
|
||||
pub file_count: u64,
|
||||
#[serde(default)]
|
||||
pub write_state: VaultWriteState,
|
||||
#[serde(default)]
|
||||
pub write_state_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether OpenHuman can write approved markdown artifacts back into a vault.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VaultWriteState {
|
||||
Writable,
|
||||
ReadOnly,
|
||||
#[default]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Per-file ledger entry used for dedup on re-sync.
|
||||
@@ -113,3 +127,12 @@ pub struct VaultSyncReport {
|
||||
pub duration_ms: i64,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result returned after writing an approved markdown/wiki artifact into a vault.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VaultWriteMarkdownReport {
|
||||
pub vault_id: String,
|
||||
pub rel_path: String,
|
||||
pub bytes_written: u64,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user