lockwire 0.2.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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/serve.py
ADDED
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
"""lockwire's sidecar: the same core served over local HTTP.
|
|
2
|
+
|
|
3
|
+
**Private to the app, verbatim:** *"Token-bound, localhost
|
|
4
|
+
only, undocumented, may change with every release. The Python library and MCP are
|
|
5
|
+
the public doors. Can be opened later without breaking anyone."* Nothing
|
|
6
|
+
here carries a compatibility promise; a caller outside the app uses
|
|
7
|
+
``lockwire.api``, the CLI's ``--json``, or MCP.
|
|
8
|
+
|
|
9
|
+
Two addresses, always: ``GET /v1/hello`` (are you alive, what can you do) and
|
|
10
|
+
``POST /v1/run`` (everything else — one discriminated-union body naming the
|
|
11
|
+
command). The free commands are ``start``, ``check``, ``page``, ``declare-form``,
|
|
12
|
+
``declare``, ``add``, ``review``, ``commit``, ``undo``, ``place``, ``move``,
|
|
13
|
+
``rotate``, ``reparent``, ``mount-shape``, ``describe``, ``what-if``, ``plan``,
|
|
14
|
+
``diff``, ``log`` and ``report``. A plugin adds its own through
|
|
15
|
+
``plugins.SERVE_COMMANDS``; this module names none of them, and a command it does
|
|
16
|
+
not know is a 422, refused by the discriminated union before any lockwire code runs.
|
|
17
|
+
Robot mode lists the write commands but refuses each by name.
|
|
18
|
+
|
|
19
|
+
**The sidecar computes nothing of its own.** Each handler calls the
|
|
20
|
+
matching ``lockwire.api`` function and wraps the document it returns. ``report``
|
|
21
|
+
and ``page``'s scene lookup is ``api.load_scene`` (E1-at); ``page`` has no CLI
|
|
22
|
+
command (``api.page`` is not in ``FUNCTIONS`` — see its docstring), so it is not
|
|
23
|
+
one of the CLI-named twins either; it wraps ``api.page``, which wraps
|
|
24
|
+
``page.build_payload``, unchanged.
|
|
25
|
+
|
|
26
|
+
The reply is always ``{version, cli_line, exit_code, verdict, payload,
|
|
27
|
+
refusal}``: a refusal (bad path, parse error, the CLI's own refusal) comes back
|
|
28
|
+
as HTTP 200 with ``exit_code``/``refusal`` set, never a traceback.
|
|
29
|
+
|
|
30
|
+
``fastapi`` and ``uvicorn`` (the ``serve`` extra) are imported when the app is built, so
|
|
31
|
+
this module's request models import without them.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import datetime as dt
|
|
37
|
+
import hmac
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import logging.handlers
|
|
41
|
+
import os
|
|
42
|
+
import secrets
|
|
43
|
+
import sys
|
|
44
|
+
import threading
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Annotated, Any, Callable, Literal, Union
|
|
47
|
+
|
|
48
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
49
|
+
from ruamel.yaml import YAMLError
|
|
50
|
+
|
|
51
|
+
from lockwire import __version__, api, plugins, vcs
|
|
52
|
+
from lockwire.cli_line import cli_line as _build_cli_line
|
|
53
|
+
from lockwire.edit import Change, contract_revision
|
|
54
|
+
from lockwire.plan import load_plan, validate_plan
|
|
55
|
+
from lockwire.report_html import write_html_report
|
|
56
|
+
from lockwire.report_html import _graph as _graph_view
|
|
57
|
+
from lockwire.scene import SceneRefusal
|
|
58
|
+
from lockwire.scene_urdf import load_scene_any
|
|
59
|
+
from lockwire.scene_view import scene_hash as _scene_hash
|
|
60
|
+
from lockwire.scene_view import scene_payload as _scene_payload
|
|
61
|
+
from lockwire.schema import ArtifactRef, load_contract
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
import uvicorn
|
|
65
|
+
from fastapi import FastAPI
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# The reply shape (plan §3): always this, whichever command ran.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunReply(BaseModel):
|
|
72
|
+
version: str
|
|
73
|
+
cli_line: str
|
|
74
|
+
exit_code: int
|
|
75
|
+
verdict: str | None = None
|
|
76
|
+
payload: Any = None
|
|
77
|
+
refusal: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CommandInfo(BaseModel):
|
|
81
|
+
name: str
|
|
82
|
+
args_schema: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HelloReply(BaseModel):
|
|
86
|
+
version: str
|
|
87
|
+
commands: list[CommandInfo]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ok(cli_line: str, *, exit_code: int, verdict: str | None, payload: Any) -> RunReply:
|
|
91
|
+
return RunReply(
|
|
92
|
+
version=__version__,
|
|
93
|
+
cli_line=cli_line,
|
|
94
|
+
exit_code=exit_code,
|
|
95
|
+
verdict=verdict,
|
|
96
|
+
payload=payload,
|
|
97
|
+
refusal=None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _refusal(cli_line: str, exit_code: int, message: str) -> RunReply:
|
|
102
|
+
return RunReply(
|
|
103
|
+
version=__version__,
|
|
104
|
+
cli_line=cli_line,
|
|
105
|
+
exit_code=exit_code,
|
|
106
|
+
verdict=None,
|
|
107
|
+
payload=None,
|
|
108
|
+
refusal=message,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _document_reply(cli_line: str, document: dict[str, Any]) -> RunReply:
|
|
113
|
+
"""Wrap a ``lockwire.api`` document. The verdict and the exit code are the
|
|
114
|
+
library's, read out of the document — the sidecar never decides either."""
|
|
115
|
+
return _ok(
|
|
116
|
+
cli_line,
|
|
117
|
+
exit_code=document["exit_code"],
|
|
118
|
+
verdict=document["verdict"],
|
|
119
|
+
payload=document,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _no_contract(path: Path) -> str | None:
|
|
124
|
+
"""The CLI's own words for a missing contract, so a refusal reads the same
|
|
125
|
+
through every door. ``None`` when the file is there and the failure is real."""
|
|
126
|
+
return None if path.exists() else f"no contract at {path}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# The command union. Each member's ``args`` fields mirror the matching typer
|
|
131
|
+
# command's arguments/options (names, defaults) from cli.py; a flag that only
|
|
132
|
+
# matters for a terminal session (``--open/--no-open``) is left off — the
|
|
133
|
+
# sidecar is headless and never opens a browser.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _StrictArgs(BaseModel):
|
|
137
|
+
model_config = ConfigDict(extra="forbid")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CheckArgs(_StrictArgs):
|
|
141
|
+
contract: str = "lockwire.yaml"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SceneRequest(_StrictArgs):
|
|
145
|
+
"""The caller's half of the geometry-behind-a-hash negotiation (e1-b2):
|
|
146
|
+
the sha256 (``scene_view.scene_hash``) of the geometry it already has."""
|
|
147
|
+
|
|
148
|
+
have: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PlanArgs(_StrictArgs):
|
|
152
|
+
contract: str
|
|
153
|
+
plan: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DescribeArgs(_StrictArgs):
|
|
157
|
+
contract: str = "lockwire.yaml"
|
|
158
|
+
out: str | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DeclareFormArgs(_StrictArgs):
|
|
162
|
+
contract: str = "lockwire.yaml"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ReportArgs(_StrictArgs):
|
|
166
|
+
contract: str = "lockwire.yaml"
|
|
167
|
+
scene: str | None = None
|
|
168
|
+
package_root: list[str] = Field(default_factory=list)
|
|
169
|
+
out: str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class PageArgs(_StrictArgs):
|
|
173
|
+
"""Same shape as ``GuiArgs`` minus ``out``: the page has nowhere to write to
|
|
174
|
+
here, it comes back in the reply's ``payload`` (e1-pg)."""
|
|
175
|
+
|
|
176
|
+
contract: str = "lockwire.yaml"
|
|
177
|
+
scene: str | None = None
|
|
178
|
+
package_root: list[str] = Field(default_factory=list)
|
|
179
|
+
plan: str | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class LogArgs(_StrictArgs):
|
|
183
|
+
contract: str = "lockwire.yaml"
|
|
184
|
+
revision_range: str | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class DiffArgs(_StrictArgs):
|
|
188
|
+
contract: str = "lockwire.yaml"
|
|
189
|
+
a: str = "HEAD"
|
|
190
|
+
b: str | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class DeclareArgs(_StrictArgs):
|
|
194
|
+
contract: str
|
|
195
|
+
path: str
|
|
196
|
+
value: Any
|
|
197
|
+
source: str
|
|
198
|
+
witness: str
|
|
199
|
+
note: str | None = None
|
|
200
|
+
locked_at: str | None = None
|
|
201
|
+
dry_run: bool = False
|
|
202
|
+
expected_revision: str | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class AddArgs(_StrictArgs):
|
|
206
|
+
contract: str
|
|
207
|
+
kind: str
|
|
208
|
+
id: str | None = None
|
|
209
|
+
fields: dict[str, Any] = Field(default_factory=dict)
|
|
210
|
+
source: str
|
|
211
|
+
witness: str
|
|
212
|
+
note: str | None = None
|
|
213
|
+
locked_at: str | None = None
|
|
214
|
+
dry_run: bool = False
|
|
215
|
+
expected_revision: str | None = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ReviewArgs(_StrictArgs):
|
|
219
|
+
contract: str
|
|
220
|
+
path: str
|
|
221
|
+
state: Literal["approved", "questioned"]
|
|
222
|
+
witness: str
|
|
223
|
+
note: str | None = None
|
|
224
|
+
locked_at: str | None = None
|
|
225
|
+
dry_run: bool = False
|
|
226
|
+
expected_revision: str | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class CommitArgs(_StrictArgs):
|
|
230
|
+
contract: str
|
|
231
|
+
message: str
|
|
232
|
+
paths: list[str]
|
|
233
|
+
dry_run: bool = False
|
|
234
|
+
expected_revision: str | None = None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class UndoArgs(_StrictArgs):
|
|
238
|
+
contract: str
|
|
239
|
+
source: str = "HEAD"
|
|
240
|
+
apply: bool = False
|
|
241
|
+
expected_revision: str | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class PlaceArgs(_StrictArgs):
|
|
245
|
+
contract: str
|
|
246
|
+
target: str
|
|
247
|
+
on: str
|
|
248
|
+
offset: tuple[float, float, float]
|
|
249
|
+
unit: str
|
|
250
|
+
source: str
|
|
251
|
+
witness: str
|
|
252
|
+
note: str | None = None
|
|
253
|
+
locked_at: str | None = None
|
|
254
|
+
dry_run: bool = False
|
|
255
|
+
expected_revision: str | None = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class MoveArgs(_StrictArgs):
|
|
259
|
+
contract: str
|
|
260
|
+
target: str
|
|
261
|
+
offset: tuple[float, float, float]
|
|
262
|
+
unit: str
|
|
263
|
+
source: str
|
|
264
|
+
witness: str
|
|
265
|
+
note: str | None = None
|
|
266
|
+
locked_at: str | None = None
|
|
267
|
+
dry_run: bool = False
|
|
268
|
+
expected_revision: str | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class RotateArgs(_StrictArgs):
|
|
272
|
+
contract: str
|
|
273
|
+
target: str
|
|
274
|
+
rpy: tuple[float, float, float]
|
|
275
|
+
unit: str
|
|
276
|
+
source: str
|
|
277
|
+
witness: str
|
|
278
|
+
note: str | None = None
|
|
279
|
+
locked_at: str | None = None
|
|
280
|
+
dry_run: bool = False
|
|
281
|
+
expected_revision: str | None = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ReparentArgs(_StrictArgs):
|
|
285
|
+
contract: str
|
|
286
|
+
target: str
|
|
287
|
+
to: str
|
|
288
|
+
source: str
|
|
289
|
+
witness: str
|
|
290
|
+
note: str | None = None
|
|
291
|
+
locked_at: str | None = None
|
|
292
|
+
dry_run: bool = False
|
|
293
|
+
expected_revision: str | None = None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class MountShape(_StrictArgs):
|
|
297
|
+
kind: Literal["plate", "bracket", "tube", "custom-mesh"]
|
|
298
|
+
dimensions_mm: tuple[float, float, float]
|
|
299
|
+
fastener_pattern: str | None = None
|
|
300
|
+
mesh_path: str | None = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class MountShapeArgs(_StrictArgs):
|
|
304
|
+
contract: str
|
|
305
|
+
target: str
|
|
306
|
+
shape: MountShape
|
|
307
|
+
source: str
|
|
308
|
+
witness: str
|
|
309
|
+
note: str | None = None
|
|
310
|
+
locked_at: str | None = None
|
|
311
|
+
dry_run: bool = False
|
|
312
|
+
expected_revision: str | None = None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class StartArgs(_StrictArgs):
|
|
316
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
317
|
+
|
|
318
|
+
urdf: str
|
|
319
|
+
overwrite: bool = False
|
|
320
|
+
expected_revision: str | None = None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class StartCommand(BaseModel):
|
|
324
|
+
model_config = ConfigDict(extra="forbid")
|
|
325
|
+
command: Literal["start"] = "start"
|
|
326
|
+
args: StartArgs
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class CheckCommand(BaseModel):
|
|
330
|
+
model_config = ConfigDict(extra="forbid")
|
|
331
|
+
command: Literal["check"] = "check"
|
|
332
|
+
args: CheckArgs = Field(default_factory=CheckArgs)
|
|
333
|
+
#: Sibling of ``args``, not nested in it: the scene negotiation is a
|
|
334
|
+
#: transport concern, not a `check` argument the CLI shares (e1-b2).
|
|
335
|
+
scene: SceneRequest | None = None
|
|
336
|
+
actuator_evidence: dict[str, ArtifactRef] | None = None
|
|
337
|
+
evidence_root: str | None = None
|
|
338
|
+
expected_revision: str | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class WhatIfChange(_StrictArgs):
|
|
342
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
343
|
+
|
|
344
|
+
path: str
|
|
345
|
+
value: str
|
|
346
|
+
source: str
|
|
347
|
+
witness: str
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class WhatIfArgs(_StrictArgs):
|
|
351
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
352
|
+
|
|
353
|
+
contract: str
|
|
354
|
+
expected_revision: str
|
|
355
|
+
change: WhatIfChange
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class WhatIfCommand(BaseModel):
|
|
359
|
+
model_config = ConfigDict(extra="forbid")
|
|
360
|
+
command: Literal["what-if"] = "what-if"
|
|
361
|
+
args: WhatIfArgs
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class PlanCommand(BaseModel):
|
|
365
|
+
command: Literal["plan"] = "plan"
|
|
366
|
+
args: PlanArgs
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class DescribeCommand(BaseModel):
|
|
370
|
+
command: Literal["describe"] = "describe"
|
|
371
|
+
args: DescribeArgs = Field(default_factory=DescribeArgs)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class DeclareFormCommand(BaseModel):
|
|
375
|
+
command: Literal["declare-form"] = "declare-form"
|
|
376
|
+
args: DeclareFormArgs = Field(default_factory=DeclareFormArgs)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ReportCommand(BaseModel):
|
|
380
|
+
command: Literal["report"] = "report"
|
|
381
|
+
args: ReportArgs
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class PageCommand(BaseModel):
|
|
387
|
+
command: Literal["page"] = "page"
|
|
388
|
+
args: PageArgs = Field(default_factory=PageArgs)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class LogCommand(BaseModel):
|
|
392
|
+
command: Literal["log"] = "log"
|
|
393
|
+
args: LogArgs = Field(default_factory=LogArgs)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class DiffCommand(BaseModel):
|
|
397
|
+
command: Literal["diff"] = "diff"
|
|
398
|
+
args: DiffArgs = Field(default_factory=DiffArgs)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class DeclareCommand(BaseModel):
|
|
402
|
+
command: Literal["declare"] = "declare"
|
|
403
|
+
args: DeclareArgs
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class AddCommand(BaseModel):
|
|
407
|
+
command: Literal["add"] = "add"
|
|
408
|
+
args: AddArgs
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class ReviewCommand(BaseModel):
|
|
412
|
+
command: Literal["review"] = "review"
|
|
413
|
+
args: ReviewArgs
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class CommitCommand(BaseModel):
|
|
417
|
+
command: Literal["commit"] = "commit"
|
|
418
|
+
args: CommitArgs
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class UndoCommand(BaseModel):
|
|
422
|
+
command: Literal["undo"] = "undo"
|
|
423
|
+
args: UndoArgs
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class PlaceCommand(BaseModel):
|
|
427
|
+
command: Literal["place"] = "place"
|
|
428
|
+
args: PlaceArgs
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class MoveCommand(BaseModel):
|
|
432
|
+
command: Literal["move"] = "move"
|
|
433
|
+
args: MoveArgs
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class RotateCommand(BaseModel):
|
|
437
|
+
command: Literal["rotate"] = "rotate"
|
|
438
|
+
args: RotateArgs
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class ReparentCommand(BaseModel):
|
|
442
|
+
command: Literal["reparent"] = "reparent"
|
|
443
|
+
args: ReparentArgs
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class MountShapeCommand(BaseModel):
|
|
447
|
+
command: Literal["mount-shape"] = "mount-shape"
|
|
448
|
+
args: MountShapeArgs
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
_LOAD_ERRORS = (FileNotFoundError, KeyError, OSError, TypeError, ValueError, YAMLError)
|
|
452
|
+
|
|
453
|
+
#: The parsed-scene cache (e1-b2): keyed by ``(path, root-file digest)`` so a scene
|
|
454
|
+
#: edited on disk is re-parsed and a re-request for the same file is not.
|
|
455
|
+
#: Single-process, in-memory, unbounded — the sidecar serves one robot's
|
|
456
|
+
#: contract tree per run, so the key space is small by construction.
|
|
457
|
+
#: ponytail: no eviction; add an LRU cap if a session ever loads many scenes.
|
|
458
|
+
_SCENE_CACHE: dict[tuple[str, str], Any] = {}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _cached_scene(contract_path: Path) -> tuple[Any, str | None]:
|
|
462
|
+
"""Discover the scene beside ``contract_path`` (T2 — ``check`` takes no
|
|
463
|
+
``--scene`` flag, `cli.py:454`) and load it, caching the parse by
|
|
464
|
+
``(path, root-file digest)``. Discovery and the actual parse (``load_scene_any``)
|
|
465
|
+
are the same calls ``api.load_scene`` makes — this wraps them with a
|
|
466
|
+
cache in front, which ``api.load_scene`` cannot do itself since it never
|
|
467
|
+
exposes the scene's path/stat to key on.
|
|
468
|
+
"""
|
|
469
|
+
from lockwire.cli import _find_scene # noqa: PLC0415 - cli's tail loads plugins
|
|
470
|
+
|
|
471
|
+
scene_path, _discovered = _find_scene(contract_path, None)
|
|
472
|
+
if scene_path is None:
|
|
473
|
+
return None, None
|
|
474
|
+
loaded_contract = load_contract(contract_path)
|
|
475
|
+
external_assets = api._selected_external_meshes(
|
|
476
|
+
loaded_contract, contract_path, scene_path
|
|
477
|
+
)
|
|
478
|
+
from hashlib import sha256
|
|
479
|
+
digest = sha256(scene_path.read_bytes()).hexdigest()
|
|
480
|
+
key = (str(scene_path.resolve()), digest)
|
|
481
|
+
# An external mesh can change without changing the selected root's digest.
|
|
482
|
+
# Reparse every selected model: `{}` means its root is selected but it
|
|
483
|
+
# declares no mesh identities, which must still refuse an external mesh.
|
|
484
|
+
# `None` alone means this scene is unselected and can use the root cache.
|
|
485
|
+
cached = None if external_assets is not None else _SCENE_CACHE.get(key)
|
|
486
|
+
if cached is None:
|
|
487
|
+
cached = load_scene_any(scene_path, external_assets=external_assets)
|
|
488
|
+
if cached.asset_sha256 != digest:
|
|
489
|
+
raise SceneRefusal(f"{scene_path}: selected asset changed while loading.")
|
|
490
|
+
if external_assets is None:
|
|
491
|
+
_SCENE_CACHE[key] = cached
|
|
492
|
+
return cached, f"{scene_path.name} (found beside the contract)"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# Handlers. Each reuses the same functions cli.py's matching command calls.
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _handle_check(command: CheckCommand) -> RunReply:
|
|
500
|
+
args, scene_request = command.args, command.scene
|
|
501
|
+
line: list[object] = ["lockwire", "check", args.contract]
|
|
502
|
+
if command.actuator_evidence is not None:
|
|
503
|
+
for case_id in sorted(command.actuator_evidence):
|
|
504
|
+
ref = command.actuator_evidence[case_id]
|
|
505
|
+
line += ["--actuator-evidence", f"{case_id}={ref.path}@{ref.sha256}"]
|
|
506
|
+
if command.evidence_root is not None:
|
|
507
|
+
line += ["--evidence-root", command.evidence_root]
|
|
508
|
+
if command.expected_revision is not None:
|
|
509
|
+
line += ["--expected-revision", command.expected_revision]
|
|
510
|
+
cli_line = _build_cli_line(*line)
|
|
511
|
+
try:
|
|
512
|
+
document = api.check(
|
|
513
|
+
args.contract,
|
|
514
|
+
actuator_evidence=command.actuator_evidence,
|
|
515
|
+
evidence_root=command.evidence_root,
|
|
516
|
+
expected_revision=command.expected_revision,
|
|
517
|
+
)
|
|
518
|
+
scene, scene_source = _cached_scene(Path(args.contract))
|
|
519
|
+
loaded_contract = load_contract(Path(args.contract))
|
|
520
|
+
from lockwire.scene_view import scene_asset_identity
|
|
521
|
+
identity = (scene_asset_identity(loaded_contract, scene, Path(args.contract))
|
|
522
|
+
if scene is not None else {})
|
|
523
|
+
except (*_LOAD_ERRORS, SceneRefusal) as error:
|
|
524
|
+
return _refusal(cli_line, 1, _no_contract(Path(args.contract)) or str(error))
|
|
525
|
+
if scene is not None:
|
|
526
|
+
current_hash = _scene_hash(scene)
|
|
527
|
+
if scene_request is not None and scene_request.have == current_hash:
|
|
528
|
+
document["scene"] = {"unchanged": True, **identity}
|
|
529
|
+
else:
|
|
530
|
+
document["scene"] = _scene_payload(
|
|
531
|
+
loaded_contract,
|
|
532
|
+
scene,
|
|
533
|
+
source=scene_source,
|
|
534
|
+
contract_path=Path(args.contract),
|
|
535
|
+
cards=[],
|
|
536
|
+
scored_records=[],
|
|
537
|
+
labels=[],
|
|
538
|
+
decimate=False,
|
|
539
|
+
)
|
|
540
|
+
# The lens bar's words ride beside the scene they label (E2-j2), on the same
|
|
541
|
+
# condition as `scene` above — `_graph_view` is `report_html._graph`, the one
|
|
542
|
+
# function the page payload already uses for this (`page.py`), not a second
|
|
543
|
+
# computation of it.
|
|
544
|
+
document["graph"] = _graph_view(loaded_contract)
|
|
545
|
+
return _document_reply(cli_line, document)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _handle_what_if(args: WhatIfArgs) -> RunReply:
|
|
549
|
+
"""Compare one observed proposal through the existing non-mutating API door."""
|
|
550
|
+
cli_line = _build_cli_line(
|
|
551
|
+
"lockwire",
|
|
552
|
+
"what-if",
|
|
553
|
+
args.contract,
|
|
554
|
+
"--change",
|
|
555
|
+
f"{args.change.path}={args.change.value}",
|
|
556
|
+
"--source",
|
|
557
|
+
args.change.source,
|
|
558
|
+
"--witness",
|
|
559
|
+
args.change.witness,
|
|
560
|
+
)
|
|
561
|
+
try:
|
|
562
|
+
if (
|
|
563
|
+
len(args.expected_revision) != 64
|
|
564
|
+
or args.expected_revision != args.expected_revision.lower()
|
|
565
|
+
or any(character not in "0123456789abcdef" for character in args.expected_revision)
|
|
566
|
+
):
|
|
567
|
+
raise ValueError("expected_revision must be the full lowercase raw contract sha256")
|
|
568
|
+
if contract_revision(args.contract) != args.expected_revision:
|
|
569
|
+
raise ValueError("contract changed since it was opened; reload before comparing")
|
|
570
|
+
document = plugins.pick("what_if", api.what_if)(
|
|
571
|
+
args.contract,
|
|
572
|
+
[
|
|
573
|
+
Change(
|
|
574
|
+
path=args.change.path,
|
|
575
|
+
value=args.change.value,
|
|
576
|
+
source=args.change.source,
|
|
577
|
+
witness=args.change.witness,
|
|
578
|
+
)
|
|
579
|
+
],
|
|
580
|
+
)
|
|
581
|
+
except _LOAD_ERRORS as error:
|
|
582
|
+
return _refusal(cli_line, 2, _no_contract(Path(args.contract)) or str(error))
|
|
583
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _label_findings(document: dict[str, Any], contract: str) -> None:
|
|
587
|
+
"""Sidecar-only: name each finding for the app's rows (the CLI documents stay as they are)."""
|
|
588
|
+
from lockwire import page as gui # noqa: PLC0415
|
|
589
|
+
from lockwire.report import Finding # noqa: PLC0415
|
|
590
|
+
|
|
591
|
+
findings = [Finding.model_validate(f) for f in document["findings"]]
|
|
592
|
+
document["finding_labels"] = gui.finding_labels(api.load(contract), findings)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _handle_plan(args: PlanArgs) -> RunReply:
|
|
596
|
+
cli_line = f"lockwire plan {args.contract} {args.plan}"
|
|
597
|
+
try:
|
|
598
|
+
document = api.plan(args.contract, args.plan)
|
|
599
|
+
_label_findings(document, args.contract)
|
|
600
|
+
from lockwire import page as gui # noqa: PLC0415
|
|
601
|
+
|
|
602
|
+
by_step = gui.step_targets(api.load(args.contract), args.plan)
|
|
603
|
+
for step in document.get("steps", []):
|
|
604
|
+
index = step["path"].split("/")[2:3] # plan/steps/<i>/...
|
|
605
|
+
if index and index[0].isdigit() and int(index[0]) < len(by_step):
|
|
606
|
+
step["targets"] = by_step[int(index[0])]
|
|
607
|
+
except _LOAD_ERRORS as error:
|
|
608
|
+
return _refusal(cli_line, 2, str(error))
|
|
609
|
+
return _document_reply(cli_line, document)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _handle_describe(args: DescribeArgs) -> RunReply:
|
|
613
|
+
cli_line_parts = ["lockwire", "describe", args.contract]
|
|
614
|
+
if args.out is not None:
|
|
615
|
+
cli_line_parts += ["--out", args.out]
|
|
616
|
+
cli_line = " ".join(cli_line_parts)
|
|
617
|
+
try:
|
|
618
|
+
files = api.describe(args.contract)["files"]
|
|
619
|
+
except _LOAD_ERRORS as error:
|
|
620
|
+
return _refusal(cli_line, 2, str(error))
|
|
621
|
+
if args.out is None:
|
|
622
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload="\n\n".join(files.values()))
|
|
623
|
+
out_dir = Path(args.out)
|
|
624
|
+
try:
|
|
625
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
626
|
+
for name, content in files.items():
|
|
627
|
+
(out_dir / name).write_text(content, encoding="utf-8")
|
|
628
|
+
except OSError as error:
|
|
629
|
+
return _refusal(cli_line, 2, f"could not write to {out_dir}: {error}")
|
|
630
|
+
text = "\n".join(f"wrote {out_dir / name}" for name in sorted(files))
|
|
631
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=text)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _handle_report(args: ReportArgs) -> RunReply:
|
|
635
|
+
cli_line_parts = ["lockwire", "report", args.contract]
|
|
636
|
+
if args.scene is not None:
|
|
637
|
+
cli_line_parts += ["--scene", args.scene]
|
|
638
|
+
for root in args.package_root:
|
|
639
|
+
cli_line_parts += ["--package-root", root]
|
|
640
|
+
cli_line_parts += ["--out", args.out]
|
|
641
|
+
cli_line = " ".join(cli_line_parts)
|
|
642
|
+
contract_path = Path(args.contract)
|
|
643
|
+
if not contract_path.is_file():
|
|
644
|
+
return _refusal(cli_line, 1, f"no contract at {contract_path}")
|
|
645
|
+
try:
|
|
646
|
+
loaded = load_contract(contract_path)
|
|
647
|
+
except _LOAD_ERRORS as error:
|
|
648
|
+
return _refusal(cli_line, 1, str(error))
|
|
649
|
+
try:
|
|
650
|
+
api.load_scene(contract_path, args.scene, args.package_root)
|
|
651
|
+
except SceneRefusal as error:
|
|
652
|
+
return _refusal(cli_line, 2, str(error))
|
|
653
|
+
out_path = Path(args.out)
|
|
654
|
+
try:
|
|
655
|
+
write_html_report(loaded, out_path)
|
|
656
|
+
except OSError as error:
|
|
657
|
+
return _refusal(cli_line, 1, f"could not write {out_path}: {error}")
|
|
658
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=f"wrote {out_path}")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def page_reply(
|
|
662
|
+
args: PageArgs, run_of: Callable[[Any], tuple[list[Any], str]] | None = None
|
|
663
|
+
) -> RunReply:
|
|
664
|
+
"""The window's document: the payload ``page.build_payload`` computes, returned in
|
|
665
|
+
the reply (e1-pg). ``page`` has no CLI command of its own (a wrapper needs a
|
|
666
|
+
twin only when one exists), so the ``cli_line`` names ``lockwire check``, the
|
|
667
|
+
nearest command that reads the same contract. ``run_of(contract)`` is a plugin's
|
|
668
|
+
way to hand the page extra findings; the free handler passes none."""
|
|
669
|
+
cli_line = f"lockwire check {args.contract}"
|
|
670
|
+
contract_path = Path(args.contract)
|
|
671
|
+
if not contract_path.exists():
|
|
672
|
+
return _refusal(cli_line, 1, f"no contract at {contract_path}")
|
|
673
|
+
try:
|
|
674
|
+
loaded = load_contract(contract_path)
|
|
675
|
+
except _LOAD_ERRORS as error:
|
|
676
|
+
return _refusal(cli_line, 1, str(error))
|
|
677
|
+
run = None
|
|
678
|
+
if run_of is not None:
|
|
679
|
+
try:
|
|
680
|
+
run = run_of(loaded)
|
|
681
|
+
except _LOAD_ERRORS as error:
|
|
682
|
+
return _refusal(cli_line, 1, str(error))
|
|
683
|
+
try:
|
|
684
|
+
loaded_scene, scene_source = api.load_scene(contract_path, args.scene, args.package_root)
|
|
685
|
+
except SceneRefusal as error:
|
|
686
|
+
return _refusal(cli_line, 2, str(error))
|
|
687
|
+
plan_outcomes = None
|
|
688
|
+
plan_path = Path(args.plan) if args.plan is not None else None
|
|
689
|
+
if plan_path is not None:
|
|
690
|
+
try:
|
|
691
|
+
plan_report = validate_plan(loaded, load_plan(plan_path))
|
|
692
|
+
except _LOAD_ERRORS as error:
|
|
693
|
+
return _refusal(cli_line, 1, str(error))
|
|
694
|
+
plan_outcomes = plan_report.outcomes
|
|
695
|
+
document = api.page(
|
|
696
|
+
loaded,
|
|
697
|
+
preflight=run,
|
|
698
|
+
scene=loaded_scene,
|
|
699
|
+
scene_source=scene_source,
|
|
700
|
+
contract_path=contract_path,
|
|
701
|
+
plan_path=plan_path,
|
|
702
|
+
plan_outcomes=plan_outcomes,
|
|
703
|
+
cli_line=cli_line,
|
|
704
|
+
)
|
|
705
|
+
# No `exit_code`/`verdict` in the document itself (`PagePayload` carries
|
|
706
|
+
# neither, seam 1 in api_payload.py) — the page's own convention: 0 whenever
|
|
707
|
+
# the page was computed, never a decision gate.
|
|
708
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _handle_page(args: PageArgs) -> RunReply:
|
|
712
|
+
return page_reply(args)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _handle_log(args: LogArgs) -> RunReply:
|
|
716
|
+
cli_line_parts = ["lockwire", "log"]
|
|
717
|
+
if args.revision_range is not None:
|
|
718
|
+
cli_line_parts.append(args.revision_range)
|
|
719
|
+
cli_line_parts += ["--", args.contract]
|
|
720
|
+
return _ok(
|
|
721
|
+
" ".join(cli_line_parts),
|
|
722
|
+
exit_code=0,
|
|
723
|
+
verdict=None,
|
|
724
|
+
payload=api.log(args.contract, args.revision_range),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _handle_diff(args: DiffArgs) -> RunReply:
|
|
729
|
+
cli_line = f"lockwire diff {args.a}"
|
|
730
|
+
if args.b is not None:
|
|
731
|
+
cli_line += f" {args.b}"
|
|
732
|
+
cli_line += f" --file {args.contract}"
|
|
733
|
+
return _ok(
|
|
734
|
+
cli_line,
|
|
735
|
+
exit_code=0,
|
|
736
|
+
verdict=None,
|
|
737
|
+
payload=api.diff(args.contract, args.a, args.b),
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _parse_locked_at(value: str | None) -> dt.date | None:
|
|
742
|
+
return dt.date.fromisoformat(value) if value is not None else None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _handle_declare(args: DeclareArgs) -> RunReply:
|
|
746
|
+
cli_line = f"lockwire declare {args.contract} {args.path}"
|
|
747
|
+
document = api.declare(
|
|
748
|
+
args.contract,
|
|
749
|
+
args.path,
|
|
750
|
+
args.value,
|
|
751
|
+
source=args.source,
|
|
752
|
+
witness=args.witness,
|
|
753
|
+
note=args.note,
|
|
754
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
755
|
+
dry_run=args.dry_run,
|
|
756
|
+
expected_revision=args.expected_revision,
|
|
757
|
+
)
|
|
758
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _handle_declare_form(args: DeclareFormArgs) -> RunReply:
|
|
762
|
+
cli_line = f"lockwire declare-form {args.contract}"
|
|
763
|
+
return _ok(
|
|
764
|
+
cli_line,
|
|
765
|
+
exit_code=0,
|
|
766
|
+
verdict=None,
|
|
767
|
+
payload=api.declare_form(args.contract),
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _handle_add(args: AddArgs) -> RunReply:
|
|
772
|
+
cli_line = f"lockwire add {args.kind} {args.contract}"
|
|
773
|
+
document = api.add(
|
|
774
|
+
args.contract,
|
|
775
|
+
args.kind,
|
|
776
|
+
id=args.id,
|
|
777
|
+
source=args.source,
|
|
778
|
+
witness=args.witness,
|
|
779
|
+
note=args.note,
|
|
780
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
781
|
+
dry_run=args.dry_run,
|
|
782
|
+
expected_revision=args.expected_revision,
|
|
783
|
+
**args.fields,
|
|
784
|
+
)
|
|
785
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _handle_review(args: ReviewArgs) -> RunReply:
|
|
789
|
+
cli_line = f"lockwire review {args.contract} {args.path} {args.state}"
|
|
790
|
+
document = api.review(
|
|
791
|
+
args.contract,
|
|
792
|
+
args.path,
|
|
793
|
+
args.state,
|
|
794
|
+
witness=args.witness,
|
|
795
|
+
note=args.note,
|
|
796
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
797
|
+
dry_run=args.dry_run,
|
|
798
|
+
expected_revision=args.expected_revision,
|
|
799
|
+
)
|
|
800
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _handle_commit(args: CommitArgs) -> RunReply:
|
|
804
|
+
cli_line = f"lockwire commit {args.contract}"
|
|
805
|
+
document = vcs.commit_contract(
|
|
806
|
+
args.contract,
|
|
807
|
+
message=args.message,
|
|
808
|
+
paths=args.paths,
|
|
809
|
+
dry_run=args.dry_run,
|
|
810
|
+
expected_revision=args.expected_revision,
|
|
811
|
+
)
|
|
812
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=document)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _handle_undo(args: UndoArgs) -> RunReply:
|
|
816
|
+
cli_line = f"lockwire undo {args.contract} --source {args.source}"
|
|
817
|
+
plan = vcs.undo_plan(args.contract, source=args.source)
|
|
818
|
+
if args.apply:
|
|
819
|
+
if args.expected_revision is None:
|
|
820
|
+
vcs.apply_undo(plan)
|
|
821
|
+
else:
|
|
822
|
+
vcs.apply_undo(plan, contract=args.contract, expected_revision=args.expected_revision)
|
|
823
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=plan)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _handle_place(args: PlaceArgs) -> RunReply:
|
|
827
|
+
document = api.place(
|
|
828
|
+
args.contract,
|
|
829
|
+
args.target,
|
|
830
|
+
args.on,
|
|
831
|
+
args.offset,
|
|
832
|
+
unit=args.unit,
|
|
833
|
+
source=args.source,
|
|
834
|
+
witness=args.witness,
|
|
835
|
+
note=args.note,
|
|
836
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
837
|
+
dry_run=args.dry_run,
|
|
838
|
+
expected_revision=args.expected_revision,
|
|
839
|
+
)
|
|
840
|
+
return _ok(
|
|
841
|
+
f"lockwire place {args.contract} {args.target}", exit_code=0, verdict=None, payload=document
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _handle_move(args: MoveArgs) -> RunReply:
|
|
846
|
+
document = api.move(
|
|
847
|
+
args.contract,
|
|
848
|
+
args.target,
|
|
849
|
+
args.offset,
|
|
850
|
+
unit=args.unit,
|
|
851
|
+
source=args.source,
|
|
852
|
+
witness=args.witness,
|
|
853
|
+
note=args.note,
|
|
854
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
855
|
+
dry_run=args.dry_run,
|
|
856
|
+
expected_revision=args.expected_revision,
|
|
857
|
+
)
|
|
858
|
+
return _ok(
|
|
859
|
+
f"lockwire move {args.contract} {args.target}", exit_code=0, verdict=None, payload=document
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _handle_rotate(args: RotateArgs) -> RunReply:
|
|
864
|
+
document = api.rotate(
|
|
865
|
+
args.contract,
|
|
866
|
+
args.target,
|
|
867
|
+
args.rpy,
|
|
868
|
+
unit=args.unit,
|
|
869
|
+
source=args.source,
|
|
870
|
+
witness=args.witness,
|
|
871
|
+
note=args.note,
|
|
872
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
873
|
+
dry_run=args.dry_run,
|
|
874
|
+
expected_revision=args.expected_revision,
|
|
875
|
+
)
|
|
876
|
+
return _ok(
|
|
877
|
+
f"lockwire rotate {args.contract} {args.target}",
|
|
878
|
+
exit_code=0,
|
|
879
|
+
verdict=None,
|
|
880
|
+
payload=document,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _handle_reparent(args: ReparentArgs) -> RunReply:
|
|
885
|
+
document = api.reparent(
|
|
886
|
+
args.contract,
|
|
887
|
+
args.target,
|
|
888
|
+
args.to,
|
|
889
|
+
source=args.source,
|
|
890
|
+
witness=args.witness,
|
|
891
|
+
note=args.note,
|
|
892
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
893
|
+
dry_run=args.dry_run,
|
|
894
|
+
expected_revision=args.expected_revision,
|
|
895
|
+
)
|
|
896
|
+
return _ok(
|
|
897
|
+
f"lockwire reparent {args.contract} {args.target}",
|
|
898
|
+
exit_code=0,
|
|
899
|
+
verdict=None,
|
|
900
|
+
payload=document,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _handle_mount_shape(args: MountShapeArgs) -> RunReply:
|
|
905
|
+
document = api.mount_shape(
|
|
906
|
+
args.contract,
|
|
907
|
+
args.target,
|
|
908
|
+
args.shape.model_dump(exclude_none=True),
|
|
909
|
+
source=args.source,
|
|
910
|
+
witness=args.witness,
|
|
911
|
+
note=args.note,
|
|
912
|
+
locked_at=_parse_locked_at(args.locked_at),
|
|
913
|
+
dry_run=args.dry_run,
|
|
914
|
+
expected_revision=args.expected_revision,
|
|
915
|
+
)
|
|
916
|
+
return _ok(
|
|
917
|
+
f"lockwire mount-shape {args.contract} {args.target}",
|
|
918
|
+
exit_code=0,
|
|
919
|
+
verdict=None,
|
|
920
|
+
payload=document,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _handle_start(args: StartArgs) -> RunReply:
|
|
925
|
+
cli_line = f"lockwire start {args.urdf} --out {Path(args.urdf).resolve().parent / 'lockwire.yaml'}"
|
|
926
|
+
try:
|
|
927
|
+
result = api.start(
|
|
928
|
+
args.urdf, overwrite=args.overwrite, expected_revision=args.expected_revision
|
|
929
|
+
)
|
|
930
|
+
except _LOAD_ERRORS as error:
|
|
931
|
+
return _refusal(cli_line, 2, str(error))
|
|
932
|
+
return _ok(cli_line, exit_code=0, verdict=None, payload=result)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
_FREE_COMMANDS = {
|
|
936
|
+
"start": plugins.ServeCommand(_handle_start, StartArgs, StartCommand),
|
|
937
|
+
"check": plugins.ServeCommand(_handle_check, CheckArgs, CheckCommand),
|
|
938
|
+
"what-if": plugins.ServeCommand(_handle_what_if, WhatIfArgs, WhatIfCommand),
|
|
939
|
+
"plan": plugins.ServeCommand(_handle_plan, PlanArgs, PlanCommand),
|
|
940
|
+
"describe": plugins.ServeCommand(_handle_describe, DescribeArgs, DescribeCommand),
|
|
941
|
+
"declare-form": plugins.ServeCommand(_handle_declare_form, DeclareFormArgs, DeclareFormCommand),
|
|
942
|
+
"report": plugins.ServeCommand(_handle_report, ReportArgs, ReportCommand),
|
|
943
|
+
"page": plugins.ServeCommand(_handle_page, PageArgs, PageCommand),
|
|
944
|
+
"log": plugins.ServeCommand(_handle_log, LogArgs, LogCommand),
|
|
945
|
+
"diff": plugins.ServeCommand(_handle_diff, DiffArgs, DiffCommand),
|
|
946
|
+
"declare": plugins.ServeCommand(_handle_declare, DeclareArgs, DeclareCommand),
|
|
947
|
+
"add": plugins.ServeCommand(_handle_add, AddArgs, AddCommand),
|
|
948
|
+
"review": plugins.ServeCommand(_handle_review, ReviewArgs, ReviewCommand),
|
|
949
|
+
"commit": plugins.ServeCommand(_handle_commit, CommitArgs, CommitCommand),
|
|
950
|
+
"undo": plugins.ServeCommand(_handle_undo, UndoArgs, UndoCommand),
|
|
951
|
+
"place": plugins.ServeCommand(_handle_place, PlaceArgs, PlaceCommand),
|
|
952
|
+
"move": plugins.ServeCommand(_handle_move, MoveArgs, MoveCommand),
|
|
953
|
+
"rotate": plugins.ServeCommand(_handle_rotate, RotateArgs, RotateCommand),
|
|
954
|
+
"reparent": plugins.ServeCommand(_handle_reparent, ReparentArgs, ReparentCommand),
|
|
955
|
+
"mount-shape": plugins.ServeCommand(_handle_mount_shape, MountShapeArgs, MountShapeCommand),
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def commands() -> dict[str, plugins.ServeCommand]:
|
|
960
|
+
"""The commands this sidecar serves: the free ones, then whatever a plugin registered."""
|
|
961
|
+
return {**_FREE_COMMANDS, **plugins.SERVE_COMMANDS}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def request_type(table: dict[str, plugins.ServeCommand] | None = None) -> Any:
|
|
965
|
+
"""The `/v1/run` body: a discriminated union of the table's command models, built
|
|
966
|
+
when the app is, so a plugin's commands are in it and nothing else is."""
|
|
967
|
+
table = commands() if table is None else table
|
|
968
|
+
return Annotated[Union[tuple(c.command for c in table.values())], Field(discriminator="command")]
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
_REVISION_GUARDED_COMMANDS = frozenset(
|
|
972
|
+
{"declare", "add", "review", "commit", "undo", "place", "move", "rotate", "reparent", "mount-shape"}
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _sidecar_write_needs_revision(body: Any) -> bool:
|
|
977
|
+
"""The app must name the exact bytes it opened before changing them."""
|
|
978
|
+
if body.command not in _REVISION_GUARDED_COMMANDS:
|
|
979
|
+
return False
|
|
980
|
+
return body.args.apply if body.command == "undo" else not body.args.dry_run
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def dispatch(body: Any, state: Any = None) -> RunReply:
|
|
984
|
+
"""Run one parsed request. A plugin's command gets `(body, state)`, `state` being
|
|
985
|
+
the app's `app.state`; a free handler gets its `args` (`check` its whole command)."""
|
|
986
|
+
try:
|
|
987
|
+
if _sidecar_write_needs_revision(body) and body.args.expected_revision is None:
|
|
988
|
+
return _refusal(
|
|
989
|
+
f"lockwire {body.command}",
|
|
990
|
+
2,
|
|
991
|
+
"expected_revision is required before applying a sidecar edit; reload first",
|
|
992
|
+
)
|
|
993
|
+
if body.command in plugins.SERVE_COMMANDS:
|
|
994
|
+
return plugins.SERVE_COMMANDS[body.command].handler(body, state)
|
|
995
|
+
handler = _FREE_COMMANDS[body.command].handler
|
|
996
|
+
return handler(body) if isinstance(body, CheckCommand) else handler(body.args)
|
|
997
|
+
except Exception as error: # noqa: BLE001 - never a traceback over the wire
|
|
998
|
+
return _refusal(f"lockwire {body.command}", 2, str(error))
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# ---------------------------------------------------------------------------
|
|
1002
|
+
# The app. Bound to 127.0.0.1 only by ``lockwire serve``; every route needs the
|
|
1003
|
+
# per-launch token, hello included.
|
|
1004
|
+
#
|
|
1005
|
+
# The token is checked in **middleware**, not in a route dependency, and that is
|
|
1006
|
+
# the whole point: FastAPI reads and validates a request body *before* it solves
|
|
1007
|
+
# a route's dependencies, so a `Depends(_auth)` guard lets an unauthenticated
|
|
1008
|
+
# caller reach pydantic with an arbitrary body and read a 422 back. Middleware
|
|
1009
|
+
# runs before routing, so an unauthenticated request is a 401 and nothing of it
|
|
1010
|
+
# is ever parsed (`test_token_checked_before_body_is_parsed`).
|
|
1011
|
+
#
|
|
1012
|
+
# There is deliberately **no CORS middleware**: a browser page must not be able
|
|
1013
|
+
# to reach this sidecar at all. The Host check below is the other half of that —
|
|
1014
|
+
# without it a page on any site can point a name it owns at 127.0.0.1 (DNS
|
|
1015
|
+
# rebinding) and the same-origin rules would let it through.
|
|
1016
|
+
|
|
1017
|
+
#: Commands that remain discoverable but are refused by name on the robot.
|
|
1018
|
+
_WRITE_COMMANDS = frozenset(
|
|
1019
|
+
{
|
|
1020
|
+
"start",
|
|
1021
|
+
"declare",
|
|
1022
|
+
"add",
|
|
1023
|
+
"review",
|
|
1024
|
+
"commit",
|
|
1025
|
+
"undo",
|
|
1026
|
+
"place",
|
|
1027
|
+
"move",
|
|
1028
|
+
"rotate",
|
|
1029
|
+
"reparent",
|
|
1030
|
+
"mount-shape",
|
|
1031
|
+
"report",
|
|
1032
|
+
}
|
|
1033
|
+
)
|
|
1034
|
+
_HOST_ONLY_COMMANDS = frozenset({"diff", "log"})
|
|
1035
|
+
|
|
1036
|
+
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _host_is_loopback(host_header: str | None) -> bool:
|
|
1040
|
+
"""``Host`` must name the loopback we bound. Absent is fine (a raw HTTP/1.0
|
|
1041
|
+
client); anything else is someone else's name."""
|
|
1042
|
+
if host_header is None:
|
|
1043
|
+
return True
|
|
1044
|
+
host = host_header.rsplit(":", 1)[0] if not host_header.startswith("[") else "::1"
|
|
1045
|
+
return host in _LOOPBACK_HOSTS
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _token_ok(offered: str | None, expected: str) -> bool:
|
|
1049
|
+
"""Constant time, always — a length or prefix leak is a token oracle."""
|
|
1050
|
+
if offered is None:
|
|
1051
|
+
return False
|
|
1052
|
+
return hmac.compare_digest(offered.encode("utf-8"), expected.encode("utf-8"))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
_ACCESS_LOG = logging.getLogger("lockwire.serve")
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def create_app(
|
|
1059
|
+
token: str,
|
|
1060
|
+
*,
|
|
1061
|
+
robot: bool = False,
|
|
1062
|
+
policy: Callable[[Any], Any] | None = None,
|
|
1063
|
+
) -> FastAPI:
|
|
1064
|
+
"""The app. `policy`, if given, rewrites each parsed request before it runs (a plugin's
|
|
1065
|
+
server-side limits); `plugins.load()` runs first so the command union includes its commands."""
|
|
1066
|
+
from fastapi import FastAPI, Request # noqa: PLC0415 - the `serve` extra
|
|
1067
|
+
from fastapi.responses import JSONResponse # noqa: PLC0415
|
|
1068
|
+
|
|
1069
|
+
plugins.ensure()
|
|
1070
|
+
table = commands()
|
|
1071
|
+
app = FastAPI(title="lockwire sidecar", version=__version__)
|
|
1072
|
+
app.state.token = token
|
|
1073
|
+
app.state.robot = robot
|
|
1074
|
+
for setup in plugins.SERVE_SETUP:
|
|
1075
|
+
setup(app.state)
|
|
1076
|
+
|
|
1077
|
+
@app.middleware("http")
|
|
1078
|
+
async def guard(request: Request, call_next): # noqa: ANN001, ANN202 - starlette's shape
|
|
1079
|
+
# Method, path and status only — never headers, so the token (carried
|
|
1080
|
+
# only in the X-Lockwire-Token header) never reaches the log,
|
|
1081
|
+
# on any of the three exits below.
|
|
1082
|
+
if not _host_is_loopback(request.headers.get("host")):
|
|
1083
|
+
_ACCESS_LOG.info("%s %s -> 400", request.method, request.url.path)
|
|
1084
|
+
return JSONResponse({"detail": "Host is not this loopback"}, status_code=400)
|
|
1085
|
+
if not _token_ok(request.headers.get("X-Lockwire-Token"), app.state.token):
|
|
1086
|
+
_ACCESS_LOG.info("%s %s -> 401", request.method, request.url.path)
|
|
1087
|
+
return JSONResponse({"detail": "missing or invalid X-Lockwire-Token"}, status_code=401)
|
|
1088
|
+
response = await call_next(request)
|
|
1089
|
+
_ACCESS_LOG.info("%s %s -> %s", request.method, request.url.path, response.status_code)
|
|
1090
|
+
return response
|
|
1091
|
+
|
|
1092
|
+
@app.get("/v1/hello", response_model=HelloReply)
|
|
1093
|
+
def hello() -> HelloReply:
|
|
1094
|
+
listed = [
|
|
1095
|
+
CommandInfo(name=name, args_schema=entry.args.model_json_schema())
|
|
1096
|
+
for name, entry in table.items()
|
|
1097
|
+
]
|
|
1098
|
+
return HelloReply(version=__version__, commands=listed)
|
|
1099
|
+
|
|
1100
|
+
def run(body): # noqa: ANN001, ANN202 - annotated below, once the union exists
|
|
1101
|
+
if policy is not None:
|
|
1102
|
+
body = policy(body)
|
|
1103
|
+
if app.state.robot and body.command in _WRITE_COMMANDS:
|
|
1104
|
+
return _refusal(
|
|
1105
|
+
f"lockwire {body.command}",
|
|
1106
|
+
2,
|
|
1107
|
+
f"write commands are not served on the robot: {body.command}",
|
|
1108
|
+
)
|
|
1109
|
+
if app.state.robot and body.command in _HOST_ONLY_COMMANDS:
|
|
1110
|
+
return _refusal(
|
|
1111
|
+
f"lockwire {body.command}",
|
|
1112
|
+
2,
|
|
1113
|
+
f"host-only commands are not served on the robot: {body.command}",
|
|
1114
|
+
)
|
|
1115
|
+
return dispatch(body, app.state)
|
|
1116
|
+
|
|
1117
|
+
run.__annotations__ = {"body": request_type(table), "return": RunReply}
|
|
1118
|
+
app.post("/v1/run", response_model=RunReply)(run)
|
|
1119
|
+
return app
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
# ---------------------------------------------------------------------------
|
|
1123
|
+
# ``lockwire serve``: bind, mint a token, print the one ready line, run until
|
|
1124
|
+
# stdin closes (the app was force-killed) or SIGTERM (uvicorn's own handler,
|
|
1125
|
+
# installed because this runs on the main thread).
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _watch_stdin(server: uvicorn.Server) -> None:
|
|
1129
|
+
"""A closed stdin is the whole orphan story: no stray process survives Electron."""
|
|
1130
|
+
while True:
|
|
1131
|
+
line = sys.stdin.readline()
|
|
1132
|
+
if line == "":
|
|
1133
|
+
server.should_exit = True
|
|
1134
|
+
return
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _log_dir() -> Path:
|
|
1138
|
+
"""The platform's real log location — never the cwd (a packaged app's
|
|
1139
|
+
working directory is wherever it happened to be launched from) and never a
|
|
1140
|
+
location the release checklist's uninstall promise ("only the log folder"
|
|
1141
|
+
survives, docs/release_checklist.md §2) would have to special-case.
|
|
1142
|
+
|
|
1143
|
+
macOS (the only shipping platform today): ``~/Library/Logs/Lockwire`` —
|
|
1144
|
+
the OS's own per-app log convention, shown by Console.app and untouched by an
|
|
1145
|
+
app bundle's own uninstall (dragging the .app to Trash). Linux/Windows follow
|
|
1146
|
+
each platform's equivalent convention so the same code holds if E6 ships them;
|
|
1147
|
+
no third-party dependency (stdlib + environment variables only).
|
|
1148
|
+
|
|
1149
|
+
``LOCKWIRE_LOG_DIR`` overrides everything above. The test suite sets it via an
|
|
1150
|
+
autouse fixture (``tests/conftest.py``) so no test run — including the
|
|
1151
|
+
subprocess-launched ``lockwire serve`` cases — ever writes into a developer's
|
|
1152
|
+
real ``~/Library/Logs``.
|
|
1153
|
+
"""
|
|
1154
|
+
override = os.environ.get("LOCKWIRE_LOG_DIR")
|
|
1155
|
+
if override:
|
|
1156
|
+
base = Path(override)
|
|
1157
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
1158
|
+
return base
|
|
1159
|
+
name = "Lockwire"
|
|
1160
|
+
if sys.platform == "darwin":
|
|
1161
|
+
base = Path.home() / "Library" / "Logs" / name
|
|
1162
|
+
elif sys.platform == "win32":
|
|
1163
|
+
base = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
|
1164
|
+
base = base / name / "logs"
|
|
1165
|
+
else:
|
|
1166
|
+
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")))
|
|
1167
|
+
base = base / "lockwire" / "log"
|
|
1168
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
1169
|
+
return base
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _configure_sidecar_logging(log_dir: Path) -> Path:
|
|
1173
|
+
"""Rotating file, stdlib only (``logging.handlers.RotatingFileHandler`` — no
|
|
1174
|
+
dependency, no hand-rolled rotator): a size bound and a small number of kept
|
|
1175
|
+
files, oldest discarded, so a long-running sidecar cannot fill a disk and a
|
|
1176
|
+
user who reports "it stopped working" has real files to send.
|
|
1177
|
+
|
|
1178
|
+
Formats only level/time/logger/message — request logging (the ``guard``
|
|
1179
|
+
middleware above) passes method, path and status, never headers, so the
|
|
1180
|
+
per-launch token never reaches this file.
|
|
1181
|
+
"""
|
|
1182
|
+
log_path = log_dir / "sidecar.log"
|
|
1183
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
1184
|
+
log_path, maxBytes=200_000, backupCount=5, encoding="utf-8"
|
|
1185
|
+
)
|
|
1186
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
1187
|
+
_ACCESS_LOG.handlers = [handler]
|
|
1188
|
+
_ACCESS_LOG.setLevel(logging.INFO)
|
|
1189
|
+
_ACCESS_LOG.propagate = False
|
|
1190
|
+
return log_path
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def run_server(
|
|
1194
|
+
host: str = "127.0.0.1",
|
|
1195
|
+
port: int = 0,
|
|
1196
|
+
*,
|
|
1197
|
+
robot: bool = False,
|
|
1198
|
+
exit_on_stdin_close: bool = False,
|
|
1199
|
+
profile: str | None = None,
|
|
1200
|
+
required_evidence: list[str] | None = None,
|
|
1201
|
+
) -> int:
|
|
1202
|
+
"""Serve the sidecar; returns a process exit code (never raises for a bind refusal).
|
|
1203
|
+
|
|
1204
|
+
``exit_on_stdin_close`` is opt-in, not always-on: the Electron parent passes it
|
|
1205
|
+
so no sidecar outlives a force-killed app, while a human running
|
|
1206
|
+
``lockwire serve`` in a terminal (or CI redirecting stdin from /dev/null)
|
|
1207
|
+
keeps a server that does not vanish the moment stdin is not a live pipe.
|
|
1208
|
+
"""
|
|
1209
|
+
if host not in _LOOPBACK_HOSTS:
|
|
1210
|
+
print(f"lockwire serve refuses to bind {host!r} — loopback only", file=sys.stderr)
|
|
1211
|
+
return 2
|
|
1212
|
+
|
|
1213
|
+
policy = None
|
|
1214
|
+
if profile is not None or required_evidence:
|
|
1215
|
+
plugins.ensure()
|
|
1216
|
+
try:
|
|
1217
|
+
if plugins.SERVE_POLICY is None:
|
|
1218
|
+
raise ValueError("this install has no server policy to apply")
|
|
1219
|
+
policy = plugins.SERVE_POLICY(profile, required_evidence)
|
|
1220
|
+
except ValueError as error:
|
|
1221
|
+
print(f"lockwire serve refused: {error}", file=sys.stderr)
|
|
1222
|
+
return 2
|
|
1223
|
+
log_path = _configure_sidecar_logging(_log_dir())
|
|
1224
|
+
token = secrets.token_urlsafe(32)
|
|
1225
|
+
import uvicorn # noqa: PLC0415 - the `serve` extra
|
|
1226
|
+
|
|
1227
|
+
app = create_app(token, robot=robot, policy=policy)
|
|
1228
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
|
1229
|
+
server = uvicorn.Server(config)
|
|
1230
|
+
original_startup = server.startup
|
|
1231
|
+
|
|
1232
|
+
async def startup(sockets=None) -> None: # noqa: ANN001 - matches uvicorn's own signature
|
|
1233
|
+
await original_startup(sockets=sockets)
|
|
1234
|
+
if server.started:
|
|
1235
|
+
bound_port = server.servers[0].sockets[0].getsockname()[1]
|
|
1236
|
+
# Written straight to stdout, never through the rich console: the
|
|
1237
|
+
# parent parses this line, and one ANSI escape makes it unparseable.
|
|
1238
|
+
sys.stdout.write(
|
|
1239
|
+
json.dumps(
|
|
1240
|
+
{
|
|
1241
|
+
"lockwire": "ready",
|
|
1242
|
+
"version": __version__,
|
|
1243
|
+
"port": bound_port,
|
|
1244
|
+
"token": token,
|
|
1245
|
+
"log": str(log_path),
|
|
1246
|
+
}
|
|
1247
|
+
)
|
|
1248
|
+
+ "\n"
|
|
1249
|
+
)
|
|
1250
|
+
sys.stdout.flush()
|
|
1251
|
+
if exit_on_stdin_close:
|
|
1252
|
+
threading.Thread(target=_watch_stdin, args=(server,), daemon=True).start()
|
|
1253
|
+
|
|
1254
|
+
server.startup = startup # type: ignore[method-assign]
|
|
1255
|
+
server.run()
|
|
1256
|
+
return 0
|