From 1c165b49a96ec5657de88a74536edb4ecf280bc0 Mon Sep 17 00:00:00 2001 From: Kevin Lago Date: Wed, 29 Jul 2026 02:22:54 -0400 Subject: [PATCH] fix(automations,#239): delete reads on fields the desktop rules out; guard the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile half of #239 — no desktop change needed. Two reads were written as "not yet published, light up when the desktop sends them". Neither is coming. The desktop has ruled both out, with regression tests pinning it: - **`targetTab` / `targetPaneIdx`** — deliberately withheld; `storeProjections.test.ts` asserts the serialized payload does not contain `targetTab`. The card is schedule + outcome; the dispatch target stays desktop-side. So `AutomationVM.targetLabel` was permanently null and the target arrow never rendered. - **`builtin` on a hook** — cannot exist. `Hook` is exclusively user-authored config and `HookCard` field-exactness is asserted desktop-side. So `HookVM.builtin` was permanently false, which made `hasSystemFloor` permanently false, which made the system-floor note and the built-in tag dead code. Removed all three, plus the now-orphaned `floorNote` style, and rewrote the module doc that promised the fields were coming. `automationsView.test.ts` loses the target-pane case (it encoded an expectation the desktop has explicitly ruled out) and gains one asserting the VM does NOT regrow those fields when a payload happens to carry them. Run-record parsing and the defensive re-sort are unchanged — verified correct against `AutomationRun`: `at` is epoch ms on both sides, so the ISO-string trap that broke `security` does not occur here. **Harness:** `automations` moves from `PENDING_DOMAINS` into `DECODERS` — Layer A re-encode plus Layer B asserting the schedule card renders from real values (the `when` union formats rather than falling back to an em-dash, runs stay newest-first, no run status falls back to `unknown`). ## Still open desktop-side (tracked on #239) The substantive gap is untouched and needs a base-studio-code change: 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. A user seeing zero enabled hooks concludes nothing is enforced while three PreToolUse blockers are active under every permission posture. Needs `AutomationsPayload.systemHooks`; the floor note returns with it. Gates: `npx tsc --noEmit` clean; 530 tests pass. Refs #239 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RAPZC6xEyCrH9qSKUKLQdy --- .../automations/AutomationsSegment.tsx | 14 ++-- src/lib/mirror/automationsView.test.ts | 35 ++++------ src/lib/mirror/automationsView.ts | 39 +++++------ src/lib/tunnel/storePayloads.fixtures.test.ts | 67 ++++++++++++++++++- 4 files changed, 101 insertions(+), 54 deletions(-) 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', () => {