Add demo operator scaffold and CLI

This commit is contained in:
Alexa Amundson
2025-11-23 15:48:33 -06:00
parent dde613a012
commit 32b15ee409
25 changed files with 4725 additions and 8 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.DS_Store
dist

View File

@@ -1,8 +1,41 @@
![Auto Assign](https://github.com/BlackRoad-OS/demo-repository/actions/workflows/auto-assign.yml/badge.svg)
# BlackRoad OS Demo
![Proof HTML](https://github.com/BlackRoad-OS/demo-repository/actions/workflows/proof-html.yml/badge.svg)
A safe **playground and showcase** for BlackRoad OS patterns. This repository runs a miniature operator, example agents, and a CLI so you can experiment without touching production systems or real data.
# Welcome to your organization's demo respository
This code repository (or "repo") is designed to demonstrate the best GitHub has to offer with the least amount of noise.
## Quick start
The repo includes an `index.html` file (so it can render a web page), two GitHub Actions workflows, and a CSS stylesheet dependency.
```bash
npm install
npm run demo
```
Type `help` inside the CLI for available commands:
- `echo <message>`
- `inc`
- `tx in|out <amount>`
- `fact <key> <true|false>`
- `summary`
- `journal`
## What this demo includes
- **Mini operator**: In-memory event bus and journal inspired by the real BlackRoad OS operator.
- **Demo agents**: Echo, counter, finance, and contradiction detectors.
- **Examples**: Sample events and finance scenarios in `examples/` and `config/demo-config.json` for reference.
## Docs
- [Overview](docs/overview.md)
- [Adding an agent](docs/adding-an-agent.md)
- [Demo scenarios](docs/demo-scenarios.md)
## Safety notice
This repo is for demonstrations only. It contains no real secrets or production integrations. Journals and state live only in memory while the process is running.
## Future TODOs
- Minimal web UI for showing event streams.
- Dockerfile for quick spin-up.
- Optional integration with the real `blackroad-os-operator` backend once available.

7
config/demo-config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"description": "Local demo configuration for BlackRoad OS Demo",
"defaultAgents": ["echo", "counter", "demo.finance", "contradiction"],
"logging": {
"level": "debug"
}
}

13
docs/adding-an-agent.md Normal file
View File

@@ -0,0 +1,13 @@
# Adding a new demo agent
This repo mirrors the shape of the real BlackRoad OS operator. Follow these steps to add your own demo agent.
1. **Create the agent file** in `src/agents/` and implement the `DemoAgent` interface.
- Subscribe to relevant event types inside `init` using the `ctx.bus.subscribe` method.
- Use `ctx.journal.journal` to record actions and `ctx.log` for console diagnostics.
2. **Register the agent** in `src/demo-operator/demoRegistry.ts` so it gets constructed and initialized when the demo starts.
3. **Wire commands (optional)** in `src/cli/runDemo.ts` to make it easy to trigger your agent from the CLI.
4. **Run the demo** with `npm run demo` and try your new events.
5. **Inspect the journal** with the `journal` command to confirm your agent is journaling the expected entries.
Use the existing `EchoAgent`, `CounterAgent`, and `DemoFinanceAgent` for concrete patterns to copy.

28
docs/demo-scenarios.md Normal file
View File

@@ -0,0 +1,28 @@
# Demo scenarios
Use these guided flows to exercise the demo operator.
## Finance mini-close
1. Start the CLI: `npm run demo`.
2. Send a few transactions:
- `tx in 500`
- `tx out 150`
- `tx out 50`
- `tx in 200`
3. Run `summary` to view the current finance snapshot.
4. Inspect `journal` to see the transaction trail and hashes.
Expected final cash balance matches the `examples/finance-scenarios/basic-close.json` scenario: **500**.
## Contradiction detection
1. From the CLI, send `fact sky true`.
2. Send `fact sky false`.
3. The `ContradictionAgent` will journal the conflict and emit a `demo.contradiction` event.
4. Run `journal` to see both the recorded fact and contradiction entries.
## Echo and counter basics
- `echo Hello, BlackRoad!` triggers the echo agent and emits a `demo.echo.response`.
- `inc` increments the counter and emits `counter.updated`. Run `summary` to check the count alongside finance data.

30
docs/overview.md Normal file
View File

@@ -0,0 +1,30 @@
# BlackRoad OS Demo
This repository hosts a **safe, in-memory playground** for experimenting with BlackRoad OS concepts. It mirrors the shapes of the real operator and finance layer without touching production systems or secrets.
## What lives here
- A mini in-process operator with an in-memory event bus and journal.
- Example agents (echo, counter, finance, contradiction) that subscribe to demo events.
- A CLI (`npm run demo`) to publish events, inspect state, and view journal entries.
- Example event payloads and finance scenarios you can replay.
## Quick start
```bash
npm install
npm run demo
```
The CLI will start and present a prompt. Type `help` to see available commands.
## Included demo agents
- **EchoAgent**: responds to `demo.echo` events and emits `demo.echo.response`.
- **CounterAgent**: increments on `counter.increment` and emits `counter.updated`.
- **DemoFinanceAgent**: tracks simple cash balances from `finance.transaction` events and emits `finance.summary.updated`.
- **ContradictionAgent**: detects conflicting `demo.fact` events and emits `demo.contradiction`.
## Safety
Everything runs locally, in memory, and uses toy data only. Journaled entries are not persisted outside the process. Treat this repository as a sandbox for experimenting with BlackRoad OS patterns.

16
examples/events.json Normal file
View File

@@ -0,0 +1,16 @@
[
{
"id": "evt-1",
"type": "demo.echo",
"source": "example",
"timestamp": "2025-01-01T00:00:00Z",
"payload": { "message": "Hello from example!" }
},
{
"id": "evt-2",
"type": "finance.transaction",
"source": "example",
"timestamp": "2025-01-01T00:00:01Z",
"payload": { "type": "in", "amount": 1000 }
}
]

View File

@@ -0,0 +1,11 @@
{
"name": "basic-close",
"description": "Toy finance close with a few transactions",
"events": [
{ "type": "finance.transaction", "payload": { "type": "in", "amount": 500 } },
{ "type": "finance.transaction", "payload": { "type": "out", "amount": 150 } },
{ "type": "finance.transaction", "payload": { "type": "out", "amount": 50 } },
{ "type": "finance.transaction", "payload": { "type": "in", "amount": 200 } }
],
"expectedFinalCashBalance": 500
}

8
jest.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true
};

