@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.1

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -2
  3. package/package.json +41 -3
  4. package/src/domain/commit-message.ts +80 -0
  5. package/src/domain/locks.ts +5 -0
  6. package/src/domain/paths.ts +98 -0
  7. package/src/index.ts +11 -0
  8. package/src/node/authoring-clients.ts +206 -0
  9. package/src/node/build.ts +42 -0
  10. package/src/node/compile-session.ts +187 -0
  11. package/src/node/config.ts +141 -0
  12. package/src/node/document-assets.ts +30 -0
  13. package/src/node/document-audio.ts +473 -0
  14. package/src/node/document-authoring.ts +436 -0
  15. package/src/node/document-editor.ts +157 -0
  16. package/src/node/document-history.ts +190 -0
  17. package/src/node/document-images.ts +241 -0
  18. package/src/node/document-operations.ts +191 -0
  19. package/src/node/document-transcription-adoption.ts +96 -0
  20. package/src/node/document-transcription.ts +471 -0
  21. package/src/node/document-voice.ts +115 -0
  22. package/src/node/errors.ts +23 -0
  23. package/src/node/git.ts +68 -0
  24. package/src/node/jobs.ts +361 -0
  25. package/src/node/locks.ts +46 -0
  26. package/src/node/page-delete.ts +106 -0
  27. package/src/node/project-files.ts +163 -0
  28. package/src/node/projects.ts +192 -0
  29. package/src/node/review/agent-session-reviews.ts +374 -0
  30. package/src/node/review/git-guard.ts +104 -0
  31. package/src/node/review/git-review.ts +428 -0
  32. package/src/node/review/record.ts +77 -0
  33. package/src/node/review/routes.ts +73 -0
  34. package/src/node/review/task-reviews.ts +50 -0
  35. package/src/node/revisions.ts +599 -0
  36. package/src/node/routes.ts +329 -0
  37. package/src/node/serve.ts +503 -0
  38. package/src/node/site.ts +132 -0
  39. package/src/node/source-editor.ts +332 -0
  40. package/src/node/speech-settings.ts +21 -0
  41. package/src/node/templates.ts +27 -0
  42. package/src/node/workbench-api.ts +176 -0
  43. package/src/node/writer.ts +389 -0
  44. package/templates/document/default/lecture.json +75 -0
