style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,11 +1,19 @@
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
import {
describe,
test,
expect,
beforeEach,
afterAll,
mock,
spyOn,
} from 'bun:test'
// Mock config before importing modules that depend on it
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-key-1", "test-key-2"],
baseUrl: "",
host: '0.0.0.0',
apiKeys: ['test-key-1', 'test-key-2'],
baseUrl: '',
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
@@ -13,153 +21,157 @@ const mockConfig = {
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
}
mock.module("../config", () => ({
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
getBaseUrl: () => 'http://localhost:3000',
}))
import { validateApiKey, hashApiKey } from "../auth/api-key";
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
import { issueToken, resolveToken } from "../auth/token";
import { storeReset, storeCreateUser } from "../store";
import { validateApiKey, hashApiKey } from '../auth/api-key'
import { generateWorkerJwt, verifyWorkerJwt } from '../auth/jwt'
import { issueToken, resolveToken } from '../auth/token'
import { storeReset, storeCreateUser } from '../store'
// ---------- api-key ----------
describe("validateApiKey", () => {
test("validates a configured API key", () => {
expect(validateApiKey("test-key-1")).toBe(true);
expect(validateApiKey("test-key-2")).toBe(true);
});
describe('validateApiKey', () => {
test('validates a configured API key', () => {
expect(validateApiKey('test-key-1')).toBe(true)
expect(validateApiKey('test-key-2')).toBe(true)
})
test("rejects unknown key", () => {
expect(validateApiKey("unknown-key")).toBe(false);
});
test('rejects unknown key', () => {
expect(validateApiKey('unknown-key')).toBe(false)
})
test("rejects undefined", () => {
expect(validateApiKey(undefined)).toBe(false);
});
test('rejects undefined', () => {
expect(validateApiKey(undefined)).toBe(false)
})
test("rejects empty string", () => {
expect(validateApiKey("")).toBe(false);
});
});
test('rejects empty string', () => {
expect(validateApiKey('')).toBe(false)
})
})
describe("hashApiKey", () => {
test("produces consistent SHA-256 hex", () => {
const hash = hashApiKey("my-key");
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashApiKey("my-key")).toBe(hash);
});
describe('hashApiKey', () => {
test('produces consistent SHA-256 hex', () => {
const hash = hashApiKey('my-key')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(hashApiKey('my-key')).toBe(hash)
})
test("different keys produce different hashes", () => {
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
});
});
test('different keys produce different hashes', () => {
expect(hashApiKey('key-a')).not.toBe(hashApiKey('key-b'))
})
})
// ---------- jwt ----------
describe("JWT", () => {
describe('JWT', () => {
// JWT reads process.env.RCS_API_KEYS directly (not via config)
const originalKeys = process.env.RCS_API_KEYS;
const originalKeys = process.env.RCS_API_KEYS
beforeEach(() => {
process.env.RCS_API_KEYS = "jwt-test-secret";
});
process.env.RCS_API_KEYS = 'jwt-test-secret'
})
afterAll(() => {
process.env.RCS_API_KEYS = originalKeys;
});
process.env.RCS_API_KEYS = originalKeys
})
describe("generateWorkerJwt", () => {
test("produces a three-part base64url token", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
expect(parts).toHaveLength(3);
describe('generateWorkerJwt', () => {
test('produces a three-part base64url token', () => {
const token = generateWorkerJwt('ses_123', 3600)
const parts = token.split('.')
expect(parts).toHaveLength(3)
for (const part of parts) {
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
expect(part).toMatch(/^[A-Za-z0-9_-]+$/)
}
});
})
test("contains correct header", () => {
const token = generateWorkerJwt("ses_123", 3600);
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
expect(header.alg).toBe("HS256");
expect(header.typ).toBe("JWT");
});
test('contains correct header', () => {
const token = generateWorkerJwt('ses_123', 3600)
const header = JSON.parse(
atob(token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')),
)
expect(header.alg).toBe('HS256')
expect(header.typ).toBe('JWT')
})
test("throws when no API key configured", () => {
delete process.env.RCS_API_KEYS;
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});
test('throws when no API key configured', () => {
delete process.env.RCS_API_KEYS
expect(() => generateWorkerJwt('ses_123', 3600)).toThrow(
'No API key configured',
)
process.env.RCS_API_KEYS = 'jwt-test-secret'
})
})
describe("verifyWorkerJwt", () => {
test("verifies a valid token", () => {
const token = generateWorkerJwt("ses_abc", 3600);
const payload = verifyWorkerJwt(token);
expect(payload).not.toBeNull();
expect(payload!.session_id).toBe("ses_abc");
expect(payload!.role).toBe("worker");
expect(payload!.iat).toBeGreaterThan(0);
expect(payload!.exp).toBeGreaterThan(payload!.iat);
});
describe('verifyWorkerJwt', () => {
test('verifies a valid token', () => {
const token = generateWorkerJwt('ses_abc', 3600)
const payload = verifyWorkerJwt(token)
expect(payload).not.toBeNull()
expect(payload!.session_id).toBe('ses_abc')
expect(payload!.role).toBe('worker')
expect(payload!.iat).toBeGreaterThan(0)
expect(payload!.exp).toBeGreaterThan(payload!.iat)
})
test("returns null for expired token", () => {
const token = generateWorkerJwt("ses_old", -10);
expect(verifyWorkerJwt(token)).toBeNull();
});
test('returns null for expired token', () => {
const token = generateWorkerJwt('ses_old', -10)
expect(verifyWorkerJwt(token)).toBeNull()
})
test("returns null for malformed token (not 3 parts)", () => {
expect(verifyWorkerJwt("a.b")).toBeNull();
expect(verifyWorkerJwt("just-a-string")).toBeNull();
});
test('returns null for malformed token (not 3 parts)', () => {
expect(verifyWorkerJwt('a.b')).toBeNull()
expect(verifyWorkerJwt('just-a-string')).toBeNull()
})
test("returns null for tampered signature", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
expect(verifyWorkerJwt(tampered)).toBeNull();
});
test('returns null for tampered signature', () => {
const token = generateWorkerJwt('ses_123', 3600)
const parts = token.split('.')
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`
expect(verifyWorkerJwt(tampered)).toBeNull()
})
test("returns null for wrong signing key", () => {
const token = generateWorkerJwt("ses_123", 3600);
process.env.RCS_API_KEYS = "wrong-key";
expect(verifyWorkerJwt(token)).toBeNull();
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});
});
test('returns null for wrong signing key', () => {
const token = generateWorkerJwt('ses_123', 3600)
process.env.RCS_API_KEYS = 'wrong-key'
expect(verifyWorkerJwt(token)).toBeNull()
process.env.RCS_API_KEYS = 'jwt-test-secret'
})
})
})
// ---------- token ----------
describe("issueToken / resolveToken", () => {
describe('issueToken / resolveToken', () => {
beforeEach(() => {
storeReset();
});
storeReset()
})
test("issues and resolves a token", () => {
storeCreateUser("alice");
const { token, expires_in } = issueToken("alice");
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
expect(expires_in).toBe(86400);
expect(resolveToken(token)).toBe("alice");
});
test('issues and resolves a token', () => {
storeCreateUser('alice')
const { token, expires_in } = issueToken('alice')
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/)
expect(expires_in).toBe(86400)
expect(resolveToken(token)).toBe('alice')
})
test("returns null for unknown token", () => {
expect(resolveToken("nonexistent")).toBeNull();
});
test('returns null for unknown token', () => {
expect(resolveToken('nonexistent')).toBeNull()
})
test("returns null for undefined token", () => {
expect(resolveToken(undefined)).toBeNull();
});
test('returns null for undefined token', () => {
expect(resolveToken(undefined)).toBeNull()
})
test("tokens are unique", () => {
storeCreateUser("alice");
const t1 = issueToken("alice").token;
const t2 = issueToken("alice").token;
expect(t1).not.toBe(t2);
});
});
test('tokens are unique', () => {
storeCreateUser('alice')
const t1 = issueToken('alice').token
const t2 = issueToken('alice').token
expect(t1).not.toBe(t2)
})
})

View File

@@ -1,182 +1,225 @@
import { describe, test, expect } from "bun:test";
import { describe, test, expect } from 'bun:test'
import {
getAutomationStateSnapshot,
getAutomationStateEventPayload,
automationStatesEqual,
} from "../services/automationState";
import type { AutomationStateResponse } from "../types/api";
} from '../services/automationState'
import type { AutomationStateResponse } from '../types/api'
// =============================================================================
// normalizeAutomationState (via getAutomationStateSnapshot)
// =============================================================================
describe("normalizeAutomationState", () => {
test("returns undefined when metadata has no automation_state key", () => {
expect(getAutomationStateSnapshot({})).toBeUndefined();
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined();
expect(getAutomationStateSnapshot(null)).toBeUndefined();
expect(getAutomationStateSnapshot(undefined)).toBeUndefined();
});
describe('normalizeAutomationState', () => {
test('returns undefined when metadata has no automation_state key', () => {
expect(getAutomationStateSnapshot({})).toBeUndefined()
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined()
expect(getAutomationStateSnapshot(null)).toBeUndefined()
expect(getAutomationStateSnapshot(undefined)).toBeUndefined()
})
test("returns disabled state for null automation_state", () => {
const result = getAutomationStateSnapshot({ automation_state: null });
test('returns disabled state for null automation_state', () => {
const result = getAutomationStateSnapshot({ automation_state: null })
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
})
})
test("returns disabled state for non-object automation_state", () => {
for (const val of ["string", 123, true, []]) {
const result = getAutomationStateSnapshot({ automation_state: val });
expect(result?.enabled).toBe(false);
test('returns disabled state for non-object automation_state', () => {
for (const val of ['string', 123, true, []]) {
const result = getAutomationStateSnapshot({ automation_state: val })
expect(result?.enabled).toBe(false)
}
});
})
test("normalizes enabled: true correctly", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true } });
expect(result?.enabled).toBe(true);
});
test("normalizes enabled to false for non-true values", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } });
expect(result?.enabled).toBe(false);
});
test("accepts phase: standby", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } });
expect(result?.phase).toBe("standby");
});
test("accepts phase: sleeping", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } });
expect(result?.phase).toBe("sleeping");
});
test("rejects invalid phase values", () => {
for (const phase of ["running", "idle", "active", "", null]) {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } });
expect(result?.phase).toBeNull();
}
});
test("normalizes next_tick_at as number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } });
expect(result?.next_tick_at).toBe(12345);
});
test("normalizes next_tick_at as null for non-number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } });
expect(result?.next_tick_at).toBeNull();
});
test("normalizes sleep_until as number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } });
expect(result?.sleep_until).toBe(99999);
});
test("normalizes sleep_until as null for non-number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } });
expect(result?.sleep_until).toBeNull();
});
test("fully normalizes a complete valid state", () => {
test('normalizes enabled: true correctly', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 },
});
automation_state: { enabled: true },
})
expect(result?.enabled).toBe(true)
})
test('normalizes enabled to false for non-true values', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: 'yes' },
})
expect(result?.enabled).toBe(false)
})
test('accepts phase: standby', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, phase: 'standby' },
})
expect(result?.phase).toBe('standby')
})
test('accepts phase: sleeping', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, phase: 'sleeping' },
})
expect(result?.phase).toBe('sleeping')
})
test('rejects invalid phase values', () => {
for (const phase of ['running', 'idle', 'active', '', null]) {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, phase },
})
expect(result?.phase).toBeNull()
}
})
test('normalizes next_tick_at as number', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, next_tick_at: 12345 },
})
expect(result?.next_tick_at).toBe(12345)
})
test('normalizes next_tick_at as null for non-number', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, next_tick_at: 'soon' },
})
expect(result?.next_tick_at).toBeNull()
})
test('normalizes sleep_until as number', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, sleep_until: 99999 },
})
expect(result?.sleep_until).toBe(99999)
})
test('normalizes sleep_until as null for non-number', () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, sleep_until: false },
})
expect(result?.sleep_until).toBeNull()
})
test('fully normalizes a complete valid state', () => {
const result = getAutomationStateSnapshot({
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: 100,
sleep_until: 200,
},
})
expect(result).toEqual({
enabled: true,
phase: "sleeping",
phase: 'sleeping',
next_tick_at: 100,
sleep_until: 200,
});
});
});
})
})
})
// =============================================================================
// getAutomationStateEventPayload
// =============================================================================
describe("getAutomationStateEventPayload", () => {
test("returns disabled default when no automation_state in metadata", () => {
const result = getAutomationStateEventPayload({});
describe('getAutomationStateEventPayload', () => {
test('returns disabled default when no automation_state in metadata', () => {
const result = getAutomationStateEventPayload({})
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
})
})
test("returns disabled default for null metadata", () => {
const result = getAutomationStateEventPayload(null);
test('returns disabled default for null metadata', () => {
const result = getAutomationStateEventPayload(null)
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
})
})
test("returns normalized state when automation_state present", () => {
test('returns normalized state when automation_state present', () => {
const result = getAutomationStateEventPayload({
automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 },
});
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 50,
sleep_until: 60,
},
})
expect(result).toEqual({
enabled: true,
phase: "standby",
phase: 'standby',
next_tick_at: 50,
sleep_until: 60,
});
});
})
})
test("returns a new object each call (not frozen reference)", () => {
const a = getAutomationStateEventPayload({});
const b = getAutomationStateEventPayload({});
expect(a).toEqual(b);
expect(a).not.toBe(b);
});
});
test('returns a new object each call (not frozen reference)', () => {
const a = getAutomationStateEventPayload({})
const b = getAutomationStateEventPayload({})
expect(a).toEqual(b)
expect(a).not.toBe(b)
})
})
// =============================================================================
// automationStatesEqual
// =============================================================================
describe("automationStatesEqual", () => {
describe('automationStatesEqual', () => {
const base: AutomationStateResponse = {
enabled: true,
phase: "standby",
phase: 'standby',
next_tick_at: 100,
sleep_until: 200,
};
}
test("returns true for identical states", () => {
expect(automationStatesEqual(base, { ...base })).toBe(true);
});
test('returns true for identical states', () => {
expect(automationStatesEqual(base, { ...base })).toBe(true)
})
test("returns false when enabled differs", () => {
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false);
});
test('returns false when enabled differs', () => {
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false)
})
test("returns false when phase differs", () => {
expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false);
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false);
});
test('returns false when phase differs', () => {
expect(automationStatesEqual(base, { ...base, phase: 'sleeping' })).toBe(
false,
)
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false)
})
test("returns false when next_tick_at differs", () => {
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false);
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false);
});
test('returns false when next_tick_at differs', () => {
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(
false,
)
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(
false,
)
})
test("returns false when sleep_until differs", () => {
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false);
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false);
});
test('returns false when sleep_until differs', () => {
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(
false,
)
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(
false,
)
})
test("returns true when both are disabled defaults", () => {
const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null };
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true);
});
});
test('returns true when both are disabled defaults', () => {
const disabled: AutomationStateResponse = {
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
}
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true)
})
})

View File

@@ -1,256 +1,276 @@
import { describe, test, expect } from "bun:test";
import { toClientPayload } from "../transport/client-payload";
import type { SessionEvent } from "../transport/event-bus";
import { describe, test, expect } from 'bun:test'
import { toClientPayload } from '../transport/client-payload'
import type { SessionEvent } from '../transport/event-bus'
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): SessionEvent {
function makeEvent(
overrides: Partial<SessionEvent> & Pick<SessionEvent, 'type' | 'sessionId'>,
): SessionEvent {
return {
id: "evt-1",
id: 'evt-1',
payload: null,
direction: "inbound",
direction: 'inbound',
seqNum: 1,
createdAt: Date.now(),
...overrides,
};
}
}
// =============================================================================
// user / user_message
// =============================================================================
describe("toClientPayload — user message", () => {
test("maps user type with content", () => {
describe('toClientPayload — user message', () => {
test('maps user type with content', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-1",
payload: { content: "hello" },
});
const result = toClientPayload(event);
expect(result.type).toBe("user");
expect(result.session_id).toBe("sess-1");
expect((result as any).message.role).toBe("user");
expect((result as any).message.content).toBe("hello");
});
type: 'user',
sessionId: 'sess-1',
payload: { content: 'hello' },
})
const result = toClientPayload(event)
expect(result.type).toBe('user')
expect(result.session_id).toBe('sess-1')
expect((result as any).message.role).toBe('user')
expect((result as any).message.content).toBe('hello')
})
test("maps user_message type same as user", () => {
test('maps user_message type same as user', () => {
const event = makeEvent({
type: "user_message",
sessionId: "sess-2",
payload: { content: "world" },
});
const result = toClientPayload(event);
expect(result.type).toBe("user");
expect(result.session_id).toBe("sess-2");
});
type: 'user_message',
sessionId: 'sess-2',
payload: { content: 'world' },
})
const result = toClientPayload(event)
expect(result.type).toBe('user')
expect(result.session_id).toBe('sess-2')
})
test("falls back to message field when content is missing", () => {
test('falls back to message field when content is missing', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-3",
payload: { message: "fallback msg" },
});
const result = toClientPayload(event);
expect((result as any).message.content).toBe("fallback msg");
});
type: 'user',
sessionId: 'sess-3',
payload: { message: 'fallback msg' },
})
const result = toClientPayload(event)
expect((result as any).message.content).toBe('fallback msg')
})
test("falls back to empty string when both content and message missing", () => {
test('falls back to empty string when both content and message missing', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-4",
type: 'user',
sessionId: 'sess-4',
payload: {},
});
const result = toClientPayload(event);
expect((result as any).message.content).toBe("");
});
})
const result = toClientPayload(event)
expect((result as any).message.content).toBe('')
})
test("includes isSynthetic when true", () => {
test('includes isSynthetic when true', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-5",
payload: { content: "auto", isSynthetic: true },
});
const result = toClientPayload(event);
expect((result as any).isSynthetic).toBe(true);
});
type: 'user',
sessionId: 'sess-5',
payload: { content: 'auto', isSynthetic: true },
})
const result = toClientPayload(event)
expect((result as any).isSynthetic).toBe(true)
})
test("does not include isSynthetic when false", () => {
test('does not include isSynthetic when false', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-6",
payload: { content: "manual", isSynthetic: false },
});
const result = toClientPayload(event);
expect((result as any).isSynthetic).toBeUndefined();
});
type: 'user',
sessionId: 'sess-6',
payload: { content: 'manual', isSynthetic: false },
})
const result = toClientPayload(event)
expect((result as any).isSynthetic).toBeUndefined()
})
test("uses payload.uuid when present", () => {
test('uses payload.uuid when present', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-7",
payload: { content: "hi", uuid: "custom-uuid" },
});
const result = toClientPayload(event);
expect(result.uuid).toBe("custom-uuid");
});
type: 'user',
sessionId: 'sess-7',
payload: { content: 'hi', uuid: 'custom-uuid' },
})
const result = toClientPayload(event)
expect(result.uuid).toBe('custom-uuid')
})
test("falls back to event.id when payload.uuid is missing", () => {
test('falls back to event.id when payload.uuid is missing', () => {
const event = makeEvent({
type: "user",
sessionId: "sess-8",
payload: { content: "hi" },
});
const result = toClientPayload(event);
expect(result.uuid).toBe("evt-1");
});
});
type: 'user',
sessionId: 'sess-8',
payload: { content: 'hi' },
})
const result = toClientPayload(event)
expect(result.uuid).toBe('evt-1')
})
})
// =============================================================================
// permission_response / control_response
// =============================================================================
describe("toClientPayload — permission response", () => {
test("approved=true maps to allow behavior", () => {
describe('toClientPayload — permission response', () => {
test('approved=true maps to allow behavior', () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-1",
payload: { approved: true, request_id: "req-1" },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
const resp = (result as any).response;
expect(resp.subtype).toBe("success");
expect(resp.request_id).toBe("req-1");
expect(resp.response.behavior).toBe("allow");
});
type: 'permission_response',
sessionId: 'sess-1',
payload: { approved: true, request_id: 'req-1' },
})
const result = toClientPayload(event)
expect(result.type).toBe('control_response')
const resp = (result as any).response
expect(resp.subtype).toBe('success')
expect(resp.request_id).toBe('req-1')
expect(resp.response.behavior).toBe('allow')
})
test("approved=false maps to deny behavior with error", () => {
test('approved=false maps to deny behavior with error', () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-2",
payload: { approved: false, request_id: "req-2" },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
const resp = (result as any).response;
expect(resp.subtype).toBe("error");
expect(resp.error).toBe("Permission denied by user");
expect(resp.response.behavior).toBe("deny");
});
type: 'permission_response',
sessionId: 'sess-2',
payload: { approved: false, request_id: 'req-2' },
})
const result = toClientPayload(event)
expect(result.type).toBe('control_response')
const resp = (result as any).response
expect(resp.subtype).toBe('error')
expect(resp.error).toBe('Permission denied by user')
expect(resp.response.behavior).toBe('deny')
})
test("approved=false includes feedback message when provided", () => {
test('approved=false includes feedback message when provided', () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-3",
payload: { approved: false, request_id: "req-3", message: "please revise" },
});
const result = toClientPayload(event);
expect((result as any).response.message).toBe("please revise");
});
type: 'permission_response',
sessionId: 'sess-3',
payload: {
approved: false,
request_id: 'req-3',
message: 'please revise',
},
})
const result = toClientPayload(event)
expect((result as any).response.message).toBe('please revise')
})
test("passes through existingResponse directly", () => {
const existingResponse = { subtype: "success", custom: true };
test('passes through existingResponse directly', () => {
const existingResponse = { subtype: 'success', custom: true }
const event = makeEvent({
type: "control_response",
sessionId: "sess-4",
type: 'control_response',
sessionId: 'sess-4',
payload: { approved: true, response: existingResponse },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
expect((result as any).response).toBe(existingResponse);
});
})
const result = toClientPayload(event)
expect(result.type).toBe('control_response')
expect((result as any).response).toBe(existingResponse)
})
test("includes updatedInput when approved with updated_input", () => {
const updatedInput = { file_path: "/new/path" };
test('includes updatedInput when approved with updated_input', () => {
const updatedInput = { file_path: '/new/path' }
const event = makeEvent({
type: "permission_response",
sessionId: "sess-5",
payload: { approved: true, request_id: "req-5", updated_input: updatedInput },
});
const result = toClientPayload(event);
expect((result as any).response.response.updatedInput).toEqual(updatedInput);
});
type: 'permission_response',
sessionId: 'sess-5',
payload: {
approved: true,
request_id: 'req-5',
updated_input: updatedInput,
},
})
const result = toClientPayload(event)
expect((result as any).response.response.updatedInput).toEqual(updatedInput)
})
test("includes updatedPermissions when approved with updated_permissions", () => {
const perms = [{ type: "allow", tool: "bash" }];
test('includes updatedPermissions when approved with updated_permissions', () => {
const perms = [{ type: 'allow', tool: 'bash' }]
const event = makeEvent({
type: "permission_response",
sessionId: "sess-6",
payload: { approved: true, request_id: "req-6", updated_permissions: perms },
});
const result = toClientPayload(event);
expect((result as any).response.response.updatedPermissions).toEqual(perms);
});
});
type: 'permission_response',
sessionId: 'sess-6',
payload: {
approved: true,
request_id: 'req-6',
updated_permissions: perms,
},
})
const result = toClientPayload(event)
expect((result as any).response.response.updatedPermissions).toEqual(perms)
})
})
// =============================================================================
// interrupt
// =============================================================================
describe("toClientPayload — interrupt", () => {
test("maps interrupt to control_request with subtype interrupt", () => {
describe('toClientPayload — interrupt', () => {
test('maps interrupt to control_request with subtype interrupt', () => {
const event = makeEvent({
type: "interrupt",
sessionId: "sess-1",
});
const result = toClientPayload(event);
expect(result.type).toBe("control_request");
expect((result as any).request_id).toBe("evt-1");
expect((result as any).request.subtype).toBe("interrupt");
});
});
type: 'interrupt',
sessionId: 'sess-1',
})
const result = toClientPayload(event)
expect(result.type).toBe('control_request')
expect((result as any).request_id).toBe('evt-1')
expect((result as any).request.subtype).toBe('interrupt')
})
})
// =============================================================================
// control_request
// =============================================================================
describe("toClientPayload — control_request", () => {
test("passes through request_id and request from payload", () => {
describe('toClientPayload — control_request', () => {
test('passes through request_id and request from payload', () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-1",
payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_request");
expect((result as any).request_id).toBe("req-99");
expect((result as any).request.subtype).toBe("permission");
});
type: 'control_request',
sessionId: 'sess-1',
payload: {
request_id: 'req-99',
request: { subtype: 'permission', tool: 'bash' },
},
})
const result = toClientPayload(event)
expect(result.type).toBe('control_request')
expect((result as any).request_id).toBe('req-99')
expect((result as any).request.subtype).toBe('permission')
})
test("falls back request to payload when no request field", () => {
test('falls back request to payload when no request field', () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-2",
payload: { request_id: "req-10", custom: "data" },
});
const result = toClientPayload(event);
expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" });
});
type: 'control_request',
sessionId: 'sess-2',
payload: { request_id: 'req-10', custom: 'data' },
})
const result = toClientPayload(event)
expect((result as any).request).toEqual({
request_id: 'req-10',
custom: 'data',
})
})
test("falls back request_id to event.id when missing", () => {
test('falls back request_id to event.id when missing', () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-3",
payload: { request: { subtype: "test" } },
});
const result = toClientPayload(event);
expect((result as any).request_id).toBe("evt-1");
});
});
type: 'control_request',
sessionId: 'sess-3',
payload: { request: { subtype: 'test' } },
})
const result = toClientPayload(event)
expect((result as any).request_id).toBe('evt-1')
})
})
// =============================================================================
// default fallback
// =============================================================================
describe("toClientPayload — default types", () => {
test("passes through unknown type with type/uuid/session_id/message", () => {
describe('toClientPayload — default types', () => {
test('passes through unknown type with type/uuid/session_id/message', () => {
const event = makeEvent({
type: "assistant",
sessionId: "sess-1",
payload: { uuid: "u-1", content: "response text" },
});
const result = toClientPayload(event);
expect(result.type).toBe("assistant");
expect(result.uuid).toBe("u-1");
expect(result.session_id).toBe("sess-1");
expect(result.message).toEqual({ uuid: "u-1", content: "response text" });
});
});
type: 'assistant',
sessionId: 'sess-1',
payload: { uuid: 'u-1', content: 'response text' },
})
const result = toClientPayload(event)
expect(result.type).toBe('assistant')
expect(result.uuid).toBe('u-1')
expect(result.session_id).toBe('sess-1')
expect(result.message).toEqual({ uuid: 'u-1', content: 'response text' })
})
})

View File

@@ -1,11 +1,11 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { describe, test, expect, beforeEach, mock } from 'bun:test'
// Mock config with very short timeout for testing
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
@@ -13,12 +13,12 @@ const mockConfig = {
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
}
mock.module("../config", () => ({
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
getBaseUrl: () => 'http://localhost:3000',
}))
import {
storeReset,
@@ -28,84 +28,91 @@ import {
storeUpdateSession,
storeGetEnvironment,
storeGetSession,
} from "../store";
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
} from '../store'
import {
getEventBus,
getAllEventBuses,
removeEventBus,
} from '../transport/event-bus'
import { runDisconnectMonitorSweep } from '../services/disconnect-monitor'
describe("Disconnect Monitor Logic", () => {
describe('Disconnect Monitor Logic', () => {
beforeEach(() => {
storeReset();
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
removeEventBus(key)
}
});
})
test("environment times out when lastPollAt is too old", () => {
const env = storeCreateEnvironment({ secret: "s" });
const timeoutMs = 300 * 1000; // 5 minutes
test('environment times out when lastPollAt is too old', () => {
const env = storeCreateEnvironment({ secret: 's' })
const timeoutMs = 300 * 1000 // 5 minutes
// Simulate lastPollAt being 6 minutes ago
const oldDate = new Date(Date.now() - timeoutMs - 60000);
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
const oldDate = new Date(Date.now() - timeoutMs - 60000)
storeUpdateEnvironment(env.id, { lastPollAt: oldDate })
runDisconnectMonitorSweep();
runDisconnectMonitorSweep()
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected");
});
const updated = storeGetEnvironment(env.id)
expect(updated?.status).toBe('disconnected')
})
test("environment stays active when lastPollAt is recent", () => {
const env = storeCreateEnvironment({ secret: "s" });
runDisconnectMonitorSweep();
test('environment stays active when lastPollAt is recent', () => {
const env = storeCreateEnvironment({ secret: 's' })
runDisconnectMonitorSweep()
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("active");
});
const updated = storeGetEnvironment(env.id)
expect(updated?.status).toBe('active')
})
test("session becomes inactive when updatedAt is too old", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "running" });
const rec = storeGetSession(session.id);
expect(rec).toBeTruthy();
if (!rec) return;
test('session becomes inactive when updatedAt is too old', () => {
const session = storeCreateSession({})
storeUpdateSession(session.id, { status: 'running' })
const rec = storeGetSession(session.id)
expect(rec).toBeTruthy()
if (!rec) return
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000)
runDisconnectMonitorSweep();
runDisconnectMonitorSweep()
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("inactive");
});
const updated = storeGetSession(session.id)
expect(updated?.status).toBe('inactive')
})
test("session stays running when recently updated", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "running" });
test('session stays running when recently updated', () => {
const session = storeCreateSession({})
storeUpdateSession(session.id, { status: 'running' })
runDisconnectMonitorSweep();
runDisconnectMonitorSweep()
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("running");
});
const updated = storeGetSession(session.id)
expect(updated?.status).toBe('running')
})
test("session timeout publishes an inactive session_status event", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "idle" });
const rec = storeGetSession(session.id);
expect(rec).toBeTruthy();
if (!rec) return;
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
test('session timeout publishes an inactive session_status event', () => {
const session = storeCreateSession({})
storeUpdateSession(session.id, { status: 'idle' })
const rec = storeGetSession(session.id)
expect(rec).toBeTruthy()
if (!rec) return
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000)
const bus = getEventBus(session.id);
const events: Array<{ type: string; payload: { status?: string } }> = [];
bus.subscribe((event) => {
events.push({ type: event.type, payload: event.payload as { status?: string } });
});
const bus = getEventBus(session.id)
const events: Array<{ type: string; payload: { status?: string } }> = []
bus.subscribe(event => {
events.push({
type: event.type,
payload: event.payload as { status?: string },
})
})
runDisconnectMonitorSweep();
runDisconnectMonitorSweep()
expect(events).toContainEqual({
type: "session_status",
payload: { status: "inactive" },
});
});
});
type: 'session_status',
payload: { status: 'inactive' },
})
})
})

View File

@@ -1,176 +1,293 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
import { describe, test, expect, beforeEach } from 'bun:test'
import {
EventBus,
getEventBus,
removeEventBus,
getAllEventBuses,
} from '../transport/event-bus'
describe("EventBus", () => {
let bus: EventBus;
describe('EventBus', () => {
let bus: EventBus
beforeEach(() => {
bus = new EventBus();
});
bus = new EventBus()
})
describe("publish", () => {
test("publishes event with seqNum starting at 1", () => {
describe('publish', () => {
test('publishes event with seqNum starting at 1', () => {
const event = bus.publish({
id: "e1",
sessionId: "s1",
type: "user",
payload: { content: "hello" },
direction: "outbound",
});
expect(event.seqNum).toBe(1);
expect(event.createdAt).toBeGreaterThan(0);
});
id: 'e1',
sessionId: 's1',
type: 'user',
payload: { content: 'hello' },
direction: 'outbound',
})
expect(event.seqNum).toBe(1)
expect(event.createdAt).toBeGreaterThan(0)
})
test("increments seqNum on each publish", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
expect(event.seqNum).toBe(3);
});
test('increments seqNum on each publish', () => {
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
bus.publish({
id: 'e2',
sessionId: 's1',
type: 'assistant',
payload: {},
direction: 'inbound',
})
const event = bus.publish({
id: 'e3',
sessionId: 's1',
type: 'result',
payload: {},
direction: 'inbound',
})
expect(event.seqNum).toBe(3)
})
test("throws when publishing to a closed bus", () => {
bus.close();
test('throws when publishing to a closed bus', () => {
bus.close()
expect(() =>
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
).toThrow("EventBus is closed");
});
});
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
}),
).toThrow('EventBus is closed')
})
})
describe("subscribe", () => {
test("receives published events", () => {
const received: unknown[] = [];
bus.subscribe((event) => received.push(event));
describe('subscribe', () => {
test('receives published events', () => {
const received: unknown[] = []
bus.subscribe(event => received.push(event))
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
expect(received).toHaveLength(1);
expect((received[0] as any).payload).toEqual({ content: "hi" });
});
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: { content: 'hi' },
direction: 'outbound',
})
expect(received).toHaveLength(1)
expect((received[0] as any).payload).toEqual({ content: 'hi' })
})
test("unsubscribe stops receiving events", () => {
const received: unknown[] = [];
const unsub = bus.subscribe((event) => received.push(event));
unsub();
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(received).toHaveLength(0);
});
test('unsubscribe stops receiving events', () => {
const received: unknown[] = []
const unsub = bus.subscribe(event => received.push(event))
unsub()
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
expect(received).toHaveLength(0)
})
test("multiple subscribers all receive events", () => {
const r1: unknown[] = [];
const r2: unknown[] = [];
bus.subscribe((e) => r1.push(e));
bus.subscribe((e) => r2.push(e));
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(r1).toHaveLength(1);
expect(r2).toHaveLength(1);
});
test('multiple subscribers all receive events', () => {
const r1: unknown[] = []
const r2: unknown[] = []
bus.subscribe(e => r1.push(e))
bus.subscribe(e => r2.push(e))
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
expect(r1).toHaveLength(1)
expect(r2).toHaveLength(1)
})
test("subscriber error does not affect other subscribers", () => {
const received: unknown[] = [];
test('subscriber error does not affect other subscribers', () => {
const received: unknown[] = []
bus.subscribe(() => {
throw new Error("boom");
});
bus.subscribe((e) => received.push(e));
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(received).toHaveLength(1);
});
throw new Error('boom')
})
bus.subscribe(e => received.push(e))
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
expect(received).toHaveLength(1)
})
test("subscriberCount", () => {
expect(bus.subscriberCount()).toBe(0);
const unsub1 = bus.subscribe(() => {});
expect(bus.subscriberCount()).toBe(1);
const unsub2 = bus.subscribe(() => {});
expect(bus.subscriberCount()).toBe(2);
unsub1();
expect(bus.subscriberCount()).toBe(1);
});
});
test('subscriberCount', () => {
expect(bus.subscriberCount()).toBe(0)
const unsub1 = bus.subscribe(() => {})
expect(bus.subscriberCount()).toBe(1)
const unsub2 = bus.subscribe(() => {})
expect(bus.subscriberCount()).toBe(2)
unsub1()
expect(bus.subscriberCount()).toBe(1)
})
})
describe("getEventsSince", () => {
test("returns events after given seqNum", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
describe('getEventsSince', () => {
test('returns events after given seqNum', () => {
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
bus.publish({
id: 'e2',
sessionId: 's1',
type: 'assistant',
payload: {},
direction: 'inbound',
})
bus.publish({
id: 'e3',
sessionId: 's1',
type: 'result',
payload: {},
direction: 'inbound',
})
const events = bus.getEventsSince(1);
expect(events).toHaveLength(2);
expect(events[0].seqNum).toBe(2);
expect(events[1].seqNum).toBe(3);
});
const events = bus.getEventsSince(1)
expect(events).toHaveLength(2)
expect(events[0].seqNum).toBe(2)
expect(events[1].seqNum).toBe(3)
})
test("returns empty for seqNum beyond last", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(bus.getEventsSince(1)).toHaveLength(0);
});
test('returns empty for seqNum beyond last', () => {
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
expect(bus.getEventsSince(1)).toHaveLength(0)
})
test("returns all events when seqNum is 0", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
expect(bus.getEventsSince(0)).toHaveLength(2);
});
});
test('returns all events when seqNum is 0', () => {
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
bus.publish({
id: 'e2',
sessionId: 's1',
type: 'assistant',
payload: {},
direction: 'inbound',
})
expect(bus.getEventsSince(0)).toHaveLength(2)
})
})
describe("getLastSeqNum", () => {
test("returns 0 for empty bus", () => {
expect(bus.getLastSeqNum()).toBe(0);
});
describe('getLastSeqNum', () => {
test('returns 0 for empty bus', () => {
expect(bus.getLastSeqNum()).toBe(0)
})
test("returns last seqNum after publishes", () => {
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
expect(bus.getLastSeqNum()).toBe(2);
});
});
test('returns last seqNum after publishes', () => {
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
bus.publish({
id: 'e2',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
})
expect(bus.getLastSeqNum()).toBe(2)
})
})
describe("close", () => {
test("clears subscribers and prevents publishing", () => {
bus.subscribe(() => {});
bus.close();
expect(bus.subscriberCount()).toBe(0);
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
});
});
});
describe('close', () => {
test('clears subscribers and prevents publishing', () => {
bus.subscribe(() => {})
bus.close()
expect(bus.subscriberCount()).toBe(0)
expect(() =>
bus.publish({
id: 'e1',
sessionId: 's1',
type: 'user',
payload: {},
direction: 'outbound',
}),
).toThrow()
})
})
})
describe("EventBus registry", () => {
describe('EventBus registry', () => {
beforeEach(() => {
// Clean up global registry
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
removeEventBus(key)
}
});
})
describe("getEventBus", () => {
test("creates new bus for unknown session", () => {
const bus = getEventBus("s1");
expect(bus).toBeInstanceOf(EventBus);
expect(getAllEventBuses().has("s1")).toBe(true);
});
describe('getEventBus', () => {
test('creates new bus for unknown session', () => {
const bus = getEventBus('s1')
expect(bus).toBeInstanceOf(EventBus)
expect(getAllEventBuses().has('s1')).toBe(true)
})
test("returns same bus for same session", () => {
const bus1 = getEventBus("s1");
const bus2 = getEventBus("s1");
expect(bus1).toBe(bus2);
});
});
test('returns same bus for same session', () => {
const bus1 = getEventBus('s1')
const bus2 = getEventBus('s1')
expect(bus1).toBe(bus2)
})
})
describe("removeEventBus", () => {
test("removes and closes bus", () => {
const bus = getEventBus("s2");
removeEventBus("s2");
expect(getAllEventBuses().has("s2")).toBe(false);
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
});
describe('removeEventBus', () => {
test('removes and closes bus', () => {
const bus = getEventBus('s2')
removeEventBus('s2')
expect(getAllEventBuses().has('s2')).toBe(false)
expect(() =>
bus.publish({
id: 'e1',
sessionId: 's2',
type: 'user',
payload: {},
direction: 'outbound',
}),
).toThrow()
})
test("no-op for non-existent bus", () => {
expect(() => removeEventBus("nonexistent")).not.toThrow();
});
});
test('no-op for non-existent bus', () => {
expect(() => removeEventBus('nonexistent')).not.toThrow()
})
})
describe("getAllEventBuses", () => {
test("returns all registered buses", () => {
getEventBus("a");
getEventBus("b");
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
});
});
});
describe('getAllEventBuses', () => {
test('returns all registered buses', () => {
getEventBus('a')
getEventBus('b')
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2)
})
})
})

View File

@@ -1,28 +1,28 @@
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'
// Mock config before imports
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: ["https://dashboard.example"],
webCorsOrigins: ['https://dashboard.example'],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
}
mock.module("../config", () => ({
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
getBaseUrl: () => 'http://localhost:3000',
}))
import { Hono } from "hono";
import { cors } from "hono/cors";
import { storeReset, storeCreateUser } from "../store";
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { storeReset, storeCreateUser } from '../store'
import {
apiKeyAuth,
encodeWebSocketAuthProtocol,
@@ -30,266 +30,266 @@ import {
sessionIngressAuth,
uuidAuth,
getUuidFromRequest,
} from "../auth/middleware";
import { issueToken } from "../auth/token";
import { generateWorkerJwt } from "../auth/jwt";
} from '../auth/middleware'
import { issueToken } from '../auth/token'
import { generateWorkerJwt } from '../auth/jwt'
import {
getAllowedWebCorsOrigins,
resolveWebCorsOrigin,
webCorsOptions,
} from "../auth/cors";
} from '../auth/cors'
// Helper: create a test app with middleware and a simple handler
function createTestApp() {
const app = new Hono();
const app = new Hono()
// Test route for apiKeyAuth
app.get("/api-key-test", apiKeyAuth, (c) => {
return c.json({ username: c.get("username") || null });
});
app.get('/api-key-test', apiKeyAuth, c => {
return c.json({ username: c.get('username') || null })
})
// Test route for sessionIngressAuth
app.get("/ingress/:id", sessionIngressAuth, (c) => {
return c.json({ ok: true, jwtPayload: c.get("jwtPayload") || null });
});
app.get('/ingress/:id', sessionIngressAuth, c => {
return c.json({ ok: true, jwtPayload: c.get('jwtPayload') || null })
})
// Test route for uuidAuth
app.get("/uuid-test", uuidAuth, (c) => {
return c.json({ uuid: c.get("uuid") });
});
app.get('/uuid-test', uuidAuth, c => {
return c.json({ uuid: c.get('uuid') })
})
// Test route for getUuidFromRequest
app.get("/uuid-extract", (c) => {
return c.json({ uuid: getUuidFromRequest(c) });
});
app.get('/uuid-extract', c => {
return c.json({ uuid: getUuidFromRequest(c) })
})
app.get("/ws-auth-token", (c) => {
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
});
app.get('/ws-auth-token', c => {
return c.json({ token: extractWebSocketAuthToken(c) ?? null })
})
return app;
return app
}
describe("Auth Middleware", () => {
let app: Hono;
describe('Auth Middleware', () => {
let app: Hono
beforeEach(() => {
storeReset();
app = createTestApp();
});
storeReset()
app = createTestApp()
})
describe("apiKeyAuth", () => {
test("accepts valid API key with username header", async () => {
const res = await app.request("/api-key-test", {
describe('apiKeyAuth', () => {
test('accepts valid API key with username header', async () => {
const res = await app.request('/api-key-test', {
headers: {
Authorization: "Bearer test-api-key",
"X-Username": "alice",
Authorization: 'Bearer test-api-key',
'X-Username': 'alice',
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("alice");
});
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.username).toBe('alice')
})
test("accepts valid API key with username query param", async () => {
const res = await app.request("/api-key-test?username=bob", {
headers: { Authorization: "Bearer test-api-key" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("bob");
});
test('accepts valid API key with username query param', async () => {
const res = await app.request('/api-key-test?username=bob', {
headers: { Authorization: 'Bearer test-api-key' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.username).toBe('bob')
})
test("accepts valid session token", async () => {
storeCreateUser("charlie");
const { token } = issueToken("charlie");
const res = await app.request("/api-key-test", {
test('accepts valid session token', async () => {
storeCreateUser('charlie')
const { token } = issueToken('charlie')
const res = await app.request('/api-key-test', {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("charlie");
});
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.username).toBe('charlie')
})
test("rejects invalid token", async () => {
const res = await app.request("/api-key-test", {
headers: { Authorization: "Bearer wrong-key" },
});
expect(res.status).toBe(401);
});
test('rejects invalid token', async () => {
const res = await app.request('/api-key-test', {
headers: { Authorization: 'Bearer wrong-key' },
})
expect(res.status).toBe(401)
})
test("rejects missing token", async () => {
const res = await app.request("/api-key-test");
expect(res.status).toBe(401);
});
test('rejects missing token', async () => {
const res = await app.request('/api-key-test')
expect(res.status).toBe(401)
})
test("rejects session token from query param", async () => {
storeCreateUser("dave");
const { token } = issueToken("dave");
const res = await app.request(`/api-key-test?token=${token}`);
expect(res.status).toBe(401);
});
});
test('rejects session token from query param', async () => {
storeCreateUser('dave')
const { token } = issueToken('dave')
const res = await app.request(`/api-key-test?token=${token}`)
expect(res.status).toBe(401)
})
})
describe("sessionIngressAuth", () => {
const originalKeys = process.env.RCS_API_KEYS;
describe('sessionIngressAuth', () => {
const originalKeys = process.env.RCS_API_KEYS
beforeEach(() => {
process.env.RCS_API_KEYS = "test-api-key";
});
process.env.RCS_API_KEYS = 'test-api-key'
})
afterAll(() => {
process.env.RCS_API_KEYS = originalKeys;
});
process.env.RCS_API_KEYS = originalKeys
})
test("accepts valid API key", async () => {
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: "Bearer test-api-key" },
});
expect(res.status).toBe(200);
});
test('accepts valid API key', async () => {
const res = await app.request('/ingress/ses_123', {
headers: { Authorization: 'Bearer test-api-key' },
})
expect(res.status).toBe(200)
})
test("accepts API key from WebSocket protocol header", async () => {
const res = await app.request("/ingress/ses_123", {
test('accepts API key from WebSocket protocol header', async () => {
const res = await app.request('/ingress/ses_123', {
headers: {
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
},
});
expect(res.status).toBe(200);
});
})
expect(res.status).toBe(200)
})
test("accepts valid JWT with matching session_id", async () => {
const jwt = generateWorkerJwt("ses_123", 3600);
const res = await app.request("/ingress/ses_123", {
test('accepts valid JWT with matching session_id', async () => {
const jwt = generateWorkerJwt('ses_123', 3600)
const res = await app.request('/ingress/ses_123', {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.jwtPayload).not.toBeNull();
expect(body.jwtPayload.session_id).toBe("ses_123");
});
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.jwtPayload).not.toBeNull()
expect(body.jwtPayload.session_id).toBe('ses_123')
})
test("rejects JWT with mismatched session_id", async () => {
const jwt = generateWorkerJwt("ses_456", 3600);
const res = await app.request("/ingress/ses_123", {
test('rejects JWT with mismatched session_id', async () => {
const jwt = generateWorkerJwt('ses_456', 3600)
const res = await app.request('/ingress/ses_123', {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(403);
});
})
expect(res.status).toBe(403)
})
test("rejects missing token", async () => {
const res = await app.request("/ingress/ses_123");
expect(res.status).toBe(401);
});
test('rejects missing token', async () => {
const res = await app.request('/ingress/ses_123')
expect(res.status).toBe(401)
})
test("rejects invalid token", async () => {
const res = await app.request("/ingress/ses_123", {
headers: { Authorization: "Bearer invalid" },
});
expect(res.status).toBe(401);
});
});
test('rejects invalid token', async () => {
const res = await app.request('/ingress/ses_123', {
headers: { Authorization: 'Bearer invalid' },
})
expect(res.status).toBe(401)
})
})
describe("extractWebSocketAuthToken", () => {
test("does not read tokens from query params", async () => {
const res = await app.request("/ws-auth-token?token=test-api-key");
const body = await res.json();
expect(body.token).toBeNull();
});
describe('extractWebSocketAuthToken', () => {
test('does not read tokens from query params', async () => {
const res = await app.request('/ws-auth-token?token=test-api-key')
const body = await res.json()
expect(body.token).toBeNull()
})
test("reads tokens from WebSocket protocol header", async () => {
const res = await app.request("/ws-auth-token", {
test('reads tokens from WebSocket protocol header', async () => {
const res = await app.request('/ws-auth-token', {
headers: {
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
},
});
const body = await res.json();
expect(body.token).toBe("test-api-key");
});
});
})
const body = await res.json()
expect(body.token).toBe('test-api-key')
})
})
describe("uuidAuth", () => {
test("accepts UUID from query param", async () => {
const res = await app.request("/uuid-test?uuid=test-uuid-1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uuid).toBe("test-uuid-1");
});
describe('uuidAuth', () => {
test('accepts UUID from query param', async () => {
const res = await app.request('/uuid-test?uuid=test-uuid-1')
expect(res.status).toBe(200)
const body = await res.json()
expect(body.uuid).toBe('test-uuid-1')
})
test("accepts UUID from header", async () => {
const res = await app.request("/uuid-test", {
headers: { "X-UUID": "test-uuid-2" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.uuid).toBe("test-uuid-2");
});
test('accepts UUID from header', async () => {
const res = await app.request('/uuid-test', {
headers: { 'X-UUID': 'test-uuid-2' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.uuid).toBe('test-uuid-2')
})
test("rejects missing UUID", async () => {
const res = await app.request("/uuid-test");
expect(res.status).toBe(401);
});
});
test('rejects missing UUID', async () => {
const res = await app.request('/uuid-test')
expect(res.status).toBe(401)
})
})
describe("getUuidFromRequest", () => {
test("extracts from query param", async () => {
const res = await app.request("/uuid-extract?uuid=from-query");
const body = await res.json();
expect(body.uuid).toBe("from-query");
});
describe('getUuidFromRequest', () => {
test('extracts from query param', async () => {
const res = await app.request('/uuid-extract?uuid=from-query')
const body = await res.json()
expect(body.uuid).toBe('from-query')
})
test("extracts from header", async () => {
const res = await app.request("/uuid-extract", {
headers: { "X-UUID": "from-header" },
});
const body = await res.json();
expect(body.uuid).toBe("from-header");
});
test('extracts from header', async () => {
const res = await app.request('/uuid-extract', {
headers: { 'X-UUID': 'from-header' },
})
const body = await res.json()
expect(body.uuid).toBe('from-header')
})
test("returns undefined when no UUID", async () => {
const res = await app.request("/uuid-extract");
const body = await res.json();
expect(body.uuid).toBeUndefined();
});
});
});
test('returns undefined when no UUID', async () => {
const res = await app.request('/uuid-extract')
const body = await res.json()
expect(body.uuid).toBeUndefined()
})
})
})
describe("Web CORS", () => {
describe('Web CORS', () => {
function createCorsApp() {
const corsApp = new Hono();
corsApp.use("/web/*", cors(webCorsOptions));
corsApp.get("/web/ping", (c) => c.text("ok"));
return corsApp;
const corsApp = new Hono()
corsApp.use('/web/*', cors(webCorsOptions))
corsApp.get('/web/ping', c => c.text('ok'))
return corsApp
}
test("allows configured origins plus local server origins", () => {
expect(getAllowedWebCorsOrigins()).toContain("https://dashboard.example");
expect(getAllowedWebCorsOrigins()).toContain("http://localhost:3000");
expect(getAllowedWebCorsOrigins()).toContain("http://127.0.0.1:3000");
expect(resolveWebCorsOrigin("https://dashboard.example")).toBe(
"https://dashboard.example",
);
});
test('allows configured origins plus local server origins', () => {
expect(getAllowedWebCorsOrigins()).toContain('https://dashboard.example')
expect(getAllowedWebCorsOrigins()).toContain('http://localhost:3000')
expect(getAllowedWebCorsOrigins()).toContain('http://127.0.0.1:3000')
expect(resolveWebCorsOrigin('https://dashboard.example')).toBe(
'https://dashboard.example',
)
})
test("rejects unknown origins by default", () => {
expect(resolveWebCorsOrigin("https://attacker.example")).toBeUndefined();
});
test('rejects unknown origins by default', () => {
expect(resolveWebCorsOrigin('https://attacker.example')).toBeUndefined()
})
test("does not emit CORS allow-origin for unknown web origins", async () => {
const res = await createCorsApp().request("/web/ping", {
headers: { Origin: "https://attacker.example" },
});
test('does not emit CORS allow-origin for unknown web origins', async () => {
const res = await createCorsApp().request('/web/ping', {
headers: { Origin: 'https://attacker.example' },
})
expect(res.status).toBe(200);
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
expect(res.status).toBe(200)
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})
test("emits CORS allow-origin for configured web origins", async () => {
const res = await createCorsApp().request("/web/ping", {
headers: { Origin: "https://dashboard.example" },
});
test('emits CORS allow-origin for configured web origins', async () => {
const res = await createCorsApp().request('/web/ping', {
headers: { Origin: 'https://dashboard.example' },
})
expect(res.status).toBe(200);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
"https://dashboard.example",
);
});
});
expect(res.status).toBe(200)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
'https://dashboard.example',
)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { describe, test, expect, beforeEach, mock } from 'bun:test'
// Mock config before importing modules
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
@@ -13,14 +13,14 @@ const mockConfig = {
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
}
mock.module("../config", () => ({
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
getBaseUrl: () => 'http://localhost:3000',
}))
import { storeReset, storeCreateEnvironment } from "../store";
import { storeReset, storeCreateEnvironment } from '../store'
import {
createSession,
createCodeSession,
@@ -33,7 +33,7 @@ import {
listSessionSummaries,
listSessionSummariesByUsername,
listSessionsByEnvironment,
} from "../services/session";
} from '../services/session'
import {
registerEnvironment,
deregisterEnvironment,
@@ -43,385 +43,404 @@ import {
listActiveEnvironmentsResponse,
listActiveEnvironmentsByUsername,
reconnectEnvironment,
} from "../services/environment";
import { normalizePayload, publishSessionEvent } from "../services/transport";
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
} from '../services/environment'
import { normalizePayload, publishSessionEvent } from '../services/transport'
import {
getEventBus,
removeEventBus,
getAllEventBuses,
} from '../transport/event-bus'
// ---------- Session Service ----------
describe("Session Service", () => {
describe('Session Service', () => {
beforeEach(() => {
storeReset();
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
removeEventBus(key)
}
});
})
describe("createSession", () => {
test("creates a session with defaults", () => {
const resp = createSession({});
expect(resp.id).toMatch(/^session_/);
expect(resp.status).toBe("idle");
expect(resp.source).toBe("remote-control");
expect(resp.environment_id).toBeNull();
expect(resp.worker_epoch).toBe(0);
expect(resp.created_at).toBeGreaterThan(0);
});
describe('createSession', () => {
test('creates a session with defaults', () => {
const resp = createSession({})
expect(resp.id).toMatch(/^session_/)
expect(resp.status).toBe('idle')
expect(resp.source).toBe('remote-control')
expect(resp.environment_id).toBeNull()
expect(resp.worker_epoch).toBe(0)
expect(resp.created_at).toBeGreaterThan(0)
})
test("creates a session with all options", () => {
const env = storeCreateEnvironment({ secret: "s" });
test('creates a session with all options', () => {
const env = storeCreateEnvironment({ secret: 's' })
const resp = createSession({
environment_id: env.id,
title: "My Session",
source: "cli",
permission_mode: "auto",
});
expect(resp.environment_id).toBe(env.id);
expect(resp.title).toBe("My Session");
expect(resp.source).toBe("cli");
expect(resp.permission_mode).toBe("auto");
});
title: 'My Session',
source: 'cli',
permission_mode: 'auto',
})
expect(resp.environment_id).toBe(env.id)
expect(resp.title).toBe('My Session')
expect(resp.source).toBe('cli')
expect(resp.permission_mode).toBe('auto')
})
test("creates session with username", () => {
const resp = createSession({ username: "alice" });
expect(resp.username).toBe("alice");
});
});
test('creates session with username', () => {
const resp = createSession({ username: 'alice' })
expect(resp.username).toBe('alice')
})
})
describe("createCodeSession", () => {
test("creates a code session with cse_ prefix", () => {
const resp = createCodeSession({});
expect(resp.id).toMatch(/^cse_/);
});
});
describe('createCodeSession', () => {
test('creates a code session with cse_ prefix', () => {
const resp = createCodeSession({})
expect(resp.id).toMatch(/^cse_/)
})
})
describe("getSession", () => {
test("returns null for non-existent session", () => {
expect(getSession("nope")).toBeNull();
});
describe('getSession', () => {
test('returns null for non-existent session', () => {
expect(getSession('nope')).toBeNull()
})
test("returns created session", () => {
const created = createSession({});
const fetched = getSession(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
});
});
test('returns created session', () => {
const created = createSession({})
const fetched = getSession(created.id)
expect(fetched).not.toBeNull()
expect(fetched!.id).toBe(created.id)
})
})
describe("updateSessionTitle", () => {
test("updates title", () => {
const s = createSession({});
updateSessionTitle(s.id, "New Title");
expect(getSession(s.id)?.title).toBe("New Title");
});
});
describe('updateSessionTitle', () => {
test('updates title', () => {
const s = createSession({})
updateSessionTitle(s.id, 'New Title')
expect(getSession(s.id)?.title).toBe('New Title')
})
})
describe("updateSessionStatus", () => {
test("updates status", () => {
const s = createSession({});
updateSessionStatus(s.id, "active");
expect(getSession(s.id)?.status).toBe("active");
});
});
describe('updateSessionStatus', () => {
test('updates status', () => {
const s = createSession({})
updateSessionStatus(s.id, 'active')
expect(getSession(s.id)?.status).toBe('active')
})
})
describe("archiveSession", () => {
test("sets status to archived and removes event bus", () => {
const s = createSession({});
describe('archiveSession', () => {
test('sets status to archived and removes event bus', () => {
const s = createSession({})
// Create event bus for this session
getEventBus(s.id);
archiveSession(s.id);
expect(getSession(s.id)?.status).toBe("archived");
expect(getAllEventBuses().has(s.id)).toBe(false);
});
});
getEventBus(s.id)
archiveSession(s.id)
expect(getSession(s.id)?.status).toBe('archived')
expect(getAllEventBuses().has(s.id)).toBe(false)
})
})
describe("incrementEpoch", () => {
test("increments epoch by 1", () => {
const s = createSession({});
expect(incrementEpoch(s.id)).toBe(1);
expect(incrementEpoch(s.id)).toBe(2);
expect(getSession(s.id)?.worker_epoch).toBe(2);
});
describe('incrementEpoch', () => {
test('increments epoch by 1', () => {
const s = createSession({})
expect(incrementEpoch(s.id)).toBe(1)
expect(incrementEpoch(s.id)).toBe(2)
expect(getSession(s.id)?.worker_epoch).toBe(2)
})
test("throws for non-existent session", () => {
expect(() => incrementEpoch("nope")).toThrow("Session not found");
});
});
test('throws for non-existent session', () => {
expect(() => incrementEpoch('nope')).toThrow('Session not found')
})
})
describe("listSessions", () => {
test("returns all sessions", () => {
createSession({});
createSession({});
expect(listSessions()).toHaveLength(2);
});
});
describe('listSessions', () => {
test('returns all sessions', () => {
createSession({})
createSession({})
expect(listSessions()).toHaveLength(2)
})
})
describe("listSessionSummaries", () => {
test("returns summaries with correct fields", () => {
createSession({ title: "Test" });
const summaries = listSessionSummaries();
expect(summaries).toHaveLength(1);
expect(summaries[0].title).toBe("Test");
expect(summaries[0].updated_at).toBeGreaterThan(0);
describe('listSessionSummaries', () => {
test('returns summaries with correct fields', () => {
createSession({ title: 'Test' })
const summaries = listSessionSummaries()
expect(summaries).toHaveLength(1)
expect(summaries[0].title).toBe('Test')
expect(summaries[0].updated_at).toBeGreaterThan(0)
// Summary should not have environment_id
expect("environment_id" in summaries[0]).toBe(false);
});
});
expect('environment_id' in summaries[0]).toBe(false)
})
})
describe("listSessionSummariesByUsername", () => {
test("filters by username", () => {
createSession({ username: "alice" });
createSession({ username: "bob" });
expect(listSessionSummariesByUsername("alice")).toHaveLength(1);
});
});
describe('listSessionSummariesByUsername', () => {
test('filters by username', () => {
createSession({ username: 'alice' })
createSession({ username: 'bob' })
expect(listSessionSummariesByUsername('alice')).toHaveLength(1)
})
})
describe("listSessionsByEnvironment", () => {
test("filters by environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
createSession({ environment_id: env.id });
createSession({});
expect(listSessionsByEnvironment(env.id)).toHaveLength(1);
});
});
});
describe('listSessionsByEnvironment', () => {
test('filters by environment', () => {
const env = storeCreateEnvironment({ secret: 's' })
createSession({ environment_id: env.id })
createSession({})
expect(listSessionsByEnvironment(env.id)).toHaveLength(1)
})
})
})
// ---------- Environment Service ----------
describe("Environment Service", () => {
describe('Environment Service', () => {
beforeEach(() => {
storeReset();
});
storeReset()
})
describe("registerEnvironment", () => {
test("registers environment with defaults", () => {
const result = registerEnvironment({});
expect(result.environment_id).toMatch(/^env_/);
expect(result.environment_secret).toBe("test-api-key");
expect(result.status).toBe("active");
});
describe('registerEnvironment', () => {
test('registers environment with defaults', () => {
const result = registerEnvironment({})
expect(result.environment_id).toMatch(/^env_/)
expect(result.environment_secret).toBe('test-api-key')
expect(result.status).toBe('active')
})
test("registers with options", () => {
test('registers with options', () => {
const result = registerEnvironment({
machine_name: "mac1",
directory: "/home/user",
branch: "main",
git_repo_url: "https://github.com/test/repo",
machine_name: 'mac1',
directory: '/home/user',
branch: 'main',
git_repo_url: 'https://github.com/test/repo',
max_sessions: 5,
worker_type: "custom",
});
const env = getEnvironment(result.environment_id);
expect(env?.machineName).toBe("mac1");
expect(env?.directory).toBe("/home/user");
expect(env?.maxSessions).toBe(5);
});
worker_type: 'custom',
})
const env = getEnvironment(result.environment_id)
expect(env?.machineName).toBe('mac1')
expect(env?.directory).toBe('/home/user')
expect(env?.maxSessions).toBe(5)
})
test("registers with username", () => {
const result = registerEnvironment({ username: "alice" });
const env = getEnvironment(result.environment_id);
expect(env?.username).toBe("alice");
});
});
test('registers with username', () => {
const result = registerEnvironment({ username: 'alice' })
const env = getEnvironment(result.environment_id)
expect(env?.username).toBe('alice')
})
})
describe("deregisterEnvironment", () => {
test("sets status to deregistered", () => {
const result = registerEnvironment({});
deregisterEnvironment(result.environment_id);
const env = getEnvironment(result.environment_id);
expect(env?.status).toBe("deregistered");
});
});
describe('deregisterEnvironment', () => {
test('sets status to deregistered', () => {
const result = registerEnvironment({})
deregisterEnvironment(result.environment_id)
const env = getEnvironment(result.environment_id)
expect(env?.status).toBe('deregistered')
})
})
describe("updatePollTime", () => {
test("updates lastPollAt", () => {
const result = registerEnvironment({});
const before = getEnvironment(result.environment_id)?.lastPollAt;
describe('updatePollTime', () => {
test('updates lastPollAt', () => {
const result = registerEnvironment({})
const before = getEnvironment(result.environment_id)?.lastPollAt
// Small delay to ensure time difference
updatePollTime(result.environment_id);
const after = getEnvironment(result.environment_id)?.lastPollAt;
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime());
});
});
updatePollTime(result.environment_id)
const after = getEnvironment(result.environment_id)?.lastPollAt
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime())
})
})
describe("listActiveEnvironments", () => {
test("returns active environments", () => {
registerEnvironment({});
registerEnvironment({});
expect(listActiveEnvironments()).toHaveLength(2);
});
});
describe('listActiveEnvironments', () => {
test('returns active environments', () => {
registerEnvironment({})
registerEnvironment({})
expect(listActiveEnvironments()).toHaveLength(2)
})
})
describe("listActiveEnvironmentsResponse", () => {
test("returns response format", () => {
registerEnvironment({ machine_name: "mac1" });
const envs = listActiveEnvironmentsResponse();
expect(envs).toHaveLength(1);
expect(envs[0].machine_name).toBe("mac1");
expect(envs[0].last_poll_at).toBeGreaterThan(0);
});
});
describe('listActiveEnvironmentsResponse', () => {
test('returns response format', () => {
registerEnvironment({ machine_name: 'mac1' })
const envs = listActiveEnvironmentsResponse()
expect(envs).toHaveLength(1)
expect(envs[0].machine_name).toBe('mac1')
expect(envs[0].last_poll_at).toBeGreaterThan(0)
})
})
describe("listActiveEnvironmentsByUsername", () => {
test("filters by username", () => {
registerEnvironment({ username: "alice" });
registerEnvironment({ username: "bob" });
expect(listActiveEnvironmentsByUsername("alice")).toHaveLength(1);
});
});
describe('listActiveEnvironmentsByUsername', () => {
test('filters by username', () => {
registerEnvironment({ username: 'alice' })
registerEnvironment({ username: 'bob' })
expect(listActiveEnvironmentsByUsername('alice')).toHaveLength(1)
})
})
describe("reconnectEnvironment", () => {
test("sets status back to active", () => {
const result = registerEnvironment({});
deregisterEnvironment(result.environment_id);
expect(getEnvironment(result.environment_id)?.status).toBe("deregistered");
reconnectEnvironment(result.environment_id);
expect(getEnvironment(result.environment_id)?.status).toBe("active");
});
});
});
describe('reconnectEnvironment', () => {
test('sets status back to active', () => {
const result = registerEnvironment({})
deregisterEnvironment(result.environment_id)
expect(getEnvironment(result.environment_id)?.status).toBe('deregistered')
reconnectEnvironment(result.environment_id)
expect(getEnvironment(result.environment_id)?.status).toBe('active')
})
})
})
// ---------- Transport Service ----------
describe("Transport Service", () => {
describe('Transport Service', () => {
beforeEach(() => {
storeReset();
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
removeEventBus(key)
}
});
})
describe("normalizePayload", () => {
test("handles string payload", () => {
const result = normalizePayload("user", "hello world");
expect(result.content).toBe("hello world");
expect(result.raw).toBe("hello world");
});
describe('normalizePayload', () => {
test('handles string payload', () => {
const result = normalizePayload('user', 'hello world')
expect(result.content).toBe('hello world')
expect(result.raw).toBe('hello world')
})
test("handles null payload", () => {
const result = normalizePayload("user", null);
expect(result.content).toBe("");
expect(result.raw).toBeNull();
});
test('handles null payload', () => {
const result = normalizePayload('user', null)
expect(result.content).toBe('')
expect(result.raw).toBeNull()
})
test("handles object with direct content", () => {
const result = normalizePayload("user", { content: "direct text" });
expect(result.content).toBe("direct text");
});
test('handles object with direct content', () => {
const result = normalizePayload('user', { content: 'direct text' })
expect(result.content).toBe('direct text')
})
test("handles object with message.content string", () => {
const result = normalizePayload("assistant", { message: { role: "assistant", content: "reply" } });
expect(result.content).toBe("reply");
});
test('handles object with message.content string', () => {
const result = normalizePayload('assistant', {
message: { role: 'assistant', content: 'reply' },
})
expect(result.content).toBe('reply')
})
test("handles object with message.content array", () => {
const result = normalizePayload("assistant", {
test('handles object with message.content array', () => {
const result = normalizePayload('assistant', {
message: {
content: [
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
},
});
expect(result.content).toBe("hello world");
});
})
expect(result.content).toBe('hello world')
})
test("preserves tool fields", () => {
const result = normalizePayload("tool_use", { tool_name: "Bash", tool_input: { cmd: "ls" } });
expect(result.tool_name).toBe("Bash");
expect(result.tool_input).toEqual({ cmd: "ls" });
});
test('preserves tool fields', () => {
const result = normalizePayload('tool_use', {
tool_name: 'Bash',
tool_input: { cmd: 'ls' },
})
expect(result.tool_name).toBe('Bash')
expect(result.tool_input).toEqual({ cmd: 'ls' })
})
test("preserves permission fields", () => {
const result = normalizePayload("permission", {
request_id: "req_1",
test('preserves permission fields', () => {
const result = normalizePayload('permission', {
request_id: 'req_1',
approved: true,
updated_input: { cmd: "ls -la" },
});
expect(result.request_id).toBe("req_1");
expect(result.approved).toBe(true);
expect(result.updated_input).toEqual({ cmd: "ls -la" });
});
updated_input: { cmd: 'ls -la' },
})
expect(result.request_id).toBe('req_1')
expect(result.approved).toBe(true)
expect(result.updated_input).toEqual({ cmd: 'ls -la' })
})
test("preserves message field", () => {
const msg = { role: "user", content: "hi" };
const result = normalizePayload("user", { message: msg });
expect(result.message).toEqual(msg);
});
test('preserves message field', () => {
const msg = { role: 'user', content: 'hi' }
const result = normalizePayload('user', { message: msg })
expect(result.message).toEqual(msg)
})
test("preserves uuid field", () => {
const result = normalizePayload("user", {
uuid: "msg_123",
content: "hi",
});
expect(result.uuid).toBe("msg_123");
});
test('preserves uuid field', () => {
const result = normalizePayload('user', {
uuid: 'msg_123',
content: 'hi',
})
expect(result.uuid).toBe('msg_123')
})
test("preserves isSynthetic field", () => {
const result = normalizePayload("user", {
content: "scheduled job: refresh analytics cache",
test('preserves isSynthetic field', () => {
const result = normalizePayload('user', {
content: 'scheduled job: refresh analytics cache',
isSynthetic: true,
});
expect(result.isSynthetic).toBe(true);
});
})
expect(result.isSynthetic).toBe(true)
})
test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read");
});
test('uses name as tool_name fallback', () => {
const result = normalizePayload('tool', { name: 'Read' })
expect(result.tool_name).toBe('Read')
})
test("uses input as tool_input fallback", () => {
const result = normalizePayload("tool", { input: { path: "/tmp" } });
expect(result.tool_input).toEqual({ path: "/tmp" });
});
test('uses input as tool_input fallback', () => {
const result = normalizePayload('tool', { input: { path: '/tmp' } })
expect(result.tool_input).toEqual({ path: '/tmp' })
})
test("handles empty content array", () => {
const result = normalizePayload("assistant", {
test('handles empty content array', () => {
const result = normalizePayload('assistant', {
message: { content: [] },
});
expect(result.content).toBe("");
});
})
expect(result.content).toBe('')
})
test("preserves task_state fields", () => {
const result = normalizePayload("task_state", {
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
});
expect(result.task_list_id).toBe("team-alpha");
test('preserves task_state fields', () => {
const result = normalizePayload('task_state', {
task_list_id: 'team-alpha',
tasks: [{ id: '1', subject: 'Task 1', status: 'pending' }],
})
expect(result.task_list_id).toBe('team-alpha')
expect(result.tasks).toEqual([
{ id: "1", subject: "Task 1", status: "pending" },
]);
});
{ id: '1', subject: 'Task 1', status: 'pending' },
])
})
test("preserves status metadata for conversation reset events", () => {
const result = normalizePayload("status", {
status: "conversation_cleared",
subtype: "status",
message: "conversation_cleared",
});
expect(result.status).toBe("conversation_cleared");
expect(result.subtype).toBe("status");
expect(result.message).toBe("conversation_cleared");
});
test('preserves status metadata for conversation reset events', () => {
const result = normalizePayload('status', {
status: 'conversation_cleared',
subtype: 'status',
message: 'conversation_cleared',
})
expect(result.status).toBe('conversation_cleared')
expect(result.subtype).toBe('status')
expect(result.message).toBe('conversation_cleared')
})
test("handles undefined payload", () => {
const result = normalizePayload("user", undefined);
expect(result.content).toBe("");
});
});
test('handles undefined payload', () => {
const result = normalizePayload('user', undefined)
expect(result.content).toBe('')
})
})
describe("publishSessionEvent", () => {
test("publishes event to session bus", () => {
const event = publishSessionEvent("s1", "user", { content: "hello" }, "outbound");
expect(event.type).toBe("user");
expect(event.direction).toBe("outbound");
expect(event.sessionId).toBe("s1");
expect(event.seqNum).toBe(1);
});
describe('publishSessionEvent', () => {
test('publishes event to session bus', () => {
const event = publishSessionEvent(
's1',
'user',
{ content: 'hello' },
'outbound',
)
expect(event.type).toBe('user')
expect(event.direction).toBe('outbound')
expect(event.sessionId).toBe('s1')
expect(event.seqNum).toBe(1)
})
test("normalizes payload before publishing", () => {
const event = publishSessionEvent("s1", "assistant", { message: { content: "reply" } }, "inbound");
const payload = event.payload as Record<string, unknown>;
expect(payload.content).toBe("reply");
});
});
});
test('normalizes payload before publishing', () => {
const event = publishSessionEvent(
's1',
'assistant',
{ message: { content: 'reply' } },
'inbound',
)
const payload = event.payload as Record<string, unknown>
expect(payload.content).toBe('reply')
})
})
})

View File

@@ -1,11 +1,11 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { describe, test, expect, beforeEach, mock } from 'bun:test'
// Mock config
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
@@ -13,170 +13,201 @@ const mockConfig = {
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
import { Hono } from "hono";
import { storeReset } from "../store";
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
import { createSSEWriter, createSSEStream } from "../transport/sse-writer";
/** Read up to N bytes from a Response stream, then cancel */
async function readPartialStream(res: Response, maxBytes = 4096): Promise<string> {
const reader = res.body?.getReader();
if (!reader) return "";
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (totalBytes < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalBytes += value.length;
// Cancel after we have some data (first keepalive + any initial events)
if (totalBytes > 0) break;
}
} finally {
reader.cancel();
}
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
describe("SSE Writer", () => {
describe("createSSEWriter", () => {
test("creates SSEWriter with send and close methods", () => {
const app = new Hono();
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null;
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => 'http://localhost:3000',
}))
app.get("/test", (c) => {
capturedWriter = createSSEWriter(c);
return c.text("ok");
});
import { Hono } from 'hono'
import { storeReset } from '../store'
import {
removeEventBus,
getAllEventBuses,
getEventBus,
} from '../transport/event-bus'
import { createSSEWriter, createSSEStream } from '../transport/sse-writer'
app.request("/test");
expect(capturedWriter).not.toBeNull();
expect(typeof capturedWriter!.send).toBe("function");
expect(typeof capturedWriter!.close).toBe("function");
});
});
/** Read up to N bytes from a Response stream, then cancel */
async function readPartialStream(
res: Response,
maxBytes = 4096,
): Promise<string> {
const reader = res.body?.getReader()
if (!reader) return ''
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (totalBytes < maxBytes) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
totalBytes += value.length
// Cancel after we have some data (first keepalive + any initial events)
if (totalBytes > 0) break
}
} finally {
reader.cancel()
}
const combined = new Uint8Array(totalBytes)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(combined)
}
describe("createSSEStream", () => {
describe('SSE Writer', () => {
describe('createSSEWriter', () => {
test('creates SSEWriter with send and close methods', () => {
const app = new Hono()
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null
app.get('/test', c => {
capturedWriter = createSSEWriter(c)
return c.text('ok')
})
app.request('/test')
expect(capturedWriter).not.toBeNull()
expect(typeof capturedWriter!.send).toBe('function')
expect(typeof capturedWriter!.close).toBe('function')
})
})
describe('createSSEStream', () => {
beforeEach(() => {
storeReset();
storeReset()
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
removeEventBus(key)
}
});
})
test("returns Response with correct SSE headers", async () => {
const app = new Hono();
test('returns Response with correct SSE headers', async () => {
const app = new Hono()
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
app.get('/stream/:sessionId', c => {
const sessionId = c.req.param('sessionId')
return createSSEStream(c, sessionId, 0)
})
const res = await app.request("/stream/s1");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
expect(res.headers.get("Cache-Control")).toBe("no-cache");
expect(res.headers.get("Connection")).toBe("keep-alive");
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
const res = await app.request('/stream/s1')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/event-stream')
expect(res.headers.get('Cache-Control')).toBe('no-cache')
expect(res.headers.get('Connection')).toBe('keep-alive')
expect(res.headers.get('X-Accel-Buffering')).toBe('no')
// Cancel the stream
res.body?.cancel();
});
res.body?.cancel()
})
test("sends initial keepalive", async () => {
const app = new Hono();
test('sends initial keepalive', async () => {
const app = new Hono()
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
app.get('/stream/:sessionId', c => {
const sessionId = c.req.param('sessionId')
return createSSEStream(c, sessionId, 0)
})
const res = await app.request("/stream/s2");
const text = await readPartialStream(res);
expect(text).toContain(": keepalive");
});
const res = await app.request('/stream/s2')
const text = await readPartialStream(res)
expect(text).toContain(': keepalive')
})
test("sends historical events when fromSeqNum > 0", async () => {
test('sends historical events when fromSeqNum > 0', async () => {
// Pre-populate event bus with events
const bus = getEventBus("s3");
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: { content: "hello" }, direction: "outbound" });
bus.publish({ id: "e2", sessionId: "s3", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
const bus = getEventBus('s3')
bus.publish({
id: 'e1',
sessionId: 's3',
type: 'user',
payload: { content: 'hello' },
direction: 'outbound',
})
bus.publish({
id: 'e2',
sessionId: 's3',
type: 'assistant',
payload: { content: 'hi' },
direction: 'inbound',
})
const app = new Hono();
const app = new Hono()
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
const fromSeq = parseInt(c.req.query("fromSeq") || "0");
return createSSEStream(c, sessionId, fromSeq);
});
app.get('/stream/:sessionId', c => {
const sessionId = c.req.param('sessionId')
const fromSeq = parseInt(c.req.query('fromSeq') || '0', 10)
return createSSEStream(c, sessionId, fromSeq)
})
const res = await app.request("/stream/s3?fromSeq=1");
const text = await readPartialStream(res);
const res = await app.request('/stream/s3?fromSeq=1')
const text = await readPartialStream(res)
// Should replay events since seq 1 (i.e., event 2)
expect(text).toContain('"seqNum":2');
expect(text).toContain("assistant");
});
expect(text).toContain('"seqNum":2')
expect(text).toContain('assistant')
})
test("no historical events when fromSeqNum is 0", async () => {
const bus = getEventBus("s5");
bus.publish({ id: "e1", sessionId: "s5", type: "user", payload: {}, direction: "outbound" });
test('no historical events when fromSeqNum is 0', async () => {
const bus = getEventBus('s5')
bus.publish({
id: 'e1',
sessionId: 's5',
type: 'user',
payload: {},
direction: 'outbound',
})
const app = new Hono();
const app = new Hono()
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
app.get('/stream/:sessionId', c => {
const sessionId = c.req.param('sessionId')
return createSSEStream(c, sessionId, 0)
})
const res = await app.request("/stream/s5");
const text = await readPartialStream(res);
const res = await app.request('/stream/s5')
const text = await readPartialStream(res)
// With fromSeqNum=0, no historical replay, just keepalive
expect(text).toContain(": keepalive");
expect(text).toContain(': keepalive')
// Should NOT contain event data (only keepalive)
expect(text).not.toContain("event: message");
});
expect(text).not.toContain('event: message')
})
test("subscribes to new events and delivers them", async () => {
const app = new Hono();
test('subscribes to new events and delivers them', async () => {
const app = new Hono()
app.get("/stream/:sessionId", (c) => {
const sessionId = c.req.param("sessionId");
return createSSEStream(c, sessionId, 0);
});
app.get('/stream/:sessionId', c => {
const sessionId = c.req.param('sessionId')
return createSSEStream(c, sessionId, 0)
})
const res = await app.request("/stream/s6");
const res = await app.request('/stream/s6')
// Read initial keepalive first
const reader = res.body!.getReader();
const { value: firstChunk } = await reader.read();
const initialText = new TextDecoder().decode(firstChunk!);
expect(initialText).toContain(": keepalive");
const reader = res.body!.getReader()
const { value: firstChunk } = await reader.read()
const initialText = new TextDecoder().decode(firstChunk!)
expect(initialText).toContain(': keepalive')
// Now publish an event
const bus = getEventBus("s6");
bus.publish({ id: "e1", sessionId: "s6", type: "user", payload: { content: "real-time" }, direction: "outbound" });
const bus = getEventBus('s6')
bus.publish({
id: 'e1',
sessionId: 's6',
type: 'user',
payload: { content: 'real-time' },
direction: 'outbound',
})
// Read the event
const { value: secondChunk } = await reader.read();
const eventText = new TextDecoder().decode(secondChunk!);
expect(eventText).toContain("event: message");
expect(eventText).toContain("real-time");
const { value: secondChunk } = await reader.read()
const eventText = new TextDecoder().decode(secondChunk!)
expect(eventText).toContain('event: message')
expect(eventText).toContain('real-time')
reader.cancel();
});
});
});
reader.cancel()
})
})
})

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { describe, test, expect, beforeEach } from 'bun:test'
import {
storeReset,
storeCreateUser,
@@ -25,372 +25,401 @@ import {
storeGetWorkItem,
storeGetPendingWorkItem,
storeUpdateWorkItem,
} from "../store";
} from '../store'
describe("store", () => {
describe('store', () => {
beforeEach(() => {
storeReset();
});
storeReset()
})
// ---------- User ----------
describe("storeCreateUser", () => {
test("creates a new user", () => {
const user = storeCreateUser("alice");
expect(user.username).toBe("alice");
expect(user.createdAt).toBeInstanceOf(Date);
});
describe('storeCreateUser', () => {
test('creates a new user', () => {
const user = storeCreateUser('alice')
expect(user.username).toBe('alice')
expect(user.createdAt).toBeInstanceOf(Date)
})
test("returns existing user on duplicate create", () => {
const first = storeCreateUser("bob");
const second = storeCreateUser("bob");
expect(first).toBe(second);
});
});
test('returns existing user on duplicate create', () => {
const first = storeCreateUser('bob')
const second = storeCreateUser('bob')
expect(first).toBe(second)
})
})
describe("storeGetUser", () => {
test("returns undefined for non-existent user", () => {
expect(storeGetUser("nobody")).toBeUndefined();
});
describe('storeGetUser', () => {
test('returns undefined for non-existent user', () => {
expect(storeGetUser('nobody')).toBeUndefined()
})
test("returns created user", () => {
storeCreateUser("charlie");
const user = storeGetUser("charlie");
expect(user?.username).toBe("charlie");
});
});
test('returns created user', () => {
storeCreateUser('charlie')
const user = storeGetUser('charlie')
expect(user?.username).toBe('charlie')
})
})
// ---------- Token ----------
describe("storeCreateToken / storeGetUserByToken", () => {
test("creates and resolves token", () => {
storeCreateUser("dave");
storeCreateToken("dave", "tk_123");
const entry = storeGetUserByToken("tk_123");
expect(entry?.username).toBe("dave");
expect(entry?.createdAt).toBeInstanceOf(Date);
});
describe('storeCreateToken / storeGetUserByToken', () => {
test('creates and resolves token', () => {
storeCreateUser('dave')
storeCreateToken('dave', 'tk_123')
const entry = storeGetUserByToken('tk_123')
expect(entry?.username).toBe('dave')
expect(entry?.createdAt).toBeInstanceOf(Date)
})
test("returns undefined for unknown token", () => {
expect(storeGetUserByToken("nonexistent")).toBeUndefined();
});
});
test('returns undefined for unknown token', () => {
expect(storeGetUserByToken('nonexistent')).toBeUndefined()
})
})
describe("storeDeleteToken", () => {
test("deletes an existing token", () => {
storeCreateUser("eve");
storeCreateToken("eve", "tk_del");
expect(storeDeleteToken("tk_del")).toBe(true);
expect(storeGetUserByToken("tk_del")).toBeUndefined();
});
describe('storeDeleteToken', () => {
test('deletes an existing token', () => {
storeCreateUser('eve')
storeCreateToken('eve', 'tk_del')
expect(storeDeleteToken('tk_del')).toBe(true)
expect(storeGetUserByToken('tk_del')).toBeUndefined()
})
test("returns false for non-existent token", () => {
expect(storeDeleteToken("nope")).toBe(false);
});
});
test('returns false for non-existent token', () => {
expect(storeDeleteToken('nope')).toBe(false)
})
})
// ---------- Environment ----------
describe("storeCreateEnvironment", () => {
test("creates environment with defaults", () => {
const env = storeCreateEnvironment({ secret: "s1" });
expect(env.id).toMatch(/^env_/);
expect(env.secret).toBe("s1");
expect(env.status).toBe("active");
expect(env.machineName).toBeNull();
expect(env.maxSessions).toBe(1);
expect(env.workerType).toBe("claude_code");
expect(env.lastPollAt).toBeInstanceOf(Date);
});
describe('storeCreateEnvironment', () => {
test('creates environment with defaults', () => {
const env = storeCreateEnvironment({ secret: 's1' })
expect(env.id).toMatch(/^env_/)
expect(env.secret).toBe('s1')
expect(env.status).toBe('active')
expect(env.machineName).toBeNull()
expect(env.maxSessions).toBe(1)
expect(env.workerType).toBe('claude_code')
expect(env.lastPollAt).toBeInstanceOf(Date)
})
test("creates environment with all options", () => {
test('creates environment with all options', () => {
const env = storeCreateEnvironment({
secret: "s2",
machineName: "mac1",
directory: "/home/user",
branch: "main",
gitRepoUrl: "https://github.com/test/repo",
secret: 's2',
machineName: 'mac1',
directory: '/home/user',
branch: 'main',
gitRepoUrl: 'https://github.com/test/repo',
maxSessions: 5,
workerType: "custom",
bridgeId: "bridge1",
username: "alice",
});
expect(env.machineName).toBe("mac1");
expect(env.directory).toBe("/home/user");
expect(env.branch).toBe("main");
expect(env.gitRepoUrl).toBe("https://github.com/test/repo");
expect(env.maxSessions).toBe(5);
expect(env.workerType).toBe("custom");
expect(env.bridgeId).toBe("bridge1");
expect(env.username).toBe("alice");
});
});
workerType: 'custom',
bridgeId: 'bridge1',
username: 'alice',
})
expect(env.machineName).toBe('mac1')
expect(env.directory).toBe('/home/user')
expect(env.branch).toBe('main')
expect(env.gitRepoUrl).toBe('https://github.com/test/repo')
expect(env.maxSessions).toBe(5)
expect(env.workerType).toBe('custom')
expect(env.bridgeId).toBe('bridge1')
expect(env.username).toBe('alice')
})
})
describe("storeGetEnvironment", () => {
test("returns undefined for non-existent env", () => {
expect(storeGetEnvironment("env_no")).toBeUndefined();
});
describe('storeGetEnvironment', () => {
test('returns undefined for non-existent env', () => {
expect(storeGetEnvironment('env_no')).toBeUndefined()
})
test("returns created environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
expect(storeGetEnvironment(env.id)).toBe(env);
});
});
test('returns created environment', () => {
const env = storeCreateEnvironment({ secret: 's' })
expect(storeGetEnvironment(env.id)).toBe(env)
})
})
describe("storeUpdateEnvironment", () => {
test("updates existing environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
const result = storeUpdateEnvironment(env.id, { status: "disconnected" });
expect(result).toBe(true);
const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected");
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(env.updatedAt.getTime());
});
describe('storeUpdateEnvironment', () => {
test('updates existing environment', () => {
const env = storeCreateEnvironment({ secret: 's' })
const result = storeUpdateEnvironment(env.id, { status: 'disconnected' })
expect(result).toBe(true)
const updated = storeGetEnvironment(env.id)
expect(updated?.status).toBe('disconnected')
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(
env.updatedAt.getTime(),
)
})
test("returns false for non-existent environment", () => {
expect(storeUpdateEnvironment("env_no", { status: "active" })).toBe(false);
});
});
test('returns false for non-existent environment', () => {
expect(storeUpdateEnvironment('env_no', { status: 'active' })).toBe(false)
})
})
describe("storeListActiveEnvironments", () => {
test("returns only active environments", () => {
const env1 = storeCreateEnvironment({ secret: "s1" });
const env2 = storeCreateEnvironment({ secret: "s2" });
storeUpdateEnvironment(env1.id, { status: "deregistered" });
const active = storeListActiveEnvironments();
expect(active).toHaveLength(1);
expect(active[0].id).toBe(env2.id);
});
});
describe('storeListActiveEnvironments', () => {
test('returns only active environments', () => {
const env1 = storeCreateEnvironment({ secret: 's1' })
const env2 = storeCreateEnvironment({ secret: 's2' })
storeUpdateEnvironment(env1.id, { status: 'deregistered' })
const active = storeListActiveEnvironments()
expect(active).toHaveLength(1)
expect(active[0].id).toBe(env2.id)
})
})
describe("storeListActiveEnvironmentsByUsername", () => {
test("filters by username", () => {
storeCreateEnvironment({ secret: "s1", username: "alice" });
storeCreateEnvironment({ secret: "s2", username: "bob" });
const aliceEnvs = storeListActiveEnvironmentsByUsername("alice");
expect(aliceEnvs).toHaveLength(1);
expect(aliceEnvs[0].username).toBe("alice");
});
});
describe('storeListActiveEnvironmentsByUsername', () => {
test('filters by username', () => {
storeCreateEnvironment({ secret: 's1', username: 'alice' })
storeCreateEnvironment({ secret: 's2', username: 'bob' })
const aliceEnvs = storeListActiveEnvironmentsByUsername('alice')
expect(aliceEnvs).toHaveLength(1)
expect(aliceEnvs[0].username).toBe('alice')
})
})
// ---------- Session ----------
describe("storeCreateSession", () => {
test("creates session with defaults", () => {
const session = storeCreateSession({});
expect(session.id).toMatch(/^session_/);
expect(session.status).toBe("idle");
expect(session.source).toBe("remote-control");
expect(session.environmentId).toBeNull();
expect(session.workerEpoch).toBe(0);
});
describe('storeCreateSession', () => {
test('creates session with defaults', () => {
const session = storeCreateSession({})
expect(session.id).toMatch(/^session_/)
expect(session.status).toBe('idle')
expect(session.source).toBe('remote-control')
expect(session.environmentId).toBeNull()
expect(session.workerEpoch).toBe(0)
})
test("creates session with options", () => {
const env = storeCreateEnvironment({ secret: "s" });
test('creates session with options', () => {
const env = storeCreateEnvironment({ secret: 's' })
const session = storeCreateSession({
environmentId: env.id,
title: "Test Session",
source: "cli",
permissionMode: "auto",
username: "alice",
});
expect(session.environmentId).toBe(env.id);
expect(session.title).toBe("Test Session");
expect(session.source).toBe("cli");
expect(session.permissionMode).toBe("auto");
expect(session.username).toBe("alice");
});
title: 'Test Session',
source: 'cli',
permissionMode: 'auto',
username: 'alice',
})
expect(session.environmentId).toBe(env.id)
expect(session.title).toBe('Test Session')
expect(session.source).toBe('cli')
expect(session.permissionMode).toBe('auto')
expect(session.username).toBe('alice')
})
test("creates session with custom idPrefix", () => {
const session = storeCreateSession({ idPrefix: "cse_" });
expect(session.id).toMatch(/^cse_/);
});
});
test('creates session with custom idPrefix', () => {
const session = storeCreateSession({ idPrefix: 'cse_' })
expect(session.id).toMatch(/^cse_/)
})
})
describe("storeGetSession", () => {
test("returns undefined for non-existent session", () => {
expect(storeGetSession("nope")).toBeUndefined();
});
});
describe('storeGetSession', () => {
test('returns undefined for non-existent session', () => {
expect(storeGetSession('nope')).toBeUndefined()
})
})
describe("storeUpdateSession", () => {
test("updates existing session", () => {
const session = storeCreateSession({});
const result = storeUpdateSession(session.id, { title: "Updated", status: "active" });
expect(result).toBe(true);
const updated = storeGetSession(session.id);
expect(updated?.title).toBe("Updated");
expect(updated?.status).toBe("active");
});
describe('storeUpdateSession', () => {
test('updates existing session', () => {
const session = storeCreateSession({})
const result = storeUpdateSession(session.id, {
title: 'Updated',
status: 'active',
})
expect(result).toBe(true)
const updated = storeGetSession(session.id)
expect(updated?.title).toBe('Updated')
expect(updated?.status).toBe('active')
})
test("returns false for non-existent session", () => {
expect(storeUpdateSession("nope", { title: "x" })).toBe(false);
});
test('returns false for non-existent session', () => {
expect(storeUpdateSession('nope', { title: 'x' })).toBe(false)
})
test("increments workerEpoch", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { workerEpoch: 1 });
expect(storeGetSession(session.id)?.workerEpoch).toBe(1);
});
});
test('increments workerEpoch', () => {
const session = storeCreateSession({})
storeUpdateSession(session.id, { workerEpoch: 1 })
expect(storeGetSession(session.id)?.workerEpoch).toBe(1)
})
})
describe("storeListSessions", () => {
test("returns all sessions", () => {
storeCreateSession({});
storeCreateSession({});
expect(storeListSessions()).toHaveLength(2);
});
});
describe('storeListSessions', () => {
test('returns all sessions', () => {
storeCreateSession({})
storeCreateSession({})
expect(storeListSessions()).toHaveLength(2)
})
})
describe("storeListSessionsByUsername", () => {
test("filters by username", () => {
storeCreateSession({ username: "alice" });
storeCreateSession({ username: "bob" });
expect(storeListSessionsByUsername("alice")).toHaveLength(1);
});
});
describe('storeListSessionsByUsername', () => {
test('filters by username', () => {
storeCreateSession({ username: 'alice' })
storeCreateSession({ username: 'bob' })
expect(storeListSessionsByUsername('alice')).toHaveLength(1)
})
})
describe("storeListSessionsByEnvironment", () => {
test("filters by environment", () => {
const env = storeCreateEnvironment({ secret: "s" });
storeCreateSession({ environmentId: env.id });
storeCreateSession({});
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1);
});
});
describe('storeListSessionsByEnvironment', () => {
test('filters by environment', () => {
const env = storeCreateEnvironment({ secret: 's' })
storeCreateSession({ environmentId: env.id })
storeCreateSession({})
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1)
})
})
describe("storeDeleteSession", () => {
test("deletes existing session", () => {
const session = storeCreateSession({});
expect(storeDeleteSession(session.id)).toBe(true);
expect(storeGetSession(session.id)).toBeUndefined();
});
describe('storeDeleteSession', () => {
test('deletes existing session', () => {
const session = storeCreateSession({})
expect(storeDeleteSession(session.id)).toBe(true)
expect(storeGetSession(session.id)).toBeUndefined()
})
test("returns false for non-existent session", () => {
expect(storeDeleteSession("nope")).toBe(false);
});
});
test('returns false for non-existent session', () => {
expect(storeDeleteSession('nope')).toBe(false)
})
})
// ---------- Session Ownership ----------
describe("storeBindSession / storeIsSessionOwner", () => {
test("binds and checks ownership", () => {
const session = storeCreateSession({});
storeBindSession(session.id, "uuid-1");
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(false);
});
describe('storeBindSession / storeIsSessionOwner', () => {
test('binds and checks ownership', () => {
const session = storeCreateSession({})
storeBindSession(session.id, 'uuid-1')
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(true)
expect(storeIsSessionOwner(session.id, 'uuid-2')).toBe(false)
})
test("unbound session has no owner", () => {
const session = storeCreateSession({});
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(false);
});
test('unbound session has no owner', () => {
const session = storeCreateSession({})
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(false)
})
test("multiple owners per session", () => {
const session = storeCreateSession({});
storeBindSession(session.id, "uuid-1");
storeBindSession(session.id, "uuid-2");
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(true);
});
});
test('multiple owners per session', () => {
const session = storeCreateSession({})
storeBindSession(session.id, 'uuid-1')
storeBindSession(session.id, 'uuid-2')
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(true)
expect(storeIsSessionOwner(session.id, 'uuid-2')).toBe(true)
})
})
describe("storeListSessionsByOwnerUuid", () => {
test("returns sessions owned by uuid", () => {
const s1 = storeCreateSession({});
const s2 = storeCreateSession({});
storeBindSession(s1.id, "uuid-1");
storeBindSession(s2.id, "uuid-1");
const owned = storeListSessionsByOwnerUuid("uuid-1");
expect(owned).toHaveLength(2);
});
describe('storeListSessionsByOwnerUuid', () => {
test('returns sessions owned by uuid', () => {
const s1 = storeCreateSession({})
const s2 = storeCreateSession({})
storeBindSession(s1.id, 'uuid-1')
storeBindSession(s2.id, 'uuid-1')
const owned = storeListSessionsByOwnerUuid('uuid-1')
expect(owned).toHaveLength(2)
})
test("returns empty for unknown uuid", () => {
expect(storeListSessionsByOwnerUuid("nope")).toHaveLength(0);
});
test('returns empty for unknown uuid', () => {
expect(storeListSessionsByOwnerUuid('nope')).toHaveLength(0)
})
test("excludes deleted sessions", () => {
const s1 = storeCreateSession({});
storeBindSession(s1.id, "uuid-1");
storeDeleteSession(s1.id);
expect(storeListSessionsByOwnerUuid("uuid-1")).toHaveLength(0);
});
});
test('excludes deleted sessions', () => {
const s1 = storeCreateSession({})
storeBindSession(s1.id, 'uuid-1')
storeDeleteSession(s1.id)
expect(storeListSessionsByOwnerUuid('uuid-1')).toHaveLength(0)
})
})
// ---------- Work Items ----------
describe("storeCreateWorkItem", () => {
test("creates work item with defaults", () => {
describe('storeCreateWorkItem', () => {
test('creates work item with defaults', () => {
const item = storeCreateWorkItem({
environmentId: "env1",
sessionId: "ses1",
secret: "sec1",
});
expect(item.id).toMatch(/^work_/);
expect(item.environmentId).toBe("env1");
expect(item.sessionId).toBe("ses1");
expect(item.state).toBe("pending");
expect(item.secret).toBe("sec1");
});
});
environmentId: 'env1',
sessionId: 'ses1',
secret: 'sec1',
})
expect(item.id).toMatch(/^work_/)
expect(item.environmentId).toBe('env1')
expect(item.sessionId).toBe('ses1')
expect(item.state).toBe('pending')
expect(item.secret).toBe('sec1')
})
})
describe("storeGetWorkItem", () => {
test("returns undefined for non-existent", () => {
expect(storeGetWorkItem("nope")).toBeUndefined();
});
describe('storeGetWorkItem', () => {
test('returns undefined for non-existent', () => {
expect(storeGetWorkItem('nope')).toBeUndefined()
})
test("returns created work item", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeGetWorkItem(item.id)).toBe(item);
});
});
test('returns created work item', () => {
const item = storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
expect(storeGetWorkItem(item.id)).toBe(item)
})
})
describe("storeGetPendingWorkItem", () => {
test("returns pending work for environment", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
const found = storeGetPendingWorkItem("env1");
expect(found?.id).toBe(item.id);
});
describe('storeGetPendingWorkItem', () => {
test('returns pending work for environment', () => {
const item = storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
const found = storeGetPendingWorkItem('env1')
expect(found?.id).toBe(item.id)
})
test("returns undefined when no pending work", () => {
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeGetPendingWorkItem("env2")).toBeUndefined();
});
test('returns undefined when no pending work', () => {
storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
expect(storeGetPendingWorkItem('env2')).toBeUndefined()
})
test("skips non-pending items", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
storeUpdateWorkItem(item.id, { state: "dispatched" });
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
});
});
test('skips non-pending items', () => {
const item = storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
storeUpdateWorkItem(item.id, { state: 'dispatched' })
expect(storeGetPendingWorkItem('env1')).toBeUndefined()
})
})
describe("storeUpdateWorkItem", () => {
test("updates existing work item", () => {
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
expect(storeUpdateWorkItem(item.id, { state: "acked" })).toBe(true);
expect(storeGetWorkItem(item.id)?.state).toBe("acked");
});
describe('storeUpdateWorkItem', () => {
test('updates existing work item', () => {
const item = storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
expect(storeUpdateWorkItem(item.id, { state: 'acked' })).toBe(true)
expect(storeGetWorkItem(item.id)?.state).toBe('acked')
})
test("returns false for non-existent", () => {
expect(storeUpdateWorkItem("nope", { state: "acked" })).toBe(false);
});
});
test('returns false for non-existent', () => {
expect(storeUpdateWorkItem('nope', { state: 'acked' })).toBe(false)
})
})
// ---------- storeReset ----------
describe("storeReset", () => {
test("clears all data", () => {
storeCreateUser("alice");
storeCreateEnvironment({ secret: "s" });
storeCreateSession({});
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
describe('storeReset', () => {
test('clears all data', () => {
storeCreateUser('alice')
storeCreateEnvironment({ secret: 's' })
storeCreateSession({})
storeCreateWorkItem({
environmentId: 'env1',
sessionId: 'ses1',
secret: 's',
})
storeReset();
storeReset()
expect(storeGetUser("alice")).toBeUndefined();
expect(storeListActiveEnvironments()).toHaveLength(0);
expect(storeListSessions()).toHaveLength(0);
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
});
});
});
expect(storeGetUser('alice')).toBeUndefined()
expect(storeListActiveEnvironments()).toHaveLength(0)
expect(storeListSessions()).toHaveLength(0)
expect(storeGetPendingWorkItem('env1')).toBeUndefined()
})
})
})

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from "bun:test";
import { describe, test, expect } from 'bun:test'
const { normalizePayload } = await import("../services/transport");
const { normalizePayload } = await import('../services/transport')
// extractContent is not exported; we test it via normalizePayload's content field
@@ -8,181 +8,190 @@ const { normalizePayload } = await import("../services/transport");
// extractContent (via normalizePayload content field)
// =============================================================================
describe("extractContent", () => {
test("returns empty string for null payload", () => {
const result = normalizePayload("assistant", null);
expect(result.content).toBe("");
});
describe('extractContent', () => {
test('returns empty string for null payload', () => {
const result = normalizePayload('assistant', null)
expect(result.content).toBe('')
})
test("returns empty string for undefined payload", () => {
const result = normalizePayload("assistant", undefined);
expect(result.content).toBe("");
});
test('returns empty string for undefined payload', () => {
const result = normalizePayload('assistant', undefined)
expect(result.content).toBe('')
})
test("returns the string for string payload", () => {
const result = normalizePayload("assistant", "hello world");
expect(result.content).toBe("hello world");
});
test('returns the string for string payload', () => {
const result = normalizePayload('assistant', 'hello world')
expect(result.content).toBe('hello world')
})
test("extracts content field from object payload", () => {
const result = normalizePayload("assistant", { content: "direct content" });
expect(result.content).toBe("direct content");
});
test('extracts content field from object payload', () => {
const result = normalizePayload('assistant', { content: 'direct content' })
expect(result.content).toBe('direct content')
})
test("extracts message.content string from object payload", () => {
const result = normalizePayload("assistant", { message: { content: "msg content" } });
expect(result.content).toBe("msg content");
});
test('extracts message.content string from object payload', () => {
const result = normalizePayload('assistant', {
message: { content: 'msg content' },
})
expect(result.content).toBe('msg content')
})
test("extracts text blocks from message.content array", () => {
test('extracts text blocks from message.content array', () => {
const payload = {
message: {
content: [
{ type: "text", text: "Hello " },
{ type: "text", text: "World" },
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
],
},
};
const result = normalizePayload("assistant", payload);
expect(result.content).toBe("Hello World");
});
}
const result = normalizePayload('assistant', payload)
expect(result.content).toBe('Hello World')
})
test("ignores non-text blocks in message.content array", () => {
test('ignores non-text blocks in message.content array', () => {
const payload = {
message: {
content: [
{ type: "image", url: "http://example.com/img.png" },
{ type: "text", text: "only this" },
{ type: 'image', url: 'http://example.com/img.png' },
{ type: 'text', text: 'only this' },
],
},
};
const result = normalizePayload("assistant", payload);
expect(result.content).toBe("only this");
});
}
const result = normalizePayload('assistant', payload)
expect(result.content).toBe('only this')
})
test("returns empty string when no extractable content", () => {
const result = normalizePayload("assistant", { foo: "bar" });
expect(result.content).toBe("");
});
test('returns empty string when no extractable content', () => {
const result = normalizePayload('assistant', { foo: 'bar' })
expect(result.content).toBe('')
})
test("prefers direct content over message.content", () => {
const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } });
expect(result.content).toBe("direct");
});
});
test('prefers direct content over message.content', () => {
const result = normalizePayload('assistant', {
content: 'direct',
message: { content: 'nested' },
})
expect(result.content).toBe('direct')
})
})
// =============================================================================
// normalizePayload — field preservation
// =============================================================================
describe("normalizePayload — field preservation", () => {
test("preserves raw payload", () => {
const payload = { content: "test", extra: true };
const result = normalizePayload("assistant", payload);
expect(result.raw).toBe(payload);
});
describe('normalizePayload — field preservation', () => {
test('preserves raw payload', () => {
const payload = { content: 'test', extra: true }
const result = normalizePayload('assistant', payload)
expect(result.raw).toBe(payload)
})
test("preserves uuid field", () => {
const result = normalizePayload("assistant", { uuid: "u-123" });
expect(result.uuid).toBe("u-123");
});
test('preserves uuid field', () => {
const result = normalizePayload('assistant', { uuid: 'u-123' })
expect(result.uuid).toBe('u-123')
})
test("does not preserve uuid when empty string", () => {
const result = normalizePayload("assistant", { uuid: "" });
expect(result.uuid).toBeUndefined();
});
test('does not preserve uuid when empty string', () => {
const result = normalizePayload('assistant', { uuid: '' })
expect(result.uuid).toBeUndefined()
})
test("preserves isSynthetic boolean", () => {
const result = normalizePayload("assistant", { isSynthetic: true });
expect(result.isSynthetic).toBe(true);
});
test('preserves isSynthetic boolean', () => {
const result = normalizePayload('assistant', { isSynthetic: true })
expect(result.isSynthetic).toBe(true)
})
test("preserves status string", () => {
const result = normalizePayload("assistant", { status: "running" });
expect(result.status).toBe("running");
});
test('preserves status string', () => {
const result = normalizePayload('assistant', { status: 'running' })
expect(result.status).toBe('running')
})
test("preserves subtype string", () => {
const result = normalizePayload("assistant", { subtype: "progress" });
expect(result.subtype).toBe("progress");
});
test('preserves subtype string', () => {
const result = normalizePayload('assistant', { subtype: 'progress' })
expect(result.subtype).toBe('progress')
})
test("preserves tool_name from tool_name field", () => {
const result = normalizePayload("tool", { tool_name: "bash" });
expect(result.tool_name).toBe("bash");
});
test('preserves tool_name from tool_name field', () => {
const result = normalizePayload('tool', { tool_name: 'bash' })
expect(result.tool_name).toBe('bash')
})
test("preserves tool_name from name field", () => {
const result = normalizePayload("tool", { name: "read" });
expect(result.tool_name).toBe("read");
});
test('preserves tool_name from name field', () => {
const result = normalizePayload('tool', { name: 'read' })
expect(result.tool_name).toBe('read')
})
test("preserves tool_input from tool_input field", () => {
const input = { command: "ls" };
const result = normalizePayload("tool", { tool_input: input });
expect(result.tool_input).toEqual(input);
});
test('preserves tool_input from tool_input field', () => {
const input = { command: 'ls' }
const result = normalizePayload('tool', { tool_input: input })
expect(result.tool_input).toEqual(input)
})
test("preserves tool_input from input field", () => {
const input = { path: "/tmp" };
const result = normalizePayload("tool", { input });
expect(result.tool_input).toEqual(input);
});
test('preserves tool_input from input field', () => {
const input = { path: '/tmp' }
const result = normalizePayload('tool', { input })
expect(result.tool_input).toEqual(input)
})
test("preserves request_id", () => {
const result = normalizePayload("permission", { request_id: "req-1" });
expect(result.request_id).toBe("req-1");
});
test('preserves request_id', () => {
const result = normalizePayload('permission', { request_id: 'req-1' })
expect(result.request_id).toBe('req-1')
})
test("preserves request object", () => {
const req = { subtype: "permission" };
const result = normalizePayload("permission", { request: req });
expect(result.request).toEqual(req);
});
test('preserves request object', () => {
const req = { subtype: 'permission' }
const result = normalizePayload('permission', { request: req })
expect(result.request).toEqual(req)
})
test("preserves approved field", () => {
const result = normalizePayload("permission", { approved: true });
expect(result.approved).toBe(true);
});
test('preserves approved field', () => {
const result = normalizePayload('permission', { approved: true })
expect(result.approved).toBe(true)
})
test("preserves updated_input", () => {
const input = { command: "rm -rf" };
const result = normalizePayload("permission", { updated_input: input });
expect(result.updated_input).toEqual(input);
});
test('preserves updated_input', () => {
const input = { command: 'rm -rf' }
const result = normalizePayload('permission', { updated_input: input })
expect(result.updated_input).toEqual(input)
})
test("preserves message field for backward compat", () => {
const msg = { role: "user", content: "hi" };
const result = normalizePayload("assistant", { message: msg });
expect(result.message).toEqual(msg);
});
});
test('preserves message field for backward compat', () => {
const msg = { role: 'user', content: 'hi' }
const result = normalizePayload('assistant', { message: msg })
expect(result.message).toEqual(msg)
})
})
// =============================================================================
// normalizePayload — task_state special handling
// =============================================================================
describe("normalizePayload — task_state type", () => {
test("preserves task_list_id (snake_case)", () => {
const result = normalizePayload("task_state", { task_list_id: "tl-1" });
expect(result.task_list_id).toBe("tl-1");
});
describe('normalizePayload — task_state type', () => {
test('preserves task_list_id (snake_case)', () => {
const result = normalizePayload('task_state', { task_list_id: 'tl-1' })
expect(result.task_list_id).toBe('tl-1')
})
test("preserves taskListId (camelCase)", () => {
const result = normalizePayload("task_state", { taskListId: "tl-2" });
expect(result.taskListId).toBe("tl-2");
});
test('preserves taskListId (camelCase)', () => {
const result = normalizePayload('task_state', { taskListId: 'tl-2' })
expect(result.taskListId).toBe('tl-2')
})
test("preserves tasks array", () => {
const tasks = [{ id: "t1", title: "Task 1" }];
const result = normalizePayload("task_state", { tasks });
expect(result.tasks).toEqual(tasks);
});
test('preserves tasks array', () => {
const tasks = [{ id: 't1', title: 'Task 1' }]
const result = normalizePayload('task_state', { tasks })
expect(result.tasks).toEqual(tasks)
})
test("does not preserve task fields for non-task_state type", () => {
const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] });
expect(result.task_list_id).toBeUndefined();
expect(result.taskListId).toBeUndefined();
expect(result.tasks).toBeUndefined();
});
});
test('does not preserve task fields for non-task_state type', () => {
const result = normalizePayload('assistant', {
task_list_id: 'tl-1',
taskListId: 'tl-2',
tasks: [],
})
expect(result.task_list_id).toBeUndefined()
expect(result.taskListId).toBeUndefined()
expect(result.tasks).toBeUndefined()
})
})

View File

@@ -1,11 +1,11 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { describe, test, expect, beforeEach, mock } from 'bun:test'
// Mock config before imports
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-api-key"],
baseUrl: "http://localhost:3000",
host: '0.0.0.0',
apiKeys: ['test-api-key'],
baseUrl: 'http://localhost:3000',
pollTimeout: 1, // Short timeout for tests
heartbeatInterval: 20,
jwtExpiresIn: 3600,
@@ -13,14 +13,20 @@ const mockConfig = {
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
}
mock.module("../config", () => ({
mock.module('../config', () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));
getBaseUrl: () => 'http://localhost:3000',
}))
import { storeReset, storeCreateEnvironment, storeCreateSession, storeGetWorkItem, storeGetPendingWorkItem } from "../store";
import {
storeReset,
storeCreateEnvironment,
storeCreateSession,
storeGetWorkItem,
storeGetPendingWorkItem,
} from '../store'
import {
createWorkItem,
pollWork,
@@ -28,132 +34,138 @@ import {
stopWork,
heartbeatWork,
reconnectWorkForEnvironment,
} from "../services/work-dispatch";
} from '../services/work-dispatch'
describe("Work Dispatch", () => {
let envId: string;
let sessionId: string;
describe('Work Dispatch', () => {
let envId: string
let sessionId: string
beforeEach(() => {
storeReset();
const env = storeCreateEnvironment({ secret: "s" });
envId = env.id;
const session = storeCreateSession({ environmentId: envId });
sessionId = session.id;
});
storeReset()
const env = storeCreateEnvironment({ secret: 's' })
envId = env.id
const session = storeCreateSession({ environmentId: envId })
sessionId = session.id
})
describe("createWorkItem", () => {
test("creates work item for active environment", async () => {
const workId = await createWorkItem(envId, sessionId);
expect(workId).toMatch(/^work_/);
const item = storeGetWorkItem(workId);
expect(item?.state).toBe("pending");
expect(item?.sessionId).toBe(sessionId);
});
describe('createWorkItem', () => {
test('creates work item for active environment', async () => {
const workId = await createWorkItem(envId, sessionId)
expect(workId).toMatch(/^work_/)
const item = storeGetWorkItem(workId)
expect(item?.state).toBe('pending')
expect(item?.sessionId).toBe(sessionId)
})
test("throws for non-existent environment", async () => {
await expect(createWorkItem("env_no", sessionId)).rejects.toThrow("not found");
});
test('throws for non-existent environment', async () => {
await expect(createWorkItem('env_no', sessionId)).rejects.toThrow(
'not found',
)
})
test("throws for inactive environment", async () => {
const inactiveEnv = storeCreateEnvironment({ secret: "s2" });
test('throws for inactive environment', async () => {
const inactiveEnv = storeCreateEnvironment({ secret: 's2' })
// Manually set status to deregistered
const { storeUpdateEnvironment } = await import("../store");
storeUpdateEnvironment(inactiveEnv.id, { status: "deregistered" });
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow("not active");
});
const { storeUpdateEnvironment } = await import('../store')
storeUpdateEnvironment(inactiveEnv.id, { status: 'deregistered' })
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow(
'not active',
)
})
test("encodes work secret as base64 JSON", async () => {
const workId = await createWorkItem(envId, sessionId);
const item = storeGetWorkItem(workId);
const decoded = JSON.parse(Buffer.from(item!.secret, "base64url").toString());
expect(decoded.version).toBe(1);
expect(decoded.session_ingress_token).toBe("test-api-key");
expect(decoded.api_base_url).toBe("http://localhost:3000");
});
});
test('encodes work secret as base64 JSON', async () => {
const workId = await createWorkItem(envId, sessionId)
const item = storeGetWorkItem(workId)
const decoded = JSON.parse(
Buffer.from(item!.secret, 'base64url').toString(),
)
expect(decoded.version).toBe(1)
expect(decoded.session_ingress_token).toBe('test-api-key')
expect(decoded.api_base_url).toBe('http://localhost:3000')
})
})
describe("pollWork", () => {
test("returns null when no work available (timeout)", async () => {
const result = await pollWork(envId, 0.1);
expect(result).toBeNull();
});
describe('pollWork', () => {
test('returns null when no work available (timeout)', async () => {
const result = await pollWork(envId, 0.1)
expect(result).toBeNull()
})
test("returns pending work and marks as dispatched", async () => {
const workId = await createWorkItem(envId, sessionId);
const result = await pollWork(envId, 1);
expect(result).not.toBeNull();
expect(result!.id).toBe(workId);
expect(result!.state).toBe("dispatched");
expect(result!.data.type).toBe("session");
expect(result!.data.id).toBe(sessionId);
test('returns pending work and marks as dispatched', async () => {
const workId = await createWorkItem(envId, sessionId)
const result = await pollWork(envId, 1)
expect(result).not.toBeNull()
expect(result!.id).toBe(workId)
expect(result!.state).toBe('dispatched')
expect(result!.data.type).toBe('session')
expect(result!.data.id).toBe(sessionId)
// Work should no longer be pending
expect(storeGetPendingWorkItem(envId)).toBeUndefined();
});
expect(storeGetPendingWorkItem(envId)).toBeUndefined()
})
test("does not return work for different environment", async () => {
const env2 = storeCreateEnvironment({ secret: "s2" });
await createWorkItem(envId, sessionId);
const result = await pollWork(env2.id, 0.1);
expect(result).toBeNull();
});
});
test('does not return work for different environment', async () => {
const env2 = storeCreateEnvironment({ secret: 's2' })
await createWorkItem(envId, sessionId)
const result = await pollWork(env2.id, 0.1)
expect(result).toBeNull()
})
})
describe("ackWork", () => {
test("marks work as acked", async () => {
const workId = await createWorkItem(envId, sessionId);
ackWork(workId);
expect(storeGetWorkItem(workId)?.state).toBe("acked");
});
});
describe('ackWork', () => {
test('marks work as acked', async () => {
const workId = await createWorkItem(envId, sessionId)
ackWork(workId)
expect(storeGetWorkItem(workId)?.state).toBe('acked')
})
})
describe("stopWork", () => {
test("marks work as completed", async () => {
const workId = await createWorkItem(envId, sessionId);
stopWork(workId);
expect(storeGetWorkItem(workId)?.state).toBe("completed");
});
});
describe('stopWork', () => {
test('marks work as completed', async () => {
const workId = await createWorkItem(envId, sessionId)
stopWork(workId)
expect(storeGetWorkItem(workId)?.state).toBe('completed')
})
})
describe("heartbeatWork", () => {
test("extends lease and returns heartbeat info", async () => {
const workId = await createWorkItem(envId, sessionId);
const result = heartbeatWork(workId);
expect(result.lease_extended).toBe(true);
expect(result.ttl_seconds).toBe(40); // heartbeatInterval * 2
expect(result.last_heartbeat).toBeTruthy();
});
describe('heartbeatWork', () => {
test('extends lease and returns heartbeat info', async () => {
const workId = await createWorkItem(envId, sessionId)
const result = heartbeatWork(workId)
expect(result.lease_extended).toBe(true)
expect(result.ttl_seconds).toBe(40) // heartbeatInterval * 2
expect(result.last_heartbeat).toBeTruthy()
})
test("returns default state for non-existent work", async () => {
const result = heartbeatWork("work_no");
expect(result.state).toBe("acked");
});
});
test('returns default state for non-existent work', async () => {
const result = heartbeatWork('work_no')
expect(result.state).toBe('acked')
})
})
describe("reconnectWorkForEnvironment", () => {
test("creates work items for idle sessions in environment", async () => {
describe('reconnectWorkForEnvironment', () => {
test('creates work items for idle sessions in environment', async () => {
// Create another idle session
storeCreateSession({ environmentId: envId });
const workIds = await reconnectWorkForEnvironment(envId);
expect(workIds).toHaveLength(2);
storeCreateSession({ environmentId: envId })
const workIds = await reconnectWorkForEnvironment(envId)
expect(workIds).toHaveLength(2)
for (const id of workIds) {
expect(storeGetWorkItem(id)?.state).toBe("pending");
expect(storeGetWorkItem(id)?.state).toBe('pending')
}
});
})
test("skips non-idle sessions", async () => {
const activeSession = storeCreateSession({ environmentId: envId });
const { storeUpdateSession } = await import("../store");
storeUpdateSession(activeSession.id, { status: "active" });
const workIds = await reconnectWorkForEnvironment(envId);
test('skips non-idle sessions', async () => {
const activeSession = storeCreateSession({ environmentId: envId })
const { storeUpdateSession } = await import('../store')
storeUpdateSession(activeSession.id, { status: 'active' })
const workIds = await reconnectWorkForEnvironment(envId)
// Only the original idle session should get work
expect(workIds).toHaveLength(1);
});
expect(workIds).toHaveLength(1)
})
test("returns empty for environment with no sessions", async () => {
const emptyEnv = storeCreateEnvironment({ secret: "s_empty" });
const workIds = await reconnectWorkForEnvironment(emptyEnv.id);
expect(workIds).toHaveLength(0);
});
});
});
test('returns empty for environment with no sessions', async () => {
const emptyEnv = storeCreateEnvironment({ secret: 's_empty' })
const workIds = await reconnectWorkForEnvironment(emptyEnv.id)
expect(workIds).toHaveLength(0)
})
})
})

View File

@@ -1,17 +1,17 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { config } from "../config";
import { createHash, timingSafeEqual } from 'node:crypto'
import { config } from '../config'
function sha256(value: string): Buffer {
return createHash("sha256").update(value).digest();
return createHash('sha256').update(value).digest()
}
/** Validate a raw API key token string */
export function validateApiKey(token: string | undefined): boolean {
if (!token) return false;
const tokenHash = sha256(token);
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
if (!token) return false
const tokenHash = sha256(token)
return config.apiKeys.some(key => timingSafeEqual(tokenHash, sha256(key)))
}
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
return createHash('sha256').update(key).digest('hex')
}

View File

@@ -1,34 +1,34 @@
import { config } from "../config";
import { config } from '../config'
function originFromUrl(rawUrl: string): string | undefined {
try {
return new URL(rawUrl).origin;
return new URL(rawUrl).origin
} catch {
return undefined;
return undefined
}
}
export function getAllowedWebCorsOrigins(): string[] {
const origins = new Set<string>(config.webCorsOrigins);
const origins = new Set<string>(config.webCorsOrigins)
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined;
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined
if (baseOrigin) {
origins.add(baseOrigin);
origins.add(baseOrigin)
}
origins.add(`http://localhost:${config.port}`);
origins.add(`http://127.0.0.1:${config.port}`);
origins.add(`http://localhost:${config.port}`)
origins.add(`http://127.0.0.1:${config.port}`)
return [...origins];
return [...origins]
}
export function resolveWebCorsOrigin(origin: string): string | undefined {
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined;
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined
}
export const webCorsOptions = {
origin: resolveWebCorsOrigin,
allowHeaders: ["Authorization", "Content-Type", "X-UUID"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ['Authorization', 'Content-Type', 'X-UUID'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
credentials: false,
};
}

View File

@@ -1,4 +1,4 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from 'node:crypto'
/**
* Lightweight JWT implementation using HMAC-SHA256.
@@ -9,29 +9,29 @@ import { createHmac, timingSafeEqual } from "node:crypto";
*/
interface JwtPayload {
session_id: string;
role: string;
iat: number;
exp: number;
session_id: string
role: string
iat: number
exp: number
}
function base64url(data: string | Buffer): string {
return Buffer.from(data as unknown as ArrayLike<number>)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function base64urlDecode(str: string): string {
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(padded, "base64").toString("utf-8");
const padded = str.replace(/-/g, '+').replace(/_/g, '/')
return Buffer.from(padded, 'base64').toString('utf-8')
}
function getSigningKey(): string {
const key = process.env.RCS_API_KEYS?.split(",").filter(Boolean)[0];
if (!key) throw new Error("No API key configured for JWT signing");
return key;
const key = process.env.RCS_API_KEYS?.split(',').filter(Boolean)[0]
if (!key) throw new Error('No API key configured for JWT signing')
return key
}
/** Generate a JWT for worker authentication. */
@@ -39,23 +39,23 @@ export function generateWorkerJwt(
sessionId: string,
expiresInSeconds: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const header = { alg: 'HS256', typ: 'JWT' }
const payload: JwtPayload = {
session_id: sessionId,
role: "worker",
role: 'worker',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
};
}
const headerB64 = base64url(JSON.stringify(header));
const payloadB64 = base64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
const headerB64 = base64url(JSON.stringify(header))
const payloadB64 = base64url(JSON.stringify(payload))
const signingInput = `${headerB64}.${payloadB64}`
const signature = createHmac("sha256", getSigningKey())
const signature = createHmac('sha256', getSigningKey())
.update(signingInput)
.digest();
.digest()
return `${signingInput}.${base64url(signature)}`;
return `${signingInput}.${base64url(signature)}`
}
/**
@@ -63,30 +63,30 @@ export function generateWorkerJwt(
* Uses timing-safe comparison to prevent timing attacks.
*/
export function verifyWorkerJwt(token: string): JwtPayload | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const parts = token.split('.')
if (parts.length !== 3) return null
const [headerB64, payloadB64, signatureB64] = parts;
const [headerB64, payloadB64, signatureB64] = parts
// Verify signature
const signingInput = `${headerB64}.${payloadB64}`;
const expectedSig = createHmac("sha256", getSigningKey())
const signingInput = `${headerB64}.${payloadB64}`
const expectedSig = createHmac('sha256', getSigningKey())
.update(signingInput)
.digest();
.digest()
const actualSig = Buffer.from(
signatureB64.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
);
signatureB64.replace(/-/g, '+').replace(/_/g, '/'),
'base64',
)
if (expectedSig.length !== actualSig.length) return null;
if (!timingSafeEqual(expectedSig, actualSig)) return null;
if (expectedSig.length !== actualSig.length) return null
if (!timingSafeEqual(expectedSig, actualSig)) return null
// Decode payload
try {
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64));
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64))
if (payload.exp < Math.floor(Date.now() / 1000)) return null
return payload
} catch {
return null;
return null
}
}

View File

@@ -1,51 +1,58 @@
import type { Context, Next } from "hono";
import { validateApiKey } from "./api-key";
import { verifyWorkerJwt } from "./jwt";
import { resolveToken } from "./token";
import type { Context, Next } from 'hono'
import { validateApiKey } from './api-key'
import { verifyWorkerJwt } from './jwt'
import { resolveToken } from './token'
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
const WS_AUTH_PROTOCOL_PREFIX = 'rcs.auth.'
/** Encode a bearer token for WebSocket clients that cannot send auth headers. */
export function encodeWebSocketAuthProtocol(token: string): string {
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, 'utf8').toString('base64url')}`
}
function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
function decodeWebSocketAuthProtocol(
protocolHeader: string | undefined,
): string | undefined {
if (!protocolHeader) {
return undefined;
return undefined
}
for (const protocol of protocolHeader.split(",")) {
const trimmed = protocol.trim();
for (const protocol of protocolHeader.split(',')) {
const trimmed = protocol.trim()
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
continue;
continue
}
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length)
if (!encoded) {
return undefined;
return undefined
}
try {
const token = Buffer.from(encoded, "base64url").toString("utf8");
return token.length > 0 ? token : undefined;
const token = Buffer.from(encoded, 'base64url').toString('utf8')
return token.length > 0 ? token : undefined
} catch {
return undefined;
return undefined
}
}
return undefined;
return undefined
}
/** Extract a Bearer token from the Authorization header only. */
export function extractBearerToken(c: Context): string | undefined {
const authHeader = c.req.header("Authorization");
return authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
const authHeader = c.req.header('Authorization')
return authHeader?.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: undefined
}
/** Extract auth for WebSocket upgrades without putting secrets in query strings. */
export function extractWebSocketAuthToken(c: Context): string | undefined {
return extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header("Sec-WebSocket-Protocol"));
return (
extractBearerToken(c) ??
decodeWebSocketAuthProtocol(c.req.header('Sec-WebSocket-Protocol'))
)
}
/**
@@ -55,28 +62,33 @@ export function extractWebSocketAuthToken(c: Context): string | undefined {
* 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → username injected
*/
export async function apiKeyAuth(c: Context, next: Next) {
const token = extractBearerToken(c);
const token = extractBearerToken(c)
// Try token authentication (Web UI)
const tokenUsername = resolveToken(token);
const tokenUsername = resolveToken(token)
if (tokenUsername) {
c.set("username", tokenUsername);
await next();
return;
c.set('username', tokenUsername)
await next()
return
}
// Try API Key authentication (CLI bridge)
if (validateApiKey(token)) {
// Extract username from X-Username header or ?username= query param
const username = c.req.header("X-Username") || c.req.query("username");
const username = c.req.header('X-Username') || c.req.query('username')
if (username) {
c.set("username", username);
c.set('username', username)
}
await next();
return;
await next()
return
}
return c.json({ error: { type: "unauthorized", message: "Invalid or missing auth token" } }, 401);
return c.json(
{
error: { type: 'unauthorized', message: 'Invalid or missing auth token' },
},
401,
)
}
/**
@@ -87,43 +99,57 @@ export async function apiKeyAuth(c: Context, next: Next) {
* downstream handlers to inspect session_id if needed.
*/
export async function sessionIngressAuth(c: Context, next: Next) {
const token = extractWebSocketAuthToken(c);
const token = extractWebSocketAuthToken(c)
if (!token) {
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Missing auth token' } },
401,
)
}
// Try API key first (backward compatible)
if (validateApiKey(token)) {
await next();
return;
await next()
return
}
// Try JWT verification — validate session_id matches route param
const payload = verifyWorkerJwt(token);
const payload = verifyWorkerJwt(token)
if (payload) {
const routeSessionId = c.req.param("id") || c.req.param("sessionId");
const routeSessionId = c.req.param('id') || c.req.param('sessionId')
if (routeSessionId && payload.session_id !== routeSessionId) {
return c.json({ error: { type: "forbidden", message: "JWT session_id does not match target session" } }, 403);
return c.json(
{
error: {
type: 'forbidden',
message: 'JWT session_id does not match target session',
},
},
403,
)
}
c.set("jwtPayload", payload);
await next();
return;
c.set('jwtPayload', payload)
await next()
return
}
return c.json({ error: { type: "unauthorized", message: "Invalid API key or JWT" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Invalid API key or JWT' } },
401,
)
}
/** Accept CLI headers but don't validate them */
export async function acceptCliHeaders(c: Context, next: Next) {
await next();
await next()
}
/**
* Extract UUID from request — query param ?uuid= or header X-UUID
*/
export function getUuidFromRequest(c: Context): string | undefined {
return c.req.query("uuid") || c.req.header("X-UUID");
return c.req.query('uuid') || c.req.header('X-UUID')
}
/**
@@ -132,20 +158,23 @@ export function getUuidFromRequest(c: Context): string | undefined {
*/
export async function uuidAuth(c: Context, next: Next) {
// Try API key auth via Authorization header
const bearer = extractBearerToken(c);
const bearer = extractBearerToken(c)
if (bearer && validateApiKey(bearer)) {
// Valid API key — generate a stable UUID from the key for downstream use
const uuid = getUuidFromRequest(c);
c.set("uuid", uuid || bearer);
await next();
return;
const uuid = getUuidFromRequest(c)
c.set('uuid', uuid || bearer)
await next()
return
}
// Fall back to UUID auth
const uuid = getUuidFromRequest(c);
const uuid = getUuidFromRequest(c)
if (!uuid) {
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Missing UUID' } },
401,
)
}
c.set("uuid", uuid);
await next();
c.set('uuid', uuid)
await next()
}

View File

@@ -1,24 +1,27 @@
import { storeCreateToken, storeGetUserByToken } from "../store";
import { storeCreateToken, storeGetUserByToken } from '../store'
let tokenCounter = 0;
let tokenCounter = 0
/** Generate a random session token and associate it with a user */
export function issueToken(username: string): { token: string; expires_in: number } {
export function issueToken(username: string): {
token: string
expires_in: number
} {
// Use crypto.getRandomValues for uniqueness
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const token = `rct_${tokenCounter++}_${hex}`;
storeCreateToken(username, token);
return { token, expires_in: 86400 };
.map(b => b.toString(16).padStart(2, '0'))
.join('')
const token = `rct_${tokenCounter++}_${hex}`
storeCreateToken(username, token)
return { token, expires_in: 86400 }
}
/** Resolve a token to a username. Returns null if invalid. */
export function resolveToken(token: string | undefined): string | null {
if (!token) return null;
const entry = storeGetUserByToken(token);
if (!entry) return null;
return entry.username;
if (!token) return null
const entry = storeGetUserByToken(token)
if (!entry) return null
return entry.username
}

View File

@@ -1,27 +1,30 @@
export const config = {
version: process.env.RCS_VERSION || "0.1.0",
port: parseInt(process.env.RCS_PORT || "3000"),
host: process.env.RCS_HOST || "0.0.0.0",
apiKeys: (process.env.RCS_API_KEYS || "").split(",").filter(Boolean),
baseUrl: process.env.RCS_BASE_URL || "",
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || "8"),
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
version: process.env.RCS_VERSION || '0.1.0',
port: parseInt(process.env.RCS_PORT || '3000', 10),
host: process.env.RCS_HOST || '0.0.0.0',
apiKeys: (process.env.RCS_API_KEYS || '').split(',').filter(Boolean),
baseUrl: process.env.RCS_BASE_URL || '',
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || '8', 10),
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || '20', 10),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || '3600', 10),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || '300', 10),
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean),
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
* this many seconds of no received data. Must be shorter than any reverse
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || "30"),
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || '30', 10),
/** Server→client keep_alive data-frame interval (seconds). Keeps reverse
* proxies from closing idle connections. Default 20s. */
wsKeepaliveInterval: parseInt(process.env.RCS_WS_KEEPALIVE_INTERVAL || "20"),
} as const;
wsKeepaliveInterval: parseInt(
process.env.RCS_WS_KEEPALIVE_INTERVAL || '20',
10,
),
} as const
export function getBaseUrl(): string {
const url = config.baseUrl || `http://localhost:${config.port}`;
return url.replace(/\/+$/, "");
const url = config.baseUrl || `http://localhost:${config.port}`
return url.replace(/\/+$/, '')
}

