lagora-cli 1.1.0
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.
- package/README.md +138 -0
- package/dist/help.txt +70 -0
- package/dist/lagora.js +342 -0
- package/dist/report-help.txt +5 -0
- package/dist/scripts/agora_playground_harness.py +263 -0
- package/dist/scripts/announce.js +41 -0
- package/dist/scripts/check-kernel-submission.py +90 -0
- package/dist/scripts/chunk-2EAJVB5D.js +100 -0
- package/dist/scripts/chunk-2KTLCUFI.js +29 -0
- package/dist/scripts/chunk-AZ3EEBVD.js +137 -0
- package/dist/scripts/chunk-NBJMYAOA.js +2128 -0
- package/dist/scripts/chunk-NCJMUBTG.js +125 -0
- package/dist/scripts/chunk-QJPQHKIO.js +23 -0
- package/dist/scripts/chunk-RIR5KGHC.js +33 -0
- package/dist/scripts/chunk-TJZVQYBL.js +8 -0
- package/dist/scripts/chunk-UHJXD4TG.js +18 -0
- package/dist/scripts/chunk-UQ6I6VTY.js +117 -0
- package/dist/scripts/cli-auth.js +348 -0
- package/dist/scripts/cli-config-IA7EOSYD.js +7 -0
- package/dist/scripts/install-skill.js +199 -0
- package/dist/scripts/issue-local-client-DZUXZOKY.js +22 -0
- package/dist/scripts/issue-search.js +1823 -0
- package/dist/scripts/issue.js +386 -0
- package/dist/scripts/keycloak-provision.js +986 -0
- package/dist/scripts/legato-fsim-runner.py +126 -0
- package/dist/scripts/legato-lowering-runner.py +156 -0
- package/dist/scripts/legato_runner_annotations.py +235 -0
- package/dist/scripts/legato_runner_env.py +91 -0
- package/dist/scripts/legato_runner_launchers.py +287 -0
- package/dist/scripts/legato_runner_script_wrapper.py +193 -0
- package/dist/scripts/notifications-EU43SIEV.js +624 -0
- package/dist/scripts/playground.js +408 -0
- package/dist/scripts/report-bundle-sync-3U7QTP4Z.js +215 -0
- package/dist/scripts/report.js +104 -0
- package/dist/scripts/resolve-sdk-package-version.py +151 -0
- package/dist/scripts/sdk-runtime-JE6H2PB2.js +992 -0
- package/dist/scripts/sdk-runtime-kubernetes-job-KOWL4ITV.js +479 -0
- package/dist/scripts/sdk-runtime-smoke.py +168 -0
- package/dist/scripts/sdk.js +256 -0
- package/dist/scripts/site-feedback-CAPE5MPX.js +136 -0
- package/dist/scripts/site-feedback-rate-limit-5BU2WSFE.js +86 -0
- package/dist/scripts/site-feedback.js +117 -0
- package/dist/scripts/storage-234FBH54.js +67 -0
- package/dist/scripts/submit-issue.sh +489 -0
- package/dist/scripts/verification-3QCY66QW.js +772 -0
- package/dist/scripts/verify-issue.js +144 -0
- package/dist/skills/legato-agora-cli/SKILL.md +556 -0
- package/dist/skills/legato-agora-cli/agents/openai.yaml +7 -0
- package/dist/skills/legato-agora-cli/reference/kernel-with-golden.py +84 -0
- package/dist/skills/legato-site-feedback/SKILL.md +49 -0
- package/dist/skills/legato-site-feedback/agents/openai.yaml +7 -0
- package/package.json +16 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Running a submitted kernel and checking it against the reference it shipped with.
|
|
2
|
+
|
|
3
|
+
The harness lives here rather than inside the submitted file. A kernel is then
|
|
4
|
+
only a statement of what it computes -- a `@legato.compile` function and,
|
|
5
|
+
optionally, a `golden` reference -- and the part that drives it, compares the
|
|
6
|
+
result and prints the verdict cannot be edited away by whoever is working on the
|
|
7
|
+
kernel.
|
|
8
|
+
|
|
9
|
+
Everything the call needs is read off the kernel's own annotations, the same way
|
|
10
|
+
lowering already synthesizes its arguments, so an author does not repeat the
|
|
11
|
+
shapes a second time in a launcher.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from legato_runner_annotations import (
|
|
20
|
+
PreflightError,
|
|
21
|
+
annotation_source,
|
|
22
|
+
get_function_def,
|
|
23
|
+
dtype_name,
|
|
24
|
+
eval_annotation,
|
|
25
|
+
is_tensor_annotation,
|
|
26
|
+
load_module,
|
|
27
|
+
place_arguments,
|
|
28
|
+
scalar_arg,
|
|
29
|
+
select_kernel,
|
|
30
|
+
tensor_shape_from_annotation_source,
|
|
31
|
+
top_level_function_names,
|
|
32
|
+
)
|
|
33
|
+
from legato_runner_launchers import kernel_has_launcher, statically_decorated_kernel_names
|
|
34
|
+
|
|
35
|
+
GOLDEN_NAME = "golden"
|
|
36
|
+
INPUTS_NAME = "inputs"
|
|
37
|
+
SHAPES_NAME = "SHAPES"
|
|
38
|
+
OUTPUT_PARAM_NAMES = {"out", "output", "result"}
|
|
39
|
+
|
|
40
|
+
# A parameter's memory space decides how its device tensor has to be laid out.
|
|
41
|
+
# Only the spaces observed in working kernels are listed: an unknown one is
|
|
42
|
+
# refused rather than guessed, because a wrong layout surfaces as a corrupt
|
|
43
|
+
# result or a legalization failure well away from its cause.
|
|
44
|
+
DEVICE_KIND_BY_MEMORY_SPACE = {
|
|
45
|
+
"shared_dram": "dram_word_interleaved",
|
|
46
|
+
"mpu_dram": "mpu_tensor",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_playground_contract(kernel_path: Path) -> bool:
|
|
51
|
+
"""Whether this file expects the harness to drive it.
|
|
52
|
+
|
|
53
|
+
A file that brings its own launcher keeps running itself -- every kernel
|
|
54
|
+
submitted before this existed does, and they must not change behaviour.
|
|
55
|
+
"""
|
|
56
|
+
return bool(statically_decorated_kernel_names(kernel_path)) and not kernel_has_launcher(kernel_path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def memory_space_of(source: str) -> str:
|
|
60
|
+
"""The memory space argument of a tensor_type annotation."""
|
|
61
|
+
call = ast.parse(source, mode="eval").body
|
|
62
|
+
if not isinstance(call, ast.Call) or len(call.args) < 3:
|
|
63
|
+
raise PreflightError(f"tensor annotation is missing its memory space: {source}")
|
|
64
|
+
space = call.args[2]
|
|
65
|
+
if not isinstance(space, ast.Constant) or not isinstance(space.value, str):
|
|
66
|
+
raise PreflightError(f"memory space must be a string literal: {source}")
|
|
67
|
+
return space.value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def device_kind_for(space: str, name: str) -> str:
|
|
71
|
+
kind = DEVICE_KIND_BY_MEMORY_SPACE.get(space)
|
|
72
|
+
if not kind:
|
|
73
|
+
known = ", ".join(sorted(DEVICE_KIND_BY_MEMORY_SPACE))
|
|
74
|
+
raise PreflightError(
|
|
75
|
+
f"parameter '{name}' is in memory space '{space}', which the Playground harness does not know how to "
|
|
76
|
+
f"allocate on the device yet. Supported: {known}. Submit a kernel with its own launcher to run this."
|
|
77
|
+
)
|
|
78
|
+
return kind
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_declared_shapes(module: Any, plan: list[dict[str, Any]]) -> None:
|
|
82
|
+
"""Let a submission pin the shapes to run at.
|
|
83
|
+
|
|
84
|
+
An annotation may leave a dimension dynamic, and the site then has to invent
|
|
85
|
+
a number -- a failure that only shows up at a particular size would never be
|
|
86
|
+
reproduced, and nothing would say which size was tried. Declaring SHAPES is
|
|
87
|
+
how a report names the one that matters without materialising tensors.
|
|
88
|
+
"""
|
|
89
|
+
declared = getattr(module, SHAPES_NAME, None)
|
|
90
|
+
tensors = {entry["name"]: entry for entry in plan if entry["tensor"]}
|
|
91
|
+
if declared is not None:
|
|
92
|
+
if not isinstance(declared, dict):
|
|
93
|
+
raise PreflightError(f"{SHAPES_NAME} must be a dict of parameter name to shape")
|
|
94
|
+
for name, shape in declared.items():
|
|
95
|
+
entry = tensors.get(name)
|
|
96
|
+
if entry is None:
|
|
97
|
+
raise PreflightError(f"{SHAPES_NAME} names '{name}', which is not a tensor parameter of the kernel")
|
|
98
|
+
if not isinstance(shape, (list, tuple)) or not all(isinstance(dim, int) and dim > 0 for dim in shape):
|
|
99
|
+
raise PreflightError(f"{SHAPES_NAME}['{name}'] must be positive integers, got {shape!r}")
|
|
100
|
+
if len(shape) != len(entry["shape"]):
|
|
101
|
+
raise PreflightError(
|
|
102
|
+
f"{SHAPES_NAME}['{name}'] has rank {len(shape)}, but the kernel declares rank {len(entry['shape'])}"
|
|
103
|
+
)
|
|
104
|
+
entry["shape"] = list(shape)
|
|
105
|
+
entry["declared"] = True
|
|
106
|
+
|
|
107
|
+
# Anything still resting on a substituted dimension is said out loud, so a
|
|
108
|
+
# reader knows the size was the site's choice and not the report's.
|
|
109
|
+
for entry in tensors.values():
|
|
110
|
+
if not entry.get("declared") and entry.get("dynamic"):
|
|
111
|
+
print(
|
|
112
|
+
f"[LEGATO_WORKER] shape_substituted {entry['name']}={tuple(entry['shape'])} "
|
|
113
|
+
f"(annotation leaves a dimension dynamic; declare {SHAPES_NAME} to pin it)",
|
|
114
|
+
flush=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parameter_plan(jit_function: Any) -> list[dict[str, Any]]:
|
|
119
|
+
"""What each parameter needs: its shape, dtype, device layout and role."""
|
|
120
|
+
function_def = get_function_def(jit_function)
|
|
121
|
+
plan: list[dict[str, Any]] = []
|
|
122
|
+
for arg in function_def.args.args:
|
|
123
|
+
source = annotation_source(arg.annotation)
|
|
124
|
+
type_value = eval_annotation(source, jit_function)
|
|
125
|
+
if not is_tensor_annotation(type_value, source):
|
|
126
|
+
plan.append({"name": arg.arg, "tensor": False, "scalar": scalar_arg(type_value)})
|
|
127
|
+
continue
|
|
128
|
+
name = arg.arg
|
|
129
|
+
space = memory_space_of(source)
|
|
130
|
+
plan.append({
|
|
131
|
+
"name": name,
|
|
132
|
+
"tensor": True,
|
|
133
|
+
"shape": tensor_shape_from_annotation_source(source, jit_function),
|
|
134
|
+
"dtype": dtype_name(type_value, source),
|
|
135
|
+
"kind": device_kind_for(space, name),
|
|
136
|
+
"space": space,
|
|
137
|
+
"output": name.lower() in OUTPUT_PARAM_NAMES or name.lower().endswith(("_out", "_output")),
|
|
138
|
+
"dynamic": "-1" in source,
|
|
139
|
+
})
|
|
140
|
+
return plan
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def host_values(module: Any, plan: list[dict[str, Any]], torch: Any) -> dict[str, Any]:
|
|
144
|
+
"""The values the kernel and the reference both see.
|
|
145
|
+
|
|
146
|
+
A submitted `inputs()` wins. A failure often only reproduces on particular
|
|
147
|
+
numbers -- zeros, denormals, a specific weight matrix -- and random data
|
|
148
|
+
would quietly run a different case than the one being reported. Without it,
|
|
149
|
+
seeded random data keeps a run reproducible at least against itself.
|
|
150
|
+
"""
|
|
151
|
+
wanted = [entry for entry in plan if entry["tensor"] and not entry["output"]]
|
|
152
|
+
supplied = getattr(module, INPUTS_NAME, None)
|
|
153
|
+
if not callable(supplied):
|
|
154
|
+
torch.manual_seed(0)
|
|
155
|
+
return {e["name"]: (torch.rand(*e["shape"]) * 2 - 1).to(getattr(torch, e["dtype"])) for e in wanted}
|
|
156
|
+
|
|
157
|
+
values = supplied()
|
|
158
|
+
if not isinstance(values, dict):
|
|
159
|
+
raise PreflightError(f"{INPUTS_NAME}() must return a dict of parameter name to tensor")
|
|
160
|
+
for entry in wanted:
|
|
161
|
+
if entry["name"] not in values:
|
|
162
|
+
raise PreflightError(f"{INPUTS_NAME}() does not provide '{entry['name']}'")
|
|
163
|
+
shape = tuple(values[entry["name"]].shape)
|
|
164
|
+
if shape != tuple(entry["shape"]):
|
|
165
|
+
raise PreflightError(
|
|
166
|
+
f"{INPUTS_NAME}()['{entry['name']}'] has shape {shape}, but the kernel declares "
|
|
167
|
+
f"{tuple(entry['shape'])}"
|
|
168
|
+
)
|
|
169
|
+
extra = sorted(set(values) - {entry["name"] for entry in wanted})
|
|
170
|
+
if extra:
|
|
171
|
+
raise PreflightError(f"{INPUTS_NAME}() provides {extra}, which the kernel does not take as input")
|
|
172
|
+
return {entry["name"]: values[entry["name"]] for entry in wanted}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_arguments(module: Any, jit_function: Any, plan: list[dict[str, Any]]) -> tuple[list[Any], dict[str, Any]]:
|
|
176
|
+
# torch must come first: it autoloads the torch_ha device backend, and
|
|
177
|
+
# importing torch_ha first re-enters a half-initialised torch.
|
|
178
|
+
import torch
|
|
179
|
+
import torch_ha # noqa: F401 registers torch.ops.lpu.*
|
|
180
|
+
host = host_values(module, plan, torch)
|
|
181
|
+
arguments: list[Any] = []
|
|
182
|
+
for entry in plan:
|
|
183
|
+
if not entry["tensor"]:
|
|
184
|
+
arguments.append(entry["scalar"])
|
|
185
|
+
continue
|
|
186
|
+
dtype = getattr(torch, entry["dtype"])
|
|
187
|
+
device_tensor = torch.ops.lpu.empty_with_kind(
|
|
188
|
+
list(entry["shape"]), entry["kind"], dtype=dtype, device="lpu:0",
|
|
189
|
+
)
|
|
190
|
+
if entry["output"]:
|
|
191
|
+
device_tensor.copy_(torch.zeros(*entry["shape"], dtype=dtype))
|
|
192
|
+
else:
|
|
193
|
+
device_tensor.copy_(host[entry["name"]].to(dtype))
|
|
194
|
+
arguments.append(device_tensor)
|
|
195
|
+
entry["device"] = device_tensor
|
|
196
|
+
return place_arguments(jit_function, arguments), host
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def pin_single_core(jit_function: Any) -> None:
|
|
200
|
+
"""A kernel that only places on core 0 must not be expanded across all cores."""
|
|
201
|
+
kinds = getattr(jit_function, "_param_kinds", None) or getattr(jit_function, "param_kinds", None) or {}
|
|
202
|
+
if any(str(kind).rstrip().removesuffix("_param") == "core" for kind in kinds.values()):
|
|
203
|
+
import legato.launcher
|
|
204
|
+
|
|
205
|
+
legato.launcher._enabled_core_count = lambda: 1
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def report_value(module: Any, plan: list[dict[str, Any]], host: dict[str, Any]) -> None:
|
|
209
|
+
"""Compare against the kernel's own reference, when it shipped one.
|
|
210
|
+
|
|
211
|
+
A kernel without a reference still runs; it just has nothing to be checked
|
|
212
|
+
against, and says so rather than claiming a pass it cannot support.
|
|
213
|
+
"""
|
|
214
|
+
import torch
|
|
215
|
+
|
|
216
|
+
golden = getattr(module, GOLDEN_NAME, None)
|
|
217
|
+
if not callable(golden):
|
|
218
|
+
print(
|
|
219
|
+
"[LEGATO_WORKER] value=unavailable no golden() reference in the submitted kernel",
|
|
220
|
+
flush=True,
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
outputs = [entry for entry in plan if entry.get("tensor") and entry.get("output")]
|
|
225
|
+
if len(outputs) != 1:
|
|
226
|
+
print(f"[LAGORA_VALUE_DETAIL] expected exactly one output parameter, found {len(outputs)}", flush=True)
|
|
227
|
+
print("[LAGORA_VALUE] fail", flush=True)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
expected = golden(**host)
|
|
231
|
+
actual = outputs[0]["device"].cpu().float()
|
|
232
|
+
expected = expected.float() if hasattr(expected, "float") else expected
|
|
233
|
+
rtol = float(getattr(module, "RTOL", 0.05))
|
|
234
|
+
atol = float(getattr(module, "ATOL", 0.2))
|
|
235
|
+
|
|
236
|
+
if tuple(actual.shape) != tuple(expected.shape):
|
|
237
|
+
print(f"[LAGORA_VALUE_DETAIL] shape {tuple(actual.shape)} != expected {tuple(expected.shape)}", flush=True)
|
|
238
|
+
print("[LAGORA_VALUE] fail", flush=True)
|
|
239
|
+
return
|
|
240
|
+
if not torch.isfinite(actual).all():
|
|
241
|
+
print("[LAGORA_VALUE_DETAIL] result contains non-finite values", flush=True)
|
|
242
|
+
print("[LAGORA_VALUE] fail", flush=True)
|
|
243
|
+
return
|
|
244
|
+
max_abs = (actual - expected).abs().max().item()
|
|
245
|
+
print(f"[LAGORA_VALUE_DETAIL] max_abs_diff={max_abs:.6f} rtol={rtol} atol={atol}", flush=True)
|
|
246
|
+
print(f"[LAGORA_VALUE] {'pass' if torch.allclose(actual, expected, rtol=rtol, atol=atol) else 'fail'}", flush=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def run_playground_kernel(kernel_path: Path, output_root: Path) -> None:
|
|
250
|
+
import legato
|
|
251
|
+
|
|
252
|
+
module = load_module(kernel_path)
|
|
253
|
+
_, jit_function = select_kernel(module, None, top_level_function_names(kernel_path))
|
|
254
|
+
plan = parameter_plan(jit_function)
|
|
255
|
+
apply_declared_shapes(module, plan)
|
|
256
|
+
arguments, host = build_arguments(module, jit_function, plan)
|
|
257
|
+
pin_single_core(jit_function)
|
|
258
|
+
|
|
259
|
+
print("[LAGORA_STAGE] BINARY", flush=True)
|
|
260
|
+
with legato.session(output_type=legato.OutputType.BINARY, output_path=str(output_root)):
|
|
261
|
+
jit_function(*arguments)
|
|
262
|
+
|
|
263
|
+
report_value(module, plan, host)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// scripts/announce.ts
|
|
2
|
+
function parseArgs(argv) {
|
|
3
|
+
if (argv[0] !== "create") {
|
|
4
|
+
throw new Error("Usage: lagora announce create --api-url <url> --title <title> --body <body> [--link <url>] [--token <token>]");
|
|
5
|
+
}
|
|
6
|
+
const values = /* @__PURE__ */ new Map();
|
|
7
|
+
for (let index = 1; index < argv.length; index += 2) {
|
|
8
|
+
const option = argv[index];
|
|
9
|
+
const value = argv[index + 1];
|
|
10
|
+
if (!option?.startsWith("--") || !value) throw new Error(`${option ?? "option"} requires a value`);
|
|
11
|
+
values.set(option, value);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
apiUrl: required(values.get("--api-url"), "--api-url").replace(/\/+$/, ""),
|
|
15
|
+
title: required(values.get("--title"), "--title"),
|
|
16
|
+
body: required(values.get("--body"), "--body"),
|
|
17
|
+
linkUrl: values.get("--link")?.trim() || void 0,
|
|
18
|
+
token: required(values.get("--token") ?? process.env.LAGORA_ANNOUNCEMENT_TOKEN, "--token or LAGORA_ANNOUNCEMENT_TOKEN")
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function required(value, name) {
|
|
22
|
+
if (!value?.trim()) throw new Error(`${name} is required`);
|
|
23
|
+
return value.trim();
|
|
24
|
+
}
|
|
25
|
+
async function main() {
|
|
26
|
+
const args = parseArgs(process.argv.slice(2));
|
|
27
|
+
const response = await fetch(`${args.apiUrl}/api/announcements`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
authorization: `Bearer ${args.token}`,
|
|
31
|
+
"content-type": "application/json"
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({ title: args.title, body: args.body, linkUrl: args.linkUrl })
|
|
34
|
+
});
|
|
35
|
+
const body = await response.text();
|
|
36
|
+
if (!response.ok) throw new Error(`Announcement failed: HTTP ${response.status} ${body.slice(0, 240)}`);
|
|
37
|
+
const payload = JSON.parse(body);
|
|
38
|
+
const delivered = payload && typeof payload === "object" ? Reflect.get(payload, "delivered") : void 0;
|
|
39
|
+
console.log(`Announcement published to ${typeof delivered === "number" ? delivered : "unknown"} users.`);
|
|
40
|
+
}
|
|
41
|
+
await main();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""What the site will be able to do with a kernel, reported at submission time.
|
|
2
|
+
|
|
3
|
+
Informational, never fatal. An arbitrary kernel is a perfectly good bug report
|
|
4
|
+
-- most of them are -- and refusing it because it cannot be value-checked would
|
|
5
|
+
turn a reporting tool into a formatting gate. But the reporter is the one person
|
|
6
|
+
who knows what the right answer was, and this is the moment to say so; asking
|
|
7
|
+
later means someone else reconstructing their intent.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import ast
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def defines_golden(tree: ast.Module) -> bool:
|
|
18
|
+
return any(isinstance(node, ast.FunctionDef) and node.name == "golden" for node in tree.body)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decorated_kernels(tree: ast.Module) -> list[str]:
|
|
22
|
+
names = []
|
|
23
|
+
for node in tree.body:
|
|
24
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
25
|
+
continue
|
|
26
|
+
for decorator in node.decorator_list:
|
|
27
|
+
text = ast.unparse(decorator)
|
|
28
|
+
if "legato.compile" in text or "legato.primitive" in text:
|
|
29
|
+
names.append(node.name)
|
|
30
|
+
break
|
|
31
|
+
return names
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_main_guard(tree: ast.Module) -> bool:
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if isinstance(node, ast.If) and "__main__" in ast.unparse(node.test):
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def report(path: Path) -> list[str]:
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(path.read_text())
|
|
44
|
+
except SyntaxError as error:
|
|
45
|
+
return [f"error: {path.name} is not valid Python: {error}"]
|
|
46
|
+
|
|
47
|
+
kernels = decorated_kernels(tree)
|
|
48
|
+
notes: list[str] = []
|
|
49
|
+
if not kernels:
|
|
50
|
+
notes.append(
|
|
51
|
+
f"note: no @legato.compile function found in {path.name}. The site can still store and show it, "
|
|
52
|
+
"but it cannot lower or run it."
|
|
53
|
+
)
|
|
54
|
+
return notes
|
|
55
|
+
|
|
56
|
+
if has_main_guard(tree):
|
|
57
|
+
notes.append(
|
|
58
|
+
f"warning: {path.name} brings its own launcher, so it runs itself and the site's harness steps aside. "
|
|
59
|
+
"A hardcoded session output type can mislabel every requested lowering artifact. Agent-generated "
|
|
60
|
+
"submissions must remove the launcher and follow reference/kernel-with-golden.py."
|
|
61
|
+
)
|
|
62
|
+
return notes
|
|
63
|
+
|
|
64
|
+
if defines_golden(tree):
|
|
65
|
+
notes.append(f"ok: {path.name} defines golden() and {kernels[0]}(); the site can lower, run and check values.")
|
|
66
|
+
else:
|
|
67
|
+
notes.append(
|
|
68
|
+
f"note: {path.name} has no golden() reference, so the site will lower and run it but report the value "
|
|
69
|
+
"check as unavailable. Add one if the correct result is known -- see reference/kernel-with-golden.py "
|
|
70
|
+
"in the lagora skill."
|
|
71
|
+
)
|
|
72
|
+
return notes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
77
|
+
parser.add_argument("kernel")
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
|
|
80
|
+
path = Path(args.kernel)
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
print(f"error: kernel not found: {path}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
for line in report(path):
|
|
85
|
+
print(f"[lagora] {line}", file=sys.stderr)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/server/encrypted-secrets.ts
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
var SECRET_NAMES = ["githubToken", "sdkCliToken", "announcementToken"];
|
|
6
|
+
var SettingsEncryptionKeyMissingError = class extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("AGORA_SETTINGS_ENCRYPTION_KEY is required before server secrets can be saved");
|
|
9
|
+
this.name = "SettingsEncryptionKeyMissingError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
async function readSecret(name) {
|
|
13
|
+
const value = (await readSecretFile())[name];
|
|
14
|
+
return value ? decryptText(value) : void 0;
|
|
15
|
+
}
|
|
16
|
+
async function readGithubToken() {
|
|
17
|
+
return await readSecret("githubToken");
|
|
18
|
+
}
|
|
19
|
+
async function readSecretFile() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(secretPath(), "utf8");
|
|
22
|
+
return parseSecretFile(JSON.parse(raw));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error instanceof Error) return {};
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function decryptText(value) {
|
|
29
|
+
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(value.iv, "base64"));
|
|
30
|
+
decipher.setAuthTag(Buffer.from(value.tag, "base64"));
|
|
31
|
+
return Buffer.concat([
|
|
32
|
+
decipher.update(Buffer.from(value.ciphertext, "base64")),
|
|
33
|
+
decipher.final()
|
|
34
|
+
]).toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
function encryptionKey() {
|
|
37
|
+
const configured = process.env.AGORA_SETTINGS_ENCRYPTION_KEY?.trim();
|
|
38
|
+
if (!configured) throw new SettingsEncryptionKeyMissingError();
|
|
39
|
+
return createHash("sha256").update(configured).digest();
|
|
40
|
+
}
|
|
41
|
+
function parseSecretFile(value) {
|
|
42
|
+
if (!isRecord(value)) return {};
|
|
43
|
+
return Object.fromEntries(
|
|
44
|
+
SECRET_NAMES.flatMap((name) => {
|
|
45
|
+
const parsed = parseEncryptedValue(value[name]);
|
|
46
|
+
return parsed ? [[name, parsed]] : [];
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
function parseEncryptedValue(value) {
|
|
51
|
+
if (!isRecord(value)) return void 0;
|
|
52
|
+
if (value.algorithm !== "aes-256-gcm") return void 0;
|
|
53
|
+
if (typeof value.iv !== "string") return void 0;
|
|
54
|
+
if (typeof value.tag !== "string") return void 0;
|
|
55
|
+
if (typeof value.ciphertext !== "string") return void 0;
|
|
56
|
+
if (typeof value.updatedAt !== "string") return void 0;
|
|
57
|
+
return {
|
|
58
|
+
algorithm: "aes-256-gcm",
|
|
59
|
+
iv: value.iv,
|
|
60
|
+
tag: value.tag,
|
|
61
|
+
ciphertext: value.ciphertext,
|
|
62
|
+
updatedAt: value.updatedAt
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function settingsRoot() {
|
|
66
|
+
return process.env.AGORA_SETTINGS_DIR ? path.resolve(process.env.AGORA_SETTINGS_DIR) : path.join(
|
|
67
|
+
/*turbopackIgnore: true*/
|
|
68
|
+
process.cwd(),
|
|
69
|
+
".local-data",
|
|
70
|
+
"settings"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
function secretPath() {
|
|
74
|
+
return path.join(settingsRoot(), "secrets.json");
|
|
75
|
+
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// lib/server/git-auth.ts
|
|
81
|
+
async function configuredGithubToken() {
|
|
82
|
+
return await readGithubToken() ?? process.env.AGORA_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
83
|
+
}
|
|
84
|
+
function gitEnv(token) {
|
|
85
|
+
if (!token) return process.env;
|
|
86
|
+
const encoded = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
87
|
+
return {
|
|
88
|
+
...process.env,
|
|
89
|
+
GIT_CONFIG_COUNT: "2",
|
|
90
|
+
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
|
|
91
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${encoded}`,
|
|
92
|
+
GIT_CONFIG_KEY_1: "url.https://github.com/.insteadOf",
|
|
93
|
+
GIT_CONFIG_VALUE_1: "git@github.com:"
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
configuredGithubToken,
|
|
99
|
+
gitEnv
|
|
100
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// lib/server/sdk-package-index.ts
|
|
2
|
+
function sdkPackageIndex(env = process.env) {
|
|
3
|
+
const url = env.AGORA_SDK_INDEX_URL?.trim();
|
|
4
|
+
if (!url) return void 0;
|
|
5
|
+
return { url, host: env.AGORA_SDK_INDEX_HOST?.trim() || hostFromUrl(url) };
|
|
6
|
+
}
|
|
7
|
+
function sdkPackageIndexEnv(env = process.env, pin) {
|
|
8
|
+
const index = sdkPackageIndex(env);
|
|
9
|
+
const versions = typeof pin === "string" ? { legatoVersion: pin } : pin ?? {};
|
|
10
|
+
return [
|
|
11
|
+
{ name: "AGORA_SDK_INDEX_URL", value: index?.url ?? "" },
|
|
12
|
+
{ name: "AGORA_SDK_INDEX_HOST", value: index?.host ?? "" },
|
|
13
|
+
// Empty means "resolve the newest in the job" for both lines.
|
|
14
|
+
{ name: "AGORA_LEGATO_PACKAGE_VERSION", value: versions.legatoVersion?.trim() ?? "" },
|
|
15
|
+
{ name: "AGORA_SDK_PACKAGE_VERSION", value: versions.sdkVersion?.trim() ?? "" }
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
function hostFromUrl(url) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(url).host || void 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return void 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
sdkPackageIndex,
|
|
28
|
+
sdkPackageIndexEnv
|
|
29
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
internalAppUrl
|
|
3
|
+
} from "./chunk-TJZVQYBL.js";
|
|
4
|
+
|
|
5
|
+
// scripts/cli-config.ts
|
|
6
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// scripts/api-session.ts
|
|
11
|
+
var defaultApiUrl = internalAppUrl;
|
|
12
|
+
function apiSession(apiUrl, credentials = {}) {
|
|
13
|
+
return {
|
|
14
|
+
apiUrl: baseApiUrl(apiUrl),
|
|
15
|
+
cookie: credentials.cookie?.trim() || void 0,
|
|
16
|
+
token: credentials.token?.trim() || void 0
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function readJsonFromApi(endpoint, session) {
|
|
20
|
+
const response = await fetch(endpoint, {
|
|
21
|
+
headers: requestHeaders(session)
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const body = await response.text();
|
|
25
|
+
throw new Error(`API request failed: HTTP ${response.status} ${body.slice(0, 240)}`);
|
|
26
|
+
}
|
|
27
|
+
return response.json();
|
|
28
|
+
}
|
|
29
|
+
async function writeJsonToApi(endpoint, method, body, session) {
|
|
30
|
+
const response = await fetch(endpoint, {
|
|
31
|
+
method,
|
|
32
|
+
headers: requestHeaders(session, { "Content-Type": "application/json" }),
|
|
33
|
+
body: JSON.stringify(body)
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const responseBody = await response.text();
|
|
37
|
+
throw new Error(`API request failed: HTTP ${response.status} ${responseBody.slice(0, 240)}`);
|
|
38
|
+
}
|
|
39
|
+
return response.json();
|
|
40
|
+
}
|
|
41
|
+
function baseApiUrl(apiUrl) {
|
|
42
|
+
const url = new URL(apiUrl.trim());
|
|
43
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
44
|
+
throw new Error("Lagora API URL must not contain credentials, query parameters, or fragments");
|
|
45
|
+
}
|
|
46
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
47
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
48
|
+
throw new Error("Lagora API URL must use HTTPS except on loopback");
|
|
49
|
+
}
|
|
50
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
51
|
+
return url.toString().replace(/\/+$/, "");
|
|
52
|
+
}
|
|
53
|
+
function requestHeaders(session, headers) {
|
|
54
|
+
return {
|
|
55
|
+
...headers,
|
|
56
|
+
...session?.cookie ? { Cookie: session.cookie } : {},
|
|
57
|
+
...session?.token ? { Authorization: `Bearer ${session.token}` } : {}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// scripts/cli-config.ts
|
|
62
|
+
var LagoraCliConfigStore = class {
|
|
63
|
+
constructor(configPath = defaultConfigPath()) {
|
|
64
|
+
this.path = configPath;
|
|
65
|
+
}
|
|
66
|
+
async read() {
|
|
67
|
+
try {
|
|
68
|
+
const raw = await readFile(this.path, "utf8");
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
return isConfig(parsed) ? parsed : {};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (isMissingFileError(error)) return {};
|
|
73
|
+
if (error instanceof SyntaxError) return {};
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async write(config) {
|
|
78
|
+
await mkdir(path.dirname(this.path), { recursive: true });
|
|
79
|
+
await writeFile(this.path, `${JSON.stringify(config, null, 2)}
|
|
80
|
+
`, { mode: 384 });
|
|
81
|
+
}
|
|
82
|
+
async clear() {
|
|
83
|
+
await rm(this.path, { force: true });
|
|
84
|
+
}
|
|
85
|
+
async author(explicitAuthor) {
|
|
86
|
+
const trimmed = explicitAuthor?.trim();
|
|
87
|
+
if (trimmed) return trimmed;
|
|
88
|
+
const config = await this.read();
|
|
89
|
+
return config.name?.trim() || "Language Developer";
|
|
90
|
+
}
|
|
91
|
+
/** Resolves in order: --api-url, LAGORA_API_URL, the saved login, the default. */
|
|
92
|
+
async apiSession(explicitApiUrl) {
|
|
93
|
+
const config = await this.read();
|
|
94
|
+
const explicit = normalizedApiUrl(explicitApiUrl ?? process.env.LAGORA_API_URL);
|
|
95
|
+
const configured = normalizedApiUrl(config.apiUrl);
|
|
96
|
+
if (explicit && configured && (config.nativeToken?.trim() || config.sessionCookie?.trim()) && explicit !== configured) {
|
|
97
|
+
throw new Error(`Saved CLI login belongs to ${configured}; run \`lagora login --api-url ${explicit}\` first`);
|
|
98
|
+
}
|
|
99
|
+
const apiUrl = explicit || configured || defaultApiUrl;
|
|
100
|
+
return {
|
|
101
|
+
apiUrl,
|
|
102
|
+
cookie: config.sessionCookie?.trim() || void 0,
|
|
103
|
+
token: config.nativeToken?.trim() || void 0
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function normalizedApiUrl(value) {
|
|
108
|
+
return value?.trim() ? baseApiUrl(value) : void 0;
|
|
109
|
+
}
|
|
110
|
+
function defaultConfigPath() {
|
|
111
|
+
const configured = process.env.LAGORA_CONFIG?.trim();
|
|
112
|
+
if (configured) return path.resolve(configured);
|
|
113
|
+
const configHome = process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config");
|
|
114
|
+
return path.join(configHome, "lagora", "config.json");
|
|
115
|
+
}
|
|
116
|
+
function isConfig(value) {
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
118
|
+
const name = Reflect.get(value, "name");
|
|
119
|
+
const passcode = Reflect.get(value, "passcode");
|
|
120
|
+
const apiUrl = Reflect.get(value, "apiUrl");
|
|
121
|
+
const sessionCookie = Reflect.get(value, "sessionCookie");
|
|
122
|
+
const nativeToken = Reflect.get(value, "nativeToken");
|
|
123
|
+
return (name === void 0 || typeof name === "string") && (passcode === void 0 || typeof passcode === "string") && (apiUrl === void 0 || typeof apiUrl === "string") && (sessionCookie === void 0 || typeof sessionCookie === "string") && (nativeToken === void 0 || typeof nativeToken === "string");
|
|
124
|
+
}
|
|
125
|
+
function isMissingFileError(error) {
|
|
126
|
+
return error instanceof Error && "code" in error && Reflect.get(error, "code") === "ENOENT";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
defaultApiUrl,
|
|
131
|
+
apiSession,
|
|
132
|
+
readJsonFromApi,
|
|
133
|
+
writeJsonToApi,
|
|
134
|
+
baseApiUrl,
|
|
135
|
+
requestHeaders,
|
|
136
|
+
LagoraCliConfigStore
|
|
137
|
+
};
|