redmine-context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Núcleo da fila de jobs de extração (#67 / M4-10.1, ADR-005).
3
+ *
4
+ * A extração de mídia é longa (minutos) e há vários anexos por issue; esta fila
5
+ * roda os jobs com **concorrência limitada** (`os.cpus().length − 1`, piso de 1),
6
+ * expõe as **transições de estado de forma observável** e **isola falhas** — um
7
+ * job que rejeita vira `failed` sem derrubar os demais.
8
+ *
9
+ * ## Observabilidade — contrato do core (ADR-005)
10
+ *
11
+ * A fila é consumida como `AsyncIterable<QueueEvent<T>>`, o mesmo padrão
12
+ * `AsyncIterable<ProgressEvent | Result>` que as superfícies já usam: cada
13
+ * transição de um job é um item da sequência, e o iterável termina quando a fila
14
+ * drena. O status REUSA o vocabulário canônico {@link ExtractionStatus}
15
+ * (`pending → processing → done | failed | cancelled`) — sem status paralelo.
16
+ *
17
+ * ## Cancelamento (#69)
18
+ *
19
+ * A fila aceita um `AbortSignal` opcional ({@link RunQueueOptions.signal}) e o
20
+ * propaga a cada job via {@link JobContext.signal}. Ao abortar: os jobs em
21
+ * execução recebem o signal (o extrator o repassa ao `runWithWatchdog`, que MATA
22
+ * o subprocesso) e são observados como `cancelled`; os jobs `pending` que ainda
23
+ * não iniciaram são marcados `cancelled` sem iniciar. Cada transição emite UM
24
+ * evento — as superfícies consomem só o `AsyncIterable`, sem API de cancelamento
25
+ * acoplada.
26
+ *
27
+ * ## Fronteira e escopo
28
+ *
29
+ * Módulo de core (pipeline de extração): não importa de `src/surfaces/**` e não
30
+ * usa `console.*` — falhas são reportadas via {@link Logger} injetado. O
31
+ * {@link JobContext} passado a cada job é o ponto de extensão do timeout/kill de
32
+ * subprocesso (#68, `timeoutMs`) e do cancelamento (#69, `signal`), sem quebrar a
33
+ * assinatura de {@link QueueJob}.
34
+ */
35
+ import os from 'node:os';
36
+ /**
37
+ * Limite de concorrência padrão da fila: `os.cpus().length − 1`, com piso de 1
38
+ * para máquinas de 1 núcleo (ADR-005 — deixa um núcleo livre para a UI/event
39
+ * loop enquanto o binário externo trabalha).
40
+ *
41
+ * @returns O número máximo de jobs simultâneos recomendado para esta máquina.
42
+ */
43
+ export function defaultConcurrency() {
44
+ return Math.max(1, os.cpus().length - 1);
45
+ }
46
+ /** Converte um erro arbitrário em uma mensagem legível para o `reason`. */
47
+ function describeError(error) {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+ function createChannel() {
51
+ const buffer = [];
52
+ let closed = false;
53
+ let wake;
54
+ const notify = () => {
55
+ const resume = wake;
56
+ wake = undefined;
57
+ resume?.();
58
+ };
59
+ return {
60
+ emit(value) {
61
+ buffer.push(value);
62
+ notify();
63
+ },
64
+ close() {
65
+ closed = true;
66
+ notify();
67
+ },
68
+ async *stream() {
69
+ for (;;) {
70
+ if (buffer.length > 0) {
71
+ yield buffer.shift();
72
+ continue;
73
+ }
74
+ if (closed) {
75
+ return;
76
+ }
77
+ await new Promise((resolve) => {
78
+ wake = resolve;
79
+ });
80
+ }
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * Roda uma coleção de jobs com concorrência limitada, expondo cada transição de
86
+ * estado como um `AsyncIterable<QueueEvent<T>>` (contrato do core, ADR-005).
87
+ *
88
+ * Garantias:
89
+ * - **Concorrência**: nunca há mais que `concurrency` jobs em `processing` ao
90
+ * mesmo tempo; os excedentes ficam em `pending` até um slot liberar (FIFO —
91
+ * a ordem de enfileiramento é respeitada).
92
+ * - **Observabilidade**: todo job emite `pending` (no enfileiramento), depois
93
+ * `processing` (ao iniciar) e por fim `done` (com `result`) ou `failed` (com
94
+ * `reason`/`error`).
95
+ * - **Isolamento de falha**: um job que rejeita vira `failed` e NÃO interrompe
96
+ * os demais — a fila continua até drenar. `runQueue` nunca rejeita por causa
97
+ * de um job.
98
+ *
99
+ * @typeParam T - Tipo do resultado dos jobs em caso de sucesso.
100
+ * @param jobs - Jobs a executar, na ordem de prioridade (FIFO).
101
+ * @param options - Concorrência e logger — ver {@link RunQueueOptions}.
102
+ * @returns Sequência assíncrona de transições, terminada quando a fila drena.
103
+ * @example
104
+ * for await (const event of runQueue(jobs, { concurrency: 2 })) {
105
+ * if (event.status === 'failed') logger.warn(event.reason ?? 'falhou');
106
+ * }
107
+ */
108
+ export function runQueue(jobs, options = {}) {
109
+ const concurrency = Math.max(1, options.concurrency ?? defaultConcurrency());
110
+ const logger = options.logger;
111
+ const jobTimeoutMs = options.jobTimeoutMs;
112
+ const signal = options.signal;
113
+ const channel = createChannel();
114
+ const pending = [...jobs];
115
+ // Todos os jobs são observados como `pending` já no enfileiramento (ordem FIFO).
116
+ for (const job of pending) {
117
+ channel.emit({ id: job.id, status: 'pending' });
118
+ }
119
+ let active = 0;
120
+ let next = 0;
121
+ // Reason: fecha o canal E remove o listener de abort — evita vazar a inscrição
122
+ // num `AbortSignal` de vida longa quando a fila drena sem cancelamento.
123
+ const closeChannel = () => {
124
+ signal?.removeEventListener('abort', onAbort);
125
+ channel.close();
126
+ };
127
+ /**
128
+ * Marca como `cancelled` todo job `pending` que ainda NÃO iniciou (índice a
129
+ * partir de `next`), impedindo que o `pump` os inicie, e fecha o canal se não há
130
+ * mais nada em execução. Os jobs em execução NÃO são tocados aqui: eles recebem
131
+ * o `signal` (matam o subprocesso) e viram `cancelled` no `catch` de {@link settle}.
132
+ */
133
+ const cancelRemaining = () => {
134
+ while (next < pending.length) {
135
+ const job = pending[next];
136
+ next += 1;
137
+ if (job === undefined) {
138
+ continue; // Inalcançável dado o bound; satisfaz noUncheckedIndexedAccess.
139
+ }
140
+ channel.emit({ id: job.id, status: 'cancelled' });
141
+ }
142
+ if (active === 0) {
143
+ closeChannel();
144
+ }
145
+ };
146
+ // Reason: função (hoisted) para que `closeChannel` possa desinscrevê-la.
147
+ function onAbort() {
148
+ cancelRemaining();
149
+ }
150
+ const settle = async (job) => {
151
+ try {
152
+ // `processing` dentro do try: se a emissão falhar, o finally ainda roda e o
153
+ // slot é liberado (sem leak). Cobre também `job.run` que lança SÍNCRONO.
154
+ channel.emit({ id: job.id, status: 'processing' });
155
+ // Reason: só inclui `timeoutMs`/`signal` quando a fila os tem — nunca injeta
156
+ // `undefined` (exactOptionalPropertyTypes) no contexto do job.
157
+ const context = {
158
+ jobId: job.id,
159
+ ...(jobTimeoutMs !== undefined ? { timeoutMs: jobTimeoutMs } : {}),
160
+ ...(signal !== undefined ? { signal } : {}),
161
+ };
162
+ const result = await job.run(context);
163
+ channel.emit({
164
+ id: job.id,
165
+ status: 'done',
166
+ ...(result !== undefined ? { result } : {}),
167
+ });
168
+ }
169
+ catch (error) {
170
+ // Cancelamento vence a falha: uma vez abortada, a rejeição do job (subprocesso
171
+ // morto) é uma transição `cancelled`, não `failed` — um evento por transição.
172
+ if (signal?.aborted === true) {
173
+ channel.emit({ id: job.id, status: 'cancelled' });
174
+ }
175
+ else {
176
+ // Isolamento de falha: reporta, mas a fila segue com os demais jobs.
177
+ const reason = describeError(error);
178
+ logger?.warn(`queue: job "${job.id}" falhou — ${reason}`);
179
+ channel.emit({ id: job.id, status: 'failed', reason, ...(error !== undefined ? { error } : {}) });
180
+ }
181
+ }
182
+ finally {
183
+ active -= 1;
184
+ // Fora da pilha do `pump`: um job que lança SÍNCRONO rodaria o finally de
185
+ // forma reentrante, gerando recursão O(N) (risco de stack overflow) e
186
+ // `close()` redundante. O microtask serializa as continuações.
187
+ queueMicrotask(pump);
188
+ }
189
+ };
190
+ /** Preenche slots livres respeitando o limite; fecha o canal quando drena. */
191
+ function pump() {
192
+ // Após um abort, não inicia novos jobs: drena os pendentes como `cancelled`.
193
+ if (signal?.aborted === true) {
194
+ cancelRemaining();
195
+ return;
196
+ }
197
+ while (active < concurrency && next < pending.length) {
198
+ const job = pending[next];
199
+ next += 1;
200
+ if (job === undefined) {
201
+ break; // Inalcançável dado o bound; satisfaz noUncheckedIndexedAccess.
202
+ }
203
+ active += 1;
204
+ void settle(job);
205
+ }
206
+ if (active === 0 && next >= pending.length) {
207
+ closeChannel();
208
+ }
209
+ }
210
+ // O cancelamento é observado por evento (contrato AsyncIterable) — sem API
211
+ // especial acoplada às superfícies (TUI/CLI consomem só o iterável).
212
+ if (signal !== undefined && !signal.aborted) {
213
+ signal.addEventListener('abort', onAbort);
214
+ }
215
+ pump();
216
+ return channel.stream();
217
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Utilitário COMPARTILHADO de subprocesso para os extratores de mídia (M4-10.2,
3
+ * #68, ADR-002).
4
+ *
5
+ * Consolida num único lugar as duas garantias de segurança/robustez que antes
6
+ * eram duplicadas (≥3 cópias) em {@link convertAudioToWav}, {@link WhisperExtractor}
7
+ * e {@link TesseractExtractor} — a review do #60 apontou essa duplicação como
8
+ * dívida:
9
+ *
10
+ * 1. **ENV SANITIZADO** ({@link sanitizedEnv}): o subprocesso recebe um env MÍNIMO
11
+ * e por ALLOWLIST — só `PATH` (com fallback), o que o chamador permitir
12
+ * explicitamente (ex.: `TESSDATA_PREFIX` do tesseract) e, no Windows, um punhado
13
+ * de variáveis NÃO-SECRETAS essenciais (`SystemRoot`/`windir`) para robustez
14
+ * (sugestão da review do #60). Segredos do processo pai (ex.: `REDMINE_API_KEY`)
15
+ * NUNCA são propagados — o env é montado por inclusão, não por herança.
16
+ *
17
+ * 2. **WATCHDOG DE TIMEOUT + KILL** ({@link runWithWatchdog}): roda o binário SEM
18
+ * shell (`execFile`) e, se estourar o timeout, envia `SIGTERM` e — após a graça
19
+ * — `SIGKILL`, rejeitando com um erro de timeout. Um binário travado NUNCA
20
+ * pendura a fila de jobs nem deixa PROCESSO ZUMBI (o `kill` encerra o child).
21
+ *
22
+ * TESTABILIDADE: `platform`/`source` do env e o `execFile` do watchdog são
23
+ * INJETÁVEIS — os três SOs e o escalonamento `SIGTERM`→`SIGKILL` são testáveis num
24
+ * único host, com fake timers e sem subprocesso real. O erro de timeout é
25
+ * INJETÁVEL (`makeTimeoutError`) para que cada extrator preserve seu tipo de erro
26
+ * canônico (ex.: `FfmpegTimeoutError`) sem duplicar o watchdog.
27
+ *
28
+ * Fronteira: módulo de core (pipeline de extração) — sem `console.*` (ADR-005) e
29
+ * sem importar de `src/surfaces/**`.
30
+ */
31
+ /** Opções de {@link sanitizedEnv} — todas injetáveis para testes multi-SO. */
32
+ export interface SanitizedEnvOptions {
33
+ /** SO alvo; default `process.platform`. Decide as variáveis essenciais do Windows. */
34
+ readonly platform?: NodeJS.Platform;
35
+ /** Env de origem; default `process.env`. É LIDO por allowlist, nunca herdado inteiro. */
36
+ readonly source?: NodeJS.ProcessEnv;
37
+ /**
38
+ * Chaves adicionais permitidas ALÉM de `PATH` (ex.: `TESSDATA_PREFIX`). Cada
39
+ * chave só entra no resultado se estiver definida na origem (respeita
40
+ * `exactOptionalPropertyTypes`).
41
+ */
42
+ readonly allow?: readonly string[];
43
+ }
44
+ /**
45
+ * Monta o env MÍNIMO e EXPLÍCITO de um subprocesso por ALLOWLIST (ADR-002). O
46
+ * resultado contém `PATH` (com fallback) mais, apenas se definidas na origem, as
47
+ * chaves de {@link SanitizedEnvOptions.allow} e — no Windows — as variáveis
48
+ * essenciais não-secretas ({@link WINDOWS_ESSENTIAL_ENV}). Nenhum segredo do
49
+ * processo pai (ex.: `REDMINE_API_KEY`) é propagado.
50
+ *
51
+ * @param options - Plataforma, origem e allowlist — ver {@link SanitizedEnvOptions}.
52
+ * @returns Env sanitizado, seguro para passar a `execFile`.
53
+ * @example
54
+ * sanitizedEnv(); // { PATH } (+ essenciais no Windows)
55
+ * sanitizedEnv({ allow: ['TESSDATA_PREFIX'] }); // + TESSDATA_PREFIX, se definida
56
+ */
57
+ export declare function sanitizedEnv(options?: SanitizedEnvOptions): NodeJS.ProcessEnv;
58
+ /** Child mínimo que o watchdog precisa: só a capacidade de enviar um sinal de kill. */
59
+ export interface WatchdogChild {
60
+ /** Envia um sinal ao processo; retorna `true` se o sinal foi entregue. */
61
+ kill(signal: NodeJS.Signals): boolean;
62
+ }
63
+ /** Callback do `execFile` na forma `(error, stdout, stderr)` com `encoding: 'utf8'`. */
64
+ type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
65
+ /**
66
+ * Forma estrutural do `execFile` que o watchdog usa (sem shell, `encoding: 'utf8'`).
67
+ * Injetável nos testes por um fake determinístico, sem tocar `child_process` real.
68
+ */
69
+ export type ExecFileLike = (file: string, args: readonly string[], options: {
70
+ readonly env: NodeJS.ProcessEnv;
71
+ readonly encoding: 'utf8';
72
+ readonly maxBuffer: number;
73
+ readonly windowsHide: boolean;
74
+ }, callback: ExecFileCallback) => WatchdogChild;
75
+ /** Invocação concreta de um subprocesso vigiado pelo {@link runWithWatchdog}. */
76
+ export interface WatchdogInvocation {
77
+ /** Caminho absoluto do binário já resolvido. */
78
+ readonly bin: string;
79
+ /** Argumentos (sem shell) — lista explícita, nunca interpolada. */
80
+ readonly args: readonly string[];
81
+ /** Env SANITIZADO do subprocesso (ver {@link sanitizedEnv}). */
82
+ readonly env: NodeJS.ProcessEnv;
83
+ /** Timeout antes do `SIGTERM` (ms). */
84
+ readonly timeoutMs: number;
85
+ /** Graça entre `SIGTERM` e `SIGKILL` (ms). */
86
+ readonly killGraceMs: number;
87
+ /** Teto do stdout/stderr capturado (bytes). */
88
+ readonly maxBuffer: number;
89
+ /**
90
+ * Sinal de CANCELAMENTO (#69, ADR-005). Quando dispara, o subprocesso é MORTO
91
+ * com o mesmo escalonamento do timeout (`SIGTERM`→ graça →`SIGKILL`) e a
92
+ * Promise rejeita com {@link SubprocessAbortedError} (ou o erro de
93
+ * {@link WatchdogDeps.makeAbortError}). Se já estiver `aborted` na chamada, o
94
+ * processo é morto de imediato. Campo OPCIONAL e ADITIVO — extratores que não
95
+ * o passam mantêm exatamente o comportamento anterior (#68).
96
+ */
97
+ readonly signal?: AbortSignal;
98
+ }
99
+ /** Dependências injetáveis de {@link runWithWatchdog} — defaults de produção. */
100
+ export interface WatchdogDeps {
101
+ /** Spawner sem shell; default: `execFile` de `node:child_process`. */
102
+ readonly execFile?: ExecFileLike;
103
+ /**
104
+ * Fábrica do erro rejeitado no timeout. Default: {@link SubprocessTimeoutError}.
105
+ * Cada extrator injeta seu erro canônico (ex.: `FfmpegTimeoutError`) para manter
106
+ * a classificação de falha existente sem reimplementar o watchdog.
107
+ */
108
+ readonly makeTimeoutError?: (timeoutMs: number) => Error;
109
+ /**
110
+ * Fábrica do erro rejeitado no cancelamento via `AbortSignal` (#69). Default:
111
+ * {@link SubprocessAbortedError}. Injetável para que um extrator preserve seu
112
+ * tipo de erro canônico ao ser cancelado, sem reimplementar o watchdog.
113
+ */
114
+ readonly makeAbortError?: () => Error;
115
+ }
116
+ /** Erro default do watchdog: o subprocesso estourou o timeout e foi encerrado. */
117
+ export declare class SubprocessTimeoutError extends Error {
118
+ readonly timeoutMs: number;
119
+ constructor(timeoutMs: number);
120
+ }
121
+ /** Erro default do watchdog quando o subprocesso é MORTO por cancelamento (#69). */
122
+ export declare class SubprocessAbortedError extends Error {
123
+ constructor();
124
+ }
125
+ /**
126
+ * Roda um binário SEM shell com env sanitizado e um WATCHDOG de timeout: no
127
+ * estouro, envia `SIGTERM` e, após a graça, `SIGKILL` — garantindo que um processo
128
+ * travado seja encerrado (sem zumbi) e não pendure a fila de jobs (ADR-002).
129
+ *
130
+ * Resolve com o `stdout` no exit 0; rejeita com o erro bruto do `execFile` em exit
131
+ * != 0; rejeita com o erro de {@link WatchdogDeps.makeTimeoutError} (default
132
+ * {@link SubprocessTimeoutError}) no timeout. Todas as transições passam por um
133
+ * `settle` idempotente (uma única resolução; timers sempre limpos — sem leak).
134
+ *
135
+ * @param invocation - Binário, args, env e limites — ver {@link WatchdogInvocation}.
136
+ * @param deps - `execFile`/`makeTimeoutError` injetáveis — ver {@link WatchdogDeps}.
137
+ * @returns O `stdout` do processo em caso de sucesso.
138
+ * @example
139
+ * const out = await runWithWatchdog({
140
+ * bin: '/opt/bin/tesseract', args: ['img.png', 'stdout'],
141
+ * env: sanitizedEnv(), timeoutMs: 30_000, killGraceMs: 2_000, maxBuffer: 16 << 20,
142
+ * });
143
+ */
144
+ export declare function runWithWatchdog(invocation: WatchdogInvocation, deps?: WatchdogDeps): Promise<string>;
145
+ export {};
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Utilitário COMPARTILHADO de subprocesso para os extratores de mídia (M4-10.2,
3
+ * #68, ADR-002).
4
+ *
5
+ * Consolida num único lugar as duas garantias de segurança/robustez que antes
6
+ * eram duplicadas (≥3 cópias) em {@link convertAudioToWav}, {@link WhisperExtractor}
7
+ * e {@link TesseractExtractor} — a review do #60 apontou essa duplicação como
8
+ * dívida:
9
+ *
10
+ * 1. **ENV SANITIZADO** ({@link sanitizedEnv}): o subprocesso recebe um env MÍNIMO
11
+ * e por ALLOWLIST — só `PATH` (com fallback), o que o chamador permitir
12
+ * explicitamente (ex.: `TESSDATA_PREFIX` do tesseract) e, no Windows, um punhado
13
+ * de variáveis NÃO-SECRETAS essenciais (`SystemRoot`/`windir`) para robustez
14
+ * (sugestão da review do #60). Segredos do processo pai (ex.: `REDMINE_API_KEY`)
15
+ * NUNCA são propagados — o env é montado por inclusão, não por herança.
16
+ *
17
+ * 2. **WATCHDOG DE TIMEOUT + KILL** ({@link runWithWatchdog}): roda o binário SEM
18
+ * shell (`execFile`) e, se estourar o timeout, envia `SIGTERM` e — após a graça
19
+ * — `SIGKILL`, rejeitando com um erro de timeout. Um binário travado NUNCA
20
+ * pendura a fila de jobs nem deixa PROCESSO ZUMBI (o `kill` encerra o child).
21
+ *
22
+ * TESTABILIDADE: `platform`/`source` do env e o `execFile` do watchdog são
23
+ * INJETÁVEIS — os três SOs e o escalonamento `SIGTERM`→`SIGKILL` são testáveis num
24
+ * único host, com fake timers e sem subprocesso real. O erro de timeout é
25
+ * INJETÁVEL (`makeTimeoutError`) para que cada extrator preserve seu tipo de erro
26
+ * canônico (ex.: `FfmpegTimeoutError`) sem duplicar o watchdog.
27
+ *
28
+ * Fronteira: módulo de core (pipeline de extração) — sem `console.*` (ADR-005) e
29
+ * sem importar de `src/surfaces/**`.
30
+ */
31
+ import { execFile as nodeExecFile } from 'node:child_process';
32
+ /**
33
+ * Variáveis NÃO-SECRETAS essenciais no Windows, preservadas por {@link sanitizedEnv}
34
+ * para robustez (sem elas, alguns binários nativos falham ao resolver caminhos do
35
+ * sistema). Nenhuma carrega segredo — são metadados de instalação do SO.
36
+ */
37
+ const WINDOWS_ESSENTIAL_ENV = ['SystemRoot', 'windir', 'SystemDrive', 'PATHEXT'];
38
+ /** `PATH` mínimo usado quando a origem não o define (ambientes muito enxutos). */
39
+ const FALLBACK_PATH = '/usr/bin:/bin';
40
+ /**
41
+ * Monta o env MÍNIMO e EXPLÍCITO de um subprocesso por ALLOWLIST (ADR-002). O
42
+ * resultado contém `PATH` (com fallback) mais, apenas se definidas na origem, as
43
+ * chaves de {@link SanitizedEnvOptions.allow} e — no Windows — as variáveis
44
+ * essenciais não-secretas ({@link WINDOWS_ESSENTIAL_ENV}). Nenhum segredo do
45
+ * processo pai (ex.: `REDMINE_API_KEY`) é propagado.
46
+ *
47
+ * @param options - Plataforma, origem e allowlist — ver {@link SanitizedEnvOptions}.
48
+ * @returns Env sanitizado, seguro para passar a `execFile`.
49
+ * @example
50
+ * sanitizedEnv(); // { PATH } (+ essenciais no Windows)
51
+ * sanitizedEnv({ allow: ['TESSDATA_PREFIX'] }); // + TESSDATA_PREFIX, se definida
52
+ */
53
+ export function sanitizedEnv(options = {}) {
54
+ const platform = options.platform ?? process.platform;
55
+ const source = options.source ?? process.env;
56
+ const env = { PATH: source.PATH ?? FALLBACK_PATH };
57
+ const allowed = platform === 'win32'
58
+ ? [...WINDOWS_ESSENTIAL_ENV, ...(options.allow ?? [])]
59
+ : (options.allow ?? []);
60
+ for (const key of allowed) {
61
+ const value = source[key];
62
+ // Reason: só inclui a chave quando há valor — nunca injeta `undefined`
63
+ // (exactOptionalPropertyTypes) e mantém o env estritamente mínimo.
64
+ if (value !== undefined) {
65
+ env[key] = value;
66
+ }
67
+ }
68
+ return env;
69
+ }
70
+ /** Erro default do watchdog: o subprocesso estourou o timeout e foi encerrado. */
71
+ export class SubprocessTimeoutError extends Error {
72
+ timeoutMs;
73
+ constructor(timeoutMs) {
74
+ super(`subprocesso excedeu o timeout de ${timeoutMs}ms e foi encerrado`);
75
+ this.timeoutMs = timeoutMs;
76
+ this.name = 'SubprocessTimeoutError';
77
+ }
78
+ }
79
+ /** Erro default do watchdog quando o subprocesso é MORTO por cancelamento (#69). */
80
+ export class SubprocessAbortedError extends Error {
81
+ constructor() {
82
+ super('subprocesso cancelado via AbortSignal e foi encerrado');
83
+ this.name = 'SubprocessAbortedError';
84
+ }
85
+ }
86
+ /**
87
+ * Roda um binário SEM shell com env sanitizado e um WATCHDOG de timeout: no
88
+ * estouro, envia `SIGTERM` e, após a graça, `SIGKILL` — garantindo que um processo
89
+ * travado seja encerrado (sem zumbi) e não pendure a fila de jobs (ADR-002).
90
+ *
91
+ * Resolve com o `stdout` no exit 0; rejeita com o erro bruto do `execFile` em exit
92
+ * != 0; rejeita com o erro de {@link WatchdogDeps.makeTimeoutError} (default
93
+ * {@link SubprocessTimeoutError}) no timeout. Todas as transições passam por um
94
+ * `settle` idempotente (uma única resolução; timers sempre limpos — sem leak).
95
+ *
96
+ * @param invocation - Binário, args, env e limites — ver {@link WatchdogInvocation}.
97
+ * @param deps - `execFile`/`makeTimeoutError` injetáveis — ver {@link WatchdogDeps}.
98
+ * @returns O `stdout` do processo em caso de sucesso.
99
+ * @example
100
+ * const out = await runWithWatchdog({
101
+ * bin: '/opt/bin/tesseract', args: ['img.png', 'stdout'],
102
+ * env: sanitizedEnv(), timeoutMs: 30_000, killGraceMs: 2_000, maxBuffer: 16 << 20,
103
+ * });
104
+ */
105
+ export function runWithWatchdog(invocation, deps = {}) {
106
+ const { bin, args, env, timeoutMs, killGraceMs, maxBuffer, signal } = invocation;
107
+ // Reason: as sobrecargas de `execFile` não colapsam na forma estrutural mínima
108
+ // que usamos (encoding fixo 'utf8'); o cast documentado é seguro — a chamada
109
+ // abaixo respeita exatamente a assinatura real.
110
+ const exec = deps.execFile ?? nodeExecFile;
111
+ const makeTimeoutError = deps.makeTimeoutError ?? ((ms) => new SubprocessTimeoutError(ms));
112
+ const makeAbortError = deps.makeAbortError ?? (() => new SubprocessAbortedError());
113
+ return new Promise((resolve, reject) => {
114
+ let settled = false;
115
+ let timedOut = false;
116
+ let aborted = false;
117
+ const timers = [];
118
+ const cleanup = () => {
119
+ for (const timer of timers)
120
+ clearTimeout(timer);
121
+ signal?.removeEventListener('abort', onAbort);
122
+ };
123
+ const settle = (fn) => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ cleanup();
128
+ fn();
129
+ };
130
+ /**
131
+ * Mata o child com o escalonamento `SIGTERM`→ graça →`SIGKILL` e, se o
132
+ * processo não responder na graça, resolve a Promise via `onKilled`. Um
133
+ * processo que responde ao SIGTERM (callback do execFile) faz `settle`
134
+ * primeiro, e o `cleanup` limpa o timer do SIGKILL (anti-pid-reciclado).
135
+ */
136
+ const killEscalating = (onKilled) => {
137
+ child.kill('SIGTERM');
138
+ // Reason: após a graça, força SIGKILL e desiste — um processo que ignora
139
+ // SIGTERM não pode segurar a fila de jobs indefinidamente (ADR-002).
140
+ timers.push(setTimeout(() => {
141
+ child.kill('SIGKILL');
142
+ onKilled();
143
+ }, killGraceMs));
144
+ };
145
+ // Reason: declaração de função (hoisted) para que `cleanup` possa referenciá-la
146
+ // e para que o listener seja registrado só após o child existir.
147
+ function onAbort() {
148
+ aborted = true;
149
+ killEscalating(() => settle(() => reject(makeAbortError())));
150
+ }
151
+ const child = exec(bin, [...args], { env, encoding: 'utf8', maxBuffer, windowsHide: true }, (error, stdout) => {
152
+ // Ordem: um cancelamento/timeout em curso vence o resultado tardio do
153
+ // processo (que respondeu ao SIGTERM) — nunca vira sucesso/erro comum.
154
+ if (aborted) {
155
+ settle(() => reject(makeAbortError()));
156
+ return;
157
+ }
158
+ if (timedOut) {
159
+ settle(() => reject(makeTimeoutError(timeoutMs)));
160
+ return;
161
+ }
162
+ if (error !== null) {
163
+ settle(() => reject(error));
164
+ return;
165
+ }
166
+ settle(() => resolve(stdout));
167
+ });
168
+ timers.push(setTimeout(() => {
169
+ timedOut = true;
170
+ killEscalating(() => settle(() => reject(makeTimeoutError(timeoutMs))));
171
+ }, timeoutMs));
172
+ if (signal !== undefined) {
173
+ if (signal.aborted) {
174
+ onAbort();
175
+ }
176
+ else {
177
+ signal.addEventListener('abort', onAbort);
178
+ }
179
+ }
180
+ });
181
+ }