A VS Code extension: a thin client over `flow lsp`, and where it must not go
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
flow lsp already gives an editor everything the language has to say. What VS Code does not have is a way to start it, so every VS Code user's first experience of Flowfile authoring is either a generic LSP client extension they have to configure by hand, or the twenty-line client docs/EDITORS.md tells them to write themselves. Both are worse than the Neovim story, which is nine lines of init.lua and is now smoke-tested in CI (#584).
This is the design for the extension that closes that. The whole of it rests on one claim: the extension is a thin client, and everything it adds beyond starting the server has to justify not being in the engine. The interesting part of the design is not the feature list, it is the boundary — and the supply chain, because a VS Code extension is how npm gets into this repository.
1. The load-bearing core is fifteen lines
Everything else in this issue is optional. This is not:
import { workspace, ExtensionContext } from 'vscode';
import { LanguageClient, TransportKind } from 'vscode-languageclient/node';
let client: LanguageClient;
export async function activate(_: ExtensionContext) {
const cfg = workspace.getConfiguration('flowstate');
client = new LanguageClient(
'flowstate',
'Flowstate',
{
command: cfg.get<string>('path', 'flow'),
// `--plugin-dir` goes here, from a machine-scoped setting — see below.
args: ['lsp', ...cfg.get<string[]>('lsp.args', [])],
transport: TransportKind.stdio,
},
{ documentSelector: [{ scheme: 'file', language: 'flowfile' }] },
);
await client.start();
}
export function deactivate() {
return client?.stop();
}
Diagnostics, hover, completion, go-to-definition, document symbols, formatting and the source.fixAll migration action all arrive for free, because the server already implements them and vscode-languageclient already wires each one to the corresponding VS Code surface. The extension's job there is to not get in the way.
The contributions that make a Flowfile a Flowfile:
{
"contributes": {
"languages": [{
"id": "flowfile",
"aliases": ["Flowfile"],
"filenames": ["Flowfile", "Flowfile.yaml"],
"filenamePatterns": ["**/workflow.yaml", "**/workflow.yml", "**/workflows/*.yaml"],
"configuration": "./language-configuration.json"
}],
"configuration": {
"title": "Flowstate",
"properties": {
"flowstate.path": {
"type": "string", "default": "flow",
"description": "Path to the flow binary.",
"scope": "machine-overridable"
},
"flowstate.lsp.args": {
"type": "array", "items": { "type": "string" }, "default": [],
"description": "Extra arguments for `flow lsp`, e.g. --plugin-dir.",
"scope": "machine-overridable"
}
}
}
}
}
scope: machine-overridable on both is deliberate and is the same argument docs/EDITORS.md already makes about --plugin-dir: a workspace decides neither which binary runs nor what flags it gets, because a repository you cloned to read must not be able to choose what your editor executes. VS Code's machine-overridable scope is the mechanism that enforces it — the setting is ignored when it appears in .vscode/settings.json.
2. What is worth having beyond the LSP
Four things, in the order they earn their keep. Each is argued rather than listed, because the list is where this design goes wrong.
A workspace view of workflows
The one addition with no engine equivalent. LSP is a protocol about a buffer at a position; there is no LSP request that means "what workflows are in this repository". Today a person finds them with a file search that also matches every Kubernetes manifest.
The view is a tree of workflows found by the same file matching the language contribution uses, each expanding to its steps, with the workflow's name: and description: as labels. Cheap, and it is the surface every other command hangs off.
The step graph — and where it must come from
A workflow's shape is a graph and reading it as YAML is work. This is the feature people will ask for first, and it is the one with a trap in it.
The trap is rendering the graph from YAML parsed in TypeScript. That is a second front end for the language: it would have to know what for_each binds, that a loop:'s body outputs do not escape, that a parallel block's branches merge on join, that undo: steps are ordered by rules placement-refusal enforces — and every one of those is a place the picture and the run can disagree. It is the same defect class as CLAUDE.md's Both execution drivers must agree: one meaning, written down twice, and nothing importing both.
So the graph comes from flow compile, which already exists and already answers exactly this:
$ flow compile examples/fan-out-and-parallel/workflow.yaml | jq '.steps[0]'
It writes the same Workflow message flow run submits, as protojson, and refuses a file with problems rather than handing out a specification beside a list of its faults. So the extension shells out, reads a typed document, and lays it out. It knows about nodes and edges, not about the DSL. When the grammar grows a construct, the picture grows with it because the compiler grew — no TypeScript changes, and no window in which the editor draws last year's semantics.
flowchart TB
subgraph editor["VS Code extension — knows nothing about the DSL"]
V["workflow view"]
G["graph webview<br/>nodes and edges only"]
C["commands"]
P["run progress"]
end
subgraph engine["flow — the only thing that understands a Flowfile"]
LSP["flow lsp<br/>diagnostics, hover, completion,<br/>definition, symbols, format, fix"]
CMP["flow compile<br/>protojson Workflow"]
RUN["flow run / run local / test / validate"]
RPC["Connect RPC<br/>Get, Watch, List, Cancel"]
end
V --> CMP
G --> CMP
C --> RUN
P --> RPC
editor -.->|"every question about<br/>what a file means"| LSP
The repo renders mermaid in docs already, which makes mermaid the tempting output format. It is the wrong one for a webview: mermaid is a layout target, and what this needs is an interactive surface where clicking a node reveals the step's compiled expressions and jumps to its line. Emitting mermaid for the clipboard — "copy this workflow as a diagram for a PR description" — is a genuinely nice second command, derived from the same compiled document.
Run, test, validate from the palette
flow run local, flow test, flow validate, flow fmt, flow fix are all one-line invocations. The extension's contribution is a task provider and a terminal, not a reimplementation. Worth doing because the authoring loop is edit, rehearse locally, look at the diff and the rehearsal currently means leaving the editor. flow run local is the important one: local and durable execution are held to agreeing, which is what makes a local rehearsal worth anything.
The rule for all of them: the extension composes a command line and shows the output. It does not parse the output to re-decide whether something passed. Exit status is the answer; --output json is there when structure is genuinely needed.
A run's live progress
Once a workflow is submitted the interesting surface is this run, right now, and the extension is well placed for it: the file is open, the step graph is drawn, and lighting up nodes as they complete is the view no CLI can offer. flow watch and flow get already follow a run; the RPCs behind them are the source.
This is also where the observability signals belong — a link out to the trace for a run, and the metrics an operator already has, rather than a second dashboard built in a webview. examples/observability/ is the deployment shape to link into.
Deployment management — pointing the editor at a server, choosing a namespace, seeing schedules — is the last slice and the one most likely to be scope creep. The honest version for an individual is a status-bar item naming which server the extension is talking to, since the most expensive editor mistake is running against the wrong one. For a team, the credential story is #549's: the extension should acquire a token by the deployment's own interactive grant when that exists, and until then should read exactly what flow reads and nothing else. An extension that invents its own credential storage is an extension that leaks credentials in a way the CLI has already been careful about.
3. Where this must not go
The failure mode is not "does too much", it is "knows something the engine also knows". Three specific refusals:
-
No YAML parsing in TypeScript, for any purpose. Not for the graph, not for the outline, not for a quick "which steps are in this file". The outline is
textDocument/documentSymbol. The graph isflow compile. A regex over the buffer to find step ids is the beginning of a second front end, and it will be right until the grammar changes. -
No diagnostics of the extension's own. Not even a helpful one.
docs/EDITORS.mdand CLAUDE.md both make the point that a false diagnostic is worse than a missing one, and an extension that squiggles based on its own reading of a file is exactly the surface that produces false ones. If a check is worth having, it belongs inflowfile/validate.go, where every one of the three front doors gets it. -
No policy evaluation. Not egress rules, not secret access, not a preview of whether a run would be allowed. Those are a deployment's answers, and CLAUDE.md's rule — report what is a property of the file, stay silent about what a deployment decides — applies with more force in an editor than anywhere else, because the machine typing is not the machine running.
There is a fourth boundary worth naming because it will come up: this is not the agent surface. flow mcp exists, it is one tool per RPC with schemas derived from the protobuf, and an agent has no cursor. An extension that grows an "ask AI about this workflow" panel is building a worse MCP client inside an editor.
4. Supply chain
A VS Code extension is the first npm dependency graph this repository would own, and npm is where the ecosystem's worst compromises have happened. Filippo Valsorda's compromise survey is the right frame: most real-world compromises are not exotic, they are a maintainer account taken over or a build machine reached, and the defenses that work are the boring structural ones — fewer dependencies, pinned versions, a delay before adopting anything new, and a build that an attacker reaching one package still cannot use to reach anything valuable.
Treated as a requirement, not a follow-up:
A committed lockfile, and exact versions. package-lock.json in the tree, save-exact=true in .npmrc, and CI installs with npm ci — which fails rather than resolving when the lockfile and manifest disagree. Caret ranges are how a compromised patch release arrives without a commit.
A cooldown before adopting a new release. The window between a malicious version being published and being caught is measured in hours to days, and nothing here needs a dependency the day it ships. Dependabot supports cooldown: in dependabot.yml; a minimum of seven days on this ecosystem costs nothing real.
A minimal transitive surface, defended actively. The runtime dependency list should be exactly one entry — vscode-languageclient, which is Microsoft's and which pulls vscode-jsonrpc and vscode-languageserver-protocol, all three from the same publisher. Everything else is a devDependency, and every proposed addition should be argued. esbuild for bundling is the likely second, and bundling is itself a supply-chain measure: what ships in the .vsix is one reviewed output file rather than a node_modules tree.
Provenance on any future publish, and a publish that is not a person's laptop. If this is ever published to the Marketplace or Open VSX, it publishes from a tagged CI run with npm provenance attestation, so the artifact is traceable to a commit and a workflow rather than to whoever ran vsce publish. The token for it lives in an environment with required reviewers, not in a repository secret every workflow can read. Nothing about this is needed for the first version — which should ship as a .vsix people install by hand — and all of it should be decided before the first version is published rather than after.
CI isolation, which #584 already established the shape of. .github/workflows/editors.yml is a separate workflow with permissions: contents: read and cache: false on setup-go, precisely so an editor's dependency graph can never write into the build cache the jobs gating Go code read from. The extension's CI is a job in that workflow or one beside it, and it inherits every one of those properties: read-only token, no Go cache, no path by which a compromised npm package can influence a Go build or a release artifact. Concretely — the extension's job must never run go build, and the Go jobs must never run npm. A postinstall script is arbitrary code execution on a runner, and the only defense that survives it is that the runner it executes on can reach nothing worth having.
Questions
- First slice? Recommended: the LSP client and the language contribution alone, published as a
.vsixand nothing else, so the supply chain starts at one runtime dependency and the boundary is easy to hold. The workflow view second, the graph third. - Where does the extension live? Recommended: this repository, under
editors/vscode/, because the version skew between an extension and theflowbinary it drives is the thing most likely to produce a bug report — and a separate repository makes that skew invisible. The cost is npm in this tree, which §4 is the answer to. - Does the graph webview ship a layout library, or lay out by hand? A layout library is the largest single supply-chain decision in the whole design. Recommended: start with a hand-rolled layered layout for the shapes the DSL actually produces (sequence,
for_each,parallel,loop), and treat a dependency here as something to argue for with a diagram it cannot draw. - Does the extension bundle
flow, or require it onPATH? Recommended: require it, and detect its absence with a clear message. Bundling means shipping a Go binary through npm, which is a per-platform artifact and a second distribution channel to keep honest.
Related: #584 (the Neovim smoke test, and the CI isolation this reuses), #549 (the interactive grant a team-scale deployment story depends on).
Generated by Claude Code
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading docs/EDITORS.md and the boundaries in flowfile/validate.go, then run flow compile examples/fan-out-and-parallel/workflow.yaml | jq '.steps[0]' to understand the existing compiled document. Review the flow lsp, flow run local, flow test, flow validate, flow fmt, flow fix, flow watch, and flow get entry points. Done means a scoped extension design and implementation that delegates language meaning to the engine and keeps dependency and settings rules explicit.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, typescript, vscode
- Domain
- developer-experience, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100