@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
package/src/node/serve.ts CHANGED
@@ -1,38 +1,54 @@
1
+ import { createProjectAuthoring } from './projects.ts'
2
+ import { createDocumentHistory } from './document-history.ts'
3
+ import { createAuthoringClients } from './authoring-clients.ts'
1
4
  import { createHash } from 'node:crypto'
2
5
  import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
3
6
  import { homedir, tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import { buildView, Compiler, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
7
+ import { join, relative } from 'node:path'
8
+ import { Compiler, operatorDir as resolveOperatorDir, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
6
9
  import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
7
- import { CHANGE_REVIEW, DOCUMENT_FILES, DOCUMENT_LOCKS, errorMessage, REVISIONS } from '@lincyaw/kernel'
8
- import type { Finding, IRDocument, Logger, ServiceToken, SiteSpec } from '@lincyaw/kernel'
9
- import { servedDocument, startServer } from '@lincyaw/server'
10
- import type { PresmiumServer, ServedDocument, ServedPlugin, ServerHost } from '@lincyaw/server'
11
- import { WORKSPACE_ID } from '../domain/api.ts'
12
- import { listDocuments, readWorkspace } from './config.ts'
13
- import { workspaceService } from './service.ts'
14
- import type { Service } from './service.ts'
15
- import { Documents, RequestError } from './documents.ts'
16
- import type { DocumentsChange } from './documents.ts'
17
- import { localWriter } from './files.ts'
18
- import type { DocumentWriter } from './files.ts'
10
+ import { CHANGE_REVIEW, DOCUMENT_FILES, DOCUMENT_LOCKS, DOCUMENT_VOICE_TASKS, errorMessage, PRODUCTION_AUDIO_AUTHORING, REVISIONS, SPEECH, VOICEOVER, VOICEOVER_EXECUTION } from '@lincyaw/kernel'
11
+ import { irRevision, operatorSpeechSecrets } from '@lincyaw/kernel/node'
12
+ import { speechSettingsRoute } from './speech-settings.ts'
13
+ import type { CanonicalSource, ChangeReviewService, Finding, IRDocument, Logger, ProductionAudioAuthoring, ServiceToken, SiteSpec, SpeechService } from '@lincyaw/kernel'
14
+ import { serverConfigKey, servedDocument, startServer } from '@lincyaw/server'
15
+ import type { DiagnosticStore, PresmiumServer, ServedDocument, ServedPlugin, ServerHost } from '@lincyaw/server'
16
+ import { CompileSession } from './compile-session.ts'
17
+ import { documentSummaries, listDocuments, readWorkspaceConfig } from './config.ts'
18
+ import { DocumentOperations, documentsDiff } from './document-operations.ts'
19
+ import type { DocumentsChange } from './document-operations.ts'
20
+ import { RequestError } from './errors.ts'
19
21
  import { Git } from './git.ts'
22
+ import { createJobs, OUTPUTS_PATH } from './jobs.ts'
20
23
  import { DocumentLocks } from './locks.ts'
21
- import { Reviews } from './review/reviews.ts'
24
+ import { AgentSessionReviews } from './review/agent-session-reviews.ts'
25
+ import { createTaskReviewService } from './review/task-reviews.ts'
22
26
  import { Revisions } from './revisions.ts'
23
- import { workspacePlugin } from './routes.ts'
24
- import type { WorkspaceRoutes } from './routes.ts'
25
- import { DocumentSession } from './session.ts'
26
- import { siteContext, siteDocument, siteIR, siteSpec, untranslated, webRenderer } from './site.ts'
27
+ import { WORKSPACE_ID, authorizeWorkspaceRoutes, workspaceRoutes, workspaceRoutesPlugin } from './routes.ts'
28
+ import { refreshDocumentVitePlugins, siteContext, siteDocument, siteDocumentIR, siteSpec, untranslatedFindings, webRenderer } from './site.ts'
29
+ import { SourceEditor } from './source-editor.ts'
30
+ import { createDocumentEditor } from './document-editor.ts'
31
+ import type { DocumentEditor } from './document-editor.ts'
32
+ import { createDocumentTranscriptions } from './document-transcription.ts'
33
+ import { createTranscriptionAdoption } from './document-transcription-adoption.ts'
34
+ import { createDocumentAudioAuthoring } from './document-audio.ts'
35
+ import { createDocumentImageAuthoring } from './document-images.ts'
36
+ import { createDocumentVoiceAuthoring } from './document-voice.ts'
37
+ import type { DocumentVoiceServices } from './document-voice.ts'
38
+ import { createWorkbenchBackend } from './workbench-api.ts'
39
+ import type { WorkbenchBackend } from './workbench-api.ts'
40
+ import { localWriter } from './writer.ts'
27
41
 
28
42
  export interface ServeOptions {
29
43
  dir: string
30
- host?: string
44
+ listenAddress?: string
31
45
  port?: number
32
46
  operatorDir?: string
33
47
  bundled?: string
34
48
  check?(compilation: Compilation): Promise<Finding[]>
35
49
  previewOrigins?: string[]
50
+ /** The address browsers use when a reverse proxy fronts the service (see `ServerOptions.publicUrl`). */
51
+ publicUrl?: string
36
52
  env?: NodeJS.ProcessEnv
37
53
  approvalTimeoutMs?: number
38
54
  log?: Logger
@@ -45,9 +61,21 @@ export interface WorkspaceServer {
45
61
  close(): Promise<void>
46
62
  }
47
63
 
64
+ /** What the workspace keeps when it is a git repository: its revisions, the document operations and the reviews. */
65
+ interface WorkspaceHistory {
66
+ git: Git
67
+ revisions: Revisions
68
+ operations: DocumentOperations
69
+ agentSessionReviews: AgentSessionReviews
70
+ taskReviews: ChangeReviewService
71
+ }
72
+
73
+ interface AuthoringServices extends DocumentVoiceServices { audio?: ProductionAudioAuthoring }
74
+ interface NodeServiceLoad { key: string, promise: Promise<AuthoringServices>, controller: AbortController }
75
+
48
76
  const DISK_LOOK_MS = 1000
49
77
 
50
- function stateDir(root: string, env: NodeJS.ProcessEnv): string {
78
+ function makeStateDir(root: string, env: NodeJS.ProcessEnv): string {
51
79
  const base = env.XDG_CACHE_HOME || join(homedir(), '.cache')
52
80
  const dir = join(base, 'presmium', 'serve', createHash('sha256').update(root).digest('hex').slice(0, 16))
53
81
  mkdirSync(dir, { recursive: true })
@@ -57,194 +85,355 @@ function stateDir(root: string, env: NodeJS.ProcessEnv): string {
57
85
  export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceServer> {
58
86
  const env = options.env ?? process.env
59
87
  const log = options.log ?? pluginLogger('presmium serve')
60
- const config = readWorkspace(options.dir)
88
+ const config = readWorkspaceConfig(options.dir)
61
89
  const { root } = config
90
+ const operatorDirectory = resolveOperatorDir(root, options.operatorDir ?? env.PRESMIUM_OPERATOR_DIR)
91
+ const stateDir = makeStateDir(root, env)
62
92
  const docs = () => listDocuments(config)
63
- const sessions = new Map<string, DocumentSession>()
93
+ const compileSessions = new Map<string, CompileSession>()
94
+ const agentToolListeners = new Set<(document: string) => void>()
64
95
  const locks = new DocumentLocks()
96
+ const writer = localWriter(config)
97
+ const editor = new SourceEditor({ config, writer, locks })
65
98
  const check = options.check ?? (compilation => compilation.check())
66
- const token = env.PRESMIUM_SERVICE_TOKEN || undefined
99
+ const launcherToken = env.PRESMIUM_SERVICE_TOKEN || undefined
67
100
  const preview = { origins: options.previewOrigins ?? [] }
101
+ // Behind a proxy that forwards a path (`--public-url https://host/user/a/preview/`), the site, the jobs' files and the
102
+ // previews' links are all below that path; the server finds its own routes below it too.
103
+ const basePath = options.publicUrl ? `${new URL(options.publicUrl).pathname.replace(/\/+$/, '')}/` : '/'
104
+ const outputsPath = `${basePath}${OUTPUTS_PATH.slice(1)}`
105
+ const publicOrigin = () => options.publicUrl ? new URL(options.publicUrl).origin : server?.url ?? ''
68
106
 
69
- let services: [ServiceToken<unknown>, unknown][] = []
70
- let hostServices: [ServiceToken<unknown>, unknown][] = []
71
- let site: SiteBackend | undefined
107
+ let serverServices: [ServiceToken<unknown>, unknown][] = []
108
+ let workspaceServices: [ServiceToken<unknown>, unknown][] = []
109
+ let site: WorkspaceSite | undefined
72
110
  let server: PresmiumServer | undefined
73
- let service: Service | undefined
111
+ let workbench: WorkbenchBackend | undefined
112
+ let history: WorkspaceHistory | undefined
113
+ let documentEditor: DocumentEditor | undefined
114
+ let documentHistory: ReturnType<typeof createDocumentHistory> | undefined
115
+ let projectAuthoring: ReturnType<typeof createProjectAuthoring> | undefined
116
+ let authoringClients: ReturnType<typeof createAuthoringClients> | undefined
117
+ let transcriptions: ReturnType<typeof createDocumentTranscriptions> | undefined
118
+ let transcriptionAdoption: ReturnType<typeof createTranscriptionAdoption> | undefined
119
+ let audioAuthoring: ReturnType<typeof createDocumentAudioAuthoring> | undefined
120
+ let imageAuthoring: ReturnType<typeof createDocumentImageAuthoring> | undefined
121
+ let voiceAuthoring: ReturnType<typeof createDocumentVoiceAuthoring> | undefined
122
+ let workspaceRoutesOwner: ReturnType<typeof workspaceRoutes> | undefined
123
+ let routesPlugin: ServedPlugin | undefined
124
+ // The builds and exports, as jobs the workbench and the AI start and follow.
125
+ const jobs = createJobs({
126
+ stateDir,
127
+ url: publicOrigin,
128
+ outputsPath,
129
+ compilation: (dir, fresh) => fresh ? compileSessionOf(dir).recompile() : compileSessionOf(dir).ready(),
130
+ revision: compilation => irRevision(compilation.doc),
131
+ origins: preview.origins,
132
+ log,
133
+ })
74
134
 
75
- const sessionOf = (dir: string): DocumentSession => {
76
- const known = sessions.get(dir)
135
+ const compileSessionOf = (dir: string): CompileSession => {
136
+ const known = compileSessions.get(dir)
77
137
  if (known) return known
78
138
  const document = docs().find(doc => doc.dir === dir)
79
139
  if (!document) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
80
- const session = new DocumentSession(dir, new Compiler(join(root, document.entry), { ...pluginOptions(), execution: { env: 'serve' } }), log)
140
+ const session = new CompileSession(dir, new Compiler(join(root, document.entry), { ...compilerOptions(), priority: 'interactive', execution: { env: 'serve' } }), log)
141
+ let configKey: string | undefined
81
142
  session.onCompile(async (compilation) => {
82
- for (const finding of untranslated(compilation, root)) {
143
+ const next = serverConfigKey(compilation)
144
+ if (configKey !== undefined && configKey !== next) void server?.unmount(dir, { preserveFocus: true }).catch((error: unknown) => {
145
+ log.error(`the previous server contributions of ${dir || '.'} did not close: ${errorMessage(error)}`)
146
+ })
147
+ configKey = next
148
+ for (const finding of untranslatedFindings(compilation, root)) {
83
149
  if (!compilation.doc.diagnostics.some(f => f.message === finding.message)) compilation.doc.diagnostics.push(finding)
84
150
  }
85
151
  })
86
152
  session.onCompile((compilation, changed) => {
87
- service?.compiled(dir, compilation, changed)
88
- void Promise.resolve(site?.update(dir, compilation)).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`))
153
+ server?.diagnostics.compiled(dir, compilation.doc, root, irRevision(compilation.doc))
154
+ workbench?.compiled(dir, compilation, changed)
155
+ for (const listener of agentToolListeners) listener(dir)
156
+ void site?.update(dir, compilation).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`))
89
157
  })
90
- sessions.set(dir, session)
158
+ session.onFailure(error => server?.diagnostics.failed(dir, errorMessage(error), { file: relative(root, session.compiler.entry).replace(/\\/g, '/') }))
159
+ compileSessions.set(dir, session)
91
160
  return session
92
161
  }
93
162
 
94
- const pluginOptions = (): CompilerOptions => ({
163
+ const compilerOptions = (): CompilerOptions => ({
95
164
  workspaceRoot: root,
96
165
  settings: config.settings,
97
- services: [...services, ...hostServices],
166
+ services: [...serverServices, ...workspaceServices],
98
167
  bundled: options.bundled,
99
- ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
100
- secret: name => env[name],
168
+ operatorDir: operatorDirectory,
169
+ secret: operatorSpeechSecrets(operatorDirectory, name => env[name]),
101
170
  })
102
171
 
172
+ // Coalesce only in-flight discovery. A later read reassembles actual services and problems;
173
+ // source edits or a repaired plugin cannot be hidden behind a retained capability cache.
174
+ const nodeServiceLoads = new Map<string, NodeServiceLoad>()
175
+ const nodeServicePending = new Set<NodeServiceLoad>()
176
+ let authoringServicesClosed = false
177
+ const authoringServices = (document: string, source: CanonicalSource): Promise<AuthoringServices> => {
178
+ if (authoringServicesClosed) throw new RequestError(503, 'document-services-unavailable')
179
+ const key = JSON.stringify(source), known = nodeServiceLoads.get(document)
180
+ if (known?.key === key) return known.promise
181
+ const selected = docs().find(doc => doc.dir === document)
182
+ if (!selected) throw new RequestError(404, 'authoring-document-missing')
183
+ const controller = new AbortController()
184
+ const promise: Promise<AuthoringServices> = new Compiler(join(root, selected.entry), compilerOptions()).nodeServices({ signal: controller.signal }).then(loaded => {
185
+ if (loaded.source.documentId !== source.documentId || loaded.source.version !== source.version || loaded.source.fingerprint !== source.fingerprint) throw new RequestError(409, 'document-version-changed')
186
+ return { audio: loaded.service(PRODUCTION_AUDIO_AUTHORING), speech: loaded.service(SPEECH), voiceover: loaded.service(VOICEOVER), voiceoverExecution: loaded.service(VOICEOVER_EXECUTION), findings: loaded.findings }
187
+ }).finally(() => { if (nodeServiceLoads.get(document) === load) nodeServiceLoads.delete(document); nodeServicePending.delete(load) })
188
+ const load: NodeServiceLoad = { key, promise, controller }
189
+ nodeServiceLoads.set(document, load)
190
+ nodeServicePending.add(load)
191
+ return promise
192
+ }
193
+ const closeAuthoring = async () => {
194
+ authoringServicesClosed = true
195
+ transcriptionAdoption?.close()
196
+ for (const load of nodeServicePending) load.controller.abort()
197
+ await Promise.all([projectAuthoring?.close(), transcriptions?.close(), audioAuthoring?.close(), voiceAuthoring?.close(), documentHistory?.close(), authoringClients?.close(), workspaceRoutesOwner?.close?.()])
198
+ await Promise.allSettled([...nodeServicePending].map(load => load.promise))
199
+ nodeServiceLoads.clear()
200
+ }
201
+
103
202
  const spec: SiteSpec = siteSpec(config, docs().map(doc => siteDocument(doc)))
104
203
  const refreshSpec = (): DocumentsChange => {
204
+ const retained = new Map(spec.documents.map(doc => [doc.dir, doc]))
205
+ const before = [...retained.keys()]
105
206
  const now = docs()
106
- const before = spec.documents.map(doc => doc.dir)
107
- spec.documents.splice(0, spec.documents.length, ...now.map(doc => siteDocument(doc)))
108
- return { removed: before.filter(dir => !now.some(doc => doc.dir === dir)), added: now.map(doc => doc.dir).filter(dir => !before.includes(dir)) }
207
+ const listed = now.map(doc => {
208
+ const summary = siteDocument(doc)
209
+ const known = retained.get(doc.dir)
210
+ if (!known) return summary
211
+ // Undoing prose also refreshes the list: retain compiled document modules and translations.
212
+ delete known.lang
213
+ delete known.tags
214
+ return Object.assign(known, summary)
215
+ })
216
+ spec.documents.splice(0, spec.documents.length, ...listed)
217
+ return documentsDiff(before, now.map(doc => doc.dir))
109
218
  }
110
219
 
111
220
  const documentsChanged = async (change: DocumentsChange): Promise<void> => {
112
221
  for (const dir of change.removed) {
113
- sessions.get(dir)?.close()
114
- sessions.delete(dir)
222
+ server?.diagnostics.forget(dir)
223
+ compileSessions.get(dir)?.close()
224
+ compileSessions.delete(dir)
225
+ for (const listener of agentToolListeners) listener(dir)
115
226
  }
116
227
  const listed = refreshSpec()
117
228
  const removed = [...new Set([...change.removed, ...listed.removed])]
118
229
  for (const dir of new Set([...removed, ...change.added, ...listed.added])) await server?.unmount(dir)
119
- await site?.documentsChanged(removed)
120
- await service?.removed(removed)
230
+ await workbench?.documentsRemoved(removed)
121
231
  }
122
232
 
123
233
  let lookedAt = 0
124
234
  const lookAtDisk = (): void => {
125
235
  if (Date.now() - lookedAt < DISK_LOOK_MS) return
126
236
  lookedAt = Date.now()
127
- const now = docs().map(doc => doc.dir)
128
- const known = spec.documents.map(doc => doc.dir)
129
- const change = { removed: known.filter(dir => !now.includes(dir)), added: now.filter(dir => !known.includes(dir)) }
237
+ const change = documentsDiff(spec.documents.map(doc => doc.dir), docs().map(doc => doc.dir))
130
238
  if (change.removed.length || change.added.length) void documentsChanged(change).catch((error: unknown) => log.warn(`the documents on disk changed, but the site did not follow: ${errorMessage(error)}`))
131
239
  }
132
240
 
133
- const workspaceOf = (): { revisions?: Revisions, reviews?: Reviews, writer: DocumentWriter, plugin: ServedPlugin } => {
134
- const writer = localWriter(config)
135
- const git = Git.isRepository(root) ? new Git(root) : undefined
136
- if (!git) log.warn(`${root} is not a git repository: editing, the document operations and the history are not available (run git init)`)
137
- const revisions = git ? new Revisions(git, { config, writer }) : undefined
138
- const reviews = revisions ? new Reviews({ config, revisions, locks, files: writer, log }) : undefined
139
- const documents = new Documents({ config, git: git!, revisions: revisions!, writer, changed: documentsChanged, locks })
140
- const routes: WorkspaceRoutes = { documents, ...revisions ? { revisions } : {}, locks, documentsChanged, ...service ? { service } : {}, ...token ? { token } : {}, ...reviews ? { reviews } : {} }
141
- return { ...revisions ? { revisions } : {}, ...reviews ? { reviews } : {}, writer, plugin: { id: WORKSPACE_ID, plugin: workspacePlugin(routes), config: {} } }
241
+ const openHistory = (): WorkspaceHistory | undefined => {
242
+ if (!Git.isRepository(root)) {
243
+ log.warn(`${root} is not a git repository: editing, the document operations and the history are not available (run git init)`)
244
+ return undefined
245
+ }
246
+ const git = new Git(root)
247
+ const revisions = new Revisions(git, { config, writer })
248
+ return {
249
+ revisions,
250
+ operations: new DocumentOperations({ config, git, revisions, writer, changed: documentsChanged, locks }),
251
+ git,
252
+ agentSessionReviews: new AgentSessionReviews({ config, revisions, locks, files: writer, log }),
253
+ taskReviews: createTaskReviewService({ config, locks, files: writer, log }),
254
+ }
142
255
  }
143
- let workspace: ReturnType<typeof workspaceOf> | undefined
144
- const plugin = (): ServedPlugin => workspace!.plugin
145
256
 
146
257
  let started!: () => void
147
258
  const ready = new Promise<void>(resolve => started = resolve)
148
259
 
149
260
  const serverHost: ServerHost = {
261
+ workspaceRoot: root,
262
+ get authoring() { return documentEditor },
263
+ get documentHistory() { return documentHistory?.service },
264
+ get projectAuthoring() { return projectAuthoring?.service },
265
+ get authoringClients() { return authoringClients?.service },
266
+ get audioAuthoring() { return audioAuthoring?.service },
267
+ get imageAuthoring() { return imageAuthoring?.service },
268
+ get voiceAuthoring() { return voiceAuthoring?.service },
269
+ get transcriptions() { return transcriptions },
270
+ get transcriptionAdoption() { return transcriptionAdoption?.service },
271
+ get routes() { return workspaceRoutesOwner?.routes ? { [WORKSPACE_ID]: workspaceRoutesOwner.routes, ...voiceAuthoring ? { 'presmium.lecture': authorizeWorkspaceRoutes(voiceAuthoring.routes, launcherToken) } : {} } : undefined },
272
+ agentTools(document) {
273
+ const compiled = (document === undefined ? [...compileSessions.values()] : [compileSessions.get(document)]).flatMap(session => session?.latest ? [session.latest] : [])
274
+ const tools = new Map(compiled.flatMap(compilation => compilation.plugins.flatMap(loaded => loaded.plugin?.tools ?? [])).map(tool => [tool.name, tool]))
275
+ return { tools: [...tools.values()], service: <T>(token: ServiceToken<T>) => compiled[0]?.service(token) }
276
+ },
277
+ watchAgentTools(listener) { agentToolListeners.add(listener); return () => { agentToolListeners.delete(listener) } },
278
+ async readSource(path, member) {
279
+ await ready
280
+ return editor.read(editor.scope(''), path, member)
281
+ },
150
282
  async documents() {
151
- return docs().map(({ entry: _entry, ...doc }) => doc)
283
+ return documentSummaries(config)
152
284
  },
153
285
  async compile(document) {
154
286
  await ready
155
- const { doc } = await sessionOf(document).ready()
287
+ const { doc } = await compileSessionOf(document).ready()
156
288
  return { doc, diagnostics: doc.diagnostics }
157
289
  },
158
290
  async check(document) {
159
291
  await ready
160
- const compilation = await sessionOf(document).ready()
161
- return [...compilation.doc.diagnostics, ...await check(compilation)]
292
+ const session = compileSessionOf(document)
293
+ const compilation = await session.ready()
294
+ const findings = await check(compilation)
295
+ if (session.latest === compilation) server?.diagnostics.replace({ document, origin: 'check' }, findings, { root, irRevision: irRevision(compilation.doc) })
296
+ return [...compilation.doc.diagnostics, ...findings]
162
297
  },
163
298
  async build(document, buildOptions = {}) {
164
299
  await ready
165
- const compilation = await sessionOf(document).ready()
166
- const outDir = join(stateDir(root, env), 'builds', encodeURIComponent(document) || '_')
167
- const report = await buildView(compilation, { to: buildOptions.to, outDir })
168
- return { outputs: report.outputs.map(({ renderer, outDir: written }) => ({ renderer, outDir: written })), findings: report.findings }
300
+ // As jobs, which the workbench shows: one a renderer, waited for.
301
+ const targets = buildOptions.to ?? (await jobs.targets(document)).filter(target => target.kind === 'render').map(target => target.id)
302
+ const ended = await Promise.all((await Promise.all(targets.map(target => jobs.start(document, target)))).map(job => jobs.wait(job.id)))
303
+ const outputs = ended.filter(job => job.state === 'done').map(job => ({ renderer: job.target, outDir: jobs.directory(job) }))
304
+ const findings: Finding[] = ended.flatMap(job => [
305
+ ...job.state === 'failed' ? [{ checkId: 'presmium.render', severity: 'error' as const, message: `${job.target}: ${job.error ?? job.state}` }] : [],
306
+ ...job.warnings.map(message => ({ checkId: 'presmium.render', severity: 'warning' as const, message })),
307
+ ])
308
+ return { outputs, findings }
169
309
  },
310
+ jobs,
170
311
  async run(document, target, signal) {
171
312
  await ready
172
- return runBlock(await sessionOf(document).ready(), target, signal)
313
+ return runBlock(await compileSessionOf(document).ready(), target, signal)
173
314
  },
174
315
  }
175
316
 
176
317
  let operators: ReturnType<typeof workspacePlugins> | undefined
177
318
  let home: string | undefined
178
- const operatorsOf = async () => {
319
+ const loadOperatorPlugins = async () => {
179
320
  await ready
180
321
  home ??= mkdtempSync(join(tmpdir(), 'presmium-home-'))
181
- return operators ??= workspacePlugins(root, pluginOptions())
322
+ return operators ??= workspacePlugins(root, compilerOptions())
182
323
  }
183
324
  const homeDocument = async (): Promise<ServedDocument> => {
184
- const loaded = await operatorsOf()
325
+ const loaded = await loadOperatorPlugins()
185
326
  for (const problem of loaded.problems) log.warn(`the home page: ${problem.message}`)
186
- const own = (token: ServiceToken<unknown>) => [...services, ...hostServices].find(([known]) => known.id === token.id)?.[1]
327
+ const own = (token: ServiceToken<unknown>) => [...serverServices, ...workspaceServices].find(([known]) => known.id === token.id)?.[1]
187
328
  return {
188
329
  deckDir: home!,
189
330
  workspaceRoot: home!,
190
- plugins: [plugin(), ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
331
+ plugins: [routesPlugin!, ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
191
332
  service: <T>(token: ServiceToken<T>) => (own(token) ?? loaded.service(token)) as T | undefined,
192
333
  settings: config.settings,
193
- ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
334
+ operatorDir: operatorDirectory,
194
335
  home: true,
195
336
  }
196
337
  }
197
338
 
198
- const stateFile = join(stateDir(root, env), 'server.json')
339
+ const stateFile = join(stateDir, 'server.json')
199
340
  server = await startServer({
200
341
  mode: 'serve',
201
- host: options.host,
342
+ listenAddress: options.listenAddress,
202
343
  port: options.port,
344
+ ...options.publicUrl ? { publicUrl: options.publicUrl } : {},
203
345
  stateFile,
204
- forward: () => {
346
+ forward: async (path) => {
205
347
  lookAtDisk()
206
- return site?.forward()
348
+ if (path.startsWith(outputsPath)) return await jobs.origin()
349
+ if (!site) return undefined
350
+ // Straight to the dev server of the path's document, not through the site's router: one hop less per module.
351
+ try {
352
+ return await site.target(path)
353
+ } catch {
354
+ return site.forward()
355
+ }
207
356
  },
208
357
  settings: config.settings,
209
358
  approvals: config.approvals,
210
- ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
359
+ operatorDir: operatorDirectory,
211
360
  serverHost,
212
361
  env,
213
362
  ...options.approvalTimeoutMs ? { approvalTimeoutMs: options.approvalTimeoutMs } : {},
214
363
  log,
215
364
  async document(dir) {
216
365
  await ready
217
- if (dir === '' && !config.single) return await homeDocument()
366
+ if (dir === '' && !config.singleDeck) return await homeDocument()
218
367
  if (!docs().some(doc => doc.dir === dir)) return undefined
219
- const compilation = await sessionOf(dir).ready()
368
+ const compilation = await compileSessionOf(dir).ready()
220
369
  const served = servedDocument(compilation)
221
- const reviews = workspace?.reviews
222
- const service = <T>(token: ServiceToken<T>) => served.service(token) ?? (reviews && token.id === CHANGE_REVIEW.id ? reviews.service as T : undefined)
223
- return { ...served, service, plugins: [plugin(), ...served.plugins] }
370
+ const taskReviews = history?.taskReviews
371
+ const service = <T>(token: ServiceToken<T>) => served.service(token) ?? (taskReviews && token.id === CHANGE_REVIEW.id ? taskReviews as T : undefined)
372
+ return { ...served, service, plugins: [routesPlugin!, ...served.plugins] }
224
373
  },
225
374
  })
226
375
  const running = server
227
- services = running.services
228
- service = workspaceService({ root, sessionOf, serverHost, url: () => running.url, preview, stateDir: stateDir(root, env), log })
229
- workspace = workspaceOf()
230
- hostServices = [
231
- ...workspace.revisions ? [[REVISIONS, workspace.revisions] as [ServiceToken<unknown>, unknown]] : [],
232
- [DOCUMENT_FILES, workspace.writer],
376
+ serverServices = running.services
377
+ voiceAuthoring = createDocumentVoiceAuthoring({ editor, services: authoringServices, revision: document => { const latest = compileSessions.get(document)?.latest; return latest ? irRevision(latest.doc) : undefined } })
378
+ workbench = createWorkbenchBackend({ root, compileSessionOf, serverHost, voices: voiceAuthoring.service, diagnostics: running.diagnostics, url: () => options.publicUrl ? new URL(options.publicUrl).origin : running.url, basePath, preview, log, warmSite: dir => site?.warm(dir) ?? Promise.resolve(), viewerPage: (dir, wait) => site?.app(dir, wait) ?? Promise.resolve(undefined) })
379
+ history = openHistory()
380
+ documentEditor = createDocumentEditor({ productionPlan: async (document, source) => { const audio = (await authoringServices(document, source)).audio; if (!audio) throw new RequestError(503, 'caption-production-unavailable'); return audio.readPlan({ source }) }, editor, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
381
+ projectAuthoring = createProjectAuthoring({ config, documentEditor, operations: history?.operations, revisions: history?.revisions, git: history?.git })
382
+ authoringClients = createAuthoringClients(editor)
383
+ documentHistory = createDocumentHistory({ config, editor, locks, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required', changed: (before, after) => documentsChanged(documentsDiff(before, after)), reverted: (target, revision) => history?.agentSessionReviews.reverted(target, revision) })
384
+ transcriptions = createDocumentTranscriptions({ root, editor, services: authoringServices })
385
+ transcriptionAdoption = createTranscriptionAdoption({ documentEditor, transcriptions })
386
+ audioAuthoring = createDocumentAudioAuthoring({ root, editor, revisions: history?.revisions, services: authoringServices, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
387
+ imageAuthoring = createDocumentImageAuthoring({ root, editor, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
388
+ const routeOptions = {
389
+ speechSettings: speechSettingsRoute(operatorDirectory, name => env[name]),
390
+ config,
391
+ editor,
392
+ documentEditor,
393
+ transcriptions,
394
+ transcriptionAdoptionRoutes: transcriptionAdoption.routes,
395
+ audioAuthoringRoutes: audioAuthoring.routes,
396
+ imageAuthoringRoutes: imageAuthoring.routes,
397
+ historyRoutes: documentHistory.routes,
398
+ authoringClientRoutes: authoringClients.routes,
399
+ projectRoutes: projectAuthoring.routes,
400
+ locks,
401
+ ...history ? { revisions: history.revisions, operations: history.operations, reviews: history.agentSessionReviews } : {},
402
+ documentsChanged,
403
+ workbench,
404
+ jobs,
405
+ ...launcherToken ? { launcherToken } : {},
406
+ }
407
+ workspaceRoutesOwner = workspaceRoutes(routeOptions)
408
+ routesPlugin = { id: WORKSPACE_ID, plugin: workspaceRoutesPlugin(routeOptions, workspaceRoutesOwner.routes), config: {} }
409
+ workspaceServices = [
410
+ ...history ? [[REVISIONS, history.revisions] as [ServiceToken<unknown>, unknown]] : [],
411
+ [DOCUMENT_FILES, writer],
233
412
  [DOCUMENT_LOCKS, locks],
413
+ [DOCUMENT_VOICE_TASKS, voiceAuthoring.service],
234
414
  ]
235
- await workspace.reviews?.recover(docs().map(doc => doc.dir))
415
+ try {
416
+ await history?.agentSessionReviews.recover(docs().map(doc => doc.dir))
417
+ } catch (error) {
418
+ history?.agentSessionReviews.close()
419
+ await closeAuthoring()
420
+ await workbench.close()
421
+ await running.close()
422
+ throw error
423
+ }
236
424
 
237
425
  started()
238
- if (config.single) for (const { dir } of docs()) void sessionOf(dir).ready().catch(() => undefined)
239
426
 
240
- const compileFull = async (dir: string): Promise<IRDocument> => {
241
- const compilation = await sessionOf(dir).ready()
242
- return service!.siteDoc(compilation, siteIR(compilation))
427
+ const compileForSite = async (dir: string): Promise<IRDocument> => {
428
+ const compilation = await compileSessionOf(dir).ready()
429
+ refreshDocumentVitePlugins(spec, dir, compilation)
430
+ return workbench!.stampPreviewRevision(compilation, siteDocumentIR(compilation))
243
431
  }
244
432
  try {
245
- site = await localSite({ root, spec, bundled: options.bundled, state: stateDir(root, env), compile: compileFull, siteDoc: (compilation, doc) => service!.siteDoc(compilation, doc), preview, log })
433
+ site = await startWorkspaceSite({ root, spec, basePath, bundled: options.bundled, stateDir, compile: compileForSite, stampPreviewRevision: (compilation, doc) => workbench!.stampPreviewRevision(compilation, doc), diagnostics: running.diagnostics, preview, log })
246
434
  } catch (error) {
247
- await service?.close()
435
+ await closeAuthoring()
436
+ await workbench.close()
248
437
  await running.close()
249
438
  if (home) rmSync(home, { recursive: true, force: true })
250
439
  throw error
@@ -255,9 +444,11 @@ export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceSe
255
444
  root,
256
445
  mcp: { url: `${running.url}/mcp`, stateFile },
257
446
  async close() {
258
- workspace?.reviews?.close()
259
- for (const session of sessions.values()) session.close()
260
- await service?.close()
447
+ history?.agentSessionReviews.close()
448
+ await closeAuthoring()
449
+ for (const session of compileSessions.values()) session.close()
450
+ await jobs.close()
451
+ await workbench?.close()
261
452
  await site?.close()
262
453
  await running.close()
263
454
  if (home) rmSync(home, { recursive: true, force: true })
@@ -265,38 +456,48 @@ export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceSe
265
456
  }
266
457
  }
267
458
 
268
- interface SiteBackend {
269
- forward(): string | undefined
270
- update(dir: string, compilation: Compilation): Promise<void> | void
271
- documentsChanged(removed: string[]): Promise<void>
272
- close(): Promise<void>
273
- }
274
-
275
- interface LocalSiteOptions {
459
+ interface WorkspaceSiteOptions {
276
460
  root: string
461
+ basePath: string
277
462
  spec: SiteSpec
278
463
  bundled?: string
279
- state: string
464
+ stateDir: string
280
465
  compile(dir: string): Promise<IRDocument>
281
- siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
466
+ stampPreviewRevision(compilation: Compilation, doc: IRDocument): IRDocument
282
467
  preview: { origins: string[] }
283
468
  log: Logger
469
+ diagnostics: DiagnosticStore
284
470
  }
285
471
 
286
- async function localSite(options: LocalSiteOptions): Promise<SiteBackend> {
472
+ type WorkspaceSite = Awaited<ReturnType<typeof startWorkspaceSite>>
473
+
474
+ /** The workspace site's dev router in this process, which serve forwards the site's paths to. */
475
+ async function startWorkspaceSite(options: WorkspaceSiteOptions) {
287
476
  const { root, spec, log } = options
288
477
  const renderer = await webRenderer(root, options.bundled)
289
478
  const findings: Finding[] = []
290
- const work = join(options.state, 'site', 'full')
291
- const ctx = siteContext({ root, outDir: work, workDir: work, findings })
479
+ const work = join(options.stateDir, 'site', 'full')
480
+ const ctx = siteContext({ root, outDir: work, workDir: work, base: options.basePath, findings })
292
481
  ctx.preview = options.preview
293
482
  const dev = await renderer.devSite!(spec, ctx, options.compile)
294
483
  const origin = new URL(dev.url).origin
295
484
  log.info(`the workspace site's dev router is at ${origin} (this machine only; pages reach it through serve)`)
296
485
  return {
297
- forward: () => origin,
298
- update: (dir, compilation) => dev.update(dir, options.siteDoc(compilation, siteIR(compilation))).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`)),
299
- documentsChanged: async () => {},
300
- close: () => dev.close(),
486
+ forward: (): string => origin,
487
+ target: (path: string): Promise<string> => dev.target?.(path) ?? Promise.resolve(origin),
488
+ update: (dir: string, compilation: Compilation): Promise<void> => {
489
+ refreshDocumentVitePlugins(spec, dir, compilation)
490
+ const revision = irRevision(compilation.doc)
491
+ return dev.update(dir, options.stampPreviewRevision(compilation, siteDocumentIR(compilation))).then(() => {
492
+ if (options.diagnostics.irRevision(dir) === revision) options.diagnostics.clear(dir, 'preview', 'web')
493
+ }, (error: unknown) => {
494
+ const message = `the site did not take ${dir}: ${errorMessage(error)}`
495
+ if (options.diagnostics.irRevision(dir) === revision) options.diagnostics.replace({ document: dir, origin: 'preview', renderer: 'web' }, [{ checkId: 'presmium.preview', severity: 'error', message }], { irRevision: revision })
496
+ log.warn(message)
497
+ })
498
+ },
499
+ warm: (dir: string): Promise<void> => dev.warm?.(dir) ?? Promise.resolve(),
500
+ app: (dir: string, wait: boolean): Promise<string | undefined> => dev.app?.(dir, wait) ?? Promise.resolve(undefined),
501
+ close: (): Promise<void> => dev.close(),
301
502
  }
302
503
  }