4023
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,22 @@
{
"name": "demo-repo",
"version": "0.2.0",
"description": "A sample package.json",
"name": "blackroad-os-demo",
"version": "0.3.0",
"description": "Demo, playground, and example hub for BlackRoad OS",
"scripts": {
"demo": "ts-node src/cli/runDemo.ts",
"dev:demo": "ts-node src/cli/runDemo.ts",
"test": "jest"
},
"dependencies": {
"@primer/css": "17.0.1"
},
"devDependencies": {
"@types/jest": "29.5.12",
"@types/node": "22.7.4",
"jest": "29.7.0",
"ts-jest": "29.2.5",
"ts-node": "10.9.2",
"typescript": "5.5.4"
},
"license": "MIT"
}

View File

@@ -0,0 +1,51 @@
import crypto from "crypto";
import { DemoAgent, DemoAgentContext } from "../demo-operator/demoAgentContext";
import { DemoEvent } from "../demo-operator/demoEventBus";
type FactValue = "true" | "false";
export class ContradictionAgent implements DemoAgent {
id = "demo.contradiction";
private ctx: DemoAgentContext | undefined;
private beliefs: Record<string, FactValue> = {};
async init(ctx: DemoAgentContext): Promise<void> {
this.ctx = ctx;
ctx.bus.subscribe("demo.fact", (event) => this.onEvent(event));
ctx.log("ContradictionAgent initialized.");
}
async onEvent(event: DemoEvent): Promise<void> {
if (!this.ctx) return;
const payload = event.payload as { key: string; value: FactValue };
if (!payload || typeof payload.key !== "string" || (payload.value !== "true" && payload.value !== "false")) {
this.ctx.log("ContradictionAgent received invalid payload", payload);
return;
}
const existing = this.beliefs[payload.key];
if (existing && existing !== payload.value) {
const journalEntry = this.ctx.journal.journal(this.id, "contradiction", {
key: payload.key,
previous: existing,
incoming: payload.value
});
this.ctx.log("ContradictionAgent detected contradiction", journalEntry);
await this.ctx.bus.publish({
id: crypto.randomUUID(),
type: "demo.contradiction",
source: this.id,
timestamp: new Date().toISOString(),
payload: {
key: payload.key,
previous: existing,
incoming: payload.value
}
});
}
this.beliefs[payload.key] = payload.value;
this.ctx.journal.journal(this.id, "fact.recorded", payload);
}
}

View File

