Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8fae26a
feat(agent-service): add workflow auto-document agent
zyratlo May 15, 2026
00588fb
feat(agent-service): enrich workflow documentation with operator meta…
zyratlo May 15, 2026
28b64b7
fix(agent-service): guard comment box comments against non-string values
zyratlo May 15, 2026
c964050
feat(frontend): cache workflow docs and add Re-generate button
zyratlo May 15, 2026
ef7063c
style(frontend): distinguish doc modal action buttons
zyratlo May 15, 2026
a97dd14
feat(frontend): add intro screen to workflow doc modal
zyratlo May 15, 2026
bd12ea9
feat: click operator names in workflow doc to jump on canvas
zyratlo May 15, 2026
c065ab1
fix(frontend): hide doc actions during first-time generation
zyratlo May 15, 2026
c7f8b1e
refactor(frontend): host workflow doc in a right-side drawer
zyratlo May 15, 2026
18b1d3d
feat(frontend): show elapsed-time stopwatch while doc is generating
zyratlo May 15, 2026
f8536e6
feat(frontend): keep history of generated workflow docs
zyratlo May 15, 2026
5d19e9f
refactor(frontend): drop in-place Re-generate button in doc view
zyratlo May 15, 2026
523e002
feat(frontend): add cancel button while doc is generating
zyratlo May 15, 2026
6ebe66b
feat(frontend): delete entries from workflow doc history
zyratlo May 15, 2026
254e4b1
fix(frontend): prevent opening duplicate workflow doc drawers
zyratlo May 15, 2026
b7ac7f9
fix(frontend): keep doc-generation stopwatch accurate on hidden tabs
zyratlo May 15, 2026
164991a
feat(frontend): compare two workflow doc reports side-by-side
zyratlo May 15, 2026
2da353f
feat(frontend): edit and save workflow doc reports
zyratlo May 15, 2026
202da8d
feat(frontend): write workflow doc from scratch
zyratlo May 15, 2026
adb98d6
feat(frontend): distinguish written vs generated entries in history list
zyratlo May 15, 2026
e90c265
style(frontend): color the doc timestamp by source
zyratlo May 15, 2026
106431e
feat(frontend): persist workflow doc state to localStorage
zyratlo May 15, 2026
9d1e703
fix(frontend): return to intro after cancelling doc generation
zyratlo May 15, 2026
f1d70bb
fix(frontend): close doc drawer when leaving workflow canvas
zyratlo May 15, 2026
4cf8c75
fix(frontend): reset doc panel to intro when leaving workflow
zyratlo May 15, 2026
9221625
feat(frontend): duplicate report from the history list
zyratlo May 15, 2026
93c9a18
feat(frontend): rename reports from history list and doc view
zyratlo May 15, 2026
82939ac
style(frontend): scroll history list internally with a stronger border
zyratlo May 16, 2026
ec3ef4b
feat(frontend): move edited reports to the top of history
zyratlo May 16, 2026
16fc410
style(frontend): slide animation between intro and doc views
zyratlo May 16, 2026
542c6a0
feat(frontend): download report as Markdown file
zyratlo May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions agent-service/src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,41 @@ export function buildSystemPrompt(metadataStore: WorkflowSystemMetadata, allowed
const base = SYSTEM_PROMPT_TEMPLATE.replace("{{OPERATOR_SCHEMA}}", operatorSchemas);
return extraSections.length > 0 ? `${base}\n${extraSections.join("\n\n")}\n` : base;
}

export const WORKFLOW_DOCUMENTATION_PROMPT = `You are a technical documentation assistant for Texera, a visual dataflow platform for collaborative data science.

You will be given a structured description of a Texera workflow containing:
- Each operator's type, display name, human-readable description, configuration properties, and — for Python/R UDF operators — the full user-authored code
- Output port schemas (column names and types) derived from static compilation, where available
- Data flow links describing which operators connect to which
- Author notes from comment boxes placed on the canvas

Using this information, generate structured markdown documentation with the following sections:

## Purpose
One to two sentences describing the overall goal of the workflow. Draw on operator descriptions, UDF code logic, property values, and author notes to state what the pipeline actually does, not just what kinds of operators it contains.

## Inputs
List each source operator with its data source — file path, table name, database query, or API endpoint — as found in the operator properties. Include column names from the output schema if available.

## Pipeline Stages
Group the operators into logical processing stages (e.g., ingestion, filtering, transformation, aggregation, output). For each stage, name the operators involved and describe in one sentence what transformation or analysis they perform. Use schema information to describe what columns are produced or consumed.

## Outputs
List each sink operator and what it produces or where it writes, including output column names where the schema is available.

## Caveats
Note anything a reviewer should be aware of: unconfigured required properties, hardcoded file paths or credentials visible in properties, UDF operators whose correctness depends on user code, disabled operators that are excluded from execution, or columns referenced in one operator that do not appear in the upstream schema. Omit this section if there are no notable caveats.

Operator references:
- Whenever you mention a specific operator by name in any section, format the mention as a markdown link with the operator's display name as the link text and a special URI of the form \`texera:op:<OPERATOR_ID>\` as the link target. The OPERATOR_ID is the exact value shown after \`### Operator\` in the context (e.g., \`Filter-operator-abc123\`).
- Example: if an operator has display name "Customer Filter" and ID \`Filter-operator-9f3a\`, write it as \`[Customer Filter](texera:op:Filter-operator-9f3a)\` — not as a plain name.
- Use the link form everywhere the operator is named, including in the Inputs, Pipeline Stages, Outputs, and Caveats sections.
- If you must group several operators together (e.g., "the three filter operators"), still emit each one as a link.

Rules:
- Write concise, precise technical prose. Prefer specific column names, file paths, and operator names over vague descriptions.
- If you cannot infer something with reasonable confidence, say so briefly rather than guessing.
- Do not reproduce raw property dictionaries in the output. Operator IDs may appear only inside \`texera:op:\` link targets, never as plain text.
- If the workflow is empty or has no connected operators, state that it contains no connected pipeline.`;

