chore: import upstream snapshot with attribution
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:41 +08:00
commit d18ada4ee7
567 changed files with 118275 additions and 0 deletions
@@ -0,0 +1,414 @@
/**
* Three-host equivalence test (NEX-131 acceptance: 三端等价性测试).
*
* Goal: prove that Desktop, Web/API and CLI hosts produce byte-equal
* task-state and event payloads when driven by the same scripted yt-dlp
* lifecycle. The kernel is host-neutral; any divergence is a contract bug
* in the host adapter.
*
* What this test covers right now:
* - Each "host" instantiates its own TaskQueueAPI + MemoryPersistAdapter
* wrapped around the same FakeYtDlpExecutor (deterministic spawn /
* progress / postprocess / finish script).
* - We add the same TaskInput on each host, drive the executor scripts,
* and assert that the resulting Task snapshots and projection outputs
* are equal across all three hosts (modulo task id and timestamps).
* - Scenarios: complete success, http-429 retry-then-success, auth
* failure (terminal), stalled cancel.
*
* What is intentionally not covered yet (left as TODO for the CLI part of
* NEX-133's Phase B):
* - Real `apps/cli` envelope output equivalence: this test currently
* stands in for the CLI by spinning up a third TaskQueueAPI instance
* in the same process. Once `apps/cli --vidbee-local` ships its
* in-process driver, point the third host at that instead.
* - Real `apps/api` SSE wire-format diff: the SSE bridge serializes
* `snapshot-changed` with a projection field. Add a wire-level diff
* once the CLI and Desktop loopback also publish the same payload.
* - SQLite-backed crash recovery scenario (kill main → resume): requires
* a child-process harness, deferred to the host-specific e2e suites.
*/
import { describe, expect, it } from 'vitest'
import { TaskQueueAPI } from '../src/api'
import type { Executor, ExecutorEvents, ExecutorRun } from '../src/executor'
import { MemoryPersistAdapter } from '../src/persist'
import { projectTaskToLegacy } from '../src/projection'
import type { Task, TaskInput, TaskStatus } from '../src/types'
type Step =
| { type: 'spawn' }
| { type: 'progress'; percent: number; postprocess?: boolean }
| { type: 'success'; filePath?: string; size?: number }
| { type: 'errorRetryable'; message?: string }
| { type: 'errorFatal'; message?: string }
const SUCCESS_SCRIPT: Step[] = [
{ type: 'spawn' },
{ type: 'progress', percent: 0.5 },
{ type: 'progress', percent: 0.95, postprocess: true },
{ type: 'success', filePath: '/tmp/x.mp4', size: 1024 }
]
const HTTP_429_THEN_SUCCESS_SCRIPT_GENERATOR = (): Step[] => [
{ type: 'spawn' },
{ type: 'progress', percent: 0.2 },
{ type: 'errorRetryable', message: 'HTTP Error 429: Too Many Requests' }
]
const AUTH_REQUIRED_SCRIPT: Step[] = [
{ type: 'spawn' },
{ type: 'errorFatal', message: 'Login required' }
]
interface ScriptedExecutor extends Executor {
setNextScriptForUrl: (url: string, script: Step[]) => void
}
const makeScriptedExecutor = (initial: Map<string, Step[]>): ScriptedExecutor => {
const scripts = new Map(initial)
let pidCounter = 1000
const executor: ScriptedExecutor = {
setNextScriptForUrl(url, script) {
scripts.set(url, script)
},
run(ctx, events: ExecutorEvents): ExecutorRun {
const script = scripts.get(ctx.input.url) ?? SUCCESS_SCRIPT
const pid = ++pidCounter
let cancelled = false
queueMicrotask(() => {
if (cancelled) {
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
return
}
for (const step of script) {
switch (step.type) {
case 'spawn':
events.onSpawn({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
pid,
pidStartedAt: 1,
kind: 'yt-dlp',
spawnedAt: 1
})
break
case 'progress':
events.onProgress({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
progress: {
percent: step.percent,
bytesDownloaded: Math.round(step.percent * 1000),
bytesTotal: 1000,
speedBps: 100,
etaMs: 100,
ticks: 1
},
enteredProcessing: Boolean(step.postprocess)
})
break
case 'success':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'success',
output: {
filePath: step.filePath ?? '/tmp/out.mp4',
size: step.size ?? 1024,
durationMs: null,
sha256: null
}
},
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
return
case 'errorRetryable':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'error',
error: {
category: 'http-429',
exitCode: 1,
rawMessage: step.message ?? 'http-429',
uiMessageKey: 'errors.rate_limited',
uiActionHints: [],
retryable: true,
suggestedRetryAfterMs: 50
},
exitCode: 1
},
closedAt: 1,
stdoutTail: '',
stderrTail: step.message ?? ''
})
return
case 'errorFatal':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'error',
error: {
category: 'auth-required',
exitCode: 1,
rawMessage: step.message ?? 'auth required',
uiMessageKey: 'errors.auth_required',
uiActionHints: ['login'],
retryable: false,
suggestedRetryAfterMs: null
},
exitCode: 1
},
closedAt: 1,
stdoutTail: '',
stderrTail: step.message ?? ''
})
return
}
}
})
const emitCancelled = (): void => {
cancelled = true
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
}
return {
cancel: async () => {
if (!cancelled) emitCancelled()
},
pause: async () => {
if (!cancelled) emitCancelled()
}
}
}
}
return executor
}
interface Host {
name: string
queue: TaskQueueAPI
executor: ScriptedExecutor
}
const buildHost = (name: string, scripts: Map<string, Step[]>): Host => {
const executor = makeScriptedExecutor(scripts)
return {
name,
executor,
queue: new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor,
maxConcurrency: 4,
// Make the test deterministic — all hosts use the same deterministic
// backoff so retry-scheduled timing matches.
rng: () => 0.5,
// The fake executor reports synthetic file paths; the kernel guards
// `processing → completed` with a real fs check by default. Disable
// for tests so the script's `success` step actually completes.
filePresent: () => true
})
}
}
const HOSTS: ReadonlyArray<{ name: string }> = [
{ name: 'desktop' },
{ name: 'api' },
{ name: 'cli' }
]
const buildHosts = (scripts: Map<string, Step[]>): Host[] =>
HOSTS.map((h) => buildHost(h.name, scripts))
const startAll = async (hosts: Host[]): Promise<void> => {
await Promise.all(hosts.map((h) => h.queue.start()))
}
const stopAll = async (hosts: Host[]): Promise<void> => {
await Promise.all(hosts.map((h) => h.queue.stop()))
}
const waitFor = async (
hosts: Host[],
predicate: (h: Host) => boolean,
timeoutMs = 2000
): Promise<void> => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (hosts.every(predicate)) return
await new Promise((r) => setTimeout(r, 5))
}
throw new Error('waitFor: predicate did not hold within timeout')
}
const submitOnAllHosts = async (
hosts: Host[],
input: TaskInput
): Promise<Map<string, string>> => {
const ids = new Map<string, string>()
for (const host of hosts) {
const { id } = await host.queue.add({ input })
ids.set(host.name, id)
}
return ids
}
const expectStatus = (host: Host, id: string, expected: TaskStatus): void => {
const t = host.queue.get(id)
expect(t, `${host.name}/${id}`).toBeTruthy()
expect(t?.status, `${host.name}/${id}`).toBe(expected)
}
const stripVolatileFields = (task: Readonly<Task>): unknown => {
const cloned = JSON.parse(JSON.stringify(task)) as Record<string, unknown>
delete cloned.id
delete cloned.createdAt
delete cloned.updatedAt
delete cloned.enteredStatusAt
delete cloned.pid
delete cloned.pidStartedAt
delete cloned.nextRetryAt
return cloned
}
const stripVolatileProjection = (task: Readonly<Task>): unknown => {
const proj = projectTaskToLegacy(task) as Record<string, unknown>
delete proj.id
delete proj.createdAt
delete proj.startedAt
delete proj.completedAt
delete proj.nextRetryAt
return proj
}
describe('three-host equivalence', () => {
it('completed download produces identical Task + projection across hosts', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/success', SUCCESS_SCRIPT]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/success',
kind: 'video',
title: 'Sample',
thumbnail: 'https://example.com/t.jpg'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'completed')
const snapshots = hosts.map((h) => stripVolatileFields(h.queue.get(ids.get(h.name)!)!))
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < snapshots.length; i++) {
expect(snapshots[i]).toStrictEqual(snapshots[0])
expect(projections[i]).toStrictEqual(projections[0])
}
} finally {
await stopAll(hosts)
}
})
it('http-429 → retry-scheduled lands on identical retry state across hosts', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/retry', HTTP_429_THEN_SUCCESS_SCRIPT_GENERATOR()]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/retry',
kind: 'video'
})
await waitFor(
hosts,
(h) => h.queue.get(ids.get(h.name)!)?.status === 'retry-scheduled'
)
for (const host of hosts) expectStatus(host, ids.get(host.name)!, 'retry-scheduled')
const projections = hosts.map((h) => projectTaskToLegacy(h.queue.get(ids.get(h.name)!)!))
for (const proj of projections) {
expect(proj.status).toBe('pending')
expect(proj.subStatus).toBe('retry-scheduled')
expect(proj.errorCategory).toBe('http-429')
// FSM increments `attempt` on every retry-scheduled transition.
expect(proj.attempt).toBe(1)
}
} finally {
await stopAll(hosts)
}
})
it('auth-required is non-retryable and yields identical failed projection', async () => {
const scripts = new Map<string, Step[]>([
['https://example.com/v/auth', AUTH_REQUIRED_SCRIPT]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/auth',
kind: 'video'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'failed')
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < projections.length; i++) {
expect(projections[i]).toStrictEqual(projections[0])
}
const proj = projectTaskToLegacy(hosts[0]!.queue.get(ids.get('desktop')!)!)
expect(proj.status).toBe('error')
expect(proj.errorCategory).toBe('auth-required')
expect(proj.uiMessageKey).toBe('errors.auth_required')
} finally {
await stopAll(hosts)
}
})
it('cancel on a running task produces identical cancelled snapshot across hosts', async () => {
const scripts = new Map<string, Step[]>([
[
'https://example.com/v/cancel-me',
[{ type: 'spawn' }, { type: 'progress', percent: 0.3 }]
]
])
const hosts = buildHosts(scripts)
await startAll(hosts)
try {
const ids = await submitOnAllHosts(hosts, {
url: 'https://example.com/v/cancel-me',
kind: 'video'
})
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'running', 1000)
await Promise.all(hosts.map((h) => h.queue.cancel(ids.get(h.name)!, 'user')))
await waitFor(hosts, (h) => h.queue.get(ids.get(h.name)!)?.status === 'cancelled')
const projections = hosts.map((h) =>
stripVolatileProjection(h.queue.get(ids.get(h.name)!)!)
)
for (let i = 1; i < projections.length; i++) {
expect(projections[i]).toStrictEqual(projections[0])
}
} finally {
await stopAll(hosts)
}
})
})
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@vidbee/task-queue",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./contract": "./src/contract.ts",
"./types": "./src/types.ts",
"./fsm": "./src/fsm/index.ts",
"./classifier": "./src/classifier/index.ts",
"./scheduler": "./src/scheduler/index.ts",
"./executor": "./src/executor/index.ts",
"./persist": "./src/persist/index.ts",
"./events": "./src/events/index.ts",
"./process": "./src/process/index.ts",
"./projection": "./src/projection/index.ts"
},
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@orpc/contract": "^1.13.5",
"ulid": "^2.3.0",
"zod": "^4.1.11"
},
"peerDependencies": {
"@vidbee/db": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.18.6",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+833
View File
@@ -0,0 +1,833 @@
/**
* TaskQueueAPI — host-neutral orchestrator. The single object every adapter
* (Desktop, Web/API, CLI) instantiates exactly once and forwards RPC calls
* into. Concrete behavior:
*
* - Owns the FSM, Scheduler, RetryScheduler, Watchdog, ProcessRegistry,
* EventBus and TaskStore.
* - Single writer to PersistAdapter.
* - Implements crash recovery on `start()` per design doc §11.
* - Provides `subscribe(listener)` so hosts can replicate events to IPC/SSE.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §3, §6, §7, §8, §11.
*/
import { createHash, randomUUID } from 'node:crypto'
import { existsSync, statSync } from 'node:fs'
import { defaultMaxAttempts, virtualError } from '../classifier'
import {
EventBus,
type TaskQueueEvent,
type TaskQueueListener
} from '../events'
import {
IllegalTransitionError,
transition as fsmTransition,
type TransitionContext
} from '../fsm'
import type { Executor, ExecutorRun } from '../executor'
import type { PersistAdapter } from '../persist'
import { ProcessRegistry, Watchdog, readPidStartTime } from '../process'
import { RetryScheduler, Scheduler, computeBackoffMs } from '../scheduler'
import { TaskStore } from '../store'
import {
EMPTY_PROGRESS,
PRIORITY_USER,
TERMINAL_STATUSES,
type ClassifiedError,
type Task,
type TaskInput,
type TaskOutput,
type TaskPriority,
type TaskProgress,
type TaskQueueStats,
type TaskStatus
} from '../types'
export interface TaskQueueAPIOptions {
persist: PersistAdapter
executor: Executor
/** Defaults to 4 — the production default for desktop and web. */
maxConcurrency?: number
/** Default per-group cap. Null = unlimited. */
defaultMaxPerGroup?: number | null
/** Test seam; defaults to Date.now. */
clock?: () => number
/** Test seam for setTimeout (Watchdog + RetryScheduler). */
setTimer?: (fn: () => void, ms: number) => unknown
clearTimer?: (handle: unknown) => void
/**
* For `processing -> completed` we hard-guard `output.filePath` exists.
* Test seam.
*/
filePresent?: (path: string) => boolean
/** Watchdog idle thresholds. */
runningIdleMs?: number
processingIdleMs?: number
/** Random source for backoff jitter. Test seam. */
rng?: () => number
/** Process kill function for ProcessRegistry. Test seam. */
killProcess?: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void
}
export interface AddTaskRequest {
input: TaskInput
priority?: TaskPriority
groupKey?: string
parentId?: string | null
maxAttempts?: number
/**
* Optional caller-supplied identifier. When the host has already minted an
* id (e.g. desktop renderer optimistic UI before the IPC round-trip) it
* should be threaded through so the resulting `task.id` matches and the
* caller does not see two separate rows in the list — the optimistic one
* and the kernel-generated one. If omitted, a random UUID is generated.
* Idempotency: re-adding with an existing id returns `{id}` without
* re-creating the task.
*/
id?: string
}
export interface ListOptions {
status?: TaskStatus
groupKey?: string
parentId?: string
limit?: number
cursor?: string | null
}
interface ActiveRun {
taskId: string
attemptId: string
run: ExecutorRun
}
const PROGRESS_DOWNSAMPLE_MS = 1_000
export class TaskQueueAPI {
private readonly bus = new EventBus()
private readonly store = new TaskStore()
private readonly scheduler: Scheduler
private readonly retry: RetryScheduler
private readonly watchdog: Watchdog
private readonly processes: ProcessRegistry
private readonly persist: PersistAdapter
private readonly executor: Executor
private readonly clock: () => number
private readonly filePresent: (p: string) => boolean
private readonly rng: () => number
private readonly active = new Map<string, ActiveRun>()
private readonly progressLastWrite = new Map<string, number>()
private readonly progressDirty = new Map<string, TaskProgress>()
private started = false
constructor(opts: TaskQueueAPIOptions) {
this.persist = opts.persist
this.executor = opts.executor
this.clock = opts.clock ?? Date.now
this.filePresent =
opts.filePresent ??
((p) => {
try {
return existsSync(p) && statSync(p).size > 0
} catch {
return false
}
})
this.rng = opts.rng ?? Math.random
this.scheduler = new Scheduler({
maxConcurrency: opts.maxConcurrency ?? 4,
defaultMaxPerGroup: opts.defaultMaxPerGroup ?? null,
dispatch: (id) => this.dispatchOne(id),
demote: (id) => this.demoteOne(id),
getTask: (id) => this.store.get(id)
})
this.retry = new RetryScheduler({
clock: this.clock,
setTimer: opts.setTimer,
clearTimer: opts.clearTimer,
onDue: (id) => this.handleRetryDue(id)
})
this.watchdog = new Watchdog((id) => this.handleStalled(id), {
runningIdleMs: opts.runningIdleMs,
processingIdleMs: opts.processingIdleMs,
setTimer: opts.setTimer,
clearTimer: opts.clearTimer,
clock: this.clock
})
this.processes = new ProcessRegistry({
persist: this.persist,
clock: this.clock,
kill: opts.killProcess
})
}
// ───────────── Lifecycle ─────────────
/**
* Crash recovery + reconciliation. Per §11:
* 1. load tasks from persistence
* 2. reconcile process_journal: kill orphans, journal `killed`
* 3. running/processing → paused('crash-recovery'); preserve progress
* 4. queued → re-enqueue
* 5. retry-scheduled → re-arm RetryScheduler with original nextRetryAt
*/
async start(): Promise<void> {
if (this.started) return
this.started = true
const tasks = await this.persist.loadAllTasks()
for (const t of tasks) this.store.insert(t)
// Kill orphans and journal them.
const orphans = await this.processes.reconcile()
for (const o of orphans) {
this.bus.emit({
type: 'orphan-killed',
taskId: o.taskId,
pid: o.pid,
pidStartedAt: null,
signal: 'SIGKILL',
at: this.clock()
})
}
// Reclassify recovered task statuses.
for (const t of tasks) {
if (t.status === 'running' || t.status === 'processing') {
await this.applyTransition(t.id, 'paused', {
trigger: 'crash-recovery',
reason: 'crash-recovery'
})
} else if (t.status === 'queued') {
await this.scheduler.enqueue(t.id, t.priority)
} else if (t.status === 'retry-scheduled' && t.nextRetryAt != null) {
this.retry.enqueue(t.id, t.nextRetryAt)
}
}
}
async stop(): Promise<void> {
if (!this.started) return
this.retry.stop()
// Cancel all active runs; let the executor reap.
for (const a of [...this.active.values()]) {
try {
await a.run.cancel(0)
} catch {
/* noop */
}
}
if (this.persist.close) await this.persist.close()
this.started = false
}
// ───────────── RPC surface ─────────────
async add(req: AddTaskRequest): Promise<{ id: string }> {
const now = this.clock()
// Honor caller-supplied id (optimistic UI correlation). Idempotent: if a
// task with that id already exists, return it instead of double-adding.
if (req.id) {
const existing = this.store.get(req.id)
if (existing) return { id: existing.id }
}
const id = req.id ?? randomUUID()
const task: Task = {
id,
kind: req.input.kind,
parentId: req.parentId ?? null,
input: req.input,
priority: req.priority ?? PRIORITY_USER,
groupKey: req.groupKey ?? defaultGroupKey(req.input),
status: 'queued',
prevStatus: null,
statusReason: null,
enteredStatusAt: now,
attempt: 0,
maxAttempts: req.maxAttempts ?? 5,
nextRetryAt: null,
progress: { ...EMPTY_PROGRESS },
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: now,
updatedAt: now
}
this.store.insert(task)
await this.persist.insertTask(task)
this.bus.emit({
type: 'transition',
taskId: id,
from: null,
to: 'queued',
reason: null,
attempt: 0,
at: now
})
this.bus.emit({
type: 'snapshot-changed',
taskId: id,
task,
at: now
})
await this.scheduler.enqueue(id, task.priority)
return { id }
}
get(id: string): Readonly<Task> | undefined {
return this.store.get(id)
}
list(opts: ListOptions = {}): { tasks: Task[]; nextCursor: string | null } {
return this.store.list(opts)
}
/**
* Read the persisted stdout/stderr tail for a task's latest attempt. Returns
* the combined log text (stdout then stderr), or null when no attempt has
* been recorded. Hosts use this to surface saved logs for terminal items
* after the live stream is gone (e.g. reopening a failed download).
*/
async getTaskLog(id: string): Promise<string | null> {
const attempt = await this.persist.loadLatestAttempt(id)
if (!attempt) return null
const parts: string[] = []
if (attempt.stdoutTail?.trim()) parts.push(attempt.stdoutTail)
if (attempt.stderrTail?.trim()) parts.push(attempt.stderrTail)
const combined = parts.join('\n').trim()
return combined.length > 0 ? combined : null
}
async cancel(id: string, reason = 'user'): Promise<void> {
const t = this.store.get(id)
if (!t) return
if (TERMINAL_STATUSES.has(t.status)) return
if (t.status === 'queued' || t.status === 'paused') {
await this.scheduler.dequeue(id)
this.retry.remove(id)
await this.applyTransition(id, 'cancelled', {
trigger: 'cancel',
reason
})
return
}
if (t.status === 'retry-scheduled') {
this.retry.remove(id)
await this.applyTransition(id, 'cancelled', {
trigger: 'cancel',
reason
})
return
}
// running/processing — issue cancel through the executor; the finish
// event will drive the FSM transition. ProcessRegistry handles the
// SIGTERM→SIGKILL grace period.
const active = this.active.get(id)
if (active) {
try {
await active.run.cancel()
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] cancel run threw', err)
}
}
// The orchestrator transitions to `cancelled` from the onFinish callback
// (executor reports `result.type === 'cancelled'`).
}
async pause(id: string, reason = 'user'): Promise<void> {
const t = this.store.get(id)
if (!t) return
if (t.status === 'queued') {
await this.scheduler.dequeue(id)
await this.applyTransition(id, 'paused', { trigger: 'pause', reason })
return
}
if (t.status === 'retry-scheduled') {
this.retry.remove(id)
await this.applyTransition(id, 'paused', { trigger: 'pause', reason })
return
}
if (t.status === 'running' || t.status === 'processing') {
const active = this.active.get(id)
if (active) {
try {
await active.run.pause()
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] pause run threw', err)
}
}
}
}
async resume(id: string): Promise<void> {
const t = this.store.get(id)
if (!t || t.status !== 'paused') return
await this.applyTransition(id, 'queued', { trigger: 'resume', reason: 'resume' })
await this.scheduler.enqueue(id, t.priority)
}
async retryManual(id: string): Promise<void> {
const t = this.store.get(id)
if (!t || (t.status !== 'failed' && t.status !== 'cancelled')) return
await this.applyTransition(id, 'queued', {
trigger: t.status === 'failed' ? 'retry-manual' : 'requeue',
reason: 'manual'
})
await this.scheduler.enqueue(id, t.priority)
}
async setMaxConcurrency(n: number): Promise<void> {
await this.scheduler.setMaxConcurrency(n)
}
async setMaxPerGroup(groupKey: string, n: number | null): Promise<void> {
await this.scheduler.setMaxPerGroup(groupKey, n)
}
async removeFromHistory(id: string): Promise<void> {
const t = this.store.get(id)
if (!t) return
if (!TERMINAL_STATUSES.has(t.status)) {
throw new Error(`removeFromHistory: ${id} is not in a terminal state`)
}
this.store.remove(id)
await this.persist.deleteTask(id)
}
stats(): TaskQueueStats {
const stats = this.store.stats(this.scheduler.stats().capacity)
// Scheduler is the source of truth for `running`/`queued` counts; merge.
const sStats = this.scheduler.stats()
stats.running = sStats.running
stats.queued = sStats.queued
stats.perGroup = { ...stats.perGroup, ...sStats.perGroup }
return stats
}
subscribe(listener: TaskQueueListener): () => void {
return this.bus.subscribe(listener)
}
on<T extends TaskQueueEvent['type']>(
type: T,
listener: (e: Extract<TaskQueueEvent, { type: T }>) => void
): () => void {
return this.bus.on(type, listener)
}
// ───────────── Internal: dispatch + executor wiring ─────────────
private async dispatchOne(id: string): Promise<boolean> {
const t = this.store.get(id)
if (!t || t.status !== 'queued') return false
const attemptId = randomUUID()
let next: Task
try {
next = await this.applyTransition(id, 'running', {
trigger: 'dispatch',
reason: null
})
} catch (err) {
if (err instanceof IllegalTransitionError) return false
throw err
}
// Insert the attempt row before the executor spawns; pid is filled in by
// the spawn callback. raw_args_hash captures the snapshot we ran with.
await this.persist.insertAttempt({
taskId: id,
attemptId,
pid: -1,
pidStartedAt: null,
startedAt: this.clock(),
rawArgsHash: hashRawArgs(t.input.rawArgs)
})
let run: ExecutorRun
try {
run = this.executor.run(
{
taskId: id,
attemptId,
attemptNumber: next.attempt,
input: t.input
},
{
onSpawn: (e) => {
this.active.set(id, { taskId: id, attemptId, run })
void this.processes.recordSpawn({
taskId: id,
attemptId,
pid: e.pid,
pidStartedAt: e.pidStartedAt ?? readPidStartTime(e.pid),
kind: e.kind,
spawnedAt: e.spawnedAt
})
// Stamp pid synchronously into the task row.
void this.persist.upsertTask({
task: { ...next, pid: e.pid, pidStartedAt: e.pidStartedAt },
progress: next.progress
})
this.watchdog.arm(id, 'running')
},
onProgress: (e) => {
this.applyProgress(id, e.progress)
this.watchdog.bump(id)
if (e.enteredProcessing) {
void this.applyTransition(id, 'processing', {
trigger: 'progressing',
reason: null
})
this.watchdog.promoteToProcessing(id)
}
},
onStd: (e) => {
this.watchdog.bump(id)
this.bus.emit({
type: 'log',
taskId: id,
attemptId,
stream: e.stream,
line: e.line,
at: this.clock()
})
},
onFinish: (e) => {
void this.handleFinish(id, attemptId, e)
}
}
)
this.active.set(id, { taskId: id, attemptId, run })
} catch (err) {
// executor.run synchronously threw → fail the task and free the slot.
const error = virtualError('unknown', String(err))
await this.applyTransition(id, 'failed', {
trigger: 'finalize-error',
reason: error.category,
error
})
await this.scheduler.releaseSlot(id)
return false
}
return true
}
private async demoteOne(id: string): Promise<void> {
const a = this.active.get(id)
if (a) {
try {
await a.run.pause()
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] demote pause threw', err)
}
}
// applyTransition(running -> paused) happens from the executor's
// onFinish callback when the child reports cancelled/paused. If it
// doesn't fire (executor is stuck), the watchdog will eventually
// surface it.
}
private async handleFinish(
id: string,
attemptId: string,
e: import('../executor').ExecutorFinishEvent
): Promise<void> {
this.watchdog.disarm(id)
this.active.delete(id)
if (e.result.type === 'success') {
const out = e.result.output
const guardOk = out.filePath
? this.filePresent(out.filePath) && out.size > 0
: false
if (guardOk) {
await this.persist.closeAttempt({
taskId: id,
attemptId,
endedAt: e.closedAt,
exitCode: 0,
errorCategory: null,
stdoutTail: e.stdoutTail,
stderrTail: e.stderrTail
})
await this.processes.recordClose(id, attemptId, 0, null)
await this.applyTransition(id, 'completed', {
trigger: 'finalize-success',
reason: null,
output: out
})
} else {
const err = virtualError(
'output-missing',
`output ${out.filePath} missing or empty`
)
await this.persist.closeAttempt({
taskId: id,
attemptId,
endedAt: e.closedAt,
exitCode: null,
errorCategory: 'output-missing',
stdoutTail: e.stdoutTail,
stderrTail: e.stderrTail
})
await this.processes.recordClose(id, attemptId, null, null)
await this.applyTransition(id, 'failed', {
trigger: 'finalize-error',
reason: 'output-missing',
error: err
})
}
await this.scheduler.releaseSlot(id)
return
}
if (e.result.type === 'cancelled') {
// The cancel may have been user-driven, demote-driven, or pause-driven.
// We already disarmed; figure out the post-state from the most recent
// intent. Default → cancelled. The orchestrator's pause()/demote()
// path overrides this by transitioning to `paused` itself.
await this.persist.closeAttempt({
taskId: id,
attemptId,
endedAt: e.closedAt,
exitCode: null,
errorCategory: 'cancelled-by-user',
stdoutTail: e.stdoutTail,
stderrTail: e.stderrTail
})
await this.processes.recordClose(id, attemptId, null, 'SIGTERM')
const t = this.store.get(id)
if (t && (t.status === 'running' || t.status === 'processing')) {
await this.applyTransition(id, 'cancelled', {
trigger: 'cancel',
reason: 'user'
})
}
await this.scheduler.releaseSlot(id)
return
}
// result.type === 'error'
const err = e.result.error
const exitCode = e.result.exitCode ?? null
await this.persist.closeAttempt({
taskId: id,
attemptId,
endedAt: e.closedAt,
exitCode,
errorCategory: err.category,
stdoutTail: e.stdoutTail,
stderrTail: e.stderrTail
})
await this.processes.recordClose(id, attemptId, exitCode, null)
this.bus.emit({
type: 'error-classified',
taskId: id,
attempt: this.store.get(id)?.attempt ?? 0,
error: err,
at: this.clock()
})
const t = this.store.get(id)
const maxAttempts = Math.max(
t?.maxAttempts ?? 0,
defaultMaxAttempts(err.category)
)
const willRetry = err.retryable && (t?.attempt ?? 0) < maxAttempts
if (willRetry) {
const wait = computeBackoffMs(
t?.attempt ?? 0,
err.suggestedRetryAfterMs,
this.rng
)
const nextRetryAt = this.clock() + wait
await this.applyTransition(id, 'retry-scheduled', {
trigger: 'finalize-error',
reason: err.category,
error: err,
nextRetryAt
})
this.retry.enqueue(id, nextRetryAt)
} else {
await this.applyTransition(id, 'failed', {
trigger: 'finalize-error',
reason: err.category,
error: err
})
}
await this.scheduler.releaseSlot(id)
}
private async handleRetryDue(id: string): Promise<void> {
const t = this.store.get(id)
if (!t || t.status !== 'retry-scheduled') return
const next = await this.applyTransition(id, 'queued', {
trigger: 'retry-tick',
reason: 'retry'
})
await this.scheduler.enqueue(id, next.priority)
}
private handleStalled(id: string): void {
void (async () => {
const err = virtualError('stalled', 'Watchdog: task idle exceeded')
const a = this.active.get(id)
if (a) {
try {
await a.run.cancel(0)
} catch {
/* noop */
}
}
// The executor will eventually call onFinish with cancelled; convert
// to error path so retry/maxAttempts is honored.
const t = this.store.get(id)
if (!t) return
const max = Math.max(t.maxAttempts, defaultMaxAttempts('stalled'))
if (t.attempt < max) {
const wait = computeBackoffMs(
t.attempt,
err.suggestedRetryAfterMs,
this.rng
)
const nextRetryAt = this.clock() + wait
await this.applyTransition(id, 'retry-scheduled', {
trigger: 'finalize-error',
reason: 'stalled',
error: err,
nextRetryAt
})
this.retry.enqueue(id, nextRetryAt)
} else {
await this.applyTransition(id, 'failed', {
trigger: 'finalize-error',
reason: 'stalled',
error: err
})
}
await this.scheduler.releaseSlot(id)
})()
}
private async applyTransition(
id: string,
to: TaskStatus,
ctx: TransitionContext
): Promise<Task> {
const cur = this.store.get(id)
if (!cur) throw new Error(`applyTransition: missing task ${id}`)
let next: Task
try {
next = fsmTransition(cur, to, { ...ctx, now: this.clock() })
} catch (err) {
if (err instanceof IllegalTransitionError) {
// panic path (§ task body): journal panic + force this task to failed,
// do not poison other tasks.
await this.persist.appendJournal({
ts: this.clock(),
op: 'panic',
taskId: id,
attemptId: null,
pid: cur.pid ?? -1,
pidStartedAt: cur.pidStartedAt,
exitCode: null,
signal: err.message
})
// Force a terminal failed transition only if legal.
if (cur.status !== 'completed' && cur.status !== 'cancelled') {
const failedErr = virtualError('unknown', err.message)
const fallback = fsmTransition(cur, 'failed', {
trigger: 'finalize-error',
reason: 'panic',
error: failedErr,
now: this.clock()
})
this.store.update(fallback)
await this.persist.upsertTask({
task: fallback,
progress: fallback.progress
})
this.bus.emit({
type: 'transition',
taskId: id,
from: cur.status,
to: 'failed',
reason: 'panic',
attempt: fallback.attempt,
at: this.clock()
})
}
throw err
}
throw err
}
this.store.update(next)
await this.persist.upsertTask({ task: next, progress: next.progress })
this.bus.emit({
type: 'transition',
taskId: id,
from: cur.status,
to: next.status,
reason: next.statusReason,
attempt: next.attempt,
at: next.updatedAt
})
this.bus.emit({
type: 'snapshot-changed',
taskId: id,
task: next,
at: next.updatedAt
})
return next
}
private applyProgress(id: string, progress: TaskProgress): void {
const t = this.store.get(id)
if (!t) return
const merged: Task = { ...t, progress, updatedAt: this.clock() }
this.store.update(merged)
this.progressDirty.set(id, progress)
this.bus.emit({
type: 'progress',
taskId: id,
progress,
at: merged.updatedAt
})
// 1Hz downsample to disk.
const last = this.progressLastWrite.get(id) ?? 0
if (merged.updatedAt - last >= PROGRESS_DOWNSAMPLE_MS) {
this.progressLastWrite.set(id, merged.updatedAt)
void this.persist.upsertProgress(id, progress)
this.progressDirty.delete(id)
}
}
}
function defaultGroupKey(input: TaskInput): string {
if (input.subscriptionId) return `sub:${input.subscriptionId}`
try {
return new URL(input.url).host || 'unknown'
} catch {
return 'unknown'
}
}
function hashRawArgs(args: readonly string[] | undefined): string {
if (!args || args.length === 0) return 'sha256:none'
const h = createHash('sha256')
h.update(args.join(' '))
return `sha256:${h.digest('hex')}`
}
// Re-export so adapters can construct rich finish events with named imports.
export type { ClassifiedError, TaskOutput, TaskProgress }
+311
View File
@@ -0,0 +1,311 @@
/**
* ErrorClassifier — single normative table mapping yt-dlp/ffmpeg stderr to a
* ClassifiedError. Every host adapter MUST use this; no host-local matching.
*
* Rules are evaluated **in order**; the first regex that matches wins. If
* nothing matches, the result is `unknown` with conservative retryability.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §7.1.
*/
import type { ClassifiedError, ErrorCategory } from '../types'
interface Rule {
category: ErrorCategory
regex: RegExp | null
exitCodeHint: number | null
defaultMaxAttempts: number
defaultBackoffMs: number | null
uiActionHints: readonly string[]
uiMessageKey: string
}
/**
* Order matters — DO NOT REORDER without updating the design doc.
*
* Rule 10 (`stalled`) and rule 11 (`cancelled-by-user`) are virtual: they have
* no stderr regex and are produced by the Watchdog and the cancel path
* respectively. They live here so callers can reach the same metadata
* (maxAttempts, hints, message key) by category.
*/
export const CLASSIFIER_RULES: readonly Rule[] = [
{
category: 'http-429',
regex: /HTTP Error 429|Too Many Requests/i,
exitCodeHint: null,
defaultMaxAttempts: 3,
defaultBackoffMs: 30_000,
uiActionHints: ['retry-later'],
uiMessageKey: 'task.error.http429'
},
{
category: 'auth-required',
regex:
/Sign in to confirm|Login required|Private video|members-only|cookies are no longer valid/i,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['import-cookies'],
uiMessageKey: 'task.error.authRequired'
},
{
category: 'geo-blocked',
regex:
/not available in your country|geo-restricted|geo restricted|content isn't available in your country/i,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['set-proxy'],
uiMessageKey: 'task.error.geoBlocked'
},
{
category: 'not-found',
regex:
/HTTP Error 404|Video unavailable|This video has been removed|Requested format is not available/i,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['remove-task'],
uiMessageKey: 'task.error.notFound'
},
{
category: 'disk-full',
regex: /ENOSPC|No space left on device/i,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['open-folder', 'free-disk'],
uiMessageKey: 'task.error.diskFull'
},
{
category: 'permission-denied',
regex: /EACCES|EPERM|Permission denied/i,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['choose-folder'],
uiMessageKey: 'task.error.permissionDenied'
},
{
category: 'binary-missing',
regex: /yt-dlp.*not found|ffmpeg.*not found|ffprobe.*not found/i,
exitCodeHint: 127,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['report-bug'],
uiMessageKey: 'task.error.binaryMissing'
},
{
category: 'ffmpeg',
regex: /ffmpeg|Postprocessing|Conversion failed/i,
exitCodeHint: null,
defaultMaxAttempts: 1,
defaultBackoffMs: 5_000,
uiActionHints: ['report-bug'],
uiMessageKey: 'task.error.ffmpeg'
},
{
category: 'network-transient',
regex:
/socket hang up|ECONNRESET|ECONNREFUSED|getaddrinfo ENOTFOUND|Connection timed out|HTTP Error 5\d\d|Read timed out/i,
exitCodeHint: null,
defaultMaxAttempts: 5,
defaultBackoffMs: null,
uiActionHints: ['retry'],
uiMessageKey: 'task.error.networkTransient'
},
// Virtual rules — produced by Watchdog/cancel; never matched against stderr.
{
category: 'stalled',
regex: null,
exitCodeHint: null,
defaultMaxAttempts: 3,
defaultBackoffMs: 5_000,
uiActionHints: ['retry'],
uiMessageKey: 'task.error.stalled'
},
{
category: 'cancelled-by-user',
regex: null,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: [],
uiMessageKey: 'task.error.cancelledByUser'
},
{
// Virtual: produced by `processing → completed` guard when the output
// file is missing or zero-byte. Non-retryable by default.
category: 'output-missing',
regex: null,
exitCodeHint: null,
defaultMaxAttempts: 0,
defaultBackoffMs: null,
uiActionHints: ['report-bug'],
uiMessageKey: 'task.error.outputMissing'
},
{
category: 'unknown',
regex: null,
exitCodeHint: null,
defaultMaxAttempts: 1,
defaultBackoffMs: null,
uiActionHints: ['retry', 'report-bug'],
uiMessageKey: 'task.error.unknown'
}
]
const RULE_BY_CATEGORY: ReadonlyMap<ErrorCategory, Rule> = new Map(
CLASSIFIER_RULES.map((r) => [r.category, r])
)
export function getRuleForCategory(c: ErrorCategory): Rule {
const r = RULE_BY_CATEGORY.get(c)
if (!r) throw new Error(`no rule for category ${c}`)
return r
}
const STDERR_TAIL_BYTES = 8 * 1024
const SECRET_PATTERNS: readonly RegExp[] = [
// Authorization / cookie / api key headers
/(authorization|cookie|x-api-key|x-auth-token):\s*([^\r\n]+)/gi,
// Inline cookies/tokens that yt-dlp may print as flags
/(--cookies-from-browser\s+\S+\s+--cookies\s+\S+)/gi,
/token=([A-Za-z0-9_\-.]+)/gi,
/secret=([A-Za-z0-9_\-.]+)/gi,
/password=([^\s&]+)/gi
]
/**
* Sanitize stderr/stdout tails before persisting. Header values, tokens, and
* inline cookie params are redacted. The redaction is intentionally aggressive
* because attempts.stderr_tail is read by support engineers.
*/
export function sanitizeOutput(s: string): string {
let out = s
for (const re of SECRET_PATTERNS) {
out = out.replace(re, (_m, p1, _p2) => {
// Keep the header/key name so triagers can see *what* was redacted.
return typeof p1 === 'string' ? `${p1}: <redacted>` : '<redacted>'
})
}
return out
}
export function takeStderrTail(s: string, bytes = STDERR_TAIL_BYTES): string {
if (Buffer.byteLength(s, 'utf8') <= bytes) return s
// We work in characters here; close enough for tail-of-log purposes and
// never longer than `bytes` bytes after the slice.
while (Buffer.byteLength(s, 'utf8') > bytes) {
s = s.slice(Math.floor(s.length * 0.1))
}
return s
}
/**
* Parse the value of an HTTP `Retry-After` header, in milliseconds.
* Accepts both delta-seconds and HTTP-date forms. Returns null on parse
* failure or if the header is absent.
*/
export function parseRetryAfter(header: string | null | undefined): number | null {
if (!header) return null
const trimmed = header.trim()
if (!trimmed) return null
if (/^\d+$/.test(trimmed)) {
const secs = Number.parseInt(trimmed, 10)
if (Number.isFinite(secs) && secs >= 0) return secs * 1000
return null
}
const t = Date.parse(trimmed)
if (Number.isNaN(t)) return null
const delta = t - Date.now()
return delta > 0 ? delta : 0
}
const RETRY_AFTER_REGEX = /Retry-After:\s*([^\r\n]+)/i
export interface ClassifyInput {
stderr: string
exitCode?: number | null
/**
* If yt-dlp surfaces a `Retry-After` value in stderr (it sometimes does),
* the classifier extracts it. Hosts can also pass an explicit value when
* they know one (e.g. captured from HTTP response headers in another path).
*/
retryAfterHeader?: string | null
}
export function classify(input: ClassifyInput): ClassifiedError {
const tail = takeStderrTail(input.stderr ?? '')
const sanitizedTail = sanitizeOutput(tail)
for (const rule of CLASSIFIER_RULES) {
if (!rule.regex) continue
if (
rule.regex.test(tail) ||
(rule.exitCodeHint != null && input.exitCode === rule.exitCodeHint)
) {
return buildError(rule, sanitizedTail, input)
}
}
// Fallback: unknown.
const unknown = getRuleForCategory('unknown')
return buildError(unknown, sanitizedTail, input)
}
/**
* Construct a virtual ClassifiedError (used by Watchdog `stalled` and cancel
* `cancelled-by-user`). No stderr regex is consulted; the rule's defaults are
* used directly.
*/
export function virtualError(
category: ErrorCategory,
rawMessage: string
): ClassifiedError {
const rule = getRuleForCategory(category)
return {
category,
exitCode: null,
rawMessage,
uiMessageKey: rule.uiMessageKey,
uiActionHints: rule.uiActionHints,
retryable: rule.defaultMaxAttempts > 0,
suggestedRetryAfterMs: rule.defaultBackoffMs
}
}
function buildError(
rule: Rule,
sanitizedTail: string,
input: ClassifyInput
): ClassifiedError {
let suggestedRetryAfterMs = rule.defaultBackoffMs
if (rule.category === 'http-429') {
const fromInput = parseRetryAfter(input.retryAfterHeader ?? null)
if (fromInput != null) {
suggestedRetryAfterMs = fromInput
} else {
const m = sanitizedTail.match(RETRY_AFTER_REGEX)
if (m && m[1]) {
const fromTail = parseRetryAfter(m[1])
if (fromTail != null) suggestedRetryAfterMs = fromTail
}
}
}
return {
category: rule.category,
exitCode: input.exitCode ?? null,
rawMessage: sanitizedTail,
uiMessageKey: rule.uiMessageKey,
uiActionHints: rule.uiActionHints,
retryable: rule.defaultMaxAttempts > 0,
suggestedRetryAfterMs
}
}
/**
* Resolve the maxAttempts to use for a category. Adapters can override per
* task by supplying an explicit value; otherwise the table default is used.
*/
export function defaultMaxAttempts(c: ErrorCategory): number {
return getRuleForCategory(c).defaultMaxAttempts
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Single source of truth for the host-neutral TaskQueue RPC surface.
*
* apps/desktop, apps/api and apps/cli MUST consume this contract directly.
* The done-gate for NEX-124 enforces byte-equal schema diffs across hosts —
* any host adding a custom route or a derived schema is a contract bug.
*/
import { oc } from '@orpc/contract'
import {
AddInputSchema,
AddOutputSchema,
ListInputSchema,
ListOutputSchema,
PauseInputSchema,
RetryInputSchema,
SetMaxConcurrencyInputSchema,
SetMaxPerGroupInputSchema,
StatsOutputSchema,
TaskIdInputSchema,
TaskSchema,
VoidOutputSchema
} from './schemas'
export const taskQueueContract = {
add: oc.input(AddInputSchema).output(AddOutputSchema),
get: oc.input(TaskIdInputSchema).output(TaskSchema),
list: oc.input(ListInputSchema).output(ListOutputSchema),
cancel: oc.input(TaskIdInputSchema).output(VoidOutputSchema),
pause: oc.input(PauseInputSchema).output(VoidOutputSchema),
resume: oc.input(TaskIdInputSchema).output(VoidOutputSchema),
retry: oc.input(RetryInputSchema).output(VoidOutputSchema),
setMaxConcurrency: oc
.input(SetMaxConcurrencyInputSchema)
.output(VoidOutputSchema),
setMaxPerGroup: oc.input(SetMaxPerGroupInputSchema).output(VoidOutputSchema),
removeFromHistory: oc.input(TaskIdInputSchema).output(VoidOutputSchema),
stats: oc.output(StatsOutputSchema)
}
export type TaskQueueContract = typeof taskQueueContract
+125
View File
@@ -0,0 +1,125 @@
import type { ClassifiedError, Task, TaskProgress, TaskStatus } from '../types'
export interface TransitionEvent {
type: 'transition'
taskId: string
from: TaskStatus | null
to: TaskStatus
reason: string | null
attempt: number
at: number
}
export interface ProgressEvent {
type: 'progress'
taskId: string
progress: Readonly<TaskProgress>
at: number
}
export interface SnapshotChangedEvent {
type: 'snapshot-changed'
taskId: string
task: Readonly<Task>
at: number
}
export interface OrphanKilledEvent {
type: 'orphan-killed'
taskId: string
pid: number
pidStartedAt: number | null
signal: 'SIGTERM' | 'SIGKILL'
at: number
}
export interface ErrorClassifiedEvent {
type: 'error-classified'
taskId: string
attempt: number
error: ClassifiedError
at: number
}
/** A single stdout/stderr line from the running executor, for live log streaming. */
export interface LogEvent {
type: 'log'
taskId: string
attemptId: string
stream: 'stdout' | 'stderr'
line: string
at: number
}
export type TaskQueueEvent =
| TransitionEvent
| ProgressEvent
| SnapshotChangedEvent
| OrphanKilledEvent
| ErrorClassifiedEvent
| LogEvent
export type TaskQueueEventType = TaskQueueEvent['type']
export type TaskQueueListener<E extends TaskQueueEvent = TaskQueueEvent> = (
event: E
) => void
export class EventBus {
private readonly listeners = new Map<TaskQueueEventType | '*', Set<TaskQueueListener>>()
subscribe(listener: TaskQueueListener): () => void {
return this.subscribeFiltered('*', listener)
}
on<T extends TaskQueueEventType>(
type: T,
listener: TaskQueueListener<Extract<TaskQueueEvent, { type: T }>>
): () => void {
return this.subscribeFiltered(type, listener as TaskQueueListener)
}
emit(event: TaskQueueEvent): void {
const typed = this.listeners.get(event.type)
if (typed) {
for (const l of typed) this.safeCall(l, event)
}
const wildcard = this.listeners.get('*')
if (wildcard) {
for (const l of wildcard) this.safeCall(l, event)
}
}
/** Number of subscribers; for tests/diagnostics. */
size(): number {
let n = 0
for (const set of this.listeners.values()) n += set.size
return n
}
private subscribeFiltered(
key: TaskQueueEventType | '*',
listener: TaskQueueListener
): () => void {
let set = this.listeners.get(key)
if (!set) {
set = new Set()
this.listeners.set(key, set)
}
set.add(listener)
return () => {
set!.delete(listener)
if (set!.size === 0) this.listeners.delete(key)
}
}
private safeCall(listener: TaskQueueListener, event: TaskQueueEvent): void {
try {
listener(event)
} catch (err) {
// Listener errors must never poison kernel control flow.
// eslint-disable-next-line no-console
console.error('[task-queue] listener threw', err)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Executor — host-neutral interface that the orchestrator uses to spawn a
* single attempt of a task. The default production implementation lives in
* @vidbee/downloader-core (`YtDlpExecutor`) and is owned by NEX-131. Tests
* supply a fake.
*/
import type {
ClassifiedError,
ProcessKind,
TaskInput,
TaskOutput,
TaskProgress
} from '../types'
export interface ExecutorSpawnEvent {
taskId: string
attemptId: string
pid: number
pidStartedAt: number | null
kind: ProcessKind
spawnedAt: number
}
export interface ExecutorProgressEvent {
taskId: string
attemptId: string
progress: TaskProgress
/**
* True when yt-dlp emits a Postprocess/Merging line — the orchestrator
* uses this to transition `running -> processing`.
*/
enteredProcessing: boolean
}
export interface ExecutorStdEvent {
taskId: string
attemptId: string
stream: 'stdout' | 'stderr'
/** A complete line with no trailing newline. */
line: string
}
export interface ExecutorFinishEvent {
taskId: string
attemptId: string
/**
* One of:
* - 'success' with output (filePath, size, ...) if the executor verified
* the file exists and is non-empty.
* - 'error' with a ClassifiedError if the run produced a non-zero exit
* or the executor decided to surface a structured failure.
* - 'cancelled' if the orchestrator requested cancel and the child has
* been reaped.
*/
result:
| { type: 'success'; output: TaskOutput }
| { type: 'error'; error: ClassifiedError; exitCode: number | null }
| { type: 'cancelled' }
closedAt: number
/** stdoutTail/stderrTail to be persisted on the attempt row. */
stdoutTail: string
stderrTail: string
}
export interface ExecutorEvents {
onSpawn: (e: ExecutorSpawnEvent) => void
onProgress: (e: ExecutorProgressEvent) => void
onStd: (e: ExecutorStdEvent) => void
onFinish: (e: ExecutorFinishEvent) => void
}
export interface ExecutorRun {
/**
* Issue SIGTERM and reap. The implementation MUST resolve onFinish with
* `{ type: 'cancelled' }` after the child closes; the orchestrator will
* issue SIGKILL after a 10s grace period (handled here so the kernel does
* not need to know OS specifics).
*/
cancel: (timeout?: number) => Promise<void>
/**
* Pause: SIGTERM but record that resume is allowed. Same finish semantics
* as cancel; orchestrator will stamp `paused('user')` on the FSM.
*/
pause: () => Promise<void>
}
export interface ExecutorContext {
taskId: string
attemptId: string
attemptNumber: number
input: TaskInput
}
export interface Executor {
/**
* Begin a new attempt. Returns a handle used to cancel/pause. The
* orchestrator subscribes to events through the supplied `events`
* callbacks; the executor MUST call them in order:
* onSpawn → (onProgress|onStd)* → onFinish (exactly once).
*/
run(ctx: ExecutorContext, events: ExecutorEvents): ExecutorRun
}
+204
View File
@@ -0,0 +1,204 @@
/**
* TaskFSM — the only allowed mutator of `task.status`.
*
* The transition table mirrors docs/vidbee-task-queue-state-machine-design.md §3.1
* one-for-one. Any combination not listed below MUST throw IllegalTransitionError;
* adapters must NOT define new transitions.
*/
import type { ClassifiedError, Task, TaskOutput, TaskStatus } from '../types'
import { TERMINAL_STATUSES } from '../types'
export type TransitionTrigger =
| 'dispatch'
| 'pause'
| 'cancel'
| 'progressing'
| 'finalize-success'
| 'finalize-error'
| 'retry-tick'
| 'resume'
| 'fail'
| 'retry-manual'
| 'requeue'
| 'crash-recovery'
export interface TransitionContext {
trigger: TransitionTrigger
reason?: string | null
/** Required when trigger=finalize-success. */
output?: TaskOutput
/** Required when trigger=finalize-error or fail. */
error?: ClassifiedError
/** Required when trigger=finalize-error and we want retry-scheduled. */
nextRetryAt?: number
/** Stamp used for `enteredStatusAt`/`updatedAt`. Defaults to Date.now(). */
now?: number
}
export class IllegalTransitionError extends Error {
readonly from: TaskStatus
readonly to: TaskStatus
readonly reason: string | undefined
constructor(from: TaskStatus, to: TaskStatus, reason?: string) {
super(
`IllegalTransition: ${from} -> ${to}${reason ? ` (${reason})` : ''}`
)
this.name = 'IllegalTransitionError'
this.from = from
this.to = to
this.reason = reason
}
}
/**
* Static legality table — enumerates every From→To combination listed in §3.1.
* Keys are From, values are the set of legal To statuses. Anything not present
* MUST be rejected.
*/
export const LEGAL_TRANSITIONS: Readonly<Record<TaskStatus, ReadonlySet<TaskStatus>>> = {
queued: new Set<TaskStatus>(['running', 'paused', 'cancelled']),
running: new Set<TaskStatus>([
'processing',
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
]),
processing: new Set<TaskStatus>([
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
]),
paused: new Set<TaskStatus>(['queued', 'cancelled', 'failed']),
'retry-scheduled': new Set<TaskStatus>(['queued', 'paused', 'cancelled']),
failed: new Set<TaskStatus>(['queued']),
cancelled: new Set<TaskStatus>(['queued']),
completed: new Set<TaskStatus>()
}
export function isLegalTransition(from: TaskStatus, to: TaskStatus): boolean {
return LEGAL_TRANSITIONS[from]?.has(to) ?? false
}
/**
* Apply a transition and return the next Task value. Pure: callers (Scheduler,
* Executor, RetryScheduler) feed in the current Task and receive a new one;
* persistence and event emission live in the orchestrator.
*
* Throws IllegalTransitionError on anything outside §3.1's table.
*/
export function transition(
task: Readonly<Task>,
to: TaskStatus,
ctx: TransitionContext
): Task {
const from = task.status
if (TERMINAL_STATUSES.has(from) && from !== to) {
// `completed` has no outgoing edges at all; failed/cancelled only allow
// user-initiated requeue/retry which IS in the table — fall through to the
// legality check, which rejects everything else.
}
if (!isLegalTransition(from, to)) {
throw new IllegalTransitionError(from, to, ctx.trigger)
}
const now = ctx.now ?? Date.now()
const next: Task = {
...task,
prevStatus: from,
status: to,
statusReason: ctx.reason ?? null,
enteredStatusAt: now,
updatedAt: now
}
switch (to) {
case 'running':
// attempt is incremented when we leave retry-scheduled (see retry-tick path);
// here we only handle the initial dispatch from `queued`. The orchestrator
// is responsible for stamping pid/pidStartedAt on the spawn callback.
if (from === 'queued' && task.attempt === 0) {
next.attempt = 1
}
break
case 'queued':
if (ctx.trigger === 'retry-tick') {
next.attempt = task.attempt + 1
next.nextRetryAt = null
} else if (ctx.trigger === 'retry-manual') {
next.attempt = 0
next.lastError = null
next.nextRetryAt = null
} else if (ctx.trigger === 'requeue') {
next.lastError = null
next.nextRetryAt = null
} else if (ctx.trigger === 'resume') {
// resume from paused: keep progress, do not bump attempt
next.nextRetryAt = null
}
break
case 'completed': {
if (!ctx.output) {
throw new IllegalTransitionError(
from,
to,
'finalize-success requires output'
)
}
// Hard guard from §3.1: completed only legal when file exists with size > 0.
// Existence is the orchestrator's job (FS check); size is checked here.
if (ctx.output.size <= 0) {
throw new IllegalTransitionError(
from,
to,
'completed requires output.size > 0'
)
}
next.output = ctx.output
next.lastError = null
next.pid = null
next.pidStartedAt = null
break
}
case 'failed': {
if (ctx.error) next.lastError = ctx.error
next.pid = null
next.pidStartedAt = null
break
}
case 'cancelled': {
next.pid = null
next.pidStartedAt = null
break
}
case 'retry-scheduled': {
if (ctx.nextRetryAt == null || ctx.nextRetryAt <= 0) {
throw new IllegalTransitionError(
from,
to,
'retry-scheduled requires nextRetryAt'
)
}
if (ctx.error) next.lastError = ctx.error
next.nextRetryAt = ctx.nextRetryAt
next.pid = null
next.pidStartedAt = null
break
}
case 'paused': {
// progress is preserved; pid is cleared because the process is gone
// (SIGTERM on the running path, or never spawned on the queued path).
next.pid = null
next.pidStartedAt = null
break
}
case 'processing':
// running -> processing — pid stays bound to the same yt-dlp child.
break
}
return next
}
+111
View File
@@ -0,0 +1,111 @@
// Public surface of @vidbee/task-queue. Adapters import from here.
export * from './types'
export * from './schemas'
export { taskQueueContract } from './contract'
export type { TaskQueueContract } from './contract'
export {
IllegalTransitionError,
isLegalTransition,
LEGAL_TRANSITIONS,
transition
} from './fsm'
export type { TransitionContext, TransitionTrigger } from './fsm'
export {
CLASSIFIER_RULES,
classify,
defaultMaxAttempts,
getRuleForCategory,
parseRetryAfter,
sanitizeOutput,
takeStderrTail,
virtualError
} from './classifier'
export type { ClassifyInput } from './classifier'
export {
EventBus
} from './events'
export type {
ErrorClassifiedEvent,
OrphanKilledEvent,
ProgressEvent,
SnapshotChangedEvent,
TaskQueueEvent,
TaskQueueEventType,
TaskQueueListener,
TransitionEvent
} from './events'
export {
computeBackoffMs,
RetryScheduler,
Scheduler
} from './scheduler'
export type {
RetrySchedulerOptions,
SchedulerCallbacks,
SchedulerOptions
} from './scheduler'
export { TaskStore } from './store'
export {
isPidAlive,
ProcessRegistry,
readPidStartTime,
setReadPidStartTimeImpl,
Watchdog
} from './process'
export type {
ProcessHandle,
ProcessRegistryDeps,
ReadPidStartTimeFn,
WatchdogConfig,
WatchdogEntry
} from './process'
export type {
Executor,
ExecutorContext,
ExecutorEvents,
ExecutorFinishEvent,
ExecutorProgressEvent,
ExecutorRun,
ExecutorSpawnEvent,
ExecutorStdEvent
} from './executor'
export {
MemoryPersistAdapter,
SqlitePersistAdapter
} from './persist'
export type {
JournalAppendInput,
PersistAdapter,
PersistTransitionInput,
RecordCloseInput,
RecordSpawnInput,
SqlitePersistOptions
} from './persist'
export { TaskQueueAPI } from './api'
export type {
AddTaskRequest,
ListOptions,
TaskQueueAPIOptions
} from './api'
export {
legacyDownloadStatusOf,
legacySubStatusOf,
projectTaskToLegacy
} from './projection'
export type {
LegacyDownloadProgress,
LegacyDownloadStatus,
LegacySubStatus,
LegacyTaskProjection
} from './projection'
@@ -0,0 +1,86 @@
/**
* PersistAdapter — single writer to the durable store. The contract here is
* what the orchestrator and ProcessRegistry use; concrete implementations
* (memory for tests, SQLite for production) live alongside.
*
* Design rules (§9):
* - One writer. The orchestrator is the only caller.
* - transition writes are immediate (BEGIN IMMEDIATE on SQLite).
* - progress writes are downsampled to ≤ 1Hz; terminal transitions force a
* flush of the most recent progress.
* - pid + pidStartedAt are written synchronously inside `recordSpawn`; no
* throttling allowed.
*/
import type {
AttemptRow,
ProcessJournalOp,
ProcessJournalRow,
Task,
TaskProgress
} from '../types'
export interface PersistTransitionInput {
task: Task
/** snapshot to atomically write progress alongside the transition. */
progress: TaskProgress
}
export interface RecordSpawnInput {
taskId: string
attemptId: string
pid: number
pidStartedAt: number | null
startedAt: number
rawArgsHash: string
}
export interface RecordCloseInput {
taskId: string
attemptId: string
endedAt: number
exitCode: number | null
errorCategory: AttemptRow['errorCategory']
stdoutTail: string | null
stderrTail: string | null
}
export interface JournalAppendInput {
ts: number
op: ProcessJournalOp
taskId: string
attemptId: string | null
pid: number
pidStartedAt: number | null
exitCode: number | null
signal: string | null
}
export interface PersistAdapter {
/** Insert a new task (called from `add()`). */
insertTask(task: Task): Promise<void>
/** Replace the existing task row + progress row. Used for every transition. */
upsertTask(input: PersistTransitionInput): Promise<void>
/** Downsampled progress write (the orchestrator calls at most every 1s). */
upsertProgress(taskId: string, progress: TaskProgress): Promise<void>
/** Remove a task (cancelFromHistory). */
deleteTask(taskId: string): Promise<void>
/** Insert a new attempt row at spawn time. */
insertAttempt(input: RecordSpawnInput): Promise<void>
/** Update the attempt row at close time. */
closeAttempt(input: RecordCloseInput): Promise<void>
/** Append a process_journal row. */
appendJournal(input: JournalAppendInput): Promise<void>
/** Find spawn rows that have no matching close/killed entry. */
findOpenSpawns(): Promise<ProcessJournalRow[]>
/** Restore all task rows on startup (crash recovery). */
loadAllTasks(): Promise<Task[]>
/** Restore the latest attempt row per task on startup. */
loadLatestAttempt(taskId: string): Promise<AttemptRow | null>
/**
* Periodic maintenance: WAL checkpoint + journal aging. Optional —
* memory impl is a no-op.
*/
maintenance?(): Promise<void>
/** Close the underlying connection. */
close?(): Promise<void> | void
}
+10
View File
@@ -0,0 +1,10 @@
export type {
PersistAdapter,
PersistTransitionInput,
RecordSpawnInput,
RecordCloseInput,
JournalAppendInput
} from './adapter'
export { MemoryPersistAdapter } from './memory'
export { SqlitePersistAdapter } from './sqlite'
export type { SqlitePersistOptions } from './sqlite'
+132
View File
@@ -0,0 +1,132 @@
/**
* Memory implementation of PersistAdapter — used by tests and for the
* `--vidbee-local` CLI mode where there is no SQLite file.
*
* Behaves like SQLite for the purposes of our state-machine tests:
* findOpenSpawns walks the journal looking for spawn rows without a
* close/killed peer.
*/
import type {
AttemptRow,
ProcessJournalRow,
Task,
TaskProgress
} from '../types'
import type {
JournalAppendInput,
PersistAdapter,
PersistTransitionInput,
RecordCloseInput,
RecordSpawnInput
} from './adapter'
export class MemoryPersistAdapter implements PersistAdapter {
private readonly tasks = new Map<string, Task>()
private readonly progress = new Map<string, TaskProgress>()
private readonly attempts = new Map<string, AttemptRow>()
private readonly journal: ProcessJournalRow[] = []
private journalSeq = 0
async insertTask(task: Task): Promise<void> {
this.tasks.set(task.id, structuredClone(task))
this.progress.set(task.id, structuredClone(task.progress))
}
async upsertTask(input: PersistTransitionInput): Promise<void> {
this.tasks.set(input.task.id, structuredClone(input.task))
this.progress.set(input.task.id, structuredClone(input.progress))
}
async upsertProgress(taskId: string, progress: TaskProgress): Promise<void> {
this.progress.set(taskId, structuredClone(progress))
const t = this.tasks.get(taskId)
if (t) t.progress = structuredClone(progress)
}
async deleteTask(taskId: string): Promise<void> {
this.tasks.delete(taskId)
this.progress.delete(taskId)
}
async insertAttempt(input: RecordSpawnInput): Promise<void> {
const t = this.tasks.get(input.taskId)
const attemptNumber = t?.attempt ?? 1
this.attempts.set(input.attemptId, {
id: input.attemptId,
taskId: input.taskId,
attemptNumber,
startedAt: input.startedAt,
endedAt: null,
exitCode: null,
errorCategory: null,
stdoutTail: null,
stderrTail: null,
rawArgsHash: input.rawArgsHash
})
}
async closeAttempt(input: RecordCloseInput): Promise<void> {
const existing = this.attempts.get(input.attemptId)
if (!existing) return
this.attempts.set(input.attemptId, {
...existing,
endedAt: input.endedAt,
exitCode: input.exitCode,
errorCategory: input.errorCategory,
stdoutTail: input.stdoutTail,
stderrTail: input.stderrTail
})
}
async appendJournal(input: JournalAppendInput): Promise<void> {
this.journal.push({
seq: ++this.journalSeq,
ts: input.ts,
op: input.op,
taskId: input.taskId,
attemptId: input.attemptId,
pid: input.pid,
pidStartedAt: input.pidStartedAt,
exitCode: input.exitCode,
signal: input.signal
})
}
async findOpenSpawns(): Promise<ProcessJournalRow[]> {
const closed = new Set<string>()
for (const row of this.journal) {
if (row.op === 'close' || row.op === 'killed') {
closed.add(this.journalKey(row))
}
}
const result: ProcessJournalRow[] = []
for (const row of this.journal) {
if (row.op !== 'spawn') continue
if (closed.has(this.journalKey(row))) continue
result.push(row)
}
return result
}
async loadAllTasks(): Promise<Task[]> {
return [...this.tasks.values()].map((t) => structuredClone(t))
}
async loadLatestAttempt(taskId: string): Promise<AttemptRow | null> {
let best: AttemptRow | null = null
for (const a of this.attempts.values()) {
if (a.taskId !== taskId) continue
if (!best || a.attemptNumber > best.attemptNumber) best = a
}
return best
}
/** Test helper: not part of the interface. */
journalSnapshot(): readonly ProcessJournalRow[] {
return this.journal
}
private journalKey(row: ProcessJournalRow): string {
return `${row.taskId}:${row.attemptId ?? 'null'}:${row.pid}`
}
}
+454
View File
@@ -0,0 +1,454 @@
/**
* SQLite implementation of PersistAdapter using better-sqlite3.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §9.
*
* Operational rules implemented here:
* - WAL + synchronous=NORMAL on connect.
* - Single writer: every write goes through this adapter.
* - transition writes use BEGIN IMMEDIATE.
* - PID/pidStartedAt are written synchronously at spawn.
* - maintenance() runs `wal_checkpoint(TRUNCATE)` and ages process_journal
* entries older than 30 days.
*
* The DDL itself is owned by `@vidbee/db/migrations` (added in this PR);
* this adapter only opens the file and assumes migrations have run.
*/
import type {
AttemptRow,
ProcessJournalOp,
ProcessJournalRow,
Task,
TaskProgress,
TaskStatus
} from '../types'
import type {
JournalAppendInput,
PersistAdapter,
PersistTransitionInput,
RecordCloseInput,
RecordSpawnInput
} from './adapter'
interface SqliteLikeStatement {
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }
all(...params: unknown[]): unknown[]
get(...params: unknown[]): unknown
}
// Match better-sqlite3's `transaction()` shape without depending on its types:
// `transaction(fn)` returns the same callable plus `.immediate / .deferred /
// .exclusive` variants. We accept any function shape here because the wrapper
// is a transparent proxy.
type SqliteTransactionFn<F extends (...args: never[]) => unknown> = F & {
immediate: F
deferred: F
exclusive: F
}
interface SqliteLikeDatabase {
prepare(sql: string): SqliteLikeStatement
exec(sql: string): void
pragma(sql: string, opts?: { simple?: boolean }): unknown
// biome-ignore lint/suspicious/noExplicitAny: structural match against better-sqlite3
transaction<F extends (...args: any[]) => unknown>(fn: F): SqliteTransactionFn<F>
close(): void
}
export interface SqlitePersistOptions {
/** Open better-sqlite3 db; passed in so we don't import it eagerly. */
db: SqliteLikeDatabase
/** Override journal-aging window. Default 30 days. */
journalAgeMs?: number
}
const DEFAULT_JOURNAL_AGE_MS = 30 * 24 * 60 * 60 * 1000
export class SqlitePersistAdapter implements PersistAdapter {
private readonly db: SqliteLikeDatabase
private readonly journalAgeMs: number
// Prepared statements (lazy on first use to allow construction before
// migrations have run in some test setups).
private stmts!: Stmts
constructor(opts: SqlitePersistOptions) {
this.db = opts.db
this.journalAgeMs = opts.journalAgeMs ?? DEFAULT_JOURNAL_AGE_MS
this.applyPragmas()
}
async insertTask(task: Task): Promise<void> {
this.ensureStmts()
const tx = this.db.transaction((row: Task) => {
this.stmts.insertTask.run(...this.bindTask(row))
})
tx.immediate(task)
}
async upsertTask(input: PersistTransitionInput): Promise<void> {
this.ensureStmts()
const tx = this.db.transaction((task: Task, progress: TaskProgress) => {
this.stmts.upsertTask.run(...this.bindTask(task))
this.stmts.upsertProgress.run(JSON.stringify(progress), task.id)
})
tx.immediate(input.task, input.progress)
}
async upsertProgress(taskId: string, progress: TaskProgress): Promise<void> {
this.ensureStmts()
this.stmts.upsertProgress.run(JSON.stringify(progress), taskId)
}
async deleteTask(taskId: string): Promise<void> {
this.ensureStmts()
const tx = this.db.transaction((id: string) => {
this.stmts.deleteAttempts.run(id)
this.stmts.deleteTask.run(id)
})
tx.immediate(taskId)
}
async insertAttempt(input: RecordSpawnInput): Promise<void> {
this.ensureStmts()
// attempt_number is computed by the orchestrator (it equals task.attempt).
// We need a numeric value; read it from the tasks table to be safe.
const row = this.stmts.selectAttemptByTaskCount.get(input.taskId) as
| { n: number | bigint }
| undefined
const attemptNumber = Number(row?.n ?? 0) + 1
this.stmts.insertAttempt.run(
input.attemptId,
input.taskId,
attemptNumber,
input.startedAt,
input.rawArgsHash
)
}
async closeAttempt(input: RecordCloseInput): Promise<void> {
this.ensureStmts()
this.stmts.closeAttempt.run(
input.endedAt,
input.exitCode,
input.errorCategory,
input.stdoutTail,
input.stderrTail,
input.attemptId
)
}
async appendJournal(input: JournalAppendInput): Promise<void> {
this.ensureStmts()
this.stmts.appendJournal.run(
input.ts,
input.op,
input.taskId,
input.attemptId,
input.pid,
input.pidStartedAt,
input.exitCode,
input.signal
)
}
async findOpenSpawns(): Promise<ProcessJournalRow[]> {
this.ensureStmts()
const rows = this.stmts.findOpenSpawns.all() as Array<{
seq: number | bigint
ts: number
op: ProcessJournalOp
task_id: string
attempt_id: string | null
pid: number
pid_started_at: number | null
exit_code: number | null
signal: string | null
}>
return rows.map((r) => ({
seq: Number(r.seq),
ts: r.ts,
op: r.op,
taskId: r.task_id,
attemptId: r.attempt_id,
pid: r.pid,
pidStartedAt: r.pid_started_at,
exitCode: r.exit_code,
signal: r.signal
}))
}
async loadAllTasks(): Promise<Task[]> {
this.ensureStmts()
const rows = this.stmts.loadAllTasks.all() as TaskDbRow[]
return rows.map(rowToTask)
}
async loadLatestAttempt(taskId: string): Promise<AttemptRow | null> {
this.ensureStmts()
const row = this.stmts.loadLatestAttempt.get(taskId) as AttemptDbRow | undefined
if (!row) return null
return {
id: row.id,
taskId: row.task_id,
attemptNumber: row.attempt_number,
startedAt: row.started_at,
endedAt: row.ended_at,
exitCode: row.exit_code,
errorCategory: row.error_category as AttemptRow['errorCategory'],
stdoutTail: row.stdout_tail,
stderrTail: row.stderr_tail,
rawArgsHash: row.raw_args_hash
}
}
async maintenance(): Promise<void> {
this.ensureStmts()
const cutoff = Date.now() - this.journalAgeMs
this.stmts.ageJournal.run(cutoff)
try {
this.db.pragma('wal_checkpoint(TRUNCATE)')
} catch {
// checkpoint failure is non-fatal
}
}
close(): void {
try {
this.db.close()
} catch {
/* noop */
}
}
private applyPragmas(): void {
try {
this.db.pragma('journal_mode = WAL')
this.db.pragma('synchronous = NORMAL')
this.db.pragma('foreign_keys = ON')
} catch {
// tests pass in stub dbs that don't support pragmas
}
}
private ensureStmts(): void {
if (this.stmts) return
this.stmts = {
insertTask: this.db.prepare(SQL_INSERT_TASK),
upsertTask: this.db.prepare(SQL_UPSERT_TASK),
upsertProgress: this.db.prepare(SQL_UPSERT_PROGRESS),
deleteTask: this.db.prepare('DELETE FROM tasks WHERE id = ?'),
deleteAttempts: this.db.prepare('DELETE FROM attempts WHERE task_id = ?'),
insertAttempt: this.db.prepare(SQL_INSERT_ATTEMPT),
closeAttempt: this.db.prepare(SQL_CLOSE_ATTEMPT),
appendJournal: this.db.prepare(SQL_APPEND_JOURNAL),
findOpenSpawns: this.db.prepare(SQL_FIND_OPEN_SPAWNS),
loadAllTasks: this.db.prepare(SQL_LOAD_ALL_TASKS),
loadLatestAttempt: this.db.prepare(SQL_LOAD_LATEST_ATTEMPT),
ageJournal: this.db.prepare(SQL_AGE_JOURNAL),
selectAttemptByTaskCount: this.db.prepare(
'SELECT COUNT(*) AS n FROM attempts WHERE task_id = ?'
)
}
}
private bindTask(t: Task): unknown[] {
return [
t.id,
t.kind,
t.parentId,
t.status,
t.prevStatus,
t.statusReason,
t.enteredStatusAt,
t.priority,
t.groupKey,
t.attempt,
t.maxAttempts,
t.nextRetryAt,
t.pid,
t.pidStartedAt,
t.createdAt,
t.updatedAt,
JSON.stringify(t.input),
JSON.stringify(t.progress),
t.output ? JSON.stringify(t.output) : null,
t.lastError ? JSON.stringify(t.lastError) : null
]
}
}
interface Stmts {
insertTask: SqliteLikeStatement
upsertTask: SqliteLikeStatement
upsertProgress: SqliteLikeStatement
deleteTask: SqliteLikeStatement
deleteAttempts: SqliteLikeStatement
insertAttempt: SqliteLikeStatement
closeAttempt: SqliteLikeStatement
appendJournal: SqliteLikeStatement
findOpenSpawns: SqliteLikeStatement
loadAllTasks: SqliteLikeStatement
loadLatestAttempt: SqliteLikeStatement
ageJournal: SqliteLikeStatement
selectAttemptByTaskCount: SqliteLikeStatement
}
interface TaskDbRow {
id: string
kind: string
parent_id: string | null
status: TaskStatus
prev_status: TaskStatus | null
status_reason: string | null
entered_status_at: number
priority: number
group_key: string
attempt: number
max_attempts: number
next_retry_at: number | null
pid: number | null
pid_started_at: number | null
created_at: number
updated_at: number
input_json: string
progress_json: string | null
output_json: string | null
last_error_json: string | null
}
interface AttemptDbRow {
id: string
task_id: string
attempt_number: number
started_at: number
ended_at: number | null
exit_code: number | null
error_category: string | null
stdout_tail: string | null
stderr_tail: string | null
raw_args_hash: string
}
function rowToTask(r: TaskDbRow): Task {
return {
id: r.id,
kind: r.kind as Task['kind'],
parentId: r.parent_id,
input: JSON.parse(r.input_json),
priority: r.priority as Task['priority'],
groupKey: r.group_key,
status: r.status,
prevStatus: r.prev_status,
statusReason: r.status_reason,
enteredStatusAt: r.entered_status_at,
attempt: r.attempt,
maxAttempts: r.max_attempts,
nextRetryAt: r.next_retry_at,
progress: r.progress_json
? JSON.parse(r.progress_json)
: {
percent: null,
bytesDownloaded: null,
bytesTotal: null,
speedBps: null,
etaMs: null,
ticks: 0
},
output: r.output_json ? JSON.parse(r.output_json) : null,
lastError: r.last_error_json ? JSON.parse(r.last_error_json) : null,
pid: r.pid,
pidStartedAt: r.pid_started_at,
createdAt: r.created_at,
updatedAt: r.updated_at
}
}
const SQL_INSERT_TASK = `
INSERT INTO tasks (
id, kind, parent_id, status, prev_status, status_reason, entered_status_at,
priority, group_key, attempt, max_attempts, next_retry_at, pid,
pid_started_at, created_at, updated_at, input_json, progress_json,
output_json, last_error_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const SQL_UPSERT_TASK = `
INSERT INTO tasks (
id, kind, parent_id, status, prev_status, status_reason, entered_status_at,
priority, group_key, attempt, max_attempts, next_retry_at, pid,
pid_started_at, created_at, updated_at, input_json, progress_json,
output_json, last_error_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
parent_id = excluded.parent_id,
status = excluded.status,
prev_status = excluded.prev_status,
status_reason = excluded.status_reason,
entered_status_at = excluded.entered_status_at,
priority = excluded.priority,
group_key = excluded.group_key,
attempt = excluded.attempt,
max_attempts = excluded.max_attempts,
next_retry_at = excluded.next_retry_at,
pid = excluded.pid,
pid_started_at = excluded.pid_started_at,
updated_at = excluded.updated_at,
input_json = excluded.input_json,
progress_json = excluded.progress_json,
output_json = excluded.output_json,
last_error_json = excluded.last_error_json
`
const SQL_UPSERT_PROGRESS = `
UPDATE tasks SET progress_json = ?, updated_at = strftime('%s', 'now') * 1000 WHERE id = ?
`
const SQL_INSERT_ATTEMPT = `
INSERT INTO attempts (id, task_id, attempt_number, started_at, raw_args_hash)
VALUES (?, ?, ?, ?, ?)
`
const SQL_CLOSE_ATTEMPT = `
UPDATE attempts SET
ended_at = ?,
exit_code = ?,
error_category = ?,
stdout_tail = ?,
stderr_tail = ?
WHERE id = ?
`
const SQL_APPEND_JOURNAL = `
INSERT INTO process_journal (ts, op, task_id, attempt_id, pid, pid_started_at, exit_code, signal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
const SQL_FIND_OPEN_SPAWNS = `
SELECT s.*
FROM process_journal s
WHERE s.op = 'spawn'
AND NOT EXISTS (
SELECT 1 FROM process_journal c
WHERE c.task_id = s.task_id
AND IFNULL(c.attempt_id, '') = IFNULL(s.attempt_id, '')
AND c.pid = s.pid
AND c.op IN ('close', 'killed')
AND c.seq > s.seq
)
ORDER BY s.seq ASC
`
const SQL_LOAD_ALL_TASKS = `SELECT * FROM tasks`
const SQL_LOAD_LATEST_ATTEMPT = `
SELECT * FROM attempts
WHERE task_id = ?
ORDER BY attempt_number DESC
LIMIT 1
`
const SQL_AGE_JOURNAL = `
DELETE FROM process_journal WHERE ts < ?
`
+11
View File
@@ -0,0 +1,11 @@
export { killProcessTree } from './kill-process-tree'
export { ProcessRegistry } from './registry'
export type { ProcessHandle, ProcessRegistryDeps } from './registry'
export { Watchdog } from './watchdog'
export type { WatchdogConfig, WatchdogEntry } from './watchdog'
export {
readPidStartTime,
setReadPidStartTimeImpl,
isPidAlive
} from './pid-start-time'
export type { ReadPidStartTimeFn } from './pid-start-time'
@@ -0,0 +1,34 @@
import { execFile } from 'node:child_process'
/**
* Kill a process together with its entire child tree, cross-platform.
*
* On Windows there are no POSIX signals, so `process.kill(pid, 'SIGTERM')`
* terminates only the parent yt-dlp process and leaves its spawned ffmpeg /
* fragment-downloader children running — the download keeps going in the
* background after the user cancels (GitHub issue #395). `taskkill /T` walks
* and terminates the whole process tree; `/F` forces it because yt-dlp does
* not handle the graceful WM_CLOSE that plain taskkill sends. On POSIX we keep
* the existing single-process signal semantics (SIGTERM grace, then SIGKILL).
*
* @param pid Process id to terminate; no-op when missing or non-positive.
* @param signal Signal used on POSIX platforms.
*/
export const killProcessTree = (
pid: number | undefined,
signal: 'SIGTERM' | 'SIGKILL' = 'SIGTERM'
): void => {
if (pid === undefined || pid <= 0) {
return
}
if (process.platform === 'win32') {
// Best-effort: the process may already be gone, so ignore taskkill errors.
execFile('taskkill', ['/PID', String(pid), '/T', '/F'], () => {
// noop — failures mean the tree is already terminated.
})
return
}
process.kill(pid, signal)
}
@@ -0,0 +1,111 @@
/**
* Cross-platform helper for reading a process's start time. The combination
* `(pid, pidStartedAt)` is the only safe way to detect pid reuse during
* crash recovery.
*
* Platform notes (per design doc §8):
* - linux: read `/proc/<pid>/stat` field 22 (starttime, in clock ticks).
* - macOS: spawn `ps -o lstart= -p <pid>`; convert to epoch ms.
* - windows: GetProcessTimes via N-API. Not implemented in pure-JS; hosts
* are expected to inject a platform-specific function via
* `setReadPidStartTimeImpl`. The kernel ships a permissive default that
* returns null on Windows so recovery falls back to "treat as orphan".
*/
import { existsSync, readFileSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
export type ReadPidStartTimeFn = (pid: number) => number | null
let impl: ReadPidStartTimeFn = defaultImpl
export function setReadPidStartTimeImpl(fn: ReadPidStartTimeFn): void {
impl = fn
}
export function readPidStartTime(pid: number): number | null {
try {
return impl(pid)
} catch {
return null
}
}
function defaultImpl(pid: number): number | null {
if (process.platform === 'linux') return readLinuxStart(pid)
if (process.platform === 'darwin') return readDarwinStart(pid)
// win32 default: null. Recovery will treat the pid as a real orphan.
return null
}
function readLinuxStart(pid: number): number | null {
const path = `/proc/${pid}/stat`
if (!existsSync(path)) return null
const raw = readFileSync(path, 'utf8')
// field 22 (starttime) — careful around the comm field which may contain
// spaces and is wrapped in parens.
const lastParen = raw.lastIndexOf(')')
if (lastParen < 0) return null
const fields = raw.slice(lastParen + 2).split(' ')
const starttimeTicks = Number.parseInt(fields[19] ?? '', 10)
if (!Number.isFinite(starttimeTicks)) return null
// Compute btime + (starttime / hz) → epoch seconds.
const stat = readFileSync('/proc/stat', 'utf8')
const btimeMatch = stat.match(/^btime\s+(\d+)/m)
if (!btimeMatch) return null
const btime = Number.parseInt(btimeMatch[1] ?? '', 10)
const hz = readClockTickRate() ?? 100
return (btime + starttimeTicks / hz) * 1000
}
function readDarwinStart(pid: number): number | null {
// `ps` is universally available. lstart prints in `Sat Jan 4 09:18:34 2025`
// form which Date.parse handles natively.
try {
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8'
}).trim()
if (!out) return null
const t = Date.parse(out)
return Number.isNaN(t) ? null : t
} catch {
return null
}
}
let cachedHz: number | null | undefined
function readClockTickRate(): number | null {
if (cachedHz !== undefined) return cachedHz
try {
const out = execFileSync('getconf', ['CLK_TCK'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8'
}).trim()
const n = Number.parseInt(out, 10)
cachedHz = Number.isFinite(n) ? n : null
} catch {
cachedHz = null
}
return cachedHz
}
/**
* `existsSync` of `/proc/<pid>` doubles as a cheap aliveness check on linux.
* On other platforms we fall back to `process.kill(pid, 0)` which throws
* ESRCH if the process no longer exists.
*/
export function isPidAlive(pid: number): boolean {
if (process.platform === 'linux') return existsSync(`/proc/${pid}`)
try {
process.kill(pid, 0)
return true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
return code === 'EPERM' // EPERM means the process exists but we can't signal it.
}
}
// Silence unused-import on hosts that don't need statSync.
// (kept here for future use when we capture mtime of /proc/<pid> as a tiebreaker)
void statSync
+199
View File
@@ -0,0 +1,199 @@
/**
* ProcessRegistry — in-memory mirror of the `process_journal` table.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §8.
*
* On spawn we record `(taskId, attemptId, pid, pidStartedAt, kind)` and
* append a `spawn` journal row. On close we append a `close` row. During
* crash recovery we walk the journal looking for spawns without a matching
* close and verify (pid, pidStartedAt) still maps to a live process; if so
* the process is killed (SIGTERM → 10s → SIGKILL).
*/
import type { PersistAdapter } from '../persist'
import type { ProcessJournalOp, ProcessKind } from '../types'
import { killProcessTree } from './kill-process-tree'
import { isPidAlive, readPidStartTime } from './pid-start-time'
export interface ProcessHandle {
taskId: string
attemptId: string
pid: number
pidStartedAt: number | null
kind: ProcessKind
spawnedAt: number
}
export interface ProcessRegistryDeps {
persist: PersistAdapter
/** Test seam for time. */
clock?: () => number
/** Test seam for sending signals. */
kill?: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void
/** Test seam for sleep — used between SIGTERM and SIGKILL. */
sleep?: (ms: number) => Promise<void>
killGracePeriodMs?: number
}
export class ProcessRegistry {
private readonly handles = new Map<string, ProcessHandle>()
private readonly clock: () => number
private readonly kill: NonNullable<ProcessRegistryDeps['kill']>
private readonly sleep: NonNullable<ProcessRegistryDeps['sleep']>
private readonly killGracePeriodMs: number
constructor(private readonly deps: ProcessRegistryDeps) {
this.clock = deps.clock ?? Date.now
this.kill = deps.kill ?? ((pid, sig) => killProcessTree(pid, sig))
this.sleep =
deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))
this.killGracePeriodMs = deps.killGracePeriodMs ?? 10_000
}
/** Record a spawn and append a journal row. */
async recordSpawn(handle: ProcessHandle): Promise<void> {
this.handles.set(this.key(handle.taskId, handle.attemptId), handle)
await this.deps.persist.appendJournal({
ts: handle.spawnedAt,
op: 'spawn',
taskId: handle.taskId,
attemptId: handle.attemptId,
pid: handle.pid,
pidStartedAt: handle.pidStartedAt,
exitCode: null,
signal: null
})
}
/** Record a clean close (process exited under its own steam). */
async recordClose(
taskId: string,
attemptId: string,
exitCode: number | null,
signal: string | null
): Promise<void> {
const handle = this.handles.get(this.key(taskId, attemptId))
this.handles.delete(this.key(taskId, attemptId))
await this.appendJournal('close', {
taskId,
attemptId,
pid: handle?.pid ?? -1,
pidStartedAt: handle?.pidStartedAt ?? null,
exitCode,
signal
})
}
/** Cancel: SIGTERM → 10s → SIGKILL → journal `killed`. */
async cancel(taskId: string, attemptId: string): Promise<void> {
const handle = this.handles.get(this.key(taskId, attemptId))
if (!handle) return
try {
this.kill(handle.pid, 'SIGTERM')
} catch {
// process already gone
}
await this.sleep(this.killGracePeriodMs)
if (isPidAlive(handle.pid)) {
try {
this.kill(handle.pid, 'SIGKILL')
} catch {
// race: gone between checks
}
}
this.handles.delete(this.key(taskId, attemptId))
await this.appendJournal('killed', {
taskId,
attemptId,
pid: handle.pid,
pidStartedAt: handle.pidStartedAt,
exitCode: null,
signal: 'SIGKILL'
})
}
/**
* Walk the journal for unclosed spawns. For each, verify the same process
* is still alive (pid + pidStartedAt match). If yes → kill it. Either way,
* append a `killed` row so the journal is reconciled.
*
* Returns the list of taskIds whose attempts the orchestrator must
* re-classify (typically: transition to `paused('crash-recovery')`).
*/
async reconcile(): Promise<
Array<{ taskId: string; attemptId: string | null; pid: number; killed: boolean }>
> {
const open = await this.deps.persist.findOpenSpawns()
const reconciled: Array<{
taskId: string
attemptId: string | null
pid: number
killed: boolean
}> = []
for (const row of open) {
const stillAlive = isPidAlive(row.pid)
const startedAtNow = stillAlive ? readPidStartTime(row.pid) : null
const sameProcess =
stillAlive &&
(row.pidStartedAt == null ||
startedAtNow == null ||
Math.abs((startedAtNow ?? 0) - (row.pidStartedAt ?? 0)) < 2_000)
let killed = false
if (sameProcess) {
try {
this.kill(row.pid, 'SIGTERM')
await this.sleep(this.killGracePeriodMs)
if (isPidAlive(row.pid)) this.kill(row.pid, 'SIGKILL')
killed = true
} catch {
// already gone
}
}
await this.appendJournal('killed', {
taskId: row.taskId,
attemptId: row.attemptId,
pid: row.pid,
pidStartedAt: row.pidStartedAt,
exitCode: null,
signal: killed ? 'SIGKILL' : null
})
reconciled.push({
taskId: row.taskId,
attemptId: row.attemptId,
pid: row.pid,
killed
})
}
return reconciled
}
size(): number {
return this.handles.size
}
private key(taskId: string, attemptId: string): string {
return `${taskId}:${attemptId}`
}
private async appendJournal(
op: ProcessJournalOp,
row: {
taskId: string
attemptId: string | null
pid: number
pidStartedAt: number | null
exitCode: number | null
signal: string | null
}
): Promise<void> {
await this.deps.persist.appendJournal({
ts: this.clock(),
op,
taskId: row.taskId,
attemptId: row.attemptId,
pid: row.pid,
pidStartedAt: row.pidStartedAt,
exitCode: row.exitCode,
signal: row.signal
})
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Watchdog — fires when a task makes no observable progress for too long.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §6, §7.1 row 10.
*
* Default thresholds:
* - status=running: 60s of stdout/stderr/progress silence → stalled.
* - status=processing: 600s (postprocessing/merge can be slow).
*
* The Watchdog only emits a "stalled" callback; the orchestrator decides what
* to do with it (typically: classify as virtual `stalled` and SIGKILL).
*/
export interface WatchdogConfig {
runningIdleMs?: number
processingIdleMs?: number
/** Test seam. */
setTimer?: (fn: () => void, ms: number) => unknown
clearTimer?: (handle: unknown) => void
clock?: () => number
}
export interface WatchdogEntry {
taskId: string
status: 'running' | 'processing'
lastBumpAt: number
timer: unknown | null
}
export class Watchdog {
private readonly entries = new Map<string, WatchdogEntry>()
private readonly runningIdleMs: number
private readonly processingIdleMs: number
private readonly setTimer: NonNullable<WatchdogConfig['setTimer']>
private readonly clearTimer: NonNullable<WatchdogConfig['clearTimer']>
private readonly clock: NonNullable<WatchdogConfig['clock']>
constructor(
private readonly onStalled: (taskId: string) => void,
config: WatchdogConfig = {}
) {
this.runningIdleMs = config.runningIdleMs ?? 60_000
this.processingIdleMs = config.processingIdleMs ?? 600_000
this.setTimer = config.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
this.clearTimer =
config.clearTimer ?? ((h) => clearTimeout(h as never))
this.clock = config.clock ?? Date.now
}
arm(taskId: string, status: 'running' | 'processing'): void {
this.disarm(taskId)
const entry: WatchdogEntry = {
taskId,
status,
lastBumpAt: this.clock(),
timer: null
}
entry.timer = this.scheduleNext(entry)
this.entries.set(taskId, entry)
}
/**
* Move from `running` → `processing` thresholds, preserving lastBumpAt.
* Used when yt-dlp emits the Postprocess line.
*/
promoteToProcessing(taskId: string): void {
const e = this.entries.get(taskId)
if (!e) return
if (e.timer) this.clearTimer(e.timer)
e.status = 'processing'
e.lastBumpAt = this.clock()
e.timer = this.scheduleNext(e)
}
bump(taskId: string): void {
const e = this.entries.get(taskId)
if (!e) return
if (e.timer) this.clearTimer(e.timer)
e.lastBumpAt = this.clock()
e.timer = this.scheduleNext(e)
}
disarm(taskId: string): void {
const e = this.entries.get(taskId)
if (!e) return
if (e.timer) this.clearTimer(e.timer)
this.entries.delete(taskId)
}
size(): number {
return this.entries.size
}
private scheduleNext(entry: WatchdogEntry): unknown {
const idle =
entry.status === 'processing'
? this.processingIdleMs
: this.runningIdleMs
return this.setTimer(() => {
const cur = this.entries.get(entry.taskId)
if (!cur) return
const elapsed = this.clock() - cur.lastBumpAt
if (elapsed >= idle) {
// disarm before notifying so onStalled can call back into us safely
this.entries.delete(entry.taskId)
try {
this.onStalled(entry.taskId)
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] watchdog handler threw', err)
}
return
}
// Bumped after we scheduled but before we fired — re-arm.
cur.timer = this.scheduleNext(cur)
}, idle)
}
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Legacy `DownloadTask` projection.
*
* Renderer / SDK / web client speak the pre-task-queue vocabulary
* (`pending / downloading / processing / completed / error / cancelled`).
* This module is the single place that maps internal `TaskStatus` →
* legacy `DownloadStatus`, plus the augmented sub-status fields that let
* UI distinguish `paused` and `retry-scheduled` from a generic `pending`.
*
* Reference: NEX-131 issue body §C, design doc §10.A.5.
*
* Rules:
* - Projection is one-way: hosts MUST NOT use legacy fields to write
* status back into a Task.
* - Projection has no host-specific knowledge: shared across Desktop,
* Web/API and CLI so all three render the same task identically.
* - The output shape intentionally avoids importing `@vidbee/downloader-core`
* to prevent a workspace cycle. Adapters narrow to their local
* `DownloadTask` type by spreading.
*/
import type {
ClassifiedError,
ErrorCategory,
Task,
TaskOutput,
TaskProgress,
TaskStatus
} from '../types'
/**
* Legacy status kept for renderer / web client back-compat.
*/
export type LegacyDownloadStatus =
| 'pending'
| 'downloading'
| 'processing'
| 'completed'
| 'error'
| 'cancelled'
/**
* Sub-status for the buckets where multiple internal statuses collapse onto
* the same legacy status. UIs that opt-in can show a richer label
* ("Paused" / "Retrying in 23s") instead of a flat "pending".
*/
export type LegacySubStatus =
| 'queued'
| 'paused'
| 'retry-scheduled'
export interface LegacyDownloadProgress {
percent: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
/**
* Shape consumed by the renderer/web client. Intentionally a superset of
* the historical `DownloadTask` shape — adapters spread this onto the
* local `DownloadTask` and drop fields the local host does not surface.
*/
export interface LegacyTaskProjection {
id: string
url: string
title?: string
thumbnail?: string
/** Legacy `type` field. Derived from TaskKind: audio for `audio`, video otherwise. */
type: 'video' | 'audio'
status: LegacyDownloadStatus
/** Internal status kept verbatim so callers that DO know about task-queue
* can branch without re-reading `task.status`. */
internalStatus: TaskStatus
/** Set when multiple internal statuses collapse onto one legacy status. */
subStatus?: LegacySubStatus
/** Mirror of `task.statusReason` (e.g. 'crash-recovery', 'retry-after-stalled'). */
statusReason?: string | null
createdAt: number
startedAt?: number
completedAt?: number
duration?: number
fileSize?: number
speed?: string
downloadPath?: string
savedFileName?: string
/**
* yt-dlp's resolved format id (e.g. `30080+30280`). Hosts compare against
* the user's pick to detect that the chain fell back to best-available.
*/
resolvedFormatId?: string
description?: string
channel?: string
uploader?: string
viewCount?: number
tags?: string[]
playlistId?: string
playlistTitle?: string
playlistIndex?: number
playlistSize?: number
progress?: LegacyDownloadProgress
error?: string
/** Set when status === 'error'. */
errorCategory?: ErrorCategory
/** ms epoch; only present when internalStatus === 'retry-scheduled'. */
nextRetryAt?: number
/** Current attempt number (0-indexed). */
attempt?: number
maxAttempts?: number
/** i18n key describing the user-facing message for the last error. */
uiMessageKey?: string
}
/**
* Map an internal `TaskStatus` to the legacy `DownloadStatus`. Pure;
* shared by Desktop/API/CLI to guarantee identical rendering.
*/
export function legacyDownloadStatusOf(status: TaskStatus): LegacyDownloadStatus {
switch (status) {
case 'queued':
return 'pending'
case 'running':
return 'downloading'
case 'processing':
return 'processing'
case 'paused':
return 'pending'
case 'retry-scheduled':
return 'pending'
case 'completed':
return 'completed'
case 'failed':
return 'error'
case 'cancelled':
return 'cancelled'
}
}
/**
* Map an internal status to its legacy sub-status, if any. Returns undefined
* for statuses that map 1-to-1 onto a legacy status.
*/
export function legacySubStatusOf(status: TaskStatus): LegacySubStatus | undefined {
switch (status) {
case 'queued':
return 'queued'
case 'paused':
return 'paused'
case 'retry-scheduled':
return 'retry-scheduled'
default:
return undefined
}
}
interface MaybeHostFields {
/** Legacy host metadata stashed in `Task.input.options` by adapters. */
description?: string
channel?: string
uploader?: string
viewCount?: number
tags?: readonly string[]
duration?: number
playlistTitle?: string
playlistSize?: number
fileSize?: number
startedAt?: number
completedAt?: number
downloadPath?: string
}
/**
* Project a Task into the legacy shape. Host adapters call this and then
* narrow to their concrete `DownloadTask` type.
*
* Pure; no I/O, no clock reads.
*/
export function projectTaskToLegacy(task: Readonly<Task>): LegacyTaskProjection {
const status = legacyDownloadStatusOf(task.status)
const subStatus = legacySubStatusOf(task.status)
const opts = (task.input.options ?? {}) as MaybeHostFields
const proj: LegacyTaskProjection = {
id: task.id,
url: task.input.url,
title: task.input.title,
thumbnail: task.input.thumbnail,
type: task.kind === 'audio' ? 'audio' : 'video',
status,
internalStatus: task.status,
statusReason: task.statusReason,
createdAt: task.createdAt,
description: opts.description,
channel: opts.channel,
uploader: opts.uploader,
viewCount: opts.viewCount,
tags: opts.tags ? [...opts.tags] : undefined,
duration: opts.duration,
playlistId: task.input.playlistId,
playlistTitle: opts.playlistTitle,
playlistIndex: task.input.playlistIndex,
playlistSize: opts.playlistSize,
fileSize: opts.fileSize,
startedAt: opts.startedAt,
completedAt: opts.completedAt,
downloadPath: opts.downloadPath,
attempt: task.attempt,
maxAttempts: task.maxAttempts
}
if (subStatus) proj.subStatus = subStatus
// Progress is meaningful for running/processing AND for paused/retry, where
// we want the UI to remember "you were 47% in" rather than reset to 0.
if (task.progress.percent != null || task.progress.bytesDownloaded != null) {
proj.progress = projectProgress(task.progress)
if (task.progress.speedBps != null) {
proj.speed = formatSpeed(task.progress.speedBps)
}
}
if (task.output) {
const out = task.output as TaskOutput
proj.fileSize = out.size
proj.savedFileName = basenameOf(out.filePath)
proj.downloadPath = dirnameOf(out.filePath)
if (out.durationMs != null) proj.duration = Math.round(out.durationMs / 1000)
if (out.formatId) proj.resolvedFormatId = out.formatId
}
if (task.lastError) {
const err = task.lastError as ClassifiedError
proj.errorCategory = err.category
proj.uiMessageKey = err.uiMessageKey
if (status === 'error') proj.error = err.rawMessage
}
if (task.status === 'retry-scheduled' && task.nextRetryAt != null) {
proj.nextRetryAt = task.nextRetryAt
}
return proj
}
function projectProgress(p: Readonly<TaskProgress>): LegacyDownloadProgress {
const percent = p.percent != null ? Math.max(0, Math.min(100, p.percent * 100)) : 0
return {
percent,
currentSpeed: p.speedBps != null ? formatSpeed(p.speedBps) : undefined,
eta: p.etaMs != null ? formatEta(p.etaMs) : undefined,
downloaded: p.bytesDownloaded != null ? formatBytes(p.bytesDownloaded) : undefined,
total: p.bytesTotal != null ? formatBytes(p.bytesTotal) : undefined
}
}
function formatBytes(n: number): string {
if (n < 1024) return `${n}B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = n / 1024
let i = 0
while (value >= 1024 && i < units.length - 1) {
value /= 1024
i++
}
return `${value.toFixed(2)}${units[i]}`
}
function formatSpeed(bps: number): string {
return `${formatBytes(bps)}/s`
}
function formatEta(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
const m = Math.floor(s / 60)
const r = s % 60
if (m >= 60) {
const h = Math.floor(m / 60)
return `${h}:${String(m % 60).padStart(2, '0')}:${String(r).padStart(2, '0')}`
}
return `${m}:${String(r).padStart(2, '0')}`
}
function basenameOf(p: string): string {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
return i >= 0 ? p.slice(i + 1) : p
}
function dirnameOf(p: string): string {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
return i >= 0 ? p.slice(0, i) : ''
}
@@ -0,0 +1,7 @@
export { Scheduler } from './scheduler'
export type { SchedulerOptions, SchedulerCallbacks } from './scheduler'
export { RetryScheduler, computeBackoffMs } from './retry-scheduler'
export type {
RetrySchedulerOptions,
Clock
} from './retry-scheduler'
@@ -0,0 +1,138 @@
/**
* RetryScheduler — owns the heap of `retry-scheduled` tasks ordered by
* `nextRetryAt`. A single setTimeout is armed for the head of the heap;
* when it fires, `tick()` releases all due tasks back to `queued` (via the
* supplied callback into the orchestrator).
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §3.1, §7.2.
*/
import { MinHeap } from '../util/min-heap'
/**
* full-jitter backoff per §7.2 (`base * 2 ** attempt`, capped, then a uniform
* sample in [0, exp)). When `suggestedMs` is non-null (e.g. http-429
* Retry-After), we honor it verbatim and skip jitter.
*/
export function computeBackoffMs(
attempt: number,
suggestedMs: number | null,
rng: () => number = Math.random
): number {
if (suggestedMs != null && suggestedMs >= 0) return suggestedMs
const base = 2_000
const cap = 60_000
const exp = Math.min(cap, base * 2 ** Math.max(0, attempt))
return Math.floor(rng() * exp)
}
interface HeapItem {
taskId: string
nextRetryAt: number
/** monotonic seq breaks tie when two tasks share the same nextRetryAt. */
seq: number
}
export type Clock = () => number
export interface RetrySchedulerOptions {
clock?: Clock
setTimer?: (fn: () => void, ms: number) => unknown
clearTimer?: (handle: unknown) => void
/**
* Called by `tick()` for each due task. The orchestrator is expected to
* lock the FSM, transition `retry-scheduled -> queued`, and re-enqueue.
* If it throws, we keep the task in the heap and the next tick retries.
*/
onDue: (taskId: string, at: number) => void | Promise<void>
}
export class RetryScheduler {
private readonly heap = new MinHeap<HeapItem>((a, b) =>
a.nextRetryAt !== b.nextRetryAt
? a.nextRetryAt - b.nextRetryAt
: a.seq - b.seq
)
private timer: unknown = null
private seqCounter = 0
private readonly clock: Clock
private readonly setTimer: NonNullable<RetrySchedulerOptions['setTimer']>
private readonly clearTimer: NonNullable<RetrySchedulerOptions['clearTimer']>
private readonly onDue: RetrySchedulerOptions['onDue']
constructor(opts: RetrySchedulerOptions) {
this.clock = opts.clock ?? Date.now
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as never))
this.onDue = opts.onDue
}
size(): number {
return this.heap.size()
}
/**
* Add (or re-add — `enqueue` first removes any existing entry for the same
* id, since a task can only be retry-scheduled once at a time).
*/
enqueue(taskId: string, nextRetryAt: number): void {
this.heap.remove((it) => it.taskId === taskId)
this.heap.push({ taskId, nextRetryAt, seq: ++this.seqCounter })
this.rearm()
}
remove(taskId: string): boolean {
const removed = this.heap.remove((it) => it.taskId === taskId)
this.rearm()
return removed != null
}
/**
* Drain all items whose nextRetryAt <= now. Public for tests; in production
* the timer fires this. Bumping a per-item handler error is logged and the
* handler is retried on the next tick.
*/
async tick(): Promise<void> {
const now = this.clock()
while (true) {
const top = this.heap.peek()
if (!top || top.nextRetryAt > now) break
const item = this.heap.pop()!
try {
await this.onDue(item.taskId, now)
} catch (err) {
// Re-enqueue so we try again shortly. We bump the time slightly to
// avoid a hot loop if the orchestrator is in a bad state.
// eslint-disable-next-line no-console
console.error('[task-queue] retry tick handler threw', err)
this.heap.push({
taskId: item.taskId,
nextRetryAt: now + 1_000,
seq: ++this.seqCounter
})
}
}
this.rearm()
}
/** Cancel any pending timer; tests use this between cases. */
stop(): void {
if (this.timer != null) {
this.clearTimer(this.timer)
this.timer = null
}
}
private rearm(): void {
if (this.timer != null) {
this.clearTimer(this.timer)
this.timer = null
}
const top = this.heap.peek()
if (!top) return
const wait = Math.max(0, top.nextRetryAt - this.clock())
this.timer = this.setTimer(() => {
this.timer = null
void this.tick()
}, wait)
}
}
@@ -0,0 +1,270 @@
/**
* Scheduler — owns the readyHeap (priority + FIFO), the global concurrency
* budget, the per-group quotas and the slot accounting around dispatch.
*
* Reference: docs/vidbee-task-queue-state-machine-design.md §6.
*
* The Scheduler does NOT touch task.status directly; it asks the orchestrator
* to perform the FSM transition through the `dispatch` callback. The Scheduler
* only owns the dispatch ordering, slot bookkeeping and demote-on-shrink logic.
*/
import type { Task, TaskPriority } from '../types'
import { AsyncMutex } from '../util/async-mutex'
import { MinHeap } from '../util/min-heap'
interface ReadyEntry {
taskId: string
priority: TaskPriority
/** monotonic insertion seq for FIFO tie-break within the same priority. */
seq: number
}
export interface SchedulerCallbacks {
/**
* Called when a slot has been reserved and the task should transition
* queued -> running. Returns true if the spawn succeeded; false (or thrown)
* causes the slot to be released and the task to be re-queued or failed
* by the orchestrator.
*/
dispatch: (taskId: string) => Promise<boolean> | boolean
/**
* Called when the global cap is lowered and we need to demote the lowest-
* priority running task. The orchestrator is responsible for issuing the
* actual SIGTERM + FSM transition to `paused('demoted')`.
*/
demote: (taskId: string) => Promise<void> | void
/**
* Provided by the store: returns the current Task. Read-only. The Scheduler
* uses this to inspect priority/groupKey for demotion decisions.
*/
getTask: (taskId: string) => Readonly<Task> | undefined
}
export interface SchedulerOptions extends SchedulerCallbacks {
maxConcurrency: number
/** default per-group cap; null/undefined → unlimited. */
defaultMaxPerGroup?: number | null
}
export class Scheduler {
private readyHeap = new MinHeap<ReadyEntry>((a, b) =>
a.priority !== b.priority ? a.priority - b.priority : a.seq - b.seq
)
/** taskId → 1 currently consuming a slot. */
private readonly running = new Set<string>()
/** groupKey → number of tasks currently consuming a slot. */
private readonly perGroupRunning = new Map<string, number>()
/** groupKey → explicit per-group cap. Null means unlimited. */
private readonly perGroupCap = new Map<string, number | null>()
private readonly mutex = new AsyncMutex()
private seqCounter = 0
private maxConcurrency: number
private defaultMaxPerGroup: number | null
constructor(private readonly opts: SchedulerOptions) {
this.maxConcurrency = opts.maxConcurrency
this.defaultMaxPerGroup = opts.defaultMaxPerGroup ?? null
}
// ─────────────── Public API ───────────────
async enqueue(taskId: string, priority: TaskPriority): Promise<void> {
await this.mutex.runExclusive(() => {
this.readyHeap.push({
taskId,
priority,
seq: ++this.seqCounter
})
})
await this.tryDispatch()
}
/** Removes a task from the ready heap (does not touch running set). */
async dequeue(taskId: string): Promise<boolean> {
return this.mutex.runExclusive(() => {
const removed = this.readyHeap.remove((e) => e.taskId === taskId)
return removed != null
})
}
/**
* Marker that a previously-dispatched task has reached a terminal/yielding
* status (completed/failed/cancelled/paused/retry-scheduled). Releases its
* slot and bumps dispatch.
*/
async releaseSlot(taskId: string): Promise<void> {
await this.mutex.runExclusive(() => {
if (!this.running.has(taskId)) return
this.running.delete(taskId)
const t = this.opts.getTask(taskId)
const groupKey = t?.groupKey
if (groupKey) this.decGroup(groupKey)
})
await this.tryDispatch()
}
async setMaxConcurrency(n: number): Promise<void> {
if (!Number.isInteger(n) || n < 1) {
throw new Error(`maxConcurrency must be ≥ 1 integer, got ${n}`)
}
const toDemote: string[] = []
await this.mutex.runExclusive(() => {
this.maxConcurrency = n
while (this.running.size > this.maxConcurrency) {
const victim = this.pickDemoteVictim()
if (!victim) break
toDemote.push(victim)
this.running.delete(victim)
const t = this.opts.getTask(victim)
if (t?.groupKey) this.decGroup(t.groupKey)
}
})
for (const id of toDemote) {
try {
await this.opts.demote(id)
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] demote callback threw', err)
}
}
void this.tryDispatch()
}
async setMaxPerGroup(groupKey: string, n: number | null): Promise<void> {
await this.mutex.runExclusive(() => {
if (n == null) {
this.perGroupCap.delete(groupKey)
} else {
if (!Number.isInteger(n) || n < 1) {
throw new Error(`maxPerGroup must be null or ≥ 1 integer, got ${n}`)
}
this.perGroupCap.set(groupKey, n)
}
})
void this.tryDispatch()
}
setDefaultMaxPerGroup(n: number | null): void {
this.defaultMaxPerGroup = n
}
stats(): {
readonly running: number
readonly queued: number
readonly capacity: number
readonly perGroup: Record<string, number>
} {
const perGroup: Record<string, number> = {}
for (const [k, v] of this.perGroupRunning) perGroup[k] = v
return {
running: this.running.size,
queued: this.readyHeap.size(),
capacity: this.maxConcurrency,
perGroup
}
}
// ─────────────── Internals ───────────────
/**
* Tries to fill open slots. Held under the mutex so two concurrent triggers
* cannot both consume the same slot. The dispatch callback is invoked
* outside the mutex (callbacks may do FSM transitions and IO).
*/
private async tryDispatch(): Promise<void> {
const toDispatch: string[] = []
await this.mutex.runExclusive(() => {
while (this.running.size < this.maxConcurrency && this.readyHeap.size() > 0) {
const candidate = this.popEligible()
if (!candidate) break
const t = this.opts.getTask(candidate.taskId)
if (!t) continue
this.running.add(candidate.taskId)
this.incGroup(t.groupKey)
toDispatch.push(candidate.taskId)
}
})
for (const id of toDispatch) {
let ok = false
try {
ok = await this.opts.dispatch(id)
} catch (err) {
// eslint-disable-next-line no-console
console.error('[task-queue] dispatch callback threw', err)
}
if (!ok) {
// Roll back the slot reservation; the orchestrator may have failed
// the task synchronously, in which case we would already have been
// told via `releaseSlot`. Idempotent.
await this.mutex.runExclusive(() => {
if (this.running.delete(id)) {
const t = this.opts.getTask(id)
if (t?.groupKey) this.decGroup(t.groupKey)
}
})
}
}
}
/**
* Pop the next entry that satisfies the per-group cap. Skipped entries are
* stashed and re-pushed at the end of the call so we do not lose them.
*/
private popEligible(): ReadyEntry | undefined {
const skipped: ReadyEntry[] = []
let chosen: ReadyEntry | undefined
while (this.readyHeap.size() > 0) {
const top = this.readyHeap.pop()!
const task = this.opts.getTask(top.taskId)
if (!task) {
// Stale entry — task was cancelled while in heap. Drop it.
continue
}
if (this.canRunInGroup(task.groupKey)) {
chosen = top
break
}
skipped.push(top)
}
for (const s of skipped) this.readyHeap.push(s)
return chosen
}
private canRunInGroup(groupKey: string): boolean {
const cap = this.perGroupCap.has(groupKey)
? this.perGroupCap.get(groupKey)!
: this.defaultMaxPerGroup
if (cap == null) return true
const n = this.perGroupRunning.get(groupKey) ?? 0
return n < cap
}
private pickDemoteVictim(): string | null {
/**
* Demote the lowest-priority, least-progress running task. We don't have
* progress here, so we approximate by walking running tasks and picking
* the one with the highest priority number (= lowest priority).
*/
let victim: string | null = null
let victimPri: number = -1
for (const id of this.running) {
const t = this.opts.getTask(id)
if (!t) continue
if (t.priority > victimPri) {
victimPri = t.priority
victim = id
}
}
return victim
}
private incGroup(groupKey: string): void {
this.perGroupRunning.set(groupKey, (this.perGroupRunning.get(groupKey) ?? 0) + 1)
}
private decGroup(groupKey: string): void {
const cur = this.perGroupRunning.get(groupKey) ?? 0
if (cur <= 1) this.perGroupRunning.delete(groupKey)
else this.perGroupRunning.set(groupKey, cur - 1)
}
}
+159
View File
@@ -0,0 +1,159 @@
import { z } from 'zod'
export const TaskKindSchema = z.enum([
'video',
'audio',
'playlist',
'subscription-item',
'yt-dlp-forward'
])
export const TaskStatusSchema = z.enum([
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
])
export const TaskPrioritySchema = z.union([
z.literal(0),
z.literal(10),
z.literal(20)
])
export const ErrorCategorySchema = z.enum([
'http-429',
'auth-required',
'geo-blocked',
'not-found',
'disk-full',
'permission-denied',
'binary-missing',
'ffmpeg',
'network-transient',
'stalled',
'cancelled-by-user',
'output-missing',
'unknown'
])
export const TaskInputSchema = z.object({
url: z.string().min(1),
kind: TaskKindSchema,
title: z.string().optional(),
thumbnail: z.string().optional(),
subscriptionId: z.string().optional(),
playlistId: z.string().optional(),
playlistIndex: z.number().int().nonnegative().optional(),
rawArgs: z.array(z.string()).optional(),
options: z.record(z.string(), z.unknown()).optional()
})
export const TaskOutputSchema = z.object({
filePath: z.string(),
size: z.number().int().nonnegative(),
durationMs: z.number().int().nullable(),
sha256: z.string().nullable()
})
export const TaskProgressSchema = z.object({
percent: z.number().min(0).max(1).nullable(),
bytesDownloaded: z.number().int().nullable(),
bytesTotal: z.number().int().nullable(),
speedBps: z.number().nullable(),
etaMs: z.number().int().nullable(),
ticks: z.number().int().nonnegative()
})
export const ClassifiedErrorSchema = z.object({
category: ErrorCategorySchema,
exitCode: z.number().int().nullable(),
rawMessage: z.string(),
uiMessageKey: z.string(),
uiActionHints: z.array(z.string()).readonly(),
retryable: z.boolean(),
suggestedRetryAfterMs: z.number().int().nonnegative().nullable()
})
export const TaskSchema = z.object({
id: z.string(),
kind: TaskKindSchema,
parentId: z.string().nullable(),
input: TaskInputSchema,
priority: TaskPrioritySchema,
groupKey: z.string(),
status: TaskStatusSchema,
prevStatus: TaskStatusSchema.nullable(),
statusReason: z.string().nullable(),
enteredStatusAt: z.number().int(),
attempt: z.number().int().nonnegative(),
maxAttempts: z.number().int().nonnegative(),
nextRetryAt: z.number().int().nullable(),
progress: TaskProgressSchema,
output: TaskOutputSchema.nullable(),
lastError: ClassifiedErrorSchema.nullable(),
pid: z.number().int().nullable(),
pidStartedAt: z.number().int().nullable(),
createdAt: z.number().int(),
updatedAt: z.number().int()
})
// ───────────── API I/O schemas ─────────────
export const AddInputSchema = z.object({
input: TaskInputSchema,
priority: TaskPrioritySchema.optional(),
groupKey: z.string().optional(),
parentId: z.string().nullable().optional(),
maxAttempts: z.number().int().nonnegative().optional()
})
export const AddOutputSchema = z.object({ id: z.string() })
export const TaskIdInputSchema = z.object({ id: z.string() })
export const ListInputSchema = z.object({
status: TaskStatusSchema.optional(),
groupKey: z.string().optional(),
parentId: z.string().optional(),
limit: z.number().int().positive().max(500).optional(),
cursor: z.string().optional()
})
export const ListOutputSchema = z.object({
tasks: z.array(TaskSchema),
nextCursor: z.string().nullable()
})
export const StatsOutputSchema = z.object({
total: z.number().int(),
byStatus: z.record(TaskStatusSchema, z.number().int()),
running: z.number().int(),
queued: z.number().int(),
capacity: z.number().int(),
perGroup: z.record(z.string(), z.number().int())
})
export const SetMaxConcurrencyInputSchema = z.object({
n: z.number().int().min(1).max(64)
})
export const SetMaxPerGroupInputSchema = z.object({
groupKey: z.string(),
n: z.number().int().min(1).max(64).nullable()
})
export const PauseInputSchema = z.object({
id: z.string(),
reason: z.string().optional()
})
export const RetryInputSchema = z.object({
id: z.string()
})
export const VoidOutputSchema = z.object({ ok: z.literal(true) })
+162
View File
@@ -0,0 +1,162 @@
/**
* TaskStore — in-memory authoritative index of Task records, indexed by id and
* by groupKey/status. Only the orchestrator writes here; consumers receive
* read-only snapshots.
*/
import type { Task, TaskQueueStats, TaskSnapshot, TaskStatus } from '../types'
const STATUSES: readonly TaskStatus[] = [
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
]
export class TaskStore {
private readonly byId = new Map<string, Task>()
private readonly byGroup = new Map<string, Set<string>>()
private readonly byStatus = new Map<TaskStatus, Set<string>>()
constructor() {
for (const s of STATUSES) this.byStatus.set(s, new Set())
}
has(id: string): boolean {
return this.byId.has(id)
}
get(id: string): Readonly<Task> | undefined {
return this.byId.get(id)
}
/** Insert a new task. Throws if id already exists. */
insert(task: Task): void {
if (this.byId.has(task.id)) {
throw new Error(`TaskStore: duplicate id ${task.id}`)
}
this.byId.set(task.id, task)
this.bucket(this.byGroup, task.groupKey).add(task.id)
this.byStatus.get(task.status)!.add(task.id)
}
/**
* Replace the existing record. Maintains index consistency for status and
* groupKey transitions. Throws if the id is missing.
*/
update(next: Task): void {
const prev = this.byId.get(next.id)
if (!prev) throw new Error(`TaskStore: missing id ${next.id}`)
if (prev.status !== next.status) {
this.byStatus.get(prev.status)?.delete(prev.id)
this.byStatus.get(next.status)?.add(next.id)
}
if (prev.groupKey !== next.groupKey) {
const oldBucket = this.byGroup.get(prev.groupKey)
oldBucket?.delete(prev.id)
if (oldBucket && oldBucket.size === 0) this.byGroup.delete(prev.groupKey)
this.bucket(this.byGroup, next.groupKey).add(next.id)
}
this.byId.set(next.id, next)
}
/** Remove a record (used by removeFromHistory). */
remove(id: string): boolean {
const prev = this.byId.get(id)
if (!prev) return false
this.byId.delete(id)
this.byStatus.get(prev.status)?.delete(id)
const groupSet = this.byGroup.get(prev.groupKey)
groupSet?.delete(id)
if (groupSet && groupSet.size === 0) this.byGroup.delete(prev.groupKey)
return true
}
snapshot(id: string): TaskSnapshot | undefined {
const t = this.byId.get(id)
return t ? { task: t } : undefined
}
list(opts?: {
status?: TaskStatus
groupKey?: string
parentId?: string
limit?: number
cursor?: string | null
}): { tasks: Task[]; nextCursor: string | null } {
let candidates: Iterable<string>
if (opts?.status) {
candidates = this.byStatus.get(opts.status) ?? []
} else if (opts?.groupKey) {
candidates = this.byGroup.get(opts.groupKey) ?? []
} else {
candidates = this.byId.keys()
}
const all: Task[] = []
for (const id of candidates) {
const t = this.byId.get(id)
if (!t) continue
if (opts?.groupKey && t.groupKey !== opts.groupKey) continue
if (opts?.parentId && t.parentId !== opts.parentId) continue
all.push(t)
}
all.sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
let startIdx = 0
if (opts?.cursor) {
startIdx = all.findIndex((t) => t.id === opts.cursor)
startIdx = startIdx < 0 ? 0 : startIdx + 1
}
const limit = opts?.limit ?? 100
const slice = all.slice(startIdx, startIdx + limit)
const nextCursor =
startIdx + slice.length < all.length && slice.length > 0
? slice[slice.length - 1]!.id
: null
return { tasks: slice, nextCursor }
}
stats(capacity: number): TaskQueueStats {
const byStatus: Record<TaskStatus, number> = {
queued: 0,
running: 0,
processing: 0,
paused: 0,
'retry-scheduled': 0,
completed: 0,
failed: 0,
cancelled: 0
}
for (const s of STATUSES) byStatus[s] = this.byStatus.get(s)?.size ?? 0
const perGroup: Record<string, number> = {}
for (const [k, set] of this.byGroup) perGroup[k] = set.size
return {
total: this.byId.size,
byStatus,
running: byStatus.running,
queued: byStatus.queued,
capacity,
perGroup
}
}
size(): number {
return this.byId.size
}
private bucket<K, V>(map: Map<K, Set<V>>, key: K): Set<V> {
let s = map.get(key)
if (!s) {
s = new Set<V>()
map.set(key, s)
}
return s
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Canonical domain model for @vidbee/task-queue.
*
* Authoritative source: docs/vidbee-task-queue-state-machine-design.md §3, §5, §7.
* Adapters in apps/desktop, apps/api, apps/cli MUST consume these types directly;
* any host-local divergence is a contract bug.
*/
export type TaskKind =
| 'video'
| 'audio'
| 'playlist'
| 'subscription-item'
| 'yt-dlp-forward'
export type TaskStatus =
| 'queued'
| 'running'
| 'processing'
| 'paused'
| 'retry-scheduled'
| 'completed'
| 'failed'
| 'cancelled'
export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set([
'completed',
'failed',
'cancelled'
])
export type TaskPriority = 0 | 10 | 20
export const PRIORITY_USER: TaskPriority = 0
export const PRIORITY_SUBSCRIPTION: TaskPriority = 10
export const PRIORITY_BACKGROUND: TaskPriority = 20
export type ErrorCategory =
| 'http-429'
| 'auth-required'
| 'geo-blocked'
| 'not-found'
| 'disk-full'
| 'permission-denied'
| 'binary-missing'
| 'ffmpeg'
| 'network-transient'
| 'stalled'
| 'cancelled-by-user'
| 'output-missing'
| 'unknown'
export interface TaskInput {
url: string
kind: TaskKind
/** Title at submit time (best-effort; may be filled in later by probe). */
title?: string
thumbnail?: string
/** RSS subscription id when this task is a subscription-item. */
subscriptionId?: string
/** Playlist parent id when applicable. */
playlistId?: string
playlistIndex?: number
/**
* Raw yt-dlp argv passed by the caller. Only used for hashing into
* `attempts.raw_args_hash` so we can correlate retries with the snapshot
* that produced them. Adapters are responsible for sanitization.
*/
rawArgs?: readonly string[]
/** Free-form host hints (output template, cookies, proxy...). */
options?: Record<string, unknown>
}
export interface TaskOutput {
filePath: string
size: number
durationMs: number | null
/** sha256 of file at completion, or null if not computed. */
sha256: string | null
/**
* yt-dlp's resolved format id (e.g. `30080+30280`) — captured via
* `--print after_move:%(format_id)s` so hosts can detect when the chain
* fell back from the user's pick. null when the executor doesn't surface
* one (e.g. fake fixtures, raw `yt-dlp -j` info-fetch).
*/
formatId?: string | null
}
export interface TaskProgress {
/** 0..1 inclusive. Sparse — null while parsing or before yt-dlp emits. */
percent: number | null
bytesDownloaded: number | null
bytesTotal: number | null
speedBps: number | null
etaMs: number | null
/** Non-monotonic rolling counter, bumped any time the executor emits. */
ticks: number
}
export const EMPTY_PROGRESS: TaskProgress = {
percent: null,
bytesDownloaded: null,
bytesTotal: null,
speedBps: null,
etaMs: null,
ticks: 0
}
export interface ClassifiedError {
category: ErrorCategory
exitCode: number | null
/** stderr tail (≤ 8KB). MUST be sanitized before persisting. */
rawMessage: string
uiMessageKey: string
uiActionHints: readonly string[]
retryable: boolean
/** null → use default backoff, non-null → honor (e.g. Retry-After). */
suggestedRetryAfterMs: number | null
}
export interface Task {
id: string
kind: TaskKind
parentId: string | null
input: TaskInput
priority: TaskPriority
groupKey: string
status: TaskStatus
/** Status the FSM held immediately before the current one (for diagnostics). */
prevStatus: TaskStatus | null
/** Why we are in this status: 'crash-recovery', 'paused-by-user', error category, etc. */
statusReason: string | null
enteredStatusAt: number
attempt: number
maxAttempts: number
nextRetryAt: number | null
progress: TaskProgress
output: TaskOutput | null
lastError: ClassifiedError | null
pid: number | null
pidStartedAt: number | null
createdAt: number
updatedAt: number
}
export interface AttemptRow {
id: string
taskId: string
attemptNumber: number
startedAt: number
endedAt: number | null
exitCode: number | null
errorCategory: ErrorCategory | null
stdoutTail: string | null
stderrTail: string | null
rawArgsHash: string
}
export type ProcessJournalOp = 'spawn' | 'close' | 'killed' | 'panic'
export interface ProcessJournalRow {
seq: number
ts: number
op: ProcessJournalOp
taskId: string
attemptId: string | null
pid: number
pidStartedAt: number | null
exitCode: number | null
signal: string | null
}
export type ProcessKind = 'yt-dlp' | 'ffmpeg' | 'ffprobe'
/**
* Read-only snapshot returned by TaskStore.snapshot(). Consumers MUST treat
* this as immutable; mutating requires going through TaskFSM.transition().
*/
export interface TaskSnapshot {
readonly task: Readonly<Task>
}
export interface TaskQueueStats {
total: number
byStatus: Record<TaskStatus, number>
running: number
queued: number
capacity: number
perGroup: Record<string, number>
}
@@ -0,0 +1,37 @@
/**
* Tiny single-owner async mutex. Used by the Scheduler so that
* dispatch / cancel / pause never overlap and slot accounting cannot
* be raced by concurrent `add()` calls.
*/
export class AsyncMutex {
private locked = false
private readonly waiters: Array<() => void> = []
async acquire(): Promise<() => void> {
if (!this.locked) {
this.locked = true
return () => this.release()
}
await new Promise<void>((resolve) => this.waiters.push(resolve))
this.locked = true
return () => this.release()
}
async runExclusive<T>(fn: () => Promise<T> | T): Promise<T> {
const release = await this.acquire()
try {
return await fn()
} finally {
release()
}
}
private release(): void {
if (!this.locked) {
throw new Error('AsyncMutex: release called while not locked')
}
this.locked = false
const next = this.waiters.shift()
if (next) next()
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Generic min-heap ordered by a comparator. Used for:
* - Scheduler ready queue (priority-then-FIFO).
* - RetryScheduler timer heap (nextRetryAt asc).
*
* Pushed elements are not deduplicated; callers wrap their items so that
* `remove()` can match by id.
*/
export class MinHeap<T> {
private readonly data: T[] = []
constructor(private readonly cmp: (a: T, b: T) => number) {}
size(): number {
return this.data.length
}
peek(): T | undefined {
return this.data[0]
}
push(item: T): void {
this.data.push(item)
this.siftUp(this.data.length - 1)
}
pop(): T | undefined {
if (this.data.length === 0) return undefined
const top = this.data[0]!
const last = this.data.pop()!
if (this.data.length > 0) {
this.data[0] = last
this.siftDown(0)
}
return top
}
/**
* Remove the first element matching `pred`. O(n). Used for cancel/pause
* which are rare relative to dispatch.
*/
remove(pred: (item: T) => boolean): T | undefined {
const idx = this.data.findIndex(pred)
if (idx < 0) return undefined
const removed = this.data[idx]
const last = this.data.pop()!
if (idx < this.data.length) {
this.data[idx] = last
this.siftDown(idx)
this.siftUp(idx)
}
return removed
}
toArray(): readonly T[] {
return this.data
}
private siftUp(idx: number): void {
let i = idx
while (i > 0) {
const parent = (i - 1) >> 1
if (this.cmp(this.data[i]!, this.data[parent]!) >= 0) return
;[this.data[i], this.data[parent]] = [this.data[parent]!, this.data[i]!]
i = parent
}
}
private siftDown(idx: number): void {
const n = this.data.length
let i = idx
while (true) {
const l = i * 2 + 1
const r = i * 2 + 2
let smallest = i
if (l < n && this.cmp(this.data[l]!, this.data[smallest]!) < 0) smallest = l
if (r < n && this.cmp(this.data[r]!, this.data[smallest]!) < 0) smallest = r
if (smallest === i) return
;[this.data[i], this.data[smallest]] = [
this.data[smallest]!,
this.data[i]!
]
i = smallest
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { Task, TaskInput, TaskPriority, TaskStatus } from '../src/types'
import { EMPTY_PROGRESS } from '../src/types'
let counter = 0
export function makeTaskInput(overrides: Partial<TaskInput> = {}): TaskInput {
return {
url: 'https://example.com/v/abc',
kind: 'video',
...overrides
}
}
export function makeTask(overrides: Partial<Task> = {}): Task {
const id = overrides.id ?? `t-${++counter}`
const now = overrides.createdAt ?? 1_700_000_000_000
const status: TaskStatus = overrides.status ?? 'queued'
return {
id,
kind: 'video',
parentId: null,
input: makeTaskInput(),
priority: 0 as TaskPriority,
groupKey: 'example.com',
status,
prevStatus: null,
statusReason: null,
enteredStatusAt: now,
attempt: 0,
maxAttempts: 5,
nextRetryAt: null,
progress: { ...EMPTY_PROGRESS },
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: now,
updatedAt: now,
...overrides
}
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import {
CLASSIFIER_RULES,
classify,
defaultMaxAttempts,
parseRetryAfter,
sanitizeOutput,
takeStderrTail,
virtualError
} from '../src/classifier'
describe('ErrorClassifier rules (design §7.1)', () => {
it('rule order matches the design doc', () => {
const order = CLASSIFIER_RULES.map((r) => r.category)
expect(order).toEqual([
'http-429',
'auth-required',
'geo-blocked',
'not-found',
'disk-full',
'permission-denied',
'binary-missing',
'ffmpeg',
'network-transient',
'stalled',
'cancelled-by-user',
'output-missing',
'unknown'
])
})
it.each([
['HTTP Error 429: Too Many Requests', 'http-429'],
['ERROR: Sign in to confirm your age', 'auth-required'],
['This video is not available in your country', 'geo-blocked'],
['HTTP Error 404: not found', 'not-found'],
['ENOSPC: no space left on device', 'disk-full'],
['EACCES: permission denied', 'permission-denied'],
['ffmpeg: not found', 'binary-missing'],
['Postprocessing: ffmpeg failed', 'ffmpeg'],
['ECONNRESET on socket', 'network-transient'],
['HTTP Error 503 Service Unavailable', 'network-transient'],
['nothing in particular', 'unknown']
])('classifies stderr %p as %s', (stderr, category) => {
const r = classify({ stderr })
expect(r.category).toBe(category)
})
it('binary-missing also fires on exit code 127', () => {
const r = classify({ stderr: 'random message', exitCode: 127 })
expect(r.category).toBe('binary-missing')
})
it('http-429 honors Retry-After from the explicit header', () => {
const r = classify({
stderr: 'HTTP Error 429: too many',
retryAfterHeader: '15'
})
expect(r.suggestedRetryAfterMs).toBe(15_000)
})
it('http-429 falls back to Retry-After parsed from stderr', () => {
const stderr = 'HTTP Error 429: too many\nRetry-After: 7\n'
const r = classify({ stderr })
expect(r.suggestedRetryAfterMs).toBe(7_000)
})
it('http-429 default backoff is 30s when no Retry-After present', () => {
const r = classify({ stderr: 'HTTP Error 429' })
expect(r.suggestedRetryAfterMs).toBe(30_000)
})
it('non-retryable categories carry retryable=false', () => {
for (const cat of [
'auth-required',
'geo-blocked',
'not-found',
'disk-full',
'permission-denied',
'binary-missing'
] as const) {
expect(defaultMaxAttempts(cat)).toBe(0)
const v = virtualError(cat, 'x')
expect(v.retryable).toBe(false)
}
})
it('retryable categories carry retryable=true and reasonable maxAttempts', () => {
expect(defaultMaxAttempts('http-429')).toBe(3)
expect(defaultMaxAttempts('network-transient')).toBe(5)
expect(defaultMaxAttempts('stalled')).toBe(3)
expect(defaultMaxAttempts('ffmpeg')).toBe(1)
expect(defaultMaxAttempts('unknown')).toBe(1)
})
})
describe('sanitizeOutput', () => {
it('redacts Authorization headers but keeps the key name', () => {
const out = sanitizeOutput('Authorization: Bearer abc.def')
expect(out).toBe('Authorization: <redacted>')
})
it('redacts cookie/token query-string values', () => {
expect(sanitizeOutput('?token=abc.def&foo=1')).toMatch(/<redacted>/)
expect(sanitizeOutput('password=hunter2')).toMatch(/<redacted>/)
})
})
describe('takeStderrTail', () => {
it('returns the input unchanged if under the budget', () => {
expect(takeStderrTail('hello', 10)).toBe('hello')
})
it('keeps trailing portion when too large', () => {
const big = 'x'.repeat(20_000)
const tail = takeStderrTail(big, 4_096)
expect(tail.length).toBeLessThanOrEqual(big.length)
expect(Buffer.byteLength(tail, 'utf8')).toBeLessThanOrEqual(4_096)
})
})
describe('parseRetryAfter', () => {
it('parses delta-seconds', () => {
expect(parseRetryAfter('120')).toBe(120_000)
expect(parseRetryAfter('0')).toBe(0)
})
it('parses HTTP-date', () => {
const future = new Date(Date.now() + 5_000).toUTCString()
const ms = parseRetryAfter(future)
expect(ms).not.toBeNull()
expect(ms!).toBeGreaterThan(0)
})
it('returns null for nonsense', () => {
expect(parseRetryAfter('')).toBeNull()
expect(parseRetryAfter(null)).toBeNull()
expect(parseRetryAfter('not-a-date')).toBeNull()
})
})
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { taskQueueContract } from '../src/contract'
import {
AddInputSchema,
TaskInputSchema,
TaskSchema,
TaskStatusSchema
} from '../src/schemas'
describe('taskQueueContract', () => {
it('exposes the routes that adapters must implement', () => {
const expected = [
'add',
'get',
'list',
'cancel',
'pause',
'resume',
'retry',
'setMaxConcurrency',
'setMaxPerGroup',
'removeFromHistory',
'stats'
]
expect(Object.keys(taskQueueContract).sort()).toEqual(expected.sort())
})
})
describe('schemas', () => {
it('TaskInput accepts a minimal payload', () => {
const parsed = TaskInputSchema.parse({
url: 'https://e.com/v',
kind: 'video'
})
expect(parsed.kind).toBe('video')
})
it('TaskStatus enumerates exactly the 8 design statuses', () => {
const expected = [
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
]
for (const s of expected) {
expect(() => TaskStatusSchema.parse(s)).not.toThrow()
}
expect(() => TaskStatusSchema.parse('downloading')).toThrow()
})
it('AddInput requires a kind on the inner input', () => {
const r = AddInputSchema.safeParse({ input: { url: 'https://e.com' } })
expect(r.success).toBe(false)
})
it('Task allows null parentId/output/lastError', () => {
const t = {
id: 'x',
kind: 'video' as const,
parentId: null,
input: { url: 'https://e.com', kind: 'video' as const },
priority: 0 as const,
groupKey: 'e.com',
status: 'queued' as const,
prevStatus: null,
statusReason: null,
enteredStatusAt: 1,
attempt: 0,
maxAttempts: 5,
nextRetryAt: null,
progress: {
percent: null,
bytesDownloaded: null,
bytesTotal: null,
speedBps: null,
etaMs: null,
ticks: 0
},
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: 1,
updatedAt: 1
}
expect(() => TaskSchema.parse(t)).not.toThrow()
})
})
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import {
IllegalTransitionError,
isLegalTransition,
LEGAL_TRANSITIONS,
transition
} from '../src/fsm'
import type { TaskOutput, TaskStatus } from '../src/types'
import { TERMINAL_STATUSES } from '../src/types'
import { makeTask } from './_fixtures'
const ALL_STATUSES: TaskStatus[] = [
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
]
describe('TaskFSM transition table (design §3.1)', () => {
it('legality table matches the design doc one-for-one', () => {
const expected: Record<TaskStatus, ReadonlyArray<TaskStatus>> = {
queued: ['running', 'paused', 'cancelled'],
running: [
'processing',
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
],
processing: [
'completed',
'retry-scheduled',
'failed',
'paused',
'cancelled'
],
paused: ['queued', 'cancelled', 'failed'],
'retry-scheduled': ['queued', 'paused', 'cancelled'],
failed: ['queued'],
cancelled: ['queued'],
completed: []
}
for (const [from, tos] of Object.entries(expected)) {
const set = LEGAL_TRANSITIONS[from as TaskStatus]
expect([...set].sort()).toEqual([...tos].sort())
}
})
it('every illegal From→To combo throws IllegalTransitionError', () => {
for (const from of ALL_STATUSES) {
for (const to of ALL_STATUSES) {
if (from === to) continue
if (isLegalTransition(from, to)) continue
const task = makeTask({ status: from })
expect(() =>
transition(task, to, { trigger: 'fail' })
).toThrowError(IllegalTransitionError)
}
}
})
it('terminal `completed` has no outgoing transitions', () => {
const t = makeTask({ status: 'completed' })
for (const to of ALL_STATUSES) {
if (to === 'completed') continue
expect(() => transition(t, to, { trigger: 'fail' })).toThrow()
}
})
it('completed requires output.size > 0 (design §3.1 guard)', () => {
const t = makeTask({ status: 'running' })
const zero: TaskOutput = {
filePath: '/tmp/x.mp4',
size: 0,
durationMs: null,
sha256: null
}
expect(() =>
transition(t, 'completed', { trigger: 'finalize-success', output: zero })
).toThrow(IllegalTransitionError)
const ok: TaskOutput = { ...zero, size: 1024 }
const next = transition(t, 'completed', {
trigger: 'finalize-success',
output: ok
})
expect(next.status).toBe('completed')
expect(next.output).toEqual(ok)
expect(next.pid).toBeNull()
})
it('queued → running on the first dispatch sets attempt to 1', () => {
const t = makeTask({ status: 'queued', attempt: 0 })
const next = transition(t, 'running', { trigger: 'dispatch' })
expect(next.status).toBe('running')
expect(next.attempt).toBe(1)
})
it('retry-scheduled → queued via tick increments attempt', () => {
const t = makeTask({
status: 'retry-scheduled',
attempt: 2,
nextRetryAt: 1
})
const next = transition(t, 'queued', { trigger: 'retry-tick' })
expect(next.attempt).toBe(3)
expect(next.nextRetryAt).toBeNull()
})
it('failed → queued via manual retry resets attempt to 0', () => {
const t = makeTask({ status: 'failed', attempt: 3 })
const next = transition(t, 'queued', { trigger: 'retry-manual' })
expect(next.attempt).toBe(0)
expect(next.lastError).toBeNull()
})
it('retry-scheduled → retry-scheduled requires nextRetryAt', () => {
const t = makeTask({ status: 'running' })
expect(() =>
transition(t, 'retry-scheduled', {
trigger: 'finalize-error'
})
).toThrow()
const next = transition(t, 'retry-scheduled', {
trigger: 'finalize-error',
nextRetryAt: 1_700_000_010_000
})
expect(next.status).toBe('retry-scheduled')
expect(next.nextRetryAt).toBe(1_700_000_010_000)
})
it('terminal statuses block writes (assert no leak through transition)', () => {
for (const term of TERMINAL_STATUSES) {
const t = makeTask({ status: term })
// Self-transition is illegal except via the explicit failed→queued/
// cancelled→queued paths (already covered by legality table).
for (const to of ALL_STATUSES) {
if (isLegalTransition(term, to)) continue
if (to === term) continue
expect(() =>
transition(t, to, { trigger: 'cancel' })
).toThrow(IllegalTransitionError)
}
}
})
})
@@ -0,0 +1,376 @@
/**
* Integration tests for TaskQueueAPI using a fake executor that scripts
* spawn/progress/finish events deterministically.
*/
import { describe, expect, it } from 'vitest'
import { TaskQueueAPI } from '../src/api'
import type {
Executor,
ExecutorEvents,
ExecutorRun
} from '../src/executor'
import { MemoryPersistAdapter } from '../src/persist'
import type { TaskQueueEvent } from '../src/events'
import type { ClassifiedError } from '../src/types'
interface ScriptStep {
type:
| 'spawn'
| 'progress'
| 'success'
| 'errorRetryable'
| 'errorFatal'
| 'cancelled'
}
function makeFakeExecutor(scriptByUrl: Map<string, ScriptStep[]>): Executor {
let pidCounter = 1000
return {
run(ctx, events: ExecutorEvents): ExecutorRun {
const script = scriptByUrl.get(ctx.input.url) ?? [
{ type: 'spawn' },
{ type: 'success' }
]
const pid = ++pidCounter
let cancelled = false
// Emit synchronously so tests don't have to await microtasks.
queueMicrotask(() => {
for (const step of script) {
if (cancelled) {
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
return
}
switch (step.type) {
case 'spawn':
events.onSpawn({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
pid,
pidStartedAt: 1,
kind: 'yt-dlp',
spawnedAt: 1
})
break
case 'progress':
events.onProgress({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
progress: {
percent: 0.5,
bytesDownloaded: 10,
bytesTotal: 20,
speedBps: 100,
etaMs: 100,
ticks: 1
},
enteredProcessing: false
})
break
case 'success':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'success',
output: {
filePath: '/fake/file.mp4',
size: 1234,
durationMs: null,
sha256: null
}
},
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
break
case 'errorRetryable': {
const err: ClassifiedError = {
category: 'network-transient',
exitCode: 1,
rawMessage: 'ECONNRESET',
uiMessageKey: 'task.error.networkTransient',
uiActionHints: ['retry'],
retryable: true,
suggestedRetryAfterMs: null
}
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'error', error: err, exitCode: 1 },
closedAt: 1,
stdoutTail: '',
stderrTail: 'ECONNRESET on socket'
})
break
}
case 'errorFatal': {
const err: ClassifiedError = {
category: 'auth-required',
exitCode: 1,
rawMessage: 'Sign in to confirm',
uiMessageKey: 'task.error.authRequired',
uiActionHints: ['import-cookies'],
retryable: false,
suggestedRetryAfterMs: null
}
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'error', error: err, exitCode: 1 },
closedAt: 1,
stdoutTail: '',
stderrTail: 'Sign in to confirm'
})
break
}
case 'cancelled':
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
break
}
}
})
return {
cancel: async () => {
cancelled = true
},
pause: async () => {
cancelled = true
}
}
}
}
}
async function flush(ms = 50): Promise<void> {
await new Promise((r) => setTimeout(r, ms))
}
describe('TaskQueueAPI orchestrator', () => {
it('happy path: queued → running → completed', async () => {
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: makeFakeExecutor(new Map()),
filePresent: () => true,
maxConcurrency: 2
})
await api.start()
const events: TaskQueueEvent[] = []
api.subscribe((e) => events.push(e))
const { id } = await api.add({
input: { url: 'https://example.com/v', kind: 'video' }
})
await flush()
const t = api.get(id)!
expect(t.status).toBe('completed')
expect(t.output?.size).toBe(1234)
const transitions = events
.filter((e) => e.type === 'transition')
.map((e) => (e as { to: string }).to)
expect(transitions).toEqual(['queued', 'running', 'completed'])
})
it('fatal error → failed (no retry)', async () => {
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: makeFakeExecutor(
new Map([
[
'https://e.com/x',
[{ type: 'spawn' }, { type: 'errorFatal' }]
]
])
),
filePresent: () => true
})
await api.start()
const { id } = await api.add({
input: { url: 'https://e.com/x', kind: 'video' }
})
await flush()
const t = api.get(id)!
expect(t.status).toBe('failed')
expect(t.lastError?.category).toBe('auth-required')
})
it('retryable error → retry-scheduled then queued on tick', async () => {
let now = 1_000_000
const ticks: number[] = []
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: makeFakeExecutor(
new Map([
[
'https://e.com/x',
// First attempt: fail. Subsequent attempts: succeed.
[{ type: 'spawn' }, { type: 'errorRetryable' }]
]
])
),
filePresent: () => true,
clock: () => now,
setTimer: (fn, ms) => {
ticks.push(ms)
// Force timer to fire on next macrotask using real setTimeout(0).
return setTimeout(fn, 0)
},
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
rng: () => 0 // deterministic backoff = 0
})
await api.start()
const { id } = await api.add({
input: { url: 'https://e.com/x', kind: 'video' }
})
await flush()
const t = api.get(id)!
// After first attempt fails the orchestrator goes retry-scheduled then
// due to rng=0 the backoff is 0, so the tick fires almost immediately.
// The fake executor still has only one script (errorRetryable), so the
// second attempt also fails → another retry. Eventually the task hits
// the same script. We verify *either* retry-scheduled or further along
// the retry path, but at minimum that the task is not stuck queued and
// that the retry timer fired at least once.
expect(['retry-scheduled', 'queued', 'running', 'failed']).toContain(t.status)
expect(ticks.length).toBeGreaterThan(0)
})
it('cancel on running issues SIGTERM and lands in cancelled', async () => {
let resolveSpawn: (() => void) | null = null
const spawnSeen = new Promise<void>((r) => {
resolveSpawn = r
})
const executor: Executor = {
run(ctx, events) {
let cancelled = false
queueMicrotask(() => {
events.onSpawn({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
pid: 1234,
pidStartedAt: 1,
kind: 'yt-dlp',
spawnedAt: 1
})
resolveSpawn?.()
})
return {
cancel: async () => {
cancelled = true
queueMicrotask(() =>
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: { type: 'cancelled' },
closedAt: 1,
stdoutTail: '',
stderrTail: ''
})
)
},
pause: async () => {
void cancelled
}
}
}
}
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor,
filePresent: () => true
})
await api.start()
const { id } = await api.add({
input: { url: 'https://e.com/c', kind: 'video' }
})
await spawnSeen
await flush(0)
await api.cancel(id, 'user')
await flush()
expect(api.get(id)!.status).toBe('cancelled')
expect(api.stats().running).toBe(0)
})
it('processing → completed guard fails when file missing/empty', async () => {
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: makeFakeExecutor(new Map()),
filePresent: () => false // pretend the file disappeared
})
await api.start()
const { id } = await api.add({
input: { url: 'https://e.com/v', kind: 'video' }
})
await flush()
const t = api.get(id)!
expect(t.status).toBe('failed')
expect(t.lastError?.category).toBe('output-missing')
})
it('crash recovery: paused/recovered tasks survive restart', async () => {
const persist = new MemoryPersistAdapter()
let api = new TaskQueueAPI({
persist,
executor: makeFakeExecutor(new Map()),
filePresent: () => true
})
await api.start()
const { id } = await api.add({
input: { url: 'https://e.com/r', kind: 'video' }
})
await flush()
expect(api.get(id)!.status).toBe('completed')
// Spin up a brand-new orchestrator on the same persist adapter.
api = new TaskQueueAPI({
persist,
executor: makeFakeExecutor(new Map()),
filePresent: () => true
})
await api.start()
expect(api.get(id)!.status).toBe('completed')
})
// Regression: NEX-124 review screenshot showed two list rows for one
// pasted URL — the renderer's optimistic placeholder (renderer-id) and
// the kernel-generated row (random UUID) coexisted because add() ignored
// the caller-supplied id. Honoring `req.id` makes them merge into one.
it('honors caller-supplied id and is idempotent on re-add', async () => {
const api = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: makeFakeExecutor(new Map()),
filePresent: () => true
})
await api.start()
const callerId = 'renderer-optimistic-id-123'
const first = await api.add({
id: callerId,
input: { url: 'https://example.com/a', kind: 'video' }
})
expect(first.id).toBe(callerId)
expect(api.get(callerId)).toBeDefined()
// Second add with the same id is a no-op (idempotent), not a duplicate.
const second = await api.add({
id: callerId,
input: { url: 'https://example.com/a', kind: 'video' }
})
expect(second.id).toBe(callerId)
const all = api.list({ limit: 100, cursor: null }).tasks
expect(all.filter((t) => t.id === callerId)).toHaveLength(1)
})
})
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { MemoryPersistAdapter } from '../src/persist'
import { ProcessRegistry } from '../src/process'
describe('ProcessRegistry', () => {
it('records spawn + close pairs in the journal', async () => {
const persist = new MemoryPersistAdapter()
const reg = new ProcessRegistry({ persist, kill: vi.fn(), sleep: async () => {} })
await reg.recordSpawn({
taskId: 't',
attemptId: 'a',
pid: 100,
pidStartedAt: 50,
kind: 'yt-dlp',
spawnedAt: Date.now()
})
await reg.recordClose('t', 'a', 0, null)
const journal = persist.journalSnapshot()
expect(journal.map((r) => r.op)).toEqual(['spawn', 'close'])
const open = await persist.findOpenSpawns()
expect(open).toHaveLength(0)
})
it('cancel issues SIGTERM then SIGKILL after grace', async () => {
const persist = new MemoryPersistAdapter()
const kill = vi.fn()
const reg = new ProcessRegistry({
persist,
kill,
sleep: async () => {},
// Simulate the process never dying so SIGKILL fires.
killGracePeriodMs: 0
})
await reg.recordSpawn({
taskId: 't',
attemptId: 'a',
pid: 100,
pidStartedAt: 50,
kind: 'yt-dlp',
spawnedAt: Date.now()
})
await reg.cancel('t', 'a')
expect(kill).toHaveBeenCalledWith(100, 'SIGTERM')
// killed row written
const journal = persist.journalSnapshot()
expect(journal.map((r) => r.op)).toContain('killed')
})
it('reconcile finds spawn rows without a matching close and journals killed', async () => {
const persist = new MemoryPersistAdapter()
// Pretend the process has been killed externally by the OS — kill stub is
// a noop but isPidAlive returns false because the pid is fake.
const reg = new ProcessRegistry({
persist,
kill: vi.fn(),
sleep: async () => {},
killGracePeriodMs: 0
})
// Append an orphan spawn directly (simulating a previous app instance).
await persist.appendJournal({
ts: Date.now(),
op: 'spawn',
taskId: 't1',
attemptId: 'a1',
pid: 999_999, // surely dead
pidStartedAt: 0,
exitCode: null,
signal: null
})
const reconciled = await reg.reconcile()
expect(reconciled).toHaveLength(1)
expect(reconciled[0]!.taskId).toBe('t1')
const ops = persist.journalSnapshot().map((r) => r.op)
expect(ops).toContain('killed')
})
})
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest'
import {
legacyDownloadStatusOf,
legacySubStatusOf,
projectTaskToLegacy
} from '../src/projection'
import type { ClassifiedError, Task, TaskStatus } from '../src/types'
const baseTask = (overrides: Partial<Task> = {}): Task => ({
id: 'task-1',
kind: 'video',
parentId: null,
input: {
url: 'https://example.com/v',
kind: 'video',
title: 'Example',
thumbnail: 'https://example.com/t.jpg'
},
priority: 0,
groupKey: 'example.com',
status: 'queued',
prevStatus: null,
statusReason: null,
enteredStatusAt: 1000,
attempt: 0,
maxAttempts: 5,
nextRetryAt: null,
progress: {
percent: null,
bytesDownloaded: null,
bytesTotal: null,
speedBps: null,
etaMs: null,
ticks: 0
},
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: 1000,
updatedAt: 1000,
...overrides
})
const sampleError: ClassifiedError = {
category: 'http-429',
exitCode: 1,
rawMessage: 'too many requests',
uiMessageKey: 'errors.rate_limited',
uiActionHints: ['retry-later'],
retryable: true,
suggestedRetryAfterMs: 30_000
}
describe('legacyDownloadStatusOf', () => {
it('maps every internal status to a legacy status', () => {
const cases: Array<[TaskStatus, ReturnType<typeof legacyDownloadStatusOf>]> = [
['queued', 'pending'],
['running', 'downloading'],
['processing', 'processing'],
['paused', 'pending'],
['retry-scheduled', 'pending'],
['completed', 'completed'],
['failed', 'error'],
['cancelled', 'cancelled']
]
for (const [internal, legacy] of cases) {
expect(legacyDownloadStatusOf(internal)).toBe(legacy)
}
})
it('legacySubStatusOf only fires for the collapsed buckets', () => {
expect(legacySubStatusOf('queued')).toBe('queued')
expect(legacySubStatusOf('paused')).toBe('paused')
expect(legacySubStatusOf('retry-scheduled')).toBe('retry-scheduled')
expect(legacySubStatusOf('running')).toBeUndefined()
expect(legacySubStatusOf('processing')).toBeUndefined()
expect(legacySubStatusOf('completed')).toBeUndefined()
expect(legacySubStatusOf('failed')).toBeUndefined()
expect(legacySubStatusOf('cancelled')).toBeUndefined()
})
})
describe('projectTaskToLegacy', () => {
it('projects queued task as pending without progress', () => {
const proj = projectTaskToLegacy(baseTask())
expect(proj.status).toBe('pending')
expect(proj.subStatus).toBe('queued')
expect(proj.internalStatus).toBe('queued')
expect(proj.progress).toBeUndefined()
})
it('projects running task with progress and speed', () => {
const proj = projectTaskToLegacy(
baseTask({
status: 'running',
progress: {
percent: 0.42,
bytesDownloaded: 1024 * 1024,
bytesTotal: 4 * 1024 * 1024,
speedBps: 512 * 1024,
etaMs: 12_345,
ticks: 5
}
})
)
expect(proj.status).toBe('downloading')
expect(proj.subStatus).toBeUndefined()
expect(proj.progress?.percent).toBeCloseTo(42, 5)
expect(proj.progress?.currentSpeed).toBe('512.00KB/s')
expect(proj.speed).toBe('512.00KB/s')
})
it('projects paused task with subStatus and statusReason', () => {
const proj = projectTaskToLegacy(
baseTask({
status: 'paused',
statusReason: 'crash-recovery',
progress: {
percent: 0.5,
bytesDownloaded: 100,
bytesTotal: 200,
speedBps: null,
etaMs: null,
ticks: 1
}
})
)
expect(proj.status).toBe('pending')
expect(proj.subStatus).toBe('paused')
expect(proj.statusReason).toBe('crash-recovery')
expect(proj.progress?.percent).toBeCloseTo(50, 5)
})
it('projects retry-scheduled task with attempt + nextRetryAt + errorCategory', () => {
const proj = projectTaskToLegacy(
baseTask({
status: 'retry-scheduled',
statusReason: 'http-429',
attempt: 2,
maxAttempts: 5,
nextRetryAt: 9_999_999,
lastError: sampleError
})
)
expect(proj.status).toBe('pending')
expect(proj.subStatus).toBe('retry-scheduled')
expect(proj.attempt).toBe(2)
expect(proj.maxAttempts).toBe(5)
expect(proj.nextRetryAt).toBe(9_999_999)
expect(proj.errorCategory).toBe('http-429')
expect(proj.uiMessageKey).toBe('errors.rate_limited')
// Don't surface raw message until status is `error`.
expect(proj.error).toBeUndefined()
})
it('projects completed task with output filename and downloadPath', () => {
const proj = projectTaskToLegacy(
baseTask({
status: 'completed',
output: {
filePath: '/Users/me/Downloads/VidBee/clip.mp4',
size: 1234,
durationMs: 60_000,
sha256: null
}
})
)
expect(proj.status).toBe('completed')
expect(proj.fileSize).toBe(1234)
expect(proj.savedFileName).toBe('clip.mp4')
expect(proj.downloadPath).toBe('/Users/me/Downloads/VidBee')
expect(proj.duration).toBe(60)
})
it('projects failed task and surfaces error/category/uiMessageKey', () => {
const proj = projectTaskToLegacy(
baseTask({
status: 'failed',
statusReason: 'auth-required',
lastError: { ...sampleError, category: 'auth-required', rawMessage: 'login needed' }
})
)
expect(proj.status).toBe('error')
expect(proj.errorCategory).toBe('auth-required')
expect(proj.uiMessageKey).toBe('errors.rate_limited')
expect(proj.error).toBe('login needed')
})
it('projects cancelled task as cancelled', () => {
const proj = projectTaskToLegacy(baseTask({ status: 'cancelled' }))
expect(proj.status).toBe('cancelled')
expect(proj.subStatus).toBeUndefined()
})
it('hoists host metadata from input.options', () => {
const proj = projectTaskToLegacy(
baseTask({
input: {
url: 'https://example.com/v',
kind: 'video',
options: {
description: 'd',
channel: 'c',
uploader: 'u',
viewCount: 100,
tags: ['a', 'b'],
duration: 99,
playlistTitle: 'PL',
playlistSize: 7,
startedAt: 555,
completedAt: 999,
downloadPath: '/tmp'
}
}
})
)
expect(proj.description).toBe('d')
expect(proj.channel).toBe('c')
expect(proj.uploader).toBe('u')
expect(proj.viewCount).toBe(100)
expect(proj.tags).toEqual(['a', 'b'])
expect(proj.duration).toBe(99)
expect(proj.playlistTitle).toBe('PL')
expect(proj.playlistSize).toBe(7)
expect(proj.startedAt).toBe(555)
expect(proj.completedAt).toBe(999)
expect(proj.downloadPath).toBe('/tmp')
})
it('audio TaskKind becomes audio type', () => {
const proj = projectTaskToLegacy(baseTask({ kind: 'audio', input: { url: 'x', kind: 'audio' } }))
expect(proj.type).toBe('audio')
})
})
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeBackoffMs, RetryScheduler } from '../src/scheduler'
describe('computeBackoffMs', () => {
it('honors a non-null suggested value verbatim (e.g. Retry-After)', () => {
expect(computeBackoffMs(7, 12_345)).toBe(12_345)
expect(computeBackoffMs(0, 0)).toBe(0)
})
it('uses exponential + full jitter when no suggestion', () => {
// attempt=2 → exp = 2_000 * 4 = 8_000 → range [0, 8000)
const v = computeBackoffMs(2, null, () => 0.999_999)
expect(v).toBeGreaterThan(0)
expect(v).toBeLessThan(8_000)
})
it('caps at 60s', () => {
const v = computeBackoffMs(20, null, () => 0.999_999)
expect(v).toBeLessThan(60_000)
})
})
describe('RetryScheduler', () => {
afterEach(() => {
vi.useRealTimers()
})
it('drains tasks at their nextRetryAt with fake timers', async () => {
vi.useFakeTimers()
let now = 1_000_000
const due: string[] = []
const sched = new RetryScheduler({
clock: () => now,
setTimer: (fn, ms) => setTimeout(fn, ms),
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
onDue: (id) => {
due.push(id)
}
})
sched.enqueue('a', 1_000_500)
sched.enqueue('b', 1_000_300)
sched.enqueue('c', 1_000_700)
expect(sched.size()).toBe(3)
now = 1_000_300
await vi.advanceTimersByTimeAsync(0)
await sched.tick()
expect(due).toEqual(['b'])
now = 1_000_500
await sched.tick()
expect(due).toEqual(['b', 'a'])
now = 1_000_700
await sched.tick()
expect(due).toEqual(['b', 'a', 'c'])
expect(sched.size()).toBe(0)
})
it('re-enqueues a task on handler error so the next tick retries', async () => {
let now = 0
let throws = true
const due: string[] = []
const sched = new RetryScheduler({
clock: () => now,
setTimer: (fn, ms) => setTimeout(fn, ms),
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
onDue: (id) => {
if (throws) {
throws = false
throw new Error('boom')
}
due.push(id)
}
})
sched.enqueue('a', 100)
now = 200
await sched.tick()
expect(sched.size()).toBe(1)
now = 1_500
await sched.tick()
expect(due).toEqual(['a'])
expect(sched.size()).toBe(0)
})
it('remove() drops a pending task', () => {
const sched = new RetryScheduler({ onDue: () => {} })
sched.enqueue('a', Date.now() + 1_000_000)
expect(sched.remove('a')).toBe(true)
expect(sched.remove('a')).toBe(false)
sched.stop()
})
})
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest'
import { Scheduler } from '../src/scheduler'
import type { Task } from '../src/types'
import { makeTask } from './_fixtures'
interface Harness {
scheduler: Scheduler
store: Map<string, Task>
/** taskIds dispatched, in order. */
dispatched: string[]
/** taskIds we issue completions for, simulating finish. */
complete: (id: string) => Promise<void>
}
function makeHarness(opts: {
maxConcurrency?: number
defaultMaxPerGroup?: number | null
failingDispatch?: Set<string>
}): Harness {
const store = new Map<string, Task>()
const dispatched: string[] = []
const scheduler = new Scheduler({
maxConcurrency: opts.maxConcurrency ?? 2,
defaultMaxPerGroup: opts.defaultMaxPerGroup ?? null,
getTask: (id) => store.get(id),
dispatch: (id) => {
if (opts.failingDispatch?.has(id)) return false
dispatched.push(id)
return true
},
demote: (_id) => {}
})
return {
scheduler,
store,
dispatched,
complete: async (id: string) => {
await scheduler.releaseSlot(id)
}
}
}
describe('Scheduler', () => {
it('respects priority then FIFO', async () => {
const h = makeHarness({ maxConcurrency: 1 })
for (let i = 0; i < 4; i++) {
const t = makeTask({
id: `t${i}`,
priority: (i === 2 ? 0 : 10) as Task['priority']
})
h.store.set(t.id, t)
}
// t0 enqueues into an empty heap with the slot free → dispatches immediately.
// t1/t2/t3 wait until t0 finishes; among the queued, t2 (priority 0) wins.
await h.scheduler.enqueue('t0', 10)
await h.scheduler.enqueue('t1', 10)
await h.scheduler.enqueue('t2', 0)
await h.scheduler.enqueue('t3', 10)
// Drain — h.complete awaits releaseSlot which now awaits tryDispatch,
// so dispatched[i] is filled in by the time we read it.
await h.complete(h.dispatched[0]!)
await h.complete(h.dispatched[1]!)
await h.complete(h.dispatched[2]!)
await h.complete(h.dispatched[3]!)
expect(h.dispatched).toEqual(['t0', 't2', 't1', 't3'])
})
it('honors the global concurrency cap', async () => {
const h = makeHarness({ maxConcurrency: 2 })
for (let i = 0; i < 5; i++) {
const t = makeTask({ id: `t${i}` })
h.store.set(t.id, t)
}
for (let i = 0; i < 5; i++) await h.scheduler.enqueue(`t${i}`, 0)
expect(h.dispatched).toHaveLength(2)
expect(h.scheduler.stats().running).toBe(2)
expect(h.scheduler.stats().queued).toBe(3)
await h.complete(h.dispatched[0]!)
expect(h.dispatched).toHaveLength(3)
})
it('honors per-group caps (subscription throttle)', async () => {
const h = makeHarness({ maxConcurrency: 4 })
for (let i = 0; i < 4; i++) {
const t = makeTask({ id: `g${i}`, groupKey: 'sub:foo' })
h.store.set(t.id, t)
}
await h.scheduler.setMaxPerGroup('sub:foo', 1)
for (let i = 0; i < 4; i++) await h.scheduler.enqueue(`g${i}`, 0)
expect(h.dispatched).toHaveLength(1)
await h.complete(h.dispatched[0]!)
expect(h.dispatched).toHaveLength(2)
})
it('1000 concurrent enqueues never over- or under-dispatch', async () => {
const h = makeHarness({ maxConcurrency: 8 })
const N = 1000
for (let i = 0; i < N; i++) {
h.store.set(`x${i}`, makeTask({ id: `x${i}` }))
}
// Enqueue in parallel — the AsyncMutex must serialize slot accounting.
await Promise.all(
Array.from({ length: N }, (_, i) => h.scheduler.enqueue(`x${i}`, 0))
)
// Drain by completing 8 at a time until the heap is empty.
let drained = 0
while (drained < N) {
const inflight = [...h.dispatched.slice(drained, drained + 8)]
if (inflight.length === 0) break
for (const id of inflight) await h.complete(id)
drained += inflight.length
}
expect(h.dispatched).toHaveLength(N)
// No duplicates.
expect(new Set(h.dispatched).size).toBe(N)
})
it('setMaxConcurrency lower demotes lowest-priority running tasks', async () => {
const demoted: string[] = []
const store = new Map<string, Task>()
const dispatched: string[] = []
const scheduler = new Scheduler({
maxConcurrency: 3,
getTask: (id) => store.get(id),
dispatch: (id) => {
dispatched.push(id)
return true
},
demote: (id) => {
demoted.push(id)
}
})
store.set('hi', makeTask({ id: 'hi', priority: 0 }))
store.set('mid', makeTask({ id: 'mid', priority: 10 }))
store.set('lo', makeTask({ id: 'lo', priority: 20 }))
await scheduler.enqueue('hi', 0)
await scheduler.enqueue('mid', 10)
await scheduler.enqueue('lo', 20)
expect(scheduler.stats().running).toBe(3)
await scheduler.setMaxConcurrency(2)
expect(demoted).toEqual(['lo'])
})
it('failing dispatch releases the slot for re-use', async () => {
const h = makeHarness({
maxConcurrency: 1,
failingDispatch: new Set(['boom'])
})
h.store.set('boom', makeTask({ id: 'boom' }))
h.store.set('next', makeTask({ id: 'next' }))
await h.scheduler.enqueue('boom', 0)
await h.scheduler.enqueue('next', 0)
expect(h.dispatched).toContain('next')
})
})
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { TaskStore } from '../src/store'
import { makeTask } from './_fixtures'
describe('TaskStore', () => {
it('insert/get/list', () => {
const store = new TaskStore()
const a = makeTask({ id: 'a', groupKey: 'g1' })
const b = makeTask({ id: 'b', groupKey: 'g1' })
const c = makeTask({ id: 'c', groupKey: 'g2', status: 'completed' })
store.insert(a)
store.insert(b)
store.insert(c)
expect(store.get('a')!.id).toBe('a')
expect(store.list({ groupKey: 'g1' }).tasks.map((t) => t.id)).toEqual(['a', 'b'])
expect(store.list({ status: 'completed' }).tasks.map((t) => t.id)).toEqual(['c'])
})
it('update maintains status/group indexes', () => {
const store = new TaskStore()
const t = makeTask({ id: 'x', status: 'queued', groupKey: 'g1' })
store.insert(t)
store.update({ ...t, status: 'completed', groupKey: 'g2' })
expect(store.list({ status: 'queued' }).tasks).toHaveLength(0)
expect(store.list({ groupKey: 'g1' }).tasks).toHaveLength(0)
expect(store.list({ status: 'completed' }).tasks).toHaveLength(1)
expect(store.list({ groupKey: 'g2' }).tasks).toHaveLength(1)
})
it('paginates with cursor', () => {
const store = new TaskStore()
for (let i = 0; i < 5; i++) {
store.insert(makeTask({ id: `t${i}`, createdAt: 1000 + i }))
}
const page1 = store.list({ limit: 2 })
expect(page1.tasks.map((t) => t.id)).toEqual(['t0', 't1'])
expect(page1.nextCursor).toBe('t1')
const page2 = store.list({ limit: 2, cursor: page1.nextCursor })
expect(page2.tasks.map((t) => t.id)).toEqual(['t2', 't3'])
const page3 = store.list({ limit: 2, cursor: page2.nextCursor })
expect(page3.tasks.map((t) => t.id)).toEqual(['t4'])
expect(page3.nextCursor).toBeNull()
})
it('rejects duplicate insert', () => {
const store = new TaskStore()
store.insert(makeTask({ id: 'x' }))
expect(() => store.insert(makeTask({ id: 'x' }))).toThrow()
})
it('stats counts by status', () => {
const store = new TaskStore()
store.insert(makeTask({ id: 'a', status: 'queued' }))
store.insert(makeTask({ id: 'b', status: 'completed' }))
store.insert(makeTask({ id: 'c', status: 'completed' }))
const s = store.stats(8)
expect(s.byStatus.queued).toBe(1)
expect(s.byStatus.completed).toBe(2)
expect(s.total).toBe(3)
expect(s.capacity).toBe(8)
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": true,
"types": ["node"]
},
"include": ["src/**/*", "test/**/*"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts', '__integration__/**/*.test.ts'],
testTimeout: 5000
}
})