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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/src-tauri/src/runtime/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use microflow_core::runtime::cloud;
use microflow_core::flow::FlowUpdate;
use microflow_core::runtime::{
CloudRequest, CloudRequestKind, ComponentBase, ComponentEvent, ComponentValue, Effects,
EffectsSink, FlowRuntime, SubscriberWiring, Wakeup, WakeupId,
EffectsSink, FlowRuntime, NodeDiagnostic, SubscriberWiring, Wakeup, WakeupId,
};
use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write};
Expand Down Expand Up @@ -433,6 +433,10 @@ impl EffectsSink for Actor {
fn dispatch_event(&mut self, event: &ComponentEvent) {
let _ = self.app.emit("component-event", event);
}

fn report_diagnostic(&mut self, diagnostic: &NodeDiagnostic) {
let _ = self.app.emit("node-diagnostic", diagnostic);
}
}

/// Performs cloud `Effects` for the desktop host (ADR-0009): the network I/O that
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/flow/nodes/i2c-device/i2c-device.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type BaseNode,
} from "../_base/_base";
import { useNodeValue } from "@/stores/node-data";
import { useNodeDiagnostic } from "@/stores/node-diagnostics";
import { useFlowNodes, useFlowSession } from "@/session";
import { dataSchema, type Data, type Value } from "./i2c-device.schema";
import {
Expand Down Expand Up @@ -48,10 +49,15 @@ function useSharedAddressWarning(id: string, address: number): string | undefine
}

export function I2cDevice(props: Props) {
const warning = useSharedAddressWarning(props.id, props.data.address);
const addressWarning = useSharedAddressWarning(props.id, props.data.address);
// A runtime fault (e.g. the device never ACKs a read) outranks the advisory
// shared-address warning: show the red error badge when the runtime raises one.
const diagnostic = useNodeDiagnostic();
const error = diagnostic?.level === "error" ? diagnostic.message : undefined;
const warning = diagnostic?.level === "warning" ? diagnostic.message : addressWarning;

return (
<NodeContainer {...props} warning={warning}>
<NodeContainer {...props} error={error} warning={warning}>
<Value />
<Settings />
<NodeHandles
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/hooks/use-node-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useListen } from "@/lib/ipc";
import type { NodeDiagnostic } from "@/lib/firmata/effects-sink";
import { useNodeDiagnosticsStore } from "@/stores/node-diagnostics";

/**
* Listens to node diagnostics from the Tauri backend and applies them to the
* same {@link useNodeDiagnosticsStore} the browser wasm reactor writes — so a
* hardware fault (e.g. an I2C device that never ACKs) surfaces on the node's
* badge identically on desktop and in the browser. The runtime raises/clears
* these on a transition, so a `null` message clears the node.
*/
export function useNodeDiagnostics() {
const apply = useNodeDiagnosticsStore((state) => state.apply);

useListen<NodeDiagnostic>({
type: "node-diagnostic",
handler: ({ payload }) => {
apply(payload);
},
});
}
15 changes: 14 additions & 1 deletion apps/web/src/lib/firmata/__tests__/effects-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type CloudRequest,
type ComponentEvent,
type EffectsSink,
type NodeDiagnostic,
type Wakeup,
} from "../effects-sink";
import type { Effects } from "@/lib/runtime/wasm";
Expand Down Expand Up @@ -31,6 +32,9 @@ class Recorder implements EffectsSink {
dispatchEvent(event: ComponentEvent): void {
this.calls.push(`event:${event.sourceHandle}`);
}
reportDiagnostic(diagnostic: NodeDiagnostic): void {
this.calls.push(`diag:${diagnostic.node}`);
}
}

function event(sourceHandle: string): ComponentEvent {
Expand All @@ -54,12 +58,20 @@ describe("applyEffects (ADR-0008 canonical order)", () => {
prompt: "hi",
},
],
nodeDiagnostics: [{ node: "i2c", level: "error", message: "no ACK" }],
};

const rec = new Recorder();
applyEffects(fx, rec);