View File

@@ -1,110 +1,119 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { serveStatic } from "hono/bun";
import { config } from "./config";
import { closeAllConnections } from "./transport/ws-handler";
import { closeAllAcpConnections } from "./transport/acp-ws-handler";
import { closeAllRelayConnections } from "./transport/acp-relay-handler";
import { startDisconnectMonitor } from "./services/disconnect-monitor";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import acpRoutes from "./routes/acp";
import { webCorsOptions } from "./auth/cors";
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { serveStatic } from 'hono/bun'
import { config } from './config'
import { closeAllConnections } from './transport/ws-handler'
import { closeAllAcpConnections } from './transport/acp-ws-handler'
import { closeAllRelayConnections } from './transport/acp-relay-handler'
import { startDisconnectMonitor } from './services/disconnect-monitor'
import { dirname, resolve } from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import acpRoutes from './routes/acp'
import { webCorsOptions } from './auth/cors'
// Routes
import v1Environments from "./routes/v1/environments";
import v1EnvironmentsWork from "./routes/v1/environments.work";
import v1Sessions from "./routes/v1/sessions";
import v1SessionIngress from "./routes/v1/session-ingress";
import { websocket } from "./transport/ws-shared";
import v2CodeSessions from "./routes/v2/code-sessions";
import v2Worker from "./routes/v2/worker";
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
import v2WorkerEvents from "./routes/v2/worker-events";
import webAuth from "./routes/web/auth";
import webSessions from "./routes/web/sessions";
import webControl from "./routes/web/control";
import webEnvironments from "./routes/web/environments";
import v1Environments from './routes/v1/environments'
import v1EnvironmentsWork from './routes/v1/environments.work'
import v1Sessions from './routes/v1/sessions'
import v1SessionIngress from './routes/v1/session-ingress'
import { websocket } from './transport/ws-shared'
import v2CodeSessions from './routes/v2/code-sessions'
import v2Worker from './routes/v2/worker'
import v2WorkerEventsStream from './routes/v2/worker-events-stream'
import v2WorkerEvents from './routes/v2/worker-events'
import webAuth from './routes/web/auth'
import webSessions from './routes/web/sessions'
import webControl from './routes/web/control'
import webEnvironments from './routes/web/environments'
console.log("[RCS] In-memory store ready (no SQLite)");
console.log('[RCS] In-memory store ready (no SQLite)')
const app = new Hono();
const app = new Hono()
// Middleware
app.use("*", logger());
app.use("*", async (c, next) => {
app.use('*', logger())
app.use('*', async (c, next) => {
// Normalize double slashes in path (e.g. //v1/environments/bridge → /v1/environments/bridge)
const path = new URL(c.req.url).pathname;
if (path.includes("//")) {
const normalized = path.replace(/\/+/g, "/");
const url = new URL(c.req.url);
url.pathname = normalized;
return app.fetch(new Request(url.toString(), c.req.raw));
const path = new URL(c.req.url).pathname
if (path.includes('//')) {
const normalized = path.replace(/\/+/g, '/')
const url = new URL(c.req.url)
url.pathname = normalized
return app.fetch(new Request(url.toString(), c.req.raw))
}
await next();
});
app.use("/web/*", cors(webCorsOptions));
await next()
})
app.use('/web/*', cors(webCorsOptions))
// Health check
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
app.get('/health', c => c.json({ status: 'ok', version: config.version }))
// Static files — serve built web UI under /code path
// Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback)
const __dirname = dirname(fileURLToPath(import.meta.url));
const distDir = resolve(__dirname, "../web/dist");
const webDir = existsSync(resolve(distDir, "index.html")) ? distDir : resolve(__dirname, "../web");
const __dirname = dirname(fileURLToPath(import.meta.url))
const distDir = resolve(__dirname, '../web/dist')
const webDir = existsSync(resolve(distDir, 'index.html'))
? distDir
: resolve(__dirname, '../web')
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
const stripCodePrefix = (p: string) => p.replace(/^\/code/, '')
// Serve all static files under /code/* from web/ directory
app.use("/code/*", serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }));
app.use(
'/code/*',
serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }),
)
// /code, /code/, and /code/:sessionId — SPA fallback
app.get("/code", serveStatic({ root: webDir, path: "index.html" }));
app.get("/code/", serveStatic({ root: webDir, path: "index.html" }));
app.get("/code/:sessionId", serveStatic({ root: webDir, path: "index.html" }));
app.get('/code', serveStatic({ root: webDir, path: 'index.html' }))
app.get('/code/', serveStatic({ root: webDir, path: 'index.html' }))
app.get('/code/:sessionId', serveStatic({ root: webDir, path: 'index.html' }))
// v1 Environment routes
app.route("/v1/environments", v1Environments);
app.route("/v1/environments", v1EnvironmentsWork);
app.route('/v1/environments', v1Environments)
app.route('/v1/environments', v1EnvironmentsWork)
// v1 Session routes
app.route("/v1/sessions", v1Sessions);
app.route('/v1/sessions', v1Sessions)
// Session Ingress (WebSocket) — mounted at both /v1 and /v2 so the bridge
// client's buildSdkUrl works with or without an Envoy proxy rewriting /v1→/v2.
app.route("/v1/session_ingress", v1SessionIngress);
app.route("/v2/session_ingress", v1SessionIngress);
app.route('/v1/session_ingress', v1SessionIngress)
app.route('/v2/session_ingress', v1SessionIngress)
// v2 Code Sessions routes
app.route("/v1/code/sessions", v2CodeSessions);
app.route("/v1/code/sessions", v2Worker);
app.route("/v1/code/sessions", v2WorkerEventsStream);
app.route("/v1/code/sessions", v2WorkerEvents);
app.route('/v1/code/sessions', v2CodeSessions)
app.route('/v1/code/sessions', v2Worker)
app.route('/v1/code/sessions', v2WorkerEventsStream)
app.route('/v1/code/sessions', v2WorkerEvents)
// Web control panel routes
app.route("/web", webAuth);
app.route("/web", webSessions);
app.route("/web", webControl);
app.route("/web", webEnvironments);
app.route('/web', webAuth)
app.route('/web', webSessions)
app.route('/web', webControl)
app.route('/web', webEnvironments)
// ACP protocol routes
console.log("[RCS] ACP support enabled");
app.route("/acp", acpRoutes);
console.log('[RCS] ACP support enabled')
app.route('/acp', acpRoutes)
const port = config.port;
const host = config.host;
const port = config.port
const host = config.host
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
console.log("[RCS] API key configuration loaded");
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
console.log(`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`);
console.log(`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`);
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`)
console.log('[RCS] API key configuration loaded')
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`)
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`)
console.log(
`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`,
)
console.log(
`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`,
)
// Start disconnect monitor
startDisconnectMonitor();
startDisconnectMonitor()
export default {
port,
@@ -115,16 +124,16 @@ export default {
idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence
},
idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds)
};
}
// Graceful shutdown
async function gracefulShutdown(signal: string) {
console.log(`\n[RCS] Received ${signal}, shutting down...`);
closeAllConnections();
closeAllAcpConnections();
closeAllRelayConnections();
process.exit(0);
console.log(`\n[RCS] Received ${signal}, shutting down...`)
closeAllConnections()
closeAllAcpConnections()
closeAllRelayConnections()
process.exit(0)
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))

View File

@@ -1,10 +1,12 @@
/** Thin logging wrapper — silent in test environment, uses console in production. */
const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST);
const isTest =
process.env.NODE_ENV === 'test' ||
(typeof Bun !== 'undefined' && !!Bun.env.BUN_TEST)
export function log(...args: unknown[]): void {
if (!isTest) console.log(...args);
if (!isTest) console.log(...args)
}
export function error(...args: unknown[]): void {
if (!isTest) console.error(...args);
if (!isTest) console.error(...args)
}

View File

@@ -1,224 +1,232 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket } from "../../transport/ws-shared";
import { Hono } from 'hono'
import { randomUUID } from 'node:crypto'
import type { Context } from 'hono'
import type { WSContext, WSMessageReceive } from 'hono/ws'
import { upgradeWebSocket } from '../../transport/ws-shared'
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
} from '../../transport/ws-payload'
import {
extractBearerToken,
extractWebSocketAuthToken,
} from "../../auth/middleware";
import { validateApiKey } from "../../auth/api-key";
} from '../../auth/middleware'
import { validateApiKey } from '../../auth/api-key'
import {
handleAcpWsOpen,
handleAcpWsMessage,
handleAcpWsClose,
} from "../../transport/acp-ws-handler";
} from '../../transport/acp-ws-handler'
import {
handleRelayOpen,
handleRelayMessage,
handleRelayClose,
} from "../../transport/acp-relay-handler";
} from '../../transport/acp-relay-handler'
import {
storeListAcpAgents,
storeListAcpAgentsByChannelGroup,
storeGetEnvironment,
} from "../../store";
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
import { log, error as logError } from "../../logger";
} from '../../store'
import { createAcpSSEStream } from '../../transport/acp-sse-writer'
import { log, error as logError } from '../../logger'
const app = new Hono();
const app = new Hono()
type WsMessageEvent = {
data: WSMessageReceive;
};
data: WSMessageReceive
}
type WsCloseEvent = {
code?: number;
reason?: string;
};
code?: number
reason?: string
}
/** Response shape for an ACP agent */
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
if (!env) return null;
if (!env) return null
return {
id: env.id,
agent_name: env.machineName,
channel_group_id: env.bridgeId,
status: env.status === "active" ? "online" : "offline",
status: env.status === 'active' ? 'online' : 'offline',
max_sessions: env.maxSessions,
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
created_at: env.createdAt.getTime() / 1000,
};
}
}
function hasAcpReadAuth(c: Context): boolean {
const token = extractBearerToken(c);
return !!token && validateApiKey(token);
const token = extractBearerToken(c)
return !!token && validateApiKey(token)
}
export function hasAcpRelayAuth(c: Context): boolean {
const token = extractWebSocketAuthToken(c);
return !!token && validateApiKey(token);
const token = extractWebSocketAuthToken(c)
return !!token && validateApiKey(token)
}
function acpReadUnauthorized(c: Context) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Missing auth' } },
401,
)
}
/** GET /acp/agents — List all registered ACP agents (API key auth) */
app.get("/agents", async (c) => {
app.get('/agents', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const agents = storeListAcpAgents();
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
});
const agents = storeListAcpAgents()
return c.json(agents.map(a => toAcpAgentResponse(a)).filter(Boolean))
})
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
app.get("/channel-groups", async (c) => {
app.get('/channel-groups', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const agents = storeListAcpAgents();
const groupMap = new Map<string, typeof agents>();
const agents = storeListAcpAgents()
const groupMap = new Map<string, typeof agents>()
for (const agent of agents) {
const groupId = agent.bridgeId || "default";
const groupId = agent.bridgeId || 'default'
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
groupMap.set(groupId, [])
}
groupMap.get(groupId)!.push(agent);
groupMap.get(groupId)!.push(agent)
}
const groups = [...groupMap.entries()].map(([id, members]) => ({
channel_group_id: id,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
}));
return c.json(groups);
});
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
}))
return c.json(groups)
})
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
app.get("/channel-groups/:id", async (c) => {
app.get('/channel-groups/:id', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const groupId = c.req.param("id")!;
const members = storeListAcpAgentsByChannelGroup(groupId);
const groupId = c.req.param('id')!
const members = storeListAcpAgentsByChannelGroup(groupId)
if (members.length === 0) {
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Channel group not found' } },
404,
)
}
return c.json({
channel_group_id: groupId,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
});
});
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
})
})
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
app.get("/channel-groups/:id/events", async (c) => {
app.get('/channel-groups/:id/events', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const groupId = c.req.param("id")!;
const groupId = c.req.param('id')!
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
const lastEventId = c.req.header('Last-Event-ID')
const fromSeq = c.req.query('from_sequence_num')
const fromSeqNum = fromSeq
? parseInt(fromSeq, 10)
: lastEventId
? parseInt(lastEventId, 10)
: 0
return createAcpSSEStream(c, groupId, fromSeqNum);
});
return createAcpSSEStream(c, groupId, fromSeqNum)
})
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
app.get(
"/ws",
upgradeWebSocket(async (c) => {
const token = extractWebSocketAuthToken(c);
'/ws',
upgradeWebSocket(async c => {
const token = extractWebSocketAuthToken(c)
if (!token || !validateApiKey(token)) {
log("[ACP-WS] Upgrade rejected: unauthorized");
log('[ACP-WS] Upgrade rejected: unauthorized')
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
// Generate unique wsId for this connection
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
const wsId = `acp_ws_${randomUUID().replace(/-/g, '')}`
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`)
return {
onOpen(_evt: Event, ws: WSContext) {
handleAcpWsOpen(ws, wsId);
handleAcpWsOpen(ws, wsId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-WS]",
`wsId=${wsId}`,
evt.data,
data => handleAcpWsMessage(ws, wsId, data),
);
handleAcpWsPayload(ws, '[ACP-WS]', `wsId=${wsId}`, evt.data, data =>
handleAcpWsMessage(ws, wsId, data),
)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
handleAcpWsClose(ws, wsId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
handleAcpWsClose(ws, wsId, 1006, "websocket error");
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt)
handleAcpWsClose(ws, wsId, 1006, 'websocket error')
},
};
}
}),
);
)
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
app.get(
"/relay/:agentId",
upgradeWebSocket(async (c) => {
'/relay/:agentId',
upgradeWebSocket(async c => {
if (!hasAcpRelayAuth(c)) {
log("[ACP-Relay] Upgrade rejected: unauthorized");
log('[ACP-Relay] Upgrade rejected: unauthorized')
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
const agentId = c.req.param("agentId")!;
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
const agentId = c.req.param('agentId')!
const relayWsId = `relay_${randomUUID().replace(/-/g, '')}`
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
log(
`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`,
)
return {
onOpen(_evt: Event, ws: WSContext) {
handleRelayOpen(ws, relayWsId, agentId);
handleRelayOpen(ws, relayWsId, agentId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-Relay]",
'[ACP-Relay]',
`relayWsId=${relayWsId}`,
evt.data,
data => handleRelayMessage(ws, relayWsId, data),
);
)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
handleRelayClose(ws, relayWsId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
handleRelayClose(ws, relayWsId, 1006, "websocket error");
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt)
handleRelayClose(ws, relayWsId, 1006, 'websocket error')
},
};
}
}),
);
)
export const decodeAcpWsMessageData = decodeWsPayload;
export const decodeAcpWsMessageData = decodeWsPayload
export function handleAcpWsPayload(
ws: WSContext,
@@ -227,7 +235,7 @@ export function handleAcpWsPayload(
payload: unknown,
handleMessage: (data: string) => void,
): boolean {
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage)
}
export default app;
export default app

