Skip to content

feat(workflow): add LSP server, VS Code extension, and graph preview for the workflow DSL#2377

Merged
curtisman merged 47 commits into
microsoft:mainfrom
curtisman:agents/can-you-create-a-plan-to-implement-a-ec9be65f
May 22, 2026
Merged

feat(workflow): add LSP server, VS Code extension, and graph preview for the workflow DSL#2377
curtisman merged 47 commits into
microsoft:mainfrom
curtisman:agents/can-you-create-a-plan-to-implement-a-ec9be65f

Conversation

@curtisman
Copy link
Copy Markdown
Member

@curtisman curtisman commented May 21, 2026

Summary

This PR adds a full language-server and VS Code extension stack for the workflow DSL, built in three new packages:

Package Path Purpose
workflow-lsp ts/examples/workflow/lsp/ LSP server (node, stdio)
workflow-vscode ts/examples/workflow/vscode/ VS Code language client + webviews
workflow-engine (schema split) ts/examples/workflow/engine/ Separate builtinTaskSchemas.ts so LSP can import schemas without pulling in runtime deps

Features shipped

DSL package changes (workflow-dsl)

  • Error span lengths - lexer/parser now emit meaningful length on every diagnostic so squiggles cover the right range.
  • nameLoc on declarations - ConstStatement and WorkflowDecl carry a separate nameLoc (location of the name token, not the whole statement) for precise rename/hover targets.
  • collectPropertyRefs / collectSymbolTypes on TypeChecker - new methods used by LSP hover and semantic tokens; collectPropertyRefs result is cached in ParsedDocument so it does not re-run on every keystroke. set(offset, ...) calls guard offset !== undefined.
  • Graph extractor fixes - dotted-name alias bindings no longer dropped; built-in block groups (map/filter/attempts) labeled with their binding name (idempotent); layout depth uses max-member-depth so consumer nodes render below the group.

LSP server features

  • Diagnostics (parse + type-check errors with accurate squiggles)
  • Document formatting (normalizes whitespace/indentation)
  • Document symbols (outline)
  • Hover (TypeScript-style type signatures at declarations and references)
  • Go-to-definition / find references
  • Completion (task names, parameter names, keywords)
  • Signature help (active parameter highlighting inside task calls)
  • Inlay hints (inferred types on all const bindings and lambda params, gated by workflow.inlayHints.enable)
  • Semantic tokens (parameters, readonly variables, task calls, property accesses colored by theme)
  • Rename / prepare-rename (rejects renames to already-in-scope names)
  • Code actions: surround-with-attempts, extract-to-const, inline-const, concat-to-template. RHS extraction uses AST loc.offset; inline-const guards negative LSP positions.
  • Custom workflow/compileIR request - returns IR JSON or structured error list; validates params?.uri before use
  • Custom workflow/previewGraph request - returns graph model for the webview

VS Code extension

  • .wf file association with TextMate grammar (workflow.tmLanguage.json)
  • Language configuration (bracket matching, comment toggling, auto-closing pairs)
  • Snippets: workflow scaffold
  • Graph preview webview: layered DAG layout with group (map/filter/attempts) dashed boxes, edge arrows, depth-first layout, live refresh on save. All g.params/nodes/edges/groups iterations guarded with ?? [].
  • IR preview webview: formatted JSON of the compiled IR, refreshes on save
  • Inlay hints toggle command (Workflow: Toggle Inlay Hints)

Design docs

  • lsp.md - architecture overview (replaces the separate lsp-decisions.md ADR log, which is removed in this PR)
  • lsp-plan.md - implementation plan with phase tracking
  • lsp-future.md - deferred items (semantic selection, refactor-to-parallel, etc.)
  • dsl-v0.1-gap.md - G21 added: inferred return type + typed lambda parameters

