Files
claw-code-parity/ts-worker/turnManager.ts
T
Wylabb f7c23fb325
Build Claw Telegram / build (push) Successful in 5m26s
Build Claw Telegram / cleanup (push) Successful in 0s
Replace Rust worker with TS worker in single image
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>
2026-04-07 23:44:57 +02:00

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
}
}