@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
@@ -1,41 +1,70 @@
1
- import { timingSafeEqual } from 'node:crypto'
1
+ import { createHash } from 'node:crypto'
2
2
  import { createRequire } from 'node:module'
3
- import { eventStream, errorMessage, isRecord, json, problem } from '@lincyaw/kernel'
4
- import type { Member, PresmiumNodePlugin, RequestContext, Route, ServerContribution } from '@lincyaw/kernel'
5
- import { bearer } from '@lincyaw/server'
6
- import type { ChangesAnswer, CommitAnswer, DocumentOperation, DocumentsAnswer, RevertAnswer, RevisionsAnswer, SavedAnswer } from '../domain/api.ts'
7
- import { WORKSPACE_ID } from '../domain/api.ts'
8
- import { trailers } from '../domain/revisions.ts'
9
- import { lockedError, RequestError } from './documents.ts'
10
- import type { Documents, DocumentsChange } from './documents.ts'
11
- import { documentDirOf } from './files.ts'
3
+ import { DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, problemResponse, PRODUCTION_AUDIO_AUTHORING, pushStream, SPEECH, sseResponse } from '@lincyaw/kernel'
4
+ import type { ChangesAnswer, CommitAnswer, CompileJob, DocumentsAnswer, JobsAnswer, Member, PresmiumNodePlugin, RequestContext, Route, SavedAnswer, ServerContribution, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
5
+ import { hasBearerToken } from '@lincyaw/server'
6
+ import { saveSummary } from '../domain/commit-message.ts'
7
+ import { documentSummaries, listDocuments } from './config.ts'
8
+ import type { WorkspaceConfig } from './config.ts'
9
+ import { documentsDiff } from './document-operations.ts'
10
+ import type { DocumentOperations, DocumentsChange } from './document-operations.ts'
11
+ import { catchRequestErrors, RequestError } from './errors.ts'
12
+ import { lockedError } from './locks.ts'
12
13
  import type { DocumentLocks } from './locks.ts'
13
14
  import type { Revisions } from './revisions.ts'
14
15
  import { reviewRoutes } from './review/routes.ts'
15
- import type { Reviews } from './review/reviews.ts'
16
- import type { WorkspaceService } from './service.ts'
17
- import { MAX_SOURCE, SourceFiles } from './source.ts'
16
+ import type { AgentSessionReviews } from './review/agent-session-reviews.ts'
17
+ import { MAX_SOURCE, MAX_SOURCE_CHANGES, validateDocumentSource } from './source-editor.ts'
18
+ import type { SourceEditor } from './source-editor.ts'
19
+ import { listTemplates } from './templates.ts'
20
+ import type { Jobs } from './jobs.ts'
21
+ import type { WorkbenchApi } from './workbench-api.ts'
22
+ import { documentEditorRoutes } from './document-editor.ts'
23
+ import type { DocumentEditor } from './document-editor.ts'
24
+ import { createDocumentAudioAuthoring } from './document-audio.ts'
25
+ import { createDocumentImageAuthoring } from './document-images.ts'
26
+ import { createDocumentTranscriptions, documentTranscriptionRoutes } from './document-transcription.ts'
27
+ import type { DocumentTranscriptions, ProductionAudioAuthoring, SpeechService } from '@lincyaw/kernel'
28
+ import { documentDirOf } from './writer.ts'
29
+ import { createDocumentHistory } from './document-history.ts'
18
30
 
19
31
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
20
32
 
21
- export interface WorkspaceRoutes {
22
- documents: Documents
23
- revisions?: Revisions
24
- locks?: DocumentLocks
25
- documentsChanged?(change: DocumentsChange): Promise<void> | void
26
- document?: string
27
- service?: WorkspaceService
28
- token?: string
29
- reviews?: Reviews
33
+ export const WORKSPACE_ID = 'presmium.workspace'
34
+
35
+ /** Host-level author routes and compiled workspace routes retain the same launcher authorization. */
36
+ export function authorizeWorkspaceRoutes(routes: Record<string, Route>, launcherToken?: string): Record<string, Route> {
37
+ if (!launcherToken) return routes
38
+ return Object.fromEntries(Object.entries(routes).map(([key, route]) => [key, { ...route, handle: (request, ctx) => hasBearerToken(request, launcherToken) ? route.handle(request, ctx) : Promise.resolve(problemResponse(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>')) } satisfies Route]))
30
39
  }
31
40
 
32
- function authorized(request: Request, token: string): boolean {
33
- const given = Buffer.from(bearer(request.headers.get('authorization')) ?? '')
34
- const own = Buffer.from(token)
35
- return given.length === own.length && timingSafeEqual(given, own)
41
+ /** The workspace's routes; the history, the document operations and the reviews are there only in a git repository. */
42
+ export interface WorkspaceRoutesOptions {
43
+ config: WorkspaceConfig
44
+ editor: SourceEditor
45
+ documentEditor: DocumentEditor
46
+ locks: DocumentLocks
47
+ revisions?: Revisions
48
+ operations?: DocumentOperations
49
+ reviews?: AgentSessionReviews
50
+ documentsChanged(change: DocumentsChange): Promise<void> | void
51
+ workbench?: WorkbenchApi
52
+ jobs?: Jobs
53
+ launcherToken?: string
54
+ audio?: ProductionAudioAuthoring
55
+ speech?: SpeechService
56
+ /** Supplied by a workspace host so remounting contributions does not replace or cancel tasks. */
57
+ transcriptions?: DocumentTranscriptions
58
+ transcriptionAdoptionRoutes?: Record<string, Route>
59
+ audioAuthoringRoutes?: Record<string, Route>
60
+ imageAuthoringRoutes?: Record<string, Route>
61
+ historyRoutes?: Record<string, Route>
62
+ authoringClientRoutes?: Record<string, Route>
63
+ projectRoutes?: Record<string, Route>
64
+ speechSettings?: Route
36
65
  }
37
66
 
38
- function rendererIds(body: unknown): string[] | undefined {
67
+ function toRendererIds(body: unknown): string[] | undefined {
39
68
  if (!isRecord(body) || !Array.isArray(body.to)) return undefined
40
69
  const ids = body.to.filter((id): id is string => typeof id === 'string' && /^[\w.-]+$/.test(id))
41
70
  return ids.length > 0 ? ids : undefined
@@ -43,197 +72,258 @@ function rendererIds(body: unknown): string[] | undefined {
43
72
 
44
73
  const NO_GIT = 'the workspace is not a git repository: run git init in it to use the document operations and the history'
45
74
 
46
- function operationOf(body: unknown): DocumentOperation | undefined {
47
- if (!isRecord(body) || typeof body.dir !== 'string') return undefined
48
- const text = (value: unknown) => (typeof value === 'string' ? value.trim() : undefined)
49
- const dir = body.dir.trim().replace(/^\/+|\/+$/g, '')
50
- switch (body.op) {
51
- case 'create': return { op: 'create', dir, ...text(body.title) ? { title: text(body.title) } : {}, ...text(body.template) ? { template: text(body.template) } : {} }
52
- case 'rename':
53
- case 'copy': return typeof body.to === 'string' ? { op: body.op, dir, to: body.to.trim().replace(/^\/+|\/+$/g, '') } : undefined
54
- case 'delete': return { op: 'delete', dir }
55
- default: return undefined
56
- }
57
- }
58
75
 
59
- async function answer(work: () => Promise<Response>): Promise<Response> {
60
- try {
61
- return await work()
62
- } catch (error) {
63
- if (error instanceof RequestError) return json({ error: error.message, ...error.detail }, error.status)
64
- return problem(500, errorMessage(error))
65
- }
76
+ function toChosenPaths(body: unknown, uncommitted: string[]): string[] {
77
+ const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
78
+ return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
66
79
  }
67
80
 
68
- function difference(before: string[], after: string[]): DocumentsChange {
69
- return { removed: before.filter(dir => !after.includes(dir)), added: after.filter(dir => !before.includes(dir)) }
81
+ function toSaveMessage(value: unknown): string {
82
+ return typeof value === 'string' ? [...(value.trim().split(/\r?\n/)[0] ?? '').trim()].slice(0, 200).join('') : ''
70
83
  }
71
84
 
72
- function chosenPaths(body: unknown, uncommitted: string[]): string[] {
73
- const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
74
- return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
75
- }
76
85
 
77
- export function workspaceContribution(options: WorkspaceRoutes): ServerContribution {
78
- const { documents, revisions, locks } = options
79
- const listed = () => documents.list().map(doc => doc.dir)
80
- const source = new SourceFiles({ config: documents.config, writer: documents.writer, ...locks ? { locks } : {} })
81
- const scopeOf = (ctx: RequestContext) => source.scope(options.document ?? ctx.document)
82
- const uncommitted = async (ctx: RequestContext) => (revisions ? await revisions.uncommittedFiles(source.roots(scopeOf(ctx))) : [])
83
- const onChanges = async (request: Request, ctx: RequestContext, holder: string, work: (paths: string[], member: Member, body: Record<string, unknown>) => Promise<Response>) => {
84
- if (request.method !== 'POST') return problem(405, 'POST')
85
- if (!revisions) return problem(409, NO_GIT)
86
+ export function workspaceRoutes(options: WorkspaceRoutesOptions): ServerContribution {
87
+ const { config, editor, locks, revisions, operations, reviews, workbench } = options
88
+ const listed = () => listDocuments(config).map(doc => doc.dir)
89
+ const scopeOf = (ctx: RequestContext) => editor.scope(ctx.document)
90
+ const uncommitted = async (ctx: RequestContext) => (revisions ? await revisions.uncommittedFiles(editor.roots(scopeOf(ctx))) : [])
91
+ const withChosenChanges = async (request: Request, ctx: RequestContext, holder: string, work: (revisions: Revisions, paths: string[], member: Member, body: Record<string, unknown>) => Promise<Response>) => {
92
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
93
+ if (!revisions) return problemResponse(409, NO_GIT)
86
94
  const body: unknown = await request.json().catch(() => ({}))
87
- const paths = chosenPaths(body, (await uncommitted(ctx)).map(file => file.path))
88
- if (paths.length === 0) return problem(409, 'no changes to take: every file is as committed')
89
- const claimed = locks?.claimAll(paths.flatMap(path => documentDirOf(documents.config, path) ?? []), holder)
90
- if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
95
+ const paths = toChosenPaths(body, (await uncommitted(ctx)).map(file => file.path))
96
+ if (paths.length === 0) return problemResponse(409, 'no changes to take: every file is as committed')
97
+ const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path) ?? ''), holder)
98
+ if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
91
99
  try {
92
- return await work(paths, ctx.member, isRecord(body) ? body : {})
100
+ return await work(revisions, paths, ctx.member, isRecord(body) ? body : {})
93
101
  } finally {
94
- claimed?.release()
102
+ claimed.release()
95
103
  }
96
104
  }
105
+ const ownedHistory = options.historyRoutes ? undefined : createDocumentHistory({ config, editor, locks, revisions, changed: (before, after) => options.documentsChanged(documentsDiff(before, after)), reverted: (target, revision) => reviews?.reverted(target, revision) })
106
+ const ownedTranscriptions = options.transcriptions ? undefined : createDocumentTranscriptions({ root: config.root, editor, audio: options.audio, speech: options.speech })
107
+ const transcriptions = options.transcriptions ?? ownedTranscriptions!
108
+ const ownedAudio = options.audioAuthoringRoutes ? undefined : createDocumentAudioAuthoring({ root: config.root, editor, revisions, audio: options.audio, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
97
109
  const routes: Record<string, Route> = {
110
+ ...options.speechSettings ? { 'speech-settings': options.speechSettings } : {},
111
+ ...documentEditorRoutes({ root: config.root, editor, documentEditor: options.documentEditor, workbench }),
112
+ ...(options.audioAuthoringRoutes ?? ownedAudio!.routes),
113
+ ...(options.imageAuthoringRoutes ?? createDocumentImageAuthoring({ root: config.root, editor, revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' }).routes),
114
+ ...documentTranscriptionRoutes(transcriptions),
115
+ ...options.transcriptionAdoptionRoutes,
98
116
  documents: {
99
117
  role: 'reader',
100
- handle: (request, ctx) => answer(async () => {
118
+ handle: (request, ctx) => catchRequestErrors(async () => {
101
119
  if (request.method === 'GET') {
102
- const status = revisions ? await revisions.status(source.roots(scopeOf(ctx))) : {}
103
- return json({ title: documents.config.title, documents: documents.list(), templates: documents.templates(), git: Boolean(revisions), ...status, single: documents.config.single || options.document !== undefined } satisfies DocumentsAnswer)
120
+ const status = revisions ? await revisions.status(editor.roots(scopeOf(ctx))) : {}
121
+ return jsonResponse({ title: config.title, documents: documentSummaries(config), templates: listTemplates(config), git: Boolean(revisions), ...status, single: config.singleDeck } satisfies DocumentsAnswer)
104
122
  }
105
- if (request.method !== 'POST') return problem(405, 'GET or POST')
106
- if (options.document !== undefined) return problem(409, 'presmium dev serves one deck: the document operations are the workspace service\'s (presmium serve)')
107
- if (!ctx.member.roles.some(role => role === 'editor' || role === 'owner')) return problem(403, 'document operations need the role editor')
108
- if (!revisions) return problem(409, NO_GIT)
109
- const operation = operationOf(await request.json().catch(() => undefined))
110
- if (!operation) return problem(400, '{ op: create | rename | copy | delete, dir, to?, title?, template? }')
111
- return json(await documents.apply(operation, ctx.member))
123
+ if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
124
+ return problemResponse(409, 'project-plan-required')
112
125
  }),
113
126
  },
114
- revisions: {
127
+ ...(options.historyRoutes ?? ownedHistory!.routes),
128
+ ...options.authoringClientRoutes,
129
+ ...options.projectRoutes,
130
+ 'files': {
115
131
  role: 'editor',
116
- handle: (request, ctx) => answer(async () => {
117
- if (request.method !== 'GET') return problem(405, 'GET')
118
- if (!revisions) return json({ revisions: [] } satisfies RevisionsAnswer)
119
- const params = new URL(request.url).searchParams
120
- const limit = Number(params.get('limit'))
121
- const document = params.get('document') ?? ((options.document ?? ctx.document) || (documents.config.single ? '.' : undefined))
122
- return json({ revisions: await revisions.history({ ...document ? { document } : {}, ...params.get('task') ? { task: params.get('task')! } : {}, ...Number.isInteger(limit) && limit > 0 ? { limit } : {} }) } satisfies RevisionsAnswer)
132
+ handle: (request, ctx) => catchRequestErrors(async () => {
133
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
134
+ return jsonResponse(editor.files(scopeOf(ctx), ctx.member))
123
135
  }),
124
136
  },
125
- 'revisions/revert': {
137
+ 'source': {
126
138
  role: 'editor',
127
- handle: (request, ctx) => answer(async () => {
128
- if (request.method !== 'POST') return problem(405, 'POST')
129
- if (!revisions) return problem(409, NO_GIT)
139
+ maxBody: MAX_SOURCE * 3,
140
+ handle: (request, ctx) => catchRequestErrors(async () => {
141
+ const scope = scopeOf(ctx)
142
+ if (request.method === 'GET') return jsonResponse(editor.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
143
+ if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
130
144
  const body: unknown = await request.json().catch(() => undefined)
131
- if (!isRecord(body) || typeof body.id !== 'string' || !/^[0-9a-f]{4,64}$/i.test(body.id)) return problem(400, '{ id: <commit> }')
132
- const claimed = locks?.claimAll(await revisions.documentsOf(body.id), 'revision:revert')
133
- if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
134
- try {
135
- const before = listed()
136
- const result = await revisions.revert(body.id, ctx.member)
137
- if (result.ok) {
138
- await options.documentsChanged?.(difference(before, listed()))
139
- const undone = trailers(result.revision.message)['Presmium-Revert']?.[0] ?? body.id
140
- void options.reviews?.reverted(undone, result.revision.id).catch(() => undefined)
141
- }
142
- return json(result satisfies RevertAnswer, result.ok ? 200 : 409)
143
- } finally {
144
- claimed?.release()
145
- }
145
+ if (!isRecord(body) || typeof body.path !== 'string' || typeof body.content !== 'string' || typeof body.base !== 'string') return problemResponse(400, '{ path, content, base: <the version opened>, message? }')
146
+ const message = toSaveMessage(body.message)
147
+ const commit = revisions && (async (path: string, lang: string | undefined) => (await revisions.ignores(path) ? undefined : revisions.commit({ files: [path], message: message || saveSummary(path, lang), origin: { kind: 'editor', member: ctx.member } })))
148
+ return jsonResponse(await editor.save(scope, body.path, body.content, body.base, ctx.member, commit) satisfies SavedAnswer)
146
149
  }),
147
150
  },
148
- 'files': {
151
+ 'source/create': {
149
152
  role: 'editor',
150
- handle: (request, ctx) => answer(async () => {
151
- if (request.method !== 'GET') return problem(405, 'GET')
152
- return json(source.files(scopeOf(ctx), ctx.member))
153
+ maxBody: 8192,
154
+ handle: (request, ctx) => catchRequestErrors(async () => {
155
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
156
+ const body: unknown = await request.json().catch(() => undefined)
157
+ if (!isRecord(body) || typeof body.path !== 'string') return problemResponse(400, '{ path: <new text file> }')
158
+ return jsonResponse(await editor.create(scopeOf(ctx), body.path, ctx.member), 201)
153
159
  }),
154
160
  },
155
- 'source': {
161
+ 'source/transaction': {
156
162
  role: 'editor',
157
- maxBody: MAX_SOURCE * 3,
158
- handle: (request, ctx) => answer(async () => {
159
- const scope = scopeOf(ctx)
160
- if (request.method === 'GET') return json(source.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
161
- if (request.method !== 'POST') return problem(405, 'GET or POST')
163
+ maxBody: MAX_SOURCE * 3 + 8192,
164
+ handle: (request, ctx) => catchRequestErrors(async () => {
165
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
162
166
  const body: unknown = await request.json().catch(() => undefined)
163
- if (!isRecord(body) || typeof body.path !== 'string' || typeof body.content !== 'string' || typeof body.base !== 'string') return problem(400, '{ path, content, base: <the version opened> }')
164
- return json({ version: await source.save(scope, body.path, body.content, body.base, ctx.member) } satisfies SavedAnswer)
167
+ if (!isRecord(body) || !Array.isArray(body.changes) || body.changes.length === 0 || body.changes.length > MAX_SOURCE_CHANGES || !body.changes.every(change => isRecord(change) && typeof change.path === 'string' && typeof change.content === 'string' && (change.base === null || (typeof change.base === 'string' && change.base.length > 0 && change.base.length <= 200)))) return problemResponse(400, '{ changes: [{ path, content, base: <the version opened, or null for a new file> }], message? }')
168
+ const changes = body.changes as SourceTransactionChange[]
169
+ const message = toSaveMessage(body.message)
170
+ const history = revisions ? {
171
+ run: <T>(work: () => Promise<T>) => revisions.transaction(work),
172
+ commit: async (paths: string[], lang: string | undefined) => {
173
+ const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => path !== undefined)
174
+ if (ignored.length) throw new RequestError(409, `a source transaction cannot record ignored files: ${ignored.join(', ')}`, { ignored })
175
+ return revisions.commitWhileLocked({ paths, summary: message || saveSummary(paths[0]!, lang), body: '', origin: { kind: 'editor', member: ctx.member } })
176
+ },
177
+ } : undefined
178
+ return jsonResponse(await editor.transaction(scopeOf(ctx), changes, ctx.member, history) satisfies SourceTransactionAnswer)
165
179
  }),
166
180
  },
167
181
  'changes': {
168
182
  role: 'editor',
169
- handle: (request, ctx) => answer(async () => {
170
- if (request.method !== 'GET') return problem(405, 'GET')
171
- return json({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
183
+ handle: (request, ctx) => catchRequestErrors(async () => {
184
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
185
+ return jsonResponse({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
172
186
  }),
173
187
  },
174
188
  'changes/commit': {
175
189
  role: 'editor',
176
- handle: (request, ctx) => answer(() => onChanges(request, ctx, 'editor:commit', async (paths, member, body) => {
190
+ handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:commit', async (history, paths, member, body) => {
177
191
  const message = typeof body.message === 'string' ? body.message.trim().slice(0, 4000) : ''
178
- if (!message) return problem(400, '{ message: what the change is, paths? }')
179
- const revision = await revisions!.commit({ files: paths, message, origin: { kind: 'editor', member } })
180
- return json({ ...revision ? { revision } : {}, paths } satisfies CommitAnswer)
192
+ if (!message) return problemResponse(400, '{ message: what the change is, paths? }')
193
+ for (const path of paths) {
194
+ if (!path.endsWith(`/${DOCUMENT_ENTRY}`) && path !== DOCUMENT_ENTRY) continue
195
+ const source = editor.read(scopeOf(ctx), path, member)
196
+ validateDocumentSource(path, source.content)
197
+ }
198
+ const revision = await history.commit({ files: paths, message, origin: { kind: 'editor', member } })
199
+ return jsonResponse({ ...revision ? { revision } : {}, paths } satisfies CommitAnswer)
181
200
  })),
182
201
  },
183
- ...options.service ? serviceRoutes(options.service) : {},
184
- ...options.reviews ? reviewRoutes(options.reviews, answer) : {},
202
+ ...workbench ? workbenchRoutes(workbench) : {},
203
+ ...options.jobs ? jobRoutes(options.jobs, editor) : {},
204
+ ...reviews ? reviewRoutes(reviews) : {},
185
205
  'changes/discard': {
186
206
  role: 'editor',
187
- handle: (request, ctx) => answer(() => onChanges(request, ctx, 'editor:discard', async (paths) => {
207
+ handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:discard', async (history, paths) => {
188
208
  const before = listed()
189
- await revisions!.discard(paths)
190
- await options.documentsChanged?.(difference(before, listed()))
191
- return json({ paths } satisfies CommitAnswer)
209
+ await history.discard(paths)
210
+ await options.documentsChanged(documentsDiff(before, listed()))
211
+ return jsonResponse({ paths } satisfies CommitAnswer)
192
212
  })),
193
213
  },
194
214
  }
195
- const { token } = options
196
- if (token) {
197
- for (const [key, route] of Object.entries(routes)) {
198
- routes[key] = { ...route, handle: (request, ctx) => (authorized(request, token) ? route.handle(request, ctx) : Promise.resolve(problem(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>'))) }
199
- }
200
- }
201
- return { routes, ...options.service ? { proxies: options.service.proxies } : {} }
215
+ return { routes: authorizeWorkspaceRoutes(routes, options.launcherToken), close: async () => { await Promise.all([ownedTranscriptions?.close(), ownedAudio?.close(), ownedHistory?.close()]) } }
202
216
  }
203
217
 
204
- function serviceRoutes(service: WorkspaceService): Record<string, Route> {
218
+ function workbenchRoutes(workbench: WorkbenchApi): Record<string, Route> {
205
219
  return {
220
+ 'voice-capabilities': {
221
+ role: 'editor',
222
+ handle: (request, ctx) => catchRequestErrors(async () => {
223
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
224
+ return jsonResponse(await workbench.voiceCapabilities(ctx.document, ctx.member, request.signal))
225
+ }),
226
+ },
227
+ 'voice-catalog': {
228
+ role: 'editor',
229
+ handle: (request, ctx) => catchRequestErrors(async () => {
230
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
231
+ const params = new URL(request.url).searchParams
232
+ return jsonResponse(await workbench.voiceCatalog(ctx.document, ctx.member, params.get('engine') || undefined, params.get('lang') || undefined, request.signal))
233
+ }),
234
+ },
206
235
  'ir': {
207
236
  role: 'editor',
208
- handle: (request, ctx) => answer(async () => (request.method === 'GET' ? json(await service.ir(ctx.document)) : problem(405, 'GET'))),
237
+ handle: (request, ctx) => catchRequestErrors(async () => {
238
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
239
+ const ir = await workbench.ir(ctx.document)
240
+ const capabilities = createHash('sha256').update(JSON.stringify([ir.sourceCapabilities ?? {}, ir.sourceFingerprints ?? {}, ir.authoring ?? {}])).digest('hex').slice(0, 8)
241
+ const etag = `"${ir.revision}-${capabilities}"`
242
+ if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'no-store' } })
243
+ const response = jsonResponse(ir)
244
+ response.headers.set('ETag', etag)
245
+ response.headers.set('Cache-Control', 'no-store')
246
+ return response
247
+ }),
209
248
  },
210
249
  'ir/events': {
211
250
  role: 'editor',
212
- handle: async (request, ctx) => (request.method === 'GET' ? eventStream(service.events(ctx.document, request.signal), request.signal) : problem(405, 'GET')),
251
+ handle: async (request, ctx) => (request.method === 'GET' ? sseResponse(workbench.events(ctx.document, request.signal), request.signal) : problemResponse(405, 'GET')),
213
252
  },
214
253
  'preview': {
215
254
  role: 'editor',
216
- handle: (request, ctx) => answer(async () => (request.method === 'GET' ? json(await service.preview(ctx.document)) : problem(405, 'GET'))),
255
+ handle: (request, ctx) => catchRequestErrors(async () => {
256
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
257
+ const params = new URL(request.url).searchParams
258
+ const renderer = params.get('view') ?? undefined
259
+ if (renderer !== undefined && renderer !== 'web' && renderer !== 'slides') return problemResponse(400, 'view must be web or slides')
260
+ return jsonResponse(await workbench.preview(ctx.document, renderer, params.get('prefetch') === '1'))
261
+ }),
217
262
  },
218
263
  'build': {
219
264
  role: 'editor',
220
- handle: (request, ctx) => answer(async () => {
221
- if (request.method !== 'POST') return problem(405, 'POST')
222
- return json(await service.build(ctx.document, rendererIds(await request.json().catch(() => undefined))))
265
+ handle: (request, ctx) => catchRequestErrors(async () => {
266
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
267
+ return jsonResponse(await workbench.build(ctx.document, toRendererIds(await request.json().catch(() => undefined))))
223
268
  }),
224
269
  },
225
270
  'check': {
226
271
  role: 'editor',
227
- handle: (request, ctx) => answer(async () => (request.method === 'POST' ? json(await service.check(ctx.document)) : problem(405, 'POST'))),
272
+ handle: (request, ctx) => catchRequestErrors(async () => (request.method === 'POST' ? jsonResponse(await workbench.check(ctx.document)) : problemResponse(405, 'POST'))),
273
+ },
274
+ }
275
+ }
276
+
277
+ /**
278
+ * The document's builds and exports (`jobs`): `GET` what it can be made into and its jobs, `POST { target }` starts one;
279
+ * `jobs/cancel` (`POST { id }`); `jobs/events`, each change of the document's jobs as it happens (SSE, event `job`).
280
+ */
281
+ function jobRoutes(jobs: Jobs, editor: SourceEditor): Record<string, Route> {
282
+ const own = (document: string, id: unknown) => {
283
+ const job = typeof id === 'string' ? jobs.get(id) : undefined
284
+ return job?.document === document ? job : undefined
285
+ }
286
+ return {
287
+ 'jobs': {
288
+ role: 'editor',
289
+ handle: (request, ctx) => catchRequestErrors(async () => {
290
+ if (request.method === 'GET') return jsonResponse({ targets: await jobs.targets(ctx.document), jobs: jobs.list(ctx.document) } satisfies JobsAnswer)
291
+ if (request.method !== 'POST') return problemResponse(405, 'GET, POST')
292
+ const body: unknown = await request.json().catch(() => undefined)
293
+ if (!isRecord(body) || typeof body.target !== 'string') return problemResponse(400, '{ target: a renderer or an export of the document }')
294
+ if (!isRecord(body.source) || typeof body.source.version !== 'string' || !body.source.version || body.source.version.length > 128 || typeof body.source.fingerprint !== 'string' || !body.source.fingerprint || body.source.fingerprint.length > 128) return problemResponse(400, 'job-source-required')
295
+ const source = editor.read(editor.scope(ctx.document), ctx.document ? `${ctx.document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, ctx.member)
296
+ if (source.version !== body.source.version || fnv1a64(source.content) !== body.source.fingerprint) throw new RequestError(409, 'job-source-changed')
297
+ return jsonResponse(await jobs.start(ctx.document, body.target, { version: body.source.version, fingerprint: body.source.fingerprint }))
298
+ }),
299
+ },
300
+ 'jobs/cancel': {
301
+ role: 'editor',
302
+ handle: (request, ctx) => catchRequestErrors(async () => {
303
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
304
+ const body: unknown = await request.json().catch(() => undefined)
305
+ const job = own(ctx.document, isRecord(body) ? body.id : undefined)
306
+ return job ? jsonResponse(jobs.cancel(job.id)) : problemResponse(404, 'no such job of the document')
307
+ }),
308
+ },
309
+ 'jobs/events': {
310
+ role: 'editor',
311
+ handle: async (request, ctx) => {
312
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
313
+ const events = pushStream<{ event: string, data: CompileJob }>(push => jobs.subscribe((job) => {
314
+ if (job.document === ctx.document) push({ event: 'job', data: job })
315
+ }), { signal: request.signal })
316
+ return sseResponse(events, request.signal)
317
+ },
228
318
  },
229
319
  }
230
320
  }
231
321
 
232
- export function workspacePlugin(options: WorkspaceRoutes): PresmiumNodePlugin {
322
+ export function workspaceRoutesPlugin(options: WorkspaceRoutesOptions, borrowedRoutes?: Record<string, Route>): PresmiumNodePlugin {
233
323
  return {
234
324
  id: WORKSPACE_ID,
235
325
  version,
236
326
  kernel: '^1.0.0',
237
- server: () => workspaceContribution(options),
327
+ server: ctx => borrowedRoutes ? { routes: borrowedRoutes } : workspaceRoutes({ ...options, audio: ctx.service(PRODUCTION_AUDIO_AUTHORING), speech: ctx.service(SPEECH) }),
238
328
  }
239
329
  }