Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions src/components/automations/AutomationsSegment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,11 @@ export function AutomationsSegment({ data }: { data: unknown }) {
view.automations.map((a) => <AutomationCard key={a.id} automation={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. */}
<SectionLabel style={styles.sectionGap}>Hooks</SectionLabel>
{view.hasSystemFloor && (
<Text style={[styles.floorNote, { color: t.fgMuted }]}>
Built-in hooks are the desktop&apos;s system floor — always on, under every
permission posture.
</Text>
)}
{view.hooks.length === 0 ? (
<EmptyRow>No hooks configured on the desktop yet.</EmptyRow>
) : (
Expand All @@ -69,9 +67,9 @@ function AutomationCard({ automation: a }: { automation: AutomationVM }) {
</Tag>
</View>

{/* Schedule only — the dispatch target is deliberately withheld desktop-side (#239). */}
<Text style={[styles.when, { color: t.fgMuted, fontFamily: t.fontMono }]} numberOfLines={1}>
{a.whenLabel}
{a.targetLabel ? ` → ${a.targetLabel}` : ''}
</Text>

<Text style={[styles.timing, { color: t.fgDim }]} numberOfLines={1}>
Expand Down Expand Up @@ -110,7 +108,6 @@ function HookRow({ hook: h }: { hook: HookVM }) {
<View style={styles.hookText}>
<View style={styles.titleLine}>
<Text style={[styles.name, { color: t.fg }]} numberOfLines={1}>{h.name}</Text>
{h.builtin && <Tag color={t.accent}>built-in</Tag>}
</View>
<Text style={[styles.hookMeta, { color: t.fgMuted, fontFamily: t.fontMono }]} numberOfLines={1}>
{h.event}
Expand All @@ -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 },
Expand Down
35 changes: 13 additions & 22 deletions src/lib/mirror/automationsView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
39 changes: 17 additions & 22 deletions src/lib/mirror/automationsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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),
};
}

Expand All @@ -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 };
}
67 changes: 66 additions & 1 deletion src/lib/tunnel/storePayloads.fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,7 +81,6 @@ const PENDING_DOMAINS: Record<string, string> = {
// 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',
};

Expand Down Expand Up @@ -343,13 +343,51 @@ 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<string, (o: Raw) => Raw> = {
glance: decodeGlance,
security: decodeSecurity,
alerts: decodeAlerts,
org: decodeOrg,
themes: decodeThemes,
skills: decodeSkills,
automations: decodeAutomations,
};

// ── Coverage + vocabulary guards ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading