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
5 changes: 5 additions & 0 deletions .github/workflows/build-node-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ jobs:
run: |
mkdir -p dist
bin_name="amesh-node"
cli_name="amesh"
if [ "${GOOS}" = "windows" ]; then
bin_name="amesh-node.exe"
cli_name="amesh.exe"
fi
go build -trimpath -ldflags="-s -w" -o "dist/${bin_name}" ./cmd/amesh-node
go build -trimpath -ldflags="-s -w" -o "dist/${cli_name}" ./cmd/amesh

- name: Run Go tests
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
Expand All @@ -67,6 +70,7 @@ jobs:
cp README.md package/
cp install-amesh-node.sh package/
cp dist/amesh-node package/
cp dist/amesh package/
tar -C package -czf "dist/${asset_base}.tar.gz" .

- name: Package zip artifact
Expand All @@ -77,6 +81,7 @@ jobs:
cp README.md package/
cp install-amesh-node.sh package/
cp dist/amesh-node.exe package/
cp dist/amesh.exe package/
cd package
zip -r "../dist/${asset_base}.zip" .

Expand Down
56 changes: 54 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
NodeDirectoryBrowsePayload,
NodeDirectoryBrowseResultPayload,
NodeHeartbeatPayload,
NodeLogEntry,
NodeLogPayload,
NodePathsUpdatePayload,
NodeRegistrationPayload,
NodeResumePayload,
Expand All @@ -29,6 +31,8 @@ import {
nodeDirectoryBrowsePayloadSchema,
nodeDirectoryBrowseResultPayloadSchema,
nodeHeartbeatPayloadSchema,
nodeLogPayloadSchema,
nodeLogsResponseSchema,
nodePathsUpdatePayloadSchema,
nodeRegistrationPayloadSchema,
nodeResumePayloadSchema,
Expand Down Expand Up @@ -76,6 +80,24 @@ type NodeSocket = {
send: (message: ProtocolEnvelope) => void;
};

class NodeLogStore {
private readonly entries = new Map<string, NodeLogEntry[]>();

append(input: NodeLogPayload) {
const entry: NodeLogEntry = {
id: nanoid(14),
...input
};
const entries = [...(this.entries.get(input.nodeId) ?? []), entry].slice(-300);
this.entries.set(input.nodeId, entries);
return entry;
}

list(nodeId: string) {
return [...(this.entries.get(nodeId) ?? [])];
}
}

type AppRouteDeps = {
app: ReturnType<typeof Fastify>;
authConfig: ReturnType<typeof resolveAuthConfig>;
Expand All @@ -96,6 +118,7 @@ type AppRouteDeps = {
broadcastTopology: () => Promise<void>;
broadcastSession: (sessionId: string) => Promise<void>;
sendToNode: (nodeId: string, envelope: ProtocolEnvelope) => void;
nodeLogs: NodeLogStore;
};

function websocketSend(socket: WebSocket, payload: unknown) {
Expand All @@ -122,6 +145,7 @@ export function buildApp(options: AppOptions = {}) {
const browserSockets = new Set<WebSocket>();
const nodeSockets = new Map<string, NodeSocket>();
const nodeVersions = new Map<string, string | null>();
const nodeLogs = new NodeLogStore();
const pendingDirectoryBrowses = new Map<
string,
{
Expand Down Expand Up @@ -222,6 +246,19 @@ export function buildApp(options: AppOptions = {}) {
}
}

function broadcastNodeLogs(nodeId: string) {
const message = browserRealtimeEventSchema.parse({
type: "node.logs.updated",
payload: nodeLogsResponseSchema.parse({
nodeId,
entries: nodeLogs.list(nodeId)
})
});
for (const socket of browserSockets) {
websocketSend(socket, message);
}
}

function maybePropagateChildCompletion(payload: SessionEventRecord) {
const state = repository.getSession(payload.sessionId);
if (!state?.session.parentSessionId) {
Expand Down Expand Up @@ -376,6 +413,12 @@ export function buildApp(options: AppOptions = {}) {
void broadcastTopology();
break;
}
case "node.log": {
const payload = nodeLogPayloadSchema.parse(envelope.payload) as NodeLogPayload;
nodeLogs.append(payload);
broadcastNodeLogs(payload.nodeId);
break;
}
case "node.capabilities.sync": {
const payload = capabilitySyncPayloadSchema.parse(
envelope.payload
Expand Down Expand Up @@ -556,7 +599,8 @@ export function buildApp(options: AppOptions = {}) {
topologySnapshot,
broadcastTopology,
broadcastSession,
sendToNode
sendToNode,
nodeLogs
});

app.server.on("upgrade", (request, socket, head) => {
Expand Down Expand Up @@ -627,7 +671,8 @@ function registerApiRoutes({
topologySnapshot,
broadcastTopology,
broadcastSession,
sendToNode
sendToNode,
nodeLogs
}: AppRouteDeps) {
app.get("/api/auth/session", async (request: FastifyRequest) => ({
authenticated: isAuthenticated(request.cookies[authConfig.cookieName]),
Expand Down Expand Up @@ -663,6 +708,13 @@ function registerApiRoutes({
repository.listTopology().triggerRules
);
app.get("/api/topology", { preHandler: requireBrowserAuth }, async () => topologySnapshot());
app.get("/api/nodes/:nodeId/logs", { preHandler: requireBrowserAuth }, async (request: FastifyRequest) => {
const params = request.params as { nodeId: string };
return nodeLogsResponseSchema.parse({
nodeId: params.nodeId,
entries: nodeLogs.list(params.nodeId)
});
});
app.post("/api/nodes/:nodeId/update", { preHandler: requireBrowserAuth }, async (request: FastifyRequest, reply: FastifyReply) =>
triggerNodeAction(request, reply, repository, nodeSockets, sendToNode, "update")
);
Expand Down
48 changes: 48 additions & 0 deletions apps/server/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,54 @@ describe("server app", () => {
socket.close();
});

it("stores and returns node logs", async () => {
const socket = new WebSocket(`ws://${address}/ws?role=node&nodeId=node-1`);
await waitForOpen(socket);
socket.send(JSON.stringify(registerNode("node-1", "a")));
await readNodeMessage(socket);

socket.send(
JSON.stringify({
type: "node.log",
requestId: "log-1",
sessionId: null,
source: "node-1",
target: "server",
payload: {
nodeId: "node-1",
level: "error",
message: "agent health probe failed",
context: {
agentId: "agent-openclaw",
error: "ACP metadata is missing"
},
observedAt: "2026-05-13T18:00:00Z"
}
})
);
await waitForIdle();

const response = await injectAuthed(app, authCookie, {
method: "GET",
url: "/api/nodes/node-1/logs"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
nodeId: "node-1",
entries: [
{
level: "error",
message: "agent health probe failed",
context: {
agentId: "agent-openclaw",
error: "ACP metadata is missing"
}
}
]
});
socket.close();
});

it("resolves the default sqlite path independently of process cwd", async () => {
const originalCwd = process.cwd();
const tempCwd = await mkdtemp(join(tmpdir(), "amesh-db-cwd-"));
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {
BrowserRealtimeEvent,
BrowseNodeDirectoriesResponse,
NodeLogsResponse,
TopologySnapshot,
TriggerRule
} from "@amesh/protocol";
import { browseNodeDirectoriesResponseSchema } from "@amesh/protocol";
import { browseNodeDirectoriesResponseSchema, nodeLogsResponseSchema } from "@amesh/protocol";

import type { SessionSummary, SessionView } from "./types.js";

Expand Down Expand Up @@ -118,6 +119,15 @@ export async function fetchNodeDirectories(nodeId: string, path?: string): Promi
return browseNodeDirectoriesResponseSchema.parse(await response.json());
}

export async function fetchNodeLogs(nodeId: string): Promise<NodeLogsResponse> {
const response = await apiFetch(`/api/nodes/${nodeId}/logs`);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(body?.message ?? "Log fetch failed");
}
return nodeLogsResponseSchema.parse(await response.json());
}

export async function createTriggerRule(input: {
sourceAgentId: string;
targetAgentId: string;
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { GitBranch, MessagesSquare } from "lucide-react";
import { FileText, GitBranch, MessagesSquare } from "lucide-react";

export function AppSidebar() {
const { location } = useRouterState();

const path = location.pathname;
const isTopology = path === "/" || path.startsWith("/topology");
const isSessions = path.startsWith("/sessions");
const isLogs = path.startsWith("/logs");

return (
<aside className="app-sidebar" aria-label="Application shell">
Expand All @@ -19,6 +20,10 @@ export function AppSidebar() {
<MessagesSquare />
<span className="sr-only">Sessions</span>
</Link>
<Link to="/logs" data-active={isLogs} aria-label="Logs" title="Logs">
<FileText />
<span className="sr-only">Logs</span>
</Link>
</nav>
</aside>
);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/topologyContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createContext, useContext, type ReactNode } from "react";
import type { TopologySnapshot } from "@amesh/protocol";
import { createContext, useContext, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { NodeLogEntry, TopologySnapshot } from "@amesh/protocol";

import { type ConnectionState, useTopologyStore } from "./topologyStore.js";

type TopologyContextValue = {
topology: TopologySnapshot;
nodeLogs: Record<string, NodeLogEntry[]>;
setNodeLogs: Dispatch<SetStateAction<Record<string, NodeLogEntry[]>>>;
connection: ConnectionState;
refresh: () => void;
};
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/lib/topologyStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { TopologySnapshot } from "@amesh/protocol";
import type { NodeLogEntry, TopologySnapshot } from "@amesh/protocol";

import { connectRealtime, fetchTopology } from "../api.js";

Expand All @@ -9,6 +9,7 @@ export type ConnectionState = "loading" | "connected" | "disconnected";

export function useTopologyStore() {
const [topology, setTopology] = useState<TopologySnapshot>(empty);
const [nodeLogs, setNodeLogs] = useState<Record<string, NodeLogEntry[]>>({});
const [connection, setConnection] = useState<ConnectionState>("loading");
const refetchRef = useRef<() => void>(() => {});

Expand Down Expand Up @@ -37,6 +38,12 @@ export function useTopologyStore() {
if (event.type === "topology.snapshot" || event.type === "topology.updated") {
setTopology(event.payload);
}
if (event.type === "node.logs.updated") {
setNodeLogs((current) => ({
...current,
[event.payload.nodeId]: event.payload.entries
}));
}
});
socket.onopen = () => active && setConnection("connected");
socket.onerror = () => active && setConnection("disconnected");
Expand All @@ -53,6 +60,8 @@ export function useTopologyStore() {

return {
topology,
nodeLogs,
setNodeLogs,
connection,
refresh: () => refetchRef.current()
};
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AppSidebar } from "./components/AppSidebar.js";
import { ErrorBanner } from "./components/ErrorBanner.js";
import { TopBar } from "./components/TopBar.js";
import { useTopology } from "./lib/topologyContext.js";
import { LogsRoute } from "./routes/LogsRoute.js";
import { SessionsRoute } from "./routes/SessionsRoute.js";
import { TopologyRoute } from "./routes/TopologyRoute.js";

Expand Down Expand Up @@ -62,7 +63,13 @@ const sessionsRoute = createRoute({
component: SessionsRoute
});

const routeTree = rootRoute.addChildren([topologyRoute, sessionsRoute]);
const logsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/logs",
component: LogsRoute
});

const routeTree = rootRoute.addChildren([topologyRoute, sessionsRoute, logsRoute]);

export const router = createRouter({ routeTree });

Expand Down
Loading
Loading