View File

@@ -1,39 +1,45 @@
import { Hono } from "hono";
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { storeBindSession } from "../../store";
import { Hono } from 'hono'
import {
registerEnvironment,
deregisterEnvironment,
reconnectEnvironment,
} from '../../services/environment'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { storeBindSession } from '../../store'
const app = new Hono();
const app = new Hono()
/** POST /v1/environments/bridge — Register an environment */
app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const result = registerEnvironment({ ...body, username });
app.post('/bridge', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const username = c.get('username')
const result = registerEnvironment({ ...body, username })
// Bind ACP session to the group ID so the web UI can find it by group
if (result.session_id) {
const groupId = body.bridge_id as string | undefined;
const groupId = body.bridge_id as string | undefined
if (groupId) {
storeBindSession(result.session_id, groupId);
storeBindSession(result.session_id, groupId)
}
}
return c.json(result, 200);
});
return c.json(result, 200)
})
/** DELETE /v1/environments/bridge/:id — Deregister */
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
deregisterEnvironment(envId);
return c.json({ status: "ok" }, 200);
});
app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
deregisterEnvironment(envId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
reconnectEnvironment(envId);
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
await reconnectWorkForEnvironment(envId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/bridge/reconnect', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
reconnectEnvironment(envId)
const { reconnectWorkForEnvironment } = await import(
'../../services/work-dispatch'
)
await reconnectWorkForEnvironment(envId)
return c.json({ status: 'ok' }, 200)
})
export default app;
export default app

