feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
This commit is contained in:
@@ -29,6 +29,8 @@ class FakeTerminalSession {
|
||||
pendingEpoch = 0
|
||||
/** B2: latest statusLine telemetry (single source of truth). */
|
||||
telemetry: unknown = null
|
||||
/** W2: pending inject-queue depth (tests set it before driving onQueue). */
|
||||
queueLength = 0
|
||||
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||
initialInput: string | undefined = undefined
|
||||
connect = vi.fn()
|
||||
@@ -50,6 +52,7 @@ class FakeTerminalSession {
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
onQueue?: (n: number) => void
|
||||
}
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
@@ -60,6 +63,7 @@ class FakeTerminalSession {
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
onQueue?: (n: number) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
constructed += 1
|
||||
@@ -89,13 +93,28 @@ vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor }))
|
||||
const mountPushToggle = vi.fn()
|
||||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||||
|
||||
const quickReply: { onSend?: (d: string) => void } = {}
|
||||
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
const quickReply: {
|
||||
onSend?: (d: string) => void
|
||||
onQueue?: (text: string, appendEnter: boolean) => void
|
||||
} = {}
|
||||
const mountQuickReply = vi.fn(
|
||||
(
|
||||
_c: HTMLElement,
|
||||
opts: { onSend: (d: string) => void; onQueue?: (text: string, appendEnter: boolean) => void },
|
||||
) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
quickReply.onQueue = opts.onQueue
|
||||
return { dispose: vi.fn() }
|
||||
},
|
||||
)
|
||||
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
|
||||
|
||||
// ── Mock the W2 queue helper (assert enqueue routing without real fetch) ──────
|
||||
const enqueueFollowupMock = vi.fn(async () => ({ ok: true, length: 1 }))
|
||||
vi.mock('../public/queue.js', () => ({
|
||||
enqueueFollowup: (...args: unknown[]) => enqueueFollowupMock(...(args as [])),
|
||||
}))
|
||||
|
||||
const timelineDispose = vi.fn()
|
||||
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
|
||||
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
|
||||
@@ -179,6 +198,8 @@ beforeEach(() => {
|
||||
voiceStart.mockClear()
|
||||
voiceStop.mockClear()
|
||||
quickReply.onSend = undefined
|
||||
quickReply.onQueue = undefined
|
||||
enqueueFollowupMock.mockClear()
|
||||
voice.onTranscript = undefined
|
||||
voice.onInterim = undefined
|
||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||
@@ -1636,3 +1657,64 @@ describe('TabApp — split-grid watch board (v1)', () => {
|
||||
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ── W2: inject-queue badge + enqueue affordance ──────────────────────────────
|
||||
describe('TabApp — W2 inject queue', () => {
|
||||
it('a queue frame (onQueue) updates the active tab’s ⧗N badge in place', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const inst = FakeTerminalSession.instances[0]!
|
||||
|
||||
// No queued items → empty badge.
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||||
|
||||
// Server broadcasts depth 3 → badge shows ⧗3.
|
||||
inst.queueLength = 3
|
||||
inst.cbs.onQueue?.(3)
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('⧗3')
|
||||
|
||||
// Drains back to 0 → badge clears.
|
||||
inst.queueLength = 0
|
||||
inst.cbs.onQueue?.(0)
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||||
})
|
||||
|
||||
it('enqueueToActive routes to enqueueFollowup with the active session id', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession('11111111-1111-4111-8111-111111111111')
|
||||
|
||||
app.enqueueToActive('run tests', true)
|
||||
|
||||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'run tests',
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('the quick-reply Queue affordance enqueues for the active session', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession('22222222-2222-4222-8222-222222222222')
|
||||
|
||||
// The onQueue handler wired into mountQuickReply routes through enqueueToActive.
|
||||
quickReply.onQueue?.('open a PR', true)
|
||||
|
||||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'open a PR',
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('enqueueToActive is a no-op when no session has attached (id null)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // fresh session → id null until server assigns
|
||||
|
||||
app.enqueueToActive('x', false)
|
||||
expect(enqueueFollowupMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user