agent-workflow-ui 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_workflow_ui/SKILL.md +258 -0
- agent_workflow_ui/__init__.py +2 -0
- agent_workflow_ui/__main__.py +64 -0
- agent_workflow_ui/browser.py +70 -0
- agent_workflow_ui/config.py +74 -0
- agent_workflow_ui/http_endpoint.py +412 -0
- agent_workflow_ui/opencode_config.py +475 -0
- agent_workflow_ui/render/__init__.py +2 -0
- agent_workflow_ui/render/default_templates/ack.html.j2 +39 -0
- agent_workflow_ui/render/default_templates/increment-planning.html.j2 +254 -0
- agent_workflow_ui/render/default_templates/project-setup.html.j2 +851 -0
- agent_workflow_ui/render/engine.py +71 -0
- agent_workflow_ui/render/frontmatter.py +56 -0
- agent_workflow_ui/roles_processor.py +237 -0
- agent_workflow_ui/server.py +66 -0
- agent_workflow_ui/skill_installer.py +72 -0
- agent_workflow_ui/state.py +318 -0
- agent_workflow_ui/tools/__init__.py +1 -0
- agent_workflow_ui/tools/awf.py +1112 -0
- agent_workflow_ui/tools/forms.py +440 -0
- agent_workflow_ui/tools/templates.py +57 -0
- agent_workflow_ui-1.0.0.dist-info/METADATA +64 -0
- agent_workflow_ui-1.0.0.dist-info/RECORD +26 -0
- agent_workflow_ui-1.0.0.dist-info/WHEEL +5 -0
- agent_workflow_ui-1.0.0.dist-info/entry_points.txt +2 -0
- agent_workflow_ui-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Localhost HTTP server for accepting form submits.
|
|
2
|
+
|
|
3
|
+
Runs in a daemon thread, started at plugin startup. Listens on 127.0.0.1 only.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .state import FormRegistry
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
MAX_BODY_BYTES = 1 * 1024 * 1024 # 1MB
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_free_port() -> int:
|
|
28
|
+
"""Ask OS for a free port by binding to port 0."""
|
|
29
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
30
|
+
s.bind(("127.0.0.1", 0))
|
|
31
|
+
return s.getsockname()[1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
|
35
|
+
"""Write YAML atomically (temp file + rename)."""
|
|
36
|
+
import yaml
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
tmp = path.with_suffix(f".{uuid4().hex}.tmp")
|
|
39
|
+
content = yaml.safe_dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
40
|
+
tmp.write_text(content, encoding="utf-8")
|
|
41
|
+
tmp.replace(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_valid_form_id(form_id: str) -> bool:
|
|
45
|
+
"""Check if form_id looks valid (FORM-* prefix, reasonable length).
|
|
46
|
+
|
|
47
|
+
Defense-in-depth against path traversal: form_id builds the submit file
|
|
48
|
+
path (``inputs_dir / f"{form_id}.yaml"``). The registry lookup is the
|
|
49
|
+
primary guard (IDs are server-generated), but reject path separators and
|
|
50
|
+
``..`` here too so a crafted ID can never escape inputs_dir even if a
|
|
51
|
+
future code path bypasses the registry check.
|
|
52
|
+
"""
|
|
53
|
+
if not form_id.startswith("FORM-") or not (6 < len(form_id) < 100):
|
|
54
|
+
return False
|
|
55
|
+
# P1: ASCII-only (prevents unicode filenames in inputs_dir)
|
|
56
|
+
if not form_id.isascii():
|
|
57
|
+
return False
|
|
58
|
+
# Legit IDs are FORM-<alphanumeric/hyphen> only.
|
|
59
|
+
if "/" in form_id or "\\" in form_id or ".." in form_id:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_origin_allowed(origin: str, referer: str) -> bool:
|
|
65
|
+
"""A2/KAUD-3: CSRF check — is this Origin/Referer combination allowed?
|
|
66
|
+
|
|
67
|
+
Uses urlparse to check hostname, not startswith (which allowed bypass
|
|
68
|
+
via http://127.0.0.1.evil.com).
|
|
69
|
+
|
|
70
|
+
Forms opened via file:// (opencode temp HTML) send Origin: "null"
|
|
71
|
+
(browser standard for sandboxed/local file origins).
|
|
72
|
+
"""
|
|
73
|
+
from urllib.parse import urlparse
|
|
74
|
+
|
|
75
|
+
ALLOWED_HOSTS = {"127.0.0.1", "localhost"}
|
|
76
|
+
|
|
77
|
+
def _check_url(url: str) -> bool:
|
|
78
|
+
if not url:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
parsed = urlparse(url)
|
|
82
|
+
return parsed.hostname in ALLOWED_HOSTS
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Origin "null" — local file:// page or sandboxed iframe. Server is
|
|
87
|
+
# 127.0.0.1-only, so this is always safe.
|
|
88
|
+
if origin == "null":
|
|
89
|
+
return True
|
|
90
|
+
if origin:
|
|
91
|
+
return _check_url(origin)
|
|
92
|
+
# No Origin header — check Referer if present (curl/non-browser has neither)
|
|
93
|
+
if referer:
|
|
94
|
+
if referer.startswith("file://"):
|
|
95
|
+
return True
|
|
96
|
+
return _check_url(referer)
|
|
97
|
+
return True # no Origin, no Referer — backward compat for curl
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ack_page(form_id: str, already_submitted: bool) -> str:
|
|
101
|
+
"""Generate HTML acknowledgement page shown after submit.
|
|
102
|
+
|
|
103
|
+
A3: rendered from render/default_templates/ack.html.j2 (was inline f-string).
|
|
104
|
+
"""
|
|
105
|
+
from .render.engine import render_template
|
|
106
|
+
from .state import get_jinja_env
|
|
107
|
+
|
|
108
|
+
message = "Эта форма уже была отправлена ранее." if already_submitted else "Форма отправлена!"
|
|
109
|
+
try:
|
|
110
|
+
env = get_jinja_env()
|
|
111
|
+
return render_template(env, "ack", {
|
|
112
|
+
"form_id": form_id,
|
|
113
|
+
"message": message,
|
|
114
|
+
})
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# Fallback: minimal HTML if template engine fails
|
|
117
|
+
return (
|
|
118
|
+
f"<!DOCTYPE html><html><body>"
|
|
119
|
+
f"<h1>{html.escape(message)}</h1>"
|
|
120
|
+
f"<p>ID: {html.escape(form_id)}</p>"
|
|
121
|
+
f"<!-- template render failed: {html.escape(str(e))} -->"
|
|
122
|
+
f"</body></html>"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SubmitHandler(BaseHTTPRequestHandler):
|
|
127
|
+
"""HTTP request handler for /submit/<form_id> and /health."""
|
|
128
|
+
|
|
129
|
+
# Injected via handler_class factory (see _make_handler_class)
|
|
130
|
+
inputs_dir: Path = None # type: ignore[assignment]
|
|
131
|
+
registry: FormRegistry = None # type: ignore[assignment]
|
|
132
|
+
|
|
133
|
+
def do_POST(self):
|
|
134
|
+
"""Handle POST /submit/<form_id>."""
|
|
135
|
+
path = urllib.parse.urlparse(self.path).path
|
|
136
|
+
if not path.startswith("/submit/"):
|
|
137
|
+
self._send_text(404, "Not Found")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
form_id = path[len("/submit/"):]
|
|
141
|
+
if not _is_valid_form_id(form_id):
|
|
142
|
+
self._send_text(404, f"Invalid form_id: {html.escape(form_id)}")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
record = self.registry.get(form_id)
|
|
146
|
+
if record is None:
|
|
147
|
+
self._send_text(404, f"Form not found: {html.escape(form_id)}")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if record.status == "submitted":
|
|
151
|
+
self._send_html(200, _ack_page(form_id, already_submitted=True))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if record.status == "cancelled":
|
|
155
|
+
self._send_text(410, f"Form {form_id} was cancelled.")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# A2: CSRF protection — verify Origin/Referer is localhost or local file.
|
|
159
|
+
origin = self.headers.get("Origin", "")
|
|
160
|
+
referer = self.headers.get("Referer", "")
|
|
161
|
+
if not _is_origin_allowed(origin, referer):
|
|
162
|
+
self._send_text(403, f"Forbidden: Origin '{origin}' not allowed")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
self._send_text(400, "Invalid Content-Length header")
|
|
169
|
+
return
|
|
170
|
+
if content_length > MAX_BODY_BYTES:
|
|
171
|
+
# Drain request body before responding, otherwise client gets
|
|
172
|
+
# BrokenPipeError when server closes connection mid-write.
|
|
173
|
+
remaining = content_length
|
|
174
|
+
while remaining > 0:
|
|
175
|
+
chunk = self.rfile.read(min(remaining, 65536))
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
remaining -= len(chunk)
|
|
179
|
+
self._send_text(413, "Payload Too Large (max 1MB)")
|
|
180
|
+
return
|
|
181
|
+
if content_length == 0:
|
|
182
|
+
self._send_text(400, "Empty body")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
|
186
|
+
parsed = urllib.parse.parse_qs(body, keep_blank_values=True)
|
|
187
|
+
data: dict[str, Any] = {}
|
|
188
|
+
for key, values in parsed.items():
|
|
189
|
+
if len(values) == 1:
|
|
190
|
+
data[key] = values[0]
|
|
191
|
+
else:
|
|
192
|
+
data[key] = values
|
|
193
|
+
|
|
194
|
+
payload = {
|
|
195
|
+
"form_id": form_id,
|
|
196
|
+
"template": record.template,
|
|
197
|
+
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
|
198
|
+
"data": data,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
inputs_dir = (record.project_dir / ".agentic" / "inputs") if record.project_dir else self.inputs_dir
|
|
202
|
+
inputs_dir.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
target = inputs_dir / f"{form_id}.yaml"
|
|
204
|
+
# H4 fix: atomic check-and-set — claim the form before writing.
|
|
205
|
+
# Without this, two parallel POSTs could both pass the status check.
|
|
206
|
+
if not self.registry.claim_for_submit(form_id):
|
|
207
|
+
self._send_text(409, f"Form {form_id} is being submitted by another request or already processed.")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# QA-FIX: if _atomic_write_yaml fails (disk full, permission, etc.),
|
|
211
|
+
# rollback form status to "pending" — otherwise it stays stuck in
|
|
212
|
+
# "submitting" forever and user can't resubmit (claim_for_submit
|
|
213
|
+
# rejects anything != "pending"). finalize_submit below only runs
|
|
214
|
+
# on success.
|
|
215
|
+
try:
|
|
216
|
+
_atomic_write_yaml(target, payload)
|
|
217
|
+
import os as _os
|
|
218
|
+
_os.chmod(target, 0o600)
|
|
219
|
+
except OSError as e:
|
|
220
|
+
self.registry.update_status(form_id, "pending")
|
|
221
|
+
log.error("Failed to write submit file %s: %s", target, e)
|
|
222
|
+
self._send_text(500, "Server error: failed to persist submit. Please retry.")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# H4 fix: finalize submit status
|
|
226
|
+
self.registry.finalize_submit(form_id)
|
|
227
|
+
|
|
228
|
+
# Persist custom roles if requested (delegates to roles_processor)
|
|
229
|
+
# QA-D: only project-setup submit has role fields. Other templates
|
|
230
|
+
# (increment-planning, etc.) called process_role_saves/deletions as
|
|
231
|
+
# no-op but wasted cycles + log noise. Gate explicitly.
|
|
232
|
+
from .roles_processor import process_role_deletions, process_role_saves
|
|
233
|
+
|
|
234
|
+
if record.template == "project-setup":
|
|
235
|
+
process_role_saves(data, project_dir=record.project_dir)
|
|
236
|
+
process_role_deletions(data, project_dir=record.project_dir)
|
|
237
|
+
|
|
238
|
+
# Dogfood-7: increment-planning submit → persist via api.apply_increment_plan
|
|
239
|
+
if record.template == "increment-planning" and record.project_dir:
|
|
240
|
+
selected_variant_id = (data.get("selected_variant", "") or "").strip()
|
|
241
|
+
variants_json = data.get("variants_json", "") or "[]"
|
|
242
|
+
if selected_variant_id and selected_variant_id != "__reject__":
|
|
243
|
+
try:
|
|
244
|
+
import json as _json
|
|
245
|
+
variants = _json.loads(variants_json) if isinstance(variants_json, str) else variants_json
|
|
246
|
+
selected = next(
|
|
247
|
+
(v for v in variants if isinstance(v, dict) and v.get("id") == selected_variant_id),
|
|
248
|
+
None,
|
|
249
|
+
)
|
|
250
|
+
if selected:
|
|
251
|
+
# Build plan.md body from selected variant
|
|
252
|
+
plan_body = _build_plan_md_from_variant(selected, variants)
|
|
253
|
+
from awf.api import apply_increment_plan
|
|
254
|
+
apply_increment_plan(
|
|
255
|
+
record.project_dir,
|
|
256
|
+
plan_body,
|
|
257
|
+
selected_variant_id=selected_variant_id,
|
|
258
|
+
variants=variants,
|
|
259
|
+
)
|
|
260
|
+
log.info(
|
|
261
|
+
"Increment plan persisted: variant=%s → plan.md",
|
|
262
|
+
selected_variant_id,
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
log.error("apply_increment_plan failed: %s", e)
|
|
266
|
+
|
|
267
|
+
log.info("Submit received for %s, written to %s", form_id, target)
|
|
268
|
+
|
|
269
|
+
self._send_html(200, _ack_page(form_id, already_submitted=False))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def do_GET(self):
|
|
273
|
+
"""Handle GET /health."""
|
|
274
|
+
path = urllib.parse.urlparse(self.path).path
|
|
275
|
+
if path == "/health":
|
|
276
|
+
self._send_json(200, {"status": "ok"})
|
|
277
|
+
return
|
|
278
|
+
self._send_text(404, "Not Found")
|
|
279
|
+
|
|
280
|
+
# Silence default logging to stdout (would corrupt MCP stdio protocol if it leaked)
|
|
281
|
+
def log_message(self, format, *args):
|
|
282
|
+
log.debug("HTTP %s - %s", self.address_string(), format % args)
|
|
283
|
+
|
|
284
|
+
# Helpers
|
|
285
|
+
def _send_html(self, code: int, body: str) -> None:
|
|
286
|
+
body_bytes = body.encode("utf-8")
|
|
287
|
+
self.send_response(code)
|
|
288
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
289
|
+
self.send_header("Content-Length", str(len(body_bytes)))
|
|
290
|
+
self.end_headers()
|
|
291
|
+
self.wfile.write(body_bytes)
|
|
292
|
+
|
|
293
|
+
def _send_text(self, code: int, body: str) -> None:
|
|
294
|
+
body_bytes = body.encode("utf-8")
|
|
295
|
+
self.send_response(code)
|
|
296
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
297
|
+
self.send_header("Content-Length", str(len(body_bytes)))
|
|
298
|
+
self.end_headers()
|
|
299
|
+
self.wfile.write(body_bytes)
|
|
300
|
+
|
|
301
|
+
def _send_json(self, code: int, data: dict) -> None:
|
|
302
|
+
body = json.dumps(data).encode("utf-8")
|
|
303
|
+
self.send_response(code)
|
|
304
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
305
|
+
self.send_header("Content-Length", str(len(body)))
|
|
306
|
+
self.end_headers()
|
|
307
|
+
self.wfile.write(body)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _make_handler_class(inputs_dir: Path, registry: FormRegistry):
|
|
311
|
+
"""Create a handler class with bound inputs_dir and registry."""
|
|
312
|
+
cls = type(
|
|
313
|
+
"BoundSubmitHandler",
|
|
314
|
+
(SubmitHandler,),
|
|
315
|
+
{"inputs_dir": inputs_dir, "registry": registry},
|
|
316
|
+
)
|
|
317
|
+
return cls
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def start_http_server(config: Config, registry: FormRegistry) -> tuple[ThreadingHTTPServer, int]:
|
|
321
|
+
"""Start HTTP server in background thread.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
config: Plugin config (provides port, inputs_dir).
|
|
325
|
+
registry: Form registry (for form_id validation).
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Tuple (server, actual_port). actual_port == config.http_port if non-zero, else auto-selected.
|
|
329
|
+
"""
|
|
330
|
+
port = config.http_port if config.http_port > 0 else _find_free_port()
|
|
331
|
+
handler_class = _make_handler_class(config.inputs_dir, registry)
|
|
332
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), handler_class)
|
|
333
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True, name="awf-ui-http")
|
|
334
|
+
thread.start()
|
|
335
|
+
log.info("HTTP server listening on http://127.0.0.1:%d", port)
|
|
336
|
+
return server, port
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _build_plan_md_from_variant(
|
|
341
|
+
selected: dict[str, Any],
|
|
342
|
+
all_variants: list[dict[str, Any]],
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Build plan.md body from user-selected increment variant.
|
|
345
|
+
|
|
346
|
+
Supervisor's variant carries the decomposition. We render it as
|
|
347
|
+
markdown so plan.md is human-readable and supervisor can edit later.
|
|
348
|
+
"""
|
|
349
|
+
title = selected.get("title", "Untitled plan")
|
|
350
|
+
strategy = selected.get("strategy", "")
|
|
351
|
+
description = selected.get("description", "")
|
|
352
|
+
increments = selected.get("increments", []) or []
|
|
353
|
+
pros = selected.get("pros", []) or []
|
|
354
|
+
cons = selected.get("cons", []) or []
|
|
355
|
+
est_todos = selected.get("estimated_todos")
|
|
356
|
+
est_time = selected.get("estimated_time")
|
|
357
|
+
|
|
358
|
+
lines: list[str] = [f"# Plan: {title}", ""]
|
|
359
|
+
if strategy:
|
|
360
|
+
lines.append(f"**Strategy:** {strategy}")
|
|
361
|
+
if description:
|
|
362
|
+
lines.append("")
|
|
363
|
+
lines.append(description)
|
|
364
|
+
lines.append("")
|
|
365
|
+
if est_todos or est_time:
|
|
366
|
+
meta_parts = []
|
|
367
|
+
if est_todos:
|
|
368
|
+
meta_parts.append(f"~{est_todos} TODOs")
|
|
369
|
+
if est_time:
|
|
370
|
+
meta_parts.append(f"~{est_time}")
|
|
371
|
+
lines.append(f"_Estimated: {', '.join(meta_parts)}_")
|
|
372
|
+
lines.append("")
|
|
373
|
+
|
|
374
|
+
lines.append("## Increments")
|
|
375
|
+
lines.append("")
|
|
376
|
+
for i, inc in enumerate(increments, start=1):
|
|
377
|
+
name = inc.get("name", f"Increment {i}")
|
|
378
|
+
goal = inc.get("goal", "")
|
|
379
|
+
artefacts = inc.get("artefacts", []) or []
|
|
380
|
+
lines.append(f"### {i}. {name}")
|
|
381
|
+
if goal:
|
|
382
|
+
lines.append("")
|
|
383
|
+
lines.append(goal)
|
|
384
|
+
if artefacts:
|
|
385
|
+
lines.append("")
|
|
386
|
+
lines.append(f"**Artefacts:** {', '.join(artefacts)}")
|
|
387
|
+
lines.append("")
|
|
388
|
+
|
|
389
|
+
if pros or cons:
|
|
390
|
+
lines.append("## Trade-offs")
|
|
391
|
+
lines.append("")
|
|
392
|
+
if pros:
|
|
393
|
+
lines.append("**Pros:**")
|
|
394
|
+
for p in pros:
|
|
395
|
+
lines.append(f"- {p}")
|
|
396
|
+
lines.append("")
|
|
397
|
+
if cons:
|
|
398
|
+
lines.append("**Cons:**")
|
|
399
|
+
for c in cons:
|
|
400
|
+
lines.append(f"- {c}")
|
|
401
|
+
lines.append("")
|
|
402
|
+
|
|
403
|
+
# Other variants as historical reference
|
|
404
|
+
if len(all_variants) > 1:
|
|
405
|
+
other = [v for v in all_variants if v.get("id") != selected.get("id")]
|
|
406
|
+
lines.append("## Other variants considered")
|
|
407
|
+
lines.append("")
|
|
408
|
+
for v in other:
|
|
409
|
+
lines.append(f"- **{v.get('id', '?')}**: {v.get('title', 'untitled')}")
|
|
410
|
+
lines.append("")
|
|
411
|
+
|
|
412
|
+
return "\n".join(lines)
|