@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.
@@ -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
+ }