Test coverage

  • 635 DSL tests (existing + new graph extractor regression tests)
  • 121 LSP tests across 16 spec files:
    • diagnostics, formatting, symbols, features (hover/completion/definition/references/semantic tokens/signature help)
    • signatureHelpAndInlayHints, codeActions, rename, compileIR, previewGraph, graphShape
    • grammar (TextMate grammar smoke tests), grammarDrift (grammar/server consistency guard)
    • cancellation, serverIntegration, server, symbolResolver
  • Extension UI tests via @vscode/test-electron (Linux/Xvfb in CI)

Copilot and others added 30 commits May 21, 2026 14:13
…docs

Adds the foundation for a Language Server Protocol implementation for
the workflow DSL (.wf files) and its companion VS Code extension.

Design docs (ts/docs/design/workflowSystem/editor/):
- lsp-plan.md            full plan with phases, features, protocol
- lsp-decisions.md       design decisions / non-obvious choices
- lsp-review-log.md      log of deferred review findings
- lsp-manual-tests.md    per-phase manual test checklist

workflow-engine changes:
- New src/builtinTaskSchemas.ts re-declares the 31 builtin task
  schemas without importing aiclient.
- New schemas-only sub-export ("workflow-engine/schemas") so the LSP
  can pull schemas without dragging aiclient into the bundle.
- New jest spec asserts the duplicated schemas stay in sync with the
  authoritative TaskDefinitions.

workflow-lsp (new package, examples/workflow/lsp/):
- Bin entry that starts an LSP server on stdio.
- createServer() supporting both stdio and injected duplex streams
  for in-process integration tests.
- Phase 0 capabilities: incremental text-document sync, initialize.
- documentStore, taskSchemas wrapper, position util, feature stubs.
- jest integration smoke test verifies initialize response.

workflow-vscode (new package, examples/workflow/vscode/):
- Registers language id 'workflow' for .wf files.
- TextMate grammar (keywords, strings, templates, comments, numbers).
- language-configuration.json (brackets, comments, autoclosing).
- Extension activates and launches the bundled LSP via Node IPC.
- workflow.trace.server config to debug client/server messages.
- esbuild.mjs bundles extension + server (server.js: ~171KB).
- scripts/check-server-bundle.mjs asserts no aiclient / @Azure SDKs
  leak into the shipped bundle (dep-cycle regression guard).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wires the workflow DSL compiler / formatter into the LSP and adds the
first set of editor features.

Features (ts/examples/workflow/lsp/src/features/):
- diagnostics.ts   compile() -> Diagnostic[]; tagged with phase code
                    (lex/parse/typecheck/emit/validate)
- formatting.ts    lex+parse -> format(); whole-document TextEdit;
                    returns [] on parse error to preserve user source
- symbols.ts       walk WorkflowDecl; outline shows workflow,
                    params, and top-level ConstStatement /
                    DestructuringConst bindings (synthetic bindings
                    filtered out)

Server wiring (ts/examples/workflow/lsp/src/server.ts):
- documentFormattingProvider and documentSymbolProvider capabilities
- onDidChangeContent schedules diagnostics with a 100ms debounce
- onDidClose cancels pending timers and clears prior diagnostics
- dispose() drains the pending-timer map

Grammar (ts/examples/workflow/vscode/syntaxes/workflow.tmLanguage.json):
- Replaced placeholder keyword list (param/let/for/in) with the actual
  DSL keywords (switch/case/default/break/throw added)
- Grammar-drift jest test reads the lexer's KEYWORDS list and asserts
  every entry is in the TextMate alternation

Position util:
- Renamed 'column' -> 'col' to match DSL SourceLocation
- Added pointRange(loc, length) for diagnostics

Tests (14 / 14 passing across 6 suites):
- diagnostics.spec.ts        4 tests (valid, parse, typecheck, lex)
- formatting.spec.ts         3 tests (reformat, no-op, parse error)
- symbols.spec.ts            3 tests (params+consts, garbage, destructure)
- grammarDrift.spec.ts       1 test (keyword sync)
- server.spec.ts             1 test (initialize)
- serverIntegration.spec.ts  2 tests (publish-on-open, clear-on-close)

