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:
70
public/queue.ts
Normal file
70
public/queue.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* public/queue.ts — W2: client helper for the server-side PTY-inject queue.
|
||||
*
|
||||
* Enqueue a follow-up prompt that the server fires into a session's PTY the next
|
||||
* time Claude goes idle (so "run tests → open a PR" advances itself while you're
|
||||
* away). Enqueue is a same-origin HTTP POST (Origin-guarded server-side), NOT a
|
||||
* WS frame — it must work with zero tabs open and target any session (e.g. from
|
||||
* the manage page), not just the WS-bound one.
|
||||
*
|
||||
* Discipline (mirrors quick-reply): every call NEVER throws — a failed fetch or
|
||||
* non-2xx response returns a falsy result the caller can ignore. Byte-shuttle:
|
||||
* `text` is sent verbatim; `appendEnter` tells the server to append \r.
|
||||
*/
|
||||
|
||||
/** Result of an enqueue attempt. `length` = the new queue depth on success. */
|
||||
export interface EnqueueResult {
|
||||
ok: boolean
|
||||
/** New queue depth (present on success). */
|
||||
length?: number
|
||||
/** HTTP status on a non-2xx response (absent on network error). */
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** POST a follow-up prompt to a session's inject queue. Never throws. */
|
||||
export async function enqueueFollowup(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
appendEnter: boolean,
|
||||
): Promise<EnqueueResult> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return { ok: false }
|
||||
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, appendEnter }),
|
||||
})
|
||||
if (!res.ok) return { ok: false, status: res.status }
|
||||
const data = (await res.json()) as { length?: unknown }
|
||||
return { ok: true, length: typeof data.length === 'number' ? data.length : undefined }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE all pending entries for a session (cancel-all). Returns success. Never throws. */
|
||||
export async function clearQueue(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return false
|
||||
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the queue depth from an incoming server frame. Returns the length for a
|
||||
* `{type:'queue',length}` frame, else null (so callers can filter unrelated
|
||||
* frames). Defensive: tolerates any unknown input shape.
|
||||
*/
|
||||
export function queueLengthFromFrame(msg: unknown): number | null {
|
||||
if (msg === null || typeof msg !== 'object') return null
|
||||
const o = msg as Record<string, unknown>
|
||||
if (o['type'] !== 'queue') return null
|
||||
return typeof o['length'] === 'number' ? o['length'] : null
|
||||
}
|
||||
Reference in New Issue
Block a user