@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.
- package/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- 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 +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- 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 +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- 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
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
|
@@ -1,422 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { errorMessage } from '@lincyaw/kernel'
|
|
5
|
-
import type { ChangeReviewService, DocumentFiles, Logger, Member, Revision, ReviewChange, ReviewStep } from '@lincyaw/kernel'
|
|
6
|
-
import { trailers } from '../../domain/revisions.ts'
|
|
7
|
-
import type { WorkspaceConfig } from '../config.ts'
|
|
8
|
-
import { listDocuments } from '../config.ts'
|
|
9
|
-
import { lockedError, RequestError } from '../documents.ts'
|
|
10
|
-
import type { DocumentLocks } from '../locks.ts'
|
|
11
|
-
import type { Revisions } from '../revisions.ts'
|
|
12
|
-
import { GIT_ENGINE, leftSessionReview, openGitReview, recoverGitReview, resumeGitReview } from './git-review.ts'
|
|
13
|
-
import type { GitReview } from './git-review.ts'
|
|
14
|
-
|
|
15
|
-
export const SESSION_TRAILER = 'Presmium-Session'
|
|
16
|
-
export const TURN_TRAILER = 'Presmium-Turn'
|
|
17
|
-
|
|
18
|
-
export interface ReviewView {
|
|
19
|
-
id: string
|
|
20
|
-
document: string
|
|
21
|
-
engine: string
|
|
22
|
-
session: string
|
|
23
|
-
holder: string
|
|
24
|
-
turns: { from: number, to: number }
|
|
25
|
-
running: boolean
|
|
26
|
-
mode: 'required' | 'auto'
|
|
27
|
-
opened: string
|
|
28
|
-
steps: ReviewStep[]
|
|
29
|
-
changes: ReviewChange[]
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ReviewEnd {
|
|
33
|
-
outcome: 'accepted' | 'rejected' | 'rolled-back' | 'empty'
|
|
34
|
-
revision?: Revision
|
|
35
|
-
files: string[]
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface ReviewFollowed {
|
|
39
|
-
document: string
|
|
40
|
-
review?: ReviewView
|
|
41
|
-
ended?: ReviewEnd
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type ReviewEvent =
|
|
45
|
-
| { type: 'review', document: string, session: string, review?: ReviewView, ended?: ReviewEnd }
|
|
46
|
-
| { type: 'reverted', session: string, documents: string[], turns?: string, revision: string, subject: string, revertedBy: string, files: string[] }
|
|
47
|
-
|
|
48
|
-
export interface ReviewsOptions {
|
|
49
|
-
config: WorkspaceConfig
|
|
50
|
-
revisions: Revisions
|
|
51
|
-
locks: DocumentLocks
|
|
52
|
-
files?: DocumentFiles
|
|
53
|
-
log: Logger
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface Open {
|
|
57
|
-
review: GitReview
|
|
58
|
-
document: string
|
|
59
|
-
session: string
|
|
60
|
-
member: Member
|
|
61
|
-
turns: { from: number, to: number }
|
|
62
|
-
running: boolean
|
|
63
|
-
opened: string
|
|
64
|
-
release: () => void
|
|
65
|
-
timer?: ReturnType<typeof setTimeout>
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function reviewSettings(config: WorkspaceConfig): { mode: 'required' | 'auto', timeoutMs: number } {
|
|
69
|
-
const settings = config.settings['presmium.assistant'] ?? {}
|
|
70
|
-
const timeout = settings.reviewTimeoutMs
|
|
71
|
-
return {
|
|
72
|
-
mode: settings.review === 'auto' ? 'auto' : 'required',
|
|
73
|
-
timeoutMs: typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0 ? timeout : 30 * 60_000,
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const holderOf = (session: string) => `agent:${session}`
|
|
78
|
-
|
|
79
|
-
const inGitDir = (path: string) => path.split('/').some(part => part.toLowerCase() === '.git')
|
|
80
|
-
|
|
81
|
-
function summaryOf(files: string[], lang: string | undefined): string {
|
|
82
|
-
const zh = !lang || lang.startsWith('zh')
|
|
83
|
-
const names = files.slice(0, 3).map(file => file.split('/').at(-1)!)
|
|
84
|
-
const more = files.length - names.length
|
|
85
|
-
return zh
|
|
86
|
-
? `AI 修改:${names.join('、')}${more > 0 ? ` 等 ${files.length} 个文件` : ''}`
|
|
87
|
-
: `AI change: ${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export class Reviews {
|
|
91
|
-
readonly #options: ReviewsOptions
|
|
92
|
-
readonly #open = new Map<string, Open>()
|
|
93
|
-
readonly #queues = new Map<string, Promise<unknown>>()
|
|
94
|
-
readonly #followers = new Map<string, Set<(event: ReviewFollowed) => void>>()
|
|
95
|
-
readonly #listeners = new Set<(event: ReviewEvent) => void>()
|
|
96
|
-
#closed = false
|
|
97
|
-
|
|
98
|
-
constructor(options: ReviewsOptions) {
|
|
99
|
-
this.#options = options
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
readonly service: ChangeReviewService = {
|
|
103
|
-
engine: GIT_ENGINE,
|
|
104
|
-
open: ({ document, task }) => openGitReview({
|
|
105
|
-
workspaceRoot: this.#options.config.root,
|
|
106
|
-
deckDir: join(this.#options.config.root, document),
|
|
107
|
-
task,
|
|
108
|
-
files: this.#options.files,
|
|
109
|
-
onRestore: paths => this.#options.log.warn(`task ${task}: the agent changed the git directory, put back: ${paths.join(', ')}`),
|
|
110
|
-
warn: message => this.#options.log.warn(`task ${task}: ${message}`),
|
|
111
|
-
}),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async recover(documents: string[], { resume = true } = {}): Promise<void> {
|
|
115
|
-
const { config, log } = this.#options
|
|
116
|
-
for (const document of documents) {
|
|
117
|
-
const deckDir = join(config.root, document)
|
|
118
|
-
if (!existsSync(deckDir)) continue
|
|
119
|
-
try {
|
|
120
|
-
const left = leftSessionReview(deckDir)
|
|
121
|
-
if (left?.session) {
|
|
122
|
-
if (resume) await this.#serial(document, () => this.#resume(document, left))
|
|
123
|
-
continue
|
|
124
|
-
}
|
|
125
|
-
const found = await recoverGitReview({ workspaceRoot: config.root, deckDir, files: this.#options.files, onRestore: paths => log.warn(`the git directory as a change task found it, put back: ${paths.join(', ')}`) })
|
|
126
|
-
if (found) log.warn(`task ${found.task} was waiting for its review when the server ended: rolled back (${found.paths.join(', ') || 'no change'})`)
|
|
127
|
-
} catch (error) {
|
|
128
|
-
log.warn(`the review left in ${document || '.'} was not taken up: ${errorMessage(error)}`)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async #resume(document: string, left: NonNullable<ReturnType<typeof leftSessionReview>>): Promise<void> {
|
|
134
|
-
const { config, log } = this.#options
|
|
135
|
-
const session = left.session!
|
|
136
|
-
const release = this.#options.locks.claim(document, holderOf(session.session))
|
|
137
|
-
if (!release) throw new Error(`its lock is ${this.#options.locks.holder(document)}'s`)
|
|
138
|
-
try {
|
|
139
|
-
const review = await resumeGitReview(left, { workspaceRoot: config.root, files: this.#options.files, onRestore: paths => log.warn(`session ${session.session}: the agent changed the git directory, put back: ${paths.join(', ')}`) })
|
|
140
|
-
const open: Open = { review, document, session: session.session, member: session.member, turns: session.turns, running: false, opened: session.opened, release }
|
|
141
|
-
this.#open.set(document, open)
|
|
142
|
-
log.info(`the review of session ${session.session} in ${document || '.'} (turns ${session.turns.from}-${session.turns.to}) waits for its author again`)
|
|
143
|
-
this.#arm(open)
|
|
144
|
-
} catch (error) {
|
|
145
|
-
release()
|
|
146
|
-
throw error
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async view(document: string): Promise<ReviewView | undefined> {
|
|
151
|
-
const open = this.#open.get(document)
|
|
152
|
-
return open && await this.#view(open)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async #view(open: Open): Promise<ReviewView> {
|
|
156
|
-
return {
|
|
157
|
-
id: open.review.task,
|
|
158
|
-
document: open.document,
|
|
159
|
-
engine: GIT_ENGINE,
|
|
160
|
-
session: open.session,
|
|
161
|
-
holder: holderOf(open.session),
|
|
162
|
-
turns: { ...open.turns },
|
|
163
|
-
running: open.running,
|
|
164
|
-
mode: reviewSettings(this.#options.config).mode,
|
|
165
|
-
opened: open.opened,
|
|
166
|
-
steps: [...open.review.steps()],
|
|
167
|
-
changes: await open.review.diff(),
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
follow(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewFollowed }> {
|
|
172
|
-
const followers = this.#followers.get(document) ?? new Set()
|
|
173
|
-
this.#followers.set(document, followers)
|
|
174
|
-
return this.#stream(signal, (push) => {
|
|
175
|
-
followers.add(push)
|
|
176
|
-
void this.view(document).then(review => push({ document, ...review ? { review } : {} }), () => push({ document }))
|
|
177
|
-
this.#rearm(document)
|
|
178
|
-
return () => {
|
|
179
|
-
followers.delete(push)
|
|
180
|
-
this.#rearm(document)
|
|
181
|
-
}
|
|
182
|
-
}, 'review')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
events(signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewEvent }> {
|
|
186
|
-
return this.#stream(signal, (push) => {
|
|
187
|
-
this.#listeners.add(push)
|
|
188
|
-
return () => this.#listeners.delete(push)
|
|
189
|
-
}, undefined, data => data.type)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#stream<T>(signal: AbortSignal, subscribe: (push: (data: T) => void) => () => void, name?: string, nameOf?: (data: T) => string): AsyncIterable<{ event: string, data: T }> {
|
|
193
|
-
const waiting: T[] = []
|
|
194
|
-
let wake: (() => void) | undefined
|
|
195
|
-
const push = (data: T) => {
|
|
196
|
-
waiting.push(data)
|
|
197
|
-
wake?.()
|
|
198
|
-
}
|
|
199
|
-
const unsubscribe = subscribe(push)
|
|
200
|
-
signal.addEventListener('abort', () => wake?.(), { once: true })
|
|
201
|
-
return {
|
|
202
|
-
async* [Symbol.asyncIterator]() {
|
|
203
|
-
try {
|
|
204
|
-
while (!signal.aborted) {
|
|
205
|
-
const next = waiting.shift()
|
|
206
|
-
if (next !== undefined) {
|
|
207
|
-
yield { event: name ?? nameOf!(next), data: next }
|
|
208
|
-
continue
|
|
209
|
-
}
|
|
210
|
-
await new Promise<void>(resolve => wake = resolve)
|
|
211
|
-
wake = undefined
|
|
212
|
-
}
|
|
213
|
-
} finally {
|
|
214
|
-
unsubscribe()
|
|
215
|
-
}
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
open(document: string, body: { session: string, turn?: number }, member: Member): Promise<ReviewView> {
|
|
221
|
-
return this.#serial(document, async () => {
|
|
222
|
-
if (this.#closed) throw new RequestError(503, 'the workspace service is closing')
|
|
223
|
-
let open = this.#open.get(document)
|
|
224
|
-
if (open && open.session !== body.session) throw lockedError(document, holderOf(open.session))
|
|
225
|
-
if (!open) {
|
|
226
|
-
const release = this.#options.locks.claim(document, holderOf(body.session))
|
|
227
|
-
if (!release) throw lockedError(document, this.#options.locks.holder(document) ?? '')
|
|
228
|
-
const { config, log } = this.#options
|
|
229
|
-
const turn = body.turn ?? 0
|
|
230
|
-
const opened = new Date().toISOString()
|
|
231
|
-
try {
|
|
232
|
-
const review = await openGitReview({
|
|
233
|
-
workspaceRoot: config.root,
|
|
234
|
-
deckDir: join(config.root, document),
|
|
235
|
-
task: randomUUID(),
|
|
236
|
-
files: this.#options.files,
|
|
237
|
-
onRestore: paths => log.warn(`session ${body.session}: the agent changed the git directory, put back: ${paths.join(', ')}`),
|
|
238
|
-
warn: message => log.warn(`session ${body.session}: ${message}`),
|
|
239
|
-
session: { session: body.session, document, member, turns: { from: turn, to: turn }, opened },
|
|
240
|
-
})
|
|
241
|
-
open = { review, document, session: body.session, member, turns: { from: turn, to: turn }, running: true, opened, release }
|
|
242
|
-
} catch (error) {
|
|
243
|
-
release()
|
|
244
|
-
throw new RequestError(409, `the review of ${document || '.'} did not open: ${errorMessage(error)}`)
|
|
245
|
-
}
|
|
246
|
-
this.#open.set(document, open)
|
|
247
|
-
}
|
|
248
|
-
this.#turn(open, body.turn)
|
|
249
|
-
open.running = true
|
|
250
|
-
this.#rearm(document)
|
|
251
|
-
return await this.#changed(open)
|
|
252
|
-
})
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
step(document: string, body: { session: string, turn?: number, label?: string, tool?: string, end?: boolean }): Promise<ReviewView | undefined> {
|
|
256
|
-
return this.#serial(document, async () => {
|
|
257
|
-
const open = this.#mine(document, body.session)
|
|
258
|
-
this.#turn(open, body.turn)
|
|
259
|
-
if (body.label) await open.review.step(body.label, body.tool)
|
|
260
|
-
if (!body.end) {
|
|
261
|
-
open.running = true
|
|
262
|
-
return await this.#changed(open)
|
|
263
|
-
}
|
|
264
|
-
open.running = false
|
|
265
|
-
const changes = await open.review.diff()
|
|
266
|
-
if (changes.length === 0) {
|
|
267
|
-
await open.review.close()
|
|
268
|
-
await this.#end(open, { outcome: 'empty', files: [] })
|
|
269
|
-
return undefined
|
|
270
|
-
}
|
|
271
|
-
if (reviewSettings(this.#options.config).mode === 'auto') {
|
|
272
|
-
await this.#accept(open, [], undefined)
|
|
273
|
-
return undefined
|
|
274
|
-
}
|
|
275
|
-
this.#arm(open)
|
|
276
|
-
return await this.#changed(open)
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
accept(document: string, body: { reject?: string[], message?: string }): Promise<ReviewEnd> {
|
|
281
|
-
return this.#serial(document, () => this.#accept(this.#any(document), body.reject ?? [], body.message))
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
async #accept(open: Open, reject: string[], message: string | undefined): Promise<ReviewEnd> {
|
|
285
|
-
const rejected = new Set(reject)
|
|
286
|
-
for (const change of await open.review.diff()) if (inGitDir(change.path)) rejected.add(change.path)
|
|
287
|
-
const files = await open.review.accept([...rejected])
|
|
288
|
-
const lang = listDocuments(this.#options.config).find(doc => doc.dir === open.document)?.lang
|
|
289
|
-
const steps = open.review.steps().map(step => `- ${step.label}`).slice(0, 20)
|
|
290
|
-
let revision: Revision | undefined
|
|
291
|
-
try {
|
|
292
|
-
revision = await this.#options.revisions.commit({
|
|
293
|
-
files,
|
|
294
|
-
message: `${message?.trim() || summaryOf(files, lang)}${steps.length ? `\n\n${steps.join('\n')}` : ''}`,
|
|
295
|
-
origin: { kind: 'agent', task: open.review.task, member: open.member },
|
|
296
|
-
trailers: [[SESSION_TRAILER, open.session], [TURN_TRAILER, `${open.turns.from}-${open.turns.to}`]],
|
|
297
|
-
})
|
|
298
|
-
} catch (error) {
|
|
299
|
-
this.#options.log.warn(`the review of session ${open.session} in ${open.document || '.'} was accepted, but not committed: ${errorMessage(error)}`)
|
|
300
|
-
}
|
|
301
|
-
const ended: ReviewEnd = { outcome: 'accepted', ...revision ? { revision } : {}, files }
|
|
302
|
-
await this.#end(open, ended)
|
|
303
|
-
return ended
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
reject(document: string, body: { paths?: string[] }): Promise<ReviewFollowed> {
|
|
307
|
-
return this.#serial(document, async () => {
|
|
308
|
-
const open = this.#any(document)
|
|
309
|
-
if (body.paths) {
|
|
310
|
-
await open.review.restoreFiles(body.paths)
|
|
311
|
-
if ((await open.review.diff()).length > 0) return { document, review: await this.#changed(open) }
|
|
312
|
-
}
|
|
313
|
-
const files = (await open.review.diff()).map(change => change.path)
|
|
314
|
-
await open.review.rollback()
|
|
315
|
-
const ended: ReviewEnd = { outcome: 'rejected', files }
|
|
316
|
-
await this.#end(open, ended)
|
|
317
|
-
return { document, ended }
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
restore(document: string, body: { step: string }): Promise<ReviewView> {
|
|
322
|
-
return this.#serial(document, async () => {
|
|
323
|
-
const open = this.#any(document)
|
|
324
|
-
try {
|
|
325
|
-
await open.review.restoreTo!(body.step)
|
|
326
|
-
} catch (error) {
|
|
327
|
-
throw new RequestError(409, errorMessage(error))
|
|
328
|
-
}
|
|
329
|
-
return await this.#changed(open)
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async reverted(id: string, revertedBy: string): Promise<void> {
|
|
334
|
-
if (this.#listeners.size === 0) return
|
|
335
|
-
const { revisions } = this.#options
|
|
336
|
-
const message = await revisions.messageOf(id)
|
|
337
|
-
const found = message === undefined ? {} : trailers(message)
|
|
338
|
-
const session = found[SESSION_TRAILER]?.[0]
|
|
339
|
-
if (!session || message === undefined) return
|
|
340
|
-
const files = await revisions.filesOf(id)
|
|
341
|
-
this.#tell({ type: 'reverted', session, documents: await revisions.documentsOf(id), ...found[TURN_TRAILER]?.[0] ? { turns: found[TURN_TRAILER][0] } : {}, revision: id, subject: message.split('\n')[0] ?? '', revertedBy, files })
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
close(): void {
|
|
345
|
-
this.#closed = true
|
|
346
|
-
for (const open of this.#open.values()) {
|
|
347
|
-
clearTimeout(open.timer)
|
|
348
|
-
open.review.suspend()
|
|
349
|
-
open.release()
|
|
350
|
-
}
|
|
351
|
-
this.#open.clear()
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
#mine(document: string, session: string): Open {
|
|
355
|
-
const open = this.#any(document)
|
|
356
|
-
if (open.session !== session) throw lockedError(document, holderOf(open.session))
|
|
357
|
-
return open
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
#any(document: string): Open {
|
|
361
|
-
const open = this.#open.get(document)
|
|
362
|
-
if (!open) throw new RequestError(404, `no review of ${document || '.'} is open`)
|
|
363
|
-
return open
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
#turn(open: Open, turn: number | undefined): void {
|
|
367
|
-
if (turn === undefined || !Number.isInteger(turn) || turn < 0) return
|
|
368
|
-
open.turns = { from: open.turns.from === 0 ? turn : Math.min(open.turns.from, turn), to: Math.max(open.turns.to, turn) }
|
|
369
|
-
open.review.note({ session: open.session, document: open.document, member: open.member, turns: open.turns, opened: open.opened })
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async #changed(open: Open): Promise<ReviewView> {
|
|
373
|
-
const review = await this.#view(open)
|
|
374
|
-
this.#follow(open.document, { document: open.document, review })
|
|
375
|
-
this.#tell({ type: 'review', document: open.document, session: open.session, review })
|
|
376
|
-
return review
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async #end(open: Open, ended: ReviewEnd): Promise<void> {
|
|
380
|
-
clearTimeout(open.timer)
|
|
381
|
-
this.#open.delete(open.document)
|
|
382
|
-
open.release()
|
|
383
|
-
this.#follow(open.document, { document: open.document, ended })
|
|
384
|
-
this.#tell({ type: 'review', document: open.document, session: open.session, ended })
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
#follow(document: string, event: ReviewFollowed): void {
|
|
388
|
-
for (const push of this.#followers.get(document) ?? []) push(event)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
#tell(event: ReviewEvent): void {
|
|
392
|
-
for (const push of this.#listeners) push(event)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
#rearm(document: string): void {
|
|
396
|
-
const open = this.#open.get(document)
|
|
397
|
-
if (open) this.#arm(open)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
#arm(open: Open): void {
|
|
401
|
-
clearTimeout(open.timer)
|
|
402
|
-
open.timer = undefined
|
|
403
|
-
if (open.running || (this.#followers.get(open.document)?.size ?? 0) > 0) return
|
|
404
|
-
const { timeoutMs } = reviewSettings(this.#options.config)
|
|
405
|
-
open.timer = setTimeout(() => {
|
|
406
|
-
void this.#serial(open.document, async () => {
|
|
407
|
-
if (this.#open.get(open.document) !== open || open.running || (this.#followers.get(open.document)?.size ?? 0) > 0) return
|
|
408
|
-
const files = (await open.review.diff()).map(change => change.path)
|
|
409
|
-
await open.review.rollback()
|
|
410
|
-
this.#options.log.info(`the review of session ${open.session} in ${open.document || '.'}: no page followed it for ${Math.round(timeoutMs / 1000)} s: rolled back`)
|
|
411
|
-
await this.#end(open, { outcome: 'rolled-back', files })
|
|
412
|
-
}).catch((error: unknown) => this.#options.log.warn(`the review of session ${open.session} did not roll back: ${errorMessage(error)}`))
|
|
413
|
-
}, timeoutMs)
|
|
414
|
-
open.timer.unref?.()
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
#serial<T>(document: string, work: () => Promise<T>): Promise<T> {
|
|
418
|
-
const next = (this.#queues.get(document) ?? Promise.resolve()).then(work, work)
|
|
419
|
-
this.#queues.set(document, next.catch(() => undefined))
|
|
420
|
-
return next
|
|
421
|
-
}
|
|
422
|
-
}
|
package/src/node/service.ts
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { createServer } from 'node:net'
|
|
3
|
-
import type { AddressInfo } from 'node:net'
|
|
4
|
-
import { join, relative, sep } from 'node:path'
|
|
5
|
-
import { dev, relativeIR } from '@lincyaw/compiler'
|
|
6
|
-
import type { Compilation } from '@lincyaw/compiler'
|
|
7
|
-
import { errorMessage, PREVIEW_REVISION } from '@lincyaw/kernel'
|
|
8
|
-
import type { Finding, IRDocument, Logger, ProxySpec } from '@lincyaw/kernel'
|
|
9
|
-
import type { ServerHost } from '@lincyaw/server'
|
|
10
|
-
import { WORKSPACE_ID } from '../domain/api.ts'
|
|
11
|
-
import type { DocumentSession } from './session.ts'
|
|
12
|
-
|
|
13
|
-
export interface IrAnswer {
|
|
14
|
-
document: string
|
|
15
|
-
revision: string
|
|
16
|
-
doc: IRDocument
|
|
17
|
-
diagnostics: Finding[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface IrEvent {
|
|
21
|
-
document: string
|
|
22
|
-
revision: string
|
|
23
|
-
diagnostics: { errors: number, warnings: number }
|
|
24
|
-
files: string[]
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface PreviewAnswer {
|
|
28
|
-
document: string
|
|
29
|
-
web: string
|
|
30
|
-
slides?: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface BuildAnswer {
|
|
34
|
-
outputs: { renderer: string, outDir: string }[]
|
|
35
|
-
findings: Finding[]
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface CheckAnswer {
|
|
39
|
-
findings: Finding[]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface WorkspaceService {
|
|
43
|
-
ir(document: string): Promise<IrAnswer>
|
|
44
|
-
events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
|
|
45
|
-
preview(document: string): Promise<PreviewAnswer>
|
|
46
|
-
build(document: string, to?: string[]): Promise<BuildAnswer>
|
|
47
|
-
check(document: string): Promise<CheckAnswer>
|
|
48
|
-
proxies: ProxySpec[]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function irRevision(doc: IRDocument): string {
|
|
52
|
-
return createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function withRevision(doc: IRDocument, revision: string): IRDocument {
|
|
56
|
-
return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function workspacePath(root: string, file: string): string {
|
|
60
|
-
return relative(root, file).split(sep).join('/')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function freePort(): Promise<number> {
|
|
64
|
-
return new Promise((resolve, reject) => {
|
|
65
|
-
const server = createServer()
|
|
66
|
-
server.once('error', reject)
|
|
67
|
-
server.listen(0, '127.0.0.1', () => {
|
|
68
|
-
const { port } = server.address() as AddressInfo
|
|
69
|
-
server.close(() => resolve(port))
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function urlPath(dir: string): string {
|
|
75
|
-
return dir.split('/').map(encodeURIComponent).join('/')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function slidesPath(dir: string): string {
|
|
79
|
-
return `slides/${dir ? `${urlPath(dir)}/` : ''}`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface ServiceOptions {
|
|
83
|
-
root: string
|
|
84
|
-
sessionOf(dir: string): DocumentSession
|
|
85
|
-
serverHost: ServerHost
|
|
86
|
-
url(): string
|
|
87
|
-
preview?: { origins: string[] }
|
|
88
|
-
stateDir: string
|
|
89
|
-
log: Logger
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface Service extends WorkspaceService {
|
|
93
|
-
compiled(dir: string, compilation: Compilation, changed: string[]): void
|
|
94
|
-
siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
|
|
95
|
-
removed(dirs: string[]): Promise<void>
|
|
96
|
-
close(): Promise<void>
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function workspaceService(options: ServiceOptions): Service {
|
|
100
|
-
const { root, log } = options
|
|
101
|
-
const revisions = new WeakMap<Compilation, string>()
|
|
102
|
-
const revisionOf = (compilation: Compilation) => {
|
|
103
|
-
let known = revisions.get(compilation)
|
|
104
|
-
if (!known) revisions.set(compilation, known = irRevision(compilation.doc))
|
|
105
|
-
return known
|
|
106
|
-
}
|
|
107
|
-
const listeners = new Map<string, Set<(event: IrEvent) => void>>()
|
|
108
|
-
const slides = new Map<string, Promise<{ update(compilation: Compilation): Promise<void>, close(): Promise<void> } | undefined>>()
|
|
109
|
-
const proxies: ProxySpec[] = []
|
|
110
|
-
let closed = false
|
|
111
|
-
|
|
112
|
-
const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
|
|
113
|
-
const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
|
|
114
|
-
return { document: dir, revision: revisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
|
|
115
|
-
}
|
|
116
|
-
const stamped = (compilation: Compilation): Compilation => (options.preview ? { ...compilation, doc: withRevision(compilation.doc, revisionOf(compilation)) } : compilation)
|
|
117
|
-
|
|
118
|
-
const slidesOf = (dir: string) => {
|
|
119
|
-
let known = slides.get(dir)
|
|
120
|
-
if (!known) {
|
|
121
|
-
known = (async () => {
|
|
122
|
-
const session = options.sessionOf(dir)
|
|
123
|
-
const compilation = await session.ready()
|
|
124
|
-
const configured = Object.keys(compilation.doc.config.renderers)
|
|
125
|
-
if (configured.length > 0 && !configured.includes('slides')) return undefined
|
|
126
|
-
const path = slidesPath(dir)
|
|
127
|
-
const started = await dev(session.compiler, {
|
|
128
|
-
outDir: join(options.stateDir, 'preview', encodeURIComponent(dir) || '_'),
|
|
129
|
-
to: ['slides'],
|
|
130
|
-
compilation: stamped(compilation),
|
|
131
|
-
base: `/__presmium/${WORKSPACE_ID}/${path}`,
|
|
132
|
-
port: await freePort(),
|
|
133
|
-
...options.preview ? { preview: { origins: options.preview.origins } } : {},
|
|
134
|
-
onCompile(_compilation, findings) {
|
|
135
|
-
for (const finding of findings.filter(f => f.checkId === 'presmium.render')) log.warn(`the slides' preview of ${dir || '.'}: ${finding.message}`)
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
const server = started.servers[0]
|
|
139
|
-
if (!server || closed) {
|
|
140
|
-
await started.close()
|
|
141
|
-
return undefined
|
|
142
|
-
}
|
|
143
|
-
const target = new URL(server.url)
|
|
144
|
-
proxies.push({ path: path.replace(/\/$/, ''), target: `${target.origin}${target.pathname.replace(/\/$/, '')}` })
|
|
145
|
-
proxies.sort((a, b) => b.path.length - a.path.length)
|
|
146
|
-
return started
|
|
147
|
-
})()
|
|
148
|
-
known.catch((error: unknown) => {
|
|
149
|
-
log.warn(`the slides' preview of ${dir || '.'} did not start: ${errorMessage(error)}`)
|
|
150
|
-
slides.delete(dir)
|
|
151
|
-
})
|
|
152
|
-
slides.set(dir, known)
|
|
153
|
-
}
|
|
154
|
-
return known
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const stop = async (dir: string) => {
|
|
158
|
-
const known = slides.get(dir)
|
|
159
|
-
slides.delete(dir)
|
|
160
|
-
const path = slidesPath(dir).replace(/\/$/, '')
|
|
161
|
-
const at = proxies.findIndex(proxy => proxy.path === path)
|
|
162
|
-
if (at >= 0) proxies.splice(at, 1)
|
|
163
|
-
await (await known?.catch(() => undefined))?.close()
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
proxies,
|
|
168
|
-
async ir(document) {
|
|
169
|
-
const compilation = await options.sessionOf(document).ready()
|
|
170
|
-
const doc = relativeIR(compilation.doc, root, root)
|
|
171
|
-
return { document, revision: revisionOf(compilation), doc, diagnostics: doc.diagnostics }
|
|
172
|
-
},
|
|
173
|
-
events(document, signal) {
|
|
174
|
-
const waiting: IrEvent[] = []
|
|
175
|
-
let wake: (() => void) | undefined
|
|
176
|
-
const listener = (event: IrEvent) => {
|
|
177
|
-
waiting.push(event)
|
|
178
|
-
wake?.()
|
|
179
|
-
}
|
|
180
|
-
const own = listeners.get(document) ?? new Set()
|
|
181
|
-
listeners.set(document, own.add(listener))
|
|
182
|
-
const done = () => own.delete(listener)
|
|
183
|
-
signal.addEventListener('abort', () => {
|
|
184
|
-
done()
|
|
185
|
-
wake?.()
|
|
186
|
-
}, { once: true })
|
|
187
|
-
const session = options.sessionOf(document)
|
|
188
|
-
if (session.latest) waiting.push(eventOf(document, session.latest, []))
|
|
189
|
-
else void session.ready().catch(() => undefined)
|
|
190
|
-
return {
|
|
191
|
-
async* [Symbol.asyncIterator]() {
|
|
192
|
-
try {
|
|
193
|
-
while (!signal.aborted) {
|
|
194
|
-
const next = waiting.shift()
|
|
195
|
-
if (next) {
|
|
196
|
-
yield { event: 'revision', data: next }
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
await new Promise<void>(resolve => wake = resolve)
|
|
200
|
-
wake = undefined
|
|
201
|
-
}
|
|
202
|
-
} finally {
|
|
203
|
-
done()
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
}
|
|
207
|
-
},
|
|
208
|
-
async preview(document) {
|
|
209
|
-
const base = options.url()
|
|
210
|
-
const web = `${base}/${document ? `${urlPath(document)}/` : ''}`
|
|
211
|
-
const own = await slidesOf(document).catch(() => undefined)
|
|
212
|
-
return { document, web, ...own ? { slides: `${base}/__presmium/${WORKSPACE_ID}/${slidesPath(document)}` } : {} }
|
|
213
|
-
},
|
|
214
|
-
async build(document, to) {
|
|
215
|
-
return await options.serverHost.build(document, to ? { to } : {})
|
|
216
|
-
},
|
|
217
|
-
async check(document) {
|
|
218
|
-
return { findings: await options.serverHost.check(document) }
|
|
219
|
-
},
|
|
220
|
-
compiled(dir, compilation, changed) {
|
|
221
|
-
const event = eventOf(dir, compilation, changed)
|
|
222
|
-
for (const listener of listeners.get(dir) ?? []) listener(event)
|
|
223
|
-
const known = slides.get(dir)
|
|
224
|
-
if (known) void known.then(started => started?.update(stamped(compilation))).catch((error: unknown) => log.warn(`the slides' preview of ${dir || '.'} did not update: ${errorMessage(error)}`))
|
|
225
|
-
},
|
|
226
|
-
siteDoc(compilation, doc) {
|
|
227
|
-
return options.preview ? withRevision(doc, revisionOf(compilation)) : doc
|
|
228
|
-
},
|
|
229
|
-
async removed(dirs) {
|
|
230
|
-
for (const dir of dirs) await stop(dir)
|
|
231
|
-
},
|
|
232
|
-
async close() {
|
|
233
|
-
closed = true
|
|
234
|
-
for (const dir of [...slides.keys()]) await stop(dir)
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
}
|