Feat v0.5.0 realtime dialog (#30)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m36s
CI/CD / test-sqlite (push) Successful in 2m43s
CI/CD / build-and-deploy (push) Successful in 1m19s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
2026-03-30 11:07:27 +00:00
committed by xcaliber
parent eb9a2d7d27
commit 2abf406db8
21 changed files with 680 additions and 22 deletions

View File

@@ -255,6 +255,11 @@ export function createDomains(restClient) {
consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2
registry: () => rc.get('/api/v1/admin/packages/registry'),
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
// v0.5.0: Extension permissions
permissions: (id) => rc.get(`/api/v1/admin/extensions/${id}/permissions`),
grantPerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/grant`, {}),
revokePerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/revoke`, {}),
grantAllPerms: (id) => rc.post(`/api/v1/admin/extensions/${id}/permissions/grant-all`, {}),
},
// v0.38.2: Full dependency graph

View File

@@ -21,6 +21,7 @@ import { createTheme } from './theme.js';
import { createStorage } from './storage.js';
import { createSlots } from './slots.js';
import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -70,6 +71,7 @@ export async function boot() {
const storage = createStorage();
const slots = createSlots(events.emit.bind(events));
const actions = createActions(events.emit.bind(events));
const realtime = createRealtime(events);
// 7. Assemble sw object
const sw = Object.create(null);
@@ -102,6 +104,9 @@ export async function boot() {
sw.slots = slots;
sw.actions = actions;
// Realtime — room-scoped pub/sub over WebSocket (v0.5.0)
sw.realtime = realtime;
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
@@ -162,7 +167,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.2.3';
sw._sdk = '0.5.0';
// 8. Expose globally
window.sw = sw;

132
src/js/sw/sdk/realtime.js Normal file
View File

@@ -0,0 +1,132 @@
// ==========================================
// Switchboard Core — SDK: Realtime
// ==========================================
// Room-scoped publish/subscribe over the WebSocket bridge.
//
// Extensions publish events from Starlark via realtime.publish().
// Clients subscribe to channels here — the SDK manages room
// join/leave over WS and dispatches incoming realtime.* events.
//
// Factory: createRealtime(events)
// ==========================================
/**
* Create the realtime module.
*
* @param {object} events — SDK event bus (from createEvents)
* @returns {object} realtime
*/
export function createRealtime(events) {
// channel → Set<{event, fn}>
const _channels = new Map();
// ── Room management ─────────────────────────
function _joinRoom(channel) {
events.emit('room.subscribe', { room: channel });
}
function _leaveRoom(channel) {
events.emit('room.unsubscribe', { room: channel });
}
// ── Incoming realtime events ────────────────
// Single wildcard listener for all realtime.* events from server
events.on('realtime.*', (payload, meta) => {
if (!meta?.room) return;
const subs = _channels.get(meta.room);
if (!subs || subs.size === 0) return;
// Extract event name: "realtime.foo.bar" → "foo.bar"
const eventName = meta.event?.replace(/^realtime\./, '') || '';
for (const entry of subs) {
// Match: no event filter (catch-all) or exact event match
if (!entry.event || entry.event === eventName) {
try {
entry.fn(payload, { ...meta, channel: meta.room, event: eventName });
} catch (e) {
console.error(`[sw.realtime] Handler error on ${meta.room}/${eventName}:`, e);
}
}
}
});
// ── Reconnect: re-join all active rooms ─────
events.on('ws.connected', () => {
for (const channel of _channels.keys()) {
if (_channels.get(channel).size > 0) {
_joinRoom(channel);
}
}
});
// ── Public API ──────────────────────────────
const realtime = {
/**
* Subscribe to a realtime channel.
*
* @param {string} channel — room/channel name
* @param {string|Function} eventOrCallback — event name filter or callback
* @param {Function} [callback] — callback if event name provided
* @returns {Function} unsubscribe
*/
subscribe(channel, eventOrCallback, callback) {
if (!channel) throw new Error('sw.realtime.subscribe: channel is required');
let event = null;
let fn;
if (typeof eventOrCallback === 'function') {
fn = eventOrCallback;
} else if (typeof eventOrCallback === 'string' && typeof callback === 'function') {
event = eventOrCallback;
fn = callback;
} else {
throw new Error('sw.realtime.subscribe: invalid arguments');
}
const entry = { event, fn };
// First subscriber for this channel — join room
if (!_channels.has(channel)) {
_channels.set(channel, new Set());
_joinRoom(channel);
}
_channels.get(channel).add(entry);
// Return unsubscribe function
return () => {
const subs = _channels.get(channel);
if (!subs) return;
subs.delete(entry);
// Last subscriber — leave room
if (subs.size === 0) {
_channels.delete(channel);
_leaveRoom(channel);
}
};
},
/**
* Debug helper: active channels and subscriber counts.
* @returns {object} { channelName: count, ... }
*/
channels() {
const result = {};
for (const [channel, subs] of _channels) {
result[channel] = subs.size;
}
return result;
},
};
return realtime;
}

View File

@@ -29,6 +29,8 @@ function sourceBadge(source) {
function statusBadge(status) {
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
if (status === 'pending_review') return html`<span class="badge" style="background:var(--warning,#e6a817);color:#000;">review</span>`;
if (status === 'suspended') return html`<span class="badge" style="background:var(--danger,#d33);color:#fff;">suspended</span>`;
return null;
}
@@ -43,6 +45,8 @@ export default function PackagesSection() {
const [registryPkgs, setRegistryPkgs] = useState([]);
const [registryLoading, setRegistryLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const [permsId, setPermsId] = useState(null); // v0.5.0: permissions drawer
const [perms, setPerms] = useState([]);
const BASE = window.__BASE__ || '';
@@ -136,6 +140,46 @@ export default function PackagesSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Permissions drawer (v0.5.0) ────────────
async function togglePerms(pkgId) {
if (permsId === pkgId) { setPermsId(null); setPerms([]); return; }
try {
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
setPermsId(pkgId);
// Close settings if open
if (expandedId === pkgId) { setExpandedId(null); setPkgSettings(null); }
} catch (e) { sw.toast(e.message, 'error'); }
}
async function grantPerm(pkgId, perm) {
try {
await sw.api.admin.packages.grantPerm(pkgId, perm);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function revokePerm(pkgId, perm) {
try {
await sw.api.admin.packages.revokePerm(pkgId, perm);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function grantAllPerms(pkgId) {
try {
await sw.api.admin.packages.grantAllPerms(pkgId);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
sw.toast('All permissions granted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Registry ────────────────────────────────
async function toggleRegistry() {
if (registryOpen) { setRegistryOpen(false); return; }
@@ -166,6 +210,8 @@ export default function PackagesSection() {
const dormant = packages.filter(p => p.status === 'dormant').length;
const hasManifestSettings = (pkg) =>
pkg.manifest?.settings || pkg.package_settings;
const hasDeclaredPerms = (pkg) =>
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
return html`
<div>
@@ -211,8 +257,8 @@ export default function PackagesSection() {
<div class="admin-actions-cell">
<button class="btn-small"
onClick=${() => toggleEnabled(pkg)}
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'}
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}>
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
${pkg.enabled ? 'Disable' : 'Enable'}
</button>
${hasManifestSettings(pkg) && html`
@@ -220,6 +266,11 @@ export default function PackagesSection() {
${expandedId === pkg.id ? 'Close' : 'Settings'}
</button>
`}
${hasDeclaredPerms(pkg) && html`
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}>
${permsId === pkg.id ? 'Close' : 'Permissions'}
</button>
`}
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html`
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
@@ -247,6 +298,35 @@ export default function PackagesSection() {
}
</div>
`}
${/* ── Inline permissions drawer (v0.5.0) ── */``}
${permsId === pkg.id && html`
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
${perms.length === 0
? html`<span class="text-muted" style="font-size:12px;">No permissions declared</span>`
: html`
<div style="margin-bottom:8px;">
${perms.map(p => html`
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<code style="min-width:140px;font-size:12px;">${p.permission}</code>
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}"
style="min-width:64px;font-size:11px;"
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
${p.granted ? 'Revoke' : 'Grant'}
</button>
${p.granted && p.granted_by && html`
<span class="text-muted" style="font-size:11px;">by ${p.granted_by}</span>
`}
</div>
`)}
</div>
${perms.some(p => !p.granted) && html`
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
`}
`
}
</div>
`}
</div>
`)}
</div>