@@ -0,0 +1,38 @@
import crypto from "crypto";
import { DemoAgent, DemoAgentContext } from "../demo-operator/demoAgentContext";
import { DemoEvent } from "../demo-operator/demoEventBus";
export class CounterAgent implements DemoAgent {
id = "demo.counter";
private ctx: DemoAgentContext | undefined;
private count = 0;
getCount(): number {
return this.count;
}
async init(ctx: DemoAgentContext): Promise<void> {
this.ctx = ctx;
ctx.bus.subscribe("counter.increment", (event) => this.onEvent(event));
ctx.log("CounterAgent initialized.");
}
async onEvent(event: DemoEvent): Promise<void> {
if (!this.ctx) return;
this.count += 1;
const journalEntry = this.ctx.journal.journal(this.id, "increment", {
previous: this.count - 1,
new: this.count,
payload: event.payload
});
this.ctx.log("CounterAgent updated count", journalEntry);
await this.ctx.bus.publish({
id: crypto.randomUUID(),
type: "counter.updated",
source: this.id,
timestamp: new Date().toISOString(),
payload: { count: this.count }
});
}
}

View File

@@ -0,0 +1,60 @@
import crypto from "crypto";
import { DemoAgent, DemoAgentContext } from "../demo-operator/demoAgentContext";
import { DemoEvent } from "../demo-operator/demoEventBus";
export interface FinanceSummary {
cashBalance: number;
totalIn: number;
totalOut: number;
}
export class DemoFinanceAgent implements DemoAgent {
id = "demo.finance";
private ctx: DemoAgentContext | undefined;
private summary: FinanceSummary = {
cashBalance: 0,
totalIn: 0,
totalOut: 0
};
getSummary(): FinanceSummary {
return { ...this.summary };
}
async init(ctx: DemoAgentContext): Promise<void> {
this.ctx = ctx;
ctx.bus.subscribe("finance.transaction", (event) => this.onEvent(event));
ctx.log("DemoFinanceAgent initialized.");
}
async onEvent(event: DemoEvent): Promise<void> {
if (!this.ctx) return;
const payload = event.payload as { amount: number; type: "in" | "out" };
if (!payload || typeof payload.amount !== "number" || (payload.type !== "in" && payload.type !== "out")) {
this.ctx.log("DemoFinanceAgent received invalid payload", payload);
return;
}
if (payload.type === "in") {
this.summary.totalIn += payload.amount;
this.summary.cashBalance += payload.amount;
} else {
this.summary.totalOut += payload.amount;
this.summary.cashBalance -= payload.amount;
}
const journalEntry = this.ctx.journal.journal(this.id, "finance.transaction", {
...payload,
snapshot: this.getSummary()
});
this.ctx.log("DemoFinanceAgent recorded transaction", journalEntry);
await this.ctx.bus.publish({
id: crypto.randomUUID(),
type: "finance.summary.updated",
source: this.id,
timestamp: new Date().toISOString(),
payload: this.getSummary()
});
}
}

29
src/agents/echoAgent.ts Normal file
View File

@@ -0,0 +1,29 @@
import crypto from "crypto";
import { DemoAgent, DemoAgentContext } from "../demo-operator/demoAgentContext";
import { DemoEvent } from "../demo-operator/demoEventBus";
export class EchoAgent implements DemoAgent {
id = "demo.echo";
private ctx: DemoAgentContext | undefined;
async init(ctx: DemoAgentContext): Promise<void> {
this.ctx = ctx;
ctx.bus.subscribe("demo.echo", (event) => this.onEvent(event));
ctx.log("EchoAgent initialized.");
}
async onEvent(event: DemoEvent): Promise<void> {
if (!this.ctx) return;
this.ctx.log(`EchoAgent received event ${event.id}`, event.payload);
const journalEntry = this.ctx.journal.journal(this.id, "received", event.payload);
this.ctx.log("EchoAgent journaled", journalEntry);
await this.ctx.bus.publish({
id: crypto.randomUUID(),
type: "demo.echo.response",
source: this.id,
timestamp: new Date().toISOString(),
payload: { originalId: event.id, message: event.payload }
});
}
}

130
src/cli/runDemo.ts Normal file
View File

