返回 html-video
task-registry.ts
根目录 / packages / cli / src / task-registry.ts
1 /**
2 * In-process registry of long-running generation tasks (video frames, audio).
3 *
4 * The point: a generation must NOT die when the browser navigates away or the
5 * SSE connection drops. Previously each generate endpoint streamed straight to
6 * the request's `res`; closing it (switching project / refresh) killed the run.
7 *
8 * Now a task runs detached from any request. It accumulates its events, so a
9 * client can (re)subscribe at any time and get a full replay + live tail. The
10 * runner's promise drives the work; subscribers come and go freely.
11 *
12 * In-memory only — tasks live for the studio process lifetime. A server restart
13 * loses in-flight tasks (acceptable: the studio is a local single-process tool).
14 */
15
16 export type TaskKind = 'message' | 'audio';
17 export type TaskStatus = 'running' | 'done' | 'failed';
18
19 export interface TaskEvent {
20 /** Monotonic per-task sequence, so a reconnecting client can skip what it saw. */
21 seq: number;
22 data: unknown;
23 }
24
25 interface Task {
26 id: string;
27 projectId: string;
28 kind: TaskKind;
29 status: TaskStatus;
30 events: TaskEvent[];
31 subscribers: Set<(e: TaskEvent) => void>;
32 error?: string;
33 startedAt: number;
34 endedAt?: number;
35 }
36
37 /** Emit handle handed to a task runner — it just calls emit(data). */
38 export interface TaskEmitter {
39 taskId: string;
40 emit: (data: unknown) => void;
41 }
42
43 export class TaskRegistry {
44 private tasks = new Map<string, Task>();
45 private seq = 0;
46 private idCounter = 0;
47
48 /** Tasks completed > this long ago are pruned on the next create(). */
49 private static readonly TTL_MS = 10 * 60_000;
50
51 /**
52 * Start a detached task. `runner` receives an emitter; whatever it emits is
53 * fanned out to current subscribers AND retained for replay. The runner's
54 * resolved value is ignored (state lives in emitted events + the project);
55 * a thrown error marks the task failed and is emitted as a final event.
56 */
57 create(
58 projectId: string,
59 kind: TaskKind,
60 runner: (emitter: TaskEmitter) => Promise<void>,
61 ): string {
62 this.prune();
63 const id = `task_${Date.now().toString(36)}_${(this.idCounter++).toString(36)}`;
64 const task: Task = {
65 id,
66 projectId,
67 kind,
68 status: 'running',
69 events: [],
70 subscribers: new Set(),
71 startedAt: Date.now(),
72 };
73 this.tasks.set(id, task);
74
75 const emit = (data: unknown) => {
76 const ev: TaskEvent = { seq: ++this.seq, data };
77 task.events.push(ev);
78 for (const fn of task.subscribers) {
79 try { fn(ev); } catch { /* a dead subscriber shouldn't break the task */ }
80 }
81 };
82
83 // Run detached. Never rejects out of here.
84 void runner({ taskId: id, emit })
85 .then(() => {
86 task.status = 'done';
87 task.endedAt = Date.now();
88 emit({ type: 'task_done' });
89 })
90 .catch((err: unknown) => {
91 task.status = 'failed';
92 task.error = err instanceof Error ? err.message : String(err);
93 task.endedAt = Date.now();
94 emit({ type: 'task_failed', message: task.error });
95 });
96
97 return id;
98 }
99
100 /** The newest still-running (or just-finished) task for a project, if any. */
101 activeTaskFor(projectId: string): { id: string; kind: TaskKind; status: TaskStatus } | null {
102 let newest: Task | null = null;
103 for (const t of this.tasks.values()) {
104 if (t.projectId !== projectId) continue;
105 if (!newest || t.startedAt > newest.startedAt) newest = t;
106 }
107 if (!newest) return null;
108 return { id: newest.id, kind: newest.kind, status: newest.status };
109 }
110
111 /**
112 * Subscribe to a task: immediately replays events after `sinceSeq`, then calls
113 * `onEvent` for each new one. Returns an unsubscribe fn, plus whether the task
114 * is already finished (so the caller can close the stream). `null` = no such task.
115 */
116 subscribe(
117 taskId: string,
118 sinceSeq: number,
119 onEvent: (e: TaskEvent) => void,
120 ): { unsubscribe: () => void; finished: boolean } | null {
121 const task = this.tasks.get(taskId);
122 if (!task) return null;
123 for (const ev of task.events) {
124 if (ev.seq > sinceSeq) onEvent(ev);
125 }
126 if (task.status !== 'running') {
127 return { unsubscribe: () => {}, finished: true };
128 }
129 task.subscribers.add(onEvent);
130 return {
131 unsubscribe: () => { task.subscribers.delete(onEvent); },
132 finished: false,
133 };
134 }
135
136 get(taskId: string): Task | undefined {
137 return this.tasks.get(taskId);
138 }
139
140 private prune(): void {
141 const now = Date.now();
142 for (const [id, t] of this.tasks) {
143 if (t.status !== 'running' && t.endedAt && now - t.endedAt > TaskRegistry.TTL_MS) {
144 this.tasks.delete(id);
145 }
146 }
147 }
148 }
149
149 lines TYPESCRIPT