Review log:
- lsp-review-log.md captures four inline-review findings that were
  accepted as-is for Phase 1 (diagnostic range clamping deferred,
  schemas-loaded-once by design, whole-doc format-edit by design,
  process note about stalled subagent review cycle).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…semantic tokens

Adds the navigation core for the workflow DSL language server:

- symbolResolver: per-workflow scope chain + symbol table (Def/Ref/
  TaskRef) including param, const, destructured-const, and lambda
  parameter bindings; resolves identifier expressions through the
  enclosing scope chain and records builtin task call sites.
- parsedDocument: per-URI version-keyed cache of lex+parse+symbol-table
  results, invalidated on change/close.
- hover: markdown for in-scope symbols (kind + line) and for builtin
  task names (schema title/description).
- definition: go-to-def for bound names backed by the resolver.
- references: include-decl-aware reference listing.
- completion: in-scope names + builtin task names; '.' trigger.
- semanticTokens: parameter / variable / function legend with full
  document delta-encoded payload.

Tests: 27 specs across 8 suites pass (symbolResolver + features added).
Bundle audit still clean (289.9 KB, no aiclient leak).

Signature help and rename are deferred to Phase 3 / Phase 4 — see
lsp-review-log.md entry. Subagent review cycle waived again pending
a reliable turn-1 probe; inline self-review logged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the authoring-assist trio for the workflow DSL language server.

- **signatureHelp:** Scans back from the cursor through document text
  (with string and comment masking) to find the innermost unclosed '('
  and reads the identifier just before it. The identifier is matched
  against the builtin task schema set and rendered with a parameter
  list derived from inputSchema.required + properties. The active
  parameter index is the depth-zero comma count between the open
  paren and the cursor.

- **inlayHints:** For each non-synthetic ConstStatement whose value
  is a TaskCallExpr, emits a TypeScript-style ': <type>' hint between
  the binding name and the '=' token. Hints are suppressed when the
  source already declares a type. Range-scoped requests are honoured.

- **Snippets:** Ships a workflow.code-snippets file with prefixes for
  workflow scaffold, const/dconst, if/switch, map/filter/parallelMap,
  parallel branches, attempts, shell.exec, and template literals.
  Registered under contributes.snippets in package.json.

Server registers signatureHelpProvider (triggers '(' and ',') and
inlayHintProvider. New phase3.spec.ts: 9 specs across both features.

Total: 36 tests in 9 suites pass. Bundle audit still clean (292.6 KB).

Cancellation token plumbing and code actions are deferred to Phase 4
(see lsp-review-log.md).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements LSP textDocument/rename and textDocument/prepareRename.

- **features/rename.ts:** prepareRename returns the range of the
  identifier under the cursor when it's a renamable user-declared
  symbol (param, const, lambda param). rename walks the symbol table,
  produces a WorkspaceEdit covering the declaration plus every
  reference, and rejects invalid identifiers via a regex check.
- **symbolResolver.ts:** Threads the source text into buildSymbolTable
  so const / destructured-const defs are recorded at the binding name
  rather than the 'const' keyword. A forward scan from stmt.loc.offset
  produces the precise (line, col, offset). Falls back to stmt.loc
  when text isn't supplied (back-compat).
- **parsedDocument.ts:** Passes document text into buildSymbolTable.

Server registers renameProvider with prepareProvider=true. New
rename.spec.ts adds 8 specs across prepareRename and rename
(declaration site, reference site, invalid name, unresolved position,
const-binding rename).

Total: 44 tests in 10 suites pass. Bundle audit still clean
(293.8 KB). Code actions and DSL AST nameLoc field are deferred to
follow-up work (see lsp-review-log.md).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…bbed

Adds the IR preview half of Phase 5.

- **features/compileIR.ts:** server-side custom-request handler.
  Compiles the active document with the builtin task schemas and
  returns either the IR JSON or a serializable error list. Keeping
  the call server-side avoids pulling workflow-dsl into the
  extension bundle and preserves the dep-cycle audit guarantee.