View File

@@ -1,41 +1,51 @@
import { Hono } from "hono";
import { pollWork, ackWork, stopWork, heartbeatWork } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { updatePollTime } from "../../services/environment";
import { Hono } from 'hono'
import {
pollWork,
ackWork,
stopWork,
heartbeatWork,
} from '../../services/work-dispatch'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { updatePollTime } from '../../services/environment'
const app = new Hono();
const app = new Hono()
/** GET /v1/environments/:id/work/poll — Long-poll for work */
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
updatePollTime(envId);
const result = await pollWork(envId);
app.get('/:id/work/poll', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
updatePollTime(envId)
const result = await pollWork(envId)
if (!result) {
// Return 204 No Content so the client's axios parses it as null
return c.body(null, 204);
return c.body(null, 204)
}
return c.json(result, 200);
});
return c.json(result, 200)
})
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
ackWork(workId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/work/:workId/ack', acceptCliHeaders, apiKeyAuth, async c => {
const workId = c.req.param('workId')!
ackWork(workId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
stopWork(workId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/work/:workId/stop', acceptCliHeaders, apiKeyAuth, async c => {
const workId = c.req.param('workId')!
stopWork(workId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
const result = heartbeatWork(workId);
return c.json(result, 200);
});
app.post(
'/:id/work/:workId/heartbeat',
acceptCliHeaders,
apiKeyAuth,
async c => {
const workId = c.req.param('workId')!
const result = heartbeatWork(workId)
return c.json(result, 200)
},
)
export default app;
export default app

View File

@@ -1,131 +1,143 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import type { Context } from 'hono'
import type { WSContext, WSMessageReceive } from 'hono/ws'
import { upgradeWebSocket, websocket } from '../../transport/ws-shared'
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
import { validateApiKey } from "../../auth/api-key";
import { verifyWorkerJwt } from "../../auth/jwt";
import { extractWebSocketAuthToken } from "../../auth/middleware";
} from '../../transport/ws-payload'
import { validateApiKey } from '../../auth/api-key'
import { verifyWorkerJwt } from '../../auth/jwt'
import { extractWebSocketAuthToken } from '../../auth/middleware'
import {
handleWebSocketOpen,
handleWebSocketMessage,
handleWebSocketClose,
ingestBridgeMessage,
} from "../../transport/ws-handler";
import { getSession, resolveExistingSessionId } from "../../services/session";
} from '../../transport/ws-handler'
import { getSession, resolveExistingSessionId } from '../../services/session'
const app = new Hono();
const app = new Hono()
type WsMessageEvent = {
data: WSMessageReceive;
};
data: WSMessageReceive
}
type WsCloseEvent = {
code?: number;
reason?: string;
};
code?: number
reason?: string
}
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
const token = extractWebSocketAuthToken(c);
function authenticateRequest(
c: Context,
label: string,
expectedSessionId?: string,
): boolean {
const token = extractWebSocketAuthToken(c)
// Try API key first
if (validateApiKey(token)) {
return true;
return true
}
// Try JWT verification — validate session_id matches if provided
if (token) {
const payload = verifyWorkerJwt(token);
const payload = verifyWorkerJwt(token)
if (payload) {
if (expectedSessionId && payload.session_id !== expectedSessionId) {
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
return false;
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`)
return false
}
return true;
return true
}
}
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
return false;
log(`[Auth] ${label}: FAILED — no valid API key or JWT`)
return false
}
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
app.post("/session/:sessionId/events", async (c) => {
const requestedSessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
app.post('/session/:sessionId/events', async c => {
const requestedSessionId = c.req.param('sessionId')!
const sessionId =
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Invalid auth' } },
401,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const events = Array.isArray(body.events) ? body.events : [body];
const body = await c.req.json()
const events = Array.isArray(body.events) ? body.events : [body]
let count = 0;
let count = 0
for (const msg of events) {
if (!msg || typeof msg !== "object") continue;
ingestBridgeMessage(sessionId, msg as Record<string, unknown>);
count++;
if (!msg || typeof msg !== 'object') continue
ingestBridgeMessage(sessionId, msg as Record<string, unknown>)
count++
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** WS /v2/session_ingress/ws/:sessionId — WebSocket transport */
app.get(
"/ws/:sessionId",
upgradeWebSocket(async (c) => {
const requestedSessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
'/ws/:sessionId',
upgradeWebSocket(async c => {
const requestedSessionId = c.req.param('sessionId')!
const sessionId =
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
log(`[WS] Upgrade rejected: session ${sessionId} not found`)
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4001, "session not found");
ws.close(4001, 'session not found')
},
};
}
}
log(`[WS] Upgrade accepted: session=${sessionId}`);
log(`[WS] Upgrade accepted: session=${sessionId}`)
return {
onOpen(_evt: Event, ws: WSContext) {
handleWebSocketOpen(ws, sessionId);
handleWebSocketOpen(ws, sessionId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleSessionIngressWsPayload(ws, sessionId, evt.data);
handleSessionIngressWsPayload(ws, sessionId, evt.data)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
handleWebSocketClose(ws, sessionId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[WS] Error on session=${sessionId}:`, evt);
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
logError(`[WS] Error on session=${sessionId}:`, evt)
handleWebSocketClose(ws, sessionId, 1006, 'websocket error')
},
};
}
}),
);
)
export const decodeSessionIngressWsMessage = decodeWsPayload;
export const decodeSessionIngressWsMessage = decodeWsPayload
export function handleSessionIngressWsPayload(
ws: WSContext,
@@ -134,12 +146,12 @@ export function handleSessionIngressWsPayload(
): boolean {
return handleSizedWsPayload(
ws,
"[WS]",
'[WS]',
`session=${sessionId}`,
payload,
data => handleWebSocketMessage(ws, sessionId, data),
);
)
}
export { websocket };
export default app;
export { websocket }
export default app

View File

@@ -1,104 +1,129 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import {
createSession,
getSession,
updateSessionTitle,
archiveSession,
resolveExistingSessionId,
} from "../../services/session";
import { createWorkItem } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport";
} from '../../services/session'
import { createWorkItem } from '../../services/work-dispatch'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { publishSessionEvent } from '../../services/transport'
const app = new Hono();
const app = new Hono()
/** POST /v1/sessions — Create session */
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const session = createSession({ ...body, username });
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const username = c.get('username')
const session = createSession({ ...body, username })
// Create work item if environment is specified
if (body.environment_id) {
try {
await createWorkItem(body.environment_id, session.id);
await createWorkItem(body.environment_id, session.id)
} catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
}
}
// Publish initial events if provided
if (body.events && Array.isArray(body.events)) {
for (const evt of body.events) {
publishSessionEvent(session.id, evt.type || "init", evt, "outbound");
publishSessionEvent(session.id, evt.type || 'init', evt, 'outbound')
}
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** GET /v1/sessions/:id — Get session */
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.get('/:id', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** PATCH /v1/sessions/:id — Update session title */
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const existing = getSession(sessionId);
app.patch('/:id', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const existing = getSession(sessionId)
if (!existing) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
if (body.title) {
updateSessionTitle(sessionId, body.title);
updateSessionTitle(sessionId, body.title)
}
const session = getSession(sessionId);
return c.json(session, 200);
});
const session = getSession(sessionId)
return c.json(session, 200)
})
/** POST /v1/sessions/:id/archive — Archive session */
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/archive', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
try {
archiveSession(sessionId);
archiveSession(sessionId)
} catch {
return c.json({ status: "ok" }, 409);
return c.json({ status: 'ok' }, 409)
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/sessions/:id/events — Send event to session */
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/events', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
const events = body.events
? Array.isArray(body.events) ? body.events : [body.events]
: Array.isArray(body) ? body : [body];
const published = [];
? Array.isArray(body.events)
? body.events
: [body.events]
: Array.isArray(body)
? body
: [body]
const published = []
for (const evt of events) {
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
published.push(result);
const result = publishSessionEvent(
sessionId,
evt.type || 'message',
evt,
'inbound',
)
published.push(result)
}
return c.json({ status: "ok", events: published.length }, 200);
});
return c.json({ status: 'ok', events: published.length }, 200)
})
export default app;
export default app

View File

@@ -1,36 +1,46 @@
import { Hono } from "hono";
import { createCodeSession, getSession, incrementEpoch } from "../../services/session";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { generateWorkerJwt } from "../../auth/jwt";
import { getBaseUrl, config } from "../../config";
import { Hono } from 'hono'
import {
createCodeSession,
getSession,
incrementEpoch,
} from '../../services/session'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { generateWorkerJwt } from '../../auth/jwt'
import { getBaseUrl, config } from '../../config'
const app = new Hono();
const app = new Hono()
/** POST /v1/code/sessions — Create code session (wrapped response for TUI compat) */
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const session = createCodeSession(body);
return c.json({ session }, 200);
});
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const session = createCodeSession(body)
return c.json({ session }, 200)
})
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/bridge', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const epoch = incrementEpoch(sessionId);
const expiresInSeconds = config.jwtExpiresIn;
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds);
const epoch = incrementEpoch(sessionId)
const expiresInSeconds = config.jwtExpiresIn
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds)
return c.json({
api_base_url: getBaseUrl(),
worker_epoch: epoch,
worker_jwt: workerJwt,
expires_in: expiresInSeconds,
}, 200);
});
return c.json(
{
api_base_url: getBaseUrl(),
worker_epoch: epoch,
worker_jwt: workerJwt,
expires_in: expiresInSeconds,
},
200,
)
})
export default app;
export default app

View File

@@ -1,24 +1,36 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { createWorkerEventStream } from "../../transport/sse-writer";
import { getSession } from "../../services/session";
import { Hono } from 'hono'
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
import { createWorkerEventStream } from '../../transport/sse-writer'
import { getSession } from '../../services/session'
const app = new Hono();
const app = new Hono()
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
app.get(
'/:id/worker/events/stream',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header('Last-Event-ID')
const fromSeq = c.req.query('from_sequence_num')
const fromSeqNum = fromSeq
? parseInt(fromSeq, 10)
: lastEventId
? parseInt(lastEventId, 10)
: 0
return createWorkerEventStream(c, sessionId, fromSeqNum);
});
return createWorkerEventStream(c, sessionId, fromSeqNum)
},
)
export default app;
export default app

View File

@@ -1,99 +1,144 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport";
import { getSession, touchSession, updateSessionStatus } from "../../services/session";
import { Hono } from 'hono'
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
import { publishSessionEvent } from '../../services/transport'
import {
getSession,
touchSession,
updateSessionStatus,
} from '../../services/session'
const app = new Hono();
const app = new Hono()
function extractWorkerEvents(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") {
return [];
if (!body || typeof body !== 'object') {
return []
}
const payload = body as Record<string, unknown>;
const payload = body as Record<string, unknown>
const rawEvents = Array.isArray(payload.events)
? payload.events
: Array.isArray(body)
? body
: [body];
: [body]
return rawEvents
.filter((evt): evt is Record<string, unknown> => !!evt && typeof evt === "object")
.map((evt) => {
const wrappedPayload = evt.payload;
if (wrappedPayload && typeof wrappedPayload === "object" && !Array.isArray(wrappedPayload)) {
return wrappedPayload as Record<string, unknown>;
.filter(
(evt): evt is Record<string, unknown> => !!evt && typeof evt === 'object',
)
.map(evt => {
const wrappedPayload = evt.payload
if (
wrappedPayload &&
typeof wrappedPayload === 'object' &&
!Array.isArray(wrappedPayload)
) {
return wrappedPayload as Record<string, unknown>
}
return evt;
});
return evt
})
}
/** POST /v1/code/sessions/:id/worker/events — Write events */
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json();
app.post(
'/:id/worker/events',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json()
const events = extractWorkerEvents(body);
const published = [];
for (const evt of events) {
const eventType = typeof evt.type === "string" ? evt.type : "message";
const result = publishSessionEvent(sessionId, eventType, evt, "inbound");
published.push(result);
}
const events = extractWorkerEvents(body)
const published = []
for (const evt of events) {
const eventType = typeof evt.type === 'string' ? evt.type : 'message'
const result = publishSessionEvent(sessionId, eventType, evt, 'inbound')
published.push(result)
}
touchSession(sessionId);
touchSession(sessionId)
return c.json({ status: "ok", count: published.length }, 200);
});
return c.json({ status: 'ok', count: published.length }, 200)
},
)
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
app.put('/:id/worker/state', acceptCliHeaders, sessionIngressAuth, async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
if (body.status) {
updateSessionStatus(sessionId, body.status);
updateSessionStatus(sessionId, body.status)
} else {
touchSession(sessionId);
touchSession(sessionId)
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: "ok" }, 200);
});
app.put(
'/:id/worker/external_metadata',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: 'ok' }, 200)
},
)
/** POST /v1/code/sessions/:id/worker/events/delivery — Batch delivery tracking (no-op) */
app.post("/:id/worker/events/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json({ status: "ok" }, 200);
});
app.post(
'/:id/worker/events/delivery',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
return c.json({ status: 'ok' }, 200)
},
)
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: "ok" }, 200);
});
app.post(
'/:id/worker/events/:eventId/delivery',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
// TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: 'ok' }, 200)
},
)
export default app;
export default app

View File

@@ -1,105 +1,139 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
import { Hono } from 'hono'
import { randomUUID } from 'node:crypto'
import {
getSession,
incrementEpoch,
touchSession,
updateSessionStatus,
} from '../../services/session'
import {
automationStatesEqual,
getAutomationStateEventPayload,
} from "../../services/automationState";
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
import { getEventBus } from "../../transport/event-bus";
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
} from '../../services/automationState'
import {
apiKeyAuth,
acceptCliHeaders,
sessionIngressAuth,
} from '../../auth/middleware'
import { getEventBus } from '../../transport/event-bus'
import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store'
const app = new Hono();
const app = new Hono()
/** GET /v1/code/sessions/:id/worker — Read worker state */
app.get("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
app.get('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const worker = storeGetSessionWorker(sessionId);
return c.json({
worker: {
worker_status: worker?.workerStatus ?? session.status,
external_metadata: worker?.externalMetadata ?? null,
requires_action_details: worker?.requiresActionDetails ?? null,
last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null,
const worker = storeGetSessionWorker(sessionId)
return c.json(
{
worker: {
worker_status: worker?.workerStatus ?? session.status,
external_metadata: worker?.externalMetadata ?? null,
requires_action_details: worker?.requiresActionDetails ?? null,
last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null,
},
},
}, 200);
});
200,
)
})
/** PUT /v1/code/sessions/:id/worker — Update worker state */
app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
app.put('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
const prevAutomationState = getAutomationStateEventPayload(
storeGetSessionWorker(sessionId)?.externalMetadata,
);
)
if (body.worker_status) {
updateSessionStatus(sessionId, body.worker_status);
updateSessionStatus(sessionId, body.worker_status)
} else {
touchSession(sessionId);
touchSession(sessionId)
}
const worker = storeUpsertSessionWorker(sessionId, {
workerStatus: body.worker_status,
externalMetadata: body.external_metadata,
requiresActionDetails: body.requires_action_details,
});
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
})
const nextAutomationState = getAutomationStateEventPayload(
worker.externalMetadata,
)
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
getEventBus(sessionId).publish({
id: randomUUID(),
sessionId,
type: "automation_state",
type: 'automation_state',
payload: nextAutomationState,
direction: "inbound",
});
direction: 'inbound',
})
}
return c.json({
status: "ok",
worker: {
worker_status: worker.workerStatus ?? session.status,
external_metadata: worker.externalMetadata,
requires_action_details: worker.requiresActionDetails,
last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null,
return c.json(
{
status: 'ok',
worker: {
worker_status: worker.workerStatus ?? session.status,
external_metadata: worker.externalMetadata,
requires_action_details: worker.requiresActionDetails,
last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null,
},
},
}, 200);
});
200,
)
})
/** POST /v1/code/sessions/:id/worker/heartbeat — Keep worker alive */
app.post("/:id/worker/heartbeat", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
app.post(
'/:id/worker/heartbeat',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const now = new Date();
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now });
touchSession(sessionId);
return c.json({ status: "ok", last_heartbeat_at: now.toISOString() }, 200);
});
const now = new Date()
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now })
touchSession(sessionId)
return c.json({ status: 'ok', last_heartbeat_at: now.toISOString() }, 200)
},
)
/** POST /v1/code/sessions/:id/worker/register — Register worker */
app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/worker/register', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId = c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const epoch = incrementEpoch(sessionId);
return c.json({ worker_epoch: epoch }, 200);
});
const epoch = incrementEpoch(sessionId)
return c.json({ worker_epoch: epoch }, 200)
})
export default app;
export default app

View File

@@ -1,27 +1,30 @@
import { Hono } from "hono";
import { storeBindSession } from "../../store";
import { resolveExistingWebSessionId, toWebSessionId } from "../../services/session";
import { Hono } from 'hono'
import { storeBindSession } from '../../store'
import {
resolveExistingWebSessionId,
toWebSessionId,
} from '../../services/session'
const app = new Hono();
const app = new Hono()
/** POST /web/bind — Bind a session to a UUID (no-login auth) */
app.post("/bind", async (c) => {
const body = await c.req.json();
const sessionId = body.sessionId;
app.post('/bind', async c => {
const body = await c.req.json()
const sessionId = body.sessionId
// UUID can come from query param (api.js sends it in URL) or body
const uuid = c.req.query("uuid") || body.uuid;
const uuid = c.req.query('uuid') || body.uuid
if (!sessionId || !uuid) {
return c.json({ error: "sessionId and uuid are required" }, 400);
return c.json({ error: 'sessionId and uuid are required' }, 400)
}
const resolvedSessionId = resolveExistingWebSessionId(sessionId);
const resolvedSessionId = resolveExistingWebSessionId(sessionId)
if (!resolvedSessionId) {
return c.json({ error: "Session not found" }, 404);
return c.json({ error: 'Session not found' }, 404)
}
storeBindSession(resolvedSessionId, uuid);
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) });
});
storeBindSession(resolvedSessionId, uuid)
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) })
})
export default app;
export default app

View File

