feat: add lda report workflow example

This commit is contained in:
lda
2026-07-01 07:14:25 +07:00 Verified
parent 9e313cd5d0
commit 7bdfa3132d
18 changed files with 1610 additions and 2 deletions
+3 -2
View File
@@ -39,8 +39,9 @@ Implementation order:
1. Completed: self-describing interrupt request/resume schemas are carried
through core execution, persisted run inspection, and resume validation.
2. Add a deterministic `examples/lda_report_workflow/` case study with local
document, report, and issue-board sources.
2. Completed: deterministic `examples/lda_report_workflow/` case study with
local document, report, issue-board sources, and typed issue-review
interrupt.
3. Add a top-level `web/` Astro/Effect app with loopback JSON-RPC connection and
method registry.
4. Add console read/inspect views for sources, drafts, artifacts, deployments,
+2
View File
@@ -0,0 +1,2 @@
.wf_lda_report_store/
issue-board.json
+52
View File
@@ -0,0 +1,52 @@
# lda.chat Report Workflow
This example is a deterministic case study for the Workflow Console and defense
demo. It uses local fixture documents, trusted Python sources, a typed
`issue_review` interrupt, and a local JSON-backed issue board.
It does not call Google Drive, email, GitHub, or an LLM.
## Sources
- `local.lda_docs`: lists and reads deterministic project documents.
- `local.lda_report`: analyses documents, builds the readiness report, creates
proposed issue drafts, and finalises the report.
- `local.issue_board`: writes selected issues to a local JSON file.
## Workflow Definition
`workflow.plan.json` is generated from `build_workflow.py`. After changing the
graph, regenerate and review the raw plan:
```powershell
uv run python examples/lda_report_workflow/build_workflow.py
```
## Product Path
From the repository root:
```powershell
uv run wf --config examples/lda_report_workflow/wf.config.json config validate
uv run wf --config examples/lda_report_workflow/wf.config.json --local cap list --source local.lda_report
uv run wf --config examples/lda_report_workflow/wf.config.json --local artifact create-from-plan examples/lda_report_workflow/workflow.plan.json --artifact lda_report_case_study --version 1 --title "lda.chat Report Case Study" --outcome completed --outcome cancelled --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board
uv run wf --config examples/lda_report_workflow/wf.config.json --local deploy save lda_report_case_study.default --artifact lda_report_case_study --version 1 --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board
uv run wf --config examples/lda_report_workflow/wf.config.json --local run start lda_report_case_study.default --input-file examples/lda_report_workflow/run-input.json
```
The run stops at an `issue_review` interrupt. Inspect it:
```powershell
uv run wf --config examples/lda_report_workflow/wf.config.json --local run inspect <run_id>
```
Resume with selected issues:
```powershell
uv run wf --config examples/lda_report_workflow/wf.config.json --local run resume <run_id> --payload '{"approved":true,"selected_issue_ids":["risk-1"],"comment":"Create selected issues."}'
```
## Cleanup
The example writes `.wf_lda_report_store/` and `issue-board.json`, both ignored
by git.
@@ -0,0 +1,207 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from wf_api.models import RawWorkflowPlan
from wf_authoring import WorkflowBuilder
from wf_core import Workflow
HERE = Path(__file__).resolve().parent
WORKFLOW_OUTPUT = [
{"path": "state.approved", "target": "approved"},
{"path": "state.final_markdown", "target": "markdown"},
{"path": "state.created_issues", "target": "created_issues"},
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
]
def build_workflow() -> Workflow:
"""Build the demo workflow with the public authoring API.
`WorkflowBuilder` does not yet expose a workflow-output setter, so this
module adds the final output projection in `_with_workflow_output()` after
compiling the graph. Keep that seam small and validated.
"""
builder = WorkflowBuilder(
name="lda_report_case_study",
input_schema={
"type": "object",
"properties": {
"selected_documents": {
"type": "array",
"items": {"type": "string"},
},
"board_path": {"type": "string"},
},
"required": ["selected_documents", "board_path"],
},
state_schema={
"type": "object",
"properties": {
"documents": {"type": "array"},
"analysis": {"type": "array"},
"report": {"type": "object"},
"report_markdown": {"type": "string"},
"proposed_issues": {"type": "array"},
"selected_issue_ids": {"type": "array"},
"approval_comment": {"type": "string"},
"approved": {"type": "boolean"},
"created_issues": {"type": "array"},
"final_markdown": {"type": "string"},
},
},
output_schema={
"type": "object",
"properties": {
"approved": {"type": "boolean"},
"markdown": {"type": "string"},
"created_issues": {"type": "array"},
"selected_issue_ids": {"type": "array"},
},
},
outcomes=["completed", "cancelled"],
)
read_docs = builder.use_ref(
"local.lda_docs.read_documents",
id="read_docs",
input=[{"path": "input.selected_documents", "target": "names"}],
output=[{"source": "documents", "target": "state.documents"}],
)
analyze = builder.use_ref(
"local.lda_report.analyze_documents",
id="analyze",
input=[{"path": "state.documents", "target": "documents"}],
output=[{"source": "analysis", "target": "state.analysis"}],
)
build_report = builder.use_ref(
"local.lda_report.build_report",
id="build_report",
input=[{"path": "state.analysis", "target": "analysis"}],
output=[
{"source": "report", "target": "state.report"},
{"source": "markdown", "target": "state.report_markdown"},
],
)
draft_issues = builder.use_ref(
"local.lda_report.create_issue_drafts",
id="draft_issues",
input=[{"path": "state.report", "target": "report"}],
output=[{"source": "issues", "target": "state.proposed_issues"}],
)
review_issues = builder.interrupt(
id="review_issues",
kind="issue_review",
request=[
{"path": "state.report_markdown", "target": "report_markdown"},
{"path": "state.proposed_issues", "target": "proposed_issues"},
],
resume=[
{"source": "approved", "target": "state.approved"},
{"source": "selected_issue_ids", "target": "state.selected_issue_ids"},
{"source": "comment", "target": "state.approval_comment"},
],
outcomes=["submitted", "cancelled"],
request_schema={
"type": "object",
"properties": {
"report_markdown": {"type": "string"},
"proposed_issues": {"type": "array"},
},
"required": ["report_markdown", "proposed_issues"],
"additionalProperties": False,
},
resume_schema={
"type": "object",
"properties": {
"approved": {"type": "boolean"},
"selected_issue_ids": {
"type": "array",
"items": {"type": "string"},
},
"comment": {"type": "string"},
},
"required": ["approved", "selected_issue_ids"],
"additionalProperties": False,
},
)
create_issues = builder.use_ref(
"local.issue_board.create_issues",
id="create_issues",
input=[
{"path": "state.proposed_issues", "target": "issues"},
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
{"path": "input.board_path", "target": "board_path"},
],
output=[{"source": "created_issues", "target": "state.created_issues"}],
)
finalise = builder.use_ref(
"local.lda_report.finalise_report",
id="finalise",
input=[
{"path": "state.report", "target": "report"},
{"path": "state.created_issues", "target": "created_issues"},
{"path": "state.approved", "target": "approved"},
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
{"path": "state.approval_comment", "target": "comment"},
],
output=[{"source": "markdown", "target": "state.final_markdown"}],
)
revision_requested = builder.use_ref(
"local.lda_report.record_revision_request",
id="revision_requested",
input=[{"path": "state.approval_comment", "target": "comment"}],
output=[
{"source": "approved", "target": "state.approved"},
{"source": "markdown", "target": "state.final_markdown"},
{"source": "created_issues", "target": "state.created_issues"},
{"source": "selected_issue_ids", "target": "state.selected_issue_ids"},
],
)
end_completed = builder.end("completed", id="end_completed")
end_cancelled = builder.end("cancelled", id="end_cancelled")
builder.set_entry_point(read_docs)
builder.connect(read_docs, "ok", analyze)
builder.connect(analyze, "ok", build_report)
builder.connect(build_report, "ok", draft_issues)
builder.connect(draft_issues, "ok", review_issues)
builder.branch(
review_issues,
{
"submitted": create_issues,
"cancelled": revision_requested,
},
)
builder.connect(create_issues, "ok", finalise)
builder.connect(finalise, "ok", end_completed)
builder.connect(revision_requested, "ok", end_cancelled)
return _with_workflow_output(builder.compile())
def _with_workflow_output(workflow: Workflow) -> Workflow:
payload = workflow.model_dump(mode="json", by_alias=True)
payload["output"] = WORKFLOW_OUTPUT
return Workflow.model_validate(payload)
def workflow_plan_payload() -> dict[str, Any]:
payload = build_workflow().model_dump(mode="json", by_alias=True)
payload.pop("node_defs", None)
RawWorkflowPlan.model_validate(payload)
return payload
def write_plan(path: Path = HERE / "workflow.plan.json") -> None:
payload = workflow_plan_payload()
path.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
write_plan()
@@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel, Field
from wf_authoring import node
_EXAMPLE_DIR = Path(__file__).resolve().parent
_DOCUMENT_DIR = _EXAMPLE_DIR / "documents"
class ListDocumentsInput(BaseModel):
include_archived: bool = Field(
default=False,
description="Reserved for future filtering; current fixture has no archived docs.",
)
class DocumentRef(BaseModel):
name: str
title: str
class ListDocumentsOutput(BaseModel):
documents: list[DocumentRef]
class ReadDocumentsInput(BaseModel):
names: list[str] = Field(description="Document names returned by list_documents.")
class DocumentText(BaseModel):
name: str
title: str
text: str
class ReadDocumentsOutput(BaseModel):
documents: list[DocumentText]
@node(
name="list_documents",
description="List deterministic lda.chat project documents available to the demo.",
)
def list_documents(payload: ListDocumentsInput) -> ListDocumentsOutput:
return _list_documents(payload)
@node(
name="read_documents",
description="Read selected lda.chat project documents by fixture name.",
)
def read_documents(payload: ReadDocumentsInput) -> ReadDocumentsOutput:
return _read_documents(payload)
def _list_documents(_payload: ListDocumentsInput) -> ListDocumentsOutput:
documents = [
DocumentRef(name=path.name, title=_title_for(path))
for path in sorted(_DOCUMENT_DIR.glob("*.md"))
]
return ListDocumentsOutput(documents=documents)
def _read_documents(payload: ReadDocumentsInput) -> ReadDocumentsOutput:
allowed = {
document.name for document in _list_documents(ListDocumentsInput()).documents
}
selected: list[DocumentText] = []
for name in payload.names:
if name not in allowed:
raise ValueError(
f"unknown or unsafe document name: {name!r}; use a known document"
)
path = _DOCUMENT_DIR / name
selected.append(
DocumentText(
name=name, title=_title_for(path), text=path.read_text(encoding="utf-8")
)
)
return ReadDocumentsOutput(documents=selected)
def _title_for(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("# "):
return line.removeprefix("# ").strip()
return path.stem.replace("-", " ").title()
registry = [list_documents, read_documents]
@@ -0,0 +1,21 @@
# Architecture Notes
Lifecycle:
- Drafts are mutable authoring workspaces with revisions.
- Artifacts are immutable versioned workflow definitions.
- Deployments bind logical source ids to configured concrete sources.
- Runs persist stopped execution records and bounded traces.
Runtime:
- The core executes typed graph nodes and routes by declared outcomes.
- State writes go through reducer-aware merge semantics.
- Interrupt nodes pause at explicit human-in-the-loop boundaries.
- Resume payloads are validated before state mutation.
Source providers:
- Python sources support trusted local demo capabilities.
- MCP sources preserve upstream session state through a runtime pool.
- OpenAPI source support exists as an experimental provider.
@@ -0,0 +1,17 @@
# Evaluation Findings
Evidence:
- Automated tests cover core runtime, artifacts, deployments, CLI, JSON-RPC,
source providers, and examples.
- A 36-trial audited agent challenge campaign evaluated the product-facing CLI
under bounded conditions.
- Manual audit flags separate product-surface success from source-code or prior
answer reads.
Limitations:
- Agent challenge runs are operational evidence, not a controlled model study.
- The campaign used small sample sizes and changing prototype snapshots.
- The prototype does not claim production security, scheduling, RBAC, or a
general autonomous planning algorithm.
@@ -0,0 +1,19 @@
# Project Brief
lda.chat is a workflow substrate for AI-agent-facing workspace automation. The
prototype separates external planning from deterministic workflow execution.
Key achievements:
- Typed Draft, Artifact, Deployment, Run, and Trace lifecycle records.
- Source-provider boundary for platform, MCP, Python, and experimental OpenAPI
sources.
- JSON-RPC and CLI surfaces usable by external agents and human operators.
- Deterministic report and browser-click examples with audited agent challenge
runs.
Current positioning:
- The system is not a bundled autonomous planner.
- External agents or humans operate the workflow lifecycle.
- The next product-facing step is a local Workflow Console and defense demo.
@@ -0,0 +1,18 @@
# Risk Register
Material risks:
- Title and product framing can overstate the implemented autonomous-agent
layer if not explained carefully.
- Evaluation evidence is stronger as systems evidence than as a controlled
empirical model comparison.
- File-backed stores are useful for auditability but not a production
transaction boundary.
- The Workflow Console needs a strict loopback-only first slice to avoid
becoming an arbitrary RPC proxy.
Mitigations:
- Keep the agent/substrate boundary explicit in the thesis and defense.
- Present challenge data as bounded operational evidence.
- Defer production storage, auth, and remote proxying to future work.
@@ -0,0 +1,15 @@
# Roadmap
Near-term:
- Add self-describing interrupt request and resume contracts.
- Build a deterministic lda.chat report workflow with typed issue approval.
- Build a local Workflow Console over JSON-RPC.
- Add live-demo replay support for the defense.
Later:
- Stabilize the experimental OpenAPI provider.
- Add production secret stores and transactional persistence.
- Add a surrounding agent interface and planner loop.
- Explore scheduling, richer debugging, and visual workflow editing.
@@ -0,0 +1,119 @@
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from pydantic import BaseModel, Field
from wf_authoring import node
from .report_source import CreatedIssue, ProposedIssue
_EXAMPLE_DIR = Path(__file__).resolve().parent
class ResetIssueBoardInput(BaseModel):
board_path: str = Field(default="issue-board.json")
class ResetIssueBoardOutput(BaseModel):
reset: bool
board_path: str
class CreateIssuesInput(BaseModel):
issues: list[ProposedIssue]
selected_issue_ids: list[str]
board_path: str = Field(default="issue-board.json")
class CreateIssuesOutput(BaseModel):
created_issues: list[CreatedIssue]
board_path: str
@node(name="reset_issue_board", description="Reset the local demo issue board file.")
def reset_issue_board(payload: ResetIssueBoardInput) -> ResetIssueBoardOutput:
return _reset_issue_board(payload)
@node(
name="create_issues",
description="Create selected issues in the local demo issue board.",
)
def create_issues(payload: CreateIssuesInput) -> CreateIssuesOutput:
return _create_issues(payload)
def _reset_issue_board(payload: ResetIssueBoardInput) -> ResetIssueBoardOutput:
path = _resolve_board_path(payload.board_path)
path.unlink(missing_ok=True)
return ResetIssueBoardOutput(reset=True, board_path=str(path))
def _create_issues(payload: CreateIssuesInput) -> CreateIssuesOutput:
path = _resolve_board_path(payload.board_path)
selected = set(payload.selected_issue_ids)
existing = _read_board(path)
created: list[CreatedIssue] = []
next_number = len(existing) + 1
for issue in payload.issues:
if issue.id not in selected:
continue
issue_id = f"ISSUE-{next_number:03d}"
created_issue = CreatedIssue(
id=issue_id,
title=issue.title,
url=f"local://issue-board/{issue_id}",
)
existing.append(
{
"id": created_issue.id,
"title": created_issue.title,
"url": created_issue.url,
"body": issue.body,
"severity": issue.severity,
}
)
created.append(created_issue)
next_number += 1
_write_board(path, existing)
return CreateIssuesOutput(created_issues=created, board_path=str(path))
def _resolve_board_path(path: str) -> Path:
candidate = Path(path)
if candidate.is_absolute():
resolved = candidate.resolve()
else:
resolved = (_EXAMPLE_DIR / candidate).resolve()
# Keep the demo capability from deleting or writing arbitrary files while still
# allowing pytest tmp_path fixtures to exercise isolated board files.
allowed_roots = [_EXAMPLE_DIR.resolve(), Path(tempfile.gettempdir()).resolve()]
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
raise ValueError("board_path must stay inside the example dir or temp dir")
return resolved
def _is_relative_to(path: Path, root: Path) -> bool:
return path == root or path.is_relative_to(root)
def _read_board(path: Path) -> list[dict[str, str]]:
if not path.exists():
return []
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, list):
raise ValueError("issue board file must contain a JSON list")
return [item for item in value if isinstance(item, dict)]
def _write_board(path: Path, value: list[dict[str, str]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(path)
registry = [reset_issue_board, create_issues]
@@ -0,0 +1,273 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from wf_authoring import node
from .document_source import DocumentText
class AnalyzeDocumentsInput(BaseModel):
documents: list[DocumentText]
class Finding(BaseModel):
source: str
summary: str
risks: list[str] = Field(default_factory=list)
actions: list[str] = Field(default_factory=list)
class AnalyzeDocumentsOutput(BaseModel):
analysis: list[Finding]
class BuildReportInput(BaseModel):
analysis: list[Finding]
class ReadinessReport(BaseModel):
title: str
summary: str
achievements: list[str]
risks: list[str]
next_actions: list[str]
class BuildReportOutput(BaseModel):
report: ReadinessReport
markdown: str
class CreateIssueDraftsInput(BaseModel):
report: ReadinessReport
class ProposedIssue(BaseModel):
id: str
title: str
body: str
severity: str = "medium"
class CreateIssueDraftsOutput(BaseModel):
issues: list[ProposedIssue]
class CreatedIssue(BaseModel):
id: str
title: str
url: str
class FinaliseReportInput(BaseModel):
report: ReadinessReport
created_issues: list[CreatedIssue] = Field(default_factory=list)
approved: bool
selected_issue_ids: list[str] = Field(default_factory=list)
comment: str | None = None
class FinalReportOutput(BaseModel):
approved: bool
markdown: str
created_issues: list[CreatedIssue]
selected_issue_ids: list[str]
comment: str | None = None
class RecordRevisionRequestInput(BaseModel):
comment: str | None = None
@node(
name="analyze_documents",
description="Extract deterministic findings from lda.chat project documents.",
)
def analyze_documents(payload: AnalyzeDocumentsInput) -> AnalyzeDocumentsOutput:
return _analyze_documents(payload)
@node(
name="build_report",
description="Build a typed lda.chat readiness report from document findings.",
)
def build_report(payload: BuildReportInput) -> BuildReportOutput:
return _build_report(payload)
@node(
name="create_issue_drafts",
description="Create proposed local issue drafts from report risks and next actions.",
)
def create_issue_drafts(payload: CreateIssueDraftsInput) -> CreateIssueDraftsOutput:
return _create_issue_drafts(payload)
@node(
name="finalise_report",
description="Render the approved report and include created issue references.",
)
def finalise_report(payload: FinaliseReportInput) -> FinalReportOutput:
return _finalise_report(payload)
@node(
name="record_revision_request",
description="Return a cancelled report result when the human asks for revision.",
)
def record_revision_request(payload: RecordRevisionRequestInput) -> FinalReportOutput:
return _record_revision_request(payload)
def _analyze_documents(payload: AnalyzeDocumentsInput) -> AnalyzeDocumentsOutput:
findings: list[Finding] = []
for document in payload.documents:
text = document.text.lower()
risks = _lines_after(document.text, "Material risks:")
actions = _lines_after(document.text, "Near-term:") or _lines_after(
document.text, "Mitigations:"
)
if "workflow substrate" in text:
summary = (
"lda.chat is positioned as a workflow substrate for external agents."
)
elif "evaluation" in text:
summary = "Evaluation evidence is bounded and should be presented as operational evidence."
elif "risk" in text:
summary = "The current risk register emphasizes framing, evaluation, and storage limits."
elif "roadmap" in text:
summary = "Near-term roadmap focuses on typed interrupts, deterministic demos, and a console."
else:
summary = document.text.splitlines()[0].lstrip("# ").strip()
findings.append(
Finding(
source=document.name,
summary=summary,
risks=risks[:3],
actions=actions[:3],
)
)
return AnalyzeDocumentsOutput(analysis=findings)
def _build_report(payload: BuildReportInput) -> BuildReportOutput:
achievements = [
finding.summary
for finding in payload.analysis
if "risk register" not in finding.summary
]
risks = _unique(item for finding in payload.analysis for item in finding.risks)
next_actions = _unique(
item for finding in payload.analysis for item in finding.actions
)
report = ReadinessReport(
title="lda.chat Thesis And Project Readiness Report",
summary=(
"lda.chat is a typed workflow substrate with lifecycle records, "
"source-provider boundaries, and agent-operable CLI/RPC surfaces."
),
achievements=achievements[:6],
risks=risks[:6],
next_actions=next_actions[:6],
)
return BuildReportOutput(report=report, markdown=_render_report(report))
def _create_issue_drafts(payload: CreateIssueDraftsInput) -> CreateIssueDraftsOutput:
issues: list[ProposedIssue] = []
for index, risk in enumerate(payload.report.risks[:4], start=1):
issue_id = f"risk-{index}"
issues.append(
ProposedIssue(
id=issue_id,
title=risk.rstrip("."),
body=f"Track mitigation for: {risk}",
severity="high" if "title" in risk.lower() else "medium",
)
)
if not issues:
issues.append(
ProposedIssue(
id="follow-up-1",
title="Review thesis demo readiness",
body="Confirm the prepared workflow and replay are ready for defense.",
severity="medium",
)
)
return CreateIssueDraftsOutput(issues=issues)
def _finalise_report(payload: FinaliseReportInput) -> FinalReportOutput:
markdown = _render_report(payload.report)
if payload.created_issues:
markdown += "\n\nCreated issues:\n"
markdown += "\n".join(
f"- {issue.id}: {issue.title} ({issue.url})"
for issue in payload.created_issues
)
if payload.comment:
markdown += f"\n\nApproval comment: {payload.comment}"
return FinalReportOutput(
approved=payload.approved,
markdown=markdown,
created_issues=payload.created_issues,
selected_issue_ids=payload.selected_issue_ids,
comment=payload.comment,
)
def _record_revision_request(payload: RecordRevisionRequestInput) -> FinalReportOutput:
comment = payload.comment or "Revision requested."
return FinalReportOutput(
approved=False,
markdown=f"# Revision Requested\n\n{comment}",
created_issues=[],
selected_issue_ids=[],
comment=comment,
)
def _render_report(report: ReadinessReport) -> str:
lines = [f"# {report.title}", "", "Summary:", report.summary, ""]
lines.append("Achievements:")
lines.extend(f"- {item}" for item in report.achievements)
lines.extend(["", "Risks:"])
lines.extend(f"- {item}" for item in report.risks)
lines.extend(["", "Next actions:"])
lines.extend(f"- {item}" for item in report.next_actions)
return "\n".join(lines)
def _lines_after(text: str, heading: str) -> list[str]:
lines: list[str] = []
active = False
for raw in text.splitlines():
line = raw.strip()
if line == heading:
active = True
continue
if active and line.endswith(":"):
break
if active and line.startswith("- "):
lines.append(line.removeprefix("- ").strip())
return lines
def _unique(items: object) -> list[str]:
seen: set[str] = set()
values: list[str] = []
for item in items: # type: ignore[union-attr]
if isinstance(item, str) and item and item not in seen:
seen.add(item)
values.append(item)
return values
registry = [
analyze_documents,
build_report,
create_issue_drafts,
finalise_report,
record_revision_request,
]
@@ -0,0 +1,10 @@
{
"selected_documents": [
"project-brief.md",
"architecture-notes.md",
"evaluation-findings.md",
"risk-register.md",
"roadmap.md"
],
"board_path": "issue-board.json"
}
@@ -0,0 +1,39 @@
{
"server": {
"store": {
"kind": "filesystem",
"root": ".wf_lda_report_store"
},
"transports": [
{
"kind": "rpc_http",
"host": "127.0.0.1",
"port": 8765,
"path": "/rpc"
}
],
"sources": [
{
"id": "local.lda_docs",
"kind": "python",
"path": ".",
"module": "document_source",
"registry": "registry"
},
{
"id": "local.lda_report",
"kind": "python",
"path": ".",
"module": "report_source",
"registry": "registry"
},
{
"id": "local.issue_board",
"kind": "python",
"path": ".",
"module": "issue_board_source",
"registry": "registry"
}
]
}
}
@@ -0,0 +1,408 @@
{
"edges": [
{
"from": "read_docs",
"outcome": "ok",
"to": "analyze"
},
{
"from": "analyze",
"outcome": "ok",
"to": "build_report"
},
{
"from": "build_report",
"outcome": "ok",
"to": "draft_issues"
},
{
"from": "draft_issues",
"outcome": "ok",
"to": "review_issues"
},
{
"from": "review_issues",
"outcome": "submitted",
"to": "create_issues"
},
{
"from": "review_issues",
"outcome": "cancelled",
"to": "revision_requested"
},
{
"from": "create_issues",
"outcome": "ok",
"to": "finalise"
},
{
"from": "finalise",
"outcome": "ok",
"to": "end_completed"
},
{
"from": "revision_requested",
"outcome": "ok",
"to": "end_cancelled"
}
],
"input_schema": {
"properties": {
"board_path": {
"type": "string"
},
"selected_documents": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"selected_documents",
"board_path"
],
"type": "object"
},
"name": "lda_report_case_study",
"nodes": [
{
"desc": null,
"id": "read_docs",
"input": [
{
"path": "input.selected_documents",
"target": "names"
}
],
"node": "local.lda_docs.read_documents",
"output": [
{
"source": "documents",
"target": "state.documents"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"desc": null,
"id": "analyze",
"input": [
{
"path": "state.documents",
"target": "documents"
}
],
"node": "local.lda_report.analyze_documents",
"output": [
{
"source": "analysis",
"target": "state.analysis"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"desc": null,
"id": "build_report",
"input": [
{
"path": "state.analysis",
"target": "analysis"
}
],
"node": "local.lda_report.build_report",
"output": [
{
"source": "report",
"target": "state.report"
},
{
"source": "markdown",
"target": "state.report_markdown"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"desc": null,
"id": "draft_issues",
"input": [
{
"path": "state.report",
"target": "report"
}
],
"node": "local.lda_report.create_issue_drafts",
"output": [
{
"source": "issues",
"target": "state.proposed_issues"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"id": "review_issues",
"kind": "issue_review",
"outcomes": [
"submitted",
"cancelled"
],
"request": [
{
"path": "state.report_markdown",
"target": "report_markdown"
},
{
"path": "state.proposed_issues",
"target": "proposed_issues"
}
],
"request_schema": {
"additionalProperties": false,
"properties": {
"proposed_issues": {
"type": "array"
},
"report_markdown": {
"type": "string"
}
},
"required": [
"report_markdown",
"proposed_issues"
],
"type": "object"
},
"resume": [
{
"source": "approved",
"target": "state.approved"
},
{
"source": "selected_issue_ids",
"target": "state.selected_issue_ids"
},
{
"source": "comment",
"target": "state.approval_comment"
}
],
"resume_schema": {
"additionalProperties": false,
"properties": {
"approved": {
"type": "boolean"
},
"comment": {
"type": "string"
},
"selected_issue_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"approved",
"selected_issue_ids"
],
"type": "object"
},
"type": "interrupt"
},
{
"desc": null,
"id": "create_issues",
"input": [
{
"path": "state.proposed_issues",
"target": "issues"
},
{
"path": "state.selected_issue_ids",
"target": "selected_issue_ids"
},
{
"path": "input.board_path",
"target": "board_path"
}
],
"node": "local.issue_board.create_issues",
"output": [
{
"source": "created_issues",
"target": "state.created_issues"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"desc": null,
"id": "finalise",
"input": [
{
"path": "state.report",
"target": "report"
},
{
"path": "state.created_issues",
"target": "created_issues"
},
{
"path": "state.approved",
"target": "approved"
},
{
"path": "state.selected_issue_ids",
"target": "selected_issue_ids"
},
{
"path": "state.approval_comment",
"target": "comment"
}
],
"node": "local.lda_report.finalise_report",
"output": [
{
"source": "markdown",
"target": "state.final_markdown"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"desc": null,
"id": "revision_requested",
"input": [
{
"path": "state.approval_comment",
"target": "comment"
}
],
"node": "local.lda_report.record_revision_request",
"output": [
{
"source": "approved",
"target": "state.approved"
},
{
"source": "markdown",
"target": "state.final_markdown"
},
{
"source": "created_issues",
"target": "state.created_issues"
},
{
"source": "selected_issue_ids",
"target": "state.selected_issue_ids"
}
],
"retry": null,
"timeout_seconds": null,
"type": "node"
},
{
"id": "end_completed",
"outcome": "completed",
"type": "end"
},
{
"id": "end_cancelled",
"outcome": "cancelled",
"type": "end"
}
],
"outcomes": [
"completed",
"cancelled"
],
"output": [
{
"path": "state.approved",
"target": "approved"
},
{
"path": "state.final_markdown",
"target": "markdown"
},
{
"path": "state.created_issues",
"target": "created_issues"
},
{
"path": "state.selected_issue_ids",
"target": "selected_issue_ids"
}
],
"output_schema": {
"properties": {
"approved": {
"type": "boolean"
},
"created_issues": {
"type": "array"
},
"markdown": {
"type": "string"
},
"selected_issue_ids": {
"type": "array"
}
},
"required": [],
"type": "object"
},
"start": "read_docs",
"state_schema": {
"properties": {
"analysis": {
"type": "array"
},
"approval_comment": {
"type": "string"
},
"approved": {
"type": "boolean"
},
"created_issues": {
"type": "array"
},
"documents": {
"type": "array"
},
"final_markdown": {
"type": "string"
},
"proposed_issues": {
"type": "array"
},
"report": {
"type": "object"
},
"report_markdown": {
"type": "string"
},
"selected_issue_ids": {
"type": "array"
}
},
"required": [],
"type": "object"
}
}
@@ -0,0 +1,314 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from examples.lda_report_workflow.build_workflow import (
build_workflow,
workflow_plan_payload,
)
from examples.lda_report_workflow.document_source import (
ListDocumentsInput,
ReadDocumentsInput,
_list_documents,
_read_documents,
)
from examples.lda_report_workflow.issue_board_source import (
CreateIssuesInput,
ResetIssueBoardInput,
_create_issues,
_reset_issue_board,
)
from examples.lda_report_workflow.report_source import (
AnalyzeDocumentsInput,
BuildReportInput,
CreateIssueDraftsInput,
FinaliseReportInput,
ReadinessReport,
RecordRevisionRequestInput,
_analyze_documents,
_build_report,
_create_issue_drafts,
_finalise_report,
_record_revision_request,
)
from wf_api.models import RawWorkflowPlan
from wf_config import load_workflow_config
from wf_server.config import build_workflow_server_from_workflow_config
EXAMPLE_DIR = Path(__file__).resolve().parents[2] / "examples" / "lda_report_workflow"
def test_lda_docs_lists_known_documents() -> None:
result = _list_documents(ListDocumentsInput())
names = {document.name for document in result.documents}
assert "project-brief.md" in names
assert "architecture-notes.md" in names
assert len(result.documents) == 5
def test_lda_docs_reads_selected_documents() -> None:
result = _read_documents(
ReadDocumentsInput(names=["project-brief.md", "roadmap.md"])
)
assert [document.name for document in result.documents] == [
"project-brief.md",
"roadmap.md",
]
assert "workflow substrate" in result.documents[0].text
def test_lda_docs_rejects_path_traversal() -> None:
with pytest.raises(ValueError, match="known document"):
_read_documents(ReadDocumentsInput(names=["../README.md"]))
def test_lda_report_source_builds_report_and_issue_drafts() -> None:
docs = _read_documents(
ReadDocumentsInput(names=["project-brief.md", "risk-register.md", "roadmap.md"])
)
analysis = _analyze_documents(AnalyzeDocumentsInput(documents=docs.documents))
report = _build_report(BuildReportInput(analysis=analysis.analysis))
issue_drafts = _create_issue_drafts(CreateIssueDraftsInput(report=report.report))
assert report.report.title == "lda.chat Thesis And Project Readiness Report"
assert "workflow substrate" in report.report.summary
assert issue_drafts.issues
assert issue_drafts.issues[0].id
assert issue_drafts.issues[0].title
def test_lda_report_source_finalises_approved_report() -> None:
docs = _read_documents(ReadDocumentsInput(names=["project-brief.md", "roadmap.md"]))
analysis = _analyze_documents(AnalyzeDocumentsInput(documents=docs.documents))
report = _build_report(BuildReportInput(analysis=analysis.analysis))
issue_drafts = _create_issue_drafts(CreateIssueDraftsInput(report=report.report))
final = _finalise_report(
FinaliseReportInput(
report=report.report,
created_issues=[],
approved=True,
selected_issue_ids=[issue_drafts.issues[0].id],
comment="Looks good.",
)
)
assert final.approved is True
assert final.markdown.startswith("# lda.chat Thesis And Project Readiness Report")
assert "Looks good." in final.markdown
def test_lda_report_source_records_revision_request() -> None:
result = _record_revision_request(
RecordRevisionRequestInput(comment="Needs revision")
)
assert result.approved is False
assert "Needs revision" in result.markdown
def test_issue_board_creates_selected_issues(tmp_path: Path) -> None:
board_path = tmp_path / "issue-board.json"
drafts = _create_issue_drafts(
CreateIssueDraftsInput(
report=ReadinessReport(
title="Test",
summary="Summary",
achievements=[],
risks=["Risk one", "Risk two"],
next_actions=[],
)
)
)
result = _create_issues(
CreateIssuesInput(
board_path=str(board_path),
issues=drafts.issues,
selected_issue_ids=[drafts.issues[0].id],
)
)
assert len(result.created_issues) == 1
assert result.created_issues[0].title == drafts.issues[0].title
assert board_path.exists()
def test_issue_board_reset_removes_existing_file(tmp_path: Path) -> None:
board_path = tmp_path / "issue-board.json"
board_path.write_text("[]", encoding="utf-8")
result = _reset_issue_board(ResetIssueBoardInput(board_path=str(board_path)))
assert result.reset is True
assert not board_path.exists()
def test_issue_board_rejects_paths_outside_example_or_temp() -> None:
unsafe_path = Path.home() / "lda-chat-unsafe-issue-board.json"
with pytest.raises(ValueError, match="board_path must stay"):
_reset_issue_board(ResetIssueBoardInput(board_path=str(unsafe_path)))
@pytest.mark.asyncio
async def test_lda_report_workflow_config_loads_sources(tmp_path: Path) -> None:
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
config.server.store.root = tmp_path / "store"
server = build_workflow_server_from_workflow_config(config)
listed = await server.api.list_capabilities(source_id="local.lda_report")
names = {capability["name"] for capability in listed["capabilities"]}
assert "local.lda_report.build_report" in names
assert "local.lda_report.finalise_report" in names
def test_lda_report_workflow_builder_generates_committed_raw_plan() -> None:
workflow = build_workflow()
payload = workflow_plan_payload()
committed = json.loads(
(EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8")
)
validated = RawWorkflowPlan.model_validate(payload)
assert workflow.name == "lda_report_case_study"
assert validated.name == "lda_report_case_study"
assert any(node.id == "review_issues" for node in validated.nodes)
assert payload == committed
@pytest.mark.asyncio
async def test_lda_report_workflow_artifact_interrupt_resume_path(
tmp_path: Path,
) -> None:
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
config.server.store.root = tmp_path / "store"
server = build_workflow_server_from_workflow_config(config)
plan = json.loads((EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8"))
await server.api.create_artifact_from_plan(
artifact_id="lda_report_case_study",
version=1,
title="lda.chat Report Case Study",
plan=plan,
outcomes=["completed", "cancelled"],
source_bindings={
"local.lda_docs": "local.lda_docs",
"local.lda_report": "local.lda_report",
"local.issue_board": "local.issue_board",
},
)
await server.api.save_deployment(
{
"id": "lda_report_case_study.default",
"artifact_id": "lda_report_case_study",
"artifact_version": 1,
"bindings": {
"local.lda_docs": "local.lda_docs",
"local.lda_report": "local.lda_report",
"local.issue_board": "local.issue_board",
},
}
)
run_input = json.loads((EXAMPLE_DIR / "run-input.json").read_text(encoding="utf-8"))
run_input["board_path"] = str(tmp_path / "issue-board.json")
started = await server.api.run_deployment(
deployment_id="lda_report_case_study.default",
workflow_input=run_input,
)
assert started["status"] == "interrupted"
assert started["interrupt"]["kind"] == "issue_review"
assert started["interrupt"]["typed"] is True
assert started["interrupt"]["request_schema"]["required"] == [
"report_markdown",
"proposed_issues",
]
assert started["interrupt"]["resume_schema"]["required"] == [
"approved",
"selected_issue_ids",
]
proposed_ids = [
issue["id"] for issue in started["interrupt"]["payload"]["proposed_issues"]
]
assert proposed_ids
resumed = await server.api.resume_run(
run_id=started["run_id"],
resume_payload={
"approved": True,
"selected_issue_ids": proposed_ids[:2],
"comment": "Create selected issues before the defense.",
},
resume_outcome="submitted",
)
assert resumed["status"] == "completed"
assert resumed["outcome"] == "completed"
assert resumed["output"]["approved"] is True
assert resumed["output"]["created_issues"]
assert resumed["output"]["markdown"].startswith(
"# lda.chat Thesis And Project Readiness Report"
)
@pytest.mark.asyncio
async def test_lda_report_workflow_cancelled_resume_path(tmp_path: Path) -> None:
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
config.server.store.root = tmp_path / "store"
server = build_workflow_server_from_workflow_config(config)
plan = json.loads((EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8"))
await server.api.create_artifact_from_plan(
artifact_id="lda_report_cancel_case",
version=1,
title="lda.chat Report Cancel Case",
plan=plan,
outcomes=["completed", "cancelled"],
source_bindings={
"local.lda_docs": "local.lda_docs",
"local.lda_report": "local.lda_report",
"local.issue_board": "local.issue_board",
},
)
await server.api.save_deployment(
{
"id": "lda_report_cancel_case.default",
"artifact_id": "lda_report_cancel_case",
"artifact_version": 1,
"bindings": {
"local.lda_docs": "local.lda_docs",
"local.lda_report": "local.lda_report",
"local.issue_board": "local.issue_board",
},
}
)
run_input = json.loads((EXAMPLE_DIR / "run-input.json").read_text(encoding="utf-8"))
run_input["board_path"] = str(tmp_path / "issue-board.json")
started = await server.api.run_deployment(
deployment_id="lda_report_cancel_case.default",
workflow_input=run_input,
)
resumed = await server.api.resume_run(
run_id=started["run_id"],
resume_payload={
"approved": False,
"selected_issue_ids": [],
"comment": "Revise risk wording.",
},
resume_outcome="cancelled",
)
assert resumed["status"] == "completed"
assert resumed["outcome"] == "cancelled"
assert resumed["output"]["approved"] is False
assert "Revision Requested" in resumed["output"]["markdown"]