diff --git a/src/components/automations/AutomationsSegment.tsx b/src/components/automations/AutomationsSegment.tsx index 4b487c0..ed7cbac 100644 --- a/src/components/automations/AutomationsSegment.tsx +++ b/src/components/automations/AutomationsSegment.tsx @@ -41,13 +41,11 @@ export function AutomationsSegment({ data }: { data: unknown }) { view.automations.map((a) => ) )} + {/* The desktop's always-on system floor (bsc-deny / bsc-confine / bsc-scope) is NOT on + the wire — it is a module constant the projector never reads (#239). The note that + used to sit here was gated on a `builtin` flag that cannot exist, so it never + rendered. It returns when the payload grows a `systemHooks` field. */} Hooks - {view.hasSystemFloor && ( - - Built-in hooks are the desktop's system floor — always on, under every - permission posture. - - )} {view.hooks.length === 0 ? ( No hooks configured on the desktop yet. ) : ( @@ -69,9 +67,9 @@ function AutomationCard({ automation: a }: { automation: AutomationVM }) { + {/* Schedule only — the dispatch target is deliberately withheld desktop-side (#239). */} {a.whenLabel} - {a.targetLabel ? ` → ${a.targetLabel}` : ''} @@ -110,7 +108,6 @@ function HookRow({ hook: h }: { hook: HookVM }) { {h.name} - {h.builtin && built-in} {h.event} @@ -128,7 +125,6 @@ function HookRow({ hook: h }: { hook: HookVM }) { const styles = StyleSheet.create({ list: { padding: 16, gap: 10, paddingBottom: 28 }, sectionGap: { marginTop: 14 }, - floorNote: { fontSize: 11.5, lineHeight: 16 }, card: { padding: 14, gap: 6 }, titleLine: { flexDirection: 'row', alignItems: 'center', gap: 8 }, diff --git a/src/lib/mirror/automationsView.test.ts b/src/lib/mirror/automationsView.test.ts index f86804a..87dd30e 100644 --- a/src/lib/mirror/automationsView.test.ts +++ b/src/lib/mirror/automationsView.test.ts @@ -64,40 +64,31 @@ describe('selectAutomationsView', () => { assert.equal(v.automations[0].runs[0].status, 'unknown'); }); - it('shows the target pane only when the payload carries it', () => { - const v = selectAutomationsView(payload); - assert.equal(v.automations[0].targetLabel, null); // today's projection omits it - const withTarget = selectAutomationsView({ - automations: [{ id: 'a', targetTab: 'Build', targetPaneIdx: 1 }], - hooks: [], - }); - assert.equal(withTarget.automations[0].targetLabel, 'Build · pane 2'); - const tabOnly = selectAutomationsView({ - automations: [{ id: 'a', targetTab: 'Build' }], - hooks: [], - }); - assert.equal(tabOnly.automations[0].targetLabel, 'Build'); - }); - it('maps hooks with matcher and scope', () => { const v = selectAutomationsView(payload); assert.equal(v.hooks.length, 2); assert.deepEqual(v.hooks[0], { id: 'h1', name: 'Lint gate', enabled: true, event: 'PreToolUse', - matcher: 'Bash', scopeLabel: 'Global', builtin: false, + matcher: 'Bash', scopeLabel: 'Global', }); assert.equal(v.hooks[1].matcher, null); assert.equal(v.hooks[1].scopeLabel, '2 projects'); - assert.equal(v.hasSystemFloor, false); }); - it('flags the system floor when the payload marks built-in hooks', () => { + /** + * #239: the VM must not regrow a field the desktop has ruled out. `targetTab` / + * `targetPaneIdx` are withheld with a regression test pinning their absence, and a hook + * `builtin` flag cannot exist — `Hook` is exclusively user-authored config. Reading either + * produced UI that could never render, which is how the dead system-floor note survived. + */ + it('ignores fields the desktop deliberately never sends', () => { const v = selectAutomationsView({ - automations: [], - hooks: [{ id: 'floor', name: 'bsc-deny', enabled: true, event: 'PreToolUse', projects: [], builtin: true }], + automations: [{ id: 'a', targetTab: 'Build', targetPaneIdx: 1 }], + hooks: [{ id: 'h', name: 'x', enabled: true, event: 'PreToolUse', projects: [], builtin: true }], }); - assert.equal(v.hasSystemFloor, true); - assert.equal(v.hooks[0].builtin, true); + assert.equal('targetLabel' in v.automations[0], false); + assert.equal('builtin' in v.hooks[0], false); + assert.equal('hasSystemFloor' in v, false); }); it('tolerates missing/partial fields', () => { diff --git a/src/lib/mirror/automationsView.ts b/src/lib/mirror/automationsView.ts index 7f17378..d4d3f73 100644 --- a/src/lib/mirror/automationsView.ts +++ b/src/lib/mirror/automationsView.ts @@ -6,9 +6,21 @@ * AutomationCard = { id, name, armed, when, lastRunAt, nextRunAt, runs } * HookCard = { id, name, enabled, event, matcher?, projects } * - * The projection does not (yet) carry the automation's target pane or a - * built-in marker on hooks; both are read tolerantly so they light up the - * moment the desktop starts publishing them, and render as absent until then. + * Two fields this file used to read speculatively are GONE (#239). Neither was + * "not yet published" — the desktop has ruled both out, with regression tests + * pinning their absence: + * + * - `targetTab` / `targetPaneIdx`: withheld on purpose. `storeProjections.test.ts` + * asserts the serialized payload does not contain "targetTab" — the card is + * schedule + outcome, the dispatch target stays desktop-side. + * - `builtin` on a hook: cannot exist. `Hook` is exclusively user-authored + * config, and `HookCard` field-exactness is asserted desktop-side. + * + * The desktop's always-on system floor (`SYSTEM_HOOKS` — bsc-deny, bsc-confine, + * bsc-scope) is a module constant the projector never reads, so it crosses no + * frame at all. Surfacing it needs a new `systemHooks` field on the payload; + * until that lands there is nothing here to render, and pretending otherwise + * showed the user an empty hook list implying nothing is enforced. */ import { @@ -30,8 +42,6 @@ export type AutomationVM = { armed: boolean; /** Human cadence label derived from the `when` union ("every day at 09:00"). */ whenLabel: string; - /** "Tab · pane N" when the payload carries the target; null until it does. */ - targetLabel: string | null; lastRunAt: number | null; nextRunAt: number | null; /** Newest first, capped to the projection's 10. */ @@ -45,20 +55,14 @@ export type HookVM = { event: string; matcher: string | null; scopeLabel: string; - /** Marked by the payload as part of the desktop's always-on system floor. */ - builtin: boolean; }; export type AutomationsView = { automations: AutomationVM[]; hooks: HookVM[]; - /** Any hook is marked built-in → show the system-floor note row. */ - hasSystemFloor: boolean; }; -export const EMPTY_AUTOMATIONS_VIEW: AutomationsView = { - automations: [], hooks: [], hasSystemFloor: false, -}; +export const EMPTY_AUTOMATIONS_VIEW: AutomationsView = { automations: [], hooks: [] }; /** Runs shown per schedule card (mirrors the desktop projection cap). */ export const RUNS_SHOWN = 10; @@ -94,13 +98,6 @@ function toAutomation(raw: unknown, index: number): AutomationVM | null { if (!r) return null; const id = readString(r.id, '') || `automation-${index}`; - // Target pane — not in today's projection; render when the desktop adds it. - const targetTab = readString(r.targetTab, '').trim(); - const paneIdx = readNumOrNull(r.targetPaneIdx); - const targetLabel = targetTab - ? paneIdx !== null ? `${targetTab} · pane ${paneIdx + 1}` : targetTab - : null; - const runs = asArray(r.runs) .map(toRun) .filter((run): run is AutomationRunVM => run !== null) @@ -112,7 +109,6 @@ function toAutomation(raw: unknown, index: number): AutomationVM | null { name: readString(r.name, '').trim() || id, armed: readBool(r.armed, false), whenLabel: formatWhen(r.when), - targetLabel, lastRunAt: readNumOrNull(r.lastRunAt), nextRunAt: readNumOrNull(r.nextRunAt), runs, @@ -130,7 +126,6 @@ function toHook(raw: unknown, index: number): HookVM | null { event: readString(r.event, '').trim() || '—', matcher: readString(r.matcher, '').trim() || null, scopeLabel: scopeLabel(r.projects), - builtin: readBool(r.builtin, false), }; } @@ -144,5 +139,5 @@ export function selectAutomationsView(data: unknown): AutomationsView { const hooks = asArray(root.hooks) .map(toHook) .filter((h): h is HookVM => h !== null); - return { automations, hooks, hasSystemFloor: hooks.some((h) => h.builtin) }; + return { automations, hooks }; } diff --git a/src/lib/tunnel/storePayloads.fixtures.test.ts b/src/lib/tunnel/storePayloads.fixtures.test.ts index 08f0de7..0e517f8 100644 --- a/src/lib/tunnel/storePayloads.fixtures.test.ts +++ b/src/lib/tunnel/storePayloads.fixtures.test.ts @@ -41,6 +41,7 @@ import { selectGlance, glanceL0Input } from '../pages/glancePage'; import { selectOrg, teamToOrgInput } from '../pages/orgPage'; import { selectThemes, groupThemes, OTHER_THEME_GROUP } from '../pages/designPage'; import { selectSkills } from '../pages/skillsPage'; +import { selectAutomationsView } from '../mirror/automationsView'; import { buildGlanceScene, buildOrgScene } from '../graph'; import { selectSecurityView } from '../mirror/securityView'; import { parseAlertsPayload, alertTarget } from '../alerts/model'; @@ -80,7 +81,6 @@ const PENDING_DOMAINS: Record = { // through verbatim. The fixture therefore shows a pared-looking kit the real wire would not // produce. Catching pass-through bloat needs the fixture INPUT to carry the optional fields. components: '#241 — C1/C3/C4 desktop-side; fixture input does not exercise kit pass-through', - automations: '#239 — system hook floor never projected; two dead reads', mcp: '#240 — built-in servers invisible; version read is unfixable', }; @@ -343,6 +343,43 @@ function decodeSkills(o: Raw): Raw { return out; } +/** + * `automations` — schedule + outcome only. The dispatch target (`targetTab` / `targetPaneIdx`) + * is withheld desktop-side with a regression test pinning its absence, and `HookCard` is + * field-exact with no `builtin`: `Hook` is exclusively user-authored config. #239 deleted the + * mobile reads that waited on both. + * + * `runs[].at` is epoch ms on both sides — the ISO-string trap that broke `security` does not + * occur here. The desktop caps `runs` at 10 (`AUTOMATION_RUNS_CAP`), asserted desktop-side. + * + * NOT covered: the always-on system floor (`SYSTEM_HOOKS` — bsc-deny / bsc-confine / bsc-scope) + * is a module constant the projector never reads, so it crosses no frame and no decoder can + * see it. Needs a `systemHooks` field on the payload. + */ +function decodeAutomations(o: Raw): Raw { + return { + automations: arr(o, 'automations').map((a) => { + const out: Raw = { + id: str(a, 'id'), name: str(a, 'name'), armed: bool(a, 'armed'), + when: a.when as Raw, + lastRunAt: num(a, 'lastRunAt'), nextRunAt: num(a, 'nextRunAt'), + runs: arr(a, 'runs').map((r) => ({ + at: num(r, 'at'), status: str(r, 'status'), note: str(r, 'note'), + })), + }; + return out; + }), + hooks: arr(o, 'hooks').map((h) => { + const out: Raw = { + id: str(h, 'id'), name: str(h, 'name'), enabled: bool(h, 'enabled'), + event: str(h, 'event'), projects: strArr(h, 'projects'), + }; + copyOptStr(h, out, 'matcher'); + return out; + }), + }; +} + const DECODERS: Record Raw> = { glance: decodeGlance, security: decodeSecurity, @@ -350,6 +387,7 @@ const DECODERS: Record Raw> = { org: decodeOrg, themes: decodeThemes, skills: decodeSkills, + automations: decodeAutomations, }; // ── Coverage + vocabulary guards ──────────────────────────────────────────────────────────── @@ -588,6 +626,33 @@ test('Layer B: skills — the no-lessons variant parses as null, not as an empty assert.equal(model.skills.length, 1, 'the library must survive a null lessons block'); }); +test('Layer B: automations — the schedule card renders from real values', () => { + const view = selectAutomationsView(fx.domains.automations); + assert.equal(view.automations.length, 1, 'the canonical automation did not survive the parse'); + + const a = view.automations[0]; + assert.equal(a.name, 'Nightly triage'); + assert.notEqual(a.name, a.id, 'the name fell back to the id'); + assert.equal(a.armed, true); + assert.equal(a.whenLabel, 'every day at 02:00', 'the `when` union must format, not fall back to —'); + assert.notEqual(a.whenLabel, '—'); + assert.equal(a.lastRunAt, 200); + assert.equal(a.nextRunAt, 300); + + // Runs: newest first, statuses mapped — `unknown` is the fallback, so it must not appear. + assert.deepEqual(a.runs.map((r) => r.at), [200, 100]); + assert.equal(a.runs[0].status, 'ok'); + assert.equal(a.runs[1].status, 'skipped'); + assert.equal(a.runs[0].note, 'triaged 4 issues'); + for (const r of a.runs) assert.notEqual(r.status, 'unknown', 'a run status fell back'); + + const h = view.hooks[0]; + assert.equal(h.name, 'deny-floor'); + assert.equal(h.enabled, true); + assert.equal(h.event, 'PreToolUse'); + assert.notEqual(h.event, '—'); +}); + // ── The invariant that gives Layer B its teeth ─────────────────────────────────────────────── test('the fixture states the no-fallback-values invariant', () => {