expect(rec.calls).toEqual(["write:3", "cancel:7", "arm:9", "cloud:llm", "event:value"]);
expect(rec.calls).toEqual([
"write:3",
"cancel:7",
"arm:9",
"cloud:llm",
"event:value",
"diag:i2c",
]);
});

test("skips writeBytes when there are no outbound bytes", () => {
Expand All @@ -69,6 +81,7 @@ describe("applyEffects (ADR-0008 canonical order)", () => {
wakeups: [],
cancellations: [],
cloudRequests: [],
nodeDiagnostics: [],
};

const rec = new Recorder();
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/lib/firmata/effects-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type { CloudRequest };
export type Wakeup = Effects["wakeups"][number];
/** One emitted component event, as carried in the `Effects` serde shape. */
export type ComponentEvent = Effects["componentEvents"][number];
/** One node health signal, as carried in the `Effects` serde shape. */
export type NodeDiagnostic = Effects["nodeDiagnostics"][number];

/**
* The platform primitives an effects application drives — the TypeScript shape
Expand All @@ -32,6 +34,7 @@ export interface EffectsSink {
armWakeup(wakeup: Wakeup): void;
performCloud(request: CloudRequest): void;
dispatchEvent(event: ComponentEvent): void;
reportDiagnostic(diagnostic: NodeDiagnostic): void;
}

/**
Expand All @@ -58,14 +61,17 @@ const EFFECT_HANDLERS: { [K in keyof Effects]: (fx: Effects, sink: EffectsSink)
componentEvents: (fx, sink) => {
for (const event of fx.componentEvents) sink.dispatchEvent(event);
},
nodeDiagnostics: (fx, sink) => {
for (const diagnostic of fx.nodeDiagnostics) sink.reportDiagnostic(diagnostic);
},
};

/**
* The **canonical order** (ADR-0008, extended by ADR-0009) the fields apply in:
* `outboundBytes → cancellations → wakeups → cloudRequests → componentEvents`.
* Bytes first (wire latency), cancel-before-arm (so a cancel + re-arm of one
* logical timer in a turn is safe), cloud launched before UI events leave, UI
* events last (they leave the runtime and do not feed back this turn).
* `outboundBytes → cancellations → wakeups → cloudRequests → componentEvents
* nodeDiagnostics`. Bytes first (wire latency), cancel-before-arm (so a cancel +
* re-arm of one logical timer in a turn is safe), cloud launched before UI events
* leave, UI events last (they leave the runtime and do not feed back this turn).
*
* `satisfies` pins every entry to a real field; {@link AssertOrderIsExhaustive}
* below pins the *reverse* — a new field absent from this tuple fails to compile,
Expand All @@ -77,6 +83,7 @@ const APPLY_ORDER = [
"wakeups",
"cloudRequests",
"componentEvents",
"nodeDiagnostics",
] as const satisfies readonly (keyof Effects)[];

/** Errors unless {@link APPLY_ORDER} lists every key of `Effects` (the wrap in a
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/firmata/flow-reactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import {
type CloudRequest,
type ComponentEvent,
type EffectsSink,
type NodeDiagnostic,
type Wakeup,
} from "./effects-sink";
import { useNodeDiagnosticsStore } from "@/stores/node-diagnostics";
import type { BoardConnection } from "./web-serial";

// Re-exported so the board controller keeps importing `CloudDeps` from here; the
Expand Down Expand Up @@ -217,4 +219,8 @@ export class FlowReactor implements EffectsSink {
dispatchEvent(event: ComponentEvent): void {
applyComponentEvent(event, this.edges);
}

reportDiagnostic(diagnostic: NodeDiagnostic): void {
useNodeDiagnosticsStore.getState().apply(diagnostic);
}
}
9 changes: 9 additions & 0 deletions apps/web/src/lib/runtime/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,20 @@ export type MidiListener = {
deviceName: string;
};

/** A node's runtime health signal, shown on its UI badge (matches the Rust
* `NodeDiagnostic` serde shape). `message: null` clears the node's diagnostic. */
export type NodeDiagnostic = {
node: string;
level: "warning" | "error";
message: string | null;
};

/** The side effects of one runtime turn (matches the Rust `Effects` serde shape). */
export type Effects = {
outboundBytes: number[];
componentEvents: ComponentEvent[];
wakeups: Wakeup[];
cancellations: number[];
cloudRequests: CloudRequest[];
nodeDiagnostics: NodeDiagnostic[];
};
2 changes: 2 additions & 0 deletions apps/web/src/routes/flow/$flowId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/session";
import { usePins, type Pin } from "@/stores/board";
import { useComponentEvents } from "@/hooks/use-component-events";
import { useNodeDiagnostics } from "@/hooks/use-node-diagnostics";
import { useHotkeyEvents } from "@/hooks/use-hotkey-events";
import { useDebouncer } from "@tanstack/react-pacer";
import { trpc } from "@/lib/trpc";
Expand All @@ -33,6 +34,7 @@ import type { Node } from "@xyflow/react";
function FlowEventListeners() {
const session = useFlowSession();
useComponentEvents();
useNodeDiagnostics();
useHotkeyEvents();
// Dispatch the live flow to the runtime — Tauri IPC on desktop, the in-browser
// wasm runtime on web; the dispatcher picks the sender by platform.
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/stores/node-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useNodeId } from "@/components/flow/nodes/_base/_base";
import type { NodeDiagnostic } from "@/lib/firmata/effects-sink";
import { create } from "zustand";

/** A node's live runtime health, fed by the runtime `node_diagnostics` effect
* (browser reactor + desktop `node-diagnostic` Tauri event). Distinct from
* {@link useNodeDataStore} — that carries edge *values*; this carries a node's
* self-reported fault, rendered on the existing `NodeContainer` error/warning
* badge. A `null` message clears the node (recovery). */
type Diagnostic = { level: "warning" | "error"; message: string };

type NodeDiagnosticsState = {
diagnostics: Record<string, Diagnostic>;
apply: (diagnostic: NodeDiagnostic) => void;
clear: () => void;
};

export const useNodeDiagnosticsStore = create<NodeDiagnosticsState>((set) => ({
diagnostics: {},
clear: () => {
set({ diagnostics: {} });
},
apply: ({ node, level, message }) => {
set((state) => {
const next = { ...state.diagnostics };
if (message === null) {
delete next[node];
} else {
next[node] = { level, message };
}
return { diagnostics: next };
});
},
}));

/** The current diagnostic for the node in context, or `undefined` if healthy. */
export function useNodeDiagnostic(): Diagnostic | undefined {
const id = useNodeId();
return useNodeDiagnosticsStore((state) => state.diagnostics[id]);
}

export function useClearNodeDiagnostics() {
return useNodeDiagnosticsStore((state) => state.clear);
}
17 changes: 12 additions & 5 deletions crates/microflow-core/src/firmata/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,17 +493,24 @@ impl FirmataClient {
}
I2C_REPLY => {
let len = buf.len();
if len < 8 {
// Minimum is `F0 77 addrLSB addrMSB regLSB regMSB END_SYSEX` (7
// bytes, zero data): a device that never ACKs a read makes the
// board stream exactly this empty reply alongside its "I2C: Too
// few bytes received" string. Parse it (`data = []`) instead of
// dropping it, so the target node sees a short read and can
// surface the fault. The old `len < 8` guard silently discarded
// every empty reply, hiding the NACK entirely.
if len < 7 {
return Step::Skipped;
}
let mut reply = I2cReply {
address: i32::from(buf[2]) | (i32::from(buf[3]) << 7),
register: i32::from(buf[4]) | (i32::from(buf[5]) << 7),
data: vec![buf[6] | (buf[7] << 7)],
data: Vec::new(),
};
let mut i = 8;
while i < len - 1 {
if buf[i] == END_SYSEX || i + 2 > len {
let mut i = 6;
while i + 1 < len {
if buf[i] == END_SYSEX {
break;
}
reply.data.push(buf[i] | (buf[i + 1] << 7));
Expand Down
Loading
Loading