@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
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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():
|
|
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
|
|
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 &&
|
|
79
|
+
if (was && isSameEntry(was, entry)) continue
|
|
79
80
|
rmSync(join(gitDir, rel), { recursive: true, force: true })
|
|
80
81
|
changed.add(rel)
|
|
81
82
|
}
|