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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -1,25 +1,22 @@
1
1
  import { execFile } from 'node:child_process'
2
2
  import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import { dirname, join, relative, resolve, sep } from 'node:path'
5
5
  import { gitConfigArgs, gitEnvironment } from '@lincyaw/kernel'
6
6
  import type { ChangeReview, DocumentFileChange, DocumentFiles, ReviewChange, ReviewChangeKind, ReviewStep } from '@lincyaw/kernel'
7
- import { guardGitDir } from './git-guard.ts'
8
- import type { GitGuard } from './git-guard.ts'
9
- import { holderRuns, readRecord, removeRecord, SELF, writeRecord } from './review-record.ts'
10
- import type { ReviewRecord, SessionRecord } from './review-record.ts'
11
-
12
- export function within(path: string, dir: string): boolean {
13
- const rel = relative(resolve(dir), resolve(path))
14
- return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
15
- }
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'
16
12
 
17
13
  export interface GitReview extends ChangeReview {
18
14
  readonly task: string
19
- checkpoints(): readonly string[]
20
15
  restoreFiles(paths: string[]): Promise<void>
21
- note(session: Omit<SessionRecord, 'checkpoints' | 'steps'>): void
16
+ noteSession(session: Omit<AgentSessionRecord, 'checkpoints' | 'steps'>): void
22
17
  suspend(): void
18
+ prepareAccept(reject: string[]): Promise<string[]>
19
+ finish(): void
23
20
  }
24
21
 
25
22
  export const GIT_ENGINE = 'git'
@@ -30,7 +27,7 @@ const MAX_DIFFS = 200
30
27
 
