@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2

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 (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -0,0 +1,473 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open } from 'node:fs/promises'
4
+ import { basename, join, relative } from 'node:path'
5
+ import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_AUDIO_MAX_BYTES, DOCUMENT_AUDIO_REQUEST_MAX_BYTES, fnv1a64, hasRole, isRecord, jsonResponse, ProductionAudioRefusal, recordingSourceRange, recordingStepIssue, validateDocument } from '@lincyaw/kernel'
6
+ import type { Asset, CanonicalSource, DocumentRecordingAdoption, VerifiedDocumentRecordingAdoption, DocumentAudioAuthoring, DocumentAudioCapabilities, DocumentAudioImport, DocumentAudioInspectionAnswer, DocumentChangeAnswer, DocumentMediaClipEdit, DocumentMediaClipValidation, DocumentMediaClipValidationAnswer, DocumentWorkspaceAudioImport, Finding, Member, ProductionAudioAuthoring, RequestContext, Route, TeachingDocument, VerifiedDocumentAudioImport, VerifiedDocumentMediaClipChange, WorkspaceAudioInspectionAnswer } from '@lincyaw/kernel'
7
+ import { productionAssetFile, validateDocumentRecordingAdoption, validateDocumentAudioInspect, validateDocumentWorkspaceAudioImport, validateDocumentWorkspaceAudioInspect, validateDocumentMediaClipValidation, validateDocumentMediaClipEdit, validateDocumentProductionPlanQuery } from '@lincyaw/kernel/node'
8
+ import { readAuthoringDocument } from './document-editor.ts'
9
+ import { catchRequestErrors, RequestError } from './errors.ts'
10
+ import type { Revisions } from './revisions.ts'
11
+ import type { SourceEditor, SourceTransactionHistory } from './source-editor.ts'
12
+
13
+ export interface DocumentAudioServices { audio?: ProductionAudioAuthoring, findings?: Finding[] }
14
+ export interface DocumentAudioAuthoringOptions extends DocumentAudioServices {
15
+ root: string
16
+ editor: SourceEditor
17
+ revisions?: Revisions
18
+ reviewRequired?: boolean
19
+ services?(document: string, source: CanonicalSource): Promise<DocumentAudioServices>
20
+ }
21
+ interface Receipt { signature: string, source?: CanonicalSource, version: string, files: DocumentChangeAnswer['files'], commit?: DocumentChangeAnswer['commit'], assetId?: string, inspection?: VerifiedDocumentAudioImport['inspection'], clip?: Pick<VerifiedDocumentMediaClipChange['verification'], 'production' | 'binding'>, recording?: Pick<VerifiedDocumentRecordingAdoption['verification'], 'stepId' | 'sha256' | 'durationMs' | 'sourceRange'> }
22
+ interface Operation { controller: AbortController, finished: Promise<unknown> }
23
+ const MAX_RECEIPTS = 64, MAX_CONCURRENT = 4
24
+ const header = (value: unknown): value is DocumentAudioImport => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && typeof value.baseDocumentVersion === 'string' && /^[\da-f]{20}$/.test(value.baseDocumentVersion)
25
+ const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex')
26
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
27
+ const scopeOf = (document: string) => ({ document, home: false })
28
+ const binding = (current: ReturnType<typeof readAuthoringDocument>): CanonicalSource => ({ documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) })
29
+ const titleOf = (name: string) => [...(name.replaceAll('\\', '/').split('/').at(-1) ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
30
+ const stamp = (stat: Awaited<ReturnType<Awaited<ReturnType<typeof open>>['stat']>>) => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':')
31
+
32
+ /** Owns the canonical transaction and receipts; plugin services only inspect actual media. */
33
+ export function createDocumentAudioAuthoring({ root, editor, revisions, audio, services, reviewRequired = false }: DocumentAudioAuthoringOptions): { service: DocumentAudioAuthoring, routes: Record<string, Route>, close(): Promise<void> } {
34
+ const receipts = new Map<string, Receipt>(), imports = new Map<string, { signature: string, source: CanonicalSource, promise: Promise<VerifiedDocumentAudioImport> }>(), operations = new Set<Operation>()
35
+ const clipChanges = new Map<string, { signature: string, promise: Promise<VerifiedDocumentMediaClipChange> }>()
36
+ const recordingChanges = new Map<string, { signature: string, promise: Promise<VerifiedDocumentRecordingAdoption> }>()
37
+ let closed = false
38
+ const authorize = (member: Member) => { if (!hasRole(member, 'editor')) throw new RequestError(403, 'audio-authoring-permission-denied') }
39
+ const available = () => { if (closed) throw new RequestError(503, 'audio-authoring-closed') }
40
+ const check = (signal?: AbortSignal) => { available(); signal?.throwIfAborted() }
41
+ const work = <T>(member: Member, signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T> => {
42
+ authorize(member); check(signal)
43
+ if (operations.size >= MAX_CONCURRENT) throw new RequestError(503, 'audio-authoring-busy')
44
+ const controller = new AbortController(), combined = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal
45
+ const operation: Operation = { controller, finished: Promise.resolve() }
46
+ operations.add(operation)
47
+ const finished = Promise.resolve().then(() => { check(combined); return run(combined) }).finally(() => operations.delete(operation))
48
+ operation.finished = finished
49
+ return finished
50
+ }
51
+ const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
52
+ const opened = (ctx: RequestContext, version?: string, source?: CanonicalSource) => {
53
+ const current = readAuthoringDocument(editor, ctx)
54
+ if (version !== undefined && current.source.version !== version || source && !sameSource(binding(current), source)) throw new RequestError(409, 'document-version-changed', { currentSource: binding(current) })
55
+ return current
56
+ }
57
+ const resolved = async (document: string, source: CanonicalSource, signal: AbortSignal) => {
58
+ check(signal)
59
+ const result = services ? await services(document, source) : { audio }
60
+ check(signal)
61
+ return result
62
+ }
63
+ const workspacePath = (document: string, path: string, member: Member) => {
64
+ editor.assertEditable(scopeOf(document), path, member)
65
+ if (path.split('/').some(part => part.startsWith('.'))) throw new RequestError(403, 'audio-file-path')
66
+ }
67
+ const reader = async (ctx: RequestContext, path: string, signal?: AbortSignal): Promise<Uint8Array> => {
68
+ signal?.throwIfAborted()
69
+ const file = editor.readableFile(scopeOf(ctx.document), path, ctx.member)
70
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
71
+ if (error.code === 'ELOOP') throw new RequestError(403, 'audio-file-path')
72
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw new RequestError(404, 'audio-file-missing')
73
+ throw error
74
+ })
75
+ try {
76
+ const before = await handle.stat(), named = await lstat(editor.readableFile(scopeOf(ctx.document), path, ctx.member))
77
+ if (!before.isFile() || before.dev !== named.dev || before.ino !== named.ino) throw new RequestError(403, 'audio-file-path')
78
+ if (!before.size || before.size > DOCUMENT_AUDIO_MAX_BYTES) throw new RequestError(413, 'production.asset-size')
79
+ const bytes = new Uint8Array(before.size + 1); let length = 0
80
+ while (length < bytes.length) {
81
+ signal?.throwIfAborted()
82
+ const read = await handle.read(bytes, length, bytes.length - length, length)
83
+ if (!read.bytesRead) break
84
+ length += read.bytesRead
85
+ }
86
+ const after = await handle.stat(), current = await lstat(editor.readableFile(scopeOf(ctx.document), path, ctx.member))
87
+ if (length !== before.size || stamp(before) !== stamp(after) || after.dev !== current.dev || after.ino !== current.ino) throw new RequestError(409, 'audio-file-changed')
88
+ signal?.throwIfAborted()
89
+ return bytes.slice(0, length)
90
+ } finally { await handle.close() }
91
+ }
92
+ const pathOf = (ctx: RequestContext, asset: Asset) => {
93
+ const file = productionAssetFile(join(root, ctx.document), asset.location)
94
+ if (!file) throw new RequestError(422, 'production.asset-path')
95
+ return relative(root, file).split('\\').join('/')
96
+ }
97
+ const inspect = async (ctx: RequestContext, asset: Asset, source: CanonicalSource, signal: AbortSignal) => {
98
+ if (asset.kind !== 'audio') throw new RequestError(422, 'production.asset-kind')
99
+ const [bytes, found] = await Promise.all([reader(ctx, pathOf(ctx, asset), signal), resolved(ctx.document, source, signal)])
100
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
101
+ if (asset.fingerprint && asset.fingerprint.replace(/^sha256:/, '').toLowerCase() !== hash(bytes)) throw new RequestError(409, 'production.asset-changed')
102
+ const { inspection } = await found.audio.inspectBytes(asset.id, bytes, signal)
103
+ check(signal); opened(ctx, source.version, source)
104
+ return inspection
105
+ }
106
+ const history = (ctx: RequestContext, content: TeachingDocument, origin: 'editor' | 'agent', validate?: () => Promise<void>): SourceTransactionHistory => ({
107
+ run: work => { const guarded = async () => { await validate?.(); return work() }; return revisions ? revisions.transaction(guarded) : guarded() },
108
+ commit: async paths => {
109
+ if (!revisions) return undefined
110
+ const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => Boolean(path))
111
+ if (ignored.length) throw new RequestError(409, 'document-files-ignored', { ignored })
112
+ return revisions.commitWhileLocked({ paths, summary: content.metadata.lang?.startsWith('en') ? 'Edit audio media' : '编辑音频素材', body: '', origin: { kind: origin, member: ctx.member } })
113
+ },
114
+ })
115
+ const remember = (key: string, receipt: Receipt) => { receipts.set(key, receipt); while (receipts.size > MAX_RECEIPTS) receipts.delete(receipts.keys().next().value!) }
116
+ const readback = async (ctx: RequestContext, id: string, receipt: Receipt): Promise<VerifiedDocumentAudioImport> => {
117
+ try {
118
+ const current = opened(ctx, receipt.version), assetId = receipt.assetId!, asset = current.content.assets[assetId], inspection = receipt.inspection!
119
+ if (!asset || asset.kind !== 'audio' || asset.fingerprint !== `sha256:${inspection.sha256}`) throw new RequestError(409, 'audio-readback-changed')
120
+ const path = pathOf(ctx, asset), bytes = await reader(ctx, path)
121
+ if (hash(bytes) !== inspection.sha256 || bytes.length !== inspection.byteLength || receipt.files.some(file => file.path === path ? file.version !== inspection.sha256.slice(0, 20) : editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'audio-readback-changed')
122
+ const verified = opened(ctx, receipt.version)
123
+ return { document: ctx.document, version: verified.source.version, fingerprint: fnv1a64(verified.source.content), content: verified.content, issues: verified.issues, changeId: id, assetId, inspection, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(verified), asset: { assetId, path, sha256: inspection.sha256, byteLength: bytes.length }, compiled: 'not-requested', runtime: 'not-requested' } }
124
+ } catch (error) {
125
+ let currentSource: CanonicalSource | undefined
126
+ try { currentSource = binding(opened(ctx)) } catch { /* The current document may be unavailable after a committed save. */ }
127
+ throw new RequestError(409, 'audio-readback-changed', { committed: true, changeId: id, assetId: receipt.assetId, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
128
+ }
129
+ }
130
+ const importKey = (ctx: RequestContext, id: string) => JSON.stringify(['audio', ctx.document, ctx.member.id, id])
131
+ const importSignature = (change: DocumentAudioImport, source: CanonicalSource, title: string, sha256: string, byteLength: number) => hash(JSON.stringify([change.id, source.documentId, change.baseDocumentVersion, source.fingerprint, title, sha256, byteLength]))
132
+ /** A known intent replays (receipt still current) or joins its flight; a different payload under the same ID is refused. */
133
+ const known = (ctx: RequestContext, signature: string, previous: Receipt | undefined, pending: { signature: string } | undefined) => {
134
+ if (!previous && !pending) return false
135
+ if ((previous ?? pending)!.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
136
+ if (previous) opened(ctx, previous.version)
137
+ return true
138
+ }
139
+ const once = (ctx: RequestContext, change: DocumentAudioImport, source: CanonicalSource, title: string, sha256: string, byteLength: number, origin: 'editor' | 'agent', run: () => Promise<VerifiedDocumentAudioImport>) => {
140
+ authorize(ctx.member); available()
141
+ if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
142
+ const key = importKey(ctx, change.id), signature = importSignature(change, source, title, sha256, byteLength)
143
+ const previous = receipts.get(key)
144
+ if (previous) {
145
+ if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
146
+ opened(ctx, previous.version)
147
+ return readback(ctx, change.id, previous)
148
+ }
149
+ const pending = imports.get(key)
150
+ if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
151
+ const promise = Promise.resolve().then(run).finally(() => { if (imports.get(key)?.promise === promise) imports.delete(key) })
152
+ imports.set(key, { signature, source, promise })
153
+ return promise
154
+ }
155
+ const importBytes = async (ctx: RequestContext, change: DocumentAudioImport, title: string, bytes: Uint8Array, source: CanonicalSource, found: DocumentAudioServices, origin: 'editor' | 'agent', signal: AbortSignal) => {
156
+ check(signal)
157
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
158
+ const current = opened(ctx, change.baseDocumentVersion, source), assetId = `audio_${randomUUID().replaceAll('-', '')}`, probed = await found.audio.inspectBytes(assetId, bytes, signal)
159
+ if (probed.inspection.sha256 !== hash(bytes) || probed.inspection.byteLength !== bytes.length) throw new RequestError(409, 'audio-inspection-changed')
160
+ const location = `assets/audio/${assetId}.${probed.extension}`, path = ctx.document ? `${ctx.document}/${location}` : location, candidate = structuredClone(current.content)
161
+ candidate.assets[assetId] = { id: assetId, kind: 'audio', location, ...title ? { title } : {}, fingerprint: `sha256:${probed.inspection.sha256}`, mediaType: probed.inspection.mediaType }
162
+ const content = `${JSON.stringify(candidate, null, 2)}\n`
163
+ check(signal); opened(ctx, change.baseDocumentVersion, source)
164
+ const saved = await editor.transactionWithBinary(scopeOf(ctx.document), { path: current.source.path, content, base: current.source.version }, { path, content: bytes }, ctx.member, history(ctx, candidate, origin, async () => { check(signal) }))
165
+ const version = saved.versions.find(file => file.path === current.source.path)!.version
166
+ const receipt: Receipt = { signature: importSignature(change, source, title, hash(bytes), bytes.length), source, version, files: saved.versions, assetId, inspection: probed.inspection, ...saved.revision ? { commit: saved.revision } : {} }
167
+ remember(importKey(ctx, change.id), receipt)
168
+ // A committed write is verified even when its request disconnected or shutdown began meanwhile.
169
+ return readback(ctx, change.id, receipt)
170
+ }
171
+ const prepareClip = (ctx: RequestContext, request: DocumentMediaClipValidation) => {
172
+ const current = opened(ctx, request.source.version, request.source), candidate = structuredClone(current.content)
173
+ const clipId = 'clip' in request ? request.clip.id : request.removeClipId, before = candidate.production.mediaClips.find(clip => clip.id === clipId)
174
+ if ('removeClipId' in request) {
175
+ if (!before) throw new RequestError(404, 'document-media-clip-missing')
176
+ candidate.production.mediaClips = candidate.production.mediaClips.filter(clip => clip.id !== clipId)
177
+ } else {
178
+ if (!sameSource(request.binding.source, request.source)) throw new RequestError(409, 'production-binding-changed')
179
+ const index = candidate.production.mediaClips.findIndex(clip => clip.id === clipId)
180
+ if (index < 0) candidate.production.mediaClips.push(structuredClone(request.clip))
181
+ else candidate.production.mediaClips[index] = structuredClone(request.clip)
182
+ }
183
+ const checked = validateDocument(candidate)
184
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
185
+ const after = checked.document.production.mediaClips.find(clip => clip.id === clipId)
186
+ const answer: DocumentMediaClipValidationAnswer = {
187
+ document: ctx.document, source: request.source, operation: 'removeClipId' in request ? 'remove' : before ? 'update' : 'add', clipId,
188
+ ...before ? { before } : {}, ...after ? { after } : {},
189
+ impact: { stepIds: [...new Set([before?.attachment.stepId, after?.attachment.stepId].filter((id): id is string => id !== undefined))], assetIds: [...new Set([before?.assetId, after?.assetId].filter((id): id is string => id !== undefined))] },
190
+ issues: checked.issues, checked: { canonical: true, production: 'clip' in request ? 'validated' : 'not-required', ...'clip' in request ? { binding: request.binding } : {} },
191
+ }
192
+ const validate = async (signal: AbortSignal) => {
193
+ check(signal); opened(ctx, request.source.version, request.source)
194
+ if ('clip' in request) {
195
+ for (const id of new Set(checked.document!.production.mediaClips.map(clip => clip.assetId))) {
196
+ const asset = checked.document!.assets[id], file = asset && productionAssetFile(join(root, ctx.document), asset.location)
197
+ if (!file) throw new RequestError(422, 'production.asset-path')
198
+ editor.readableFile(scopeOf(ctx.document), relative(root, file).split('\\').join('/'), ctx.member)
199
+ }
200
+ const found = await resolved(ctx.document, request.source, signal)
201
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
202
+ await found.audio.validateClips(request.binding, checked.document!.production.mediaClips, checked.document!.assets, signal)
203
+ }
204
+ check(signal); opened(ctx, request.source.version, request.source)
205
+ }
206
+ return { current, candidate: checked.document, content: `${JSON.stringify(checked.document, null, 2)}\n`, answer, validate }
207
+ }
208
+ const clipReadback = (ctx: RequestContext, id: string, receipt: Receipt): VerifiedDocumentMediaClipChange => {
209
+ try {
210
+ const current = opened(ctx, receipt.version)
211
+ if (receipt.files.some(file => editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'media-clip-readback-changed')
212
+ return { document: ctx.document, version: current.source.version, fingerprint: fnv1a64(current.source.content), content: current.content, issues: current.issues, changeId: id, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(current), ...receipt.clip!, compiled: 'not-requested', runtime: 'not-requested' } }
213
+ } catch (error) {
214
+ let currentSource: CanonicalSource | undefined
215
+ try { currentSource = binding(opened(ctx)) } catch { /* Report the committed receipt even when its document is unavailable. */ }
216
+ throw new RequestError(409, 'media-clip-readback-changed', { committed: true, changeId: id, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
217
+ }
218
+ }
219
+ const clipIntent = (ctx: RequestContext, request: DocumentMediaClipEdit, origin: 'editor' | 'agent') => {
220
+ if (!validateDocumentMediaClipEdit(request)) throw new RequestError(400, 'document-media-clip-invalid')
221
+ if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
222
+ if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
223
+ const key = JSON.stringify(['clip', ctx.document, ctx.member.id, request.id]), source = request.source
224
+ const signature = hash(JSON.stringify([request.id, source.documentId, source.version, source.fingerprint, ...'clip' in request ? [request.binding.irRevision, request.binding.planId, request.binding.source.documentId, request.binding.source.version, request.binding.source.fingerprint, request.clip.id, request.clip.assetId, request.clip.sourceRange.startMs, request.clip.sourceRange.endMs, request.clip.attachment.stepId, request.clip.attachment.offsetMs, request.clip.volume, request.clip.muted ?? false] : [request.removeClipId]]))
225
+ return { key, source, signature }
226
+ }
227
+ const changeClip = (ctx: RequestContext, request: DocumentMediaClipEdit, origin: 'editor' | 'agent', signal: AbortSignal): Promise<VerifiedDocumentMediaClipChange> => {
228
+ const { key, source, signature } = clipIntent(ctx, request, origin)
229
+ const previous = receipts.get(key)
230
+ if (previous) {
231
+ if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
232
+ opened(ctx, previous.version)
233
+ return Promise.resolve(clipReadback(ctx, request.id, previous))
234
+ }
235
+ const pending = clipChanges.get(key)
236
+ if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
237
+ const promise = Promise.resolve().then(async () => {
238
+ check(signal)
239
+ const prepared = prepareClip(ctx, request)
240
+ const saved = await editor.transaction(scopeOf(ctx.document), [{ path: prepared.current.source.path, content: prepared.content, base: source.version }], ctx.member, history(ctx, prepared.candidate, origin, () => prepared.validate(signal)))
241
+ const receipt: Receipt = { signature, source, version: saved.versions[0]!.version, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {}, clip: { production: 'clip' in request ? 'validated-before-save' : 'not-required', ...'clip' in request ? { binding: request.binding } : {} } }
242
+ remember(key, receipt)
243
+ return clipReadback(ctx, request.id, receipt)
244
+ }).finally(() => { if (clipChanges.get(key)?.promise === promise) clipChanges.delete(key) })
245
+ clipChanges.set(key, { signature, promise })
246
+ return promise
247
+ }
248
+ const recordingReadback = async (ctx: RequestContext, id: string, receipt: Receipt): Promise<VerifiedDocumentRecordingAdoption> => {
249
+ try {
250
+ const current = opened(ctx, receipt.version), recording = receipt.recording!, assetId = receipt.assetId!, asset = current.content.assets[assetId]
251
+ const source = current.content.production.stepOverrides[recording.stepId]?.voiceSource
252
+ if (source?.type !== 'recording' || source.assetId !== assetId || JSON.stringify(source.sourceRange) !== JSON.stringify(recording.sourceRange) || !asset || asset.fingerprint !== `sha256:${recording.sha256}`) throw new RequestError(409, 'recording-readback-changed')
253
+ const bytes = await reader(ctx, pathOf(ctx, asset))
254
+ if (hash(bytes) !== recording.sha256 || receipt.files.some(file => editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'recording-readback-changed')
255
+ const verified = opened(ctx, receipt.version)
256
+ const range = recordingSourceRange(source, recording.durationMs)
257
+ if (!range) throw new RequestError(409, 'recording-readback-changed')
258
+ return { document: ctx.document, version: verified.source.version, fingerprint: fnv1a64(verified.source.content), content: verified.content, issues: verified.issues, changeId: id, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(verified), assetId, ...recording, sourceRange: range, usedDurationMs: range.endMs - range.startMs, compiled: 'not-requested', runtime: 'not-requested' } }
259
+ } catch (error) {
260
+ let currentSource: CanonicalSource | undefined
261
+ try { currentSource = binding(opened(ctx)) } catch { /* Preserve a committed receipt if the saved source is no longer readable. */ }
262
+ throw new RequestError(409, 'recording-readback-changed', { committed: true, changeId: id, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
263
+ }
264
+ }
265
+ const recordingIntent = (ctx: RequestContext, request: DocumentRecordingAdoption, origin: 'editor' | 'agent') => {
266
+ if (!validateDocumentRecordingAdoption(request)) throw new RequestError(400, 'document-recording-invalid')
267
+ if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
268
+ if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
269
+ return { key: JSON.stringify(['recording', ctx.document, ctx.member.id, request.id]), signature: hash(JSON.stringify([request.id, request.source.documentId, request.source.version, request.source.fingerprint, request.stepId, request.assetId, request.sha256, request.sourceRange])) }
270
+ }
271
+ const prepareRecording = async (ctx: RequestContext, request: DocumentRecordingAdoption, signal: AbortSignal) => {
272
+ check(signal)
273
+ const current = opened(ctx, request.source.version, request.source), step = current.content.steps[request.stepId], asset = current.content.assets[request.assetId]
274
+ if (!step) throw new RequestError(404, 'document-step-missing')
275
+ if (!asset) throw new RequestError(404, 'production.asset-missing')
276
+ const original = current.content.production.stepOverrides[step.id]?.voiceSource ?? current.content.production.sectionOverrides?.[step.sectionId]?.voiceSource ?? current.content.production.defaults.voiceSource
277
+ if (request.sourceRange && (original?.type !== 'recording' || original.assetId !== asset.id)) throw new RequestError(409, 'recording-source-changed')
278
+ const issue = recordingStepIssue(current.content, request.stepId)
279
+ if (issue) throw new RequestError(422, issue)
280
+ const inspection = await inspect(ctx, asset, request.source, signal)
281
+ if (inspection.sha256 !== request.sha256) throw new RequestError(409, 'production.asset-changed')
282
+ const voice = { type: 'recording' as const, assetId: asset.id, ...request.sourceRange ? { sourceRange: { ...request.sourceRange } } : {} }
283
+ if (!recordingSourceRange(voice, inspection.durationMs)) throw new RequestError(422, 'production.media-range')
284
+ const candidate = structuredClone(current.content)
285
+ candidate.assets[asset.id]!.fingerprint = `sha256:${inspection.sha256}`
286
+ candidate.production.stepOverrides[step.id] = { ...candidate.production.stepOverrides[step.id], voiceSource: voice }
287
+ const checked = validateDocument(candidate)
288
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
289
+ return { current, step, asset, inspection, voice, document: checked.document, content: `${JSON.stringify(checked.document, null, 2)}\n` }
290
+ }
291
+ const adoptRecording = (ctx: RequestContext, request: DocumentRecordingAdoption, origin: 'editor' | 'agent', signal: AbortSignal): Promise<VerifiedDocumentRecordingAdoption> => {
292
+ const { key, signature } = recordingIntent(ctx, request, origin)
293
+ const previous = receipts.get(key)
294
+ if (previous) {
295
+ if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
296
+ opened(ctx, previous.version)
297
+ return recordingReadback(ctx, request.id, previous)
298
+ }
299
+ const pending = recordingChanges.get(key)
300
+ if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
301
+ const promise = Promise.resolve().then(async () => {
302
+ const { current, step, asset, inspection, voice, document, content } = await prepareRecording(ctx, request, signal)
303
+ const validate = async () => {
304
+ check(signal); opened(ctx, request.source.version, request.source)
305
+ const actual = await inspect(ctx, asset, request.source, signal)
306
+ if (actual.sha256 !== request.sha256 || actual.durationMs !== inspection.durationMs) throw new RequestError(409, 'production.asset-changed')
307
+ if (!recordingSourceRange(voice, actual.durationMs)) throw new RequestError(422, 'production.media-range')
308
+ check(signal)
309
+ }
310
+ const saved = await editor.transaction(scopeOf(ctx.document), [{ path: current.source.path, content, base: request.source.version }], ctx.member, history(ctx, document, origin, validate))
311
+ const receipt: Receipt = { signature, source: request.source, version: saved.versions[0]!.version, files: saved.versions, assetId: asset.id, recording: { stepId: step.id, sha256: inspection.sha256, durationMs: inspection.durationMs, ...request.sourceRange ? { sourceRange: { ...request.sourceRange } } : {} }, ...saved.revision ? { commit: saved.revision } : {} }
312
+ remember(key, receipt)
313
+ return recordingReadback(ctx, request.id, receipt)
314
+ }).finally(() => { if (recordingChanges.get(key)?.promise === promise) recordingChanges.delete(key) })
315
+ recordingChanges.set(key, { signature, promise })
316
+ return promise
317
+ }
318
+ const workspaceImport = (document: string, request: DocumentWorkspaceAudioImport, member: Member) => {
319
+ authorize(member); available()
320
+ if (!validateDocumentWorkspaceAudioImport(request)) throw new RequestError(400, 'document-audio-import-invalid')
321
+ if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
322
+ workspacePath(document, request.file.path, member)
323
+ return { ctx: ctxOf(document, member), title: titleOf(basename(request.file.path)), change: { id: request.id, baseDocumentVersion: request.source.version } }
324
+ }
325
+ const service: DocumentAudioAuthoring = {
326
+ capabilities: (document, member) => work(member, undefined, async signal => {
327
+ const ctx = ctxOf(document, member), current = opened(ctx), source = binding(current), found = await resolved(document, source, signal)
328
+ opened(ctx, source.version, source)
329
+ const available = Boolean(found.audio), unknown = !available && Boolean(found.findings?.some(finding => finding.severity === 'error'))
330
+ return { source, available, status: available ? 'available' : unknown ? 'unknown' : 'unavailable', ...unknown ? { reason: 'services-unavailable' as const } : !available ? { reason: 'audio-unavailable' as const } : {}, verification: 'configuration-only', ...found.findings?.length ? { findings: found.findings } : {}, actions: { inspect: available, inspectWorkspace: available, importWorkspace: available && !reviewRequired, readProductionPlan: available, validateClipChange: true, changeClip: !reviewRequired, adoptRecording: available && !reviewRequired }, ...reviewRequired ? { importUnavailableReason: 'review-required' as const, clipChangeUnavailableReason: 'review-required' as const } : {}, clips: { upsert: available, remove: true, upsertRequires: 'current-production-binding' }, limits: { audioBytes: DOCUMENT_AUDIO_MAX_BYTES, requestBytes: DOCUMENT_AUDIO_REQUEST_MAX_BYTES, receipts: MAX_RECEIPTS, concurrent: MAX_CONCURRENT }, workspaceFileScope: 'same-document', idempotency: 'process-current-source', review: reviewRequired ? 'required-unsupported' : revisions ? 'locks-and-history' : 'locks-only' } satisfies DocumentAudioCapabilities
331
+ }),
332
+ inspect: (document, request, member, signal) => work(member, signal, async signal => {
333
+ if (!validateDocumentAudioInspect(request)) throw new RequestError(400, 'document-audio-query-invalid')
334
+ const ctx = ctxOf(document, member), current = opened(ctx, request.source.version, request.source), asset = current.content.assets[request.assetId]
335
+ if (!asset) throw new RequestError(404, 'production.asset-missing')
336
+ const inspection = await inspect(ctx, asset, request.source, signal)
337
+ return { document, version: request.source.version, fingerprint: request.source.fingerprint, inspection } satisfies DocumentAudioInspectionAnswer
338
+ }),
339
+ inspectWorkspace: (document, request, member, signal) => work(member, signal, async signal => {
340
+ if (!validateDocumentWorkspaceAudioInspect(request)) throw new RequestError(400, 'document-audio-query-invalid')
341
+ workspacePath(document, request.path, member)
342
+ const ctx = ctxOf(document, member)
343
+ opened(ctx, request.source.version, request.source)
344
+ const [bytes, found] = await Promise.all([reader(ctx, request.path, signal), resolved(document, request.source, signal)])
345
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
346
+ const probed = await found.audio.inspectBytes('workspace-audio-probe', bytes, signal)
347
+ const { assetId: _assetId, mediaId: _mediaId, ...inspection } = probed.inspection
348
+ if (inspection.sha256 !== hash(bytes) || inspection.byteLength !== bytes.length) throw new RequestError(409, 'audio-inspection-changed')
349
+ check(signal); opened(ctx, request.source.version, request.source)
350
+ return { document, source: request.source, file: { path: request.path, sha256: inspection.sha256, byteLength: inspection.byteLength }, inspection } satisfies WorkspaceAudioInspectionAnswer
351
+ }),
352
+ readProductionPlan: (document, request, member, signal) => work(member, signal, async signal => {
353
+ if (!validateDocumentProductionPlanQuery(request)) throw new RequestError(400, 'production-query-invalid')
354
+ const ctx = ctxOf(document, member)
355
+ opened(ctx, request.source.version, request.source)
356
+ const found = await resolved(document, request.source, signal)
357
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
358
+ const plan = await found.audio.readPlan(request, signal)
359
+ check(signal); opened(ctx, request.source.version, request.source)
360
+ if (!sameSource(plan.binding.source, request.source) || request.irRevision !== undefined && plan.binding.irRevision !== request.irRevision) throw new RequestError(409, 'production-binding-changed')
361
+ return plan
362
+ }),
363
+ checkWorkspaceImport(document, request, member, signal) {
364
+ const { ctx, title, change } = workspaceImport(document, request, member)
365
+ return work(member, signal, async signal => {
366
+ if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
367
+ const key = importKey(ctx, request.id)
368
+ if (known(ctx, importSignature(change, request.source, title, request.file.sha256, request.file.byteLength), receipts.get(key), imports.get(key))) return
369
+ opened(ctx, request.source.version, request.source)
370
+ const [bytes, found] = await Promise.all([reader(ctx, request.file.path, signal), resolved(document, request.source, signal)])
371
+ if (bytes.length !== request.file.byteLength || hash(bytes) !== request.file.sha256) throw new RequestError(409, 'audio-file-changed')
372
+ if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
373
+ await found.audio.inspectBytes('workspace-audio-probe', bytes, signal)
374
+ check(signal); opened(ctx, request.source.version, request.source)
375
+ })
376
+ },
377
+ importWorkspace(document, request, member, signal) {
378
+ const { ctx, title, change } = workspaceImport(document, request, member)
379
+ return work(member, signal, signal => once(ctx, change, request.source, title, request.file.sha256, request.file.byteLength, 'agent', async () => {
380
+ opened(ctx, request.source.version, request.source)
381
+ const [bytes, found] = await Promise.all([reader(ctx, request.file.path, signal), resolved(document, request.source, signal)])
382
+ if (bytes.length !== request.file.byteLength || hash(bytes) !== request.file.sha256) throw new RequestError(409, 'audio-file-changed')
383
+ return importBytes(ctx, change, title, bytes, request.source, found, 'agent', signal)
384
+ }))
385
+ },
386
+ validateClipChange: (document, request, member, signal) => work(member, signal, async signal => {
387
+ if (!validateDocumentMediaClipValidation(request)) throw new RequestError(400, 'document-media-clip-invalid')
388
+ if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
389
+ const ctx = ctxOf(document, member), prepared = prepareClip(ctx, request)
390
+ await prepared.validate(signal)
391
+ await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
392
+ check(signal); opened(ctx, request.source.version, request.source)
393
+ return prepared.answer
394
+ }),
395
+ checkClipChange: (document, request, member, signal) => work(member, signal, async signal => {
396
+ const ctx = ctxOf(document, member), { key, signature } = clipIntent(ctx, request, 'agent')
397
+ if (known(ctx, signature, receipts.get(key), clipChanges.get(key))) return
398
+ const prepared = prepareClip(ctx, request)
399
+ await prepared.validate(signal)
400
+ await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
401
+ check(signal); opened(ctx, request.source.version, request.source)
402
+ }),
403
+ checkRecordingAdoption: (document, request, member, signal) => work(member, signal, async signal => {
404
+ const ctx = ctxOf(document, member), { key, signature } = recordingIntent(ctx, request, 'agent')
405
+ if (known(ctx, signature, receipts.get(key), recordingChanges.get(key))) return
406
+ const prepared = await prepareRecording(ctx, request, signal)
407
+ await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
408
+ check(signal); opened(ctx, request.source.version, request.source)
409
+ }),
410
+ changeClip: (document, request, member, signal) => work(member, signal, signal => changeClip(ctxOf(document, member), request, 'agent', signal)),
411
+ adoptRecording: (document, request, member, signal) => work(member, signal, signal => adoptRecording(ctxOf(document, member), request, 'agent', signal)),
412
+ }
413
+ const guarded = (run: () => Promise<Response>) => catchRequestErrors(async () => { try { return await run() } catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error } })
414
+ const routes: Record<string, Route> = {
415
+ 'document/recording/adopt': { role: 'editor', maxBody: DOCUMENT_AUDIO_REQUEST_MAX_BYTES, handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
416
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
417
+ return jsonResponse(await adoptRecording(ctx, await request.json().catch(() => undefined), 'editor', signal))
418
+ })) },
419
+ 'document/audio': { role: 'editor', maxBody: 17 << 20, handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
420
+ if (request.method === 'GET') {
421
+ const params = new URL(request.url).searchParams, version = params.get('baseDocumentVersion'), id = params.get('assetId')
422
+ if (!version || !id || id.length > 200) throw new RequestError(400, 'document-audio-query-invalid')
423
+ const current = opened(ctx, version), asset = current.content.assets[id]
424
+ if (!asset) throw new RequestError(404, 'production.asset-missing')
425
+ return jsonResponse({ document: ctx.document, version, fingerprint: fnv1a64(current.source.content), inspection: await inspect(ctx, asset, binding(current), signal) } satisfies DocumentAudioInspectionAnswer)
426
+ }
427
+ if (request.method !== 'POST') throw new RequestError(405, 'GET or POST')
428
+ const form = await request.formData().catch(() => undefined)
429
+ if (!form || [...form.keys()].some(key => key !== 'change' && key !== 'file') || form.getAll('change').length !== 1 || form.getAll('file').length !== 1) throw new RequestError(400, 'document-audio-form-invalid')
430
+ const text = form.get('change'), file = form.get('file')
431
+ if (typeof text !== 'string' || text.length > 4096 || !(file instanceof File)) throw new RequestError(400, 'document-audio-form-invalid')
432
+ let change: unknown
433
+ try { change = JSON.parse(text) } catch { throw new RequestError(400, 'document-audio-change-invalid') }
434
+ if (!header(change) || Object.keys(change).some(key => key !== 'id' && key !== 'baseDocumentVersion')) throw new RequestError(400, 'document-audio-change-invalid')
435
+ if (!file.size || file.size > DOCUMENT_AUDIO_MAX_BYTES) throw new RequestError(413, 'production.asset-size')
436
+ const title = titleOf(file.name), bytes = new Uint8Array(await file.arrayBuffer())
437
+ const key = JSON.stringify(['audio', ctx.document, ctx.member.id, change.id])
438
+ const source = receipts.get(key)?.source ?? imports.get(key)?.source ?? binding(opened(ctx, change.baseDocumentVersion))
439
+ const answer = await once(ctx, change, source, title, hash(bytes), bytes.length, 'editor', async () => {
440
+ opened(ctx, change.baseDocumentVersion, source)
441
+ const found = await resolved(ctx.document, source, signal)
442
+ return importBytes(ctx, change, title, bytes, source, found, 'editor', signal)
443
+ })
444
+ return jsonResponse(answer)
445
+ })) },
446
+ 'document/media-clip': {
447
+ role: 'editor', maxBody: 1 << 20,
448
+ handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
449
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
450
+ const raw: unknown = await request.json().catch(() => undefined)
451
+ if (!isRecord(raw) || !header(raw)) throw new RequestError(400, 'document-media-clip-invalid')
452
+ const fields = raw as Record<string, unknown>
453
+ if ((fields.clip === undefined) === (fields.removeClipId === undefined)) throw new RequestError(400, 'document-media-clip-invalid')
454
+ const upsert = fields.clip !== undefined, allowed = new Set(['id', 'baseDocumentVersion', ...upsert ? ['binding', 'clip'] : ['removeClipId']])
455
+ if (Object.keys(fields).some(key => !allowed.has(key))) throw new RequestError(400, 'document-media-clip-invalid')
456
+ const previous = receipts.get(JSON.stringify(['clip', ctx.document, ctx.member.id, raw.id]))
457
+ const source = upsert && isRecord(fields.binding) ? fields.binding.source : previous?.source?.version === raw.baseDocumentVersion ? previous.source : binding(opened(ctx, raw.baseDocumentVersion))
458
+ if (!isRecord(source) || source.version !== raw.baseDocumentVersion) throw new RequestError(409, 'production-binding-changed')
459
+ const change = { id: raw.id, source, ...upsert ? { binding: fields.binding, clip: fields.clip } : { removeClipId: fields.removeClipId } }
460
+ if (!validateDocumentMediaClipEdit(change)) throw new RequestError(400, 'document-media-clip-invalid')
461
+ return jsonResponse(await changeClip(ctx, change, 'editor', signal))
462
+ })),
463
+ },
464
+ 'document/media-clip/validate': {
465
+ role: 'editor', maxBody: DOCUMENT_AUDIO_REQUEST_MAX_BYTES,
466
+ handle: (request, ctx) => guarded(async () => {
467
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
468
+ return jsonResponse(await service.validateClipChange(ctx.document, await request.json().catch(() => undefined), ctx.member, request.signal))
469
+ }),
470
+ },
471
+ }
472
+ return { service, routes, async close() { closed = true; for (const operation of operations) operation.controller.abort(); await Promise.allSettled([...operations].map(operation => operation.finished)); receipts.clear() } }
473
+ }