@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
|
@@ -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,
|
|
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 {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
92
|
+
readonly scopeDir: string
|
|
94
93
|
readonly root: string
|
|
95
94
|
|
|
96
|
-
constructor(dirs: GitDirs,
|
|
95
|
+
constructor(dirs: GitDirs, scopeDir: string, root: string, gitConfig: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
|
|
97
96
|
this.top = dirs.top
|
|
98
|
-
this.
|
|
97
|
+
this.scopeDir = scopeDir
|
|
99
98
|
this.root = root
|
|
100
|
-
this.#pathspec = relative(dirs.top,
|
|
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'),
|
|
105
|
-
if (
|
|
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 =>
|
|
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);
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
+
saved: () => snapshot?.saved(),
|
|
233
239
|
restore() {
|
|
234
|
-
const paths =
|
|
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
|
|
242
|
-
let running = recovering.get(
|
|
247
|
+
const scopeDir = realpathSync(options.scopeDir)
|
|
248
|
+
let running = recovering.get(scopeDir)
|
|
243
249
|
if (!running) {
|
|
244
|
-
running =
|
|
245
|
-
recovering.set(
|
|
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
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
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)
|
|
257
|
-
trees = new
|
|
258
|
-
const paths =
|
|
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(
|
|
267
|
+
removeRecord(scopeDir)
|
|
263
268
|
trees?.dispose()
|
|
264
269
|
}
|
|
265
270
|
}
|
|
266
271
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
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<
|
|
274
|
-
const
|
|
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 (
|
|
277
|
-
const left = readRecord(
|
|
278
|
-
if (left &&
|
|
279
|
-
if (left?.session) throw new Error(`a review of ${relative(root,
|
|
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
|
|
283
|
-
const trees = new
|
|
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
|
-
|
|
291
|
+
openScopes.add(scopeDir)
|
|
286
292
|
let snapshot: string
|
|
287
293
|
try {
|
|
288
294
|
snapshot = await trees.tree()
|
|
289
295
|
} catch (error) {
|
|
290
|
-
|
|
296
|
+
openScopes.delete(scopeDir)
|
|
291
297
|
trees.dispose()
|
|
292
298
|
throw error
|
|
293
299
|
}
|
|
294
|
-
const
|
|
295
|
-
|
|
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' | '
|
|
299
|
-
const
|
|
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 (
|
|
302
|
-
const { restore:
|
|
303
|
-
|
|
304
|
-
const trees = new
|
|
305
|
-
|
|
306
|
-
return
|
|
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
|
|
310
|
-
const {
|
|
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
|
-
|
|
331
|
-
if (!suspended) removeRecord(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
const changed = new Set(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
406
|
+
return files
|
|
395
407
|
},
|
|
396
408
|
async rollback() {
|
|
397
409
|
live()
|
|
398
|
-
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { isRecord, jsonResponse, problemResponse, sseResponse } from '@lincyaw/kernel'
|
|
2
2
|
import type { Route } from '@lincyaw/kernel'
|
|
3
|
-
import
|
|
3
|
+
import { catchRequestErrors } from '../errors.ts'
|
|
4
|
+
import type { AgentSessionReviews } from './agent-session-reviews.ts'
|
|
4
5
|
|
|
5
|
-
async function
|
|
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
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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:
|
|
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) =>
|
|
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) =>
|
|
23
|
-
if (request.method !== 'GET') return
|
|
24
|
-
if (request.headers.get('accept')?.includes('text/event-stream')) return
|
|
25
|
-
const review = await reviews.
|
|
26
|
-
return
|
|
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' ?
|
|
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) =>
|
|
36
|
-
if (request.method !== 'POST') return
|
|
37
|
-
const body = await
|
|
38
|
-
const session =
|
|
39
|
-
if (!session) return
|
|
40
|
-
const turn =
|
|
41
|
-
return
|
|
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 =
|
|
46
|
-
if (!session) return
|
|
47
|
-
const 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
|
-
...
|
|
52
|
-
...
|
|
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
|
|
56
|
+
return jsonResponse(review ? { review } : {})
|
|
56
57
|
}),
|
|
57
58
|
'review/accept': post(async (body, document) => {
|
|
58
|
-
const reject =
|
|
59
|
-
const message =
|
|
60
|
-
return
|
|
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 =
|
|
64
|
-
return
|
|
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 =
|
|
68
|
-
if (!step) return
|
|
69
|
-
return
|
|
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
|
+
}
|