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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -0,0 +1,374 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { DOCUMENT_ENTRY, errorMessage, pushStream } from '@lincyaw/kernel'
5
+ import type { DocumentFiles, Logger, Member, ReviewEnd, ReviewSnapshot, ReviewUpdate } from '@lincyaw/kernel'
6
+ import { trailers } from '../../domain/commit-message.ts'
7
+ import type { WorkspaceConfig } from '../config.ts'
8
+ import { listDocuments } from '../config.ts'
9
+ import { RequestError } from '../errors.ts'
10
+ import { lockedError } from '../locks.ts'
11
+ import type { DocumentLocks } from '../locks.ts'
12
+ import type { Revisions } from '../revisions.ts'
13
+ import { writeChanges } from '../writer.ts'
14
+ import { GIT_ENGINE, openGitReview, pendingAgentSessionReview, resumeGitReview } from './git-review.ts'
15
+ import type { GitReview } from './git-review.ts'
16
+ import { readRecord } from './record.ts'
17
+ import type { ReviewRecord } from './record.ts'
18
+ import { rollBackLeftTaskReview } from './task-reviews.ts'
19
+ import { validateDocumentSource } from '../source-editor.ts'
20
+
21
+ export const AGENT_SESSION_TRAILER = 'Presmium-Session'
22
+ export const TURN_TRAILER = 'Presmium-Turn'
23
+
24
+ export type ReviewEvent =
25
+ | { type: 'review', document: string, session: string, review?: ReviewSnapshot, ended?: ReviewEnd }
26
+ | { type: 'reverted', session: string, documents: string[], turns?: string, revision: string, subject: string, revertedBy: string, files: string[] }
27
+
28
+ export interface AgentSessionReviewsOptions {
29
+ config: WorkspaceConfig
30
+ revisions: Revisions
31
+ locks: DocumentLocks
32
+ /** How a plugin's task writes, for rolling back the tasks a process left. */
33
+ files?: DocumentFiles
34
+ log: Logger
35
+ }
36
+
37
+ interface OpenReview {
38
+ review: GitReview
39
+ document: string
40
+ session: string
41
+ member: Member
42
+ turns: { from: number, to: number }
43
+ running: boolean
44
+ opened: string
45
+ release: () => void
46
+ timer?: ReturnType<typeof setTimeout>
47
+ }
48
+
49
+ function reviewSettings(config: WorkspaceConfig): { mode: 'required' | 'auto', timeoutMs: number } {
50
+ const settings = config.settings['presmium.assistant'] ?? {}
51
+ const timeout = settings.reviewTimeoutMs
52
+ return {
53
+ mode: settings.review === 'required' ? 'required' : 'auto',
54
+ timeoutMs: typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0 ? timeout : 30 * 60_000,
55
+ }
56
+ }
57
+
58
+ const lockHolder = (session: string) => `agent:${session}`
59
+
60
+ const isInGitDir = (path: string) => path.split('/').some(part => part.toLowerCase() === '.git')
61
+
62
+ function commitSummary(files: string[], lang: string | undefined): string {
63
+ const zh = !lang || lang.startsWith('zh')
64
+ const names = files.slice(0, 3).map(file => file.split('/').at(-1)!)
65
+ const more = files.length - names.length
66
+ return zh
67
+ ? `AI 修改:${names.join('、')}${more > 0 ? ` 等 ${files.length} 个文件` : ''}`
68
+ : `AI change: ${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
69
+ }
70
+
71
+ /**
72
+ * The review of a dsh agent session's changes to the whole workspace: one at a time, under the workspace's lock,
73
+ * across the session's turns. A review that no page watches rolls back after the configured time.
74
+ */
75
+ export class AgentSessionReviews {
76
+ readonly #options: AgentSessionReviewsOptions
77
+ #open?: OpenReview
78
+ #queue: Promise<unknown> = Promise.resolve()
79
+ readonly #workspaceFiles: DocumentFiles
80
+ readonly #watchers = new Set<(update: ReviewUpdate) => void>()
81
+ readonly #listeners = new Set<(event: ReviewEvent) => void>()
82
+ #closed = false
83
+
84
+ constructor(options: AgentSessionReviewsOptions) {
85
+ this.#options = options
86
+ this.#workspaceFiles = { write: async changes => writeChanges(options.config, changes, true) }
87
+ }
88
+
89
+ /** Takes up the reviews a process that has ended left: resumes its agent session's review, rolls back its tasks' reviews. */
90
+ async recover(documents: string[]): Promise<void> {
91
+ const { config, log } = this.#options
92
+ const legacy = documents.filter(document => document !== '' && readRecord(join(config.root, document))?.session)
93
+ if (legacy.length > 0) throw new Error(`Cannot restore document-scoped session reviews in ${legacy.join(', ')} as a workspace review. Use the previous Presmium version to accept or reject them first. Their review records and snapshots have been kept.`)
94
+ for (const document of new Set([...documents, ''])) {
95
+ const scopeDir = join(config.root, document)
96
+ if (!existsSync(scopeDir)) continue
97
+ try {
98
+ const pending = pendingAgentSessionReview(scopeDir)
99
+ if (pending?.session) {
100
+ await this.#serial(() => this.#resume(pending))
101
+ continue
102
+ }
103
+ await rollBackLeftTaskReview(this.#options, document)
104
+ } catch (error) {
105
+ log.warn(`the review left in ${document || '.'} was not taken up: ${errorMessage(error)}`)
106
+ }
107
+ }
108
+ }
109
+
110
+ async #resume(pending: ReviewRecord): Promise<void> {
111
+ const { config, log } = this.#options
112
+ const session = pending.session!
113
+ const release = this.#options.locks.claim('', lockHolder(session.session))
114
+ if (!release) throw new Error(`its lock is ${this.#options.locks.holder('')}'s`)
115
+ try {
116
+ const review = await resumeGitReview(pending, { workspaceRoot: config.root, files: this.#workspaceFiles, onRestore: paths => log.warn(`session ${session.session}: the agent changed the git directory, put back: ${paths.join(', ')}`) })
117
+ const open: OpenReview = { review, document: session.document, session: session.session, member: session.member, turns: session.turns, running: false, opened: session.opened, release }
118
+ this.#open = open
119
+ log.info(`the review of session ${session.session} in ${session.document || '.'} (turns ${session.turns.from}-${session.turns.to}) waits for its author again`)
120
+ this.#scheduleRollback(open)
121
+ } catch (error) {
122
+ release()
123
+ throw error
124
+ }
125
+ }
126
+
127
+ async snapshot(): Promise<ReviewSnapshot | undefined> {
128
+ const open = this.#open
129
+ return open && await this.#snapshotOf(open)
130
+ }
131
+
132
+ async #snapshotOf(open: OpenReview): Promise<ReviewSnapshot> {
133
+ return {
134
+ id: open.review.task,
135
+ document: open.document,
136
+ engine: GIT_ENGINE,
137
+ session: open.session,
138
+ holder: lockHolder(open.session),
139
+ turns: { ...open.turns },
140
+ running: open.running,
141
+ mode: reviewSettings(this.#options.config).mode,
142
+ opened: open.opened,
143
+ steps: [...open.review.steps()],
144
+ changes: await open.review.diff(),
145
+ }
146
+ }
147
+
148
+ /** The review's updates for a page; while a page watches, the review does not time out. */
149
+ watch(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewUpdate }> {
150
+ return pushStream((push) => {
151
+ const send = (update: ReviewUpdate) => push({ event: 'review', data: { ...update, document } })
152
+ this.#watchers.add(send)
153
+ void this.snapshot().then(review => send({ document, ...review ? { review } : {} }), () => send({ document }))
154
+ this.#rescheduleRollback()
155
+ return () => {
156
+ this.#watchers.delete(send)
157
+ this.#rescheduleRollback()
158
+ }
159
+ }, { signal })
160
+ }
161
+
162
+ events(signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewEvent }> {
163
+ return pushStream((push) => {
164
+ const listener = (event: ReviewEvent) => push({ event: event.type, data: event })
165
+ this.#listeners.add(listener)
166
+ return () => this.#listeners.delete(listener)
167
+ }, { signal })
168
+ }
169
+
170
+ open(document: string, body: { session: string, turn?: number }, member: Member): Promise<ReviewSnapshot> {
171
+ return this.#serial(async () => {
172
+ if (this.#closed) throw new RequestError(503, 'the workspace service is closing')
173
+ let open = this.#open
174
+ if (open && open.session !== body.session) throw lockedError(document, lockHolder(open.session))
175
+ if (!open) {
176
+ const release = this.#options.locks.claim('', lockHolder(body.session))
177
+ if (!release) throw lockedError(document, this.#options.locks.holder('')!)
178
+ const { config, log } = this.#options
179
+ const turn = body.turn ?? 0
180
+ const opened = new Date().toISOString()
181
+ try {
182
+ const review = await openGitReview({
183
+ workspaceRoot: config.root,
184
+ scopeDir: config.root,
185
+ task: randomUUID(),
186
+ files: this.#workspaceFiles,
187
+ onRestore: paths => log.warn(`session ${body.session}: the agent changed the git directory, put back: ${paths.join(', ')}`),
188
+ warn: message => log.warn(`session ${body.session}: ${message}`),
189
+ session: { session: body.session, document, member, turns: { from: turn, to: turn }, opened },
190
+ })
191
+ open = { review, document, session: body.session, member, turns: { from: turn, to: turn }, running: true, opened, release }
192
+ } catch (error) {
193
+ release()
194
+ throw new RequestError(409, `the review of ${document || '.'} did not open: ${errorMessage(error)}`)
195
+ }
196
+ this.#open = open
197
+ }
198
+ this.#noteTurn(open, body.turn)
199
+ open.running = true
200
+ this.#rescheduleRollback()
201
+ return await this.#publish(open)
202
+ })
203
+ }
204
+
205
+ step(document: string, body: { session: string, turn?: number, label?: string, tool?: string, end?: boolean }): Promise<ReviewSnapshot | undefined> {
206
+ return this.#serial(async () => {
207
+ const open = this.#requireSession(document, body.session)
208
+ this.#noteTurn(open, body.turn)
209
+ if (body.label) await open.review.step(body.label, body.tool)
210
+ if (!body.end) {
211
+ open.running = true
212
+ return await this.#publish(open)
213
+ }
214
+ open.running = false
215
+ const changes = await open.review.diff()
216
+ if (changes.length === 0) {
217
+ await open.review.close()
218
+ await this.#end(open, { outcome: 'empty', files: [] })
219
+ return undefined
220
+ }
221
+ if (reviewSettings(this.#options.config).mode === 'auto') {
222
+ await this.#accept(open, [], undefined)
223
+ return undefined
224
+ }
225
+ this.#scheduleRollback(open)
226
+ return await this.#publish(open)
227
+ })
228
+ }
229
+
230
+ accept(document: string, body: { reject?: string[], message?: string }): Promise<ReviewEnd> {
231
+ return this.#serial(() => this.#accept(this.#requireOpen(document), body.reject ?? [], body.message))
232
+ }
233
+
234
+ async #accept(open: OpenReview, reject: string[], message: string | undefined): Promise<ReviewEnd> {
235
+ const rejected = new Set(reject)
236
+ for (const change of await open.review.diff()) if (isInGitDir(change.path)) rejected.add(change.path)
237
+ const files = await open.review.prepareAccept([...rejected])
238
+ for (const path of files) {
239
+ if (path !== DOCUMENT_ENTRY && !path.endsWith(`/${DOCUMENT_ENTRY}`)) continue
240
+ const file = join(this.#options.config.root, path)
241
+ if (existsSync(file)) validateDocumentSource(path, readFileSync(file, 'utf8'))
242
+ }
243
+ const lang = listDocuments(this.#options.config).find(doc => doc.dir === open.document)?.lang
244
+ const steps = open.review.steps().map(step => `- ${step.label}`).slice(0, 20)
245
+ const revision = await this.#options.revisions.commit({
246
+ files,
247
+ message: `${message?.trim() || commitSummary(files, lang)}${steps.length ? `\n\n${steps.join('\n')}` : ''}`,
248
+ origin: { kind: 'agent', task: open.review.task, member: open.member },
249
+ trailers: [[AGENT_SESSION_TRAILER, open.session], [TURN_TRAILER, `${open.turns.from}-${open.turns.to}`]],
250
+ })
251
+ open.review.finish()
252
+ const ended: ReviewEnd = { outcome: 'accepted', ...revision ? { revision } : {}, files }
253
+ await this.#end(open, ended)
254
+ return ended
255
+ }
256
+
257
+ reject(document: string, body: { paths?: string[] }): Promise<ReviewUpdate> {
258
+ return this.#serial(async () => {
259
+ const open = this.#requireOpen(document)
260
+ if (body.paths) {
261
+ await open.review.restoreFiles(body.paths)
262
+ if ((await open.review.diff()).length > 0) return { document, review: await this.#publish(open) }
263
+ }
264
+ const files = (await open.review.diff()).map(change => change.path)
265
+ await open.review.rollback()
266
+ const ended: ReviewEnd = { outcome: 'rejected', files }
267
+ await this.#end(open, ended)
268
+ return { document, ended }
269
+ })
270
+ }
271
+
272
+ restore(document: string, body: { step: string }): Promise<ReviewSnapshot> {
273
+ return this.#serial(async () => {
274
+ const open = this.#requireOpen(document)
275
+ try {
276
+ await open.review.restoreTo!(body.step)
277
+ } catch (error) {
278
+ throw new RequestError(409, errorMessage(error))
279
+ }
280
+ return await this.#publish(open)
281
+ })
282
+ }
283
+
284
+ async reverted(id: string, revertedBy: string): Promise<void> {
285
+ if (this.#listeners.size === 0) return
286
+ const { revisions } = this.#options
287
+ const message = await revisions.messageOf(id)
288
+ const found = message === undefined ? {} : trailers(message)
289
+ const session = found[AGENT_SESSION_TRAILER]?.[0]
290
+ if (!session || message === undefined) return
291
+ const files = await revisions.filesOf(id)
292
+ this.#emit({ 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 })
293
+ }
294
+
295
+ close(): void {
296
+ this.#closed = true
297
+ const open = this.#open
298
+ if (open) {
299
+ clearTimeout(open.timer)
300
+ open.review.suspend()
301
+ open.release()
302
+ }
303
+ this.#open = undefined
304
+ }
305
+
306
+ #requireSession(document: string, session: string): OpenReview {
307
+ const open = this.#requireOpen(document)
308
+ if (open.session !== session) throw lockedError(document, lockHolder(open.session))
309
+ return open
310
+ }
311
+
312
+ #requireOpen(document: string): OpenReview {
313
+ const open = this.#open
314
+ if (!open) throw new RequestError(404, `no review of ${document || '.'} is open`)
315
+ return open
316
+ }
317
+
318
+ #noteTurn(open: OpenReview, turn: number | undefined): void {
319
+ if (turn === undefined || !Number.isInteger(turn) || turn < 0) return
320
+ open.turns = { from: open.turns.from === 0 ? turn : Math.min(open.turns.from, turn), to: Math.max(open.turns.to, turn) }
321
+ open.review.noteSession({ session: open.session, document: open.document, member: open.member, turns: open.turns, opened: open.opened })
322
+ }
323
+
324
+ async #publish(open: OpenReview): Promise<ReviewSnapshot> {
325
+ const review = await this.#snapshotOf(open)
326
+ this.#sendToWatchers({ document: open.document, review })
327
+ this.#emit({ type: 'review', document: open.document, session: open.session, review })
328
+ return review
329
+ }
330
+
331
+ async #end(open: OpenReview, ended: ReviewEnd): Promise<void> {
332
+ clearTimeout(open.timer)
333
+ this.#open = undefined
334
+ open.release()
335
+ this.#sendToWatchers({ document: open.document, ended })
336
+ this.#emit({ type: 'review', document: open.document, session: open.session, ended })
337
+ }
338
+
339
+ #sendToWatchers(update: ReviewUpdate): void {
340
+ for (const send of this.#watchers) send(update)
341
+ }
342
+
343
+ #emit(event: ReviewEvent): void {
344
+ for (const listener of this.#listeners) listener(event)
345
+ }
346
+
347
+ #rescheduleRollback(): void {
348
+ const open = this.#open
349
+ if (open) this.#scheduleRollback(open)
350
+ }
351
+
352
+ #scheduleRollback(open: OpenReview): void {
353
+ clearTimeout(open.timer)
354
+ open.timer = undefined
355
+ if (open.running || this.#watchers.size > 0) return
356
+ const { timeoutMs } = reviewSettings(this.#options.config)
357
+ open.timer = setTimeout(() => {
358
+ void this.#serial(async () => {
359
+ if (this.#open !== open || open.running || this.#watchers.size > 0) return
360
+ const files = (await open.review.diff()).map(change => change.path)
361
+ await open.review.rollback()
362
+ 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`)
363
+ await this.#end(open, { outcome: 'rolled-back', files })
364
+ }).catch((error: unknown) => this.#options.log.warn(`the review of session ${open.session} did not roll back: ${errorMessage(error)}`))
365
+ }, timeoutMs)
366
+ open.timer.unref?.()
367
+ }
368
+
369
+ #serial<T>(work: () => Promise<T>): Promise<T> {
370
+ const next = this.#queue.then(work, work)
371
+ this.#queue = next.catch(() => undefined)
372
+ return next
373
+ }
374
+ }
@@ -3,14 +3,14 @@ import { join, sep } from 'node:path'
3
3
 
4
4
  type Entry = { kind: 'file', data: Buffer, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }
5
5
 
6
- export interface SavedGuard {
6
+ export interface SavedGitDir {
7
7
  identity: string
8
8
  entries: [string, { kind: 'file', data: string, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }][]
9
9
  }
10
10
 
11
11
  const UNGUARDED = new Set(['refs', 'logs', 'lfs'])
12
12
 
13
- function unguarded(parts: string[]): boolean {
13
+ function isUnguarded(parts: string[]): boolean {
14
14
  const name = parts.at(-1)!
15
15
  if (name === 'index' || name.endsWith('.lock')) return true
16
16
  return parts.some((part, i) => UNGUARDED.has(part) || (part === 'objects' && i + 1 < parts.length && parts[i + 1] !== 'info'))
@@ -19,7 +19,7 @@ function unguarded(parts: string[]): boolean {
19
19
  function scan(gitDir: string): Map<string, Entry> {
20
20
  const entries = new Map<string, Entry>()
21
21
  const visit = (rel: string) => {
22
- if (unguarded(rel.split(sep))) return
22
+ if (isUnguarded(rel.split(sep))) return
23
23
  const path = join(gitDir, rel)
24
24
  let stat
25
25
  try {
@@ -40,16 +40,17 @@ function scan(gitDir: string): Map<string, Entry> {
40
40
  return entries
41
41
  }
42
42
 
43
- function same(a: Entry, b: Entry): boolean {
43
+ function isSameEntry(a: Entry, b: Entry): boolean {
44
44
  if (a.kind !== b.kind) return false
45
45
  if (a.kind === 'file' && b.kind === 'file') return a.mode === b.mode && a.data.equals(b.data)
46
46
  if (a.kind === 'link' && b.kind === 'link') return a.target === b.target
47
47
  return a.kind === 'dir'
48
48
  }
49
49
 
50
- export interface GitGuard {
50
+ /** What a git directory held, to put it back as it was: `restore` returns the paths it changed. */
51
+ export interface GitDirSnapshot {
51
52
  restore(): string[]
52
- saved(): SavedGuard
53
+ saved(): SavedGitDir
53
54
  }
54
55
 
55
56
  function identityOf(gitDir: string): string {
@@ -57,7 +58,7 @@ function identityOf(gitDir: string): string {
57
58
  return `${stat.dev}:${stat.ino}`
58
59
  }
59
60
 
60
- export function guardGitDir(gitDir: string, saved?: SavedGuard): GitGuard {
61
+ export function snapshotGitDir(gitDir: string, saved?: SavedGitDir): GitDirSnapshot {
61
62
  const identity = saved?.identity ?? identityOf(gitDir)
62
63
  const before: Map<string, Entry> = saved
63
64
  ? new Map(saved.entries.map(([rel, entry]) => [rel, entry.kind === 'file' ? { ...entry, data: Buffer.from(entry.data, 'base64') } : entry]))
@@ -75,7 +76,7 @@ export function guardGitDir(gitDir: string, saved?: SavedGuard): GitGuard {
75
76
  const changed = new Set<string>()
76
77
  for (const [rel, entry] of [...now].sort((a, b) => b[0].length - a[0].length)) {
77
78
  const was = before.get(rel)
78
- if (was && same(was, entry)) continue
79
+ if (was && isSameEntry(was, entry)) continue
79
80
  rmSync(join(gitDir, rel), { recursive: true, force: true })
80
81
  changed.add(rel)
81
82
  }