@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.1

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -2
  3. package/package.json +41 -3
  4. package/src/domain/commit-message.ts +80 -0
  5. package/src/domain/locks.ts +5 -0
  6. package/src/domain/paths.ts +98 -0
  7. package/src/index.ts +11 -0
  8. package/src/node/authoring-clients.ts +206 -0
  9. package/src/node/build.ts +42 -0
  10. package/src/node/compile-session.ts +187 -0
  11. package/src/node/config.ts +141 -0
  12. package/src/node/document-assets.ts +30 -0
  13. package/src/node/document-audio.ts +473 -0
  14. package/src/node/document-authoring.ts +436 -0
  15. package/src/node/document-editor.ts +157 -0
  16. package/src/node/document-history.ts +190 -0
  17. package/src/node/document-images.ts +241 -0
  18. package/src/node/document-operations.ts +191 -0
  19. package/src/node/document-transcription-adoption.ts +96 -0
  20. package/src/node/document-transcription.ts +471 -0
  21. package/src/node/document-voice.ts +115 -0
  22. package/src/node/errors.ts +23 -0
  23. package/src/node/git.ts +68 -0
  24. package/src/node/jobs.ts +361 -0
  25. package/src/node/locks.ts +46 -0
  26. package/src/node/page-delete.ts +106 -0
  27. package/src/node/project-files.ts +163 -0
  28. package/src/node/projects.ts +192 -0
  29. package/src/node/review/agent-session-reviews.ts +374 -0
  30. package/src/node/review/git-guard.ts +104 -0
  31. package/src/node/review/git-review.ts +428 -0
  32. package/src/node/review/record.ts +77 -0
  33. package/src/node/review/routes.ts +73 -0
  34. package/src/node/review/task-reviews.ts +50 -0
  35. package/src/node/revisions.ts +599 -0
  36. package/src/node/routes.ts +329 -0
  37. package/src/node/serve.ts +503 -0
  38. package/src/node/site.ts +132 -0
  39. package/src/node/source-editor.ts +332 -0
  40. package/src/node/speech-settings.ts +21 -0
  41. package/src/node/templates.ts +27 -0
  42. package/src/node/workbench-api.ts +176 -0
  43. package/src/node/writer.ts +389 -0
  44. package/templates/document/default/lecture.json +75 -0
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { jsonResponse, prepareTranscriptionAdoption, ProductionAudioRefusal } from '@lincyaw/kernel'
3
+ import { validateTranscriptionAdoptionApply, validateTranscriptionAdoptionDraft } from '@lincyaw/kernel/node'
4
+ import type { CanonicalSource, DocumentChange, DocumentTranscriptionAdoption, DocumentTranscriptions, Member, Route, TranscriptionAdoptionAnswer, TranscriptionAdoptionApply, TranscriptionAdoptionDraft, TranscriptionAdoptionPlan } from '@lincyaw/kernel'
5
+ import type { DocumentEditor } from './document-authoring.ts'
6
+ import { catchRequestErrors, RequestError } from './errors.ts'
7
+
8
+ interface Receipt { document: string, member: string, key: string, draft: Pick<TranscriptionAdoptionDraft, 'source' | 'candidate' | 'draftRevision'>, change: DocumentChange, expires: number, bytes: number, applied: boolean }
9
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
10
+ const sameCandidate = (a: { id: string, revision: string }, b: { id: string, revision: string }) => a.id === b.id && a.revision === b.revision
11
+ const stable = (value: unknown): string => JSON.stringify(value, (_, child) => child && typeof child === 'object' && !Array.isArray(child) ? Object.fromEntries(Object.entries(child).sort(([a], [b]) => a.localeCompare(b))) : child)
12
+
13
+ /** One preview and the existing document transaction, with separate trusted-editor and agent write paths. */
14
+ export function createTranscriptionAdoption({ documentEditor, transcriptions }: { documentEditor: DocumentEditor, transcriptions: DocumentTranscriptions }): { service: DocumentTranscriptionAdoption, routes: Record<string, Route>, close(): void } {
15
+ const receipts = new Map<string, Receipt>(), latest = new Map<string, { revision: number, digest: string }>()
16
+ let bytes = 0, closed = false
17
+ const available = () => { if (closed) throw new RequestError(503, 'adoption-closed') }
18
+ const assertLatest = (receipt: Receipt) => {
19
+ available()
20
+ if (latest.get(receipt.key)?.revision !== receipt.draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
21
+ }
22
+ const preflight = async (document: string, raw: TranscriptionAdoptionApply, member: Member) => {
23
+ available()
24
+ if (!validateTranscriptionAdoptionApply(raw)) throw new RequestError(400, 'adoption-request-invalid')
25
+ const receipt = receipts.get(raw.planId)
26
+ if (!receipt || receipt.document !== document || receipt.member !== member.id) throw new RequestError(409, 'adoption-plan-missing')
27
+ if (raw.draftRevision !== receipt.draft.draftRevision || !sameSource(raw.source, receipt.draft.source) || !sameCandidate(raw.candidate, receipt.draft.candidate)) throw new RequestError(409, 'adoption-plan-changed')
28
+ assertLatest(receipt)
29
+ if (!receipt.applied) {
30
+ if (Date.now() > receipt.expires) throw new RequestError(409, 'adoption-plan-expired')
31
+ await transcriptions.verifyResult(document, raw.candidate.id, raw.candidate.revision, raw.source, member)
32
+ assertLatest(receipt)
33
+ }
34
+ return receipt
35
+ }
36
+ const apply = async (document: string, raw: TranscriptionAdoptionApply, member: Member, origin: 'editor' | 'agent'): Promise<TranscriptionAdoptionAnswer> => {
37
+ const receipt = await preflight(document, raw, member)
38
+ // Origin is selected by the host closure. No request field can choose the editor path.
39
+ const answer = origin === 'agent'
40
+ ? await documentEditor.apply(document, { planId: raw.planId, change: receipt.change }, member)
41
+ : await documentEditor.change(document, receipt.change, member)
42
+ receipt.applied = true
43
+ return { ...answer, adoption: { planId: raw.planId, candidate: structuredClone(raw.candidate), draftRevision: raw.draftRevision } }
44
+ }
45
+ const service: DocumentTranscriptionAdoption = {
46
+ async plan(document, raw, member) {
47
+ available()
48
+ if (!validateTranscriptionAdoptionDraft(raw)) throw new RequestError(400, 'adoption-draft-invalid')
49
+ if (Buffer.byteLength(JSON.stringify(raw)) > 2 << 20) throw new RequestError(413, 'adoption-draft-size')
50
+ const draft = structuredClone(raw), key = JSON.stringify([document, member.id, draft.id]), digest = createHash('sha256').update(stable(draft)).digest('hex')
51
+ const previous = latest.get(key)
52
+ if (previous && (previous.revision > draft.draftRevision || previous.revision === draft.draftRevision && previous.digest !== digest)) throw new RequestError(409, 'adoption-draft-changed')
53
+ latest.set(key, { revision: draft.draftRevision, digest })
54
+ while (latest.size > 128) latest.delete(latest.keys().next().value!)
55
+ const current = await documentEditor.read(document, member)
56
+ if (!sameSource({ documentId: current.content.id, version: current.version, fingerprint: current.fingerprint }, draft.source)) throw new RequestError(409, 'document-version-changed')
57
+ const candidate = await transcriptions.verifyResult(document, draft.candidate.id, draft.candidate.revision, draft.source, member)
58
+ const prepared = prepareTranscriptionAdoption(current.content, candidate, draft)
59
+ const response: TranscriptionAdoptionPlan = { document, changeId: draft.id, source: draft.source, candidate: draft.candidate, draftRevision: draft.draftRevision, ...prepared }
60
+ if (prepared.conflicts.length || !prepared.content) return response
61
+ if (latest.get(key)?.revision !== draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
62
+ const change: DocumentChange = { id: draft.id, baseDocumentVersion: draft.source.version, replacement: prepared.content }
63
+ const plan = await documentEditor.plan(document, change, member)
64
+ available()
65
+ if (latest.get(key)?.revision !== draft.draftRevision) throw new RequestError(409, 'adoption-draft-changed')
66
+ const size = Buffer.byteLength(JSON.stringify(change))
67
+ receipts.set(plan.planId, { document, member: member.id, key, draft: { source: draft.source, candidate: draft.candidate, draftRevision: draft.draftRevision }, change, expires: Date.parse(plan.expiresAt), bytes: size, applied: false })
68
+ bytes += size
69
+ while (receipts.size > 64 || bytes > 16 << 20) { const id = receipts.keys().next().value!; bytes -= receipts.get(id)!.bytes; receipts.delete(id) }
70
+ return { ...response, planId: plan.planId, expiresAt: plan.expiresAt, content: plan.content }
71
+ },
72
+ async check(document, request, member) {
73
+ const receipt = await preflight(document, request, member)
74
+ await documentEditor.checkApply(document, { planId: request.planId, change: receipt.change }, member)
75
+ },
76
+ apply: (document, request, member) => apply(document, request, member, 'agent'),
77
+ }
78
+ const guarded = (run: () => Promise<unknown>) => catchRequestErrors(async () => {
79
+ try { return jsonResponse(await run()) }
80
+ catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error }
81
+ })
82
+ return {
83
+ service,
84
+ routes: {
85
+ 'document/transcription/adoption/plan': { role: 'editor', maxBody: 2 << 20, handle: (request, ctx) => guarded(async () => {
86
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
87
+ return service.plan(ctx.document, await request.json().catch(() => undefined), ctx.member)
88
+ }) },
89
+ 'document/transcription/adoption/apply': { role: 'editor', maxBody: 8 << 10, handle: (request, ctx) => guarded(async () => {
90
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
91
+ return apply(ctx.document, await request.json().catch(() => undefined), ctx.member, 'editor')
92
+ }) },
93
+ },
94
+ close() { closed = true; receipts.clear(); latest.clear(); bytes = 0 },
95
+ }
96
+ }
@@ -0,0 +1,471 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, mkdir, open, opendir, realpath, rename, unlink } from 'node:fs/promises'
4
+ import { dirname, join, relative } from 'node:path'
5
+ import { setImmediate } from 'node:timers/promises'
6
+ import { DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, ProductionAudioRefusal, TRANSCRIPTION_REQUEST_MAX_BYTES } from '@lincyaw/kernel'
7
+ import { productionAssetFile, validateDocumentTranscriptionRequest } from '@lincyaw/kernel/node'
8
+ import type { CanonicalSource, DocumentTranscriptions, Finding, Member, ProductionAudioAuthoring, RequestContext, Route, SpeechEngineInfo, SpeechService, Transcript, DocumentTranscriptionRequest, TranscriptionListAnswer, TranscriptionRevision, TranscriptionTask } from '@lincyaw/kernel'
9
+ import { readAuthoringDocument } from './document-editor.ts'
10
+ import { catchRequestErrors, RequestError } from './errors.ts'
11
+ import type { SourceEditor } from './source-editor.ts'
12
+
13
+ export interface TranscriptionServices { audio?: ProductionAudioAuthoring, speech?: SpeechService, findings?: Finding[] }
14
+ interface Options extends TranscriptionServices {
15
+ root: string
16
+ editor: SourceEditor
17
+ /** Assemble Node services for this exact source without compiling scenes or executing passes. */
18
+ services?(document: string, source: CanonicalSource): Promise<TranscriptionServices>
19
+ }
20
+ interface RecordEntry { format: 1, document: string, documentId: string, member: string, intent: string, payload?: string, cancelled?: true, task?: TranscriptionTask }
21
+ interface Active { record: RecordEntry, directory: string, controller: AbortController, saving: Promise<void>, finished?: Promise<void> }
22
+ const MAX_AUDIO = 16 << 20, MAX_RESULT = 2 << 20, MAX_TASKS = 1024
23
+ const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex')
24
+ const idValid = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{64}$/.test(value)
25
+ const intentValid = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z\d_-]{16,128}$/.test(value)
26
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
27
+ const now = () => new Date().toISOString()
28
+ const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
29
+
30
+ async function readBounded(file: string, limit: number): Promise<Uint8Array> {
31
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
32
+ if (error.code === 'ELOOP') throw new RequestError(403, 'transcription-storage-path')
33
+ throw error
34
+ })
35
+ try {
36
+ const stat = await handle.stat()
37
+ if (!stat.isFile()) throw new RequestError(403, 'transcription-storage-path')
38
+ if (stat.size > limit) throw new RequestError(413, 'transcription-file-size')
39
+ const bytes = new Uint8Array(stat.size + 1)
40
+ let length = 0
41
+ while (length < bytes.length) {
42
+ const read = await handle.read(bytes, length, bytes.length - length, length)
43
+ if (!read.bytesRead) break
44
+ length += read.bytesRead
45
+ }
46
+ if (length > stat.size) throw new RequestError(409, 'transcription-file-changed')
47
+ return bytes.slice(0, length)
48
+ } finally { await handle.close() }
49
+ }
50
+
51
+ async function directory(path: string, create: boolean): Promise<void> {
52
+ if (create) await mkdir(path).catch(error => { if (error.code !== 'EEXIST') throw error })
53
+ const stat = await lstat(path)
54
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new RequestError(403, 'transcription-storage-path')
55
+ }
56
+
57
+ async function taskNames(path: string, limit: number): Promise<string[]> {
58
+ const names: string[] = []
59
+ for await (const entry of await opendir(path)) { names.push(entry.name); if (names.length >= limit) break }
60
+ return names
61
+ }
62
+
63
+ async function writeJson(path: string, value: unknown, exclusive = false): Promise<void> {
64
+ const text = JSON.stringify(value)
65
+ if (Buffer.byteLength(text) > MAX_RESULT) throw new RequestError(413, 'transcription-result-size')
66
+ const target = exclusive ? path : `${path}.${randomUUID()}.tmp`
67
+ const file = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
68
+ try { await file.writeFile(text); await file.sync() }
69
+ catch (error) { await unlink(target).catch(() => undefined); throw error }
70
+ finally { await file.close() }
71
+ if (!exclusive) {
72
+ try { await rename(target, path) }
73
+ catch (error) { await unlink(target).catch(() => undefined); throw error }
74
+ }
75
+ }
76
+
77
+ /** Recognition is an immutable candidate. Only a later, separate authoring action may adopt it. */
78
+ export function createDocumentTranscriptions({ root, editor, audio, speech, services }: Options): DocumentTranscriptions & { close(): Promise<void> } {
79
+ const active = new Map<string, Active>()
80
+ const admitting = new Map<string, { document: string, documentId: string, member: string, intent: string, controller: AbortController, finished: Promise<void> }>()
81
+ let closed = false
82
+ const authorize = (document: string, member: Member) => {
83
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'transcription-permission-denied')
84
+ if (closed) throw new RequestError(503, 'transcription-closed')
85
+ // Cancelling already-admitted work needs file permission, not valid editable JSON.
86
+ return editor.readableFile(editor.scope(document), document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, member)
87
+ }
88
+ const opened = (document: string, member: Member) => {
89
+ authorize(document, member)
90
+ const current = readAuthoringDocument(editor, ctxOf(document, member))
91
+ return { ...current, binding: { documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) } }
92
+ }
93
+ const servicesOf = (document: string, source: CanonicalSource) => services ? services(document, source) : Promise.resolve({ audio, speech })
94
+ const capabilityOf = async (source: CanonicalSource, resolved: TranscriptionServices) => {
95
+ const engines = resolved.speech ? (await resolved.speech.engines()).filter(engine => engine.stt) : []
96
+ const available = Boolean(resolved.audio && engines.length)
97
+ const unknown = !available && resolved.findings?.some(finding => finding.severity === 'error')
98
+ return { source, available, status: available ? 'available' as const : unknown ? 'unknown' as const : 'unavailable' as const, ...unknown ? { reason: 'services-unavailable' as const } : !resolved.audio ? { reason: 'audio-unavailable' as const } : !resolved.speech ? { reason: 'speech-unavailable' as const } : !engines.length ? { reason: 'engine-unavailable' as const } : {}, verification: 'configuration-only' as const, ...resolved.findings?.length ? { findings: resolved.findings } : {}, engines, range: 'whole-asset' as const, limits: { audioBytes: MAX_AUDIO, durationMs: 600_000, tasks: MAX_TASKS, requestBytes: TRANSCRIPTION_REQUEST_MAX_BYTES } }
99
+ }
100
+ const taskId = (document: string, documentId: string, member: Member, intent: string) => hash(JSON.stringify([document, documentId, member.id, intent]))
101
+ const storage = async (document: string, member: Member, id: string, create = false) => {
102
+ if (!idValid(id)) throw new RequestError(400, 'transcription-id-invalid')
103
+ const path = join(dirname(authorize(document, member)), 'transcriptions')
104
+ await directory(path, create)
105
+ const target = join(path, id)
106
+ if (create && !(await lstat(target).catch(() => undefined)) && (await taskNames(path, MAX_TASKS)).length >= MAX_TASKS) throw new RequestError(503, 'transcription-intent-capacity')
107
+ await directory(target, create)
108
+ return target
109
+ }
110
+ const load = async (document: string, member: Member, id: string): Promise<{ record: RecordEntry, directory: string } | undefined> => {
111
+ try {
112
+ const target = await storage(document, member, id)
113
+ const text = new TextDecoder().decode(await readBounded(join(target, 'task.json'), MAX_RESULT))
114
+ let record: RecordEntry
115
+ try { record = JSON.parse(text) } catch { throw new RequestError(409, 'transcription-record-invalid') }
116
+ if (!isRecord(record) || record.format !== 1 || record.document !== document || typeof record.documentId !== 'string' || typeof record.member !== 'string' || !intentValid(record.intent)
117
+ || hash(JSON.stringify([record.document, record.documentId, record.member, record.intent])) !== id
118
+ || record.task && (!isRecord(record.task) || record.task.kind !== 'transcription' || record.task.id !== id || record.task.document !== document || record.task.intent !== record.intent || typeof record.task.createdAt !== 'string'
119
+ || !isRecord(record.task.source) || record.task.source.documentId !== record.documentId || typeof record.task.source.version !== 'string' || !/^[a-f\d]{20}$/.test(record.task.source.version) || typeof record.task.source.fingerprint !== 'string' || !/^[a-f\d]{16}$/.test(record.task.source.fingerprint)
120
+ || !isRecord(record.task.audio) || typeof record.task.audio.assetId !== 'string' || !idValid(record.task.audio.sha256) || !Number.isSafeInteger(record.task.sequence) || record.task.sequence < 0
121
+ || !['queued', 'running', 'cancelling', 'cancelled', 'done', 'failed'].includes(record.task.state) || !isRecord(record.task.progress) || !['queued', 'recognizing', 'saving', 'finished'].includes(record.task.progress.phase)
122
+ || record.task.state === 'done' && !isRecord(record.task.result)
123
+ || record.task.result !== undefined && (!isRecord(record.task.result) || record.task.result.transcriptionId !== id || !idValid(record.task.result.revision)))) throw new RequestError(409, 'transcription-record-invalid')
124
+ return { record, directory: target }
125
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error }
126
+ }
127
+ const persist = (entry: Active) => {
128
+ const snapshot = structuredClone(entry.record)
129
+ entry.saving = entry.saving.catch(() => undefined).then(() => writeJson(join(entry.directory, 'task.json'), snapshot))
130
+ return entry.saving
131
+ }
132
+ const change = (entry: Active) => { entry.record.task!.sequence++; return persist(entry) }
133
+ type Observation = { current?: ReturnType<typeof opened>, audio: Map<string, Promise<boolean>> }
134
+ const observe = (document: string, member: Member): Observation => {
135
+ try { return { current: opened(document, member), audio: new Map() } }
136
+ catch (error) { if (error instanceof RequestError && error.status === 422) return { audio: new Map() }; throw error }
137
+ }
138
+ const present = async (document: string, member: Member, task: TranscriptionTask, observation?: Observation): Promise<TranscriptionTask> => {
139
+ authorize(document, member)
140
+ const shown = structuredClone(task)
141
+ // A journal from an interrupted owner is not permission to repeat a spending request.
142
+ if (!active.has(task.id) && ['queued', 'running', 'cancelling'].includes(shown.state)) { shown.state = 'failed'; shown.error = 'transcription-interrupted' }
143
+ try {
144
+ const current = observation ? observation.current : opened(document, member)
145
+ if (!current) { shown.current = false; shown.staleReason = 'document-unavailable'; return shown }
146
+ shown.current = sameSource(current.binding, shown.source)
147
+ if (!shown.current) shown.staleReason = 'document-changed'
148
+ else {
149
+ const key = JSON.stringify([shown.audio.assetId, shown.audio.sha256])
150
+ let checked = observation?.audio.get(key)
151
+ if (!checked) {
152
+ checked = (async () => {
153
+ const asset = current.content.assets[shown.audio.assetId]
154
+ const path = asset?.kind === 'audio' && productionAssetFile(join(root, document), asset.location)
155
+ const file = path && editor.readableFile(editor.scope(document), relative(root, path).split('\\').join('/'), member)
156
+ return Boolean(file && hash(await readBounded(file, MAX_AUDIO)) === shown.audio.sha256)
157
+ })().catch(() => false)
158
+ observation?.audio.set(key, checked)
159
+ }
160
+ if (!await checked) { shown.current = false; shown.staleReason = 'audio-changed' }
161
+ }
162
+ if (!observation && !sameSource(opened(document, member).binding, shown.source)) { shown.current = false; shown.staleReason = 'document-changed' }
163
+ } catch (error) {
164
+ if (!(error instanceof RequestError) || error.status !== 422) throw error
165
+ shown.current = false; shown.staleReason = 'document-unavailable'
166
+ }
167
+ return shown
168
+ }
169
+ const resolveId = (document: string, member: Member, query: { id: string } | { intent: string }) => {
170
+ authorize(document, member)
171
+ if ('id' in query) { if (!idValid(query.id)) throw new RequestError(400, 'transcription-id-invalid'); return query.id }
172
+ if (!intentValid(query.intent)) throw new RequestError(400, 'transcription-intent-invalid')
173
+ const running = [...active.values()].find(entry => entry.record.document === document && entry.record.member === member.id && entry.record.intent === query.intent)
174
+ if (running) return running.record.task!.id
175
+ const pending = [...admitting.entries()].find(([, entry]) => entry.document === document && entry.member === member.id && entry.intent === query.intent)
176
+ if (pending) return pending[0]
177
+ try { return taskId(document, opened(document, member).content.id, member, query.intent) }
178
+ catch (error) { if (error instanceof RequestError && error.status === 422) throw new RequestError(409, 'transcription-task-id-required'); throw error }
179
+ }
180
+ const lookup = async (document: string, member: Member, query: { id: string } | { intent: string }) => {
181
+ const id = resolveId(document, member, query)
182
+ const entry = active.get(id) ?? await load(document, member, id)
183
+ if (!entry) throw new RequestError(404, 'transcription-task-missing')
184
+ if (entry.record.document !== document) throw new RequestError(409, 'transcription-document-changed')
185
+ return { id, entry }
186
+ }
187
+ const inspect = async (document: string, member: Member, request: DocumentTranscriptionRequest, signal?: AbortSignal) => {
188
+ const current = opened(document, member)
189
+ if (!sameSource(current.binding, request.source)) throw new RequestError(409, 'document-version-changed')
190
+ const asset = current.content.assets[request.assetId]
191
+ if (!asset || asset.kind !== 'audio') throw new RequestError(422, 'transcription-audio-required')
192
+ const path = productionAssetFile(join(root, document), asset.location)
193
+ if (!path) throw new RequestError(422, 'production.asset-path')
194
+ const file = editor.readableFile(editor.scope(document), relative(root, path).split('\\').join('/'), member)
195
+ const bytes = await readBounded(file, MAX_AUDIO)
196
+ if (!bytes.length || hash(bytes) !== request.audioSha256 || asset.fingerprint !== undefined && asset.fingerprint !== `sha256:${request.audioSha256}`) throw new RequestError(409, 'transcription-audio-changed')
197
+ const resolved = await servicesOf(document, current.binding)
198
+ if (!resolved.audio) throw new RequestError(503, 'transcription-audio-unavailable')
199
+ const { inspection } = await resolved.audio.inspectBytes(asset.id, bytes, signal)
200
+ signal?.throwIfAborted()
201
+ if (inspection.sha256 !== request.audioSha256) throw new RequestError(409, 'transcription-audio-changed')
202
+ if (!Number.isFinite(inspection.durationMs) || inspection.durationMs <= 0 || inspection.durationMs > 600_000) throw new RequestError(422, 'production.asset-duration')
203
+ if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
204
+ return { bytes, inspection, services: resolved }
205
+ }
206
+ const capabilities: DocumentTranscriptions['capabilities'] = async (document, member) => {
207
+ const current = opened(document, member)
208
+ const capability = await capabilityOf(current.binding, await servicesOf(document, current.binding))
209
+ if (!sameSource(opened(document, member).binding, current.binding)) throw new RequestError(409, 'document-version-changed')
210
+ return capability
211
+ }
212
+ /** Everything start checks before it stores a task; a replay of the same intent returns its recorded task. */
213
+ const admission = async (document: string, request: DocumentTranscriptionRequest, member: Member, signal?: AbortSignal) => {
214
+ if (!validateDocumentTranscriptionRequest(request)) throw new RequestError(400, 'transcription-request-invalid')
215
+ if (Buffer.byteLength(JSON.stringify(request)) > TRANSCRIPTION_REQUEST_MAX_BYTES) throw new RequestError(413, 'transcription-request-size')
216
+ const current = opened(document, member), id = taskId(document, current.content.id, member, request.intent), payload = hash(JSON.stringify([request.source.documentId, request.source.version, request.source.fingerprint, request.assetId, request.audioSha256, request.engine, request.lang ?? null, request.hints ?? []]))
217
+ const previous = active.get(id) ?? await load(document, member, id)
218
+ if (previous) {
219
+ if (previous.record.cancelled) throw new RequestError(409, 'transcription-intent-cancelled')
220
+ if (previous.record.payload !== payload) throw new RequestError(409, 'transcription-intent-reused')
221
+ const task = previous.record.task
222
+ if (!task) throw new RequestError(409, 'transcription-record-invalid')
223
+ return { current, id, payload, previous: task }
224
+ }
225
+ const input = await inspect(document, member, request, signal)
226
+ const capability = await capabilityOf(request.source, input.services)
227
+ const engine = capability.engines.find(engine => engine.id === request.engine)
228
+ if (!engine) {
229
+ // Asking for an engine that is configured but does not recognize speech, or is not configured, is a bad request; only unresolved speech services may come back.
230
+ const speech = capability.status === 'unknown' ? undefined : input.services.speech
231
+ if (!speech) throw new RequestError(503, 'transcription-engine-unavailable')
232
+ const named = (await speech.engines()).some(own => own.id === request.engine)
233
+ throw new RequestError(422, named ? 'transcription-engine-unsupported' : 'transcription-engine-unknown', { engine: request.engine, engines: capability.engines.map(own => own.id) })
234
+ }
235
+ signal?.throwIfAborted()
236
+ if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
237
+ if ([...active.values()].some(entry => entry.record.document === document) || [...admitting.values()].some(entry => entry.document === document)) throw new RequestError(409, 'transcription-busy')
238
+ return { current, id, payload, input, engine }
239
+ }
240
+ const run = async (entry: Active, request: DocumentTranscriptionRequest, engine: SpeechEngineInfo, input: Awaited<ReturnType<typeof inspect>>) => {
241
+ const task = entry.record.task!, signal = entry.controller.signal
242
+ try {
243
+ await setImmediate()
244
+ signal.throwIfAborted()
245
+ task.state = 'running'; task.startedAt = now(); task.progress.phase = 'recognizing'; await change(entry)
246
+ signal.throwIfAborted()
247
+ const transcript = await input.services.speech!.transcribe({ engine: engine.id, audio: new Blob([new Uint8Array(input.bytes)], { type: input.inspection.mediaType }), lang: request.lang, hints: request.hints }, signal)
248
+ signal.throwIfAborted()
249
+ const result = normalizeTranscript(transcript, input.inspection.durationMs, task.id)
250
+ task.progress.phase = 'saving'; await change(entry)
251
+ signal.throwIfAborted()
252
+ const candidate = { transcriptionId: task.id, createdAt: now(), source: request.source, audio: { assetId: request.assetId, sha256: request.audioSha256, sourceRange: { startMs: 0, endMs: input.inspection.durationMs } }, request: { engine: engine.id, provider: engine.provider, local: engine.local, ...request.lang ? { lang: request.lang } : {}, hints: request.hints ?? [], fingerprint: entry.record.payload! }, ...result }
253
+ const revision = hash(JSON.stringify(candidate))
254
+ await writeJson(join(entry.directory, `${revision}.json`), { ...candidate, revision }, true)
255
+ task.result = { transcriptionId: task.id, revision }
256
+ signal.throwIfAborted()
257
+ task.state = 'done'
258
+ } catch (error) {
259
+ task.state = signal.aborted ? 'cancelled' : 'failed'
260
+ if (!signal.aborted) task.error = error instanceof RequestError ? error.message : 'transcription-provider-failed'
261
+ } finally {
262
+ task.finishedAt = now(); task.progress.phase = 'finished'
263
+ await change(entry).catch(() => { task.state = 'failed'; task.error = 'transcription-storage-failed' })
264
+ // Retain the task record, not its audio bytes or AbortController, after completion.
265
+ active.delete(task.id)
266
+ }
267
+ }
268
+ return {
269
+ capabilities,
270
+ async list(document, member): Promise<TranscriptionListAnswer> {
271
+ const path = join(dirname(authorize(document, member)), 'transcriptions'), observation = observe(document, member)
272
+ let stored: string[] = []
273
+ try { await directory(path, false); stored = await taskNames(path, MAX_TASKS + 1) }
274
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
275
+ const all = [...new Set([...active.values()].filter(entry => entry.record.document === document).map(entry => entry.record.task!.id).concat(stored.sort()))]
276
+ const ids = all.slice(0, MAX_TASKS), answer: TranscriptionListAnswer = { tasks: [], issues: [], ...all.length > MAX_TASKS ? { truncated: true } : {} }
277
+ let index = 0, bytes = 0
278
+ await Promise.all(Array.from({ length: Math.min(4, ids.length) }, async () => {
279
+ while (index < ids.length) {
280
+ const id = ids[index++]!
281
+ if (!idValid(id)) { answer.issues.push({ id, code: 'transcription-storage-entry-invalid' }); continue }
282
+ try {
283
+ const entry = active.get(id) ?? await load(document, member, id)
284
+ if (!entry) { if (admitting.get(id)?.document !== document) answer.issues.push({ id, code: 'transcription-record-missing' }); continue }
285
+ if (!entry.record.task) { if (!entry.record.cancelled) answer.issues.push({ id, code: 'transcription-record-invalid' }); continue }
286
+ const task = await present(document, member, entry.record.task, observation), size = Buffer.byteLength(JSON.stringify(task))
287
+ if (bytes + size > MAX_RESULT) { answer.truncated = true; answer.issues.push({ id, code: 'transcription-list-size' }); continue }
288
+ bytes += size; answer.tasks.push(task)
289
+ } catch (error) {
290
+ if (!(error instanceof RequestError)) throw error
291
+ answer.issues.push({ id, code: error.message })
292
+ }
293
+ }
294
+ }))
295
+ const latest = observe(document, member)
296
+ if (!latest.current || !observation.current || !sameSource(latest.current.binding, observation.current.binding)) for (const task of answer.tasks) {
297
+ task.current = false; task.staleReason = latest.current ? 'document-changed' : 'document-unavailable'
298
+ }
299
+ answer.tasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id))
300
+ answer.issues.sort((a, b) => a.id.localeCompare(b.id))
301
+ // Account for issues and JSON framing too, without repeatedly serializing the whole list.
302
+ let size = Buffer.byteLength(JSON.stringify(answer))
303
+ if (size > MAX_RESULT) {
304
+ answer.truncated = true; size = Buffer.byteLength(JSON.stringify(answer))
305
+ while (size > MAX_RESULT && (answer.tasks.length || answer.issues.length)) {
306
+ const items = answer.tasks.length ? answer.tasks : answer.issues
307
+ const removed = items.pop()!
308
+ size -= Buffer.byteLength(JSON.stringify(removed)) + (items.length ? 1 : 0)
309
+ }
310
+ }
311
+ return answer
312
+ },
313
+ async checkStart(document, request, member, signal) { await admission(document, request, member, signal) },
314
+ async checkCancel(document, query, member) {
315
+ const id = resolveId(document, member, query)
316
+ if (admitting.has(id)) return undefined
317
+ const running = active.get(id), entry = running ?? await load(document, member, id)
318
+ if (!entry) { if ('intent' in query) return undefined; throw new RequestError(404, 'transcription-task-missing') }
319
+ const task = entry.record.task
320
+ // A journal left queued or running by an interrupted owner is still corrected by cancel.
321
+ if (task && (running ? task.state === 'queued' || task.state === 'running' : ['queued', 'running', 'cancelling'].includes(task.state))) return undefined
322
+ return { intent: entry.record.intent, state: task?.state ?? 'cancelled', ...task ? { task: await present(document, member, task) } : {} }
323
+ },
324
+ async start(document, request, member, signal) {
325
+ const admitted = await admission(document, request, member, signal)
326
+ if (admitted.previous) return present(document, member, admitted.previous)
327
+ const { current, id, payload, input, engine } = admitted
328
+ const controller = new AbortController()
329
+ let finished!: () => void
330
+ admitting.set(id, { document, documentId: current.content.id, member: member.id, intent: request.intent, controller, finished: new Promise<void>(resolve => { finished = resolve }) })
331
+ try {
332
+ const target = await storage(document, member, id, true)
333
+ if (!sameSource(opened(document, member).binding, request.source)) throw new RequestError(409, 'document-version-changed')
334
+ const task: TranscriptionTask = { kind: 'transcription', id, intent: request.intent, document, source: request.source, audio: { assetId: request.assetId, sha256: request.audioSha256 }, sequence: 0, state: 'queued', current: true, progress: { phase: 'queued' }, createdAt: now() }
335
+ const entry: Active = { record: { format: 1, document, documentId: current.content.id, member: member.id, intent: request.intent, payload, task }, directory: target, controller, saving: Promise.resolve() }
336
+ try { await writeJson(join(target, 'task.json'), entry.record, true) }
337
+ catch (error) {
338
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
339
+ const existing = await load(document, member, id)
340
+ if (existing?.record.cancelled) throw new RequestError(409, 'transcription-intent-cancelled')
341
+ if (existing?.record.payload !== payload || !existing.record.task) throw new RequestError(409, 'transcription-intent-reused')
342
+ return present(document, member, existing.record.task)
343
+ }
344
+ throw error
345
+ }
346
+ if (closed || signal?.aborted || controller.signal.aborted) { controller.abort(); task.state = 'cancelled'; task.finishedAt = now(); task.progress.phase = 'finished'; await change(entry); return structuredClone(task) }
347
+ active.set(id, entry)
348
+ const accepted = structuredClone(task)
349
+ entry.finished = run(entry, structuredClone(request), engine, input)
350
+ void entry.finished.catch(() => undefined)
351
+ return accepted
352
+ } finally { admitting.delete(id); finished() }
353
+ },
354
+ async get(document, query, member) {
355
+ const { entry } = await lookup(document, member, query)
356
+ if (!entry.record.task) throw new RequestError(409, 'transcription-intent-cancelled')
357
+ return present(document, member, entry.record.task)
358
+ },
359
+ async readResult(document, id, revision, member) {
360
+ if (!idValid(revision)) throw new RequestError(400, 'transcription-revision-invalid')
361
+ const { entry } = await lookup(document, member, { id })
362
+ if (entry.record.task?.result?.revision !== revision) throw new RequestError(404, 'transcription-result-missing')
363
+ const candidate = JSON.parse(new TextDecoder().decode(await readBounded(join(entry.directory, `${revision}.json`), MAX_RESULT))) as TranscriptionRevision
364
+ const { revision: recorded, ...content } = candidate
365
+ if (candidate.transcriptionId !== id || recorded !== revision || hash(JSON.stringify(content)) !== revision) throw new RequestError(409, 'transcription-result-changed')
366
+ return candidate
367
+ },
368
+ async verifyResult(document, id, revision, source, member) {
369
+ const candidate = await this.readResult(document, id, revision, member)
370
+ if (!isRecord(candidate.source) || candidate.source.documentId !== source.documentId || !isRecord(candidate.audio) || typeof candidate.audio.assetId !== 'string' || !idValid(candidate.audio.sha256)
371
+ || !isRecord(candidate.audio.sourceRange) || !isRecord(candidate.result) || typeof candidate.result.text !== 'string' || !Array.isArray(candidate.result.segments)
372
+ || !candidate.result.segments.length || candidate.result.segments.length > 10_000
373
+ || candidate.result.segments.some(segment => !isRecord(segment) || typeof segment.id !== 'string' || !segment.id || segment.id.length > 200 || typeof segment.text !== 'string')
374
+ || new Set(candidate.result.segments.map(segment => segment.id)).size !== candidate.result.segments.length) throw new RequestError(422, 'transcription-result-invalid')
375
+ const { inspection } = await inspect(document, member, { intent: 'result-verification', source, assetId: candidate.audio.assetId, audioSha256: candidate.audio.sha256, engine: '', confirmed: true })
376
+ if (candidate.audio.sourceRange.startMs !== 0 || candidate.audio.sourceRange.endMs !== inspection.durationMs) throw new RequestError(409, 'transcription-audio-changed')
377
+ return candidate
378
+ },
379
+ async cancel(document, query, member) {
380
+ const id = resolveId(document, member, query)
381
+ const pending = admitting.get(id)
382
+ pending?.controller.abort()
383
+ let entry = active.get(id) ?? await load(document, member, id)
384
+ if (!entry) {
385
+ if (!('intent' in query)) throw new RequestError(404, 'transcription-task-missing')
386
+ const current = opened(document, member)
387
+ const target = await storage(document, member, id, true)
388
+ const record: RecordEntry = { format: 1, document, documentId: current.content.id, member: member.id, intent: query.intent, cancelled: true }
389
+ try { await writeJson(join(target, 'task.json'), record, true) }
390
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') return this.cancel(document, query, member); throw error }
391
+ return { intent: query.intent, state: 'cancelled' }
392
+ }
393
+ const task = entry.record.task
394
+ if (task && ['queued', 'running', 'cancelling'].includes(task.state)) {
395
+ const running = active.get(id)
396
+ task.state = running ? task.state !== 'queued' ? 'cancelling' : 'cancelled' : pending ? 'cancelled' : 'failed'
397
+ running?.controller.abort()
398
+ if (running) await change(running)
399
+ else { if (!pending) task.error = 'transcription-interrupted'; task.finishedAt = now(); task.sequence++; await writeJson(join(entry.directory, 'task.json'), entry.record) }
400
+ }
401
+ return { intent: entry.record.intent, state: task?.state ?? 'cancelled', ...task ? { task: await present(document, member, task) } : {} }
402
+ },
403
+ async close() {
404
+ closed = true
405
+ const entries = [...active.values()]
406
+ for (const entry of entries) entry.controller.abort()
407
+ const admissions = [...admitting.values()]
408
+ for (const entry of admissions) entry.controller.abort()
409
+ await Promise.allSettled([...entries.map(entry => entry.finished), ...admissions.map(entry => entry.finished)])
410
+ active.clear()
411
+ },
412
+ }
413
+ }
414
+
415
+ export function normalizeTranscript(transcript: Transcript, durationMs: number, id: string): Pick<TranscriptionRevision, 'result' | 'timing' | 'warnings'> {
416
+ if (!isRecord(transcript) || typeof transcript.text !== 'string' || typeof transcript.lang !== 'string' || !Array.isArray(transcript.segments) || transcript.segments.length > 10_000 || Buffer.byteLength(JSON.stringify(transcript)) > MAX_RESULT) throw new RequestError(422, 'transcription-result-invalid')
417
+ if (!transcript.segments.length && transcript.text.length) return { result: { lang: transcript.lang, text: transcript.text, segments: [{ id: `${id}:1`, text: transcript.text }] }, timing: 'none', warnings: ['transcription-timing-unavailable'] }
418
+ const warnings = new Set<string>()
419
+ const range = (start: unknown, end: unknown) => typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end) && start >= 0 && Math.round(end * 1000) > Math.round(start * 1000) && Math.round(end * 1000) <= durationMs ? { startMs: Math.round(start * 1000), endMs: Math.round(end * 1000) } : undefined
420
+ let previousStart = 0
421
+ const segments = transcript.segments.map((segment, index) => {
422
+ if (!isRecord(segment) || typeof segment.text !== 'string' || !Array.isArray(segment.words) || segment.words.length > 100_000) throw new RequestError(422, 'transcription-result-invalid')
423
+ let timing = range(segment.start, segment.end)
424
+ if (timing && timing.startMs < previousStart) timing = undefined
425
+ if (timing) previousStart = timing.startMs
426
+ if (!timing) warnings.add('transcription-timing-unavailable')
427
+ let previousWordEnd = timing?.startMs ?? 0
428
+ const words = segment.words.flatMap(word => {
429
+ const at = isRecord(word) && range(word.start, word.end)
430
+ if (!at || typeof word.text !== 'string' || !word.text.trim() || !timing || at.startMs < previousWordEnd || at.endMs > timing.endMs) { warnings.add('transcription-timing-unavailable'); return [] }
431
+ previousWordEnd = at.endMs
432
+ return [{ text: word.text, ...at }]
433
+ })
434
+ return { id: `${id}:${index + 1}`, text: segment.text, ...timing, ...words.length ? { words } : {} }
435
+ })
436
+ return { result: { lang: transcript.lang, text: transcript.text, segments }, timing: segments.some(segment => segment.words?.length) ? 'words' : segments.some(segment => segment.startMs !== undefined) ? 'segments' : 'none', warnings: [...warnings] }
437
+ }
438
+
439
+ /** Thin HTTP adapter; source/permission/idempotency rules live in the shared service. */
440
+ export function documentTranscriptionRoutes(service: DocumentTranscriptions): Record<string, Route> {
441
+ const query = (value: unknown): { id: string } | { intent: string } => {
442
+ if (!isRecord(value) || Object.keys(value).length !== 1) throw new RequestError(400, 'transcription-query-invalid')
443
+ if (typeof value.id === 'string' && idValid(value.id)) return { id: value.id }
444
+ if (typeof value.intent === 'string' && intentValid(value.intent)) return { intent: value.intent }
445
+ throw new RequestError(400, 'transcription-query-invalid')
446
+ }
447
+ const guarded = (work: () => Promise<unknown>) => catchRequestErrors(async () => {
448
+ try { return jsonResponse(await work()) }
449
+ catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error }
450
+ })
451
+ return {
452
+ 'document/transcription/capabilities': { role: 'editor', handle: (request, ctx) => guarded(async () => {
453
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
454
+ return service.capabilities(ctx.document, ctx.member)
455
+ }) },
456
+ 'document/transcription': { role: 'editor', maxBody: TRANSCRIPTION_REQUEST_MAX_BYTES, handle: (request, ctx) => guarded(async () => {
457
+ if (request.method === 'GET') { const params = new URL(request.url).searchParams; params.delete('document'); return params.size ? service.get(ctx.document, query(Object.fromEntries(params)), ctx.member) : service.list(ctx.document, ctx.member) }
458
+ if (request.method !== 'POST') throw new RequestError(405, 'GET or POST')
459
+ return service.start(ctx.document, await request.json().catch(() => undefined), ctx.member, request.signal)
460
+ }) },
461
+ 'document/transcription/result': { role: 'editor', handle: (request, ctx) => guarded(async () => {
462
+ if (request.method !== 'GET') throw new RequestError(405, 'GET')
463
+ const params = new URL(request.url).searchParams
464
+ return service.readResult(ctx.document, params.get('id') ?? '', params.get('revision') ?? '', ctx.member)
465
+ }) },
466
+ 'document/transcription/cancel': { role: 'editor', maxBody: 4 << 10, handle: (request, ctx) => guarded(async () => {
467
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
468
+ return service.cancel(ctx.document, query(await request.json().catch(() => undefined)), ctx.member)
469
+ }) },
470
+ }
471
+ }