multi-agent-platform 0.1.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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""``map experiment ...`` sub-app — cli/main.py split.
|
|
2
|
+
|
|
3
|
+
Owns experiment lifecycle commands plus nested ``lock`` / ``review`` / ``plan``.
|
|
4
|
+
Command bodies lazy-import ``cli.main`` helpers to break the
|
|
5
|
+
``cli.main ↔ cli.commands.*`` import cycle.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
from map_client.client import MAPClient
|
|
16
|
+
from map_client.exceptions import MAPNotFoundError
|
|
17
|
+
from map_sdk.evidence import (
|
|
18
|
+
EVIDENCE_METADATA_KEYS,
|
|
19
|
+
metadata_has_completion_evidence,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from cli.table_render import enum_value, format_datetime, render_table, short_uuid, truncate
|
|
23
|
+
from server.domain.schemas import (
|
|
24
|
+
ExperimentComplete,
|
|
25
|
+
ExperimentCreate,
|
|
26
|
+
ExperimentLogCreate,
|
|
27
|
+
ExperimentResultDecision,
|
|
28
|
+
PlanInput,
|
|
29
|
+
PlanRevise,
|
|
30
|
+
ReviewCreate,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
experiment_app = typer.Typer(help="Experiment commands", rich_markup_mode=None)
|
|
34
|
+
lock_app = typer.Typer(help="Experiment execution lock commands (per-project).")
|
|
35
|
+
review_app = typer.Typer(help="Review commands")
|
|
36
|
+
plan_app = typer.Typer(help="Plan commands")
|
|
37
|
+
experiment_app.add_typer(lock_app, name="lock")
|
|
38
|
+
experiment_app.add_typer(review_app, name="review")
|
|
39
|
+
experiment_app.add_typer(plan_app, name="plan")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@experiment_app.command("create")
|
|
43
|
+
def experiment_create(
|
|
44
|
+
title: str = typer.Option(..., "--title"),
|
|
45
|
+
plan_file: Path = typer.Option(..., "--plan-file"),
|
|
46
|
+
project: uuid.UUID | None = typer.Option(None, "--project"),
|
|
47
|
+
project_key: str | None = typer.Option(None, "--project-key"),
|
|
48
|
+
description: str | None = typer.Option(None, "--description"),
|
|
49
|
+
submit_for_review: bool = typer.Option(False, "--submit-for-review"),
|
|
50
|
+
topic_id: uuid.UUID | None = typer.Option(None, "--topic-id"),
|
|
51
|
+
force_lint_bypass: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--force-lint-bypass",
|
|
54
|
+
help="Skip the local plan frontmatter lint pre-check (server still enforces it).",
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
from cli.main import _read_text_file, _resolve_project, _run # lazy: avoid cycle
|
|
58
|
+
content = _read_text_file(plan_file, kind="plan")
|
|
59
|
+
# Local plan frontmatter lint pre-check (a764abf6 I1.(c)). The server
|
|
60
|
+
# has its own hard validator (``assert_plan_frontmatter_ok``), but a
|
|
61
|
+
# local gate saves a round trip and gives a clearer error message
|
|
62
|
+
# when the author simply forgot the YAML block.
|
|
63
|
+
if not force_lint_bypass:
|
|
64
|
+
from server.services.plan_marker_service import validate_plan_frontmatter
|
|
65
|
+
|
|
66
|
+
result = validate_plan_frontmatter(content)
|
|
67
|
+
missing_fields = [
|
|
68
|
+
w.field for w in result.warnings if w.code == "PLAN_MARKER_MISSING_FIELD"
|
|
69
|
+
]
|
|
70
|
+
if missing_fields or any(
|
|
71
|
+
w.code == "PLAN_MARKER_FRONT_MATTER_MISSING"
|
|
72
|
+
for w in result.warnings
|
|
73
|
+
):
|
|
74
|
+
keys = ", ".join(sorted(set(missing_fields))) if missing_fields else "(no frontmatter)"
|
|
75
|
+
typer.echo(
|
|
76
|
+
f"Error: plan frontmatter lint failed ({keys}). "
|
|
77
|
+
f"Run `map experiment plan validate --plan-file {plan_file}` "
|
|
78
|
+
f"for details, or re-run with --force-lint-bypass to skip the local check.",
|
|
79
|
+
err=True,
|
|
80
|
+
)
|
|
81
|
+
raise typer.Exit(2)
|
|
82
|
+
|
|
83
|
+
payload = ExperimentCreate(
|
|
84
|
+
title=title,
|
|
85
|
+
description=description,
|
|
86
|
+
plan=PlanInput(content_md=content),
|
|
87
|
+
submit_for_review=submit_for_review,
|
|
88
|
+
topic_id=topic_id,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def action(c: MAPClient):
|
|
92
|
+
pid = _resolve_project(c, project, project_key)
|
|
93
|
+
return c.create_experiment(pid, payload)
|
|
94
|
+
|
|
95
|
+
_run(action)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render_experiment_table(experiments: Any) -> str:
|
|
99
|
+
"""Render a list of ExperimentSummaryRead as a compact table."""
|
|
100
|
+
headers = ["ID", "Title", "Phase", "Plan v", "Logs", "Topic", "Updated"]
|
|
101
|
+
rows = []
|
|
102
|
+
for e in experiments:
|
|
103
|
+
rows.append([
|
|
104
|
+
short_uuid(e.id),
|
|
105
|
+
truncate(e.title, 50),
|
|
106
|
+
enum_value(e.phase),
|
|
107
|
+
f"v{e.current_plan_version}",
|
|
108
|
+
str(getattr(e, "log_count", 0)),
|
|
109
|
+
short_uuid(e.topic_id) if e.topic_id else "-",
|
|
110
|
+
format_datetime(e.updated_at),
|
|
111
|
+
])
|
|
112
|
+
return render_table(headers, rows)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@experiment_app.command("list")
|
|
116
|
+
def experiment_list(
|
|
117
|
+
project: uuid.UUID | None = typer.Option(None, "--project"),
|
|
118
|
+
project_key: str | None = typer.Option(None, "--project-key"),
|
|
119
|
+
phase: str | None = typer.Option(None, "--phase"),
|
|
120
|
+
creator_agent_id: uuid.UUID | None = typer.Option(None, "--creator-agent-id"),
|
|
121
|
+
q: str | None = typer.Option(None, "--q"),
|
|
122
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
123
|
+
page_size: int = typer.Option(100, "--page-size", min=1, max=100),
|
|
124
|
+
include_archived: bool = typer.Option(False, "--include-archived"),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""List experiments in the current project.
|
|
127
|
+
|
|
128
|
+
Defaults to a compact table view. Use ``--format yaml`` or
|
|
129
|
+
``--format json`` for full structured output (scripts / piping).
|
|
130
|
+
"""
|
|
131
|
+
from cli.main import _resolve_project, _run # lazy: avoid cycle
|
|
132
|
+
from server.domain.models import ExperimentPhase
|
|
133
|
+
|
|
134
|
+
def action(c: MAPClient):
|
|
135
|
+
pid = _resolve_project(c, project, project_key)
|
|
136
|
+
phase_filter = ExperimentPhase(phase) if phase else None
|
|
137
|
+
return c.list_experiments(
|
|
138
|
+
pid,
|
|
139
|
+
phase=phase_filter,
|
|
140
|
+
creator_agent_id=creator_agent_id,
|
|
141
|
+
q=q,
|
|
142
|
+
page=page,
|
|
143
|
+
page_size=page_size,
|
|
144
|
+
include_archived=include_archived,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
_run(action, table_renderer=_render_experiment_table)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@experiment_app.command("submit-review")
|
|
151
|
+
def experiment_submit_review(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
|
|
152
|
+
from cli.main import _run # lazy: avoid cycle
|
|
153
|
+
_run(lambda c: c.submit_for_review(experiment_id), experiment_id=experiment_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@experiment_app.command("approve")
|
|
157
|
+
def experiment_approve(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
|
|
158
|
+
from cli.main import _run # lazy: avoid cycle
|
|
159
|
+
_run(lambda c: c.approve_experiment(experiment_id), experiment_id=experiment_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@experiment_app.command("start")
|
|
163
|
+
def experiment_start(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
|
|
164
|
+
from cli.main import _run # lazy: avoid cycle
|
|
165
|
+
_run(lambda c: c.start_experiment(experiment_id), experiment_id=experiment_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@experiment_app.command("pre-complete")
|
|
169
|
+
def experiment_pre_complete(
|
|
170
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
171
|
+
metadata_file: Path | None = typer.Option(
|
|
172
|
+
None,
|
|
173
|
+
"--metadata",
|
|
174
|
+
help="YAML evidence file with keys such as alembic_current, api_health, pytest_summary, image_digest.",
|
|
175
|
+
),
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Validate local completion evidence before `experiment complete`.
|
|
178
|
+
|
|
179
|
+
This is intentionally local and side-effect free: it checks that the
|
|
180
|
+
experiment exists and that the supplied metadata carries at least one
|
|
181
|
+
deploy/test evidence field.
|
|
182
|
+
"""
|
|
183
|
+
from cli.main import _read_yaml_file, _run # lazy: avoid cycle
|
|
184
|
+
|
|
185
|
+
metadata = _read_yaml_file(metadata_file)
|
|
186
|
+
if not metadata_has_completion_evidence(metadata):
|
|
187
|
+
keys = ", ".join(sorted(EVIDENCE_METADATA_KEYS))
|
|
188
|
+
typer.echo(
|
|
189
|
+
"Error: missing completion evidence metadata. "
|
|
190
|
+
f"Accepted keys include: {keys}.",
|
|
191
|
+
err=True,
|
|
192
|
+
)
|
|
193
|
+
raise typer.Exit(2)
|
|
194
|
+
|
|
195
|
+
def _action(client: MAPClient):
|
|
196
|
+
exp = client.get_experiment(experiment_id)
|
|
197
|
+
return {
|
|
198
|
+
"experiment_id": str(exp.id),
|
|
199
|
+
"phase": exp.phase,
|
|
200
|
+
"current_plan_version": exp.current_plan_version,
|
|
201
|
+
"evidence_keys": sorted(str(key) for key in metadata) if isinstance(metadata, dict) else [],
|
|
202
|
+
"ok": True,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_run(_action)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@experiment_app.command("complete")
|
|
209
|
+
def experiment_complete(
|
|
210
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
211
|
+
summary: str = typer.Option(..., "--summary"),
|
|
212
|
+
log_file: Path = typer.Option(..., "--file"),
|
|
213
|
+
metadata_file: Path | None = typer.Option(None, "--metadata"),
|
|
214
|
+
allow_missing_evidence: bool = typer.Option(
|
|
215
|
+
False,
|
|
216
|
+
"--allow-missing-evidence",
|
|
217
|
+
help="Bypass metadata evidence check for non-deployment experiments.",
|
|
218
|
+
),
|
|
219
|
+
schema: bool = typer.Option(
|
|
220
|
+
False,
|
|
221
|
+
"--schema",
|
|
222
|
+
help="cli-ux PR1: print the --metadata YAML template (with field hints) and exit. "
|
|
223
|
+
"Use this to discover accepted keys without grepping the SDK.",
|
|
224
|
+
),
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Submit experiment result for reviewer approval (running → result_review).
|
|
227
|
+
|
|
228
|
+
The log body must follow the 4-段 template contract (summary / 实施 log /
|
|
229
|
+
风险 / acceptance). Soft validation runs server-side; any
|
|
230
|
+
``template_validation.warnings`` are surfaced to stderr (one
|
|
231
|
+
``[WARN] template: <code>`` line each) and to the stdout YAML/JSON
|
|
232
|
+
payload under ``template_validation``. Warnings never block the
|
|
233
|
+
transition — add a follow-up log to address them.
|
|
234
|
+
"""
|
|
235
|
+
from cli.main import ( # lazy: avoid cycle
|
|
236
|
+
_load_complete_metadata,
|
|
237
|
+
_print_complete_metadata_schema_and_exit,
|
|
238
|
+
_read_text_file,
|
|
239
|
+
_run,
|
|
240
|
+
)
|
|
241
|
+
if schema:
|
|
242
|
+
_print_complete_metadata_schema_and_exit()
|
|
243
|
+
metadata = _load_complete_metadata(
|
|
244
|
+
metadata_file,
|
|
245
|
+
allow_missing_evidence=allow_missing_evidence,
|
|
246
|
+
)
|
|
247
|
+
payload = ExperimentComplete(
|
|
248
|
+
summary=summary,
|
|
249
|
+
content_md=_read_text_file(log_file, kind="log"),
|
|
250
|
+
metadata=metadata,
|
|
251
|
+
)
|
|
252
|
+
_run(lambda c: c.complete_experiment(experiment_id, payload), experiment_id=experiment_id)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@experiment_app.command("accept-result")
|
|
256
|
+
def experiment_accept_result(
|
|
257
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
258
|
+
summary: str = typer.Option(..., "--summary"),
|
|
259
|
+
log_file: Path = typer.Option(..., "--file"),
|
|
260
|
+
metadata_file: Path | None = typer.Option(None, "--metadata"),
|
|
261
|
+
review_verdict_file: Path | None = typer.Option(
|
|
262
|
+
None,
|
|
263
|
+
"--review-verdict-file",
|
|
264
|
+
help=(
|
|
265
|
+
"Optional structured verdict file (YAML). When omitted, the call "
|
|
266
|
+
"is treated as legacy free-text (pre_schema_accept_result='true'). "
|
|
267
|
+
"When provided, server validates review_id/item_id ownership and "
|
|
268
|
+
"records verdict breakdown in log metadata."
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
schema: bool = typer.Option(
|
|
272
|
+
False,
|
|
273
|
+
"--schema",
|
|
274
|
+
help="cli-ux PR1: print the --review-verdict-file YAML template (with field hints) and exit. "
|
|
275
|
+
"Use this to discover the verdict schema without grepping the SDK.",
|
|
276
|
+
),
|
|
277
|
+
) -> None:
|
|
278
|
+
from cli.main import ( # lazy: avoid cycle
|
|
279
|
+
_load_review_verdict_file,
|
|
280
|
+
_print_review_verdict_schema_and_exit,
|
|
281
|
+
_read_text_file,
|
|
282
|
+
_read_yaml_file,
|
|
283
|
+
_run,
|
|
284
|
+
)
|
|
285
|
+
if schema:
|
|
286
|
+
_print_review_verdict_schema_and_exit()
|
|
287
|
+
metadata = _read_yaml_file(metadata_file)
|
|
288
|
+
verdict_file = _load_review_verdict_file(review_verdict_file)
|
|
289
|
+
payload = ExperimentResultDecision(
|
|
290
|
+
summary=summary,
|
|
291
|
+
content_md=_read_text_file(log_file, kind="log"),
|
|
292
|
+
metadata=metadata,
|
|
293
|
+
verdict_file=verdict_file,
|
|
294
|
+
)
|
|
295
|
+
_run(lambda c: c.accept_experiment_result(experiment_id, payload), experiment_id=experiment_id)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@experiment_app.command("reject-result")
|
|
299
|
+
def experiment_reject_result(
|
|
300
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
301
|
+
summary: str = typer.Option(..., "--summary"),
|
|
302
|
+
log_file: Path = typer.Option(..., "--file"),
|
|
303
|
+
metadata_file: Path | None = typer.Option(None, "--metadata"),
|
|
304
|
+
review_verdict_file: Path | None = typer.Option(
|
|
305
|
+
None,
|
|
306
|
+
"--review-verdict-file",
|
|
307
|
+
help=(
|
|
308
|
+
"Optional structured verdict file (YAML). When omitted, the call "
|
|
309
|
+
"is treated as legacy free-text (pre_schema_accept_result='true'). "
|
|
310
|
+
"When provided, server validates review_id/item_id ownership and "
|
|
311
|
+
"records verdict breakdown in log metadata."
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
schema: bool = typer.Option(
|
|
315
|
+
False,
|
|
316
|
+
"--schema",
|
|
317
|
+
help="cli-ux PR1: print the --review-verdict-file YAML template (with field hints) and exit. "
|
|
318
|
+
"Use this to discover the verdict schema without grepping the SDK.",
|
|
319
|
+
),
|
|
320
|
+
) -> None:
|
|
321
|
+
from cli.main import ( # lazy: avoid cycle
|
|
322
|
+
_load_review_verdict_file,
|
|
323
|
+
_print_review_verdict_schema_and_exit,
|
|
324
|
+
_read_text_file,
|
|
325
|
+
_read_yaml_file,
|
|
326
|
+
_run,
|
|
327
|
+
)
|
|
328
|
+
if schema:
|
|
329
|
+
_print_review_verdict_schema_and_exit()
|
|
330
|
+
metadata = _read_yaml_file(metadata_file)
|
|
331
|
+
verdict_file = _load_review_verdict_file(review_verdict_file)
|
|
332
|
+
payload = ExperimentResultDecision(
|
|
333
|
+
summary=summary,
|
|
334
|
+
content_md=_read_text_file(log_file, kind="log"),
|
|
335
|
+
metadata=metadata,
|
|
336
|
+
verdict_file=verdict_file,
|
|
337
|
+
)
|
|
338
|
+
_run(lambda c: c.reject_experiment_result(experiment_id, payload), experiment_id=experiment_id)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@experiment_app.command("log")
|
|
342
|
+
def experiment_log(
|
|
343
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
344
|
+
summary: str = typer.Option(..., "--summary"),
|
|
345
|
+
log_file: Path = typer.Option(..., "--file"),
|
|
346
|
+
metadata_file: Path | None = typer.Option(None, "--metadata"),
|
|
347
|
+
force_skip_similarity: bool = typer.Option(
|
|
348
|
+
False,
|
|
349
|
+
"--force-skip-similarity",
|
|
350
|
+
help=(
|
|
351
|
+
"b72d0542 I1.b(2)(e): acknowledge the soft content-similarity "
|
|
352
|
+
"warning when the new log body is >= 70%% similar to a prior "
|
|
353
|
+
"log. The warning is suppressed in stdout and a "
|
|
354
|
+
"``log.force_skip`` audit row is written instead."
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Append an execution log entry to an experiment.
|
|
359
|
+
|
|
360
|
+
Output (8ac93d4e I1.d): stdout is the ``LogCreateResponse`` wrapper
|
|
361
|
+
(yaml/json, top-level ``log`` + ``validation``). Soft plan evidence_keys
|
|
362
|
+
warnings appear as ``validation.warnings`` in stdout. When the plan's
|
|
363
|
+
frontmatter existed but its YAML failed to parse, the CLI additionally
|
|
364
|
+
emits ``[WARN] plan evidence_keys 解析失败: <msg>`` on stderr so the
|
|
365
|
+
host notices the malformed plan; the log itself is still saved.
|
|
366
|
+
|
|
367
|
+
b72d0542 I1.b(2)(e): when the server detects content similarity
|
|
368
|
+
>= threshold against a prior log on the same experiment, the
|
|
369
|
+
``similarity_warning`` block is added to the stdout payload AND a
|
|
370
|
+
``[WARN] similarity: HIGH_CONTENT_SIMILARITY score=X >= threshold=Y``
|
|
371
|
+
line is emitted on stderr. Pass ``--force-skip-similarity`` to
|
|
372
|
+
suppress the warning and instead write a ``log.force_skip`` audit row.
|
|
373
|
+
"""
|
|
374
|
+
from cli.main import _read_text_file, _read_yaml_file, _run # lazy: avoid cycle
|
|
375
|
+
metadata = _read_yaml_file(metadata_file)
|
|
376
|
+
payload = ExperimentLogCreate(
|
|
377
|
+
summary=summary,
|
|
378
|
+
content_md=_read_text_file(log_file, kind="log"),
|
|
379
|
+
metadata=metadata,
|
|
380
|
+
force_skip_similarity=force_skip_similarity,
|
|
381
|
+
)
|
|
382
|
+
_run(lambda c: c.create_log(experiment_id, payload), experiment_id=experiment_id)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@experiment_app.command("logs")
|
|
386
|
+
def experiment_logs(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
|
|
387
|
+
from cli.main import _run # lazy: avoid cycle
|
|
388
|
+
_run(lambda c: c.list_logs(experiment_id), experiment_id=experiment_id)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@experiment_app.command("status")
|
|
392
|
+
def experiment_status(
|
|
393
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
394
|
+
persona_compare: bool = typer.Option(
|
|
395
|
+
False,
|
|
396
|
+
"--persona-compare",
|
|
397
|
+
help="0db51e10 I1(5a): diff the per-actor view of this experiment across "
|
|
398
|
+
"multiple personas (default: host+reviewer+participant). Each persona's "
|
|
399
|
+
"view is fetched independently via its own token.",
|
|
400
|
+
),
|
|
401
|
+
raw: bool = typer.Option(
|
|
402
|
+
False,
|
|
403
|
+
"--raw",
|
|
404
|
+
help="0db51e10 I1(5a): with --persona-compare, print each persona's full "
|
|
405
|
+
"snapshot side-by-side (YAML) instead of the diff table.",
|
|
406
|
+
),
|
|
407
|
+
compare_personas: str | None = typer.Option(
|
|
408
|
+
None,
|
|
409
|
+
"--for-personas",
|
|
410
|
+
help="0db51e10 I1(5a): with --persona-compare, comma-separated persona "
|
|
411
|
+
"names to compare (e.g. 'host,reviewer'); default = all known personas.",
|
|
412
|
+
),
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Show one experiment's phase, actions, blocked_on, and (per-actor) capabilities."""
|
|
415
|
+
from cli.main import _persona_compare_view, _run # lazy: avoid cycle
|
|
416
|
+
if persona_compare:
|
|
417
|
+
_run(
|
|
418
|
+
lambda c: _persona_compare_view(
|
|
419
|
+
c,
|
|
420
|
+
experiment_id,
|
|
421
|
+
personas=(
|
|
422
|
+
[p.strip() for p in compare_personas.split(",") if p.strip()]
|
|
423
|
+
if compare_personas
|
|
424
|
+
else None
|
|
425
|
+
),
|
|
426
|
+
raw=raw,
|
|
427
|
+
),
|
|
428
|
+
experiment_id=experiment_id,
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
def _action(client: MAPClient):
|
|
433
|
+
result = client.get_experiment(experiment_id)
|
|
434
|
+
typer.echo(f"actions: {list(result.actions)}")
|
|
435
|
+
typer.echo(f"blocked_on: {result.blocked_on}")
|
|
436
|
+
typer.echo(f"phase_owner: {getattr(result, 'phase_owner', 'host')}")
|
|
437
|
+
typer.echo(f"informational_only: {getattr(result, 'informational_only', False)}")
|
|
438
|
+
if result.blocked_on == "open_unreasonable_item" or "plan_revise" in result.actions:
|
|
439
|
+
typer.echo(
|
|
440
|
+
"obligation: revise plan for open unreasonable items "
|
|
441
|
+
"(see pending_plan_revisions in map todos / map work)"
|
|
442
|
+
)
|
|
443
|
+
elif result.blocked_on == "awaiting_review_for_current_plan_version":
|
|
444
|
+
typer.echo(
|
|
445
|
+
"obligation: reviewer must experiment review add for the current "
|
|
446
|
+
f"plan version (v{result.current_plan_version}); host cannot approve until then"
|
|
447
|
+
)
|
|
448
|
+
elif result.blocked_on == "awaiting_non_creator_review":
|
|
449
|
+
typer.echo(
|
|
450
|
+
"obligation: reviewer must submit the first experiment review "
|
|
451
|
+
"(experiment review add)"
|
|
452
|
+
)
|
|
453
|
+
# f873c287 I1(d): unified "host blocked, waiting on {phase_owner}"
|
|
454
|
+
# catch-all for blocked experiments whose decision authority is
|
|
455
|
+
# held by a non-host persona and have NO blocked_on-specific
|
|
456
|
+
# actionable hint above (e.g. result_review /
|
|
457
|
+
# awaiting_result_approval). When a specific obligation branch
|
|
458
|
+
# already matched, the phase_owner line printed above carries
|
|
459
|
+
# the equivalent "who's holding this" signal — no need to
|
|
460
|
+
# duplicate.
|
|
461
|
+
elif (
|
|
462
|
+
getattr(result, "blocked_on", None)
|
|
463
|
+
and getattr(result, "phase_owner", None)
|
|
464
|
+
and result.phase_owner.value != "host"
|
|
465
|
+
and not result.actions
|
|
466
|
+
):
|
|
467
|
+
typer.echo(
|
|
468
|
+
f"obligation: blocked, waiting on {result.phase_owner.value} "
|
|
469
|
+
f"(blocked_on={result.blocked_on})"
|
|
470
|
+
)
|
|
471
|
+
return result
|
|
472
|
+
|
|
473
|
+
_run(_action)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@experiment_app.command("show")
|
|
477
|
+
def experiment_show(
|
|
478
|
+
experiment_id: uuid.UUID | None = typer.Option(None, "--id", help="Experiment UUID."),
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Show one experiment (including archived) by UUID."""
|
|
481
|
+
from cli.main import _require_option_uuid, _run # lazy: avoid cycle
|
|
482
|
+
experiment_id = _require_option_uuid(experiment_id)
|
|
483
|
+
_run(lambda c: c.get_experiment(experiment_id), experiment_id=experiment_id)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@experiment_app.command(
|
|
487
|
+
"archive",
|
|
488
|
+
epilog="Use --undo or --unarchive to restore an archived experiment.",
|
|
489
|
+
)
|
|
490
|
+
def experiment_archive(
|
|
491
|
+
experiment_id: uuid.UUID | None = typer.Option(None, "--id", help="Experiment UUID."),
|
|
492
|
+
undo: bool = typer.Option(
|
|
493
|
+
False,
|
|
494
|
+
"--undo",
|
|
495
|
+
help="Unarchive instead of archive. Equivalent to --unarchive.",
|
|
496
|
+
),
|
|
497
|
+
unarchive: bool = typer.Option(
|
|
498
|
+
False,
|
|
499
|
+
"--unarchive",
|
|
500
|
+
help="Alias of --undo: unarchive instead of archive.",
|
|
501
|
+
),
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Archive (or unarchive) an experiment.
|
|
504
|
+
|
|
505
|
+
Thin wrapper around ``PATCH /experiments/{id}`` with ``archived=true``
|
|
506
|
+
(or ``false`` when ``--undo``/``--unarchive`` is set). Archive hides the
|
|
507
|
+
experiment from ``experiment list`` by default but ``experiment show``
|
|
508
|
+
still returns it including ``archived_at``. Archive is reversible —
|
|
509
|
+
re-run with ``--undo`` to restore.
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
|
|
513
|
+
# Archive
|
|
514
|
+
map --persona host experiment archive --id <uuid>
|
|
515
|
+
|
|
516
|
+
# Unarchive (two equivalent spellings)
|
|
517
|
+
map --persona host experiment archive --id <uuid> --undo
|
|
518
|
+
map --persona host experiment archive --id <uuid> --unarchive
|
|
519
|
+
"""
|
|
520
|
+
from cli.main import _require_option_uuid, _run # lazy: avoid cycle
|
|
521
|
+
experiment_id = _require_option_uuid(experiment_id)
|
|
522
|
+
from server.domain.schemas import ExperimentUpdate
|
|
523
|
+
|
|
524
|
+
payload = ExperimentUpdate(archived=not (undo or unarchive))
|
|
525
|
+
object_kind = "experiment"
|
|
526
|
+
|
|
527
|
+
def action(c: MAPClient):
|
|
528
|
+
try:
|
|
529
|
+
return c.update_experiment(experiment_id, payload)
|
|
530
|
+
except MAPNotFoundError as exc:
|
|
531
|
+
typer.echo(
|
|
532
|
+
f"Error: {object_kind} {experiment_id} not found",
|
|
533
|
+
err=True,
|
|
534
|
+
)
|
|
535
|
+
raise typer.Exit(1) from exc
|
|
536
|
+
|
|
537
|
+
_run(action)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@lock_app.command("acquire")
|
|
541
|
+
def experiment_lock_acquire(
|
|
542
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
543
|
+
ttl: int = typer.Option(1800, "--ttl", min=1, help="Lock TTL in seconds."),
|
|
544
|
+
) -> None:
|
|
545
|
+
from cli.main import _run # lazy: avoid cycle
|
|
546
|
+
_run(lambda c: c.acquire_experiment_lock(experiment_id, ttl_seconds=ttl), experiment_id=experiment_id)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@lock_app.command("release")
|
|
550
|
+
def experiment_lock_release(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
|
|
551
|
+
from cli.main import _run # lazy: avoid cycle
|
|
552
|
+
_run(lambda c: c.release_experiment_lock(experiment_id), experiment_id=experiment_id)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@lock_app.command("force-release")
|
|
556
|
+
def experiment_lock_force_release(
|
|
557
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
558
|
+
reason: str = typer.Option(..., "--reason"),
|
|
559
|
+
actor: str | None = typer.Option(None, "--actor"),
|
|
560
|
+
) -> None:
|
|
561
|
+
from cli.main import _run # lazy: avoid cycle
|
|
562
|
+
_run(lambda c: c.force_release_experiment_lock(experiment_id, reason=reason, actor=actor), experiment_id=experiment_id)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@lock_app.command("skip")
|
|
566
|
+
def experiment_lock_skip(
|
|
567
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
568
|
+
next_attempt_at: str = typer.Option(..., "--next-attempt-at"),
|
|
569
|
+
) -> None:
|
|
570
|
+
from cli.main import _run # lazy: avoid cycle
|
|
571
|
+
_run(lambda c: c.record_experiment_lock_skip(experiment_id, next_attempt_at=next_attempt_at), experiment_id=experiment_id)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@lock_app.command("scan-stalled")
|
|
575
|
+
def experiment_lock_scan_stalled() -> None:
|
|
576
|
+
"""Scan running experiment locks and emit no-progress notifications."""
|
|
577
|
+
from cli.main import _run # lazy: avoid cycle
|
|
578
|
+
|
|
579
|
+
_run(lambda c: c.scan_stalled_experiment_locks())
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@review_app.command("add")
|
|
583
|
+
def review_add(
|
|
584
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
585
|
+
review_file: Path = typer.Option(..., "--review"),
|
|
586
|
+
) -> None:
|
|
587
|
+
from cli.main import _read_text_file, _run # lazy: avoid cycle
|
|
588
|
+
raw = yaml.safe_load(_read_text_file(review_file, kind="review"))
|
|
589
|
+
payload = ReviewCreate.model_validate(raw)
|
|
590
|
+
_run(lambda c: c.create_review(experiment_id, payload), experiment_id=experiment_id)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@plan_app.command("revise")
|
|
594
|
+
def plan_revise(
|
|
595
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
596
|
+
plan_file: Path = typer.Option(..., "--plan-file"),
|
|
597
|
+
note: str | None = typer.Option(None, "--note"),
|
|
598
|
+
addressed_item: list[uuid.UUID] = typer.Option(
|
|
599
|
+
[],
|
|
600
|
+
"--addressed-item",
|
|
601
|
+
help="Unreasonable review item UUID to mark addressed (repeatable).",
|
|
602
|
+
),
|
|
603
|
+
) -> None:
|
|
604
|
+
from cli.main import _read_text_file, _run # lazy: avoid cycle
|
|
605
|
+
payload = PlanRevise(
|
|
606
|
+
content_md=_read_text_file(plan_file, kind="plan"),
|
|
607
|
+
change_note=note,
|
|
608
|
+
addressed_item_ids=list(addressed_item),
|
|
609
|
+
)
|
|
610
|
+
_run(lambda c: c.revise_plan(experiment_id, payload), experiment_id=experiment_id)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@plan_app.command("validate")
|
|
614
|
+
def plan_validate(
|
|
615
|
+
plan_file: Path = typer.Option(..., "--plan-file"),
|
|
616
|
+
strict: bool = typer.Option(
|
|
617
|
+
False,
|
|
618
|
+
"--strict/--no-strict",
|
|
619
|
+
help="Exit 1 when warnings are present (default: exit 0, warnings only).",
|
|
620
|
+
),
|
|
621
|
+
) -> None:
|
|
622
|
+
"""Local plan frontmatter lint (no API call).
|
|
623
|
+
|
|
624
|
+
Reads the plan file, parses YAML frontmatter, and lists any
|
|
625
|
+
missing/invalid required fields (``title`` / ``acceptance`` /
|
|
626
|
+
``evidence_keys`` / ``dependencies``). Soft-validates by default
|
|
627
|
+
(always exits 0 unless the file is unreadable); use ``--strict`` to
|
|
628
|
+
exit 1 when warnings are present, so ``experiment create`` scripts
|
|
629
|
+
can gate on lint outcome.
|
|
630
|
+
"""
|
|
631
|
+
from cli.main import _print_json, _project_cli_default_format, _read_text_file # lazy: avoid cycle
|
|
632
|
+
from server.services.plan_marker_service import validate_plan_frontmatter
|
|
633
|
+
|
|
634
|
+
content = _read_text_file(plan_file, kind="plan")
|
|
635
|
+
result = validate_plan_frontmatter(content)
|
|
636
|
+
|
|
637
|
+
payload = {
|
|
638
|
+
"valid": result.valid,
|
|
639
|
+
"fields_present": list(result.fields_present),
|
|
640
|
+
"warnings": [
|
|
641
|
+
{
|
|
642
|
+
"code": w.code,
|
|
643
|
+
"field": w.field,
|
|
644
|
+
"detail": w.detail,
|
|
645
|
+
}
|
|
646
|
+
for w in result.warnings
|
|
647
|
+
],
|
|
648
|
+
"frontmatter": result.frontmatter,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
fmt = _project_cli_default_format(None)
|
|
652
|
+
if fmt == "json":
|
|
653
|
+
_print_json(payload)
|
|
654
|
+
else:
|
|
655
|
+
typer.echo(
|
|
656
|
+
f"Plan lint: {'PASS' if result.valid else 'FAIL'} "
|
|
657
|
+
f"(fields_present={len(result.fields_present)}, warnings={len(result.warnings)})"
|
|
658
|
+
)
|
|
659
|
+
if result.frontmatter is not None:
|
|
660
|
+
typer.echo(f"frontmatter: {result.frontmatter}")
|
|
661
|
+
for w in result.warnings:
|
|
662
|
+
field_label = w.field or "-"
|
|
663
|
+
detail = f" ({w.detail})" if w.detail else ""
|
|
664
|
+
typer.echo(f" - [{w.code}] {field_label}{detail}")
|
|
665
|
+
|
|
666
|
+
if strict and result.warnings:
|
|
667
|
+
raise typer.Exit(1)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@review_app.command("list")
|
|
671
|
+
def review_list(
|
|
672
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
673
|
+
# N=2 过渡期默认 true (experiment 18f1d8f6 I1(c));N=2 release 后切到 False。
|
|
674
|
+
# 见 plan 当前_plan_version 的 history 默认展示策略。
|
|
675
|
+
include_archived: bool = typer.Option(
|
|
676
|
+
True,
|
|
677
|
+
"--include-archived/--no-include-archived",
|
|
678
|
+
help="Include archived reviews. Default true during N=2 transition; flips to false at N=2 release.",
|
|
679
|
+
),
|
|
680
|
+
plan_version: int | None = typer.Option(
|
|
681
|
+
None,
|
|
682
|
+
"--plan-version",
|
|
683
|
+
help="Filter by exact plan_version. Combine with --include-archived to inspect historical review chains.",
|
|
684
|
+
),
|
|
685
|
+
) -> None:
|
|
686
|
+
from cli.main import _run # lazy: avoid cycle
|
|
687
|
+
def action(c: MAPClient):
|
|
688
|
+
return c.list_reviews(
|
|
689
|
+
experiment_id,
|
|
690
|
+
include_archived=include_archived,
|
|
691
|
+
plan_version=plan_version,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
_run(action)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@review_app.command("withdraw")
|
|
698
|
+
def review_withdraw(
|
|
699
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
700
|
+
review_id: uuid.UUID = typer.Option(..., "--review-id"),
|
|
701
|
+
) -> None:
|
|
702
|
+
from cli.main import _run # lazy: avoid cycle
|
|
703
|
+
_run(lambda c: c.withdraw_review(experiment_id, review_id))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@review_app.command("resolve-item")
|
|
707
|
+
def review_resolve_item(
|
|
708
|
+
item_id: uuid.UUID = typer.Option(..., "--id"),
|
|
709
|
+
status: str = typer.Option(
|
|
710
|
+
"resolved",
|
|
711
|
+
"--status",
|
|
712
|
+
help="Target terminal status for this review item.",
|
|
713
|
+
),
|
|
714
|
+
) -> None:
|
|
715
|
+
"""Resolve a single review item.
|
|
716
|
+
|
|
717
|
+
``--status`` accepts ``resolved`` (accept the item) or ``rebutted``
|
|
718
|
+
(host pushes back on the item while keeping the experiment moving).
|
|
719
|
+
Defaults to ``resolved`` so existing scripts that omit ``--status``
|
|
720
|
+
keep their old behaviour — the migration is opt-in.
|
|
721
|
+
"""
|
|
722
|
+
from map_types.enums import ReviewItemStatus
|
|
723
|
+
|
|
724
|
+
from cli.main import _run # lazy: avoid cycle
|
|
725
|
+
|
|
726
|
+
_run(lambda c: c.update_review_item(item_id, ReviewItemStatus(status)))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@experiment_app.command("comment")
|
|
730
|
+
def experiment_comment(
|
|
731
|
+
experiment_id: uuid.UUID = typer.Option(..., "--id"),
|
|
732
|
+
anchor_type: str = typer.Option(..., "--anchor-type"),
|
|
733
|
+
anchor_id: uuid.UUID = typer.Option(..., "--anchor-id"),
|
|
734
|
+
body: str | None = typer.Option(None, "--body"),
|
|
735
|
+
body_file: Path | None = typer.Option(None, "--file", help="Read body from a file (avoids shell-quoting issues)."),
|
|
736
|
+
parent: uuid.UUID | None = typer.Option(None, "--parent"),
|
|
737
|
+
) -> None:
|
|
738
|
+
from cli.main import _read_text_file, _run # lazy: avoid cycle
|
|
739
|
+
from server.domain.models import CommentAnchorType
|
|
740
|
+
from server.domain.schemas import CommentCreate
|
|
741
|
+
|
|
742
|
+
if body is None and body_file is None:
|
|
743
|
+
typer.echo("Error: either --body or --file is required", err=True)
|
|
744
|
+
raise typer.Exit(2)
|
|
745
|
+
if body is not None and body_file is not None:
|
|
746
|
+
typer.echo("Error: use only one of --body or --file", err=True)
|
|
747
|
+
raise typer.Exit(2)
|
|
748
|
+
content = body if body is not None else _read_text_file(body_file, kind="comment")
|
|
749
|
+
payload = CommentCreate(
|
|
750
|
+
anchor_type=CommentAnchorType(anchor_type),
|
|
751
|
+
anchor_id=anchor_id,
|
|
752
|
+
parent_id=parent,
|
|
753
|
+
body=content,
|
|
754
|
+
)
|
|
755
|
+
_run(lambda c: c.create_comment(experiment_id, payload))
|