@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.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +41 -3
- package/src/domain/commit-message.ts +80 -0
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +98 -0
- package/src/index.ts +11 -0
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +42 -0
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +141 -0
- 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 +68 -0
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +46 -0
- 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 +104 -0
- package/src/node/review/git-review.ts +428 -0
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +73 -0
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +599 -0
- package/src/node/routes.ts +329 -0
- package/src/node/serve.ts +503 -0
- package/src/node/site.ts +132 -0
- 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
|
@@ -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
|
+
}
|