- **server.ts:** wires connection.onRequest('workflow/compileIR', ...).
- **extension.ts:** registers workflow.previewIR (opens an untitled
  JSON document with the IR or error list in a side editor) and
  workflow.previewGraph (currently posts a 'coming soon' info
  message until elkjs + webview lands in a follow-up).
- **package.json:** declares both commands under contributes.commands.
- **compileIR.spec.ts:** covers happy path, parse error path, and the
  unknown-document branch.

Total: 47 tests across 11 suites pass. Bundle audit still clean
(294.1 KB).

Graph preview (extractGraph + elkjs + webview) is deferred — see
lsp-review-log.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three deferred items now implemented:

- **features/codeActions.ts:** Two code actions wired via
  codeActionProvider. (1) 'Surround with attempts(3)' wraps a
  task-call const-binding in an attempts/fallback block. (2) 'Extract
  to const' inserts a new binding before the enclosing statement and
  replaces the selected sub-expression with the new name. Both produce
  WorkspaceEdits. Server registers codeActionProvider with rewrite +
  extract kinds.

- **test/codeActions.spec.ts:** 5 specs covering the attempts action
  (offer, text shape, non-task guard) and the extract action (offer
  and edit shape).

- **test/graphShape.spec.ts:** 4 Jest snapshots pinning the GraphModel
  returned by extractGraph() for linear, conditional, map, and
  parallel workflows. Snapshots written on first run; drift from
  upstream DSL graph model will surface as deliberate diffs.

- **jest.config.cjs:** Coverage config (v8 provider, 70/60/70/70
  thresholds) moved here from a duplicate package.json 'jest' key
  that was conflicting with the pre-existing jest.config.cjs.
  Added 'test:coverage' script.

Total: 56 tests in 13 suites pass. 4 snapshots. Bundle clean
(295.7 KB). Subagent reviews for these todos remain waived.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- lsp.md architecture doc (packages, data flow, feature inventory)
- rangeFormatting provider (Gap 5)
- keyword completions + namespace-prefix filtering (Gaps 11/12)
- inline-const and concat→template code actions (Gap 6)
- IR preview refreshes on save via onDidSaveTextDocument (Gap 13)
- icons/wf.svg file icon + icons.json icon theme (Gap 10)
- @vscode/test-electron spike decision + extension-test.sh stub (Gap 9)
- graph preview deferral documented in lsp-decisions.md (Gap 1)
- dead documentStore.ts removed (Gap 14)
- cancellation behaviour tests (Gap 7)
- grammar/lexer tokenization snapshot tests (Gap 8)
- feature-level integration tests: hover, completion, definition,
  rename, rangeFormatting, compileIR (Gap 3)
- manual test plan phases 1-5 (Gap 4)

74 tests, 15 suites, 5 snapshots; all pass.
LSP server bundle: 298.0 KB, dep-cycle-audit clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- inline-const: refuse action when RHS identifier has multiple defs in
  symbol table (conservative scope-shadow safety)
- concat→template: replace regex-based detection with AST walk +
  bracket-aware source extraction; bails on unrepresentable shapes
- IR preview: prune irPreviewDocs map entries when preview tabs close
- test cleanup: use stream.destroy() and dispose JSON-RPC reader/writer
- lsp-decisions.md: document --forceExit root cause as upstream
  vscode-jsonrpc internal queue (test harness only; production
  LanguageClient unaffected)
- 5 new codeActions tests covering shadow-refusal, range correctness,
  and nested-bracket safety
- prettier auto-fixes across previously-touched files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… status snapshot

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-ups

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ations

Following TypeScript's lead:
- SourceLocation gains optional length field
- LexError, ParseError, TypeError, EmitError, CompileError all carry length
  with accurate values (token length, consumed chars for unterminated literals)