31
28
  const DIFF = ['diff', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-color']
32
29
 
33
- const opened = new Set<string>()
30
+ const openScopes = new Set<string>()
34
31
 
35
32
  function git(cwd: string, args: string[], env: Record<string, string> = {}): Promise<Buffer> {
36
33
  return new Promise((resolve, reject) => {
@@ -46,7 +43,7 @@ function git(cwd: string, args: string[], env: Record<string, string> = {}): Pro
46
43
  })
47
44
  }
48
45
 
49
- const text = async (cwd: string, args: string[], env?: Record<string, string>) => (await git(cwd, args, env)).toString('utf8')
46
+ const gitText = async (cwd: string, args: string[], env?: Record<string, string>) => (await git(cwd, args, env)).toString('utf8')
50
47
 
51
48
  export interface GitDirs {
52
49
  top: string
@@ -55,16 +52,17 @@ export interface GitDirs {
55
52
  }
56
53
 
57
54
  async function gitDirsOf(root: string): Promise<GitDirs> {
58
- const [top = '', gitDir = '', commonDir = ''] = (await text(root, ['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])).split('\n')
55
+ const [top = '', gitDir = '', commonDir = ''] = (await gitText(root, ['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])).split('\n')
59
56
  return { top: realpathSync(top), gitDir: realpathSync(gitDir), commonDir: realpathSync(resolve(root, commonDir)) }
60
57
  }
61
58
 
62
- const READING = '^(core\\.(filemode|symlinks|ignorecase|precomposeunicode|autocrlf|eol|safecrlf|excludesfile)|extensions\\.objectformat)$'
59
+ const COPIED_CONFIG_KEYS = '^(core\\.(filemode|symlinks|ignorecase|precomposeunicode|autocrlf|eol|safecrlf|excludesfile)|extensions\\.objectformat)$'
63
60
 
64
61
  const quoted = (value: string) => `"${value.replace(/[\\"]/g, c => `\\${c}`).replace(/\n/g, '\\n')}"`
65
62
 
66
- async function readingOf(dirs: GitDirs): Promise<{ config: string, exclude?: string }> {
67
- const listed = await text(dirs.top, ['config', '-z', '--get-regexp', READING], { GIT_DIR: dirs.gitDir, GIT_COMMON_DIR: dirs.commonDir }).catch(() => '')
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(() => '')
68
66
  const sections = new Map<string, string[]>()
69
67
  for (const entry of listed.split('\0').filter(Boolean)) {
70
68
  const at = entry.indexOf('\n')
@@ -83,26 +81,27 @@ async function readingOf(dirs: GitDirs): Promise<{ config: string, exclude?: str
83
81
  return { config: `${config}\n`, ...exclude ? { exclude } : {} }
84
82
  }
85
83
 
86
- class Trees {
84
+ /** Trees of the reviewed directory written with an index and a git directory of the review's own. */
85
+ class ReviewTrees {
87
86
  readonly #work = mkdtempSync(join(tmpdir(), 'presmium-review-'))
88
87
  #indexes = 0
89
88
  readonly #pathspec: string
90
89
  readonly #env: Record<string, string>
91
90
  readonly #files?: DocumentFiles
92
91
  readonly top: string
93
- readonly deck: string
92
+ readonly scopeDir: string
94
93
  readonly root: string
95
94
 
96
- constructor(dirs: GitDirs, deck: string, root: string, reading: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
95
+ constructor(dirs: GitDirs, scopeDir: string, root: string, gitConfig: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
97
96
  this.top = dirs.top
98
- this.deck = deck
97
+ this.scopeDir = scopeDir
99
98
  this.root = root
100
- this.#pathspec = relative(dirs.top, deck).split(sep).join('/') || '.'
99
+ this.#pathspec = relative(dirs.top, scopeDir).split(sep).join('/') || '.'
101
100
  const own = join(this.#work, 'git')
102
101
  for (const dir of ['refs', 'objects', 'info']) mkdirSync(join(own, dir), { recursive: true })
103
102
  writeFileSync(join(own, 'HEAD'), 'ref: refs/heads/review\n')
104
- writeFileSync(join(own, 'config'), reading.config)
105
- if (reading.exclude !== undefined) writeFileSync(join(own, 'info', 'exclude'), reading.exclude)
103
+ writeFileSync(join(own, 'config'), gitConfig.config)
104
+ if (gitConfig.exclude !== undefined) writeFileSync(join(own, 'info', 'exclude'), gitConfig.exclude)
106
105
  this.#env = { GIT_DIR: own, GIT_OBJECT_DIRECTORY: join(dirs.commonDir, 'objects'), GIT_WORK_TREE: dirs.top }
107
106
  this.#files = options.files
108
107
  }
@@ -132,6 +131,12 @@ class Trees {
132
131
  }
133
132
  }
134
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
+
135
140
  async compare(from: string, to: string): Promise<ReviewChange[]> {
136
141
  if (from === to) return []
137
142
  const status = (await this.#text([...DIFF, '--name-status', '-z', from, to])).split('\0').filter(Boolean)
@@ -157,7 +162,7 @@ class Trees {
157
162
  }
158
163
 
159
164
  async restore(tree: string, paths: string[]): Promise<void> {
160
- const ours = paths.map(this.toGit).filter(path => within(join(this.top, path), this.deck))
165
+ const ours = paths.map(this.toGit).filter(path => isInside(join(this.top, path), this.scopeDir))
161
166
  if (ours.length === 0) return
162
167
  if (this.#files) return this.#write(tree, ours)
163
168
  const known = new Set((await this.#text(['ls-tree', '-r', '-z', '--name-only', tree, '--', ...ours])).split('\0').filter(Boolean))
@@ -174,7 +179,7 @@ class Trees {
174
179
  for (const path of ours.filter(path => !known.has(path))) {
175
180
  const file = join(this.top, path)
176
181
  rmSync(file, { force: true })
177
- for (let dir = dirname(file); within(dir, this.deck) && dir !== this.deck; dir = dirname(dir)) {
182
+ for (let dir = dirname(file); isInside(dir, this.scopeDir) && dir !== this.scopeDir; dir = dirname(dir)) {
178
183
  try {
179
184
  if (readdirSync(dir).length > 0) break
180
185
  rmdirSync(dir)
@@ -207,7 +212,7 @@ class Trees {
207
212
  }
208
213
 
209
214
  async reset(tree: string): Promise<void> {
210
- await this.restore(tree, (await this.compare(tree, await this.tree())).map(change => change.path))
215
+ await this.restore(tree, await this.changedPaths(tree, await this.tree()))
211
216
  }
212
217
 
213
218
  dispose(): void {
@@ -217,7 +222,7 @@ class Trees {
217
222
 
218
223
  export interface GitReviewOptions {
219
224
  workspaceRoot: string
220
- deckDir: string
225
+ scopeDir: string
221
226
  task: string
222
227
  onRestore?: (paths: string[]) => void
223
228
  warn?: (message: string) => void
@@ -226,88 +231,90 @@ export interface GitReviewOptions {
226
231
 
227
232
  const recovering = new Map<string, Promise<{ task: string, paths: string[] } | undefined>>()
228
233
 
229
- function guarding(gitDir: string, deck: string, root: string, onRestore: GitReviewOptions['onRestore'], guard?: GitGuard) {
230
- const found = guard ?? (within(gitDir, deck) ? guardGitDir(gitDir) : undefined)
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
231
237
  return {
232
- guard: found,
238
+ saved: () => snapshot?.saved(),
233
239
  restore() {
234
- const paths = found?.restore() ?? []
240
+ const paths = snapshot?.restore() ?? []
235
241
  if (paths.length > 0) onRestore?.(paths.map(path => relative(root, join(gitDir, path)).split(sep).join('/')))
236
242
  },
237
243
  }
238
244
  }
239
245
 
240
246
  export function recoverGitReview(options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
241
- const deck = realpathSync(options.deckDir)
242
- let running = recovering.get(deck)
247
+ const scopeDir = realpathSync(options.scopeDir)
248
+ let running = recovering.get(scopeDir)
243
249
  if (!running) {
244
- running = recover(deck, realpathSync(options.workspaceRoot), options).finally(() => recovering.delete(deck))
245
- recovering.set(deck, running)
250
+ running = rollBackLeftReview(scopeDir, realpathSync(options.workspaceRoot), options).finally(() => recovering.delete(scopeDir))
251
+ recovering.set(scopeDir, running)
246
252
  }
247
253
  return running
248
254
  }
249
255
 
250
- async function recover(deck: string, root: string, options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
251
- const { onRestore } = options
252
- const record = readRecord(deck)
253
- if (!record || record.session || opened.has(deck) || holderRuns(record)) return undefined
254
- let trees: Trees | undefined
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
255
260
  try {
256
- if (record.guard) guarding(record.dirs.gitDir, deck, root, onRestore, guardGitDir(record.dirs.gitDir, record.guard)).restore()
257
- trees = new Trees(record.dirs, deck, root, await readingOf(record.dirs), options)
258
- const paths = (await trees.compare(record.snapshot, await trees.tree())).map(change => change.path)
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())
259
264
  await trees.restore(record.snapshot, paths)
260
265
  return { task: record.task, paths }
261
266
  } finally {
262
- removeRecord(deck)
267
+ removeRecord(scopeDir)
263
268
  trees?.dispose()
264
269
  }
265
270
  }
266
271
 
267
- export function leftSessionReview(deckDir: string): ReviewRecord | undefined {
268
- const deck = realpathSync(deckDir)
269
- const record = readRecord(deck)
270
- return record?.session && !opened.has(deck) && !holderRuns(record) ? record : undefined
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
271
277
  }
272
278
 
273
- export async function openGitReview(options: GitReviewOptions & { session?: Omit<SessionRecord, 'checkpoints' | 'steps'> }): Promise<GitReview> {
274
- const deck = realpathSync(options.deckDir)
279
+ export async function openGitReview(options: GitReviewOptions & { session?: Omit<AgentSessionRecord, 'checkpoints' | 'steps'> }): Promise<GitReview> {
280
+ const scopeDir = realpathSync(options.scopeDir)
275
281
  const root = realpathSync(options.workspaceRoot)
276
- if (opened.has(deck)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way already`)
277
- const left = readRecord(deck)
278
- if (left && holderRuns(left)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way in another process (${left.pid})`)
279
- if (left?.session) throw new Error(`a review of ${relative(root, deck) || '.'} waits for its author (session ${left.session.session}): accept or reject it in the workbench first`)
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`)
280
286
  if (left) await recoverGitReview(options)
281
287
  const dirs = await gitDirsOf(root)
282
- const { guard, restore: guarded } = guarding(dirs.gitDir, deck, root, options.onRestore)
283
- const trees = new Trees(dirs, deck, root, await readingOf(dirs), options)
288
+ const guard = gitDirGuard(dirs.gitDir, scopeDir, root, options.onRestore)
289
+ const trees = new ReviewTrees(dirs, scopeDir, root, await isolatedGitConfig(dirs), options)
284
290
 
285
- opened.add(deck)
291
+ openScopes.add(scopeDir)
286
292
  let snapshot: string
287
293
  try {
288
294
  snapshot = await trees.tree()
289
295
  } catch (error) {
290
- opened.delete(deck)
296
+ openScopes.delete(scopeDir)
291
297
  trees.dispose()
292
298
  throw error
293
299
  }
294
- const record: ReviewRecord = { deck, dirs, snapshot, task: options.task, ...SELF, ...guard ? { guard: guard.saved() } : {}, ...options.session ? { session: { ...options.session, checkpoints: [], steps: [] } } : {} }
295
- return reviewOf({ deck, trees, guarded, record, warn: options.warn })
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 })
296
303
  }
297
304
 
298
- export async function resumeGitReview(record: ReviewRecord, options: Omit<GitReviewOptions, 'task' | 'deckDir'>): Promise<GitReview> {
299
- const deck = realpathSync(record.deck)
305
+ export async function resumeGitReview(record: ReviewRecord, options: Omit<GitReviewOptions, 'task' | 'scopeDir'>): Promise<GitReview> {
306
+ const scopeDir = realpathSync(record.scopeDir)
300
307
  const root = realpathSync(options.workspaceRoot)
301
- if (opened.has(deck) || holderRuns(record)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way already`)
302
- const { restore: guarded } = guarding(record.dirs.gitDir, deck, root, options.onRestore, record.guard ? guardGitDir(record.dirs.gitDir, record.guard) : undefined)
303
- guarded()
304
- const trees = new Trees(record.dirs, deck, root, await readingOf(record.dirs), options)
305
- opened.add(deck)
306
- return reviewOf({ deck, trees, guarded, record: { ...record, ...SELF }, warn: options.warn })
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 })
307
314
  }
308
315
 
309
- function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, record: ReviewRecord, warn?: (message: string) => void }): GitReview {
310
- const { deck, trees, guarded, record } = open
316
+ function createGitReview(open: { scopeDir: string, trees: ReviewTrees, restoreGitDir: () => void, record: ReviewRecord, warn?: (message: string) => void }): GitReview {
317
+ const { scopeDir, trees, restoreGitDir, record } = open
311
318
  const { snapshot } = record
312
319
  const steps: ReviewStep[] = [...record.session?.steps ?? []]
313
320
  const checkpoints = [snapshot, ...record.session?.checkpoints ?? []]
@@ -327,22 +334,29 @@ function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, recor
327
334
  const settle = () => {
328
335
  if (settled) return
329
336
  settled = true
330
- opened.delete(deck)
331
- if (!suspended) removeRecord(deck)
337
+ openScopes.delete(scopeDir)
338
+ if (!suspended) removeRecord(scopeDir)
332
339
  trees.dispose()
333
340
  }
334
341
  const live = () => {
335
342
  if (settled) throw new Error('this review has ended')
336
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
+ }
337
352
 
338
353
  return {
339
- root: deck,
354
+ root: scopeDir,
340
355
  visibility: 'immediate',
341
356
  task: record.task,
342
- checkpoints: () => checkpoints.slice(1),
343
357
  async step(label, tool) {
344
358
  live()
345
- guarded()
359
+ restoreGitDir()
346
360
  const now = await trees.tree()
347
361
  if (steps.length >= MAX_STEPS) {
348
362
  checkpoints[checkpoints.length - 1] = now
@@ -360,14 +374,14 @@ function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, recor
360
374
  steps: () => steps,
361
375
  async diff() {
362
376
  live()
363
- guarded()
377
+ restoreGitDir()
364
378
  return trees.compare(snapshot, await trees.tree())
365
379
  },
366
380
  async restoreTo(stepId) {
367
381
  live()
368
382
  const at = steps.findIndex(step => step.id === stepId)
369
383
  if (at < 0) throw new Error(`no step ${stepId} in this review`)
370
- guarded()
384
+ restoreGitDir()
371
385
  await trees.reset(checkpoints[at]!)
372
386
  steps.splice(at)
373
387
  checkpoints.splice(at + 1)
@@ -375,34 +389,32 @@ function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, recor
375
389
  },
376
390
  async restoreFiles(paths) {
377
391
  live()
378
- guarded()
379
- const changed = new Set((await trees.compare(snapshot, await trees.tree())).map(change => change.path))
392
+ restoreGitDir()
393
+ const changed = new Set(await trees.changedPaths(snapshot, await trees.tree()))
380
394
  await trees.restore(snapshot, paths.filter(path => changed.has(path)))
381
395
  },
382
- note(session) {
396
+ noteSession(session) {
383
397
  if (!record.session) return
384
398
  record.session = { ...record.session, ...session }
385
399
  write()
386
400
  },
401
+ prepareAccept,
402
+ finish: settle,
387
403
  async accept(reject = []) {
388
- live()
389
- guarded()
390
- const changes = await trees.compare(snapshot, await trees.tree())
391
- const rejected = new Set(reject.filter(path => changes.some(change => change.path === path)))
392
- await trees.restore(snapshot, [...rejected])
404
+ const files = await prepareAccept(reject)
393
405
  settle()
394
- return changes.filter(change => !rejected.has(change.path)).map(change => change.path)
406
+ return files
395
407
  },
396
408
  async rollback() {
397
409
  live()
398
- guarded()
410
+ restoreGitDir()
399
411
  await trees.reset(snapshot)
400
412
  settle()
401
413
  },
402
414
  async close() {
403
415
  if (settled) return
404
416
  try {
405
- guarded()
417
+ restoreGitDir()
406
418
  await trees.reset(snapshot)
407
419
  } finally {
408
420
  settle()
@@ -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
+ }
@@ -1,72 +1,73 @@
1
- import { eventStream, isRecord, json, problem } from '@lincyaw/kernel'
1
+ import { isRecord, jsonResponse, problemResponse, sseResponse } from '@lincyaw/kernel'
2
2
  import type { Route } from '@lincyaw/kernel'
3
- import type { Reviews } from './reviews.ts'
3
+ import { catchRequestErrors } from '../errors.ts'
4
+ import type { AgentSessionReviews } from './agent-session-reviews.ts'
4
5
 
5
- async function bodyOf(request: Request): Promise<Record<string, unknown>> {
6
+ async function readBody(request: Request): Promise<Record<string, unknown>> {
6
7
  const body: unknown = await request.json().catch(() => undefined)
7
8
  return isRecord(body) ? body : {}
8
9
  }
9
10
 
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)
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)
13
14
 
14
- export function reviewRoutes(reviews: Reviews, answer: (work: () => Promise<Response>) => Promise<Response>): Record<string, Route> {
15
+ export function reviewRoutes(reviews: AgentSessionReviews): Record<string, Route> {
15
16
  const post = (handle: (body: Record<string, unknown>, document: string) => Promise<Response>): Route => ({
16
17
  role: 'editor',
17
- handle: (request, ctx) => answer(async () => (request.method === 'POST' ? handle(await bodyOf(request), ctx.document) : problem(405, 'POST'))),
18
+ handle: (request, ctx) => catchRequestErrors(async () => (request.method === 'POST' ? handle(await readBody(request), ctx.document) : problemResponse(405, 'POST'))),
18
19
  })
19
20
  return {
20
21
  'review': {
21
22
  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 } : {})
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 } : {})
27
28
  }),
28
29
  },
29
30
  'review/events': {
30
31
  role: 'editor',
31
- handle: async (request) => (request.method === 'GET' ? eventStream(reviews.events(request.signal), request.signal) : problem(405, 'GET')),
32
+ handle: async (request) => (request.method === 'GET' ? sseResponse(reviews.events(request.signal), request.signal) : problemResponse(405, 'GET')),
32
33
  },
33
34
  'review/open': {
34
35
  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) })
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) })
42
43
  }),
43
44
  },
44
45
  '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)
46
+ const session = toText(body.session, 200)
47
+ if (!session) return problemResponse(400, '{ session, turn?, label?, tool?, end? }')
48
+ const turn = toTurn(body.turn)
48
49
  const review = await reviews.step(document, {
49
50
  session,
50
51
  ...turn !== undefined ? { turn } : {},
51
- ...text(body.label, 300) ? { label: text(body.label, 300)! } : {},
52
- ...text(body.tool, 100) ? { tool: text(body.tool, 100)! } : {},
52
+ ...toText(body.label, 300) ? { label: toText(body.label, 300)! } : {},
53
+ ...toText(body.tool, 100) ? { tool: toText(body.tool, 100)! } : {},
53
54
  ...body.end === true ? { end: true } : {},
54
55
  })
55
- return json(review ? { review } : {})
56
+ return jsonResponse(review ? { review } : {})
56
57
  }),
57
58
  '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 } : {} }) })
59
+ const reject = toPaths(body.reject)
60
+ const message = toText(body.message)
61
+ return jsonResponse({ ended: await reviews.accept(document, { ...reject ? { reject } : {}, ...message ? { message } : {} }) })
61
62
  }),
62
63
  'review/reject': post(async (body, document) => {
63
- const chosen = paths(body.paths)
64
- return json(await reviews.reject(document, chosen ? { paths: chosen } : {}))
64
+ const chosen = toPaths(body.paths)
65
+ return jsonResponse(await reviews.reject(document, chosen ? { paths: chosen } : {}))
65
66
  }),
66
67
  '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 }) })
68
+ const step = toText(body.step, 100)
69
+ if (!step) return problemResponse(400, '{ step }')
70
+ return jsonResponse({ review: await reviews.restore(document, { step }) })
70
71
  }),
71
72
  }
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
+ }