Skip to content

Commit eef275b

Browse files
waydelyleclaude
andcommitted
Sync all parallel development: NATS, search, x402, outbox, worker
Major additions from parallel agent: - NATS JetStream event bus (lib/nats.ts, services/outbox.ts) - Meilisearch integration (services/search.ts) - Real x402 USDC transfers via viem (services/x402.ts, escrow.ts) - Background worker (worker.ts) - Updated health endpoint with NATS/search status - render.yaml updates - CLI and SDK improvements - Spec renamed to spec.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 44a433b commit eef275b

25 files changed

Lines changed: 1399 additions & 30 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ ED25519_CHALLENGE_TTL=300
2222
PLATFORM_WALLET_ADDRESS=
2323
PLATFORM_WALLET_PRIVATE_KEY=
2424
X402_NETWORK=base-sepolia
25+
X402_FACILITATOR_URL=https://x402.org/facilitator
26+
EVM_RPC_URL=
27+
USDC_CONTRACT_ADDRESS=
2528

2629
# ──────────────────────────────────────────────
2730
# Coinbase AgentKit (optional for MVP)
@@ -39,6 +42,11 @@ PLATFORM_URL=http://localhost:3100
3942
# Server / Environment
4043
# ──────────────────────────────────────────────
4144
NODE_ENV=development
45+
ENABLE_EVENT_OUTBOX=1
46+
NATS_URL=nats://localhost:4222
47+
OUTBOX_POLL_INTERVAL_MS=2000
48+
MEILISEARCH_URL=http://localhost:7700
49+
MEILISEARCH_API_KEY=localdev
4250

4351
# Comma-separated list of allowed CORS origins
4452
CORS_ORIGINS=http://localhost:3200

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,22 @@ swarmdock submit <task-id> --file ./submission.json
3535
swarmdock dispute <task-id> --reason "Artifacts do not match the requested deliverable"
3636
```
3737

38+
For x402-backed task funding and approval flows, configure both agent auth and payment signing:
39+
40+
```bash
41+
export SWARMDOCK_AGENT_PRIVATE_KEY=...
42+
export SWARMDOCK_WALLET_PRIVATE_KEY=0x...
43+
export SWARMDOCK_WALLET_ADDRESS=0x...
44+
```
45+
3846
## Local Development
3947

4048
```bash
49+
docker compose up -d
4150
pnpm install
4251
pnpm type-check
4352
pnpm build
4453
pnpm dev
4554
```
55+
56+
The local stack now includes Postgres, Redis, NATS JetStream, and Meilisearch. Copy `.env.example` to `.env` and set the x402/Base Sepolia values before testing real payment flows.

docker-compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,28 @@ services:
1717
volumes:
1818
- redisdata:/data
1919

20+
nats:
21+
image: nats:2-alpine
22+
ports:
23+
- "4222:4222"
24+
- "8222:8222"
25+
command:
26+
- "--jetstream"
27+
- "--store_dir=/data"
28+
volumes:
29+
- natsdata:/data
30+
31+
meilisearch:
32+
image: getmeili/meilisearch:v1.12
33+
ports:
34+
- "7700:7700"
35+
environment:
36+
MEILI_MASTER_KEY: localdev
37+
volumes:
38+
- meilidata:/meili_data
39+
2040
volumes:
2141
pgdata:
2242
redisdata:
43+
natsdata:
44+
meilidata:

packages/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "tsx watch src/index.ts",
88
"build": "tsc",
99
"start": "node dist/index.js",
10-
"test": "node --import ./node_modules/tsx/dist/loader.mjs --test tests/**/*.test.ts",
10+
"worker": "node dist/worker.js",
11+
"test": "ENABLE_EVENT_OUTBOX=0 node --import ./node_modules/tsx/dist/loader.mjs --test tests/**/*.test.ts",
1112
"type-check": "tsc --noEmit",
1213
"db:generate": "drizzle-kit generate",
1314
"db:push": "drizzle-kit push",

packages/api/src/db/schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@ export const disputes = pgTable('disputes', {
147147
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
148148
});
149149

150+
export const eventOutbox = pgTable('event_outbox', {
151+
id: uuid('id').primaryKey().defaultRandom(),
152+
subject: text('subject').notNull(),
153+
target: text('target').notNull(),
154+
agentId: uuid('agent_id').references(() => agents.id),
155+
eventType: text('event_type').notNull(),
156+
payload: jsonb('payload').notNull(),
157+
status: text('status').default('pending').notNull(),
158+
attempts: integer('attempts').default(0).notNull(),
159+
lastError: text('last_error'),
160+
publishedAt: timestamp('published_at', { withTimezone: true }),
161+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
162+
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
163+
});
164+
150165
export const challenges = pgTable('challenges', {
151166
id: uuid('id').primaryKey().defaultRandom(),
152167
publicKey: text('public_key').notNull(),

packages/api/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import adminRoutes from './routes/admin.js';
1313
import artifactRoutes from './routes/artifacts.js';
1414
import a2aRoutes from './routes/a2a.js';
1515
import { getAgentCardById } from './services/agent-card.js';
16+
import { eventBus } from './lib/events.js';
1617

1718
// Fix BigInt JSON serialization (Drizzle returns bigint columns as JS BigInt)
1819
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
@@ -84,4 +85,7 @@ if (process.env.NODE_ENV === 'production' && !process.env.JWT_SECRET) {
8485
}
8586

8687
console.log(`SwarmDock API starting on port ${port}`);
88+
void eventBus.startTransportBridge().catch((error) => {
89+
console.error('[EVENTS] failed to start NATS transport bridge:', error);
90+
});
8791
serve({ fetch: app.fetch, port });

packages/api/src/lib/events.ts

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
type EventCallback = (event: { type: string; data: unknown }) => void;
1+
import { enqueueOutboxEvent, OUTBOX_TARGET, type EventEnvelope } from '../services/outbox.js';
2+
import { subscribeNatsEvents } from './nats.js';
3+
4+
type EventCallback = (event: EventEnvelope) => void | Promise<void>;
5+
6+
const INSTANCE_ID = crypto.randomUUID();
7+
const RECENT_EVENT_TTL_MS = 5 * 60 * 1000;
8+
9+
function buildSubject(target: 'agent' | 'broadcast', agentId?: string) {
10+
return target === 'agent' && agentId ? `events.agent.${agentId}` : 'events.broadcast';
11+
}
212

313
class EventBus {
414
private listeners = new Map<string, Set<EventCallback>>();
15+
private recentEventIds = new Map<string, number>();
16+
private bridgeStarted = false;
17+
private unsubscribeBridge: (() => void) | null = null;
518

619
subscribe(agentId: string, callback: EventCallback): () => void {
720
if (!this.listeners.has(agentId)) {
@@ -17,21 +30,116 @@ class EventBus {
1730
};
1831
}
1932

20-
emit(agentId: string, event: { type: string; data: unknown }): void {
33+
async startTransportBridge(): Promise<void> {
34+
if (this.bridgeStarted) {
35+
return;
36+
}
37+
38+
this.bridgeStarted = true;
39+
this.unsubscribeBridge = await subscribeNatsEvents(async (_subject, event) => {
40+
if (event.originInstanceId === INSTANCE_ID) {
41+
return;
42+
}
43+
44+
if (event.outboxId && this.isDuplicate(event.outboxId)) {
45+
return;
46+
}
47+
48+
this.dispatch(event);
49+
});
50+
}
51+
52+
stopTransportBridge(): void {
53+
this.unsubscribeBridge?.();
54+
this.unsubscribeBridge = null;
55+
this.bridgeStarted = false;
56+
}
57+
58+
emit(agentId: string, event: { type: string; data: Record<string, unknown> }): void {
59+
const envelope: EventEnvelope = {
60+
type: event.type,
61+
data: event.data,
62+
timestamp: new Date().toISOString(),
63+
originInstanceId: INSTANCE_ID,
64+
target: OUTBOX_TARGET.AGENT,
65+
agentId,
66+
};
67+
68+
this.dispatchToAgent(agentId, envelope);
69+
void enqueueOutboxEvent({
70+
subject: buildSubject('agent', agentId),
71+
target: OUTBOX_TARGET.AGENT,
72+
agentId,
73+
type: event.type,
74+
envelope,
75+
}).catch((error) => {
76+
console.error('[EVENTS] failed to enqueue agent event:', error);
77+
});
78+
}
79+
80+
broadcast(event: { type: string; data: Record<string, unknown> }): void {
81+
const envelope: EventEnvelope = {
82+
type: event.type,
83+
data: event.data,
84+
timestamp: new Date().toISOString(),
85+
originInstanceId: INSTANCE_ID,
86+
target: OUTBOX_TARGET.BROADCAST,
87+
agentId: null,
88+
};
89+
90+
this.dispatchBroadcast(envelope);
91+
void enqueueOutboxEvent({
92+
subject: buildSubject('broadcast'),
93+
target: OUTBOX_TARGET.BROADCAST,
94+
type: event.type,
95+
envelope,
96+
}).catch((error) => {
97+
console.error('[EVENTS] failed to enqueue broadcast event:', error);
98+
});
99+
}
100+
101+
private dispatch(event: EventEnvelope): void {
102+
if (event.target === OUTBOX_TARGET.AGENT && event.agentId) {
103+
this.dispatchToAgent(event.agentId, event);
104+
return;
105+
}
106+
107+
this.dispatchBroadcast(event);
108+
}
109+
110+
private dispatchToAgent(agentId: string, event: EventEnvelope): void {
21111
const callbacks = this.listeners.get(agentId);
22-
if (callbacks) {
23-
for (const cb of callbacks) {
24-
cb(event);
112+
if (!callbacks) {
113+
return;
114+
}
115+
116+
for (const callback of callbacks) {
117+
void callback(event);
118+
}
119+
}
120+
121+
private dispatchBroadcast(event: EventEnvelope): void {
122+
for (const callbacks of this.listeners.values()) {
123+
for (const callback of callbacks) {
124+
void callback(event);
25125
}
26126
}
27127
}
28128

29-
broadcast(event: { type: string; data: unknown }): void {
30-
for (const [, callbacks] of this.listeners) {
31-
for (const cb of callbacks) {
32-
cb(event);
129+
private isDuplicate(outboxId: string): boolean {
130+
const now = Date.now();
131+
for (const [seenId, seenAt] of this.recentEventIds.entries()) {
132+
if (now - seenAt > RECENT_EVENT_TTL_MS) {
133+
this.recentEventIds.delete(seenId);
33134
}
34135
}
136+
137+
if (this.recentEventIds.has(outboxId)) {
138+
return true;
139+
}
140+
141+
this.recentEventIds.set(outboxId, now);
142+
return false;
35143
}
36144
}
37145

packages/api/src/lib/nats.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { JSONCodec, connect, type NatsConnection, type Subscription } from 'nats';
2+
import type { EventEnvelope } from '../services/outbox.js';
3+
4+
const codec = JSONCodec<EventEnvelope>();
5+
let connectionPromise: Promise<NatsConnection | null> | null = null;
6+
7+
export function isNatsConfigured(): boolean {
8+
return Boolean(process.env.NATS_URL?.trim());
9+
}
10+
11+
export async function getNatsConnection(): Promise<NatsConnection | null> {
12+
if (!isNatsConfigured()) {
13+
return null;
14+
}
15+
16+
if (!connectionPromise) {
17+
connectionPromise = connect({
18+
servers: process.env.NATS_URL!,
19+
name: process.env.NATS_CLIENT_NAME ?? 'swarmdock-api',
20+
}).catch((error) => {
21+
console.error('[NATS] connection failed:', error);
22+
connectionPromise = null;
23+
return null;
24+
});
25+
}
26+
27+
return connectionPromise;
28+
}
29+
30+
export async function publishNatsEvent(subject: string, event: EventEnvelope): Promise<boolean> {
31+
const nc = await getNatsConnection();
32+
if (!nc) {
33+
return false;
34+
}
35+
36+
nc.publish(subject, codec.encode(event));
37+
return true;
38+
}
39+
40+
export async function subscribeNatsEvents(
41+
onEvent: (subject: string, event: EventEnvelope) => void | Promise<void>,
42+
): Promise<() => void> {
43+
const nc = await getNatsConnection();
44+
if (!nc) {
45+
return () => {};
46+
}
47+
48+
const subscriptions: Subscription[] = [
49+
nc.subscribe('events.agent.*'),
50+
nc.subscribe('events.broadcast'),
51+
];
52+
53+
let closed = false;
54+
for (const subscription of subscriptions) {
55+
void (async () => {
56+
for await (const message of subscription) {
57+
if (closed) {
58+
break;
59+
}
60+
61+
try {
62+
await onEvent(message.subject, codec.decode(message.data));
63+
} catch (error) {
64+
console.error('[NATS] event handler failed:', error);
65+
}
66+
}
67+
})();
68+
}
69+
70+
return () => {
71+
closed = true;
72+
for (const subscription of subscriptions) {
73+
subscription.unsubscribe();
74+
}
75+
};
76+
}

0 commit comments

Comments
 (0)