@@ -0,0 +1,130 @@
import crypto from "crypto";
import readline from "readline";
import { startDemo } from "../demo-operator/demoRunner";
import { DemoFinanceAgent } from "../agents/demoFinanceAgent";
import { CounterAgent } from "../agents/counterAgent";
import { DemoAgent } from "../demo-operator/demoAgentContext";
function printHelp() {
console.log(`Available commands:
echo <message> - send demo.echo event
inc - increment counter
tx in <amount> - record incoming finance transaction
tx out <amount> - record outgoing finance transaction
fact <key> <true|false> - record a fact (contradiction demo)
summary - print finance summary and counter
journal - print journal entries
help - show this help
exit | quit - stop the demo`);
}
async function run() {
const { bus, journal, agents } = await startDemo();
const finance = agents.find((a): a is DemoFinanceAgent => (a as DemoAgent).id === "demo.finance") as
| DemoFinanceAgent
| undefined;
const counter = agents.find((a): a is CounterAgent => (a as DemoAgent).id === "demo.counter") as
| CounterAgent
| undefined;
console.log("BlackRoad OS Demo ready. Type 'help' for commands.");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "blackroad-demo> "
});
rl.prompt();
rl.on("line", async (line) => {
const [cmd, ...rest] = line.trim().split(/\s+/);
try {
if (cmd === "echo") {
const msg = rest.join(" ");
await bus.publish({
id: crypto.randomUUID(),
type: "demo.echo",
source: "cli",
timestamp: new Date().toISOString(),
payload: { message: msg }
});
} else if (cmd === "inc") {
await bus.publish({
id: crypto.randomUUID(),
type: "counter.increment",
source: "cli",
timestamp: new Date().toISOString(),
payload: { reason: "manual" }
});
if (counter) {
console.log(`Counter is now ${counter.getCount()}`);
}
} else if (cmd === "tx") {
const direction = rest[0];
const amount = Number(rest[1]);
if ((direction === "in" || direction === "out") && !Number.isNaN(amount)) {
await bus.publish({
id: crypto.randomUUID(),
type: "finance.transaction",
source: "cli",
timestamp: new Date().toISOString(),
payload: { type: direction, amount }
});
if (finance) {
console.log("Finance summary:", finance.getSummary());
}
} else {
console.log("Usage: tx in|out <amount>");
}
} else if (cmd === "summary") {
if (finance) {
console.log("Finance summary:", finance.getSummary());
}
if (counter) {
console.log("Counter:", counter.getCount());
}
} else if (cmd === "fact") {
const key = rest[0];
const value = rest[1];
if (key && (value === "true" || value === "false")) {
await bus.publish({
id: crypto.randomUUID(),
type: "demo.fact",
source: "cli",
timestamp: new Date().toISOString(),
payload: { key, value }
});
} else {
console.log("Usage: fact <key> <true|false>");
}
} else if (cmd === "journal") {
console.log(journal.getEntries());
} else if (cmd === "help" || cmd === "?") {
printHelp();
} else if (cmd === "exit" || cmd === "quit") {
rl.close();
return;
} else if (cmd === "") {
// ignore empty line
} else {
console.log("Unknown command. Type 'help' for options.");
}
} catch (err) {
console.error("Error handling command:", err);
}
rl.prompt();
});
rl.on("close", () => {
console.log("Exiting demo.");
process.exit(0);
});
}
run().catch((err) => {
console.error("Failed to start demo:", err);
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
import type { InMemoryDemoEventBus, DemoEvent } from "./demoEventBus";
import type { DemoJournal } from "./demoJournal";
export interface DemoAgentContext {
bus: InMemoryDemoEventBus;
journal: DemoJournal;
log: (msg: string, meta?: unknown) => void;
}
export interface DemoAgent {
id: string;
init(ctx: DemoAgentContext): Promise<void>;
onEvent(event: DemoEvent): Promise<void>;
}

View File

@@ -0,0 +1,31 @@
export type DemoEventHandler = (event: DemoEvent) => Promise<void> | void;
export interface DemoEvent {
id: string;
type: string;
source: string;
timestamp: string;
payload: unknown;
}
export interface DemoEventBus {
publish(event: DemoEvent): Promise<void>;
subscribe(type: string, handler: DemoEventHandler): void;
}
export class InMemoryDemoEventBus implements DemoEventBus {
private handlers = new Map<string, DemoEventHandler[]>();
subscribe(type: string, handler: DemoEventHandler): void {
const arr = this.handlers.get(type) || [];
arr.push(handler);
this.handlers.set(type, arr);
}
async publish(event: DemoEvent): Promise<void> {
const handlers = this.handlers.get(event.type) || [];
for (const h of handlers) {
await h(event);
}
}
}

View File

@@ -0,0 +1,38 @@
import crypto from "crypto";
export interface DemoJournalEntry {
id: string;
timestamp: string;
actorId: string;
actionType: string;
payload: unknown;
previousHash?: string;
hash: string;
}
export class DemoJournal {
private lastHash: string | undefined;
private entries: DemoJournalEntry[] = [];
journal(actorId: string, actionType: string, payload: unknown): DemoJournalEntry {
const id = crypto.randomUUID();
const timestamp = new Date().toISOString();
const previousHash = this.lastHash;
const hash = this.computeHash({ id, timestamp, actorId, actionType, payload, previousHash });
const entry: DemoJournalEntry = { id, timestamp, actorId, actionType, payload, previousHash, hash };
this.entries.push(entry);
this.lastHash = hash;
return entry;
}
private computeHash(obj: Omit<DemoJournalEntry, "hash">): string {
const h = crypto.createHash("sha256");
h.update(JSON.stringify(obj));
return h.digest("hex");
}
getEntries(): DemoJournalEntry[] {
return [...this.entries];
}
}

View File

@@ -0,0 +1,9 @@
import { DemoAgent } from "./demoAgentContext";
import { EchoAgent } from "../agents/echoAgent";
import { CounterAgent } from "../agents/counterAgent";
import { DemoFinanceAgent } from "../agents/demoFinanceAgent";
import { ContradictionAgent } from "../agents/contradictionAgent";
export function createDemoAgents(): DemoAgent[] {
return [new EchoAgent(), new CounterAgent(), new DemoFinanceAgent(), new ContradictionAgent()];
}

View File

@@ -0,0 +1,28 @@
import { InMemoryDemoEventBus } from "./demoEventBus";
import { DemoJournal } from "./demoJournal";
import { createDemoAgents } from "./demoRegistry";
export async function startDemo() {
const bus = new InMemoryDemoEventBus();
const journal = new DemoJournal();
const agents = createDemoAgents();
const ctx = {
bus,
journal,
log: (msg: string, meta?: unknown) => {
if (meta) {
console.log(`[DEMO] ${msg}`, meta);
} else {
console.log(`[DEMO] ${msg}`);
}
}
};
for (const agent of agents) {
await agent.init(ctx);
}
return { bus, journal, agents };
}

View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
import { startDemo } from "./demoRunner";
async function main() {
const { bus, journal } = await startDemo();
console.log("Demo operator started.");
await bus.publish({
id: "e1",
type: "demo.echo",
source: "demo-cli",
timestamp: new Date().toISOString(),
payload: { message: "Hello, BlackRoad!" }
});
await bus.publish({
id: crypto.randomUUID(),
type: "counter.increment",
source: "demo-cli",
timestamp: new Date().toISOString(),
payload: { reason: "startup" }
});
console.log("Initial journal:", journal.getEntries());
}
main().catch((err) => {
console.error("Error running demo operator:", err);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
import { DemoFinanceAgent } from "../src/agents/demoFinanceAgent";
import { InMemoryDemoEventBus } from "../src/demo-operator/demoEventBus";
import { DemoJournal } from "../src/demo-operator/demoJournal";
import { DemoAgentContext } from "../src/demo-operator/demoAgentContext";
describe("DemoFinanceAgent", () => {
it("tracks cash balance and totals", async () => {
const bus = new InMemoryDemoEventBus();
const journal = new DemoJournal();
const finance = new DemoFinanceAgent();
const ctx: DemoAgentContext = {
bus,
journal,
log: () => {}
};
await finance.init(ctx);
await bus.publish({
id: "t1",
type: "finance.transaction",
source: "test",
timestamp: new Date().toISOString(),
payload: { type: "in", amount: 200 }
});
await bus.publish({
id: "t2",
type: "finance.transaction",
source: "test",
timestamp: new Date().toISOString(),
payload: { type: "out", amount: 50 }
});
const summary = finance.getSummary();
expect(summary.cashBalance).toBe(150);
expect(summary.totalIn).toBe(200);
expect(summary.totalOut).toBe(50);
});
});

View File

@@ -0,0 +1,19 @@
import { startDemo } from "../src/demo-operator/demoRunner";
describe("demo operator", () => {
it("journals events from agents", async () => {
const { bus, journal } = await startDemo();
await bus.publish({
id: "test-echo",
type: "demo.echo",
source: "test",
timestamp: new Date().toISOString(),
payload: { message: "hello" }
});
const entries = journal.getEntries();
expect(entries.length).toBeGreaterThan(0);
expect(entries[0].actionType).toBeDefined();
});
});

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"resolveJsonModule": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}