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

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 +71 -9
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -0,0 +1,187 @@
1
+ import { existsSync, statSync, watch } from 'node:fs'
2
+ import type { FSWatcher } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+ import type { Compilation, Compiler } from '@lincyaw/compiler'
5
+ import { errorMessage } from '@lincyaw/kernel'
6
+ import type { Logger } from '@lincyaw/kernel'
7
+
8
+ /** One document of the workspace server: its compiler, the latest compilation, and recompiling when a file it read changes. */
9
+ export class CompileSession {
10
+ readonly dir: string
11
+ readonly compiler: Compiler
12
+ #latest?: Compilation
13
+ #first?: Promise<Compilation>
14
+ #running: Promise<unknown> = Promise.resolve()
15
+ #queued?: Promise<Compilation>
16
+ readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
17
+ readonly #failures = new Set<(error: unknown) => void>()
18
+ #changedFiles = new Set<string>()
19
+ readonly #watchers = new Map<string, FSWatcher>()
20
+ #relevant = new Map<string, Set<string>>()
21
+ #timer?: NodeJS.Timeout
22
+ #closed = false
23
+ #compiling = false
24
+ #version = 0
25
+ #controller?: AbortController
26
+ readonly #observed = new Map<string, number>()
27
+ readonly #log: Logger
28
+
29
+ constructor(dir: string, compiler: Compiler, log: Logger) {
30
+ this.dir = dir
31
+ this.compiler = compiler
32
+ this.#log = log
33
+ }
34
+
35
+ ready(): Promise<Compilation> {
36
+ if (this.#latest) return Promise.resolve(this.#latest)
37
+ if (!this.#first) {
38
+ const first = this.recompile()
39
+ first.catch(() => {
40
+ if (this.#first === first) this.#first = undefined
41
+ })
42
+ this.#first = first
43
+ }
44
+ return this.#first
45
+ }
46
+
47
+ get latest(): Compilation | undefined {
48
+ return this.#latest
49
+ }
50
+
51
+ onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
52
+ this.#listeners.add(listener)
53
+ return () => this.#listeners.delete(listener)
54
+ }
55
+
56
+ onFailure(listener: (error: unknown) => void): () => void {
57
+ this.#failures.add(listener)
58
+ return () => { this.#failures.delete(listener) }
59
+ }
60
+
61
+ recompile(): Promise<Compilation> {
62
+ if (this.#queued) return this.#queued
63
+ const next = this.#running.then(async () => {
64
+ this.#queued = undefined
65
+ this.#compiling = true
66
+ try {
67
+ let compilation: Compilation | undefined
68
+ const changed = new Set<string>()
69
+ let version: number
70
+ do {
71
+ version = this.#version
72
+ for (const file of this.#changedFiles) changed.add(file)
73
+ this.#changedFiles.clear()
74
+ const started = Date.now()
75
+ this.#log.info(`[compile] document=${JSON.stringify(this.dir || '.')} state=started changed=${changed.size}`)
76
+ const controller = this.#controller = new AbortController()
77
+ try {
78
+ compilation = await this.compiler.compile({ signal: controller.signal })
79
+ } catch (error) {
80
+ if (!this.#closed && controller.signal.aborted && version !== this.#version) continue
81
+ throw error
82
+ }
83
+ this.#watch(compilation, started)
84
+ this.#log.info(`[compile] document=${JSON.stringify(this.dir || '.')} state=compiled elapsedMs=${Date.now() - started} sections=${compilation.doc.sections.length} diagnostics=${compilation.doc.diagnostics.length}`)
85
+ } while (!this.#closed && version !== this.#version)
86
+ if (!compilation) throw new Error('the compilation did not complete')
87
+ if (this.#closed) return compilation
88
+ this.#latest = compilation
89
+ for (const listener of this.#listeners) {
90
+ try {
91
+ await listener(compilation, [...changed])
92
+ } catch (error) {
93
+ this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
94
+ }
95
+ }
96
+ return compilation
97
+ } finally {
98
+ this.#controller = undefined
99
+ this.#compiling = false
100
+ if (!this.#closed && this.#changedFiles.size > 0) this.#schedule()
101
+ }
102
+ })
103
+ this.#queued = next
104
+ this.#running = next.catch((error: unknown) => {
105
+ if (!this.#closed) {
106
+ this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`)
107
+ for (const listener of this.#failures) listener(error)
108
+ }
109
+ })
110
+ return next
111
+ }
112
+
113
+ close(): void {
114
+ this.#closed = true
115
+ this.#controller?.abort()
116
+ clearTimeout(this.#timer)
117
+ for (const watcher of this.#watchers.values()) watcher.close()
118
+ this.#watchers.clear()
119
+ this.#listeners.clear()
120
+ this.#failures.clear()
121
+ }
122
+
123
+ #watch(compilation: Compilation, started: number): void {
124
+ if (this.#closed) return
125
+ const files = new Set([...compilation.sourceFiles, ...compilation.inputs.files])
126
+ const next = new Map<string, Set<string>>()
127
+ for (const file of files) {
128
+ let dir = dirname(file)
129
+ let name = basename(file)
130
+ while (!existsSync(dir) && dirname(dir) !== dir) {
131
+ name = basename(dir)
132
+ dir = dirname(dir)
133
+ }
134
+ next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
135
+ }
136
+ this.#relevant = next
137
+ for (const [dir, watcher] of this.#watchers) {
138
+ if (next.has(dir)) continue
139
+ watcher.close()
140
+ this.#watchers.delete(dir)
141
+ }
142
+ for (const dir of next.keys()) {
143
+ if (this.#watchers.has(dir)) continue
144
+ const failed = (error: unknown) => {
145
+ if (this.#closed) return
146
+ const failure = new Error(`cannot watch ${dir}: ${errorMessage(error)}`)
147
+ this.#log.error(failure.message)
148
+ for (const listener of this.#failures) listener(failure)
149
+ }
150
+ try {
151
+ const watcher = watch(dir, (_event, name) => {
152
+ if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
153
+ })
154
+ this.#watchers.set(dir, watcher)
155
+ watcher.on('error', (error) => {
156
+ watcher.close()
157
+ if (this.#watchers.get(dir) === watcher) this.#watchers.delete(dir)
158
+ failed(error)
159
+ })
160
+ } catch (error) {
161
+ failed(error)
162
+ }
163
+ }
164
+ const modified = [...files].filter((file) => {
165
+ const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
166
+ const changed = time >= started - 20 && time <= Date.now() && this.#observed.get(file) !== time
167
+ this.#observed.set(file, time)
168
+ return changed
169
+ })
170
+ for (const file of this.#observed.keys()) if (!files.has(file)) this.#observed.delete(file)
171
+ for (const file of modified) this.#changed(file)
172
+ }
173
+
174
+ #changed(file: string): void {
175
+ if (this.#closed) return
176
+ this.#changedFiles.add(file)
177
+ this.#observed.set(file, statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0)
178
+ this.#version++
179
+ this.#controller?.abort()
180
+ if (!this.#compiling) this.#schedule()
181
+ }
182
+
183
+ #schedule(): void {
184
+ clearTimeout(this.#timer)
185
+ this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 10)
186
+ }
187
+ }
@@ -1,26 +1,24 @@
1
1
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
- import { DECK_ENTRY, readFrontmatter, WORKSPACE_CONFIG } from '@lincyaw/compiler'
3
+ import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
4
4
  import type { OperatorSettings } from '@lincyaw/compiler'
5
- import { errorMessage, isRecord } from '@lincyaw/kernel'
6
- import type { ApprovalKind } from '@lincyaw/kernel'
5
+ import { APPROVAL_KINDS, DOCUMENT_ENTRY, errorMessage, isRecord } from '@lincyaw/kernel'
6
+ import type { ApprovalKind, DocumentSummary } from '@lincyaw/kernel'
7
7
  import type { ApprovalPolicy } from '@lincyaw/server'
8
8
  import { parse as parseYaml } from 'yaml'
9
- import type { WorkspaceDocument } from '../domain/api.ts'
10
9
  import { matchesPattern, patternSegments } from '../domain/paths.ts'
11
10
 
12
- export const DEFAULT_DOCUMENTS = [`*/${DECK_ENTRY}`]
11
+ export const DEFAULT_DOCUMENTS = [`*/${DOCUMENT_ENTRY}`]
13
12
 
14
13
  const SKIPPED = new Set(['node_modules', '.git', '.presmium', 'dist'])
15
14
 
16
- const KINDS: ApprovalKind[] = ['operate', 'spend', 'write', 'agent']
17
15
  const POLICIES = new Set<ApprovalPolicy>(['off', 'ask', 'allow'])
18
16
 
19
17
  export interface WorkspaceConfig {
20
18
  root: string
21
19
  title: string
22
20
  documents: string[]
23
- single: boolean
21
+ singleDeck: boolean
24
22
  site: Record<string, unknown>
25
23
  approvals: Partial<Record<ApprovalKind, ApprovalPolicy>>
26
24
  templates?: string
@@ -31,7 +29,7 @@ function strings(value: unknown): string[] {
31
29
  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => v.trim()) : typeof value === 'string' && value.trim() ? [value.trim()] : []
32
30
  }
33
31
 
34
- export function readWorkspace(root: string): WorkspaceConfig {
32
+ export function readWorkspaceConfig(root: string): WorkspaceConfig {
35
33
  const dir = realpathSync(resolve(root))
36
34
  const file = join(dir, WORKSPACE_CONFIG)
37
35
  let data: Record<string, unknown> = {}
@@ -44,16 +42,20 @@ export function readWorkspace(root: string): WorkspaceConfig {
44
42
  }
45
43
  }
46
44
  const declared = strings(data.documents)
47
- const single = declared.length === 0 && existsSync(join(dir, DECK_ENTRY))
45
+ const singleDeck = declared.length === 0 && existsSync(join(dir, DOCUMENT_ENTRY))
48
46
  const approvals = isRecord(data.approvals) ? data.approvals : {}
49
47
  const settings = isRecord(data.settings) ? data.settings : {}
48
+ const renderers = isRecord(data.renderers) ? data.renderers : {}
49
+ for (const [path, value] of [['renderers.lecture', renderers.lecture], ['settings.presmium.lecture', settings['presmium.lecture']]] as const) {
50
+ if (isRecord(value) && Object.hasOwn(value, 'script')) throw new Error(`${file}: ${path}.script: External lecture scripts are not supported. Author narration in each document's lecture.json narrations.`)
51
+ }
50
52
  return {
51
53
  root: dir,
52
54
  title: typeof data.title === 'string' && data.title.trim() ? data.title.trim() : dir.split('/').at(-1) ?? 'Presmium',
53
- documents: single ? [DECK_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
54
- single,
55
+ documents: singleDeck ? [DOCUMENT_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
56
+ singleDeck,
55
57
  site: isRecord(data.site) ? data.site : {},
56
- approvals: Object.fromEntries(KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
58
+ approvals: Object.fromEntries(APPROVAL_KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
57
59
  ...typeof data.templates === 'string' && data.templates.trim() ? { templates: resolve(dir, data.templates.trim()) } : {},
58
60
  settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, isRecord(value) ? value : {}])),
59
61
  }
@@ -62,7 +64,7 @@ export function readWorkspace(root: string): WorkspaceConfig {
62
64
  export function isWorkspaceRoot(dir: string): boolean {
63
65
  if (!existsSync(join(dir, WORKSPACE_CONFIG))) return false
64
66
  try {
65
- return !readWorkspace(dir).single
67
+ return !readWorkspaceConfig(dir).singleDeck
66
68
  } catch {
67
69
  return true
68
70
  }
@@ -87,7 +89,7 @@ function entries(config: WorkspaceConfig): string[] {
87
89
  return names.flatMap((name) => {
88
90
  const path = join(dir, name)
89
91
  const next = rel ? `${rel}/${name}` : name
90
- if (last) return existsSync(path) && statSync(path).isFile() ? [next] : []
92
+ if (last) return name === DOCUMENT_ENTRY && existsSync(path) && statSync(path).isFile() ? [next] : []
91
93
  return existsSync(path) && statSync(path).isDirectory() ? walk(path, next, i + 1) : []
92
94
  })
93
95
  }
@@ -96,15 +98,23 @@ function entries(config: WorkspaceConfig): string[] {
96
98
  return found
97
99
  }
98
100
 
99
- export interface FoundDocument extends WorkspaceDocument {
101
+ export interface DocumentEntry {
102
+ dir: string
100
103
  entry: string
101
104
  }
102
105
 
103
- function documentOf(config: WorkspaceConfig, entry: string): FoundDocument {
104
- const dir = entry.split('/').slice(0, -1).join('/')
106
+ export interface FoundDocument extends DocumentSummary, DocumentEntry {}
107
+
108
+ /** The configured documents that exist on disk, without reading their content. */
109
+ export function listDocumentEntries(config: WorkspaceConfig): DocumentEntry[] {
110
+ return entries(config).map(entry => ({ dir: entry.split('/').slice(0, -1).join('/'), entry }))
111
+ }
112
+
113
+ export function readDocumentSummary(config: WorkspaceConfig, { dir, entry }: DocumentEntry): FoundDocument {
105
114
  let headmatter: Record<string, unknown> = {}
106
115
  try {
107
- headmatter = readFrontmatter(readFileSync(join(config.root, entry), 'utf8'))
116
+ const parsed: unknown = JSON.parse(readFileSync(join(config.root, entry), 'utf8'))
117
+ headmatter = isRecord(parsed) && isRecord(parsed.metadata) ? parsed.metadata : {}
108
118
  } catch {
109
119
  }
110
120
  const tags = strings(headmatter.tags)
@@ -112,15 +122,20 @@ function documentOf(config: WorkspaceConfig, entry: string): FoundDocument {
112
122
  dir,
113
123
  entry,
114
124
  title: typeof headmatter.title === 'string' && headmatter.title.trim() ? headmatter.title.trim() : dir || config.title,
125
+ ...typeof headmatter.description === 'string' && headmatter.description.trim() ? { description: headmatter.description.trim() } : {},
115
126
  ...typeof headmatter.lang === 'string' && headmatter.lang.trim() ? { lang: headmatter.lang.trim() } : {},
116
127
  ...tags.length > 0 ? { tags } : {},
117
128
  }
118
129
  }
119
130
 
120
131
  export function listDocuments(config: WorkspaceConfig): FoundDocument[] {
121
- return entries(config).map(entry => documentOf(config, entry))
132
+ return listDocumentEntries(config).map(entry => readDocumentSummary(config, entry))
133
+ }
134
+
135
+ export function documentSummaries(config: WorkspaceConfig): DocumentSummary[] {
136
+ return listDocuments(config).map(({ entry: _entry, ...document }) => document)
122
137
  }
123
138
 
124
- export function namedByPatterns(config: WorkspaceConfig, dir: string): boolean {
125
- return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DECK_ENTRY}` : DECK_ENTRY))
139
+ export function isDocumentDir(config: WorkspaceConfig, dir: string): boolean {
140
+ return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY))
126
141
  }
@@ -0,0 +1,30 @@
1
+ import { createReadStream, realpathSync } from 'node:fs'
2
+ import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
3
+ import { Readable } from 'node:stream'
4
+ import { lookup } from 'mime-types'
5
+ import type { TeachingDocument } from '@lincyaw/kernel'
6
+ import { RequestError } from './errors.ts'
7
+
8
+ /** Read only a file registered by the current canonical document, without compiling it. */
9
+ export function documentAssetResponse(root: string, directory: string, document: TeachingDocument, id: string, readableFile: (path: string) => string, signal?: AbortSignal): Response {
10
+ const asset = document.assets[id]
11
+ if (!asset) throw new RequestError(404, 'document-asset-missing')
12
+ if (!asset.location || /^(?:[a-z][a-z\d+.-]*:|\/)/i.test(asset.location)) throw new RequestError(400, 'document-asset-not-local')
13
+ let location = asset.location.replace(/[?#].*$/s, '')
14
+ try { location = decodeURIComponent(location) } catch { throw new RequestError(400, 'document-asset-path') }
15
+ if (!location || isAbsolute(location) || location.includes('\\') || location.includes('\0')) throw new RequestError(400, 'document-asset-path')
16
+ const workspace = realpathSync(root), target = resolve(workspace, directory, location)
17
+ const path = relative(workspace, target)
18
+ if (!path || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) throw new RequestError(403, 'document-asset-outside-workspace')
19
+ const file = readableFile(path.split(sep).join('/'))
20
+ const type = lookup(file) || 'application/octet-stream'
21
+ const inline = /^(?:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|bmp|x-icon)|audio\/|video\/)/i.test(type)
22
+ return new Response(Readable.toWeb(createReadStream(file, { signal })), { headers: {
23
+ 'content-type': type,
24
+ 'cache-control': 'private, no-store',
25
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(basename(file))}`,
26
+ 'x-content-type-options': 'nosniff',
27
+ 'cross-origin-resource-policy': 'same-origin',
28
+ 'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
29
+ } })
30
+ }