@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fnv1a64, irBlocks, isRecord, jsonResponse, pageIncomingReferences, pageRemovalTargets, removePageSource } from '@lincyaw/kernel'
|
|
2
|
+
import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
|
|
3
|
+
import type { IRBlock, IRSection, MdastNode, PageDeleteAnswer, PageDeletePlan, RequestContext, Route } from '@lincyaw/kernel'
|
|
4
|
+
import { listDocuments, readWorkspaceConfig } from './config.ts'
|
|
5
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
6
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
7
|
+
import { lockedError } from './locks.ts'
|
|
8
|
+
import type { DocumentLocks } from './locks.ts'
|
|
9
|
+
import type { Revisions } from './revisions.ts'
|
|
10
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
11
|
+
import type { WorkbenchApi } from './workbench-api.ts'
|
|
12
|
+
|
|
13
|
+
interface Options { config: WorkspaceConfig, editor: SourceEditor, workbench: WorkbenchApi, locks: DocumentLocks, revisions?: Revisions }
|
|
14
|
+
const markdown = (node: MdastNode): MdastNode => ({ ...node, position: undefined, ...node.children ? { children: node.children.map(markdown) } : {} })
|
|
15
|
+
const blockSemantic = (block: IRBlock): unknown => ({ ...block, source: undefined, node: block.node.type === 'markdown' ? { ...block.node, mdast: markdown(block.node.mdast) } : block.node.type === 'component' ? { ...block.node, ...block.node.children ? { children: block.node.children.map(blockSemantic) } : {}, ...block.node.fallback ? { fallback: markdown(block.node.fallback) } : {} } : block.node })
|
|
16
|
+
const semantic = (section: IRSection): string => JSON.stringify({ ...section, index: undefined, source: undefined, blocks: section.blocks.map(blockSemantic) })
|
|
17
|
+
|
|
18
|
+
/** Source ranges and incoming links are checked again at confirmation, never trusted from the browser's plan. */
|
|
19
|
+
export function pageDeleteRoute({ config, editor, workbench, locks, revisions }: Options): Route {
|
|
20
|
+
const inspect = async (ctx: RequestContext, section: string) => {
|
|
21
|
+
const answer = await workbench.ir(ctx.document)
|
|
22
|
+
const page = answer.doc.sections.find(page => page.id === section)
|
|
23
|
+
if (!page) throw new RequestError(409, 'page-context-changed')
|
|
24
|
+
const source = editor.read(editor.scope(ctx.document), answer.doc.meta.entry, ctx.member)
|
|
25
|
+
if (answer.sourceFingerprints?.[source.path] !== fnv1a64(source.content)) throw new RequestError(409, 'page-context-changed')
|
|
26
|
+
const blocks = [...irBlocks(page.blocks)]
|
|
27
|
+
const plan: PageDeletePlan = {
|
|
28
|
+
document: ctx.document, section, revision: answer.revision, source: { path: source.path, version: source.version },
|
|
29
|
+
counts: { steps: page.steps + 1, blocks: blocks.length, narration: blocks.filter(block => block.role === 'narration').length },
|
|
30
|
+
last: answer.doc.sections.length === 1, allowed: true,
|
|
31
|
+
}
|
|
32
|
+
const emptyTitle = answer.doc.meta.lang?.startsWith('en') ? 'Untitled page' : '未命名页面'
|
|
33
|
+
let removal
|
|
34
|
+
try { removal = removePageSource(answer.doc, section, source.content, emptyTitle, answer.sourceCapabilities) }
|
|
35
|
+
catch (error) { plan.allowed = false; plan.reason = error instanceof Error ? error.message : 'page-boundary'; return { plan, answer, source } }
|
|
36
|
+
const targets = pageRemovalTargets(answer.doc, section, source.content)
|
|
37
|
+
const others = listDocuments(config).filter(document => document.dir !== ctx.document)
|
|
38
|
+
const checked = await Promise.all(others.map(async document => ({ document: document.dir, answer: await workbench.ir(document.dir) })))
|
|
39
|
+
const references = checked.flatMap(other => pageIncomingReferences(other.answer.doc, answer.doc.meta.entry, targets).map(reference => ({ document: other.document, ...reference })))
|
|
40
|
+
const dependencies = new Map<string, string>()
|
|
41
|
+
if (!config.singleDeck) {
|
|
42
|
+
const workspaceSource = editor.read(editor.scope(''), WORKSPACE_CONFIG, ctx.member)
|
|
43
|
+
const currentConfig = readWorkspaceConfig(config.root)
|
|
44
|
+
if (JSON.stringify(currentConfig.documents) !== JSON.stringify(config.documents)) throw new RequestError(409, 'page-references-unavailable')
|
|
45
|
+
dependencies.set(workspaceSource.path, workspaceSource.version)
|
|
46
|
+
const workspaceView = { ...answer.doc, meta: { ...answer.doc.meta, entry: WORKSPACE_CONFIG }, config: { ...answer.doc.config, presmium: { site: currentConfig.site } }, sections: [] }
|
|
47
|
+
if (pageIncomingReferences(workspaceView, answer.doc.meta.entry, targets).length) references.push({ document: '', section })
|
|
48
|
+
}
|
|
49
|
+
for (const other of checked) {
|
|
50
|
+
if (!other.answer.sourceFingerprints?.[other.answer.doc.meta.entry]) throw new RequestError(409, 'page-references-unavailable')
|
|
51
|
+
if (other.answer.sourceFingerprints[source.path]) references.push({ document: other.document, section: other.answer.doc.sections.find(page => page.source.file === source.path)?.id ?? '' })
|
|
52
|
+
for (const [path, fingerprint] of Object.entries(other.answer.sourceFingerprints)) {
|
|
53
|
+
const opened = editor.read(editor.scope(''), path, ctx.member)
|
|
54
|
+
if (fnv1a64(opened.content) !== fingerprint) throw new RequestError(409, 'page-references-unavailable')
|
|
55
|
+
dependencies.set(path, opened.version)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (references.length) { plan.allowed = false; plan.reason = 'page-referenced'; plan.references = references }
|
|
59
|
+
return { plan, answer, source, removal, dependencies }
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
role: 'editor', maxBody: 8192,
|
|
63
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
64
|
+
if (request.method === 'GET') return jsonResponse((await inspect(ctx, new URL(request.url).searchParams.get('section') ?? '')).plan)
|
|
65
|
+
if (request.method !== 'POST') throw new RequestError(405, 'GET or POST')
|
|
66
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
67
|
+
if (!isRecord(body) || typeof body.section !== 'string' || typeof body.revision !== 'string' || typeof body.base !== 'string') throw new RequestError(400, '{ section, revision, base }')
|
|
68
|
+
// Other Studio writes cannot add a new incoming reference between this check and the commit.
|
|
69
|
+
const directories = listDocuments(config).map(document => document.dir)
|
|
70
|
+
// The empty document key is the global lock. The source save itself owns
|
|
71
|
+
// that lock when editing a root document; dependency versions cover the
|
|
72
|
+
// interval before it is acquired.
|
|
73
|
+
const claimed = locks.claimAll(ctx.document === '' ? [] : directories.filter(directory => directory !== ctx.document && directory !== ''), 'editor:page-delete-check')
|
|
74
|
+
if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
75
|
+
try {
|
|
76
|
+
const { plan, answer, source, removal, dependencies } = await inspect(ctx, body.section)
|
|
77
|
+
if (!plan.allowed || !removal) throw new RequestError(409, plan.reason ?? 'page-boundary')
|
|
78
|
+
if (plan.revision !== body.revision || source.version !== body.base) throw new RequestError(409, 'page-context-changed')
|
|
79
|
+
const expected = answer.doc.sections.filter(page => page.id !== body.section)
|
|
80
|
+
let changed = answer
|
|
81
|
+
const saved = await editor.save(editor.scope(ctx.document), source.path, removal.content, source.version, ctx.member, async path => {
|
|
82
|
+
const started = Date.now()
|
|
83
|
+
while (changed.revision === answer.revision) {
|
|
84
|
+
if (Date.now() - started > 15000) throw new RequestError(409, 'page-compile-timeout')
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
86
|
+
changed = await workbench.ir(ctx.document)
|
|
87
|
+
}
|
|
88
|
+
const actual = changed.doc.sections
|
|
89
|
+
if (editor.read(editor.scope(ctx.document), source.path, ctx.member).content !== removal.content) throw new RequestError(409, 'page-context-changed')
|
|
90
|
+
for (const [path, version] of dependencies ?? []) if (editor.read(editor.scope(''), path, ctx.member).version !== version) throw new RequestError(409, 'page-references-unavailable')
|
|
91
|
+
if (removal.retainedEmpty ? actual.length !== 1 || actual[0]?.id !== body.section || actual[0]?.blocks.length !== 0
|
|
92
|
+
: actual.length !== expected.length || expected.some((page, index) => {
|
|
93
|
+
// The compiler treats the first H1 as its cover. Removing the old
|
|
94
|
+
// cover normalizes the next unchanged H1 from depth 1 to 0.
|
|
95
|
+
const normalized = index === 0 && page.depth === 1 && actual[index]?.depth === 0 ? { ...page, depth: 0 } : page
|
|
96
|
+
return actual[index]?.id !== page.id || semantic(actual[index]!) !== semantic(normalized)
|
|
97
|
+
})) throw new RequestError(409, 'page-result-changed')
|
|
98
|
+
if (JSON.stringify(listDocuments(config).map(document => document.dir)) !== JSON.stringify(directories)) throw new RequestError(409, 'page-context-changed')
|
|
99
|
+
return await revisions?.commit({ files: [path], message: answer.doc.meta.lang?.startsWith('en') ? `Delete page “${answer.doc.sections.find(page => page.id === body.section)!.title}”` : `删除页面“${answer.doc.sections.find(page => page.id === body.section)!.title}”`, origin: { kind: 'editor', member: ctx.member } })
|
|
100
|
+
})
|
|
101
|
+
return jsonResponse({ document: ctx.document, selected: removal.selected, retainedEmpty: removal.retainedEmpty, sourceVersion: saved.version, irRevision: changed.revision, ...saved.revision ? { commit: saved.revision } : {} } satisfies PageDeleteAnswer)
|
|
102
|
+
} finally { claimed.release() }
|
|
103
|
+
}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
|
|
3
|
+
import { join, posix } from 'node:path'
|
|
4
|
+
import { DOCUMENT_ENTRY, parseAnchor, validateDocument } from '@lincyaw/kernel'
|
|
5
|
+
import type { ProjectOperation, TeachingDocument, DocumentFileChange } from '@lincyaw/kernel'
|
|
6
|
+
import { invalidDocumentDir, isWithin, movedUrl, rewriteLinks } from '../domain/paths.ts'
|
|
7
|
+
import { isDocumentDir, listDocuments, readWorkspaceConfig } from './config.ts'
|
|
8
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
9
|
+
import { RequestError } from './errors.ts'
|
|
10
|
+
import { DOCUMENT_OPERATION_MAX_BACKUP_BYTES, DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES, documentSourceEntries } from './writer.ts'
|
|
11
|
+
import { findTemplate, listTemplates, DEFAULT_TEMPLATE } from './templates.ts'
|
|
12
|
+
|
|
13
|
+
export const projectDigest = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex')
|
|
14
|
+
const entry = (dir: string) => dir ? `${dir}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY
|
|
15
|
+
const remote = /^(?:[a-z][a-z\d+.-]*:|\/|#|\?)/i
|
|
16
|
+
export function projectConfiguration(config: WorkspaceConfig): string {
|
|
17
|
+
const file = join(config.root, 'presmium.workspace.yaml'), stat = lstatSync(file, { throwIfNoEntry: false })
|
|
18
|
+
if (stat && (!stat.isFile() || stat.size > 1 << 20)) throw new RequestError(413, 'project-configuration-limit')
|
|
19
|
+
const current = readWorkspaceConfig(config.root)
|
|
20
|
+
if (JSON.stringify(current) !== JSON.stringify(config)) throw new RequestError(409, 'project-configuration-changed')
|
|
21
|
+
return projectDigest(Buffer.concat([Buffer.from(JSON.stringify(config)), existsSync(file) ? readFileSync(file) : Buffer.alloc(0)]))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Bounded actual directory bytes plus every canonical reference and legacy relink input. No compilation. */
|
|
25
|
+
export function projectMaterial(config: WorkspaceConfig, operation: ProjectOperation, documentId?: string, result = false) {
|
|
26
|
+
if (operation.dir !== '' && invalidDocumentDir(operation.dir)) throw new RequestError(400, 'project-directory-invalid')
|
|
27
|
+
if ((operation.op === 'create' && invalidDocumentDir(operation.dir)) || ((operation.op === 'copy' || operation.op === 'rename') && invalidDocumentDir(operation.to))) throw new RequestError(400, 'project-directory-invalid')
|
|
28
|
+
const hash = createHash('sha256'), seen = new Set<string>(), known = listDocuments(config)
|
|
29
|
+
let files = 0, bytes = 0
|
|
30
|
+
const add = (label: string, value: string | Uint8Array) => { hash.update(JSON.stringify([label, projectDigest(value)])) }
|
|
31
|
+
const limit = () => { throw new RequestError(413, 'project-tree-limit', { maxEntries: DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES, maxBytes: DOCUMENT_OPERATION_MAX_BACKUP_BYTES }) }
|
|
32
|
+
const walk = (path: string, label: string, documentRoot: string) => {
|
|
33
|
+
if (seen.has(path)) return
|
|
34
|
+
seen.add(path)
|
|
35
|
+
const stat = lstatSync(path, { throwIfNoEntry: false })
|
|
36
|
+
if (!stat) { add(label, 'absent'); return }
|
|
37
|
+
if (++files > DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES || (bytes += stat.isDirectory() ? 0 : stat.size) > DOCUMENT_OPERATION_MAX_BACKUP_BYTES) limit()
|
|
38
|
+
if (stat.isSymbolicLink()) {
|
|
39
|
+
const target = readlinkSync(path), resolved = posix.normalize(posix.join(posix.dirname(label), target))
|
|
40
|
+
if (target.startsWith('/') || !isWithin(resolved, documentRoot)) throw new RequestError(409, 'project-external-link-unavailable', { path: label })
|
|
41
|
+
add(label, 'link:' + target); return
|
|
42
|
+
}
|
|
43
|
+
add(label + ':mode', String(stat.mode & 0o777))
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
for (const name of readdirSync(path).sort()) {
|
|
46
|
+
if (name === '.git') throw new RequestError(409, 'project-nested-repository')
|
|
47
|
+
walk(join(path, name), label ? `${label}/${name}` : name, documentRoot)
|
|
48
|
+
}
|
|
49
|
+
} else if (stat.isFile()) add(label, readFileSync(path))
|
|
50
|
+
else throw new RequestError(409, 'project-file-kind-unavailable')
|
|
51
|
+
}
|
|
52
|
+
const safeParents = (dir: string) => {
|
|
53
|
+
let path = config.root
|
|
54
|
+
for (const part of dir.split('/').filter(Boolean)) {
|
|
55
|
+
path = join(path, part)
|
|
56
|
+
const stat = lstatSync(path, { throwIfNoEntry: false })
|
|
57
|
+
if (!stat) break
|
|
58
|
+
if (!stat.isDirectory()) throw new RequestError(409, 'project-directory-unavailable')
|
|
59
|
+
if (existsSync(join(path, '.git'))) throw new RequestError(409, 'project-nested-repository')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const dirs = operation.op === 'copy' || operation.op === 'rename' ? [operation.dir, operation.to] : [operation.dir]
|
|
63
|
+
if (operation.op === 'retitle') walk(join(config.root, entry(operation.dir)), entry(operation.dir), operation.dir)
|
|
64
|
+
else for (const dir of dirs) { safeParents(dir); walk(join(config.root, dir), dir, dir) }
|
|
65
|
+
const canonical = new Map<string, TeachingDocument>()
|
|
66
|
+
for (const doc of known) {
|
|
67
|
+
safeParents(doc.dir)
|
|
68
|
+
const path = join(config.root, doc.entry), stat = lstatSync(path)
|
|
69
|
+
if (!stat.isFile() || stat.size > 2 << 20) throw new RequestError(413, 'project-document-limit', { document: doc.dir })
|
|
70
|
+
if (++files > DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES || (bytes += stat.size) > DOCUMENT_OPERATION_MAX_BACKUP_BYTES) limit()
|
|
71
|
+
const raw = readFileSync(path); add(doc.entry, raw)
|
|
72
|
+
let parsed: unknown
|
|
73
|
+
try { parsed = JSON.parse(raw.toString('utf8')) } catch { throw new RequestError(422, 'project-document-invalid', { document: doc.dir }) }
|
|
74
|
+
const checked = validateDocument(parsed)
|
|
75
|
+
if (!checked.document) throw new RequestError(422, 'project-document-invalid', { document: doc.dir })
|
|
76
|
+
canonical.set(doc.dir, checked.document)
|
|
77
|
+
if (operation.op === 'rename' || operation.op === 'delete') for (const item of documentSourceEntries(join(config.root, doc.dir), () => { if (++files > DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES) limit() })) {
|
|
78
|
+
if (item.link !== undefined || !item.path.endsWith('.md')) continue
|
|
79
|
+
const rel = doc.dir ? `${doc.dir}/${item.path}` : item.path, content = readFileSync(join(config.root, rel))
|
|
80
|
+
if ((bytes += content.length) > DOCUMENT_OPERATION_MAX_BACKUP_BYTES || ++files > DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES) limit()
|
|
81
|
+
add(rel, content)
|
|
82
|
+
if (!result && operation.op === 'delete' && !isWithin(rel, operation.dir) && rewriteLinks(content.toString('utf8'), rel, rel, operation.dir, '__removed__') !== content.toString('utf8')) throw new RequestError(409, 'project-referenced', { path: rel })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let template: string | undefined
|
|
86
|
+
if (operation.op === 'create') {
|
|
87
|
+
const templates = listTemplates(config)
|
|
88
|
+
template = operation.template ?? (templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0])
|
|
89
|
+
const source = template && findTemplate(config, template)
|
|
90
|
+
if (!source) throw new RequestError(400, 'project-template-unavailable')
|
|
91
|
+
walk(source, '@template', '@template')
|
|
92
|
+
const stat = lstatSync(join(source, DOCUMENT_ENTRY), { throwIfNoEntry: false })
|
|
93
|
+
if (!stat?.isFile() || stat.size > 2 << 20) throw new RequestError(422, 'project-template-invalid')
|
|
94
|
+
const checked = validateDocument(JSON.parse(readFileSync(join(source, DOCUMENT_ENTRY), 'utf8')))
|
|
95
|
+
if (!checked.document) throw new RequestError(422, 'project-template-invalid')
|
|
96
|
+
canonical.set('@template', checked.document)
|
|
97
|
+
}
|
|
98
|
+
if (result) return { impactFiles: 0, impactBytes: 0, tree: hash.digest('hex'), files, bytes, changes: [] as DocumentFileChange[], template }
|
|
99
|
+
if (operation.op !== 'create' && !canonical.has(operation.dir)) throw new RequestError(404, 'project-document-not-found')
|
|
100
|
+
const target = operation.op === 'create' ? operation.dir : operation.op === 'copy' || operation.op === 'rename' ? operation.to : undefined
|
|
101
|
+
if (target !== undefined) {
|
|
102
|
+
if (invalidDocumentDir(target) || !isDocumentDir(config, target)) throw new RequestError(400, 'project-directory-invalid')
|
|
103
|
+
if (existsSync(join(config.root, target))) throw new RequestError(409, 'project-target-exists')
|
|
104
|
+
if (known.some(doc => doc.dir !== '' && (isWithin(target, doc.dir) || isWithin(doc.dir, target)))) throw new RequestError(409, 'project-nested-document')
|
|
105
|
+
}
|
|
106
|
+
const impactRoot = operation.op === 'create' ? findTemplate(config, template!)! : join(config.root, operation.dir)
|
|
107
|
+
const impactEntries = operation.op === 'retitle' ? [{ path: DOCUMENT_ENTRY }] : operation.op === 'delete' || operation.op === 'rename'
|
|
108
|
+
? (() => { const entries: { path: string }[] = []; const collect = (dir: string, prefix: string) => { for (const name of readdirSync(dir)) { const path = prefix ? `${prefix}/${name}` : name, stat = lstatSync(join(impactRoot, path)); if (stat.isDirectory()) collect(join(impactRoot, path), path); else entries.push({ path }) } }; collect(impactRoot, ''); return entries })()
|
|
109
|
+
: documentSourceEntries(impactRoot)
|
|
110
|
+
const impactFiles = impactEntries.length, impactBytes = impactEntries.reduce((sum, item) => sum + lstatSync(join(impactRoot, item.path)).size, 0)
|
|
111
|
+
const changes: DocumentFileChange[] = []
|
|
112
|
+
const moving = operation.op === 'copy' || operation.op === 'rename', deleting = operation.op === 'delete'
|
|
113
|
+
for (const [dir, document] of canonical) {
|
|
114
|
+
const own = dir === operation.dir || dir === '@template', from = dir === '@template' ? '' : dir
|
|
115
|
+
if (operation.op === 'copy' && !own || operation.op === 'create' && dir !== '@template' || operation.op === 'retitle' && !own || deleting && own) continue
|
|
116
|
+
const to = own && moving ? operation.to : own && operation.op === 'create' ? operation.dir : from
|
|
117
|
+
const was = entry(from), next = entry(to), before = JSON.stringify(document)
|
|
118
|
+
const refused = (pointer: string) => { throw new RequestError(409, 'project-reference-unsupported', { document: dir, pointer }) }
|
|
119
|
+
const url = (value: string, pointer: string): string => {
|
|
120
|
+
if (remote.test(value)) return value
|
|
121
|
+
let path: string
|
|
122
|
+
try { path = decodeURI(value.split(/[?#]/)[0]!) } catch { return refused(pointer) }
|
|
123
|
+
if (deleting && isWithin(posix.normalize(posix.join(from, path)), operation.dir)) throw new RequestError(409, 'project-referenced', { document: dir, pointer })
|
|
124
|
+
return moving ? movedUrl(value, next, was, operation.dir, operation.to) ?? value : value
|
|
125
|
+
}
|
|
126
|
+
for (const [id, asset] of Object.entries(document.assets)) asset.location = url(asset.location, `/assets/${id}/location`)
|
|
127
|
+
for (const [id, definition] of Object.entries(document.definitions)) if (definition.implementation.type === 'file') definition.implementation.path = url(definition.implementation.path, `/definitions/${id}/implementation/path`)
|
|
128
|
+
const rich = (value: unknown, pointer: string) => {
|
|
129
|
+
if (!value || typeof value !== 'object') return
|
|
130
|
+
if (Array.isArray(value)) { value.forEach((node, index) => rich(node, `${pointer}/${index}`)); return }
|
|
131
|
+
const node = value as Record<string, unknown>
|
|
132
|
+
if (node.type === 'link' && typeof node.href === 'string') node.href = url(node.href, `${pointer}/href`)
|
|
133
|
+
// Only the typed rich-text tree is traversed; plugin data and arbitrary strings are never rewritten.
|
|
134
|
+
if (Array.isArray(node.children)) rich(node.children, pointer + '/children')
|
|
135
|
+
if (Array.isArray(node.fallback)) rich(node.fallback, pointer + '/fallback')
|
|
136
|
+
}
|
|
137
|
+
for (const [id, block] of Object.entries(document.blocks)) {
|
|
138
|
+
if (block.type === 'text') rich(block.content, `/blocks/${id}/content`)
|
|
139
|
+
if (block.type === 'image') rich(block.caption, `/blocks/${id}/caption`)
|
|
140
|
+
if (block.type === 'code') for (const origin of [block.origin, ...block.variants?.map(variant => variant.origin) ?? []]) {
|
|
141
|
+
if (!origin || origin.repo) continue
|
|
142
|
+
const parsed = parseAnchor(origin.anchor), at = parsed?.scheme === 'pattern' ? parsed.target.indexOf('#') : parsed?.target.lastIndexOf('#')
|
|
143
|
+
if (!parsed || !['region', 'symbol', 'pattern'].includes(parsed.scheme) || at === undefined || at < 1) { if (own && moving) refused(`/blocks/${id}/origin`); continue }
|
|
144
|
+
const path = parsed.target.slice(0, at)
|
|
145
|
+
if (deleting && isWithin(path, operation.dir)) throw new RequestError(409, 'project-referenced', { document: dir, pointer: `/blocks/${id}/origin` })
|
|
146
|
+
if (moving && isWithin(path, operation.dir) && origin.pin) {
|
|
147
|
+
if (operation.op === 'rename') throw new RequestError(409, 'project-pinned-anchor-rename-unavailable', { document: dir, pointer: `/blocks/${id}/origin` })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (moving && isWithin(path, operation.dir)) origin.anchor = `${parsed.scheme}:${operation.to}${path.slice(operation.dir.length)}${parsed.target.slice(at)}`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [threadId, thread] of Object.entries(document.comments ?? {})) for (const comment of thread.comments) rich(comment.content, `/comments/${threadId}/comments/${comment.id}/content`)
|
|
154
|
+
if (document.metadata.translationOf) document.metadata.translationOf.entry = url(document.metadata.translationOf.entry, '/metadata/translationOf/entry')
|
|
155
|
+
if (document.metadata.translations) for (const lang of Object.keys(document.metadata.translations)) document.metadata.translations[lang] = url(document.metadata.translations[lang]!, `/metadata/translations/${lang}`)
|
|
156
|
+
if (own && (operation.op === 'create' || operation.op === 'copy')) { document.id = documentId!; if (operation.title) { document.metadata.title = operation.title; for (const section of Object.values(document.sections)) if (section.title === '{{title}}') section.title = operation.title } }
|
|
157
|
+
if (own && operation.op === 'retitle') document.metadata.title = operation.title
|
|
158
|
+
if (JSON.stringify(document) !== before || own && (operation.op === 'create' || operation.op === 'copy')) changes.push({ path: next, content: Buffer.from(JSON.stringify(document, null, 2) + '\n') })
|
|
159
|
+
}
|
|
160
|
+
if (operation.op === 'retitle' && changes.length === 0) throw new RequestError(409, 'project-no-change')
|
|
161
|
+
if (changes.length > 16 || changes.reduce((size, change) => size + ('content' in change ? change.content.length : 0), 0) > 2 << 20) throw new RequestError(413, 'project-reference-limit', { maxFiles: 16, maxBytes: 2 << 20 })
|
|
162
|
+
return { impactFiles, impactBytes, tree: hash.digest('hex'), files, bytes, changes, template }
|
|
163
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { DocumentEditor } from './document-authoring.ts'
|
|
2
|
+
import { listDocuments } from './config.ts'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { PROJECT_MAX_PLANS, PROJECT_PLAN_TTL_MS, PROJECT_MAX_REQUEST_CHARS, PROJECT_MAX_RESPONSE_CHARS, hasRole, jsonResponse, problemResponse, validateDocument } from '@lincyaw/kernel'
|
|
7
|
+
import type { CanonicalSource, DocumentChange, ProjectApplied, ProjectApply, ProjectAuthoring, ProjectCaller, ProjectOperation, ProjectPlan, ProjectPlanRequest, Route } from '@lincyaw/kernel'
|
|
8
|
+
import { validateProjectApply, validateProjectPlan } from '@lincyaw/kernel/node'
|
|
9
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
10
|
+
import type { DocumentOperations } from './document-operations.ts'
|
|
11
|
+
import type { Git } from './git.ts'
|
|
12
|
+
import type { Revisions } from './revisions.ts'
|
|
13
|
+
import { DOCUMENT_OPERATION_MAX_BACKUP_BYTES, DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES, documentDirOf } from './writer.ts'
|
|
14
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
15
|
+
import { projectConfiguration, projectDigest, projectMaterial } from './project-files.ts'
|
|
16
|
+
|
|
17
|
+
type Admission = { signature: string, admission: Promise<ProjectPlan> }
|
|
18
|
+
type Stored = { member: string, origin: ProjectCaller['origin'], signature: string, plan: ProjectPlan, material?: ReturnType<typeof projectMaterial>, canonical?: { change: DocumentChange, planId: string, source: CanonicalSource }, receipt?: ProjectApplied, work?: Promise<ProjectApplied> }
|
|
19
|
+
const budget = (value: unknown) => { const raw = JSON.stringify(value); if (raw === undefined) throw new RequestError(400, 'project-request-invalid'); if (raw.length > PROJECT_MAX_REQUEST_CHARS) throw new RequestError(413, 'project-request-too-large') }
|
|
20
|
+
const bounded = <T>(value: T): T => { if (JSON.stringify(value, null, 2).length > PROJECT_MAX_RESPONSE_CHARS) throw new RequestError(413, 'project-response-too-large'); return value }
|
|
21
|
+
const signature = (request: ProjectPlanRequest) => projectDigest(JSON.stringify([request.id, Object.fromEntries(Object.entries(request.operation).sort(([a], [b]) => a.localeCompare(b)))]))
|
|
22
|
+
|
|
23
|
+
/** One project plan owner; directory and metadata changes retain their existing transaction owners. */
|
|
24
|
+
export function createProjectAuthoring(options: { config: WorkspaceConfig, operations?: DocumentOperations, revisions?: Revisions, git?: Git, documentEditor?: DocumentEditor }) {
|
|
25
|
+
const { config, operations, revisions, git, documentEditor } = options, plans = new Map<string, Stored | Admission>()
|
|
26
|
+
const settled = (): Stored[] => [...plans.values()].filter((value): value is Stored => 'plan' in value)
|
|
27
|
+
let closed = false
|
|
28
|
+
const reasons = (caller: ProjectCaller, operation?: ProjectOperation) => [
|
|
29
|
+
...!hasRole(caller.member, 'editor') ? ['project-permission-denied'] : [],
|
|
30
|
+
...caller.boundDocument !== undefined && (!operation || operation.op !== 'retitle' || operation.dir !== caller.boundDocument) ? ['project-workspace-scope-required'] : [],
|
|
31
|
+
...operation?.op === 'retitle' ? (!documentEditor ? ['project-metadata-unavailable'] : []) : [
|
|
32
|
+
...config.singleDeck ? ['project-single-document-workspace'] : [],
|
|
33
|
+
...!operations || !revisions || !git ? ['project-git-unavailable'] : [],
|
|
34
|
+
],
|
|
35
|
+
...caller.origin === 'agent' && config.approvals.operate === 'off' ? ['project-operation-disabled'] : [],
|
|
36
|
+
...caller.origin === 'agent' && config.settings['presmium.assistant']?.review === 'required' ? ['authoring-review-required'] : [],
|
|
37
|
+
]
|
|
38
|
+
const authorize = (caller: ProjectCaller, operation?: ProjectOperation) => {
|
|
39
|
+
if (closed) throw new RequestError(503, 'project-owner-closed')
|
|
40
|
+
const refusal = reasons(caller, operation)[0]
|
|
41
|
+
if (refusal) throw new RequestError(refusal === 'project-permission-denied' || refusal === 'project-workspace-scope-required' ? 403 : 409, refusal)
|
|
42
|
+
}
|
|
43
|
+
const sourceOf = (value: Awaited<ReturnType<DocumentEditor['read']>>): CanonicalSource => ({ documentId: value.content.id, version: value.version, fingerprint: value.fingerprint })
|
|
44
|
+
const compactRevision = (value: ProjectApplied['revision']) => value && { ...value, message: value.message.split('\n')[0]!.slice(0, 240), author: value.author.slice(0, 120), origin: { kind: value.origin.kind }, files: value.files.filter(path => path.length <= 500).slice(0, 6) }
|
|
45
|
+
const observation = async () => ({ snapshot: await revisions?.head(), configuration: projectConfiguration(config) })
|
|
46
|
+
const current = async (stored: Stored, result: boolean, caller: ProjectCaller) => {
|
|
47
|
+
if (stored.canonical) {
|
|
48
|
+
if (projectConfiguration(config) !== stored.plan.observation.configuration) throw new RequestError(409, 'project-configuration-changed')
|
|
49
|
+
const actual = sourceOf(await documentEditor!.read(stored.plan.operation.dir, caller.member))
|
|
50
|
+
const expected = result ? stored.receipt?.source : stored.canonical.source
|
|
51
|
+
if (!expected || actual.documentId !== expected.documentId || actual.version !== expected.version || actual.fingerprint !== expected.fingerprint) throw new RequestError(409, 'document-version-changed', { source: actual })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const observed = await observation()
|
|
55
|
+
const expected = result ? stored.receipt : stored.plan.observation
|
|
56
|
+
if (observed.configuration !== stored.plan.observation.configuration || observed.snapshot !== (result ? stored.receipt?.snapshot : expected?.snapshot)) throw new RequestError(409, 'project-source-changed', { observation: observed })
|
|
57
|
+
const material = projectMaterial(config, stored.plan.operation, stored.plan.documentId, result)
|
|
58
|
+
if (material.tree !== (result ? stored.receipt?.tree : stored.plan.tree)) throw new RequestError(409, 'project-tree-changed')
|
|
59
|
+
}
|
|
60
|
+
const key = (request: ProjectPlanRequest, caller: ProjectCaller) => JSON.stringify([caller.member.id, caller.origin, request.id])
|
|
61
|
+
const service: ProjectAuthoring = {
|
|
62
|
+
async capabilities(caller) {
|
|
63
|
+
if (closed) throw new RequestError(503, 'project-owner-closed')
|
|
64
|
+
const observed = await observation(), directory = reasons(caller), metadata = reasons(caller, { op: 'retitle', dir: caller.boundDocument ?? '', title: 'capability' })
|
|
65
|
+
if (!observed.snapshot && !directory.includes('project-git-unavailable')) directory.push('project-head-unavailable')
|
|
66
|
+
const actions = Object.fromEntries(['create', 'copy', 'rename', 'delete', 'retitle'].map(op => { const unavailable = op === 'retitle' ? metadata : directory; return [op, { available: unavailable.length === 0, reasons: unavailable }] })) as import('@lincyaw/kernel').ProjectCapabilities['actions']
|
|
67
|
+
const available = Object.values(actions).some(action => action.available)
|
|
68
|
+
return bounded({ available, actions, reasons: available ? [] : [...new Set([...directory, ...metadata])], observation: observed, verification: 'configuration-only', rootDirectoryOperations: false,
|
|
69
|
+
limits: { requestChars: PROJECT_MAX_REQUEST_CHARS, responseChars: PROJECT_MAX_RESPONSE_CHARS, backupEntries: DOCUMENT_OPERATION_MAX_BACKUP_ENTRIES, backupBytes: DOCUMENT_OPERATION_MAX_BACKUP_BYTES, plans: PROJECT_MAX_PLANS, ttlMs: PROJECT_PLAN_TTL_MS } })
|
|
70
|
+
},
|
|
71
|
+
async plan(request, caller) {
|
|
72
|
+
budget(request)
|
|
73
|
+
if (!validateProjectPlan(request)) throw new RequestError(400, 'project-plan-invalid')
|
|
74
|
+
authorize(caller, request.operation)
|
|
75
|
+
if (request.operation.op === 'retitle') {
|
|
76
|
+
if (!listDocuments(config).some(document => document.dir === request.operation.dir)) throw new RequestError(404, 'project-document-not-found')
|
|
77
|
+
const id = key(request, caller), signatureValue = signature(request), existing = plans.get(id)
|
|
78
|
+
if (existing) { if (existing.signature !== signatureValue) throw new RequestError(409, 'project-intent-id-reused'); if ('admission' in existing) return existing.admission; await current(existing, Boolean(existing.receipt), caller); return structuredClone(existing.plan) }
|
|
79
|
+
for (const [id, value] of plans) if ('plan' in value && !value.work && Date.parse(value.plan.expiresAt) <= Date.now()) plans.delete(id)
|
|
80
|
+
if (plans.size >= PROJECT_MAX_PLANS) throw new RequestError(429, 'project-plan-capacity')
|
|
81
|
+
const operation = { ...request.operation, title: request.operation.title.trim() }
|
|
82
|
+
if (!operation.title) throw new RequestError(400, 'project-title-required')
|
|
83
|
+
const admission = (async () => {
|
|
84
|
+
const before = await documentEditor!.read(operation.dir, caller.member), source = sourceOf(before)
|
|
85
|
+
const change: DocumentChange = { id: 'project-title:' + randomUUID(), baseDocumentVersion: source.version, replacement: { ...before.content, metadata: { ...before.content.metadata, title: operation.title } } }
|
|
86
|
+
const canonical = await documentEditor!.plan(operation.dir, change, caller.member)
|
|
87
|
+
authorize(caller, operation)
|
|
88
|
+
const draft = { id: request.id, planId: randomUUID(), operation, observation: { configuration: projectConfiguration(config) }, source, documentId: source.documentId, expiresAt: canonical.expiresAt, impact: { added: [operation.dir], removed: [], files: 1, bytes: Buffer.byteLength(JSON.stringify(before.content)), referenceDocuments: [] as string[], referencePolicy: 'preserve-or-refuse' as const } }
|
|
89
|
+
const plan = bounded({ ...draft, digest: projectDigest(JSON.stringify([draft, canonical.digest])) })
|
|
90
|
+
plans.set(id, { member: caller.member.id, origin: caller.origin, signature: signatureValue, plan, canonical: { source, change, planId: canonical.planId } })
|
|
91
|
+
return structuredClone(plan)
|
|
92
|
+
})()
|
|
93
|
+
const pending: Admission = { signature: signatureValue, admission }; plans.set(id, pending)
|
|
94
|
+
try { return await admission } catch (error) { if (plans.get(id) === pending) plans.delete(id); throw error }
|
|
95
|
+
}
|
|
96
|
+
if (request.operation.dir === '' && !['create', 'retitle'].includes(request.operation.op)) throw new RequestError(409, 'project-root-operation-unavailable')
|
|
97
|
+
return git!.exclusive(async () => {
|
|
98
|
+
authorize(caller, request.operation)
|
|
99
|
+
for (const [id, stored] of plans) if ('plan' in stored && !stored.work && Date.parse(stored.plan.expiresAt) <= Date.now()) plans.delete(id)
|
|
100
|
+
const id = key(request, caller), existing = plans.get(id), sig = signature(request)
|
|
101
|
+
if (existing) { if (existing.signature !== sig) throw new RequestError(409, 'project-intent-id-reused'); if ('admission' in existing) return existing.admission; await current(existing, Boolean(existing.receipt), caller); return structuredClone(existing.plan) }
|
|
102
|
+
if (plans.size >= PROJECT_MAX_PLANS) throw new RequestError(429, 'project-plan-capacity')
|
|
103
|
+
const observed = await observation()
|
|
104
|
+
if (!observed.snapshot) throw new RequestError(409, 'project-head-unavailable')
|
|
105
|
+
const documentId = request.operation.op === 'create' || request.operation.op === 'copy' ? randomUUID() : undefined
|
|
106
|
+
const input = request.operation.op === 'create' ? { ...request.operation, title: request.operation.title?.trim() || request.operation.dir.split('/').at(-1)! } : structuredClone(request.operation)
|
|
107
|
+
const material = projectMaterial(config, input, documentId)
|
|
108
|
+
const operation = input.op === 'create' ? { ...input, template: material.template } : input
|
|
109
|
+
{
|
|
110
|
+
const dirty = await revisions!.uncommittedFiles([...material.changes.map(change => change.path), ...operation.op === 'delete' || operation.op === 'rename' ? [operation.dir] : []])
|
|
111
|
+
if (dirty.length) throw new RequestError(409, 'project-uncommitted-target', { count: dirty.length })
|
|
112
|
+
}
|
|
113
|
+
const draft = { id: request.id, planId: randomUUID(), operation, observation: observed, tree: material.tree, ...documentId ? { documentId } : {}, expiresAt: new Date(Date.now() + PROJECT_PLAN_TTL_MS).toISOString(),
|
|
114
|
+
impact: { added: operation.op === 'delete' ? [] : [operation.op === 'rename' || operation.op === 'copy' ? operation.to : operation.dir], removed: operation.op === 'delete' || operation.op === 'rename' ? [operation.dir] : [], files: material.impactFiles, bytes: material.impactBytes, referenceDocuments: [...new Set(material.changes.map(change => documentDirOf(config, change.path)).filter((dir): dir is string => dir !== undefined && dir !== (operation.op === 'rename' || operation.op === 'copy' ? operation.to : operation.dir)))], referencePolicy: 'preserve-or-refuse' as const } }
|
|
115
|
+
const plan = bounded({ ...draft, digest: projectDigest(JSON.stringify(draft)) })
|
|
116
|
+
plans.set(id, { member: caller.member.id, origin: caller.origin, signature: sig, plan, material })
|
|
117
|
+
return structuredClone(plan)
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
async readPlan(request, caller) {
|
|
121
|
+
budget(request)
|
|
122
|
+
if (!validateProjectApply(request)) throw new RequestError(400, 'project-apply-invalid')
|
|
123
|
+
const stored = settled().find(item => item.plan.planId === request.planId && item.member === caller.member.id && item.origin === caller.origin)
|
|
124
|
+
if (!stored || stored.plan.digest !== request.digest) throw new RequestError(404, 'project-plan-not-found')
|
|
125
|
+
authorize(caller, stored.plan.operation)
|
|
126
|
+
if (!stored.receipt && Date.parse(stored.plan.expiresAt) <= Date.now()) throw new RequestError(409, 'project-plan-expired')
|
|
127
|
+
await current(stored, Boolean(stored.receipt), caller)
|
|
128
|
+
return structuredClone(stored.plan)
|
|
129
|
+
},
|
|
130
|
+
async apply(request: ProjectApply, caller: ProjectCaller, signal?: AbortSignal) {
|
|
131
|
+
signal?.throwIfAborted()
|
|
132
|
+
budget(request)
|
|
133
|
+
if (!validateProjectApply(request)) throw new RequestError(400, 'project-apply-invalid')
|
|
134
|
+
const stored = settled().find(item => item.plan.planId === request.planId && item.member === caller.member.id && item.origin === caller.origin)
|
|
135
|
+
if (!stored || stored.plan.digest !== request.digest) throw new RequestError(404, 'project-plan-not-found')
|
|
136
|
+
authorize(caller, stored.plan.operation)
|
|
137
|
+
if (stored.plan.operation.op === 'delete' && request.confirmDeletion !== true) throw new RequestError(409, 'project-deletion-confirmation-required')
|
|
138
|
+
if (stored.receipt) { await current(stored, true, caller); return structuredClone(stored.receipt) }
|
|
139
|
+
if (Date.parse(stored.plan.expiresAt) <= Date.now()) throw new RequestError(409, 'project-plan-expired')
|
|
140
|
+
if (stored.work) return stored.work
|
|
141
|
+
const work = (async () => {
|
|
142
|
+
if (stored.canonical) {
|
|
143
|
+
await current(stored, false, caller); signal?.throwIfAborted(); authorize(caller, stored.plan.operation)
|
|
144
|
+
const answer = caller.origin === 'agent'
|
|
145
|
+
? await documentEditor!.apply(stored.plan.operation.dir, { change: stored.canonical.change, planId: stored.canonical.planId }, caller.member)
|
|
146
|
+
: await documentEditor!.change(stored.plan.operation.dir, stored.canonical.change, caller.member)
|
|
147
|
+
const source = sourceOf(answer), actual = sourceOf(await documentEditor!.read(stored.plan.operation.dir, caller.member))
|
|
148
|
+
if (JSON.stringify(source) !== JSON.stringify(actual)) throw new RequestError(409, 'project-readback-changed', { saved: true, source })
|
|
149
|
+
const receipt: ProjectApplied = bounded({ id: stored.plan.id, planId: stored.plan.planId, dir: stored.plan.operation.dir, documentId: source.documentId, source,
|
|
150
|
+
...answer.commit ? { snapshot: answer.commit.id, revision: compactRevision(answer.commit), revisionTruncated: true, fileCount: answer.commit.files.length } : {}, verification: { saved: true, readback: 'verified', compiled: 'not-requested' } })
|
|
151
|
+
stored.receipt = receipt
|
|
152
|
+
return structuredClone(receipt)
|
|
153
|
+
}
|
|
154
|
+
if (stored.plan.operation.op === 'retitle' || !stored.material) throw new RequestError(409, 'project-plan-invalid')
|
|
155
|
+
// DocumentOperations owns the queue; verify runs inside that transaction before any mutation.
|
|
156
|
+
const answer = await operations!.apply(stored.plan.operation, caller.member, { documentId: stored.plan.documentId, origin: caller.origin, changes: stored.material.changes,
|
|
157
|
+
verify: async () => { signal?.throwIfAborted(); authorize(caller, stored.plan.operation); if (Date.parse(stored.plan.expiresAt) <= Date.now()) throw new RequestError(409, 'project-plan-expired'); await current(stored, false, caller)
|
|
158
|
+
const op = stored.plan.operation
|
|
159
|
+
if ((await revisions!.uncommittedFiles([...stored.material!.changes.map(change => change.path), ...op.op === 'rename' || op.op === 'delete' ? [op.dir] : []])).length) throw new RequestError(409, 'project-uncommitted-target')
|
|
160
|
+
} })
|
|
161
|
+
try {
|
|
162
|
+
const snapshot = await revisions!.head()
|
|
163
|
+
if (!snapshot || answer.revision?.id !== snapshot) throw new RequestError(409, 'project-readback-changed', { committed: true })
|
|
164
|
+
const material = projectMaterial(config, stored.plan.operation, stored.plan.documentId, true)
|
|
165
|
+
const revision = answer.revision && { ...answer.revision, message: answer.revision.message.split('\n')[0]!.slice(0, 240), author: answer.revision.author.slice(0, 120), origin: { kind: answer.revision.origin.kind }, files: answer.revision.files.filter(path => path.length <= 500).slice(0, 6) }
|
|
166
|
+
const receipt: ProjectApplied = { dir: answer.dir, revision, revisionTruncated: true, fileCount: answer.revision?.files.length, id: stored.plan.id, planId: stored.plan.planId, snapshot, tree: material.tree, ...stored.plan.documentId ? { documentId: stored.plan.documentId } : {}, verification: { saved: true, readback: 'verified', compiled: 'not-requested' } }
|
|
167
|
+
const op = stored.plan.operation
|
|
168
|
+
if (op.op === 'rename' || op.op === 'delete') if (existsSync(join(config.root, op.dir))) throw new RequestError(409, 'project-readback-changed', { committed: true })
|
|
169
|
+
for (const change of stored.material!.changes) if ('content' in change && projectDigest(readFileSync(join(config.root, change.path))) !== projectDigest(change.content)) throw new RequestError(409, 'project-readback-changed', { committed: true })
|
|
170
|
+
if (stored.plan.documentId && answer.dir) {
|
|
171
|
+
const checked = validateDocument(JSON.parse(readFileSync(join(config.root, answer.dir, 'lecture.json'), 'utf8')))
|
|
172
|
+
if (checked.document?.id !== stored.plan.documentId) throw new RequestError(409, 'project-readback-changed', { committed: true })
|
|
173
|
+
}
|
|
174
|
+
stored.receipt = bounded(receipt)
|
|
175
|
+
return structuredClone(stored.receipt)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof RequestError && error.detail?.committed === true) throw error
|
|
178
|
+
throw new RequestError(409, 'project-readback-changed', { committed: true, planId: stored.plan.planId })
|
|
179
|
+
}
|
|
180
|
+
})()
|
|
181
|
+
stored.work = work
|
|
182
|
+
try { return await work } finally { if (stored.work === work) stored.work = undefined }
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
const caller = (member: ProjectCaller['member']): ProjectCaller => ({ member, origin: 'editor' })
|
|
186
|
+
const routes: Record<string, Route> = {
|
|
187
|
+
'project/capabilities': { role: 'reader', handle: (request, ctx) => catchRequestErrors(async () => request.method === 'GET' ? jsonResponse(await service.capabilities(caller(ctx.member))) : problemResponse(405, 'GET')) },
|
|
188
|
+
'project/plan': { role: 'editor', maxBody: PROJECT_MAX_REQUEST_CHARS * 4, handle: (request, ctx) => catchRequestErrors(async () => { if (request.method !== 'POST') return problemResponse(405, 'POST'); return jsonResponse(await service.plan(await request.json().catch(() => undefined), caller(ctx.member))) }) },
|
|
189
|
+
'project/apply': { role: 'editor', maxBody: PROJECT_MAX_REQUEST_CHARS * 4, handle: (request, ctx) => catchRequestErrors(async () => { if (request.method !== 'POST') return problemResponse(405, 'POST'); return jsonResponse(await service.apply(await request.json().catch(() => undefined), caller(ctx.member), request.signal)) }) },
|
|
190
|
+
}
|
|
191
|
+
return { service, routes, async close() { closed = true; await Promise.allSettled<unknown>([...plans.values()].map(value => 'admission' in value ? value.admission : value.work)); plans.clear() } }
|
|
192
|
+
}
|