experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(rules): event-dispatch rule engine + tag-actions migration

Lands plan 3d7d9239 stages 1-3.

Stage 1 — rule registry. New `rules` table (schema/v1.json) backs an
event-dispatch surface: each row pairs an event topic + JSON match
predicate with a script id and optional `display` block. Pure-SQL
CRUD lives in rules.ts; tile-ipc exposes list/register/unregister
plus a cross-feature list-affordances. Manifest contributions reconcile
on feature install/load with content-hash ids for stability across
reboots. A drift test asserts no manifest declares both `resident:true`
and a non-empty `rules:[...]`.

Stage 2 — dispatcher. rule-engine.ts subscribes to every concrete
topic referenced by an enabled rule; on a fire, evaluates the
predicate (operators: $eq/$ne/$in/$exists/$startsWith/$endsWith/
$contains/$and/$or/$not, dotted paths, structural object equality,
unknown-op throws) and publishes `scripts:execute:<scriptId>` so
each script can lazy-load via `lazyEvents` on its own topic. Disabled
rules filter at fire time; recursion caps at 8 with `rules:loop-detected`
telemetry. Wildcard rules (`page:*`) are persisted but not yet
subscribed — concrete topics only for now (matcher is tested for
forward compat).

Stage 3 — tag-actions migration (big bang). Pairs are no longer
imperative settings serving `tag-actions:get-all`; each pair is one
source='user' rule with `topic: 'affordance:click'`,
`scriptId: 'tag-swap'`, `match` + `config` + `display`. tag-actions
drops `resident: true`; the old fat background.js is replaced by a
tiny lazy bg tile that wakes on `scripts:execute:tag-swap` and runs
the swap against the datastore. home.js does pair CRUD via
api.rules.register/unregister and migrates from legacy
`feature_settings[tag-actions:data].pairs` on first load. The
shared affordance lib (app/lib/tag-action-affordances.js) reads
api.rules.listAffordances and publishes `affordance:click` on toggle
click. Consumer manifests (lists/search/groups/tags/pagestream) drop
the legacy `tag-actions:*` topics and add `affordance:click` +
`rules:changed` + the `rules` capability. Playwright spec rewritten
to seed a tag-swap rule from the bg window and assert end-to-end.

Tests: 124 unit tests green (rules: 23, rule-engine: 39, drift: 1,
manifest: 42, tile-ipc/gate/feature-installer/registry: 60, plus the
existing trusted-builtin/features-strict suites). tsc clean.

