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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -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
+ }