@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,329 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createRequire } from 'node:module'
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'
13
+ import type { DocumentLocks } from './locks.ts'
14
+ import type { Revisions } from './revisions.ts'
15
+ import { reviewRoutes } from './review/routes.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'
30
+
31
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
32
+
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]))
39
+ }
40
+
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
65
+ }
66
+
67
+ function toRendererIds(body: unknown): string[] | undefined {
68
+ if (!isRecord(body) || !Array.isArray(body.to)) return undefined
69
+ const ids = body.to.filter((id): id is string => typeof id === 'string' && /^[\w.-]+$/.test(id))
70
+ return ids.length > 0 ? ids : undefined
71
+ }
72
+
73
+ const NO_GIT = 'the workspace is not a git repository: run git init in it to use the document operations and the history'
74
+
75
+
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
79
+ }
80
+
81
+ function toSaveMessage(value: unknown): string {
82
+ return typeof value === 'string' ? [...(value.trim().split(/\r?\n/)[0] ?? '').trim()].slice(0, 200).join('') : ''
83
+ }
84
+
85
+
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)
94
+ const body: unknown = await request.json().catch(() => ({}))
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)
99
+ try {
100
+ return await work(revisions, paths, ctx.member, isRecord(body) ? body : {})
101
+ } finally {
102
+ claimed.release()
103
+ }
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' })
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,
116
+ documents: {
117
+ role: 'reader',
118
+ handle: (request, ctx) => catchRequestErrors(async () => {
119
+ if (request.method === 'GET') {
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)
122
+ }
123
+ if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
124
+ return problemResponse(409, 'project-plan-required')
125
+ }),
126
+ },
127
+ ...(options.historyRoutes ?? ownedHistory!.routes),
128
+ ...options.authoringClientRoutes,
129
+ ...options.projectRoutes,
130
+ 'files': {
131
+ role: 'editor',
132
+ handle: (request, ctx) => catchRequestErrors(async () => {
133
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
134
+ return jsonResponse(editor.files(scopeOf(ctx), ctx.member))
135
+ }),
136
+ },
137
+ 'source': {
138
+ role: 'editor',
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')
144
+ const body: unknown = await request.json().catch(() => undefined)
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)
149
+ }),
150
+ },
151
+ 'source/create': {
152
+ role: 'editor',
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)
159
+ }),
160
+ },
161
+ 'source/transaction': {
162
+ role: 'editor',
163
+ maxBody: MAX_SOURCE * 3 + 8192,
164
+ handle: (request, ctx) => catchRequestErrors(async () => {
165
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
166
+ const body: unknown = await request.json().catch(() => undefined)
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)
179
+ }),
180
+ },
181
+ 'changes': {
182
+ role: 'editor',
183
+ handle: (request, ctx) => catchRequestErrors(async () => {
184
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
185
+ return jsonResponse({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
186
+ }),
187
+ },
188
+ 'changes/commit': {
189
+ role: 'editor',
190
+ handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:commit', async (history, paths, member, body) => {
191
+ const message = typeof body.message === 'string' ? body.message.trim().slice(0, 4000) : ''
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)
200
+ })),
201
+ },
202
+ ...workbench ? workbenchRoutes(workbench) : {},
203
+ ...options.jobs ? jobRoutes(options.jobs, editor) : {},
204
+ ...reviews ? reviewRoutes(reviews) : {},
205
+ 'changes/discard': {
206
+ role: 'editor',
207
+ handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:discard', async (history, paths) => {
208
+ const before = listed()
209
+ await history.discard(paths)
210
+ await options.documentsChanged(documentsDiff(before, listed()))
211
+ return jsonResponse({ paths } satisfies CommitAnswer)
212
+ })),
213
+ },
214
+ }
215
+ return { routes: authorizeWorkspaceRoutes(routes, options.launcherToken), close: async () => { await Promise.all([ownedTranscriptions?.close(), ownedAudio?.close(), ownedHistory?.close()]) } }
216
+ }
217
+
218
+ function workbenchRoutes(workbench: WorkbenchApi): Record<string, Route> {
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
+ },
235
+ 'ir': {
236
+ role: 'editor',
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
+ }),
248
+ },
249
+ 'ir/events': {
250
+ role: 'editor',
251
+ handle: async (request, ctx) => (request.method === 'GET' ? sseResponse(workbench.events(ctx.document, request.signal), request.signal) : problemResponse(405, 'GET')),
252
+ },
253
+ 'preview': {
254
+ role: 'editor',
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
+ }),
262
+ },
263
+ 'build': {
264
+ role: 'editor',
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))))
268
+ }),
269
+ },
270
+ 'check': {
271
+ role: 'editor',
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
+ },
318
+ },
319
+ }
320
+ }
321
+
322
+ export function workspaceRoutesPlugin(options: WorkspaceRoutesOptions, borrowedRoutes?: Record<string, Route>): PresmiumNodePlugin {
323
+ return {
324
+ id: WORKSPACE_ID,
325
+ version,
326
+ kernel: '^1.0.0',
327
+ server: ctx => borrowedRoutes ? { routes: borrowedRoutes } : workspaceRoutes({ ...options, audio: ctx.service(PRODUCTION_AUDIO_AUTHORING), speech: ctx.service(SPEECH) }),
328
+ }
329
+ }