+2434 -700
+113 -180
app/lib/tag-action-affordances.js
··· 1 1 /** 2 - * Tag Action Affordances - Shared module for rendering tag action UI elements on cards. 2 + * Tag Action Affordances — shared module for rendering affordances on 3 + * cards (toggle checkboxes, icon badges) declared by the rule engine. 3 4 * 4 - * Queries the tag-actions extension for applicable affordances given an item's tags, 5 - * and renders the appropriate DOM elements (icon badges, toggle checkboxes, etc.). 5 + * Stage 3 of peek MCP plan 3d7d9239: this module no longer queries the 6 + * tag-actions feature's own pubsub topics. Instead it asks the rule 7 + * engine for "every rule with a non-empty `display` block" via 8 + * api.rules.listAffordances() and renders each row that applies to 9 + * the item's current tags. On click, it publishes `affordance:click` 10 + * — which the rule engine routes to the rule's script. The script 11 + * (e.g. tag-swap, in tag-actions/background.js) does the actual 12 + * datastore work. 6 13 * 7 - * Usage: 8 - * import { createAffordanceElements, resolveAffordances } from 'peek://app/lib/tag-action-affordances.js'; 9 - * 10 - * // Get affordance descriptors for an item 11 - * const affordances = resolveAffordances(itemTags, actionRules); 12 - * 13 - * // Or create DOM elements directly 14 - * const elements = createAffordanceElements(itemId, itemTags, actionRules, api); 14 + * Public surface: 15 + * resolveAffordances(itemTagNames, rules) 16 + * → array of affordance descriptors (icon / toggle) 17 + * createAffordanceElements(itemId, itemTagNames, rules, api, options) 18 + * → DOM container with per-affordance elements 19 + * createActionRulesCache(api) 20 + * → { getRules, isLoaded, destroy } — keeps a fresh local cache via 21 + * api.rules.listAffordances + a rules:changed subscription 15 22 * 16 - * The module is loosely coupled: it receives action rules as data, doesn't import 17 - * the tag-actions extension, and works with any action type the config defines. 23 + * Consumers must declare `rules` in their tile manifest's capabilities. 18 24 */ 19 25 20 26 // ── SVG Icons (subset matching tag-actions icon set) ────────────────────── ··· 24 30 star: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 25 31 flag: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>', 26 32 bookmark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>', 27 - download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', 28 - archive: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>', 29 - clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', 30 - check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>', 31 33 heart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', 32 - pin: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' 33 34 }; 34 35 35 - // Toggle-specific SVGs (checkbox style, matching mobile pattern) 36 36 const TOGGLE_SVGS = { 37 - // Checkbox pair 38 37 unchecked: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>', 39 38 checked: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polyline points="9 11 12 14 22 4"></polyline></svg>', 40 - // Eye pair 41 39 'eye-open': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>', 42 40 'eye-closed': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>', 43 - // Star pair 44 41 'star-empty': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 45 42 'star-filled': '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 46 - // Heart pair 47 43 'heart-empty': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', 48 44 'heart-filled': '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', 49 - // Flag pair 50 45 'flag-empty': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>', 51 46 'flag-filled': '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>', 52 - // Bookmark pair 53 47 'bookmark-empty': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>', 54 - 'bookmark-filled': '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>' 48 + 'bookmark-filled': '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>', 55 49 }; 56 50 57 51 // ── Affordance Resolution ───────────────────────────────────────────────── 58 52 59 53 /** 60 - * Resolve which affordances apply to an item given its tags and the action rules. 54 + * Resolve which affordances apply to an item given its tags and the 55 + * affordance-rules list (each rule has display + match + scriptId). 61 56 * 62 - * @param {string[]} itemTagNames - Array of tag name strings on the item 63 - * @param {object[]} actionRules - Array of tag action rule objects from the extension 64 - * @returns {object[]} Array of affordance descriptors 57 + * Stage 1: only tag-swap rules are recognised (the only kind 58 + * shipping yet). The shape leaves room for other scriptIds — different 59 + * scripts will produce different affordance shapes (icon, toggle, …). 65 60 * 66 - * Affordance descriptor shapes: 67 - * { type: 'icon', icon: string, color: string, tooltip: string } 68 - * { type: 'toggle', checked: boolean, activeTag: string, completedTag: string, 69 - * label: string, icon: string, checkedIcon: string, color: string, checkedColor: string } 61 + * @param {string[]} itemTagNames 62 + * @param {object[]} rules - Array of rule objects from api.rules.listAffordances 63 + * @returns {object[]} affordance descriptors 70 64 */ 71 - export const resolveAffordances = (itemTagNames, actionRules) => { 72 - if (!actionRules || !Array.isArray(actionRules) || actionRules.length === 0) return []; 73 - if (!itemTagNames || itemTagNames.length === 0) return []; 65 + export const resolveAffordances = (itemTagNames, rules) => { 66 + if (!Array.isArray(rules) || rules.length === 0) return []; 67 + if (!Array.isArray(itemTagNames) || itemTagNames.length === 0) return []; 74 68 75 69 const tagSet = new Set(itemTagNames); 76 70 const affordances = []; 77 71 78 - for (const action of actionRules) { 79 - if (!action.enabled) continue; 72 + for (const rule of rules) { 73 + if (!rule.enabled) continue; 80 74 81 - switch (action.actionType) { 82 - case 'icon': { 83 - if (tagSet.has(action.triggerTag)) { 84 - const config = action.actionConfig || {}; 85 - affordances.push({ 86 - type: 'icon', 87 - actionId: action.id, 88 - icon: config.icon || 'star', 89 - color: config.color || '#999999', 90 - tooltip: config.tooltip || action.triggerTag 91 - }); 92 - } 93 - break; 94 - } 75 + // tag-swap is the only Stage-1 affordance kind. Future scriptIds 76 + // (e.g. 'badge', 'open-url') would branch here. 77 + if (rule.scriptId === 'tag-swap') { 78 + const config = rule.config || {}; 79 + const display = rule.display || {}; 80 + const activeTag = config.activeTag; 81 + const completedTag = config.completedTag; 82 + if (!activeTag || !completedTag) continue; 95 83 96 - case 'toggle': { 97 - const config = action.actionConfig || {}; 98 - const activeTag = config.activeTag || action.triggerTag; 99 - const completedTag = config.completedTag || ''; 100 - if (!completedTag) break; 84 + const hasActive = tagSet.has(activeTag); 85 + const hasCompleted = tagSet.has(completedTag); 86 + if (!hasActive && !hasCompleted) continue; 101 87 102 - const hasActive = tagSet.has(activeTag); 103 - const hasCompleted = tagSet.has(completedTag); 104 - if (!hasActive && !hasCompleted) break; 105 - 106 - affordances.push({ 107 - type: 'toggle', 108 - actionId: action.id, 109 - checked: hasCompleted, 110 - activeTag, 111 - completedTag, 112 - label: action.name || activeTag + '/' + completedTag, 113 - icon: config.icon || 'unchecked', 114 - checkedIcon: config.checkedIcon || 'checked', 115 - color: config.color || '#007aff', 116 - checkedColor: config.checkedColor || '#34c759' 117 - }); 118 - break; 119 - } 120 - 121 - // Other action types (publish, open-url, tag) don't produce visual affordances on cards 88 + affordances.push({ 89 + type: 'toggle', 90 + ruleId: rule.id, 91 + checked: hasCompleted, 92 + activeTag, 93 + completedTag, 94 + label: `${activeTag}/${completedTag}`, 95 + icon: display.icon || 'unchecked', 96 + checkedIcon: display.checkedIcon || 'checked', 97 + color: display.color || '#007aff', 98 + checkedColor: display.checkedColor || '#34c759', 99 + }); 122 100 } 123 101 } 124 102 ··· 130 108 /** 131 109 * Create a container with affordance DOM elements for an item. 132 110 * 133 - * @param {string} itemId - The item's ID 134 - * @param {string[]} itemTagNames - Tag names on the item 135 - * @param {object[]} actionRules - Tag action rules from the extension 111 + * @param {string} itemId 112 + * @param {string[]} itemTagNames 113 + * @param {object[]} rules - From api.rules.listAffordances 136 114 * @param {object} api - The window.app API object 137 - * @param {object} [options] - Options 138 - * @param {Function} [options.onToggle] - Callback after a toggle executes: (itemId, affordance) => void 139 - * @returns {HTMLElement|null} A container element with affordance elements, or null if none 115 + * @param {object} [options] 116 + * @param {Function} [options.onToggle] - (itemId, affordance) => void 117 + * @returns {HTMLElement|null} 140 118 */ 141 - export const createAffordanceElements = (itemId, itemTagNames, actionRules, api, options = {}) => { 142 - const affordances = resolveAffordances(itemTagNames, actionRules); 119 + export const createAffordanceElements = (itemId, itemTagNames, rules, api, options = {}) => { 120 + const affordances = resolveAffordances(itemTagNames, rules); 143 121 if (affordances.length === 0) return null; 144 122 145 123 const container = document.createElement('span'); 146 124 container.className = 'tag-action-affordances'; 147 125 container.style.cssText = 'display:inline-flex;align-items:center;gap:4px;flex-shrink:0;'; 148 126 149 - // Stop clicks on the affordance container from propagating to parent card handlers 150 127 container.addEventListener('click', (e) => { 151 128 e.stopPropagation(); 152 129 e.preventDefault(); ··· 155 132 for (const aff of affordances) { 156 133 let el; 157 134 switch (aff.type) { 158 - case 'icon': 159 - el = createIconBadge(aff); 160 - break; 161 135 case 'toggle': 162 136 el = createToggleButton(itemId, aff, api, options); 163 137 break; ··· 169 143 }; 170 144 171 145 /** 172 - * Create an icon badge element for an icon affordance. 173 - */ 174 - const createIconBadge = (aff) => { 175 - const badge = document.createElement('span'); 176 - badge.className = 'tag-action-icon'; 177 - badge.title = aff.tooltip; 178 - badge.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:' + aff.color + ';flex-shrink:0;'; 179 - badge.innerHTML = ICON_SVGS[aff.icon] || ICON_SVGS.star; 180 - // Scale the SVG down 181 - const svg = badge.querySelector('svg'); 182 - if (svg) { 183 - svg.style.width = '14px'; 184 - svg.style.height = '14px'; 185 - } 186 - return badge; 187 - }; 188 - 189 - /** 190 - * Create a toggle button element for a toggle affordance. 191 - * Handles click to swap tags (remove one, add the other) via the datastore API. 146 + * Create a toggle button. On click, publishes `affordance:click` with 147 + * { ruleId, itemId, checked, activeTag, completedTag }. The rule 148 + * engine routes this to the rule's script, which performs the swap. 149 + * 150 + * The visual state updates optimistically — the rule's behaviour is 151 + * deterministic and the script runs in-process, so a failed swap is 152 + * an exceptional condition (it would have logged in the script). 192 153 */ 193 154 const createToggleButton = (itemId, aff, api, options = {}) => { 194 155 const btn = document.createElement('button'); 195 156 btn.className = 'tag-action-toggle ' + (aff.checked ? 'checked' : ''); 196 157 btn.title = aff.label; 197 - btn.dataset.actionId = aff.actionId; 158 + btn.dataset.ruleId = aff.ruleId; 198 159 btn.style.cssText = 199 160 'background:none;border:none;padding:2px;cursor:pointer;' + 200 161 'color:' + (aff.checked ? aff.checkedColor : aff.color) + ';' + 201 162 'flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;' + 202 163 'transition:color 0.15s,opacity 0.15s;opacity:0.85;'; 203 164 204 - // Use toggle-specific SVGs or fall back to icon library 205 165 const iconKey = aff.checked ? aff.checkedIcon : aff.icon; 206 166 btn.innerHTML = TOGGLE_SVGS[iconKey] || ICON_SVGS[iconKey] || TOGGLE_SVGS[aff.checked ? 'checked' : 'unchecked']; 207 167 208 168 btn.addEventListener('mouseenter', () => { btn.style.opacity = '1'; }); 209 169 btn.addEventListener('mouseleave', () => { btn.style.opacity = '0.85'; }); 210 170 211 - btn.addEventListener('click', async (e) => { 171 + btn.addEventListener('click', (e) => { 212 172 e.stopPropagation(); 213 173 e.preventDefault(); 214 - 215 - // Prevent double-click 216 174 if (btn.dataset.executing === 'true') return; 217 175 btn.dataset.executing = 'true'; 218 176 btn.style.opacity = '0.4'; 219 177 220 - try { 221 - const removeTag = aff.checked ? aff.completedTag : aff.activeTag; 222 - const addTag = aff.checked ? aff.activeTag : aff.completedTag; 223 - 224 - // Get or create the tag to add 225 - const addTagResult = await api.datastore.getOrCreateTag(addTag); 226 - if (!addTagResult.success) { 227 - console.error('[tag-action-affordances] Failed to get/create tag:', addTag, addTagResult.error); 228 - return; 229 - } 230 - 231 - // Get tag ID of the tag to remove 232 - const removeTagResult = await api.datastore.getOrCreateTag(removeTag); 233 - if (!removeTagResult.success) { 234 - console.error('[tag-action-affordances] Failed to get tag for removal:', removeTag, removeTagResult.error); 235 - return; 236 - } 237 - 238 - // Remove old tag, add new tag 239 - await api.datastore.untagItem(itemId, removeTagResult.data.tag.id); 240 - await api.datastore.tagItem(itemId, addTagResult.data.tag.id); 178 + const nowChecked = !aff.checked; 179 + api.pubsub.publish('affordance:click', { 180 + ruleId: aff.ruleId, 181 + itemId, 182 + checked: nowChecked, 183 + activeTag: aff.activeTag, 184 + completedTag: aff.completedTag, 185 + }); 241 186 242 - // Update visual state immediately 243 - const nowChecked = !aff.checked; 244 - btn.className = 'tag-action-toggle ' + (nowChecked ? 'checked' : ''); 245 - btn.style.color = nowChecked ? aff.checkedColor : aff.color; 246 - const newIconKey = nowChecked ? aff.checkedIcon : aff.icon; 247 - btn.innerHTML = TOGGLE_SVGS[newIconKey] || ICON_SVGS[newIconKey] || TOGGLE_SVGS[nowChecked ? 'checked' : 'unchecked']; 248 - aff.checked = nowChecked; 187 + // Optimistic visual update. 188 + btn.className = 'tag-action-toggle ' + (nowChecked ? 'checked' : ''); 189 + btn.style.color = nowChecked ? aff.checkedColor : aff.color; 190 + const newIconKey = nowChecked ? aff.checkedIcon : aff.icon; 191 + btn.innerHTML = TOGGLE_SVGS[newIconKey] || ICON_SVGS[newIconKey] || TOGGLE_SVGS[nowChecked ? 'checked' : 'unchecked']; 192 + aff.checked = nowChecked; 249 193 250 - // Notify caller 251 - if (options.onToggle) { 252 - options.onToggle(itemId, aff); 253 - } 254 - } catch (err) { 255 - console.error('[tag-action-affordances] Toggle failed:', err); 256 - } finally { 257 - btn.dataset.executing = 'false'; 258 - btn.style.opacity = '0.85'; 194 + if (options.onToggle) { 195 + try { options.onToggle(itemId, aff); } 196 + catch (err) { console.error('[tag-action-affordances] onToggle threw:', err); } 259 197 } 198 + 199 + btn.dataset.executing = 'false'; 200 + btn.style.opacity = '0.85'; 260 201 }); 261 202 262 203 return btn; ··· 265 206 // ── Action Rules Cache ──────────────────────────────────────────────────── 266 207 267 208 /** 268 - * Subscribe to tag action rules from the extension and maintain a local cache. 269 - * Returns an object with `getRules()` to access cached rules, and `destroy()` to clean up. 209 + * Subscribe to affordance rules and maintain a local cache. Rebuilds 210 + * when `rules:changed` fires. 270 211 * 271 212 * @param {object} api - The window.app API object 272 - * @returns {{ getRules: () => object[], destroy: () => void }} 213 + * @returns {{ getRules: () => object[], isLoaded: () => boolean, destroy: () => void }} 273 214 */ 274 215 export const createActionRulesCache = (api) => { 275 216 let rules = []; 276 217 let loaded = false; 277 218 278 - // Listen for rules broadcast from the tag-actions extension 279 - const unsubRules = api.subscribe('tag-actions:get-all:response', (msg) => { 280 - if (msg.success && Array.isArray(msg.data)) { 281 - rules = msg.data; 282 - loaded = true; 219 + const refresh = async () => { 220 + try { 221 + const res = await api.rules.listAffordances(); 222 + if (res && Array.isArray(res.rules)) { 223 + rules = res.rules; 224 + loaded = true; 225 + } 226 + } catch (err) { 227 + console.error('[tag-action-affordances] refresh failed:', err); 283 228 } 284 - }); 285 - 286 - // Listen for changes (create/update/delete) 287 - const unsubCreate = api.subscribe('tag-actions:create:response', () => { 288 - api.publish('tag-actions:get-all', {}); 289 - }); 290 - 291 - const unsubUpdate = api.subscribe('tag-actions:update:response', () => { 292 - api.publish('tag-actions:get-all', {}); 293 - }); 229 + }; 294 230 295 - const unsubDelete = api.subscribe('tag-actions:delete:response', () => { 296 - api.publish('tag-actions:get-all', {}); 297 - }); 231 + // Initial fetch. 232 + refresh(); 298 233 299 - // Initial fetch 300 - api.publish('tag-actions:get-all', {}); 234 + // Pubsub subscribe returns an unsubscribe function in this codebase. 235 + const unsub = api.pubsub.subscribe('rules:changed', () => { refresh(); }); 301 236 302 237 return { 303 238 getRules: () => rules, 304 239 isLoaded: () => loaded, 305 240 destroy: () => { 306 - if (unsubRules) unsubRules(); 307 - if (unsubCreate) unsubCreate(); 308 - if (unsubUpdate) unsubUpdate(); 309 - if (unsubDelete) unsubDelete(); 310 - } 241 + try { if (typeof unsub === 'function') unsub(); } 242 + catch { /* swallow */ } 243 + }, 311 244 }; 312 245 };
+26
backend/electron/datastore.ts
··· 319 319 disabledAt INTEGER -- null = enabled 320 320 ); 321 321 CREATE INDEX IF NOT EXISTS idx_manifest_cache_disabled ON manifest_cache(disabledAt); 322 + 323 + -- rules: declarative event-dispatch subscribers. Each row binds an 324 + -- event topic + match predicate to a script (executed lazily on match). 325 + -- Drives the rule engine that lets features participate in event 326 + -- pipelines without staying resident. See peek MCP plan 3d7d9239. 327 + -- Mirrors schema/v1.json — keep the two definitions in sync (this 328 + -- file is hand-maintained, not generated; the generated SQL lives at 329 + -- schema/generated/sqlite-full.sql for documentation only). 330 + CREATE TABLE IF NOT EXISTS rules ( 331 + id TEXT PRIMARY KEY NOT NULL, 332 + featureId TEXT NOT NULL, 333 + topic TEXT NOT NULL, 334 + match TEXT DEFAULT '{}', 335 + scriptId TEXT NOT NULL, 336 + config TEXT DEFAULT '{}', 337 + display TEXT DEFAULT '', 338 + enabled INTEGER DEFAULT 1, 339 + source TEXT NOT NULL CHECK(source IN ('manifest', 'user', 'runtime')), 340 + ordering INTEGER DEFAULT 0, 341 + createdAt INTEGER NOT NULL, 342 + updatedAt INTEGER NOT NULL 343 + ); 344 + CREATE INDEX IF NOT EXISTS idx_rules_topic ON rules(topic); 345 + CREATE INDEX IF NOT EXISTS idx_rules_featureId ON rules(featureId); 346 + CREATE INDEX IF NOT EXISTS idx_rules_source ON rules(source); 347 + CREATE INDEX IF NOT EXISTS idx_rules_enabled ON rules(enabled); 322 348 `; 323 349 324 350 // Module state
+47
backend/electron/feature-startup.ts
··· 19 19 import * as manifestCache from './manifest-cache.js'; 20 20 import * as tileLifecycle from './tile-lifecycle.js'; 21 21 import { isEphemeralProfile } from './config.js'; 22 + import { getDb } from './datastore.js'; 23 + import { syncManifestRules, deleteRulesByFeature, type RulesDb, type ManifestRule } from './rules.js'; 24 + import type { TileRule } from './tile-manifest.js'; 22 25 23 26 // ─── Types ────────────────────────────────────────────────────────── 24 27 ··· 87 90 } 88 91 } 89 92 93 + // ─── rules bridge ─────────────────────────────────────────────────── 94 + 95 + /** 96 + * Reconcile the `rules` table with the feature's manifest.rules 97 + * declaration. Called from the registry listener so that an install / 98 + * update / re-register all converge to the same desired state without 99 + * each caller having to know about the rules table. 100 + * 101 + * Errors are swallowed with a warning — a feature whose rules fail to 102 + * sync should not block its tile lifecycle, and the next reconcile 103 + * (next boot or next install) will heal any drift. 104 + */ 105 + function reconcileRulesForEntry(entry: FeatureRegistryEntry): void { 106 + try { 107 + const row = manifestCache.get(entry.id); 108 + const declared = (row?.manifest?.rules as TileRule[] | undefined) ?? []; 109 + const manifestRules: ManifestRule[] = declared.map(r => ({ 110 + topic: r.topic, 111 + scriptId: r.scriptId, 112 + match: r.match, 113 + config: r.config, 114 + display: r.display ?? null, 115 + enabled: r.enabled, 116 + ordering: r.ordering, 117 + })); 118 + const db = getDb() as unknown as RulesDb; 119 + syncManifestRules(db, entry.id, manifestRules, Date.now()); 120 + } catch (err) { 121 + console.warn(`[feature-startup] rules reconcile failed for ${entry.id}:`, err); 122 + } 123 + } 124 + 125 + function dropManifestRulesForFeature(id: string): void { 126 + try { 127 + const db = getDb() as unknown as RulesDb; 128 + deleteRulesByFeature(db, id); 129 + } catch (err) { 130 + console.warn(`[feature-startup] rules cleanup failed for ${id}:`, err); 131 + } 132 + } 133 + 90 134 // ─── tile-lifecycle bridge ────────────────────────────────────────── 91 135 92 136 /** ··· 142 186 // manifest to enumerate tile entries. 143 187 upsertManifestCacheForEntry(entry); 144 188 fireFsmTriggerForFeature(entry, tileLifecycle.TRIGGERS.INSTALL); 189 + reconcileRulesForEntry(entry); 145 190 }, 146 191 onUnregister: (id) => { 147 192 // Transition first while the cache row still exists (the FSM ··· 159 204 } 160 205 try { manifestCache.remove(id); } 161 206 catch (err) { console.warn(`[feature-startup] manifest-cache remove failed for ${id}:`, err); } 207 + dropManifestRulesForFeature(id); 162 208 }, 163 209 onDisableChange: (id, disabled) => { 164 210 try { manifestCache.setDisabled(id, disabled); } ··· 195 241 } 196 242 } 197 243 fireFsmTriggerForFeature(entry, tileLifecycle.TRIGGERS.INSTALL); 244 + reconcileRulesForEntry(entry); 198 245 } 199 246 200 247 // 2. Sync builtins
+26 -1
backend/electron/main.ts
··· 25 25 import { installLoadOnDispatchHook } from './tile-lazy.js'; 26 26 import { initTray } from './tray.js'; 27 27 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 28 - import { publish, subscribe, setPubsubBroadcaster, getSystemAddress } from './pubsub.js'; 28 + import { publish, subscribe, unsubscribe, setPubsubBroadcaster, getSystemAddress } from './pubsub.js'; 29 + import { createRuleEngine, type RuleEngine } from './rule-engine.js'; 30 + import { listRules as rulesList, type RulesDb } from './rules.js'; 29 31 import { installDirectSendGuard, installOffPathWindowGuard, unguardedWebContentsSend } from './tile-ipc-gate.js'; 30 32 import { WEB_CORE_ADDRESS, isTestProfile, isDevProfile, isEphemeralProfile, isHeadless, getProfile, setTilePreloadPath, getTilePreloadPath, DEBUG } from './config.js'; 31 33 import { getSystemThemeBackgroundColor } from './windows.js'; ··· 155 157 // Register custom protocol scheme (must be before app.ready) 156 158 registerScheme(); 157 159 } 160 + 161 + // Rule engine singleton — created during initialize() after the 162 + // datastore + features are up. Held at module scope so it stays alive 163 + // for the process lifetime. 164 + let ruleEngine: RuleEngine | null = null; 158 165 159 166 /** 160 167 * Initialize the application ··· 802 809 DEBUG && console.log(`[feature] Feature startup: ${loadedTileIds.size} tiles loaded`); 803 810 } catch (err) { 804 811 console.error('[feature] Feature startup failed:', err); 812 + } 813 + 814 + // ── Rule Engine ── 815 + // Stage 2 of peek MCP plan 3d7d9239. The engine subscribes to every 816 + // concrete topic referenced by an enabled rule and dispatches 817 + // matching events to scripts:execute. Created after feature-startup 818 + // so manifest-declared rules are already reconciled into the table. 819 + try { 820 + ruleEngine = createRuleEngine({ 821 + pubsub: { 822 + publish: (source, topic, msg) => publish(source, topic, msg), 823 + subscribe: (source, topic, cb) => subscribe(source, topic, cb), 824 + unsubscribe: (source, topic) => unsubscribe(source, topic), 825 + }, 826 + listEnabledRules: () => rulesList(getDb() as unknown as RulesDb, { enabled: true }), 827 + }); 828 + } catch (err) { 829 + console.error('[rule-engine] init failed:', err); 805 830 } 806 831 807 832 // Phase 1: Early
+412
backend/electron/rule-engine.test.ts
··· 1 + /** 2 + * Unit tests for rule-engine.ts (Stage 2 of peek MCP plan 3d7d9239). 3 + * 4 + * Three test groups mirror the module's three layers: 5 + * - evaluatePredicate — pure predicate evaluation matrix 6 + * - topicMatches — exact + trailing-wildcard match 7 + * - createRuleEngine — dispatcher behavior with a fake pubsub 8 + * 9 + * Runs under Electron-as-node like the rest of the backend unit 10 + * suite. No SQL: the engine accepts an injected `listEnabledRules`, 11 + * so tests just hand it an array. 12 + */ 13 + 14 + import { describe, it } from 'node:test'; 15 + import * as assert from 'node:assert'; 16 + 17 + import { 18 + evaluatePredicate, 19 + topicMatches, 20 + isWildcardTopic, 21 + createRuleEngine, 22 + type RuleEnginePubsub, 23 + type RuleLike, 24 + } from './rule-engine.js'; 25 + 26 + // ─── Predicate ────────────────────────────────────────────────────── 27 + 28 + describe('evaluatePredicate: empty / trivial', () => { 29 + it('empty {} matches anything', () => { 30 + assert.strictEqual(evaluatePredicate({}, { x: 1 }), true); 31 + assert.strictEqual(evaluatePredicate({}, null), true); 32 + assert.strictEqual(evaluatePredicate({}, 42), true); 33 + }); 34 + 35 + it('throws on array predicate', () => { 36 + assert.throws(() => evaluatePredicate([1, 2], {}), /array/); 37 + }); 38 + }); 39 + 40 + describe('evaluatePredicate: shorthand $eq', () => { 41 + it('matches when field equals value', () => { 42 + assert.strictEqual(evaluatePredicate({ tagName: 'todo' }, { tagName: 'todo' }), true); 43 + }); 44 + it('rejects when field differs', () => { 45 + assert.strictEqual(evaluatePredicate({ tagName: 'todo' }, { tagName: 'done' }), false); 46 + }); 47 + it('rejects when field is missing', () => { 48 + assert.strictEqual(evaluatePredicate({ tagName: 'todo' }, {}), false); 49 + }); 50 + }); 51 + 52 + describe('evaluatePredicate: dotted path', () => { 53 + it('navigates nested objects', () => { 54 + assert.strictEqual( 55 + evaluatePredicate({ 'item.url': 'https://x.dev' }, { item: { url: 'https://x.dev' } }), 56 + true, 57 + ); 58 + }); 59 + it('returns undefined on broken path (no match)', () => { 60 + assert.strictEqual( 61 + evaluatePredicate({ 'item.url': 'x' }, { item: null }), 62 + false, 63 + ); 64 + }); 65 + }); 66 + 67 + describe('evaluatePredicate: operators', () => { 68 + it('$eq', () => { 69 + assert.strictEqual(evaluatePredicate({ x: { $eq: 1 } }, { x: 1 }), true); 70 + assert.strictEqual(evaluatePredicate({ x: { $eq: 1 } }, { x: 2 }), false); 71 + }); 72 + it('$ne', () => { 73 + assert.strictEqual(evaluatePredicate({ x: { $ne: 1 } }, { x: 2 }), true); 74 + assert.strictEqual(evaluatePredicate({ x: { $ne: 1 } }, { x: 1 }), false); 75 + }); 76 + it('$in', () => { 77 + assert.strictEqual(evaluatePredicate({ x: { $in: [1, 2] } }, { x: 2 }), true); 78 + assert.strictEqual(evaluatePredicate({ x: { $in: [1, 2] } }, { x: 3 }), false); 79 + }); 80 + it('$exists', () => { 81 + assert.strictEqual(evaluatePredicate({ x: { $exists: true } }, { x: 1 }), true); 82 + assert.strictEqual(evaluatePredicate({ x: { $exists: true } }, {}), false); 83 + assert.strictEqual(evaluatePredicate({ x: { $exists: false } }, {}), true); 84 + }); 85 + it('$startsWith / $endsWith / $contains', () => { 86 + assert.strictEqual(evaluatePredicate({ s: { $startsWith: 'foo' } }, { s: 'foobar' }), true); 87 + assert.strictEqual(evaluatePredicate({ s: { $startsWith: 'foo' } }, { s: 'barfoo' }), false); 88 + assert.strictEqual(evaluatePredicate({ s: { $endsWith: 'bar' } }, { s: 'foobar' }), true); 89 + assert.strictEqual(evaluatePredicate({ s: { $contains: 'oob' } }, { s: 'foobar' }), true); 90 + assert.strictEqual(evaluatePredicate({ s: { $contains: 'zzz' } }, { s: 'foobar' }), false); 91 + }); 92 + it('string ops return false on non-string actuals (no throw)', () => { 93 + assert.strictEqual(evaluatePredicate({ s: { $startsWith: 'foo' } }, { s: 42 }), false); 94 + assert.strictEqual(evaluatePredicate({ s: { $contains: 'x' } }, {}), false); 95 + }); 96 + }); 97 + 98 + describe('evaluatePredicate: combinators', () => { 99 + it('$and', () => { 100 + const p = { $and: [{ a: 1 }, { b: 2 }] }; 101 + assert.strictEqual(evaluatePredicate(p, { a: 1, b: 2 }), true); 102 + assert.strictEqual(evaluatePredicate(p, { a: 1, b: 3 }), false); 103 + }); 104 + it('$or', () => { 105 + const p = { $or: [{ a: 1 }, { b: 2 }] }; 106 + assert.strictEqual(evaluatePredicate(p, { a: 1, b: 9 }), true); 107 + assert.strictEqual(evaluatePredicate(p, { a: 9, b: 2 }), true); 108 + assert.strictEqual(evaluatePredicate(p, { a: 9, b: 9 }), false); 109 + }); 110 + it('$not', () => { 111 + assert.strictEqual(evaluatePredicate({ $not: { a: 1 } }, { a: 2 }), true); 112 + assert.strictEqual(evaluatePredicate({ $not: { a: 1 } }, { a: 1 }), false); 113 + }); 114 + it('nested combinators', () => { 115 + const p = { $and: [{ kind: 'url' }, { $or: [{ host: 'x.dev' }, { host: 'y.dev' }] }] }; 116 + assert.strictEqual(evaluatePredicate(p, { kind: 'url', host: 'x.dev' }), true); 117 + assert.strictEqual(evaluatePredicate(p, { kind: 'url', host: 'z.dev' }), false); 118 + assert.strictEqual(evaluatePredicate(p, { kind: 'page', host: 'x.dev' }), false); 119 + }); 120 + }); 121 + 122 + describe('evaluatePredicate: error cases', () => { 123 + it('throws on unknown top-level operator', () => { 124 + assert.throws(() => evaluatePredicate({ $bogus: 1 }, {}), /unknown predicate operator/); 125 + }); 126 + it('throws on unknown field operator', () => { 127 + assert.throws(() => evaluatePredicate({ x: { $bogus: 1 } }, { x: 1 }), /unknown predicate operator/); 128 + }); 129 + it('throws on mixed operator + literal keys in a clause', () => { 130 + assert.throws(() => evaluatePredicate({ x: { $eq: 1, foo: 2 } }, { x: 1 }), /mixed/); 131 + }); 132 + it('throws on $and with non-array', () => { 133 + assert.throws(() => evaluatePredicate({ $and: { a: 1 } }, {}), /\$and/); 134 + }); 135 + }); 136 + 137 + describe('evaluatePredicate: literal-object equality', () => { 138 + it('matches when nested object structurally equals expected', () => { 139 + assert.strictEqual( 140 + evaluatePredicate({ meta: { kind: 'url', host: 'x' } }, { meta: { kind: 'url', host: 'x' } }), 141 + true, 142 + ); 143 + assert.strictEqual( 144 + evaluatePredicate({ meta: { kind: 'url', host: 'x' } }, { meta: { kind: 'url', host: 'y' } }), 145 + false, 146 + ); 147 + }); 148 + }); 149 + 150 + // ─── Topic matcher ────────────────────────────────────────────────── 151 + 152 + describe('topicMatches', () => { 153 + it('exact match', () => { 154 + assert.strictEqual(topicMatches('tag:added', 'tag:added'), true); 155 + assert.strictEqual(topicMatches('tag:added', 'tag:removed'), false); 156 + }); 157 + it('trailing wildcard matches one segment', () => { 158 + assert.strictEqual(topicMatches('page:*', 'page:loaded'), true); 159 + }); 160 + it('trailing wildcard matches multiple segments', () => { 161 + assert.strictEqual(topicMatches('page:*', 'page:nav:back'), true); 162 + }); 163 + it('wildcard does NOT match the bare prefix', () => { 164 + assert.strictEqual(topicMatches('page:*', 'page:'), false); 165 + assert.strictEqual(topicMatches('page:*', 'page'), false); 166 + }); 167 + it('wildcard does NOT match a different namespace', () => { 168 + assert.strictEqual(topicMatches('page:*', 'tag:added'), false); 169 + }); 170 + it('isWildcardTopic', () => { 171 + assert.strictEqual(isWildcardTopic('page:*'), true); 172 + assert.strictEqual(isWildcardTopic('page:loaded'), false); 173 + }); 174 + }); 175 + 176 + // ─── Dispatcher ───────────────────────────────────────────────────── 177 + 178 + interface FakeMsg { source: string; topic: string; msg: unknown } 179 + 180 + function makeFakePubsub(): RuleEnginePubsub & { 181 + published: FakeMsg[]; 182 + subs: Map<string, Map<string, (m: unknown) => void>>; 183 + emit(topic: string, msg: unknown): void; 184 + } { 185 + const subs = new Map<string, Map<string, (m: unknown) => void>>(); 186 + const published: FakeMsg[] = []; 187 + return { 188 + published, 189 + subs, 190 + publish(source: string, topic: string, msg: unknown) { 191 + published.push({ source, topic, msg }); 192 + // Deliver to subscribers — real pubsub does this; tests rely on 193 + // it to model end-to-end loops. 194 + const m = subs.get(topic); 195 + if (m) for (const [, cb] of m) cb(msg); 196 + }, 197 + subscribe(source: string, topic: string, cb: (m: unknown) => void) { 198 + let m = subs.get(topic); 199 + if (!m) { m = new Map(); subs.set(topic, m); } 200 + m.set(source, cb); 201 + }, 202 + unsubscribe(source: string, topic: string) { 203 + const m = subs.get(topic); 204 + if (!m) return false; 205 + return m.delete(source); 206 + }, 207 + /** Test helper: deliver an event to all subscribers of `topic`. */ 208 + emit(topic: string, msg: unknown) { 209 + const m = subs.get(topic); 210 + if (!m) return; 211 + for (const [, cb] of m) cb(msg); 212 + }, 213 + }; 214 + } 215 + 216 + function makeRule(o: Partial<RuleLike> = {}): RuleLike { 217 + return { 218 + id: 'r1', 219 + featureId: 'f', 220 + topic: 'tag:added', 221 + match: {}, 222 + scriptId: 'script-1', 223 + config: {}, 224 + enabled: true, 225 + ordering: 0, 226 + ...o, 227 + }; 228 + } 229 + 230 + describe('rule-engine: dispatcher subscribes to concrete topics', () => { 231 + it('rebuild() subscribes to each unique concrete topic', () => { 232 + const pubsub = makeFakePubsub(); 233 + const rules = [ 234 + makeRule({ id: 'a', topic: 'tag:added' }), 235 + makeRule({ id: 'b', topic: 'tag:removed' }), 236 + makeRule({ id: 'c', topic: 'tag:added' }), // duplicate topic 237 + ]; 238 + createRuleEngine({ pubsub, listEnabledRules: () => rules }); 239 + assert.ok(pubsub.subs.has('tag:added')); 240 + assert.ok(pubsub.subs.has('tag:removed')); 241 + // also rules:changed is subscribed 242 + assert.ok(pubsub.subs.has('rules:changed')); 243 + }); 244 + 245 + it('does NOT subscribe to wildcard rule topics (Stage 2 limitation)', () => { 246 + const pubsub = makeFakePubsub(); 247 + createRuleEngine({ 248 + pubsub, 249 + listEnabledRules: () => [makeRule({ id: 'w', topic: 'page:*' })], 250 + }); 251 + assert.strictEqual(pubsub.subs.has('page:*'), false); 252 + assert.strictEqual(pubsub.subs.has('page'), false); 253 + }); 254 + }); 255 + 256 + describe('rule-engine: dispatch fires scripts:execute', () => { 257 + it('publishes scripts:execute with the rule scriptId, event, and config', () => { 258 + const pubsub = makeFakePubsub(); 259 + const engine = createRuleEngine({ 260 + pubsub, 261 + listEnabledRules: () => [makeRule({ 262 + scriptId: 'tag-swap', 263 + config: { activeTag: 'todo', completedTag: 'done' }, 264 + })], 265 + }); 266 + engine.dispatch('tag:added', { tagName: 'todo', itemId: 'item-1' }); 267 + 268 + const exec = pubsub.published.find(p => p.topic === 'scripts:execute:tag-swap'); 269 + assert.ok(exec, 'scripts:execute was published'); 270 + assert.deepStrictEqual(exec.msg, { 271 + scriptId: 'tag-swap', 272 + context: { 273 + event: { tagName: 'todo', itemId: 'item-1' }, 274 + config: { activeTag: 'todo', completedTag: 'done' }, 275 + ruleId: 'r1', 276 + featureId: 'f', 277 + }, 278 + }); 279 + }); 280 + 281 + it('skips when predicate does not match', () => { 282 + const pubsub = makeFakePubsub(); 283 + const engine = createRuleEngine({ 284 + pubsub, 285 + listEnabledRules: () => [makeRule({ match: { tagName: 'todo' } })], 286 + }); 287 + engine.dispatch('tag:added', { tagName: 'other' }); 288 + assert.strictEqual(pubsub.published.filter(p => p.topic === 'scripts:execute:tag-swap').length, 0); 289 + }); 290 + 291 + it('skips disabled rules at fire time (filtered, not unsubscribed)', () => { 292 + const pubsub = makeFakePubsub(); 293 + const engine = createRuleEngine({ 294 + pubsub, 295 + listEnabledRules: () => [makeRule({ enabled: false })], 296 + }); 297 + engine.dispatch('tag:added', { tagName: 'todo' }); 298 + assert.strictEqual( 299 + pubsub.published.filter(p => p.topic.startsWith('scripts:execute:')).length, 300 + 0, 301 + ); 302 + }); 303 + 304 + it('orders by rule.ordering ascending', () => { 305 + const pubsub = makeFakePubsub(); 306 + const engine = createRuleEngine({ 307 + pubsub, 308 + listEnabledRules: () => [ 309 + makeRule({ id: 'late', scriptId: 's-late', ordering: 5 }), 310 + makeRule({ id: 'early', scriptId: 's-early', ordering: 1 }), 311 + ], 312 + }); 313 + engine.dispatch('tag:added', {}); 314 + const execs = pubsub.published.filter(p => p.topic.startsWith('scripts:execute:')); 315 + assert.strictEqual(execs.length, 2); 316 + assert.strictEqual(execs[0].topic, 'scripts:execute:s-early'); 317 + assert.strictEqual(execs[1].topic, 'scripts:execute:s-late'); 318 + }); 319 + 320 + it('fires for the right rules when topic has multiple subscribers', () => { 321 + const pubsub = makeFakePubsub(); 322 + const engine = createRuleEngine({ 323 + pubsub, 324 + listEnabledRules: () => [ 325 + makeRule({ id: 'a', topic: 'tag:added', scriptId: 's-a' }), 326 + makeRule({ id: 'b', topic: 'tag:added', scriptId: 's-b' }), 327 + makeRule({ id: 'c', topic: 'tag:removed', scriptId: 's-c' }), 328 + ], 329 + }); 330 + engine.dispatch('tag:added', {}); 331 + const topics = pubsub.published 332 + .filter(p => p.topic.startsWith('scripts:execute:')) 333 + .map(p => p.topic) 334 + .sort(); 335 + assert.deepStrictEqual(topics, ['scripts:execute:s-a', 'scripts:execute:s-b']); 336 + }); 337 + }); 338 + 339 + describe('rule-engine: rules:changed triggers rebuild', () => { 340 + it('subscribing to a new rule\'s topic happens after rules:changed fires', () => { 341 + const pubsub = makeFakePubsub(); 342 + let rules: RuleLike[] = [makeRule({ topic: 'tag:added' })]; 343 + createRuleEngine({ pubsub, listEnabledRules: () => rules }); 344 + assert.ok(pubsub.subs.has('tag:added')); 345 + assert.strictEqual(pubsub.subs.has('tag:removed'), false); 346 + 347 + rules = [makeRule({ topic: 'tag:removed' })]; 348 + pubsub.emit('rules:changed', {}); 349 + 350 + assert.ok(pubsub.subs.has('tag:removed')); 351 + // old subscription removed 352 + const oldSubs = pubsub.subs.get('tag:added'); 353 + assert.ok(!oldSubs || oldSubs.size === 0); 354 + }); 355 + }); 356 + 357 + describe('rule-engine: end-to-end via real subscription path', () => { 358 + it('publish→dispatch via the subscribed callback fires scripts:execute', () => { 359 + const pubsub = makeFakePubsub(); 360 + createRuleEngine({ 361 + pubsub, 362 + listEnabledRules: () => [makeRule({ 363 + topic: 'tag:added', 364 + match: { tagName: 'todo' }, 365 + scriptId: 'tag-swap', 366 + })], 367 + }); 368 + pubsub.emit('tag:added', { tagName: 'todo', itemId: 'i1' }); 369 + assert.ok(pubsub.published.some(p => p.topic === 'scripts:execute:tag-swap')); 370 + }); 371 + }); 372 + 373 + describe('rule-engine: recursion cap', () => { 374 + it('beyond maxDepth, emits rules:loop-detected and drops the deepest fire', () => { 375 + const pubsub = makeFakePubsub(); 376 + const engine = createRuleEngine({ 377 + pubsub, 378 + listEnabledRules: () => [makeRule({ topic: 'loop:tick', scriptId: 'noop' })], 379 + maxDepth: 3, 380 + }); 381 + 382 + // Simulate a "script" that re-publishes the same event on every 383 + // scripts:execute — fake pubsub.publish delivers synchronously, so 384 + // the engine's own subscription on loop:tick re-enters dispatch. 385 + pubsub.subscribe('test-script', 'scripts:execute:noop', () => { 386 + pubsub.publish('test-script', 'loop:tick', {}); 387 + }); 388 + 389 + engine.dispatch('loop:tick', {}); 390 + 391 + const loopHits = pubsub.published.filter(p => p.topic === 'rules:loop-detected'); 392 + assert.ok(loopHits.length >= 1, 'rules:loop-detected was emitted'); 393 + }); 394 + }); 395 + 396 + describe('rule-engine: stop()', () => { 397 + it('unsubscribes from all topics', () => { 398 + const pubsub = makeFakePubsub(); 399 + const engine = createRuleEngine({ 400 + pubsub, 401 + listEnabledRules: () => [ 402 + makeRule({ topic: 'a' }), 403 + makeRule({ topic: 'b' }), 404 + ], 405 + }); 406 + engine.stop(); 407 + const aSubs = pubsub.subs.get('a'); 408 + const bSubs = pubsub.subs.get('b'); 409 + assert.ok(!aSubs || aSubs.size === 0); 410 + assert.ok(!bSubs || bSubs.size === 0); 411 + }); 412 + });
+354
backend/electron/rule-engine.ts
··· 1 + /** 2 + * Rule Engine — Stage 2 of peek MCP plan 3d7d9239. 3 + * 4 + * Subscribes to the pubsub bus on behalf of every enabled rule in the 5 + * `rules` table; on a matching event, evaluates the rule's JSON 6 + * predicate against the payload and publishes `scripts:execute` so the 7 + * scripts feature runs the named script. 8 + * 9 + * Three layers, each independently testable: 10 + * 11 + * 1. `evaluatePredicate(predicate, payload)` — pure function. The 12 + * predicate language is a JSON object: top-level keys are field 13 + * paths (dotted, e.g. `item.url`), values are either a plain 14 + * value (shorthand `$eq`) or an operator object. Boolean 15 + * combinators `$and` / `$or` / `$not` short-circuit. 16 + * 2. `topicMatches(ruleTopic, eventTopic)` — pure function. Exact 17 + * match, or `prefix:*` style trailing wildcard (one segment or 18 + * many — `page:*` matches `page:loaded`, `page:nav:back`, …). 19 + * 3. The dispatcher (`createRuleEngine`) — wires the rule index to 20 + * pubsub. Stage 2 supports concrete-topic subscriptions only; 21 + * wildcard rules are persisted but not dispatched (TODO below). 22 + * 23 + * Recursion: scripts that publish events can re-enter the dispatcher. 24 + * A per-publish depth counter caps recursion at MAX_DEPTH; exceeding 25 + * it emits `rules:loop-detected` telemetry and drops the deepest fire. 26 + */ 27 + 28 + // ─── Predicate language ───────────────────────────────────────────── 29 + 30 + const SUPPORTED_OPS = new Set([ 31 + '$eq', '$ne', '$in', '$exists', 32 + '$startsWith', '$endsWith', '$contains', 33 + '$and', '$or', '$not', 34 + ]); 35 + 36 + function getPath(payload: unknown, path: string): unknown { 37 + if (payload === null || typeof payload !== 'object') return undefined; 38 + const parts = path.split('.'); 39 + let cur: unknown = payload; 40 + for (const p of parts) { 41 + if (cur === null || typeof cur !== 'object') return undefined; 42 + cur = (cur as Record<string, unknown>)[p]; 43 + } 44 + return cur; 45 + } 46 + 47 + function isPlainObject(v: unknown): v is Record<string, unknown> { 48 + return v !== null && typeof v === 'object' && !Array.isArray(v); 49 + } 50 + 51 + function evalOperator(op: string, expected: unknown, actual: unknown): boolean { 52 + switch (op) { 53 + case '$eq': 54 + return actual === expected; 55 + case '$ne': 56 + return actual !== expected; 57 + case '$in': 58 + return Array.isArray(expected) && expected.includes(actual); 59 + case '$exists': 60 + return (actual !== undefined) === Boolean(expected); 61 + case '$startsWith': 62 + return typeof actual === 'string' && typeof expected === 'string' && actual.startsWith(expected); 63 + case '$endsWith': 64 + return typeof actual === 'string' && typeof expected === 'string' && actual.endsWith(expected); 65 + case '$contains': 66 + return typeof actual === 'string' && typeof expected === 'string' && actual.includes(expected); 67 + default: 68 + throw new Error(`unknown predicate operator: ${op}`); 69 + } 70 + } 71 + 72 + /** 73 + * Evaluate a JSON predicate against a payload. Throws on unknown 74 + * operators so typos are caught loudly at register time (callers can 75 + * choose to wrap-and-log if they prefer). Empty `{}` matches anything. 76 + */ 77 + export function evaluatePredicate(predicate: unknown, payload: unknown): boolean { 78 + // `null` / non-object predicates: only match an identical payload. 79 + if (predicate === null) return payload === null; 80 + if (typeof predicate !== 'object') return predicate === payload; 81 + 82 + // Arrays are not valid predicates at the top level. 83 + if (Array.isArray(predicate)) { 84 + throw new Error('predicate must be an object, not an array'); 85 + } 86 + 87 + for (const [key, value] of Object.entries(predicate)) { 88 + if (key === '$and') { 89 + if (!Array.isArray(value)) throw new Error('$and expects an array'); 90 + if (!value.every(p => evaluatePredicate(p, payload))) return false; 91 + continue; 92 + } 93 + if (key === '$or') { 94 + if (!Array.isArray(value)) throw new Error('$or expects an array'); 95 + if (!value.some(p => evaluatePredicate(p, payload))) return false; 96 + continue; 97 + } 98 + if (key === '$not') { 99 + if (evaluatePredicate(value, payload)) return false; 100 + continue; 101 + } 102 + if (key.startsWith('$')) { 103 + throw new Error(`unknown predicate operator at top level: ${key}`); 104 + } 105 + 106 + // Field clause. Value is either a plain value (shorthand $eq) or 107 + // an operator object. 108 + const actual = getPath(payload, key); 109 + if (isPlainObject(value)) { 110 + // Detect: are the keys all operators, or is it a literal nested 111 + // object we should compare structurally? Keep the rule simple: 112 + // if ANY top-level key starts with `$`, treat the whole object 113 + // as operators (and reject mixed). Otherwise compare by deep 114 + // equality (JSON-stringified). 115 + const keys = Object.keys(value); 116 + const hasOp = keys.some(k => k.startsWith('$')); 117 + if (hasOp) { 118 + for (const opKey of keys) { 119 + if (!opKey.startsWith('$')) { 120 + throw new Error(`mixed operator and literal keys in clause for "${key}"`); 121 + } 122 + if (!SUPPORTED_OPS.has(opKey)) { 123 + throw new Error(`unknown predicate operator: ${opKey}`); 124 + } 125 + if (!evalOperator(opKey, (value as Record<string, unknown>)[opKey], actual)) { 126 + return false; 127 + } 128 + } 129 + } else { 130 + // Structural equality. 131 + if (JSON.stringify(actual) !== JSON.stringify(value)) return false; 132 + } 133 + } else { 134 + // Plain value — shorthand for $eq. 135 + if (actual !== value) return false; 136 + } 137 + } 138 + return true; 139 + } 140 + 141 + // ─── Topic matcher ────────────────────────────────────────────────── 142 + 143 + /** 144 + * Match a rule's topic against a published event topic. 145 + * 146 + * Supported forms: 147 + * - exact: `tag:added` matches `tag:added` 148 + * - trailing wildcard: `page:*` matches `page:loaded`, `page:nav:back`, … 149 + * 150 + * The wildcard always lives at the end; embedded wildcards are not 151 + * supported (and would silently never match — callers should validate 152 + * at register time if they care). 153 + */ 154 + export function topicMatches(ruleTopic: string, eventTopic: string): boolean { 155 + if (ruleTopic === eventTopic) return true; 156 + if (!ruleTopic.endsWith(':*')) return false; 157 + const prefix = ruleTopic.slice(0, -1); // keep the trailing colon 158 + return eventTopic.startsWith(prefix) && eventTopic.length > prefix.length; 159 + } 160 + 161 + export function isWildcardTopic(topic: string): boolean { 162 + return topic.endsWith(':*'); 163 + } 164 + 165 + // ─── Dispatcher ───────────────────────────────────────────────────── 166 + 167 + /** 168 + * Minimal pubsub surface — kept abstract so tests can pass a fake 169 + * without dragging in the real module. Mirrors `pubsub.ts`'s shape. 170 + */ 171 + export interface RuleEnginePubsub { 172 + publish(source: string, topic: string, msg: unknown): void; 173 + subscribe(source: string, topic: string, cb: (msg: unknown) => void): void; 174 + unsubscribe(source: string, topic: string): boolean; 175 + } 176 + 177 + /** 178 + * Minimal datastore surface — exposes only what the engine needs. 179 + * `listRules` mirrors rules.ts:listRules; we accept it as an injected 180 + * function so the engine module has no SQL dependency at import time. 181 + */ 182 + export interface RuleEngineDeps { 183 + pubsub: RuleEnginePubsub; 184 + /** Returns every enabled rule. Filters can be applied by the caller. */ 185 + listEnabledRules: () => RuleLike[]; 186 + /** Publish source identity for the engine itself. */ 187 + source?: string; 188 + /** Override telemetry topic prefix; defaults to `rules:`. */ 189 + telemetryPrefix?: string; 190 + /** Override the script-invocation topic prefix; defaults to 191 + * `scripts:execute:` so each script gets its own concrete topic 192 + * (`scripts:execute:<scriptId>`). Per-script topics let lazy 193 + * features wake only when their own script fires, instead of every 194 + * scripts:execute publish loading every script-owning feature. */ 195 + scriptsExecuteTopicPrefix?: string; 196 + /** Recursion cap. Default 8. */ 197 + maxDepth?: number; 198 + } 199 + 200 + /** Subset of the Rule shape the engine needs. */ 201 + export interface RuleLike { 202 + id: string; 203 + featureId: string; 204 + topic: string; 205 + match: Record<string, unknown>; 206 + scriptId: string; 207 + config: Record<string, unknown>; 208 + enabled: boolean; 209 + ordering: number; 210 + } 211 + 212 + const DEFAULT_MAX_DEPTH = 8; 213 + 214 + export interface RuleEngine { 215 + /** 216 + * Rebuild the topic→rules index from `listEnabledRules()` and 217 + * reconcile pubsub subscriptions: subscribe to any newly-needed 218 + * concrete topic, unsubscribe from topics whose rules have all gone 219 + * away. 220 + */ 221 + rebuild(): void; 222 + /** 223 + * Test seam: invoke the dispatcher synchronously as if `topic` was 224 + * published with `payload`. Used by unit tests and by tracing tools. 225 + */ 226 + dispatch(topic: string, payload: unknown): void; 227 + /** Stop the engine — unsubscribe from everything. */ 228 + stop(): void; 229 + } 230 + 231 + export function createRuleEngine(deps: RuleEngineDeps): RuleEngine { 232 + const source = deps.source ?? 'rule-engine'; 233 + const scriptsExecuteTopicPrefix = deps.scriptsExecuteTopicPrefix ?? 'scripts:execute:'; 234 + const telemetryPrefix = deps.telemetryPrefix ?? 'rules:'; 235 + const maxDepth = deps.maxDepth ?? DEFAULT_MAX_DEPTH; 236 + 237 + // Index: concrete topic → ordered rule list. Wildcard rules go in a 238 + // separate list and are NOT subscribed; they're documented as future 239 + // work. Their topicMatches() behavior is still tested so when we 240 + // wire wildcard subscription, we already trust the matcher. 241 + const concreteIndex = new Map<string, RuleLike[]>(); 242 + const wildcardRules: RuleLike[] = []; 243 + const subscribedTopics = new Set<string>(); 244 + let depth = 0; 245 + 246 + function fireRule(rule: RuleLike, payload: unknown): void { 247 + let matched: boolean; 248 + try { 249 + matched = evaluatePredicate(rule.match, payload); 250 + } catch (err) { 251 + // Bad predicate — log + skip. A malformed rule shouldn't break 252 + // dispatch for siblings. 253 + console.warn(`[rule-engine] predicate eval threw for rule ${rule.id}:`, err); 254 + return; 255 + } 256 + if (!matched) return; 257 + 258 + deps.pubsub.publish(source, scriptsExecuteTopicPrefix + rule.scriptId, { 259 + scriptId: rule.scriptId, 260 + context: { event: payload, config: rule.config, ruleId: rule.id, featureId: rule.featureId }, 261 + }); 262 + } 263 + 264 + function dispatch(topic: string, payload: unknown): void { 265 + if (depth >= maxDepth) { 266 + try { 267 + deps.pubsub.publish(source, `${telemetryPrefix}loop-detected`, { 268 + topic, depth, maxDepth, 269 + }); 270 + } catch { 271 + // telemetry must never throw out of the engine 272 + } 273 + return; 274 + } 275 + 276 + const concreteHits = concreteIndex.get(topic); 277 + const wildcardHits = wildcardRules.filter(r => topicMatches(r.topic, topic)); 278 + 279 + // Combine + sort by ordering. Skip disabled rules at fire time so 280 + // hot-toggling enabled flips reflect immediately on the next event 281 + // without needing an index rebuild. 282 + const all = [...(concreteHits ?? []), ...wildcardHits] 283 + .filter(r => r.enabled) 284 + .sort((a, b) => a.ordering - b.ordering); 285 + 286 + if (all.length === 0) return; 287 + 288 + depth++; 289 + try { 290 + for (const r of all) fireRule(r, payload); 291 + } finally { 292 + depth--; 293 + } 294 + } 295 + 296 + function rebuild(): void { 297 + const rules = deps.listEnabledRules(); 298 + const nextConcrete = new Map<string, RuleLike[]>(); 299 + const nextWildcard: RuleLike[] = []; 300 + const wantedTopics = new Set<string>(); 301 + 302 + for (const r of rules) { 303 + if (isWildcardTopic(r.topic)) { 304 + nextWildcard.push(r); 305 + continue; 306 + } 307 + const list = nextConcrete.get(r.topic) ?? []; 308 + list.push(r); 309 + nextConcrete.set(r.topic, list); 310 + wantedTopics.add(r.topic); 311 + } 312 + 313 + // Subscribe to newly-needed concrete topics. 314 + for (const t of wantedTopics) { 315 + if (subscribedTopics.has(t)) continue; 316 + deps.pubsub.subscribe(source, t, (msg) => dispatch(t, msg)); 317 + subscribedTopics.add(t); 318 + } 319 + 320 + // Unsubscribe from concrete topics no rule wants any more. 321 + for (const t of Array.from(subscribedTopics)) { 322 + if (!wantedTopics.has(t)) { 323 + deps.pubsub.unsubscribe(source, t); 324 + subscribedTopics.delete(t); 325 + } 326 + } 327 + 328 + concreteIndex.clear(); 329 + for (const [t, list] of nextConcrete) concreteIndex.set(t, list); 330 + wildcardRules.length = 0; 331 + wildcardRules.push(...nextWildcard); 332 + } 333 + 334 + function stop(): void { 335 + for (const t of subscribedTopics) deps.pubsub.unsubscribe(source, t); 336 + subscribedTopics.clear(); 337 + concreteIndex.clear(); 338 + wildcardRules.length = 0; 339 + } 340 + 341 + // Listen for rules:changed so register/unregister IPC writes are 342 + // reflected without a full reboot. The handler is intentionally 343 + // minimal — the index rebuild is cheap (single SELECT + map work) 344 + // and easier to reason about than incremental delta application. 345 + deps.pubsub.subscribe(source, 'rules:changed', () => { 346 + try { rebuild(); } 347 + catch (err) { console.error('[rule-engine] rebuild after rules:changed failed:', err); } 348 + }); 349 + 350 + // Initial population. 351 + rebuild(); 352 + 353 + return { rebuild, dispatch, stop }; 354 + }
+82
backend/electron/rules-drift.test.ts
··· 1 + /** 2 + * Drift detection: invariants the rule engine assumes about feature 3 + * manifests. These are standing regressions per 4 + * `feedback_drift_detection_tests.md` — when a recurring class of bug 5 + * could re-emerge, encode the invariant as a test. 6 + * 7 + * Stage 1 invariant (peek MCP plan 3d7d9239): 8 + * A feature MUST NOT declare both `resident: true` (on any tile 9 + * entry) AND `rules: [...]` (top-level). The whole point of the 10 + * rule engine is to let features participate in event pipelines 11 + * without being permanently resident — the two declarations 12 + * together would mean we're still booting that feature on every 13 + * start, defeating the migration. Catch it at build time so an 14 + * author who lifts old code into a new feature gets a loud failure 15 + * instead of silently undoing the work. 16 + */ 17 + 18 + import { describe, it } from 'node:test'; 19 + import * as assert from 'node:assert'; 20 + import fs from 'node:fs'; 21 + import path from 'node:path'; 22 + 23 + import type { TileManifest } from './tile-manifest.js'; 24 + 25 + function repoRoot(): string { 26 + // Test runs from the dist/ build, so we walk up to the repo root 27 + // by pattern: dist/backend/electron → repo root. 28 + // __dirname-style resolution would require import.meta.url plumbing; 29 + // process.cwd() is the test runner invocation directory which is 30 + // already the repo root for `yarn test:unit`. 31 + return process.cwd(); 32 + } 33 + 34 + function listFeatureDirs(featuresDir: string): string[] { 35 + if (!fs.existsSync(featuresDir)) return []; 36 + return fs.readdirSync(featuresDir, { withFileTypes: true }) 37 + .filter(d => d.isDirectory()) 38 + .map(d => path.join(featuresDir, d.name)); 39 + } 40 + 41 + function readManifest(featureDir: string): (TileManifest & { rules?: unknown[] }) | null { 42 + const manifestPath = path.join(featureDir, 'manifest.json'); 43 + if (!fs.existsSync(manifestPath)) return null; 44 + try { 45 + return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as TileManifest & { rules?: unknown[] }; 46 + } catch { 47 + return null; 48 + } 49 + } 50 + 51 + describe('rules: drift detection', () => { 52 + it('no feature declares both resident:true and a non-empty rules:[]', () => { 53 + const featuresDir = path.join(repoRoot(), 'features'); 54 + const dirs = listFeatureDirs(featuresDir); 55 + if (dirs.length === 0) { 56 + // Test runner cwd is not the repo root — skip rather than false-pass. 57 + console.warn('[rules-drift] features/ not found from cwd, skipping'); 58 + return; 59 + } 60 + 61 + const offenders: string[] = []; 62 + for (const dir of dirs) { 63 + const m = readManifest(dir); 64 + if (!m) continue; 65 + const hasRules = Array.isArray(m.rules) && m.rules.length > 0; 66 + if (!hasRules) continue; 67 + const tiles = Array.isArray(m.tiles) ? m.tiles : []; 68 + const hasResident = tiles.some(t => t && (t as { resident?: boolean }).resident === true); 69 + if (hasResident) { 70 + offenders.push(path.basename(dir)); 71 + } 72 + } 73 + 74 + assert.deepStrictEqual( 75 + offenders, [], 76 + `These features declare both resident:true and rules:[...] — pick one. ` + 77 + `Manifest rules are designed for non-resident features that participate in ` + 78 + `event pipelines on demand. If a feature genuinely needs to stay live, drop ` + 79 + `the rules block; if it can go lazy, drop resident. Offenders: ${offenders.join(', ')}`, 80 + ); 81 + }); 82 + });
+296
backend/electron/rules.test.ts
··· 1 + /** 2 + * Unit tests for rules.ts — the rule registry storage layer (Stage 1 3 + * of peek MCP plan 3d7d9239). 4 + * 5 + * Mirrors permission-store.test.ts: in-memory better-sqlite3 with the 6 + * `rules` table shape from datastore.ts. Runs under Electron-as-node 7 + * via ELECTRON_RUN_AS_NODE. 8 + */ 9 + 10 + import { describe, it, before, beforeEach } from 'node:test'; 11 + import * as assert from 'node:assert'; 12 + import Database from 'better-sqlite3'; 13 + 14 + import { 15 + computeManifestRuleId, 16 + deleteRule, 17 + deleteRulesByFeature, 18 + getRule, 19 + listRules, 20 + parseRule, 21 + serializeRule, 22 + syncManifestRules, 23 + upsertRule, 24 + type ManifestRule, 25 + type Rule, 26 + type RulesDb, 27 + } from './rules.js'; 28 + 29 + let db: Database.Database; 30 + 31 + function makeDb(): Database.Database { 32 + const d = new Database(':memory:'); 33 + d.exec(` 34 + CREATE TABLE rules ( 35 + id TEXT PRIMARY KEY NOT NULL, 36 + featureId TEXT NOT NULL, 37 + topic TEXT NOT NULL, 38 + match TEXT DEFAULT '{}', 39 + scriptId TEXT NOT NULL, 40 + config TEXT DEFAULT '{}', 41 + display TEXT DEFAULT '', 42 + enabled INTEGER DEFAULT 1, 43 + source TEXT NOT NULL CHECK(source IN ('manifest', 'user', 'runtime')), 44 + ordering INTEGER DEFAULT 0, 45 + createdAt INTEGER NOT NULL, 46 + updatedAt INTEGER NOT NULL 47 + ); 48 + CREATE INDEX idx_rules_topic ON rules(topic); 49 + CREATE INDEX idx_rules_featureId ON rules(featureId); 50 + CREATE INDEX idx_rules_source ON rules(source); 51 + CREATE INDEX idx_rules_enabled ON rules(enabled); 52 + `); 53 + return d; 54 + } 55 + 56 + function asRulesDb(d: Database.Database): RulesDb { 57 + return d as unknown as RulesDb; 58 + } 59 + 60 + function makeRule(overrides: Partial<Rule> = {}): Rule { 61 + return { 62 + id: 'rule_test', 63 + featureId: 'tag-actions', 64 + topic: 'tag:added', 65 + match: { tagName: 'todo' }, 66 + scriptId: 'tag-swap', 67 + config: { activeTag: 'todo', completedTag: 'done' }, 68 + display: { icon: 'check' }, 69 + enabled: true, 70 + source: 'manifest', 71 + ordering: 0, 72 + createdAt: 1000, 73 + updatedAt: 1000, 74 + ...overrides, 75 + }; 76 + } 77 + 78 + describe('rules: serialize / parse round-trip', () => { 79 + it('round-trips a fully-populated rule', () => { 80 + const rule = makeRule(); 81 + const back = parseRule(serializeRule(rule)); 82 + assert.deepStrictEqual(back, rule); 83 + }); 84 + 85 + it('round-trips a rule with display=null', () => { 86 + const rule = makeRule({ display: null }); 87 + const back = parseRule(serializeRule(rule)); 88 + assert.strictEqual(back.display, null); 89 + }); 90 + 91 + it('round-trips disabled state', () => { 92 + const rule = makeRule({ enabled: false }); 93 + const back = parseRule(serializeRule(rule)); 94 + assert.strictEqual(back.enabled, false); 95 + }); 96 + 97 + it('falls back to {} for malformed JSON in match/config', () => { 98 + const back = parseRule({ 99 + id: 'r', featureId: 'f', topic: 't', match: '{not json', 100 + scriptId: 's', config: '{also bad', display: '', 101 + enabled: 1, source: 'user', ordering: 0, 102 + createdAt: 0, updatedAt: 0, 103 + }); 104 + assert.deepStrictEqual(back.match, {}); 105 + assert.deepStrictEqual(back.config, {}); 106 + assert.strictEqual(back.display, null); 107 + }); 108 + }); 109 + 110 + describe('rules: CRUD', () => { 111 + before(() => { db = makeDb(); }); 112 + beforeEach(() => { db.exec('DELETE FROM rules'); }); 113 + 114 + it('upsert -> get returns the same rule', () => { 115 + upsertRule(asRulesDb(db), makeRule()); 116 + const got = getRule(asRulesDb(db), 'rule_test'); 117 + assert.ok(got); 118 + assert.strictEqual(got.topic, 'tag:added'); 119 + assert.strictEqual(got.scriptId, 'tag-swap'); 120 + assert.deepStrictEqual(got.config, { activeTag: 'todo', completedTag: 'done' }); 121 + }); 122 + 123 + it('getRule returns null for unknown id', () => { 124 + assert.strictEqual(getRule(asRulesDb(db), 'nope'), null); 125 + }); 126 + 127 + it('upsert preserves createdAt on update but advances updatedAt', () => { 128 + upsertRule(asRulesDb(db), makeRule({ createdAt: 1000, updatedAt: 1000 })); 129 + upsertRule(asRulesDb(db), makeRule({ createdAt: 9999, updatedAt: 2000, scriptId: 'other' })); 130 + const got = getRule(asRulesDb(db), 'rule_test'); 131 + assert.strictEqual(got!.createdAt, 1000, 'createdAt frozen on update'); 132 + assert.strictEqual(got!.updatedAt, 2000); 133 + assert.strictEqual(got!.scriptId, 'other'); 134 + }); 135 + 136 + it('deleteRule returns true when row existed and false otherwise', () => { 137 + upsertRule(asRulesDb(db), makeRule()); 138 + assert.strictEqual(deleteRule(asRulesDb(db), 'rule_test'), true); 139 + assert.strictEqual(deleteRule(asRulesDb(db), 'rule_test'), false); 140 + assert.strictEqual(getRule(asRulesDb(db), 'rule_test'), null); 141 + }); 142 + }); 143 + 144 + describe('rules: listRules filters', () => { 145 + before(() => { db = makeDb(); }); 146 + beforeEach(() => { db.exec('DELETE FROM rules'); }); 147 + 148 + it('filters by topic / featureId / source / enabled', () => { 149 + upsertRule(asRulesDb(db), makeRule({ id: 'a', topic: 'tag:added', featureId: 'tag-actions', source: 'manifest', enabled: true })); 150 + upsertRule(asRulesDb(db), makeRule({ id: 'b', topic: 'tag:removed', featureId: 'tag-actions', source: 'manifest', enabled: true })); 151 + upsertRule(asRulesDb(db), makeRule({ id: 'c', topic: 'tag:added', featureId: 'entities', source: 'user', enabled: false })); 152 + 153 + assert.deepStrictEqual( 154 + listRules(asRulesDb(db), { topic: 'tag:added' }).map(r => r.id).sort(), 155 + ['a', 'c'], 156 + ); 157 + assert.deepStrictEqual( 158 + listRules(asRulesDb(db), { featureId: 'tag-actions' }).map(r => r.id).sort(), 159 + ['a', 'b'], 160 + ); 161 + assert.deepStrictEqual( 162 + listRules(asRulesDb(db), { source: 'user' }).map(r => r.id), 163 + ['c'], 164 + ); 165 + assert.deepStrictEqual( 166 + listRules(asRulesDb(db), { enabled: false }).map(r => r.id), 167 + ['c'], 168 + ); 169 + }); 170 + 171 + it('orders by ordering ASC then createdAt ASC', () => { 172 + upsertRule(asRulesDb(db), makeRule({ id: 'late-low', ordering: 0, createdAt: 200 })); 173 + upsertRule(asRulesDb(db), makeRule({ id: 'early-low', ordering: 0, createdAt: 100 })); 174 + upsertRule(asRulesDb(db), makeRule({ id: 'high', ordering: 5, createdAt: 50 })); 175 + assert.deepStrictEqual( 176 + listRules(asRulesDb(db)).map(r => r.id), 177 + ['early-low', 'late-low', 'high'], 178 + ); 179 + }); 180 + 181 + it('empty filter returns all rules', () => { 182 + upsertRule(asRulesDb(db), makeRule({ id: 'a' })); 183 + upsertRule(asRulesDb(db), makeRule({ id: 'b' })); 184 + assert.strictEqual(listRules(asRulesDb(db)).length, 2); 185 + }); 186 + }); 187 + 188 + describe('rules: deleteRulesByFeature', () => { 189 + before(() => { db = makeDb(); }); 190 + beforeEach(() => { db.exec('DELETE FROM rules'); }); 191 + 192 + it('deletes only rows for the named feature', () => { 193 + upsertRule(asRulesDb(db), makeRule({ id: 'a', featureId: 'tag-actions' })); 194 + upsertRule(asRulesDb(db), makeRule({ id: 'b', featureId: 'tag-actions' })); 195 + upsertRule(asRulesDb(db), makeRule({ id: 'c', featureId: 'entities' })); 196 + const n = deleteRulesByFeature(asRulesDb(db), 'tag-actions'); 197 + assert.strictEqual(n, 2); 198 + const remaining = listRules(asRulesDb(db)); 199 + assert.strictEqual(remaining.length, 1); 200 + assert.strictEqual(remaining[0].id, 'c'); 201 + }); 202 + 203 + it('returns 0 when no rows match', () => { 204 + assert.strictEqual(deleteRulesByFeature(asRulesDb(db), 'ghost'), 0); 205 + }); 206 + }); 207 + 208 + describe('rules: computeManifestRuleId', () => { 209 + it('is stable across calls for identical input', () => { 210 + const m: ManifestRule = { topic: 't', scriptId: 's', match: { x: 1 } }; 211 + assert.strictEqual(computeManifestRuleId('f', m), computeManifestRuleId('f', m)); 212 + }); 213 + 214 + it('changes when topic / scriptId / match / config / display / ordering changes', () => { 215 + const base: ManifestRule = { topic: 't', scriptId: 's' }; 216 + const id0 = computeManifestRuleId('f', base); 217 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, topic: 't2' })); 218 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, scriptId: 's2' })); 219 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, match: { y: 1 } })); 220 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, config: { y: 1 } })); 221 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, display: { icon: 'x' } })); 222 + assert.notStrictEqual(id0, computeManifestRuleId('f', { ...base, ordering: 5 })); 223 + }); 224 + 225 + it('does NOT change when only enabled flips — users toggling off must not lose identity', () => { 226 + const base: ManifestRule = { topic: 't', scriptId: 's', enabled: true }; 227 + const off: ManifestRule = { ...base, enabled: false }; 228 + assert.strictEqual(computeManifestRuleId('f', base), computeManifestRuleId('f', off)); 229 + }); 230 + 231 + it('changes when featureId changes', () => { 232 + const m: ManifestRule = { topic: 't', scriptId: 's' }; 233 + assert.notStrictEqual(computeManifestRuleId('a', m), computeManifestRuleId('b', m)); 234 + }); 235 + }); 236 + 237 + describe('rules: syncManifestRules', () => { 238 + before(() => { db = makeDb(); }); 239 + beforeEach(() => { db.exec('DELETE FROM rules'); }); 240 + 241 + it('inserts declared rules and assigns content-hash ids', () => { 242 + const declared: ManifestRule[] = [ 243 + { topic: 'tag:added', scriptId: 'swap', config: { a: 1 } }, 244 + { topic: 'tag:removed', scriptId: 'swap-back' }, 245 + ]; 246 + const out = syncManifestRules(asRulesDb(db), 'tag-actions', declared, 1000); 247 + assert.strictEqual(out.length, 2); 248 + assert.strictEqual(out[0].id, computeManifestRuleId('tag-actions', declared[0])); 249 + assert.strictEqual(out[0].source, 'manifest'); 250 + assert.strictEqual(out[0].featureId, 'tag-actions'); 251 + }); 252 + 253 + it('is idempotent on identical input — second run preserves createdAt', () => { 254 + const declared: ManifestRule[] = [{ topic: 't', scriptId: 's' }]; 255 + const first = syncManifestRules(asRulesDb(db), 'f', declared, 1000); 256 + const second = syncManifestRules(asRulesDb(db), 'f', declared, 2000); 257 + assert.strictEqual(first[0].id, second[0].id); 258 + const got = getRule(asRulesDb(db), first[0].id); 259 + assert.strictEqual(got!.createdAt, 1000, 'createdAt preserved'); 260 + assert.strictEqual(got!.updatedAt, 2000, 'updatedAt advances'); 261 + }); 262 + 263 + it('removes manifest rules whose declarations are gone', () => { 264 + const v1: ManifestRule[] = [ 265 + { topic: 'a', scriptId: 's' }, 266 + { topic: 'b', scriptId: 's' }, 267 + ]; 268 + syncManifestRules(asRulesDb(db), 'f', v1, 1000); 269 + assert.strictEqual(listRules(asRulesDb(db)).length, 2); 270 + 271 + const v2: ManifestRule[] = [{ topic: 'a', scriptId: 's' }]; 272 + syncManifestRules(asRulesDb(db), 'f', v2, 2000); 273 + const remaining = listRules(asRulesDb(db)); 274 + assert.strictEqual(remaining.length, 1); 275 + assert.strictEqual(remaining[0].topic, 'a'); 276 + }); 277 + 278 + it('leaves user/runtime rules for the same feature untouched', () => { 279 + upsertRule(asRulesDb(db), makeRule({ id: 'user-rule', featureId: 'f', source: 'user' })); 280 + upsertRule(asRulesDb(db), makeRule({ id: 'rt-rule', featureId: 'f', source: 'runtime' })); 281 + syncManifestRules(asRulesDb(db), 'f', [{ topic: 't', scriptId: 's' }], 1000); 282 + const ids = listRules(asRulesDb(db), { featureId: 'f' }).map(r => r.id).sort(); 283 + assert.ok(ids.includes('user-rule')); 284 + assert.ok(ids.includes('rt-rule')); 285 + assert.strictEqual(ids.length, 3); 286 + }); 287 + 288 + it('clearing the declaration list deletes all manifest rules for the feature', () => { 289 + syncManifestRules(asRulesDb(db), 'f', [ 290 + { topic: 'a', scriptId: 's' }, 291 + { topic: 'b', scriptId: 's' }, 292 + ], 1000); 293 + syncManifestRules(asRulesDb(db), 'f', [], 2000); 294 + assert.strictEqual(listRules(asRulesDb(db), { featureId: 'f', source: 'manifest' }).length, 0); 295 + }); 296 + });
+261
backend/electron/rules.ts
··· 1 + /** 2 + * Event-dispatch rule registry. 3 + * 4 + * Rules tie an event topic + JSON match predicate to a script. Stage 1 5 + * (peek MCP plan 3d7d9239) lands the storage layer + CRUD + manifest 6 + * contribution; Stage 2 introduces the dispatcher and predicate 7 + * language. All Stage 1 rows are local-only (`sync: false` in 8 + * schema/v1.json). 9 + * 10 + * Pure SQL wrapper — no Electron imports — so the module is 11 + * unit-testable under plain node via an in-memory better-sqlite3, the 12 + * same shape as permission-store.ts. 13 + */ 14 + 15 + import { createHash } from 'node:crypto'; 16 + 17 + export type RuleSource = 'manifest' | 'user' | 'runtime'; 18 + 19 + /** In-memory shape. JSON columns (`match`, `config`, `display`) are 20 + * parsed; the SQL row keeps them as TEXT. */ 21 + export interface Rule { 22 + id: string; 23 + featureId: string; 24 + topic: string; 25 + match: Record<string, unknown>; 26 + scriptId: string; 27 + config: Record<string, unknown>; 28 + display: Record<string, unknown> | null; 29 + enabled: boolean; 30 + source: RuleSource; 31 + ordering: number; 32 + createdAt: number; 33 + updatedAt: number; 34 + } 35 + 36 + /** What a manifest may declare under top-level `rules: [...]`. The id 37 + * is computed from a content hash; createdAt/updatedAt are assigned 38 + * on insert. */ 39 + export interface ManifestRule { 40 + topic: string; 41 + scriptId: string; 42 + match?: Record<string, unknown>; 43 + config?: Record<string, unknown>; 44 + display?: Record<string, unknown> | null; 45 + enabled?: boolean; 46 + ordering?: number; 47 + } 48 + 49 + /** Subset of better-sqlite3 used here — keeps the type local so tests 50 + * can pass an in-memory db without importing the full Database type. */ 51 + export interface RulesDb { 52 + prepare(sql: string): { 53 + get(...params: unknown[]): unknown; 54 + run(...params: unknown[]): unknown; 55 + all(...params: unknown[]): unknown[]; 56 + }; 57 + } 58 + 59 + interface RuleRow { 60 + id: string; 61 + featureId: string; 62 + topic: string; 63 + match: string; 64 + scriptId: string; 65 + config: string; 66 + display: string; 67 + enabled: number; 68 + source: string; 69 + ordering: number; 70 + createdAt: number; 71 + updatedAt: number; 72 + } 73 + 74 + // ─── (de)serialization ────────────────────────────────────────────── 75 + 76 + function parseJsonObject(s: string, fallback: Record<string, unknown>): Record<string, unknown> { 77 + if (!s) return fallback; 78 + try { 79 + const v = JSON.parse(s); 80 + return (v && typeof v === 'object' && !Array.isArray(v)) ? (v as Record<string, unknown>) : fallback; 81 + } catch { 82 + return fallback; 83 + } 84 + } 85 + 86 + export function parseRule(row: RuleRow): Rule { 87 + return { 88 + id: row.id, 89 + featureId: row.featureId, 90 + topic: row.topic, 91 + match: parseJsonObject(row.match, {}), 92 + scriptId: row.scriptId, 93 + config: parseJsonObject(row.config, {}), 94 + display: row.display ? parseJsonObject(row.display, {}) : null, 95 + enabled: row.enabled !== 0, 96 + source: row.source as RuleSource, 97 + ordering: row.ordering, 98 + createdAt: row.createdAt, 99 + updatedAt: row.updatedAt, 100 + }; 101 + } 102 + 103 + export function serializeRule(rule: Rule): RuleRow { 104 + return { 105 + id: rule.id, 106 + featureId: rule.featureId, 107 + topic: rule.topic, 108 + match: JSON.stringify(rule.match ?? {}), 109 + scriptId: rule.scriptId, 110 + config: JSON.stringify(rule.config ?? {}), 111 + display: rule.display ? JSON.stringify(rule.display) : '', 112 + enabled: rule.enabled ? 1 : 0, 113 + source: rule.source, 114 + ordering: rule.ordering, 115 + createdAt: rule.createdAt, 116 + updatedAt: rule.updatedAt, 117 + }; 118 + } 119 + 120 + // ─── id derivation ────────────────────────────────────────────────── 121 + 122 + /** 123 + * Stable id for a manifest-declared rule. SHA-256 over the 124 + * (featureId, topic, scriptId, normalized match, normalized config, 125 + * normalized display, ordering) tuple — so identical reboots don't 126 + * churn the table, but any meaningful edit produces a new id. 127 + * 128 + * `enabled` is intentionally NOT in the hash: a user toggling a 129 + * manifest rule off shouldn't fight the manifest re-syncing it back on 130 + * with a new id. 131 + */ 132 + export function computeManifestRuleId(featureId: string, rule: ManifestRule): string { 133 + const canonical = JSON.stringify({ 134 + f: featureId, 135 + t: rule.topic, 136 + s: rule.scriptId, 137 + m: rule.match ?? {}, 138 + c: rule.config ?? {}, 139 + d: rule.display ?? null, 140 + o: rule.ordering ?? 0, 141 + }); 142 + return 'rule_' + createHash('sha256').update(canonical, 'utf-8').digest('hex').slice(0, 32); 143 + } 144 + 145 + // ─── CRUD ─────────────────────────────────────────────────────────── 146 + 147 + export interface ListRulesFilter { 148 + topic?: string; // exact topic match (no wildcard expansion at this layer) 149 + featureId?: string; 150 + enabled?: boolean; 151 + source?: RuleSource; 152 + } 153 + 154 + export function listRules(db: RulesDb, filter: ListRulesFilter = {}): Rule[] { 155 + const where: string[] = []; 156 + const params: unknown[] = []; 157 + if (filter.topic !== undefined) { where.push('topic = ?'); params.push(filter.topic); } 158 + if (filter.featureId !== undefined) { where.push('featureId = ?'); params.push(filter.featureId); } 159 + if (filter.enabled !== undefined) { where.push('enabled = ?'); params.push(filter.enabled ? 1 : 0); } 160 + if (filter.source !== undefined) { where.push('source = ?'); params.push(filter.source); } 161 + const sql = 162 + 'SELECT id, featureId, topic, match, scriptId, config, display, enabled, source, ordering, createdAt, updatedAt ' + 163 + 'FROM rules' + 164 + (where.length ? ' WHERE ' + where.join(' AND ') : '') + 165 + ' ORDER BY ordering ASC, createdAt ASC'; 166 + const rows = db.prepare(sql).all(...params) as RuleRow[]; 167 + return rows.map(parseRule); 168 + } 169 + 170 + export function getRule(db: RulesDb, id: string): Rule | null { 171 + const row = db 172 + .prepare( 173 + 'SELECT id, featureId, topic, match, scriptId, config, display, enabled, source, ordering, createdAt, updatedAt FROM rules WHERE id = ?', 174 + ) 175 + .get(id) as RuleRow | undefined; 176 + return row ? parseRule(row) : null; 177 + } 178 + 179 + /** 180 + * Insert-or-replace a rule. Caller supplies the full Rule including 181 + * id; createdAt is preserved on update. 182 + */ 183 + export function upsertRule(db: RulesDb, rule: Rule): Rule { 184 + const existing = getRule(db, rule.id); 185 + const createdAt = existing ? existing.createdAt : rule.createdAt; 186 + const final: Rule = { ...rule, createdAt }; 187 + const row = serializeRule(final); 188 + db.prepare( 189 + 'INSERT OR REPLACE INTO rules (id, featureId, topic, match, scriptId, config, display, enabled, source, ordering, createdAt, updatedAt) ' + 190 + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', 191 + ).run( 192 + row.id, row.featureId, row.topic, row.match, row.scriptId, row.config, row.display, 193 + row.enabled, row.source, row.ordering, row.createdAt, row.updatedAt, 194 + ); 195 + return final; 196 + } 197 + 198 + /** Returns true if a row was deleted. */ 199 + export function deleteRule(db: RulesDb, id: string): boolean { 200 + const res = db.prepare('DELETE FROM rules WHERE id = ?').run(id) as { changes?: number }; 201 + return (res.changes ?? 0) > 0; 202 + } 203 + 204 + /** Used on feature uninstall. Returns the number of rows deleted. */ 205 + export function deleteRulesByFeature(db: RulesDb, featureId: string): number { 206 + const res = db.prepare('DELETE FROM rules WHERE featureId = ?').run(featureId) as { changes?: number }; 207 + return res.changes ?? 0; 208 + } 209 + 210 + /** 211 + * Reconcile the set of manifest-declared rules for a feature. 212 + * 213 + * - Computes content-hash ids for each declared rule. 214 + * - Inserts new rows, updates rows whose id already matches (no-op if 215 + * nothing changed), and deletes manifest rows for this feature whose 216 + * id is no longer in the declared set. 217 + * - Leaves user/runtime rules for the same feature alone. 218 + * 219 + * Returns the persisted rules (in declaration order). 220 + */ 221 + export function syncManifestRules( 222 + db: RulesDb, 223 + featureId: string, 224 + declared: ManifestRule[], 225 + nowMs: number, 226 + ): Rule[] { 227 + const wantIds = new Set<string>(); 228 + const persisted: Rule[] = []; 229 + 230 + for (const m of declared) { 231 + const id = computeManifestRuleId(featureId, m); 232 + wantIds.add(id); 233 + const rule: Rule = { 234 + id, 235 + featureId, 236 + topic: m.topic, 237 + match: m.match ?? {}, 238 + scriptId: m.scriptId, 239 + config: m.config ?? {}, 240 + display: m.display ?? null, 241 + enabled: m.enabled !== false, 242 + source: 'manifest', 243 + ordering: m.ordering ?? 0, 244 + createdAt: nowMs, 245 + updatedAt: nowMs, 246 + }; 247 + persisted.push(upsertRule(db, rule)); 248 + } 249 + 250 + // Drop manifest rows for this feature that the manifest no longer declares. 251 + const stale = db 252 + .prepare("SELECT id FROM rules WHERE featureId = ? AND source = 'manifest'") 253 + .all(featureId) as Array<{ id: string }>; 254 + for (const row of stale) { 255 + if (!wantIds.has(row.id)) { 256 + deleteRule(db, row.id); 257 + } 258 + } 259 + 260 + return persisted; 261 + }
+63
backend/electron/tile-api.d.ts
··· 330 330 unregister(shortcut: string, options?: TileShortcutOptions): void; 331 331 } 332 332 333 + // ─── Rules (event-dispatch registry, Stage 1) ──────────────────────── 334 + 335 + interface TileRule { 336 + id: string; 337 + featureId: string; 338 + topic: string; 339 + match: Record<string, unknown>; 340 + scriptId: string; 341 + config: Record<string, unknown>; 342 + display: Record<string, unknown> | null; 343 + enabled: boolean; 344 + source: 'manifest' | 'user' | 'runtime'; 345 + ordering: number; 346 + createdAt: number; 347 + updatedAt: number; 348 + } 349 + 350 + interface TileRulesRegisterArgs { 351 + /** Optional caller-supplied id suffix. The persisted id is prefixed 352 + * with `rule_<featureId>_` so two features can share the same suffix 353 + * without colliding. If absent, a random suffix is generated. */ 354 + id?: string; 355 + topic: string; 356 + scriptId: string; 357 + match?: Record<string, unknown>; 358 + config?: Record<string, unknown>; 359 + display?: Record<string, unknown> | null; 360 + enabled?: boolean; 361 + ordering?: number; 362 + } 363 + 364 + interface TileRules { 365 + /** 366 + * List rules owned by the calling feature. 367 + * Requires `rules` capability. 368 + */ 369 + list(options?: { topic?: string; enabled?: boolean }): Promise<{ rules: TileRule[]; error?: string }>; 370 + 371 + /** 372 + * List every enabled rule with a non-empty `display` block, across 373 + * all features. Used by shared card UIs to render affordances 374 + * declared by any feature. Requires `rules` capability. 375 + */ 376 + listAffordances(): Promise<{ rules: TileRule[]; error?: string }>; 377 + 378 + /** 379 + * Register a runtime rule for the calling feature. Idempotent when 380 + * the same `id` suffix is supplied — a re-register replaces the 381 + * previous row in place. Returns the persisted rule. 382 + * Requires `rules` capability. 383 + */ 384 + register(rule: TileRulesRegisterArgs): Promise<{ rule?: TileRule; error?: string }>; 385 + 386 + /** 387 + * Unregister a runtime rule by id. Only own-feature, source='runtime' 388 + * rows are deletable — manifest-declared rules are managed by the 389 + * install pipeline. 390 + * Requires `rules` capability. 391 + */ 392 + unregister(id: string): Promise<{ ok: boolean; error?: string }>; 393 + } 394 + 333 395 // ─── Context ───────────────────────────────────────────────────────── 334 396 335 397 interface TileContextEntry { ··· 706 768 datastore: TileDatastore; 707 769 settings: TileSettings; 708 770 shortcuts: TileShortcuts; 771 + rules: TileRules; 709 772 context: TileContext; 710 773 filesystem: TileFilesystem; 711 774 dialogs: TileDialogs;
+116
backend/electron/tile-ipc.ts
··· 84 84 normalizeUrl, 85 85 } from './datastore.js'; 86 86 import { tagItemAndPublish as dsTagItemAndPublish } from './tag-events.js'; 87 + import { 88 + listRules as rulesList, 89 + upsertRule as rulesUpsert, 90 + deleteRule as rulesDelete, 91 + type Rule, 92 + type RulesDb, 93 + } from './rules.js'; 87 94 import type { TableName } from '../types/index.js'; 88 95 import { installFromBundle } from './feature-installer.js'; 89 96 import { resolveCapabilities, validateTileManifest, detectManifestVersion } from './tile-manifest.js'; ··· 604 611 }], 605 612 } 606 613 ); 614 + }); 615 + 616 + // ── Rules (event-dispatch registry) ─────────────────────────────── 617 + // 618 + // Stage 1 of the rule engine (peek MCP plan 3d7d9239). The storage 619 + // layer lives in rules.ts; these handlers expose runtime CRUD over 620 + // the per-feature slice of the `rules` table. Manifest-declared 621 + // rules are reconciled at feature install/load time elsewhere 622 + // (feature-startup.ts) — the runtime handlers here only ever read 623 + // their own feature's rows and only ever write source='runtime' 624 + // rows. Keeping the source split prevents a runtime call from 625 + // racing with a manifest reconcile. 626 + // 627 + // featureId is bound to grant.tileId so a tile can never list / 628 + // register / unregister rules for another feature. 629 + 630 + registerTileIpc('tile:rules:list', { 631 + mode: 'handle', 632 + requiredCapabilities: ['rules'], 633 + }, (event, args: { token: string; topic?: string; enabled?: boolean }, grant) => { 634 + void event; 635 + const filter: { featureId: string; topic?: string; enabled?: boolean } = { featureId: grant.tileId }; 636 + if (typeof args.topic === 'string') filter.topic = args.topic; 637 + if (typeof args.enabled === 'boolean') filter.enabled = args.enabled; 638 + const db = getDb() as unknown as RulesDb; 639 + return { rules: rulesList(db, filter) }; 640 + }); 641 + 642 + registerTileIpc('tile:rules:register', { 643 + mode: 'handle', 644 + requiredCapabilities: ['rules'], 645 + }, (event, args: { 646 + token: string; 647 + id?: string; 648 + topic: string; 649 + scriptId: string; 650 + match?: Record<string, unknown>; 651 + config?: Record<string, unknown>; 652 + display?: Record<string, unknown> | null; 653 + enabled?: boolean; 654 + ordering?: number; 655 + }, grant) => { 656 + void event; 657 + if (!args.topic || typeof args.topic !== 'string') return { error: 'topic-required' }; 658 + if (!args.scriptId || typeof args.scriptId !== 'string') return { error: 'scriptId-required' }; 659 + 660 + const db = getDb() as unknown as RulesDb; 661 + const id = args.id && typeof args.id === 'string' 662 + ? `rule_${grant.tileId}_${args.id}` 663 + : `rule_${grant.tileId}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; 664 + const nowMs = Date.now(); 665 + const rule: Rule = { 666 + id, 667 + featureId: grant.tileId, 668 + topic: args.topic, 669 + match: args.match ?? {}, 670 + scriptId: args.scriptId, 671 + config: args.config ?? {}, 672 + display: args.display ?? null, 673 + enabled: args.enabled !== false, 674 + source: 'runtime', 675 + ordering: typeof args.ordering === 'number' ? args.ordering : 0, 676 + createdAt: nowMs, 677 + updatedAt: nowMs, 678 + }; 679 + const persisted = rulesUpsert(db, rule); 680 + publish(`peek://${grant.tileId}/background`, 'rules:changed', { 681 + featureId: grant.tileId, 682 + action: 'register', 683 + ruleId: persisted.id, 684 + }); 685 + return { rule: persisted }; 686 + }); 687 + 688 + registerTileIpc('tile:rules:list-affordances', { 689 + mode: 'handle', 690 + requiredCapabilities: ['rules'], 691 + }, (event, _args: { token: string }, _grant) => { 692 + void event; void _args; void _grant; 693 + // Cross-feature read: every enabled rule with a non-empty `display` 694 + // block. Affordances are visual surfaces that any consumer (card UI, 695 + // page widget, …) may render — feature scoping wouldn't make sense. 696 + const db = getDb() as unknown as RulesDb; 697 + const all = rulesList(db, { enabled: true }); 698 + return { 699 + rules: all.filter(r => r.display && Object.keys(r.display).length > 0), 700 + }; 701 + }); 702 + 703 + registerTileIpc('tile:rules:unregister', { 704 + mode: 'handle', 705 + requiredCapabilities: ['rules'], 706 + }, (event, args: { token: string; id: string }, grant) => { 707 + void event; 708 + if (!args.id || typeof args.id !== 'string') return { error: 'id-required' }; 709 + const db = getDb() as unknown as RulesDb; 710 + // Only own-feature, runtime-source rules are deletable via this 711 + // handler. Manifest rows are owned by the install pipeline. 712 + const rows = rulesList(db, { featureId: grant.tileId, source: 'runtime' }); 713 + if (!rows.some(r => r.id === args.id)) return { ok: false }; 714 + const ok = rulesDelete(db, args.id); 715 + if (ok) { 716 + publish(`peek://${grant.tileId}/background`, 'rules:changed', { 717 + featureId: grant.tileId, 718 + action: 'unregister', 719 + ruleId: args.id, 720 + }); 721 + } 722 + return { ok }; 607 723 }); 608 724 609 725 // ── Shortcuts (strict) ────────────────────────────────────────────
+78
backend/electron/tile-manifest.ts
··· 342 342 session?: boolean; 343 343 /** Can query IZUI coordinator state / ask own window to close */ 344 344 izui?: IzuiCapability; 345 + /** Can register / unregister event-dispatch rules at runtime */ 346 + rules?: boolean; 345 347 } 346 348 347 349 // ─── Tile Definition Types ─────────────────────────────────────────── ··· 435 437 mode?: string; 436 438 } 437 439 440 + /** 441 + * Manifest-declared rule. Persisted into the `rules` table by 442 + * feature-startup with `source: 'manifest'` and an id derived by 443 + * content-hashing this object — see rules.ts:computeManifestRuleId. 444 + * 445 + * Stage 1 (peek MCP plan 3d7d9239) defines the storage shape; Stage 2 446 + * adds the dispatcher and the predicate language for `match`. 447 + */ 448 + export interface TileRule { 449 + /** Event topic this rule listens for. Trailing wildcard supported. */ 450 + topic: string; 451 + /** Script to execute when the predicate matches. */ 452 + scriptId: string; 453 + /** JSON predicate evaluated against the event payload. Stage 2 spec. */ 454 + match?: Record<string, unknown>; 455 + /** Config object passed to the script alongside the event. */ 456 + config?: Record<string, unknown>; 457 + /** UI affordance hints. Empty / null = compute-only rule. */ 458 + display?: { icon?: string; checkedIcon?: string; color?: string; checkedColor?: string; label?: string } | null; 459 + /** Defaults to true. Lets users disable a rule without deleting it. */ 460 + enabled?: boolean; 461 + /** Tiebreaker when multiple rules match the same event. */ 462 + ordering?: number; 463 + } 464 + 438 465 // ─── Manifest Types ────────────────────────────────────────────────── 439 466 440 467 /** ··· 466 493 commands?: TileCommand[]; 467 494 /** Keyboard shortcuts */ 468 495 shortcuts?: TileShortcut[]; 496 + /** Event-dispatch rules contributed by this feature. */ 497 + rules?: TileRule[]; 469 498 /** Settings schema file path */ 470 499 settingsSchema?: string; 471 500 /** ··· 811 840 } 812 841 } 813 842 843 + // Validate rules capability shape — boolean only. 844 + if (caps.rules !== undefined && typeof caps.rules !== 'boolean') { 845 + errors.push({ path: 'capabilities.rules', message: 'rules must be a boolean' }); 846 + } 847 + 814 848 815 849 // Validate settings capability shape — must be boolean or { readForeign?: string[] } 816 850 if (caps.settings !== undefined) { ··· 907 941 } 908 942 } 909 943 944 + // Rules validation — top-level array of TileRule. 945 + if (json.rules !== undefined) { 946 + if (!Array.isArray(json.rules)) { 947 + errors.push({ path: 'rules', message: 'rules must be an array' }); 948 + } else { 949 + for (let i = 0; i < json.rules.length; i++) { 950 + const r = json.rules[i] as Record<string, unknown>; 951 + const prefix = `rules[${i}]`; 952 + if (!r || typeof r !== 'object') { 953 + errors.push({ path: prefix, message: 'rule must be an object' }); 954 + continue; 955 + } 956 + if (!r.topic || typeof r.topic !== 'string') { 957 + errors.push({ path: `${prefix}.topic`, message: 'rule topic is required and must be a string' }); 958 + } 959 + if (!r.scriptId || typeof r.scriptId !== 'string') { 960 + errors.push({ path: `${prefix}.scriptId`, message: 'rule scriptId is required and must be a string' }); 961 + } 962 + if (r.match !== undefined && (typeof r.match !== 'object' || r.match === null || Array.isArray(r.match))) { 963 + errors.push({ path: `${prefix}.match`, message: 'match must be an object' }); 964 + } 965 + if (r.config !== undefined && (typeof r.config !== 'object' || r.config === null || Array.isArray(r.config))) { 966 + errors.push({ path: `${prefix}.config`, message: 'config must be an object' }); 967 + } 968 + if (r.display !== undefined && r.display !== null && (typeof r.display !== 'object' || Array.isArray(r.display))) { 969 + errors.push({ path: `${prefix}.display`, message: 'display must be an object or null' }); 970 + } 971 + if (r.enabled !== undefined && typeof r.enabled !== 'boolean') { 972 + errors.push({ path: `${prefix}.enabled`, message: 'enabled must be a boolean' }); 973 + } 974 + if (r.ordering !== undefined && typeof r.ordering !== 'number') { 975 + errors.push({ path: `${prefix}.ordering`, message: 'ordering must be a number' }); 976 + } 977 + } 978 + } 979 + } 980 + 910 981 // Commands validation 911 982 if (json.commands !== undefined) { 912 983 if (!Array.isArray(json.commands)) { ··· 1150 1221 granted.izui = true; 1151 1222 } 1152 1223 1224 + // Rules: boolean grant. Lets a tile call api.rules.* (Stage 1 of 1225 + // the rule engine — peek MCP plan 3d7d9239). 1226 + if (requested.rules) { 1227 + granted.rules = true; 1228 + } 1229 + 1153 1230 return { tileId, capabilities: granted, denied }; 1154 1231 } 1155 1232 ··· 1223 1300 }, 1224 1301 session: true, 1225 1302 izui: true, 1303 + rules: true, 1226 1304 }; 1227 1305 1228 1306 return {
+53
backend/electron/tile-preload.cts
··· 1459 1459 }, 1460 1460 }; 1461 1461 1462 + // ── Rules (event-dispatch registry, Stage 1) ────────────────────── 1463 + // 1464 + // Stage 1 of the rule engine (peek MCP plan 3d7d9239). Tiles call 1465 + // these to register/unregister runtime rules — rows persisted to 1466 + // the local `rules` table with source='runtime'. Manifest-declared 1467 + // rules are reconciled at install time and not touched here. 1468 + // 1469 + // Capability check is per-call to match how shortcuts/commands are 1470 + // wired (grantedCapabilities is populated asynchronously). 1471 + function hasRulesCapability(): boolean { 1472 + return grantedCapabilities?.rules === true; 1473 + } 1474 + 1475 + api.rules = { 1476 + list: (options?: { topic?: string; enabled?: boolean }) => { 1477 + if (!hasRulesCapability()) { 1478 + return Promise.reject(new Error('[tile-preload] api.rules.list requires rules capability in manifest')); 1479 + } 1480 + return ipcRenderer.invoke('tile:rules:list', { 1481 + token: tileToken, 1482 + topic: options?.topic, 1483 + enabled: options?.enabled, 1484 + }); 1485 + }, 1486 + listAffordances: () => { 1487 + if (!hasRulesCapability()) { 1488 + return Promise.reject(new Error('[tile-preload] api.rules.listAffordances requires rules capability in manifest')); 1489 + } 1490 + return ipcRenderer.invoke('tile:rules:list-affordances', { token: tileToken }); 1491 + }, 1492 + register: (rule: { 1493 + id?: string; 1494 + topic: string; 1495 + scriptId: string; 1496 + match?: Record<string, unknown>; 1497 + config?: Record<string, unknown>; 1498 + display?: Record<string, unknown> | null; 1499 + enabled?: boolean; 1500 + ordering?: number; 1501 + }) => { 1502 + if (!hasRulesCapability()) { 1503 + return Promise.reject(new Error('[tile-preload] api.rules.register requires rules capability in manifest')); 1504 + } 1505 + return ipcRenderer.invoke('tile:rules:register', { token: tileToken, ...rule }); 1506 + }, 1507 + unregister: (id: string) => { 1508 + if (!hasRulesCapability()) { 1509 + return Promise.reject(new Error('[tile-preload] api.rules.unregister requires rules capability in manifest')); 1510 + } 1511 + return ipcRenderer.invoke('tile:rules:unregister', { token: tileToken, id }); 1512 + }, 1513 + }; 1514 + 1462 1515 // ── Files (removed v1-compat) ──────────────────────────────────── 1463 1516 // Use api.dialogs.save / api.dialogs.open for file dialogs, 1464 1517 // and api.filesystem.read / api.filesystem.write for path operations.
+4 -6
features/groups/manifest.json
··· 31 31 "ext:ready", 32 32 "ext:groups:shutdown", 33 33 "app:shutdown", 34 - "tag-actions:get-all", 35 - "tag-actions:get-all:response", 36 - "tag-actions:create:response", 37 - "tag-actions:update:response", 38 - "tag-actions:delete:response" 34 + "affordance:click", 35 + "rules:changed" 39 36 ] 40 37 }, 41 38 "datastore": { ··· 54 51 }, 55 52 "commands": true, 56 53 "shortcuts": true, 57 - "settings": { "readForeign": ["spaces"] } 54 + "settings": { "readForeign": ["spaces"] }, 55 + "rules": true 58 56 }, 59 57 "commands": [ 60 58 {
+5 -2
features/lists/manifest.json
··· 32 32 "item:deleted", 33 33 "tag:item-added", 34 34 "tag:item-removed", 35 - "editor:open" 35 + "editor:open", 36 + "affordance:click", 37 + "rules:changed" 36 38 ] 37 39 }, 38 40 "datastore": { ··· 43 45 }, 44 46 "commands": true, 45 47 "shortcuts": true, 46 - "settings": true 48 + "settings": true, 49 + "rules": true 47 50 }, 48 51 "commands": [ 49 52 {
+4 -6
features/pagestream/manifest.json
··· 38 38 "tag:item-added", 39 39 "tag:item-removed", 40 40 "sync:pull-completed", 41 - "tag-actions:get-all", 42 - "tag-actions:get-all:response", 43 - "tag-actions:create:response", 44 - "tag-actions:update:response", 45 - "tag-actions:delete:response" 41 + "affordance:click", 42 + "rules:changed" 46 43 ] 47 44 }, 48 45 "datastore": { ··· 55 52 }, 56 53 "commands": true, 57 54 "shortcuts": true, 58 - "settings": true 55 + "settings": true, 56 + "rules": true 59 57 }, 60 58 "commands": [ 61 59 {
+4 -6
features/search/manifest.json
··· 36 36 "tag:item-added", 37 37 "tag:item-removed", 38 38 "editor:open", 39 - "tag-actions:get-all", 40 - "tag-actions:get-all:response", 41 - "tag-actions:create:response", 42 - "tag-actions:update:response", 43 - "tag-actions:delete:response" 39 + "affordance:click", 40 + "rules:changed" 44 41 ] 45 42 }, 46 43 "datastore": { ··· 49 46 "window": { 50 47 "create": true 51 48 }, 52 - "commands": true 49 + "commands": true, 50 + "rules": true 53 51 }, 54 52 "commands": [ 55 53 {
+53 -240
features/tag-actions/background.js
··· 1 1 /** 2 - * Tag Actions Extension - Background Script 2 + * Tag Actions — background script handler. 3 3 * 4 - * Stores a simplified "tag pairs" config and converts it to action rules 5 - * for the affordances renderer. Each pair defines two tags that toggle 6 - * on item cards with a chosen icon style. 4 + * Owns the `tag-swap` script. The rule engine publishes 5 + * `scripts:execute:tag-swap` when an `affordance:click` event matches 6 + * a tag-swap rule; this tile wakes via `lazyEvents` (manifest), runs 7 + * the swap against the datastore, and goes back to sleep. 7 8 * 8 - * Data model (stored in settings): 9 - * pairs: [ 10 - * { activeTag: "todo", completedTag: "done", iconPair: "checkbox" }, 11 - * { activeTag: "readme", completedTag: "read", iconPair: "eye" } 12 - * ] 13 - * 14 - * Internally converts pairs to action rules so resolveAffordances() works unchanged. 9 + * The pair-management UI lives in home.js / home.html and writes 10 + * rules via api.rules. There's no derived state here — rules ARE the 11 + * pair definitions. 15 12 */ 16 13 17 - import { registerNoun, unregisterNoun } from 'peek://cmd/nouns.js'; 18 - 19 14 const api = window.app; 20 15 const debug = api.debug; 21 16 22 - // ==================== State ==================== 23 - 24 - let currentPairs = []; 25 - let currentActions = []; // derived from pairs 26 - 27 - // ==================== Settings ==================== 28 - 29 - const loadSettings = async () => { 30 - const result = await api.settings.get('data'); 31 - if (result.success && result.data) { 32 - // Support new pairs format 33 - if (Array.isArray(result.data.pairs)) { 34 - currentPairs = result.data.pairs; 35 - } 36 - // Migration: convert old actions format to pairs 37 - else if (Array.isArray(result.data.actions) && result.data.actions.length > 0) { 38 - currentPairs = migrateActionsToToPairs(result.data.actions); 39 - } else { 40 - currentPairs = []; 41 - } 42 - } else { 43 - currentPairs = []; 17 + /** 18 + * Execute the tag swap. 19 + * 20 + * Inputs (via the rule engine's `scripts:execute:<id>` payload): 21 + * - context.event.itemId — the item the affordance was clicked on 22 + * - context.event.checked — the affordance's NEW state (post-click) 23 + * - context.config.activeTag — tag name to use when unchecked 24 + * - context.config.completedTag — tag name to use when checked 25 + * 26 + * Going from unchecked → checked: remove activeTag, add completedTag. 27 + * Going from checked → unchecked: remove completedTag, add activeTag. 28 + */ 29 + const runTagSwap = async (context) => { 30 + const { event, config } = context || {}; 31 + if (!event || !config) { 32 + debug && console.warn('[tag-actions] tag-swap: missing event or config', context); 33 + return; 44 34 } 45 - 46 - // Derive action rules from pairs 47 - currentActions = pairsToActionRules(currentPairs); 48 - debug && console.log('[tag-actions] Loaded', currentPairs.length, 'pairs ->', currentActions.length, 'action rules'); 49 - }; 50 - 51 - const saveSettings = async () => { 52 - const result = await api.settings.set('data', { pairs: currentPairs }); 53 - if (!result.success) { 54 - console.error('[tag-actions] Failed to save settings:', result.error); 35 + const { itemId, checked } = event; 36 + const { activeTag, completedTag } = config; 37 + if (!itemId || !activeTag || !completedTag) { 38 + debug && console.warn('[tag-actions] tag-swap: missing itemId/activeTag/completedTag', { itemId, activeTag, completedTag }); 39 + return; 55 40 } 56 - // Regenerate action rules 57 - currentActions = pairsToActionRules(currentPairs); 58 - return result; 59 - }; 60 41 61 - // ==================== Migration ==================== 42 + const removeName = checked ? activeTag : completedTag; 43 + const addName = checked ? completedTag : activeTag; 62 44 63 - /** 64 - * Convert old-format actions array to new pairs format. 65 - * Only migrates toggle-type actions (the visual affordance type). 66 - */ 67 - const migrateActionsToToPairs = (actions) => { 68 - const pairs = []; 69 - for (const action of actions) { 70 - if (action.actionType === 'toggle' && action.actionConfig) { 71 - const config = action.actionConfig; 72 - // Map old icon names to new iconPair names 73 - let iconPair = 'checkbox'; 74 - if (config.icon === 'eye' || config.checkedIcon === 'eye') iconPair = 'eye'; 75 - else if (config.icon === 'star' || config.checkedIcon === 'star') iconPair = 'star'; 76 - else if (config.icon === 'heart' || config.checkedIcon === 'heart') iconPair = 'heart'; 77 - else if (config.icon === 'flag' || config.checkedIcon === 'flag') iconPair = 'flag'; 78 - else if (config.icon === 'bookmark' || config.checkedIcon === 'bookmark') iconPair = 'bookmark'; 79 - 80 - pairs.push({ 81 - activeTag: config.activeTag || action.triggerTag || '', 82 - completedTag: config.completedTag || '', 83 - iconPair 84 - }); 45 + try { 46 + const addTagRes = await api.datastore.getOrCreateTag(addName); 47 + if (!addTagRes.success) { 48 + console.error('[tag-actions] failed to get/create tag', addName, addTagRes.error); 49 + return; 50 + } 51 + const removeTagRes = await api.datastore.getOrCreateTag(removeName); 52 + if (!removeTagRes.success) { 53 + console.error('[tag-actions] failed to get tag for removal', removeName, removeTagRes.error); 54 + return; 85 55 } 56 + await api.datastore.untagItem(itemId, removeTagRes.data.tag.id); 57 + await api.datastore.tagItem(itemId, addTagRes.data.tag.id); 58 + debug && console.log('[tag-actions] tag-swap done', { itemId, removed: removeName, added: addName }); 59 + } catch (err) { 60 + console.error('[tag-actions] tag-swap threw:', err); 86 61 } 87 - return pairs; 88 62 }; 89 63 90 - // ==================== Pairs to Action Rules ==================== 91 - 92 - /** 93 - * Icon pair name to the icon/checkedIcon names used by tag-action-affordances.js 94 - */ 95 - const ICON_PAIR_MAP = { 96 - checkbox: { icon: 'unchecked', checkedIcon: 'checked' }, 97 - eye: { icon: 'eye-open', checkedIcon: 'eye-closed' }, 98 - star: { icon: 'star-empty', checkedIcon: 'star-filled' }, 99 - heart: { icon: 'heart-empty', checkedIcon: 'heart-filled' }, 100 - flag: { icon: 'flag-empty', checkedIcon: 'flag-filled' }, 101 - bookmark: { icon: 'bookmark-empty', checkedIcon: 'bookmark-filled' } 102 - }; 103 - 104 - /** 105 - * Convert simplified pairs to action rule objects compatible with resolveAffordances(). 106 - */ 107 - const pairsToActionRules = (pairs) => { 108 - return pairs 109 - .filter(p => p.activeTag && p.completedTag) 110 - .map((pair, i) => { 111 - const icons = ICON_PAIR_MAP[pair.iconPair] || ICON_PAIR_MAP.checkbox; 112 - return { 113 - id: `pair_${i}`, 114 - name: `${pair.activeTag}/${pair.completedTag}`, 115 - enabled: true, 116 - triggerTag: pair.activeTag, 117 - triggerOn: 'add', 118 - actionType: 'toggle', 119 - actionConfig: { 120 - activeTag: pair.activeTag, 121 - completedTag: pair.completedTag, 122 - icon: icons.icon, 123 - checkedIcon: icons.checkedIcon, 124 - color: '#007aff', 125 - checkedColor: '#34c759' 126 - }, 127 - itemTypes: null 128 - }; 129 - }); 130 - }; 131 - 132 - const generateId = () => { 133 - return `ta_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; 134 - }; 135 - 136 - // ==================== Event Handling ==================== 137 - 138 - // Toggle actions are purely UI affordances rendered by card UIs. 139 - // No auto-execution needed for tag events -- the user clicks the toggle. 140 - 141 - // ==================== Commands ==================== 142 - 143 - const openTagActions = () => { 144 - api.window.open('peek://tag-actions/home.html', { 145 - role: 'workspace', 146 - key: 'tag-actions-home', 147 - width: 560, 148 - height: 400, 149 - title: 'Tag Pairs' 150 - }); 151 - }; 152 - 153 - const registerCommands = () => { 154 - registerNoun({ 155 - name: 'tag actions', 156 - singular: 'tag action', 157 - description: 'Tag pair toggles on item cards', 158 - 159 - query: async ({ search }) => { 160 - let filtered = currentPairs; 161 - if (search) { 162 - const s = search.toLowerCase(); 163 - filtered = filtered.filter(p => 164 - p.activeTag.toLowerCase().includes(s) || 165 - p.completedTag.toLowerCase().includes(s) 166 - ); 167 - } 168 - if (filtered.length === 0) { 169 - return { output: 'No tag pairs found.', mimeType: 'text/plain' }; 170 - } 171 - return { 172 - success: true, 173 - output: { 174 - data: filtered, 175 - mimeType: 'application/json', 176 - title: `Tag Pairs (${filtered.length})` 177 - } 178 - }; 179 - }, 180 - 181 - browse: async () => { openTagActions(); }, 182 - 183 - create: async ({ search }) => { 184 - currentPairs.push({ activeTag: search || '', completedTag: '', iconPair: 'checkbox' }); 185 - await saveSettings(); 186 - openTagActions(); 187 - return { success: true }; 188 - }, 189 - 190 - produces: 'application/json' 191 - }); 192 - }; 193 - 194 - // ==================== Extension Lifecycle ==================== 195 - 196 64 const init = async () => { 197 - debug && console.log('[tag-actions] init'); 65 + debug && console.log('[tag-actions] background init (tag-swap handler)'); 198 66 199 - await loadSettings(); 200 - registerCommands(); 201 - 202 - // Respond to pairs CRUD from the home UI 203 - api.pubsub.subscribe('tag-actions:get-pairs', () => { 204 - api.pubsub.publish('tag-actions:get-pairs:response', { 205 - success: true, 206 - data: currentPairs 207 - }); 67 + api.pubsub.subscribe('scripts:execute:tag-swap', async (msg) => { 68 + await runTagSwap(msg && msg.context); 208 69 }); 209 - 210 - api.pubsub.subscribe('tag-actions:set-pairs', async (msg) => { 211 - if (Array.isArray(msg.pairs)) { 212 - currentPairs = msg.pairs; 213 - await saveSettings(); 214 - } 215 - api.pubsub.publish('tag-actions:set-pairs:response', { 216 - success: true, 217 - data: currentPairs 218 - }); 219 - }); 220 - 221 - // Backward-compatible: respond to get-all with derived action rules 222 - // so tag-action-affordances.js cache still works 223 - api.pubsub.subscribe('tag-actions:get-all', () => { 224 - api.pubsub.publish('tag-actions:get-all:response', { 225 - success: true, 226 - data: currentActions 227 - }); 228 - }); 229 - 230 - // Legacy CRUD endpoints -- respond gracefully but pairs is the source of truth now 231 - api.pubsub.subscribe('tag-actions:create', async (msg) => { 232 - // If it looks like a toggle action, convert to pair 233 - if (msg.actionType === 'toggle' && msg.actionConfig) { 234 - currentPairs.push({ 235 - activeTag: msg.actionConfig.activeTag || msg.triggerTag || '', 236 - completedTag: msg.actionConfig.completedTag || '', 237 - iconPair: 'checkbox' 238 - }); 239 - await saveSettings(); 240 - } 241 - api.pubsub.publish('tag-actions:create:response', { success: true }); 242 - }); 243 - 244 - api.pubsub.subscribe('tag-actions:update', async (msg) => { 245 - api.pubsub.publish('tag-actions:update:response', { success: true }); 246 - }); 247 - 248 - api.pubsub.subscribe('tag-actions:delete', async (msg) => { 249 - api.pubsub.publish('tag-actions:delete:response', { success: true }); 250 - }); 251 - 252 - debug && console.log('[tag-actions] initialized with', currentPairs.length, 'pairs'); 253 70 }; 254 71 255 - const uninit = () => { 256 - unregisterNoun('tag actions'); 257 - }; 72 + const uninit = () => {}; 258 73 259 74 export default { 260 75 id: 'tag-actions', 261 - labels: { 262 - name: 'Tag Actions' 263 - }, 76 + labels: { name: 'Tag Actions' }, 264 77 init, 265 - uninit 78 + uninit, 266 79 };
+165 -195
features/tag-actions/home.js
··· 1 1 /** 2 - * Tag Actions - Single Resident Tile 2 + * Tag Actions — pair management UI. 3 3 * 4 - * Combines background state management and UI in one tile (resident: true). 5 - * State (pairs, action rules), settings I/O, command registration, and 6 - * external pubsub handlers (tag-actions:get-all, :create, :update, :delete) 7 - * all live here. Internal get-pairs/set-pairs request-response replaced with 8 - * direct function calls. 4 + * Stage 3 of peek MCP plan 3d7d9239: pairs are NOT stored in settings 5 + * any more. Each pair is one row in the `rules` table: 6 + * topic : 'affordance:click' 7 + * scriptId: 'tag-swap' 8 + * match : { activeTag, completedTag } — fires this rule only 9 + * config : { activeTag, completedTag } — passed to tag-swap 10 + * display : { icon, checkedIcon, color, ..., iconPair } 9 11 * 10 - * Shows a flat list of tag pairs. Each pair has: 11 - * - Active tag (e.g. "todo") 12 - * - Completed tag (e.g. "done") 13 - * - Icon style (checkbox, eye, star, etc.) 12 + * The card UI (app/lib/tag-action-affordances.js) reads these via 13 + * api.rules.listAffordances() and publishes affordance:click on user 14 + * interaction; the rule engine (backend) routes that to 15 + * scripts:execute:tag-swap, which the background tile handles. 14 16 * 15 - * External consumers (tag-action-affordances.js) still use: 16 - * tag-actions:get-all / tag-actions:get-all:response 17 - * tag-actions:create:response, :update:response, :delete:response 17 + * One-shot migration runs on first load: any pairs found in the 18 + * legacy settings keys ('data' or 'pairs') are converted to rules and 19 + * the legacy keys are cleared. 18 20 */ 19 21 20 22 import { registerNoun, unregisterNoun } from 'peek://cmd/nouns.js'; ··· 51 53 } 52 54 }; 53 55 54 - // ==================== Default Pairs ==================== 56 + const ICON_PAIR_MAP = { 57 + checkbox: { icon: 'unchecked', checkedIcon: 'checked' }, 58 + eye: { icon: 'eye-open', checkedIcon: 'eye-closed' }, 59 + star: { icon: 'star-empty', checkedIcon: 'star-filled' }, 60 + heart: { icon: 'heart-empty', checkedIcon: 'heart-filled' }, 61 + flag: { icon: 'flag-empty', checkedIcon: 'flag-filled' }, 62 + bookmark: { icon: 'bookmark-empty', checkedIcon: 'bookmark-filled' } 63 + }; 55 64 56 65 const DEFAULT_PAIRS = [ 57 66 { activeTag: 'todo', completedTag: 'done', iconPair: 'checkbox' }, ··· 60 69 61 70 // ==================== State ==================== 62 71 63 - let currentPairs = []; 64 - let currentActions = []; // derived from pairs 72 + // In-memory mirror of the rules table for this feature. Each entry: 73 + // { id, suffix, activeTag, completedTag, iconPair } 74 + // where suffix is the per-row stable id passed to api.rules.register. 75 + let pairs = []; 65 76 66 - // ==================== Settings ==================== 77 + const buildDisplay = (iconPair) => { 78 + const icons = ICON_PAIR_MAP[iconPair] || ICON_PAIR_MAP.checkbox; 79 + return { 80 + icon: icons.icon, 81 + checkedIcon: icons.checkedIcon, 82 + color: '#007aff', 83 + checkedColor: '#34c759', 84 + iconPair, // home UI reads this to populate the icon-style dropdown 85 + }; 86 + }; 67 87 68 - const loadSettings = async () => { 69 - // Canonical storage is the schema-declared 'pairs' key (array of pair objects). 70 - // Prior versions wrote a combined object under 'data' ({ pairs: [...] } 71 - // or legacy { actions: [...] }). When both rows exist, 'data' is the 72 - // authoritative source — it's where the buggy code persisted the user's 73 - // most recent state — so migrate from 'data' first if present, then 74 - // re-save under 'pairs'. The stale 'data' row is left in place 75 - // (harmless; no foreign reads). 76 - const legacy = await api.settings.get('data'); 77 - if (legacy.success && legacy.data) { 78 - if (Array.isArray(legacy.data.pairs)) { 79 - currentPairs = legacy.data.pairs; 80 - } else if (Array.isArray(legacy.data.actions) && legacy.data.actions.length > 0) { 81 - currentPairs = migrateActionsToToPairs(legacy.data.actions); 82 - } else { 83 - currentPairs = []; 84 - } 85 - // Persist under the canonical key so subsequent loads skip the legacy branch. 86 - await api.settings.set('pairs', currentPairs); 87 - // Mark the legacy row as migrated so we never read it again. 88 - await api.settings.set('data', null); 89 - } else { 90 - const pairsResult = await api.settings.get('pairs'); 91 - currentPairs = (pairsResult.success && Array.isArray(pairsResult.data)) 92 - ? pairsResult.data 93 - : []; 88 + const ruleToPair = (rule) => ({ 89 + id: rule.id, 90 + suffix: rule.id.startsWith('rule_tag-actions_') 91 + ? rule.id.slice('rule_tag-actions_'.length) 92 + : rule.id, 93 + activeTag: (rule.config && rule.config.activeTag) || '', 94 + completedTag: (rule.config && rule.config.completedTag) || '', 95 + iconPair: (rule.display && rule.display.iconPair) || 'checkbox', 96 + }); 97 + 98 + const generateSuffix = () => `p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 99 + 100 + // ==================== Rules I/O ==================== 101 + 102 + const loadPairsFromRules = async () => { 103 + const res = await api.rules.list({ topic: 'affordance:click' }); 104 + if (!res || !Array.isArray(res.rules)) { 105 + pairs = []; 106 + return; 94 107 } 108 + pairs = res.rules 109 + .filter(r => r.scriptId === 'tag-swap') 110 + .map(ruleToPair); 111 + }; 95 112 96 - // Derive action rules from pairs 97 - currentActions = pairsToActionRules(currentPairs); 98 - debug && console.log('[tag-actions] Loaded', currentPairs.length, 'pairs ->', currentActions.length, 'action rules'); 113 + const upsertPair = async (pair) => { 114 + if (!pair.suffix) pair.suffix = generateSuffix(); 115 + const args = { 116 + id: pair.suffix, 117 + topic: 'affordance:click', 118 + scriptId: 'tag-swap', 119 + match: { activeTag: pair.activeTag, completedTag: pair.completedTag }, 120 + config: { activeTag: pair.activeTag, completedTag: pair.completedTag }, 121 + display: buildDisplay(pair.iconPair), 122 + enabled: true, 123 + }; 124 + const res = await api.rules.register(args); 125 + if (res && res.rule) pair.id = res.rule.id; 126 + else if (res && res.error) console.error('[tag-actions] register failed:', res.error); 99 127 }; 100 128 101 - const saveSettings = async () => { 102 - const result = await api.settings.set('pairs', currentPairs); 103 - if (!result.success) { 104 - console.error('[tag-actions] Failed to save settings:', result.error); 105 - } 106 - // Regenerate action rules 107 - currentActions = pairsToActionRules(currentPairs); 108 - return result; 129 + const deletePair = async (pair) => { 130 + if (!pair.id) return; 131 + const res = await api.rules.unregister(pair.id); 132 + if (res && res.error) console.error('[tag-actions] unregister failed:', res.error); 109 133 }; 110 134 111 - // ==================== Migration ==================== 135 + // ==================== One-shot migration ==================== 112 136 113 - /** 114 - * Convert old-format actions array to new pairs format. 115 - * Only migrates toggle-type actions (the visual affordance type). 116 - */ 117 - const migrateActionsToToPairs = (actions) => { 118 - const pairs = []; 119 - for (const action of actions) { 120 - if (action.actionType === 'toggle' && action.actionConfig) { 121 - const config = action.actionConfig; 122 - // Map old icon names to new iconPair names 123 - let iconPair = 'checkbox'; 124 - if (config.icon === 'eye' || config.checkedIcon === 'eye') iconPair = 'eye'; 125 - else if (config.icon === 'star' || config.checkedIcon === 'star') iconPair = 'star'; 126 - else if (config.icon === 'heart' || config.checkedIcon === 'heart') iconPair = 'heart'; 127 - else if (config.icon === 'flag' || config.checkedIcon === 'flag') iconPair = 'flag'; 128 - else if (config.icon === 'bookmark' || config.checkedIcon === 'bookmark') iconPair = 'bookmark'; 137 + const migrateLegacySettings = async () => { 138 + // Old shapes: { actions: [...] } (very old) or { pairs: [...] } (recent) 139 + // under either 'data' or 'pairs' settings keys. We only honour pair-shaped 140 + // rows here — legacy 'actions' was already simplified to pairs in v2. 141 + const candidatePairs = []; 142 + const seen = (p) => candidatePairs.some(c => c.activeTag === p.activeTag && c.completedTag === p.completedTag); 129 143 130 - pairs.push({ 131 - activeTag: config.activeTag || action.triggerTag || '', 132 - completedTag: config.completedTag || '', 133 - iconPair 134 - }); 144 + const dataRes = await api.settings.get('data'); 145 + if (dataRes.success && dataRes.data) { 146 + if (Array.isArray(dataRes.data.pairs)) { 147 + for (const p of dataRes.data.pairs) { 148 + if (p && p.activeTag && p.completedTag && !seen(p)) candidatePairs.push(p); 149 + } 135 150 } 136 151 } 137 - return pairs; 138 - }; 139 152 140 - // ==================== Pairs to Action Rules ==================== 153 + const pairsRes = await api.settings.get('pairs'); 154 + if (pairsRes.success && Array.isArray(pairsRes.data)) { 155 + for (const p of pairsRes.data) { 156 + if (p && p.activeTag && p.completedTag && !seen(p)) candidatePairs.push(p); 157 + } 158 + } 141 159 142 - /** 143 - * Icon pair name to the icon/checkedIcon names used by tag-action-affordances.js 144 - */ 145 - const ICON_PAIR_MAP = { 146 - checkbox: { icon: 'unchecked', checkedIcon: 'checked' }, 147 - eye: { icon: 'eye-open', checkedIcon: 'eye-closed' }, 148 - star: { icon: 'star-empty', checkedIcon: 'star-filled' }, 149 - heart: { icon: 'heart-empty', checkedIcon: 'heart-filled' }, 150 - flag: { icon: 'flag-empty', checkedIcon: 'flag-filled' }, 151 - bookmark: { icon: 'bookmark-empty', checkedIcon: 'bookmark-filled' } 152 - }; 160 + if (candidatePairs.length === 0) return 0; 153 161 154 - /** 155 - * Convert simplified pairs to action rule objects compatible with resolveAffordances(). 156 - */ 157 - const pairsToActionRules = (pairs) => { 158 - return pairs 159 - .filter(p => p.activeTag && p.completedTag) 160 - .map((pair, i) => { 161 - const icons = ICON_PAIR_MAP[pair.iconPair] || ICON_PAIR_MAP.checkbox; 162 - return { 163 - id: `pair_${i}`, 164 - name: `${pair.activeTag}/${pair.completedTag}`, 165 - enabled: true, 166 - triggerTag: pair.activeTag, 167 - triggerOn: 'add', 168 - actionType: 'toggle', 169 - actionConfig: { 170 - activeTag: pair.activeTag, 171 - completedTag: pair.completedTag, 172 - icon: icons.icon, 173 - checkedIcon: icons.checkedIcon, 174 - color: '#007aff', 175 - checkedColor: '#34c759' 176 - }, 177 - itemTypes: null 178 - }; 179 - }); 162 + let migrated = 0; 163 + for (const p of candidatePairs) { 164 + const np = { 165 + suffix: generateSuffix(), 166 + activeTag: p.activeTag, 167 + completedTag: p.completedTag, 168 + iconPair: p.iconPair || 'checkbox', 169 + }; 170 + await upsertPair(np); 171 + migrated++; 172 + } 173 + // Clear the legacy keys so we never migrate twice. 174 + await api.settings.set('data', null); 175 + await api.settings.set('pairs', null); 176 + debug && console.log('[tag-actions] migrated', migrated, 'legacy pair(s) to rules'); 177 + return migrated; 180 178 }; 181 179 182 180 // ==================== Commands ==================== ··· 198 196 description: 'Tag pair toggles on item cards', 199 197 200 198 query: async ({ search }) => { 201 - let filtered = currentPairs; 199 + let filtered = pairs; 202 200 if (search) { 203 201 const s = search.toLowerCase(); 204 202 filtered = filtered.filter(p => ··· 212 210 return { 213 211 success: true, 214 212 output: { 215 - data: filtered, 213 + data: filtered.map(p => ({ activeTag: p.activeTag, completedTag: p.completedTag, iconPair: p.iconPair })), 216 214 mimeType: 'application/json', 217 215 title: `Tag Pairs (${filtered.length})` 218 216 } ··· 222 220 browse: async () => { openTagActions(); }, 223 221 224 222 create: async ({ search }) => { 225 - currentPairs.push({ activeTag: search || '', completedTag: '', iconPair: 'checkbox' }); 226 - await saveSettings(); 223 + const np = { 224 + suffix: generateSuffix(), 225 + activeTag: search || '', 226 + completedTag: '', 227 + iconPair: 'checkbox', 228 + }; 229 + pairs.push(np); 230 + await upsertPair(np); 227 231 openTagActions(); 228 232 return { success: true }; 229 233 }, ··· 232 236 }); 233 237 }; 234 238 235 - // ==================== External Pubsub Handlers ==================== 236 - // These topics are consumed by tag-action-affordances.js (external shared lib) 237 - // and must remain as pubsub even in the single-tile model. 238 - 239 - const registerExternalHandlers = () => { 240 - // tag-action-affordances.js queries for action rules 241 - api.pubsub.subscribe('tag-actions:get-all', () => { 242 - api.pubsub.publish('tag-actions:get-all:response', { 243 - success: true, 244 - data: currentActions 245 - }); 246 - }); 247 - 248 - // Legacy CRUD endpoints -- respond gracefully; pairs is the source of truth 249 - api.pubsub.subscribe('tag-actions:create', async (msg) => { 250 - // If it looks like a toggle action, convert to pair 251 - if (msg.actionType === 'toggle' && msg.actionConfig) { 252 - currentPairs.push({ 253 - activeTag: msg.actionConfig.activeTag || msg.triggerTag || '', 254 - completedTag: msg.actionConfig.completedTag || '', 255 - iconPair: 'checkbox' 256 - }); 257 - await saveSettings(); 258 - } 259 - api.pubsub.publish('tag-actions:create:response', { success: true }); 260 - }); 261 - 262 - api.pubsub.subscribe('tag-actions:update', async (_msg) => { 263 - api.pubsub.publish('tag-actions:update:response', { success: true }); 264 - }); 265 - 266 - api.pubsub.subscribe('tag-actions:delete', async (_msg) => { 267 - api.pubsub.publish('tag-actions:delete:response', { success: true }); 268 - }); 269 - }; 270 - 271 239 // ==================== Escape Handler ==================== 272 240 273 - const handleEscape = () => { 274 - return { handled: false }; 275 - }; 241 + const handleEscape = () => ({ handled: false }); 276 242 277 243 // ==================== Rendering ==================== 278 244 ··· 280 246 const container = document.querySelector('.pairs-list'); 281 247 container.innerHTML = ''; 282 248 283 - if (currentPairs.length === 0) { 249 + if (pairs.length === 0) { 284 250 container.innerHTML = '<div class="empty-state">No tag pairs yet. Click + to create one.</div>'; 285 251 return; 286 252 } 287 253 288 - currentPairs.forEach((pair, index) => { 254 + pairs.forEach((pair, index) => { 289 255 container.appendChild(createPairRow(pair, index)); 290 256 }); 291 257 }; ··· 294 260 const row = document.createElement('div'); 295 261 row.className = 'pair-row'; 296 262 297 - // Icon preview 298 263 const iconPreview = document.createElement('div'); 299 264 iconPreview.className = 'pair-icon-preview'; 300 265 const style = ICON_STYLES[pair.iconPair] || ICON_STYLES.checkbox; 301 266 iconPreview.innerHTML = style.svg; 302 267 row.appendChild(iconPreview); 303 268 304 - // Tags area 305 269 const tagsArea = document.createElement('div'); 306 270 tagsArea.className = 'pair-tags'; 307 271 308 - // Active tag input 309 272 const activeInput = document.createElement('peek-input'); 310 273 activeInput.className = 'pair-tag-input'; 311 274 activeInput.value = pair.activeTag || ''; 312 275 activeInput.placeholder = 'active tag'; 313 - activeInput.addEventListener('change', (e) => { 276 + activeInput.addEventListener('change', async (e) => { 314 277 pair.activeTag = e.target.value.trim(); 315 - saveSettings(); 278 + await upsertPair(pair); 316 279 }); 317 280 tagsArea.appendChild(activeInput); 318 281 319 - // Arrow 320 282 const arrow = document.createElement('span'); 321 283 arrow.className = 'pair-arrow'; 322 284 arrow.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'; 323 285 tagsArea.appendChild(arrow); 324 286 325 - // Completed tag input 326 287 const completedInput = document.createElement('peek-input'); 327 288 completedInput.className = 'pair-tag-input'; 328 289 completedInput.value = pair.completedTag || ''; 329 290 completedInput.placeholder = 'completed tag'; 330 - completedInput.addEventListener('change', (e) => { 291 + completedInput.addEventListener('change', async (e) => { 331 292 pair.completedTag = e.target.value.trim(); 332 - saveSettings(); 293 + await upsertPair(pair); 333 294 }); 334 295 tagsArea.appendChild(completedInput); 335 296 336 297 row.appendChild(tagsArea); 337 298 338 - // Icon style selector 339 299 const iconSelect = document.createElement('select'); 340 300 iconSelect.className = 'pair-icon-select'; 341 301 for (const [key, def] of Object.entries(ICON_STYLES)) { ··· 345 305 opt.selected = pair.iconPair === key; 346 306 iconSelect.appendChild(opt); 347 307 } 348 - iconSelect.addEventListener('change', () => { 308 + iconSelect.addEventListener('change', async () => { 349 309 pair.iconPair = iconSelect.value; 350 - // Update icon preview 351 310 const newStyle = ICON_STYLES[pair.iconPair] || ICON_STYLES.checkbox; 352 311 iconPreview.innerHTML = newStyle.svg; 353 - saveSettings(); 312 + await upsertPair(pair); 354 313 }); 355 314 row.appendChild(iconSelect); 356 315 357 - // Delete button 358 316 const deleteBtn = document.createElement('button'); 359 317 deleteBtn.className = 'pair-delete-btn'; 360 318 deleteBtn.title = 'Remove pair'; 361 319 deleteBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'; 362 320 deleteBtn.addEventListener('click', async () => { 363 - currentPairs.splice(index, 1); 364 - await saveSettings(); 321 + const removed = pairs.splice(index, 1)[0]; 322 + if (removed) await deletePair(removed); 365 323 render(); 366 324 }); 367 325 row.appendChild(deleteBtn); ··· 374 332 const init = async () => { 375 333 debug && console.log('[tag-actions] init'); 376 334 377 - await loadSettings(); 335 + await migrateLegacySettings(); 336 + await loadPairsFromRules(); 378 337 registerCommands(); 379 - registerExternalHandlers(); 380 338 381 339 api.escape.onEscape(handleEscape); 382 340 383 - // If no pairs exist, populate defaults 384 - if (currentPairs.length === 0) { 385 - currentPairs = DEFAULT_PAIRS.map(p => ({ ...p })); 386 - await saveSettings(); 341 + // First-run defaults: only when nothing exists in rules either. 342 + if (pairs.length === 0) { 343 + for (const p of DEFAULT_PAIRS) { 344 + const np = { 345 + suffix: generateSuffix(), 346 + activeTag: p.activeTag, 347 + completedTag: p.completedTag, 348 + iconPair: p.iconPair, 349 + }; 350 + pairs.push(np); 351 + await upsertPair(np); 352 + } 387 353 } 388 354 389 - // Add pair button 390 355 document.querySelector('.add-pair-btn').addEventListener('click', async () => { 391 - currentPairs.push({ activeTag: '', completedTag: '', iconPair: 'checkbox' }); 392 - await saveSettings(); 356 + const np = { 357 + suffix: generateSuffix(), 358 + activeTag: '', 359 + completedTag: '', 360 + iconPair: 'checkbox', 361 + }; 362 + pairs.push(np); 363 + await upsertPair(np); 393 364 render(); 394 - // Focus the first empty input in the new row 395 365 requestAnimationFrame(() => { 396 366 const rows = document.querySelectorAll('.pair-row'); 397 367 const lastRow = rows[rows.length - 1]; ··· 402 372 }); 403 373 }); 404 374 405 - render(); 406 - 407 - // Proactive broadcast: consumers' first :get-all request may arrive before our 408 - // subscribe; pubsub doesn't buffer, so the cache would stay empty forever. 409 - api.pubsub.publish('tag-actions:get-all:response', { 410 - success: true, 411 - data: currentActions 375 + // Keep the UI in sync with external rule changes (other windows, future 376 + // user-facing rule editor). Rebuild from the table when something flips. 377 + api.pubsub.subscribe('rules:changed', async () => { 378 + await loadPairsFromRules(); 379 + render(); 412 380 }); 413 381 414 - debug && console.log('[tag-actions] initialized with', currentPairs.length, 'pairs'); 382 + render(); 383 + 384 + debug && console.log('[tag-actions] initialized with', pairs.length, 'pair(s)'); 415 385 }; 416 386 417 387 const uninit = () => {
+15 -11
features/tag-actions/manifest.json
··· 4 4 "shortname": "tag-actions", 5 5 "name": "Tag Actions", 6 6 "description": "Define custom actions triggered by tags", 7 - "version": "2.0.0", 7 + "version": "3.0.0", 8 8 "builtin": true, 9 9 "settingsSchema": "./settings-schema.json", 10 10 "tiles": [ 11 11 { 12 + "id": "background", 13 + "url": "background.html", 14 + "lazy": true, 15 + "lazyEvents": ["scripts:execute:tag-swap"] 16 + }, 17 + { 12 18 "id": "home", 13 19 "url": "home.html", 14 20 "width": 560, ··· 16 22 "title": "Tag Pairs", 17 23 "role": "workspace", 18 24 "key": "tag-actions-home", 19 - "resident": true 25 + "lazy": true 20 26 } 21 27 ], 22 28 "capabilities": { ··· 26 32 "ext:ready", 27 33 "ext:tag-actions:shutdown", 28 34 "app:shutdown", 29 - "tag-actions:get-all", 30 - "tag-actions:get-all:response", 31 - "tag-actions:create", 32 - "tag-actions:create:response", 33 - "tag-actions:update", 34 - "tag-actions:update:response", 35 - "tag-actions:delete", 36 - "tag-actions:delete:response" 35 + "scripts:execute:tag-swap", 36 + "rules:changed", 37 + "tag:added", 38 + "tag:removed" 37 39 ] 38 40 }, 39 41 "window": { 40 42 "create": true 41 43 }, 42 44 "commands": true, 43 - "settings": true 45 + "settings": true, 46 + "rules": true, 47 + "datastore": { "tables": ["items", "tags", "item_tags"] } 44 48 }, 45 49 "commands": [ 46 50 {
+4 -6
features/tags/manifest.json
··· 35 35 "ext:ready", 36 36 "ext:tags:shutdown", 37 37 "app:shutdown", 38 - "tag-actions:get-all", 39 - "tag-actions:get-all:response", 40 - "tag-actions:create:response", 41 - "tag-actions:update:response", 42 - "tag-actions:delete:response" 38 + "affordance:click", 39 + "rules:changed" 43 40 ] 44 41 }, 45 42 "datastore": { ··· 51 48 }, 52 49 "commands": true, 53 50 "shortcuts": true, 54 - "settings": { "readForeign": ["editor"] } 51 + "settings": { "readForeign": ["editor"] }, 52 + "rules": true 55 53 }, 56 54 "commands": [ 57 55 {
+21
schema/generated/sqlite-full.sql
··· 67 67 CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId); 68 68 CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId); 69 69 70 + -- Event-dispatch rules — declarative subscribers that tie an event topic + match predicate to a script. Drives the rule engine that lets features participate in event pipelines without being permanently resident. See peek MCP plan 3d7d9239. 71 + CREATE TABLE IF NOT EXISTS rules ( 72 + id TEXT PRIMARY KEY NOT NULL, 73 + featureId TEXT NOT NULL, 74 + topic TEXT NOT NULL, 75 + match TEXT DEFAULT '{}', 76 + scriptId TEXT NOT NULL, 77 + config TEXT DEFAULT '{}', 78 + display TEXT DEFAULT '', 79 + enabled INTEGER DEFAULT 1, 80 + source TEXT NOT NULL CHECK(source IN ('manifest', 'user', 'runtime')), 81 + ordering INTEGER DEFAULT 0, 82 + createdAt INTEGER NOT NULL, 83 + updatedAt INTEGER NOT NULL 84 + ); 85 + 86 + CREATE INDEX IF NOT EXISTS idx_rules_topic ON rules(topic); 87 + CREATE INDEX IF NOT EXISTS idx_rules_featureId ON rules(featureId); 88 + CREATE INDEX IF NOT EXISTS idx_rules_source ON rules(source); 89 + CREATE INDEX IF NOT EXISTS idx_rules_enabled ON rules(enabled); 90 + 70 91 -- Events/entries for series and feeds - append-only time-series data 71 92 CREATE TABLE IF NOT EXISTS item_events ( 72 93 id TEXT PRIMARY KEY NOT NULL,
+6
schema/generated/sqlite-sync.sql
··· 49 49 CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId); 50 50 CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId); 51 51 52 + -- Event-dispatch rules — declarative subscribers that tie an event topic + match predicate to a script. Drives the rule engine that lets features participate in event pipelines without being permanently resident. See peek MCP plan 3d7d9239. 53 + CREATE TABLE IF NOT EXISTS rules ( 54 + 55 + ); 56 + 57 + 52 58 -- Events/entries for series and feeds - append-only time-series data 53 59 CREATE TABLE IF NOT EXISTS item_events ( 54 60 id TEXT PRIMARY KEY NOT NULL,
+33
schema/generated/types.rs
··· 104 104 pub created_at: i64, 105 105 } 106 106 107 + /// Event-dispatch rules — declarative subscribers that tie an event topic + match predicate to a script. Drives the rule engine that lets features participate in event pipelines without being permanently resident. See peek MCP plan 3d7d9239. 108 + #[derive(Debug, Clone, Serialize, Deserialize)] 109 + pub struct SchemaRules { 110 + /// Stable rule id. SHA-256 content-hash for source='manifest' rows (so identical reboots don't churn the table); UUID for source='user' / 'runtime'. 111 + pub id: String, 112 + #[serde(rename = "featureId")] 113 + /// Owning feature id (or 'user' for user-created rules). Used to scope cleanup on feature uninstall. 114 + pub feature_id: String, 115 + /// Event topic the rule listens for. Trailing wildcard supported (e.g. 'page:*'). 116 + pub topic: String, 117 + /// JSON predicate evaluated against the event payload by the dispatcher. See plan 3d7d9239 Stage 2 for the language. 118 + pub match: String, 119 + #[serde(rename = "scriptId")] 120 + /// Script to execute when the predicate matches. References an entry in the scripts feature. 121 + pub script_id: String, 122 + /// JSON config passed to the script alongside the event (e.g. { activeTag, completedTag } for tag-swap). 123 + pub config: String, 124 + /// JSON UI affordance hints ({ icon, checkedIcon, color, checkedColor, label }). Empty = compute-only rule, no UI affordance. 125 + pub display: String, 126 + /// 0/1. Lets users disable a rule without deleting it. 127 + pub enabled: i64, 128 + /// Where the rule came from: 'manifest' (declared by a feature manifest), 'user' (rule-editor UI), 'runtime' (live api.rules.register). 129 + pub source: String, 130 + /// Tiebreaker when multiple rules match the same event. 131 + pub ordering: i64, 132 + #[serde(rename = "createdAt")] 133 + /// Creation timestamp (Unix ms). 134 + pub created_at: i64, 135 + #[serde(rename = "updatedAt")] 136 + /// Last update timestamp (Unix ms). 137 + pub updated_at: i64, 138 + } 139 + 107 140 /// Events/entries for series and feeds - append-only time-series data 108 141 #[derive(Debug, Clone, Serialize, Deserialize)] 109 142 pub struct SchemaItemEvents {
+30 -1
schema/generated/types.ts
··· 84 84 createdAt: number; 85 85 } 86 86 87 + /** Event-dispatch rules — declarative subscribers that tie an event topic + match predicate to a script. Drives the rule engine that lets features participate in event pipelines without being permanently resident. See peek MCP plan 3d7d9239. */ 88 + export interface SchemaRules { 89 + /** Stable rule id. SHA-256 content-hash for source='manifest' rows (so identical reboots don't churn the table); UUID for source='user' / 'runtime'. */ 90 + id: string; 91 + /** Owning feature id (or 'user' for user-created rules). Used to scope cleanup on feature uninstall. */ 92 + featureId: string; 93 + /** Event topic the rule listens for. Trailing wildcard supported (e.g. 'page:*'). */ 94 + topic: string; 95 + /** JSON predicate evaluated against the event payload by the dispatcher. See plan 3d7d9239 Stage 2 for the language. */ 96 + match: string; 97 + /** Script to execute when the predicate matches. References an entry in the scripts feature. */ 98 + scriptId: string; 99 + /** JSON config passed to the script alongside the event (e.g. { activeTag, completedTag } for tag-swap). */ 100 + config: string; 101 + /** JSON UI affordance hints ({ icon, checkedIcon, color, checkedColor, label }). Empty = compute-only rule, no UI affordance. */ 102 + display: string; 103 + /** 0/1. Lets users disable a rule without deleting it. */ 104 + enabled: number; 105 + /** Where the rule came from: 'manifest' (declared by a feature manifest), 'user' (rule-editor UI), 'runtime' (live api.rules.register). */ 106 + source: string; 107 + /** Tiebreaker when multiple rules match the same event. */ 108 + ordering: number; 109 + /** Creation timestamp (Unix ms). */ 110 + createdAt: number; 111 + /** Last update timestamp (Unix ms). */ 112 + updatedAt: number; 113 + } 114 + 87 115 /** Events/entries for series and feeds - append-only time-series data */ 88 116 export interface SchemaItemEvents { 89 117 /** Unique identifier (UUID) */ ··· 103 131 } 104 132 105 133 /** Valid sync table names */ 106 - export type SchemaSyncTableName = 'items' | 'tags' | 'item_tags' | 'item_events'; 134 + export type SchemaSyncTableName = 'items' | 'tags' | 'item_tags' | 'rules' | 'item_events'; 107 135 108 136 /** Required sync columns by table */ 109 137 export const REQUIRED_SYNC_COLUMNS: Record<SchemaSyncTableName, string[]> = { ··· 111 139 tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 112 140 item_tags: ["itemId","tagId","createdAt"], 113 141 item_events: ["id","itemId","content","value","occurredAt","metadata","createdAt"], 142 + rules: [], 114 143 };
+2
schema/generated/validate.js
··· 18 18 tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 19 19 item_tags: ["itemId","tagId","createdAt"], 20 20 item_events: ["id","itemId","content","value","occurredAt","metadata","createdAt"], 21 + rules: [], 21 22 }; 22 23 23 24 const missing = []; ··· 61 62 tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 62 63 item_tags: ["itemId","tagId","createdAt"], 63 64 item_events: ["id","itemId","content","value","occurredAt","metadata","createdAt"], 65 + rules: [], 64 66 };
+88 -1
schema/v1.json
··· 252 252 ] 253 253 }, 254 254 255 + "rules": { 256 + "description": "Event-dispatch rules — declarative subscribers that tie an event topic + match predicate to a script. Drives the rule engine that lets features participate in event pipelines without being permanently resident. See peek MCP plan 3d7d9239.", 257 + "columns": { 258 + "id": { 259 + "type": "text", 260 + "primary_key": true, 261 + "not_null": true, 262 + "sync": false, 263 + "description": "Stable rule id. SHA-256 content-hash for source='manifest' rows (so identical reboots don't churn the table); UUID for source='user' / 'runtime'." 264 + }, 265 + "featureId": { 266 + "type": "text", 267 + "not_null": true, 268 + "sync": false, 269 + "description": "Owning feature id (or 'user' for user-created rules). Used to scope cleanup on feature uninstall." 270 + }, 271 + "topic": { 272 + "type": "text", 273 + "not_null": true, 274 + "sync": false, 275 + "description": "Event topic the rule listens for. Trailing wildcard supported (e.g. 'page:*')." 276 + }, 277 + "match": { 278 + "type": "text", 279 + "default": "'{}'", 280 + "sync": false, 281 + "description": "JSON predicate evaluated against the event payload by the dispatcher. See plan 3d7d9239 Stage 2 for the language." 282 + }, 283 + "scriptId": { 284 + "type": "text", 285 + "not_null": true, 286 + "sync": false, 287 + "description": "Script to execute when the predicate matches. References an entry in the scripts feature." 288 + }, 289 + "config": { 290 + "type": "text", 291 + "default": "'{}'", 292 + "sync": false, 293 + "description": "JSON config passed to the script alongside the event (e.g. { activeTag, completedTag } for tag-swap)." 294 + }, 295 + "display": { 296 + "type": "text", 297 + "default": "''", 298 + "sync": false, 299 + "description": "JSON UI affordance hints ({ icon, checkedIcon, color, checkedColor, label }). Empty = compute-only rule, no UI affordance." 300 + }, 301 + "enabled": { 302 + "type": "integer", 303 + "default": "1", 304 + "sync": false, 305 + "description": "0/1. Lets users disable a rule without deleting it." 306 + }, 307 + "source": { 308 + "type": "text", 309 + "not_null": true, 310 + "check": "source IN ('manifest', 'user', 'runtime')", 311 + "sync": false, 312 + "description": "Where the rule came from: 'manifest' (declared by a feature manifest), 'user' (rule-editor UI), 'runtime' (live api.rules.register)." 313 + }, 314 + "ordering": { 315 + "type": "integer", 316 + "default": "0", 317 + "sync": false, 318 + "description": "Tiebreaker when multiple rules match the same event." 319 + }, 320 + "createdAt": { 321 + "type": "integer", 322 + "not_null": true, 323 + "sync": false, 324 + "description": "Creation timestamp (Unix ms)." 325 + }, 326 + "updatedAt": { 327 + "type": "integer", 328 + "not_null": true, 329 + "sync": false, 330 + "description": "Last update timestamp (Unix ms)." 331 + } 332 + }, 333 + "indexes": [ 334 + { "name": "idx_rules_topic", "columns": ["topic"], "sync": false }, 335 + { "name": "idx_rules_featureId", "columns": ["featureId"], "sync": false }, 336 + { "name": "idx_rules_source", "columns": ["source"], "sync": false }, 337 + { "name": "idx_rules_enabled", "columns": ["enabled"], "sync": false } 338 + ] 339 + }, 340 + 255 341 "item_events": { 256 342 "description": "Events/entries for series and feeds - append-only time-series data", 257 343 "columns": { ··· 314 400 "items": ["id", "type", "content", "syncId", "syncedAt", "createdAt", "updatedAt", "deletedAt"], 315 401 "tags": ["id", "name", "frequency", "lastUsed", "frecencyScore", "createdAt", "updatedAt"], 316 402 "item_tags": ["itemId", "tagId", "createdAt"], 317 - "item_events": ["id", "itemId", "content", "value", "occurredAt", "metadata", "createdAt"] 403 + "item_events": ["id", "itemId", "content", "value", "occurredAt", "metadata", "createdAt"], 404 + "rules": [] 318 405 } 319 406 } 320 407 }
+73 -45
tests/desktop/tag-actions-toggles.spec.ts
··· 1 1 /** 2 2 * Tag Action Toggles — render + click coverage across consumer tiles. 3 3 * 4 - * Regression coverage for the 2026-04-26 bug where the toggle affordance 5 - * silently never rendered on item cards because consumer manifests 6 - * (tags/groups/search/pagestream) didn't list `tag-actions:*` in their 7 - * pubsub topic allowlist, and because the cold-start subscribe-before-publish 8 - * race could leave the consumer rules cache empty. 4 + * Stage 3 of peek MCP plan 3d7d9239: tag-actions migrated from a 5 + * resident extension serving `tag-actions:*` pubsub queries into the 6 + * rule engine. Pairs are now rules (`topic: 'affordance:click'`, 7 + * `scriptId: 'tag-swap'`); the card UI reads them via 8 + * `api.rules.listAffordances()` and publishes `affordance:click` on 9 + * user interaction; the rule engine routes that to 10 + * `scripts:execute:tag-swap`, which the lazy tag-actions background 11 + * tile handles. 9 12 * 10 13 * Tests: 11 - * 1. tags/home — full pipeline: toggle renders on a todo-tagged card, 12 - * clicking it swaps `todo` → `done`. 13 - * 2-4. groups/search/pagestream — gate-permits round-trip: each consumer 14 - * window can publish `tag-actions:get-all` and receive a non-empty 15 - * action rules response. 14 + * 1. tags/home — full pipeline: register a tag-swap rule, render a 15 + * todo-tagged item, click toggle, verify tags swapped. 16 + * 2-3. groups/pagestream — gate-permits round-trip: each consumer 17 + * can call api.rules.listAffordances and see the registered rule. 18 + * 4. search — static manifest assertion (workspace-key collapse 19 + * blocks a fresh runtime test there; manifest grant is what 20 + * matters for the gate). 16 21 */ 17 22 import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 18 23 import { Page } from '@playwright/test'; ··· 24 29 25 30 test.beforeAll(async () => { 26 31 ({ app, bgWindow } = await createPerDescribeApp('tag-action-toggles')); 32 + 33 + // Seed a tag-swap rule so the affordance renders. The bg window 34 + // is a core renderer (trustedBuiltin) so api.rules.* bypasses the 35 + // per-feature capability check. 36 + await bgWindow.evaluate(async () => { 37 + const api = (window as any).app; 38 + await api.rules.register({ 39 + id: 'todo-done-test', 40 + topic: 'affordance:click', 41 + scriptId: 'tag-swap', 42 + match: { activeTag: 'todo', completedTag: 'done' }, 43 + config: { activeTag: 'todo', completedTag: 'done' }, 44 + display: { 45 + icon: 'unchecked', 46 + checkedIcon: 'checked', 47 + color: '#007aff', 48 + checkedColor: '#34c759', 49 + iconPair: 'checkbox', 50 + }, 51 + enabled: true, 52 + }); 53 + }); 27 54 }); 28 55 29 56 test.afterAll(async () => { ··· 31 58 }); 32 59 33 60 /** 34 - * In a consumer window, subscribe to tag-actions:get-all:response, publish 35 - * tag-actions:get-all, and resolve with the response payload. Validates 36 - * (a) the consumer manifest grants the topics (gate would reject otherwise) 37 - * and (b) tag-actions/home is registered as a responder. 61 + * Cross-feature read: any consumer with the rules capability can 62 + * see every enabled rule with a non-empty display block. 38 63 */ 39 - async function fetchActionRules(window: Page): Promise<{ success: boolean; data: any }> { 64 + async function fetchAffordanceRules(window: Page): Promise<{ success: boolean; data: any }> { 40 65 return await window.evaluate(async () => { 41 66 const api = (window as any).app; 42 - return await new Promise((resolve) => { 43 - const timeout = setTimeout(() => resolve({ success: false, data: null }), 5000); 44 - const unsub = api.pubsub.subscribe('tag-actions:get-all:response', (msg: any) => { 45 - clearTimeout(timeout); 46 - if (typeof unsub === 'function') unsub(); 47 - resolve({ success: !!msg.success, data: msg.data }); 48 - }); 49 - api.pubsub.publish('tag-actions:get-all', {}); 50 - }); 67 + try { 68 + const res = await api.rules.listAffordances(); 69 + return { success: !!(res && Array.isArray(res.rules)), data: res?.rules ?? null }; 70 + } catch (err: any) { 71 + return { success: false, data: null, error: err?.message }; 72 + } 51 73 }); 52 74 } 53 75 ··· 55 77 const ts = Date.now(); 56 78 const testUri = `https://tag-actions-toggle-test-${ts}.example.com/`; 57 79 58 - // Create the test item tagged `todo` (tag-actions default active tag). 80 + // Create the test item tagged `todo`. 59 81 const setup = await bgWindow.evaluate(async (uri: string) => { 60 82 const api = (window as any).app; 61 83 const todo = await api.datastore.getOrCreateTag('todo'); ··· 97 119 ); 98 120 expect(initialChecked).toBe(false); 99 121 100 - // Click toggle — should remove `todo`, add `done`. 122 + // Click toggle — publishes affordance:click → rule engine routes to 123 + // scripts:execute:tag-swap → tag-actions background tile loads 124 + // (lazyEvents) → runs the swap. 101 125 await tagsWindow.click(toggleSelector); 102 126 103 - // Wait for the optimistic visual flip + datastore round-trip. 127 + // Optimistic visual flip is immediate; datastore round-trip is async. 104 128 await tagsWindow.waitForFunction( 105 129 (sel) => { 106 130 const btn = document.querySelector(sel) as HTMLElement | null; ··· 110 134 { timeout: 5000 } 111 135 ); 112 136 113 - // Verify datastore: item now has `done`, not `todo`. 137 + // Verify datastore: item now has `done`, not `todo`. The lazy bg 138 + // tile boot can take a moment on cold start. 139 + await expect.poll(async () => { 140 + return await bgWindow.evaluate(async (itemId: string) => { 141 + const result = await (window as any).app.datastore.getItemTags(itemId); 142 + return result.success ? result.data.map((t: any) => t.name) : null; 143 + }, setup.itemId); 144 + }, { timeout: 10000 }).toEqual(expect.arrayContaining(['done'])); 145 + 114 146 const tagsAfter = await bgWindow.evaluate(async (itemId: string) => { 115 147 const result = await (window as any).app.datastore.getItemTags(itemId); 116 148 return result.success ? result.data.map((t: any) => t.name) : null; ··· 118 150 expect(tagsAfter).toContain('done'); 119 151 expect(tagsAfter).not.toContain('todo'); 120 152 121 - // Cleanup 122 153 if (open.id) { 123 154 try { 124 155 await bgWindow.evaluate(async (id: number) => { ··· 128 159 } 129 160 }); 130 161 131 - test('groups/home gate permits tag-actions:get-all round-trip', async () => { 162 + test('groups/home gate permits api.rules.listAffordances round-trip', async () => { 132 163 const open = await bgWindow.evaluate(async () => { 133 164 return await (window as any).app.window.open('peek://groups/home.html', { 134 165 width: 800, height: 600, key: 'groups-tag-actions-test' ··· 140 171 expect(groupsWindow).toBeTruthy(); 141 172 await groupsWindow.waitForLoadState('domcontentloaded'); 142 173 143 - const response = await fetchActionRules(groupsWindow); 174 + const response = await fetchAffordanceRules(groupsWindow); 144 175 expect(response.success).toBe(true); 145 176 expect(Array.isArray(response.data)).toBe(true); 146 177 expect(response.data.length).toBeGreaterThan(0); ··· 154 185 } 155 186 }); 156 187 157 - test('search manifest declares tag-actions topics', async () => { 158 - // Static manifest check — search/home has a separate, pre-existing pubsub 159 - // initialization quirk (its workspace key collapses opens, blocking a 160 - // runtime round-trip from a fresh test window) that's out of scope for 161 - // this fix. The manifest-level gate grant is what matters for the bug, 162 - // and the gate logic is identical to groups/pagestream which are 163 - // exercised at runtime above. 188 + test('search manifest declares the rules capability + affordance topics', async () => { 189 + // Static manifest check — search/home has a separate, pre-existing 190 + // workspace-key quirk (its key collapses opens) that blocks a runtime 191 + // round-trip from a fresh test window. The manifest-level grant is 192 + // what matters for the gate; the gate logic is identical to the 193 + // runtime-tested groups/pagestream. 164 194 const manifest = await bgWindow.evaluate(async () => { 165 195 const res = await fetch('peek://search/manifest.json'); 166 196 return await res.json(); 167 197 }); 168 198 const topics = manifest?.capabilities?.pubsub?.topics ?? []; 169 - expect(topics).toContain('tag-actions:get-all'); 170 - expect(topics).toContain('tag-actions:get-all:response'); 171 - expect(topics).toContain('tag-actions:create:response'); 172 - expect(topics).toContain('tag-actions:update:response'); 173 - expect(topics).toContain('tag-actions:delete:response'); 199 + expect(topics).toContain('affordance:click'); 200 + expect(topics).toContain('rules:changed'); 201 + expect(manifest?.capabilities?.rules).toBe(true); 174 202 }); 175 203 176 - test('pagestream/home gate permits tag-actions:get-all round-trip', async () => { 204 + test('pagestream/home gate permits api.rules.listAffordances round-trip', async () => { 177 205 const open = await bgWindow.evaluate(async () => { 178 206 return await (window as any).app.window.open('peek://pagestream/home.html', { 179 207 width: 600, height: 800, key: 'pagestream-tag-actions-test' ··· 185 213 expect(psWindow).toBeTruthy(); 186 214 await psWindow.waitForLoadState('domcontentloaded'); 187 215 188 - const response = await fetchActionRules(psWindow); 216 + const response = await fetchAffordanceRules(psWindow); 189 217 expect(response.success).toBe(true); 190 218 expect(Array.isArray(response.data)).toBe(true); 191 219 expect(response.data.length).toBeGreaterThan(0);