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
cli/commands/action.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""``map action ...`` sub-app — arch experiment 0519e2a3 PR7.
|
|
2
|
+
|
|
3
|
+
Topic action item lifecycle. Action items are surfaced in
|
|
4
|
+
``map todos.action_items`` (waker D6 stage: WAKE / STALE escalation).
|
|
5
|
+
All command bodies lazy-import ``cli.main._run`` to break the
|
|
6
|
+
``cli.main ↔ cli.commands.*`` import cycle.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from map_client.client import MAPClient
|
|
14
|
+
|
|
15
|
+
action_app = typer.Typer(help="Topic action item commands")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@action_app.command("list")
|
|
19
|
+
def action_list(
|
|
20
|
+
project: uuid.UUID | None = typer.Option(None, "--project"),
|
|
21
|
+
project_key: str | None = typer.Option(None, "--project-key"),
|
|
22
|
+
owner_agent_id: uuid.UUID | None = typer.Option(None, "--owner-agent-id"),
|
|
23
|
+
mine: bool = typer.Option(False, "--mine", help="Only action items assigned to the current agent."),
|
|
24
|
+
status: str | None = typer.Option("open", "--status"),
|
|
25
|
+
limit: int = typer.Option(100, "--limit", min=1, max=200),
|
|
26
|
+
) -> None:
|
|
27
|
+
from map_types.enums import TopicActionItemStatus
|
|
28
|
+
|
|
29
|
+
from cli.main import _resolve_project, _run # lazy: avoid cli.main ↔ cli.commands.* cycle
|
|
30
|
+
|
|
31
|
+
def action(c: MAPClient):
|
|
32
|
+
pid = _resolve_project(c, project, project_key)
|
|
33
|
+
owner = owner_agent_id
|
|
34
|
+
if mine:
|
|
35
|
+
owner = c.get_me().id
|
|
36
|
+
status_filter = TopicActionItemStatus(status) if status else None
|
|
37
|
+
return c.list_project_action_items(
|
|
38
|
+
pid,
|
|
39
|
+
owner_agent_id=owner,
|
|
40
|
+
status=status_filter,
|
|
41
|
+
limit=limit,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_run(action)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@action_app.command("complete")
|
|
48
|
+
def action_complete(
|
|
49
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to mark done."),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Close an action item as done (open -> done)."""
|
|
52
|
+
from cli.main import _run # lazy
|
|
53
|
+
|
|
54
|
+
def action(c: MAPClient):
|
|
55
|
+
return c.complete_action_item(action_item_id)
|
|
56
|
+
|
|
57
|
+
_run(action)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@action_app.command("deliver")
|
|
61
|
+
def action_deliver(
|
|
62
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to deliver."),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Deliver an open action item (source topic may be closed/archived)."""
|
|
65
|
+
from cli.main import _run # lazy
|
|
66
|
+
|
|
67
|
+
def action(c: MAPClient):
|
|
68
|
+
return c.deliver_action_item(action_item_id)
|
|
69
|
+
|
|
70
|
+
_run(action)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@action_app.command("cancel")
|
|
74
|
+
def action_cancel(
|
|
75
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to cancel."),
|
|
76
|
+
reason: str = typer.Option(..., "--reason", help="Cancellation reason (length-validated by category)."),
|
|
77
|
+
category: str | None = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--category",
|
|
80
|
+
help="implementation | decision | unspecified (default). Affects reason length threshold.",
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Close an action item as cancelled (open -> cancelled)."""
|
|
84
|
+
from map_types.enums import ActionItemCategory
|
|
85
|
+
from map_types.schemas import ActionItemCancel
|
|
86
|
+
|
|
87
|
+
from cli.main import _run # lazy
|
|
88
|
+
|
|
89
|
+
def action(c: MAPClient):
|
|
90
|
+
cat = ActionItemCategory(category) if category else None
|
|
91
|
+
payload = ActionItemCancel(reason=reason, category=cat)
|
|
92
|
+
return c.cancel_action_item(action_item_id, payload)
|
|
93
|
+
|
|
94
|
+
_run(action)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@action_app.command("link")
|
|
98
|
+
def action_link(
|
|
99
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to link."),
|
|
100
|
+
experiment_id: uuid.UUID = typer.Option(
|
|
101
|
+
...,
|
|
102
|
+
"--experiment-id",
|
|
103
|
+
help="Experiment UUID to attach to the action item (must share project).",
|
|
104
|
+
),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Attach an experiment to an open action item so future experiment
|
|
107
|
+
``done`` cascades the action item automatically."""
|
|
108
|
+
from cli.main import _run # lazy
|
|
109
|
+
|
|
110
|
+
def action(c: MAPClient):
|
|
111
|
+
return c.link_action_item(action_item_id, experiment_id)
|
|
112
|
+
|
|
113
|
+
_run(action)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@action_app.command("mark-wake-sent")
|
|
117
|
+
def action_mark_wake_sent(
|
|
118
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to wake."),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Bump wake_count + stamp last_woken_at + write ``action_item.wake_sent``
|
|
121
|
+
audit row. Used by the runtime-waker CLI to advance the three-stage
|
|
122
|
+
escalation timeline (experiment B / I4). Owner or admin only."""
|
|
123
|
+
from cli.main import _run # lazy
|
|
124
|
+
|
|
125
|
+
def action(c: MAPClient):
|
|
126
|
+
return c.mark_wake_sent(action_item_id)
|
|
127
|
+
|
|
128
|
+
_run(action)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@action_app.command("mark-stale")
|
|
132
|
+
def action_mark_stale(
|
|
133
|
+
action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to mark stale."),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Stamp stale_at + write the ``action_item.stale`` audit row after the
|
|
136
|
+
4th unanswered wake. Admin only (system escalation, experiment B / I4)."""
|
|
137
|
+
from cli.main import _run # lazy
|
|
138
|
+
|
|
139
|
+
def action(c: MAPClient):
|
|
140
|
+
return c.mark_stale(action_item_id)
|
|
141
|
+
|
|
142
|
+
_run(action)
|
cli/commands/agent.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""``map agent ...`` sub-app — arch experiment 0519e2a3 PR3.
|
|
2
|
+
|
|
3
|
+
Mirrors ``GET /api/v1/agents`` + ``/api/v1/agents/me`` for the CLI.
|
|
4
|
+
Distinct from ``map persona ...`` which manages the local
|
|
5
|
+
``.map/agents.yaml`` identity config.
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
* ``map agent show`` — current MAP agent identity (== ``map me``)
|
|
9
|
+
* ``map agent list`` — agents visible to the caller
|
|
10
|
+
* ``map agent escalation-target`` — STATE_MACHINE.* error escalation
|
|
11
|
+
contact resolver (I1(c))
|
|
12
|
+
|
|
13
|
+
The user-facing ``map persona list`` / ``map persona whoami`` path is
|
|
14
|
+
unchanged — this sub-app is additive.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
agent_app = typer.Typer(help="Agent commands (server-side identity)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_uuid(raw: str | None, *, field: str) -> uuid.UUID | None:
|
|
27
|
+
if raw is None:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return uuid.UUID(raw)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise typer.BadParameter(f"{field} must be a UUID, got {raw!r}") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@agent_app.command("show")
|
|
36
|
+
def agent_show() -> None:
|
|
37
|
+
"""Show the current MAP agent identity (== ``map me`` / ``map persona whoami``)."""
|
|
38
|
+
from cli.main import _run # lazy: avoid cli.main ↔ cli.commands.agent cycle
|
|
39
|
+
|
|
40
|
+
def action(c: Any) -> Any:
|
|
41
|
+
return c.get_me()
|
|
42
|
+
|
|
43
|
+
_run(action)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@agent_app.command("list")
|
|
47
|
+
def agent_list(
|
|
48
|
+
project_id: str | None = typer.Option(None, "--project-id", help="Filter by project UUID."),
|
|
49
|
+
role: str | None = typer.Option(
|
|
50
|
+
None, "--role", help="Filter by role (admin | agent)."
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""List agents visible to the caller.
|
|
54
|
+
|
|
55
|
+
Admins see every agent. Project-bound agents see admins and agents
|
|
56
|
+
in their own project. Use ``--project-id`` / ``--role`` to narrow.
|
|
57
|
+
"""
|
|
58
|
+
from cli.main import _run # lazy: see agent_show
|
|
59
|
+
|
|
60
|
+
pid = _parse_uuid(project_id, field="project_id")
|
|
61
|
+
|
|
62
|
+
def action(c: Any) -> Any:
|
|
63
|
+
return c.list_agents(project_id=pid, role=role)
|
|
64
|
+
|
|
65
|
+
_run(action)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@agent_app.command("escalation-target")
|
|
69
|
+
def agent_escalation_target(
|
|
70
|
+
experiment_id: str | None = typer.Option(
|
|
71
|
+
None,
|
|
72
|
+
"--experiment-id",
|
|
73
|
+
help="Experiment UUID — its escalation_target_agent_id override takes precedence.",
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Resolve the escalation contact for a STATE_MACHINE.* error.
|
|
77
|
+
|
|
78
|
+
Server runs the 3-tier rule (override → caller → same-role → admin)
|
|
79
|
+
and returns the chosen agent + the tier that picked it. CLI uses
|
|
80
|
+
this on ``MAPHTTPError`` to append ``Escalation: @<name>`` to the
|
|
81
|
+
error output.
|
|
82
|
+
"""
|
|
83
|
+
from cli.main import _run # lazy: see agent_show
|
|
84
|
+
|
|
85
|
+
eid = _parse_uuid(experiment_id, field="experiment_id")
|
|
86
|
+
|
|
87
|
+
def action(c: Any) -> Any:
|
|
88
|
+
return c.get_escalation_target(experiment_id=eid)
|
|
89
|
+
|
|
90
|
+
_run(action)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@agent_app.command("register")
|
|
94
|
+
def agent_register(
|
|
95
|
+
name: str = typer.Option(..., "--name", help="Unique agent name (1..128 chars)."),
|
|
96
|
+
role: str = typer.Option("agent", "--role", help="admin | agent (default agent)."),
|
|
97
|
+
project_key: str | None = typer.Option(
|
|
98
|
+
None, "--project-key", help="Project key for role=agent (mutually exclusive with --project-id)."
|
|
99
|
+
),
|
|
100
|
+
project_id: str | None = typer.Option(
|
|
101
|
+
None, "--project-id", help="Project UUID for role=agent (mutually exclusive with --project-key)."
|
|
102
|
+
),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Register a new agent (admin-only; role=agent requires a project)."""
|
|
105
|
+
from cli.main import _run # lazy: see agent_show
|
|
106
|
+
|
|
107
|
+
pid = _parse_uuid(project_id, field="project_id")
|
|
108
|
+
|
|
109
|
+
def action(c: Any) -> Any:
|
|
110
|
+
return c.register_agent(
|
|
111
|
+
name=name,
|
|
112
|
+
role=role,
|
|
113
|
+
project_key=project_key,
|
|
114
|
+
project_id=pid,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
_run(action, admin=True)
|
cli/commands/audit.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""``map audit ...`` sub-app — arch experiment 0519e2a3 PR5.
|
|
2
|
+
|
|
3
|
+
Admin-only audit log surface — wraps ``GET /api/v1/audit``.
|
|
4
|
+
|
|
5
|
+
Bypasses the shared ``_run`` wrapper on purpose: the API returns
|
|
6
|
+
``(rows, total)`` which we render as a header line + YAML rows rather
|
|
7
|
+
than a raw tuple. The shared wrapper would YAML-dump the tuple as
|
|
8
|
+
``[rows, total]`` which is the wrong shape for a greppable timeline.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from map_client.exceptions import MAPHTTPError
|
|
16
|
+
|
|
17
|
+
audit_app = typer.Typer(help="Audit log commands (admin)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@audit_app.command("list")
|
|
21
|
+
def audit_list(
|
|
22
|
+
kind: str | None = typer.Option(
|
|
23
|
+
None,
|
|
24
|
+
"--kind",
|
|
25
|
+
help="Filter by AuditLog.action (e.g. review_item.mutation).",
|
|
26
|
+
),
|
|
27
|
+
experiment_id: uuid.UUID | None = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--experiment",
|
|
30
|
+
help="Filter to a single experiment (matches payload_json.experiment_id).",
|
|
31
|
+
),
|
|
32
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
33
|
+
page_size: int = typer.Option(50, "--page-size", min=1, max=200),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""List global audit log entries (admin only).
|
|
36
|
+
|
|
37
|
+
Examples::
|
|
38
|
+
|
|
39
|
+
map audit list
|
|
40
|
+
map audit list --kind review_item.mutation
|
|
41
|
+
map audit list --kind review_item.mutation --experiment <uuid>
|
|
42
|
+
"""
|
|
43
|
+
from cli.main import _admin_client_ctx, _print_json
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with _admin_client_ctx() as client:
|
|
47
|
+
items, total = client.list_audit_global(
|
|
48
|
+
page=page,
|
|
49
|
+
page_size=page_size,
|
|
50
|
+
kind=kind,
|
|
51
|
+
experiment_id=experiment_id,
|
|
52
|
+
)
|
|
53
|
+
except MAPHTTPError as exc:
|
|
54
|
+
suffix = ""
|
|
55
|
+
error_code = getattr(exc, "error_code", None)
|
|
56
|
+
hint = getattr(exc, "hint", None)
|
|
57
|
+
if error_code:
|
|
58
|
+
suffix += f" [error_code={error_code}]"
|
|
59
|
+
if hint:
|
|
60
|
+
suffix += f"\nHint: {hint}"
|
|
61
|
+
typer.echo(f"Error {exc.status_code}: {exc.detail}{suffix}", err=True)
|
|
62
|
+
raise typer.Exit(1) from exc
|
|
63
|
+
|
|
64
|
+
typer.echo(
|
|
65
|
+
f"# audit_log total={total} returned={len(items)} "
|
|
66
|
+
f"kind={kind or '*'} experiment={experiment_id or '*'}"
|
|
67
|
+
)
|
|
68
|
+
_print_json(items)
|
cli/commands/docs.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``map docs ...`` sub-app — arch experiment 0519e2a3 PR7.
|
|
2
|
+
|
|
3
|
+
Repo-bundled documentation surface — no MAP API required.
|
|
4
|
+
``docs error-codes`` reads ``docs/error-codes/index.json`` from the
|
|
5
|
+
repo root and renders the catalog as a YAML table or filtered JSON.
|
|
6
|
+
|
|
7
|
+
The repo-root discovery helper (``_resolve_repo_root``) and the index
|
|
8
|
+
loader (``_load_error_codes_index``) are co-located here because they
|
|
9
|
+
are only used by this command. The loader reads ``_cli_options`` from
|
|
10
|
+
``cli.main`` lazily inside the function body to break the
|
|
11
|
+
``cli.main ↔ cli.commands.*`` import cycle.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
docs_app = typer.Typer(help="Repo-bundled documentation commands (no API).")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_ERROR_CODES_INDEX_RELATIVE = "docs/error-codes/index.json"
|
|
25
|
+
|
|
26
|
+
_ERROR_CODES_SEARCH_FIELDS: tuple[str, ...] = (
|
|
27
|
+
"code",
|
|
28
|
+
"category",
|
|
29
|
+
"title",
|
|
30
|
+
"description",
|
|
31
|
+
"hint",
|
|
32
|
+
"docs_url",
|
|
33
|
+
"owner_experiment",
|
|
34
|
+
"since_version",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_repo_root(start: Path | None = None) -> Path:
|
|
39
|
+
"""Walk upward from ``start`` (or cwd) until a directory containing
|
|
40
|
+
``docs/error-codes/index.json`` is found. Falls back to the start
|
|
41
|
+
directory if not found, so the loader can produce a clean error.
|
|
42
|
+
"""
|
|
43
|
+
current = (start or Path.cwd()).resolve()
|
|
44
|
+
for candidate in [current, *current.parents]:
|
|
45
|
+
if (candidate / _ERROR_CODES_INDEX_RELATIVE).is_file():
|
|
46
|
+
return candidate
|
|
47
|
+
return current
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_error_codes_index(
|
|
51
|
+
project_root: Path | None = None,
|
|
52
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
53
|
+
from cli.main import _cli_options # lazy: avoid cli.main ↔ cli.commands.* cycle
|
|
54
|
+
|
|
55
|
+
repo_root = _resolve_repo_root(project_root or _cli_options.get("project_root"))
|
|
56
|
+
index_path = repo_root / _ERROR_CODES_INDEX_RELATIVE
|
|
57
|
+
if not index_path.is_file():
|
|
58
|
+
typer.echo(
|
|
59
|
+
f"Error: error codes index not found at {index_path}. "
|
|
60
|
+
"Run from the repo root or pass --project-root.",
|
|
61
|
+
err=True,
|
|
62
|
+
)
|
|
63
|
+
raise typer.Exit(2)
|
|
64
|
+
try:
|
|
65
|
+
raw = yaml.safe_load(index_path.read_text(encoding="utf-8"))
|
|
66
|
+
except yaml.YAMLError as exc:
|
|
67
|
+
typer.echo(f"Error: failed to parse {index_path}: {exc}", err=True)
|
|
68
|
+
raise typer.Exit(2) from None
|
|
69
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("codes"), list):
|
|
70
|
+
typer.echo(
|
|
71
|
+
f"Error: {index_path} must be a JSON object with a 'codes' list.",
|
|
72
|
+
err=True,
|
|
73
|
+
)
|
|
74
|
+
raise typer.Exit(2)
|
|
75
|
+
return index_path, raw
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _match_error_code(code_entry: dict[str, Any], keywords: list[str]) -> bool:
|
|
79
|
+
if not keywords:
|
|
80
|
+
return True
|
|
81
|
+
haystack_parts: list[str] = []
|
|
82
|
+
for field in _ERROR_CODES_SEARCH_FIELDS:
|
|
83
|
+
value = code_entry.get(field)
|
|
84
|
+
if value is None:
|
|
85
|
+
continue
|
|
86
|
+
haystack_parts.append(str(value))
|
|
87
|
+
haystack = "\n".join(haystack_parts).lower()
|
|
88
|
+
return all(kw.lower() in haystack for kw in keywords)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _search_keywords_to_list(raw: list[str] | None) -> list[str]:
|
|
92
|
+
"""Normalize --search repeats: split comma-separated, strip, drop empties,
|
|
93
|
+
preserve order, dedupe while preserving order.
|
|
94
|
+
"""
|
|
95
|
+
if not raw:
|
|
96
|
+
return []
|
|
97
|
+
out: list[str] = []
|
|
98
|
+
seen: set[str] = set()
|
|
99
|
+
for item in raw:
|
|
100
|
+
for piece in str(item).split(","):
|
|
101
|
+
kw = piece.strip()
|
|
102
|
+
if not kw or kw in seen:
|
|
103
|
+
continue
|
|
104
|
+
seen.add(kw)
|
|
105
|
+
out.append(kw)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@docs_app.command("error-codes")
|
|
110
|
+
def docs_error_codes(
|
|
111
|
+
search: list[str] | None = typer.Option(
|
|
112
|
+
None,
|
|
113
|
+
"--search",
|
|
114
|
+
help=(
|
|
115
|
+
"Keyword filter (case-insensitive substring match against "
|
|
116
|
+
"code / category / title / description / hint / docs_url / "
|
|
117
|
+
"owner_experiment / since_version). Repeat for AND of "
|
|
118
|
+
"multiple keywords, or comma-separate in one value."
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
project_root: Path | None = typer.Option(
|
|
122
|
+
None,
|
|
123
|
+
"--project-root",
|
|
124
|
+
help="Repo root containing docs/error-codes/index.json (default: walk up from cwd).",
|
|
125
|
+
),
|
|
126
|
+
as_json: bool = typer.Option(
|
|
127
|
+
False,
|
|
128
|
+
"--json",
|
|
129
|
+
help="Emit the raw index.json payload (machine-readable) instead of the default YAML table.",
|
|
130
|
+
),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""List MAP error codes from ``docs/error-codes/index.json``."""
|
|
133
|
+
from cli.main import _print_json # lazy
|
|
134
|
+
|
|
135
|
+
index_path, payload = _load_error_codes_index(project_root)
|
|
136
|
+
keywords = _search_keywords_to_list(search)
|
|
137
|
+
codes = payload.get("codes") or []
|
|
138
|
+
matched = [entry for entry in codes if isinstance(entry, dict) and _match_error_code(entry, keywords)]
|
|
139
|
+
|
|
140
|
+
if as_json:
|
|
141
|
+
if keywords:
|
|
142
|
+
_print_json({"schema_version": payload.get("schema_version"), "codes": matched})
|
|
143
|
+
else:
|
|
144
|
+
_print_json(payload)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
schema_version = payload.get("schema_version")
|
|
148
|
+
generated_at = payload.get("generated_at")
|
|
149
|
+
owner_experiment = payload.get("owner_experiment")
|
|
150
|
+
header_lines = [
|
|
151
|
+
f"# error codes index: {index_path}",
|
|
152
|
+
f"# schema_version: {schema_version}",
|
|
153
|
+
f"# generated_at: {generated_at}",
|
|
154
|
+
f"# owner_experiment: {owner_experiment}",
|
|
155
|
+
]
|
|
156
|
+
if keywords:
|
|
157
|
+
header_lines.append(f"# search: {', '.join(keywords)} (AND)")
|
|
158
|
+
header_lines.append(f"# matched: {len(matched)}/{len(codes)}")
|
|
159
|
+
typer.echo("\n".join(header_lines))
|
|
160
|
+
if not matched:
|
|
161
|
+
return
|
|
162
|
+
table_rows: list[list[str]] = []
|
|
163
|
+
for entry in matched:
|
|
164
|
+
table_rows.append(
|
|
165
|
+
[
|
|
166
|
+
str(entry.get("code", "")),
|
|
167
|
+
str(entry.get("category", "")),
|
|
168
|
+
str(entry.get("http_status", "")),
|
|
169
|
+
str(entry.get("title", "")),
|
|
170
|
+
str(entry.get("owner_experiment", "")),
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
header = ["code", "category", "http", "title", "owner_experiment"]
|
|
174
|
+
widths = [max(len(h), *(len(row[i]) for row in table_rows)) for i, h in enumerate(header)]
|
|
175
|
+
sep = "-+-".join("-" * w for w in widths)
|
|
176
|
+
typer.echo(" | ".join(h.ljust(widths[i]) for i, h in enumerate(header)))
|
|
177
|
+
typer.echo(sep)
|
|
178
|
+
for row in table_rows:
|
|
179
|
+
typer.echo(" | ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
|