@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.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +41 -3
- package/src/domain/commit-message.ts +80 -0
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +98 -0
- package/src/index.ts +11 -0
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +42 -0
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +141 -0
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +68 -0
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +46 -0
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +104 -0
- package/src/node/review/git-review.ts +428 -0
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +73 -0
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +599 -0
- package/src/node/routes.ts +329 -0
- package/src/node/serve.ts +503 -0
- package/src/node/site.ts +132 -0
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { createProjectAuthoring } from './projects.ts'
|
|
2
|
+
import { createDocumentHistory } from './document-history.ts'
|
|
3
|
+
import { createAuthoringClients } from './authoring-clients.ts'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
6
|
+
import { homedir, tmpdir } from 'node:os'
|
|
7
|
+
import { join, relative } from 'node:path'
|
|
8
|
+
import { Compiler, operatorDir as resolveOperatorDir, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
|
|
9
|
+
import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
|
|
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'
|
|
21
|
+
import { Git } from './git.ts'
|
|
22
|
+
import { createJobs, OUTPUTS_PATH } from './jobs.ts'
|
|
23
|
+
import { DocumentLocks } from './locks.ts'
|
|
24
|
+
import { AgentSessionReviews } from './review/agent-session-reviews.ts'
|
|
25
|
+
import { createTaskReviewService } from './review/task-reviews.ts'
|
|
26
|
+
import { Revisions } from './revisions.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'
|
|
41
|
+
|
|
42
|
+
export interface ServeOptions {
|
|
43
|
+
dir: string
|
|
44
|
+
listenAddress?: string
|
|
45
|
+
port?: number
|
|
46
|
+
operatorDir?: string
|
|
47
|
+
bundled?: string
|
|
48
|
+
check?(compilation: Compilation): Promise<Finding[]>
|
|
49
|
+
previewOrigins?: string[]
|
|
50
|
+
/** The address browsers use when a reverse proxy fronts the service (see `ServerOptions.publicUrl`). */
|
|
51
|
+
publicUrl?: string
|
|
52
|
+
env?: NodeJS.ProcessEnv
|
|
53
|
+
approvalTimeoutMs?: number
|
|
54
|
+
log?: Logger
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkspaceServer {
|
|
58
|
+
url: string
|
|
59
|
+
root: string
|
|
60
|
+
mcp: { url: string, stateFile: string }
|
|
61
|
+
close(): Promise<void>
|
|
62
|
+
}
|
|
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
|
+
|
|
76
|
+
const DISK_LOOK_MS = 1000
|
|
77
|
+
|
|
78
|
+
function makeStateDir(root: string, env: NodeJS.ProcessEnv): string {
|
|
79
|
+
const base = env.XDG_CACHE_HOME || join(homedir(), '.cache')
|
|
80
|
+
const dir = join(base, 'presmium', 'serve', createHash('sha256').update(root).digest('hex').slice(0, 16))
|
|
81
|
+
mkdirSync(dir, { recursive: true })
|
|
82
|
+
return dir
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceServer> {
|
|
86
|
+
const env = options.env ?? process.env
|
|
87
|
+
const log = options.log ?? pluginLogger('presmium serve')
|
|
88
|
+
const config = readWorkspaceConfig(options.dir)
|
|
89
|
+
const { root } = config
|
|
90
|
+
const operatorDirectory = resolveOperatorDir(root, options.operatorDir ?? env.PRESMIUM_OPERATOR_DIR)
|
|
91
|
+
const stateDir = makeStateDir(root, env)
|
|
92
|
+
const docs = () => listDocuments(config)
|
|
93
|
+
const compileSessions = new Map<string, CompileSession>()
|
|
94
|
+
const agentToolListeners = new Set<(document: string) => void>()
|
|
95
|
+
const locks = new DocumentLocks()
|
|
96
|
+
const writer = localWriter(config)
|
|
97
|
+
const editor = new SourceEditor({ config, writer, locks })
|
|
98
|
+
const check = options.check ?? (compilation => compilation.check())
|
|
99
|
+
const launcherToken = env.PRESMIUM_SERVICE_TOKEN || undefined
|
|
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 ?? ''
|
|
106
|
+
|
|
107
|
+
let serverServices: [ServiceToken<unknown>, unknown][] = []
|
|
108
|
+
let workspaceServices: [ServiceToken<unknown>, unknown][] = []
|
|
109
|
+
let site: WorkspaceSite | undefined
|
|
110
|
+
let server: PresmiumServer | 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
|
+
})
|
|
134
|
+
|
|
135
|
+
const compileSessionOf = (dir: string): CompileSession => {
|
|
136
|
+
const known = compileSessions.get(dir)
|
|
137
|
+
if (known) return known
|
|
138
|
+
const document = docs().find(doc => doc.dir === dir)
|
|
139
|
+
if (!document) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
|
|
140
|
+
const session = new CompileSession(dir, new Compiler(join(root, document.entry), { ...compilerOptions(), priority: 'interactive', execution: { env: 'serve' } }), log)
|
|
141
|
+
let configKey: string | undefined
|
|
142
|
+
session.onCompile(async (compilation) => {
|
|
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)) {
|
|
149
|
+
if (!compilation.doc.diagnostics.some(f => f.message === finding.message)) compilation.doc.diagnostics.push(finding)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
session.onCompile((compilation, changed) => {
|
|
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)}`))
|
|
157
|
+
})
|
|
158
|
+
session.onFailure(error => server?.diagnostics.failed(dir, errorMessage(error), { file: relative(root, session.compiler.entry).replace(/\\/g, '/') }))
|
|
159
|
+
compileSessions.set(dir, session)
|
|
160
|
+
return session
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const compilerOptions = (): CompilerOptions => ({
|
|
164
|
+
workspaceRoot: root,
|
|
165
|
+
settings: config.settings,
|
|
166
|
+
services: [...serverServices, ...workspaceServices],
|
|
167
|
+
bundled: options.bundled,
|
|
168
|
+
operatorDir: operatorDirectory,
|
|
169
|
+
secret: operatorSpeechSecrets(operatorDirectory, name => env[name]),
|
|
170
|
+
})
|
|
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
|
+
|
|
202
|
+
const spec: SiteSpec = siteSpec(config, docs().map(doc => siteDocument(doc)))
|
|
203
|
+
const refreshSpec = (): DocumentsChange => {
|
|
204
|
+
const retained = new Map(spec.documents.map(doc => [doc.dir, doc]))
|
|
205
|
+
const before = [...retained.keys()]
|
|
206
|
+
const now = docs()
|
|
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))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const documentsChanged = async (change: DocumentsChange): Promise<void> => {
|
|
221
|
+
for (const dir of change.removed) {
|
|
222
|
+
server?.diagnostics.forget(dir)
|
|
223
|
+
compileSessions.get(dir)?.close()
|
|
224
|
+
compileSessions.delete(dir)
|
|
225
|
+
for (const listener of agentToolListeners) listener(dir)
|
|
226
|
+
}
|
|
227
|
+
const listed = refreshSpec()
|
|
228
|
+
const removed = [...new Set([...change.removed, ...listed.removed])]
|
|
229
|
+
for (const dir of new Set([...removed, ...change.added, ...listed.added])) await server?.unmount(dir)
|
|
230
|
+
await workbench?.documentsRemoved(removed)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let lookedAt = 0
|
|
234
|
+
const lookAtDisk = (): void => {
|
|
235
|
+
if (Date.now() - lookedAt < DISK_LOOK_MS) return
|
|
236
|
+
lookedAt = Date.now()
|
|
237
|
+
const change = documentsDiff(spec.documents.map(doc => doc.dir), docs().map(doc => doc.dir))
|
|
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)}`))
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let started!: () => void
|
|
258
|
+
const ready = new Promise<void>(resolve => started = resolve)
|
|
259
|
+
|
|
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
|
+
},
|
|
282
|
+
async documents() {
|
|
283
|
+
return documentSummaries(config)
|
|
284
|
+
},
|
|
285
|
+
async compile(document) {
|
|
286
|
+
await ready
|
|
287
|
+
const { doc } = await compileSessionOf(document).ready()
|
|
288
|
+
return { doc, diagnostics: doc.diagnostics }
|
|
289
|
+
},
|
|
290
|
+
async check(document) {
|
|
291
|
+
await ready
|
|
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]
|
|
297
|
+
},
|
|
298
|
+
async build(document, buildOptions = {}) {
|
|
299
|
+
await ready
|
|
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 }
|
|
309
|
+
},
|
|
310
|
+
jobs,
|
|
311
|
+
async run(document, target, signal) {
|
|
312
|
+
await ready
|
|
313
|
+
return runBlock(await compileSessionOf(document).ready(), target, signal)
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let operators: ReturnType<typeof workspacePlugins> | undefined
|
|
318
|
+
let home: string | undefined
|
|
319
|
+
const loadOperatorPlugins = async () => {
|
|
320
|
+
await ready
|
|
321
|
+
home ??= mkdtempSync(join(tmpdir(), 'presmium-home-'))
|
|
322
|
+
return operators ??= workspacePlugins(root, compilerOptions())
|
|
323
|
+
}
|
|
324
|
+
const homeDocument = async (): Promise<ServedDocument> => {
|
|
325
|
+
const loaded = await loadOperatorPlugins()
|
|
326
|
+
for (const problem of loaded.problems) log.warn(`the home page: ${problem.message}`)
|
|
327
|
+
const own = (token: ServiceToken<unknown>) => [...serverServices, ...workspaceServices].find(([known]) => known.id === token.id)?.[1]
|
|
328
|
+
return {
|
|
329
|
+
deckDir: home!,
|
|
330
|
+
workspaceRoot: home!,
|
|
331
|
+
plugins: [routesPlugin!, ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
|
|
332
|
+
service: <T>(token: ServiceToken<T>) => (own(token) ?? loaded.service(token)) as T | undefined,
|
|
333
|
+
settings: config.settings,
|
|
334
|
+
operatorDir: operatorDirectory,
|
|
335
|
+
home: true,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const stateFile = join(stateDir, 'server.json')
|
|
340
|
+
server = await startServer({
|
|
341
|
+
mode: 'serve',
|
|
342
|
+
listenAddress: options.listenAddress,
|
|
343
|
+
port: options.port,
|
|
344
|
+
...options.publicUrl ? { publicUrl: options.publicUrl } : {},
|
|
345
|
+
stateFile,
|
|
346
|
+
forward: async (path) => {
|
|
347
|
+
lookAtDisk()
|
|
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
|
+
}
|
|
356
|
+
},
|
|
357
|
+
settings: config.settings,
|
|
358
|
+
approvals: config.approvals,
|
|
359
|
+
operatorDir: operatorDirectory,
|
|
360
|
+
serverHost,
|
|
361
|
+
env,
|
|
362
|
+
...options.approvalTimeoutMs ? { approvalTimeoutMs: options.approvalTimeoutMs } : {},
|
|
363
|
+
log,
|
|
364
|
+
async document(dir) {
|
|
365
|
+
await ready
|
|
366
|
+
if (dir === '' && !config.singleDeck) return await homeDocument()
|
|
367
|
+
if (!docs().some(doc => doc.dir === dir)) return undefined
|
|
368
|
+
const compilation = await compileSessionOf(dir).ready()
|
|
369
|
+
const served = servedDocument(compilation)
|
|
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] }
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
const running = server
|
|
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],
|
|
412
|
+
[DOCUMENT_LOCKS, locks],
|
|
413
|
+
[DOCUMENT_VOICE_TASKS, voiceAuthoring.service],
|
|
414
|
+
]
|
|
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
|
+
}
|
|
424
|
+
|
|
425
|
+
started()
|
|
426
|
+
|
|
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))
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
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 })
|
|
434
|
+
} catch (error) {
|
|
435
|
+
await closeAuthoring()
|
|
436
|
+
await workbench.close()
|
|
437
|
+
await running.close()
|
|
438
|
+
if (home) rmSync(home, { recursive: true, force: true })
|
|
439
|
+
throw error
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
url: running.url,
|
|
444
|
+
root,
|
|
445
|
+
mcp: { url: `${running.url}/mcp`, stateFile },
|
|
446
|
+
async 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()
|
|
452
|
+
await site?.close()
|
|
453
|
+
await running.close()
|
|
454
|
+
if (home) rmSync(home, { recursive: true, force: true })
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
interface WorkspaceSiteOptions {
|
|
460
|
+
root: string
|
|
461
|
+
basePath: string
|
|
462
|
+
spec: SiteSpec
|
|
463
|
+
bundled?: string
|
|
464
|
+
stateDir: string
|
|
465
|
+
compile(dir: string): Promise<IRDocument>
|
|
466
|
+
stampPreviewRevision(compilation: Compilation, doc: IRDocument): IRDocument
|
|
467
|
+
preview: { origins: string[] }
|
|
468
|
+
log: Logger
|
|
469
|
+
diagnostics: DiagnosticStore
|
|
470
|
+
}
|
|
471
|
+
|
|
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) {
|
|
476
|
+
const { root, spec, log } = options
|
|
477
|
+
const renderer = await webRenderer(root, options.bundled)
|
|
478
|
+
const findings: Finding[] = []
|
|
479
|
+
const work = join(options.stateDir, 'site', 'full')
|
|
480
|
+
const ctx = siteContext({ root, outDir: work, workDir: work, base: options.basePath, findings })
|
|
481
|
+
ctx.preview = options.preview
|
|
482
|
+
const dev = await renderer.devSite!(spec, ctx, options.compile)
|
|
483
|
+
const origin = new URL(dev.url).origin
|
|
484
|
+
log.info(`the workspace site's dev router is at ${origin} (this machine only; pages reach it through serve)`)
|
|
485
|
+
return {
|
|
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(),
|
|
502
|
+
}
|
|
503
|
+
}
|
package/src/node/site.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { compileTranslations, directoryAsset, fileHash, lineContaining, loadPlugins, nodeVitePlugins, portable, publishesDirectory, viewFor } from '@lincyaw/compiler'
|
|
4
|
+
import type { Compilation } from '@lincyaw/compiler'
|
|
5
|
+
import { DOCUMENT_ENTRY, isRecord, NON_FILE_URL, WEB_RENDERER_PACKAGE } from '@lincyaw/kernel'
|
|
6
|
+
import type { DocumentSummary, Finding, IRDocument, RenderContext, Renderer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
|
|
7
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
8
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
9
|
+
|
|
10
|
+
export async function webRenderer(root: string, bundled?: string): Promise<Renderer> {
|
|
11
|
+
const { plugins, problems } = await loadPlugins(root, [WEB_RENDERER_PACKAGE], { workspaceRoot: root, listed: [], bundled })
|
|
12
|
+
const renderer = plugins[0]?.plugin?.renderer
|
|
13
|
+
if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${WEB_RENDERER_PACKAGE}) is not installed: ${problems.map(p => p.message).join('; ') || 'it has no workspace site'}`)
|
|
14
|
+
return renderer
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The document as the workspace site has it, every form's blocks in it: its plugins named by their directories. */
|
|
18
|
+
export function siteDocumentIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
|
|
19
|
+
const dirs = doc.config.plugins.flatMap((name) => {
|
|
20
|
+
const loaded = compilation.plugins.find(plugin => plugin.name === name)
|
|
21
|
+
return loaded ? [loaded.dir] : []
|
|
22
|
+
})
|
|
23
|
+
return { ...doc, config: { ...doc.config, plugins: dirs } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The document's web form on the workspace site. */
|
|
27
|
+
export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
|
|
28
|
+
return viewFor(siteDocumentIR(compilation, doc), 'web')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function siteTranslations(compilation: Compilation): Promise<{ translations: NonNullable<SiteDocument['translations']>, findings: Finding[] }> {
|
|
32
|
+
if (!compilation.doc.config.translations) return { translations: [], findings: [] }
|
|
33
|
+
const { translations, findings } = await compileTranslations(compilation)
|
|
34
|
+
return { translations: translations.map(({ lang, doc }) => ({ lang, doc: siteIR(compilation, doc) })), findings }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function siteDocument(document: DocumentSummary, doc?: IRDocument, translations?: SiteDocument['translations'], compilation?: Compilation): SiteDocument {
|
|
38
|
+
return { dir: document.dir, title: document.title, description: document.description, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {}, ...compilation ? { nodeVitePlugins: nodeVitePlugins(compilation) } : {} }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function refreshDocumentVitePlugins(site: SiteSpec, dir: string, compilation: Compilation): void {
|
|
42
|
+
let document = site.documents.find(document => document.dir === dir)
|
|
43
|
+
if (!document) {
|
|
44
|
+
document = { dir, title: compilation.doc.meta.title, lang: compilation.doc.meta.lang }
|
|
45
|
+
site.documents.push(document)
|
|
46
|
+
}
|
|
47
|
+
document.nodeVitePlugins = nodeVitePlugins(compilation)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function siteSpec(config: WorkspaceConfig, documents: SiteDocument[]): SiteSpec {
|
|
51
|
+
return { title: config.title, documents, ...typeof config.site.home === 'string' ? { home: config.site.home } : {}, config: config.site }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SiteContextOptions {
|
|
55
|
+
root: string
|
|
56
|
+
outDir: string
|
|
57
|
+
workDir: string
|
|
58
|
+
base?: string
|
|
59
|
+
findings: Finding[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function siteContext(options: SiteContextOptions): RenderContext {
|
|
63
|
+
const { root } = options
|
|
64
|
+
const ctx: RenderContext = {
|
|
65
|
+
deckDir: root,
|
|
66
|
+
workspaceRoot: root,
|
|
67
|
+
outDir: options.outDir,
|
|
68
|
+
workDir: options.workDir,
|
|
69
|
+
config: {},
|
|
70
|
+
extensions: [],
|
|
71
|
+
plugins: [],
|
|
72
|
+
pluginDirs: {},
|
|
73
|
+
renderers: ['web'],
|
|
74
|
+
base: options.base ?? '/',
|
|
75
|
+
extensionFor: () => undefined,
|
|
76
|
+
portable: (value, from) => portable(value, from, root),
|
|
77
|
+
localAsset: (source, url) => publishedAsset(options, source, url, ctx.report),
|
|
78
|
+
report(finding) {
|
|
79
|
+
if (!options.findings.some(f => JSON.stringify(f) === JSON.stringify(finding))) options.findings.push(finding)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
return ctx
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deckOf(file: string, root: string): string | undefined {
|
|
86
|
+
for (let dir = dirname(file); isInside(dir, root); dir = dirname(dir)) {
|
|
87
|
+
if (existsSync(join(dir, DOCUMENT_ENTRY))) return dir
|
|
88
|
+
if (dir === root) return undefined
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function publishedAsset(options: SiteContextOptions, source: SourcePos, url: string, report: (finding: Finding) => void): { file: string, name: string } | undefined {
|
|
94
|
+
if (NON_FILE_URL.test(url)) return undefined
|
|
95
|
+
const path = url.replace(/[?#].*$/s, '')
|
|
96
|
+
if (!path || path.startsWith('/')) return undefined
|
|
97
|
+
let name = path
|
|
98
|
+
try {
|
|
99
|
+
name = decodeURIComponent(path)
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineContaining(source, url) } })
|
|
103
|
+
const target = resolve(dirname(source.file), name)
|
|
104
|
+
const stat = statSync(target, { throwIfNoEntry: false })
|
|
105
|
+
if (!stat) {
|
|
106
|
+
warn(`${url} is not published: there is no such file`)
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const root = realpathSync(options.root)
|
|
110
|
+
const real = realpathSync(target)
|
|
111
|
+
if (!isInside(real, root)) {
|
|
112
|
+
if (stat.isFile()) warn(`${url} is not published: the file lies outside the workspace`)
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
if (stat.isFile()) return { file: target, name: `${fileHash(target).slice(0, 8)}-${basename(target)}` }
|
|
116
|
+
const deck = deckOf(source.file, options.root)
|
|
117
|
+
if (!deck || !publishesDirectory(target, deck)) return undefined
|
|
118
|
+
const document: unknown = JSON.parse(readFileSync(join(deck, DOCUMENT_ENTRY), 'utf8'))
|
|
119
|
+
const lang = isRecord(document) && isRecord(document.metadata) ? document.metadata.lang : undefined
|
|
120
|
+
return directoryAsset(target, deck, join(options.workDir, 'files'), typeof lang === 'string' ? lang : undefined, message => warn(`${url} is not published: ${message}`))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function untranslatedFindings(compilation: Compilation, root: string): Finding[] {
|
|
124
|
+
const { translations } = compilation.doc.config
|
|
125
|
+
if (!translations) return []
|
|
126
|
+
return Object.entries(translations).map(([lang, entry]) => ({
|
|
127
|
+
checkId: 'presmium.translations',
|
|
128
|
+
severity: 'warning' as const,
|
|
129
|
+
message: `translation "${lang}" (${entry}) is not in the workspace site of presmium serve, which shows the originals; presmium build at the workspace root publishes it (or presmium build ${relative(root, compilation.deckDir).split(sep).join('/') || '.'} for this document alone)`,
|
|
130
|
+
source: { file: compilation.entry, line: 1 },
|
|
131
|
+
}))
|
|
132
|
+
}
|