@@ -1,86 +1,130 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
import { publishSessionEvent } from "../../services/transport";
import { getEventBus } from "../../transport/event-bus";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import {
getSession,
isSessionClosedStatus,
resolveOwnedWebSessionId,
updateSessionStatus,
} from '../../services/session'
import { publishSessionEvent } from '../../services/transport'
import { getEventBus } from '../../transport/event-bus'
const app = new Hono();
const app = new Hono()
type OwnershipCheckResult =
| { error: true }
| { error: true; reason: string }
| { error: false; session: NonNullable<ReturnType<typeof getSession>>; sessionId: string };
| {
error: false
session: NonNullable<ReturnType<typeof getSession>>
sessionId: string
}
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string): OwnershipCheckResult {
const uuid = c.get("uuid")!;
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid);
function checkOwnership(
c: { get: (key: string) => string | undefined },
sessionId: string,
): OwnershipCheckResult {
const uuid = c.get('uuid')!
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid)
if (!resolvedSessionId) {
return { error: true };
return { error: true }
}
const session = getSession(resolvedSessionId);
const session = getSession(resolvedSessionId)
if (!session) {
return { error: true };
return { error: true }
}
if (isSessionClosedStatus(session.status)) {
return { error: true, reason: `Session is ${session.status}` };
return { error: true, reason: `Session is ${session.status}` }
}
return { error: false, session, sessionId: resolvedSessionId };
return { error: false, session, sessionId: resolvedSessionId }
}
function closedSessionResponse(message: string) {
return { error: { type: "session_closed", message } };
return { error: { type: 'session_closed', message } }
}
/** POST /web/sessions/:id/events — Send user message to session */
app.post("/sessions/:id/events", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/events', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
const body = await c.req.json();
const eventType = body.type || "user";
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
return c.json({ status: "ok", event }, 200);
});
const body = await c.req.json()
const eventType = body.type || 'user'
log(
`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`,
)
const event = publishSessionEvent(sessionId, eventType, body, 'outbound')
log(
`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`,
)
return c.json({ status: 'ok', event }, 200)
})
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */
app.post("/sessions/:id/control", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/control', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
const body = await c.req.json();
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
return c.json({ status: "ok", event }, 200);
});
const body = await c.req.json()
const event = publishSessionEvent(
sessionId,
body.type || 'control_request',
body,
'outbound',
)
return c.json({ status: 'ok', event }, 200)
})
/** POST /web/sessions/:id/interrupt — Interrupt session */
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/interrupt', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
updateSessionStatus(sessionId, "idle");
return c.json({ status: "ok" }, 200);
});
publishSessionEvent(
sessionId,
'interrupt',
{ action: 'interrupt' },
'outbound',
)
updateSessionStatus(sessionId, 'idle')
return c.json({ status: 'ok' }, 200)
})
export default app;
export default app

View File

@@ -1,14 +1,14 @@
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { listActiveEnvironmentsResponse } from "../../services/environment";
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import { listActiveEnvironmentsResponse } from '../../services/environment'
const app = new Hono();
const app = new Hono()
/** GET /web/environments — List active environments (UUID-based, no user filtering) */
app.get("/environments", uuidAuth, async (c) => {
app.get('/environments', uuidAuth, async c => {
// Environments are shared across all UUIDs for now
const envs = listActiveEnvironmentsResponse();
return c.json(envs, 200);
});
const envs = listActiveEnvironmentsResponse()
return c.json(envs, 200)
})
export default app;
export default app

View File

@@ -1,7 +1,7 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getAutomationStateSnapshot } from "../../services/automationState";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import { getAutomationStateSnapshot } from '../../services/automationState'
import {
createSession,
getSession,
@@ -10,109 +10,137 @@ import {
listWebSessionsByOwnerUuid,
resolveOwnedWebSessionId,
toWebSessionResponse,
} from "../../services/session";
import { storeBindSession, storeGetSessionWorker } from "../../store";
import { createWorkItem } from "../../services/work-dispatch";
import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus";
} from '../../services/session'
import { storeBindSession, storeGetSessionWorker } from '../../store'
import { createWorkItem } from '../../services/work-dispatch'
import { createSSEStream } from '../../transport/sse-writer'
import { getEventBus } from '../../transport/event-bus'
const app = new Hono();
const app = new Hono()
/** POST /web/sessions — Create a session from web UI */
app.post("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const body = await c.req.json();
app.post('/sessions', uuidAuth, async c => {
const uuid = c.get('uuid')!
const body = await c.req.json()
const session = createSession({
environment_id: body.environment_id || null,
title: body.title || "New Session",
source: "web",
permission_mode: body.permission_mode || "default",
});
title: body.title || 'New Session',
source: 'web',
permission_mode: body.permission_mode || 'default',
})
// Auto-bind to creator's UUID
storeBindSession(session.id, uuid);
storeBindSession(session.id, uuid)
// Dispatch work to environment if specified
if (body.environment_id) {
try {
await createWorkItem(body.environment_id, session.id);
await createWorkItem(body.environment_id, session.id)
} catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
}
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** GET /web/sessions — List sessions owned by the requesting UUID */
app.get("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessions = listWebSessionsByOwnerUuid(uuid);
return c.json(sessions, 200);
});
app.get('/sessions', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessions = listWebSessionsByOwnerUuid(uuid)
return c.json(sessions, 200)
})
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
app.get("/sessions/all", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessions = listWebSessionSummariesByOwnerUuid(uuid);
return c.json(sessions, 200);
});
app.get('/sessions/all', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessions = listWebSessionSummariesByOwnerUuid(uuid)
return c.json(sessions, 200)
})
/** GET /web/sessions/:id — Session detail */
app.get("/sessions/:id", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const worker = storeGetSessionWorker(sessionId);
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
const response = toWebSessionResponse(session);
const worker = storeGetSessionWorker(sessionId)
const automationState = getAutomationStateSnapshot(worker?.externalMetadata)
const response = toWebSessionResponse(session)
return c.json(
automationState === undefined ? response : { ...response, automation_state: automationState },
automationState === undefined
? response
: { ...response, automation_state: automationState },
200,
);
});
)
})
/** GET /web/sessions/:id/history — Historical events for session */
app.get("/sessions/:id/history", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id/history', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const bus = getEventBus(sessionId);
const events = bus.getEventsSince(0);
return c.json({ events }, 200);
});
const bus = getEventBus(sessionId)
const events = bus.getEventsSince(0)
return c.json({ events }, 200)
})
/** SSE /web/sessions/:id/events — Real-time event stream */
app.get("/sessions/:id/events", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id/events', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
if (isSessionClosedStatus(session.status)) {
return c.json({ error: { type: "session_closed", message: `Session is ${session.status}` } }, 409);
return c.json(
{
error: {
type: 'session_closed',
message: `Session is ${session.status}`,
},
},
409,
)
}
const lastEventId = c.req.header("Last-Event-ID");
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
return createSSEStream(c, sessionId, fromSeqNum);
});
const lastEventId = c.req.header('Last-Event-ID')
const fromSeqNum = lastEventId ? parseInt(lastEventId, 10) : 0
return createSSEStream(c, sessionId, fromSeqNum)
})
export default app;
export default app

View File

@@ -1,54 +1,66 @@
import type { AutomationStateResponse } from "../types/api";
import type { AutomationStateResponse } from '../types/api'
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
})
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
return { ...state };
function cloneAutomationState(
state: AutomationStateResponse,
): AutomationStateResponse {
return { ...state }
}
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
if (!raw || typeof raw !== "object") {
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
if (!raw || typeof raw !== 'object') {
return cloneAutomationState(DISABLED_AUTOMATION_STATE)
}
const state = raw as Record<string, unknown>;
const state = raw as Record<string, unknown>
return {
enabled: state.enabled === true,
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
};
phase:
state.phase === 'standby' || state.phase === 'sleeping'
? state.phase
: null,
next_tick_at:
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
sleep_until:
typeof state.sleep_until === 'number' ? state.sleep_until : null,
}
}
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
if (!metadata || typeof metadata !== "object") {
return undefined;
function readAutomationStateValue(
metadata: Record<string, unknown> | null | undefined,
): unknown {
if (!metadata || typeof metadata !== 'object') {
return undefined
}
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
return undefined;
if (!Object.hasOwn(metadata, 'automation_state')) {
return undefined
}
return metadata.automation_state;
return metadata.automation_state
}
export function getAutomationStateSnapshot(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse | undefined {
const raw = readAutomationStateValue(metadata);
const raw = readAutomationStateValue(metadata)
if (raw === undefined) {
return undefined;
return undefined
}
return normalizeAutomationState(raw);
return normalizeAutomationState(raw)
}
export function getAutomationStateEventPayload(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse {
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
return (
getAutomationStateSnapshot(metadata) ??
cloneAutomationState(DISABLED_AUTOMATION_STATE)
)
}
export function automationStatesEqual(
@@ -60,5 +72,5 @@ export function automationStatesEqual(
a.phase === b.phase &&
a.next_tick_at === b.next_tick_at &&
a.sleep_until === b.sleep_until
);
)
}

View File

@@ -1,37 +1,47 @@
import { log, error as logError } from "../logger";
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
import { storeListSessions } from "../store";
import { config } from "../config";
import { updateSessionStatus } from "./session";
import { log, error as logError } from '../logger'
import {
storeListActiveEnvironments,
storeUpdateEnvironment,
storeMarkAcpAgentOffline,
} from '../store'
import { storeListSessions } from '../store'
import { config } from '../config'
import { updateSessionStatus } from './session'
export function runDisconnectMonitorSweep(now = Date.now()) {
const timeoutMs = config.disconnectTimeout * 1000;
const timeoutMs = config.disconnectTimeout * 1000
// Check environment heartbeat timeout
const envs = storeListActiveEnvironments();
const envs = storeListActiveEnvironments()
for (const env of envs) {
// Skip ACP agents — they use WS keepalive, not polling
if (env.workerType === "acp") {
if (env.workerType === 'acp') {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
log(`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeMarkAcpAgentOffline(env.id);
log(
`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
)
storeMarkAcpAgentOffline(env.id)
}
continue;
continue
}
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" });
log(
`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
)
storeUpdateEnvironment(env.id, { status: 'disconnected' })
}
}
// Check session timeout (2x disconnect timeout with no update)
const sessions = storeListSessions();
const sessions = storeListSessions()
for (const session of sessions) {
if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime();
if (session.status === 'running' || session.status === 'idle') {
const elapsed = now - session.updatedAt.getTime()
if (elapsed > timeoutMs * 2) {
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
updateSessionStatus(session.id, "inactive");
log(
`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`,
)
updateSessionStatus(session.id, 'inactive')
}
}
}
@@ -39,6 +49,6 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
export function startDisconnectMonitor() {
setInterval(() => {
runDisconnectMonitorSweep();
}, 60_000); // Check every minute
runDisconnectMonitorSweep()
}, 60_000) // Check every minute
}

View File

@@ -1,4 +1,4 @@
import { config } from "../config";
import { config } from '../config'
import {
storeCreateEnvironment,
storeCreateSession,
@@ -7,9 +7,12 @@ import {
storeListActiveEnvironments,
storeListActiveEnvironmentsByUsername,
storeListSessionsByEnvironment,
} from "../store";
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
import type { EnvironmentRecord } from "../store";
} from '../store'
import type {
RegisterEnvironmentRequest,
EnvironmentResponse,
} from '../types/api'
import type { EnvironmentRecord } from '../store'
function toResponse(row: EnvironmentRecord): EnvironmentResponse {
return {
@@ -23,12 +26,17 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
worker_type: row.workerType,
channel_group_id: row.bridgeId,
capabilities: row.capabilities,
};
}
}
export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata?: { worker_type?: string }; username?: string }) {
const secret = config.apiKeys[0] || "";
const workerType = req.worker_type || req.metadata?.worker_type;
export function registerEnvironment(
req: RegisterEnvironmentRequest & {
metadata?: { worker_type?: string }
username?: string
},
) {
const secret = config.apiKeys[0] || ''
const workerType = req.worker_type || req.metadata?.worker_type
const record = storeCreateEnvironment({
secret,
machineName: req.machine_name,
@@ -40,51 +48,58 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
bridgeId: req.bridge_id,
username: req.username,
capabilities: req.capabilities,
});
})
let sessionId: string | undefined;
let sessionId: string | undefined
// ACP agents: reuse existing session or create one
if (workerType === "acp") {
const existing = storeListSessionsByEnvironment(record.id);
if (workerType === 'acp') {
const existing = storeListSessionsByEnvironment(record.id)
if (existing.length > 0) {
sessionId = existing[0].id;
sessionId = existing[0].id
} else {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
title: req.machine_name || 'ACP Agent',
source: 'acp',
})
sessionId = session.id
}
}
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
return {
environment_id: record.id,
environment_secret: record.secret,
status: record.status as 'active',
session_id: sessionId,
}
}
export function deregisterEnvironment(envId: string) {
storeUpdateEnvironment(envId, { status: "deregistered" });
storeUpdateEnvironment(envId, { status: 'deregistered' })
}
export function getEnvironment(envId: string) {
return storeGetEnvironment(envId);
return storeGetEnvironment(envId)
}
export function updatePollTime(envId: string) {
storeUpdateEnvironment(envId, { lastPollAt: new Date() });
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
}
export function listActiveEnvironments() {
return storeListActiveEnvironments();
return storeListActiveEnvironments()
}
export function listActiveEnvironmentsResponse(): EnvironmentResponse[] {
return storeListActiveEnvironments().map(toResponse);
return storeListActiveEnvironments().map(toResponse)
}
export function listActiveEnvironmentsByUsername(username: string): EnvironmentResponse[] {
return storeListActiveEnvironmentsByUsername(username).map(toResponse);
export function listActiveEnvironmentsByUsername(
username: string,
): EnvironmentResponse[] {
return storeListActiveEnvironmentsByUsername(username).map(toResponse)
}
export function reconnectEnvironment(envId: string) {
storeUpdateEnvironment(envId, { status: "active" });
storeUpdateEnvironment(envId, { status: 'active' })
}

View File

@@ -9,16 +9,32 @@ import {
storeListSessionsByUsername,
storeListSessionsByEnvironment,
storeListSessionsByOwnerUuid,
} from "../store";
import { randomUUID } from "node:crypto";
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
} from '../store'
import { randomUUID } from 'node:crypto'
import { getAllEventBuses, removeEventBus } from '../transport/event-bus'
import type {
CreateSessionRequest,
CreateCodeSessionRequest,
SessionResponse,
SessionSummaryResponse,
} from '../types/api'
const CODE_SESSION_PREFIX = "cse_";
const WEB_SESSION_PREFIX = "session_";
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
const CODE_SESSION_PREFIX = 'cse_'
const WEB_SESSION_PREFIX = 'session_'
const CLOSED_SESSION_STATUSES = new Set(['archived', 'inactive'])
function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse {
function toResponse(row: {
id: string
environmentId: string | null
title: string | null
status: string
source: string
permissionMode: string | null
workerEpoch: number
username: string | null
createdAt: Date
updatedAt: Date
}): SessionResponse {
return {
id: row.id,
environment_id: row.environmentId,
@@ -30,172 +46,200 @@ function toResponse(row: { id: string; environmentId: string | null; title: stri
username: row.username,
created_at: row.createdAt.getTime() / 1000,
updated_at: row.updatedAt.getTime() / 1000,
};
}
}
export function toWebSessionId(sessionId: string): string {
if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId;
return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}`;
if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId
return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}`
}
function toCompatibleCodeSessionId(sessionId: string): string | null {
if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null;
return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}`;
if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null
return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}`
}
export function toWebSessionResponse(session: SessionResponse): SessionResponse {
return { ...session, id: toWebSessionId(session.id) };
export function toWebSessionResponse(
session: SessionResponse,
): SessionResponse {
return { ...session, id: toWebSessionId(session.id) }
}
function toWebSessionSummaryResponse(session: SessionSummaryResponse): SessionSummaryResponse {
return { ...session, id: toWebSessionId(session.id) };
function toWebSessionSummaryResponse(
session: SessionSummaryResponse,
): SessionSummaryResponse {
return { ...session, id: toWebSessionId(session.id) }
}
export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse {
export function createSession(
req: CreateSessionRequest & { username?: string },
): SessionResponse {
const record = storeCreateSession({
environmentId: req.environment_id,
title: req.title,
source: req.source,
permissionMode: req.permission_mode,
username: req.username,
});
return toResponse(record);
})
return toResponse(record)
}
export function createCodeSession(req: CreateCodeSessionRequest): SessionResponse {
export function createCodeSession(
req: CreateCodeSessionRequest,
): SessionResponse {
const record = storeCreateSession({
idPrefix: "cse_",
idPrefix: 'cse_',
title: req.title,
source: req.source,
permissionMode: req.permission_mode,
});
return toResponse(record);
})
return toResponse(record)
}
export function getSession(sessionId: string): SessionResponse | null {
const record = storeGetSession(sessionId);
return record ? toResponse(record) : null;
const record = storeGetSession(sessionId)
return record ? toResponse(record) : null
}
export function isSessionClosedStatus(status: string | null | undefined): boolean {
return !!status && CLOSED_SESSION_STATUSES.has(status);
export function isSessionClosedStatus(
status: string | null | undefined,
): boolean {
return !!status && CLOSED_SESSION_STATUSES.has(status)
}
export function resolveExistingSessionId(sessionId: string): string | null {
if (storeGetSession(sessionId)) {
return sessionId;
return sessionId
}
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId)
if (compatibleCodeSessionId && storeGetSession(compatibleCodeSessionId)) {
return compatibleCodeSessionId;
return compatibleCodeSessionId
}
return null;
return null
}
export function resolveExistingWebSessionId(sessionId: string): string | null {
return resolveExistingSessionId(sessionId);
return resolveExistingSessionId(sessionId)
}
export function resolveOwnedWebSessionId(sessionId: string, uuid: string): string | null {
export function resolveOwnedWebSessionId(
sessionId: string,
uuid: string,
): string | null {
if (storeIsSessionOwner(sessionId, uuid)) {
return sessionId;
return sessionId
}
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
if (compatibleCodeSessionId && storeIsSessionOwner(compatibleCodeSessionId, uuid)) {
return compatibleCodeSessionId;
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId)
if (
compatibleCodeSessionId &&
storeIsSessionOwner(compatibleCodeSessionId, uuid)
) {
return compatibleCodeSessionId
}
// Auto-bind: if the session exists but has no owner, claim it for the requesting user
const existingId = resolveExistingSessionId(sessionId);
const existingId = resolveExistingSessionId(sessionId)
if (existingId) {
const owners = storeGetSessionOwners(existingId);
const owners = storeGetSessionOwners(existingId)
if (!owners || owners.size === 0) {
storeBindSession(existingId, uuid);
return existingId;
storeBindSession(existingId, uuid)
return existingId
}
}
return null;
return null
}
export function listWebSessionsByOwnerUuid(uuid: string): SessionResponse[] {
return storeListSessionsByOwnerUuid(uuid)
.filter((session) => !isSessionClosedStatus(session.status))
.filter(session => !isSessionClosedStatus(session.status))
.map(toResponse)
.map(toWebSessionResponse);
.map(toWebSessionResponse)
}
export function listWebSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
export function listWebSessionSummariesByOwnerUuid(
uuid: string,
): SessionSummaryResponse[] {
return storeListSessionsByOwnerUuid(uuid)
.filter((session) => !isSessionClosedStatus(session.status))
.filter(session => !isSessionClosedStatus(session.status))
.map(toSummaryResponse)
.map(toWebSessionSummaryResponse);
.map(toWebSessionSummaryResponse)
}
export function updateSessionTitle(sessionId: string, title: string) {
storeUpdateSession(sessionId, { title });
storeUpdateSession(sessionId, { title })
}
export function updateSessionStatus(sessionId: string, status: string) {
storeUpdateSession(sessionId, { status });
const bus = getAllEventBuses().get(sessionId);
if (!bus) return;
storeUpdateSession(sessionId, { status })
const bus = getAllEventBuses().get(sessionId)
if (!bus) return
bus.publish({
id: randomUUID(),
sessionId,
type: "session_status",
type: 'session_status',
payload: { status },
direction: "inbound",
});
direction: 'inbound',
})
}
export function touchSession(sessionId: string) {
storeUpdateSession(sessionId, {});
storeUpdateSession(sessionId, {})
}
export function archiveSession(sessionId: string) {
updateSessionStatus(sessionId, "archived");
removeEventBus(sessionId);
updateSessionStatus(sessionId, 'archived')
removeEventBus(sessionId)
}
export function incrementEpoch(sessionId: string): number {
const record = storeGetSession(sessionId);
if (!record) throw new Error("Session not found");
const newEpoch = record.workerEpoch + 1;
storeUpdateSession(sessionId, { workerEpoch: newEpoch });
return newEpoch;
const record = storeGetSession(sessionId)
if (!record) throw new Error('Session not found')
const newEpoch = record.workerEpoch + 1
storeUpdateSession(sessionId, { workerEpoch: newEpoch })
return newEpoch
}
export function listSessions() {
return storeListSessions().map(toResponse);
return storeListSessions().map(toResponse)
}
function toSummaryResponse(row: { id: string; title: string | null; status: string; username: string | null; updatedAt: Date }): SessionSummaryResponse {
function toSummaryResponse(row: {
id: string
title: string | null
status: string
username: string | null
updatedAt: Date
}): SessionSummaryResponse {
return {
id: row.id,
title: row.title,
status: row.status,
username: row.username,
updated_at: row.updatedAt.getTime() / 1000,
};
}
}
export function listSessionSummaries(): SessionSummaryResponse[] {
return storeListSessions().map(toSummaryResponse);
return storeListSessions().map(toSummaryResponse)
}
export function listSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse);
export function listSessionSummariesByOwnerUuid(
uuid: string,
): SessionSummaryResponse[] {
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse)
}
export function listSessionSummariesByUsername(username: string): SessionSummaryResponse[] {
return storeListSessionsByUsername(username).map(toSummaryResponse);
export function listSessionSummariesByUsername(
username: string,
): SessionSummaryResponse[] {
return storeListSessionsByUsername(username).map(toSummaryResponse)
}
export function listSessionsByEnvironment(envId: string) {
return storeListSessionsByEnvironment(envId).map(toResponse);
return storeListSessionsByEnvironment(envId).map(toResponse)
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { getEventBus } from "../transport/event-bus";
import { randomUUID } from 'node:crypto'
import { getEventBus } from '../transport/event-bus'
/**
* Extract plain text from various message payload formats.
@@ -9,75 +9,87 @@ import { getEventBus } from "../transport/event-bus";
* { message: { content: [{type:"text",text:"..."}] } }
*/
function extractContent(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return typeof payload === "string" ? payload : "";
if (!payload || typeof payload !== 'object') {
return typeof payload === 'string' ? payload : ''
}
const p = payload as Record<string, unknown>;
const p = payload as Record<string, unknown>
// Direct content field
if (typeof p.content === "string" && p.content) return p.content;
if (typeof p.content === 'string' && p.content) return p.content
// message.content (child process format)
const msg = p.message;
if (msg && typeof msg === "object") {
const mc = (msg as Record<string, unknown>).content;
if (typeof mc === "string") return mc;
const msg = p.message
if (msg && typeof msg === 'object') {
const mc = (msg as Record<string, unknown>).content
if (typeof mc === 'string') return mc
if (Array.isArray(mc)) {
return mc
.filter((b: unknown) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "text")
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
.join("");
.filter(
(b: unknown) =>
typeof b === 'object' &&
b !== null &&
(b as Record<string, unknown>).type === 'text',
)
.map(
(b: Record<string, unknown>) =>
(b as Record<string, unknown>).text || '',
)
.join('')
}
}
return "";
return ''
}
/**
* Normalize event payload into a flat structure with guaranteed `content` string.
* Preserves original payload in `raw` field and keeps tool-specific fields.
*/
export function normalizePayload(type: string, payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== "object") {
return { content: typeof payload === "string" ? payload : "", raw: payload };
export function normalizePayload(
type: string,
payload: unknown,
): Record<string, unknown> {
if (!payload || typeof payload !== 'object') {
return { content: typeof payload === 'string' ? payload : '', raw: payload }
}
const p = payload as Record<string, unknown>;
const content = extractContent(payload);
const p = payload as Record<string, unknown>
const content = extractContent(payload)
const normalized: Record<string, unknown> = {
content,
raw: payload,
};
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
if (typeof p.status === "string") normalized.status = p.status;
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
// Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name;
if (p.name) normalized.tool_name = p.name;
if (p.tool_input) normalized.tool_input = p.tool_input;
if (p.input) normalized.tool_input = p.input;
// Preserve permission fields
if (p.request_id) normalized.request_id = p.request_id;
if (p.request) normalized.request = p.request;
if (p.approved !== undefined) normalized.approved = p.approved;
if (p.updated_input) normalized.updated_input = p.updated_input;
// Preserve message field for backward compat
if (p.message) normalized.message = p.message;
if (type === "task_state") {
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
}
return normalized;
if (typeof p.uuid === 'string' && p.uuid) normalized.uuid = p.uuid
if (typeof p.isSynthetic === 'boolean') normalized.isSynthetic = p.isSynthetic
if (typeof p.status === 'string') normalized.status = p.status
if (typeof p.subtype === 'string') normalized.subtype = p.subtype
// Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name
if (p.name) normalized.tool_name = p.name
if (p.tool_input) normalized.tool_input = p.tool_input
if (p.input) normalized.tool_input = p.input
// Preserve permission fields
if (p.request_id) normalized.request_id = p.request_id
if (p.request) normalized.request = p.request
if (p.approved !== undefined) normalized.approved = p.approved
if (p.updated_input) normalized.updated_input = p.updated_input
// Preserve message field for backward compat
if (p.message) normalized.message = p.message
if (type === 'task_state') {
if (typeof p.task_list_id === 'string')
normalized.task_list_id = p.task_list_id
if (typeof p.taskListId === 'string') normalized.taskListId = p.taskListId
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks
}
return normalized
}
/** Publish an event to a session's bus (in-memory only) */
@@ -85,12 +97,12 @@ export function publishSessionEvent(
sessionId: string,
type: string,
payload: unknown,
direction: "inbound" | "outbound",
direction: 'inbound' | 'outbound',
) {
const bus = getEventBus(sessionId);
const eventId = randomUUID();
const bus = getEventBus(sessionId)
const eventId = randomUUID()
const normalized = normalizePayload(type, payload);
const normalized = normalizePayload(type, payload)
const event = bus.publish({
id: eventId,
@@ -98,7 +110,7 @@ export function publishSessionEvent(
type,
payload: normalized,
direction,
});
})
return event;
return event
}

View File

@@ -1,4 +1,4 @@
import { log, error as logError } from "../logger";
import { log, error as logError } from '../logger'
import {
storeCreateWorkItem,
storeGetWorkItem,
@@ -6,94 +6,111 @@ import {
storeUpdateWorkItem,
storeListSessionsByEnvironment,
storeGetEnvironment,
} from "../store";
import { config } from "../config";
import { getBaseUrl } from "../config";
import type { WorkResponse } from "../types/api";
} from '../store'
import { config } from '../config'
import { getBaseUrl } from '../config'
import type { WorkResponse } from '../types/api'
/** Encode work secret as base64 JSON (no JWT — just API key as token) */
function encodeWorkSecret(): string {
const payload = {
version: 1,
session_ingress_token: config.apiKeys[0] || "",
session_ingress_token: config.apiKeys[0] || '',
api_base_url: getBaseUrl(),
sources: [] as string[],
auth: [] as string[],
use_code_sessions: false,
};
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}
return Buffer.from(JSON.stringify(payload)).toString('base64url')
}
export async function createWorkItem(environmentId: string, sessionId: string): Promise<string> {
export async function createWorkItem(
environmentId: string,
sessionId: string,
): Promise<string> {
// Validate environment exists and is active
const env = storeGetEnvironment(environmentId);
const env = storeGetEnvironment(environmentId)
if (!env) {
throw new Error(`Environment ${environmentId} not found`);
throw new Error(`Environment ${environmentId} not found`)
}
if (env.status !== "active") {
throw new Error(`Environment ${environmentId} is not active (status: ${env.status})`);
if (env.status !== 'active') {
throw new Error(
`Environment ${environmentId} is not active (status: ${env.status})`,
)
}
const secret = encodeWorkSecret();
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
return record.id;
const secret = encodeWorkSecret()
const record = storeCreateWorkItem({ environmentId, sessionId, secret })
log(
`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`,
)
return record.id
}
/** Long-poll for work — blocks until work is available or timeout.
* Returns null when no work is available, matching the CLI bridge client protocol. */
export async function pollWork(environmentId: string, timeoutSeconds = config.pollTimeout): Promise<WorkResponse | null> {
const deadline = Date.now() + timeoutSeconds * 1000;
export async function pollWork(
environmentId: string,
timeoutSeconds = config.pollTimeout,
): Promise<WorkResponse | null> {
const deadline = Date.now() + timeoutSeconds * 1000
while (Date.now() < deadline) {
const item = storeGetPendingWorkItem(environmentId);
const item = storeGetPendingWorkItem(environmentId)
if (item) {
storeUpdateWorkItem(item.id, { state: "dispatched" });
storeUpdateWorkItem(item.id, { state: 'dispatched' })
return {
id: item.id,
type: "work",
type: 'work',
environment_id: environmentId,
state: "dispatched",
state: 'dispatched',
data: {
type: "session",
type: 'session',
id: item.sessionId,
},
secret: item.secret,
created_at: item.createdAt.toISOString(),
};
}
}
await new Promise((r) => setTimeout(r, 500));
await new Promise(r => setTimeout(r, 500))
}
return null;
return null
}
export function ackWork(workId: string) {
storeUpdateWorkItem(workId, { state: "acked" });
storeUpdateWorkItem(workId, { state: 'acked' })
}
export function stopWork(workId: string) {
storeUpdateWorkItem(workId, { state: "completed" });
storeUpdateWorkItem(workId, { state: 'completed' })
}
export function heartbeatWork(workId: string): { lease_extended: boolean; state: string; last_heartbeat: string; ttl_seconds: number } {
storeUpdateWorkItem(workId, {} as any); // just bump updatedAt
const item = storeGetWorkItem(workId);
const now = new Date();
export function heartbeatWork(workId: string): {
lease_extended: boolean
state: string
last_heartbeat: string
ttl_seconds: number
} {
storeUpdateWorkItem(workId, {} as any) // just bump updatedAt
const item = storeGetWorkItem(workId)
const now = new Date()
return {
lease_extended: true,
state: item?.state ?? "acked",
state: item?.state ?? 'acked',
last_heartbeat: now.toISOString(),
ttl_seconds: config.heartbeatInterval * 2,
};
}
}
/** Reconnect: re-queue sessions associated with an environment */
export function reconnectWorkForEnvironment(envId: string) {
const activeSessions = storeListSessionsByEnvironment(envId).filter((s) => s.status === "idle");
const promises = activeSessions.map((s) => createWorkItem(envId, s.id));
return Promise.all(promises);
const activeSessions = storeListSessionsByEnvironment(envId).filter(
s => s.status === 'idle',
)
const promises = activeSessions.map(s => createWorkItem(envId, s.id))
return Promise.all(promises)
}

View File

@@ -1,117 +1,119 @@
import { randomUUID } from "node:crypto";
import { randomUUID } from 'node:crypto'
// ---------- Types ----------
export interface UserRecord {
username: string;
createdAt: Date;
username: string
createdAt: Date
}
export interface EnvironmentRecord {
id: string;
secret: string;
machineName: string | null;
directory: string | null;
branch: string | null;
gitRepoUrl: string | null;
maxSessions: number;
workerType: string;
bridgeId: string | null;
capabilities: Record<string, unknown> | null;
status: string;
username: string | null;
lastPollAt: Date | null;
createdAt: Date;
updatedAt: Date;
id: string
secret: string
machineName: string | null
directory: string | null
branch: string | null
gitRepoUrl: string | null
maxSessions: number
workerType: string
bridgeId: string | null
capabilities: Record<string, unknown> | null
status: string
username: string | null
lastPollAt: Date | null
createdAt: Date
updatedAt: Date
}
export interface SessionRecord {
id: string;
environmentId: string | null;
title: string | null;
status: string;
source: string;
permissionMode: string | null;
workerEpoch: number;
username: string | null;
createdAt: Date;
updatedAt: Date;
id: string
environmentId: string | null
title: string | null
status: string
source: string
permissionMode: string | null
workerEpoch: number
username: string | null
createdAt: Date
updatedAt: Date
}
export interface WorkItemRecord {
id: string;
environmentId: string;
sessionId: string;
state: string;
secret: string;
createdAt: Date;
updatedAt: Date;
id: string
environmentId: string
sessionId: string
state: string
secret: string
createdAt: Date
updatedAt: Date
}
export interface SessionWorkerRecord {
sessionId: string;
workerStatus: string | null;
externalMetadata: Record<string, unknown> | null;
requiresActionDetails: Record<string, unknown> | null;
lastHeartbeatAt: Date | null;
createdAt: Date;
updatedAt: Date;
sessionId: string
workerStatus: string | null
externalMetadata: Record<string, unknown> | null
requiresActionDetails: Record<string, unknown> | null
lastHeartbeatAt: Date | null
createdAt: Date
updatedAt: Date
}
// ---------- Stores (in-memory Maps) ----------
const users = new Map<string, UserRecord>();
const tokenToUser = new Map<string, { username: string; createdAt: Date }>();
const environments = new Map<string, EnvironmentRecord>();
const sessions = new Map<string, SessionRecord>();
const workItems = new Map<string, WorkItemRecord>();
const sessionWorkers = new Map<string, SessionWorkerRecord>();
const users = new Map<string, UserRecord>()
const tokenToUser = new Map<string, { username: string; createdAt: Date }>()
const environments = new Map<string, EnvironmentRecord>()
const sessions = new Map<string, SessionRecord>()
const workItems = new Map<string, WorkItemRecord>()
const sessionWorkers = new Map<string, SessionWorkerRecord>()
// UUID → session ownership: sessionId → Set of UUIDs
const sessionOwners = new Map<string, Set<string>>();
const sessionOwners = new Map<string, Set<string>>()
// ---------- User ----------
export function storeCreateUser(username: string): UserRecord {
const existing = users.get(username);
if (existing) return existing;
const record: UserRecord = { username, createdAt: new Date() };
users.set(username, record);
return record;
const existing = users.get(username)
if (existing) return existing
const record: UserRecord = { username, createdAt: new Date() }
users.set(username, record)
return record
}
export function storeGetUser(username: string): UserRecord | undefined {
return users.get(username);
return users.get(username)
}
export function storeCreateToken(username: string, token: string): void {
tokenToUser.set(token, { username, createdAt: new Date() });
tokenToUser.set(token, { username, createdAt: new Date() })
}
export function storeGetUserByToken(token: string): { username: string; createdAt: Date } | undefined {
return tokenToUser.get(token);
export function storeGetUserByToken(
token: string,
): { username: string; createdAt: Date } | undefined {
return tokenToUser.get(token)
}
export function storeDeleteToken(token: string): boolean {
return tokenToUser.delete(token);
return tokenToUser.delete(token)
}
// ---------- Environment ----------
export function storeCreateEnvironment(req: {
secret: string;
machineName?: string;
directory?: string;
branch?: string;
gitRepoUrl?: string;
maxSessions?: number;
workerType?: string;
bridgeId?: string;
username?: string;
capabilities?: Record<string, unknown>;
secret: string
machineName?: string
directory?: string
branch?: string
gitRepoUrl?: string
maxSessions?: number
workerType?: string
bridgeId?: string
username?: string
capabilities?: Record<string, unknown>
}): EnvironmentRecord {
const id = `env_${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const id = `env_${randomUUID().replace(/-/g, '')}`
const now = new Date()
const record: EnvironmentRecord = {
id,
secret: req.secret,
@@ -120,108 +122,136 @@ export function storeCreateEnvironment(req: {
branch: req.branch ?? null,
gitRepoUrl: req.gitRepoUrl ?? null,
maxSessions: req.maxSessions ?? 1,
workerType: req.workerType ?? "claude_code",
workerType: req.workerType ?? 'claude_code',
bridgeId: req.bridgeId ?? null,
capabilities: req.capabilities ?? null,
status: "active",
status: 'active',
username: req.username ?? null,
lastPollAt: now,
createdAt: now,
updatedAt: now,
};
environments.set(id, record);
return record;
}
environments.set(id, record)
return record
}
export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
return environments.get(id);
return environments.get(id)
}
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt" | "capabilities" | "machineName" | "maxSessions" | "bridgeId">>): boolean {
const rec = environments.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
export function storeUpdateEnvironment(
id: string,
patch: Partial<
Pick<
EnvironmentRecord,
| 'status'
| 'lastPollAt'
| 'updatedAt'
| 'capabilities'
| 'machineName'
| 'maxSessions'
| 'bridgeId'
>
>,
): boolean {
const rec = environments.get(id)
if (!rec) return false
Object.assign(rec, patch, { updatedAt: new Date() })
return true
}
export function storeListActiveEnvironments(): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.status === "active");
return [...environments.values()].filter(e => e.status === 'active')
}
export function storeListActiveEnvironmentsByUsername(username: string): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.status === "active" && e.username === username);
export function storeListActiveEnvironmentsByUsername(
username: string,
): EnvironmentRecord[] {
return [...environments.values()].filter(
e => e.status === 'active' && e.username === username,
)
}
// ---------- Session ----------
export function storeCreateSession(req: {
environmentId?: string | null;
title?: string | null;
source?: string;
permissionMode?: string | null;
idPrefix?: string;
username?: string | null;
environmentId?: string | null
title?: string | null
source?: string
permissionMode?: string | null
idPrefix?: string
username?: string | null
}): SessionRecord {
const id = `${req.idPrefix || "session_"}${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const id = `${req.idPrefix || 'session_'}${randomUUID().replace(/-/g, '')}`
const now = new Date()
const record: SessionRecord = {
id,
environmentId: req.environmentId ?? null,
title: req.title ?? null,
status: "idle",
source: req.source ?? "remote-control",
status: 'idle',
source: req.source ?? 'remote-control',
permissionMode: req.permissionMode ?? null,
workerEpoch: 0,
username: req.username ?? null,
createdAt: now,
updatedAt: now,
};
sessions.set(id, record);
return record;
}
sessions.set(id, record)
return record
}
export function storeGetSession(id: string): SessionRecord | undefined {
return sessions.get(id);
return sessions.get(id)
}
export function storeUpdateSession(id: string, patch: Partial<Pick<SessionRecord, "title" | "status" | "workerEpoch" | "updatedAt">>): boolean {
const rec = sessions.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
export function storeUpdateSession(
id: string,
patch: Partial<
Pick<SessionRecord, 'title' | 'status' | 'workerEpoch' | 'updatedAt'>
>,
): boolean {
const rec = sessions.get(id)
if (!rec) return false
Object.assign(rec, patch, { updatedAt: new Date() })
return true
}
export function storeListSessions(): SessionRecord[] {
return [...sessions.values()];
return [...sessions.values()]
}
export function storeListSessionsByUsername(username: string): SessionRecord[] {
return [...sessions.values()].filter((s) => s.username === username);
return [...sessions.values()].filter(s => s.username === username)
}
export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
return [...sessions.values()].filter((s) => s.environmentId === envId);
return [...sessions.values()].filter(s => s.environmentId === envId)
}
export function storeDeleteSession(id: string): boolean {
sessionWorkers.delete(id);
return sessions.delete(id);
sessionWorkers.delete(id)
return sessions.delete(id)
}
// ---------- Session Worker ----------
export function storeGetSessionWorker(sessionId: string): SessionWorkerRecord | undefined {
return sessionWorkers.get(sessionId);
export function storeGetSessionWorker(
sessionId: string,
): SessionWorkerRecord | undefined {
return sessionWorkers.get(sessionId)
}
export function storeUpsertSessionWorker(sessionId: string, patch: {
workerStatus?: string | null;
externalMetadata?: Record<string, unknown> | null;
requiresActionDetails?: Record<string, unknown> | null;
lastHeartbeatAt?: Date | null;
}): SessionWorkerRecord {
const now = new Date();
const existing = sessionWorkers.get(sessionId);
export function storeUpsertSessionWorker(
sessionId: string,
patch: {
workerStatus?: string | null
externalMetadata?: Record<string, unknown> | null
requiresActionDetails?: Record<string, unknown> | null
lastHeartbeatAt?: Date | null
},
): SessionWorkerRecord {
const now = new Date()
const existing = sessionWorkers.get(sessionId)
const record: SessionWorkerRecord = existing ?? {
sessionId,
workerStatus: null,
@@ -230,31 +260,31 @@ export function storeUpsertSessionWorker(sessionId: string, patch: {
lastHeartbeatAt: null,
createdAt: now,
updatedAt: now,
};
}
if (patch.workerStatus !== undefined) {
record.workerStatus = patch.workerStatus;
record.workerStatus = patch.workerStatus
}
if (patch.externalMetadata !== undefined) {
if (patch.externalMetadata === null) {
record.externalMetadata = null;
record.externalMetadata = null
} else {
record.externalMetadata = {
...(record.externalMetadata ?? {}),
...patch.externalMetadata,
};
}
}
}
if (patch.requiresActionDetails !== undefined) {
record.requiresActionDetails = patch.requiresActionDetails;
record.requiresActionDetails = patch.requiresActionDetails
}
if (patch.lastHeartbeatAt !== undefined) {
record.lastHeartbeatAt = patch.lastHeartbeatAt;
record.lastHeartbeatAt = patch.lastHeartbeatAt
}
record.updatedAt = now;
record.updatedAt = now
sessionWorkers.set(sessionId, record);
return record;
sessionWorkers.set(sessionId, record)
return record
}
// ---------- Work Items ----------
@@ -262,141 +292,154 @@ export function storeUpsertSessionWorker(sessionId: string, patch: {
// ---------- Session Ownership (UUID-based) ----------
export function storeBindSession(sessionId: string, uuid: string): void {
let owners = sessionOwners.get(sessionId);
let owners = sessionOwners.get(sessionId)
if (!owners) {
owners = new Set();
sessionOwners.set(sessionId, owners);
owners = new Set()
sessionOwners.set(sessionId, owners)
}
owners.add(uuid);
owners.add(uuid)
}
export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
const owners = sessionOwners.get(sessionId);
return owners ? owners.has(uuid) : false;
const owners = sessionOwners.get(sessionId)
return owners ? owners.has(uuid) : false
}
export function storeGetSessionOwners(sessionId: string): Set<string> | undefined {
return sessionOwners.get(sessionId);
export function storeGetSessionOwners(
sessionId: string,
): Set<string> | undefined {
return sessionOwners.get(sessionId)
}
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
const result: SessionRecord[] = [];
const resultIds = new Set<string>();
const result: SessionRecord[] = []
const resultIds = new Set<string>()
// Collect sessions already owned by this UUID
for (const [sessionId, owners] of sessionOwners) {
if (owners.has(uuid)) {
const session = sessions.get(sessionId);
const session = sessions.get(sessionId)
if (session) {
result.push(session);
resultIds.add(sessionId);
result.push(session)
resultIds.add(sessionId)
}
}
}
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
for (const [sessionId, session] of sessions) {
if (resultIds.has(sessionId)) continue;
const owners = sessionOwners.get(sessionId);
if (resultIds.has(sessionId)) continue
const owners = sessionOwners.get(sessionId)
// No owners map entry at all, or empty owners set
const isOrphaned = !owners || owners.size === 0;
const isOrphaned = !owners || owners.size === 0
if (isOrphaned) {
storeBindSession(sessionId, uuid);
result.push(session);
resultIds.add(sessionId);
storeBindSession(sessionId, uuid)
result.push(session)
resultIds.add(sessionId)
}
}
return result;
return result
}
// ---------- Work Items (cont.) ----------
export function storeCreateWorkItem(req: {
environmentId: string;
sessionId: string;
secret: string;
environmentId: string
sessionId: string
secret: string
}): WorkItemRecord {
const id = `work_${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const id = `work_${randomUUID().replace(/-/g, '')}`
const now = new Date()
const record: WorkItemRecord = {
id,
environmentId: req.environmentId,
sessionId: req.sessionId,
state: "pending",
state: 'pending',
secret: req.secret,
createdAt: now,
updatedAt: now,
};
workItems.set(id, record);
return record;
}
workItems.set(id, record)
return record
}
export function storeGetWorkItem(id: string): WorkItemRecord | undefined {
return workItems.get(id);
return workItems.get(id)
}
export function storeGetPendingWorkItem(environmentId: string): WorkItemRecord | undefined {
export function storeGetPendingWorkItem(
environmentId: string,
): WorkItemRecord | undefined {
for (const item of workItems.values()) {
if (item.environmentId === environmentId && item.state === "pending") {
return item;
if (item.environmentId === environmentId && item.state === 'pending') {
return item
}
}
return undefined;
return undefined
}
export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemRecord, "state" | "updatedAt">>): boolean {
const rec = workItems.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
return true;
export function storeUpdateWorkItem(
id: string,
patch: Partial<Pick<WorkItemRecord, 'state' | 'updatedAt'>>,
): boolean {
const rec = workItems.get(id)
if (!rec) return false
Object.assign(rec, patch, { updatedAt: new Date() })
return true
}
// ---------- ACP Agent (reuses EnvironmentRecord with workerType="acp") ----------
/** List all ACP agents (environments with workerType="acp") */
export function storeListAcpAgents(): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.workerType === "acp");
return [...environments.values()].filter(e => e.workerType === 'acp')
}
/** List ACP agents by channel group (stored in bridgeId field) */
export function storeListAcpAgentsByChannelGroup(channelGroupId: string): EnvironmentRecord[] {
export function storeListAcpAgentsByChannelGroup(
channelGroupId: string,
): EnvironmentRecord[] {
return [...environments.values()].filter(
(e) => e.workerType === "acp" && e.bridgeId === channelGroupId,
);
e => e.workerType === 'acp' && e.bridgeId === channelGroupId,
)
}
/** List online ACP agents */
export function storeListOnlineAcpAgents(): EnvironmentRecord[] {
return [...environments.values()].filter(
(e) => e.workerType === "acp" && e.status === "active",
);
e => e.workerType === 'acp' && e.status === 'active',
)
}
/** Mark an ACP agent as offline */
export function storeMarkAcpAgentOffline(id: string): boolean {
const rec = environments.get(id);
if (!rec || rec.workerType !== "acp") return false;
Object.assign(rec, { status: "offline", updatedAt: new Date() });
return true;
const rec = environments.get(id)
if (!rec || rec.workerType !== 'acp') return false
Object.assign(rec, { status: 'offline', updatedAt: new Date() })
return true
}
/** Mark an ACP agent as online (on reconnect) */
export function storeMarkAcpAgentOnline(id: string): boolean {
const rec = environments.get(id);
if (!rec || rec.workerType !== "acp") return false;
Object.assign(rec, { status: "active", lastPollAt: new Date(), updatedAt: new Date() });
return true;
const rec = environments.get(id)
if (!rec || rec.workerType !== 'acp') return false
Object.assign(rec, {
status: 'active',
lastPollAt: new Date(),
updatedAt: new Date(),
})
return true
}
// ---------- Reset (for tests) ----------
export function storeReset() {
users.clear();
tokenToUser.clear();
environments.clear();
sessions.clear();
workItems.clear();
sessionWorkers.clear();
sessionOwners.clear();
users.clear()
tokenToUser.clear()
environments.clear()
sessions.clear()
workItems.clear()
sessionWorkers.clear()
sessionOwners.clear()
}

