ScuttleBot

feat: topology panel, task channel management, ROE template editor (#115, #114, #118) #115 — Topology management panel: channels tab now shows channel type rules (prefix, autojoin, TTL, supervision) and active provisioned channels. Provision/drop channels directly from the UI. Added ListChannels() to topology Manager and active_channels to GET /v1/topology. #114 — Task channel management: active task channels shown with age, TTL, type, and drop button. Ephemeral channels tracked with provisioning timestamp. #118 — ROE template editor: add roe_templates to policies. UI card in channels tab lets operators define ROE presets (name, channels, permissions, rate limits) applied at agent registration.

lmata 2026-04-05 14:55 trunk
Commit 346af161e1e7f72ab4f430a381b6a80a7091e385b3a75bff7e588846d8e7c902
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
1414
ProvisionChannel(ch topology.ChannelConfig) error
1515
DropChannel(channel string)
1616
Policy() *topology.Policy
1717
GrantAccess(nick, channel, level string)
1818
RevokeAccess(nick, channel string)
19
+ ListChannels() []topology.ChannelInfo
1920
}
2021
2122
type provisionChannelRequest struct {
2223
Name string `json:"name"`
2324
Topic string `json:"topic,omitempty"`
@@ -91,12 +92,13 @@
9192
Ephemeral bool `json:"ephemeral,omitempty"`
9293
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
9394
}
9495
9596
type topologyResponse struct {
96
- StaticChannels []string `json:"static_channels"`
97
- Types []channelTypeInfo `json:"types"`
97
+ StaticChannels []string `json:"static_channels"`
98
+ Types []channelTypeInfo `json:"types"`
99
+ ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
98100
}
99101
100102
// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
101103
// Drops the ChanServ registration of an ephemeral channel.
102104
func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +148,8 @@
146148
}
147149
148150
writeJSON(w, http.StatusOK, topologyResponse{
149151
StaticChannels: staticNames,
150152
Types: typeInfos,
153
+ ActiveChannels: s.topoMgr.ListChannels(),
151154
})
152155
}
153156
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
 