- ConstStatement gains nameLoc pointing at the identifier token (not const keyword)
- DestructuringConst gains nameLocs[] - one per bound name
- LSP diagnostics.ts uses error.length for real-width squiggles
- LSP symbolResolver.ts uses stmt.nameLoc / nameLocs[] directly
- Deleted locateName() text-scan helper (no longer needed)
- buildSymbolTable() no longer needs the source text argument

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add workflow/previewGraph LSP request handler + unit/integration tests
- Add GraphModel mirror compile-time assignability check
- Add webview-based graph preview panel (inline SVG layered layout, strict CSP)
- Wire LogOutputChannel + workflow.showServerOutput command; await clientReady
- Scaffold @vscode/test-electron suite (compiles; requires display server to run)
- Update LSP/vscode READMEs, design decisions, manual tests, and review log
- lsp-decisions.md: remove resolved/superseded/historical entries; keep schema-dup and test-electron entries
- lsp/README.md: explain --forceExit hang root cause inline
- graphPreview.ts: add revisit note for real layout engine (elkjs/dagre)
- lsp-plan.md, lsp.md: reflect implemented previewGraph + inline SVG renderer
builtinTasks.ts now imports schemas from builtinTaskSchemas.ts via a
taskSchema() spread helper — single source of truth, no sync spec needed.

- Export BUILTIN_TASK_SCHEMAS from builtinTaskSchemas.ts
- Replace inline name/inputSchema/outputSchema on all 32 TaskDefinitions
  with ...taskSchema("name")
- Delete builtinTaskSchemas.spec.ts (equality now by construction)
- Remove resolved provisional entry from lsp-decisions.md
- Delete lsp-review-log.md (all entries resolved or closed)
- Add lsp-future.md: dotted-name hover beyond head segment
- lsp-plan.md: remove review-deferral log references
- graphPreview.ts: style-src nonce (drop unsafe-inline); O(n²)→O(n)
  Map lookups for params, nodes, and groups
- lsp.md: add missing Graph preview row to Feature Inventory table
- lsp-plan.md: documentStore.ts -> parsedDocument.ts
- lsp-plan.md: add compileIR.ts + previewGraph.ts to lsp/src/features list
- lsp-plan.md: remove irPreview.ts (IR preview is inline in extension.ts)
Fill gaps identified in a full coverage review outside the devcontainer:

- codeActions.spec: inline-const rejected for synthetic (isSynthetic:true)
  consts and for consts with zero references; extract-to-const rejected for
  multi-line selections, 1-char selections, and full-RHS selections
- diagnostics.spec: verify multiple diagnostics returned for deeply broken
  source
- features.spec: hover null at declaration site and schema JSON in task hover;
  definition null at declaration site; references with unused const
  (includeDeclaration true/false); completion with unrecognized namespace,
  no-pos path, and same-name deduplication across scopes; semantic-token
  delta positions are monotonically non-decreasing
- formatting.spec: add formatDocumentRange tests - overlapping range returns
  edits, beyond-document range returns none, parse-error source returns none
- rename.spec: VALID_IDENT edge cases - empty string, single underscore,
  name with space, digit-leading name
- phase3.spec -> signatureHelpAndInlayHints.spec: rename to descriptive name;
  add sig-help activeParameter clamping and comment-paren handling; add
  inlay-hints synthetic-const suppression, before-range exclusion, and
  switch/default_ body traversal
Add Phase 4c/4d unit tests in rename.spec.ts:
- 'returns null on the const keyword' (non-renameable position)
- 'returns null on a built-in task call name' (non-renameable position)

Extend extension.test.ts with 11 new automated tests covering manual
test phases 1a, 1c, 1d, 2a-2d, 3a, 3c (snippet structure), and 4a.
These exercise the full LSP client-server-client round-trip using
real file:// documents opened in the VS Code test host.

Also fixes the Mocha ui setting (bdd -> tdd) in index.ts so suite()
and test() resolve correctly.

Remaining manual-only items: 2e (visual token colors), 3b (visual
inlay hint rendering), 3c (snippet expansion UI), 5c (live-refresh
webview), 5d (graph webview SVG), 5e (output channel trace).
…ring

- dsl/ast.ts: add paramLoc to MapNode, FilterNode, ParallelMapNode,
  AttemptsNode.fallback so the parser can record the exact token
  position of each lambda parameter