128 changes: 127 additions & 1 deletion agent-service/src/agent/texera-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
OperatorResultSerializationMode,
INITIAL_STEP_ID,
} from "../types/agent";
import { buildSystemPrompt } from "./prompts";
import { buildSystemPrompt, WORKFLOW_DOCUMENTATION_PROMPT } from "./prompts";
import {
createAddOperatorTool,
createModifyOperatorTool,
Expand All @@ -41,6 +41,7 @@ import {
TOOL_NAME_DELETE_OPERATOR,
type ToolContext,
} from "./tools/workflow-crud-tools";
import type { WorkflowContent, LogicalPlan } from "../types/workflow";
import {
createExecuteOperatorTool,
executeOperatorAndFormat,
Expand Down Expand Up @@ -823,6 +824,131 @@ export class TexeraAgent {
return relevantSteps;
}

async documentWorkflow(workflowContent?: WorkflowContent): Promise<string> {
const content = workflowContent ?? this.workflowState.getWorkflowContent();
const context = await this.buildDocumentationContext(content);
const { text } = await generateText({
model: this.model,
system: WORKFLOW_DOCUMENTATION_PROMPT,
prompt: context,
});
return text;
}

private async buildDocumentationContext(content: WorkflowContent): Promise<string> {
const UDF_CODE_KEYS = new Set(["code", "script"]);
const UDF_TYPES = new Set(["PythonUDFV2", "RUDF"]);

// Attempt compilation to get port schemas; fail gracefully.
let outputSchemas: Record<string, Record<string, readonly { attributeName: string; attributeType: string }[] | undefined>> = {};
const enabledOps = content.operators.filter(op => !op.isDisabled);
if (enabledOps.length > 0) {
try {
const logicalPlan: LogicalPlan = {
operators: enabledOps.map(op => ({
operatorID: op.operatorID,
operatorType: op.operatorType,
...op.operatorProperties,
inputPorts: op.inputPorts,
outputPorts: op.outputPorts,
})),
links: content.links
.filter(l => enabledOps.some(o => o.operatorID === l.source.operatorID) &&
enabledOps.some(o => o.operatorID === l.target.operatorID))
.map(l => ({
fromOpId: l.source.operatorID,
fromPortId: { id: parseInt(l.source.portID) || 0, internal: false },
toOpId: l.target.operatorID,
toPortId: { id: parseInt(l.target.portID) || 0, internal: false },
})),
};
const compiled = await compileWorkflowAsync(logicalPlan);
if (compiled) {
outputSchemas = compiled.operatorOutputSchemas as typeof outputSchemas;
}
} catch {
// schema enrichment is best-effort
}
}

const opDisplayName = (op: WorkflowContent["operators"][number]) =>
op.customDisplayName || op.operatorType;

const lines: string[] = [];

// Operators
lines.push("## Operators\n");
for (const op of content.operators) {
const label = opDisplayName(op);
const desc = this.metadataStore.getDescription(op.operatorType);
const disabled = op.isDisabled ? " [DISABLED — excluded from execution]" : "";

lines.push(`### Operator \`${op.operatorID}\` — "${label}" (${op.operatorType})${disabled}`);
if (desc) lines.push(`Description: ${desc}`);

const props = op.operatorProperties ?? {};
const isUDF = UDF_TYPES.has(op.operatorType);

if (isUDF) {
for (const key of UDF_CODE_KEYS) {
const code = props[key];
if (typeof code === "string" && code.trim()) {
lines.push(`${key}:\n\`\`\`python\n${code.trim()}\n\`\`\``);
}
}
const otherEntries = Object.entries(props).filter(([k, v]) => !UDF_CODE_KEYS.has(k) && v !== undefined && v !== null && v !== "");
if (otherEntries.length > 0) {
lines.push("Other properties: " + otherEntries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", "));
}
} else {
const entries = Object.entries(props).filter(([, v]) => v !== undefined && v !== null && v !== "");
if (entries.length > 0) {
lines.push("Properties:");
for (const [k, v] of entries) {
lines.push(` - ${k}: ${JSON.stringify(v)}`);
}
}
}

// Output schemas from compilation
const opSchemas = outputSchemas[op.operatorID];
if (opSchemas) {
for (const [portId, schema] of Object.entries(opSchemas)) {
if (schema && schema.length > 0) {
const cols = schema.map(a => `${a.attributeName}: ${a.attributeType}`).join(", ");
lines.push(`Output port ${portId} schema: [${cols}]`);
}
}
}

lines.push("");
}

// Links
if (content.links.length > 0) {
const opMap = new Map(content.operators.map(op => [op.operatorID, opDisplayName(op)]));
lines.push("## Data Flow\n");
for (const link of content.links) {
const fromName = opMap.get(link.source.operatorID) ?? link.source.operatorID;
const toName = opMap.get(link.target.operatorID) ?? link.target.operatorID;
lines.push(`- "${fromName}" \`${link.source.operatorID}\` → "${toName}" \`${link.target.operatorID}\``);
}
lines.push("");
}

// Comment boxes (author notes)
const notes = (content.commentBoxes ?? []).filter(b => typeof b.comments === "string" && b.comments.trim());
if (notes.length > 0) {
lines.push("## Author Notes\n");
for (const box of notes) {
lines.push(`- ${(box.comments as string).trim()}`);
}
lines.push("");
}

return lines.join("\n");
}

destroy(): void {
if (this.workflowChangeSubscription) {
this.workflowChangeSubscription.unsubscribe();
Expand Down
25 changes: 25 additions & 0 deletions agent-service/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,31 @@ describe("Agent control routes", () => {
});
});

describe(`POST ${API}/agents/:id/document-workflow`, () => {
test("returns 404 for an unknown agent", async () => {
const res = await postJson(`${API}/agents/agent-does-not-exist/document-workflow`, {});
expect(res.status).toBe(404);
const body = await readJson<{ error: string }>(res);
expect(body.error).toBe("Agent not found");
});

test("accepts an empty body without schema error", async () => {
const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" }));
const res = await postJson(`${API}/agents/${created.id}/document-workflow`, {});
// In the test environment the LLM call will fail (no real model backend).
// We accept any non-schema-validation failure (4xx/5xx) as long as it is not a 422.
expect(res.status).not.toBe(422);
});

test("accepts a workflowContent body without schema error", async () => {
const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" }));
const res = await postJson(`${API}/agents/${created.id}/document-workflow`, {
workflowContent: { operators: [], links: [], operatorPositions: {}, commentBoxes: [], settings: { dataTransferBatchSize: 10 } },
});
expect(res.status).not.toBe(422);
});
});

describe(`PATCH ${API}/agents/:id/settings`, () => {
test("updates settings and returns the new values", async () => {
const created = await readJson<{ id: string }>(await postJson(`${API}/agents`, { modelType: "m" }));
Expand Down
17 changes: 17 additions & 0 deletions agent-service/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,23 @@ const agentsRouter = new Elysia({ prefix: "/agents" })
return { status: "stopping" };
})

.post(
"/:id/document-workflow",
async ({ params: { id }, body }) => {
const agent = getAgent(id);
const { workflowContent } = (body ?? {}) as { workflowContent?: any };
const markdown = await agent.documentWorkflow(workflowContent);
return { markdown };
},
{
body: t.Optional(
t.Object({
workflowContent: t.Optional(t.Any()),
})
),
}
)

.post("/:id/clear", ({ params: { id } }) => {
const agent = getAgent(id);
agent.clearHistory();
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"concaveman": "2.0.0",
"d3-shape": "2.1.0",
"dagre": "0.8.5",
"diff": "^9.0.0",
"file-saver": "2.0.5",
"fuse.js": "6.5.3",
"html2canvas": "1.4.1",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import { NzResizableModule } from "ng-zorro-antd/resizable";
import { WorkflowRuntimeStatisticsComponent } from "./dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-runtime-statistics/workflow-runtime-statistics.component";
import { TimeTravelComponent } from "./workspace/component/left-panel/time-travel/time-travel.component";
import { NzModalModule } from "ng-zorro-antd/modal";
import { NzDrawerModule } from "ng-zorro-antd/drawer";
import { NzDescriptionsModule } from "ng-zorro-antd/descriptions";
import { OverlayModule } from "@angular/cdk/overlay";
import { HighlightSearchTermsPipe } from "./dashboard/component/user/user-workflow/user-workflow-list-item/highlight-search-terms.pipe";
Expand Down Expand Up @@ -232,6 +233,7 @@ registerLocaleData(en);
NzUploadModule,
NgxJsonViewerModule,
NzModalModule,
NzDrawerModule,
NzDescriptionsModule,
NzCardModule,
NzTagModule,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@
nz-icon
nzType="info-circle"></i>
</button>
<button
(click)="onClickDocumentWorkflow()"
[disabled]="isWorkflowEmpty"
nz-button
title="document workflow with AI">
<i
nz-icon
nzType="file-text"></i>
</button>
</nz-space-compact>
<ng-template #utilities>
<nz-space-compact>
Expand Down
48 changes: 47 additions & 1 deletion frontend/src/app/workspace/component/menu/menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ import { CoeditorPresenceService } from "../../service/workflow-graph/model/coed
import { firstValueFrom, of, Subscription, timer } from "rxjs";
import { isDefined } from "../../../common/util/predicate";
import { NzModalService } from "ng-zorro-antd/modal";
import { NzDrawerRef, NzDrawerService } from "ng-zorro-antd/drawer";
import { ResultExportationComponent } from "../result-exportation/result-exportation.component";
import { ReportGenerationService } from "../../service/report-generation/report-generation.service";
import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component";
import { PanelService } from "../../service/panel/panel.service";
import { DocEditingState, DocEntry, WorkflowDocService } from "../../service/workflow-doc/workflow-doc.service";
import { WorkflowDocPanelComponent } from "../workflow-doc-panel/workflow-doc-panel.component";
import { DASHBOARD_USER_WORKFLOW } from "../../../app-routing.constant";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
Expand Down Expand Up @@ -185,11 +188,13 @@ export class MenuComponent implements OnInit, OnDestroy {
public operatorMenu: OperatorMenuService,
public coeditorPresenceService: CoeditorPresenceService,
private modalService: NzModalService,
private drawerService: NzDrawerService,
private reportGenerationService: ReportGenerationService,
private panelService: PanelService,
private computingUnitStatusService: ComputingUnitStatusService,
protected config: GuiConfigService,
private router: Router
private router: Router,
private workflowDocService: WorkflowDocService
) {
workflowWebsocketService
.subscribeToEvent("ExecutionDurationUpdateEvent")
Expand Down Expand Up @@ -255,6 +260,8 @@ export class MenuComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.workflowResultExportService.resetFlags();
this.computingUnitStatusSubscription.unsubscribe();
this.docDrawerRef?.close();
this.workflowDocService.setLastView(this.workflowId, "intro");
}

private subscribeToComputingUnitSelection(): void {
Expand Down Expand Up @@ -690,6 +697,45 @@ export class MenuComponent implements OnInit, OnDestroy {
});
}

private docDrawerRef: NzDrawerRef | null = null;

public onClickDocumentWorkflow(): void {
if (this.docDrawerRef) return;
const wid = this.workflowId;
const history = this.workflowDocService.getHistory(wid);
const lastView = this.workflowDocService.getLastView(wid);
const editingState = this.workflowDocService.getEditingState(wid);
const hasDraftOrViewable = !!editingState || (lastView === "doc" && history.length > 0);
const initialView = hasDraftOrViewable ? "doc" : "intro";
this.docDrawerRef = this.drawerService.create({
nzTitle: "Workflow Documentation",
nzContent: WorkflowDocPanelComponent,
nzData: {
history,
initialView,
onGenerate: () => this.workflowDocService.generateDocumentation(),
onViewChange: (view: "intro" | "doc") => this.workflowDocService.setLastView(wid, view),
onDeleteEntry: (entry: DocEntry) => this.workflowDocService.deleteHistoryEntry(wid, entry),
onUpdateEntry: (entry: DocEntry, newMarkdown: string) =>
this.workflowDocService.updateHistoryEntry(wid, entry, newMarkdown),
onCreateEntry: (markdown: string) => this.workflowDocService.createBlankEntry(wid, markdown),
onDuplicateEntry: (entry: DocEntry) => this.workflowDocService.duplicateEntry(wid, entry),
onRenameEntry: (entry: DocEntry, newTitle: string) =>
this.workflowDocService.renameEntry(wid, entry, newTitle),
editingState,
onEditingChange: (state: DocEditingState | null) =>
this.workflowDocService.setEditingState(wid, state),
},
nzWidth: 520,
nzPlacement: "right",
nzMask: false,
nzClosable: true,
});
this.docDrawerRef.afterClose.pipe(untilDestroyed(this)).subscribe(() => {
this.docDrawerRef = null;
});
}

/**
* Returns true if there's any operator on the graph; false otherwise
*/
Expand Down
Loading
Loading