diff --git a/docs/thesis/assets/comparison/README.md b/docs/thesis/assets/comparison/README.md new file mode 100644 index 00000000..7b081254 --- /dev/null +++ b/docs/thesis/assets/comparison/README.md @@ -0,0 +1,20 @@ +# Comparison screenshots + +Author-provided captures used in the thesis's related-systems discussion. +These are exact pixel crops; no interface text or results were redrawn. +The originals remain in the author's local `random shit/` folder and are +not needed to build the thesis. + +| Asset | Original | Crop (x, y, width, height), pixels | +| --- | --- | --- | +| `n8n-merge-append.png` | `a9fb75.png` | 475, 100, 1125, 630 | +| `zapier-path-condition.png` | `filter.png` | 974, 111, 657, 762 | + +Coordinates use the top-left corner of the 1644 × 985 originals. The n8n +crop retains configuration and output. The Zapier crop retains the condition +and test result while excluding the account header and unrelated graph nodes. + +The screenshots illustrate configuration and sample results. Execution +ordering and synchronization claims use the documentation cited in the +manuscript. The Edit Fields capture (`fca1d3.png`) was omitted because its +field-assignment example adds a separate topic to this comparison. diff --git a/docs/thesis/assets/comparison/n8n-merge-append.png b/docs/thesis/assets/comparison/n8n-merge-append.png new file mode 100644 index 00000000..ff1f9578 Binary files /dev/null and b/docs/thesis/assets/comparison/n8n-merge-append.png differ diff --git a/docs/thesis/assets/comparison/zapier-path-condition.png b/docs/thesis/assets/comparison/zapier-path-condition.png new file mode 100644 index 00000000..e8b3bd27 Binary files /dev/null and b/docs/thesis/assets/comparison/zapier-path-condition.png differ diff --git a/docs/thesis/header-includes.tex b/docs/thesis/header-includes.tex index d9610cdd..9ce676f0 100644 --- a/docs/thesis/header-includes.tex +++ b/docs/thesis/header-includes.tex @@ -6,13 +6,16 @@ \usepackage[dvipsnames]{xcolor} \usepackage{fancyhdr} \usepackage{float} +% Let screenshots share a page with their explanation before using float pages. +\floatplacement{figure}{htbp} +\renewcommand{\topfraction}{0.85} +\renewcommand{\textfraction}{0.1} +\renewcommand{\floatpagefraction}{0.75} \pagestyle{fancy} -\usepackage{seqsplit} - -% Pandoc emits inline code as \texttt{...}. This blunt wrapper keeps long -% paths and commands from overflowing PDF table cells. -\let\origtexttt\texttt -\renewcommand{\texttt}[1]{{\origtexttt{\seqsplit{#1}}}} +% Keep inline identifiers intact. Move unusually long paths to a code block +% or shorten their prose label instead of splitting callable names mid-word. +% Libertinus Mono permits ordinary word hyphenation, so disable that too. +\DeclareTextFontCommand{\texttt}{\ttfamily\hyphenchar\font=-1\relax} \usepackage{fvextra} \fvset{breaklines=true, breaknonspaceingroup=true, breakanywhere=true} diff --git a/docs/thesis/system-design-implementation.md b/docs/thesis/system-design-implementation.md index 68f44b87..60867c8b 100644 --- a/docs/thesis/system-design-implementation.md +++ b/docs/thesis/system-design-implementation.md @@ -67,10 +67,9 @@ diagram: Preparing reports, transforming documents, and collecting workspace information often involve procedures that must be repeated with new inputs. An AI assistant can help perform such work. Repeating it requires preserving the operations -and their data connections as an executable procedure. This thesis presents -`lda.chat`, a -programmable workflow platform for defining, checking, running, and inspecting -reusable workspace procedures. +and their data connections as an executable procedure. This thesis develops +the programmable workflow subsystem of `lda.chat` for defining, checking, +running, and inspecting reusable workspace procedures. A workflow describes the operations to perform, the data they exchange, and the decisions that select the next step. An **artifact** is an immutable saved @@ -88,10 +87,10 @@ choice to separate a node's data output from its routing outcome. The implementation includes a workflow runtime, Python authoring objects, a workflow service, and adapters for trusted Python functions and external tools. -Evidence includes -a deterministic case study and focused tests of validation, -execution, and persistence. Authoring usability remains to be evaluated as -the interaction design develops. +The evaluation focuses on workflow construction and execution through the +Python client. It uses a deterministic case study and identifies focused +tests of validation, execution, and persistence. Agent-driven authoring and +authoring usability remain to be evaluated as the interaction design develops. # Introduction @@ -327,6 +326,13 @@ or produce combinations. The node's configuration and worked examples make these operations distinguishable. Append waits for connected inputs and emits their items in input order [@n8n-merge-2026]. +[@fig:n8n-merge-append] shows the Append setting and the resulting two +output records. The fields remain in separate records; selecting Append +does not combine them into one record. + + +![n8n Merge configured to append two inputs, with its output preview. Author's screenshot.](assets/comparison/n8n-merge-append.png){#fig:n8n-merge-append width=100%} + Selecting a merge mode changes which records appear in the output. For the report task, joining actions by owner is different from appending two action lists. @@ -356,6 +362,14 @@ distinguish complete records from records needing attention. Multiple paths can qualify, so exclusivity must follow from the rules rather than the branching appearance alone [@zapier-paths-2026]. +[@fig:zapier-path-condition] shows a rule testing whether the selected +`Hello` field contains `world`. The test record matches, and the interface +reports that this path would have continued. This preview lets the author +check a rule against a concrete value before running the workflow. + + +![A Zapier Paths condition and its matching test record. Author's screenshot.](assets/comparison/zapier-path-condition.png){#fig:zapier-path-condition width=75%} + The same documentation describes sequential execution of qualifying paths. The rules can permit several branches to execute in one run. An exclusive choice requires conditions that cannot qualify together. @@ -367,6 +381,10 @@ The interface therefore needs to communicate both the condition being tested and the consequence of a match. In lda.chat, an ordinary outcome selects one successor, whereas several Zapier Paths may qualify. +```{=latex} +\begin{minipage}{\linewidth} +``` + ## LangGraph: describing decisions in Python LangGraph's Graph API lets an author define state, add node functions and @@ -374,6 +392,47 @@ routing, compile the graph, and invoke it. For the report task, a developer can represent extracted fields in state and write a routing function that selects what happens next [@langgraph-graph-api-2026]. +In this small example, `clean` returns an update to the `text` field. +The separate `choose` function reads that updated state and selects either +the rendering step or the end of the graph: + + +```python +from typing import TypedDict +from langgraph.graph import START, END, StateGraph + +class ReportState(TypedDict): + text: str + report: str + +def clean(state: ReportState): + return {"text": state["text"].strip()} + +def choose(state: ReportState): + return "render" if state["text"] else END + +def render(state: ReportState): + return {"report": "# Report\n\n" + state["text"]} + +builder = StateGraph(ReportState) +builder.add_node("clean", clean) +builder.add_node("render", render) +builder.add_edge(START, "clean") +builder.add_conditional_edges("clean", choose, ["render", END]) +builder.add_edge("render", END) +graph = builder.compile() +``` + +```{=latex} +\end{minipage} +``` + +Calling `graph.invoke({"text": " Notes ", "report": ""})` cleans the text, +then produces a report. Whitespace-only input ends after `clean`. The state +update and the routing decision are expressed in separate functions here; +LangGraph also offers `Command` to return both from a node +[@langgraph-graph-api-2026]. + This authoring style exposes more behavior as code. Nodes produce state updates, reducers determine how updates combine, and conditional routes select subsequent execution. The documented model supports graph loops and super-step @@ -417,8 +476,8 @@ mapping selects a configured provider that must satisfy the saved source requirements. An app connection or credential is therefore only a partial analogy: the source also supplies operations and their contracts. -The design contribution is the composition of these established lifecycle -responsibilities with the typed graph and client interface. Each system must +The system combines these lifecycle responsibilities with its typed graph +and Python client. Each system must distinguish an edit to future work from the recorded definition and data of a past execution. @@ -485,7 +544,7 @@ flowchart LR Read["Read
notes"] -->|ok| Extract["Extract
report"] Extract -->|ok| Check{Complete?} Check -->|ready| Render["Render
report"] - Check -->|needs_information| Ask["Request
information"] + Check -->|needs_information| Ask["Request information
(interrupt)"] Ask -->|submitted| Render Render -->|ok| Finish([End]) ``` @@ -611,7 +670,7 @@ classDiagram Deployment "1" <-- "0..*" Run : started from ``` -## Available operations and environment binding +## Available operations and deployment binding A **capability** describes functionality available from a source. The report example uses operation capabilities, such as extraction and rendering. A @@ -622,7 +681,7 @@ A **source** groups capabilities under a configured identity. The extraction operation might come from trusted Python code, while another operation or document is supplied by an external service. -An **environment binding**, also called a **deployment binding**, connects a +A **deployment binding** connects a logical source requirement in the workflow to a concrete source in the environment. Validation checks whether that source exists and matches the saved requirements. **Source drift** means those @@ -767,6 +826,15 @@ Refreshing it requests a new snapshot. This makes network activity explicit, although applications must decide when to refresh and how to present progress. +A local/static server can also start deployment runs on a schedule. The +optional scheduler supports one-time and recurring starts and records each +scheduled occurrence for inspection. The Python client's `App.create_schedule()` +registers a schedule for an existing deployment; `App.schedule()` retrieves +its configuration, and `App.schedule_occurrences()` retrieves its history. +The server must have scheduling enabled to start runs without a connected +client. The Limitations chapter discusses scheduling's storage and +concurrency constraints. + The draft API and CLI also support persisted editing workspaces, which retain unfinished definitions between requests. Python authoring can save directly from its editable object. Both routes produce a saved definition for deployment @@ -775,7 +843,8 @@ and execution. ## Executing the graph The execution core is the runtime that runs the saved graph. It holds the -workflow input, working state, and current execution positions. To execute +workflow input, working state, and the active node for each in-flight item +or call. To execute an ordinary operation node, it resolves the node's input bindings, calls the selected operation, checks its result, applies output bindings, and follows the edge selected by the outcome. Repeating that cycle advances the run until @@ -818,8 +887,8 @@ flowchart TB A declared outcome such as `needs_information` is a workflow decision. A handler exception or exhausted node-execution allowance is an execution -failure. A workflow can therefore complete with a non-success business outcome -without being a failed runtime execution. Where iteration supports collecting item +failure. A workflow can complete with an outcome such as `needs_information` +without failing the run. Where iteration supports collecting item errors, that policy must be explicit; errors do not automatically become ordinary outcome edges. @@ -841,7 +910,8 @@ a time. An output binding appends each rendered report to a `reports` state field, which the assembly step reads. [@fig:foreach-graph] shows the authored graph. The `loop` edge enters the -item body. Its return edge, `render.ok -> each`, ends that item's work. +item body. Its return edge (a back-edge), `render.ok -> each`, ends that +item's work. The `done` edge leads to the continuation after the collection is processed. @@ -862,7 +932,9 @@ controller from `Prepare documents` starts an invocation over the collection. [@fig:foreach-region] follows one invocation from start to finish. Read downward for time. Solid arrows request work or apply a data binding; -dashed arrows report completion. The labels identify the data and routes. +dashed arrows report completion. Here line style distinguishes requests from +completion; in [@fig:report-state], it distinguishes execution from data +bindings. The labels identify the data and routes. ```{.mermaid #fig:foreach-region width=95% caption="Serial iteration finishes both documents before assembly."} @@ -888,7 +960,7 @@ The same authored render node executes twice. The runtime distinguishes those executions so that finishing A advances to B, while finishing B permits assembly. Each item returns to the foreach invocation that started it. -The back-edge in [@fig:foreach-graph] records the item's return. Its output +The return edge in [@fig:foreach-graph] records the item's return. Its output binding appends the report before that return. A node in the body belongs to that loop's control region. The validator rejects using the same node both inside and outside the body, or returning past the immediate enclosing loop. @@ -915,7 +987,7 @@ The parent item then continues to its own return edge. sequenceDiagram participant Item as Parent item A participant Child as Child workflow - participant Result as Item A reports writes + participant Result as A's pending report writes participant Each as Foreach Item->>Child: Input binding: document = A.md activate Child @@ -941,7 +1013,7 @@ its child does not permit assembly while B is still running. The foreach waits for its required item completions before following `done`. Concurrent items retain separate pending writes until their results combine. -In [@fig:scope-boundaries], “Item A reports writes” represents these pending +In [@fig:scope-boundaries], “A's pending report writes” represents these pending writes. In serial mode, the output binding updates enclosing state so the next item can read it. @@ -1005,9 +1077,9 @@ re-inspects the exact artifact version. The resulting artifact object is therefore reconstructed from the stored definition. Identity checks reject responses identifying a different artifact or deployment. -The service's `inspect_run` operation retrieves a stored run by its identifier. -The Python client's `Run.refresh()` method calls that operation, verifies the -run and deployment identities, and returns a new run snapshot. `Run.resume()` +The Python client's `Run.refresh()` method retrieves a stored run by its +identifier through the service's `inspect_run` operation. It verifies the +run and deployment identities and returns a new run snapshot. `Run.resume()` submits the requested response for an interrupted run and also returns a new snapshot. The caller retains the returned object to see the updated status and result. @@ -1059,7 +1131,8 @@ document's separate view until the outer foreach combines the documents. Publishing every serial section's write directly into shared workflow state would expose unfinished document results to the other concurrent items. -The runtime records where each execution belongs. A **frame** records one +The runtime tracks the active node, the workflow invocation that owns its +data, and the updates held separately by concurrent items. A **frame** records one execution position, including its current node and owning foreach when it is an iteration item. A **scope** contains the input, working state, and context of one workflow invocation. A child workflow has its own scope. A **lineage** @@ -1095,8 +1168,14 @@ unnamed current-item value would leave it unclear which one the node receives. **Execution context** supplies runtime information to expressions and input bindings. Its foreach entries are keyed by the authored controller's identity. -Inside the inner body, `context.foreach.documents.item` selects the enclosing -document, while `context.foreach.actions.item` selects the current action. +Inside the inner body, two paths distinguish the enclosing document from +the current action: + +```text +context.foreach.documents.item # enclosing document +context.foreach.actions.item # current action +``` + The runtime constructs these entries from the active frames. Repeated executions of the same node therefore resolve the paths to their own items. @@ -1198,9 +1277,10 @@ Their Pydantic models describe the input and output contracts. The author consumes those operations from the service inventory; the client does not import their implementations to execute them locally. -The following blocks form one asynchronous Python session. The retained test -executes these blocks against the real service in process, replacing only -the HTTP connection assignment. It does not verify remote server startup. +The Python blocks form one asynchronous session. The retained test runs that +session against the real service in process. It substitutes an in-process +connection for the HTTP connection assignment; remote server startup is not +tested. For HTTP use, the blocks assume a server using `examples/report_workflow/wf.config.json`, reachable at its configured address, and a fresh artifact name or unused version. The fixture @@ -1444,9 +1524,9 @@ sequenceDiagram Client->>API: Bind deployment to version API-->>Client: Deployment and readiness Client->>API: Run with input - API->>Provider: Invoke graph steps - Provider-->>API: Outputs and outcomes - API-->>Client: Stopped run snapshot + API->>Provider: Invoke bound operation + Provider-->>API: Output and outcome + API-->>Client: Run snapshot Client->>API: Inspect run and bounded trace API-->>Client: Stored execution evidence Client-->>Author: Result or diagnostic @@ -1506,6 +1586,42 @@ recorded risks and followups, and a Markdown report headed example's data reached the intended outputs. The extraction contract requires the sectioned notes format described at the start of this chapter. +## Registering a scheduled run + +The same deployment can be invoked later by the server's scheduler. With +scheduling enabled on the local/static server, the client can register a +one-time start and inspect its stored configuration and occurrence history: + +```python +from datetime import UTC, datetime, timedelta + +schedule = await app.create_schedule( + schedule_id="report-once", + deployment_id=deployment.deployment_id, + trigger={ + "kind": "oneshot", + "at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + }, + input_bindings=[{ + "target": "text", + "expression": {"kind": "literal", "value": notes}, + }], + max_steps=100, +) +schedule = await app.schedule(schedule.schedule_id) +history = await app.schedule_occurrences(schedule.schedule_id, limit=10) +``` + +The input binding stores the current contents of `notes`. The scheduled run +receives that text even if the original file changes. A recurring workflow +that needs fresh notes would need an operation that retrieves them when it +runs. Occurrence history records what happened to scheduled starts, including +admission and completion. Immediately after registration, it can be empty. + +The session check verifies registration and inspection without waiting for +the scheduled time. Scheduler execution is exercised separately in +[`test_scheduled_deployment_example.py`](../../tests/examples/test_scheduled_deployment_example.py). + ## What this case demonstrates The executable check in @@ -1520,7 +1636,7 @@ discovery and invocation, and the artifact/deployment/run lifecycle using the raw-plan fixture. Those tests are evidence for the report operations and lifecycle, complementing the Python session check. -The Python presentation makes the current authoring experience concrete: +The Python session shows the current authoring steps: inspect operations, declare contracts, connect data and outcomes, save, select bindings, and inspect an execution. The CLI and draft surface offer alternative interfaces to related lifecycle operations. @@ -1540,8 +1656,9 @@ are needed to evaluate the interaction beyond this scripted session. Evaluation distinguishes three questions: whether the runtime follows its contract, whether the public lifecycle composes correctly, and whether an -author can use that lifecycle effectively. The current evidence addresses -the first two through controlled tests and an adapted in-process walkthrough. +author can use that lifecycle effectively. The reported in-process walkthrough +exercises the public lifecycle. Targeted tests for runtime contracts are +mapped in the Evidence Index. The authoring assessment identifies the operations available through the interface. Usability remains a separate evaluation question. @@ -1845,7 +1962,7 @@ different from promising that an earlier action can be undone. This report examined how a useful procedure can become a reusable workflow that an author can define and an operator can inspect. The implemented -system separates the saved definition, its environment bindings, and each +system separates the saved definition, its deployment bindings, and each execution into artifacts, deployments, and runs. Those distinctions give workflow use a record beyond the lifetime of an editing session or a single script invocation. diff --git a/tests/docs/test_thesis_front_matter.py b/tests/docs/test_thesis_front_matter.py index 1fed0026..7c2e4083 100644 --- a/tests/docs/test_thesis_front_matter.py +++ b/tests/docs/test_thesis_front_matter.py @@ -58,3 +58,46 @@ def test_abbreviations_compile_without_a_dummy_float(tmp_path: Path) -> None: assert "API" in rendered assert "Application Programming Interface" in rendered assert "None 1" not in rendered + + +@pytest.mark.skipif( + any(shutil.which(tool) is None for tool in ("pandoc", "xelatex", "pdftotext")), + reason="Pandoc, XeLaTeX, and pdftotext are needed for the inline-code build", +) +def test_inline_identifier_moves_to_next_line_intact(tmp_path: Path) -> None: + """A prose line ending must not split a callable's name into fragments.""" + output = tmp_path / "inline.pdf" + identifier = "local.report.render_markdown_report" + result = subprocess.run( + [ + "pandoc", + "--standalone", + "--pdf-engine=xelatex", + "--variable=geometry:textwidth=10cm", + "--variable=monofont:Libertinus Mono", + "--include-in-header", + str(THESIS / "header-includes.tex"), + "--output", + str(output), + ], + # Shift the identifier along the line to expose both forced splitting + # and font-dependent automatic hyphenation near the right margin. + input="\n\n".join( + f"{'word ' * count}`{identifier}`." for count in range(1, 16) + ), + text=True, + encoding="utf-8", + capture_output=True, + check=False, + timeout=90, + ) + assert result.returncode == 0, result.stderr + rendered = subprocess.run( + ["pdftotext", "-layout", str(output), "-"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + timeout=15, + ).stdout + assert rendered.count(identifier) == 15 diff --git a/tests/docs/test_thesis_langgraph_example.py b/tests/docs/test_thesis_langgraph_example.py new file mode 100644 index 00000000..7d6e70b2 --- /dev/null +++ b/tests/docs/test_thesis_langgraph_example.py @@ -0,0 +1,28 @@ +"""Exercise the comparison snippet when the optional LangGraph library exists.""" + +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize( + ("text", "expected_report"), + [(" Notes ", "# Report\n\nNotes"), (" ", "")], +) +def test_langgraph_comparison_routes_updated_state( + text: str, expected_report: str +) -> None: + """Check the rendered example without making LangGraph a product dependency.""" + pytest.importorskip("langgraph.graph") + manuscript = ( + Path(__file__).resolve().parents[2] + / "docs/thesis/system-design-implementation.md" + ).read_text(encoding="utf-8") + section = manuscript.split("", 1)[1] + snippet = section.split("```python\n", 1)[1].split("```", 1)[0] + namespace = {} + # This is trusted repository prose, executed exactly as displayed. + exec(compile(snippet, "thesis-langgraph-example", "exec"), namespace) + result = namespace["graph"].invoke({"text": text, "report": ""}) + assert result["text"] == text.strip() + assert result["report"] == expected_report diff --git a/tests/examples/test_thesis_python_walkthrough.py b/tests/examples/test_thesis_python_walkthrough.py index 4d3d7bda..3e4a2df0 100644 --- a/tests/examples/test_thesis_python_walkthrough.py +++ b/tests/examples/test_thesis_python_walkthrough.py @@ -10,9 +10,11 @@ from typing import Any, cast import pytest -from wf_client import App, Deployment, Run +from wf_api.durable_context import durable_workflow_api +from wf_client import App, Deployment, Run, Schedule from wf_client.protocols import WorkflowClientPort from wf_config import load_workflow_config +from wf_scheduling.store import FileScheduleStore from wf_server.config import build_workflow_server_from_workflow_config ROOT = Path(__file__).resolve().parents[2] @@ -32,7 +34,11 @@ async def test_thesis_python_session_preserves_report_and_repair( config = load_workflow_config(ROOT / "examples/report_workflow/wf.config.json") config.server.store.root = tmp_path / "store" server = build_workflow_server_from_workflow_config(config) - app = App._from_port(cast(WorkflowClientPort, server.api)) + # Enable real schedule persistence without starting a background scheduler: + # this session verifies registration, while another test exercises dispatch. + schedule_store = FileScheduleStore(config.server.store.root) + api = durable_workflow_api(server.context, schedule_store=schedule_store) + app = App._from_port(cast(WorkflowClientPort, api)) monkeypatch.chdir(ROOT) manuscript = THESIS.read_text(encoding="utf-8") @@ -84,3 +90,14 @@ async def test_thesis_python_session_preserves_report_and_repair( for displayed in displayed_outputs: assert displayed in diagnostic, "Displayed output differs from the session" assert namespace["trace"].frames + schedule = namespace["schedule"] + assert isinstance(schedule, Schedule) + assert schedule.deployment_id == deployment.deployment_id + assert schedule.max_steps == 100 + assert schedule.trigger["kind"] == "oneshot" + persisted = schedule_store.get_schedule(schedule.schedule_id) + assert persisted is not None + expression = persisted.input_bindings[0].expression + assert expression.kind == "literal" + assert expression.value == namespace["notes"] + assert namespace["history"]["occurrences"] == []