refactor(ai): own headless lint models behind a host, read the editor when present

Introduces HeadlessModelHost as the single owner of Monaco models for
linting. Owned models live under a reserved `__wmlint__/<workspace>/…`
namespace disjoint from every editor URI, keyed by the draft cell
(workspace, itemKind, storagePath) plus a sub-path for a flow module or app
runnable — so a headless model can never adopt, overwrite, or dispose one an
editor owns, and a fork and its parent never share a model. When an editor is
already showing the item in the same workspace, the lint reads its markers
instead of creating anything; those markers are settled first so a lint right
after an edit can't read a stale clean. The editor registers itself on mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-21 08:22:22 +02:00
co-authored by Claude Opus 4.8
parent 8ac43ed7e8
commit f92ece776b
5 changed files with 259 additions and 98 deletions
+10
View File
@@ -107,6 +107,7 @@
} from './lint/typescriptExtraLibs'
import { createWindmillAta, genAtaRoot } from './lint/typescriptAta'
import { readModelMarkers } from './lint/markers'
import { registerEditor as registerLintEditor } from './lint/headlessModelHost'
import { buildDenoImportMap, hasLanguageServers, lspServersFor } from './lint/lspLanguageConfig'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import type { Selection } from 'monaco-editor'
@@ -1683,8 +1684,16 @@
})
let loadTimeout: number | undefined = undefined
// Announce this editor to the headless lint host: when the AI chat lints the same item
// in the same workspace, it reads these markers instead of touching this model.
let unregisterFromLintHost: (() => void) | undefined = undefined
onMount(async () => {
if (BROWSER) {
unregisterFromLintHost = registerLintEditor(uri, $workspaceStore ?? '', () => ({
markers: getLintErrors(),
content: getCode()
}))
if (loadAsync) {
loadTimeout = setTimeout(() => loadMonaco().then((x) => (disposeMethod = x)), 0)
} else {
@@ -1696,6 +1705,7 @@
onDestroy(() => {
console.log('destroying editor')
unregisterFromLintHost?.()
valueAfterDispose = getCode()
pasteCleanup?.()
destroyed = true
@@ -3997,7 +3997,14 @@ type LintTargetArgs = {
async function resolveLintTarget(
args: LintTargetArgs,
workspace: string
): Promise<{ content: string; language: string; lintPath: string; label: string }> {
): Promise<{
content: string
language: string
itemKind: string
storagePath: string
subPath?: string
label: string
}> {
if (args.kind === 'script') {
const script = await loadScriptForEdit(args.path, workspace).catch(() => {
throw new Error(`No draft or deployed script found at "${args.path}".`)
@@ -4005,7 +4012,8 @@ async function resolveLintTarget(
return {
content: script.content,
language: script.language,
lintPath: args.path,
itemKind: 'script',
storagePath: args.path,
label: `script "${args.path}"`
}
}
@@ -4027,8 +4035,11 @@ async function resolveLintTarget(
return {
content: module.value.content,
language: module.value.language,
// The editor mounts flow modules at "<flow path>/<module id>"; lint the same URI.
lintPath: `${args.path}/${args.module_id}`,
// The editor mounts a flow module at "<flow path>/<module id>"; the owned model
// mirrors that under the flow's draft cell.
itemKind: 'flow',
storagePath: args.path,
subPath: args.module_id,
label: `module "${args.module_id}" of flow "${args.path}"`
}
}
@@ -4050,7 +4061,9 @@ async function resolveLintTarget(
return {
content: runnable.inlineScript.content ?? '',
language: runnable.inlineScript.language,
lintPath: `${args.path}/${runnableKey}`,
itemKind: 'app',
storagePath: args.path,
subPath: runnableKey,
label: `runnable "${runnableKey}" of app "${args.path}"`
}
}
@@ -4153,8 +4166,10 @@ async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise<
const outcome = await lintCode({
content: target.content,
scriptLang: target.language,
path: target.lintPath,
workspace
workspace,
itemKind: target.itemKind,
storagePath: target.storagePath,
subPath: target.subPath
})
const result = outcome.result
const incomplete = outcome.status === 'incomplete'
@@ -11,8 +11,9 @@ import {
setMonacoTypescriptOptions,
TS_DIAGNOSTIC_CODES_TO_IGNORE
} from '../monacoLanguagesOptions'
import { computeModelPath, computeModelUri } from './monacoUri'
import { computeModelUri, computeOwnedUri } from './monacoUri'
import { readModelMarkers } from './markers'
import { acquireOwnedModel, readThrough, releaseOwnedModel } from './headlessModelHost'
import { ensureCustomWmillTypes, ensureResourceTypeNamespace } from './typescriptExtraLibs'
import { createWindmillAta, genAtaRoot } from './typescriptAta'
import { lintWithLsp } from './headlessLsp'
@@ -35,15 +36,20 @@ export function canLintHeadless(scriptLang: string | undefined): boolean {
export interface HeadlessLintRequest {
content: string
scriptLang: string
/** Canonical editor path without extension: script path, `<flow>/<moduleId>`, `<app>/<runnableKey>`. */
path: string
workspace: string
/** The draft cell this code lives in — its identity, keyed like UserDraft. */
itemKind: string
storagePath: string
/** A unit within the cell: a flow module id or an app runnable key; absent for a whole script. */
subPath?: string
}
// Models we created ourselves, least recently used first.
const ownedModels: string[] = []
const MAX_OWNED_MODELS = 4
/** The editor path this request maps to — where a mounted editor would put its model. */
function editorPathOf(req: HeadlessLintRequest): string {
return req.subPath ? `${req.storagePath}/${req.subPath}` : req.storagePath
}
const MAX_ATA_INSTANCES = 4
const absolutePathExtraLibs = new Map<string, { dispose: () => void }>()
const ataByKey = new Map<string, (source: string) => Promise<void>>()
const pendingByUri = new Map<string, Promise<LintOutcome>>()
@@ -52,31 +58,6 @@ function withDeadline(promise: Promise<unknown>, ms: number): Promise<unknown> {
return Promise.race([promise, new Promise((resolve) => setTimeout(resolve, ms))])
}
function touchOwnedModel(uriString: string) {
const at = ownedModels.indexOf(uriString)
if (at >= 0) ownedModels.splice(at, 1)
ownedModels.push(uriString)
}
function evictOwnedModels(keepUri: string) {
while (ownedModels.length > MAX_OWNED_MODELS) {
const candidate = ownedModels.shift()
if (!candidate) continue
if (candidate === keepUri) {
// Still in use: keep tracking it, or a later lint would mistake it for a model
// owned by an editor and refuse to update its content.
ownedModels.push(candidate)
continue
}
const model = meditor.getModel(Uri.parse(candidate))
// An editor may have adopted it in the meantime; disposing it would close the
// user's buffer.
if (model && !model.isAttachedToEditor()) {
model.dispose()
}
}
}
export async function lintCode(
req: HeadlessLintRequest,
opts?: { timeoutMs?: number }
@@ -94,26 +75,27 @@ export async function lintCode(
scriptLang: req.scriptLang,
editorLang,
workspace: req.workspace,
path: req.path,
path: editorPathOf(req),
timeoutMs: opts?.timeoutMs
})
}
const uriString = computeModelUri(
computeModelPath(req.path, req.scriptLang),
const ownedUri = computeOwnedUri(
{ workspace: req.workspace, itemKind: req.itemKind, storagePath: req.storagePath },
req.subPath,
req.scriptLang,
editorLang
)
// Two lints of the same URI would race on model content; run them in order.
const previous = pendingByUri.get(uriString) ?? Promise.resolve(undefined)
// Two lints of the same owned model would race on its content; run them in order.
const previous = pendingByUri.get(ownedUri) ?? Promise.resolve(undefined)
const run = previous
.catch(() => undefined)
.then(() => lintOne(req, uriString, editorLang, opts?.timeoutMs ?? 3000))
.then(() => lintOne(req, ownedUri, editorLang, opts?.timeoutMs ?? 3000))
pendingByUri.set(
uriString,
ownedUri,
run.finally(() => {
if (pendingByUri.get(uriString) === run) pendingByUri.delete(uriString)
if (pendingByUri.get(ownedUri) === run) pendingByUri.delete(ownedUri)
}) as Promise<LintOutcome>
)
return run
@@ -121,7 +103,7 @@ export async function lintCode(
async function lintOne(
req: HeadlessLintRequest,
uriString: string,
ownedUri: string,
editorLang: string,
timeoutMs: number
): Promise<LintOutcome> {
@@ -132,69 +114,72 @@ async function lintOne(
await initializeVscode('headlessLint')
keepModelAroundToAvoidDisposalOfWorkers()
const uri = Uri.parse(uriString)
let model = meditor.getModel(uri)
let contentMismatch = false
if (model) {
// Attachment decides, not who created it: an editor can adopt a model we made, and
// its buffer is what the user sees and would save. Never write to it.
if (model.isAttachedToEditor() || !ownedModels.includes(uriString)) {
contentMismatch = model.getValue() !== req.content
} else if (model.getValue() !== req.content) {
model.setValue(req.content)
// Markers from the previous content survive until revalidation finishes, and a
// matching marker count would let the wait below settle on them.
meditor.setModelMarkers(model, editorLang, [])
touchOwnedModel(uriString)
// If an editor in this workspace is showing the item, read its markers — never touch a
// model it owns. Its markers still have to settle: right after an edit the buffer holds
// the new content but the worker may not have revalidated yet.
const editorUri = computeModelUri(editorPathOf(req), req.scriptLang, editorLang)
const rt = readThrough(editorUri, req.workspace, req.content)
if (rt) {
const settled = await waitForMarkersToSettle(Uri.parse(editorUri), editorLang, timeoutMs)
const result = rt.reread()
return settled
? { status: 'complete', result, contentMismatch: rt.contentMismatch }
: {
status: 'incomplete',
result,
missing: ['the TypeScript checker'],
contentMismatch: rt.contentMismatch
}
}
// Otherwise lint an owned model.
const uri = acquireOwnedModel(ownedUri, req.content, editorLang)
try {
// Bounded: these fetch over the network, and lints of one URI are serialized behind
// each other, so one hung request would wedge the tool for that item indefinitely.
if (editorLang === 'typescript') {
await withDeadline(
Promise.all([
ensureResourceTypeNamespace(req.workspace, req.scriptLang),
ensureCustomWmillTypes(req.workspace)
]).catch((e) => console.error('headlessLint: extra libs unavailable', e)),
timeoutMs
)
}
} else {
model = meditor.createModel(req.content, editorLang, uri)
touchOwnedModel(uriString)
evictOwnedModels(uriString)
}
// Bounded: these fetch over the network, and lints of one URI are serialized behind
// each other, so one hung request would wedge the tool for that item indefinitely.
if (editorLang === 'typescript') {
await withDeadline(
Promise.all([
ensureResourceTypeNamespace(req.workspace, req.scriptLang),
ensureCustomWmillTypes(req.workspace)
]).catch((e) => console.error('headlessLint: extra libs unavailable', e)),
timeoutMs
)
}
if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') {
await withDeadline(
acquireTypes(req, ownedUri).catch((e) =>
console.error('headlessLint: type acquisition failed', e)
),
timeoutMs
)
}
if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') {
await withDeadline(
acquireTypes(req, uriString).catch((e) =>
console.error('headlessLint: type acquisition failed', e)
),
timeoutMs
)
const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs)
const result = readModelMarkers(uri)
// A worker that never came up leaves the markers empty for lack of analysis; report
// it as incomplete rather than let an empty set read as clean.
return settled
? { status: 'complete', result }
: { status: 'incomplete', result, missing: ['the TypeScript checker'] }
} finally {
releaseOwnedModel(ownedUri)
}
const settled = await waitForMarkersToSettle(uri, editorLang, timeoutMs)
const result = readModelMarkers(uri)
// A worker that never came up leaves the markers empty for lack of analysis; report it
// as incomplete rather than let an empty set read as clean.
return settled
? { status: 'complete', result, contentMismatch }
: { status: 'incomplete', result, missing: ['the TypeScript checker'], contentMismatch }
}
async function acquireTypes(req: HeadlessLintRequest, uriString: string): Promise<void> {
const key = `${req.workspace}:${uriString}`
let ata = ataByKey.get(key)
if (!ata) {
while (ataByKey.size >= MAX_OWNED_MODELS) {
while (ataByKey.size >= MAX_ATA_INSTANCES) {
const oldest = ataByKey.keys().next().value
if (oldest === undefined) break
ataByKey.delete(oldest)
}
ata = await createWindmillAta({
root: await genAtaRoot(req.workspace),
scriptPath: req.path,
scriptPath: editorPathOf(req),
modelUri: uriString,
absolutePathExtraLibs
})
@@ -0,0 +1,123 @@
import { editor as meditor, Uri } from 'monaco-editor'
import type { ScriptLintResult } from '../copilot/chat/shared'
// The single owner of every model the headless linter touches. Two rules:
// 1. When an editor is already showing the item (same URI and workspace), read ITS
// markers — never create or write.
// 2. Otherwise work in an owned model whose URI is disjoint from every editor's (see
// OWNED_ROOT in monacoUri.ts), so an owned model can never be one an editor owns.
// Nothing outside this module mutates Monaco's model registry for linting.
interface RegisteredEditor {
/** Editor's own view of its markers and current buffer, read lazily. */
read: () => { markers: ScriptLintResult; content: string }
}
// keyed by workspace + editor URI: two editors on the same path in different workspaces
// are distinct registrations, so a fork's editor never shadows the parent's.
const registeredEditors = new Map<string, RegisteredEditor>()
// owned models we created, least-recently-used first; keyed by owned URI string
const ownedModels: string[] = []
const MAX_OWNED_MODELS = 4
// owned models currently mid-lint (awaiting settle); the evictor must not dispose them
const inFlight = new Set<string>()
function editorKey(workspace: string, uri: string): string {
return `${workspace}\n${uri}`
}
/**
* An editor announces the URI it owns, its workspace, and how to read its markers/content.
* Returns a disposer for unmount. This is what makes read-through workspace-correct: a
* session linting a fork never reads a parent-workspace editor's markers.
*/
export function registerEditor(
uri: string,
workspace: string,
read: () => { markers: ScriptLintResult; content: string }
): () => void {
const k = editorKey(workspace, uri)
const entry: RegisteredEditor = { read }
registeredEditors.set(k, entry)
return () => {
if (registeredEditors.get(k) === entry) registeredEditors.delete(k)
}
}
export interface ReadThrough {
/** The editor's buffer differs from the content we were asked to lint. */
contentMismatch: boolean
/** Re-read the editor's markers (call after waiting for them to settle). */
reread: () => ScriptLintResult
}
/**
* If an editor in the same workspace owns `editorUri`, return a handle to its markers (and
* whether its buffer differs from `requestedContent`). Never creates or writes a model.
*/
export function readThrough(
editorUri: string,
workspace: string,
requestedContent: string
): ReadThrough | undefined {
const reg = registeredEditors.get(editorKey(workspace, editorUri))
if (!reg) return undefined
return {
contentMismatch: reg.read().content !== requestedContent,
reread: () => reg.read().markers
}
}
function touch(uriString: string) {
const at = ownedModels.indexOf(uriString)
if (at >= 0) ownedModels.splice(at, 1)
ownedModels.push(uriString)
}
function evict(keepUri: string) {
// Bounded by the list length so a set of all-protected models can't spin forever.
let guard = ownedModels.length
while (ownedModels.length > MAX_OWNED_MODELS && guard-- > 0) {
const candidate = ownedModels.shift()
if (!candidate) continue
// Protect the model this lint is using and any other lint's in-flight model — a
// concurrent lint of a different item is still awaiting its markers.
if (candidate === keepUri || inFlight.has(candidate)) {
ownedModels.push(candidate)
continue
}
const model = meditor.getModel(Uri.parse(candidate))
// Owned models are never editor-attached (disjoint namespace); the guard is defence
// in depth against a future change that violates that.
if (model && !model.isAttachedToEditor()) model.dispose()
}
}
/**
* Ensure an owned model exists at `ownedUri` holding `content`, marking it in-flight so a
* concurrent lint's eviction cannot dispose it. The caller must call `releaseOwnedModel`
* when done. Clears stale markers on a content change so a later settle cannot succeed on
* the previous version's markers.
*/
export function acquireOwnedModel(ownedUri: string, content: string, editorLang: string): Uri {
inFlight.add(ownedUri)
const uri = Uri.parse(ownedUri)
const existing = meditor.getModel(uri)
if (existing) {
if (existing.getValue() !== content) {
existing.setValue(content)
meditor.setModelMarkers(existing, editorLang, [])
}
touch(ownedUri)
} else {
meditor.createModel(content, editorLang, uri)
touch(ownedUri)
}
evict(ownedUri)
return uri
}
export function releaseOwnedModel(ownedUri: string) {
inFlight.delete(ownedUri)
}
+32 -4
View File
@@ -14,7 +14,8 @@ export function computeModelPath(path: string | undefined, scriptLang: string |
return path
}
export function computeModelUri(
/** The file part (path + extension, no leading slash) shared by every URI scheme below. */
function computeFilePart(
filePath: string,
scriptLang: string | undefined,
editorLang: string
@@ -25,10 +26,37 @@ export function computeModelUri(
} else {
file = `${filePath}.${scriptLang == 'tsx' ? 'tsx' : langToExt(editorLang)}`
}
if (file.startsWith('/')) {
file = file.slice(1)
}
return file.startsWith('/') ? file.slice(1) : file
}
export function computeModelUri(
filePath: string,
scriptLang: string | undefined,
editorLang: string
): string {
const file = computeFilePart(filePath, scriptLang, editorLang)
return !RANDOMIZED_PATH_LANGS.includes(scriptLang ?? '')
? `file:///${file}`
: `file:///tmp/monaco/${file}`
}
// A reserved root segment no workspace name or script path can produce, so owned-model
// URIs are disjoint from every editor URI. This is the invariant a headless lint relies on
// to never adopt, overwrite, or dispose a model an editor owns; keep owned URIs under it.
export const OWNED_ROOT = '__wmlint__'
/**
* URI for a model the headless linter owns, keyed by the draft cell it reflects
* (workspace + itemKind + storagePath) plus an optional sub-path (a flow module id or an
* app file). One linted code unit ⇔ one owned model, in a namespace no editor can enter.
*/
export function computeOwnedUri(
cell: { workspace: string; itemKind: string; storagePath: string },
subPath: string | undefined,
scriptLang: string | undefined,
editorLang: string
): string {
const inner = subPath ? `${cell.storagePath}/${subPath}` : cell.storagePath
const file = computeFilePart(inner, scriptLang, editorLang)
return `file:///${OWNED_ROOT}/${cell.workspace}/${cell.itemKind}/${file}`
}