19 }
20
21 type provisionChannelRequest struct {
22 Name string `json:"name"`
23 Topic string `json:"topic,omitempty"`
@@ -91,12 +92,13 @@
91 Ephemeral bool `json:"ephemeral,omitempty"`
92 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
93 }
94
95 type topologyResponse struct {
96 StaticChannels []string `json:"static_channels"`
97 Types []channelTypeInfo `json:"types"`
 
98 }
99
100 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
101 // Drops the ChanServ registration of an ephemeral channel.
102 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +148,8 @@
146 }
147
148 writeJSON(w, http.StatusOK, topologyResponse{
149 StaticChannels: staticNames,
150 Types: typeInfos,
 
151 })
152 }
153
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
@@ -91,12 +92,13 @@
92 Ephemeral bool `json:"ephemeral,omitempty"`
93 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
94 }
95
96 type topologyResponse struct {
97 StaticChannels []string `json:"static_channels"`
98 Types []channelTypeInfo `json:"types"`
99 ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
100 }
101
102 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
103 // Drops the ChanServ registration of an ephemeral channel.
104 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +148,8 @@
148 }
149
150 writeJSON(w, http.StatusOK, topologyResponse{
151 StaticChannels: staticNames,
152 Types: typeInfos,
153 ActiveChannels: s.topoMgr.ListChannels(),
154 })
155 }
156
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -47,10 +47,12 @@
4747
}
4848
4949
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
5050
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
5151
}
52
+
53
+func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
5254
5355
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
5456
type stubProvisioner struct {
5557
accounts map[string]string
5658
}
5759
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -47,10 +47,12 @@
47 }
48
49 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51 }
 
 
52
53 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
54 type stubProvisioner struct {
55 accounts map[string]string
56 }
57
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -47,10 +47,12 @@
47 }
48
49 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51 }
52
53 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
54
55 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
56 type stubProvisioner struct {
57 accounts map[string]string
58 }
59
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
6868
AWSSecretKey string `json:"aws_secret_key,omitempty"`
6969
Allow []string `json:"allow,omitempty"`
7070
Block []string `json:"block,omitempty"`
7171
Default bool `json:"default,omitempty"`
7272
}
73
+
74
+// ROETemplate is a rules-of-engagement template.
75
+type ROETemplate struct {
76
+ Name string `json:"name"`
77
+ Description string `json:"description,omitempty"`
78
+ Channels []string `json:"channels,omitempty"`
79
+ Permissions []string `json:"permissions,omitempty"`
80
+ RateLimit struct {
81
+ MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
82
+ Burst int `json:"burst,omitempty"`
83
+ } `json:"rate_limit,omitempty"`
84
+}
7385
7486
// Policies is the full mutable settings blob, persisted to policies.json.
7587
type Policies struct {
76
- Behaviors []BehaviorConfig `json:"behaviors"`
77
- AgentPolicy AgentPolicy `json:"agent_policy"`
78
- Bridge BridgePolicy `json:"bridge"`
79
- Logging LoggingPolicy `json:"logging"`
80
- LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
88
+ Behaviors []BehaviorConfig `json:"behaviors"`
89
+ AgentPolicy AgentPolicy `json:"agent_policy"`
90
+ Bridge BridgePolicy `json:"bridge"`
91
+ Logging LoggingPolicy `json:"logging"`
92
+ LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
93
+ ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
8194
}
8295
8396
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
8497
var defaultBehaviors = []BehaviorConfig{
8598
{
8699
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
 
 
 
 
 
 
 
 
 
 
 
 
73
74 // Policies is the full mutable settings blob, persisted to policies.json.
75 type Policies struct {
76 Behaviors []BehaviorConfig `json:"behaviors"`
77 AgentPolicy AgentPolicy `json:"agent_policy"`
78 Bridge BridgePolicy `json:"bridge"`
79 Logging LoggingPolicy `json:"logging"`
80 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
 
81 }
82
83 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84 var defaultBehaviors = []BehaviorConfig{
85 {
86
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
73
74 // ROETemplate is a rules-of-engagement template.
75 type ROETemplate struct {
76 Name string `json:"name"`
77 Description string `json:"description,omitempty"`
78 Channels []string `json:"channels,omitempty"`
79 Permissions []string `json:"permissions,omitempty"`
80 RateLimit struct {
81 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
82 Burst int `json:"burst,omitempty"`
83 } `json:"rate_limit,omitempty"`
84 }
85
86 // Policies is the full mutable settings blob, persisted to policies.json.
87 type Policies struct {
88 Behaviors []BehaviorConfig `json:"behaviors"`
89 AgentPolicy AgentPolicy `json:"agent_policy"`
90 Bridge BridgePolicy `json:"bridge"`
91 Logging LoggingPolicy `json:"logging"`
92 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
93 ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
94 }
95
96 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
97 var defaultBehaviors = []BehaviorConfig{
98 {
99
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485485
<button class="sm primary" onclick="quickJoin()">join</button>
486486
</div>
487487
</div>
488488
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489489
</div>
490
+
491
+ <!-- topology panel -->
492
+ <div class="card" id="card-topology">
493
+ <div class="card-header" onclick="toggleCard('card-topology',event)">
494
+ <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495
+ <div class="spacer"></div>
496
+ <div style="display:flex;gap:6px;align-items:center">
497
+ <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498
+ <button class="sm primary" onclick="provisionChannel()">provision</button>
499
+ </div>
500
+ </div>
501
+ <div class="card-body" style="padding:0">
502
+ <div id="topology-types"></div>
503
+ <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504
+ </div>
505
+ </div>
506
+
507
+ <!-- ROE templates -->
508
+ <div class="card" id="card-roe">
509
+ <div class="card-header" onclick="toggleCard('card-roe',event)">
510
+ <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511
+ <div class="spacer"></div>
512
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513
+ </div>
514
+ <div class="card-body">
515
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516
+ <div id="roe-list"></div>
517
+ <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518
+ </div>
519
+ </div>
490520
</div>
491521
</div>
492522
493523
<!-- CHAT -->
494524
<div class="tab-pane" id="pane-chat">
@@ -1726,10 +1756,19 @@
17261756
allChannels = (data.channels || []).sort();
17271757
renderChanList();
17281758
} catch(e) {
17291759
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
17301760
}
1761
+ loadTopology();
1762
+ // Load ROE templates from policies for the ROE card.
1763
+ try {
1764
+ const s = await api('GET', '/v1/settings');
1765
+ if (s && s.policies) {
1766
+ currentPolicies = s.policies;
1767
+ renderROETemplates(s.policies.roe_templates || []);
1768
+ }
1769
+ } catch(e) {}
17311770
}
17321771
17331772
function renderChanList() {
17341773
const q = (document.getElementById('chan-search').value||'').toLowerCase();
17351774
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1803,138 @@
17641803
await loadChanTab();
17651804
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
17661805
} catch(e) { alert('Join failed: '+e.message); }
17671806
}
17681807
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1808
+
1809
+// --- topology panel (#115) + task channels (#114) ---
1810
+async function loadTopology() {
1811
+ try {
1812
+ const data = await api('GET', '/v1/topology');
1813
+ renderTopologyTypes(data.types || []);
1814
+ renderTopologyActive(data.active_channels || [], data.types || []);
1815
+ } catch(e) {
1816
+ document.getElementById('topology-types').innerHTML = '';
1817
+ document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1818
+ }
1819
+}
1820
+
1821
+function renderTopologyTypes(types) {
1822
+ if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1823
+ const rows = types.map(t => {
1824
+ const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1825
+ const tags = [];
1826
+ if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1827
+ if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1828
+ return `<tr>
1829
+ <td><strong>${esc(t.name)}</strong></td>
1830
+ <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1831
+ <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1832
+ <td style="font-size:12px">${ttl}</td>
1833
+ <td>${tags.join(' ')}</td>
1834
+ </tr>`;
1835
+ }).join('');
1836
+ document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1837
+}
1838
+
1839
+function renderTopologyActive(channels, types) {
1840
+ const el = document.getElementById('topology-active');
1841
+ const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1842
+ if (!tasks.length) {
1843
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1844
+ return;
1845
+ }
1846
+ const rows = tasks.map(c => {
1847
+ const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1848
+ const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1849
+ return `<tr>
1850
+ <td><strong>${esc(c.name)}</strong></td>
1851
+ <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1852
+ <td style="font-size:12px">${age}</td>
1853
+ <td style="font-size:12px">${ttl}</td>
1854
+ <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1855
+ </tr>`;
1856
+ }).join('');
1857
+ el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1858
+}
1859
+
1860
+function timeSince(date) {
1861
+ const s = Math.floor((new Date() - date) / 1000);
1862
+ if (s < 60) return s + 's';
1863
+ if (s < 3600) return Math.floor(s/60) + 'm';
1864
+ if (s < 86400) return Math.floor(s/3600) + 'h';
1865
+ return Math.floor(s/86400) + 'd';
1866
+}
1867
+
1868
+async function provisionChannel() {
1869
+ let ch = document.getElementById('provision-channel-input').value.trim();
1870
+ if (!ch) return;
1871
+ if (!ch.startsWith('#')) ch = '#' + ch;
1872
+ try {
1873
+ await api('POST', '/v1/channels', {name: ch});
1874
+ document.getElementById('provision-channel-input').value = '';
1875
+ loadTopology();
1876
+ loadChanTab();
1877
+ } catch(e) { alert('Provision failed: ' + e.message); }
1878
+}
1879
+
1880
+async function dropChannel(ch) {
1881
+ if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1882
+ const slug = ch.replace(/^#/,'');
1883
+ try {
1884
+ await api('DELETE', `/v1/topology/channels/${slug}`);
1885
+ loadTopology();
1886
+ loadChanTab();
1887
+ } catch(e) { alert('Drop failed: ' + e.message); }
1888
+}
1889
+
1890
+// --- ROE template editor (#118) ---
1891
+function renderROETemplates(templates) {
1892
+ const el = document.getElementById('roe-list');
1893
+ if (!templates || !templates.length) {
1894
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1895
+ return;
1896
+ }
1897
+ el.innerHTML = templates.map((t, i) => `
1898
+ <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1899
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1900
+ <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1901
+ <button class="sm danger" onclick="removeROE(${i})">remove</button>
1902
+ </div>
1903
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1904
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1905
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1906
+ </div>
1907
+ <div style="display:flex;gap:10px">
1908
+ <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1909
+ <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1910
+ <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1911
+ </div>
1912
+ </div>
1913
+ `).join('');
1914
+}
1915
+
1916
+function addROETemplate() {
1917
+ if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1918
+ currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1919
+ renderROETemplates(currentPolicies.roe_templates);
1920
+}
1921
+function removeROE(i) {
1922
+ currentPolicies.roe_templates.splice(i, 1);
1923
+ renderROETemplates(currentPolicies.roe_templates);
1924
+}
1925
+function updateROE(i, field, val) {
1926
+ if (field === 'channels' || field === 'permissions') {
1927
+ currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1928
+ } else {
1929
+ currentPolicies.roe_templates[i][field] = val;
1930
+ }
1931
+}
1932
+function updateROERateLimit(i, field, val) {
1933
+ if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1934
+ currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1935
+}
17691936
17701937
// --- chat ---
17711938
let chatChannel = null, chatSSE = null;
17721939
17731940
async function loadChannels() {
17741941
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490 </div>
491 </div>
492
493 <!-- CHAT -->
494 <div class="tab-pane" id="pane-chat">
@@ -1726,10 +1756,19 @@
1726 allChannels = (data.channels || []).sort();
1727 renderChanList();
1728 } catch(e) {
1729 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1730 }
 
 
 
 
 
 
 
 
 
1731 }
1732
1733 function renderChanList() {
1734 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1735 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1803,138 @@
1764 await loadChanTab();
1765 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1766 } catch(e) { alert('Join failed: '+e.message); }
1767 }
1768 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1769
1770 // --- chat ---
1771 let chatChannel = null, chatSSE = null;
1772
1773 async function loadChannels() {
1774
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
490
491 <!-- topology panel -->
492 <div class="card" id="card-topology">
493 <div class="card-header" onclick="toggleCard('card-topology',event)">
494 <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495 <div class="spacer"></div>
496 <div style="display:flex;gap:6px;align-items:center">
497 <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498 <button class="sm primary" onclick="provisionChannel()">provision</button>
499 </div>
500 </div>
501 <div class="card-body" style="padding:0">
502 <div id="topology-types"></div>
503 <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504 </div>
505 </div>
506
507 <!-- ROE templates -->
508 <div class="card" id="card-roe">
509 <div class="card-header" onclick="toggleCard('card-roe',event)">
510 <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511 <div class="spacer"></div>
512 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513 </div>
514 <div class="card-body">
515 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516 <div id="roe-list"></div>
517 <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518 </div>
519 </div>
520 </div>
521 </div>
522
523 <!-- CHAT -->
524 <div class="tab-pane" id="pane-chat">
@@ -1726,10 +1756,19 @@
1756 allChannels = (data.channels || []).sort();
1757 renderChanList();
1758 } catch(e) {
1759 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1760 }
1761 loadTopology();
1762 // Load ROE templates from policies for the ROE card.
1763 try {
1764 const s = await api('GET', '/v1/settings');
1765 if (s && s.policies) {
1766 currentPolicies = s.policies;
1767 renderROETemplates(s.policies.roe_templates || []);
1768 }
1769 } catch(e) {}
1770 }
1771
1772 function renderChanList() {
1773 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1774 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1803,138 @@
1803 await loadChanTab();
1804 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1805 } catch(e) { alert('Join failed: '+e.message); }
1806 }
1807 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1808
1809 // --- topology panel (#115) + task channels (#114) ---
1810 async function loadTopology() {
1811 try {
1812 const data = await api('GET', '/v1/topology');
1813 renderTopologyTypes(data.types || []);
1814 renderTopologyActive(data.active_channels || [], data.types || []);
1815 } catch(e) {
1816 document.getElementById('topology-types').innerHTML = '';
1817 document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1818 }
1819 }
1820
1821 function renderTopologyTypes(types) {
1822 if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1823 const rows = types.map(t => {
1824 const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1825 const tags = [];
1826 if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1827 if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1828 return `<tr>
1829 <td><strong>${esc(t.name)}</strong></td>
1830 <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1831 <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1832 <td style="font-size:12px">${ttl}</td>
1833 <td>${tags.join(' ')}</td>
1834 </tr>`;
1835 }).join('');
1836 document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1837 }
1838
1839 function renderTopologyActive(channels, types) {
1840 const el = document.getElementById('topology-active');
1841 const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1842 if (!tasks.length) {
1843 el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1844 return;
1845 }
1846 const rows = tasks.map(c => {
1847 const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1848 const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1849 return `<tr>
1850 <td><strong>${esc(c.name)}</strong></td>
1851 <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1852 <td style="font-size:12px">${age}</td>
1853 <td style="font-size:12px">${ttl}</td>
1854 <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1855 </tr>`;
1856 }).join('');
1857 el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1858 }
1859
1860 function timeSince(date) {
1861 const s = Math.floor((new Date() - date) / 1000);
1862 if (s < 60) return s + 's';
1863 if (s < 3600) return Math.floor(s/60) + 'm';
1864 if (s < 86400) return Math.floor(s/3600) + 'h';
1865 return Math.floor(s/86400) + 'd';
1866 }
1867
1868 async function provisionChannel() {
1869 let ch = document.getElementById('provision-channel-input').value.trim();
1870 if (!ch) return;
1871 if (!ch.startsWith('#')) ch = '#' + ch;
1872 try {
1873 await api('POST', '/v1/channels', {name: ch});
1874 document.getElementById('provision-channel-input').value = '';
1875 loadTopology();
1876 loadChanTab();
1877 } catch(e) { alert('Provision failed: ' + e.message); }
1878 }
1879
1880 async function dropChannel(ch) {
1881 if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1882 const slug = ch.replace(/^#/,'');
1883 try {
1884 await api('DELETE', `/v1/topology/channels/${slug}`);
1885 loadTopology();
1886 loadChanTab();
1887 } catch(e) { alert('Drop failed: ' + e.message); }
1888 }
1889
1890 // --- ROE template editor (#118) ---
1891 function renderROETemplates(templates) {
1892 const el = document.getElementById('roe-list');
1893 if (!templates || !templates.length) {
1894 el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1895 return;
1896 }
1897 el.innerHTML = templates.map((t, i) => `
1898 <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1899 <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1900 <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1901 <button class="sm danger" onclick="removeROE(${i})">remove</button>
1902 </div>
1903 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1904 <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1905 <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1906 </div>
1907 <div style="display:flex;gap:10px">
1908 <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1909 <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1910 <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1911 </div>
1912 </div>
1913 `).join('');
1914 }
1915
1916 function addROETemplate() {
1917 if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1918 currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1919 renderROETemplates(currentPolicies.roe_templates);
1920 }
1921 function removeROE(i) {
1922 currentPolicies.roe_templates.splice(i, 1);
1923 renderROETemplates(currentPolicies.roe_templates);
1924 }
1925 function updateROE(i, field, val) {
1926 if (field === 'channels' || field === 'permissions') {
1927 currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1928 } else {
1929 currentPolicies.roe_templates[i][field] = val;
1930 }
1931 }
1932 function updateROERateLimit(i, field, val) {
1933 if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1934 currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1935 }
1936
1937 // --- chat ---
1938 let chatChannel = null, chatSSE = null;
1939
1940 async function loadChannels() {
1941
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -297,10 +297,42 @@
297297
298298
func (m *Manager) chanserv(format string, args ...any) {
299299
msg := fmt.Sprintf(format, args...)
300300
m.client.Cmd.Message("ChanServ", msg)
301301
}
302
+
303
+// ChannelInfo describes an active provisioned channel.
304
+type ChannelInfo struct {
305
+ Name string `json:"name"`
306
+ ProvisionedAt time.Time `json:"provisioned_at"`
307
+ Type string `json:"type,omitempty"`
308
+ Ephemeral bool `json:"ephemeral,omitempty"`
309
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
310
+}
311
+
312
+// ListChannels returns all actively provisioned channels.
313
+func (m *Manager) ListChannels() []ChannelInfo {
314
+ m.mu.Lock()
315
+ defer m.mu.Unlock()
316
+ out := make([]ChannelInfo, 0, len(m.channels))
317
+ for _, rec := range m.channels {
318
+ ci := ChannelInfo{
319
+ Name: rec.name,
320
+ ProvisionedAt: rec.provisionedAt,
321
+ }
322
+ if m.policy != nil {
323
+ ci.Type = m.policy.TypeName(rec.name)
324
+ ci.Ephemeral = m.policy.IsEphemeral(rec.name)
325
+ ttl := m.policy.TTLFor(rec.name)
326
+ if ttl > 0 {
327
+ ci.TTLSeconds = int64(ttl.Seconds())
328
+ }
329
+ }
330
+ out = append(out, ci)
331
+ }
332
+ return out
333
+}
302334
303335
// ValidateName checks that a channel name follows scuttlebot conventions.
304336
func ValidateName(name string) error {
305337
if !strings.HasPrefix(name, "#") {
306338
return fmt.Errorf("topology: channel name must start with #: %q", name)
307339
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -297,10 +297,42 @@
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
303 // ValidateName checks that a channel name follows scuttlebot conventions.
304 func ValidateName(name string) error {
305 if !strings.HasPrefix(name, "#") {
306 return fmt.Errorf("topology: channel name must start with #: %q", name)
307
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -297,10 +297,42 @@
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301 }
302
303 // ChannelInfo describes an active provisioned channel.
304 type ChannelInfo struct {
305 Name string `json:"name"`
306 ProvisionedAt time.Time `json:"provisioned_at"`
307 Type string `json:"type,omitempty"`
308 Ephemeral bool `json:"ephemeral,omitempty"`
309 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
310 }
311
312 // ListChannels returns all actively provisioned channels.
313 func (m *Manager) ListChannels() []ChannelInfo {
314 m.mu.Lock()
315 defer m.mu.Unlock()
316 out := make([]ChannelInfo, 0, len(m.channels))
317 for _, rec := range m.channels {
318 ci := ChannelInfo{
319 Name: rec.name,
320 ProvisionedAt: rec.provisionedAt,
321 }
322 if m.policy != nil {
323 ci.Type = m.policy.TypeName(rec.name)
324 ci.Ephemeral = m.policy.IsEphemeral(rec.name)
325 ttl := m.policy.TTLFor(rec.name)
326 if ttl > 0 {
327 ci.TTLSeconds = int64(ttl.Seconds())
328 }
329 }
330 out = append(out, ci)
331 }
332 return out
333 }
334
335 // ValidateName checks that a channel name follows scuttlebot conventions.
336 func ValidateName(name string) error {
337 if !strings.HasPrefix(name, "#") {
338 return fmt.Errorf("topology: channel name must start with #: %q", name)
339

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button