@lincyaw/server 1.0.0-rc.0 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +4 -4
- package/src/auth.ts +11 -16
- package/src/control.ts +42 -53
- package/src/diagnostics.ts +139 -0
- package/src/focus.ts +6 -6
- package/src/http.ts +39 -20
- package/src/index.ts +7 -7
- package/src/mcp-access.ts +76 -0
- package/src/mcp-host.ts +96 -0
- package/src/mounts.ts +118 -0
- package/src/server-host.ts +75 -0
- package/src/server-routes.ts +203 -0
- package/src/server.ts +123 -285
- package/src/host.ts +0 -52
- package/src/mcp.ts +0 -64
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { LOCAL_AUTHOR } from '@lincyaw/kernel'
|
|
3
|
+
import type { McpEndpoint, McpGrant } from '@lincyaw/kernel'
|
|
4
|
+
import type { McpCaller } from '@lincyaw/mcp'
|
|
5
|
+
|
|
6
|
+
function endedBy(response: Response, signal: AbortSignal, done: () => void): Response {
|
|
7
|
+
if (!response.body) {
|
|
8
|
+
done()
|
|
9
|
+
return response
|
|
10
|
+
}
|
|
11
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
|
|
12
|
+
void response.body.pipeTo(writable, { signal, preventAbort: true })
|
|
13
|
+
.catch(() => writable.close().catch(() => undefined))
|
|
14
|
+
.finally(done)
|
|
15
|
+
return new Response(readable, { status: response.status, statusText: response.statusText, headers: response.headers })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The tokens that open /mcp: this process's own (written to its state file), the grants MCP_ENDPOINT issues,
|
|
20
|
+
* and the grants the launcher asks for its agent sessions. Revoking a grant ends the event streams it opened.
|
|
21
|
+
*/
|
|
22
|
+
export class McpTokens {
|
|
23
|
+
readonly processMcpToken = randomBytes(24).toString('base64url')
|
|
24
|
+
readonly endpoint: McpEndpoint
|
|
25
|
+
readonly #callers = new Map<string, McpCaller>()
|
|
26
|
+
readonly #streams = new Map<string, Set<AbortController>>()
|
|
27
|
+
readonly #agentSessionGrants = new Map<string, McpGrant>()
|
|
28
|
+
|
|
29
|
+
constructor(serverUrl: () => string) {
|
|
30
|
+
this.endpoint = {
|
|
31
|
+
issue: (member, mode, issued = {}) => {
|
|
32
|
+
const mcpToken = randomBytes(24).toString('base64url')
|
|
33
|
+
this.#callers.set(mcpToken, { member, mode, ...issued.confirmed ? { confirmed: true } : {}, ...issued.document !== undefined ? { document: issued.document } : {} })
|
|
34
|
+
return { url: `${serverUrl()}/mcp`, token: mcpToken, revoke: () => this.#revoke(mcpToken) }
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
callerOf(mcpToken: string | undefined): McpCaller | undefined {
|
|
40
|
+
if (mcpToken === undefined) return undefined
|
|
41
|
+
return mcpToken === this.processMcpToken ? { member: LOCAL_AUTHOR, mode: 'author' } : this.#callers.get(mcpToken)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ties an event stream to the grant that opened it, so that revoking the grant ends the stream. */
|
|
45
|
+
tieToGrant(mcpToken: string, response: Response): Response {
|
|
46
|
+
if (mcpToken === this.processMcpToken || !response.headers.get('content-type')?.startsWith('text/event-stream')) return response
|
|
47
|
+
if (!this.#callers.has(mcpToken)) return endedBy(response, AbortSignal.abort(), () => {})
|
|
48
|
+
const stream = new AbortController()
|
|
49
|
+
const open = this.#streams.get(mcpToken) ?? new Set()
|
|
50
|
+
this.#streams.set(mcpToken, open.add(stream))
|
|
51
|
+
return endedBy(response, stream.signal, () => open.delete(stream))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
grantAgentSession(session: string, document: string): McpGrant {
|
|
55
|
+
this.#agentSessionGrants.get(session)?.revoke()
|
|
56
|
+
const grant = this.endpoint.issue(LOCAL_AUTHOR, 'author', { confirmed: true, document })
|
|
57
|
+
this.#agentSessionGrants.set(session, grant)
|
|
58
|
+
return grant
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Revokes the grant of one agent session, or of every one; returns how many it revoked. */
|
|
62
|
+
revokeAgentSessions(session?: string): number {
|
|
63
|
+
const revoked = session === undefined ? [...this.#agentSessionGrants.keys()] : this.#agentSessionGrants.has(session) ? [session] : []
|
|
64
|
+
for (const id of revoked) {
|
|
65
|
+
this.#agentSessionGrants.get(id)!.revoke()
|
|
66
|
+
this.#agentSessionGrants.delete(id)
|
|
67
|
+
}
|
|
68
|
+
return revoked.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#revoke(mcpToken: string): void {
|
|
72
|
+
this.#callers.delete(mcpToken)
|
|
73
|
+
for (const stream of this.#streams.get(mcpToken) ?? []) stream.abort()
|
|
74
|
+
this.#streams.delete(mcpToken)
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/mcp-host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { isPresentingRole } from '@lincyaw/kernel'
|
|
3
|
+
import type { AgentTool, Member, ServiceToken } from '@lincyaw/kernel'
|
|
4
|
+
import type { McpHost, McpPluginTools, McpPosition } from '@lincyaw/mcp'
|
|
5
|
+
import type { ControlChannel } from './control.ts'
|
|
6
|
+
import type { FocusStore } from './focus.ts'
|
|
7
|
+
import type { DiagnosticStore } from './diagnostics.ts'
|
|
8
|
+
import type { ServedDocument, ServerHost } from './server-host.ts'
|
|
9
|
+
|
|
10
|
+
export interface McpHostOptions {
|
|
11
|
+
serverHost?: ServerHost
|
|
12
|
+
control: ControlChannel
|
|
13
|
+
command: 'dev' | 'serve'
|
|
14
|
+
mount(document: string): Promise<ServedDocument | undefined>
|
|
15
|
+
mounted(): Promise<ServedDocument[]>
|
|
16
|
+
service<T>(token: ServiceToken<T>): T | undefined
|
|
17
|
+
focus: FocusStore
|
|
18
|
+
diagnostics: DiagnosticStore
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toolsOf(documents: ServedDocument[], own: McpHostOptions['service']): McpPluginTools {
|
|
22
|
+
const tools = new Map<string, AgentTool>()
|
|
23
|
+
for (const doc of documents) {
|
|
24
|
+
for (const { plugin } of doc.plugins) for (const tool of plugin.tools ?? []) if (!tools.has(tool.name)) tools.set(tool.name, tool)
|
|
25
|
+
}
|
|
26
|
+
const doc = documents[0]
|
|
27
|
+
return { tools: [...tools.values()], service: <T>(token: ServiceToken<T>) => own(token) ?? doc?.service(token) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function serverMcpHost(options: McpHostOptions): McpHost {
|
|
31
|
+
const { control } = options
|
|
32
|
+
const host = (): ServerHost => {
|
|
33
|
+
if (!options.serverHost) throw new Error('this server has no host operations')
|
|
34
|
+
return options.serverHost
|
|
35
|
+
}
|
|
36
|
+
const position = (entry: ReturnType<ControlChannel['positions']>[number]): McpPosition => {
|
|
37
|
+
const { report } = entry
|
|
38
|
+
const { slide, step, view, section, trail } = report.position!
|
|
39
|
+
return { document: entry.document, member: entry.member, slide, step, view, ...section === undefined ? {} : { section }, ...trail ? { trail } : {}, role: report.role, at: report.at }
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
process: { command: options.command },
|
|
43
|
+
get projectAuthoring() { return options.serverHost?.projectAuthoring },
|
|
44
|
+
get authoring() { return options.serverHost?.authoring },
|
|
45
|
+
get documentHistory() { return options.serverHost?.documentHistory },
|
|
46
|
+
get authoringClients() { return options.serverHost?.authoringClients },
|
|
47
|
+
get audioAuthoring() { return options.serverHost?.audioAuthoring },
|
|
48
|
+
get imageAuthoring() { return options.serverHost?.imageAuthoring },
|
|
49
|
+
get voiceAuthoring() { return options.serverHost?.voiceAuthoring },
|
|
50
|
+
get transcriptions() { return options.serverHost?.transcriptions },
|
|
51
|
+
get transcriptionAdoption() { return options.serverHost?.transcriptionAdoption },
|
|
52
|
+
readyTools: document => options.serverHost?.agentTools?.(document) ?? { tools: [], service: options.service },
|
|
53
|
+
watchTools: listener => options.serverHost?.watchAgentTools?.(listener) ?? (() => {}),
|
|
54
|
+
documents: () => host().documents(),
|
|
55
|
+
compile: document => host().compile(document),
|
|
56
|
+
async check(document) {
|
|
57
|
+
const { doc } = await host().compile(document)
|
|
58
|
+
const irRevision = options.diagnostics.irRevision(document)
|
|
59
|
+
const findings = await host().check(document)
|
|
60
|
+
if (irRevision === options.diagnostics.irRevision(document)) {
|
|
61
|
+
const compiled = new Set(doc.diagnostics.map(finding => JSON.stringify(finding)))
|
|
62
|
+
options.diagnostics.replace({ document, origin: 'check' }, findings.filter(finding => !compiled.has(JSON.stringify(finding))), { root: host().workspaceRoot ?? dirname(doc.meta.entry), irRevision })
|
|
63
|
+
}
|
|
64
|
+
return findings
|
|
65
|
+
},
|
|
66
|
+
build: (document, build) => host().build(document, build),
|
|
67
|
+
get jobs() {
|
|
68
|
+
return options.serverHost?.jobs
|
|
69
|
+
},
|
|
70
|
+
run: (document, target, signal) => host().run(document, target, signal),
|
|
71
|
+
approve: (request, signal) => control.approvals.request(request, signal),
|
|
72
|
+
offers: kind => control.policy(kind) !== 'off',
|
|
73
|
+
approval: kind => control.policy(kind),
|
|
74
|
+
async tools(document) {
|
|
75
|
+
if (document === undefined) return toolsOf(await options.mounted(), options.service)
|
|
76
|
+
const doc = await options.mount(document)
|
|
77
|
+
return toolsOf(doc ? [doc] : [], options.service)
|
|
78
|
+
},
|
|
79
|
+
positions(document: string | undefined, member: Member) {
|
|
80
|
+
const all = control.positions({ document, viewer: member })
|
|
81
|
+
const presenting = (w: typeof all[number]) => w.member === member.id && isPresentingRole(w.report.role)
|
|
82
|
+
return [...all.filter(presenting), ...all.filter(w => !presenting(w))].map(position)
|
|
83
|
+
},
|
|
84
|
+
watchPositions: listener => control.watch(listener),
|
|
85
|
+
focus: document => options.focus.find(document),
|
|
86
|
+
watchFocus: listener => options.focus.watch(listener),
|
|
87
|
+
async diagnostics(document) {
|
|
88
|
+
return options.diagnostics.snapshot(document)
|
|
89
|
+
},
|
|
90
|
+
watchDiagnostics: listener => options.diagnostics.watch(listener),
|
|
91
|
+
classroomStatus: document => control.status(document),
|
|
92
|
+
runtime: document => control.runtime(document).map(({ window, report }) => ({ window, role: report.role, position: report.position, reported: new Date(report.at).toISOString(), status: report.status ?? {} })),
|
|
93
|
+
navigate: (document, to, member) => control.go(to, { member, document }),
|
|
94
|
+
setClassroom: (document, action, name, member) => control.setClassroom(action, name, { document, member }),
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/mounts.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { errorMessage, prefixedLogger } from '@lincyaw/kernel'
|
|
2
|
+
import type { Logger, ServerContext, ServerContribution, ServiceToken } from '@lincyaw/kernel'
|
|
3
|
+
import { operatorSpeechSecrets } from '@lincyaw/kernel/node'
|
|
4
|
+
import type { DiagnosticStore } from './diagnostics.ts'
|
|
5
|
+
import type { ServedDocument } from './server-host.ts'
|
|
6
|
+
|
|
7
|
+
export interface Mounted {
|
|
8
|
+
doc: ServedDocument
|
|
9
|
+
contributions: Map<string, ServerContribution>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MountsOptions {
|
|
13
|
+
document(dir: string): Promise<ServedDocument | undefined>
|
|
14
|
+
ownService<T>(token: ServiceToken<T>): T | undefined
|
|
15
|
+
settings?: Record<string, Record<string, unknown>>
|
|
16
|
+
operatorDir?: string
|
|
17
|
+
env: NodeJS.ProcessEnv
|
|
18
|
+
diagnostics: DiagnosticStore
|
|
19
|
+
log: Logger
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The plugins' server contributions of each document, made when a request first names the document. */
|
|
23
|
+
export class Mounts {
|
|
24
|
+
readonly #options: MountsOptions
|
|
25
|
+
readonly #mounted = new Map<string, Promise<Mounted | undefined>>()
|
|
26
|
+
readonly #tearingDown = new Map<string, Promise<void>>()
|
|
27
|
+
#stopped = false
|
|
28
|
+
|
|
29
|
+
constructor(options: MountsOptions) {
|
|
30
|
+
this.#options = options
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
mount(dir: string): Promise<Mounted | undefined> {
|
|
34
|
+
if (this.#stopped) return Promise.resolve(undefined)
|
|
35
|
+
const known = this.#mounted.get(dir)
|
|
36
|
+
if (known) return known
|
|
37
|
+
const loading = this.#load(dir, this.#tearingDown.get(dir))
|
|
38
|
+
loading.then((found) => {
|
|
39
|
+
if (!found && this.#mounted.get(dir) === loading) this.#mounted.delete(dir)
|
|
40
|
+
}, () => {
|
|
41
|
+
if (this.#mounted.get(dir) === loading) this.#mounted.delete(dir)
|
|
42
|
+
})
|
|
43
|
+
this.#mounted.set(dir, loading)
|
|
44
|
+
return loading
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async documents(): Promise<ServedDocument[]> {
|
|
48
|
+
return (await Promise.all([...this.#mounted.values()].map(known => known.catch(() => undefined)))).flatMap(m => (m ? [m.doc] : []))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async unmount(dir: string): Promise<void> {
|
|
52
|
+
const known = this.#mounted.get(dir)
|
|
53
|
+
this.#mounted.delete(dir)
|
|
54
|
+
if (!known) return this.#tearingDown.get(dir)
|
|
55
|
+
this.#options.log.debug(`unmounting the server contributions of ${dir || 'the deck'}`)
|
|
56
|
+
const stopped = this.#takeDown(known).finally(() => this.#options.diagnostics.clear(dir, 'server'))
|
|
57
|
+
this.#tearingDown.set(dir, stopped)
|
|
58
|
+
try {
|
|
59
|
+
await stopped
|
|
60
|
+
} finally {
|
|
61
|
+
if (this.#tearingDown.get(dir) === stopped) this.#tearingDown.delete(dir)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mounts nothing more: every later request for a document finds none. */
|
|
66
|
+
stop(): void {
|
|
67
|
+
this.#stopped = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async close(): Promise<void> {
|
|
71
|
+
this.stop()
|
|
72
|
+
await Promise.all([...this.#mounted.keys()].map(dir => this.unmount(dir)))
|
|
73
|
+
await Promise.all(this.#tearingDown.values())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #load(dir: string, previous: Promise<void> | undefined): Promise<Mounted | undefined> {
|
|
77
|
+
const { diagnostics, env, log } = this.#options
|
|
78
|
+
await previous
|
|
79
|
+
const doc = await this.#options.document(dir)
|
|
80
|
+
if (!doc) return undefined
|
|
81
|
+
log.debug(`mounting the server contributions of ${dir || 'the deck'}: ${doc.plugins.filter(p => p.plugin.server).map(p => p.id).join(', ') || 'none'}`)
|
|
82
|
+
const contributions = new Map<string, ServerContribution>()
|
|
83
|
+
for (const { id, plugin, config } of doc.plugins) {
|
|
84
|
+
if (!plugin.server) continue
|
|
85
|
+
const operatorDir = this.#options.operatorDir ?? doc.operatorDir
|
|
86
|
+
const ctx: ServerContext = {
|
|
87
|
+
deckDir: doc.deckDir,
|
|
88
|
+
workspaceRoot: doc.workspaceRoot,
|
|
89
|
+
config,
|
|
90
|
+
settings: (this.#options.settings ?? doc.settings)?.[id] ?? {},
|
|
91
|
+
lang: doc.lang,
|
|
92
|
+
service: <T>(token: ServiceToken<T>) => this.#options.ownService(token) ?? doc.service(token),
|
|
93
|
+
secret: operatorSpeechSecrets(operatorDir, name => env[name]),
|
|
94
|
+
...operatorDir ? { operatorDir } : {},
|
|
95
|
+
...doc.home ? { home: true } : {},
|
|
96
|
+
log: prefixedLogger(log, id),
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
contributions.set(id, await plugin.server.call(plugin, ctx))
|
|
100
|
+
diagnostics.replace({ document: dir, origin: 'server', instance: id }, [])
|
|
101
|
+
} catch (error) {
|
|
102
|
+
log.error(`plugin ${id} failed to make its server contribution: ${errorMessage(error)}`)
|
|
103
|
+
diagnostics.replace({ document: dir, origin: 'server', instance: id }, [{ checkId: id, severity: 'error', message: `plugin ${id} failed to make its server contribution: ${errorMessage(error)}` }])
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { doc, contributions }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async #takeDown(known: Promise<Mounted | undefined>): Promise<void> {
|
|
110
|
+
for (const [id, contribution] of (await known.catch(() => undefined))?.contributions ?? []) {
|
|
111
|
+
try {
|
|
112
|
+
await contribution.close?.()
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.#options.log.error(`the contribution of ${id} failed to close: ${errorMessage(error)}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ProjectAuthoring, DocumentHistory } from '@lincyaw/kernel'
|
|
2
|
+
import type { Compilation } from '@lincyaw/compiler'
|
|
3
|
+
import { pluginId } from '@lincyaw/compiler'
|
|
4
|
+
import type { DocumentSummary, Finding, IRDocument, Member, OutputJobs, PresmiumNodePlugin, RunResult, ServiceToken } from '@lincyaw/kernel'
|
|
5
|
+
import type { AgentTool, DocumentAuthoring, DocumentAudioAuthoring, DocumentImageAuthoring, DocumentVoiceAuthoring, DocumentTranscriptionAdoption, DocumentTranscriptions, Route } from '@lincyaw/kernel'
|
|
6
|
+
|
|
7
|
+
export interface ServedPlugin {
|
|
8
|
+
id: string
|
|
9
|
+
plugin: PresmiumNodePlugin
|
|
10
|
+
config: Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ServedDocument {
|
|
14
|
+
deckDir: string
|
|
15
|
+
workspaceRoot: string
|
|
16
|
+
lang?: string
|
|
17
|
+
plugins: ServedPlugin[]
|
|
18
|
+
service<T>(token: ServiceToken<T>): T | undefined
|
|
19
|
+
settings?: Record<string, Record<string, unknown>>
|
|
20
|
+
operatorDir?: string
|
|
21
|
+
home?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the server contributions of a compilation depend on, as a key that changes when they must be made again. */
|
|
25
|
+
export function serverConfigKey(compilation: Pick<Compilation, 'doc' | 'plugins'>): string {
|
|
26
|
+
return JSON.stringify([
|
|
27
|
+
compilation.doc.meta.lang,
|
|
28
|
+
compilation.plugins.filter(loaded => loaded.plugin).map(loaded => [pluginId(loaded), loaded.dir, loaded.entry]),
|
|
29
|
+
compilation.doc.config.presmium,
|
|
30
|
+
])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function servedDocument(compilation: Compilation): ServedDocument {
|
|
34
|
+
const { presmium } = compilation.doc.config
|
|
35
|
+
return {
|
|
36
|
+
deckDir: compilation.deckDir,
|
|
37
|
+
workspaceRoot: compilation.workspaceRoot,
|
|
38
|
+
lang: compilation.doc.meta.lang,
|
|
39
|
+
plugins: compilation.plugins.flatMap((loaded) => {
|
|
40
|
+
if (!loaded.plugin) return []
|
|
41
|
+
const config = presmium[pluginId(loaded)]
|
|
42
|
+
return [{ id: pluginId(loaded), plugin: loaded.plugin, config: config !== null && typeof config === 'object' && !Array.isArray(config) ? config as Record<string, unknown> : {} }]
|
|
43
|
+
}),
|
|
44
|
+
service: compilation.service,
|
|
45
|
+
settings: compilation.settings,
|
|
46
|
+
...compilation.operatorDir ? { operatorDir: compilation.operatorDir } : {},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ServerHost {
|
|
51
|
+
projectAuthoring?: ProjectAuthoring
|
|
52
|
+
authoring?: DocumentAuthoring
|
|
53
|
+
documentHistory?: DocumentHistory
|
|
54
|
+
authoringClients?: AuthoringClients
|
|
55
|
+
audioAuthoring?: DocumentAudioAuthoring
|
|
56
|
+
imageAuthoring?: DocumentImageAuthoring
|
|
57
|
+
voiceAuthoring?: DocumentVoiceAuthoring
|
|
58
|
+
transcriptions?: DocumentTranscriptions
|
|
59
|
+
transcriptionAdoption?: DocumentTranscriptionAdoption
|
|
60
|
+
/** Host-owned routes are available before a document's compiled plugin contributions. */
|
|
61
|
+
routes?: Record<string, Record<string, Route>>
|
|
62
|
+
/** Only already compiled plugin tools. This snapshot must never start or wait for compilation. */
|
|
63
|
+
agentTools?(document?: string): { tools: AgentTool[], service<T>(token: ServiceToken<T>): T | undefined }
|
|
64
|
+
watchAgentTools?(listener: (document: string) => void): () => void
|
|
65
|
+
readSource?(path: string, member: Member): Promise<{ content: string }>
|
|
66
|
+
workspaceRoot?: string
|
|
67
|
+
documents(): Promise<DocumentSummary[]>
|
|
68
|
+
compile(document: string): Promise<{ doc: IRDocument, diagnostics: Finding[] }>
|
|
69
|
+
check(document: string): Promise<Finding[]>
|
|
70
|
+
build(document: string, options?: { to?: string[] }): Promise<{ outputs: { renderer: string, outDir: string }[], findings: Finding[] }>
|
|
71
|
+
run(document: string, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult>
|
|
72
|
+
/** The documents' builds and exports as jobs, where the host runs them so (the workspace service). */
|
|
73
|
+
jobs?: OutputJobs
|
|
74
|
+
}
|
|
75
|
+
import type { AuthoringClients } from '@lincyaw/kernel'
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { IncomingMessage } from 'node:http'
|
|
3
|
+
import { errorMessage, hasRole, isRecord, jsonResponse, MAX_BODY, problemResponse, sseResponse } from '@lincyaw/kernel'
|
|
4
|
+
import type { AuthoringDiagnostics, CanonicalSource, Finding, Member, PreviewDiagnostics } from '@lincyaw/kernel'
|
|
5
|
+
import type { McpHost } from '@lincyaw/mcp'
|
|
6
|
+
import type { ControlChannel, WindowPosition } from './control.ts'
|
|
7
|
+
import type { DiagnosticStore } from './diagnostics.ts'
|
|
8
|
+
import { toFocusRequest } from './focus.ts'
|
|
9
|
+
import type { FocusStore } from './focus.ts'
|
|
10
|
+
import { hasBearerToken, limitBody } from './http.ts'
|
|
11
|
+
import type { McpTokens } from './mcp-access.ts'
|
|
12
|
+
import type { ServerHost } from './server-host.ts'
|
|
13
|
+
|
|
14
|
+
export const SERVER_PATH = '/__presmium/presmium.server'
|
|
15
|
+
|
|
16
|
+
export interface ServerRoutesOptions {
|
|
17
|
+
control: ControlChannel
|
|
18
|
+
mcpTokens: McpTokens
|
|
19
|
+
focus: FocusStore
|
|
20
|
+
diagnostics: DiagnosticStore
|
|
21
|
+
mcpHost: McpHost
|
|
22
|
+
serverHost?: ServerHost
|
|
23
|
+
launcherToken?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServerRouteContext {
|
|
27
|
+
member: Member
|
|
28
|
+
document: string
|
|
29
|
+
req: IncomingMessage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The server's own routes under SERVER_PATH: hello, control, tokens, focus and diagnostics. */
|
|
33
|
+
export function createServerRouteHandler(options: ServerRoutesOptions): (request: Request, route: string, ctx: ServerRouteContext) => Promise<Response> {
|
|
34
|
+
const { control, mcpTokens, focus, diagnostics, launcherToken } = options
|
|
35
|
+
const isFromLauncher = (request: Request) => launcherToken !== undefined && hasBearerToken(request, launcherToken)
|
|
36
|
+
const hasDocument = async (serverHost: ServerHost, document: string) => (await serverHost.documents()).some(doc => doc.dir === document)
|
|
37
|
+
|
|
38
|
+
const controlRoute = async (request: Request, { member, document, req }: ServerRouteContext): Promise<Response> => {
|
|
39
|
+
if (request.method === 'GET') {
|
|
40
|
+
const query = new URL(request.url).searchParams
|
|
41
|
+
const window = query.get('window') || randomUUID()
|
|
42
|
+
const stream = control.open(window, member, document, { observer: query.get('observe') === 'approvals' })
|
|
43
|
+
req.once('close', () => stream.end())
|
|
44
|
+
return sseResponse(stream, request.signal)
|
|
45
|
+
}
|
|
46
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
47
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
48
|
+
if (limited instanceof Response) return limited
|
|
49
|
+
const body: unknown = await limited.json().catch(() => undefined)
|
|
50
|
+
if (!isRecord(body)) return problemResponse(400, 'a JSON object')
|
|
51
|
+
if (body.type === 'answer' && typeof body.id === 'string') {
|
|
52
|
+
const status = control.answer(body.id, body.allow === true, member)
|
|
53
|
+
return status === 200 ? jsonResponse({ ok: true }) : problemResponse(status, status === 404 ? 'no such approval' : 'this member may not answer this approval')
|
|
54
|
+
}
|
|
55
|
+
if (body.type === 'report' && typeof body.window === 'string') {
|
|
56
|
+
const position = toWindowPosition(body.position)
|
|
57
|
+
const ok = control.report(body.window, member, { position, role: typeof body.role === 'string' ? body.role : undefined, status: isRecord(body.status) ? body.status : undefined })
|
|
58
|
+
return ok ? jsonResponse({ ok: true }) : problemResponse(404, 'unknown window')
|
|
59
|
+
}
|
|
60
|
+
return problemResponse(400, 'unknown message')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tokensRoute = async (request: Request): Promise<Response> => {
|
|
64
|
+
if (!launcherToken) return problemResponse(403, 'session tokens are issued only to the host that started this service (PRESMIUM_SERVICE_TOKEN)')
|
|
65
|
+
if (!isFromLauncher(request)) return problemResponse(401, 'session tokens need Authorization: Bearer <the service token>')
|
|
66
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
67
|
+
if (limited instanceof Response) return limited
|
|
68
|
+
const body: unknown = await limited.json().catch(() => ({}))
|
|
69
|
+
const session = isRecord(body) && typeof body.session === 'string' && body.session !== '' ? body.session : undefined
|
|
70
|
+
if (request.method === 'DELETE') return jsonResponse({ revoked: mcpTokens.revokeAgentSessions(session) })
|
|
71
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST or DELETE')
|
|
72
|
+
if (!isRecord(body) || typeof body.document !== 'string' || session === undefined) return problemResponse(400, '{ document, session }')
|
|
73
|
+
const document = body.document
|
|
74
|
+
if (options.serverHost && !(await hasDocument(options.serverHost, document))) return problemResponse(404, `no document ${JSON.stringify(document)}`)
|
|
75
|
+
const grant = mcpTokens.grantAgentSession(session, document)
|
|
76
|
+
return jsonResponse({ url: grant.url, token: grant.token, document }, 201)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const focusRoute = async (request: Request, member: Member): Promise<Response> => {
|
|
80
|
+
if (request.method === 'GET') {
|
|
81
|
+
const params = new URL(request.url).searchParams
|
|
82
|
+
const found = focus.find(params.has('document') ? params.get('document')! : undefined)
|
|
83
|
+
return jsonResponse(found ? { document: found.document, focus: found.focus, reported: new Date(found.at).toISOString() } : { focus: null })
|
|
84
|
+
}
|
|
85
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
86
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'a focus report needs Authorization: Bearer <the service token>')
|
|
87
|
+
const { serverHost } = options
|
|
88
|
+
if (!serverHost) return problemResponse(409, 'this server compiles no document')
|
|
89
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
90
|
+
if (limited instanceof Response) return limited
|
|
91
|
+
const body = toFocusRequest(await limited.json().catch(() => undefined))
|
|
92
|
+
if (typeof body === 'string') return problemResponse(400, body)
|
|
93
|
+
const workspaceFocus = body.focus.filter(report => report.scope === 'workspace')
|
|
94
|
+
if (workspaceFocus.length && !hasRole(member, 'owner')) return problemResponse(403, 'workspace source selections need the owner role')
|
|
95
|
+
if (workspaceFocus.length && !serverHost.readSource) return problemResponse(400, 'this host does not read workspace source selections')
|
|
96
|
+
if (!(await hasDocument(serverHost, body.document))) return problemResponse(404, `no document ${JSON.stringify(body.document)}`)
|
|
97
|
+
try {
|
|
98
|
+
const doc = body.focus.some(report => report.scope !== 'workspace') ? (await serverHost.compile(body.document)).doc : undefined
|
|
99
|
+
const sources = new Map(await Promise.all(workspaceFocus.map(async (report) => {
|
|
100
|
+
const path = report.source!.file
|
|
101
|
+
return [path, (await serverHost.readSource!(path, member)).content] as const
|
|
102
|
+
})))
|
|
103
|
+
const record = focus.report(body.document, doc, body.focus, path => sources.get(path)!)
|
|
104
|
+
return jsonResponse({ document: record.document, focus: record.focus, reported: new Date(record.at).toISOString() })
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return problemResponse(400, errorMessage(error))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const diagnosticsRoute = async (request: Request, stream: boolean, req: IncomingMessage): Promise<Response> => {
|
|
111
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'diagnostics need Authorization: Bearer <the service token>')
|
|
112
|
+
const { serverHost } = options
|
|
113
|
+
if (!serverHost) return problemResponse(409, 'this server compiles no document')
|
|
114
|
+
const params = new URL(request.url).searchParams
|
|
115
|
+
if (request.method === 'GET') {
|
|
116
|
+
const document = params.has('document') ? params.get('document')! : undefined
|
|
117
|
+
if (document !== undefined) {
|
|
118
|
+
if (!(await hasDocument(serverHost, document))) return problemResponse(404, `no document ${JSON.stringify(document)}`)
|
|
119
|
+
await options.mcpHost.diagnostics!(document)
|
|
120
|
+
}
|
|
121
|
+
if (!stream) return jsonResponse(diagnostics.snapshot(document))
|
|
122
|
+
const connection = new AbortController()
|
|
123
|
+
req.once('close', () => connection.abort())
|
|
124
|
+
return sseResponse(diagnostics.events(document, AbortSignal.any([connection.signal, request.signal])), request.signal)
|
|
125
|
+
}
|
|
126
|
+
if (request.method !== 'POST' || stream) return problemResponse(405, stream ? 'GET' : 'GET or POST')
|
|
127
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
128
|
+
if (limited instanceof Response) return limited
|
|
129
|
+
const body: unknown = await limited.json().catch(() => undefined)
|
|
130
|
+
const report = toPreviewDiagnostics(body)
|
|
131
|
+
if (typeof report === 'string') return problemResponse(400, report)
|
|
132
|
+
if (!(await hasDocument(serverHost, report.document))) return problemResponse(404, `no document ${JSON.stringify(report.document)}`)
|
|
133
|
+
return jsonResponse({ accepted: diagnostics.report(report) })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return async (request, route, ctx) => {
|
|
137
|
+
if (route === 'hello') return jsonResponse({ member: ctx.member, document: ctx.document })
|
|
138
|
+
if (route === 'control') return await controlRoute(request, ctx)
|
|
139
|
+
if (route === 'tokens') return await tokensRoute(request)
|
|
140
|
+
if (route === 'focus') return await focusRoute(request, ctx.member)
|
|
141
|
+
if (route === 'diagnostics/authoring') {
|
|
142
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'authoring diagnostics need the service token')
|
|
143
|
+
if (!hasRole(ctx.member, 'editor')) return problemResponse(403, 'authoring diagnostics need the role editor')
|
|
144
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
145
|
+
if (!options.serverHost?.authoring) return problemResponse(409, 'authoring diagnostics are unavailable')
|
|
146
|
+
const limited = await limitBody(request, 64 << 10)
|
|
147
|
+
if (limited instanceof Response) return limited
|
|
148
|
+
const report = toAuthoringDiagnostics(await limited.json().catch(() => undefined))
|
|
149
|
+
if (typeof report === 'string') return problemResponse(400, report)
|
|
150
|
+
if (!(await hasDocument(options.serverHost, report.document))) return problemResponse(404, 'no such authoring document')
|
|
151
|
+
diagnostics.reportAuthoring(report, ctx.member.id)
|
|
152
|
+
return jsonResponse({ accepted: true })
|
|
153
|
+
}
|
|
154
|
+
if (route === 'diagnostics' || route === 'diagnostics/events') return await diagnosticsRoute(request, route === 'diagnostics/events', ctx.req)
|
|
155
|
+
return problemResponse(404, 'not found')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toAuthoringDiagnostics(body: unknown): AuthoringDiagnostics | string {
|
|
160
|
+
if (!isRecord(body) || Object.keys(body).some(key => !['document', 'instance', 'findings'].includes(key))
|
|
161
|
+
|| typeof body.document !== 'string' || body.document.length > 1024 || typeof body.instance !== 'string' || !/^workbench:[a-zA-Z0-9.-]{1,80}$/.test(body.instance)
|
|
162
|
+
|| !Array.isArray(body.findings) || body.findings.length > 20) return 'authoring diagnostics need document, workbench instance and at most 20 findings'
|
|
163
|
+
const findings: AuthoringDiagnostics['findings'] = []
|
|
164
|
+
for (const item of body.findings) {
|
|
165
|
+
if (!isRecord(item) || Object.keys(item).some(key => !['checkId', 'severity', 'message', 'authoringSource'].includes(key))
|
|
166
|
+
|| typeof item.checkId !== 'string' || !item.checkId.startsWith('presmium.workbench.') || item.checkId.length > 100
|
|
167
|
+
|| item.severity !== 'error' || typeof item.message !== 'string' || item.message.length > 600) return 'invalid authoring diagnostic'
|
|
168
|
+
let source: CanonicalSource | undefined
|
|
169
|
+
if (item.authoringSource !== undefined) {
|
|
170
|
+
const value = item.authoringSource
|
|
171
|
+
if (!isRecord(value) || Object.keys(value).some(key => !['documentId', 'version', 'fingerprint'].includes(key))
|
|
172
|
+
|| !['documentId', 'version'].every(key => typeof value[key] === 'string' && (value[key] as string).length > 0 && (value[key] as string).length <= 200)
|
|
173
|
+
|| typeof value.fingerprint !== 'string' || !/^[a-f\d]{16}$/.test(value.fingerprint)) return 'invalid authoring diagnostic source'
|
|
174
|
+
source = { documentId: value.documentId as string, version: value.version as string, fingerprint: value.fingerprint }
|
|
175
|
+
}
|
|
176
|
+
findings.push({ checkId: item.checkId, severity: 'error', message: item.message, ...source ? { authoringSource: source } : {} })
|
|
177
|
+
}
|
|
178
|
+
return { document: body.document, instance: body.instance, findings }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toPreviewDiagnostics(body: unknown): PreviewDiagnostics | string {
|
|
182
|
+
if (!isRecord(body) || typeof body.document !== 'string' || (body.renderer !== 'web' && body.renderer !== 'slides') || typeof body.instance !== 'string' || !body.instance || typeof body.revision !== 'string' || !Array.isArray(body.findings)) return 'a preview diagnostic report needs document, renderer, instance, revision and findings'
|
|
183
|
+
const findings: Finding[] = []
|
|
184
|
+
for (const item of body.findings) {
|
|
185
|
+
if (!isRecord(item) || typeof item.checkId !== 'string' || typeof item.message !== 'string' || !['error', 'warning', 'info'].includes(String(item.severity))) return 'each diagnostic needs checkId, severity and message'
|
|
186
|
+
if (item.hint !== undefined && typeof item.hint !== 'string') return 'a diagnostic hint must be text'
|
|
187
|
+
if (item.source !== undefined && (!isRecord(item.source) || typeof item.source.file !== 'string' || (item.source.line !== undefined && (!Number.isInteger(item.source.line) || Number(item.source.line) < 1)))) return 'a diagnostic source needs a file and an optional positive line'
|
|
188
|
+
findings.push({ checkId: item.checkId, severity: item.severity as Finding['severity'], message: item.message, ...item.hint ? { hint: item.hint as string } : {}, ...item.source ? { source: item.source as Finding['source'] } : {} })
|
|
189
|
+
}
|
|
190
|
+
return { document: body.document, renderer: body.renderer, instance: body.instance, revision: body.revision, findings }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toWindowPosition(value: unknown): WindowPosition | undefined {
|
|
194
|
+
if (!isRecord(value) || typeof value.slide !== 'number' || typeof value.step !== 'number') return undefined
|
|
195
|
+
const { trail } = value
|
|
196
|
+
return {
|
|
197
|
+
slide: value.slide,
|
|
198
|
+
step: value.step,
|
|
199
|
+
...typeof value.view === 'string' ? { view: value.view } : {},
|
|
200
|
+
...typeof value.section === 'string' ? { section: value.section } : {},
|
|
201
|
+
...Array.isArray(trail) && trail.length <= 10_000 && trail.every(key => typeof key === 'string') ? { trail: trail as string[] } : {},
|
|
202
|
+
}
|
|
203
|
+
}
|