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

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.
@@ -0,0 +1,103 @@
1
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { join, sep } from 'node:path'
3
+
4
+ type Entry = { kind: 'file', data: Buffer, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }
5
+
6
+ export interface SavedGuard {
7
+ identity: string
8
+ entries: [string, { kind: 'file', data: string, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }][]
9
+ }
10
+
11
+ const UNGUARDED = new Set(['refs', 'logs', 'lfs'])
12
+
13
+ function unguarded(parts: string[]): boolean {
14
+ const name = parts.at(-1)!
15
+ if (name === 'index' || name.endsWith('.lock')) return true
16
+ return parts.some((part, i) => UNGUARDED.has(part) || (part === 'objects' && i + 1 < parts.length && parts[i + 1] !== 'info'))
17
+ }
18
+
19
+ function scan(gitDir: string): Map<string, Entry> {
20
+ const entries = new Map<string, Entry>()
21
+ const visit = (rel: string) => {
22
+ if (unguarded(rel.split(sep))) return
23
+ const path = join(gitDir, rel)
24
+ let stat
25
+ try {
26
+ stat = lstatSync(path)
27
+ } catch {
28
+ return
29
+ }
30
+ if (stat.isSymbolicLink()) {
31
+ entries.set(rel, { kind: 'link', target: readlinkSync(path) })
32
+ } else if (stat.isDirectory()) {
33
+ entries.set(rel, { kind: 'dir' })
34
+ for (const name of readdirSync(path)) visit(join(rel, name))
35
+ } else {
36
+ entries.set(rel, stat.isFile() ? { kind: 'file', data: readFileSync(path), mode: stat.mode & 0o7777 } : { kind: 'other' })
37
+ }
38
+ }
39
+ for (const name of readdirSync(gitDir)) visit(name)
40
+ return entries
41
+ }
42
+
43
+ function same(a: Entry, b: Entry): boolean {
44
+ if (a.kind !== b.kind) return false
45
+ if (a.kind === 'file' && b.kind === 'file') return a.mode === b.mode && a.data.equals(b.data)
46
+ if (a.kind === 'link' && b.kind === 'link') return a.target === b.target
47
+ return a.kind === 'dir'
48
+ }
49
+
50
+ export interface GitGuard {
51
+ restore(): string[]
52
+ saved(): SavedGuard
53
+ }
54
+
55
+ function identityOf(gitDir: string): string {
56
+ const stat = lstatSync(gitDir)
57
+ return `${stat.dev}:${stat.ino}`
58
+ }
59
+
60
+ export function guardGitDir(gitDir: string, saved?: SavedGuard): GitGuard {
61
+ const identity = saved?.identity ?? identityOf(gitDir)
62
+ const before: Map<string, Entry> = saved
63
+ ? new Map(saved.entries.map(([rel, entry]) => [rel, entry.kind === 'file' ? { ...entry, data: Buffer.from(entry.data, 'base64') } : entry]))
64
+ : scan(gitDir)
65
+ return {
66
+ restore() {
67
+ let stat
68
+ try {
69
+ stat = lstatSync(gitDir)
70
+ } catch {
71
+ throw new Error(`the git directory ${gitDir} is gone`)
72
+ }
73
+ if (!stat.isDirectory() || `${stat.dev}:${stat.ino}` !== identity) throw new Error(`the git directory ${gitDir} was replaced`)
74
+ const now = scan(gitDir)
75
+ const changed = new Set<string>()
76
+ for (const [rel, entry] of [...now].sort((a, b) => b[0].length - a[0].length)) {
77
+ const was = before.get(rel)
78
+ if (was && same(was, entry)) continue
79
+ rmSync(join(gitDir, rel), { recursive: true, force: true })
80
+ changed.add(rel)
81
+ }
82
+ for (const [rel, entry] of [...before].sort((a, b) => a[0].length - b[0].length)) {
83
+ const path = join(gitDir, rel)
84
+ try {
85
+ lstatSync(path)
86
+ continue
87
+ } catch {
88
+ }
89
+ if (entry.kind === 'dir') mkdirSync(path)
90
+ else if (entry.kind === 'link') symlinkSync(entry.target, path)
91
+ else if (entry.kind === 'file') {
92
+ writeFileSync(path, entry.data, { mode: entry.mode })
93
+ chmodSync(path, entry.mode)
94
+ } else continue
95
+ changed.add(rel)
96
+ }
97
+ return [...changed].sort()
98
+ },
99
+ saved() {
100
+ return { identity, entries: [...before].map(([rel, entry]) => [rel, entry.kind === 'file' ? { ...entry, data: entry.data.toString('base64') } : entry]) }
101
+ },
102
+ }
103
+ }
@@ -0,0 +1,416 @@
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, isAbsolute, 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 { 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
+ }
16
+
17
+ export interface GitReview extends ChangeReview {
18
+ readonly task: string
19
+ checkpoints(): readonly string[]
20
+ restoreFiles(paths: string[]): Promise<void>
21
+ note(session: Omit<SessionRecord, 'checkpoints' | 'steps'>): void
22
+ suspend(): void
23
+ }
24
+
25
+ export const GIT_ENGINE = 'git'
26
+
27
+ const MAX_STEPS = 100
28
+ const MAX_DIFF = 256 * 1024
29
+ const MAX_DIFFS = 200
30
+
31
+ const DIFF = ['diff', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-color']
32
+
33
+ const opened = new Set<string>()
34
+
35
+ function git(cwd: string, args: string[], env: Record<string, string> = {}): Promise<Buffer> {
36
+ return new Promise((resolve, reject) => {
37
+ execFile('git', [...gitConfigArgs(), ...args], {
38
+ cwd,
39
+ encoding: 'buffer',
40
+ maxBuffer: 256 << 20,
41
+ env: { ...gitEnvironment(process.env), ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1' },
42
+ }, (error, stdout, stderr) => {
43
+ if (error) reject(new Error(`git ${args[0]}: ${stderr.toString('utf8').trim() || error.message}`))
44
+ else resolve(stdout)
45
+ })
46
+ })
47
+ }
48
+
49
+ const text = async (cwd: string, args: string[], env?: Record<string, string>) => (await git(cwd, args, env)).toString('utf8')
50
+
51
+ export interface GitDirs {
52
+ top: string
53
+ gitDir: string
54
+ commonDir: string
55
+ }
56
+
57
+ 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')
59
+ return { top: realpathSync(top), gitDir: realpathSync(gitDir), commonDir: realpathSync(resolve(root, commonDir)) }
60
+ }
61
+
62
+ const READING = '^(core\\.(filemode|symlinks|ignorecase|precomposeunicode|autocrlf|eol|safecrlf|excludesfile)|extensions\\.objectformat)$'
63
+
64
+ const quoted = (value: string) => `"${value.replace(/[\\"]/g, c => `\\${c}`).replace(/\n/g, '\\n')}"`
65
+
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(() => '')
68
+ const sections = new Map<string, string[]>()
69
+ for (const entry of listed.split('\0').filter(Boolean)) {
70
+ const at = entry.indexOf('\n')
71
+ const [key, value] = at < 0 ? [entry, 'true'] : [entry.slice(0, at), entry.slice(at + 1)]
72
+ const [section = '', name = ''] = key.split('.')
73
+ sections.set(section, [...sections.get(section) ?? [], `\t${name} = ${quoted(value)}`])
74
+ }
75
+ const version = sections.has('extensions') ? 1 : 0
76
+ const others = [...sections].filter(([section]) => section !== 'core').map(([section, lines]) => `[${section}]\n${lines.join('\n')}`)
77
+ const config = [`[core]\n\trepositoryformatversion = ${version}\n\tbare = false`, ...sections.get('core') ?? [], ...others].join('\n')
78
+ let exclude: string | undefined
79
+ try {
80
+ exclude = readFileSync(join(dirs.commonDir, 'info', 'exclude'), 'utf8')
81
+ } catch {
82
+ }
83
+ return { config: `${config}\n`, ...exclude ? { exclude } : {} }
84
+ }
85
+
86
+ class Trees {
87
+ readonly #work = mkdtempSync(join(tmpdir(), 'presmium-review-'))
88
+ #indexes = 0
89
+ readonly #pathspec: string
90
+ readonly #env: Record<string, string>
91
+ readonly #files?: DocumentFiles
92
+ readonly top: string
93
+ readonly deck: string
94
+ readonly root: string
95
+
96
+ constructor(dirs: GitDirs, deck: string, root: string, reading: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
97
+ this.top = dirs.top
98
+ this.deck = deck
99
+ this.root = root
100
+ this.#pathspec = relative(dirs.top, deck).split(sep).join('/') || '.'
101
+ const own = join(this.#work, 'git')
102
+ for (const dir of ['refs', 'objects', 'info']) mkdirSync(join(own, dir), { recursive: true })
103
+ 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)
106
+ this.#env = { GIT_DIR: own, GIT_OBJECT_DIRECTORY: join(dirs.commonDir, 'objects'), GIT_WORK_TREE: dirs.top }
107
+ this.#files = options.files
108
+ }
109
+
110
+ #git(args: string[], index?: string): Promise<Buffer> {
111
+ return git(this.top, args, { ...this.#env, ...index ? { GIT_INDEX_FILE: index } : {} })
112
+ }
113
+
114
+ async #text(args: string[], index?: string): Promise<string> {
115
+ return (await this.#git(args, index)).toString('utf8')
116
+ }
117
+
118
+ fromGit = (path: string) => relative(this.root, join(this.top, path)).split(sep).join('/')
119
+ toGit = (path: string) => relative(this.top, join(this.root, path)).split(sep).join('/')
120
+
121
+ #index(): string {
122
+ return join(this.#work, `index-${++this.#indexes}`)
123
+ }
124
+
125
+ async tree(): Promise<string> {
126
+ const index = this.#index()
127
+ try {
128
+ await this.#git(['add', '-A', '--', this.#pathspec], index)
129
+ return (await this.#text(['write-tree'], index)).trim()
130
+ } finally {
131
+ rmSync(index, { force: true })
132
+ }
133
+ }
134
+
135
+ async compare(from: string, to: string): Promise<ReviewChange[]> {
136
+ if (from === to) return []
137
+ const status = (await this.#text([...DIFF, '--name-status', '-z', from, to])).split('\0').filter(Boolean)
138
+ const kinds = new Map<string, ReviewChangeKind>()
139
+ 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')
140
+ const counts = new Map<string, { added: number, removed: number, binary: boolean }>()
141
+ for (const entry of (await this.#text([...DIFF, '--numstat', '-z', from, to])).split('\0').filter(Boolean)) {
142
+ const [added = '0', removed = '0', ...path] = entry.split('\t')
143
+ const binary = added === '-'
144
+ counts.set(path.join('\t'), { added: binary ? 0 : Number(added), removed: binary ? 0 : Number(removed), binary })
145
+ }
146
+ const changes: ReviewChange[] = []
147
+ for (const [path, kind] of kinds) {
148
+ const count = counts.get(path) ?? { added: 0, removed: 0, binary: false }
149
+ const change: ReviewChange = { path: this.fromGit(path), kind, ...count }
150
+ if (!count.binary && changes.filter(c => c.diff).length < MAX_DIFFS) {
151
+ const diff = await this.#text([...DIFF, from, to, '--', path])
152
+ if (diff.length <= MAX_DIFF) change.diff = diff
153
+ }
154
+ changes.push(change)
155
+ }
156
+ return changes.sort((a, b) => a.path.localeCompare(b.path))
157
+ }
158
+
159
+ async restore(tree: string, paths: string[]): Promise<void> {
160
+ const ours = paths.map(this.toGit).filter(path => within(join(this.top, path), this.deck))
161
+ if (ours.length === 0) return
162
+ if (this.#files) return this.#write(tree, ours)
163
+ const known = new Set((await this.#text(['ls-tree', '-r', '-z', '--name-only', tree, '--', ...ours])).split('\0').filter(Boolean))
164
+ const back = ours.filter(path => known.has(path))
165
+ if (back.length > 0) {
166
+ const index = this.#index()
167
+ try {
168
+ await this.#git(['read-tree', tree], index)
169
+ await this.#git(['checkout-index', '-f', '--', ...back], index)
170
+ } finally {
171
+ rmSync(index, { force: true })
172
+ }
173
+ }
174
+ for (const path of ours.filter(path => !known.has(path))) {
175
+ const file = join(this.top, path)
176
+ rmSync(file, { force: true })
177
+ for (let dir = dirname(file); within(dir, this.deck) && dir !== this.deck; dir = dirname(dir)) {
178
+ try {
179
+ if (readdirSync(dir).length > 0) break
180
+ rmdirSync(dir)
181
+ } catch {
182
+ break
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ async #write(tree: string, paths: string[]): Promise<void> {
189
+ const entries = new Map<string, { mode: string, oid: string }>()
190
+ for (const entry of (await this.#text(['ls-tree', '-r', '-z', tree, '--', ...paths])).split('\0').filter(Boolean)) {
191
+ const [meta = '', path = ''] = entry.split('\t')
192
+ const [mode = '', , oid = ''] = meta.split(' ')
193
+ entries.set(path, { mode, oid })
194
+ }
195
+ const changes: DocumentFileChange[] = []
196
+ for (const path of paths) {
197
+ const entry = entries.get(path)
198
+ if (!entry) {
199
+ changes.push({ path: this.fromGit(path), remove: true })
200
+ continue
201
+ }
202
+ if (entry.mode === '160000') continue
203
+ const blob = await this.#git(['cat-file', 'blob', entry.oid])
204
+ changes.push(entry.mode === '120000' ? { path: this.fromGit(path), link: blob.toString('utf8') } : { path: this.fromGit(path), content: blob, ...entry.mode === '100755' ? { executable: true } : {} })
205
+ }
206
+ await this.#files!.write(changes)
207
+ }
208
+
209
+ async reset(tree: string): Promise<void> {
210
+ await this.restore(tree, (await this.compare(tree, await this.tree())).map(change => change.path))
211
+ }
212
+
213
+ dispose(): void {
214
+ rmSync(this.#work, { recursive: true, force: true })
215
+ }
216
+ }
217
+
218
+ export interface GitReviewOptions {
219
+ workspaceRoot: string
220
+ deckDir: string
221
+ task: string
222
+ onRestore?: (paths: string[]) => void
223
+ warn?: (message: string) => void
224
+ files?: DocumentFiles
225
+ }
226
+
227
+ const recovering = new Map<string, Promise<{ task: string, paths: string[] } | undefined>>()
228
+
229
+ function guarding(gitDir: string, deck: string, root: string, onRestore: GitReviewOptions['onRestore'], guard?: GitGuard) {
230
+ const found = guard ?? (within(gitDir, deck) ? guardGitDir(gitDir) : undefined)
231
+ return {
232
+ guard: found,
233
+ restore() {
234
+ const paths = found?.restore() ?? []
235
+ if (paths.length > 0) onRestore?.(paths.map(path => relative(root, join(gitDir, path)).split(sep).join('/')))
236
+ },
237
+ }
238
+ }
239
+
240
+ 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)
243
+ if (!running) {
244
+ running = recover(deck, realpathSync(options.workspaceRoot), options).finally(() => recovering.delete(deck))
245
+ recovering.set(deck, running)
246
+ }
247
+ return running
248
+ }
249
+
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
255
+ 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)
259
+ await trees.restore(record.snapshot, paths)
260
+ return { task: record.task, paths }
261
+ } finally {
262
+ removeRecord(deck)
263
+ trees?.dispose()
264
+ }
265
+ }
266
+
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
271
+ }
272
+
273
+ export async function openGitReview(options: GitReviewOptions & { session?: Omit<SessionRecord, 'checkpoints' | 'steps'> }): Promise<GitReview> {
274
+ const deck = realpathSync(options.deckDir)
275
+ 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`)
280
+ if (left) await recoverGitReview(options)
281
+ 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)
284
+
285
+ opened.add(deck)
286
+ let snapshot: string
287
+ try {
288
+ snapshot = await trees.tree()
289
+ } catch (error) {
290
+ opened.delete(deck)
291
+ trees.dispose()
292
+ throw error
293
+ }
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 })
296
+ }
297
+
298
+ export async function resumeGitReview(record: ReviewRecord, options: Omit<GitReviewOptions, 'task' | 'deckDir'>): Promise<GitReview> {
299
+ const deck = realpathSync(record.deck)
300
+ 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 })
307
+ }
308
+
309
+ function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, record: ReviewRecord, warn?: (message: string) => void }): GitReview {
310
+ const { deck, trees, guarded, record } = open
311
+ const { snapshot } = record
312
+ const steps: ReviewStep[] = [...record.session?.steps ?? []]
313
+ const checkpoints = [snapshot, ...record.session?.checkpoints ?? []]
314
+ let made = Math.max(0, ...steps.map(step => Number(/^step-(\d+)$/.exec(step.id)?.[1] ?? 0)))
315
+ let settled = false
316
+ let suspended = false
317
+
318
+ const write = () => {
319
+ try {
320
+ writeRecord({ ...record, ...record.session ? { session: { ...record.session, checkpoints: checkpoints.slice(1), steps } } : {} })
321
+ } catch (error) {
322
+ open.warn?.(`the review's record was not written, so a crash would leave its changes: ${error instanceof Error ? error.message : String(error)}`)
323
+ }
324
+ }
325
+ write()
326
+
327
+ const settle = () => {
328
+ if (settled) return
329
+ settled = true
330
+ opened.delete(deck)
331
+ if (!suspended) removeRecord(deck)
332
+ trees.dispose()
333
+ }
334
+ const live = () => {
335
+ if (settled) throw new Error('this review has ended')
336
+ }
337
+
338
+ return {
339
+ root: deck,
340
+ visibility: 'immediate',
341
+ task: record.task,
342
+ checkpoints: () => checkpoints.slice(1),
343
+ async step(label, tool) {
344
+ live()
345
+ guarded()
346
+ const now = await trees.tree()
347
+ if (steps.length >= MAX_STEPS) {
348
+ checkpoints[checkpoints.length - 1] = now
349
+ const last: ReviewStep = { ...steps.at(-1)!, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-2)!, now) }
350
+ steps[steps.length - 1] = last
351
+ if (record.session) write()
352
+ return last
353
+ }
354
+ const step: ReviewStep = { id: `step-${++made}`, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-1)!, now) }
355
+ checkpoints.push(now)
356
+ steps.push(step)
357
+ if (record.session) write()
358
+ return step
359
+ },
360
+ steps: () => steps,
361
+ async diff() {
362
+ live()
363
+ guarded()
364
+ return trees.compare(snapshot, await trees.tree())
365
+ },
366
+ async restoreTo(stepId) {
367
+ live()
368
+ const at = steps.findIndex(step => step.id === stepId)
369
+ if (at < 0) throw new Error(`no step ${stepId} in this review`)
370
+ guarded()
371
+ await trees.reset(checkpoints[at]!)
372
+ steps.splice(at)
373
+ checkpoints.splice(at + 1)
374
+ if (record.session) write()
375
+ },
376
+ async restoreFiles(paths) {
377
+ live()
378
+ guarded()
379
+ const changed = new Set((await trees.compare(snapshot, await trees.tree())).map(change => change.path))
380
+ await trees.restore(snapshot, paths.filter(path => changed.has(path)))
381
+ },
382
+ note(session) {
383
+ if (!record.session) return
384
+ record.session = { ...record.session, ...session }
385
+ write()
386
+ },
387
+ 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])
393
+ settle()
394
+ return changes.filter(change => !rejected.has(change.path)).map(change => change.path)
395
+ },
396
+ async rollback() {
397
+ live()
398
+ guarded()
399
+ await trees.reset(snapshot)
400
+ settle()
401
+ },
402
+ async close() {
403
+ if (settled) return
404
+ try {
405
+ guarded()
406
+ await trees.reset(snapshot)
407
+ } finally {
408
+ settle()
409
+ }
410
+ },
411
+ suspend() {
412
+ suspended = true
413
+ settle()
414
+ },
415
+ }
416
+ }
@@ -0,0 +1,73 @@
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 { SavedGuard } from './git-guard.ts'
7
+ import type { GitDirs } from './git-review.ts'
8
+
9
+ export interface SessionRecord {
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
+ export interface ReviewRecord {
20
+ deck: string
21
+ dirs: GitDirs
22
+ snapshot: string
23
+ task: string
24
+ pid: number
25
+ started?: string
26
+ guard?: SavedGuard
27
+ session?: SessionRecord
28
+ }
29
+
30
+ export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
31
+ return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'presmium', 'reviews')
32
+ }
33
+
34
+ const fileOf = (deck: string) => join(recordsDir(), `${createHash('sha256').update(deck).digest('hex').slice(0, 24)}.json`)
35
+
36
+ export function startTime(pid: number): string | undefined {
37
+ try {
38
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
39
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ export const SELF = { pid: process.pid, started: startTime(process.pid) }
46
+
47
+ export function holderRuns(record: ReviewRecord): boolean {
48
+ try {
49
+ process.kill(record.pid, 0)
50
+ } catch (error) {
51
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
52
+ }
53
+ return record.started === undefined || startTime(record.pid) === record.started
54
+ }
55
+
56
+ export function readRecord(deck: string): ReviewRecord | undefined {
57
+ try {
58
+ return JSON.parse(readFileSync(fileOf(deck), 'utf8')) as ReviewRecord
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ export function writeRecord(record: ReviewRecord): void {
65
+ const file = fileOf(record.deck)
66
+ mkdirSync(recordsDir(), { recursive: true, mode: 0o700 })
67
+ writeFileSync(`${file}.${process.pid}`, JSON.stringify(record), { mode: 0o600 })
68
+ renameSync(`${file}.${process.pid}`, file)
69
+ }
70
+
71
+ export function removeRecord(deck: string): void {
72
+ rmSync(fileOf(deck), { force: true })
73
+ }