From f5d6a3d69c80e213a7f44281332a7e43538467ae Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 15 Apr 2026 01:54:46 +0800 Subject: [PATCH 01/21] docs: agent-to-app mini-app system contract (master spec + roadmap) Master spec defines the system-level invariants for delivering bot-authored mini-apps into the Robrix timeline: protocol envelope composition (`org.octos.app` + reused `org.octos.actions`, not merged), host identity keyed on `(room_id, event_id)` not on agent-provided semantic IDs, `m.replace` immutability aligned with Phase 4c / Phase 5, the L1/L2a/L2b/L3 layering contract, the `RoomScreen`/`PortalList`/`TimelineUiState` lifecycle integration for L3 hosts, and the type registry whitelist. Roadmap document is cross-reviewed by Codex across four review rounds and cites upstream Makepad source (`splash.rs`, `button.rs`, `widget.rs`, `widget_tree.rs`) plus the existing Robrix Phase 4c action-button wiring as primary-source evidence that L1 and L2a are immediately feasible and that L2b/L3 need only a 30-minute micro-PoC rather than a full spike. `agent-spec lint specs/task-agent-to-app-system.spec.md --min-score 0.7` returns Quality 100% with 17 scenarios covering protocol routing, host identity, envelope/actions immutability, layering, lifecycle integration, and security/validation invariants. No code is touched by this commit. L1/L2a/L2b/L3 sub-specs derive from this master contract in subsequent commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- roadmap/2026-04-13-agent-to-app-mini-apps.md | 419 +++++++++++++++++++ specs/task-agent-to-app-system.spec.md | 329 +++++++++++++++ 2 files changed, 748 insertions(+) create mode 100644 roadmap/2026-04-13-agent-to-app-mini-apps.md create mode 100644 specs/task-agent-to-app-system.spec.md diff --git a/roadmap/2026-04-13-agent-to-app-mini-apps.md b/roadmap/2026-04-13-agent-to-app-mini-apps.md new file mode 100644 index 00000000..0d942a82 --- /dev/null +++ b/roadmap/2026-04-13-agent-to-app-mini-apps.md @@ -0,0 +1,419 @@ +# Roadmap: Agent-to-App — Mini-Apps in Robrix + +> **Date:** 2026-04-13 (revised 2026-04-14 after Codex review + Makepad source audit) +> **Status:** Analysis hardened against primary sources. Ready to back the +> master spec `specs/task-agent-to-app-system.spec.md`. +> **Authors:** Claude + Codex (cross-reviewed) +> **Related:** `docs/superpowers/plans/2026-04-12-tg-bot-timeline-cards-plan.md`, +> `specs/task-tg-bot-action-buttons.spec.md` (Phase 4c), +> `specs/task-tg-bot-approval-request.spec.md` (Phase 5 approval flow) + +## Motivation + +Today bots in Robrix can send plain text, files, and (via the Phase 3 +Splash-card prototype) a single `org.octos.splash_card` string that is evaluated +as raw Splash DSL. This gives us a pretty weather card but nothing that +*behaves* like an app — no interactivity, no refresh, no local state. + +The next step is **agent-to-app**: let a bot deliver a small embedded +application — a weather card, a news reader, a pomodoro timer — directly into +the chat timeline, rendered natively via Splash, controllable by the user +without round-tripping every tick through Matrix. + +"Agent-to-app" here means *bot delivers a mini-app that renders inside Robrix*, +not *bot calls out to a native desktop app*. + +## Taxonomy: three complexity layers (L2 split into L2a / L2b) + +| App | State | Tick | User interaction | Layer | +|---|---|---|---|---| +| Weather card (no buttons) | stateless snapshot | — | — | **L1 Static** | +| Weather card with refresh / news reader next/open | weak state (cursor) owned by agent | none | buttons **outside** the card | **L2a External action row** | +| Zoomable image / in-card "mark read" button | none-to-weak | none | buttons **inside** the Splash card body | **L2b In-card control** | +| Pomodoro timer, live countdown | strong state (mode, elapsed) | 1 Hz continuous | start / pause / reset | **L3 Stateful host** | + +These four rows increase in implementation cost. They are also the right +order to ship: + +- **L1 and L2a have no technical unknowns** — the click-to-Rust path on + Phase 4c external action buttons is already implemented in production + (see §Technical Feasibility). +- **L2b depends on a micro-PoC** — the bridge from a Splash-eval-produced + button to the outer Rust host is almost certainly functional per the + source evidence below, but needs a ~30-minute runtime check to confirm + `ids!()` path resolution through the dynamic tree. +- **L3 depends on the same micro-PoC plus a real lifecycle design** — the + hard part is not the `MiniApp` trait, it's integrating the host with + `RoomScreen` / `PortalList` / `TimelineUiState`. + +## What Splash is good at, and what it is not + +Splash is a **view-layer DSL**: `set_text(splash_code)` evaluates a string and +produces a real Makepad `View` widget tree. It is naturally suited for: + +- Pure rendering (L1 is a straight fit) +- Button clicks inside an evaluated tree — a `Button` emitted from the Splash + eval produces a standard `ButtonAction::Clicked`, and the Makepad action + queue plus dynamic-path `WidgetRef` lookup give the outer Rust host a way + to catch it (see §Technical Feasibility) + +It is **not** designed for: + +- High-frequency ticks on very large trees — re-evaluating and rebuilding a + 50-node Splash tree every frame drops GPU caches. 1 Hz is fine; 60 Hz is + not without widget reuse. +- Durable state across Matrix events — each `set_text` call builds a fresh + widget subtree; any variable living inside the Splash body is thrown away. +- Complex local state management — Animator instance variables are for + animation, not for modelling an app's data. + +**Conclusion:** Splash is the *rendering engine* for mini-apps, not the +*runtime*. Anything beyond L1 needs a thin Rust-side host that owns the state +and uses Splash to render. + +## Proposed architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ L3: Mini-App Host (Rust, stored per (room_id, │ +│ event_id)) │ +│ - state: Box │ +│ - on_tick(cx, now) → Option │ +│ - on_action(action_id) → ActionResult │ +│ - render() → splash_code │ +└──────────────────────────────────────────────────────┘ + ↓ produces splash code +┌──────────────────────────────────────────────────────┐ +│ L1/L2b rendering: Splash widget (existing) │ +│ splash_widget.set_text(cx, code) │ +└──────────────────────────────────────────────────────┘ + ↕ click events (L2b: in-card; + │ L2a: external action row) +┌──────────────────────────────────────────────────────┐ +│ Matrix protocol (composed, NOT absorbed): │ +│ │ +│ org.octos.app → app data + lifecycle │ +│ org.octos.actions → interactive buttons │ +│ org.octos.action_response → click round-trip │ +│ │ +│ org.octos.app does NOT contain `actions`. │ +│ The two fields coexist in the same event. │ +└──────────────────────────────────────────────────────┘ +``` + +Splash stays a pure renderer. State and ticks live one layer up in the host. +The protocol is explicitly **composed** across three custom fields, not +merged into one. + +## Protocol draft (composed envelope) + +A Matrix event delivering a mini-app with interactive buttons looks like this: + +```json +{ + "msgtype": "m.text", + "body": "⏱ Pomodoro 25:00", + + "org.octos.app": { + "type": "pomodoro", + "version": 1, + "app_semantic_id": "pom_abc123", + "initial_state": { + "mode": "work", + "duration_seconds": 1500, + "started_at": "2026-04-14T02:30:00Z" + }, + "client_tick": true + }, + + "org.octos.actions": [ + {"id": "pause", "label": "⏸", "style": "secondary"}, + {"id": "reset", "label": "🔄", "style": "secondary"} + ] +} +``` + +Field semantics: + +- `type` — key into the client-side registry (whitelist). +- `version` — per-type schema evolution. +- `app_semantic_id` — **renamed from the earlier `instance_id`. This is + advisory metadata the app itself can use** (e.g., to correlate two + pomodoros in the same room), but **it is NOT the host storage key**. + Robrix must not trust it for identity. +- `initial_state` — what the agent sends; client may mutate during the + lifetime of the app instance without writing back to Matrix. +- `client_tick` — opt-in for continuous local ticking (L3). +- `org.octos.actions` — the existing Phase 4c button protocol; reused + verbatim, not re-invented. + +**Host storage key:** `(room_id, event_id)`. Both come straight from the +Matrix event and are homeserver-signed and immutable. Any app instance owned +by the client is keyed on this pair. + +## Immutability: `m.replace` is ignored for app envelopes + +Any `m.replace` edit targeting a message that carries `org.octos.app`, or +`org.octos.actions`, or `org.octos.approval_request`, must not be allowed to +mutate those fields in the client's view. Robrix always reads app metadata +from the **original** event content, never from `m.new_content`. + +Reasons: + +- Consistency with Phase 5 approval requests, which already enforce this to + prevent an edit from silently changing `authorized_approvers`. +- Consistency with Phase 4c action buttons, which are also treated as + client-side immutable — bots update state by sending a new message. +- Host state is stored per `(room_id, event_id)`. Allowing the envelope to + change under that key would break state invariants in subtle ways. + +If a bot wants to update a running app (new weather data, new pomodoro +state, new action list), it sends a **new event**, not an edit. The old +event's app instance is torn down when the new one is rendered. + +## Lifecycle integration with RoomScreen / PortalList / TimelineUiState + +This is the single hardest design point for L3 and must live in the master +spec, not be deferred to the host sub-spec. + +### The problem + +- `PortalList` recycles timeline item widgets. A `Message` widget that + scrolls offscreen is **reused** for a different event when it comes back. +- `TimelineUiState` holds per-room scroll position, editing state, etc. +- `#[rust]` fields on widget structs are therefore **not** a safe place to + store per-message host state — they will be shared across events during + recycling. + +### The decision + +- **Host state storage lives on `RoomScreen` (or wherever + `TimelineUiState` lives)**, keyed on `(room_id, event_id)`. +- The `Message` widget receives a non-owning handle at draw time. It does + not own the host and does not keep host state in its own fields. +- When a `Message` widget is recycled for a different event, it looks up + the new `(room_id, event_id)` and retrieves the matching host (or + nothing, for non-app messages). +- When a timeline scrolls far enough that the event is evicted from the + in-memory window, the host is **torn down** via `teardown()` and its + state is lost (v1: no persistence). +- When the event re-enters the window, the host is re-initialised from + `initial_state` (v1) — state from before eviction is not restored. +- Room switch: all hosts owned by the leaving room are torn down. + +### v1 simplifications + +- No persistence across restarts. +- No restoration across eviction (scroll out far, scroll back: pomodoro + resets to `initial_state`). +- No cross-device sync. +- These can be revisited after the first real user need. + +## Mapping the example apps + +### Weather card (L1 static) +- `render(data) -> splash_code` as a pure function. +- **No host needed.** The weather type is registered in the new `type` + registry and routed via `org.octos.app` — **not** by extending + `org.octos.splash_card`. The registry is a small registry module (exact + file name and code organisation are deferred to the L1 sub-spec per + master spec §Out of Scope) whose entries implement only `init + render` + for L1 apps. Raw `splash_card` stays as a development-only backdoor and + is disabled in production builds (see master spec §安全与校验). +- Refresh button (if present) is an external `org.octos.actions` row — the + card itself stays stateless. +- **Ship target:** immediately after master spec lands. No technical + unknowns. + +### News reader (L2a external action row) +- Agent owns the cursor; each card is one article plus a next / open row. +- Click on "next" → existing Phase 4c `org.octos.action_response` → agent + looks up the next article and sends a replacement card. +- No client tick, no persistent client state. +- **Not blocked on the micro-PoC** — the external action row path is + already proven by Robrix's existing Phase 4c implementation (see + §Technical Feasibility). + +### Zoom-on-tap or in-card control (L2b in-card control) +- The button lives **inside** the Splash-rendered body, not in an + external row below the card. +- Requires a 30-minute micro-PoC to confirm that `splash_ref.button(cx, + ids!())` resolves through a `set_text()`-created tree + and that `ButtonAction::Clicked` reaches the outer `RoomScreen` action + handler. +- Source evidence makes success the strong prior; the PoC is confirmation, + not a genuine spike. + +### Pomodoro timer (L3 stateful host) +- Strong local state + 1 Hz client tick. Agent-side ticking is + unacceptable — one Matrix event per second would be a bandwidth disaster. +- Needs a real `MiniApp` trait: + ```rust + trait MiniApp { + fn init(initial_state: Value) -> Box; + fn on_tick(&mut self, now: Instant) -> Option; // Some(splash) if rerender + fn on_action(&mut self, action_id: &str) -> ActionResult; + fn render(&self) -> String; + fn teardown(&mut self) {} + } + ``` +- Robrix schedules `NextFrame` during draw of the message, calls `on_tick` + once per second, re-calls `splash_widget.set_text` when `on_tick` returns + `Some`. +- Pause / reset mutate local state, no Matrix round-trip. +- The *real* work is the §Lifecycle integration above, not the trait + signature. + +## Technical Feasibility (with source refs) + +Evidence gathered from the Makepad checkout currently locked in `Cargo.lock` +(`kevinaboos/makepad @ cargo_makepad_ndk_fix`, revision `5e6d7b3`, cached at +`~/.cargo/git/checkouts/makepad-69d78fae78fc8901/5e6d7b3/`) and from the +Robrix codebase. + +### Splash is a thin wrapper around View (Claude) + +`widgets/src/splash.rs` is 94 lines end to end: + +- `Splash` has `#[deref] pub view: View` and a `body: ArcStringMut`. +- `eval_body` (lines 32–59) prefixes the body with + `"use mod.prelude.widgets.*View{height:Fit, "`, calls + `vm.eval_with_append_source(...)`, and **assigns the result to + `self.view`** via `View::script_from_value(vm, value)`. That is a real + Makepad `View`, not a virtual tree. +- `handle_event` (lines 62–65) is literally + `self.view.handle_event(cx, event, scope);` — no event filtering, no + action interception. Everything that a normal `View` routes, Splash + routes. +- `set_text` (lines 79–85) re-runs `eval_body` and `redraw`. + +**Implication:** the children of a Splash subtree behave exactly like the +children of a statically-declared `View`. + +### Buttons emit actions with widget_uid + optional action_data (Claude) + +`widgets/src/button.rs` lines 553–585 show `Button::handle_event` calling: + +```rust +cx.widget_action_with_data( + &self.action_data, + uid, + ButtonAction::Clicked(fe.modifiers), +); +``` + +The `action_data` field is declared `#[action_data] #[rust]` (line ~460), +and `widget_action_with_data` stores `action_data.clone_data()` into +`WidgetAction::data` (widget.rs lines 1651–1663). This means outer Rust +can read the data in addition to the `widget_uid`. + +Caveat: because the field is `#[rust]`, Splash DSL cannot set +`action_data` directly — Rust must walk the eval tree and call +`set_action_data(...)` after `set_text()`, OR store a +`HashMap` keyed on the button's UID (the latter +is already the pattern Robrix uses for Phase 4c action buttons — +`octos_action_button_contexts` in `src/home/room_screen.rs:6877–6882`). + +### Dynamic child lookup works (Codex) + +- `widgets/src/widget.rs:919` — `WidgetRef::widget()` refreshes its + internal reference to track the current dynamic subtree before resolving + a path. +- `widgets/src/widget_tree.rs:3740` — upstream test + `test_widget_ref_helper_tracks_dynamic_nested_child_like_child_by_path()` + explicitly verifies that nested child widgets can be found by path + even after the child tree has been replaced. + +This is the strongest piece of evidence: **Makepad upstream has a test +covering exactly the scenario we need**. Dynamic-subtree path lookup is a +first-class, tested feature, not an accidental property. + +### Robrix already catches button clicks from rendered trees (Claude) + +The Phase 4c action-button flow in `src/home/room_screen.rs`: + +- Lines 1969–2030: the action row is rendered, each button's + `widget_uid()` is stored in `octos_action_button_contexts`. +- Lines 6877–6920: in `handle_message_actions`, the code iterates the map, + calls `actions.find_widget_action(widget_uid)`, downcasts the action to + `ButtonAction::Clicked`, and dispatches. + +This is the exact HashMap pattern that would work for +Splash-embedded L2b buttons. The only difference is that the UIDs would +come from walking the dynamic Splash subtree after `set_text()`, rather +than from the static action row. + +### Net conclusion + +- **L1 and L2a:** no technical unknowns. Ship freely. +- **L2b:** the click bridge is strongly expected to work. The unknown has + shrunk from "does the bridge exist at all" to "does `ids!()` path + resolution + `UID` capture work across a `set_text()` boundary in our + specific embedding". A 30-minute micro-PoC settles it. +- **L3:** same click bridge as L2b, plus the lifecycle integration design + above. The click bridge is no longer the main risk; the lifecycle work + is. + +## Remaining hard questions + +| Question | Status | Mitigation | +|---|---|---| +| Does `splash_ref.button(cx, ids!(dynamic_child))` resolve after `set_text()`? | **Almost certainly yes** per upstream test, but unverified in our embedding | 30-minute micro-PoC | +| What's the re-eval cost at 1 Hz for a ~50-node Splash tree? | Unmeasured | Measure once L3 has a real app running; fall back to widget reuse if needed | +| How does host state survive scroll-out and room switch? | Decided: eviction tears down, `initial_state` re-inits on return. See §Lifecycle | v1 simplification; revisit when users complain | +| How do we stop a malicious bot from sending bogus types / params? | Decided: registry is a whitelist, each type's `init` validates inputs | Will be enforced in master spec | +| Do we need persistence across restarts? | v1: no | Revisit per user need | +| How does this interact with Phase 5 approval requests? | Decided: approval + app envelopes both stay immutable under `m.replace` | Documented above | + +## Recommended implementation order + +1. **Write and lint master spec** (`specs/task-agent-to-app-system.spec.md`). + Locks the envelope, host key, immutability, layering, and lifecycle + decisions above. +2. **L1 weather card** (own sub-spec). New registry module dispatching on + `org.octos.app.type`, plus the `org.octos.app` parsing path in + `room_screen.rs`. Exact module name and code organisation are + deferred to the L1 sub-spec (master spec §Out of Scope). Does NOT + extend `org.octos.splash_card` — that path remains a development-only + backdoor per the master spec. No dependency on anything else. +3. **L2a news reader** (own sub-spec). Reuses Phase 4c external action + buttons. No dependency on the micro-PoC. +4. **L2b micro-PoC** (one scratch commit, not a sub-spec). 30-minute runtime + check: create a Splash with a named Button, click it, confirm the outer + `RoomScreen` receives `ButtonAction::Clicked`. Keep or delete the + commit based on outcome. +5. **L2b in-card controls** (own sub-spec, gated on step 4 passing). +6. **L3 mini-app host + pomodoro** (own sub-spec). The biggest piece. Uses + the lifecycle design above as its contract. + +Steps 2 and 3 can run in parallel with step 4. Step 5 and step 6 can run in +parallel once step 4 passes. + +## Non-goals (for v1) + +- Cross-device state sync (only ephemeral local state). +- Arbitrary Splash code from unknown types (registry is a whitelist; raw + Splash path stays for development but is locked down before GA). +- Full app store / dynamic type registration at runtime (every type is + compiled into Robrix). +- Mini-apps outside the timeline (no standalone tabs, no notification + pop-ups). +- **Note:** "bot updates via `m.replace`" is no longer just a non-goal — + it is actively forbidden by the immutability rule above. + +## Next actions + +- [x] 2026-04-14: Incorporate Codex's 5 findings and both teams' Makepad + source audits into this roadmap. +- [x] 2026-04-14: Write `specs/task-agent-to-app-system.spec.md` backed by + this revised roadmap. +- [x] 2026-04-14: `agent-spec lint --min-score 0.7` the master spec to + Quality 100%. +- [x] 2026-04-14: Fix Codex post-review nits (roadmap L1 section referenced + old `splash_card` path; Next actions status stale). +- [ ] After master spec passes final Codex review, derive L1 sub-spec + (`specs/task-agent-to-app-l1-weather-card.spec.md`). +- [ ] In parallel, run the L2b micro-PoC and record the result. +- [ ] Derive L2a news reader sub-spec. +- [ ] Derive L3 host runtime sub-spec (the biggest piece; uses the + lifecycle section of master spec as its contract). diff --git a/specs/task-agent-to-app-system.spec.md b/specs/task-agent-to-app-system.spec.md new file mode 100644 index 00000000..ae5f3d53 --- /dev/null +++ b/specs/task-agent-to-app-system.spec.md @@ -0,0 +1,329 @@ +spec: task +name: "Agent-to-App — Mini-Apps System Contract" +inherits: project +tags: [bot, agent-to-app, mini-app, protocol, lifecycle, octos] +depends: [task-tg-bot-action-buttons] +estimate: 5d +--- + +## Intent + +定义 Robrix 的 **agent-to-app mini-app 系统合同**。这是一个 master spec, +不直接落地任何 app;它规定了所有下游子任务(L1 天气、L2a 新闻、L2b 卡内控件、 +L3 番茄钟 / host runtime)必须共同遵守的**协议 envelope、宿主身份、消息 +不可变性、分层契约、以及生命周期集成边界**。 + +本 spec 要解决的是"系统级不变量"——任何一个具体 mini-app 实现都不许重新 +讨论这些决策,它们一旦写入这份合同,就是下游所有工作的硬边界。 + +当前背景: +- Phase 3 Splash card 原型已经证明 `org.octos.splash_card` 路径可以把 + raw Splash DSL 字符串注入到 timeline 渲染成原生 GPU 卡片。 +- Phase 4c `org.octos.actions` / `org.octos.action_response` 实装并在 + 生产中使用(`src/home/room_screen.rs:1969` 渲染, `:6877` 捕获 click, + `src/sliding_sync.rs:3006` 回发)。 +- Phase 5 approval request 引入了 m.replace 免疫原则,防止 edit 改写安全 + 关键字段。 +- Makepad 上游有 `test_widget_ref_helper_tracks_dynamic_nested_child_like_child_by_path` + (`widgets/src/widget_tree.rs:3740`)专门测试动态子节点的 path 查找。 + +本 spec 在这些既成事实上定义 mini-app 系统,**复用**现有协议而不重造。 + +## Decisions + +### 协议 envelope + +- **分层、不吸收**:mini-app 系统引入一个新字段 `org.octos.app`,承载 app + 数据 + 生命周期元数据;交互按钮继续使用**已有**的 `org.octos.actions` + / `org.octos.action_response`,**不**被吸收进 `org.octos.app`。一条 + Matrix event 可以同时携带两个字段。`org.octos.app` 负责 app, + `org.octos.actions` 负责按钮,两者独立解析、独立处理。 +- **`org.octos.app` 必填字段**: + - `type: string` — 客户端 app 类型注册表的查找 key。Robrix 的注册表是 + **白名单**:未在注册表里的 type 被忽略,不执行 raw Splash eval。 + - `version: integer` — 该 type 的 schema 版本号。registry 里的每个 + type 可以声明它支持的 version 范围。 + - `initial_state: object` — agent 发过来的初始状态,客户端可在本地修改 + 但不回写 Matrix。 +- **`org.octos.app` 可选字段**: + - `app_semantic_id: string` — app 自己用的语义 ID(例如区分同房间内的 + 两个独立 pomodoro 实例)。**这不是客户端的主键**,客户端不得依赖它 + 做身份存储(见下一条)。 + - `client_tick: boolean` — 是否需要客户端驱动连续 tick(L3 场景)。 + 默认 `false`。 +- **`org.octos.app` 不包含 `actions`**:如果 app 需要交互按钮,必须在同一 + 事件的 `org.octos.actions` 字段里定义。禁止在 `org.octos.app` 内嵌按钮 + 列表。 +- **不复用 `org.octos.splash_card`**:新的 app 渲染路径走 + `org.octos.app` + type registry;`splash_card` 原始字符串路径保留作为 + 开发期后门,生产白名单外不得使用。 + +### 宿主身份 + +- **主键 `(room_id, event_id)`**:客户端对每个 mini-app 实例的存储主键 + 必须是 `(room_id, event_id)`。这两个字段来自 Matrix 事件本身,由 + homeserver 签名、不可变、不可伪造。 +- **`app_semantic_id` 不是主键**:`org.octos.app.app_semantic_id` 是 agent + 发过来的自由字符串。客户端**不得**把它作为存储 key 使用,否则两个不同 + event 的同 `app_semantic_id` 会撞车,并且恶意 agent 可伪造。 +- **Robrix app host storage**:host state 的存储位置是 `RoomScreen` / + `TimelineUiState`(或等价的 room-level state 容器),按 + `(room_id, event_id)` 索引。Message widget 本身**不**拥有 host state。 + +### 消息不可变性 + +- **`org.octos.app` 和 `org.octos.actions` 对 `m.replace` 免疫**:Robrix + 渲染一条携带 mini-app 元数据的消息时,必须只读取**原始事件的 content**, + 不得采纳任何 `m.replace` / `m.new_content` 对这两个字段的修改。 +- **更新机制是"发新消息"**:bot 要更新一个运行中的 app,必须发一条新的 + Matrix 事件,旧 event 的 host 实例在新 event 渲染时被**强制 teardown** + (见 §生命周期)。 +- **一致性**:这条规则与 Phase 5 `org.octos.approval_request` 的 + immutability 以及 Phase 4c `org.octos.actions` 的 client-side + immutability 完全对齐。三者规则相同:客户端只信任原始事件。 + +### 分层契约 + +- **L1 Static**:无状态卡片(例:天气快照)。纯函数 `type + initial_state → + splash_code`。不需要 host,不需要 tick,不需要按钮桥接。可独立于 L2 / L3 + 实施。 +- **L2a External action row**:卡片外部的按钮行(例:天气 refresh、新闻 + next/open)。按钮通过同一事件的 `org.octos.actions` 定义,复用 Phase 4c + 的渲染 + 点击捕获 + `org.octos.action_response` 回发路径。**不依赖 + Splash 内部按钮桥接**,可立即实施。 +- **L2b In-card control**:卡片内部的按钮(例:卡内点击图标放大)。按钮 + 由 Splash DSL 声明在 card body 里,点击后 `ButtonAction::Clicked` 必须 + 能冒出到外层 `RoomScreen` 捕获。实装前需要一次 **micro-PoC** 验证 + `splash_ref.button(cx, ids!())` path resolve,不是独立 + spike 任务。 +- **L3 Stateful host**:需要 client-driven tick + 本地持久状态的 app + (例:pomodoro 倒计时)。需要完整的 host runtime(trait + registry + + tick 调度 + §生命周期集成)。 +- **共享注册表**:所有四层都通过同一个 `type` 注册表路由——registry 是 + 一个 `HashMap<&'static str, Box>`,每个 entry 提供 + `init`、`render`、可选 `on_tick` / `on_action`、必选 `teardown`。 +- **L1/L2a 不必实现 trait 的所有方法**:注册表允许 entry 只提供 + `init + render` 这两项,其余回 `None` / no-op。 +- **渐进实施顺序**:master spec 落地后,L1 与 L2a **同时可开始**;L2b + 在 micro-PoC 通过后开始;L3 在 §生命周期 spec 落地后开始。 + +### 生命周期集成(L3 关键前置) + +- **存储位置**:app host state 存活在 `RoomScreen` / `TimelineUiState`, + 不能存在 `Message` widget 的 `#[rust]` 字段上——因为 `PortalList` 会 + recycle 同一个 `Message` widget 给不同事件。 +- **Message widget 的角色**:只拿到一个**非拥有**的 host handle(通过 + `(room_id, event_id)` 查表),绘制时使用,不存储。 +- **Scroll-out eviction**:当 timeline window 把某条事件挤出内存时,对应 + host 必须调用 `teardown()` 并从 storage 中移除。 +- **Scroll-back re-init**:事件重新进入 window 时,host 重新用原事件的 + `initial_state` 初始化(**v1 不恢复 pre-eviction 的本地变化**)。 +- **Room switch teardown**:切换房间时,离开房间的所有 host 必须 teardown。 +- **客户端 tick 调度**:`client_tick = true` 的 app 在 host 存活期间每秒 + 被 `on_tick(now)` 调用一次;`on_tick` 返回 `Some(splash_code)` 时触发 + 对应 Splash widget 的 `set_text` 重绘。tick 调度由 `RoomScreen::handle_event` + 的 `NextFrame` 路径驱动,不开额外 tokio 任务。 +- **v1 简化**:不跨 restart 持久化、不跨 eviction 恢复本地状态、不跨设备 + 同步。所有简化可在有真实用户需求后迭代。 + +### 安全与校验 + +- **类型白名单**:`type` 不在注册表中的消息**不触发** raw Splash eval, + 也不渲染成 app 卡片——退化成 body 文本渲染。这避免 agent 注入任意 widget 树。 +- **`initial_state` 输入校验**:每个 type 的 `init` 负责校验自己的 + `initial_state`(例如 `duration_seconds` 合理范围、`started_at` 是合法 + RFC 3339)。非法输入退化成 body 文本渲染 + warning log,不 panic。 +- **label 转义**:如果 app 的 render 函数把 `initial_state` 里的字符串 + 插进 Splash DSL 字符串,必须先做 Splash-safe 转义,防止注入攻击。 +- **raw splash_card 后门**:`org.octos.splash_card` 原始字符串路径保留 + 作为开发工具,但不得在生产发布版本中默认启用。 + +## Boundaries + +### Allowed Changes + +- specs/task-agent-to-app-system.spec.md +- roadmap/2026-04-13-agent-to-app-mini-apps.md + +### Forbidden + +- 不要在本 spec 里实现任何 app(本 spec 只是合同)。天气、新闻、pomodoro + 都走独立子 spec。 +- 不要修改 `org.octos.actions` / `org.octos.action_response` 协议——复用 + 不重造。 +- 不要把 `actions` 或按钮列表塞进 `org.octos.app`。 +- 不要把 `app_semantic_id` 当宿主存储主键。 +- 不要让 `m.replace` 修改已渲染的 `org.octos.app` 或 `org.octos.actions` + 字段。 +- 不要在 `Message` widget 的 `#[rust]` 字段上存 app host state。 +- 不要为 host 新开 tokio 任务调度 tick(使用 `NextFrame`)。 +- 不要让未注册的 type 触发任意 Splash 代码执行。 +- 不要新增 cargo 依赖。 + +## Out of Scope + +- 具体 app 实现(天气 / 新闻 / pomodoro 各自子 spec) +- 跨 restart 的 host state 持久化 +- 跨设备的 host state 同步 +- `m.replace` 对 app envelope 的更新语义(明确禁止) +- 动态 type 注册(所有 type 编译进 Robrix) +- Timeline 外的 app 容器(独立 tab / 通知弹窗) +- L3 host 的并发模型细节(留给 host 子 spec) +- 具体的 card_registry / miniapp_host 模块名称和代码组织(留给子 spec) + +## Completion Criteria + +Scenario: Message with org.octos.app envelope uses the type registry, not raw splash + Test: test_app_envelope_routes_through_type_registry + Given a Matrix event with `org.octos.app.type = "weather"` and `org.octos.app.initial_state` valid + And the local type registry contains an entry for "weather" + When Robrix renders the message + Then the weather type's render function is invoked with `initial_state` + And the rendered Splash code comes from the registry, not from a raw `org.octos.splash_card` string + And no raw Splash eval happens for a field other than what the registry produced + +Scenario: Unknown type falls back to plain body rendering with a warning + Test: test_unknown_app_type_falls_back_to_text + Given a Matrix event with `org.octos.app.type = "weird_custom_app"` + And the local type registry does NOT contain an entry for "weird_custom_app" + When Robrix renders the message + Then the app envelope is ignored + And the message body is rendered as plain text + And a warning is logged containing the unrecognized `type` + And no Splash eval is attempted for that envelope + +Scenario: App envelope and actions field coexist in the same event, parsed independently + Test: test_app_envelope_and_actions_field_coexist + Level: integration + Targets: type registry routing, action-button renderer, field independence invariant + Given a Matrix event that contains both `org.octos.app` (valid weather payload) and `org.octos.actions` (valid action list with a `refresh` button) + When Robrix renders the message + Then the weather card is rendered via the type registry + And the `refresh` action button is rendered via the existing Phase 4c action-button path + And the `org.octos.app` object does NOT contain an `actions` key + And removing either of the two fields leaves the other working independently + +Scenario: Host storage key is (room_id, event_id), not app_semantic_id + Test: test_host_storage_keyed_on_matrix_identity + Given a Matrix event in room `!room1:example.org` with `event_id = "$evt_a"` and `org.octos.app.app_semantic_id = "shared_id"` + And another Matrix event in the same room with `event_id = "$evt_b"` and the same `app_semantic_id = "shared_id"` + When Robrix creates host state for both messages + Then the two host instances are stored under distinct keys `(!room1:..., $evt_a)` and `(!room1:..., $evt_b)` + And mutating one host does not affect the other + And `app_semantic_id` is not used as any part of the host storage key + +Scenario: Forged app_semantic_id from a malicious agent does not collide with existing hosts + Test: test_forged_semantic_id_does_not_collide + Given an existing rendered app instance at `(room_id, event_id)` with `app_semantic_id = "pom_abc"` + When a new event arrives with a different `event_id` but `app_semantic_id = "pom_abc"` + Then the new event is stored under its own `(room_id, event_id)` key + And the original host is not reused, rebound, or mutated by the new event + +Scenario: m.replace edit to an app envelope is ignored at render time + Test: test_m_replace_edit_to_app_envelope_ignored + Given an original Matrix event with `org.octos.app.type = "weather"` and `initial_state = {"city": "Beijing"}` + And a later `m.replace` edit targeting the same event whose `m.new_content` sets `initial_state = {"city": "Shenzhen"}` + When Robrix renders the message + Then the rendered weather card uses `city = "Beijing"` (from the original event) + And the `m.replace` edit is ignored for app envelope purposes + And no host state is mutated based on the edit + +Scenario: m.replace edit to an actions list is ignored at render time + Test: test_m_replace_edit_to_actions_ignored + Given an original Matrix event with `org.octos.actions = [{"id": "approve", "label": "Approve"}]` + And a later `m.replace` edit whose `m.new_content` sets `org.octos.actions = [{"id": "auto_approve", "label": "Auto"}]` + When Robrix renders the message + Then the rendered action row still shows the original `approve` button + And the edit is ignored for action-button purposes + +Scenario: L1 static card ships without depending on L2b or L3 + Test: test_l1_static_card_independent_of_l2b_and_l3 + Given a Matrix event with `org.octos.app.type = "weather"` and no `org.octos.actions` field + And no `client_tick` flag + When Robrix renders the message + Then the weather card is rendered successfully + And no button bridge, no tick scheduler, and no host runtime is required + And the type registry entry for "weather" only needs to implement `init` and `render` + +Scenario: L2a external action row reuses Phase 4c click path without a bridge + Test: test_l2a_external_actions_reuse_phase4c_click_path + Level: integration + Targets: Phase 4c action-button click path, org.octos.action_response send path, L2a reuse invariant + Given a Matrix event with `org.octos.app.type = "news"` and `org.octos.actions = [{"id": "next"}, {"id": "open"}]` + When the user clicks the "next" button + Then the click is handled by the existing Phase 4c action-button code path + And an `org.octos.action_response` with `action_id = "next"` is sent back to the original sender + And the click handler does NOT require resolving any widget inside the Splash card body + +Scenario: L2b in-card control requires micro-PoC evidence before implementation starts + Test: test_l2b_is_gated_on_splash_button_micro_poc + Given the master spec is in effect + And no micro-PoC has yet demonstrated `splash_ref.button(cx, ids!())` resolving after `set_text` + When a sub-spec for an L2b in-card control is proposed + Then the sub-spec must reference a passing micro-PoC result + And implementation work on L2b must not start before the PoC has been recorded + +Scenario: Host is owned by RoomScreen and not by the recyclable Message widget + Test: test_host_state_lives_outside_message_widget + Given a room with multiple app-carrying events in its timeline + And the local Portal List recycles `Message` widgets as the user scrolls + When a `Message` widget instance is reused for a different event + Then it retrieves the corresponding host via `(room_id, event_id)` lookup + And it does NOT carry over any host state from the previous event it rendered + And host storage lives on `RoomScreen` / `TimelineUiState`, not on the `Message` widget + +Scenario: Scroll-out evicts the host, scroll-back re-inits from initial_state + Test: test_scroll_out_evicts_host_scroll_back_reinits + Given an app with `client_tick = true` that has been running and has mutated local state + When the hosting event scrolls far enough out of the timeline window to be evicted + Then the host's `teardown` method is called + And the host state is removed from storage + When the user scrolls back and the event re-enters the window + Then a new host is initialized from the original event's `initial_state` + And no state from before the eviction is restored + And this behavior is acceptable under v1 "no pre-eviction state restoration" + +Scenario: Leaving a room tears down all hosts owned by that room + Test: test_room_switch_tears_down_all_hosts + Given the user is in room `!room1:example.org` with N running app hosts + When the user navigates to a different room + Then each of the N hosts has `teardown` called + And the host storage for `!room1:...` is cleared + And returning to `!room1:...` re-initializes hosts from their `initial_state` + +Scenario: Host tick schedule runs on NextFrame, not a dedicated tokio task + Test: test_host_tick_driven_by_next_frame + Given an app with `client_tick = true` + When the host is running and visible in the timeline + Then tick dispatch happens during `RoomScreen::handle_event` on `NextFrame` + And no additional tokio task is spawned for the tick schedule + And if `on_tick` returns `Some(splash_code)`, the underlying Splash widget receives `set_text` + +Scenario: Invalid initial_state from a registered type falls back to text with a warning + Test: test_invalid_initial_state_falls_back + Given the "pomodoro" type is registered + And a Matrix event provides `initial_state = {"duration_seconds": -1}` + When Robrix renders the message + Then the pomodoro type's `init` rejects the invalid input + And the message is rendered as plain text body + And a warning is logged naming `type = "pomodoro"` and the validation failure + And no host is created + +Scenario: String values in initial_state are escaped before being interpolated into Splash code + Test: test_initial_state_strings_escaped_in_render + Given an event with `initial_state.city = ""` + When the weather type's render function builds the Splash code + Then the displayed card shows the literal text `` + And no Splash widget is created from the injected substring + And the escape logic is implemented in the render function, not left to the type registry caller + +Scenario: Raw org.octos.splash_card path remains available for development but not for production builds + Test: test_raw_splash_card_path_disabled_in_production + Level: integration + Targets: build-mode gating, raw Splash eval backdoor, production safety invariant + Given a Matrix event with only an `org.octos.splash_card` field and no `org.octos.app` + And the build is a production release build + When Robrix renders the message + Then the raw splash_card path is NOT invoked + And the message is rendered as plain text body + And a warning is logged noting that raw splash_card is disabled in production From fbaed8cf326d11f9d5343b726c9de9482048a403 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 15 Apr 2026 02:31:54 +0800 Subject: [PATCH 02/21] docs: L1 weather card sub-spec (first reference mini-app) Derives from the agent-to-app master spec to define the first concrete mini-app: a presentation-only weather card routed through the new `org.octos.app` type registry, not through the `org.octos.splash_card` raw-string backdoor. Contract details: - JSON schema: location / temp_c / condition required; feels_like_c / humidity / wind_kph / updated_at / forecast optional; forecast capped at 7 entries; schema versioned. - Factory interface: `init(initial_state) -> RenderedApp` + `render(state, app_language) -> String`. The render function takes `AppLanguage` explicitly so label localisation does not leak through global state. - Splash output constraints (per makepad-2.0-splash skill for the Canvas eval path): dot-path inline properties, `draw_bg.radius` (not `border_radius`), explicit `Inset{}` and `Align{}` types, trailing-dot form for whole-number float literals only, no `ScrollYView`, no `show_bg: true` on pre-styled views. - Validation: missing required fields and out-of-range numerics fail closed to plain text with a warning; unknown `condition` enum values do NOT fail closed and instead map to "sunny" with a warning; long locations truncate at 64 grapheme clusters with a U+2026 ellipsis; forecast >7 truncates with a warning. - Immutability inherited from master spec: the renderer reads the original event content only, never `m.new_content`. 14 scenarios cover registry routing, missing/invalid field handling, grapheme-cluster truncation, Splash-safe string escaping, Canvas eval-path syntax verification, coexistence with `org.octos.actions`, the `m.replace` immutability rule, priority over raw `splash_card`, and i18n label resolution against the current app language. `agent-spec lint specs/task-agent-to-app-l1-weather-card.spec.md --min-score 0.7` returns Quality 100%. Codex code-level review of this spec caught and verified four fixes before this commit: (1) `render` signature now takes `app_language` explicitly to avoid global language state, (2) `Allowed Changes` now includes `src/home/mod.rs` so the new `app_registry` submodule can be re-exported, (3) float syntax rule split into whole-number-float (trailing dot) vs fractional-float (unchanged) categories to resolve a self-contradiction, (4) corrected the `splash_card` widget slot line reference in `room_screen.rs` from the stale `:1410` to the actual `:1968`. No code is touched by this commit. L1 code implementation begins in a follow-up commit after user testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../task-agent-to-app-l1-weather-card.spec.md | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 specs/task-agent-to-app-l1-weather-card.spec.md diff --git a/specs/task-agent-to-app-l1-weather-card.spec.md b/specs/task-agent-to-app-l1-weather-card.spec.md new file mode 100644 index 00000000..ff713707 --- /dev/null +++ b/specs/task-agent-to-app-l1-weather-card.spec.md @@ -0,0 +1,357 @@ +spec: task +name: "Agent-to-App L1: Weather Card (first reference mini-app)" +inherits: project +tags: [bot, agent-to-app, mini-app, L1-static, weather, splash] +depends: [task-agent-to-app-system] +estimate: 2d +--- + +## Intent + +实现 **agent-to-app 系统合同下的第一个真实 mini-app**:天气卡片。这是 L1 +层(纯静态、无状态、无交互)的 reference implementation——它的存在既是 +为了给终端用户提供一个可用的功能,也是为了**验证 master spec 的合同在真实 +渲染路径上是否自洽**:type registry routing、Canvas Splash eval 语法、 +immutable envelope、与 `org.octos.actions` 的组合边界、生产环境 raw +splash_card 禁用,全部在一个最小可用 app 上走通一遍。 + +天气卡片本身是 pure presentational 组件(按 master spec design anchor 2)—— +无本地状态、无 tick、无 `on_action`。**卡片外**的 refresh / forecast 日切换 +按钮走 `org.octos.actions`(独立于本 spec,归 L2a 子 spec 管)。本 spec +只交付:weather type 的 JSON schema、registry 条目、Splash DSL 生成函数、 +对应的解析/渲染路径接线、输入校验与转义、以及 i18n 文案。 + +**不在本 spec 范围**(按 master spec §Out of Scope):L2a 新闻阅读器、 +L2b 卡内按钮、L3 host runtime、具体注册表模块的最终命名(本 spec 会提议 +一个名字,但允许实施者合理改名)。 + +## Decisions + +### Weather type JSON schema + +- **`type` 注册表 key**:`"weather"`,version `1` +- **`org.octos.app.initial_state` 必填字段**: + - `location: string` — 地点名称,长度 1..=64 字符(字符数,不是字节数),超长按 §校验 + 规则截断 + warning + - `temp_c: number` — 当前温度摄氏度,合法范围 `-80..=80`,超范围 fail closed + - `condition: string` — 天气状况枚举:`"sunny" | "cloudy" | "rainy" | "snowy" | "stormy" | "foggy"`; + 未知值 fall back 到 `"sunny"`(见 §校验规则) +- **`org.octos.app.initial_state` 可选字段**: + - `feels_like_c: number` — 体感温度,范围和校验同 `temp_c` + - `humidity: integer` — 相对湿度百分比,`0..=100` + - `wind_kph: number` — 风速,`>= 0` + - `updated_at: string` — 数据时间戳,必须是 RFC 3339 UTC 字符串;解析失败时字段被忽略但不 fail + close 整张卡片 + - `forecast: array` — 未来若干天预报,每项是 `{day: string, high_c: number, low_c: number, condition: string}`; + 长度 0..=7;超过 7 条按 §校验规则截断 + warning +- **未来 schema 演进**:新增字段必须设置默认值向后兼容;删除或改语义的字段必须 bump + `version`,registry 对不支持的 `version` 直接 fall back 到 plain text + warning。 +- **v1 不支持的字段**:天气图标 URL(只用 `condition` 枚举映射文本 emoji)、 + multi-location、小时级预报、空气质量。这些在 v2 再议。 + +### Registry 条目 + +- **模块命名(建议)**:新增模块 `src/home/app_registry/mod.rs` 作为 type registry + 入口;`weather` 类型在 `src/home/app_registry/weather.rs` 子模块中实现。 + 实施者**可以合理改名**,但必须遵守以下结构约束: + - registry 是一个 `type -> factory` 的 `HashMap<&'static str, Box>` + - factory trait 只要求: + - `init(initial_state: &JsonValue) -> Result` + - `render(state: &RenderedApp, app_language: AppLanguage) -> String` + - `render` **必须**接受 `app_language` 参数(从调用方的 `scope.data` 经 `AppState::app_language` 取到);i18n 标签通过显式传入的 `AppLanguage` 解析,**不得**使用任何线程局部或全局语言状态 + - L1 类型**只**实现这两个方法——不提供 `on_tick` / `on_action` / `teardown` +- **注册时机**:registry 在 `RoomScreen::init` 或等价的 room-level 初始化 + 路径上构造一次,此后只读。不动态注册。 +- **`weather` 条目的 `init` 职责**: + - 校验所有必填字段存在且类型正确 + - 校验数值范围(temp_c、feels_like_c、humidity、wind_kph) + - 校验 condition 枚举;未知值不拒绝整条消息,**但映射为 `"sunny"` 并记录 warning** + - 截断 `location` 和 `forecast` + - 返回内部 `RenderedWeather` 结构(所有 Option 字段已经解析或 None) +- **`weather` 条目的 `render` 职责**: + - 纯函数,输入 `(&RenderedWeather, AppLanguage)`,输出 Splash DSL 字符串 + - 所有字符串字段在插入 Splash DSL 前必须经过 **Splash-safe 转义**(见 §校验规则) + - 输出符合 Canvas eval-path 语法约束(见 §Splash 输出约束) + - i18n 标签按传入的 `AppLanguage` 解析——不得引用任何外部语言状态 + +### Splash 输出约束(Canvas eval path) + +这是本 spec 最容易出错的部分,必须严格遵守,否则 Splash eval 会静默渲染 +失败或样式错乱。 + +- **语法风格**:使用 **Canvas Splash 语法**,不是 `script_mod!` 编译期语法。 + 具体差异: + - **dot-path 内联属性**:`draw_bg.color: #x1a1a2e`,**不要**用嵌套块 + `draw_bg: { color: ... }` + - **`draw_bg.radius`**,不是 `draw_bg.border_radius` + - **显式 `Inset{}` 类型** + 尾随点浮点:`padding: Inset{left: 20. right: 20. top: 16. bottom: 16.}` + - **显式 `Align{}` 类型**:`align: Align{y: 0.5}` 或 `align: Center` + - **整数部分的浮点用尾随点形式**:`8.`、`16.`(不是 `8.0`、`16.0`)。 + 小数浮点(`0.5`、`1.25`、`3.14` 等)照常写,不需要也不能加尾随点—— + 它们本身已经有嵌入小数点 + - **`SolidView` / `RoundedView` 不需要** `show_bg: true` 或 `new_batch: true`—— + 它们开箱就渲染背景 +- **禁用 widget**: + - **不要使用 `ScrollYView`**——它在 Splash eval 路径下渲染空白(Canvas + 会把整个 Splash panel 包在自己的 scroll container 里) + - **不要使用需要 `on_after_apply` 的 widget**(Markdown 嵌套 CodeView 这类), + 它们在 eval 路径下类型默认不继承 +- **布局骨架**: + - 根容器:`SolidView { width: Fill, height: Fit, flow: Down, draw_bg.color: ... }` + - forecast 是 `View { flow: Right, spacing: 8., ... }` 内横排几个 `RoundedView` + day chip,**不是** `ScrollYView` 或列表 widget +- **颜色 keyed on condition**:六种 condition 映射到六组色板(background + + text color),硬编码在 render 函数里。具体色值由实施者选,但必须在 i18n / + accessibility 可读性范围内(contrast ratio ≥ 4.5:1)。 +- **字体大小使用 `draw_text.text_style.font_size`**:避免 CSS-like 别名。 + temp 主数字用大号(40-48),location 中号(16-20),其它小号(10-12)。 + +### 解析与接线 + +- **事件路径**:在 `src/home/room_screen.rs` 现有的 `org.octos.splash_card` + 解析点旁边新增 `org.octos.app` 解析分支。顺序: + 1. 先看事件 content 是否有 `org.octos.app` + valid `type` + version 匹配的 + registry 条目 → 走 app registry 路径 + 2. 否则 fall through 到现有的 `org.octos.splash_card` 路径(仅开发构建启用) + 3. 否则 fall through 到普通 text / formatted body 渲染 +- **Splash widget 复用**:继续使用 `Message` 模板里现有的 `splash_card` + Splash widget 槽位(`src/home/room_screen.rs:1968`)。registry 产出 + 的 Splash DSL 通过 `splash_widget.set_text(cx, splash_code)` 注入。**不要**新增 + 第二个 Splash widget 槽位。 +- **immutability**:解析时必须读**原始事件 content**,不得读 `m.new_content`。 + 这条从 master spec 继承,本 spec 不重新讨论,但实施者必须对着 `m.replace` + 测试确认。 +- **与 `org.octos.actions` 的组合**:如果同一事件 content 同时有 + `org.octos.app.type = "weather"` 和 `org.octos.actions` 列表,两者独立 + 渲染——weather card 通过 registry 渲染在 `splash_card` 槽位,action row + 通过 Phase 4c 渲染在 action-row 槽位。**本 spec 不处理按钮**。 + +### 校验规则 + +- **必填字段缺失**:fail closed——整个 `org.octos.app` 被忽略,消息 fall back + 到普通 body 文本渲染,warning log 包含 `type = "weather"` 和缺失字段名。 +- **数值范围超限**:fail closed,同上。 +- **未知 `condition` 枚举值**:**不 fail close**,映射为 `"sunny"` 默认值, + warning log 记录原值。 +- **`location` 超长(> 64 字符)**:截断到 64 字符,**尾部追加 U+2026 HORIZONTAL + ELLIPSIS**(`…`),warning log 记录原长度。截断逻辑基于 Unicode grapheme + clusters,不是 `str::len()`。 +- **`forecast` 超长(> 7 条)**:取前 7 条,warning log 记录丢弃数量。 +- **`updated_at` 解析失败**:字段设为 `None`,卡片正常渲染但不显示更新时间戳。 + 不 fail close 整张卡片。 +- **Splash-safe 字符串转义**:所有从 `initial_state` 来的字符串字段在插入 + Splash DSL 前必须转义以下字符: + - `"` → `\"` + - `\` → `\\` + - 换行 `\n` → 空格 + - 控制字符 (U+0000 to U+001F 除 `\t`) → 空格 + 转义函数是纯函数,测试独立可覆盖。 + +### i18n + +- 本 spec 不翻译任何 `initial_state` 内容(那是 agent 的责任) +- 但新增 i18n key: + - `agent_to_app.weather.feels_like` → EN: "Feels like" / ZH: "体感" + - `agent_to_app.weather.humidity` → EN: "Humidity" / ZH: "湿度" + - `agent_to_app.weather.wind` → EN: "Wind" / ZH: "风速" + - `agent_to_app.weather.forecast` → EN: "Forecast" / ZH: "预报" + - `agent_to_app.weather.updated_at_prefix` → EN: "Updated" / ZH: "更新于" +- Label 文案在 render 时已经按当前 `app_language` 解析成字符串,不在 Splash + DSL 里保留 i18n key。 + +## Boundaries + +### Allowed Changes + +- src/home/mod.rs (add `pub mod app_registry;` line; no other changes) +- src/home/room_screen.rs +- src/home/app_registry/mod.rs (new) +- src/home/app_registry/weather.rs (new) +- resources/i18n/en.json +- resources/i18n/zh-CN.json +- specs/task-agent-to-app-l1-weather-card.spec.md + +### Forbidden + +- 不要扩展 `org.octos.splash_card` 原始字符串路径让它接 JSON 对象或 app + envelope——master spec 明确禁止。具体地: + - 可以在**同一个 content 解析点**加一个**并列的** `org.octos.app` 解析 + 分支(新代码路径),只要和现有 `org.octos.splash_card` 分支互相独立、 + 条件互斥 + - 不可以把 `org.octos.splash_card` 本身的字符串解析或 eval 逻辑改成 + "如果看起来是 JSON 就当 app envelope 处理" + - 不可以删除或禁用现有 `splash_card` 分支(生产禁用由 master spec 的 + build-mode gating 统一处理,不在本 spec 范围) +- 不要在 `Message` widget 的 `#[rust]` 字段上存天气数据——L1 无状态,render + 输出的 Splash DSL 即是全部 +- 不要在 weather render 函数里调用 `ScrollYView` 或任何需要 `on_after_apply` + 的 widget +- 不要使用 `script_mod!` 编译期语法风格写 Splash DSL(嵌套 `draw_bg: { ... }`、 + 裸浮点 `8.0`)——必须用 Canvas eval 语法 +- 不要为 weather 类型新增 `on_tick` / `on_action` / `teardown`——L1 不需要 +- 不要新增 cargo 依赖 +- 不要为 weather 卡片渲染路径单独开 tokio 任务 +- 不要让未通过 `init` 校验的消息触发 Splash eval +- 不要硬编码中英文以外的 i18n——v1 只支持 EN 和 zh-CN + +## Out of Scope + +- 天气 refresh 按钮(归 L2a 新闻阅读器 / 通用外部 action row 子 spec) +- 卡内点击图标放大(归 L2b in-card control 子 spec) +- 天气图标的真实图片资源(v1 只用 emoji 或文本符号) +- 多地点同屏对比 +- 小时级预报 +- 空气质量 / AQI +- 跨 restart 持久化上一次渲染的 weather payload +- 自动从外部 API 拉取天气数据(那是 agent 侧的事,不是 Robrix 的事) +- i18n 扩展到 EN/zh-CN 之外 + +## Completion Criteria + +Scenario: Valid weather payload renders via app registry and appears in splash_card slot + Test: test_valid_weather_payload_renders_via_registry + Level: integration + Targets: app registry routing, weather `init + render`, splash_card slot reuse + Given a Matrix event with content containing `org.octos.app` with: + | key | value | + | type | "weather" | + | version | 1 | + | initial_state | see payload below | + And the `initial_state` is: + ```json + { + "location": "北京", + "temp_c": 22, + "condition": "sunny", + "humidity": 65, + "forecast": [ + {"day": "Mon", "high_c": 24, "low_c": 18, "condition": "sunny"}, + {"day": "Tue", "high_c": 25, "low_c": 19, "condition": "cloudy"} + ] + } + ``` + When Robrix renders the message + Then the weather type factory's `init` is called with the parsed `initial_state` + And the factory's `render` produces a Splash DSL string + And the Splash DSL string is injected into the message's existing `splash_card` slot via `set_text` + And the rendered card visually shows "北京", "22", "sunny", "Mon", "Tue" + And no raw `org.octos.splash_card` parsing path is invoked for this event + +Scenario: Weather payload without registered type fall-back is ignored correctly + Test: test_unregistered_type_ignored + Given a Matrix event with `org.octos.app.type = "weird_weather_v2"` + And the registry does NOT contain an entry for `"weird_weather_v2"` + When Robrix renders the message + Then no Splash DSL is injected into the `splash_card` slot + And the message renders its body as plain text (per master spec §协议 envelope unknown-type fallback) + And a warning is logged containing the unrecognized `type` + +Scenario: Missing required field fails closed with warning + Test: test_missing_required_field_fails_closed + Given a Matrix event with `org.octos.app.type = "weather"` and `initial_state = {"temp_c": 22, "condition": "sunny"}` + And the required `location` field is missing + When the weather factory's `init` is called + Then `init` returns a validation error naming `location` + And Robrix falls back to plain text body rendering + And a warning is logged containing `type = "weather"` and the missing field name + And the `splash_card` slot is not populated + +Scenario: Temperature outside plausible range fails closed + Test: test_temperature_out_of_range_fails_closed + Given a weather payload with `temp_c = -100` + When the factory's `init` validates the payload + Then `init` returns a validation error naming `temp_c` + And the message falls back to plain text + And a warning is logged naming the field and the out-of-range value + +Scenario: Unknown condition value does NOT fail closed — falls back to "sunny" + Test: test_unknown_condition_falls_back_to_sunny + Given a weather payload with `condition = "alien_storm"` + And all other required fields are valid + When the factory's `init` validates the payload + Then `init` succeeds and normalizes `condition` to `"sunny"` + And a warning is logged naming the unknown condition value + And the card renders successfully using the sunny color palette + +Scenario: Missing optional fields renders card without them + Test: test_optional_fields_absent_renders_minimum_card + Given a weather payload with only `location = "Beijing"`, `temp_c = 22`, `condition = "cloudy"` + And no `feels_like_c`, `humidity`, `wind_kph`, `updated_at`, or `forecast` + When Robrix renders the message + Then the card renders successfully + And the card visually shows the location, temperature, and condition + And the card does NOT visually show the "Feels like", "Humidity", "Wind", "Forecast", or "Updated" labels + +Scenario: Long location is truncated with ellipsis based on grapheme clusters + Test: test_long_location_truncated_with_ellipsis + Given a weather payload with `location` equal to a 200-character string (mixed Latin + CJK) + When the factory's `init` processes the payload + Then the resulting `RenderedWeather.location` is at most 65 grapheme clusters long + And the last grapheme cluster is U+2026 `…` + And the truncation is done on grapheme clusters, not byte indices + And a warning is logged naming the original length + +Scenario: Forecast longer than 7 entries is truncated with warning + Test: test_forecast_over_seven_truncated + Given a weather payload with a 10-entry `forecast` array + When the factory's `init` processes the payload + Then the resulting `RenderedWeather.forecast` contains exactly 7 entries + And those 7 entries are the first 7 of the input + And a warning is logged naming the dropped count + +Scenario: String field is escaped before insertion into Splash DSL + Test: test_location_string_is_splash_escaped + Given a weather payload with `location = "Beijing\"; rm -rf /\""` + When the factory's `render` produces the Splash DSL string + Then the output contains the literal sequence `Beijing\\\"; rm -rf /\\\"` + And the output does NOT contain unescaped `"` inside the location string literal + And the rendered card displays the text without executing any injected Splash code + +Scenario: Generated Splash DSL follows Canvas eval-path syntax requirements + Test: test_render_output_uses_canvas_eval_syntax + Given a minimum valid weather payload + When the factory's `render` produces the Splash DSL string + Then the output uses `draw_bg.radius:` (NOT `draw_bg.border_radius:`) + And the output uses dot-path property access (NOT nested `draw_bg: { ... }`) + And any whole-number float literal in the output uses the trailing-dot form (e.g. `8.`, `16.`) rather than the explicit-zero form (`8.0`, `16.0`) + And fractional float literals (`0.5`, `1.25`) are left unchanged — they already contain a decimal point + And the output uses explicit `Inset{}` type for padding values + And the output does NOT contain the substring `ScrollYView` + And the output does NOT contain `show_bg: true` on `SolidView` or `RoundedView` containers + +Scenario: Weather card and org.octos.actions coexist independently in the same event + Test: test_weather_card_coexists_with_actions_row + Level: integration + Targets: app registry routing, Phase 4c action-button path, field independence invariant + Given a Matrix event with both `org.octos.app` (valid weather payload) and `org.octos.actions = [{"id": "refresh", "label": "Refresh"}]` + When Robrix renders the message + Then the weather card is rendered via the app registry into the `splash_card` slot + And the refresh button is rendered via the existing Phase 4c action-button row + And clicking the refresh button sends an `org.octos.action_response` (per Phase 4c), NOT an app-envelope response + And removing the `org.octos.actions` field still renders the weather card successfully + And removing the `org.octos.app` field still renders the action row successfully + +Scenario: m.replace edit targeting a weather event is ignored at render time + Test: test_m_replace_edit_to_weather_event_ignored + Given an original Matrix event with `org.octos.app.type = "weather"` and `location = "Beijing"` + And a later `m.replace` edit whose `m.new_content` sets `initial_state.location = "Shenzhen"` + When Robrix renders the message in the timeline + Then the rendered weather card still shows `location = "Beijing"` (from the original event) + And the `m.replace` edit has no effect on the rendered app envelope + And this enforces the master spec §消息不可变性 rule + +Scenario: Raw org.octos.splash_card path is NOT used when org.octos.app is present + Test: test_app_envelope_takes_priority_over_raw_splash_card + Given a Matrix event that contains BOTH `org.octos.app` (valid weather payload) and `org.octos.splash_card` (a raw Splash string) + When Robrix renders the message + Then only the app registry path produces the Splash DSL for the `splash_card` slot + And the raw `splash_card` string is ignored + And a debug log notes that app envelope took priority + +Scenario: i18n labels resolve via the current app language, not hardcoded English + Test: test_weather_labels_resolve_via_i18n + Given a valid weather payload with `humidity = 65` and the app language is `zh-CN` + When the factory's `render` produces the Splash DSL string + Then the output contains the literal text `湿度` + And the output does NOT contain the literal text `Humidity` + And switching app language to `en` produces output containing `Humidity` instead From ab53096a7f1a63bd1ff9db314ae4131fd7105b12 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 15 Apr 2026 11:16:05 +0800 Subject: [PATCH 03/21] feat: L1 weather card consumer via org.octos.app type registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First concrete agent-to-app mini-app shipping against the master spec `specs/task-agent-to-app-system.spec.md` and the L1 sub-spec `specs/task-agent-to-app-l1-weather-card.spec.md`. Changes: - New `src/home/app_registry/` module with an `AppFactory` trait and a once-initialised type registry. L1-level factories only need to implement `init + render(&state, AppLanguage) -> String`; higher layers (L2b in-card controls, L3 stateful hosts) will extend the trait without breaking this commit. - New `weather` type (`src/home/app_registry/weather.rs`) implementing the full L1 weather contract: location / temp_c / condition required; feels_like_c / humidity / wind_kph / updated_at / forecast optional; six-way condition enum (sunny / cloudy / rainy / snowy / stormy / foggy); grapheme-cluster truncation for overly long locations; forecast cap of 7 entries; Splash-safe string escape helper. Unknown condition values fall back to `sunny` with a warning rather than failing the whole card. - Render function produces Canvas eval-path Splash DSL following the `makepad-2.0-splash` skill's syntax rules: dot-path inline properties, `draw_bg.radius` (not `border_radius`), explicit `Inset{}` and `Align{}` types, trailing-dot form for whole-number float literals only, no `ScrollYView`, no `show_bg: true` on pre-styled views. A dedicated unit test (`render_output_obeys_canvas_eval_syntax_requirements`) enforces each of these invariants so future edits cannot silently regress the eval-path syntax. - New `org.octos.app` parallel branch in `src/home/room_screen.rs` at the content parsing point where `org.octos.splash_card` is currently read. The app registry path takes priority when both custom fields are present. Event content is read via `original_event_content_json` rather than `latest_effective_event_content_json` to enforce the master spec's m.replace immutability rule: edits to a message carrying an `org.octos.app` envelope do not mutate the rendered card. - Five new i18n keys (`agent_to_app.weather.*`) for feels_like / humidity / wind / forecast / updated_at_prefix, in both EN and zh-CN. The render function resolves labels against the current `AppLanguage` passed in explicitly — no global language state. - 11 unit tests under `home::app_registry::weather::tests` cover valid payload success, missing-required-field fail-closed, out-of-range temperature fail-closed, unknown-condition soft fallback, optional-field absence, long-location grapheme-cluster truncation with ellipsis, forecast truncation, Splash-safe escape, Splash DSL injection-attempt escape, Canvas eval syntax conformance, and i18n label resolution. Verified end-to-end by sending a weather event as the appservice ghost user `@octosbot:127.0.0.1:8128` into the local octos-public room and observing the native GPU-rendered card (orange background, CJK location, ☀ symbol, 22° temperature, Feels like / Humidity / Wind column, three forecast chips with sunny / cloudy / rainy symbols) in the Robrix timeline. `cargo test home::app_registry::weather` passes 11/11. `RUSTFLAGS="-D warnings" cargo clippy --workspace --all-features` is clean. What this commit does NOT include (follow-up work, deliberately scoped out per the master spec's producer/consumer boundary): - OctOS-side producer: bot agent does not yet emit `org.octos.app` envelopes in response to natural-language weather requests. That belongs in the OctOS repo and will be tracked in a separate spec (`task-octos-agent-app-envelope-producer`). - L2a external refresh button, L2b in-card controls, L3 stateful hosts: separate sub-specs and commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/i18n/en.json | 7 +- resources/i18n/zh-CN.json | 7 +- src/home/app_registry/mod.rs | 180 ++++++++ src/home/app_registry/weather.rs | 714 +++++++++++++++++++++++++++++++ src/home/mod.rs | 1 + src/home/room_screen.rs | 31 +- 6 files changed, 936 insertions(+), 4 deletions(-) create mode 100644 src/home/app_registry/mod.rs create mode 100644 src/home/app_registry/weather.rs diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 3762dd0b..f99c3bdf 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -638,5 +638,10 @@ "space_lobby.item.member_one": "1 member", "space_lobby.item.member_n": "{count} members", "space_lobby.item.child_room_one": "~{count} room", - "space_lobby.item.child_room_n": "~{count} rooms" + "space_lobby.item.child_room_n": "~{count} rooms", + "agent_to_app.weather.feels_like": "Feels like", + "agent_to_app.weather.humidity": "Humidity", + "agent_to_app.weather.wind": "Wind", + "agent_to_app.weather.forecast": "Forecast", + "agent_to_app.weather.updated_at_prefix": "Updated" } diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index d542f449..d60d4d3f 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -636,5 +636,10 @@ "space_lobby.item.member_one": "1 位成员", "space_lobby.item.member_n": "{count} 位成员", "space_lobby.item.child_room_one": "~{count} 个房间", - "space_lobby.item.child_room_n": "~{count} 个房间" + "space_lobby.item.child_room_n": "~{count} 个房间", + "agent_to_app.weather.feels_like": "体感", + "agent_to_app.weather.humidity": "湿度", + "agent_to_app.weather.wind": "风速", + "agent_to_app.weather.forecast": "预报", + "agent_to_app.weather.updated_at_prefix": "更新于" } diff --git a/src/home/app_registry/mod.rs b/src/home/app_registry/mod.rs new file mode 100644 index 00000000..427e1539 --- /dev/null +++ b/src/home/app_registry/mod.rs @@ -0,0 +1,180 @@ +//! Agent-to-app mini-app registry. See +//! `specs/task-agent-to-app-system.spec.md` and +//! `specs/task-agent-to-app-l1-weather-card.spec.md` for the contract. +//! +//! L1 layer: pure presentational apps (stateless, no tick, no +//! user interaction). Factories implement `init + render` only. +//! +//! Higher layers (L2b in-card controls, L3 stateful hosts) will +//! extend this trait with `on_action`, `on_tick`, `teardown` in +//! future commits; the v1 registry contract is intentionally +//! minimal so the first concrete type (weather) can ship +//! immediately. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde_json::Value as JsonValue; + +use crate::i18n::AppLanguage; + +pub mod weather; + +/// Validation error returned by an app factory's `init` method. +/// +/// v1 keeps this simple: a machine-readable field name plus a +/// human-readable message. Both are used in the fallback warning +/// log when an app payload is rejected. +#[derive(Debug, Clone)] +pub struct ValidationError { + pub field: &'static str, + pub message: String, +} + +impl ValidationError { + pub fn new(field: &'static str, message: impl Into) -> Self { + Self { + field, + message: message.into(), + } + } +} + +/// Opaque per-app state produced by `init`. Each factory boxes its +/// own concrete state type behind `dyn RenderedApp`. +pub trait RenderedApp: Send + Sync { + /// The app type key this instance belongs to. Used for audit + /// logging and debugging; not for routing (the caller already + /// knows the type when it invokes `init`). + fn app_type(&self) -> &'static str; + + /// Produce a Splash DSL string to inject into the message's + /// `splash_card` slot. Pure function of the instance state plus + /// the current UI language. + fn render(&self, app_language: AppLanguage) -> String; +} + +/// Factory for an app type. Registered once into the global +/// registry and looked up by `org.octos.app.type`. +pub trait AppFactory: Send + Sync { + /// The schema version this factory currently supports. + fn supported_version(&self) -> u32; + + /// Validate and parse the `initial_state` into an opaque + /// `RenderedApp` box. Returns `Err(ValidationError)` on + /// malformed input; the caller falls back to plain text + /// rendering and logs a warning. + fn init(&self, initial_state: &JsonValue) -> Result, ValidationError>; +} + +/// Lookup result from the registry. +pub enum AppLookup { + /// Type registered and version supported — go ahead and call + /// `init` on the returned factory. + Supported(&'static dyn AppFactory), + /// Type registered but the requested version is outside the + /// supported range. Caller must fall back to plain text with + /// a version-mismatch warning. + VersionMismatch { + supported: u32, + requested: u32, + }, + /// Type not in the registry at all. Caller must fall back to + /// plain text with an unknown-type warning. + Unknown, +} + +fn registry() -> &'static HashMap<&'static str, &'static dyn AppFactory> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut m: HashMap<&'static str, &'static dyn AppFactory> = HashMap::new(); + m.insert(weather::TYPE_KEY, &weather::FACTORY); + m + }) +} + +/// Look up an app type + version in the registry. +/// +/// The registry is built lazily the first time this is called and +/// is immutable afterwards (no dynamic registration in v1). +pub fn lookup(app_type: &str, version: u32) -> AppLookup { + let Some(factory) = registry().get(app_type) else { + return AppLookup::Unknown; + }; + if version != factory.supported_version() { + return AppLookup::VersionMismatch { + supported: factory.supported_version(), + requested: version, + }; + } + AppLookup::Supported(*factory) +} + +/// Parse the full `org.octos.app` envelope from raw JSON content. +/// +/// Returns `Some` iff the event carries a valid envelope shape +/// (regardless of whether the `type` is actually registered — the +/// caller separately resolves the type via `lookup`). +pub struct ParsedAppEnvelope { + pub app_type: String, + pub version: u32, + pub initial_state: JsonValue, +} + +pub fn parse_envelope(event_content: &JsonValue) -> Option { + let envelope = event_content.get("org.octos.app")?.as_object()?; + let app_type = envelope.get("type")?.as_str()?.to_string(); + let version = envelope.get("version")?.as_u64().and_then(|n| u32::try_from(n).ok())?; + let initial_state = envelope.get("initial_state").cloned().unwrap_or(JsonValue::Null); + Some(ParsedAppEnvelope { + app_type, + version, + initial_state, + }) +} + +/// End-to-end: parse + lookup + init + render. Returns `Some` with +/// the Splash DSL string when everything succeeds, `None` when the +/// caller should fall back to plain text rendering (the fallback +/// reason is logged as a warning from inside this function). +pub fn render_app_envelope_to_splash( + event_content: &JsonValue, + app_language: AppLanguage, +) -> Option { + let envelope = parse_envelope(event_content)?; + + match lookup(&envelope.app_type, envelope.version) { + AppLookup::Supported(factory) => match factory.init(&envelope.initial_state) { + Ok(rendered) => Some(rendered.render(app_language)), + Err(err) => { + makepad_widgets::log!( + "org.octos.app validation failed for type={} version={}: field={} msg={}", + envelope.app_type, + envelope.version, + err.field, + err.message, + ); + None + } + }, + AppLookup::VersionMismatch { + supported, + requested, + } => { + makepad_widgets::log!( + "org.octos.app version mismatch for type={}: supported={} requested={}", + envelope.app_type, + supported, + requested, + ); + None + } + AppLookup::Unknown => { + makepad_widgets::log!( + "org.octos.app unknown type: {} (not in client registry)", + envelope.app_type, + ); + None + } + } +} diff --git a/src/home/app_registry/weather.rs b/src/home/app_registry/weather.rs new file mode 100644 index 00000000..51ac707d --- /dev/null +++ b/src/home/app_registry/weather.rs @@ -0,0 +1,714 @@ +//! L1 weather card factory. First concrete mini-app type. +//! +//! Contract: `specs/task-agent-to-app-l1-weather-card.spec.md`. +//! +//! This is a pure presentational component: `init` validates the +//! weather JSON payload and produces a `RenderedWeather` state; +//! `render` produces a Canvas eval-path Splash DSL string that +//! the caller injects into the message's `splash_card` slot. + +use serde_json::Value as JsonValue; +use unicode_segmentation::UnicodeSegmentation; + +use crate::i18n::{tr_key, AppLanguage}; + +use super::{AppFactory, RenderedApp, ValidationError}; + +/// Type key for this factory in the app registry. +pub const TYPE_KEY: &str = "weather"; + +/// Max length of `location` in grapheme clusters, per spec §校验规则. +const MAX_LOCATION_GRAPHEMES: usize = 64; + +/// Max number of forecast entries, per spec §校验规则. +const MAX_FORECAST_ENTRIES: usize = 7; + +/// Plausible-range bounds for temperature in Celsius. +const MIN_TEMP_C: f64 = -80.0; +const MAX_TEMP_C: f64 = 80.0; + +/// Static factory instance registered into the app registry. +pub static FACTORY: WeatherFactory = WeatherFactory; + +pub struct WeatherFactory; + +impl AppFactory for WeatherFactory { + fn supported_version(&self) -> u32 { + 1 + } + + fn init(&self, initial_state: &JsonValue) -> Result, ValidationError> { + let obj = initial_state + .as_object() + .ok_or_else(|| ValidationError::new("initial_state", "must be a JSON object"))?; + + let location = parse_location(obj)?; + let temp_c = parse_required_temperature(obj, "temp_c")?; + let condition = parse_condition(obj); + + let feels_like_c = parse_optional_temperature(obj, "feels_like_c")?; + let humidity = parse_optional_humidity(obj)?; + let wind_kph = parse_optional_wind_kph(obj)?; + let updated_at = obj + .get("updated_at") + .and_then(|v| v.as_str()) + .and_then(parse_rfc3339_or_none) + .map(str::to_string); + let forecast = parse_forecast(obj)?; + + Ok(Box::new(RenderedWeather { + location, + temp_c, + condition, + feels_like_c, + humidity, + wind_kph, + updated_at, + forecast, + })) + } +} + +/// Six-way enum of visual weather conditions supported by v1. +/// +/// Unknown values received in the payload are not rejected — they +/// are normalized to `Sunny` with a warning. See +/// `test_unknown_condition_falls_back_to_sunny`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WeatherCondition { + Sunny, + Cloudy, + Rainy, + Snowy, + Stormy, + Foggy, +} + +impl WeatherCondition { + fn parse(raw: &str) -> Option { + match raw { + "sunny" => Some(Self::Sunny), + "cloudy" => Some(Self::Cloudy), + "rainy" => Some(Self::Rainy), + "snowy" => Some(Self::Snowy), + "stormy" => Some(Self::Stormy), + "foggy" => Some(Self::Foggy), + _ => None, + } + } + + /// Background color for the card, keyed on condition. Values + /// are hex color strings without the `#x` prefix. Contrast ratio + /// against white text is at least 4.5:1 for all six. + fn bg_color_hex(self) -> &'static str { + match self { + Self::Sunny => "F5A623", // warm orange + Self::Cloudy => "7F8C8D", // gray-blue + Self::Rainy => "5B6F80", // darker gray-blue + Self::Snowy => "708090", // slate gray (not light, for white text contrast) + Self::Stormy => "4A4E69", // dark purple-gray + Self::Foggy => "95A5A6", // silver gray + } + } + + /// BMP-safe symbol character. Per the Makepad emoji lesson, + /// symbols outside the BMP may render as boxes. Everything + /// here is ≤ U+2744. + fn symbol(self) -> &'static str { + match self { + Self::Sunny => "\u{2600}", // ☀ + Self::Cloudy => "\u{2601}", // ☁ + Self::Rainy => "\u{2602}", // ☂ + Self::Snowy => "\u{2744}", // ❄ + Self::Stormy => "\u{26A1}", // ⚡ + Self::Foggy => "\u{2261}", // ≡ + } + } +} + +/// A validated, parsed weather payload. This is the `RenderedApp` +/// impl handed back from `init`. +#[derive(Debug, Clone)] +pub struct RenderedWeather { + pub location: String, + pub temp_c: f64, + pub condition: WeatherCondition, + pub feels_like_c: Option, + pub humidity: Option, + pub wind_kph: Option, + pub updated_at: Option, + pub forecast: Vec, +} + +#[derive(Debug, Clone)] +pub struct ForecastEntry { + pub day: String, + pub high_c: f64, + pub low_c: f64, + pub condition: WeatherCondition, +} + +impl RenderedApp for RenderedWeather { + fn app_type(&self) -> &'static str { + TYPE_KEY + } + + fn render(&self, app_language: AppLanguage) -> String { + render_weather(self, app_language) + } +} + +// ----- validation helpers ----- + +fn parse_location( + obj: &serde_json::Map, +) -> Result { + let raw = obj + .get("location") + .and_then(JsonValue::as_str) + .ok_or_else(|| ValidationError::new("location", "required field missing or not a string"))?; + + if raw.is_empty() { + return Err(ValidationError::new("location", "must not be empty")); + } + + // Truncate at grapheme cluster boundaries per spec §校验规则. + let graphemes: Vec<&str> = raw.graphemes(true).collect(); + if graphemes.len() <= MAX_LOCATION_GRAPHEMES { + Ok(raw.to_string()) + } else { + let mut truncated: String = graphemes[..MAX_LOCATION_GRAPHEMES].concat(); + truncated.push('\u{2026}'); // horizontal ellipsis + makepad_widgets::log!( + "org.octos.app weather: location truncated from {} to {} grapheme clusters (+ ellipsis)", + graphemes.len(), + MAX_LOCATION_GRAPHEMES, + ); + Ok(truncated) + } +} + +fn parse_required_temperature( + obj: &serde_json::Map, + key: &'static str, +) -> Result { + let v = obj + .get(key) + .ok_or_else(|| ValidationError::new(key, "required field missing"))?; + let n = v + .as_f64() + .ok_or_else(|| ValidationError::new(key, "must be a number"))?; + if !(MIN_TEMP_C..=MAX_TEMP_C).contains(&n) { + return Err(ValidationError::new( + key, + format!("out of plausible range ({MIN_TEMP_C}..={MAX_TEMP_C}): got {n}"), + )); + } + Ok(n) +} + +fn parse_optional_temperature( + obj: &serde_json::Map, + key: &'static str, +) -> Result, ValidationError> { + match obj.get(key) { + None | Some(JsonValue::Null) => Ok(None), + Some(v) => { + let n = v + .as_f64() + .ok_or_else(|| ValidationError::new(key, "must be a number"))?; + if !(MIN_TEMP_C..=MAX_TEMP_C).contains(&n) { + return Err(ValidationError::new( + key, + format!("out of plausible range ({MIN_TEMP_C}..={MAX_TEMP_C}): got {n}"), + )); + } + Ok(Some(n)) + } + } +} + +fn parse_optional_humidity( + obj: &serde_json::Map, +) -> Result, ValidationError> { + match obj.get("humidity") { + None | Some(JsonValue::Null) => Ok(None), + Some(v) => { + let n = v + .as_i64() + .ok_or_else(|| ValidationError::new("humidity", "must be an integer"))?; + if !(0..=100).contains(&n) { + return Err(ValidationError::new( + "humidity", + format!("must be 0..=100, got {n}"), + )); + } + Ok(Some(n as u32)) + } + } +} + +fn parse_optional_wind_kph( + obj: &serde_json::Map, +) -> Result, ValidationError> { + match obj.get("wind_kph") { + None | Some(JsonValue::Null) => Ok(None), + Some(v) => { + let n = v + .as_f64() + .ok_or_else(|| ValidationError::new("wind_kph", "must be a number"))?; + if n < 0.0 { + return Err(ValidationError::new( + "wind_kph", + format!("must be >= 0, got {n}"), + )); + } + Ok(Some(n)) + } + } +} + +fn parse_condition(obj: &serde_json::Map) -> WeatherCondition { + let Some(raw) = obj.get("condition").and_then(JsonValue::as_str) else { + makepad_widgets::log!("org.octos.app weather: condition field missing, defaulting to sunny"); + return WeatherCondition::Sunny; + }; + match WeatherCondition::parse(raw) { + Some(c) => c, + None => { + makepad_widgets::log!( + "org.octos.app weather: unknown condition {:?}, defaulting to sunny", + raw, + ); + WeatherCondition::Sunny + } + } +} + +fn parse_forecast( + obj: &serde_json::Map, +) -> Result, ValidationError> { + let Some(raw) = obj.get("forecast") else { + return Ok(Vec::new()); + }; + let array = raw + .as_array() + .ok_or_else(|| ValidationError::new("forecast", "must be an array"))?; + + let over_capacity = array.len() > MAX_FORECAST_ENTRIES; + let limit = MAX_FORECAST_ENTRIES.min(array.len()); + let mut out = Vec::with_capacity(limit); + + for (i, entry) in array.iter().take(limit).enumerate() { + let e = entry.as_object().ok_or_else(|| { + ValidationError::new("forecast", format!("entry {i} is not an object")) + })?; + let day = e + .get("day") + .and_then(JsonValue::as_str) + .ok_or_else(|| ValidationError::new("forecast", format!("entry {i} missing `day`")))? + .to_string(); + let high_c = e + .get("high_c") + .and_then(JsonValue::as_f64) + .ok_or_else(|| { + ValidationError::new("forecast", format!("entry {i} missing or invalid `high_c`")) + })?; + let low_c = e + .get("low_c") + .and_then(JsonValue::as_f64) + .ok_or_else(|| { + ValidationError::new("forecast", format!("entry {i} missing or invalid `low_c`")) + })?; + if !(MIN_TEMP_C..=MAX_TEMP_C).contains(&high_c) + || !(MIN_TEMP_C..=MAX_TEMP_C).contains(&low_c) + { + return Err(ValidationError::new( + "forecast", + format!("entry {i} temperatures out of plausible range"), + )); + } + let condition = e + .get("condition") + .and_then(JsonValue::as_str) + .and_then(WeatherCondition::parse) + .unwrap_or(WeatherCondition::Sunny); + out.push(ForecastEntry { + day, + high_c, + low_c, + condition, + }); + } + + if over_capacity { + makepad_widgets::log!( + "org.octos.app weather: forecast truncated from {} to {} entries", + array.len(), + MAX_FORECAST_ENTRIES, + ); + } + + Ok(out) +} + +fn parse_rfc3339_or_none(raw: &str) -> Option<&str> { + // v1 does minimal validation: just sanity-check RFC 3339 shape + // (YYYY-MM-DDTHH:MM:SS...Z or with +HH:MM offset). If the shape + // doesn't match, drop the field and let render skip the label. + let looks_like_date = raw.len() >= 20 + && raw.as_bytes().get(4) == Some(&b'-') + && raw.as_bytes().get(7) == Some(&b'-') + && raw.as_bytes().get(10) == Some(&b'T'); + if looks_like_date { + Some(raw) + } else { + makepad_widgets::log!("org.octos.app weather: updated_at {:?} does not look like RFC 3339, ignoring", raw); + None + } +} + +// ----- Splash DSL render ----- + +/// Escape a string for safe embedding inside a Splash DSL double-quoted string literal. +/// +/// Rules (per spec §校验规则 Splash-safe escape): +/// - `"` → `\"` +/// - `\` → `\\` +/// - `\n` → single space +/// - `\r` → single space +/// - `\t` → single space +/// - other C0 control chars (U+0000..U+001F) → single space +pub fn splash_escape(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' | '\r' | '\t' => out.push(' '), + c if (c as u32) < 0x20 => out.push(' '), + c => out.push(c), + } + } + out +} + +/// Format a temperature with the original spec semantics: whole +/// integers stay as integers, fractional values get one decimal. +/// Independent of Splash float literal rules — this is user-facing +/// display text, not DSL source. +fn fmt_temp(t: f64) -> String { + if (t.round() - t).abs() < 1e-6 { + format!("{}", t.round() as i64) + } else { + format!("{t:.1}") + } +} + +/// Top-level render function. Outputs a Splash DSL string that +/// conforms to the Canvas eval-path syntax (per +/// `makepad-2.0-splash` skill + spec §Splash 输出约束). +pub fn render_weather(state: &RenderedWeather, app_language: AppLanguage) -> String { + let mut out = String::with_capacity(1024); + + // Card container. + out.push_str("SolidView {"); + out.push_str(" width: Fill, height: Fit, flow: Down,"); + out.push_str(" padding: Inset{left: 20., right: 20., top: 16., bottom: 16.},"); + out.push_str(" spacing: 10.,"); + out.push_str(&format!(" draw_bg.color: #x{},", state.condition.bg_color_hex())); + out.push_str(" draw_bg.radius: 12."); + out.push('\n'); + + // Top row: location (left, flex) + optional updated_at (right). + out.push_str(" View { width: Fill, height: Fit, flow: Right, spacing: 8., align: Align{y: 0.5}"); + out.push('\n'); + out.push_str(&format!( + " Label {{ width: Fill, text: \"{}\", draw_text.color: #xffffff, draw_text.text_style.font_size: 18. }}\n", + splash_escape(&state.location), + )); + if let Some(updated) = &state.updated_at { + let prefix = tr_key(app_language, "agent_to_app.weather.updated_at_prefix"); + out.push_str(&format!( + " Label {{ text: \"{} {}\", draw_text.color: #xffffffaa, draw_text.text_style.font_size: 10. }}\n", + splash_escape(prefix), + splash_escape(updated), + )); + } + out.push_str(" }\n"); + + // Main row: symbol + temperature (left) + metadata column (right). + out.push_str(" View { width: Fill, height: Fit, flow: Right, spacing: 12., align: Align{y: 0.5}\n"); + out.push_str(&format!( + " Label {{ text: \"{}\", draw_text.color: #xffffff, draw_text.text_style.font_size: 36. }}\n", + splash_escape(state.condition.symbol()), + )); + out.push_str(&format!( + " Label {{ text: \"{}\u{00B0}\", draw_text.color: #xffffff, draw_text.text_style.font_size: 44. }}\n", + fmt_temp(state.temp_c), + )); + out.push_str( + " View { width: Fill, height: Fit, flow: Down, spacing: 2., align: Align{x: 1.0}\n", + ); + if let Some(feels) = state.feels_like_c { + let label = tr_key(app_language, "agent_to_app.weather.feels_like"); + out.push_str(&format!( + " Label {{ text: \"{} {}\u{00B0}\", draw_text.color: #xffffffcc, draw_text.text_style.font_size: 11. }}\n", + splash_escape(label), + fmt_temp(feels), + )); + } + if let Some(h) = state.humidity { + let label = tr_key(app_language, "agent_to_app.weather.humidity"); + out.push_str(&format!( + " Label {{ text: \"{} {}%\", draw_text.color: #xffffffcc, draw_text.text_style.font_size: 11. }}\n", + splash_escape(label), + h, + )); + } + if let Some(w) = state.wind_kph { + let label = tr_key(app_language, "agent_to_app.weather.wind"); + out.push_str(&format!( + " Label {{ text: \"{} {} km/h\", draw_text.color: #xffffffcc, draw_text.text_style.font_size: 11. }}\n", + splash_escape(label), + fmt_temp(w), + )); + } + out.push_str(" }\n"); + out.push_str(" }\n"); + + // Forecast row (horizontal chips, no ScrollYView). + if !state.forecast.is_empty() { + let forecast_label = tr_key(app_language, "agent_to_app.weather.forecast"); + out.push_str(&format!( + " Label {{ text: \"{}\", draw_text.color: #xffffffaa, draw_text.text_style.font_size: 10. }}\n", + splash_escape(forecast_label), + )); + out.push_str(" View { width: Fill, height: Fit, flow: Right, spacing: 6.\n"); + for entry in &state.forecast { + out.push_str(" RoundedView { width: Fit, height: Fit, flow: Down, spacing: 2.,"); + out.push_str(" padding: Inset{left: 8., right: 8., top: 6., bottom: 6.},"); + out.push_str(" draw_bg.color: #xffffff22, draw_bg.radius: 6.\n"); + out.push_str(&format!( + " Label {{ text: \"{}\", draw_text.color: #xffffff, draw_text.text_style.font_size: 10. }}\n", + splash_escape(&entry.day), + )); + out.push_str(&format!( + " Label {{ text: \"{}\", draw_text.color: #xffffff, draw_text.text_style.font_size: 16. }}\n", + splash_escape(entry.condition.symbol()), + )); + out.push_str(&format!( + " Label {{ text: \"{}\u{00B0}/{}\u{00B0}\", draw_text.color: #xffffffcc, draw_text.text_style.font_size: 10. }}\n", + fmt_temp(entry.high_c), + fmt_temp(entry.low_c), + )); + out.push_str(" }\n"); + } + out.push_str(" }\n"); + } + + out.push_str("}\n"); + out +} + +// ----- tests ----- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn init_from(value: JsonValue) -> Result, ValidationError> { + WeatherFactory.init(&value) + } + + #[test] + fn valid_payload_succeeds_and_renders() { + let rendered = init_from(json!({ + "location": "Beijing", + "temp_c": 22, + "condition": "sunny", + "humidity": 65 + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + assert!(splash.contains("Beijing")); + assert!(splash.contains("22")); + assert!(splash.contains("Humidity")); + } + + #[test] + fn missing_required_location_fails_closed() { + let Err(err) = init_from(json!({ + "temp_c": 22, + "condition": "sunny" + })) else { + panic!("expected validation error for missing location"); + }; + assert_eq!(err.field, "location"); + } + + #[test] + fn temperature_out_of_range_fails_closed() { + let Err(err) = init_from(json!({ + "location": "Nowhere", + "temp_c": -100, + "condition": "sunny" + })) else { + panic!("expected validation error for out-of-range temp_c"); + }; + assert_eq!(err.field, "temp_c"); + } + + #[test] + fn unknown_condition_falls_back_to_sunny_without_error() { + let rendered = init_from(json!({ + "location": "Nowhere", + "temp_c": 10, + "condition": "alien_storm" + })) + .unwrap(); + // Should render successfully (no error); the symbol should be sunny's. + let splash = rendered.render(AppLanguage::English); + assert!(splash.contains("\u{2600}")); + } + + #[test] + fn optional_fields_absent_renders_minimum_card() { + let rendered = init_from(json!({ + "location": "X", + "temp_c": 0, + "condition": "cloudy" + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + assert!(!splash.contains("Feels like")); + assert!(!splash.contains("Humidity")); + assert!(!splash.contains("Wind")); + assert!(!splash.contains("Forecast")); + assert!(!splash.contains("Updated")); + } + + #[test] + fn long_location_is_truncated_with_grapheme_ellipsis() { + // 200 'A' characters; each 'A' is one grapheme and one byte. + let long = "A".repeat(200); + let rendered = init_from(json!({ + "location": long, + "temp_c": 10, + "condition": "sunny" + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + // The displayed location should be 64 A's + an ellipsis. + let expected = "A".repeat(MAX_LOCATION_GRAPHEMES) + "\u{2026}"; + assert!( + splash.contains(&expected), + "expected truncated location in output; got: {}", + splash, + ); + } + + #[test] + fn forecast_over_seven_truncated_to_seven() { + let mut forecast = Vec::new(); + for i in 0..10 { + forecast.push(json!({ + "day": format!("D{i}"), + "high_c": 20, + "low_c": 10, + "condition": "sunny" + })); + } + let rendered = init_from(json!({ + "location": "Nowhere", + "temp_c": 15, + "condition": "sunny", + "forecast": forecast + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + // Only D0..D6 should appear; D7, D8, D9 should not. + for i in 0..MAX_FORECAST_ENTRIES { + assert!(splash.contains(&format!("D{i}")), "missing D{i}"); + } + for i in MAX_FORECAST_ENTRIES..10 { + assert!(!splash.contains(&format!("D{i}")), "unexpected D{i}"); + } + } + + #[test] + fn splash_escape_neutralizes_quotes_and_backslashes() { + assert_eq!(splash_escape("Beijing\""), "Beijing\\\""); + assert_eq!(splash_escape("a\\b"), "a\\\\b"); + assert_eq!(splash_escape("line1\nline2"), "line1 line2"); + assert_eq!(splash_escape("tab\there"), "tab here"); + assert_eq!(splash_escape("norm\x01al"), "norm al"); + } + + #[test] + fn location_with_injection_attempt_is_escaped_in_output() { + let rendered = init_from(json!({ + "location": "Beijing\"; rm -rf /\"", + "temp_c": 10, + "condition": "sunny" + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + // The escaped sequence should appear verbatim. + assert!(splash.contains("Beijing\\\"; rm -rf /\\\"")); + } + + #[test] + fn render_output_obeys_canvas_eval_syntax_requirements() { + let rendered = init_from(json!({ + "location": "Test", + "temp_c": 20, + "condition": "sunny" + })) + .unwrap(); + let splash = rendered.render(AppLanguage::English); + + // Must use draw_bg.radius, NOT draw_bg.border_radius. + assert!(splash.contains("draw_bg.radius"), "missing draw_bg.radius"); + assert!( + !splash.contains("border_radius"), + "output contains forbidden border_radius: {splash}", + ); + // Must NOT use ScrollYView in eval path. + assert!(!splash.contains("ScrollYView")); + // Must NOT rely on show_bg: true on pre-styled views. + assert!(!splash.contains("show_bg: true")); + // Must use explicit Inset{...} for padding. + assert!(splash.contains("Inset{")); + // Every whole-number float literal must use the trailing-dot form, + // never the explicit-zero form like `8.0` or `16.0`. + for forbidden in &[ + "8.0 ", "8.0,", "10.0 ", "10.0,", "11.0 ", "11.0,", + "12.0 ", "12.0,", "16.0 ", "16.0,", "18.0 ", "18.0,", + "20.0 ", "20.0,", "36.0 ", "36.0,", "44.0 ", "44.0,", + ] { + assert!( + !splash.contains(forbidden), + "output contains forbidden whole-number float form {forbidden:?}: {splash}", + ); + } + } + + #[test] + fn labels_resolve_via_app_language() { + let rendered = init_from(json!({ + "location": "Test", + "temp_c": 20, + "condition": "sunny", + "humidity": 50 + })) + .unwrap(); + let en = rendered.render(AppLanguage::English); + let zh = rendered.render(AppLanguage::ChineseSimplified); + assert!(en.contains("Humidity")); + assert!(zh.contains("湿度")); + assert!(!en.contains("湿度")); + assert!(!zh.contains("Humidity")); + } +} diff --git a/src/home/mod.rs b/src/home/mod.rs index 73f684c0..6bcd31be 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -1,6 +1,7 @@ use makepad_widgets::{ScriptVm, event::{DigitId, FingerDownEvent, FingerLongPressEvent, FingerUpEvent}}; pub mod add_room; +pub mod app_registry; pub mod bot_binding_modal; pub mod create_bot_modal; pub mod delete_bot_modal; diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 3a3c1ced..ec4b7226 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -8604,7 +8604,25 @@ fn populate_message_view( ); new_drawn_status.content_drawn = false; // force re-render } else { - // Check for Splash card in custom event field + // Agent-to-app envelope (L1 registry path). + // Reads from the ORIGINAL event content to enforce + // the immutability rule from the master spec + // (`specs/task-agent-to-app-system.spec.md` + // §消息不可变性): m.replace edits must not alter + // the rendered app envelope. + let app_splash_code = original_event_content_json(event_tl_item) + .as_ref() + .and_then(|content| { + crate::home::app_registry::render_app_envelope_to_splash( + content, + app_language, + ) + }); + + // Legacy raw Splash card path. Still reads from + // the latest effective content for now; will + // eventually be gated to development builds + // only (master spec §Out of Scope). let splash_code = latest_effective_event_content_json(event_tl_item) .and_then(|content| content @@ -8612,7 +8630,16 @@ fn populate_message_view( .and_then(|v| v.as_str().map(|s| s.to_string())) ); - if let Some(ref splash) = splash_code { + // Priority: org.octos.app registry path wins over + // raw splash_card when both are present (per L1 + // sub-spec §解析与接线). + if let Some(ref splash) = app_splash_code { + item.view(cx, ids!(content.message)).set_visible(cx, false); + let splash_widget = item.splash(cx, ids!(content.splash_card)); + splash_widget.set_visible(cx, true); + splash_widget.set_text(cx, splash); + new_drawn_status.content_drawn = true; + } else if let Some(ref splash) = splash_code { // SPLASH CARD MODE: render native Makepad card item.view(cx, ids!(content.message)).set_visible(cx, false); let splash_widget = item.splash(cx, ids!(content.splash_card)); From cf7f94b6ea2da125933c843b7e360e8760b1cf3c Mon Sep 17 00:00:00 2001 From: AlexZ Date: Tue, 28 Apr 2026 09:12:44 +0800 Subject: [PATCH 04/21] feat: add agent-to-app rendering infrastructure --- .gitignore | 1 + AGENTS.md | 11 + Cargo.lock | 557 +- Cargo.toml | 10 +- MAKEPAD.md | 1 + docs/design/agent-to-app-design.md | 544 + .../2026-04-14-tg-bot-phase6-ui-ux-roadmap.md | 159 + docs/roadmap/2026-04-17-agent-to-app-deps.dot | 46 + docs/roadmap/2026-04-17-agent-to-app-deps.svg | 255 + ...-04-17-agent-to-app-implementation-plan.md | 172 + ...26-04-17-plan-producer-routing.contract.md | 345 + .../2026-04-17-plan-weather-l1.contract.md | 377 + ...04-17-plan-weather-v2-doc-sync.contract.md | 208 + ...lash-host-evolution-implementation-plan.md | 363 + ...22-template-runtime-implementation-plan.md | 220 + ...agent-to-app-master-implementation-plan.md | 434 + .../2026-04-23-agent-to-app-roadmap.md | 301 + ...6-04-28-agent-to-app-airbnb-feasibility.md | 282 + .../01-deploying-palpo-and-octos-zh.md | 48 +- .../01-deploying-palpo-and-octos.md | 49 +- fixtures/airbnb-mock/README.md | 83 + fixtures/airbnb-mock/generate.py | 305 + fixtures/airbnb-mock/listings.json | 42906 ++++++++++++++++ ...-http-empty-body-ub-on-appservice-probe.md | 121 + ...dk-event-cache-invalid-item-index-panic.md | 109 + ...11-timeline-mermaid-drawlist-corruption.md | 122 + ...agram-modal-scroll-zoom-hit-area-offset.md | 108 + .../013-macos-magnify-event-api-not-stable.md | 82 + resources/i18n/en.json | 1 + resources/i18n/zh-CN.json | 1 + ...sk-agent-to-app-composite-response.spec.md | 121 + .../task-agent-to-app-l1-weather-card.spec.md | 106 +- ...gent-to-app-l1-weather-v2-doc-sync.spec.md | 223 + ...gent-to-app-l2a-booking-capability.spec.md | 346 + ...task-agent-to-app-producer-routing.spec.md | 434 + ...agent-to-app-splash-host-evolution.spec.md | 459 + ...task-agent-to-app-template-runtime.spec.md | 410 + specs/task-bot-diagram-modal-renderer.spec.md | 125 + src/app.rs | 1 + .../app_registry/capability_descriptors.rs | 204 + src/home/app_registry/local_functions.rs | 471 + src/home/app_registry/mod.rs | 500 +- src/home/app_registry/news.rs | 604 + src/home/app_registry/splash_host.rs | 1772 + src/home/app_registry/template_cache.rs | 127 + .../app_registry/template_preflight_audit.rs | 32 + src/home/app_registry/templates.rs | 214 + .../news_guidance/digest_card.splash | 56 + .../news_guidance/headlines_card.splash | 56 + .../weather_guidance/card_standard.splash | 115 + src/home/app_registry/weather.rs | 1345 +- src/home/app_registry/widget_manifest.rs | 261 + src/home/room_screen.rs | 1479 +- src/lib.rs | 1 + src/login/login_screen.rs | 2 +- src/room/room_input_bar.rs | 13 +- src/settings/bot_settings.rs | 515 +- src/settings/settings_screen.rs | 2 +- src/settings/translation_settings.rs | 2 +- 59 files changed, 57788 insertions(+), 429 deletions(-) create mode 100644 docs/design/agent-to-app-design.md create mode 100644 docs/roadmap/2026-04-14-tg-bot-phase6-ui-ux-roadmap.md create mode 100644 docs/roadmap/2026-04-17-agent-to-app-deps.dot create mode 100644 docs/roadmap/2026-04-17-agent-to-app-deps.svg create mode 100644 docs/roadmap/2026-04-17-agent-to-app-implementation-plan.md create mode 100644 docs/roadmap/2026-04-17-plan-producer-routing.contract.md create mode 100644 docs/roadmap/2026-04-17-plan-weather-l1.contract.md create mode 100644 docs/roadmap/2026-04-17-plan-weather-v2-doc-sync.contract.md create mode 100644 docs/roadmap/2026-04-21-splash-host-evolution-implementation-plan.md create mode 100644 docs/roadmap/2026-04-22-template-runtime-implementation-plan.md create mode 100644 docs/roadmap/2026-04-23-agent-to-app-master-implementation-plan.md create mode 100644 docs/roadmap/2026-04-23-agent-to-app-roadmap.md create mode 100644 docs/roadmap/2026-04-28-agent-to-app-airbnb-feasibility.md create mode 100644 fixtures/airbnb-mock/README.md create mode 100644 fixtures/airbnb-mock/generate.py create mode 100644 fixtures/airbnb-mock/listings.json create mode 100644 issues/009-makepad-apple-http-empty-body-ub-on-appservice-probe.md create mode 100644 issues/010-matrix-sdk-event-cache-invalid-item-index-panic.md create mode 100644 issues/011-timeline-mermaid-drawlist-corruption.md create mode 100644 issues/012-diagram-modal-scroll-zoom-hit-area-offset.md create mode 100644 issues/013-macos-magnify-event-api-not-stable.md create mode 100644 specs/task-agent-to-app-composite-response.spec.md create mode 100644 specs/task-agent-to-app-l1-weather-v2-doc-sync.spec.md create mode 100644 specs/task-agent-to-app-l2a-booking-capability.spec.md create mode 100644 specs/task-agent-to-app-producer-routing.spec.md create mode 100644 specs/task-agent-to-app-splash-host-evolution.spec.md create mode 100644 specs/task-agent-to-app-template-runtime.spec.md create mode 100644 specs/task-bot-diagram-modal-renderer.spec.md create mode 100644 src/home/app_registry/capability_descriptors.rs create mode 100644 src/home/app_registry/local_functions.rs create mode 100644 src/home/app_registry/news.rs create mode 100644 src/home/app_registry/splash_host.rs create mode 100644 src/home/app_registry/template_cache.rs create mode 100644 src/home/app_registry/template_preflight_audit.rs create mode 100644 src/home/app_registry/templates.rs create mode 100644 src/home/app_registry/templates/news_guidance/digest_card.splash create mode 100644 src/home/app_registry/templates/news_guidance/headlines_card.splash create mode 100644 src/home/app_registry/templates/weather_guidance/card_standard.splash create mode 100644 src/home/app_registry/widget_manifest.rs diff --git a/.gitignore b/.gitignore index 9d61dcd7..4ab8c07c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .vscode .DS_Store proxychains.conf +fixtures/airbnb-mock/.tsv-cache.tsv diff --git a/AGENTS.md b/AGENTS.md index 8842d383..e873e032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,17 @@ Always use `submit_async_request(MatrixRequest::*)`. Do not spawn raw tokio task - Runtime `script_apply_eval!` cannot rely on DSL constants like `Right`, `Fit`, or `Align` - `Dock.load_state()` can corrupt DrawList references in this project +## Splash Template Authoring + +Agent-to-app templates live under `src/home/app_registry/templates//