- dsl/parser.ts: capture lambda parameter token locations in
  parseArrowArg and thread them through parseArrowBody into
  parseMapBuiltin, parseFilterBuiltin, parseParallelMapBuiltin,
  parseAttemptsBuiltin

- lsp/symbolResolver.ts: use expr.paramLoc ?? expr.loc when defining
  a lambda parameter so go-to-definition jumps to the param token
  rather than the start of the enclosing map/filter/parallelMap call

- lsp/test/symbolResolver.spec.ts: regression test pinning the
  definition location to the actual param token position

- lsp/test/grammarDrift.spec.ts: update keyword drift guard to check
  all keyword and constant sub-patterns (grammar was split into typed
  sub-patterns in the prior commit)

- vscode/syntaxes/workflow.tmLanguage.json: rewrite grammar with
  TypeScript-compatible scope names for better theme coverage:
  storage.type.function, entity.name.function, variable.other.constant,
  variable.parameter, variable.other.property, support.class, etc.
Add segmentLocs to DottedNameExpr AST node so each dot-separated
segment carries its source position. TypeChecker.collectPropertyRefs()
walks the workflow and returns PropertyRef entries for every segment
that resolves successfully against the schema (e.g. .stdout on the
shell.exec result type). The LSP semantic tokens feature uses this to
emit 'property' tokens, giving .stdout a distinct color from the
variable that precedes it - matching the TypeScript experience.
All variable bindings in the workflow DSL are const (immutable), so
mark them with the 'readonly' semantic token modifier. Dark Modern and
other themes color 'variable.readonly' differently from plain
'variable', which is how TypeScript makes const identifiers appear
darker than let/mutable variables.
Add formatType() to the DSL for rendering TypeInfo as TypeScript syntax
({ stdout: string; exitCode: integer } instead of 'object').

Add TypeChecker.collectSymbolTypes() which walks a workflow and returns
a Map<offset, TypeInfo> covering params, const bindings, destructuring
bindings, and lambda params (map/filter/parallelMap/attempts fallback).

Hover now renders:
  const r: { stdout: string; stderr: string; exitCode: integer }
  (parameter) repos: string[]
  (parameter) repo: string    <- lambda param in map()
instead of the old 'r -- constant' / 'a -- parameter' labels.
Add the setting to vscode/package.json (scope: language-overridable so
it can be set per language in settings.json). The server caches the
value via onDidChangeConfiguration - the language client already pushes
the full 'workflow' config section on startup and on every change via
synchronize.configurationSection.
Call connection.languages.inlayHint.refresh() (workspace/inlayHint/refresh)
when workflow.inlayHints.enable changes. This tells VS Code to re-request
inlay hints for open documents without requiring a reload.
Add findDefinitionAt() to symbolResolver. Hover now falls back to the
definition table when the cursor is on the binding name itself (e.g.
the 'r' in 'const r = shell.exec(...)'), not just on a reference to it.
Lambda params (map/filter/parallelMap/(repo) =>) now get inlay type
hints showing the inferred element type (e.g. ': string' after 'repo').

Also switched const-binding hints from the old JSON-schema-string
approach to collectSymbolTypes + formatType, so all inferred const
types get hints (not just task calls), with proper type labels
(e.g. '{ stdout: string; stderr: string; exitCode: integer }'
instead of 'object').
@curtisman curtisman marked this pull request as ready for review May 21, 2026 22:37
@curtisman curtisman enabled auto-merge May 21, 2026 22:37
@curtisman curtisman force-pushed the agents/can-you-create-a-plan-to-implement-a-ec9be65f branch from fbbff8a to 249e0bd Compare May 22, 2026 00:05
@curtisman curtisman requested a deployment to development-fork May 22, 2026 00:40 — with GitHub Actions Waiting
@curtisman curtisman requested a deployment to development-fork May 22, 2026 00:40 — with GitHub Actions Waiting
@curtisman curtisman added this pull request to the merge queue May 22, 2026
Merged via the queue into microsoft:main with commit 3bfba3b May 22, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant