f7c23fb325
Add ts-worker/ with the Bun/TypeScript worker that replaces claw-profile-worker. The Dockerfile now builds a single image containing both the Rust gateway (claw-telegram) and the TS worker. The image defaults to worker mode (bun run ts-worker/main.ts). The gateway Unraid XML overrides with --entrypoint claw-telegram. Worker containers use the same image with the default CMD. - Add ts-worker/ (12 files): HTTP/SSE server, Anthropic SDK engine, approval broker, event translator, state stores - Add package.json with @anthropic-ai/sdk dependency - Rewrite Dockerfile: three-stage build (Rust + Bun + runtime) - Revert CLAW_GATEWAY_WORKER_IMAGE to claw-telegram:latest - Remove image pull from docker_worker_manager (same image, already local) - Add ts-worker paths to CI trigger Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
147 lines
4.0 KiB
TypeScript
147 lines
4.0 KiB
TypeScript
/**
|
|
* TurnManager — owns the lifecycle of a single user turn.
|
|
*
|
|
* Each turn gets a unique ID. The gateway:
|
|
* 1. POSTs /v1/turns → gets back { turn_id }
|
|
* 2. GETs /v1/turns/:turn_id/events → SSE stream of WorkerTurnEvent
|
|
* 3. POSTs /v1/turns/:turn_id/approval → resolves a pending tool approval
|
|
* 4. POSTs /v1/turns/:turn_id/cancel → aborts the turn
|
|
*
|
|
* Internally we hold:
|
|
* - a queue of WorkerTurnEvent waiting to be flushed to SSE clients
|
|
* - an optional pending approval (blocks the engine until resolved)
|
|
* - an AbortController so cancel works
|
|
*/
|
|
|
|
import { randomUUID } from 'crypto'
|
|
import type {
|
|
ApprovalDecision,
|
|
WorkerTurnEvent,
|
|
WorkerTurnRequest,
|
|
} from './protocol.js'
|
|
|
|
export type PendingApproval = {
|
|
approval_id: string
|
|
resolve: (decision: ApprovalDecision) => void
|
|
}
|
|
|
|
export type Turn = {
|
|
id: string
|
|
request: WorkerTurnRequest
|
|
events: WorkerTurnEvent[]
|
|
/** SSE clients listening for events on this turn */
|
|
listeners: Set<(event: WorkerTurnEvent) => void>
|
|
pendingApproval: PendingApproval | null
|
|
abortController: AbortController
|
|
done: boolean
|
|
}
|
|
|
|
export class TurnManager {
|
|
private turns = new Map<string, Turn>()
|
|
|
|
create(request: WorkerTurnRequest): Turn {
|
|
const id = randomUUID()
|
|
const turn: Turn = {
|
|
id,
|
|
request,
|
|
events: [],
|
|
listeners: new Set(),
|
|
pendingApproval: null,
|
|
abortController: new AbortController(),
|
|
done: false,
|
|
}
|
|
this.turns.set(id, turn)
|
|
return turn
|
|
}
|
|
|
|
get(turnId: string): Turn | undefined {
|
|
return this.turns.get(turnId)
|
|
}
|
|
|
|
/** Push an event to the turn's log and broadcast to all SSE listeners. */
|
|
emit(turnId: string, event: WorkerTurnEvent): void {
|
|
const turn = this.turns.get(turnId)
|
|
if (!turn) return
|
|
turn.events.push(event)
|
|
for (const listener of turn.listeners) {
|
|
listener(event)
|
|
}
|
|
if (event.type === 'completed' || event.type === 'failed') {
|
|
turn.done = true
|
|
}
|
|
}
|
|
|
|
/** Register an SSE listener. Returns unsubscribe function. */
|
|
subscribe(
|
|
turnId: string,
|
|
listener: (event: WorkerTurnEvent) => void,
|
|
): (() => void) | null {
|
|
const turn = this.turns.get(turnId)
|
|
if (!turn) return null
|
|
|
|
// Replay buffered events first
|
|
for (const event of turn.events) {
|
|
listener(event)
|
|
}
|
|
|
|
if (turn.done) return null
|
|
|
|
turn.listeners.add(listener)
|
|
return () => {
|
|
turn.listeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
/** Set a pending approval on the turn. Returns a promise that resolves
|
|
* when the gateway sends the decision. */
|
|
requestApproval(turnId: string, approvalId: string): Promise<ApprovalDecision> {
|
|
const turn = this.turns.get(turnId)
|
|
if (!turn) return Promise.reject(new Error(`unknown turn ${turnId}`))
|
|
|
|
return new Promise<ApprovalDecision>((resolve) => {
|
|
turn.pendingApproval = { approval_id: approvalId, resolve }
|
|
})
|
|
}
|
|
|
|
resolveApproval(turnId: string, approvalId: string, decision: ApprovalDecision): boolean {
|
|
const turn = this.turns.get(turnId)
|
|
if (!turn?.pendingApproval) return false
|
|
if (turn.pendingApproval.approval_id !== approvalId) return false
|
|
turn.pendingApproval.resolve(decision)
|
|
turn.pendingApproval = null
|
|
return true
|
|
}
|
|
|
|
cancel(turnId: string): boolean {
|
|
const turn = this.turns.get(turnId)
|
|
if (!turn) return false
|
|
turn.abortController.abort()
|
|
return true
|
|
}
|
|
|
|
/** Prune completed turns older than `maxAgeMs`. */
|
|
prune(maxAgeMs = 5 * 60 * 1000): void {
|
|
const now = Date.now()
|
|
for (const [id, turn] of this.turns) {
|
|
if (turn.done) {
|
|
const lastEvent = turn.events[turn.events.length - 1]
|
|
// Simple heuristic — if turn is done and old, drop it
|
|
if (lastEvent && turn.events.length > 0) {
|
|
this.turns.delete(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
get activeTurnId(): string | null {
|
|
for (const [id, turn] of this.turns) {
|
|
if (!turn.done) return id
|
|
}
|
|
return null
|
|
}
|
|
|
|
get isBusy(): boolean {
|
|
return this.activeTurnId !== null
|
|
}
|
|
}
|