@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.0
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 +36 -3
- package/src/domain/api.ts +102 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/paths.ts +98 -0
- package/src/domain/revisions.ts +80 -0
- package/src/index.ts +6 -0
- package/src/node/build.ts +42 -0
- package/src/node/config.ts +126 -0
- package/src/node/documents.ts +185 -0
- package/src/node/files.ts +252 -0
- package/src/node/git.ts +68 -0
- package/src/node/locks.ts +41 -0
- package/src/node/review/git-guard.ts +103 -0
- package/src/node/review/git-review.ts +416 -0
- package/src/node/review/review-record.ts +73 -0
- package/src/node/review/reviews.ts +422 -0
- package/src/node/review/routes.ts +72 -0
- package/src/node/revisions.ts +346 -0
- package/src/node/routes.ts +239 -0
- package/src/node/serve.ts +302 -0
- package/src/node/service.ts +237 -0
- package/src/node/session.ts +123 -0
- package/src/node/site.ts +121 -0
- package/src/node/source.ts +148 -0
- package/templates/document/default/lecture.md +18 -0
|
@@ -0,0 +1,422 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { eventStream, isRecord, json, problem } from '@lincyaw/kernel'
|
|
2
|
+
import type { Route } from '@lincyaw/kernel'
|
|
3
|
+
import type { Reviews } from './reviews.ts'
|
|
4
|
+
|
|
5
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
6
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
7
|
+
return isRecord(body) ? body : {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const text = (value: unknown, max = 4000) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
|
|
11
|
+
const turnOf = (value: unknown) => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined)
|
|
12
|
+
const paths = (value: unknown) => (Array.isArray(value) ? value.filter((path): path is string => typeof path === 'string' && path !== '') : undefined)
|
|
13
|
+
|
|
14
|
+
export function reviewRoutes(reviews: Reviews, answer: (work: () => Promise<Response>) => Promise<Response>): Record<string, Route> {
|
|
15
|
+
const post = (handle: (body: Record<string, unknown>, document: string) => Promise<Response>): Route => ({
|
|
16
|
+
role: 'editor',
|
|
17
|
+
handle: (request, ctx) => answer(async () => (request.method === 'POST' ? handle(await bodyOf(request), ctx.document) : problem(405, 'POST'))),
|
|
18
|
+
})
|
|
19
|
+
return {
|
|
20
|
+
'review': {
|
|
21
|
+
role: 'editor',
|
|
22
|
+
handle: (request, ctx) => answer(async () => {
|
|
23
|
+
if (request.method !== 'GET') return problem(405, 'GET')
|
|
24
|
+
if (request.headers.get('accept')?.includes('text/event-stream')) return eventStream(reviews.follow(ctx.document, request.signal), request.signal)
|
|
25
|
+
const review = await reviews.view(ctx.document)
|
|
26
|
+
return json(review ? { review } : {})
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
'review/events': {
|
|
30
|
+
role: 'editor',
|
|
31
|
+
handle: async (request) => (request.method === 'GET' ? eventStream(reviews.events(request.signal), request.signal) : problem(405, 'GET')),
|
|
32
|
+
},
|
|
33
|
+
'review/open': {
|
|
34
|
+
role: 'editor',
|
|
35
|
+
handle: (request, ctx) => answer(async () => {
|
|
36
|
+
if (request.method !== 'POST') return problem(405, 'POST')
|
|
37
|
+
const body = await bodyOf(request)
|
|
38
|
+
const session = text(body.session, 200)
|
|
39
|
+
if (!session) return problem(400, '{ session, turn? }')
|
|
40
|
+
const turn = turnOf(body.turn)
|
|
41
|
+
return json({ review: await reviews.open(ctx.document, { session, ...turn !== undefined ? { turn } : {} }, ctx.member) })
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
'review/step': post(async (body, document) => {
|
|
45
|
+
const session = text(body.session, 200)
|
|
46
|
+
if (!session) return problem(400, '{ session, turn?, label?, tool?, end? }')
|
|
47
|
+
const turn = turnOf(body.turn)
|
|
48
|
+
const review = await reviews.step(document, {
|
|
49
|
+
session,
|
|
50
|
+
...turn !== undefined ? { turn } : {},
|
|
51
|
+
...text(body.label, 300) ? { label: text(body.label, 300)! } : {},
|
|
52
|
+
...text(body.tool, 100) ? { tool: text(body.tool, 100)! } : {},
|
|
53
|
+
...body.end === true ? { end: true } : {},
|
|
54
|
+
})
|
|
55
|
+
return json(review ? { review } : {})
|
|
56
|
+
}),
|
|
57
|
+
'review/accept': post(async (body, document) => {
|
|
58
|
+
const reject = paths(body.reject)
|
|
59
|
+
const message = text(body.message)
|
|
60
|
+
return json({ ended: await reviews.accept(document, { ...reject ? { reject } : {}, ...message ? { message } : {} }) })
|
|
61
|
+
}),
|
|
62
|
+
'review/reject': post(async (body, document) => {
|
|
63
|
+
const chosen = paths(body.paths)
|
|
64
|
+
return json(await reviews.reject(document, chosen ? { paths: chosen } : {}))
|
|
65
|
+
}),
|
|
66
|
+
'review/restore': post(async (body, document) => {
|
|
67
|
+
const step = text(body.step, 100)
|
|
68
|
+
if (!step) return problem(400, '{ step }')
|
|
69
|
+
return json({ review: await reviews.restore(document, { step }) })
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
}
|