Add a checkpointed LangGraph path for the analyzer, behind ANALYZE_GRAPH
Analysis was the last pipeline that could not resume. sync and embed have
been resumable since they were written, but a two-hundred-requirement analysis
is two hundred model calls, and losing it to a restart meant starting over.
analyzeBrd now dispatches to services/analyze/graph.js when ANALYZE_GRAPH
is set: extract -> classify (one Send task per requirement) -> reduce, the
shape it already had. Every verdict is written to a checkpoint as it lands, so
a run that dies at requirement 180 of 200 resumes with twenty left. The thread
id is content-derived and project-scoped, since the project scope changes which
records are retrievable and therefore changes every verdict.
Off by default, and the import is dynamic and cached, so with the flag unset
LangGraph is never loaded and this service's startup is untouched. classifyAll
is unchanged and stays the covered path; the point of the flag is that the two
can be compared.
No judgement moved into the graph. classifyRequirement is called unchanged, so
the evidence gate stays where it is and stays covered by classifier.test.js.
This module owns fan-out, ordering and persistence only.
Checkpoints go to SQLite via builtin node:sqlite, not
@langchain/langgraph-checkpoint-sqlite: that depends on native better-sqlite3,
which needs a compiler or a matching prebuilt at image build time, and
deploy/DEPLOY.md already calls npm ci the most likely step to fail here. Same
reasoning as the hand-rolled readers in services/parsers/. The trade is a
version floor — node:sqlite is unflagged from Node 23.4 and needs
--experimental-sqlite on the 22.x line the Dockerfile pins — so where the module
is missing this degrades to LangGraph's in-memory saver and reports
durable: false. That still resumes within one process, which is exactly
today's behaviour, so the fallback is never a failure to start.
Two LangGraph 1.4.13 behaviours worked around:
- A node may not share a name with a state channel, so the reduce node is
reduceandmatrixis the channel its output lands in. -
maxConcurrency: 1silently truncates a run. The task loop in pregel/runner.js continues only while a task is still executing, so with no overlap it exits after the first task andinvokeresolves with partial state and no error — one classification out of two hundred, reported as success. The bound is applied by a local semaphore inside the classify node instead, where it cannot cut the run short. Raising the floor to 2 would have quietly violated a limit that exists to keep from queueing every call at Ollama.
23 tests in tests/analyzeGraph.test.js: graph-vs-classifyAll parity on rows, coverage, sections, attention and results; a bound of one still classifying a whole document; crash-and-resume; durability across two separate connections to a real SQLite file; and state hygiene, asserting the 25 MB upload buffer, the injected functions and the progress callback stay in the closure and never reach the checkpoint.