View File

@@ -1,72 +1,75 @@
import type { WSContext } from "hono/ws";
import {
findAcpConnectionByAgentId,
sendToAgentWs,
} from "./acp-ws-handler";
import { getAcpEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { log, error as logError } from "../logger";
import type { WSContext } from 'hono/ws'
import { findAcpConnectionByAgentId, sendToAgentWs } from './acp-ws-handler'
import { getAcpEventBus } from './event-bus'
import type { SessionEvent } from './event-bus'
import { log, error as logError } from '../logger'
// Per-relay connection state
interface RelayConnectionEntry {
agentId: string;
unsub: (() => void) | null;
keepalive: ReturnType<typeof setInterval> | null;
ws: WSContext;
openTime: number;
agentId: string
unsub: (() => void) | null
keepalive: ReturnType<typeof setInterval> | null
ws: WSContext
openTime: number
}
const relayConnections = new Map<string, RelayConnectionEntry>(); // key: relayWsId
const relayConnections = new Map<string, RelayConnectionEntry>() // key: relayWsId
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000;
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000
/** Send a JSON message to relay WS */
function sendToRelayWs(ws: WSContext, msg: object): void {
if (ws.readyState !== 1) return;
if (ws.readyState !== 1) return
try {
ws.send(JSON.stringify(msg));
ws.send(JSON.stringify(msg))
} catch (err) {
logError("[ACP-Relay] send error:", err);
logError('[ACP-Relay] send error:', err)
}
}
/** Called from onOpen — finds target agent and bridges connection */
export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: string): void {
log(`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`);
export function handleRelayOpen(
ws: WSContext,
relayWsId: string,
agentId: string,
): void {
log(
`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`,
)
// Check if agent is online
const agentConn = findAcpConnectionByAgentId(agentId);
const agentConn = findAcpConnectionByAgentId(agentId)
if (!agentConn) {
log(`[ACP-Relay] Agent ${agentId} not found or offline`);
sendToRelayWs(ws, { type: "error", message: "Agent not found or offline" });
ws.close(4004, "agent not found");
return;
log(`[ACP-Relay] Agent ${agentId} not found or offline`)
sendToRelayWs(ws, { type: 'error', message: 'Agent not found or offline' })
ws.close(4004, 'agent not found')
return
}
// Keepalive interval
const keepalive = setInterval(() => {
const entry = relayConnections.get(relayWsId);
const entry = relayConnections.get(relayWsId)
if (!entry || entry.ws.readyState !== 1) {
clearInterval(keepalive);
return;
clearInterval(keepalive)
return
}
sendToRelayWs(entry.ws, { type: "keep_alive" });
}, RELAY_KEEPALIVE_INTERVAL_MS);
sendToRelayWs(entry.ws, { type: 'keep_alive' })
}, RELAY_KEEPALIVE_INTERVAL_MS)
// Subscribe to channel group EventBus — forward agent responses to frontend
const channelGroupId = agentConn.channelGroupId;
const bus = getAcpEventBus(channelGroupId);
const channelGroupId = agentConn.channelGroupId
const bus = getAcpEventBus(channelGroupId)
const unsub = bus.subscribe((event: SessionEvent) => {
if (ws.readyState !== 1) return;
if (event.direction !== "inbound") return;
if (ws.readyState !== 1) return
if (event.direction !== 'inbound') return
// Handle agent disconnect specially: send status to frontend
if (event.type === "agent_disconnect") {
sendToRelayWs(ws, { type: "status", payload: { connected: false } });
return;
if (event.type === 'agent_disconnect') {
sendToRelayWs(ws, { type: 'status', payload: { connected: false } })
return
}
// Forward agent responses to the frontend WebSocket
sendToRelayWs(ws, event.payload as object);
});
sendToRelayWs(ws, event.payload as object)
})
relayConnections.set(relayWsId, {
agentId,
@@ -74,7 +77,7 @@ export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: strin
keepalive,
ws,
openTime: Date.now(),
});
})
// Don't send a synthetic status message here!
// The frontend sends a "connect" command, which acp-link processes
@@ -82,70 +85,83 @@ export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: strin
// Sending a fake status would make the frontend think it's connected
// before the agent process is actually ready.
log(`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`);
log(
`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`,
)
}
/** Called from onMessage — forwards frontend messages to acp-link */
export function handleRelayMessage(ws: WSContext, relayWsId: string, data: string): void {
const entry = relayConnections.get(relayWsId);
if (!entry) return;
export function handleRelayMessage(
ws: WSContext,
relayWsId: string,
data: string,
): void {
const entry = relayConnections.get(relayWsId)
if (!entry) return
const lines = data.split("\n").filter((l) => l.trim());
const lines = data.split('\n').filter(l => l.trim())
for (const line of lines) {
let msg: Record<string, unknown>;
let msg: Record<string, unknown>
try {
msg = JSON.parse(line);
msg = JSON.parse(line)
} catch {
logError("[ACP-Relay] parse error:", line);
continue;
logError('[ACP-Relay] parse error:', line)
continue
}
// Ignore keepalive responses
if (msg.type === "keep_alive") continue;
if (msg.type === 'keep_alive') continue
// Forward to acp-link agent
const sent = sendToAgentWs(entry.agentId, msg);
const sent = sendToAgentWs(entry.agentId, msg)
if (!sent) {
sendToRelayWs(ws, { type: "error", message: "Agent connection lost" });
return;
sendToRelayWs(ws, { type: 'error', message: 'Agent connection lost' })
return
}
}
}
/** Called from onClose — cleans up relay connection */
export function handleRelayClose(ws: WSContext, relayWsId: string, code?: number, reason?: string): void {
const entry = relayConnections.get(relayWsId);
if (!entry) return;
export function handleRelayClose(
ws: WSContext,
relayWsId: string,
code?: number,
reason?: string,
): void {
const entry = relayConnections.get(relayWsId)
if (!entry) return
const duration = Math.round((Date.now() - entry.openTime) / 1000);
log(`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
const duration = Math.round((Date.now() - entry.openTime) / 1000)
log(
`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
)
if (entry.unsub) {
entry.unsub();
entry.unsub()
}
if (entry.keepalive) {
clearInterval(entry.keepalive);
clearInterval(entry.keepalive)
}
relayConnections.delete(relayWsId);
relayConnections.delete(relayWsId)
}
/** Close all relay connections (for graceful shutdown) */
export function closeAllRelayConnections(): void {
if (relayConnections.size === 0) return;
if (relayConnections.size === 0) return
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`);
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`)
for (const [relayWsId, entry] of relayConnections) {
try {
if (entry.unsub) entry.unsub();
if (entry.keepalive) clearInterval(entry.keepalive);
if (entry.unsub) entry.unsub()
if (entry.keepalive) clearInterval(entry.keepalive)
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
entry.ws.close(1001, 'server_shutdown')
}
} catch {
// ignore errors during shutdown
}
}
relayConnections.clear();
log("[ACP-Relay] All relay connections closed");
relayConnections.clear()
log('[ACP-Relay] All relay connections closed')
}

View File

@@ -1,19 +1,23 @@
import { log } from "../logger";
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getAcpEventBus } from "./event-bus";
import { log } from '../logger'
import type { Context } from 'hono'
import type { SessionEvent } from './event-bus'
import { getAcpEventBus } from './event-bus'
/** Create SSE response stream for an ACP channel group */
export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNum = 0) {
const bus = getAcpEventBus(channelGroupId);
export function createAcpSSEStream(
c: Context,
channelGroupId: string,
fromSeqNum = 0,
) {
const bus = getAcpEventBus(channelGroupId)
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const encoder = new TextEncoder()
// Send historical events if reconnecting
if (fromSeqNum > 0) {
const missed = bus.getEventsSince(fromSeqNum);
const missed = bus.getEventsSince(fromSeqNum)
for (const event of missed) {
const data = JSON.stringify({
type: event.type,
@@ -21,60 +25,70 @@ export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNu
direction: event.direction,
seqNum: event.seqNum,
channel_group_id: channelGroupId,
});
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
})
controller.enqueue(
encoder.encode(
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
),
)
}
}
// Send initial keepalive
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
// Subscribe to new events
const unsub = bus.subscribe((event) => {
const unsub = bus.subscribe(event => {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
channel_group_id: channelGroupId,
});
})
try {
log(`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`);
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
log(
`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`,
)
controller.enqueue(
encoder.encode(
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
),
)
} catch {
unsub();
unsub()
}
});
})
// Keepalive interval
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
} catch {
clearInterval(keepalive);
unsub();
clearInterval(keepalive)
unsub()
}
}, 15000);
}, 15000)
// Cleanup on abort
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
c.req.raw.signal.addEventListener('abort', () => {
unsub()
clearInterval(keepalive)
try {
controller.close();
controller.close()
} catch {
// already closed
}
});
})
},
});
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
})
}

View File

@@ -1,313 +1,343 @@
import type { WSContext } from "hono/ws";
import { randomUUID } from "node:crypto";
import { getAcpEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import type { WSContext } from 'hono/ws'
import { randomUUID } from 'node:crypto'
import { getAcpEventBus } from './event-bus'
import type { SessionEvent } from './event-bus'
import {
storeCreateEnvironment,
storeGetEnvironment,
storeMarkAcpAgentOffline,
storeMarkAcpAgentOnline,
storeUpdateEnvironment,
} from "../store";
import { config } from "../config";
import { log, error as logError } from "../logger";
} from '../store'
import { config } from '../config'
import { log, error as logError } from '../logger'
// Per-connection state
interface AcpConnectionEntry {
agentId: string | null; // Set after register message
channelGroupId: string;
unsub: (() => void) | null;
keepalive: ReturnType<typeof setInterval> | null;
ws: WSContext;
openTime: number;
lastClientActivity: number;
capabilities: Record<string, unknown> | null;
agentId: string | null // Set after register message
channelGroupId: string
unsub: (() => void) | null
keepalive: ReturnType<typeof setInterval> | null
ws: WSContext
openTime: number
lastClientActivity: number
capabilities: Record<string, unknown> | null
}
const connections = new Map<string, AcpConnectionEntry>(); // key: wsId
const connections = new Map<string, AcpConnectionEntry>() // key: wsId
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000;
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3
/** Send a JSON message to a WS connection (NDJSON format) */
function sendToWs(ws: WSContext, msg: object): void {
if (ws.readyState !== 1) return;
if (ws.readyState !== 1) return
try {
ws.send(JSON.stringify(msg) + "\n");
ws.send(JSON.stringify(msg) + '\n')
} catch (err) {
logError("[ACP-WS] send error:", err);
logError('[ACP-WS] send error:', err)
}
}
/** Called from onOpen — initializes connection tracking */
export function handleAcpWsOpen(ws: WSContext, wsId: string): void {
log(`[ACP-WS] Connection opened: wsId=${wsId}`);
log(`[ACP-WS] Connection opened: wsId=${wsId}`)
const keepalive = setInterval(() => {
const entry = connections.get(wsId);
const entry = connections.get(wsId)
if (!entry || entry.ws.readyState !== 1) {
clearInterval(keepalive);
return;
clearInterval(keepalive)
return
}
const silenceMs = Date.now() - entry.lastClientActivity;
const silenceMs = Date.now() - entry.lastClientActivity
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
log(`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`);
log(
`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`,
)
try {
entry.ws.close(1000, "client inactive");
entry.ws.close(1000, 'client inactive')
} catch {
clearInterval(keepalive);
clearInterval(keepalive)
}
return;
return
}
sendToWs(entry.ws, { type: "keep_alive" });
}, SERVER_KEEPALIVE_INTERVAL_MS);
sendToWs(entry.ws, { type: 'keep_alive' })
}, SERVER_KEEPALIVE_INTERVAL_MS)
connections.set(wsId, {
agentId: null,
channelGroupId: "",
channelGroupId: '',
unsub: null,
keepalive,
ws,
openTime: Date.now(),
lastClientActivity: Date.now(),
capabilities: null,
});
})
}
/** Handle register message — legacy WS-only registration (still supported) */
function handleRegister(wsId: string, msg: Record<string, unknown>): void {
const entry = connections.get(wsId);
if (!entry) return;
const entry = connections.get(wsId)
if (!entry) return
if (entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Already registered" });
return;
sendToWs(entry.ws, { type: 'error', message: 'Already registered' })
return
}
const agentName = (msg.agent_name as string) || "unknown";
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
const channelGroupId = (msg.channel_group_id as string) || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const acpLinkVersion = (msg.acp_link_version as string) || null;
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
const agentName = (msg.agent_name as string) || 'unknown'
const capabilities = msg.capabilities as Record<string, unknown> | undefined
const channelGroupId =
(msg.channel_group_id as string) ||
`group_${randomUUID().replace(/-/g, '').slice(0, 12)}`
const acpLinkVersion = (msg.acp_link_version as string) || null
const maxSessions =
typeof msg.max_sessions === 'number' ? msg.max_sessions : 1
// Create EnvironmentRecord with workerType="acp"
const secret = config.apiKeys[0] || "";
const secret = config.apiKeys[0] || ''
const record = storeCreateEnvironment({
secret,
machineName: agentName,
workerType: "acp",
workerType: 'acp',
bridgeId: channelGroupId,
maxSessions,
capabilities: capabilities || undefined,
} as Parameters<typeof storeCreateEnvironment>[0]);
} as Parameters<typeof storeCreateEnvironment>[0])
// Store ACP-specific metadata via environment update
storeUpdateEnvironment(record.id, {
status: "active",
} as Parameters<typeof storeUpdateEnvironment>[1]);
status: 'active',
} as Parameters<typeof storeUpdateEnvironment>[1])
entry.agentId = record.id;
entry.channelGroupId = channelGroupId;
entry.capabilities = capabilities || null;
entry.agentId = record.id
entry.channelGroupId = channelGroupId
entry.capabilities = capabilities || null
// Subscribe to channel group EventBus — broadcast events to this WS
const bus = getAcpEventBus(channelGroupId);
const bus = getAcpEventBus(channelGroupId)
const unsub = bus.subscribe((event: SessionEvent) => {
if (entry.ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
if (entry.ws.readyState !== 1) return
if (event.direction !== 'outbound') return
// Forward outbound events as raw ACP messages
sendToWs(entry.ws, event.payload as object);
});
entry.unsub = unsub;
sendToWs(entry.ws, event.payload as object)
})
entry.unsub = unsub
log(`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`);
log(
`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`,
)
sendToWs(entry.ws, {
type: "registered",
type: 'registered',
agent_id: record.id,
channel_group_id: channelGroupId,
});
})
}
/** Handle identify message — binds WS to an existing agent registered via REST */
function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
const entry = connections.get(wsId);
if (!entry) return;
const entry = connections.get(wsId)
if (!entry) return
if (entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Already identified" });
return;
sendToWs(entry.ws, { type: 'error', message: 'Already identified' })
return
}
const agentId = msg.agent_id as string;
const agentId = msg.agent_id as string
if (!agentId) {
sendToWs(entry.ws, { type: "error", message: "Missing agent_id" });
return;
sendToWs(entry.ws, { type: 'error', message: 'Missing agent_id' })
return
}
// Look up the environment record (created via REST registration)
const record = storeGetEnvironment(agentId);
if (!record || record.workerType !== "acp") {
sendToWs(entry.ws, { type: "error", message: "Agent not found" });
return;
const record = storeGetEnvironment(agentId)
if (!record || record.workerType !== 'acp') {
sendToWs(entry.ws, { type: 'error', message: 'Agent not found' })
return
}
// Update status to active
storeMarkAcpAgentOnline(agentId);
storeMarkAcpAgentOnline(agentId)
const channelGroupId = record.bridgeId || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const channelGroupId =
record.bridgeId || `group_${randomUUID().replace(/-/g, '').slice(0, 12)}`
entry.agentId = record.id;
entry.channelGroupId = channelGroupId;
entry.capabilities = record.capabilities || null;
entry.agentId = record.id
entry.channelGroupId = channelGroupId
entry.capabilities = record.capabilities || null
// Subscribe to channel group EventBus — broadcast events to this WS
const bus = getAcpEventBus(channelGroupId);
const bus = getAcpEventBus(channelGroupId)
const unsub = bus.subscribe((event: SessionEvent) => {
if (entry.ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
sendToWs(entry.ws, event.payload as object);
});
entry.unsub = unsub;
if (entry.ws.readyState !== 1) return
if (event.direction !== 'outbound') return
sendToWs(entry.ws, event.payload as object)
})
entry.unsub = unsub
log(`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`);
log(
`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`,
)
sendToWs(entry.ws, {
type: "identified",
type: 'identified',
agent_id: record.id,
channel_group_id: channelGroupId,
});
})
}
/** Called from onMessage — processes NDJSON lines */
export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): void {
const entry = connections.get(wsId);
if (!entry) return;
export function handleAcpWsMessage(
ws: WSContext,
wsId: string,
data: string,
): void {
const entry = connections.get(wsId)
if (!entry) return
entry.lastClientActivity = Date.now();
entry.lastClientActivity = Date.now()
const lines = data.split("\n").filter((l) => l.trim());
const lines = data.split('\n').filter(l => l.trim())
for (const line of lines) {
let msg: Record<string, unknown>;
let msg: Record<string, unknown>
try {
msg = JSON.parse(line);
msg = JSON.parse(line)
} catch {
logError("[ACP-WS] parse error:", line);
continue;
logError('[ACP-WS] parse error:', line)
continue
}
// Handle keepalive
if (msg.type === "keep_alive") {
if (msg.type === 'keep_alive') {
// Update last activity timestamp (only if registered)
if (entry.agentId) {
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
storeUpdateEnvironment(entry.agentId, {
lastPollAt: new Date(),
} as Parameters<typeof storeUpdateEnvironment>[1])
}
continue;
continue
}
// Handle registration (legacy WS-only)
if (msg.type === "register") {
handleRegister(wsId, msg);
continue;
if (msg.type === 'register') {
handleRegister(wsId, msg)
continue
}
// Handle identify (REST registration + WS binding)
if (msg.type === "identify") {
handleIdentify(wsId, msg);
continue;
if (msg.type === 'identify') {
handleIdentify(wsId, msg)
continue
}
// Not registered yet — reject
if (!entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Not registered. Send register message first." });
continue;
sendToWs(entry.ws, {
type: 'error',
message: 'Not registered. Send register message first.',
})
continue
}
// Update agent activity
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
storeUpdateEnvironment(entry.agentId, {
lastPollAt: new Date(),
} as Parameters<typeof storeUpdateEnvironment>[1])
// Pass-through: publish to channel group EventBus as inbound
const bus = getAcpEventBus(entry.channelGroupId);
const bus = getAcpEventBus(entry.channelGroupId)
bus.publish({
id: randomUUID(),
sessionId: entry.channelGroupId,
type: (msg.type as string) || "acp_message",
type: (msg.type as string) || 'acp_message',
payload: msg,
direction: "inbound",
});
direction: 'inbound',
})
}
}
/** Called from onClose — marks agent offline and cleans up */
export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, reason?: string): void {
const entry = connections.get(wsId);
if (!entry) return;
export function handleAcpWsClose(
ws: WSContext,
wsId: string,
code?: number,
reason?: string,
): void {
const entry = connections.get(wsId)
if (!entry) return
const duration = Math.round((Date.now() - entry.openTime) / 1000);
log(`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
const duration = Math.round((Date.now() - entry.openTime) / 1000)
log(
`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
)
if (entry.unsub) {
entry.unsub();
entry.unsub()
}
if (entry.keepalive) {
clearInterval(entry.keepalive);
clearInterval(entry.keepalive)
}
// Mark agent as offline (don't delete record — allow reconnect)
if (entry.agentId) {
storeMarkAcpAgentOffline(entry.agentId);
storeMarkAcpAgentOffline(entry.agentId)
// Notify all relay connections that this agent is gone
if (entry.channelGroupId) {
const bus = getAcpEventBus(entry.channelGroupId);
const bus = getAcpEventBus(entry.channelGroupId)
bus.publish({
id: randomUUID(),
sessionId: entry.channelGroupId,
type: "agent_disconnect",
type: 'agent_disconnect',
payload: { agentId: entry.agentId },
direction: "inbound",
});
direction: 'inbound',
})
}
}
connections.delete(wsId);
connections.delete(wsId)
}
/** Find an active ACP connection by agent ID */
export function findAcpConnectionByAgentId(agentId: string): AcpConnectionEntry | null {
export function findAcpConnectionByAgentId(
agentId: string,
): AcpConnectionEntry | null {
for (const entry of connections.values()) {
if (entry.agentId === agentId && entry.ws.readyState === 1) {
return entry;
return entry
}
}
return null;
return null
}
/** Send a JSON message directly to an agent's WebSocket connection */
export function sendToAgentWs(agentId: string, msg: object): boolean {
const entry = findAcpConnectionByAgentId(agentId);
if (!entry) return false;
sendToWs(entry.ws, msg);
return true;
const entry = findAcpConnectionByAgentId(agentId)
if (!entry) return false
sendToWs(entry.ws, msg)
return true
}
/** Gracefully close all ACP WebSocket connections */
export function closeAllAcpConnections(): void {
if (connections.size === 0) return;
if (connections.size === 0) return
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`);
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`)
for (const [wsId, entry] of connections) {
try {
if (entry.unsub) entry.unsub();
if (entry.keepalive) clearInterval(entry.keepalive);
if (entry.unsub) entry.unsub()
if (entry.keepalive) clearInterval(entry.keepalive)
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
entry.ws.close(1001, 'server_shutdown')
}
if (entry.agentId) {
storeMarkAcpAgentOffline(entry.agentId);
storeMarkAcpAgentOffline(entry.agentId)
}
} catch {
// ignore errors during shutdown
}
}
connections.clear();
log("[ACP-WS] All connections closed");
connections.clear()
log('[ACP-WS] All connections closed')
}

View File

@@ -1,74 +1,83 @@
import type { SessionEvent } from "./event-bus";
import type { SessionEvent } from './event-bus'
/**
* Convert an internal session event into the SDK/control message shape that
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
*/
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
const payload = event.payload as Record<string, unknown> | null;
const payload = event.payload as Record<string, unknown> | null
const messageUuid =
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
typeof payload?.uuid === 'string' && payload.uuid ? payload.uuid : event.id
if (event.type === "user" || event.type === "user_message") {
if (event.type === 'user' || event.type === 'user_message') {
return {
type: "user",
type: 'user',
uuid: messageUuid,
session_id: event.sessionId,
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
role: 'user',
content: payload?.content ?? payload?.message ?? '',
},
};
}
}
if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (
event.type === 'permission_response' ||
event.type === 'control_response'
) {
const approved = !!payload?.approved
const existingResponse = payload?.response as
| Record<string, unknown>
| undefined
if (existingResponse) {
return { type: "control_response", response: existingResponse };
return { type: 'control_response', response: existingResponse }
}
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
const updatedInput = payload?.updated_input as
| Record<string, unknown>
| undefined
const updatedPermissions = payload?.updated_permissions as
| Record<string, unknown>[]
| undefined
const feedbackMessage = payload?.message as string | undefined
return {
type: "control_response",
type: 'control_response',
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
subtype: approved ? 'success' : 'error',
request_id: payload?.request_id ?? '',
...(approved
? {
response: {
behavior: "allow" as const,
behavior: 'allow' as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
error: 'Permission denied by user',
response: { behavior: 'deny' as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
}
if (event.type === "interrupt") {
if (event.type === 'interrupt') {
return {
type: "control_request",
type: 'control_request',
request_id: event.id,
request: { subtype: "interrupt" },
};
request: { subtype: 'interrupt' },
}
}
if (event.type === "control_request") {
if (event.type === 'control_request') {
return {
type: "control_request",
type: 'control_request',
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
}
}
return {
@@ -76,5 +85,5 @@ export function toClientPayload(event: SessionEvent): Record<string, unknown> {
uuid: messageUuid,
session_id: event.sessionId,
message: payload,
};
}
}

View File

@@ -1,116 +1,116 @@
import { log, error as logError } from "../logger";
import { log, error as logError } from '../logger'
export interface SessionEvent {
id: string;
sessionId: string;
type: string;
payload: unknown;
direction: "inbound" | "outbound";
seqNum: number;
createdAt: number;
id: string
sessionId: string
type: string
payload: unknown
direction: 'inbound' | 'outbound'
seqNum: number
createdAt: number
}
type Subscriber = (event: SessionEvent) => void;
type Subscriber = (event: SessionEvent) => void
const MAX_EVENTS_PER_BUS = 5000;
const MAX_EVENTS_PER_BUS = 5000
export class EventBus {
private subscribers = new Set<Subscriber>();
private events: SessionEvent[] = [];
private seqNum = 0;
private closed = false;
private subscribers = new Set<Subscriber>()
private events: SessionEvent[] = []
private seqNum = 0
private closed = false
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
this.subscribers.add(callback)
return () => this.subscribers.delete(callback)
}
subscriberCount(): number {
return this.subscribers.size;
return this.subscribers.size
}
publish(event: Omit<SessionEvent, "seqNum" | "createdAt">): SessionEvent {
if (this.closed) throw new Error("EventBus is closed");
publish(event: Omit<SessionEvent, 'seqNum' | 'createdAt'>): SessionEvent {
if (this.closed) throw new Error('EventBus is closed')
const full: SessionEvent = {
...event,
seqNum: ++this.seqNum,
createdAt: Date.now(),
};
this.events.push(full);
}
this.events.push(full)
// Evict oldest events when exceeding limit
if (this.events.length > MAX_EVENTS_PER_BUS) {
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2));
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2))
}
log(
`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`,
event.type === "error" ? `payload=${JSON.stringify(event.payload)}` : "",
);
event.type === 'error' ? `payload=${JSON.stringify(event.payload)}` : '',
)
for (const cb of this.subscribers) {
try {
cb(full);
cb(full)
} catch (err) {
logError(`[RC-DEBUG] bus subscriber error:`, err);
logError(`[RC-DEBUG] bus subscriber error:`, err)
}
}
return full;
return full
}
getLastSeqNum(): number {
return this.seqNum;
return this.seqNum
}
getEventsSince(seqNum: number): SessionEvent[] {
const idx = this.events.findIndex((e) => e.seqNum > seqNum);
if (idx === -1) return [];
return this.events.slice(idx);
const idx = this.events.findIndex(e => e.seqNum > seqNum)
if (idx === -1) return []
return this.events.slice(idx)
}
close() {
this.closed = true;
this.subscribers.clear();
this.closed = true
this.subscribers.clear()
}
}
/** Global registry of per-session event buses */
const buses = new Map<string, EventBus>();
const buses = new Map<string, EventBus>()
export function getEventBus(sessionId: string): EventBus {
let bus = buses.get(sessionId);
let bus = buses.get(sessionId)
if (!bus) {
bus = new EventBus();
buses.set(sessionId, bus);
bus = new EventBus()
buses.set(sessionId, bus)
}
return bus;
return bus
}
export function removeEventBus(sessionId: string) {
const bus = buses.get(sessionId);
const bus = buses.get(sessionId)
if (bus) {
bus.close();
buses.delete(sessionId);
bus.close()
buses.delete(sessionId)
}
}
export function getAllEventBuses(): Map<string, EventBus> {
return buses;
return buses
}
/** Global registry of per-channel-group ACP event buses */
const acpBuses = new Map<string, EventBus>();
const acpBuses = new Map<string, EventBus>()
export function getAcpEventBus(channelGroupId: string): EventBus {
let bus = acpBuses.get(channelGroupId);
let bus = acpBuses.get(channelGroupId)
if (!bus) {
bus = new EventBus();
acpBuses.set(channelGroupId, bus);
bus = new EventBus()
acpBuses.set(channelGroupId, bus)
}
return bus;
return bus
}
export function removeAcpEventBus(channelGroupId: string) {
const bus = acpBuses.get(channelGroupId);
const bus = acpBuses.get(channelGroupId)
if (bus) {
bus.close();
acpBuses.delete(channelGroupId);
bus.close()
acpBuses.delete(channelGroupId)
}
}

View File

@@ -1,163 +1,177 @@
import { log, error as logError } from "../logger";
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getEventBus } from "./event-bus";
import { toClientPayload } from "./client-payload";
import { log, error as logError } from '../logger'
import type { Context } from 'hono'
import type { SessionEvent } from './event-bus'
import { getEventBus } from './event-bus'
import { toClientPayload } from './client-payload'
export interface SSEWriter {
send(event: SessionEvent): void;
close(): void;
send(event: SessionEvent): void
close(): void
}
export function createSSEWriter(c: Context): SSEWriter {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
c.req.raw.signal.addEventListener("abort", () => {
controller.close();
});
const encoder = new TextEncoder()
c.req.raw.signal.addEventListener('abort', () => {
controller.close()
})
// Store encoder and controller for later use
(c as any)._sseEncoder = encoder;
(c as any)._sseController = controller;
;(c as any)._sseEncoder = encoder
;(c as any)._sseController = controller
},
});
})
return {
send(event: SessionEvent) {
const encoder = (c as any)._sseEncoder as TextEncoder;
const controller = (c as any)._sseController as ReadableStreamDefaultController;
if (!encoder || !controller) return;
const encoder = (c as any)._sseEncoder as TextEncoder
const controller = (c as any)
._sseController as ReadableStreamDefaultController
if (!encoder || !controller) return
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`;
controller.enqueue(encoder.encode(msg));
})
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`
controller.enqueue(encoder.encode(msg))
},
close() {
const controller = (c as any)._sseController as ReadableStreamDefaultController;
controller?.close();
const controller = (c as any)
._sseController as ReadableStreamDefaultController
controller?.close()
},
};
}
}
/** Create SSE response stream for a session */
export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
const bus = getEventBus(sessionId);
const bus = getEventBus(sessionId)
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const encoder = new TextEncoder()
// Send historical events if reconnecting
if (fromSeqNum > 0) {
const missed = bus.getEventsSince(fromSeqNum);
const missed = bus.getEventsSince(fromSeqNum)
for (const event of missed) {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
})
controller.enqueue(
encoder.encode(
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
),
)
}
}
// Send initial keepalive
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
// Subscribe to new events
const unsub = bus.subscribe((event) => {
const unsub = bus.subscribe(event => {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
});
})
try {
log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
log(
`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`,
)
controller.enqueue(
encoder.encode(
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
),
)
} catch {
unsub();
unsub()
}
});
})
// Keepalive interval
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
} catch {
clearInterval(keepalive);
unsub();
clearInterval(keepalive)
unsub()
}
}, 15000);
}, 15000)
// Cleanup on abort
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
c.req.raw.signal.addEventListener('abort', () => {
unsub()
clearInterval(keepalive)
try {
controller.close();
controller.close()
} catch {
// already closed
}
});
})
},
});
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
})
}
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
if (
event.type === "permission_response" ||
event.type === "control_response" ||
event.type === "control_request" ||
event.type === "interrupt"
event.type === 'permission_response' ||
event.type === 'control_response' ||
event.type === 'control_request' ||
event.type === 'interrupt'
) {
return toClientPayload(event);
return toClientPayload(event)
}
const normalized =
event.payload && typeof event.payload === "object"
event.payload && typeof event.payload === 'object'
? (event.payload as Record<string, unknown>)
: undefined;
: undefined
const raw =
normalized?.raw && typeof normalized.raw === "object" && !Array.isArray(normalized.raw)
normalized?.raw &&
typeof normalized.raw === 'object' &&
!Array.isArray(normalized.raw)
? (normalized.raw as Record<string, unknown>)
: undefined;
: undefined
const payload: Record<string, unknown> = {
...(raw ?? normalized ?? {}),
type: event.type,
};
}
if (event.type === "user") {
const message = payload.message;
if (!message || typeof message !== "object" || !("content" in message)) {
if (event.type === 'user') {
const message = payload.message
if (!message || typeof message !== 'object' || !('content' in message)) {
const content =
typeof normalized?.content === "string"
typeof normalized?.content === 'string'
? normalized.content
: typeof payload.content === "string"
: typeof payload.content === 'string'
? payload.content
: typeof event.payload === "string"
: typeof event.payload === 'string'
? event.payload
: "";
payload.content = content;
payload.message = { content };
: ''
payload.content = content
payload.message = { content }
}
}
return payload;
return payload
}
function toWorkerClientFrame(event: SessionEvent): string {
@@ -165,70 +179,74 @@ function toWorkerClientFrame(event: SessionEvent): string {
event_id: event.id,
sequence_num: event.seqNum,
event_type: event.type,
source: "client",
source: 'client',
payload: toWorkerClientPayload(event),
created_at: new Date(event.createdAt).toISOString(),
});
return `id: ${event.seqNum}\nevent: client_event\ndata: ${data}\n\n`;
})
return `id: ${event.seqNum}\nevent: client_event\ndata: ${data}\n\n`
}
/** Create CCR worker SSE stream (client_event frames, outbound events only). */
export function createWorkerEventStream(c: Context, sessionId: string, fromSeqNum = 0) {
const bus = getEventBus(sessionId);
export function createWorkerEventStream(
c: Context,
sessionId: string,
fromSeqNum = 0,
) {
const bus = getEventBus(sessionId)
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const encoder = new TextEncoder()
if (fromSeqNum > 0) {
const missed = bus
.getEventsSince(fromSeqNum)
.filter((event) => event.direction === "outbound");
.filter(event => event.direction === 'outbound')
for (const event of missed) {
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
controller.enqueue(encoder.encode(toWorkerClientFrame(event)))
}
}
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
const unsub = bus.subscribe((event) => {
if (event.direction !== "outbound") {
return;
const unsub = bus.subscribe(event => {
if (event.direction !== 'outbound') {
return
}
try {
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
controller.enqueue(encoder.encode(toWorkerClientFrame(event)))
} catch {
unsub();
unsub()
}
});
})
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
controller.enqueue(encoder.encode(': keepalive\n\n'))
} catch {
clearInterval(keepalive);
unsub();
clearInterval(keepalive)
unsub()
}
}, 15000);
}, 15000)
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
c.req.raw.signal.addEventListener('abort', () => {
unsub()
clearInterval(keepalive)
try {
controller.close();
controller.close()
} catch {
// already closed
}
});
})
},
});
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
})
}

View File

@@ -1,31 +1,31 @@
import type { WSContext } from "hono/ws";
import { getEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { publishSessionEvent } from "../services/transport";
import { log, error as logError } from "../logger";
import { toClientPayload } from "./client-payload";
import { config } from "../config";
import type { WSContext } from 'hono/ws'
import { getEventBus } from './event-bus'
import type { SessionEvent } from './event-bus'
import { publishSessionEvent } from '../services/transport'
import { log, error as logError } from '../logger'
import { toClientPayload } from './client-payload'
import { config } from '../config'
// Per-connection cleanup, keyed by sessionId (only one WS per session)
interface CleanupEntry {
unsub: () => void;
keepalive: ReturnType<typeof setInterval>;
ws: WSContext;
openTime: number;
lastClientActivity: number;
unsub: () => void
keepalive: ReturnType<typeof setInterval>
ws: WSContext
openTime: number
lastClientActivity: number
}
const cleanupBySession = new Map<string, CleanupEntry>();
const cleanupBySession = new Map<string, CleanupEntry>()
// Track all active WS connections for graceful shutdown
const activeConnections = new Set<WSContext>();
const activeConnections = new Set<WSContext>()
// Server-side keepalive interval (configurable via RCS_WS_KEEPALIVE_INTERVAL).
// Sends data frames to keep reverse proxies from closing idle connections.
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000;
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000
// If no client data received within this threshold, the connection is
// considered dead. Set to 3x keepalive to tolerate one missed interval.
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3
/**
* Convert internal EventBus event -> SDK message for bridge client.
@@ -33,36 +33,36 @@ const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
function toSDKMessage(event: SessionEvent): string {
// NDJSON format: each message MUST end with \n so the child process's
// line-based parser can split messages correctly.
return JSON.stringify(toClientPayload(event)) + "\n";
return JSON.stringify(toClientPayload(event)) + '\n'
}
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
const openTime = Date.now();
const lastClientActivity = Date.now();
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
activeConnections.add(ws);
const openTime = Date.now()
const lastClientActivity = Date.now()
log(`[RC-DEBUG] [WS] Open session=${sessionId}`)
activeConnections.add(ws)
// If there's an existing connection for this session, clean it up first
const existing = cleanupBySession.get(sessionId);
const existing = cleanupBySession.get(sessionId)
if (existing) {
log(`[WS] Replacing existing connection for session=${sessionId}`);
existing.unsub();
clearInterval(existing.keepalive);
activeConnections.delete(existing.ws);
log(`[WS] Replacing existing connection for session=${sessionId}`)
existing.unsub()
clearInterval(existing.keepalive)
activeConnections.delete(existing.ws)
}
const bus = getEventBus(sessionId);
const bus = getEventBus(sessionId)
// Replay ALL events (inbound + outbound) so the bridge can reconstruct
// the full conversation history — assistant replies are inbound events.
const missed = bus.getEventsSince(0);
const missed = bus.getEventsSince(0)
if (missed.length > 0) {
log(`[WS] Replaying ${missed.length} missed event(s)`);
log(`[WS] Replaying ${missed.length} missed event(s)`)
for (const event of missed) {
if (ws.readyState !== 1) break;
if (ws.readyState !== 1) break
try {
ws.send(toSDKMessage(event));
ws.send(toSDKMessage(event))
} catch {
// ignore send errors during replay
}
@@ -70,75 +70,96 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
}
const unsub = bus.subscribe((event: SessionEvent) => {
if (ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
if (ws.readyState !== 1) return
if (event.direction !== 'outbound') return
try {
const sdkMsg = toSDKMessage(event);
log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
ws.send(sdkMsg);
const sdkMsg = toSDKMessage(event)
log(
`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`,
)
ws.send(sdkMsg)
} catch (err) {
logError("[RC-DEBUG] [WS] send error:", err);
logError('[RC-DEBUG] [WS] send error:', err)
}
});
})
const keepalive = setInterval(() => {
if (ws.readyState !== 1) {
clearInterval(keepalive);
return;
clearInterval(keepalive)
return
}
// Check if client is still alive — close if no data received for too long
const silenceMs = Date.now() - lastClientActivity;
const silenceMs = Date.now() - lastClientActivity
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
log(`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`);
log(
`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`,
)
try {
ws.close(1000, "client inactive");
ws.close(1000, 'client inactive')
} catch {
clearInterval(keepalive);
clearInterval(keepalive)
}
return;
return
}
try {
ws.send('{"type":"keep_alive"}\n');
ws.send('{"type":"keep_alive"}\n')
} catch {
clearInterval(keepalive);
clearInterval(keepalive)
}
}, SERVER_KEEPALIVE_INTERVAL_MS);
}, SERVER_KEEPALIVE_INTERVAL_MS)
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime, lastClientActivity });
cleanupBySession.set(sessionId, {
unsub,
keepalive,
ws,
openTime,
lastClientActivity,
})
}
/**
* Called from onMessage — bridge sends newline-delimited JSON.
*/
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
export function handleWebSocketMessage(
ws: WSContext,
sessionId: string,
data: string,
) {
// Track client activity for dead-connection detection
const entry = cleanupBySession.get(sessionId);
const entry = cleanupBySession.get(sessionId)
if (entry) {
entry.lastClientActivity = Date.now();
entry.lastClientActivity = Date.now()
}
const lines = data.split("\n").filter((l) => l.trim());
const lines = data.split('\n').filter(l => l.trim())
for (const line of lines) {
try {
ingestBridgeMessage(sessionId, JSON.parse(line));
ingestBridgeMessage(sessionId, JSON.parse(line))
} catch (err) {
logError("[WS] parse error:", err);
logError('[WS] parse error:', err)
}
}
}
/** Called from onClose — unsubscribes from event bus */
export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: number, reason?: string) {
activeConnections.delete(ws);
export function handleWebSocketClose(
ws: WSContext,
sessionId: string,
code?: number,
reason?: string,
) {
activeConnections.delete(ws)
const entry = cleanupBySession.get(sessionId);
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
const entry = cleanupBySession.get(sessionId)
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1
log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
log(
`[WS] Close session=${sessionId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
)
if (entry) {
entry.unsub();
clearInterval(entry.keepalive);
cleanupBySession.delete(sessionId);
entry.unsub()
clearInterval(entry.keepalive)
cleanupBySession.delete(sessionId)
}
}
@@ -150,88 +171,104 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu
* {"subtype":"success","uuid":"...","result":"..."} → type "result"
*/
function deriveEventType(msg: Record<string, unknown>): string {
if (msg.type && typeof msg.type === "string") return msg.type;
if (msg.type && typeof msg.type === 'string') return msg.type
// Child process stream-json format: message.role determines type
const message = msg.message as Record<string, unknown> | undefined;
if (message && typeof message.role === "string") {
return message.role; // "user", "assistant", "system"
const message = msg.message as Record<string, unknown> | undefined
if (message && typeof message.role === 'string') {
return message.role // "user", "assistant", "system"
}
// Result message
if (msg.subtype || msg.result !== undefined) return "result";
if (msg.subtype || msg.result !== undefined) return 'result'
// System/init message
if (msg.session_id) return "system";
if (msg.session_id) return 'system'
return "unknown";
return 'unknown'
}
/**
* Parse a single SDK message from bridge -> publish to EventBus as inbound.
*/
export function ingestBridgeMessage(sessionId: string, msg: Record<string, unknown>) {
if (msg.type === "keep_alive") return;
export function ingestBridgeMessage(
sessionId: string,
msg: Record<string, unknown>,
) {
if (msg.type === 'keep_alive') return
const eventType = deriveEventType(msg);
const eventType = deriveEventType(msg)
log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
log(
`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ''} msg=${JSON.stringify(msg).slice(0, 300)}`,
)
let payload: unknown;
let payload: unknown
if (eventType === "assistant" || eventType === "partial_assistant") {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content;
if (eventType === 'assistant' || eventType === 'partial_assistant') {
const message = msg.message as Record<string, unknown> | undefined
const content = message?.content
// Extract text from content blocks for simple display
let text = "";
if (typeof content === "string") {
text = content;
let text = ''
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
text = content
.filter((b: unknown) => b && typeof b === "object" && "type" in (b as Record<string, unknown>) && (b as Record<string, unknown>).type === "text")
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
.join("");
.filter(
(b: unknown) =>
b &&
typeof b === 'object' &&
'type' in (b as Record<string, unknown>) &&
(b as Record<string, unknown>).type === 'text',
)
.map(
(b: Record<string, unknown>) =>
(b as Record<string, unknown>).text || '',
)
.join('')
}
payload = { message: msg.message, uuid: msg.uuid, content: text };
} else if (eventType === "user" || eventType === "system") {
payload = { message: msg.message, uuid: msg.uuid, content: text }
} else if (eventType === 'user' || eventType === 'system') {
payload = {
message: msg.message,
uuid: msg.uuid,
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
};
} else if (eventType === "control_request") {
payload = { request_id: msg.request_id, request: msg.request };
} else if (eventType === "control_response") {
payload = { response: msg.response };
} else if (eventType === "result" || eventType === "result_success") {
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result };
...(typeof msg.isSynthetic === 'boolean'
? { isSynthetic: msg.isSynthetic }
: {}),
}
} else if (eventType === 'control_request') {
payload = { request_id: msg.request_id, request: msg.request }
} else if (eventType === 'control_response') {
payload = { response: msg.response }
} else if (eventType === 'result' || eventType === 'result_success') {
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result }
} else {
payload = msg;
payload = msg
}
publishSessionEvent(sessionId, eventType, payload, "inbound");
publishSessionEvent(sessionId, eventType, payload, 'inbound')
}
/**
* Gracefully close all active WebSocket connections.
*/
export function closeAllConnections(): void {
const count = activeConnections.size;
if (count === 0) return;
const count = activeConnections.size
if (count === 0) return
log(`[WS] Gracefully closing ${count} active connection(s)...`);
log(`[WS] Gracefully closing ${count} active connection(s)...`)
for (const [sessionId, entry] of cleanupBySession) {
try {
entry.unsub();
clearInterval(entry.keepalive);
entry.unsub()
clearInterval(entry.keepalive)
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
entry.ws.close(1001, 'server_shutdown')
}
} catch {
// ignore errors during shutdown
}
}
cleanupBySession.clear();
activeConnections.clear();
log("[WS] All connections closed");
cleanupBySession.clear()
activeConnections.clear()
log('[WS] All connections closed')
}

View File

@@ -1,39 +1,42 @@
import { Buffer } from "node:buffer";
import type { WSContext } from "hono/ws";
import { error as logError } from "../logger";
import { Buffer } from 'node:buffer'
import type { WSContext } from 'hono/ws'
import { error as logError } from '../logger'
const textDecoder = new TextDecoder();
const textDecoder = new TextDecoder()
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024
export type DecodedWsMessage =
| { ok: true; data: string; size: number }
| { ok: false; reason: string; size?: number };
| { ok: false; reason: string; size?: number }
export function decodeWsPayload(data: unknown): DecodedWsMessage {
if (typeof data === "string") {
return { ok: true, data, size: Buffer.byteLength(data, "utf8") };
if (typeof data === 'string') {
return { ok: true, data, size: Buffer.byteLength(data, 'utf8') }
}
if (data instanceof ArrayBuffer) {
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: data.byteLength };
return { ok: false, reason: 'message too large', size: data.byteLength }
}
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
return { ok: true, data: textDecoder.decode(data), size: data.byteLength }
}
if (data instanceof Uint8Array) {
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: data.byteLength };
return { ok: false, reason: 'message too large', size: data.byteLength }
}
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
return { ok: true, data: textDecoder.decode(data), size: data.byteLength }
}
if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) {
const bytes = new Uint8Array(data);
if (
typeof SharedArrayBuffer !== 'undefined' &&
data instanceof SharedArrayBuffer
) {
const bytes = new Uint8Array(data)
if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: bytes.byteLength };
return { ok: false, reason: 'message too large', size: bytes.byteLength }
}
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength };
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength }
}
return { ok: false, reason: typeof data };
return { ok: false, reason: typeof data }
}
export function handleSizedWsPayload(
@@ -43,22 +46,28 @@ export function handleSizedWsPayload(
payload: unknown,
handleMessage: (data: string) => void,
): boolean {
const decoded = decodeWsPayload(payload);
const decoded = decodeWsPayload(payload)
if (!decoded.ok) {
if (decoded.reason === "message too large" && decoded.size !== undefined) {
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
ws.close(1009, "message too large");
return false;
if (decoded.reason === 'message too large' && decoded.size !== undefined) {
logError(
`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`,
)
ws.close(1009, 'message too large')
return false
}
logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`);
ws.close(1003, "unsupported message payload");
return false;
logError(
`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`,
)
ws.close(1003, 'unsupported message payload')
return false
}
if (decoded.size > MAX_WS_MESSAGE_SIZE) {
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
ws.close(1009, "message too large");
return false;
logError(
`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`,
)
ws.close(1009, 'message too large')
return false
}
handleMessage(decoded.data);
return true;
handleMessage(decoded.data)
return true
}

View File

@@ -1 +1 @@
export { upgradeWebSocket, websocket } from "hono/bun";
export { upgradeWebSocket, websocket } from 'hono/bun'

View File

@@ -1,159 +1,159 @@
/** API 请求/响应类型定义 */
// Hono context variable types
declare module "hono" {
declare module 'hono' {
interface ContextVariableMap {
username?: string;
uuid?: string;
jwtPayload?: { session_id: string; role: string; iat: number; exp: number };
username?: string
uuid?: string
jwtPayload?: { session_id: string; role: string; iat: number; exp: number }
}
}
// --- Environment ---
export interface RegisterEnvironmentRequest {
machine_name?: string;
directory?: string;
branch?: string;
git_repo_url?: string;
max_sessions?: number;
worker_type?: string;
bridge_id?: string;
capabilities?: Record<string, unknown>;
machine_name?: string
directory?: string
branch?: string
git_repo_url?: string
max_sessions?: number
worker_type?: string
bridge_id?: string
capabilities?: Record<string, unknown>
}
export interface RegisterEnvironmentResponse {
id: string;
secret: string;
status: string;
id: string
secret: string
status: string
}
export interface WorkResponse {
id: string;
type: "work";
environment_id: string;
state: string;
id: string
type: 'work'
environment_id: string
state: string
data: {
type: "session" | "healthcheck";
id: string;
};
secret: string;
created_at: string;
type: 'session' | 'healthcheck'
id: string
}
secret: string
created_at: string
}
export interface WorkSecretPayload {
version: number;
session_ingress_token: string;
api_base_url: string;
sources: string[];
auth: string[];
use_code_sessions: boolean;
version: number
session_ingress_token: string
api_base_url: string
sources: string[]
auth: string[]
use_code_sessions: boolean
}
// --- Session ---
export interface CreateSessionRequest {
environment_id?: string | null;
title?: string;
events?: unknown[];
source?: string;
permission_mode?: string;
environment_id?: string | null
title?: string
events?: unknown[]
source?: string
permission_mode?: string
}
export interface SessionResponse {
id: string;
environment_id: string | null;
title: string | null;
status: string;
source: string;
permission_mode: string | null;
worker_epoch: number;
username: string | null;
created_at: number;
updated_at: number;
automation_state?: AutomationStateResponse;
id: string
environment_id: string | null
title: string | null
status: string
source: string
permission_mode: string | null
worker_epoch: number
username: string | null
created_at: number
updated_at: number
automation_state?: AutomationStateResponse
}
export interface AutomationStateResponse {
enabled: boolean;
phase: "standby" | "sleeping" | null;
next_tick_at: number | null;
sleep_until: number | null;
enabled: boolean
phase: 'standby' | 'sleeping' | null
next_tick_at: number | null
sleep_until: number | null
}
// --- v2 Code Sessions ---
export interface CreateCodeSessionRequest {
title?: string;
source?: string;
permission_mode?: string;
title?: string
source?: string
permission_mode?: string
}
export interface BridgeResponse {
api_base_url: string;
worker_epoch: number;
worker_jwt: string;
expires_in: number;
api_base_url: string
worker_epoch: number
worker_jwt: string
expires_in: number
}
// --- Web ---
export interface EnvironmentResponse {
id: string;
machine_name: string | null;
directory: string | null;
branch: string | null;
status: string;
username: string | null;
last_poll_at: number | null;
worker_type?: string;
channel_group_id?: string | null;
capabilities?: Record<string, unknown> | null;
id: string
machine_name: string | null
directory: string | null
branch: string | null
status: string
username: string | null
last_poll_at: number | null
worker_type?: string
channel_group_id?: string | null
capabilities?: Record<string, unknown> | null
}
export interface SessionSummaryResponse {
id: string;
title: string | null;
status: string;
username: string | null;
updated_at: number;
id: string
title: string | null
status: string
username: string | null
updated_at: number
}
// --- Web Auth ---
export interface WebLoginRequest {
apiKey: string;
username: string;
apiKey: string
username: string
}
export interface WebLoginResponse {
token: string;
expires_in: number;
token: string
expires_in: number
}
export interface WebControlRequest {
type: string;
content?: string;
[key: string]: unknown;
type: string
content?: string
[key: string]: unknown
}
// --- Error ---
export interface ErrorResponse {
error: {
type: string;
message: string;
};
type: string
message: string
}
}
// --- Event ---
export interface SessionEventPayload {
id: string;
session_id: string;
type: string;
payload: unknown;
direction: "inbound" | "outbound";
seq_num: number;
created_at: number;
id: string
session_id: string
type: string
payload: unknown
direction: 'inbound' | 'outbound'
seq_num: number
created_at: number
}

View File

@@ -1,83 +1,83 @@
/** SDK 消息类型 — 与 CC CLI bridge 模块兼容 */
export interface SDKMessage {
type: string;
content?: unknown;
[key: string]: unknown;
type: string
content?: unknown
[key: string]: unknown
}
export interface UserMessage extends SDKMessage {
type: "user";
content: string;
type: 'user'
content: string
}
export interface AssistantMessage extends SDKMessage {
type: "assistant";
content: string;
type: 'assistant'
content: string
}
export interface PermissionRequest extends SDKMessage {
type: "permission_request";
tool_name: string;
tool_input: unknown;
type: 'permission_request'
tool_name: string
tool_input: unknown
}
export interface PermissionResponse extends SDKMessage {
type: "permission_response";
approved: boolean;
request_id: string;
type: 'permission_response'
approved: boolean
request_id: string
}
export interface ControlRequest extends SDKMessage {
type: "control_request";
action: string;
[key: string]: unknown;
type: 'control_request'
action: string
[key: string]: unknown
}
export type SessionEventType =
| "user"
| "assistant"
| "automation_state"
| "permission_request"
| "permission_response"
| "control_request"
| "tool_use"
| "tool_result"
| "status"
| "error";
| 'user'
| 'assistant'
| 'automation_state'
| 'permission_request'
| 'permission_response'
| 'control_request'
| 'tool_use'
| 'tool_result'
| 'status'
| 'error'
// --- Normalized Event Payloads (SSE contract) ---
export interface NormalizedEventPayload {
content: string;
raw?: unknown;
isSynthetic?: boolean;
[key: string]: unknown;
content: string
raw?: unknown
isSynthetic?: boolean
[key: string]: unknown
}
export interface UserEventPayload extends NormalizedEventPayload {
content: string;
content: string
}
export interface AssistantEventPayload extends NormalizedEventPayload {
content: string;
content: string
}
export interface ToolUseEventPayload extends NormalizedEventPayload {
content: string;
tool_name: string;
tool_input: unknown;
content: string
tool_name: string
tool_input: unknown
}
export interface ToolResultEventPayload extends NormalizedEventPayload {
content: string;
content: string
}
export interface PermissionEventPayload extends NormalizedEventPayload {
content: string;
request_id: string;
content: string
request_id: string
request: {
subtype: string;
tool_name: string;
tool_input: unknown;
};
subtype: string
tool_name: string
tool_input: unknown
}
}