llar 0.1.1__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.
- llar/__init__.py +2 -0
- llar/__main__.py +7 -0
- llar/cli.py +646 -0
- llar/core/__init__.py +2 -0
- llar/core/activity.py +32 -0
- llar/core/associations.py +271 -0
- llar/core/atomic.py +38 -0
- llar/core/context.py +36 -0
- llar/core/conversations.py +93 -0
- llar/core/coverage.py +293 -0
- llar/core/doctor.py +72 -0
- llar/core/file_copy.py +431 -0
- llar/core/inspection.py +40 -0
- llar/core/inventory.py +41 -0
- llar/core/scope.py +29 -0
- llar/core/snapshot.py +94 -0
- llar/core/ssh.py +335 -0
- llar/core/ssh_receiver.py +161 -0
- llar/core/ssh_transfer.py +306 -0
- llar/core/state.py +185 -0
- llar/core/transfer_plan.py +725 -0
- llar/core/transfers.py +206 -0
- llar/harnesses/__init__.py +8 -0
- llar/harnesses/base.py +19 -0
- llar/harnesses/claude/__init__.py +121 -0
- llar/harnesses/claude/activity.py +80 -0
- llar/harnesses/claude/copy.py +577 -0
- llar/harnesses/claude/locking.py +136 -0
- llar/harnesses/claude/paths.py +18 -0
- llar/harnesses/claude/records.py +220 -0
- llar/harnesses/claude/state.py +385 -0
- llar/harnesses/claude/transfer.py +131 -0
- llar/harnesses/codex/Apache-2.0.txt +201 -0
- llar/harnesses/codex/NOTICE +6 -0
- llar/harnesses/codex/__init__.py +96 -0
- llar/harnesses/codex/activity.py +75 -0
- llar/harnesses/codex/copy.py +355 -0
- llar/harnesses/codex/database.py +155 -0
- llar/harnesses/codex/import_state.py +481 -0
- llar/harnesses/codex/native-schema.json +621 -0
- llar/harnesses/codex/publication.py +108 -0
- llar/harnesses/codex/schemas.json +501 -0
- llar/harnesses/codex/schemas.py +38 -0
- llar/harnesses/codex/state.py +519 -0
- llar/harnesses/codex/transfer.py +100 -0
- llar/harnesses/transfers.py +91 -0
- llar-0.1.1.dist-info/METADATA +311 -0
- llar-0.1.1.dist-info/RECORD +51 -0
- llar-0.1.1.dist-info/WHEEL +4 -0
- llar-0.1.1.dist-info/entry_points.txt +3 -0
- llar-0.1.1.dist-info/licenses/LICENSE +21 -0
llar/__init__.py
ADDED
llar/__main__.py
ADDED
llar/cli.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# ABOUTME: Exposes project summaries, conversations, activity and diagnostics.
|
|
2
|
+
# ABOUTME: Applies explicit directory scopes to the appropriate evidence fields.
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import stat
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from llar.core.context import Context
|
|
13
|
+
from llar.core.coverage import Report
|
|
14
|
+
from llar.core.file_copy import CopyResult
|
|
15
|
+
from llar.core.inventory import Project
|
|
16
|
+
from llar.core.scope import Scope
|
|
17
|
+
from llar.core.snapshot import Snapshot, collect
|
|
18
|
+
from llar.core.transfer_plan import TransferPlan, build_plan
|
|
19
|
+
from llar.core.transfers import Endpoint, TransferError
|
|
20
|
+
from llar.harnesses import HARNESSES
|
|
21
|
+
from llar.harnesses.base import Harness
|
|
22
|
+
from llar.harnesses.transfers import execute_application
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
help="Look after the local state coding agents keep about your projects.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
projects_app = typer.Typer(
|
|
30
|
+
no_args_is_help=True, help="Inspect known project directories."
|
|
31
|
+
)
|
|
32
|
+
sessions_app = typer.Typer(no_args_is_help=True, help="Inspect logical conversations.")
|
|
33
|
+
activity_app = typer.Typer(no_args_is_help=True, help="Inspect runtime observations.")
|
|
34
|
+
trust_app = typer.Typer(no_args_is_help=True, help="Inspect explicit trust settings.")
|
|
35
|
+
app.add_typer(projects_app, name="projects")
|
|
36
|
+
app.add_typer(sessions_app, name="sessions")
|
|
37
|
+
app.add_typer(activity_app, name="activity")
|
|
38
|
+
projects_app.add_typer(trust_app, name="trust")
|
|
39
|
+
|
|
40
|
+
Home = Annotated[Path | None, typer.Option(help="Inspect an isolated alternate home.")]
|
|
41
|
+
Json = Annotated[bool, typer.Option("--json", help="Emit machine-readable output.")]
|
|
42
|
+
Selection = Annotated[
|
|
43
|
+
list[str] | None, typer.Option("--harness", help="Select a harness; repeatable.")
|
|
44
|
+
]
|
|
45
|
+
Directory = Annotated[
|
|
46
|
+
Path | None, typer.Option("--dir", help="Exact associated directory.")
|
|
47
|
+
]
|
|
48
|
+
Recursive = Annotated[bool, typer.Option(help="Include descendants of --dir.")]
|
|
49
|
+
Unassigned = Annotated[bool, typer.Option(help="Only objects with unknown ownership.")]
|
|
50
|
+
ProjectPath = Annotated[Path, typer.Argument(help="Exact project directory.")]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def selected(names: list[str] | None) -> list[Harness]:
|
|
54
|
+
if names is not None and set(names) - {harness.name for harness in HARNESSES}:
|
|
55
|
+
raise typer.BadParameter("Unknown harness. Choose claude or codex.")
|
|
56
|
+
return [harness for harness in HARNESSES if names is None or harness.name in names]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inspect(home: Path | None, names: list[str] | None) -> Snapshot:
|
|
60
|
+
return collect(Context.create(home), selected(names))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scope(
|
|
64
|
+
directory: Path | None, recursive: bool = False, unassigned: bool = False
|
|
65
|
+
) -> Scope:
|
|
66
|
+
try:
|
|
67
|
+
return Scope(
|
|
68
|
+
directory.expanduser().resolve() if directory is not None else None,
|
|
69
|
+
recursive,
|
|
70
|
+
unassigned,
|
|
71
|
+
)
|
|
72
|
+
except ValueError as error:
|
|
73
|
+
raise typer.BadParameter(str(error)) from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit(
|
|
77
|
+
rows: list[dict[str, object]],
|
|
78
|
+
json_: bool,
|
|
79
|
+
report: Report,
|
|
80
|
+
*,
|
|
81
|
+
empty: str = "No matching records in inspected sources.",
|
|
82
|
+
) -> None:
|
|
83
|
+
if json_:
|
|
84
|
+
typer.echo(
|
|
85
|
+
json.dumps(
|
|
86
|
+
{
|
|
87
|
+
"schema_version": 1,
|
|
88
|
+
"items": rows,
|
|
89
|
+
"diagnostics": report.diagnostics,
|
|
90
|
+
"coverage": report.as_dict(),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
if not rows:
|
|
96
|
+
typer.echo(
|
|
97
|
+
empty
|
|
98
|
+
if report.complete
|
|
99
|
+
else "Inspection incomplete; no matching records in readable sources."
|
|
100
|
+
)
|
|
101
|
+
for row in rows:
|
|
102
|
+
if "explicit_trust" in row:
|
|
103
|
+
typer.echo(f"{row['harness']} {json.dumps(row['path'])}")
|
|
104
|
+
trust = row["explicit_trust"]
|
|
105
|
+
if trust is None:
|
|
106
|
+
trust = (
|
|
107
|
+
"unset" if row.get("explicit_trust_inspected") else "unknown"
|
|
108
|
+
)
|
|
109
|
+
typer.echo(f" explicit_trust={trust}")
|
|
110
|
+
if "session_count" in row:
|
|
111
|
+
typer.echo(
|
|
112
|
+
f" exists={row['exists']}, known={row['known']}, "
|
|
113
|
+
f"sessions={row['session_count']}, "
|
|
114
|
+
f"transcripts={row['transcript_count']}, "
|
|
115
|
+
f"activity_records={row['activity_record_count']}, "
|
|
116
|
+
f"live_sessions={row['live_session_count']}"
|
|
117
|
+
)
|
|
118
|
+
typer.echo(" memory_inventory=not_inspected")
|
|
119
|
+
paths = row.get("state_paths", [])
|
|
120
|
+
if isinstance(paths, list):
|
|
121
|
+
for path in paths:
|
|
122
|
+
typer.echo(f" state: {json.dumps(path)}")
|
|
123
|
+
typer.echo(f" config: {json.dumps(row['config_file'])}")
|
|
124
|
+
elif "ref" in row:
|
|
125
|
+
typer.echo(
|
|
126
|
+
f"{json.dumps(row['ref'])} "
|
|
127
|
+
f"started_in={json.dumps(row['started_in'])}"
|
|
128
|
+
)
|
|
129
|
+
typer.echo(
|
|
130
|
+
f" activity={row['activity']}, "
|
|
131
|
+
f"transcripts={row['transcript_count']}, "
|
|
132
|
+
f"activity_records={row['activity_record_count']}"
|
|
133
|
+
)
|
|
134
|
+
elif "session_id" in row:
|
|
135
|
+
typer.echo(
|
|
136
|
+
f"{row['harness']} {row['state']} {json.dumps(row['session_id'])} "
|
|
137
|
+
f"observed_in={json.dumps(row['observed_in'])}"
|
|
138
|
+
)
|
|
139
|
+
typer.echo(f" Evidence: {row['evidence']}")
|
|
140
|
+
elif "code" in row:
|
|
141
|
+
typer.echo(f"{row['harness']} {row['code']} {json.dumps(row['path'])}")
|
|
142
|
+
typer.echo(f" Evidence: {row['evidence']}")
|
|
143
|
+
typer.echo(f" Next: {row['suggestion']}")
|
|
144
|
+
else:
|
|
145
|
+
typer.echo(json.dumps(row, ensure_ascii=True))
|
|
146
|
+
typer.echo(
|
|
147
|
+
f"Coverage: {'complete' if report.complete else 'incomplete'} "
|
|
148
|
+
f"for inspected metadata; {len(report.limitations)} declared limitations."
|
|
149
|
+
)
|
|
150
|
+
for limitation in report.limitations:
|
|
151
|
+
typer.echo(f" Limit: {limitation['harness']}: {limitation['detail']}")
|
|
152
|
+
for diagnostic in report.diagnostics:
|
|
153
|
+
typer.echo(
|
|
154
|
+
f"{diagnostic['harness']} {diagnostic['code']}: "
|
|
155
|
+
f"{diagnostic['message']} {json.dumps(diagnostic['path'])}",
|
|
156
|
+
err=True,
|
|
157
|
+
)
|
|
158
|
+
if not report.complete:
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def project_rows(
|
|
163
|
+
snapshot: Snapshot,
|
|
164
|
+
context: Context,
|
|
165
|
+
names: list[str] | None,
|
|
166
|
+
path: Path,
|
|
167
|
+
) -> list[dict[str, object]]:
|
|
168
|
+
target = path.expanduser().resolve()
|
|
169
|
+
result: list[dict[str, object]] = []
|
|
170
|
+
for harness in selected(names):
|
|
171
|
+
matches = [
|
|
172
|
+
project
|
|
173
|
+
for project in snapshot.projects
|
|
174
|
+
if project.path == target and project.harness == harness.name
|
|
175
|
+
]
|
|
176
|
+
if not matches:
|
|
177
|
+
item = Project(target, harness.name, harness.config_file(context))
|
|
178
|
+
item.explicit_trust_inspected = snapshot.report.inspected(
|
|
179
|
+
harness.name, "project_config"
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
item.exists = stat.S_ISDIR(target.stat().st_mode)
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
item.exists = False
|
|
185
|
+
except OSError:
|
|
186
|
+
snapshot.report.add(
|
|
187
|
+
harness.name,
|
|
188
|
+
"unreadable-project",
|
|
189
|
+
target,
|
|
190
|
+
"Cannot inspect the requested project directory.",
|
|
191
|
+
)
|
|
192
|
+
matches = [item]
|
|
193
|
+
result.extend(project.as_dict() for project in matches)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def show_version(value: bool) -> None:
|
|
198
|
+
if value:
|
|
199
|
+
typer.echo(version("llar"))
|
|
200
|
+
raise typer.Exit()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.callback()
|
|
204
|
+
def main(
|
|
205
|
+
version_: Annotated[
|
|
206
|
+
bool, typer.Option("--version", callback=show_version, is_eager=True)
|
|
207
|
+
] = False,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Inspect local coding-agent state."""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def harnesses(
|
|
214
|
+
home: Home = None, json_: Json = False, harness: Selection = None
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Show detected harnesses and supported read capabilities."""
|
|
217
|
+
context = Context.create(home)
|
|
218
|
+
report = Report()
|
|
219
|
+
rows: list[dict[str, object]] = []
|
|
220
|
+
for item in selected(harness):
|
|
221
|
+
root = item.state_home(context)
|
|
222
|
+
config_path = item.config_file(context)
|
|
223
|
+
home_source = report.source(item.name, "home_discovery", root)
|
|
224
|
+
config_source = report.source(item.name, "config_discovery", config_path)
|
|
225
|
+
home_mode = home_source.mode(root, optional=True)
|
|
226
|
+
config_mode = config_source.mode(config_path, optional=True)
|
|
227
|
+
if home_mode is not None and not stat.S_ISDIR(home_mode):
|
|
228
|
+
home_source.problem(
|
|
229
|
+
"unsupported-home", root, "Harness home is not a directory."
|
|
230
|
+
)
|
|
231
|
+
if config_mode is not None and not stat.S_ISREG(config_mode):
|
|
232
|
+
config_source.problem(
|
|
233
|
+
"unsupported-config", config_path, "Config is not a regular file."
|
|
234
|
+
)
|
|
235
|
+
present = (home_mode is not None and stat.S_ISDIR(home_mode)) or (
|
|
236
|
+
config_mode is not None and stat.S_ISREG(config_mode)
|
|
237
|
+
)
|
|
238
|
+
unknown = home_source.status == "partial" or config_source.status == "partial"
|
|
239
|
+
rows.append(
|
|
240
|
+
{
|
|
241
|
+
"name": item.name,
|
|
242
|
+
"present": True if present else None if unknown else False,
|
|
243
|
+
"config_file": str(config_path),
|
|
244
|
+
"state_locations": [str(root), str(config_path)],
|
|
245
|
+
"capabilities": [
|
|
246
|
+
"projects",
|
|
247
|
+
"conversations",
|
|
248
|
+
"activity",
|
|
249
|
+
"diagnostics",
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
emit(rows, json_, report)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.command()
|
|
257
|
+
def status(
|
|
258
|
+
path: ProjectPath = Path("."),
|
|
259
|
+
home: Home = None,
|
|
260
|
+
json_: Json = False,
|
|
261
|
+
harness: Selection = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Summarize one exact directory, including discovered storage locations."""
|
|
264
|
+
snapshot = inspect(home, harness)
|
|
265
|
+
emit(
|
|
266
|
+
project_rows(snapshot, Context.create(home), harness, path),
|
|
267
|
+
json_,
|
|
268
|
+
snapshot.report,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@projects_app.command("list")
|
|
273
|
+
def list_projects(
|
|
274
|
+
home: Home = None,
|
|
275
|
+
json_: Json = False,
|
|
276
|
+
harness: Selection = None,
|
|
277
|
+
directory: Directory = None,
|
|
278
|
+
recursive: Recursive = False,
|
|
279
|
+
missing: Annotated[bool, typer.Option(help="Only missing directories.")] = False,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""List all recorded directories, optionally selecting an exact subtree."""
|
|
282
|
+
selection = scope(directory, recursive)
|
|
283
|
+
snapshot = inspect(home, harness)
|
|
284
|
+
emit(
|
|
285
|
+
[
|
|
286
|
+
row.as_dict()
|
|
287
|
+
for row in snapshot.projects
|
|
288
|
+
if selection.matches(row.path) and (not missing or row.exists is False)
|
|
289
|
+
],
|
|
290
|
+
json_,
|
|
291
|
+
snapshot.report,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@trust_app.command("show")
|
|
296
|
+
def show_trust(
|
|
297
|
+
path: ProjectPath = Path("."),
|
|
298
|
+
home: Home = None,
|
|
299
|
+
json_: Json = False,
|
|
300
|
+
harness: Selection = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Show stored explicit trust, without inferring effective trust."""
|
|
303
|
+
snapshot = inspect(home, harness)
|
|
304
|
+
emit(
|
|
305
|
+
[
|
|
306
|
+
{
|
|
307
|
+
key: row[key]
|
|
308
|
+
for key in (
|
|
309
|
+
"path",
|
|
310
|
+
"harness",
|
|
311
|
+
"explicit_trust",
|
|
312
|
+
"explicit_trust_inspected",
|
|
313
|
+
"config_file",
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
for row in project_rows(snapshot, Context.create(home), harness, path)
|
|
317
|
+
],
|
|
318
|
+
json_,
|
|
319
|
+
snapshot.report,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@sessions_app.command("list")
|
|
324
|
+
def list_sessions(
|
|
325
|
+
home: Home = None,
|
|
326
|
+
json_: Json = False,
|
|
327
|
+
harness: Selection = None,
|
|
328
|
+
directory: Directory = None,
|
|
329
|
+
recursive: Recursive = False,
|
|
330
|
+
unassigned: Unassigned = False,
|
|
331
|
+
live: Annotated[
|
|
332
|
+
bool, typer.Option(help="Only verified active conversations.")
|
|
333
|
+
] = False,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""List conversations by starting directory, never substituting runtime cwd."""
|
|
336
|
+
selection = scope(directory, recursive, unassigned)
|
|
337
|
+
snapshot = inspect(home, harness)
|
|
338
|
+
emit(
|
|
339
|
+
[
|
|
340
|
+
row.as_dict()
|
|
341
|
+
for row in snapshot.conversations
|
|
342
|
+
if selection.matches(row.started_in)
|
|
343
|
+
and (not live or row.activity_state == "live")
|
|
344
|
+
],
|
|
345
|
+
json_,
|
|
346
|
+
snapshot.report,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@sessions_app.command("show")
|
|
351
|
+
def show_session(
|
|
352
|
+
ref: str, home: Home = None, json_: Json = False, harness: Selection = None
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Show metadata for one harness-qualified or unambiguous native session ID."""
|
|
355
|
+
snapshot = inspect(home, harness)
|
|
356
|
+
matches = [
|
|
357
|
+
row for row in snapshot.conversations if row.ref == ref or row.session_id == ref
|
|
358
|
+
]
|
|
359
|
+
if not matches and not snapshot.report.complete:
|
|
360
|
+
emit([], json_, snapshot.report)
|
|
361
|
+
if len(matches) != 1:
|
|
362
|
+
raise typer.BadParameter(
|
|
363
|
+
"Session reference is missing or ambiguous; use harness:ID."
|
|
364
|
+
)
|
|
365
|
+
emit([matches[0].as_dict()], json_, snapshot.report)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@activity_app.command("list")
|
|
369
|
+
def list_activity(
|
|
370
|
+
home: Home = None,
|
|
371
|
+
json_: Json = False,
|
|
372
|
+
harness: Selection = None,
|
|
373
|
+
directory: Directory = None,
|
|
374
|
+
recursive: Recursive = False,
|
|
375
|
+
unassigned: Unassigned = False,
|
|
376
|
+
state: Annotated[
|
|
377
|
+
str | None, typer.Option(help="Filter live, stale or unknown observations.")
|
|
378
|
+
] = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""List runtime observations by observed directory, including stale records."""
|
|
381
|
+
selection = scope(directory, recursive, unassigned)
|
|
382
|
+
if state is not None and state not in ("live", "stale", "unknown"):
|
|
383
|
+
raise typer.BadParameter("--state must be live, stale or unknown.")
|
|
384
|
+
snapshot = inspect(home, harness)
|
|
385
|
+
emit(
|
|
386
|
+
[
|
|
387
|
+
row.as_dict()
|
|
388
|
+
for row in snapshot.activity
|
|
389
|
+
if selection.matches(row.observed_in)
|
|
390
|
+
and (state is None or row.state == state)
|
|
391
|
+
],
|
|
392
|
+
json_,
|
|
393
|
+
snapshot.report,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command()
|
|
398
|
+
def doctor(
|
|
399
|
+
home: Home = None,
|
|
400
|
+
json_: Json = False,
|
|
401
|
+
harness: Selection = None,
|
|
402
|
+
directory: Directory = None,
|
|
403
|
+
recursive: Recursive = False,
|
|
404
|
+
unassigned: Unassigned = False,
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Explain issues in inspected state without repairing anything."""
|
|
407
|
+
selection = scope(directory, recursive, unassigned)
|
|
408
|
+
snapshot = inspect(home, harness)
|
|
409
|
+
findings = snapshot.findings
|
|
410
|
+
emit(
|
|
411
|
+
[
|
|
412
|
+
row.as_dict()
|
|
413
|
+
for row in findings
|
|
414
|
+
if selection.matches(
|
|
415
|
+
None if row.code == "unassigned-state" else Path(row.path)
|
|
416
|
+
)
|
|
417
|
+
],
|
|
418
|
+
json_,
|
|
419
|
+
snapshot.report,
|
|
420
|
+
empty="No issues found in inspected state.",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def emit_transfer(
|
|
425
|
+
plan: TransferPlan,
|
|
426
|
+
*,
|
|
427
|
+
apply: bool,
|
|
428
|
+
json_: bool,
|
|
429
|
+
result: CopyResult | None = None,
|
|
430
|
+
) -> None:
|
|
431
|
+
mode = "apply" if apply else "dry-run"
|
|
432
|
+
row = {"mode": mode, **plan.as_dict()}
|
|
433
|
+
if result is not None:
|
|
434
|
+
row.update(result.as_dict())
|
|
435
|
+
if json_:
|
|
436
|
+
typer.echo(
|
|
437
|
+
json.dumps(
|
|
438
|
+
{
|
|
439
|
+
"schema_version": 1,
|
|
440
|
+
"items": [row],
|
|
441
|
+
"diagnostics": plan.diagnostics,
|
|
442
|
+
"coverage": plan.coverage(),
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
typer.echo(f"{plan.operation} {mode}")
|
|
448
|
+
for label, address, root in (
|
|
449
|
+
("Source", plan.source_endpoint, plan.source),
|
|
450
|
+
("Destination", plan.destination_endpoint, plan.destination),
|
|
451
|
+
):
|
|
452
|
+
value = str(root) if root is not None else f"{address.host}:{address.path}"
|
|
453
|
+
if address.host is not None and root is not None:
|
|
454
|
+
value = f"{address.host}:{root}"
|
|
455
|
+
typer.echo(f"{label}: {json.dumps(value)}")
|
|
456
|
+
typer.echo("Components: " + ", ".join(plan.components))
|
|
457
|
+
typer.echo(f"Proposed changes: {len(plan.changes)}")
|
|
458
|
+
for change in plan.changes:
|
|
459
|
+
target = str(change.destination) if change.destination is not None else None
|
|
460
|
+
typer.echo(
|
|
461
|
+
f" {change.effect} {change.harness or 'filesystem'} "
|
|
462
|
+
f"{change.component}: {json.dumps(str(change.source))} "
|
|
463
|
+
f"-> {json.dumps(target)}"
|
|
464
|
+
)
|
|
465
|
+
if change.source_key or change.destination_key:
|
|
466
|
+
typer.echo(
|
|
467
|
+
f" selector: {json.dumps(change.source_key)} "
|
|
468
|
+
f"-> {json.dumps(change.destination_key)}"
|
|
469
|
+
)
|
|
470
|
+
if change.destination_owners:
|
|
471
|
+
typer.echo(
|
|
472
|
+
" owners: "
|
|
473
|
+
+ json.dumps([str(owner) for owner in change.destination_owners])
|
|
474
|
+
)
|
|
475
|
+
if change.started_in is not None:
|
|
476
|
+
typer.echo(
|
|
477
|
+
f" started_in (preserved): {json.dumps(str(change.started_in))}"
|
|
478
|
+
)
|
|
479
|
+
if change.cleanup == "after_verification":
|
|
480
|
+
typer.echo(" source cleanup only after verification")
|
|
481
|
+
for blocker in plan.blockers:
|
|
482
|
+
location = str(blocker.path) if blocker.path is not None else None
|
|
483
|
+
typer.echo(
|
|
484
|
+
f"BLOCKED {blocker.code} ({blocker.endpoint}): "
|
|
485
|
+
f"{json.dumps(blocker.message)} {json.dumps(location)}"
|
|
486
|
+
)
|
|
487
|
+
for diagnostic in plan.diagnostics:
|
|
488
|
+
if diagnostic.get("incomplete") is True:
|
|
489
|
+
typer.echo(
|
|
490
|
+
f" {diagnostic['endpoint']} {diagnostic['code']}: "
|
|
491
|
+
f"{json.dumps(diagnostic['message'])} "
|
|
492
|
+
f"{json.dumps(diagnostic['path'])}"
|
|
493
|
+
)
|
|
494
|
+
typer.echo(
|
|
495
|
+
"Inspection coverage: "
|
|
496
|
+
+ ("complete" if plan.coverage()["complete"] else "incomplete")
|
|
497
|
+
+ " for declared metadata. Transfer support is incomplete."
|
|
498
|
+
)
|
|
499
|
+
if result is not None and result.applied:
|
|
500
|
+
typer.echo("Copy completed and verified. Source retained.")
|
|
501
|
+
elif result is not None and result.error is not None:
|
|
502
|
+
typer.echo(f"Apply failed: {result.error}. Source retained.")
|
|
503
|
+
publication = (
|
|
504
|
+
"unknown"
|
|
505
|
+
if row.get("publication_unknown")
|
|
506
|
+
else result.destination_published
|
|
507
|
+
)
|
|
508
|
+
typer.echo(f"Destination published: {publication}")
|
|
509
|
+
else:
|
|
510
|
+
typer.echo("No changes made.")
|
|
511
|
+
if plan.can_apply:
|
|
512
|
+
typer.echo("Use --apply to perform this copy.")
|
|
513
|
+
native = row.get("published_native")
|
|
514
|
+
if isinstance(native, list):
|
|
515
|
+
for path in native:
|
|
516
|
+
typer.echo("Published native state: " + json.dumps(path))
|
|
517
|
+
if result is not None and result.record is not None:
|
|
518
|
+
host = row.get("operation_host")
|
|
519
|
+
receipt = f"{host}:{result.record}" if host else str(result.record)
|
|
520
|
+
typer.echo("Operation record: " + json.dumps(receipt))
|
|
521
|
+
if result is not None and result.staging is not None:
|
|
522
|
+
host = row.get("operation_host")
|
|
523
|
+
stage = f"{host}:{result.staging}" if host else str(result.staging)
|
|
524
|
+
typer.echo("Staging retained: " + json.dumps(stage))
|
|
525
|
+
if (
|
|
526
|
+
plan.blockers
|
|
527
|
+
or not plan.coverage()["complete"]
|
|
528
|
+
or result is not None
|
|
529
|
+
and result.error is not None
|
|
530
|
+
):
|
|
531
|
+
raise typer.Exit(1)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@app.command("mv", help="Plan a project move; application is not implemented.")
|
|
535
|
+
@app.command("cp", help="Plan a copy; apply files or supported local native state.")
|
|
536
|
+
def transfer(
|
|
537
|
+
context: typer.Context,
|
|
538
|
+
source: Annotated[
|
|
539
|
+
str, typer.Argument(help="Explicit local or SSH source directory.")
|
|
540
|
+
],
|
|
541
|
+
destination: Annotated[
|
|
542
|
+
str, typer.Argument(help="Exact resulting local or SSH project directory.")
|
|
543
|
+
],
|
|
544
|
+
home: Home = None,
|
|
545
|
+
target_home: Annotated[
|
|
546
|
+
Path | None,
|
|
547
|
+
typer.Option(
|
|
548
|
+
"--target-home",
|
|
549
|
+
help="Isolated destination home; defaults to --home for local transfers.",
|
|
550
|
+
),
|
|
551
|
+
] = None,
|
|
552
|
+
ssh_config: Annotated[
|
|
553
|
+
Path | None,
|
|
554
|
+
typer.Option(
|
|
555
|
+
"--ssh-config", help="OpenSSH configuration file for this transfer."
|
|
556
|
+
),
|
|
557
|
+
] = None,
|
|
558
|
+
only: Annotated[
|
|
559
|
+
list[str] | None,
|
|
560
|
+
typer.Option(
|
|
561
|
+
"--only",
|
|
562
|
+
help="Select files, sessions, memory, settings or artifacts; repeatable.",
|
|
563
|
+
),
|
|
564
|
+
] = None,
|
|
565
|
+
harness: Selection = None,
|
|
566
|
+
dry_run: Annotated[
|
|
567
|
+
bool, typer.Option("--dry-run", help="Show the transfer plan (default).")
|
|
568
|
+
] = False,
|
|
569
|
+
apply_: Annotated[
|
|
570
|
+
bool,
|
|
571
|
+
typer.Option("--apply", help="Apply a supported transfer after preflight."),
|
|
572
|
+
] = False,
|
|
573
|
+
json_: Json = False,
|
|
574
|
+
) -> None:
|
|
575
|
+
if dry_run and apply_:
|
|
576
|
+
raise typer.BadParameter("--apply and --dry-run are mutually exclusive.")
|
|
577
|
+
names = tuple(item.name for item in selected(harness))
|
|
578
|
+
try:
|
|
579
|
+
origin = Endpoint.parse(source)
|
|
580
|
+
target = Endpoint.parse(destination)
|
|
581
|
+
if target_home is None and origin.host is None and target.host is None:
|
|
582
|
+
target_home = home
|
|
583
|
+
if origin.host is not None or target.host is not None:
|
|
584
|
+
from llar.core.ssh_transfer import execute, plan_transfer
|
|
585
|
+
|
|
586
|
+
plan, connection = plan_transfer(
|
|
587
|
+
"mv" if context.info_name == "mv" else "cp",
|
|
588
|
+
origin,
|
|
589
|
+
target,
|
|
590
|
+
home=home,
|
|
591
|
+
target_home=target_home,
|
|
592
|
+
only=set(only) if only is not None else None,
|
|
593
|
+
harnesses=names,
|
|
594
|
+
ssh_config=ssh_config,
|
|
595
|
+
)
|
|
596
|
+
remote_result = execute(plan, connection, apply=True) if apply_ else None
|
|
597
|
+
emit_transfer(plan, apply=apply_, json_=json_, result=remote_result)
|
|
598
|
+
return
|
|
599
|
+
if ssh_config is not None:
|
|
600
|
+
raise typer.BadParameter("--ssh-config requires one SSH endpoint.")
|
|
601
|
+
plan = build_plan(
|
|
602
|
+
"mv" if context.info_name == "mv" else "cp",
|
|
603
|
+
origin,
|
|
604
|
+
target,
|
|
605
|
+
Context.create(home),
|
|
606
|
+
Context.create(target_home),
|
|
607
|
+
only=set(only) if only is not None else None,
|
|
608
|
+
harnesses=names,
|
|
609
|
+
)
|
|
610
|
+
except typer.Exit:
|
|
611
|
+
raise
|
|
612
|
+
except TransferError as error:
|
|
613
|
+
raise typer.BadParameter(str(error)) from None
|
|
614
|
+
except (OSError, RuntimeError):
|
|
615
|
+
raise typer.BadParameter(
|
|
616
|
+
"Cannot resolve the requested filesystem context."
|
|
617
|
+
) from None
|
|
618
|
+
result = (
|
|
619
|
+
execute_application(
|
|
620
|
+
plan,
|
|
621
|
+
Context.create(home),
|
|
622
|
+
Context.create(target_home),
|
|
623
|
+
apply=True,
|
|
624
|
+
)
|
|
625
|
+
if apply_ and plan.can_apply
|
|
626
|
+
else None
|
|
627
|
+
)
|
|
628
|
+
emit_transfer(plan, apply=apply_, json_=json_, result=result)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@app.command("_remote", hidden=True)
|
|
632
|
+
def remote_peer() -> None:
|
|
633
|
+
from llar.core.ssh import serve
|
|
634
|
+
|
|
635
|
+
serve()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@app.command(
|
|
639
|
+
"_rsync",
|
|
640
|
+
hidden=True,
|
|
641
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
642
|
+
)
|
|
643
|
+
def remote_rsync(context: typer.Context) -> None:
|
|
644
|
+
from llar.core.ssh import serve_rsync
|
|
645
|
+
|
|
646
|
+
serve_rsync(context.args)
|
llar/core/__init__.py
ADDED
llar/core/activity.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ABOUTME: Represents live, stale and unverifiable runtime ownership observations.
|
|
2
|
+
# ABOUTME: Keeps process and writer-lock evidence separate from transcript counts.
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
ActivityState = Literal["live", "stale", "unknown"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Activity:
|
|
13
|
+
harness: str
|
|
14
|
+
session_id: str | None
|
|
15
|
+
observed_in: Path | None
|
|
16
|
+
source: Path
|
|
17
|
+
state: ActivityState
|
|
18
|
+
evidence: str
|
|
19
|
+
pid: int | None = None
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, object]:
|
|
22
|
+
return {
|
|
23
|
+
"harness": self.harness,
|
|
24
|
+
"session_id": self.session_id,
|
|
25
|
+
"observed_in": str(self.observed_in)
|
|
26
|
+
if self.observed_in is not None
|
|
27
|
+
else None,
|
|
28
|
+
"source": str(self.source),
|
|
29
|
+
"state": self.state,
|
|
30
|
+
"evidence": self.evidence,
|
|
31
|
+
"pid": self.pid,
|
|
32
|
+
}
|