feat(epic222): Workshop — Timmy as wizard presence, world state, WS bootstrap (#31)

This commit is contained in:
2026-03-18 22:15:46 -04:00
parent ea4cddc2ad
commit a70898e939
13 changed files with 863 additions and 703 deletions

View 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";
}
}

View File

@@ -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 */
}

View File

@@ -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);

View 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;