feat: Mattermost plugin + daemon integration (Phases 2-5)
Plugin (Go server + React webapp): - Custom post type 'custom_livestatus' with terminal-style rendering - WebSocket broadcasts for real-time updates (no PUT, no '(edited)') - KV store for session persistence across reconnects - Shared secret auth for daemon-to-plugin communication - Auto-scroll terminal with user scroll override - Collapsible sub-agent sections - Theme-compatible CSS (light/dark) Daemon integration: - PluginClient for structured data push to plugin - Auto-detection: GET /health on startup + periodic re-check - Graceful fallback: if plugin unavailable, uses REST API (PUT) - Per-session mode tracking: sessions created via plugin stay on plugin - Mid-session fallback: if plugin update fails, auto-switch to REST Plugin deployed and active on Mattermost v11.4.0.
This commit is contained in:
@@ -24,6 +24,7 @@ const { getLogger } = require('./logger');
|
||||
const { SessionMonitor } = require('./session-monitor');
|
||||
const { StatusWatcher } = require('./status-watcher');
|
||||
const { StatusBox } = require('./status-box');
|
||||
const { PluginClient } = require('./plugin-client');
|
||||
// status-formatter is used inline via require() in helpers
|
||||
const { HealthServer } = require('./health');
|
||||
const { loadLabels } = require('./tool-labels');
|
||||
@@ -159,6 +160,36 @@ async function startDaemon() {
|
||||
maxSockets: config.mm.maxSockets,
|
||||
});
|
||||
|
||||
// Plugin client (optional — enhances rendering via WebSocket instead of PUT)
|
||||
var pluginClient = null;
|
||||
var usePlugin = false;
|
||||
|
||||
if (config.plugin && config.plugin.enabled && config.plugin.url && config.plugin.secret) {
|
||||
pluginClient = new PluginClient({
|
||||
pluginUrl: config.plugin.url,
|
||||
secret: config.plugin.secret,
|
||||
logger: logger.child({ module: 'plugin-client' }),
|
||||
});
|
||||
|
||||
// Initial plugin detection
|
||||
pluginClient.isHealthy().then(function (healthy) {
|
||||
usePlugin = healthy;
|
||||
logger.info({ usePlugin, url: config.plugin.url }, healthy
|
||||
? 'Plugin detected — using WebSocket rendering mode'
|
||||
: 'Plugin not available — using REST API fallback');
|
||||
});
|
||||
|
||||
// Periodic re-detection
|
||||
setInterval(function () {
|
||||
pluginClient.isHealthy().then(function (healthy) {
|
||||
if (healthy !== usePlugin) {
|
||||
usePlugin = healthy;
|
||||
logger.info({ usePlugin }, healthy ? 'Plugin came online' : 'Plugin went offline — fallback to REST API');
|
||||
}
|
||||
});
|
||||
}, config.plugin.detectIntervalMs || 60000);
|
||||
}
|
||||
|
||||
// StatusWatcher
|
||||
const watcher = new StatusWatcher({
|
||||
transcriptDir: config.transcriptDir,
|
||||
@@ -182,6 +213,7 @@ async function startDaemon() {
|
||||
...globalMetrics,
|
||||
...sharedStatusBox.getMetrics(),
|
||||
activeSessions: activeBoxes.size,
|
||||
pluginEnabled: usePlugin,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -237,9 +269,16 @@ async function startDaemon() {
|
||||
// Create new post if none found
|
||||
if (!postId) {
|
||||
try {
|
||||
const initialText = buildInitialText(agentId, sessionKey);
|
||||
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
|
||||
logger.info({ sessionKey, postId, channelId }, 'Created status box');
|
||||
if (usePlugin && pluginClient) {
|
||||
// Plugin mode: create custom_livestatus post via plugin
|
||||
postId = await pluginClient.createSession(sessionKey, channelId, rootPostId, agentId);
|
||||
logger.info({ sessionKey, postId, channelId, mode: 'plugin' }, 'Created status box via plugin');
|
||||
} else {
|
||||
// REST API fallback: create regular post with formatted text
|
||||
var initialText = buildInitialText(agentId, sessionKey);
|
||||
postId = await sharedStatusBox.createPost(channelId, initialText, rootPostId);
|
||||
logger.info({ sessionKey, postId, channelId, mode: 'rest' }, 'Created status box via REST API');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ sessionKey, err }, 'Failed to create status post');
|
||||
globalMetrics.lastError = err.message;
|
||||
@@ -252,6 +291,7 @@ async function startDaemon() {
|
||||
channelId,
|
||||
agentId,
|
||||
rootPostId,
|
||||
usePlugin: usePlugin && !!pluginClient, // track which mode this session uses
|
||||
children: new Map(),
|
||||
});
|
||||
globalMetrics.activeSessions = activeBoxes.size;
|
||||
@@ -287,17 +327,37 @@ async function startDaemon() {
|
||||
const box = activeBoxes.get(sessionKey);
|
||||
if (!box) {
|
||||
// Sub-agent: update parent
|
||||
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, sessionKey, state, logger);
|
||||
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build status text
|
||||
const text = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
|
||||
sharedStatusBox.updatePost(box.postId, text).catch((err) => {
|
||||
logger.error({ sessionKey, err }, 'Failed to update status post');
|
||||
globalMetrics.lastError = err.message;
|
||||
globalMetrics.updatesFailed++;
|
||||
});
|
||||
if (box.usePlugin && pluginClient) {
|
||||
// Plugin mode: send structured data (WebSocket broadcast, no post edit)
|
||||
pluginClient.updateSession(sessionKey, {
|
||||
status: state.status,
|
||||
lines: state.lines,
|
||||
elapsed_ms: Date.now() - state.startTime,
|
||||
token_count: state.tokenCount || 0,
|
||||
children: (state.children || []).map(function (c) {
|
||||
return { session_key: c.sessionKey, agent_id: c.agentId, status: c.status, lines: c.lines || [], elapsed_ms: Date.now() - (c.startTime || Date.now()), token_count: c.tokenCount || 0 };
|
||||
}),
|
||||
start_time_ms: state.startTime,
|
||||
}).catch(function (err) {
|
||||
// If plugin rejects (e.g. session not found), fall back to REST for this session
|
||||
logger.warn({ sessionKey, err: err.message }, 'Plugin update failed — falling back to REST');
|
||||
box.usePlugin = false;
|
||||
var fallbackText = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
|
||||
sharedStatusBox.updatePost(box.postId, fallbackText).catch(function () {});
|
||||
});
|
||||
} else {
|
||||
// REST API fallback: format text and PUT update post
|
||||
var text = buildStatusText(box, state, activeBoxes, watcher, sessionKey);
|
||||
sharedStatusBox.updatePost(box.postId, text).catch(function (err) {
|
||||
logger.error({ sessionKey, err }, 'Failed to update status post');
|
||||
globalMetrics.lastError = err.message;
|
||||
globalMetrics.updatesFailed++;
|
||||
});
|
||||
}
|
||||
|
||||
// Persist offsets periodically
|
||||
saveOffsets(config.offsetFile, watcher.sessions);
|
||||
@@ -308,7 +368,7 @@ async function startDaemon() {
|
||||
const box = activeBoxes.get(sessionKey);
|
||||
if (!box) {
|
||||
// Sub-agent completed
|
||||
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, sessionKey, state, logger);
|
||||
updateParentWithChild(activeBoxes, watcher, sharedStatusBox, pluginClient, sessionKey, state, logger);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,12 +381,19 @@ async function startDaemon() {
|
||||
|
||||
// Final update with done status
|
||||
const doneState = { ...state, status: 'done' };
|
||||
const text = buildStatusText(box, doneState, activeBoxes, watcher, sessionKey);
|
||||
|
||||
try {
|
||||
await sharedStatusBox.forceFlush(box.postId);
|
||||
await sharedStatusBox.updatePost(box.postId, text);
|
||||
logger.info({ sessionKey, postId: box.postId }, 'Session complete — status box updated');
|
||||
if (box.usePlugin && pluginClient) {
|
||||
// Plugin mode: mark session complete
|
||||
await pluginClient.deleteSession(sessionKey);
|
||||
logger.info({ sessionKey, postId: box.postId, mode: 'plugin' }, 'Session complete via plugin');
|
||||
} else {
|
||||
// REST API fallback
|
||||
var text = buildStatusText(box, doneState, activeBoxes, watcher, sessionKey);
|
||||
await sharedStatusBox.forceFlush(box.postId);
|
||||
await sharedStatusBox.updatePost(box.postId, text);
|
||||
logger.info({ sessionKey, postId: box.postId, mode: 'rest' }, 'Session complete — status box updated');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ sessionKey, err }, 'Failed to update final status');
|
||||
}
|
||||
@@ -396,6 +463,7 @@ async function startDaemon() {
|
||||
// Cleanup
|
||||
await healthServer.stop();
|
||||
sharedStatusBox.destroy();
|
||||
if (pluginClient) pluginClient.destroy();
|
||||
removePidFile(config.pidFile);
|
||||
|
||||
logger.info('Shutdown complete');
|
||||
@@ -461,17 +529,31 @@ function buildStatusText(box, state, activeBoxes, watcher, _sessionKey) {
|
||||
return format({ ...state, children: childStates });
|
||||
}
|
||||
|
||||
function updateParentWithChild(activeBoxes, watcher, statusBox, childKey, childState, logger) {
|
||||
function updateParentWithChild(activeBoxes, watcher, statusBox, pluginClient, childKey, childState, logger) {
|
||||
// Find parent
|
||||
for (const [parentKey, box] of activeBoxes) {
|
||||
for (var entry of activeBoxes.entries()) {
|
||||
var parentKey = entry[0];
|
||||
var box = entry[1];
|
||||
if (box.children && box.children.has(childKey)) {
|
||||
const parentState = watcher.getSessionState(parentKey);
|
||||
var parentState = watcher.getSessionState(parentKey);
|
||||
if (!parentState) return;
|
||||
|
||||
const text = buildStatusText(box, parentState, activeBoxes, watcher, parentKey);
|
||||
statusBox
|
||||
.updatePost(box.postId, text)
|
||||
.catch((err) => logger.error({ parentKey, childKey, err }, 'Failed to update parent'));
|
||||
if (box.usePlugin && pluginClient) {
|
||||
// Plugin mode: send structured update for parent
|
||||
pluginClient.updateSession(parentKey, {
|
||||
status: parentState.status,
|
||||
lines: parentState.lines,
|
||||
elapsed_ms: Date.now() - parentState.startTime,
|
||||
token_count: parentState.tokenCount || 0,
|
||||
children: [],
|
||||
start_time_ms: parentState.startTime,
|
||||
}).catch(function (err) { logger.error({ parentKey, childKey, err: err.message }, 'Failed to update parent via plugin'); });
|
||||
} else {
|
||||
var text = buildStatusText(box, parentState, activeBoxes, watcher, parentKey);
|
||||
statusBox
|
||||
.updatePost(box.postId, text)
|
||||
.catch(function (err) { logger.error({ parentKey, childKey, err }, 'Failed to update parent'); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user