@@ -0,0 +1,428 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, relative, resolve, sep } from 'node:path'
5
+ import { gitConfigArgs, gitEnvironment } from '@lincyaw/kernel'
6
+ import type { ChangeReview, DocumentFileChange, DocumentFiles, ReviewChange, ReviewChangeKind, ReviewStep } from '@lincyaw/kernel'
7
+ import { isInside } from '@lincyaw/kernel/node'
8
+ import { snapshotGitDir } from './git-guard.ts'
9
+ import type { SavedGitDir } from './git-guard.ts'
10
+ import { isHolderAlive, readRecord, removeRecord, THIS_PROCESS, writeRecord } from './record.ts'
11
+ import type { AgentSessionRecord, ReviewRecord } from './record.ts'
12
+
13
+ export interface GitReview extends ChangeReview {
14
+ readonly task: string
15
+ restoreFiles(paths: string[]): Promise<void>
16
+ noteSession(session: Omit<AgentSessionRecord, 'checkpoints' | 'steps'>): void
17
+ suspend(): void
18
+ prepareAccept(reject: string[]): Promise<string[]>
19
+ finish(): void
20
+ }
21
+
22
+ export const GIT_ENGINE = 'git'
23
+
24
+ const MAX_STEPS = 100
25
+ const MAX_DIFF = 256 * 1024
26
+ const MAX_DIFFS = 200
27
+
28
+ const DIFF = ['diff', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-color']
29
+
30
+ const openScopes = new Set<string>()
31
+
32
+ function git(cwd: string, args: string[], env: Record<string, string> = {}): Promise<Buffer> {
33
+ return new Promise((resolve, reject) => {
34
+ execFile('git', [...gitConfigArgs(), ...args], {
35
+ cwd,
36
+ encoding: 'buffer',
37
+ maxBuffer: 256 << 20,
38
+ env: { ...gitEnvironment(process.env), ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1' },
39
+ }, (error, stdout, stderr) => {
40
+ if (error) reject(new Error(`git ${args[0]}: ${stderr.toString('utf8').trim() || error.message}`))
41
+ else resolve(stdout)
42
+ })
43
+ })
44
+ }
45
+
46
+ const gitText = async (cwd: string, args: string[], env?: Record<string, string>) => (await git(cwd, args, env)).toString('utf8')
47
+
48
+ export interface GitDirs {
49
+ top: string
50
+ gitDir: string
51
+ commonDir: string
52
+ }
53
+
54
+ async function gitDirsOf(root: string): Promise<GitDirs> {
55
+ const [top = '', gitDir = '', commonDir = ''] = (await gitText(root, ['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])).split('\n')
56
+ return { top: realpathSync(top), gitDir: realpathSync(gitDir), commonDir: realpathSync(resolve(root, commonDir)) }
57
+ }
58
+
59
+ const COPIED_CONFIG_KEYS = '^(core\\.(filemode|symlinks|ignorecase|precomposeunicode|autocrlf|eol|safecrlf|excludesfile)|extensions\\.objectformat)$'
60
+
61
+ const quoted = (value: string) => `"${value.replace(/[\\"]/g, c => `\\${c}`).replace(/\n/g, '\\n')}"`
62
+
63
+ /** The settings of the repository's git configuration that reading its files depends on, for a git directory of the review's own. */
64
+ async function isolatedGitConfig(dirs: GitDirs): Promise<{ config: string, exclude?: string }> {
65
+ const listed = await gitText(dirs.top, ['config', '-z', '--get-regexp', COPIED_CONFIG_KEYS], { GIT_DIR: dirs.gitDir, GIT_COMMON_DIR: dirs.commonDir }).catch(() => '')
66
+ const sections = new Map<string, string[]>()
67
+ for (const entry of listed.split('\0').filter(Boolean)) {
68
+ const at = entry.indexOf('\n')
69
+ const [key, value] = at < 0 ? [entry, 'true'] : [entry.slice(0, at), entry.slice(at + 1)]
70
+ const [section = '', name = ''] = key.split('.')
71
+ sections.set(section, [...sections.get(section) ?? [], `\t${name} = ${quoted(value)}`])
72
+ }
73
+ const version = sections.has('extensions') ? 1 : 0
74
+ const others = [...sections].filter(([section]) => section !== 'core').map(([section, lines]) => `[${section}]\n${lines.join('\n')}`)
75
+ const config = [`[core]\n\trepositoryformatversion = ${version}\n\tbare = false`, ...sections.get('core') ?? [], ...others].join('\n')
76
+ let exclude: string | undefined
77
+ try {
78
+ exclude = readFileSync(join(dirs.commonDir, 'info', 'exclude'), 'utf8')
79
+ } catch {
80
+ }
81
+ return { config: `${config}\n`, ...exclude ? { exclude } : {} }
82
+ }
83
+
84
+ /** Trees of the reviewed directory written with an index and a git directory of the review's own. */
85
+ class ReviewTrees {
86
+ readonly #work = mkdtempSync(join(tmpdir(), 'presmium-review-'))
87
+ #indexes = 0
88
+ readonly #pathspec: string
89
+ readonly #env: Record<string, string>
90
+ readonly #files?: DocumentFiles
91
+ readonly top: string
92
+ readonly scopeDir: string
93
+ readonly root: string
94
+
95
+ constructor(dirs: GitDirs, scopeDir: string, root: string, gitConfig: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
96
+ this.top = dirs.top
97
+ this.scopeDir = scopeDir
98
+ this.root = root
99
+ this.#pathspec = relative(dirs.top, scopeDir).split(sep).join('/') || '.'
100
+ const own = join(this.#work, 'git')
101
+ for (const dir of ['refs', 'objects', 'info']) mkdirSync(join(own, dir), { recursive: true })
102
+ writeFileSync(join(own, 'HEAD'), 'ref: refs/heads/review\n')
103
+ writeFileSync(join(own, 'config'), gitConfig.config)
104
+ if (gitConfig.exclude !== undefined) writeFileSync(join(own, 'info', 'exclude'), gitConfig.exclude)
105
+ this.#env = { GIT_DIR: own, GIT_OBJECT_DIRECTORY: join(dirs.commonDir, 'objects'), GIT_WORK_TREE: dirs.top }
106
+ this.#files = options.files
107
+ }
108
+
109
+ #git(args: string[], index?: string): Promise<Buffer> {
110
+ return git(this.top, args, { ...this.#env, ...index ? { GIT_INDEX_FILE: index } : {} })
111
+ }
112
+
113
+ async #text(args: string[], index?: string): Promise<string> {
114
+ return (await this.#git(args, index)).toString('utf8')
115
+ }
116
+
117
+ fromGit = (path: string) => relative(this.root, join(this.top, path)).split(sep).join('/')
118
+ toGit = (path: string) => relative(this.top, join(this.root, path)).split(sep).join('/')
119
+
120
+ #index(): string {
121
+ return join(this.#work, `index-${++this.#indexes}`)
122
+ }
123
+
124
+ async tree(): Promise<string> {
125
+ const index = this.#index()
126
+ try {
127
+ await this.#git(['add', '-A', '--', this.#pathspec], index)
128
+ return (await this.#text(['write-tree'], index)).trim()
129
+ } finally {
130
+ rmSync(index, { force: true })
131
+ }
132
+ }
133
+
134
+ async changedPaths(from: string, to: string): Promise<string[]> {
135
+ if (from === to) return []
136
+ return (await this.#text([...DIFF, '--name-only', '-z', from, to])).split('\0').filter(Boolean)
137
+ .map(this.fromGit).sort((a, b) => a.localeCompare(b))
138
+ }
139
+
140
+ async compare(from: string, to: string): Promise<ReviewChange[]> {
141
+ if (from === to) return []
142
+ const status = (await this.#text([...DIFF, '--name-status', '-z', from, to])).split('\0').filter(Boolean)
143
+ const kinds = new Map<string, ReviewChangeKind>()
144
+ for (let i = 0; i + 1 < status.length; i += 2) kinds.set(status[i + 1]!, status[i]!.startsWith('A') ? 'added' : status[i]!.startsWith('D') ? 'deleted' : 'modified')
145
+ const counts = new Map<string, { added: number, removed: number, binary: boolean }>()
146
+ for (const entry of (await this.#text([...DIFF, '--numstat', '-z', from, to])).split('\0').filter(Boolean)) {
147
+ const [added = '0', removed = '0', ...path] = entry.split('\t')
148
+ const binary = added === '-'
149
+ counts.set(path.join('\t'), { added: binary ? 0 : Number(added), removed: binary ? 0 : Number(removed), binary })
150
+ }
151
+ const changes: ReviewChange[] = []
152
+ for (const [path, kind] of kinds) {
153
+ const count = counts.get(path) ?? { added: 0, removed: 0, binary: false }
154
+ const change: ReviewChange = { path: this.fromGit(path), kind, ...count }
155
+ if (!count.binary && changes.filter(c => c.diff).length < MAX_DIFFS) {
156
+ const diff = await this.#text([...DIFF, from, to, '--', path])
157
+ if (diff.length <= MAX_DIFF) change.diff = diff
158
+ }
159
+ changes.push(change)
160
+ }
161
+ return changes.sort((a, b) => a.path.localeCompare(b.path))
162
+ }
163
+
164
+ async restore(tree: string, paths: string[]): Promise<void> {
165
+ const ours = paths.map(this.toGit).filter(path => isInside(join(this.top, path), this.scopeDir))
166
+ if (ours.length === 0) return
167
+ if (this.#files) return this.#write(tree, ours)
168
+ const known = new Set((await this.#text(['ls-tree', '-r', '-z', '--name-only', tree, '--', ...ours])).split('\0').filter(Boolean))
169
+ const back = ours.filter(path => known.has(path))
170
+ if (back.length > 0) {
171
+ const index = this.#index()
172
+ try {
173
+ await this.#git(['read-tree', tree], index)
174
+ await this.#git(['checkout-index', '-f', '--', ...back], index)
175
+ } finally {
176
+ rmSync(index, { force: true })
177
+ }
178
+ }
179
+ for (const path of ours.filter(path => !known.has(path))) {
180
+ const file = join(this.top, path)
181
+ rmSync(file, { force: true })
182
+ for (let dir = dirname(file); isInside(dir, this.scopeDir) && dir !== this.scopeDir; dir = dirname(dir)) {
183
+ try {
184
+ if (readdirSync(dir).length > 0) break
185
+ rmdirSync(dir)
186
+ } catch {
187
+ break
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ async #write(tree: string, paths: string[]): Promise<void> {
194
+ const entries = new Map<string, { mode: string, oid: string }>()
195
+ for (const entry of (await this.#text(['ls-tree', '-r', '-z', tree, '--', ...paths])).split('\0').filter(Boolean)) {
196
+ const [meta = '', path = ''] = entry.split('\t')
197
+ const [mode = '', , oid = ''] = meta.split(' ')
198
+ entries.set(path, { mode, oid })
199
+ }
200
+ const changes: DocumentFileChange[] = []
201
+ for (const path of paths) {
202
+ const entry = entries.get(path)
203
+ if (!entry) {
204
+ changes.push({ path: this.fromGit(path), remove: true })
205
+ continue
206
+ }
207
+ if (entry.mode === '160000') continue
208
+ const blob = await this.#git(['cat-file', 'blob', entry.oid])
209
+ changes.push(entry.mode === '120000' ? { path: this.fromGit(path), link: blob.toString('utf8') } : { path: this.fromGit(path), content: blob, ...entry.mode === '100755' ? { executable: true } : {} })
210
+ }
211
+ await this.#files!.write(changes)
212
+ }
213
+
214
+ async reset(tree: string): Promise<void> {
215
+ await this.restore(tree, await this.changedPaths(tree, await this.tree()))
216
+ }
217
+
218
+ dispose(): void {
219
+ rmSync(this.#work, { recursive: true, force: true })
220
+ }
221
+ }
222
+
223
+ export interface GitReviewOptions {
224
+ workspaceRoot: string
225
+ scopeDir: string
226
+ task: string
227
+ onRestore?: (paths: string[]) => void
228
+ warn?: (message: string) => void
229
+ files?: DocumentFiles
230
+ }
231
+
232
+ const recovering = new Map<string, Promise<{ task: string, paths: string[] } | undefined>>()
233
+
234
+ /** Puts a git directory inside the reviewed directory back as it was, from a saved snapshot or one taken now. */
235
+ function gitDirGuard(gitDir: string, scopeDir: string, root: string, onRestore: GitReviewOptions['onRestore'], saved?: SavedGitDir): { saved(): SavedGitDir | undefined, restore(): void } {
236
+ const snapshot = saved ? snapshotGitDir(gitDir, saved) : isInside(gitDir, scopeDir) ? snapshotGitDir(gitDir) : undefined
237
+ return {
238
+ saved: () => snapshot?.saved(),
239
+ restore() {
240
+ const paths = snapshot?.restore() ?? []
241
+ if (paths.length > 0) onRestore?.(paths.map(path => relative(root, join(gitDir, path)).split(sep).join('/')))
242
+ },
243
+ }
244
+ }
245
+
246
+ export function recoverGitReview(options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
247
+ const scopeDir = realpathSync(options.scopeDir)
248
+ let running = recovering.get(scopeDir)
249
+ if (!running) {
250
+ running = rollBackLeftReview(scopeDir, realpathSync(options.workspaceRoot), options).finally(() => recovering.delete(scopeDir))
251
+ recovering.set(scopeDir, running)
252
+ }
253
+ return running
254
+ }
255
+
256
+ async function rollBackLeftReview(scopeDir: string, root: string, options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
257
+ const record = readRecord(scopeDir)
258
+ if (!record || record.session || openScopes.has(scopeDir) || isHolderAlive(record)) return undefined
259
+ let trees: ReviewTrees | undefined
260
+ try {
261
+ if (record.guard) gitDirGuard(record.dirs.gitDir, scopeDir, root, options.onRestore, record.guard).restore()
262
+ trees = new ReviewTrees(record.dirs, scopeDir, root, await isolatedGitConfig(record.dirs), options)
263
+ const paths = await trees.changedPaths(record.snapshot, await trees.tree())
264
+ await trees.restore(record.snapshot, paths)
265
+ return { task: record.task, paths }
266
+ } finally {
267
+ removeRecord(scopeDir)
268
+ trees?.dispose()
269
+ }
270
+ }
271
+
272
+ /** The record of an agent session's review that a process which has ended left waiting for its author. */
273
+ export function pendingAgentSessionReview(scopeDir: string): ReviewRecord | undefined {
274
+ const real = realpathSync(scopeDir)
275
+ const record = readRecord(real)
276
+ return record?.session && !openScopes.has(real) && !isHolderAlive(record) ? record : undefined
277
+ }
278
+
279
+ export async function openGitReview(options: GitReviewOptions & { session?: Omit<AgentSessionRecord, 'checkpoints' | 'steps'> }): Promise<GitReview> {
280
+ const scopeDir = realpathSync(options.scopeDir)
281
+ const root = realpathSync(options.workspaceRoot)
282
+ if (openScopes.has(scopeDir)) throw new Error(`a change of ${relative(root, scopeDir) || '.'} is under way already`)
283
+ const left = readRecord(scopeDir)
284
+ if (left && isHolderAlive(left)) throw new Error(`a change of ${relative(root, scopeDir) || '.'} is under way in another process (${left.pid})`)
285
+ if (left?.session) throw new Error(`a review of ${relative(root, scopeDir) || '.'} waits for its author (session ${left.session.session}): accept or reject it in the workbench first`)
286
+ if (left) await recoverGitReview(options)
287
+ const dirs = await gitDirsOf(root)
288
+ const guard = gitDirGuard(dirs.gitDir, scopeDir, root, options.onRestore)
289
+ const trees = new ReviewTrees(dirs, scopeDir, root, await isolatedGitConfig(dirs), options)
290
+
291
+ openScopes.add(scopeDir)
292
+ let snapshot: string
293
+ try {
294
+ snapshot = await trees.tree()
295
+ } catch (error) {
296
+ openScopes.delete(scopeDir)
297
+ trees.dispose()
298
+ throw error
299
+ }
300
+ const saved = guard.saved()
301
+ const record: ReviewRecord = { scopeDir, dirs, snapshot, task: options.task, ...THIS_PROCESS, ...saved ? { guard: saved } : {}, ...options.session ? { session: { ...options.session, checkpoints: [], steps: [] } } : {} }
302
+ return createGitReview({ scopeDir, trees, restoreGitDir: guard.restore, record, warn: options.warn })
303
+ }
304
+
305
+ export async function resumeGitReview(record: ReviewRecord, options: Omit<GitReviewOptions, 'task' | 'scopeDir'>): Promise<GitReview> {
306
+ const scopeDir = realpathSync(record.scopeDir)
307
+ const root = realpathSync(options.workspaceRoot)
308
+ if (openScopes.has(scopeDir) || isHolderAlive(record)) throw new Error(`a change of ${relative(root, scopeDir) || '.'} is under way already`)
309
+ const { restore: restoreGitDir } = gitDirGuard(record.dirs.gitDir, scopeDir, root, options.onRestore, record.guard)
310
+ restoreGitDir()
311
+ const trees = new ReviewTrees(record.dirs, scopeDir, root, await isolatedGitConfig(record.dirs), options)
312
+ openScopes.add(scopeDir)
313
+ return createGitReview({ scopeDir, trees, restoreGitDir, record: { ...record, ...THIS_PROCESS }, warn: options.warn })
314
+ }
315
+
316
+ function createGitReview(open: { scopeDir: string, trees: ReviewTrees, restoreGitDir: () => void, record: ReviewRecord, warn?: (message: string) => void }): GitReview {
317
+ const { scopeDir, trees, restoreGitDir, record } = open
318
+ const { snapshot } = record
319
+ const steps: ReviewStep[] = [...record.session?.steps ?? []]
320
+ const checkpoints = [snapshot, ...record.session?.checkpoints ?? []]
321
+ let made = Math.max(0, ...steps.map(step => Number(/^step-(\d+)$/.exec(step.id)?.[1] ?? 0)))
322
+ let settled = false
323
+ let suspended = false
324
+
325
+ const write = () => {
326
+ try {
327
+ writeRecord({ ...record, ...record.session ? { session: { ...record.session, checkpoints: checkpoints.slice(1), steps } } : {} })
328
+ } catch (error) {
329
+ open.warn?.(`the review's record was not written, so a crash would leave its changes: ${error instanceof Error ? error.message : String(error)}`)
330
+ }
331
+ }
332
+ write()
333
+
334
+ const settle = () => {
335
+ if (settled) return
336
+ settled = true
337
+ openScopes.delete(scopeDir)
338
+ if (!suspended) removeRecord(scopeDir)
339
+ trees.dispose()
340
+ }
341
+ const live = () => {
342
+ if (settled) throw new Error('this review has ended')
343
+ }
344
+ const prepareAccept = async (reject: string[]) => {
345
+ live()
346
+ restoreGitDir()
347
+ const changes = await trees.changedPaths(snapshot, await trees.tree())
348
+ const rejected = new Set(reject.filter(path => changes.includes(path)))
349
+ await trees.restore(snapshot, [...rejected])
350
+ return changes.filter(path => !rejected.has(path))
351
+ }
352
+
353
+ return {
354
+ root: scopeDir,
355
+ visibility: 'immediate',
356
+ task: record.task,
357
+ async step(label, tool) {
358
+ live()
359
+ restoreGitDir()
360
+ const now = await trees.tree()
361
+ if (steps.length >= MAX_STEPS) {
362
+ checkpoints[checkpoints.length - 1] = now
363
+ const last: ReviewStep = { ...steps.at(-1)!, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-2)!, now) }
364
+ steps[steps.length - 1] = last
365
+ if (record.session) write()
366
+ return last
367
+ }
368
+ const step: ReviewStep = { id: `step-${++made}`, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-1)!, now) }
369
+ checkpoints.push(now)
370
+ steps.push(step)
371
+ if (record.session) write()
372
+ return step
373
+ },
374
+ steps: () => steps,
375
+ async diff() {
376
+ live()
377
+ restoreGitDir()
378
+ return trees.compare(snapshot, await trees.tree())
379
+ },
380
+ async restoreTo(stepId) {
381
+ live()
382
+ const at = steps.findIndex(step => step.id === stepId)
383
+ if (at < 0) throw new Error(`no step ${stepId} in this review`)
384
+ restoreGitDir()
385
+ await trees.reset(checkpoints[at]!)
386
+ steps.splice(at)
387
+ checkpoints.splice(at + 1)
388
+ if (record.session) write()
389
+ },
390
+ async restoreFiles(paths) {
391
+ live()
392
+ restoreGitDir()
393
+ const changed = new Set(await trees.changedPaths(snapshot, await trees.tree()))
394
+ await trees.restore(snapshot, paths.filter(path => changed.has(path)))
395
+ },
396
+ noteSession(session) {
397
+ if (!record.session) return
398
+ record.session = { ...record.session, ...session }
399
+ write()
400
+ },
401
+ prepareAccept,
402
+ finish: settle,
403
+ async accept(reject = []) {
404
+ const files = await prepareAccept(reject)
405
+ settle()
406
+ return files
407
+ },
408
+ async rollback() {
409
+ live()
410
+ restoreGitDir()
411
+ await trees.reset(snapshot)
412
+ settle()
413
+ },
414
+ async close() {
415
+ if (settled) return
416
+ try {
417
+ restoreGitDir()
418
+ await trees.reset(snapshot)
419
+ } finally {
420
+ settle()
421
+ }
422
+ },
423
+ suspend() {
424
+ suspended = true
425
+ settle()
426
+ },
427
+ }
428
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { Member, ReviewStep } from '@lincyaw/kernel'
6
+ import type { SavedGitDir } from './git-guard.ts'
7
+ import type { GitDirs } from './git-review.ts'
8
+
9
+ export interface AgentSessionRecord {
10
+ session: string
11
+ document: string
12
+ member: Member
13
+ turns: { from: number, to: number }
14
+ opened: string
15
+ checkpoints: string[]
16
+ steps: ReviewStep[]
17
+ }
18
+
19
+ /** A review in progress, kept in the user's state directory so that another process can roll it back or resume it. */
20
+ export interface ReviewRecord {
21
+ /** The directory the review covers: a document's for a plugin's task, the workspace root for an agent session. */
22
+ scopeDir: string
23
+ dirs: GitDirs
24
+ snapshot: string
25
+ task: string
26
+ pid: number
27
+ started?: string
28
+ guard?: SavedGitDir
29
+ session?: AgentSessionRecord
30
+ }
31
+
32
+ export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
33
+ return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'presmium', 'reviews')
34
+ }
35
+
36
+ const recordFile = (scopeDir: string) => join(recordsDir(), `${createHash('sha256').update(scopeDir).digest('hex').slice(0, 24)}.json`)
37
+
38
+ export function processStartTime(pid: number): string | undefined {
39
+ try {
40
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
41
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ export const THIS_PROCESS = { pid: process.pid, started: processStartTime(process.pid) }
48
+
49
+ export function isHolderAlive(record: ReviewRecord): boolean {
50
+ try {
51
+ process.kill(record.pid, 0)
52
+ } catch (error) {
53
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
54
+ }
55
+ return record.started === undefined || processStartTime(record.pid) === record.started
56
+ }
57
+
58
+ /** Reads the record of a review of the directory; records of earlier versions name the directory `deck`. */
59
+ export function readRecord(scopeDir: string): ReviewRecord | undefined {
60
+ try {
61
+ const { deck, ...record } = JSON.parse(readFileSync(recordFile(scopeDir), 'utf8')) as Partial<ReviewRecord> & { deck?: string }
62
+ return { ...record, scopeDir: record.scopeDir ?? deck } as ReviewRecord
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ export function writeRecord(record: ReviewRecord): void {
69
+ const file = recordFile(record.scopeDir)
70
+ mkdirSync(recordsDir(), { recursive: true, mode: 0o700 })
71
+ writeFileSync(`${file}.${process.pid}`, JSON.stringify(record), { mode: 0o600 })
72
+ renameSync(`${file}.${process.pid}`, file)
73
+ }
74
+
75
+ export function removeRecord(scopeDir: string): void {
76
+ rmSync(recordFile(scopeDir), { force: true })
77
+ }
@@ -0,0 +1,73 @@
1
+ import { isRecord, jsonResponse, problemResponse, sseResponse } from '@lincyaw/kernel'
2
+ import type { Route } from '@lincyaw/kernel'
3
+ import { catchRequestErrors } from '../errors.ts'
4
+ import type { AgentSessionReviews } from './agent-session-reviews.ts'
5
+
6
+ async function readBody(request: Request): Promise<Record<string, unknown>> {
7
+ const body: unknown = await request.json().catch(() => undefined)
8
+ return isRecord(body) ? body : {}
9
+ }
10
+
11
+ const toText = (value: unknown, max = 4000) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
12
+ const toTurn = (value: unknown) => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined)
13
+ const toPaths = (value: unknown) => (Array.isArray(value) ? value.filter((path): path is string => typeof path === 'string' && path !== '') : undefined)
14
+
15
+ export function reviewRoutes(reviews: AgentSessionReviews): Record<string, Route> {
16
+ const post = (handle: (body: Record<string, unknown>, document: string) => Promise<Response>): Route => ({
17
+ role: 'editor',
18
+ handle: (request, ctx) => catchRequestErrors(async () => (request.method === 'POST' ? handle(await readBody(request), ctx.document) : problemResponse(405, 'POST'))),
19
+ })
20
+ return {
21
+ 'review': {
22
+ role: 'editor',
23
+ handle: (request, ctx) => catchRequestErrors(async () => {
24
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
25
+ if (request.headers.get('accept')?.includes('text/event-stream')) return sseResponse(reviews.watch(ctx.document, request.signal), request.signal)
26
+ const review = await reviews.snapshot()
27
+ return jsonResponse(review ? { review } : {})
28
+ }),
29
+ },
30
+ 'review/events': {
31
+ role: 'editor',
32
+ handle: async (request) => (request.method === 'GET' ? sseResponse(reviews.events(request.signal), request.signal) : problemResponse(405, 'GET')),
33
+ },
34
+ 'review/open': {
35
+ role: 'editor',
36
+ handle: (request, ctx) => catchRequestErrors(async () => {
37
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
38
+ const body = await readBody(request)
39
+ const session = toText(body.session, 200)
40
+ if (!session) return problemResponse(400, '{ session, turn? }')
41
+ const turn = toTurn(body.turn)
42
+ return jsonResponse({ review: await reviews.open(ctx.document, { session, ...turn !== undefined ? { turn } : {} }, ctx.member) })
43
+ }),
44
+ },
45
+ 'review/step': post(async (body, document) => {
46
+ const session = toText(body.session, 200)
47
+ if (!session) return problemResponse(400, '{ session, turn?, label?, tool?, end? }')
48
+ const turn = toTurn(body.turn)
49
+ const review = await reviews.step(document, {
50
+ session,
51
+ ...turn !== undefined ? { turn } : {},
52
+ ...toText(body.label, 300) ? { label: toText(body.label, 300)! } : {},
53
+ ...toText(body.tool, 100) ? { tool: toText(body.tool, 100)! } : {},
54
+ ...body.end === true ? { end: true } : {},
55
+ })
56
+ return jsonResponse(review ? { review } : {})
57
+ }),
58
+ 'review/accept': post(async (body, document) => {
59
+ const reject = toPaths(body.reject)
60
+ const message = toText(body.message)
61
+ return jsonResponse({ ended: await reviews.accept(document, { ...reject ? { reject } : {}, ...message ? { message } : {} }) })
62
+ }),
63
+ 'review/reject': post(async (body, document) => {
64
+ const chosen = toPaths(body.paths)
65
+ return jsonResponse(await reviews.reject(document, chosen ? { paths: chosen } : {}))
66
+ }),
67
+ 'review/restore': post(async (body, document) => {
68
+ const step = toText(body.step, 100)
69
+ if (!step) return problemResponse(400, '{ step }')
70
+ return jsonResponse({ review: await reviews.restore(document, { step }) })
71
+ }),
72
+ }
73
+ }
@@ -0,0 +1,50 @@
1
+ import { join } from 'node:path'
2
+ import type { ChangeReviewService, DocumentFiles, Logger } from '@lincyaw/kernel'
3
+ import type { WorkspaceConfig } from '../config.ts'
4
+ import { lockedError } from '../locks.ts'
5
+ import type { DocumentLocks } from '../locks.ts'
6
+ import { GIT_ENGINE, openGitReview, recoverGitReview } from './git-review.ts'
7
+
8
+ export interface TaskReviewOptions {
9
+ config: WorkspaceConfig
10
+ locks: DocumentLocks
11
+ files?: DocumentFiles
12
+ log: Logger
13
+ }
14
+
15
+ /** CHANGE_REVIEW for plugins: the review of a task's changes to one document, which holds that document's lock. */
16
+ export function createTaskReviewService(options: TaskReviewOptions): ChangeReviewService {
17
+ return {
18
+ engine: GIT_ENGINE,
19
+ open: async ({ document, task }) => {
20
+ const release = options.locks.claim(document, `agent:${task}`)
21
+ if (!release) throw lockedError(document, options.locks.holder(document)!)
22
+ try {
23
+ const review = await openGitReview({
24
+ workspaceRoot: options.config.root,
25
+ scopeDir: join(options.config.root, document),
26
+ task,
27
+ files: options.files,
28
+ onRestore: paths => options.log.warn(`task ${task}: the agent changed the git directory, put back: ${paths.join(', ')}`),
29
+ warn: message => options.log.warn(`task ${task}: ${message}`),
30
+ })
31
+ const finish = <Args extends unknown[], T>(work: (...args: Args) => Promise<T>) => async (...args: Args): Promise<T> => {
32
+ const result = await work(...args)
33
+ release()
34
+ return result
35
+ }
36
+ return { ...review, accept: finish(review.accept), rollback: finish(review.rollback), close: finish(review.close) }
37
+ } catch (error) {
38
+ release()
39
+ throw error
40
+ }
41
+ },
42
+ }
43
+ }
44
+
45
+ /** Rolls back the changes of a task whose process ended while its review of the document was open. */
46
+ export async function rollBackLeftTaskReview(options: Pick<TaskReviewOptions, 'config' | 'files' | 'log'>, document: string): Promise<void> {
47
+ const { config, log } = options
48
+ const found = await recoverGitReview({ workspaceRoot: config.root, scopeDir: join(config.root, document), files: options.files, onRestore: paths => log.warn(`the git directory as a change task found it, put back: ${paths.join(', ')}`) })
49
+ if (found) log.warn(`task ${found.task} was waiting for its review when the server ended: rolled back (${found.paths.join(', ') || 'no change'})`)
50
+ }