Setup
git clone https://github.com/protoLabsAI/protoWorkstacean.git
cd protoWorkstacean
bun installRequires Bun >= 1.1. There is no Node.js build step — Bun runs TypeScript directly.
Running the server locally
cp .env.dist .env # then fill in at minimum ANTHROPIC_API_KEY and WORKSTACEAN_API_KEY
bun run src/index.tsUse --watch during development for automatic restarts on file change:
bun run --watch src/index.tsRunning tests
bun testTests are co-located with source. Test files follow the pattern <name>.test.ts or live in a __tests__/ directory alongside the module they test.
# Run a single test file
bun test src/executor/__tests__/executor-registry.test.ts
# Run tests matching a pattern
bun test --test-name-pattern "resolution order"There is no separate test runner configuration file — bun test discovers all *.test.ts files automatically.
Test structure
Tests use bun:test — the same API as Jest/Vitest:
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";Unit tests — co-located with source, mock all bus dependencies:
// src/executor/__tests__/executor-registry.test.ts
import { describe, test, expect, mock } from "bun:test";
import { ExecutorRegistry } from "../executor-registry.ts";
import type { IExecutor, SkillRequest, SkillResult } from "../types.ts";
function makeExecutor(type: string): IExecutor {
return {
type,
execute: mock(async (req: SkillRequest): Promise<SkillResult> => ({
text: `result from ${type}`,
isError: false,
correlationId: req.correlationId,
})),
};
}
describe("ExecutorRegistry", () => {
test("resolves registered skill", () => {
const registry = new ExecutorRegistry();
const exec = makeExecutor("deep-agent");
registry.register("daily_standup", exec);
expect(registry.resolve("daily_standup")).toBe(exec);
});
});Integration tests — in test/integration/, use a real InMemoryEventBus:
// test/integration/router-dispatch-flow.test.ts
import { describe, test, expect } from "bun:test";
import { InMemoryEventBus } from "../../lib/bus.ts";
import { RouterPlugin } from "../../src/router/router-plugin.ts";
import { SkillDispatcherPlugin } from "../../src/executor/skill-dispatcher-plugin.ts";
describe("router → dispatcher integration", () => {
test("inbound message routes to skill request", async () => {
const bus = new InMemoryEventBus();
// install plugins, publish a message.inbound.* event, assert the resolved
// agent.skill.request arrives on the bus
});
});Writing a plugin
Create src/plugins/my-plugin.ts and implement the Plugin interface:
import type { Plugin, EventBus, BusMessage } from "../../lib/types.ts";
export class MyPlugin implements Plugin {
readonly name = "my-plugin";
readonly description = "Short description";
readonly capabilities = ["my-capability"];
private bus?: EventBus;
private readonly subscriptionIds: string[] = [];
install(bus: EventBus): void {
this.bus = bus;
const id = bus.subscribe("some.topic.#", this.name, (msg: BusMessage) => {
void this._handle(msg);
});
this.subscriptionIds.push(id);
}
uninstall(): void {
if (this.bus) {
for (const id of this.subscriptionIds) {
this.bus.unsubscribe(id);
}
}
this.subscriptionIds.length = 0;
this.bus = undefined;
}
private async _handle(msg: BusMessage): Promise<void> {
// ... handle the message
}
}Wire it into src/index.ts in the pluginRegistry array:
{
name: "my-plugin",
condition: () => true, // or () => !!process.env.MY_ENV_VAR
factory: async () => {
const { MyPlugin } = await import("./plugins/my-plugin.js");
return new MyPlugin();
},
},Writing an executor
Implement IExecutor in src/executor/executors/:
import type { IExecutor, SkillRequest, SkillResult } from "../types.ts";
export class MyExecutor implements IExecutor {
readonly type = "my-executor";
async execute(req: SkillRequest): Promise<SkillResult> {
try {
const result = await doSomethingWith(req.content ?? req.skill);
return { text: result, isError: false, correlationId: req.correlationId };
} catch (err) {
return {
text: "",
isError: true,
correlationId: req.correlationId,
data: { error: String(err) },
};
}
}
}Register it in a plugin's install() — do not subscribe to agent.skill.request directly:
install(_bus: EventBus): void {
this.registry.register("my_skill", new MyExecutor(), { priority: 5 });
}File structure
src/
index.ts # Bootstrap and plugin wiring
executor/
types.ts # IExecutor, SkillRequest, SkillResult, ExecutorRegistration
executor-registry.ts # ExecutorRegistry
skill-dispatcher-plugin.ts # Sole agent.skill.request subscriber
executors/
a2a-executor.ts
deep-agent-executor.ts # LangGraph — default in-process runtime
function-executor.ts
__tests__/
plugins/
CeremonyPlugin.ts
agent-fleet-health-plugin.ts
alert-skill-executor-plugin.ts
ceremony-skill-executor-plugin.ts
skill-broker-plugin.ts
agent-runtime/
agent-runtime-plugin.ts # Registrar for in-process agents
agent-definition-loader.ts
types.ts
router/
router-plugin.ts
skill-resolver.ts
project-enricher.ts
event-bus/
topics.ts
payloads.ts
api/
bus-topology.ts # GET /api/bus/topology
...
lib/
types.ts # BusMessage, Plugin (with publishes/subscribes), EventBus
bus.ts # InMemoryEventBus
plugins/
discord.ts
github.ts
linear.ts
google.ts
scheduler.ts
a2a-delivery.ts
operator-routing.ts
...
workspace/
agents.yaml # A2A remote agents
agents/ # In-process agent YAMLs
ceremonies/ # Scheduled rituals
crons/ # Plain cron entries (created by SchedulerPlugin)
channels.yaml # Channel→agent + per-project channel bindings
test/
integration/
tests/
(schema and submission tests)Type checking
bunx tsc --noEmitThe project uses TypeScript strict mode. All exported types should have JSDoc comments on non-obvious fields.
SQLite store conventions
Every bun:sqlite store sets the same connection pragmas at init: journal_mode=WAL, synchronous=NORMAL, and busy_timeout=5000 (so a contended writer blocks-and-retries instead of throwing SQLITE_BUSY — several subsystems share knowledge.db / events.db). close() runs PRAGMA wal_checkpoint(TRUNCATE) before closing (graceful shutdown, #792).
Changing a SQLite store schema
The bun:sqlite stores back a persistent prod volume (/data), and deploy is a watchtower image pull with no migration step. So a bare CREATE TABLE IF NOT EXISTS change silently no-ops against an existing DB and drifts at query time. Use the versioned migration runner instead (lib/sqlite-migrate.ts):
import { runMigrations } from "../../lib/sqlite-migrate.ts";
// in the store's init, after the PRAGMAs:
runMigrations(this.db, [
{ version: 1, up: (d) => d.exec(`CREATE TABLE IF NOT EXISTS … `) }, // baseline
{ version: 2, up: (d) => d.exec(`ALTER TABLE … ADD COLUMN … `) }, // future change
]);runMigrations applies only migrations newer than the DB's PRAGMA user_version, each in a transaction, bumping the version after each succeeds. Never edit a shipped migration's up — append a new version. src/executor/task-tracker-store.ts is the reference adoption; other stores adopt the same pattern on their next schema change.
Logging
New/edited code uses the structured logger (lib/log.ts) over console.*:
import { logger } from "../log.ts";
const log = logger("my-component");
log.info("dispatched", { correlationId, skill });
log.error("publish failed", { topic, err }); // Error → {message, stack}Leveled (LOG_LEVEL=debug|info|warn|error), JSON in prod (LOG_FORMAT=json or NODE_ENV=production) so lines are filterable + queryable by field, readable in dev. log.child({ correlationId }) binds fields onto every line. The bulk console.* migration is incremental — prefer the logger when you touch a file.
Conventions
- Named exports only in
.tsfiles. Exception:_meta.tsfiles use a default export for Nextra. - No inter-plugin references — plugins communicate through the bus.
- Async handlers —
bus.subscribecallbacks should bevoidfunctions that internally handle errors with try/catch or.catch(). Do not let unhandled promise rejections propagate. - correlationId is sacred — never generate a new
correlationIdmid-flow. Always propagate the one from the triggering message. - Imports use
.tsextensions (Bun resolves them correctly; avoid.jsaliases in source files).