feat(epic222): Workshop — Timmy as wizard presence, world state, WS bootstrap (#31)
This commit is contained in:
52
artifacts/api-server/src/lib/world-state.ts
Normal file
52
artifacts/api-server/src/lib/world-state.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface TimmyState {
|
||||
mood: string;
|
||||
activity: string;
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
timmyState: TimmyState;
|
||||
agentStates: Record<string, string>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMMY: TimmyState = {
|
||||
mood: "contemplative",
|
||||
activity: "idle",
|
||||
};
|
||||
|
||||
const _state: WorldState = {
|
||||
timmyState: { ...DEFAULT_TIMMY },
|
||||
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export function getWorldState(): WorldState {
|
||||
return {
|
||||
timmyState: { ..._state.timmyState },
|
||||
agentStates: { ..._state.agentStates },
|
||||
updatedAt: _state.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function setAgentStateInWorld(agentId: string, agentState: string): void {
|
||||
_state.agentStates[agentId] = agentState;
|
||||
_state.updatedAt = new Date().toISOString();
|
||||
_deriveTimmy();
|
||||
}
|
||||
|
||||
function _deriveTimmy(): void {
|
||||
const states = Object.values(_state.agentStates);
|
||||
if (states.includes("working")) {
|
||||
_state.timmyState.activity = "working";
|
||||
_state.timmyState.mood = "focused";
|
||||
} else if (states.includes("thinking") || states.includes("evaluating")) {
|
||||
_state.timmyState.activity = "thinking";
|
||||
_state.timmyState.mood = "curious";
|
||||
} else if (states.some((s) => s !== "idle")) {
|
||||
_state.timmyState.activity = "active";
|
||||
_state.timmyState.mood = "attentive";
|
||||
} else {
|
||||
_state.timmyState.activity = "idle";
|
||||
_state.timmyState.mood = "contemplative";
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,77 @@
|
||||
/**
|
||||
* /api/ws — WebSocket bridge from the internal EventBus to connected Matrix clients.
|
||||
* /api/ws — WebSocket bridge from the internal EventBus to connected Workshop clients.
|
||||
*
|
||||
* Protocol (server → client):
|
||||
* { type: "world_state", timmyState, agentStates, recentEvents, updatedAt }
|
||||
* { type: "agent_state", agentId: string, state: "idle"|"active"|"working"|"thinking" }
|
||||
* { type: "job_started", jobId: string, agentId: string }
|
||||
* { type: "job_completed", jobId: string, agentId: string }
|
||||
* { type: "chat", agentId: string, text: string }
|
||||
* { type: "visitor_count", count: number }
|
||||
* { type: "ping" }
|
||||
*
|
||||
* Protocol (client → server):
|
||||
* { type: "subscribe", channel: "agents", clientId: string }
|
||||
* { type: "visitor_enter", visitorId: string, visitorName?: string }
|
||||
* { type: "visitor_leave", visitorId: string }
|
||||
* { type: "visitor_message", visitorId: string, text: string }
|
||||
* { type: "pong" }
|
||||
*
|
||||
* Agent mapping (Matrix IDs → Timmy roles):
|
||||
* Agent mapping (Workshop):
|
||||
* alpha — orchestrator (overall job lifecycle)
|
||||
* beta — eval (Haiku judge)
|
||||
* gamma — work (Sonnet executor)
|
||||
* delta — lightning (payment monitor / invoice watcher)
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { WebSocket } from "ws";
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { Server } from "http";
|
||||
import { eventBus, type BusEvent } from "../lib/event-bus.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js";
|
||||
import { db, worldEvents } from "@workspace/db";
|
||||
|
||||
const logger = makeLogger("ws-events");
|
||||
|
||||
const PING_INTERVAL_MS = 30_000;
|
||||
|
||||
function updateAgentWorld(agentId: string, state: string): void {
|
||||
try {
|
||||
setAgentStateInWorld(agentId, state);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function logWorldEvent(
|
||||
type: string,
|
||||
summary: string,
|
||||
agentId?: string,
|
||||
jobId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.insert(worldEvents).values({
|
||||
id: randomUUID(),
|
||||
type,
|
||||
summary,
|
||||
agentId: agentId ?? null,
|
||||
jobId: jobId ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal — don't crash WS on DB error */
|
||||
}
|
||||
}
|
||||
|
||||
function translateEvent(ev: BusEvent): object | null {
|
||||
switch (ev.type) {
|
||||
// ── Mode 1 job lifecycle ─────────────────────────────────────────────────
|
||||
case "job:state": {
|
||||
if (ev.state === "evaluating") {
|
||||
updateAgentWorld("alpha", "active");
|
||||
updateAgentWorld("beta", "thinking");
|
||||
void logWorldEvent("job:evaluating", `Evaluating job ${ev.jobId.slice(0, 8)}`, "beta", ev.jobId);
|
||||
return [
|
||||
{ type: "agent_state", agentId: "alpha", state: "active" },
|
||||
{ type: "agent_state", agentId: "beta", state: "thinking" },
|
||||
@@ -42,21 +79,30 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
];
|
||||
}
|
||||
if (ev.state === "awaiting_eval_payment") {
|
||||
updateAgentWorld("alpha", "active");
|
||||
return { type: "agent_state", agentId: "alpha", state: "active" };
|
||||
}
|
||||
if (ev.state === "awaiting_work_payment") {
|
||||
updateAgentWorld("beta", "idle");
|
||||
updateAgentWorld("delta", "active");
|
||||
return [
|
||||
{ type: "agent_state", agentId: "beta", state: "idle" },
|
||||
{ type: "agent_state", agentId: "delta", state: "active" },
|
||||
];
|
||||
}
|
||||
if (ev.state === "executing") {
|
||||
updateAgentWorld("delta", "idle");
|
||||
updateAgentWorld("gamma", "working");
|
||||
void logWorldEvent("job:executing", `Working on job ${ev.jobId.slice(0, 8)}`, "gamma", ev.jobId);
|
||||
return [
|
||||
{ type: "agent_state", agentId: "delta", state: "idle" },
|
||||
{ type: "agent_state", agentId: "gamma", state: "working" },
|
||||
];
|
||||
}
|
||||
if (ev.state === "complete") {
|
||||
updateAgentWorld("gamma", "idle");
|
||||
updateAgentWorld("alpha", "idle");
|
||||
void logWorldEvent("job:complete", `Completed job ${ev.jobId.slice(0, 8)}`, "alpha", ev.jobId);
|
||||
return [
|
||||
{ type: "agent_state", agentId: "gamma", state: "idle" },
|
||||
{ type: "agent_state", agentId: "alpha", state: "idle" },
|
||||
@@ -64,6 +110,8 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
];
|
||||
}
|
||||
if (ev.state === "rejected" || ev.state === "failed") {
|
||||
["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle"));
|
||||
void logWorldEvent(`job:${ev.state}`, `Job ${ev.jobId.slice(0, 8)} ${ev.state}`, "alpha", ev.jobId);
|
||||
return [
|
||||
{ type: "agent_state", agentId: "beta", state: "idle" },
|
||||
{ type: "agent_state", agentId: "gamma", state: "idle" },
|
||||
@@ -74,12 +122,15 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
return null;
|
||||
}
|
||||
case "job:completed":
|
||||
updateAgentWorld("gamma", "idle");
|
||||
updateAgentWorld("alpha", "idle");
|
||||
return [
|
||||
{ type: "agent_state", agentId: "gamma", state: "idle" },
|
||||
{ type: "agent_state", agentId: "alpha", state: "idle" },
|
||||
{ type: "chat", agentId: "gamma", text: `Job ${ev.jobId.slice(0, 8)} complete` },
|
||||
];
|
||||
case "job:failed":
|
||||
["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle"));
|
||||
return [
|
||||
{ type: "agent_state", agentId: "alpha", state: "idle" },
|
||||
{ type: "agent_state", agentId: "beta", state: "idle" },
|
||||
@@ -89,6 +140,9 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
];
|
||||
case "job:paid":
|
||||
if (ev.invoiceType === "eval") {
|
||||
updateAgentWorld("delta", "idle");
|
||||
updateAgentWorld("beta", "thinking");
|
||||
void logWorldEvent("payment:eval", "Eval payment confirmed", "delta");
|
||||
return [
|
||||
{ type: "agent_state", agentId: "delta", state: "idle" },
|
||||
{ type: "agent_state", agentId: "beta", state: "thinking" },
|
||||
@@ -96,6 +150,9 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
];
|
||||
}
|
||||
if (ev.invoiceType === "work") {
|
||||
updateAgentWorld("delta", "idle");
|
||||
updateAgentWorld("gamma", "working");
|
||||
void logWorldEvent("payment:work", "Work payment confirmed", "delta");
|
||||
return [
|
||||
{ type: "agent_state", agentId: "delta", state: "idle" },
|
||||
{ type: "agent_state", agentId: "gamma", state: "working" },
|
||||
@@ -146,6 +203,25 @@ function broadcast(socket: WebSocket, ev: BusEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWorldStateBootstrap(socket: WebSocket): Promise<void> {
|
||||
try {
|
||||
const state = getWorldState();
|
||||
const { desc } = await import("drizzle-orm");
|
||||
const recent = await db
|
||||
.select()
|
||||
.from(worldEvents)
|
||||
.orderBy(desc(worldEvents.createdAt))
|
||||
.limit(20);
|
||||
send(socket, {
|
||||
type: "world_state",
|
||||
...state,
|
||||
recentEvents: recent.reverse(),
|
||||
});
|
||||
} catch {
|
||||
send(socket, { type: "world_state", ...getWorldState(), recentEvents: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export function attachWebSocketServer(server: Server): void {
|
||||
const wss = new WebSocketServer({ server, path: "/api/ws" });
|
||||
|
||||
@@ -153,6 +229,8 @@ export function attachWebSocketServer(server: Server): void {
|
||||
const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown";
|
||||
logger.info("ws client connected", { ip, clients: wss.clients.size });
|
||||
|
||||
void sendWorldStateBootstrap(socket);
|
||||
|
||||
const busHandler = (ev: BusEvent) => broadcast(socket, ev);
|
||||
eventBus.on("bus", busHandler);
|
||||
|
||||
@@ -162,11 +240,34 @@ export function attachWebSocketServer(server: Server): void {
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString()) as { type?: string };
|
||||
const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string };
|
||||
if (msg.type === "pong") return;
|
||||
if (msg.type === "subscribe") {
|
||||
send(socket, { type: "agent_count", count: wss.clients.size });
|
||||
}
|
||||
if (msg.type === "visitor_enter") {
|
||||
wss.clients.forEach(c => {
|
||||
if (c !== socket && c.readyState === 1) {
|
||||
c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size }));
|
||||
}
|
||||
});
|
||||
send(socket, { type: "visitor_count", count: wss.clients.size });
|
||||
}
|
||||
if (msg.type === "visitor_leave") {
|
||||
wss.clients.forEach(c => {
|
||||
if (c !== socket && c.readyState === 1) {
|
||||
c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (msg.type === "visitor_message" && msg.text) {
|
||||
const text = String(msg.text).slice(0, 500);
|
||||
wss.clients.forEach(c => {
|
||||
if (c.readyState === 1) {
|
||||
c.send(JSON.stringify({ type: "chat", agentId: "visitor", text }));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed messages */
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import testkitRouter from "./testkit.js";
|
||||
import uiRouter from "./ui.js";
|
||||
import nodeDiagnosticsRouter from "./node-diagnostics.js";
|
||||
import metricsRouter from "./metrics.js";
|
||||
import worldRouter from "./world.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -21,6 +22,7 @@ router.use(demoRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
router.use(nodeDiagnosticsRouter);
|
||||
router.use(worldRouter);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
router.use(devRouter);
|
||||
|
||||
29
artifacts/api-server/src/routes/world.ts
Normal file
29
artifacts/api-server/src/routes/world.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { db, worldEvents } from "@workspace/db";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getWorldState } from "../lib/world-state.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = makeLogger("world");
|
||||
const router = Router();
|
||||
|
||||
router.get("/world/state", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const state = getWorldState();
|
||||
const recent = await db
|
||||
.select()
|
||||
.from(worldEvents)
|
||||
.orderBy(desc(worldEvents.createdAt))
|
||||
.limit(20);
|
||||
|
||||
res.json({
|
||||
...state,
|
||||
recentEvents: recent.reverse(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("GET /api/world/state failed", { error: String(err) });
|
||||
res.status(500).json({ error: "world_state_error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user